* docs(design): define default background subagents
* feat(core): improve subagent delegation defaults
* docs(core): cross-reference the three background-classification sites
Add pointer comments linking the core dispatch source of truth
(AgentTool.execute) and its two UI mirrors (web-shell
isBackgroundSubAgentToolCall, desktop detectBackgroundEvents) so the
replicated top-level-agent background heuristic is not changed in
isolation. Addresses PR review feedback.
* fix(core): align background classification for fork and named-teammate launches
Address review feedback on the background-classification rule so core dispatch
and the two UI classifiers stay consistent:
- core: exclude a name-without-active-team launch from the default-background
path so it stays foreground, matching both UI classifiers (which exclude
name). Previously such a launch was backgrounded by core but tracked as
foreground by the UIs.
- web-shell and desktop classifiers: exclude subagent_type "fork" from the
default-background heuristic, mirroring core's !isForkRequested guard. A
top-level fork request with an omitted flag runs foreground in core but was
classified as background by the UIs.
- add a core dispatch test asserting a working_dir launch with an omitted
run_in_background flag stays in the foreground.
* test: cover fork/background classification and precedence per review feedback
Address unresolved review threads on PR #7048:
- Add web-shell and desktop UI classifier tests asserting an omitted-flag
`subagent_type: "fork"` launch stays in the foreground, verifying the
documented `!isForkRequested` parity with core dispatch.
- Add a core AgentTool test asserting an explicit `run_in_background: false`
overrides a subagent config with `background: true`, locking in the
`run_in_background ?? config` precedence against a `||` regression.
- Harden the Explore read-only prompt: pipelines must not send data to a
network endpoint (no curl/wget/nc), closing the `cat file | curl`
exfiltration gap.
* fix(core): restore general no-unnecessary-files guard in general-purpose prompt
Address review feedback: the rewritten general-purpose prompt dropped the
broad guard against creating unrequested files, keeping only the
documentation-specific line. Restore a general 'do not create files unless
necessary' guard so speculative utility/config files are not created.
* test(desktop): cover named-teammate foreground guard in detectBackgroundEvents
Add a desktop tool-matching test asserting a top-level Agent with a
`name` set (named teammate) stays foreground and emits no
task_backgrounded event, mirroring the web-shell classifier's
named-teammate coverage and the existing fork-exclusion test.
* test(core): cover named-teammate foreground dispatch when flag omitted
Add a core-dispatch test asserting a top-level Agent launch with `name`
set and `run_in_background` omitted stays foreground when no team is
active, guarding the `this.params.name === undefined` exclusion in
backgroundRequested directly (previously only covered by the UI
classifiers).
---------
Co-authored-by: Claude <noreply@anthropic.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>
The split view (2+ sessions side by side) was lost on every refresh: its
pane set lived only in React state, and the one-shot ?split= deep link is
consumed on load. Persist the live session set to sessionStorage while the
split is the active view, and restore it on load when no ?split= deep link
is present, so a refresh brings the split back.
sessionStorage (not localStorage) is deliberate: it is scoped per browser
tab, so a split opened in its own tab and the in-window split never clobber
each other, and a fresh unrelated tab restores nothing — while still
surviving a refresh of the same tab. The ?split= URL stays the shareable,
cross-tab channel.
Only an explicit close (the split's back button) clears the persisted set;
detours to a single session keep it, so the split is treated as the user's
lasting context until they close it. Controlled hosts (which own their split
lifecycle) never auto-persist or auto-restore.
Co-authored-by: wenshao <wenshao@example.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>
Several restoration paths return a failed prompt's text to the composer
by prepending it above the current draft: failed queue submits, failed
mid-turn inserts, and queue clears. More than one of them can fire for
the same prompt across reconnects and refreshes, and a user retrying an
identical message produces identical text — each pass stacked another
copy, which surfaced as multiple sent messages concatenated back into
the input box after a page refresh (#7128).
Extract the merge into mergeRestoredPromptText() and make it
idempotent: restoring text that is already at the top of the editor is
a no-op. Restoring different text still prepends above the draft.
This addresses the text-stacking defect (bug 3 in the triage analysis).
The SSE-reconnect-on-prompt question (bug 1) is a behavioral decision
left to maintainers.
Fixes#7128
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.
* test(web-shell): replace flaky split-view-restored shot with a workspace-sidebar scenario
The split-view "restored" screenshot was byte-nondeterministic between
identical renders (the reappearing pane re-renders its content just after
the restore click), so it periodically diffed above the before/after
threshold and surfaced a false-positive "changed view" unrelated to the
PR under review. It is also visually identical to the tiled `split view`
shot. Drop the capture but keep the restore click + "both panes back"
assertion, so the restore path still has behavioral coverage.
Add a `workspace sidebar` scenario with two workspaces so the sidebar
groups sessions per workspace and tags the primary one. This is the only
scenario that renders the primary-workspace label/badge (it is gated on
more than one displayed workspace), so changes to those labels — which no
single-workspace scenario can surface — now show up in the visual preview.
* test(web-shell): freeze looping animations so captures are deterministic
The sidebar's activity spinner is a GPU-composited transform loop that
Playwright's `animations: 'disabled'` captures mid-rotation at a random
angle, so `sidebar attention` differed in ~0.12% of pixels between two
identical renders — above the 0.02% before/after threshold, i.e. a
false-positive "changed view" on any PR that renders it.
Before each capture, pause every infinite Web Animation and rewind it to
time 0 (a two-frame wait lets the compositor commit the frozen frame);
finite animations are still left to `animations: 'disabled'`. Verified
with a pixel diff: the whole suite now renders pixel-identical across two
runs (worst 0.0001% of pixels, vs the 0.02% threshold).
* test(web-shell): document freeze scope, pin scenario deps, test the freeze
Address review on the visual-capture determinism changes:
- Note freezeLoopingAnimations' coverage scope in its docstring (WAAPI +
CSS @keyframes via document.getAnimations(), not a hand-rolled
requestAnimationFrame loop), so a future spinner rewrite that
reintroduces the flake leads a debugger back to this function.
- Pin the workspace-sidebar scenario's primary workspace cwd and loaded
session name explicitly rather than leaning on createWebShellDaemonScenario
defaults, so renaming those defaults in mockDaemon.ts can't turn the
settle-wait into a cryptic "not visible" failure.
- Add harness.spec.ts pinning the freeze contract: an infinite animation
is paused and rewound to time 0, while a finite one is left running.
---------
Co-authored-by: wenshao <wenshao@example.com>
* 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>
* test(web-shell): add extensions-manager visual scenario
Add a full-page Extensions manager scenario to the web-shell visual
suite, proving a manager PAGE (not just a transcript or dialog) is
reachable in the mock-daemon harness and captured in both themes.
- Make the mocked workspace extensions scenario-driven: empty by
default (mirroring skills/settings/tools), so a scenario can seed
sample extensions via createWebShellDaemonScenario({ extensions }).
- Mock the two endpoints the manager fires on mount so the captured
page renders without a spurious error banner:
GET /workspace/extensions/operations (idle poll) and
POST /workspace/extensions/check-updates (no updates available).
- Seed three extensions (enabled/disabled, marketplace/local, with
varied capability counts) so the manager renders real cards.
* test(web-shell): structural locators + scenario-driven extension routes
Address review feedback on the extensions-manager visual scenario:
- Gate the scenario on the page heading (a stable `heading` role) and
assert the seeded card via its `button` role, instead of a bare
getByText('Context7') that a card-heading refactor or a toast/sidebar
match could break.
- Wire the /operations and /check-updates mock routes through the
scenario (new extensionOperations / extensionUpdateCheck fields with
idle defaults) so a future test can preview an in-flight install or a
pending update, matching how every other workspace route delegates to
the scenario rather than returning a hardcoded inline object.
* test(web-shell): serve mocked extensions directly from the scenario
Address review: inline the trivial `workspaceExtensions()` pass-through
at its one call site (`await json(route, scenario.extensions)`) and drop
the function. This matches how the other full-object scenario fields
(providers/skills/settings) are served directly, rather than the
synthesizing helpers (workspaceTools/workspaceMcp) that build a fresh
object each call.
* test(web-shell): assert the disabled, local-source extension renders
Address review: the scenario seeds a disabled/local extension but only
asserted the enabled one, so a regression that hides `isActive: false`
or local-source rows would pass here and only differ in the (visually
reviewed) screenshot. Also assert the "Local Notes" card is visible.
---------
Co-authored-by: wenshao <wenshao@example.com>
When the viewport shrinks below the large-screen breakpoint the split view
folds back to the single chat. If that chat had no session of its own — the
common case when the split was entered from the Session Overview or a
`?split=a,b` link — the user was stranded on an empty "new chat" instead of the
split's first (leftmost) pane.
This restores a fallback that was tried and then superseded during #6746: the
fold-time re-point to the first pane was dropped because it wiped the git branch
and changed the session+URL of a chat that already had a session. Re-add it but
guarded on the *empty* chat (`!currentSessionId`) — an empty chat has no git
branch or session to preserve — so it fixes the empty-new-chat case without
regressing the branch-preservation case. Best-effort and standalone-split-only,
as before.
Covered by two tests: lands on the first pane when the outer chat is empty, and
keeps the existing session (no re-point) when it isn't.
Co-authored-by: wenshao <wenshao@example.com>
* feat(web-shell): color-code each split pane by workspace
On a narrow split (split-screen / mobile), it was hard to tell which
workspace each pane belonged to: a pane's header showed only its session
name, and the sole workspace signal — the composer chip at the bottom —
collapsed to a bare folder icon that looked identical for every
workspace, so the workspace was discoverable only by hovering each one.
Surface the workspace where you actually scan — the pane header — and
give each workspace a stable accent color so panes read apart at a
glance and same-workspace panes read as a group:
- Add a colored workspace tag (dot + basename) at the start of each
pane header on a multi-workspace daemon, and colorize the header
divider with the same accent. The dot never shrinks, so panes stay
distinguishable even when the name and session title ellipsize.
- Derive a stable per-workspace color from the workspace's position in
the daemon's advertised workspaces[], reusing the sidebar
session-group palette so the two surfaces speak the same color
language. Extracted into a shared workspaceAccent.module.css.
- Tint the composer workspace chip with the same accent (folder + faint
background) so it stays distinguishable even in its icon-only compact
state, instead of a generic folder.
Single-workspace daemons are unchanged: no tag, and the header divider
falls back to the neutral border.
* refactor(web-shell): address review on split-pane workspace accent
- Rename workspaceAccent.module.css -> WorkspaceAccent.module.css to match the
PascalCase convention used by every other component .module.css; update both
import sites.
- Hoist the four raw-hex accent colors (red/orange/yellow/green) into shared
--accent-* theme tokens in App.module.css, and point the workspace accent
module, the sidebar group dots, and the overview badges at them. The palette
now has a single source of truth and can't drift between the four surfaces
(values are unchanged, so rendering is identical).
- Add a compile-time exhaustiveness guard so adding a
DaemonSessionGroupPresetColor without extending WORKSPACE_ACCENT_COLORS (and
its CSS class) fails the build instead of silently dropping that accent.
- Give the pane-header workspace tag role="img" so its "Workspace: <name>"
aria-label is reliably announced; aria-label on a bare span (generic role)
is not.
* refactor(web-shell): address follow-up review on workspace accent
- Hoist the four --accent-* tokens out of both theme blocks into the
theme-independent .app scope, so they are declared once (the values do not
vary by theme) — a genuine single declaration rather than two kept in sync.
- Add a dev-only runtime check that every accent color has a matching class in
WorkspaceAccent.module.css, closing the gap the compile-time guard cannot
cover: CSS modules are typed Record<string, string>, so a renamed/removed
class would otherwise silently drop that color's accent.
- Rename the "same workspace same color" test to describe what it actually
asserts (a stable color per cwd, and distinct colors across workspaces).
* refactor(web-shell): address second follow-up review on workspace accent
- WorkspaceIndicator tests: assert on imported CSS-module class names instead of
string literals, so a CSS-module naming change can't silently make the
substring checks vacuous; add an expanded-mode (non-compact) accent test so a
refactor that gated the accent on `compact` would be caught.
- workspaceColor.ts: run the CSS-class contract check unconditionally — throw in
dev, but console.error in production — so a missing class in a prod build is
at least diagnosable instead of a silent accent drop.
- WorkspaceAccent.module.css: correct the docstring to state exactly which
tokens come from where — red/orange/yellow/green from --accent-* in
App.module.css, blue/purple deliberately reusing the --agent-* brand tokens.
---------
Co-authored-by: wenshao <wenshao@example.com>
* test(web-shell): add a mermaid diagram visual scenario
Add a `mermaid diagram` scenario to the visuals suite so the preview
covers the Mermaid rendering surface — an assistant message with a
mermaid fenced flowchart. It renders the real MermaidBlock (async
mermaid import, injected <svg>) in light and dark, waiting on the
rendered SVG so the capture is never the "rendering…" placeholder.
This is the surface the diagram zoom/pan work (#6881) enriches, so once
the before/after preview lands it gives that PR a real before/after
target instead of an unrelated canned screenshot.
* test(web-shell): add a split-view (+ maximize) visual scenario
Add a `split view` scenario: enter the two-pane split via the `?split=a,b`
deep link, then maximize one pane (#6951). Captures the tiled state (both
panes, with the maximize controls) and the maximized state (one pane
filling, restore control) in light and dark, driving the real SplitView
against the mock daemon serving two sessions.
* test(web-shell): add a sidebar attention-badge visual scenario
Add a `sidebar attention` scenario: four sessions in distinct states —
waiting-on-permission, waiting-on-user-question, running, idle — so the
sidebar renders #6956's "Waiting for approval" / "User input needed"
attention pills. Renders in light and dark; asserts on session names
(present with or without the pills) so the frame is the same shape on
main and the PR, letting the before/after preview surface the pills.
* test(web-shell): derive the split view's second session from the scenario list
Addresses a review suggestion: the split view test hardcoded the
'previous-session' id, which only worked because it is in
createWebShellDaemonScenario's default sessions list. Derive the second
pane's session from the scenario's own list instead (and throw a clear
error if absent), so a future rename/removal of that default surfaces as
a self-explaining failure rather than a confusing SSE connection timeout.
* test(web-shell): tidy split copy and mermaid width in visual scenarios
Address review nits on the visual scenarios:
- Split scenario: the mock replays the same events into both panes, so
"Here is the first pane of the split." read wrong in the second pane.
Use pane-neutral copy ("Here are the two sessions, side by side.").
- Mermaid scenario: the flowchart's rightmost node clipped at the code-block
edge at the 1280px capture viewport. Shorten the node labels (same nodes and
flow) so the whole diagram fits with margin.
Re-ran both scenarios (light + dark) locally: 4/4 pass, and confirmed in the
captures that the diagram no longer clips and the neutral copy reads correctly
in both panes.
* test(web-shell): capture split-view restore and assert all sidebar sessions
Address review nits on the visual scenarios:
- Split view: after maximize, click "Restore pane" and capture the restored
tiled layout, asserting the maximize control returns on both panes — so a
regression in the restore path is caught, not just the tiled and maximized
states.
- Sidebar attention: assert all four session names render (not just the two
waiting ones). The running session is also the loaded one, so its name also
shows in the main view — scope the running/idle checks to the sidebar
landmark so the match stays unambiguous.
---------
Co-authored-by: wenshao <wenshao@example.com>
* fix(web-shell): show workspace chip tooltip on narrow composer
The composer's workspace chip surfaced its full cwd only through a native
`title` attribute, unlike the sibling git-branch and model chips which use a
styled Radix tooltip. On a narrow (split-screen / mobile) composer the chip
ellipsizes or collapses to an icon, so the workspace is discoverable only on
hover — and a native `title` is inconsistent and never fires on touch.
Give WorkspaceIndicator the same Radix tooltip as GitBranchIndicator (with the
full cwd as content), completing the documented "mirrors GitBranchIndicator"
intent. Its visually-hidden tooltip mirror also exposes the cwd to screen
readers, which the native `title` did not do reliably.
* test(web-shell): assert the workspace tooltip renders on hover
Address review feedback: the WorkspaceIndicator tests checked the
`data-web-shell-workspace-title` hook but never opened the tooltip, so a
regression rendering the short name (or nothing) in the Radix `TooltipContent`
would have gone unnoticed. Open the tooltip via a `pointermove` (jsdom has no
`PointerEvent`; Radix opens on mouse move after `delayDuration`) and assert the
portalled `[role="tooltip"]` shows the full cwd — and, in compact mode, assert
the `workspaceChipCompact` icon-only class is actually applied.
* test(web-shell): guard the compact chip before asserting on it
Move the `if (!chip)` null guard above the assertions in the compact-mode test
so a failure to render surfaces the descriptive "workspace chip was not
rendered" error instead of an opaque `expect(undefined)` throw from the
optional-chained access. Matches the first test in the file.
---------
Co-authored-by: wenshao <wenshao@example.com>
* feat(web-shell): maximize a single split pane
Add a per-pane maximize/restore toggle to the split view. Clicking it makes
one pane fill the whole split and hides the others; the hidden panes stay
mounted so their sessions keep streaming (a purely visual solo). Restore via
the header button or Escape — Escape defers to the composer, the add-session
picker, and open dialogs so it never steals their key.
The toggle only appears with 2+ panes, adding a session exits maximize to
reveal the new pane, and the maximize is dropped whenever its pane leaves the
set or the split shrinks to a single pane.
* refactor(web-shell): use lucide icons for the maximize toggle; cover switch + picker-Escape
Address review on #6951:
- Swap the hand-written Maximize2/Minimize2 SVG paths for the named lucide-react
components, per the web-shell icon convention (README) and matching DialogShell.
- Add tests for moving maximize between panes (guards the toggle's switch branch)
and for Escape closing the add-session picker without un-maximizing (guards the
pickerOpen deferral).
---------
Co-authored-by: wenshao <wenshao@example.com>
* feat(web-shell): add zoom and pan controls to Mermaid diagrams
* fix(web-shell): bind Mermaid drag events to window and address review nits
- Move mousemove/mouseup listeners from wrapper div to window via
useEffect, preventing drag cancellation when cursor exits the
container during fast pans (Critical from review)
- Clamp Y-axis offset to ±1500px to prevent dragging into the
overflow-y: hidden clipped region; X-axis remains unclamped
since overflow-x: auto provides native horizontal scrolling
- Remove unused wrapperRef (dead code)
- Merge duplicate handleZoomReset/handleDoubleClick into shared
resetZoomAndPan callback
- Add missing title attribute to zoom reset button for tooltip
accessibility parity with zoom-in/zoom-out buttons
* fix(web-shell): address review round 2 — drag state, max-width, code change reset
- resetZoomAndPan now clears dragRef and isDragging, preventing stuck
grab cursor when user alt-tabs or loses focus during an active drag
- Add window blur listener in drag useEffect to cancel drag on focus
loss (alt-tab, browser notification, DevTools stealing focus)
- Add flowchart.useMaxWidth: false in mermaid.initialize so the CSS
max-width: none rule actually takes effect (Mermaid v11 stamps an
inline max-width via useMaxWidth:true by default, overriding CSS)
- Reset zoom and offset when code prop changes (diagram regenerated)
via a dedicated useEffect keyed on code; theme-only toggles preserve
the user's current zoom/pan state
---------
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
* feat(web-shell): auto-post visual previews (screenshots + flow GIFs) on PRs
PRs that touch the web-shell UI now get an auto-updated comment with
light/dark screenshots of key views (transcript, slash menu, model/theme
dialogs, permission panel) and short GIF recordings of common flows,
rendered against the existing mock daemon — no real backend, no secrets.
Split into two workflows for security, since capture runs untrusted PR code:
- web-shell-visuals.yml (pull_request): checks out the PR head, builds and
renders it with Playwright, captures PNGs + webm, converts webm->GIF with
ffmpeg, and uploads an artifact. `contents: read` only, references no
secrets — fork PRs run with a read-only token and no secrets.
- web-shell-visuals-publish.yml (workflow_run): downloads the artifact,
binds it to its real PR by requiring the PR head SHA to equal the run's
authenticated head SHA, hosts the images on a per-PR `pr-assets/*` branch
(referenced by immutable commit SHA), and posts/updates one inline
comment. Never checks out or runs PR code; the write token lives only here.
Capture infra is self-contained in packages/web-shell
(playwright.visuals.config.ts + client/e2e/visuals/*), reusing the mock
daemon harness. Run locally with:
`npm run test:e2e:visuals --workspace=packages/web-shell`.
* fix(web-shell): guard empty gh api response in visuals publish
Addresses review feedback on #6880: if `gh api` returns empty (network
error / rate limit), jq on empty stdin errors and `set -e` kills the
publish job. Skip gracefully instead.
* fix(web-shell): address review nits on visuals capture
- harness recordFlow: wrap video saveAs/delete in try/catch so a video
I/O error (e.g. drive failed before navigation) can't mask the real
driveError.
- capture workflow: drop the unused head_sha.txt artifact field; the
publish job binds to the authenticated workflow_run.head_sha, and an
artifact-sourced SHA would be untrusted.
* fix(web-shell): address second review round on visuals capture
- context.close() in recordFlow's finally is now best-effort (try/catch)
so a close/crash error can't mask the real driveError.
- add a flows spec that asserts a throwing drive propagates its own error.
- trigger the capture workflow on playwright.visuals.config.ts changes too.
* fix(web-shell): address third review round on visuals capture
- harness: log (don't silently swallow) a video save/null when drive
succeeded; keep masking-suppression only when driveError is set.
- publish: HTML-escape interpolated values in the comment builder (defense
in depth, independent of the upstream filename sanitization); fix the
stale 'single pr-assets branch' comment and key concurrency on source
repo+branch so different PRs (incl. same-named fork branches) parallelize.
- capture: bump checkout to v6.0.3 (repo standard); surface ffmpeg's stderr
on GIF-conversion failure instead of discarding it.
* fix(web-shell): harden visuals publish/capture (review round 4)
Publish (privileged workflow_run):
- CRITICAL: capture basename before `tr` so its trailing newline isn't
turned into `_` (which broke the .png/.gif filter -> empty preview).
- dedup only against the bot's OWN comment (author + marker), not any
marker-bearing comment a participant can post.
- bound the pr-assets branch: force-push a single orphan snapshot per run
(previous snapshot GC'd) instead of appending unbounded untrusted content;
this also removes the rebase/retry path.
- cap EXAMINED candidates (not just accepted) before validation; tighten
per-file (3MiB) and accepted-image (14) caps.
- re-validate PR open + head-SHA immediately before the comment write
(TOCTOU); retry the comment listing and abort rather than POST a duplicate
when listing fails.
- esc() the runUrl for consistency with the self-defending HTML.
Capture (pull_request):
- upload raw recordings as a SEPARATE artifact the publisher never downloads,
so an untrusted multi-GB video can't exhaust the privileged job.
- also trigger on packages/webui/src and packages/sdk-typescript/src (the
visuals dev server aliases them).
- create screenshots/gifs dirs before the metadata counts (defensive).
Harness recordFlow:
- track drive failure with an explicit boolean (handles `throw undefined`);
discard the recording on failure so a failed flow leaves no bogus webm.
* refactor(web-shell): extract + unit-test the visuals publish staging/comment
Addresses the review's testability gap (the class of bug that let the
filename sanitizer break the whole preview slip through green CI). The image
validation (magic bytes, filename sanitization, examined/accepted/size caps)
and the comment builder (light/dark pairing, flow labels, HTML escaping) move
from inline workflow bash/node into .github/scripts/web-shell-visuals-publish
.mjs, covered by web-shell-visuals-publish.test.mjs (run in ci.yml's
node --test line). The publish workflow sparse-checks-out and calls the
script instead. Behaviour is unchanged; it just gained a test surface.
* fix(web-shell): retry the visuals asset force-push; drop stale comment
Round-4 switched hosting to a force-push but left a comment referencing a
'push-retry loop' that no longer existed, and the force-push was a single
call that set -e would abort on a transient failure. Add a bounded retry and
correct the comment.
* fix(web-shell): harden visuals publish/capture (review round 6)
Script (unit-tested):
- flow labels: own-property lookup so `toString.gif`/`constructor.gif` can't
leak Object.prototype members into the comment.
- per-kind image caps (screenshots vs gifs) so a large screenshot set can't
silently starve the flow GIFs from the preview.
- tests for both, plus the per-kind cap.
Publish:
- bind the artifact PR number to the run's authenticated head repo+branch
(not just head SHA), rejecting a sibling PR that shares the same commit.
- re-validate before the force-push and again right before the comment write
(close the download/stage/lookup TOCTOU windows).
Capture:
- bound artifact contents before upload (drop oversized / excess files) so an
untrusted spec can't bloat the published or video artifact.
- trigger on the capture workflow file itself.
- new close-trigger cleanup workflow deletes a PR's asset branch on close, so
pr-assets/* refs don't accumulate without bound.
- single-source the capture viewport (constants.ts) shared by config + harness.
- model-switch flow asserts the daemon model request actually fired.
* fix(web-shell): stricter visuals error handling (review round 7)
Harness recordFlow:
- when the drive SUCCEEDS, a failed context.close() or video.saveAs() (or a
missing recording) now FAILS the flow instead of a swallowed console.warn —
a silent pass with no .webm makes the downstream GIF step fail confusingly.
A drive FAILURE still discards the partial video and rethrows the original
error (unchanged).
Publish:
- validate_pr distinguishes a transient API failure (empty after retries ->
exit 1, re-triggerable) from a genuine invalid state (closed / head mismatch
-> skip), via a `gate` wrapper used at all three checkpoints.
- add a 2s backoff between comment-listing retries (matching the push retry).
---------
Co-authored-by: wenshao <wenshao@example.com>
* fix(web-shell): persist collapsed session group sections across reload
Store collapsed section ids in localStorage using the existing
qwen-code-web-shell-* key namespace, and skip the first catalog sync
auto-collapse so restored expand/collapse state survives remount.
Fixes QwenLM/qwen-code#6870
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(web-shell): clarify collapsed-groups demo GIF storyboard
Crop to the sidebar, caption the four beats (expand → collapse →
reload → still collapsed), and keep Pinned out of the organized
session list mock so the Backend collapse is obvious.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web-shell): address collapsed-groups CR feedback
Export the storage key for unit tests, use an explicit first-catalog
latch instead of size===0, and cover corrupt/disabled storage plus
mid-session auto-collapse of newly appeared sections.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web-shell): harden collapsed-groups persistence for CR feedback
Wait for groups+sessions catalog settlement before the initial latch,
persist secondary-workspace collapse via shared namespaced localStorage,
and keep primary/workspace writers from clobbering each other.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(web-shell): drop demo-capture tooling from collapsed-groups test
The committed GIF, capture script, and frame-assembly helper only served
the PR description's embedded image and were referenced by nothing else
in the repo; the CAPTURE_DEMO branches in the e2e spec were pure
screenshot staging with no assertions. The remaining spec still covers
every acceptance criterion of #6870 and keeps its @smoke tag.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KuV2hx2ZqEqdRwsJz4KJH
* fix(web-shell): keep collapse latch armed until catalogs settle successfully
Two paths could consume the first-sync latch against a partial catalog
and then auto-collapse (and persist over) the user's restored expansions:
a failed initial sessions/groups request counted as settled, and a
mid-session organization_enabled flip let the auto-collapse effect run
one commit before the groups gate closed. Errors no longer settle either
readiness gate, and the gate now closes during the flip render itself.
Also drop the WorkspaceSection reload effect and exhaustive-deps
suppression that defended a workspace.id change which cannot happen (the
render site keys the component by workspace id), and import the storage
key in tests from collapsedSessionSections directly instead of
re-exporting it through WebShellSidebar.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KuV2hx2ZqEqdRwsJz4KJH
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude <noreply@anthropic.com>
PR #6872 ("fix(web-shell): make composer height adaptive") added a
second `import { useWebShellPortalRoot } from '../portalRoot'` at
ChatEditor.tsx:46 without noticing the identical import already existed
at line 21. TypeScript reports:
TS2300: Duplicate identifier 'useWebShellPortalRoot'.
This fails the web-shell build (`vite build && ... && tsc -p
tsconfig.lib.json`) in `npm run prepare`, so every subsequent PR's CI
`Install dependencies` step aborts before any real test runs. Remove
the duplicate line 46 (keep the earlier import) so `main` builds again.
The two callers at lines 247 and 858 continue to resolve to the
single retained import.