Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/workflows/webui-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ jobs:
shell: bash
run: |
echo "MCODE_WEBUI_VERIFY_DIR=$RUNNER_TEMP/webui-package-checks" >> "$GITHUB_ENV"
echo "WEBUI_BROWSER_OUTPUT_DIR=$RUNNER_TEMP/webui-browser-results" >> "$GITHUB_ENV"
echo "WEBUI_PLAYWRIGHT_OUTPUT_DIR=$RUNNER_TEMP/webui-playwright-results" >> "$GITHUB_ENV"
echo "WEBUI_BROWSER_FONT_CACHE_DIR=$RUNNER_TEMP/webui-browser-font-cache" >> "$GITHUB_ENV"
if [ "$RUNNER_OS" = "macOS" ]; then
echo "WEBUI_BROWSER_HEADED=1" >> "$GITHUB_ENV"
fi
Expand Down Expand Up @@ -66,18 +67,26 @@ jobs:
if: runner.os == 'Linux'
run: pnpm exec playwright install-deps chromium
- name: Test WebUI units and integrations
env:
WEBUI_BROWSER_OUTPUT_DIR: ${{ runner.temp }}/webui-browser-diagnostics/units
run: pnpm test:webui
- name: Test WebUI browser interactions
env:
WEBUI_BROWSER_OUTPUT_DIR: ${{ runner.temp }}/webui-browser-diagnostics/browser
if: ${{ !cancelled() && steps.build.outcome == 'success' && steps.browsers.outcome == 'success' }}
run: pnpm test:webui-browser
- name: Verify WebUI package installation
env:
WEBUI_BROWSER_OUTPUT_DIR: ${{ runner.temp }}/webui-browser-diagnostics/package
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
run: pnpm test:webui-package
- name: Upload WebUI browser diagnostics
if: ${{ !cancelled() }}
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: webui-browser-${{ matrix.os }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/webui-browser-results/
path: |
${{ runner.temp }}/webui-browser-diagnostics/
${{ runner.temp }}/webui-playwright-results/
retention-days: 7
if-no-files-found: ignore
Original file line number Diff line number Diff line change
Expand Up @@ -2018,11 +2018,12 @@ describe('LocalPermissionFacade', () => {
});

it('write to a path inside dataDir still ASKs (sandbox is read-only)', async () => {
const { facade, dataDir } = freshFacade({ permissionMode: 'default' });
const { facade, dataDir } = freshFacade({ permissionMode: 'default', workspaceDir: '/synthetic-workspace' });
try {
const r = await facade.checkPermission({
toolName: 'write',
input: { path: path.join(dataDir, 'skills', 'generated', 'SKILL.md') },
agentName: 'synthetic-agent',
});
// Writes to dataDir go through normal review. The sandbox
// auto-allow is scoped to READ-ONLY tools because fs-permission's
Expand Down Expand Up @@ -2118,14 +2119,15 @@ describe('LocalPermissionFacade', () => {

describe('turn-scoped trusted exact writes', () => {
it('allows only the supplied exact Plan target without persisting a rule', async () => {
const { facade, ruleStore, dataDir } = freshFacade({ permissionMode: 'default' });
const { facade, ruleStore, dataDir } = freshFacade({ permissionMode: 'default', workspaceDir: '/synthetic-workspace' });
const planPath = path.join(dataDir, 'v2', 'sessions', 'session-a', 'artifacts', 'plan.md');
try {
await expect(
facade.checkPermission({
toolName: 'write',
input: { path: planPath, content: '# Plan' },
sessionId: 'session-a',
agentName: 'synthetic-agent',
trustedExactWritePaths: [planPath],
}),
).resolves.toMatchObject({ behavior: 'allow' });
Expand All @@ -2134,6 +2136,7 @@ describe('LocalPermissionFacade', () => {
toolName: 'write',
input: { path: `${planPath}.bak`, content: '# Escape' },
sessionId: 'session-a',
agentName: 'synthetic-agent',
trustedExactWritePaths: [planPath],
}),
).resolves.toMatchObject({ behavior: 'ask' });
Expand Down
5 changes: 4 additions & 1 deletion packages/webui/docs/client/ownership.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,10 @@ and extraction revision. Channel filter writes use the selected bot's detail rev
Account login uses attempt and status-read revisions across begin, poll, cancel and dialog close.
Credential removal is shared by switch and sign-out: retire the credential epoch and reconcile
account/usage before beginning another login. Malformed check-in and directory responses enter
recoverable error states. Settings subscribes once per resource slot, independently of repeated
recoverable error states. Repeated hover/focus on an open usage panel does not start another quota
read; explicit refresh and retry still do. The rail displays the owner's last quota result while
a new read is loading, then adopts the new answer instead of hiding the subscription name.
Settings subscribes once per resource slot, independently of repeated
reads; retained model answers retire with their epoch/session resource. Composer catalogue
invalidation also retires outstanding promises, preventing stale successful consumption.

Expand Down
13 changes: 12 additions & 1 deletion packages/webui/docs/client/transcript-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ Workspace file references and delivered-file rows share the same inline media re
(including SVG and PNG), supported video and audio files render their contents in the transcript;
video and audio use native controls without autoplay. Markdown image syntax and recognized bare
media filenames in inline code use the same workspace resource path. Non-media files keep links.
Remote HTTP(S) media and paths outside the current workspace are not automatically previewed.
Remote HTTP(S) media are not automatically previewed. Explicit absolute local paths also work
outside the session workspace: the file tab and authenticated media URL use the named file's
containing directory as their read root. Relative traversal paths remain invalid.

Delivered-file rows keep their icon and display name together, including when a reference cannot
be opened. The icon type comes from the source filename, independently of a human-readable caption.

The host serves actual workspace bytes with byte-range support. A transient media load failure
retries a bounded number of times; content synchronization and media-byte availability remain
Expand Down Expand Up @@ -69,6 +74,12 @@ Pinned by `test/unit/markdown-codeblock-c2-line-numbers.test.tsx`.
- A reference of the form `` `path/to/file.ts:120` `` renders as a clickable anchor carrying the
line number, not as literal text.
- Clicking it invokes `onOpenFile` with the parsed reference.
- Absolute local references outside the workspace retain their absolute identity until the file
tab or media URL splits them into directory and filename. Existing workspace-relative references
keep their original meaning.
- If a relative path with directories is absent, the runtime adapter may complete a unique,
segment-aligned suffix from workspace search. Ambiguous or truncated search results do not pick
a file. Bare-filename references retain their existing workspace search behavior.
- A bare `` `file.ts` `` with no line number is still a valid reference.
- A string that merely contains a colon is not turned into a reference.

Expand Down
7 changes: 7 additions & 0 deletions packages/webui/docs/design/design-language.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,13 @@ contract.
is scoped to the shell, so a page-level form keeps the field contract's own scale. Popovers and
menus stay non-modal. The app-wide Settings host keeps its page-like full-viewport layout as a
documented exception.
Clicking the backdrop dismisses a dialog by default. A caller that must block dismissal
passes `dismissOnBackdrop={false}` or gates `onClose` while its command is running.
Escape dismissal remains opt-in. The settings dialog wrapper inherits the backdrop default.
- **Progress overview sections size to their content.** The environment keeps every action visible;
short todo lists do not reserve the long-list height. Todo and subagent lists retain bounded
scrolling. `WebuiDisclosure` owns the animated body height; section shells must not impose a
competing collapsed height or flex basis.
- **Repeated generic grouping uses `WebuiInline` and `WebuiStack`.** They own direction, alignment,
main-axis distribution (start/center/end/between), inline formatting and named spacing steps
(`hairline`, `micro`, `tight`, `compact`, `small`, `medium`, `roomy`, `large`). `compact` maps to the shared 6px spacing token. Alignment also includes `baseline` for text-centered inline groups. Callers keep content-specific width, clipping,
Expand Down
2 changes: 1 addition & 1 deletion packages/webui/docs/runtime/boot.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ session transfer · the terminal process manager (one per service) · the six sl

| Entry | Path | Notes |
| --- | --- | --- |
| Development server | `scripts/run-webui-server.mjs` via `pnpm --filter @mavis/webui dev:server` | Spawns itself under `tsx` with the repository's `tsconfig.standalone.json`; assembles the host, constructs `new WebuiService(...)` with the registry and the terminal manager, calls `service.start()`, and closes the service on SIGINT/SIGTERM. Serves the built client from `dist-webui/client/`. `WEBUI_SERVER_PORT` overrides 2233. `WEBUI_HOST` overrides the bind address (`127.0.0.1` by default); a wider bind prints the LAN URLs and the per-start credential, and a request that did not arrive over loopback presents that credential (ADR 0031). |
| Development server | `scripts/run-webui-server.mjs` via `pnpm --filter @mavis/webui dev:server` | Spawns itself under `tsx` with the repository's `tsconfig.standalone.json`; assembles the host, constructs `new WebuiService(...)` with the registry and the terminal manager, calls `service.start()`, and closes the service on SIGINT/SIGTERM or an authenticated `POST /webui-shutdown`. The HTTP route acknowledges the request before the same bounded process cleanup starts. Serves the built client from `dist-webui/client/`. `WEBUI_SERVER_PORT` overrides 2233. `WEBUI_HOST` overrides the bind address (`127.0.0.1` by default); a wider bind prints the LAN URLs and the per-start credential, and a request that did not arrive over loopback presents that credential (ADR 0031). |
| Packaged service | `runtime/index.ts` + `server/index.ts` | The runtime's public entry and the server build entry. `scripts/build-webui.mjs` bundles both. |
| npm preview package | `release/webui-npm/` | See [`../releasing.md`](../releasing.md). |

Expand Down
3 changes: 2 additions & 1 deletion packages/webui/src/client/features/account/ui/UserMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ export function UserMenu({
const loadUsage = (forceRefresh = false) => {
setUsageOpen(true);
setSigninOpen(false);
if (!forceRefresh && (usageOpen || accountSnapshot.usage.status === "loading")) return;
void accountWorkflows?.loadUsage(forceRefresh);
};

Expand Down Expand Up @@ -310,7 +311,7 @@ export function UserMenu({
const avatarUrl =
profile?.avatarUrl?.trim() || profile?.defaultAvatarUrl?.trim();
const planLabel = formatRailPlanLabel(
usage.status === "ready" ? usage.result : undefined,
accountSnapshot.usage.result,
);
const identity = objectValue(account?.identity);
const auth = objectValue(account?.auth);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export function SessionRailRegion(props: {
</div> : null}
<div className="relative min-h-0 flex-1" hidden={props.collapsed}>
<div className="webui-rail-scroll h-full overflow-x-hidden overflow-y-auto px-4">
<WebuiStack gap="hairline" className="pb-3">
<WebuiStack gap="hairline" className="pb-0.5">
<WebuiFileInput ref={props.importInputRef} accept=".json,application/json" data-webui-session-import-input="true" aria-label="导入会话文件" onChange={props.onImportPicked} />
<div className="webui-rail-command-group">
<RailRow label="导入会话" icon={<WebuiIconSessionImport className="flex-shrink-0" />} active={false} inert={props.importBusy} onSelect={props.openImportPicker} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export function SettingsDialog({
testId,
width = "narrow",
variant = "default",
dismissOnMaskClick = false,
dismissOnMaskClick,
}: {
readonly open?: boolean;
readonly title: string;
Expand All @@ -160,10 +160,8 @@ export function SettingsDialog({
/**
* Whether a click on the scrim closes the dialog.
*
* The dialogs that carry a close button in their header do not need this:
* they say how to leave. A preview that shows the image it is about to save
* has no such button, so the scrim is the way out — clicking anywhere
* outside the preview dismisses it.
* Omitted values inherit the shared dialog's backdrop policy. An explicit
* value also preserves this wrapper's legacy Escape-key override.
*/
readonly dismissOnMaskClick?: boolean;
}): ReactElement {
Expand All @@ -184,7 +182,7 @@ export function SettingsDialog({
title={title}
onClose={onClose}
width={width === "wide" ? "standard" : "compact"}
dismissOnEscape={dismissOnMaskClick}
dismissOnEscape={dismissOnMaskClick ?? false}
dismissOnBackdrop={dismissOnMaskClick}
portalTarget={dom.portalTarget()}
testId={testId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export interface WebuiMessageFileReference {
readonly lineEnd?: number;
}

/** Accept only workspace-relative paths; links and prose remain ordinary markdown. */
/** Local file references retain explicit absolute paths outside the workspace. */
export function parseWebuiMessageFileReference(value: string, workspaceDir?: string): WebuiMessageFileReference | undefined {
let normalized = value.trim().replace(/\\/gu, "/").replace(/^\.\//u, "");
if (/%[\da-f]{2}/iu.test(normalized)) {
Expand All @@ -13,22 +13,21 @@ export function parseWebuiMessageFileReference(value: string, workspaceDir?: str
if (!normalized || /^(?:https?:|mailto:|\/\/)/iu.test(normalized)) return undefined;
if (/[\u0000-\u001f\u007f]/u.test(normalized)) return undefined;
if (normalized.startsWith("/") || /^[a-z]:\//iu.test(normalized)) {
if (!workspaceDir) return undefined;
const base = workspaceDir.replace(/\\/gu, "/").replace(/\/$/u, "");
const base = workspaceDir?.replace(/\\/gu, "/").replace(/\/$/u, "");
const candidate = /^[a-z]:/iu.test(normalized) ? normalized.toLowerCase() : normalized;
const root = /^[a-z]:/iu.test(base) ? base.toLowerCase() : base;
if (!candidate.startsWith(`${root}/`)) return undefined;
normalized = normalized.slice(base.length + 1);
const root = base && /^[a-z]:/iu.test(base) ? base.toLowerCase() : base;
if (base && candidate.startsWith(`${root}/`)) normalized = normalized.slice(base.length + 1);
}
const match = normalized.match(/^([^:]+?)(?::(\d+)(?:-(\d+))?)?$/u);
const match = normalized.match(/^((?:[a-z]:\/)?[^:]+?)(?::(\d+)(?:-(\d+))?)?$/iu);
if (!match) return undefined;
const path = match[1];
if (!path || (!path.includes("/") && !/\.[a-z\d_-]+$/iu.test(path))) return undefined;
if (path.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) return undefined;
const segments = path.replace(/^(?:[a-z]:)?\//iu, "").split("/");
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) return undefined;
// Paths come from generated files and user workspaces. Filenames
// may contain spaces or non-ASCII characters, so validate by path segment
// instead of rejecting everything outside ASCII `\w`.
if (path.split("/").some((segment) => !/^[\p{L}\p{N}_@.+ ()\[\]-]+$/u.test(segment))) return undefined;
if (segments.some((segment) => !/^[\p{L}\p{N}_@.+ ()\[\]-]+$/u.test(segment))) return undefined;
const lineStart = match[2] ? Number(match[2]) : undefined;
const lineEnd = match[3] ? Number(match[3]) : undefined;
if (lineStart !== undefined && (!Number.isSafeInteger(lineStart) || lineStart < 1)) return undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useId, useState, type ReactElement } from "react";
import { WebuiButton } from "../../../ui/button.js";
import { WebuiAgentAvatar } from "../../../ui/agent-avatar.js";
import { WebuiIconAlertTriangle, WebuiIconChevronDown, WebuiIconFileForName } from "../../../shell/icons.js";
import { WebuiStack } from "../../../ui/layout-primitives.js";
import { WebuiInline, WebuiStack } from "../../../ui/layout-primitives.js";
import { WebuiDisclosure, WebuiMotionSlot } from "../../../ui/page.js";
import { WebuiFileMedia, WebuiMarkdown } from "./markdown.public.js";
import { basenamePath } from "../../../projection/common/paths.js";
Expand Down Expand Up @@ -124,7 +124,7 @@ function WebuiDeliveredAssets({ assets, workspaceDir, onOpenFile }: {
const name = String(asset.name ?? (basenamePath(src) || "文件"));
const caption = typeof asset.caption === "string" && asset.caption !== name ? asset.caption : undefined;
const reference = src ? parseWebuiMessageFileReference(src, workspaceDir) : undefined;
const label = <><span className="webui-delivered-asset-icon" aria-hidden="true"><WebuiIconFileForName fileName={name} /></span><span>{name}</span></>;
const label = <WebuiInline as="span" inline gap="tight"><span className="webui-delivered-asset-icon" aria-hidden="true"><WebuiIconFileForName fileName={src} /></span><span>{name}</span></WebuiInline>;
return <li key={`${src}-${index}`} className="webui-delivered-asset" data-deleted={asset.deleted === true ? "true" : undefined}>{reference && onOpenFile ? <a className="webui-message-file-link" href={reference.path} title={reference.path} data-webui-file-reference={reference.path} onClick={(event) => { event.preventDefault(); onOpenFile(reference); }}>{label}</a> : <span>{label}</span>}{caption ? <span className="webui-delivered-asset-caption"> — {caption}</span> : null}{asset.deleted === true ? <span className="webui-delivered-asset-deleted">已删除</span> : null}{reference ? <WebuiFileMedia path={reference.path} /> : null}</li>;
})}</WebuiStack>;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ function inline(tokens: readonly Token[] | undefined, onOpenFile?: (reference: N
const fileIcon = fileReferenceEmoji(extension);
const previousToken = tokens?.[index - 1];
const hasExistingIcon = previousToken?.type === "text" && (previousToken as Token & { readonly text: string }).text.trim() === fileIcon;
return isSafeWebuiMarkdownHref(token.href) ? (
return fileReference || isSafeWebuiMarkdownHref(token.href) ? (
fileReference ? fileReferenceAnchor(key, token.href, fileReference, onOpenFile!, !hasExistingIcon) : <a key={key} href={token.href} rel="noreferrer">
{/* The label was tokenized by marked already. Re-lexing it here would
re-emit an autolink inside this anchor — `<a>` inside `<a>`. */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,7 @@
* (`styles/markdown.css`), because three businesses render it. */
.webui-delivered-assets { margin: var(--spacing_8) 0; padding-left: var(--spacing_24); list-style: disc; }
.webui-delivered-asset { color: var(--text_default_secondary); }
.webui-delivered-asset-icon { margin-right: 5px; }
.webui-delivered-asset-icon { flex-shrink: 0; }
.webui-delivered-asset-caption { color: var(--text_default_secondary); }
.webui-delivered-asset-deleted { margin-left: var(--spacing_8); color: var(--text_default_tertiary); font-size: var(--size_12); }

Expand Down
Loading
Loading