* perf(web-shell): paint the composer git chip before git status completes
New sessions gated the chip on a full `git status --porcelain` subprocess
behind GET /workspaces/:ws/git, so the branch chip appeared hundreds of
milliseconds (worst case seconds) after the composer was ready.
The daemon now keeps a per-workspace last-known summary with in-flight
dedup and a 2s background-refresh throttle: the default GET returns the
cached status (branch-only on a cold start) immediately and recomputes in
the background, publishing git_status_changed over SSE only on a delta,
while ?wait=1 keeps the previous blocking semantics. The composer fetches
both paths concurrently — the fresh GET also covers the no-session state,
which has no per-session SSE stream — so the branch paints in ~3ms and
the counters land when the computation finishes. The sidebar keeps
wait:true since it has no SSE fill-in path.
* fix(web-shell): add exhaustiveness guard and worktree git-status test (#7680)
* fix(web-shell): add exhaustiveness guard and worktree git-status test (#7680)
* fix(web-shell): add exhaustiveness guard and worktree git-status test (#7680)
* fix(cli): use writeStderrLineSafe in git-status refresh error path (#7680)
* fix(web-shell): add debug trail to fresh-path catch and test branch-watcher dispose guard (#7680)
* fix(cli): assert writeStderrLineSafe in git-status refresh failure test (#7680)
---------
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Clicking the "New branch" or "Worktree" option dismissed the popover
instead of revealing the branch-name input / confirm button. The option
click bubbles as a React synthetic event through the portal up to the
composer surface onClick, which calls core.focus() and moves focus
outside the popover — tripping the Radix focus-outside dismissal (the
popover only guarded the pointer path via onInteractOutside).
Stop click propagation on the popover content, matching the composer
ToolbarPopover pattern in ChatEditor. Also fix the e2e selectors
(getByText('New branch') matched both the name and description spans)
and add a delayed still-open assertion so the flash-then-dismiss
regression cannot false-pass.
* feat(core): add configurable image generation models
* fix(core): keep undici out of ACP bundle
* fix(core): address review feedback for image generation (#7607)
- Use loadUndici() instead of direct import('undici') in downloadPng
to handle esbuild CJS bundling where named exports are unavailable
- Check response.ok before parsing JSON body so non-JSON error pages
(e.g. 502 HTML) produce structured HTTP status errors
- Use matched.baseUrl instead of matched.registryBaseUrl in the image
model handler for consistency with the vision model handler
- Add zh-CN and zh-TW translations for ImageGen tool display name,
model command description with --image, and all new image model
UI strings (fixes i18n test failures)
- Add tests: redirect-following path, max redirect limit, non-JSON
error body, permission-disabled registration, imageOnly vision guard
* fix(cli): add missing English i18n keys for image model feature (#7607)
* fix(core): prevent signed-URL leak via error cause chain in image-gen (#7607)
* fix(core): add web-shell image_gen display name and fix safe-mode re-read (#7607)
---------
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
* feat(web-shell): add git mode selector for new session creation
Support three git workflows when creating a new session:
1. Current branch (default, unchanged behavior)
2. New branch — daemon runs git checkout -b before spawning
3. Worktree isolation (existing, now unified into the same UI)
The mode selector lives in the composer's git chip as a popover,
replacing the previous worktree-only toggle in the welcome header.
API: POST /session accepts branch: { name } (mutually exclusive
with worktree). Server validates branch name, checks dirty tree,
creates branch, and rolls back on spawn failure.
Design doc: docs/design/2026-07-22-webshell-session-git-mode.md
* fix(web-shell): prevent Radix popover dismissal in git mode selector
The portal container used by Web Shell's Popover primitive is not
recognized by Radix's DismissableLayer, causing the popover to close
on any interaction. Add onInteractOutside prevention so the popover
only closes via explicit selection.
Also replace prototype screenshots with real Playwright captures and
add e2e test + screenshot capture script.
* refactor(web-shell): remove redundant worktree toggle from welcome header
The composer git chip popover now fully covers worktree selection,
making the welcome header toggle/badge redundant. Remove the toggle
UI, its state (worktreeToggleEligible, refs, handlers, focus effect),
and all associated tests (unit + e2e + visuals).
* chore: re-capture PR screenshots after worktree toggle removal
* fix(web-shell): audit fixes for git mode selector
- Add missing setSessionBranch(undefined) in loadSidebarSession,
createNewSession, and session switch effect (!sid path)
- Add setSessionBranch(summary.branch) in session status restore
- Add branch rollback in client disconnect (!res.writable) path
- Fix onInteractOutside: use containment check instead of
unconditional prevention so genuine outside clicks close popover
- Hoist promisify(execFile) to module level
- Narrow reserved branch name check to only HEAD (FETCH_HEAD etc.
are valid branch names)
* fix(web-shell): address review feedback for git mode selector (#7471)
* test(web-shell): capture the git-mode selector in the visuals suite
This PR adds the new-session git-mode selector (current branch / new branch /
worktree) but no visuals scenario renders it, so the before/after preview showed
no image for an entirely new UI — the empty result was a coverage gap, not a
clean bill of health. The PR also removed the `worktree empty state` scenario
(its `worktree-welcome-toggle` no longer exists, replaced by this popover),
leaving the suite with no view of the new-session empty state at all.
Add a `git mode selector` scenario that seeds a trusted git-repo workspace and
lands on the empty state (the only place App.tsx wires the intent props), then
captures the composer chip and the opened three-mode popover in both themes.
Both are byte-stable across runs (0% pixel diff), and asserting an option is
visible makes a regression that fails to open the popover fail here rather than
only in the screenshot.
The branch-name sub-state is deliberately not captured: its input autoFocuses
and the popover then dismisses on the idle frame the capture waits for, so it
can't be shot stably through this pipeline — the functional
web-shell.git-mode.spec.ts already drives that path. Restores the empty-state
coverage this PR dropped and gives the new selector a head-only (NEW) preview.
* fix: address review feedback for git mode selector (#7471)
- Forward the branch override in createDetachedSession so the cold-start
(no active session) path no longer silently drops a user-selected new
branch.
- Reserve the workspace before 'git checkout -b' to close the TOCTOU in
the activeBranchSessions guard; two concurrent branch creations could
both pass the guard and race on HEAD. The reservation is released on
every exit path.
- Return (and close the browser) when the branch input never appears in
the screenshot script instead of falling through to a guaranteed throw.
- Add an e2e test asserting the default current-branch submit sends
neither branch nor worktree.
* fix(web-shell): address review feedback for git mode selector (#7471)
* fix(web-shell): address review feedback for git mode selector (#7471)
* fix(web-shell): address review feedback for git mode selector (#7471)
* fix(cli): sync ink patch with semantic selection types (#7471)
* fix(web-shell): remove orphaned worktree CSS and dead i18n keys (#7471)
* fix: address review feedback for git mode selector (#7471)
* fix: address review feedback for git mode selector (#7471)
Release the route-local in-flight branch reservation in the
disconnect-after-spawn cleanup path so a throwing killSession/
removeSession no longer permanently blocks the workspace from new
branch sessions. Also reject branch names ending in .git on both
the server and the composer validator, associate the branch-name
label with its input, abort the screenshot capture script cleanly
when the chip or popover is missing, and add focused coverage for
the git-mode gating, branch forwarding, and branch pass-through.
* test(cli): cover branch session route validation and mutual exclusion (#7471)
* fix(cli,web-shell): accept Unicode branch names in validation (#7471)
The branch name validation regex rejected all non-ASCII characters,
preventing users from creating branches with Unicode names that git
accepts (e.g. 功能/fix-login). Replace the ASCII-only character class
with Unicode property escapes (\p{L}\p{N}) and the u flag, applied
consistently to both the server-side route and the client-side
GitModePopover validation.
* fix(web-shell): address review feedback on git mode selector (#7471)
- Revert unrelated ink patch change (transformers: [] → newTransformers)
- Extract duplicated branch rollback logic into rollbackBranchCreation helper
- Pessimistically track activeBranchSessions when killSession throws in
disconnect-reap path, preventing concurrent branch session on surviving
session
- Use ref pattern for gitModeIntent in ensureSessionForPrompt to avoid
callback cascade on every git-mode toggle
- Add aria-label to git mode clear button for screen reader accessibility
* fix(cli): harden git branch session creation and clarify UX (#7471)
Address review feedback on the git mode selector:
- Bound every branch git operation with a 30s timeout (mirroring
GitWorktreeService) so a stuck repository lock or slow hook can no
longer hang the request and leave the workspace permanently reserved
in inFlightBranchWorkspaces.
- Run branch shape/name validation before the active-session conflict
check so a malformed body gets 400 instead of 409.
- Compare the reserved HEAD name case-insensitively (ref storage is
case-folding on macOS/Windows), in both the route and the popover.
- Surface the design-doc "switches the working directory to a new
branch" hint in the popover so users know HEAD will move.
- Correct the design doc: branch metadata is in-memory only and does
not survive a daemon restart.
* fix(web-shell,cli): fix light theme, stale intent, and branch init guard (#7471)
- Replace undefined --web-shell-* CSS variables with shadcn design tokens
(--foreground, --muted-foreground, --border, --popover-foreground, etc.)
and add --git-mode-* accent variables to both .themeDark and .themeLight
so the git mode popover is readable in light theme.
- Add useEffect to clear gitModeIntent when gitModeEligible flips to
false, preventing stale branch intent from leaking to another workspace.
- Move gitModeIntentRef assignment from render body into useEffect to
avoid ref mutation during render (concurrent React safety).
- Wrap GitWorktreeService constructor in try/catch on the branch path,
matching the worktree path's guard, so a constructor throw returns 500
instead of hanging the request.
- Show branchConflictWarning hint only when a valid branch name is
entered, not as a static default hint.
- Reword GIT_RESERVED_BRANCH comment and add cross-reference comments
between the duplicated client/server validation predicates.
* fix(cli,web-shell): address review feedback on git-mode PR (#7471)
- Add clearBranchSessionEntry cleanup hook on session close/delete to
prevent stale activeBranchSessions entries from causing spurious
409 branch_session_conflict on the next branch creation request.
- Extract git branch mutations (rev-parse, status, checkout -b,
rollback) into a mockable git-branch-ops module, closing the test
gap on the git-mutation paths that previously had no CI coverage.
- Add 6 new server tests: branch_already_exists, branch_dirty_tree,
branch_checkout_failed, happy-path 200 with branch metadata,
rollback on spawn failure, and branch_session_conflict.
- Export validateBranchName and add shared test vectors matching the
server-side validation to catch future client/server drift.
* fix(cli): close concurrency guard gap in branch session creation (#7471)
The synchronous reserve point only re-checked inFlightBranchWorkspaces,
not activeBranchSessions. A request that passed the early guard before a
concurrent request registered could slip through after the first request
completed and cleared inFlightBranchWorkspaces. Re-check both structures
at the reserve point (no await between check and add) to close the window.
Also clarifies the dirty-tree gate comment to explain the real intent
(surprise-prevention, not data protection).
* test(cli,web-shell): cover worktree intent forwarding and branch session delete lifecycle (#7471)
* fix(cli): address review feedback on git-mode branch sessions (#7471)
* test(cli): cover git-branch-ops git command semantics (#7471)
* fix(cli,web-shell): roll back failed checkouts and guard shared-checkout branch creation (#7471)
* fix(cli,web-shell): address review feedback on git-mode branch sessions (#7471)
---------
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
* fix(web-shell): render a plain textarea composer on touch devices
Mobile browsers could not type into the Web Shell composer (#5958):
CodeMirror's contenteditable interacts poorly with virtual keyboards, and
three non-gesture view.focus() calls claim activeElement on iOS without
opening the keyboard, after which taps may never refocus the editor.
On touch devices ('(hover: none) and (pointer: coarse)' plus
maxTouchPoints > 0 — touch laptops keep the desktop editor) useComposerCore
now skips creating an EditorView entirely and exposes a mobileComposer
backend that ChatEditor renders as a controlled <textarea> at the same
mount point. The internal submit pipeline was hoisted out of the
editor-creation effect and accepts view: EditorView | null, so history,
prompt building, tags, images, and slash/! text interpretation are shared
unchanged between both backends. Enter inserts a newline natively;
submission goes through the Send button.
Programmatic (non-gesture) focus is additionally suppressed on
coarse-pointer devices even when CodeMirror is forced, and
?composer=textarea|codemirror serves as a debugging and rollback escape
hatch. The choice is frozen at mount so a mid-session flip cannot drop the
draft.
Known textarea-backend degradations (commands still work as typed text):
no slash/@ completion menus, no inline tag chips (fall back to the top
placement), no history arrow navigation, no large-paste placeholders, and
no followup Tab-accept.
Fixes#5958
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web-shell): address review — textarea auto-grow, change notifications, caret restore
Address the five inline review suggestions on #7587:
- Auto-grow the mobile textarea with its content, capped by the computed
CSS max-height (so --chat-editor-input-max-height overrides stay
authoritative). Previously rows={1} plus resize:none meant multi-line
drafts scrolled inside ~1.5 visible lines and the CSS max-height was
dead. Asserted in the mobile e2e spec via bounding-box growth.
- Fire onInputTextChange from setMobileText, matching the CodeMirror
updateListener contract: programmatic draft changes (setText, history
restore, post-submit clear) now notify parent trackers too.
handleMobileChange delegates to setMobileText.
- Restore the caret after mobile insertText: a controlled textarea resets
the caret to the end on value change; setSelectionRange puts it back
after React re-renders (rAF with a setTimeout fallback), matching the
CodeMirror path's explicit selection anchor.
- Cover the mobile submitSearchMatch path: select a history match, submit
through the shared pipeline, draft cleared.
- Cover the ChatEditor mobile quick-action gating: the history quick
action opens the search UI (never dispatches into a missing EditorView)
and the keyboard shortcut hints grid is hidden on the mobile composer
with a desktop control.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web-shell): decide the touch composer by media query alone
Independent macOS verification on #7587 found that Playwright's stock
WebKit iPhone profiles match '(hover: none) and (pointer: coarse)' but
report navigator.maxTouchPoints === 0, so the automatic detection selected
CodeMirror under unmodified WebKit emulation.
The maxTouchPoints requirement added nothing the AND media query does not
already provide: touch laptops are excluded by the query itself (their
primary pointer hovers and is fine), and the only devices that match the
query with zero touch points are emulated profiles and TV-style browsers,
where the plain textarea is a safe fallback. Dropping it makes stock
iPhone/WebKit Playwright runs exercise the automatic detection branch.
Real-device behavior is unchanged: phones and tablets match the query and
report touch points either way.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web-shell): keep the mobile textarea scrollable past its height cap
As .editorArea's last child the textarea inherited `overflow: clip` from
the `.editorArea > :last-child` wrapper rule (written for the CodeMirror
container, whose inner .cm-scroller does the scrolling). `clip` also
forbids programmatic scrolling, so once auto-grow reached the CSS
max-height, content beyond the cap was unreachable — scrollTop stayed
pinned at 0.
Override with `overflow-y: auto` via `.editorArea > textarea.mobileTextarea`
(the extra type selector outweighs the wrapper rule's specificity). New
mobile e2e regression fills 20 lines, asserts growth stops at the computed
300px cap, and verifies the overflow stays reachable: scrollHeight above
clientHeight and scrollTop actually moving to the bottom — the exact probe
from the review, which pinned at 0 before this fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: ComplexSimply <rudy.arrowsong@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(core): restore background agent roster
* fix(web-shell): add list_agents to TOOL_DISPLAY_NAMES
The new list_agents core wire tool was added to core's ToolNames but not
to the web-shell TOOL_DISPLAY_NAMES map, causing toolFormatting.drift.test.ts
to fail (expected ['list_agents'] to deeply equal []). Add the missing
'ListAgents' display-name entry so the browser panel shows a friendly name
instead of the raw wire name and the drift guard passes.
* fix(cli): reload old-session background agents on failed resume rollback
When /resume fails after core has swapped but before the UI swap, the catch
block rolls core back to the old session via startNewSession(oldSessionId).
However the forward path already called resetBackgroundStateForSessionSwitch,
which cleared the old session's in-memory background agents. The rollback did
not reload them, so list_agents returned empty for the old session (whose
sidecars are still on disk) until the next process start or successful resume.
Reload the old session's paused background agents after rolling core back, so
the restored roster matches on-disk state. Placed after startNewSession so the
loadPausedBackgroundAgents current-session guard is satisfied; best-effort via
.catch so it never blocks the rollback path.
* fix(web-shell): add zh translation for list_agents tool name
The toolFormatting test 'has a zh translation for every tool in the
display-name map' failed with expected ['list_agents'] to deeply equal []
because list_agents was added to TOOL_DISPLAY_NAMES without a matching
toolName.list_agents zh-CN entry. Add the translation to restore parity.
* fix(cli): resolve CI failures for background-agent roster restore
- Add toolDisplayName.ListAgents translations (en, zh, zh-TW, ca) so the
new list_agents tool has a zh entry; fixes i18n/index.test.ts.
- Add loadPausedBackgroundAgents and consumePendingRecoveredAgentsNotice
to the acpAgent worktree test config mock, which loadSession now calls
via #restoreBackgroundAgentsOnResume; fixes acpAgent.worktree.test.ts.
* refactor(core): extract incompatible-isolation blocked reason to a const
Move the incompatible-isolation blocked-reason string out of an inline
literal into a module-level INCOMPATIBLE_ISOLATION_BLOCKED_REASON const,
matching its four sibling reasons so the text is discoverable by
constant-name grep and edited alongside the others.
* fix(core): preserve retained activity state on failed agent revive
Address review feedback on the background-agent roster restore:
- On a failed completed-agent revive, restore UI state with a non-empty
guard instead of `??`. Because `restorePausedEntry` resets the paused
entry's `recentActivities` to `[]`, the previous `failedEntry?.field ??
completedEntry.field` kept that empty array and dropped the pre-revive
snapshot (the UI Progress section rendered empty). Applied consistently
to pendingMessages, recentActivities, and pendingApprovals.
Add regression coverage for previously untested paths:
- failed revive preserves pre-revive recentActivities
- terminal-agent cap admits only the newest MAX_RETAINED_TERMINAL_AGENTS
completed sidecars on restore
- /resume rollback reloads the old session's background agents
- headless resume prepends the recovered-agents notice to the prompt
* test(cli): cover interrupted-turn continuation not consuming recovered-agents notice
Add ACP and headless regression tests asserting an interrupted-turn
continuation does not consume the one-shot recovered-agents notice
(the !isContinue / !continueInterrupted guards), so it is delivered on
the user's next ordinary prompt. Mirrors the existing slash-command
coverage.
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat(web-shell): add managed workspace selector
Let Web Shell create and select daemon-managed workspaces without
changing ownership of existing sessions.
- Add capability-gated existing and scratch workspace registration
- Validate scratch roots, trust provenance, capacity, and shutdown races
- Serialize workspace mutations, session switching, and refresh results
- Add SDK/WebUI wiring and focused cross-package regression coverage
# Conflicts:
# packages/web-shell/client/App.tsx
# packages/web-shell/client/components/sidebar/WebShellSidebar.tsx
# Conflicts:
# packages/cli/src/serve/capabilities.ts
# packages/cli/src/serve/routes/workspace-management.ts
# packages/cli/src/serve/server.test.ts
# packages/sdk-typescript/src/daemon/DaemonClient.ts
# packages/web-shell/client/App.tsx
# packages/web-shell/client/components/dialogs/AddWorkspaceDialog.tsx
# packages/web-shell/client/components/sidebar/WebShellSidebar.tsx
* fix(web-shell): revalidate workspace before session creation
Prevent a stale workspace selection from bypassing the latest trusted
capability snapshot during lazy session creation.
- Validate the selected workspace before passing it to the daemon
- Fall back to the primary workspace when trust has been revoked
- Add a regression test for the pre-effect race window
- Remove stale branch state and clarify add-workspace ownership
* fix(web-shell): improve workspace removal feedback
Keep workspace removal controls legible and make blocked force removals
visibly inactive.
- Size the action menu independently from its narrow icon trigger
- Add a disabled affordance and suppress destructive hover styling
- Cover the removal menu width override with a regression test
* fix(web-shell): centralize existing workspace registration
Route sidebar and composer entry points through the App-owned dialog so
capability gating and workspace reconciliation remain consistent.
- Forward display names only when the daemon advertises support
- Hide and suppress persistence when registration is runtime-only
- Mark directory registrations with existing-workspace provenance
- Cover both entry points and capability combinations with tests
* fix(web-shell): address review feedback on workspace dialogs and capability docs (#7390)
- Document dynamic_workspace_registration and scratch_workspace_registration
in the conditional serve-features table so the capabilities-docs-contract
test passes.
- Gate DialogShell backdrop-click and Escape dismissal on the dismissible
prop so non-dismissible dialogs ignore both gestures.
- Surface an inline error when an added folder registers but the capability
refresh fails, mirroring the scratch recovery path.
- Add coverage for the active-session workspace switch and the add-folder
refresh-failure paths.
* fix(web-shell): address review feedback on workspace dialogs and capability docs (#7390)
---------
Co-authored-by: heyang.why <heyang.why@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
* feat(web-shell): Add sidebar customization API for branding, navigation, session actions, and footer
Add new sidebar configuration options to WebShellSidebarOptions:
- primaryNav.items: Control which built-in nav buttons are shown (newTask, plugins, scheduledTasks, goals)
- primaryNav.render(): Append custom content after built-in nav buttons
- hideProjectHeader: Hide the 'Projects' header row (search + add workspace)
- sessionActions.items: Control which session action items appear (both inline and dropdown)
- sessionActions.inlineItems: Control which items render as inline hover buttons (supports all action types with icon/text fallback)
- footer.render(): Inject custom UI elements on the left side of the footer
- Move scheduledTasks and goals from footer.items to primaryNav.items
Visibility follows a strict 'hide-first' policy: inline buttons require all three conditions (items includes + inlineItems includes + built-in capability check) to render.
* fix(web-shell): Prevent inline/dropdown duplication and add pin/archive dropdown fallback
- Critical fix 1: Dropdown items now exclude items already shown as inline buttons (added !inlineActionItems.has guard to each dropdown entry and trigger visibility).
- Critical fix 2: pin and archive now have dropdown menu entries as fallback when not configured as inline items, preventing them from becoming inaccessible.
- Narrowed inlineItems type to WebShellSidebarSessionInlineActionItem (excludes details/group which have no working inline handlers), preventing dead buttons.
- Added destructive color styling (var(--destructive)) to inline delete button when not disabled.
* fix(web-shell): Re-export new sidebar types and add visibility matrix tests
- Re-export WebShellSidebarPrimaryNavOptions, WebShellSidebarPrimaryNavItem, WebShellSidebarSessionActionsOptions, WebShellSidebarSessionActionItem, and WebShellSidebarSessionInlineActionItem from client/index.tsx so consumers can import them by name.
- Add session-action-visibility.test.ts: table-driven tests covering the items × inlineItems × capability matrix (default config, empty items/inlineItems, dedup guarantee, pin fallback to dropdown). 7 test cases, all pass.
* fix(web-shell): gate readOnly archive on sessionActionItems, fix footer null safety and docs accuracy (#7379)
---------
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
* feat(web-shell): surface worktree isolation in the new-session empty state
The worktree-isolated session entry was buried in the sidebar git-branch
pill dropdown, making it hard to discover. Add a visible toggle to the
chat empty state — the de-facto new-session page — that reuses the
existing pending-worktree state machine and lazy session creation, so no
SDK or daemon changes are needed. Enabling it shows the pending badge
with a cancel affordance; the first prompt then creates the session in an
isolated worktree. The toggle is offered only when the target workspace
is trusted and is a git repository, mirroring the sidebar entry gating.
Also simplify the sidebar git pill: drop the now-redundant "New worktree
task" item and make the pill open the changes view directly instead of a
single-item dropdown.
* chore: add PR verification screenshots for the worktree toggle
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* test(web-shell): capture the new-session empty state in the visuals suite
The worktree toggle lives in the new-session empty state, and every visuals
scenario navigates to /session/:id via gotoSession — so the suite had never
rendered the empty state at all, and the before/after preview reported "no
screenshot changes" for this PR despite the new UI.
Add a `gotoNewSession` harness helper (primes the theme, lands on `/`, asserts
the theme took effect; no replay to settle) and a `worktree empty state`
scenario using the git-ready workspace this PR already made mockable
(`gitStatus` + the /workspaces/:cwd/git route). It captures both states — the
offered toggle and, after clicking, the pending-worktree badge with its cancel
affordance — and asserts the swap, so a regression fails an assertion rather
than only differing in the screenshot. All four captures are byte-stable
across runs (0% pixel diff).
The helper also closes the structural gap: any future empty-state work
(onboarding copy, first-run affordances) now has a way into the preview.
* refactor(web-shell): drop dead worktree session opt; click-test git chip (#7365)
* fix(web-shell): address review feedback on worktree toggle (#7365)
- Move focus to cancel button on toggle enable and back on cancel (a11y)
- Include branch name in git-pill button aria-label (a11y)
- Replace hardcoded flush() ticks with vi.waitFor() in test helper
- Move git-repo mock default from afterEach to beforeEach
- Add test: sidebar New chat clears pending worktree intent
---------
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
* feat(web-shell): add git commit history browser
Add a read-only Git Log dialog to the Web Shell, accessible via /log
command or the History tab in the Changes dialog.
Full-stack implementation across core, daemon, SDK, and web-shell:
- core: fetchGitLog (paginated commit list) and fetchGitCommitDetail
(message body + per-file numstat) with 12 integration tests
- daemon: GET /workspace/git/log and /workspace/git/log/commit routes
with bound + qualified dual registration
- SDK: DaemonGitLog/DaemonGitCommitDetail types and client methods
- web-shell: GitLogDialog with commit list, expandable details,
SHA copy icon, Load more pagination, and Changes/History tab
switching in both dialogs
* fix(web-shell): address review feedback on git log browser
- Critical: use first-parent diff for merge commits (diff-tree without
-c or explicit parent outputs nothing for merges)
- Remove dead embedded prop from GitDiffDialog and GitLogDialog
- Replace span role=button with aria-hidden for copy icon (a11y)
- Add loadMore error feedback instead of silent catch
- Refresh relative timestamps every 60s (useState + interval)
- Remove dead branch param from subtitle i18n call
* fix(web-shell): address R2 review feedback on git log browser
- Use bounded split in parseLogFields (first 7 separators) to prevent
subject containing literal \x1f from shifting the parents field
- Add SHA hex format validation at daemon route layer (400 for invalid)
- Add rendering branch for detail.available === false (error message
instead of empty content)
* fix(web-shell): count renamed files in commit detail + test git-log route & dialog
Follows the R2 review-feedback commit (which fixed the bounded parse, the
non-hex SHA 400, and the unavailable-detail render). Remaining items:
- Commit detail counts renamed files. diff-tree is plumbing and does not
honour diff.renames, so a `git mv` split into a delete + add pair (or an
empty-path entry) instead of one file — understating filesCount /
linesAdded / linesRemoved. Run diff-tree with -M and give the inline
numstat parser the same pending-rename state machine as parseGitNumstat,
so a rename is one file keyed by its new path. Covered by a real-repo
rename test, plus a merge-commit test that locks the first-parent diff.
- Tests for the two previously-untested modules: the workspace-git-log
route (list shape, pagination clamping, sha-required + non-hex 400, trust
gating) and GitLogDialog (all five list state paths, load-more offset +
error, detail expand + both failure branches incl. available:false, and
the relative-time render).
* fix(web-shell): address R3 review feedback on git log browser
- Fix timeAgo '0y ago' for commits ~360-364 days old (Math.max(1, ...))
- Add .catch() to clipboard writeText to prevent unhandled rejection
- Reset loadMoreError on initial re-fetch (daemon reconnect)
- Preserve prev.available in loadMore merge instead of overwriting
- Add ARIA tab semantics (role=tablist/tab, aria-selected) to both
Changes and History tab bars
* fix(web-shell): address R4 review feedback on git log browser
- Fix vacuous limit-clamp test: seed 3 commits, verify limit=2 returns
2 + hasMore, limit=0 clamps to 1
- Add CSS var fallbacks for --subtle-bg and --success-bg (dialog
portals outside App.module.css scope)
- Extract GIT_DIALOG_SWITCH_DELAY_MS constant with doc comment
- Make copy-SHA control keyboard accessible (tabIndex, onKeyDown,
aria-label instead of aria-hidden)
* fix(web-shell): escape apostrophe in worktree welcome string
* fix(web-shell): address git history review feedback
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* test(web-shell): mock git diff content in app tests
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(core): harden git log metadata parsing
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
---------
Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
- serve.test.ts: increase startup timeout from 15s/20s to 30s/40s
(CI cold-start can exceed 15s when compiling TypeScript)
- usage-stats.test.ts: increase pending-load wait from 20ms to 200ms
(supertest needs time to reach the handler on slow CI runners)
- TasksStatusMessage.test.tsx: increase keydown guard waits from 80ms
to 200ms (50ms guard delay + only 30ms margin was too tight for CI)
No production code changes — test-only timing adjustments.
CI run: https://github.com/QwenLM/qwen-code/actions/runs/29724763252
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Add support for creating sessions in isolated git worktrees from the
Web Shell, enabling multiple tasks to run in parallel within the same
workspace without polluting the main working directory.
Daemon:
- POST /session accepts optional worktree param, creates worktree via
GitWorktreeService, relocates session via changeSessionCwd
- Worktree metadata persisted in SessionEntry, BridgeSessionSummary,
and sidecar file (<sessionId>.worktree.json) for daemon restart
recovery
- GET /workspaces/:workspace/git supports ?cwd= for worktree-scoped
git status queries (path.resolve + containment check)
SDK:
- CreateSessionRequest/DaemonSession/DaemonSessionSummary gain
worktree field; DaemonSessionClient exposes worktree getter
- WorkspaceDaemonClient.workspaceGit() accepts optional cwd param
Web Shell:
- Workspace branch pill dropdown offers 'New Worktree Task' (git repos
only) with purple GitForkIcon and description
- Git chip turns purple with GitForkIcon for worktree sessions
- Session list shows inline ⑂ badge for worktree sessions
- Empty-state welcome badge explains worktree isolation
- Git status queries target worktree path, not workspace root
- session_cwd_changed event filtered from chat transcript
Design doc: docs/design/2026-07-19-webshell-worktree-sessions.md
* feat(web-shell): add readonly transcript renderer
* feat(transcript): project chat records to daemon transcript
* chore: remove unrelated merge changes
* fix(cli): align transcript replay test mock
* fix(transcript): preserve replay metadata and todo context
Keep vision disclosures and assistant usage visible across tool replay boundaries.
Propagate plan tool-call identity through daemon projection and provide Todo contexts in WebShell so snapshots remain independent. Accept session_source records and cover the cross-layer behavior with regression tests.
* docs(transcript): translate designs and remove benchmark table
---------
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
* fix(web-shell): make approval and question overlays keyboard accessible
The tool-approval and ask-user-question overlays rendered their options as
plain <div onClick> elements with no role, tabindex, or key handling, so
keyboard and screen-reader users could not authorize a tool call or answer a
question — the core interaction of the chat surface.
Convert the options to real <button>s in a roving-tabindex group, with
focus-scoped keyboard navigation (arrows/j/k, Home/End, Enter/Space, digit
shortcuts, Escape), visible :focus-visible rings, and an alertdialog role with
labelledby/describedby so assistive tech announces the prompt on arrival. The
question text, previously display:none, is kept in the accessibility tree as
the dialog description.
The overlays now own their own focus, pulling it to the safe-default option
when they become the topmost surface; the app just signals topmost-ness via a
keyboardActive prop. Replacing the old global window key listener with
panel-scoped handling also stops a keypress from confirming a different
split-view pane's request.
* fix(web-shell): address review feedback on approval overlay keyboard nav
Follow-up to the review comments on the keyboard-accessible approval overlays:
- AskUserQuestion moveSelection now writes selectedIdxRef synchronously, so a
held arrow key (repeating faster than React re-renders) advances correctly
instead of sticking on one option. The reset effect syncs the ref too, so a
fresh request focuses the right option rather than the previous request's
stale selection.
- ToolApproval restores the user's selected option when the overlay is
re-activated (e.g. a covering panel closes) instead of snapping focus back to
the safe default and silently changing what Enter would confirm.
- The "Other" custom-input trigger now carries aria-keyshortcuts so its digit
shortcut is discoverable to screen readers.
- Adds tests for Home/End navigation, the rapid-repeat regression, the
custom-input digit guard, and the AskUserQuestion keyboardActive wiring.
* test(web-shell): cover keyboardActive=false on the pane AskUserQuestion
Mirror the existing ToolApproval coverage: capture keyboardActive on the
AskUserQuestion mock and assert a split-view pane's question renders with
keyboardActive={false}, so a refactor that drops the prop — which would let a
pane's question auto-grab focus away from the pane the user is in — is caught.
* test(web-shell): cover AskUserQuestion focus restore on re-activation
Mirror the ToolApproval guard: when a covering panel flips keyboardActive
false then true, focus must return to the option the user had selected rather
than snapping back to the default (which would silently change what Enter
submits). Verified the test fails if the focus effect snaps to the default.
* fix(web-shell): scope AskUserQuestion key handling to option elements
handleKeyDown was attached to the whole panel, so digit / j-k / Home / End /
Escape fired whenever focus was on any non-editable descendant — including the
Submit/Previous/Next buttons and the collapse toggle. A keyboard user tabbed
onto Submit could silently overwrite their selected answer with a digit, or
cancel the question with Escape; when collapsed (only the toggle focusable) the
same shortcuts fired against options that weren't even rendered. Guard the
handler so it only reacts when focus is on an option (a roving-tabindex button
or the "Other" trigger).
Also adds the two tests the review asked for: the action-button hijack guard
above, and a ToolApproval assertion that Enter is left to native button
activation (so a reintroduced double-press guard would be caught).
* fix(web-shell): refine approval/question ARIA semantics per review
- Single-select AskUserQuestion options now use radiogroup/radio + aria-checked
(instead of toggle-button aria-pressed) so screen readers convey mutual
exclusivity; multi-select keeps toggle buttons.
- The expanded question dialog is named with both the tool name and the question
(aria-labelledby references both), so the tool-name context isn't dropped when
aria-labelledby overrides aria-label.
- Drop the redundant role/label on ToolApproval's option container — the
alertdialog already exposes the question via aria-describedby, so labelling
the container with the same text made screen readers speak the question twice.
Adds tests locking in the radio semantics and the expanded-dialog naming.
* fix(web-shell): more approval/question ARIA refinements per review
- ToolApproval options now use radiogroup/radio + aria-checked (the approval
choice is single-select), matching the AskUserQuestion pattern.
- ToolApproval's alertdialog aria-describedby now also references the tool
description and the command/content, so screen readers announce WHAT will run
(e.g. `rm -rf …`), not just the question.
- Add multi-select test coverage for AskUserQuestion (group + aria-pressed
toggle semantics, toggle activation, joined-answer submission) — the isMulti
branch previously had zero coverage.
Reverse-audited each: the new tests fail if the radio role, the command in
aria-describedby, or the multi-select group role is removed.
* fix(web-shell): avoid dangling aria-describedby refs; cover Other focus restore
- ToolApproval's aria-describedby now only references the description/command
ids when those elements actually render, so there are no dangling ARIA
IDREFs (axe-core aria-valid-attr-value) for approvals without a command or
description.
- Add a test that the "Other" trigger regains focus when the question overlay
is re-activated (covers the focus effect's customRef branch).
- Add a no-dangling-IDREF test for the basic approval.
Reverse-audited both: each new test fails when the corresponding behavior is
broken.
* test(web-shell): cover new-request-while-active focus path in ToolApproval
Add a test that when a new request (different id) arrives while the approval is
keyboard-active and the user has navigated off the default, focus moves to the
new request's safe default rather than the stale option index (which could map
to a more permissive option in the new request). Reverse-audited: it fails if
both the reset's selectedRef sync and the focus effect's requestChanged branch
are removed.
* fix(web-shell): make single-select arrow keys change the answer (radiogroup)
Critical: in single-select AskUserQuestion, arrow keys moved focus but not the
committed answer, so aria-checked (bound to `answers`) stayed on the original
option and Submit sent the answer the user never chose — violating the
radiogroup contract. moveSelection now updates the answer for single-select
(the "Other" row still opens on Enter, not on arrow).
Also:
- aria-keyshortcuts is only advertised for options 1-9 (the handler ignores
multi-digit keys, so a 10+ option would announce a shortcut that fails).
- ToolApproval's focusOption blurs before refocusing an already-focused option,
so a new request landing on the same index re-announces for screen readers.
- Tests: arrow-keys-change-the-answer (Critical guard) and
new-question-while-active focus reset.
Note: this changes single-select interaction so arrows select immediately
(standard radiogroup behavior) rather than highlight-only-then-Enter.
* fix(web-shell): unify single-select navigation so aria-checked follows focus
Home/End and the "Other" branch of moveSelection didn't keep the committed
answer in sync with focus, so aria-checked stayed on a stale option (a
radiogroup-contract violation) — the same class of bug as the arrow-key fix.
Extract a shared selectIndex() used by arrows, Home, and End: moving to a
regular option commits it as the answer; moving to "Other" clears the regular
answer (the custom answer isn't committed until the user types it).
Tests: Home/End change the single-select answer; moving to "Other" clears the
regular answer (both reverse-audited).
* fix(web-shell): make the whole "Other" row clickable
The "Other" row wrapper kept cursor:pointer (from styles.option) but lost its
onClick when the inner span became a button/input, so clicking the row's
padding showed a clickable cursor yet did nothing. Add onClick to the wrapper
(the trigger button's click and native Enter/Space activation bubble up to it),
and stop the custom input's click from bubbling so caret positioning isn't
re-triggered.
Test: clicking the row padding opens the custom input (reverse-audited).
---------
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
* feat(web-shell): git status chip, visual working-tree diff, and sidebar git status
Bring working-tree Git awareness to the Web Shell (browser daemon session UI):
- Toolbar branch chip becomes a live status indicator: dirty (staged/unstaged/
untracked), ahead/behind upstream, stash count, detached HEAD, in-progress
operation (merge/rebase/cherry-pick/revert/bisect), and conflict count, each
with a non-color cue.
- Read-only "Changes" dialog: working-tree-vs-HEAD file list with per-file,
line-level, per-side syntax-highlighted diffs; opens via /diff or a dirty
chip; untracked files expand as fully-added and deleted files still diff.
- Per-workspace git status in the sidebar: a compact icon-only chip per trusted
workspace (status dot + hover tooltip); click opens that workspace's dialog.
All git access goes through the daemon REST API with per-workspace trust
gating; new SDK status fields are optional and additive (v2).
* fix(web-shell): themed tooltips and git-chip review follow-ups
Tooltips now render on the themed popover surface (bg-popover /
text-popover-foreground / border + fill-popover arrow) instead of the
inverted bg-foreground default, so they read dark-on-dark rather than a
bright box on the dark theme. Fixing the shared primitive corrects the
git branch tooltip in the composer toolbar and sidebar, plus every other
tooltip, at once.
Also addressing review feedback on the git integration:
- Replace the hand-drawn detached/conflict/stash SVG icons with
lucide-react (CircleDot / TriangleAlert / Layers) per the web-shell
icon convention.
- Gate the tooltip "Working tree clean" message on an enriched status
(computedAt) so a branch-only status no longer asserts clean.
- Include the file path in the diff dialog row aria-label so screen
readers can distinguish files.
- Reset the toolbar git chip on workspace switch so it never shows the
previous repo's branch/counts while the new fetch resolves.
- Log a sidebar git poll failure only on the success->failure transition
to avoid spamming a long-lived tab.
- Correct the SDK doc for DaemonWorkspaceGitDiffFile.added/removed
(0, not undefined, for binary files).
* fix(web-shell): address git-integration review suggestions
Follow-ups from the /review pass on the git integration:
- GitBranchIndicator: include the short SHA in the detached-HEAD tooltip
title, and add the "Working tree clean" status to the aria-label (gated
on an enriched status, matching the tooltip) so the two never drift.
- WorkspaceSection: keep the last known git status on a transient poll
failure instead of blanking the chip for a whole interval.
- App: surface a toast for `/diff` when no workspace is available instead
of silently consuming the composer input.
- Tests: cover the diff dialog's list-load and per-file load error paths,
and detectGitOperation's revert/bisect branches.
- Design doc: align the getGitWorkingTreeStatus spec text with the
decision (transient states return status with `operation`; null is
reserved for non-repo / git failure).
* fix(web-shell): focus-visible ring for git chip button; align doc poll interval
- Add a :focus-visible outline to .gitBranchChipButton so keyboard users
get a visible focus indicator (the chip resets UA button chrome).
- Design doc: align the active-workspace poll-interval references at 30s
to match the implementation.
* fix(web-shell): surface capped diffs, catch row-build failures, cover degradation paths
Address the remaining review findings on the git integration:
- Truncation is no longer silent: fetchGitDiffHunksForFile now returns
{ hunks, truncated } — the parser records files that actually lost
lines to MAX_LINES_PER_FILE (tracked path), and the untracked
synthesis reports its byte/line caps. The route forwards an additive
`truncated` flag on the hunks response (absent when not truncated, so
older clients and daemons are unaffected), and the Changes dialog
renders a "Diff truncated" note under the visible window.
- DiffHunks catches an unexpected buildRows rejection (e.g. malformed
hunk lines) and shows the per-file error instead of leaving an
unhandled rejection and a silently empty diff area.
- New tests: untracked and tracked truncation at the core caps, the
route's truncated passthrough (and its absence when clean), the
branch-only degradation when the working-tree summary throws, the
malformed-hunks error path, and the Shiki success path (a fake
tokenizer proving add rows pull new-side tokens and del rows pull
old-side tokens, not the plain-text fallback).
* fix(web-shell): drop dialog backdrop-blur that froze the page on open
The dialog and alert-dialog overlays applied `backdrop-blur-xs`, which
forces the browser to rasterize and blur the entire content behind the
overlay when a dialog opens. With a long transcript behind it, that
main-thread paint+blur froze the whole page — e.g. clicking the git
branch chip to open the Changes dialog. Keep the bg-black/10 scrim for
separation and drop the blur.
* fix(core): guard synthesizeUntrackedHunk against non-regular files
synthesizeUntrackedHunk opened an untracked path before checking its
type, so an untracked FIFO (listed by `ls-files --others`) would block
on open() forever waiting on a writer — hanging the daemon's event loop
and leaving the Web Shell Changes dialog stuck on a permanent loading
state. lstat-gate on regular files before opening, matching the existing
guard in countUntrackedLines. Adds a FIFO regression test.
* fix(web-shell,core): rename expansion, no-newline marker, chip measurement
Round-5 review Criticals:
- core: key renamed diff entries by the real (post-rename) path and carry
the old path for display, so renamed rows can be expanded — the synthetic
`old => new` key was sent to git as a nonexistent literal path. The diff
dialog renders the rename as `old → new`.
- core: preserve Git's `\ No newline at end of file` marker through the hunk
parser so a trailing-newline-only edit isn't shown as identical
removed/added lines (the viewer already renders it as a meta row).
- web-shell: the toolbar's hidden git-chip measurement replica now renders
the full chip content via the extracted GitBranchChipContent, so the
expanded width includes the status indicators and the compact/expanded
toggle no longer oscillates near the responsive threshold.
* fix(build): generate git-commit info even when prepare build is skipped
The review tooling runs `npm ci` with QWEN_SKIP_PREPARE=1 (to skip the
heavy prepare build) and then builds only the changed workspaces. Because
`prepare` exited before generating the gitignored git-commit.ts, a
per-workspace build of packages/cli failed at the unchanged systemInfo.ts
on the missing `../generated/git-commit.js` module. Generate the git-commit
info in the skip path too — it is cheap and never fails hard — so a later
per-workspace build or typecheck finds the module. The non-skip path still
generates it via `npm run build`.
* fix(web-shell,cli): address round-6 review suggestions
- cli: carry the pre-rename path (oldPath) through DiffRenderRow and show
renamed files as `old → new` in both the Ink and plain-text renderers.
The rename-keying fix updated the daemon and web-shell dialog but not the
CLI `/diff` renderer, which silently dropped the old path.
- web-shell: key DiffFileRow by workspace + path so switching workspace
remounts the row instead of reusing another workspace's hunks/open state
for a path both workspaces share.
- web-shell: show a loading placeholder in DiffHunks while rows are (re)built
(e.g. after a theme switch) instead of an empty, jumpily-resized box.
- web-shell: cover the /diff local intercept in App.test.tsx (opens the
Changes dialog and is not forwarded to the agent).
* fix(web-shell,cli,core): address round-7 review suggestions
- cli: sanitize the rendered filename (and pre-rename oldPath) in the Ink
DiffStatsDisplay via sanitizeFilenameForDisplay, matching the plain-text
renderer so a crafted path can't inject into the interactive view.
- cli: apply the read headers before awaiting the per-file diff fetch (as
handleDiffList does) so error responses also carry no-store/nosniff.
- cli + web-shell: strip Unicode bidi embedding/isolate controls
(U+202A-202E, U+2066-2069) in the filename/control-char sanitizers so a
crafted filename can't visually spoof its extension.
- core: guard countStashEntries with an lstat type check before readFile, so
a symlink-to-FIFO at logs/refs/stash can't block the event loop (the same
hazard already guarded in the untracked-file readers).
- core: cover fetchGitDiffHunksForFile's transient-state guard with a test
(the sibling helpers already had one).
* fix(web-shell,cli,core): address round-8 review suggestions
- core: pass --no-optional-locks to the ls-files call in
fetchGitDiffHunksForFile, matching the other runGit calls so it doesn't
contend for an optional index-refresh lock alongside concurrent git
add/commit.
- cli: add a route test asserting a rename's oldPath survives serialization
end-to-end (keyed by the new path, old path carried alongside).
- web-shell: add a GitDiffDialog test for the hiddenCount>0 "N more files
not shown" note (every payload previously used hiddenCount: 0).
- web-shell: drop the nonexistent primaryLabel prop from the WorkspaceSection
test (it is not a WorkspaceSectionProps member).
- docs: correct the plan doc — large-diff virtual scrolling was explicitly
descoped (core caps + per-file lazy loading), not implemented in Phase 2.
* fix(cli,web-shell): address round-9 review findings
- cli: propagate the pre-rename oldPath through DiffDialog's
perFileToUnified and render renamed files as `old → new` in the
interactive diff viewer (the rename-keying fix had updated the daemon,
the web-shell dialog, and the /diff stats, but not this viewer).
- cli: cover DiffStatsDisplay's rename (`old → new`) rendering and the
sanitizeFilenameForDisplay path for hostile filenames carrying control
characters.
- web-shell: guard the GitBranchIndicator test afterEach against
double-unmounting an already-unmounted root (the localization tests
assert on getTranslator without calling render()).
* fix(core,cli,web-shell): rename-aware single-file diff (old→new)
fetchGitDiffHunksForFile pathspec-limited the diff to the new path, which
defeats git's rename detection — a renamed file was reported as fully
added (every line +) instead of its actual edit. Thread an optional
pre-rename path through the single-file endpoint (core → route → SDK →
dialog) and diff old→new with -M when it is present, so expanding a
renamed file shows its real content change.
* fix(cli): address round-10 review suggestions
- DiffDialog: split the path-width budget between old and new paths for a
rename (reserving the " → " separator) so the combined width stays within
maxPathChars instead of overflowing the row layout.
- textUtils: extend MULTILINE_CONTROL_CHARS_REGEX with the Unicode bidi
ranges (matching FILENAME_CONTROL_CHARS_REGEX) and add a test that
sanitizeFilenameForDisplay strips bidi embedding/isolate controls.
- workspace-git-diff route: add a test that ?oldPath= is parsed and
forwarded to fetchGitDiffHunksForFile.
* test(sdk),docs: cover diff client methods; align design doc
- sdk: add DaemonClient unit tests for workspaceGitDiff() and
workspaceGitDiffFile(path, oldPath?) — URL construction (incl. urlEncode
on path/oldPath, with and without oldPath, plus the workspace-qualified
route) and response deserialization, mirroring the existing workspaceGit()
test.
- docs: add the oldPath? param to the workspaceGitDiffFile API spec; record
that the diff client methods now have unit tests (correcting the claim
that workspaceGit() had none); attribute the bundle-limit bump to
packages/sdk-typescript/scripts/build.js; clarify ahead/behind are relative
to upstream (0, and ↑N/↓N not shown, without one).
* fix(web-shell,core): address round-11 review suggestions
- GitBranchIndicator: count conflicted entries as dirty — a merge where every
changed file is conflicted (staged=unstaged=untracked=0) is still
uncommitted, so the expanded chip's dirty dot / data-dirty now reflect it.
- core: split the status branch line at the last "..." (the branch/upstream
separator) so a dotted branch name isn't truncated at the first "...".
- GitDiffDialog: guard DiffFileRow's in-flight fetch against unmount via a
cancelled ref, matching DiffHunks / GitDiffDialog.
- tests: forward oldPath when expanding a renamed file in the web-shell
dialog; bidi-strip coverage for the web-shell sanitizeControlChars;
untrusted-guard coverage on the single-file diff route; conflicted-only
dirty; branch-line "..." split.
* fix(web-shell,cli): address round-12 review suggestions
- DiffDialog: only render the rename "old → new" when there's room for both
sides (≥19 cols, so each gets ≥8); otherwise fall back to the new path
alone, so a narrow terminal no longer overflows the row (the Math.max(8,…)
floor could exceed maxPathChars).
- GitBranchIndicator test: guard afterEach container.remove() for non-render
tests run in isolation, and make the compact-mode ↑-suppression assertion
non-vacuous by giving the fixture an ahead count.
- App: compute the active workspace once (useMemo) and share it between the
git-status effect and the Changes-dialog entry point, so the chip and the
dialog can't drift onto different repos.
* fix(core,docs): address round-13 review suggestions
- core: add a rebase-apply detection test (git am / an interrupted
`rebase --apply` creates rebase-apply, which detectGitOperation also maps
to 'rebase'); previously only rebase-merge was exercised.
- docs: correct section 5 to describe the actual diff-dialog mechanism
(diffWorkspaceCwd state, not the stale activePanel design).
* test(core): cover stray no-newline marker before any hunk header
parseGitDiff's pre-hunk guard already skips a "\ No newline at end of
file" marker that appears before any @@ header, so a malformed/truncated
diff can't throw on a null currentHunk and lose subsequent files' hunks;
add a regression test pinning that behavior.
* fix(web-shell): unstick per-file diff loading and skip non-path git poll
- DiffFileRow: reset the cancelled-fetch flag on mount so StrictMode's
mount/unmount/mount replay no longer leaves it latched at true, which
dropped the fetched hunks and froze the row on "Loading changes…" despite
a 200 response.
- WorkspaceSection: skip the git status poll when the workspace cwd is not an
absolute path. A synthetic fallback workspace carries a display name there,
which the cwd-qualified route rejects with a 400.
* fix(web-shell,cli): address review suggestions on the git diff surface
- GitDiffDialog: highlight each diff side independently so a small side
keeps syntax highlighting even when the other side exceeds the size cap
(the old guard dropped both as soon as either was too large).
- ChatEditor: complete the .gitBranchChipButton reset (font/color/padding/
margin) so the clickable dirty-tree chip matches the read-only output chip
instead of picking up UA button styling.
- DiffDialog: cover the interactive rename display (old to new on a wide
terminal), mirroring the rename tests DiffStatsDisplay and GitDiffDialog
already have.
* test(web-shell,cli): cover git chip clean/reload/traversal paths, fix doc
- GitDiffDialog: add the missing expect(header).not.toBeNull() guard to the
three expand-file tests that lacked it, matching the others in the block.
- GitBranchIndicator: cover the known-clean aria-label branch (computedAt set
and every change counter zero).
- WorkspaceSection: verify a reloadToken change re-fetches git status instead
of waiting for the next 60s poll.
- workspace-git-diff route: verify a traversal oldPath is forwarded to core
and surfaced as available:false rather than escaping the workspace.
- Design doc: /diff is handled via setDiffWorkspaceCwd, not setActivePanel.
* fix(core): allow literal `..foo` paths in diff normalization
- toRepoRelativePath: reject only a real climb-out (`..` or `../…`), not a
literal `..foo` filename at the repo root, which the bare startsWith('..')
over-rejected, leaving the diff viewer unable to render such a file.
- parseGitDiff: cover the truncatedPaths output set directly (it was only
exercised indirectly through fetchGitDiffHunksForFile).
---------
Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
* fix(goals): persist goal cards and restore the hook on daemon resume
In daemon mode a `/goal` was silently lost whenever its session was
reloaded or `qwen serve` restarted: the goal card vanished from the
transcript and the Stop hook was never re-registered, so the loop simply
stopped advancing. The TUI does neither of these things wrong; the ACP
path was missing both halves.
Goal cards were only ever emitted as live SSE `_meta` (MessageEmitter's
emitGoalStatus / emitGoalTerminal) and never written to the transcript,
so the one durable store — the ChatRecord JSONL — had nothing to restore
from. Record them from Session.emitGoalStatus, the single choke point for
`set` and `cleared` (the sessionGoalClear ext method routes through it
too), and from the goal terminal observer for `achieved` / `failed` /
`aborted`. Persisting `cleared` matters on its own: without it the last
stored card stays `set`, and a later resume would revive a goal the user
explicitly dropped.
HistoryReplayer dropped those records on the way back out — it reads only
`item['text']`, and a goal card has no `text` field — so re-emit them as
`_meta.goalStatus`. Per-iteration `checking` cards are skipped: a TUI
transcript stores one per stop-hook turn and clients suppress them as
noise. That costs no fidelity, because restore reads the records directly
rather than the replay output.
With the transcript carrying the goal again, add #restoreGoalOnResume to
loadSession and unstable_resumeSession, alongside #restoreWorktreeOnResume.
It rebuilds the goal cards from the resumed ChatRecords (they live inside
system/slash_command records' outputHistoryItems) and reuses the existing
findGoalToRestore / findLastTerminalGoal / registerGoalHook logic, trust
and hook-policy gates included.
* feat(web-shell): add a workspace Goals page
`/goal` had no visual surface in the web shell. You could set and clear
one from the composer, but the only feedback was a status-bar pill and a
transcript card, and there was no way to see every goal running in the
workspace at once. Add a full-pane Goals page alongside Scheduled Tasks.
Each row shows the condition, the session driving it, whether the loop is
mid-turn, the judge's turn count and last verdict, and how long the goal
has been running. A row opens its session — the transcript IS the goal's
history — or clears the goal. A form starts a new goal in a fresh session,
so the loop doesn't take over a conversation already in progress.
Reading the goals needs a round trip. They live in the owning `qwen --acp`
child's in-memory store, and serve runs in a separate process holding only
a bridge, so there is nothing local to read. Add a `sessionGoalGet` ext
method that reports one session's goal state, wrap it in
bridge.getSessionGoal (mirroring clearSessionGoal), and have `GET /goals`
fan out over the workspace's live sessions concurrently — one timeout for
a wedged child rather than one per session. A session whose probe rejects
is dropped rather than failing the whole list. Clearing reuses
`POST /session/:id/goal/clear`, so the page and a `/goal clear` typed in
chat take the same path through the daemon.
Only loaded sessions appear, which is the honest answer rather than a
limitation: a goal advances only while its session is resident.
Three entry points: a sidebar button, the status-bar goal pill (now a
button), and a bare `/goal`, which opens the page instead of asking the
daemon to print its status as text — matching how `/schedule` behaves. It
sends no prompt and touches no session, so it works mid-turn too.
`/goal <condition>` and `/goal clear` are unchanged.
The integration test exercises the whole chain against a real daemon:
`GET /goals` -> bridge -> ext method in a spawned `qwen --acp` child.
* fix(web-shell): stop the Goals poll from overlapping itself
`GET /goals` fans out one ext-method probe per live session, and a wedged
child holds it for the bridge's 10s `initTimeoutMs` — the same order as the
10s poll interval. `withActionTimeout` rejects the wait at 30s but never
aborts the underlying fetch, so a fixed `setInterval` could stack several
fan-outs against an already-struggling daemon. `reloadSeqRef` only keeps a
stale response from overwriting state; it does nothing about the pile-up.
Replace the interval with a single self-chaining loop that owns both the
initial load and the polling, scheduling each fetch only once the previous
one has settled. Folding the mount load into the chain matters: left in its
own effect, the first timer would still fire while it was in flight.
Reported by Copilot on #6561.
* fix(goals): address review — clear-keyword condition, silent failures, theme vars
From the /review suggestions on #6561. Applied the ones that held up under
verification; the rest are answered in the PR thread with evidence.
- The New goal form accepted a clear keyword as a condition. It travels as
`/goal <condition>`, so "clear" (or stop/off/reset/none/cancel) reached the
daemon as a clear command: the fresh session dropped its own goal the instant
it was set, with nothing to show for it. Reject it in the form. The keyword
list and `/goal` arg parsing move to `utils/goalCondition.ts` so the page and
App share one definition instead of the page reaching into App.
- Starting a goal failed silently. `onCreateGoal` switches to the chat view
first, which unmounts the Goals page, so the inline form error that
`sendPrompt` rejection produced was dropped by the page's own unmount guard.
Surface it as a toast instead.
- `GoalsDialog.module.css` used `var(--destructive, #dc2626)`, but nothing
defines `--destructive`; the hardcoded fallback stayed the same red in both
themes. Use `--error-color` and match ScheduledTasksDialog's focus outline.
- `recordGoalStatusItem` swallowed recording failures with a bare `catch {}`.
Silently losing that write is precisely the failure this recording exists to
prevent, so log it.
- `GET /goals` dropped failed probes silently — an empty page and a page whose
probes all failed look identical to the client. Log the dropped sessions and
their reasons.
Tests: clear-keyword and MAX_GOAL_LENGTH form validation, goalCondition unit
tests, `sessionGoalGet` argument validation, session load surviving a throwing
goal restore, `/goals` drop logging, and a regression test showing `/goal clear`
sent as a prompt does persist its cleared card (a reviewer flagged this as
missing; it is not).
* fix(goals): cap restored conditions, keep goal-creation errors on screen
Second round of review on #6561.
- `restoreGoalFromHistory` re-registered whatever condition the transcript
held, skipping the 4000-char cap `/goal` enforces at set time. A transcript
is a file: a corrupted or hand-edited `condition` would ride along in every
judge call and continuation prompt for the rest of the session. Gate it
alongside the existing trust and hook-policy gates. `MAX_GOAL_LENGTH` moves
to `restoreGoal.ts` and `goalCommand.ts` imports it — the reverse direction
would be a cycle, since goalCommand already depends on this module.
- Starting a goal switched to the chat view before awaiting `sendPrompt`,
which unmounted the Goals page. The previous commit routed the rejection to
a toast, but the better fix is not to leave: switch views only once the
prompt is admitted, so the error lands in the form the user is looking at.
`GoalsDialog` keeps a toast fallback for the case where the page is closed
while the prompt is still in flight.
- Move the `debugLogger` declaration below the imports in `restoreGoal.ts`.
Imports are hoisted so this compiled, but a statement wedged between two
import blocks is not something to leave behind.
* fix(goals): surface restore/record failures, report unprobed sessions
Third round of review on #6561.
- `debugLogger.warn` no-ops unless a debug session is active
(`debugLogger.ts:216`), so a failed goal restore and a failed goal-card
write were both invisible in production — the two failure modes this PR
exists to fix. Promote them to `writeStderrLine`, which both `ui/App.tsx`
and `session/Session.ts` already use.
- `GET /goals` now returns `droppedCount`. A brownout in which every probe
fails returned `{ goals: [] }`, indistinguishable from a workspace with no
goals — so the user re-creates goals that are already running. The Goals
page shows a notice when the list is incomplete.
- `running` on the wire is really "the owning session is mid-turn", which a
manual prompt in that session also sets. Renamed to `hasActivePrompt` so
the field reports what the daemon actually knows. The UI still maps it to
Working/Waiting.
- Fix the stale "keep in sync" pointer in `goalCommand.ts`: the clear keywords
moved from `App.tsx` to `utils/goalCondition.ts` in the previous commit.
Tests for the four coverage gaps the review named: the `systemMessage` fallback
in `goalTerminalEventToHistoryItem` (including the known lossy collapse when
both fields are set), `#restoreGoalOnResume` on an empty transcript,
`listGoals`/`clearGoal` in `actions.ts`, and the `sendPrompt`-after-
`createNewSession` failure path (added last commit). Plus `droppedCount`
projection and the degradation notice.
* test(goals): update the /goals integration test for droppedCount
Adding `droppedCount` to the `GET /goals` payload broke the end-to-end
assertions, which still expected `{ v: 1, goals: [] }`. Caught in review, not
by CI: the Integration Tests job is gated off for this PR, so nothing ran
these against a real daemon after the shape changed.
`droppedCount: 0` is the load-bearing half of the live-session assertion. A
dropped probe also yields an empty `goals`, so the old assertion could not
tell a successful ext-method round trip from a silently failed one.
Re-ran against a spawned `qwen serve` + `qwen --acp` child: green with the
fix, red without it.
* fix(goals): refuse to replay an oversized goal card
`restoreGoalFromHistory` gates the condition at MAX_GOAL_LENGTH, but
`HistoryReplayer` did not: a corrupted or hand-edited transcript could still
ship an unbounded `condition` to every client inside `_meta.goalStatus`. Apply
the same gate at the replay emit site, so neither the card nor the hook
survives an oversized condition.
The gate deliberately does NOT move into `parseGoalStatusItem`, which would be
the tidier-looking place. `findGoalToRestore` and `findLastTerminalGoal` scan
backwards and stop at the FIRST goal card they meet, so dropping a card at
parse time silently promotes the card before it. A transcript ending in an
oversized `cleared` would then restore the `set` that preceded it — resurrecting
a goal the user explicitly cleared, the exact failure persisting `cleared` was
added to prevent. Parsing therefore stays lossless and the length check lives at
each consumer.
Tests pin both halves: replay refuses at 4001 and emits at exactly 4000, and
three scanner tests show an oversized card still wins the scan so restore can
fail closed on it.
* fix(goals): keep the terminal observer alive across ACP resume
Addresses the latest review round on #6561.
`registerGoalHook` calls `unregisterGoalHook`, which clears the session's
goal-terminal observer. The ACP restore path passes no `addItem`, so nothing
reinstalled it: a restored goal reached achieved/failed/aborted with no wire
update and no persisted terminal card, and the next reload revived a goal that
had already finished. The no-goal branch unregisters too, so every ACP resume
lost the observer, not just ones with a goal. `#restoreGoalOnResume` now
reinstalls it unconditionally.
A restore blocked by trust or hook policy left the client showing an active
goal that nothing drives. Restore now reports `blockedBy`, and history replay
emits a trailing `cleared` card naming the reason. The card is emitted, not
recorded, so a later resume in a trusted folder still restores the goal. It is
emitted from inside replay because `loadSession` batches replay updates into
its response, and a notification sent afterwards would reach the client first.
Gated behind a `HistoryReplayer` option: export and `restoreSessionHistory`
render a transcript rather than resume it, and the export config is a stub that
throws on any method it does not implement.
Transcript payloads are now treated as untrusted. `outputHistoryItems` is
checked with `Array.isArray` before iteration and each entry for being a plain
object before any field is read; a hand-edited record could otherwise throw and
take the whole restore down, skipping the hook while replay still showed the
goal as active.
Also:
- Carry `setAt` across resume instead of restarting the clock, scanning back to
the run's `set` card when the newest card is a `checking` card (which had no
`setAt`; they now persist one).
- Refuse to restore an empty condition, as `/goal` does.
- Warn instead of silently no-opping when no chat recording service is present.
- Cap `GET /goals` session probes at 10 in flight.
- Drop `lastTerminal` from the `sessionGoalGet` response and `BridgeSessionGoal`
— no consumer reads it, and it was returned unprojected.
- `GoalsDialog` keeps the form and the typed condition when creation fails, and
clears a stale dropped-session count when a reload fails outright.
- Cross-package test pinning `GOAL_CLEAR_KEYWORDS` and `MAX_GOAL_LENGTH` against
the CLI sources they mirror.
* fix(goals): drop the condition length cap on restore and in the web shell
#6665 removed the 4,000-character cap `/goal` applied when setting a goal, but
the restore path and the Web Shell form still enforced it. After merging main
that split the surfaces: a long condition `/goal` now accepts was persisted as a
`set` card, then refused by `restoreGoalFromHistory` on the next resume and
dropped from the replay entirely — the goal died on reload and the user never
saw a card explaining why.
Remove the cap everywhere rather than reinstate it at set time. A corrupted or
hand-edited transcript can now restore an arbitrarily long condition, but that
is exactly what `/goal` itself permits, so it is no longer a distinct risk. The
empty-condition gate stays: it is the one case that is meaningless rather than
merely large.
- `goalConditionBlockedBy` rejects only an empty condition.
- `HistoryReplayer` no longer skips long goal cards.
- `GoalsDialog` drops the form check and the `maxLength` attribute, which had
been silently truncating a long condition before the user could submit it.
- `MAX_GOAL_LENGTH` and the now-orphaned `goals.error.tooLong` i18n strings are
deleted, along with the drift test's length half; the clear-keyword half of
that test still guards the constant that is genuinely duplicated.
Also drops the `MAX_GOAL_LENGTH` import #6665 left unused in `goalCommand.ts`,
which failed `eslint --max-warnings 0`.
* fix(web-shell): reuse the empty session a failed goal attempt leaves behind
Setting a goal starts a fresh session and then sends `/goal <condition>` into
it. The daemon session is not created by the "new session" step, though —
`clearSession` only detaches and clears local state. `ensureSessionForPrompt`
creates the session lazily inside `sendPrompt`, so a prompt that fails after
the session exists leaves a created-but-empty one behind.
The Goals form keeps the condition and invites a retry, and the retry called
`createNewSession()` again: the empty session from the previous attempt was
abandoned and another created in its place. A user retrying a few times against
a busy daemon ended up with a column of blank chats in the sidebar.
Remember the stranded session and reuse it when it is still the current one,
rather than creating another. Nothing is deleted — a session is only reused
when the failed attempt left it empty and it has not been switched away from.
Once a goal actually lands, the session belongs to it, so the next goal starts
a fresh one as before.
* fix(goals): forget the stranded goal session on leaving the Goals page
Addresses the latest review round on #6561.
The stranded-session reuse added in bee3295aa was only safe while the Goals
page stayed up. Leaving it (Back button) and then talking to that session from
the composer turned it into a real conversation, but the ref still pointed at
it: returning to Goals and setting a goal would reuse it and drop the goal loop
on top of the user's conversation — the exact thing starting a fresh session
exists to prevent. The ref is now cleared whenever the view leaves 'goals', so
reuse can only ever hit a session the failed attempt itself created.
Also:
- `registerGoalHook` rejects a `setAt` in the future, not just a non-finite or
non-positive one. Every duration downstream is `Date.now() - setAt`, so a
transcript claiming the goal starts tomorrow rendered negative elapsed times.
- `makeRestoreInnerConfig` gains `isTrustedFolder`. Without it, `goalRestoreBlockedBy`
threw `config.isTrustedFolder is not a function` on every resume in these
tests, and `#restoreGoalOnResume` swallowed it — so the goal-gate assertions
passed through the catch rather than the branch each one names. The
hooks-disabled test now pins the branch it took, and fails if the config
regresses.
- The status-bar goal pill names the goal in its accessible label. The visible
pill is only "◎ /goal active (2m)" and the condition lived solely in `title`,
a hover tooltip screen readers do not reliably announce.
- `.iconAction` gains a `:focus-visible` rule, matching `.iconButton` in
DialogShell.module.css; keyboard users had no focus indicator on the
clear-goal button.
- `GoalsDialog.test.tsx` restores real timers in `afterEach` rather than inline
per test, so a failing assertion can no longer leak fake timers into the rest
of the file.
- Tests for the Goals form's Cancel button and for the status-bar pill, neither
of which had any coverage.
* fix(goals): identify a goal run by its condition, not just its card kinds
Addresses the latest review round on #6561.
`findSetAtOfRun` walked back from the active card for the `setAt` on the `set`
card that opened the run, stopping at any card that was not `set`/`checking`.
That assumed a terminal card always separates two goals, and a transcript is a
file: hand-edited, truncated, or written by a version that did not persist
terminal cards, it can hold two goals back to back. The scan then walked past
the second goal's cards into the first and returned ITS start time, so the
active goal's elapsed time was measured from a goal that had already ended. The
condition is what identifies a run, so the scan now stops when it changes.
Also:
- A malformed condition is reported once on resume, not twice.
`restoreGoalFromHistory` is the only caller that knows the condition is bad,
and three of its four callers (the TUI ones) discard the result entirely, so
it stays the reporter; `#restoreGoalOnResume` no longer adds a second line for
`condition-invalid`. The env gates were already reporting exactly once.
- Goal-restore stderr can no longer take down a session load. `writeStderrLine`
reaches `process.stderr.write`, which throws on EPIPE or a closed fd; a throw
from the catch block would have escaped into `loadSession`, so a best-effort
restore would fail the very load it promises not to block.
- `isGoalClearCommand` checks the `/goal` prefix instead of assuming it.
`goalArgOf` returns unrecognised text unchanged, so a bare `"clear"` — an
ordinary thing to type into a chat box — answered true. Latent today because
every caller pre-validates the prefix, but the contract was a trap.
- Tests for the throw path reinstalling the terminal observer, and for the Goals
page opening a goal's session (success and failure), neither of which had any
coverage.
* fix(web-shell): announce Goals dialog errors and give its buttons a focus ring
Addresses the latest review round on #6561.
The form-validation error and the goal-list load error were painted but never
announced: `role="alert"` puts them in a live region, so a screen-reader user
learns the submit was rejected instead of believing the goal was created, and
learns the list went stale on a poll that failed after the page was already up.
Matches the existing pattern in RewindDialog.
`.primaryButton` / `.secondaryButton` had no `:focus-visible` rule, so keyboard
users tabbing to Set goal / Cancel saw no focus indicator — an inconsistency
with `.iconAction` and `.sessionLink` in the same file. They now take the ring
the form controls already use (`outline: 2px solid var(--primary)`), offset
outwards rather than inset: `.primaryButton` is filled with `--primary`, so an
inset ring in that colour would be invisible on it.
* fix(cli): stop a broken stderr from abandoning a transcript replay
Addresses the latest review round on #6561.
`process.stderr.write` throws on EPIPE or a closed fd — reachable whenever the
reader goes away (`qwen … | head`) or a daemon redirects its stderr. The goal
path writes diagnostics from inside work that must not be destroyed by a failed
diagnostic, and `bee3295aa` only guarded one of the five sites.
The worst of the rest was in `HistoryReplayer`: the "skipping a goal card whose
condition is empty" line sits inside the loop over a record's cards. A throw
there abandoned that record's remaining cards, propagated to the record loop,
and aborted the whole replay — the user lost their transcript because we failed
to complain about one bad card.
Add `writeStderrLineSafe` to stdioHelpers and route the goal path's five sites
through it, replacing the one-off `#warnGoalRestore` wrapper in acpAgent so
there is a single implementation. It is deliberately not the default:
`writeStderrLine` still throws, because most of the CLI wants a broken stderr to
be loud. This variant is for writes that are incidental to real work.
Also adds the first tests for `stdioHelpers`, and covers two untested Goals
dialog behaviours: the Refresh button, and the clear button disabling itself
while its clear is in flight (a double-click otherwise fired two concurrent
clears at the same session).
* fix(web-shell): keep the Goals page mounted across createNewSession
main's `createNewSession` gained a `setMainView('chat')` of its own, fired
synchronously before any await. That silently defeated the Goals handler's
deferred switch: by the time `sendPrompt` rejected, the page — and the form
that renders the error — was already gone, dropping the user into an empty
chat with no explanation. This is the exact failure the deferred switch was
written to prevent; the two changes only had to meet for it to come back.
`createNewSession` takes a `keepView` opt-out, and the Goals handler uses it,
so the page survives until the prompt is admitted. Saving and restoring
`mainView` around the call would also work but flips the view to chat and back,
which the user would see. A test pins the page staying mounted across a failed
submit; it fails if `keepView` stops being honoured.
Also from the same round:
- `registerGoalHook`'s `initialSetAt` guards are now tested — a future
timestamp, NaN, Infinity, 0 and a negative all fall back to now, and a usable
value survives. The future case is the one with teeth: `Date.now() - setAt`
renders a negative elapsed time rather than failing loudly, and nothing
covered it.
- The goals list carries `role="list"` / `role="listitem"`. They are divs, and
even a real `<ul>` loses its implicit role under `display: flex` in Safari.
- The open-session button names the action *and* the session. Its visible text
is only the session name, which says nothing about what activating it does;
the name stays in the accessible name so it still contains the visible label.
- `.fieldLabel` matches ScheduledTasksDialog's `--muted-foreground`. The two
dialogs sit side by side and had drifted.
Not taken: deferring `setMainView` in `onOpenSession` until the load resolves.
The sibling `handleOpenSessionFromOverview` switches first by the same pattern,
and `loadSidebarSession` clears the transcript and shows a loading skeleton —
which is the feedback for the common success path. Deferring would leave a
click looking dead until the load lands, and would make Goals diverge from the
Session Overview panel. If we want that behaviour it should change both.
* fix(web-shell): stop the visuals spec asserting a badge #7035 removed
The "Capture web-shell visuals" job fails on this PR at
`screenshots.spec.ts:395`, asserting the sidebar's "Primary" badge is visible:
Error: expect(locator).toBeVisible() failed
Error: element(s) not found
Not from this branch. The chain is on main:
- 2026-07-15 #6880 adds the visuals spec, asserting the "Primary" badge —
correct at the time.
- 2026-07-17 #7035 drops that badge as redundant (the workspace selector's
checkmark already conveys the default target), removing the `primaryLabel`
prop and its `<span className={styles.badge}>` render, and updates the *unit*
test to assert its absence — but leaves this spec asserting it is visible.
The capture job only runs on pull requests (it needs a PR head and a
merge-base), so main never went red for it and the breakage surfaces on the
next PR to merge main — this one.
Assert the badge's absence instead of deleting the check, mirroring the unit
test #7035 added, so a regression re-adding it still fails here.
---------
Co-authored-by: 易良 <1204183885@qq.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Typing the full absolute path of a project by hand into the Add
Workspace dialog was slow and error-prone, and the only feedback was a
generic error after submitting. The existing GET /list route could not
back an autocomplete here because it resolves paths through a
registered workspace's filesystem boundary, and the path being picked
is not a workspace yet.
Add a deliberately narrow read-only daemon route,
GET /workspace-path-suggestions?prefix=<absolute>, that returns only
the names of subdirectories matching the prefix (case-insensitive on
the final segment, dot-directories only once the filter starts with a
dot, symlinked directories included, capped at 50 entries). It shares
the trust surface of POST /workspaces, which already lets an
authenticated client stat and register any absolute directory.
The dialog's path field becomes a combobox fed by that route through
DaemonClient.workspacePathSuggestions() and a new
suggestWorkspacePaths workspace action: suggestions render in a
listbox under the input (debounced 150ms, stale responses dropped),
ArrowUp/Down move the highlight, Enter/Tab or click accepts a
directory and descends into it, and Escape closes just the list —
intercepted on window capture so Radix does not close the whole
dialog.
Fixes#7102
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The #5074 sidebar request is largely implemented (session list, search,
rename, delete, collapse persistence), but the keyboard shortcut item
was still missing: there was no way to toggle the sidebar without
reaching for the mouse.
Add the editor-convention binding: Cmd+B (macOS) / Ctrl+B collapses and
expands the sidebar, persisting the preference through the existing
writeSidebarCollapsed path. Phone-width layouts render the sidebar as a
drawer, so the shortcut toggles the drawer there instead. Shift/Alt
variants and the ambiguous Cmd+Ctrl combination are left untouched for
the browser and other bindings, and the matcher lives in a small pure
module with its own tests.
Refs #5074
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Add a lightweight composer status-row suggestion that routes clearly new-topic drafts into a fresh session, preserve the normal submit path, and cover the async handoff regressions with focused tests.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(web-shell): use formatSettingCategory for fallback UI category
The manually-pushed "UI" settings category used a direct t() call for
`settings.category.UI`, which only exists in the ZH dictionary. English
users would see the raw i18n key instead of a label. Switch to
formatSettingCategory() — the same helper groupByCategory already uses —
so the category falls back to the daemon-provided name when no
translation exists.
* fix(web-shell): use raw key for fallback UI category id and add test
Use the raw 'UI' key as the fallback category id (consistent with how
normal categories use untranslated keys), keeping formatSettingCategory
only for the display label. This ensures CategoryIcon matching works
correctly across all locales.
Add a DOM test asserting the fallback UI category renders a readable
label and never leaks the raw i18n key.
* fix(web-shell): batch transcript dispatch to avoid tab-return freeze
Dispatching each buffered SSE event individually makes a tab-return burst O(events x blocks) on the main thread (per-dispatch block-array copy + freeze), freezing very long sessions for minutes. Coalesce the live stream into one dispatch per macrotask, cap the client's in-memory transcript window, and skip the dev-only block freeze in production.
* fix(web-shell): flush transcript buffer on teardown, guard freeze for browser
Address review feedback: teardown now flushes buffered transcript events instead of dropping them (the SSE client advances lastSeenEventId as events are yielded, so a dropped buffer would be skipped by a same-session incremental resume). Guard FREEZE_TRANSCRIPT_BLOCKS with typeof process so an unbundled browser consumer of the daemon/ui surface does not throw a ReferenceError. Add a dispatch-count assertion to the burst test and an unmount-flush regression test, and align the design doc (setTimeout-only flush, verification plan).
* fix(web-shell): flush before observer debug guard to keep assistant bursts in one block
Address ytahdn's PR #7012 review: the batched-dispatch debug guard read the committed store's activeAssistantBlockId, which lags the pending buffer within a burst, so a debug event interleaved in an observer assistant burst was not filtered and split the block. Flush the buffer before the guard, scoped to observer-mode debug events (rare) so steady streaming keeps batching. Add a focused burst regression test, make the unmount-flush test deterministic with fake timers (it was timing-racy), and update the design doc.
* fix(web-shell): flush buffered transcript on SSE loop error
The catch block at the end of the connection loop skipped the post-loop
flush, leaving buffered transcript events on a scheduled timer. The
retriable path resumes via Last-Event-ID without resetting the store,
and lastSeenEventId has already advanced past those events, so clearing
the buffer would drop them on the incremental delta-resume. Flush
instead.
Also route the restored-prompt settle and replay_complete control
dispatches through dispatchTranscriptNow so each is self-contained
(flush + dispatch) rather than relying on an earlier flush by timing,
and tighten the burst regression test from toContain(CHUNK_COUNT) to
toEqual([CHUNK_COUNT]) so a regression emitting redundant per-event
dispatches also fails.
Addresses the ci-bot review.
* fix(web-shell): keep a batched transcript dispatch throw from cascading
A reducer throw inside runTranscriptFlush escaped as an uncaught
setTimeout error on the macrotask path and, via flushTranscriptSync,
propagated out of the catch block (aborting lastSeenEventId bookkeeping,
reconnect, auth branching, terminal cleanup, and pendingSessionLoad
rejection) and out of the useEffect cleanup (leaving half-torn-down
state). Wrap the dispatch in try/catch and log it with the batch size so
the throw is surfaced without crashing the session or skipping teardown;
one guard fixes all three paths.
Also document the flush precondition on settleActivePromptFromTurnEvent,
which dispatches assistant.done directly and previously carried that
contract only as an inline comment at the call site.
Addresses the ci-bot review.
* refactor(web-shell): drop redundant primary-workspace label
The workspace selector in the composer already marks the default target
with its own checkmark, so appending "· Primary" to the primary entry's
name carried no extra information. Remove that tag everywhere it showed:
- composer selector: trigger, tooltip, and dropdown list
- sidebar workspace header badge (also lets the name show untruncated)
- session overview / split-view picker badges — the primary now shows
its folder basename, consistent with the other workspaces
- scheduled-tasks dialog workspace labels
Delete the now-unused i18n keys (sidebar.workspacePrimary,
scheduledTasks.workspacePrimaryTag; en + zh) and update the two tests
that asserted the old tag.
* refactor(web-shell): reuse workspaceBasename + cover primary-badge removal
Address /review suggestions on the primary-workspace-label cleanup:
- ScheduledTasksDialog's local workspaceLabel() is now functionally identical to the shared workspaceBasename() util (both return the cwd's last path segment), so reuse the util and delete the duplicate.
- Add a WebShellSidebar test asserting the primary workspace header no longer renders a "Primary" badge, so a regression re-adding it fails.
* test(web-shell): assert SplitView primary picker item has no "Primary" tag
Covers the fourth /review suggestion (terminal-only): the multi-workspace picker test now asserts primary-workspace sessions render their basename, not the removed "Primary" label.
* test(web-shell): assert scheduled-tasks picker option text drops (primary)
Covers the re-review suggestion: the workspace <select> picker options were checked for count and value but not visible text, so a regression re-adding a "(primary)" suffix to the primary option would pass undetected. Assert the option labels are the bare basenames.
---------
Co-authored-by: wenshao <wenshao@example.com>