From 531a15dd931053588337d8d7bbe1dbc5a310c026 Mon Sep 17 00:00:00 2001 From: jinye Date: Fri, 12 Jun 2026 00:34:49 +0800 Subject: [PATCH] feat(daemon): merge daemon-mode feature batch into main (#4490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(core): F2 cleanup PR A — R9/W11/W12/R10 (post-merge follow-ups) (#4411) * refactor(core): F2 PR A R9 — McpClientManager options-object ctor R9 (filed as F2 follow-up from #4336 review): 7 positional ctor args collapse to (config, toolRegistry, options?: McpClientManagerOptions). The trailing 5 (eventEmitter, sendSdkMcpMessage, healthConfig, budgetConfig, pool) become named fields on `McpClientManagerOptions`. Test factory `mkManager(overrides?)` introduced at the top of `mcp-client-manager.test.ts` so each of the prior 80 inline constructions becomes a single line naming only the field(s) the test overrides; the 4 `undefined` sentinels each test threaded through to reach the trailing `pool` arg are gone. Net: 113 LOC removed (test) + 35 LOC added (src exposes interface + mkManager factory + tool-registry call site update). Behavior unchanged — same field assignments, same downgrade-enforce-without- budget breadcrumb, same budget event wiring. Filed bucket: F2 perf / cleanup PR A (R9 + W11 + W12 + R10/R23 T7), see issue #4175 item 7 "F2 post-merge cleanup PRs". This is the first of the 4 fixes in PR A; W11/W12/R10 follow as separate commits. Test sweep: 84/84 mcp-client-manager.test.ts pass; typecheck clean. * refactor(core): F2 PR A W11 — extract attachPooledSession + rollbackReservationOnSpawnFailure W11 (filed as F2 follow-up from #4336 review): two private helpers on `McpTransportPool` to eliminate inline duplication in `acquire()`: - `attachPooledSession(entry, id, serverName, cfg, sessionId, toolReg, promptReg)`: builds `SessionMcpView` + `entry.attach` with the standard pool release callback. Used by both the fast-path attach (existing entry) and the post-spawn attach (after `await inFlight`). NOT used by `createUnpooledConnection` — its release callback runs `entry.forceShutdown('manual')` + `indexDetach` directly (no pool refcount accounting since unpooled entries are per-session). - `rollbackReservationOnSpawnFailure(reservationResult, serverName)`: R24 T17 contract — only release the budget slot if THIS acquire actually reserved a new slot (`'reserved'`); `'already_held'` skips because the sibling owns it. Used by both the unpooled catch and the pooled spawn-in-flight catch. Race-window invariants (W10 / W77 / W90 / W111 / W125 / R24 T17) stay at the call sites because they describe the SURROUNDING ordering, not the helpers themselves. Helpers are documented to defer those decisions back to callers. Behavior unchanged. Filed bucket: F2 perf cleanup PR A (R9 done / W11 this commit / W12 + R10 to follow). Test sweep: 28/28 mcp-transport-pool.test.ts pass; typecheck clean. * refactor(core): F2 PR A W12 — SessionMcpView precompute filter Sets W12 (filed as F2 follow-up from #4336 review): `applyTools` / `applyPrompts` precompute `excludeSet` + `includeSet` once per pass instead of scanning `cfg.includeTools` / `cfg.excludeTools` arrays inside every per-tool iteration. Pre-fix the per-tool predicate (`passesSessionFilter`) walked both arrays for every snapshot entry → O(M × N) per `applyTools` call. With M tools × N filter entries, typical M=5-20 / N=2-5 case finishes in microseconds either way; the win is data-structure correctness and code clarity, not perceived perf. `passesSessionFilter` / `passesSessionPromptFilter` (the array- based predicates) stay exported and unchanged for unit tests + any caller wanting to test a single name without paying Set construction. The bulk path uses two new private helpers `compileNameFilter` + `compiledFilterAccepts` whose Sets live on the `applyTools` / `applyPrompts` stack frame. Same semantics: `excludeTools` is direct-equality match (no parens strip — pre-F2 behavior preserved); `includeTools` strips the first `(...)` suffix so `toolName(args)` matches `toolName`. Filed bucket: F2 perf cleanup PR A (R9 + W11 done / W12 this commit / R10 to follow). Test sweep: 13/13 session-mcp-view.test.ts pass; typecheck clean. * perf(core): F2 PR A R10 / R23 T7 — pid-descendants ps snapshot + pgrep fallback R10 / R23 T7 (filed as F2 follow-up from #4336 review): the Linux / macOS pid-descendant enumeration moves from per-pid `pgrep -P ` BFS (one subprocess fork per node visited) to a single `ps -A -o pid=,ppid=` snapshot followed by an in-memory tree walk over `Map`. Windows analog: single `Get-CimInstance Win32_Process | ConvertTo-Csv` snapshot of all `(ProcessId, ParentProcessId)` rows replaces per-pid `Get-CimInstance -Filter "ParentProcessId=$p"` BFS. Two motivations: 1. **Fork count**: typical `npx → tool` / `uvx → tool` wrapper trees are 2-3 levels deep with B=1-3 children per node → pre-fix BFS forked ~5-10 subprocesses per pool-shutdown call. Post-fix: exactly 1 fork regardless of tree depth. 2. **Snapshot consistency**: pre-fix BFS walked the table level by level; a child that forked between two adjacent BFS levels could be missed (we'd see the child but query its descendants AFTER the new fork). The snapshot path captures the table at one instant; new descendants forked after the snapshot are tolerated by the existing ESRCH-tolerant SIGTERM loop. Caveats: - `ps -A -o pid=,ppid=` is POSIX standard (macOS / Linux / *BSD), but BusyBox `ps` ` add. `root` seeded into `visited` so a malformed snapshot listing root as a descendant of its own child doesn't re-enqueue root either. PR-A-R2 #2 (session-mcp-view.ts:117 — predicate dedup): After W12, the exported `passesSessionFilter` / `passesSessionPromptFilter` still called `passesNameFilter` (the pre-W12 array-based implementation), while `applyTools` / `applyPrompts` used `compiledFilterAccepts(compileNameFilter(...))`. Two parallel implementations of the same predicate — future change to one without the other would silently diverge: - the exported function's tests (passesSessionFilter unit tests) would still pass - the production filter path in applyTools/applyPrompts would behave differently Reviewer also noted `passesSessionPromptFilter` had zero callers in production code or tests after W12 — `applyPrompts` no longer references it. Kept the export rather than deleting it (matches the `passesSessionFilter` shape for symmetry + the F3 audit-path comment block earmarks both as the replay predicates), but routed both through `compiledFilterAccepts(compileNameFilter(...))` so there is a single source of truth. Set construction is per-call for these exports (negligible for unit-test / one-off probes); the bulk paths in `applyTools` / `applyPrompts` still construct ONE filter per pass via the original W12 code path. `passesNameFilter` (the standalone array-based helper) deleted — its only callers were the two exports, which now use the compiled path. Public-API surface unchanged: the two exported functions keep their signatures and semantics. Test sweep: 19/19 pid-descendants + session-mcp-view tests pass; typecheck + ESLint clean. Continues commit chain: f05917071 (R9) → 20d2f1b90 (W11) → 6cf18f641 (W12) → 2a41c6fae (R10) → this (R2 followups). * fix(core): F2 PR A R3 T3 — Windows CSV delimiter locale fix `ConvertTo-Csv -NoTypeInformation` honors the system locale's list separator on PowerShell 5.1. On German / French / Dutch / Italian / ... locales the separator is `;` not `,`, so the regex `^"(\d+)","(\d+)"$` in `snapshotProcessTreeWin` never matched → `parsedRows === 0` → snapshot threw → fell back to the per-pid CIM filter path with ~0.5-1s extra PowerShell startup latency per descendant on every pool shutdown. Fix: 1-LOC `-Delimiter ","` on `ConvertTo-Csv`. Forces comma regardless of locale or PowerShell version. PowerShell 7+ defaults to comma already; 5.1 (the Windows-bundled version most users have without explicit upgrade) honored locale. The explicit delimiter makes both consistent. Skipped wenshao's companion Suggestion T4 (test coverage for walkDescendants MAX_DESCENDANTS / MAX_DEPTH caps) as F2 hardening follow-up — the caps are simple 2-line guards exercisable by inspection; ~50 LOC of mock infrastructure isn't commensurate with the regression risk on currently-stable defensive code, and (per the issue #4175 follow-up bucket) we keep dedicated test-coverage work out of perf-cleanup PRs. Continues commit chain: f05917071 (R9) → 20d2f1b90 (W11) → 6cf18f641 (W12) → 2a41c6fae (R10) → ced5d62b0 (R2) → this (R3 T3). Test sweep: 6/6 pid-descendants tests pass; typecheck + ESLint clean. * refactor(acp-bridge): F1 test split — lift bridge.test.ts (6861 LOC) to acp-bridge (#4445) * refactor(acp-bridge): rename httpAcpBridge.test.ts -> bridge.test.ts (git mv) Pure file rename; zero content change. Follow-up commits will: - extract FakeAgent + makeChannel + makeBridge into testUtils.ts - split 4 daemon-host integration tests back to cli/daemonStatusProvider.test.ts Part of #4175 F1 test split (deferred from #4334). * refactor(acp-bridge): extract testUtils + split daemon-host tests to cli (#4175 F1) Net mechanical extraction following commit 2aff1a4d1 (pure git mv of httpAcpBridge.test.ts -> bridge.test.ts). After this commit `@qwen-code/acp-bridge` owns the bulk of the lifted bridge test suite, and cli keeps only the 4 daemon-host integration tests that need to wire `createDaemonStatusProvider()`. Changes: 1. New `packages/acp-bridge/src/internal/testUtils.ts` (~280 LOC): FakeAgent, FakeAgentOpts, ChannelHandle, makeChannel, makeBridge (no statusProvider default — acp-bridge tests exercise the no-provider fallback path), WS_A/WS_B/SESS_A constants. Marked @internal; lives under `internal/` matching the existing `stderrLine.ts` package-private convention. Exposed via new `./internal/testUtils` subpath in package.json exports. 2. `packages/acp-bridge/src/bridge.test.ts` shrinks from 6861 -> ~6400 LOC: fixtures replaced with named imports from `./internal/testUtils.js`; cross-package import `from './daemonStatusProvider.js'` removed (4 daemon-host tests moved out); ACP SDK + bridgeErrors / workspacePaths / bridge / channel / bridgeTypes imports split into multiple statements reflecting actual post-F1 provenance. 3. New `packages/cli/src/serve/daemonStatusProvider.test.ts` (~240 LOC, 4 tests): wires real `createDaemonStatusProvider()` through a cli-side `makeBridge` wrapper to assert end-to-end daemon env / preflight cells. Imports `createHttpAcpBridge` via the `./httpAcpBridge.js` re-export shim — doubles as a shim surface smoke check. Verification: - acp-bridge: 291/291 tests pass (177 in bridge.test.ts). - cli: daemonStatusProvider.test.ts 4/4 pass; full cli suite 6742/6767 green (16 pre-existing failures in AuthDialog / memoryDiagnostics / useAtCompletion — all on `daemon_mode_b_main` baseline, last modified by commits predating this branch). - Tests counts pre-split: 181 in httpAcpBridge.test.ts; post-split: 177 in bridge.test.ts + 4 in daemonStatusProvider.test.ts = 181 (parity preserved). Part of #4175 F1 test split (deferred from #4334). * refactor(acp-bridge): self-review round 1 — vitest alias + doc/comment polish Five code-reviewer findings folded in on top of e97282f30: S1 [Suggestion] — Test-utils ships to npm + cli reads stale dist. Added `packages/cli/vitest.config.ts:resolve.alias` mapping `@qwen-code/acp-bridge/internal/testUtils` → the .ts source. The package subpath export is RETAINED (required for TypeScript `nodenext` to resolve types — it won't fall back to tsconfig paths once exports rejects a subpath). Dual-channel approach documented in the testUtils JSDoc, including the alpha-stage 0.0.1 tradeoff that the file still ships in dist (stripInternal / .npmignore deferred). S2 [Suggestion] — Stale wording "two tests" in narrative comment. bridge.test.ts split-marker now correctly says "4 fallback tests" (no-provider × 2 surfaces + throwing-provider × 2 surfaces). S3 [Suggestion] — "Shim smoke check" only half-applied. daemonStatusProvider.test.ts now routes `BridgeOptions` and `HttpAcpBridge` types through `./httpAcpBridge.js` shim too (alongside `createHttpAcpBridge`), so the entire factory surface the cli tests rely on flows through the F1 re-export shim. N1 [Nit] — Asymmetric split-marker phrasing. Both markers now describe the 4 moved tests by surface (env real / preflight idle / preflight merged-live / preflight extMethod-throws) rather than "1 of" + "3 more". N2 [Nit] — testUtils "the suite" ambiguity. makeChannel JSDoc now references `bridge.test.ts` explicitly instead of "the suite" (which was unambiguous pre-split when helpers + 10 createInMemoryChannel sites lived in the same file). Verification: 291/291 acp-bridge tests pass; 4/4 cli daemon integration tests pass; tsc clean on both packages (pre-existing server.ts errors on baseline unchanged); eslint --max-warnings 0 clean on all 4 touched files. * docs(cli): self-review round 2 — fix stale vitest.config.ts alias comment Round 2 reviewer caught a 3-way contradiction in the round 1 docs: - vitest.config.ts said: alias replaces the export, internal/* stays unpublished (matches stderrLine convention). - package.json: subpath export IS declared. - testUtils.ts JSDoc: both channels intentionally retained, testUtils ships in dist. Round 1 explicitly chose to retain the export because TS `nodenext` won't fall back to tsconfig `paths` once `exports` rejects a subpath; the alias only serves to short-circuit *runtime* resolution so cli reads src/ not dist/. Rewriting the vitest.config.ts comment to reflect that dual-channel reality (and pointing readers at testUtils.ts for the full rationale). * fix(acp-bridge): #4445 round 3 fold-in — 4 of 7 reviewer threads adopted PR #4445 review pass — 4 adopt + 3 decline (declines replied inline; not folded here): ADOPTED: T1 [copilot daemonStatusProvider.test.ts:136 — bridge.shutdown missing]: added `await bridge.shutdown()` to test 2 (preflight idle). Three of four tests already shut down; symmetry + future-proof if `createHttpAcpBridge` gains background work even when no channel was spawned. T5 [wenshao testUtils.ts:92 — makeBridge naming collision]: cli- side helper renamed `makeBridge` -> `makeBridgeWithDaemonStatusProvider` (4 call sites in daemonStatusProvider.test.ts), JSDoc updated to reference the wenshao thread. testUtils.makeBridge stays as the canonical name used by ~100 tests in bridge.test.ts. A future contributor can no longer pick the wrong helper by accident. T6 [wenshao testUtils.ts:32 — JSDoc mis-claims @internal tag matches stderrLine.ts convention]: fixed wording. stderrLine.ts uses prose only; @internal is an additional package-private signal, not a convention match. Also restructured the npm-leak paragraph to describe the new .npmignore-via-files-negation enforcement (T7). T7 [wenshao package.json:70 — testUtils ships to npm]: switched `files: ["dist"]` -> `files: ["dist", "!dist/internal/testUtils.*", "!dist/**/*.test.*"]`. Wenshao's suggested `"test"` exports condition wasn't viable: vitest sets `vitest` not `test`, and gating on `vitest` would hide types from the cli's tsc compile. The negation-pattern files-field excludes the built testUtils from the publish surface while keeping the subpath export entry that TypeScript `nodenext` needs to resolve types. Verified via `npm pack --dry-run`: dist/internal/stderrLine.* still ships (production internal helper); dist/internal/testUtils.* + dist/**/*.test.* are excluded. DECLINED (replied on PR threads, not folded here): T2/T3 [copilot — `handles` array unused in tests 3/4]: bookkeeping matches the pre-split bridge.test.ts verbatim; cleanup is scope creep on this rename PR. T4 [copilot — testUtils eager-imports createHttpAcpBridge, cross-copy identity risk]: cli daemonStatusProvider.test.ts uses its OWN local `makeBridgeWithDaemonStatusProvider` and never imports testUtils.makeBridge — the cross-copy concern isn't triggered. Premature abstraction on a test-only fixture. Verification: 291/291 acp-bridge tests pass; 4/4 cli daemon tests pass; tsc clean both packages; eslint --max-warnings 0 clean on 2 touched .ts files; `npm pack --dry-run` confirms publish-surface exclusions. * fix(core): F2 cleanup PR B — self-heal observability (W133-a + W134) (#4460) * fix(core): F2 cleanup PR B — self-heal observability (W133-a + W134) W93 declined as already satisfied by W1 fix in #4336 commit 6 (spawnEntry's catch already calls forceShutdown which runs the full cleanup table — listener removal, timer clear, subscriber detach, sweep+disconnect, onClosed eviction). Source-verified non-repro. W133-a: McpClient.onerror now captures the error in a private `lastTransportError` field (reset at each connect()); the W120 silent-drop block at mcp-pool-entry.ts:346 reads it via the new `getLastTransportError()` getter and appends `: ` to the lastError string on the emitted 'failed' event. Preserves the literal "silent transport drop" prefix invariant for log-grep backward compat — pre-fix marker stays a substring. W134: sweepAndDisconnect now returns SweepResult instead of void — { pidSweepError?, disconnectError?, descendantsFound?, descendantsSignaled? }. The silent-drop fire-and-forget caller chains to inspect the result and emits a structured warn log when either pid-sweep threw OR sigtermPids partially signaled (signaled < found) — surfaces orphan-process pressure without inflating PR scope (no new SSE event or SDK reducer state; deferred to W134-followup if maintainers want metrics). forceShutdown / doRestart sweep callers ignore the return value (JS implicit-void at await sites preserves behavior). 4 new tests in mcp-transport-pool.test.ts covering W133-a happy path + fallback (no prior onerror) + W134 pidSweepError + W134 partial-signal failure modes. Module-mocks pid-descendants.js for controllable sweep behavior, and debugLogger.js to observe warn calls (production logger is session-gated and a no-op in tests). Singleton-stub debugLogger mock so production module-load `createDebugLogger('McpPool:Entry')` and the test's retrieval get the same vi.fn instances. Verification: - tsc clean: packages/core, packages/cli (server.ts pre-existing errors unchanged) - F2 transport-pool: 32/32 pass (28 pre-existing + 4 new) - mcp-client: 46/46 pass - eslint --max-warnings 0 clean on 3 touched files Part of #4175 #4336 follow-up bucket. * fix(core): #4460 round 1 fold-in — 4 copilot doc/comment threads adopted T1 [copilot mcp-pool-entry.ts:116 — stale line ref in SweepResult JSDoc]: replaced `mcp-pool-entry.ts:383` with stable method-anchor reference to the W120 silent-drop block inside `statusChangeListener`. Line numbers drift on every edit; method names don't. T2 [copilot mcp-pool-entry.ts:453 — `?? 0` ambiguous in warn payload]: silent-drop warn log now prints `descendantsFound=unknown` and `descendantsSignaled=unknown` when the values are undefined (only reachable in the pidSweepError branch — sweep threw before assignment). Operators triaging the warn can now distinguish "sweep succeeded but found 0 descendants" from "sweep itself threw, count is genuinely unmeasured". Locked in via a new assertion in the W134 pidSweepError test. T3 [copilot mcp-client.ts:116 — brittle line refs in lastTransportError JSDoc]: replaced `mcp-pool-entry.ts:346` and `mcp-client.ts:130` with stable method/block names (the `statusChangeListener` silent- drop block; the `client.onerror` arrow inside connect()). Same fix applied to the parallel comment in mcp-transport-pool.test.ts:730 for consistency. T4 [copilot mcp-transport-pool.test.ts:797 — singleton-stub mock comment contradictory]: rewrote the comment to unambiguously describe what the mock DOES (factory body runs once; inner arrow returns the same object on every call) instead of the prior hypothetical phrasing ("Returning a fresh object would have...") which read as a description of current behavior at first glance. All 4 are doc/comment fixes — zero behavior change apart from the T2 string format ('unknown' instead of '0'). Verified: - 32/32 mcp-transport-pool.test.ts pass - tsc clean on packages/core - eslint --max-warnings 0 clean on 3 touched files * fix(core): #4460 round 2 fold-in — remove dead SweepResult.disconnectError field T5 [wenshao mcp-pool-entry.ts:134 — `disconnectError` is dead data]: glm-5.1 review caught that the field was populated when `client.disconnect()` threw (line 844) but no consumer ever read it — the silent-drop `.then()` handler gated only on `pidSweepError` and partial-signal; `forceShutdown` and `doRestart` ignore the return; no test asserted on it. Removed the field from `SweepResult` and the assignment in the disconnect catch. The pre-existing `debugLogger.error(`client.disconnect failed for ...`)` inside `sweepAndDisconnect` already gives operators the signal — adding it to the outer silent-drop warn would have been duplicate noise. If a future consumer needs to gate logic on disconnect failures, re-add the field + reader at that point. Verification: 32/32 mcp-transport-pool.test.ts pass; tsc + eslint clean on the touched file. * feat(sdk/daemon-ui): unified completeness follow-up to #4328 (#4353) * feat(sdk/daemon-ui): expand event coverage to 28+ daemon event types (PR-A) Closes the "12+ daemon events fall through to debug" gap surfaced in the PR the daemon currently emits (Stage 1 + Wave 3-4), so renderers stop having to peek at `rawEvent.data` for known event categories. Session-meta: - session.metadata.changed (from session_metadata_updated) - session.approval_mode.changed (from approval_mode_changed) - session.available_commands (from available_commands_update; upgraded from a status-text fallback to a typed event carrying the command list) Workspace state (Wave 3-4): - workspace.memory.changed - workspace.agent.changed - workspace.tool.toggled - workspace.initialized - workspace.mcp.budget_warning - workspace.mcp.child_refused - workspace.mcp.server_restarted - workspace.mcp.server_restart_refused Auth device-flow (Wave 4 OAuth, RFC 8628): - auth.device_flow.started - auth.device_flow.throttled - auth.device_flow.authorized - auth.device_flow.failed (carries DaemonAuthDeviceFlowSdkErrorKind) - auth.device_flow.cancelled - `DaemonUiErrorEvent.errorKind?: DaemonErrorKind` — closed-enum error category propagated from daemon's typed-error taxonomy. Renderers can branch on errorKind for "retry auth" vs "check file path" affordances instead of regex-matching `text`. - `DaemonUiToolUpdateEvent.provenance?: DaemonUiToolProvenance` + `.serverId?` — closed enum ('builtin' | 'mcp' | 'subagent' | 'unknown'). Falls back to the `mcp____` naming heuristic when the daemon doesn't stamp provenance explicitly. Unblocks UI namespace dispatch without string-matching toolName. Session-meta / workspace / auth events do NOT push transcript blocks. They are intentional sidechannel observations: `lastEventId` advances (monotonic invariant preserved), but the chat-stream transcript stays focused on user/assistant/tool/shell/permission content. Renderers consume them via selectors (introduced in follow-up PRs). All new event types produce short structured lines in `daemonUiEventToTerminalText` for tail-style debug consumers. Web/IDE renderers should consume the typed events directly via subscription. 40/40 tests pass. New tests verify: - All 16 new event types normalize correctly - Malformed payloads fall back to debug without leaking raw data (`secret` field never appears in fallback text) - MCP tool provenance heuristic (`mcp__github__create_issue` → provenance='mcp', serverId='github') - errorKind propagation on session_died / stream_error - Reducer is no-op on new event types; lastEventId still advances This is PR-A of the unified-renderer-layer follow-up series: - PR-A (this commit) — event coverage + closed-enum schema - PR-B — server-side timestamps + ordering refactor - PR-C — multimodal content + tool preview taxonomy - PR-D — render contract (toMarkdown / toHtml / toPlainText) + adapter conformance test framework - PR-E — reducer state machine (subagent / progress / current tool / cancellation propagation) See https://github.com/QwenLM/qwen-code/pull/4328#issuecomment-4494179724 for the full proposal. Generated with AI Co-authored-by: Claude Opus 4.7 * feat(sdk/daemon-ui): server timestamps + event-id-based ordering (PR-B) Closes the "时间定义不标准" gap surfaced in the PR #4328 review: - Client-side `Date.now()` drifts across clients - No daemon-authoritative timestamp propagated to UI - Out-of-order replay events get fresher `state.now` than originals, breaking `createdAt` ordering - `DaemonUiEventBase.serverTimestamp?: number` — daemon-authoritative wall-clock timestamp extracted from envelope. - `DaemonTranscriptBlockBase.serverTimestamp?: number` + `clientReceivedAt: number`. - `createdAt` preserved as `@deprecated` alias for `clientReceivedAt` (backward compat for code written before this PR). `extractServerTimestamp` looks at three candidate envelope locations: 1. `event.serverTimestamp` (preferred when daemon adds it) 2. `event._meta.serverTimestamp` (Anthropic-style metadata convention) 3. `event.data._meta.serverTimestamp` (sessionUpdate nested location) The SDK is ready to consume serverTimestamp WHEN daemon emits it, without requiring a coordinated SDK release. Undefined when daemon doesn't emit (current state) — graceful degradation to client-clock ordering. `selectTranscriptBlocksOrderedByEventId(state)` — returns blocks sorted by: 1. `eventId` (daemon-monotonic SSE cursor) — primary key 2. `serverTimestamp` (daemon wall clock) — fallback for synthetic frames 3. `clientReceivedAt` (local clock) — last resort Use this when displaying long sessions where event id 5 may arrive AFTER event id 7 (typical in SSE replay-after-reconnect). `formatBlockTimestamp(block, opts)` — formats the most authoritative timestamp on a block using `Intl.DateTimeFormat`. Prefers `serverTimestamp` over `clientReceivedAt` for cross-client consistency. Accepts locale / timeZone / dateStyle / timeStyle. Daemon needs to stamp `_meta.serverTimestamp` on every SSE envelope. This SDK PR is ready to consume it the moment the daemon ships the field; no coordination needed. - serverTimestamp extraction from all three envelope locations - Defaults undefined when envelope has none - `selectTranscriptBlocksOrderedByEventId` sorts mixed-arrival events by eventId (replay scenario) - `formatBlockTimestamp` prefers serverTimestamp; returns localized string PR-B of the unified follow-up to PR #4328 (PR-A + PR-B + PR-C + PR-D + PR-E in one branch). Generated with AI Co-authored-by: Claude Opus 4.7 * feat(sdk/daemon-ui): reducer state machine — currentTool / approvalMode / cancellation propagation (PR-E) Closes the "reducer state machine 设计缺漏" gap surfaced in the PR #4328 review: - No `currentTool` — UI scans `blocks[]` to find the running tool - No mirrored approval mode — UI walks events to badge "plan"/"yolo" - Cancellation does not propagate — in-flight tool blocks stuck at 'in_progress' forever when the parent prompt is cancelled ## State additions (sidechannel, no transcript blocks) `DaemonTranscriptSidechannelState`: - `currentToolCallId?: string` — toolCallId of the in-flight tool - `approvalMode?: string` — mirrored from session.approval_mode.changed - `toolProgress: Record` — per-tool progress shape (daemon-side emission of `tool.progress` events pending) ## Reducer behavior ### `tool.update` events `IN_FLIGHT_TOOL_STATUSES` = { pending, confirming, running, in_progress } `TERMINAL_TOOL_STATUSES` = { completed, success, failed, error, canceled, cancelled } - Tool enters in-flight: set `currentToolCallId = event.toolCallId` - Tool enters terminal: clear `currentToolCallId` if it matches - Unknown status (forward-compat): leave pointer untouched This avoids the failure mode where a future daemon-emitted status like `'paused'` would silently mark unknown states as either in-flight or terminal incorrectly. ### `session.approval_mode.changed` Mirror `event.next` onto `state.approvalMode`. Renderers can render a mode badge ("plan" / "default" / "auto-edit" / "yolo") with a single selector call, no event-stream walking. ### `assistant.done` with `reason === 'cancelled'` `propagateCancellationToInFlightTools` walks every tool block whose status is still in-flight and force-sets it to 'cancelled'. The daemon does not guarantee terminal `tool_call_update` for every in-flight tool when the parent prompt is cancelled, so this propagation prevents UI spinners from spinning forever. `currentToolCallId` is also cleared in the same call. Non-cancellation `assistant.done` (e.g., `reason: 'end_turn'`) does NOT propagate — in-flight tools remain in-flight until the daemon emits their terminal update naturally. ## Selectors - `selectCurrentTool(state)` — returns the running tool block, or undefined - `selectApprovalMode(state)` — returns the mirrored approval mode - `selectToolProgress(state, toolCallId)` — per-tool progress query All exported from `@qwen-code/sdk/daemon`. ## Scope deliberately deferred Subagent nesting (`parentBlockId` / `delegationId` / `DaemonSubagentTranscriptBlock`) is NOT in this PR. The shape needs design discussion (how to project nested events; whether to bake delegation tracking into transcript or sidechannel). PR-D / PR-F follow-up. ## Test coverage (51/51 pass) - currentToolCallId set on enter, cleared on terminal - approvalMode mirrors changes - Cancellation marks in-flight tools 'cancelled', leaves completed alone - Unknown status does NOT clear currentToolCallId (forward-compat) - Non-cancellation `assistant.done` does NOT propagate ## Roadmap PR-E of the unified follow-up to PR #4328 (PR-A + PR-B + PR-E in this branch; PR-C / PR-D pending). Generated with AI Co-authored-by: Claude Opus 4.7 * feat(sdk/daemon-ui): tool preview taxonomy + multimodal content extraction (PR-C) Closes two related gaps surfaced in the PR #4328 review: - `DaemonToolPreview` had only 4 kinds — UI fell back to `key_value` / `generic` for tools that deserved structured display - `getTextContent` silently dropped non-text content (image / audio / resource), so multimodal conversations vanished from the UI `DaemonToolPreview` extends from 4 to 8 variants: - `file_diff` — `{ path, oldText?, newText?, patch? }` — file edit tools (Anthropic-style `oldText/newText`, aider-style `patch`, write-style `newText` alone) - `file_read` — `{ path, range?: [start, end] }` — file read tools, with range extracted from `lineRange` tuple OR `offset/limit` pair - `web_fetch` — `{ url, method? }` — HTTP fetch tools (requires URL with scheme to avoid false positives on relative paths) - `mcp_invocation` — `{ serverId, toolName, argsSummary? }` — MCP server tool calls, identified via `mcp____` naming convention (same heuristic as PR-A `DaemonUiToolUpdateEvent.provenance`) Detector order matters — MCP wins first (most specific), then file_diff, file_read, web_fetch, then the existing command / key_value fallbacks. New helper `extractContentPart(value): DaemonUiContentPart | undefined` returns a discriminated union: ```ts type DaemonUiContentPart = | { kind: 'text'; text: string } | { kind: 'image'; mediaType: string; source: { url?, data? } } | { kind: 'audio'; mediaType: string; source: { url?, data? } } | { kind: 'resource'; uri: string; mediaType?, description? }; ``` The existing `getTextContent` is preserved for backward compat. Renderers that need to surface non-text content (web UI thumbnails, IDE attachment chips) now have a typed shape to consume. - Wiring `extractContentPart` into the normalizer / reducer so text blocks accumulate `parts: DaemonUiContentPart[]` alongside `text` (additive shape change requires render contract coordination — PR-D). - 5 additional tool preview kinds (image_generation / code_block / tabular / subagent_delegation / search) — useful but not urgent; current 8 kinds cover the typical agent flows. - file_diff detection from Anthropic / aider / write shapes - file_read with lineRange tuple AND offset+limit pair - web_fetch with method, REJECTS relative paths (no scheme) - mcp_invocation with serverId + toolName extraction - Detector priority: MCP wins over file_diff on conflicting shapes - extractContentPart for text / image (url) / audio (data) / resource - Unknown content type returns undefined (skip rather than synthesize) - Image without source returns undefined (defensive) PR-C of the unified follow-up to PR #4328 (PR-A + PR-B + PR-E + PR-C in this branch; PR-D render contract pending). Generated with AI Co-authored-by: Claude Opus 4.7 * feat(sdk/daemon-ui): render contract — markdown / HTML / plain text helpers (PR-D) Closes the "render 契约只覆盖 terminal" gap surfaced in the PR #4328 review: > PR ships `daemonUiEventToTerminalText` for terminal. Web/IDE/channel > adapters each roll their own projection. No shared contract → adapter > divergence is inevitable. ## New helpers ```ts daemonBlockToMarkdown(block, opts?): string // GFM-compatible daemonBlockToHtml(block, opts?): string // conservatively escaped HTML daemonBlockToPlainText(block, opts?): string // for copy-paste / logs daemonToolPreviewToMarkdown(preview, opts?): string ``` All three respect the same `kind` discrimination so adapters can switch between them without touching call sites. ## Per-kind projection For each `DaemonTranscriptBlock['kind']`: - `user` / `assistant` / `thought` — plain text with role labels - `tool` — header with toolName + structured preview + status badge - `shell` — fenced code block, stream-discriminated (stdout vs stderr) - `permission` — title + options list + resolved/pending indicator - `status` / `debug` / `error` — semantic class / role (error → role=alert) For each `DaemonToolPreview['kind']`: - `ask_user_question` — question + options as bullet list - `command` — fenced bash with optional cwd comment - `file_diff` — unified diff in fenced code block (oldText/newText OR patch) - `file_read` — `path (lines N-M)` line - `web_fetch` — `METHOD url` line - `mcp_invocation` — `serverId::toolName` with args summary - `key_value` — bullet list - `generic` — emphasized summary ## Security - Default HTML sanitizer escapes `<`, `>`, `&`, `"`, `'` and FIRST strips ANSI/control sequences via `sanitizeTerminalText` (defense against agent-emitted escape codes in HTML output). - Custom sanitizer hook for consumers wanting markdown→HTML pipelines (markdown-it + DOMPurify, etc.). - `sanitizeUrls` option strips token-like query params (`token=`, `key=`, `x-amz-`, etc.) from URLs in `web_fetch` previews. - `maxFieldLength` truncation defaults 8192, prevents pathological rendering on huge content. ## Adapter conformance (out of scope for this commit) The conformance test framework (fixture corpus + `runAdapterConformanceSuite`) mentioned in PR-D scope is deferred to a follow-up. The render helpers here are the precondition — once stable, the conformance framework can use them as the reference projection. ## Test coverage (77/77 pass) - All 9 block kinds render in markdown (verified for user/assistant/tool/ shell/permission/error specifically) - file_diff renders as unified diff with old/new lines - mcp_invocation renders as `server::tool` format - HTML escapes XSS (`` pass the protocol check. Modern browsers don't execute ``, but the comment claimed "never legitimate in ``" which slightly over-claimed the protection. Tighten the data: branch to require an `image/` MIME prefix. Verified by a new test that covers: https (allow), data:image/png (allow), data:text/html (reject → '#'), javascript: (reject → '#'). Generated with AI Co-authored-by: Claude Opus 4.7 * fix(daemon-ui): wenshao + doudouOUC R4 review batch Walks 6 wenshao items (delivered as 8 review submissions — 2 CHANGES_REQUESTED + 6 individual COMMENTED — but 6 distinct concerns) and 3 doudouOUC R4 nits. All 9 real issues addressed; no false-positives this round. ## Real Criticals ### awaitingResync recovery API (wenshao R4) `store.reset()` requires session-id change semantics — wrong shape for "same-session reconnect with SSE replay" recovery. Added explicit `store.clearAwaitingResync()` API. Latch is still set on receipt of `session.state_resync_required` (intentional one-way during replay window); consumers now have a clean path to clear after the replay stream drains. ### normalizeAuthDeviceFlowCancelled test coverage (wenshao R4) Coverage gap surfaced — happy path (valid deviceFlowId) and malformed fallback to debug both untested. Added 2 tests. ## Real Suggestions ### sanitizeUrl: AWS / Azure / GCP credential patterns The previous regex caught `x-amz-` and `x-goog-` headers + generic `signature` / `sig`, but missed: - `AWSAccessKeyId` (S3 presigned) - Azure SAS short codes (`sv` / `se` / `sr` / `sp` / `st` / `spr` / `sip` / `ss` / `srt` / `sig` / `skoid` / etc.) - GCP signed-URL `GoogleAccessId` + `Expires` (paired with credentials in signed URL contexts) Widened regex to include `aws|google|expires` prefixes + added explicit Azure-SAS Set check. ### detectFileDiff: `content` alias disambiguated `{ path, content }` was being classified as `file_diff` regardless of tool semantics — but the same shape is common for file_read assertions or search queries. Since detectFileDiff runs BEFORE detectFileRead in the detector chain, this caused mis-classification. Fix: restrict bare `content` to require either (a) write-intent tool name (write/create/edit/replace/save/update) OR (b) co-occurrence with `oldText`. Explicit `newText` / `new_text` / etc. still pass through unconditionally. Required adding `opts` to the `detectFileDiff` signature (callers already pass opts to siblings). ### detectFileRead: 0-based offset → 1-based range Type doc says `range: [startLine, endLine]` is 1-based inclusive. The offset+limit conversion produced 0-based output ([0, 9] for offset=0/limit=10), which displayed as "lines 0-9" — line 0 doesn't exist in 1-based. Convert at the detector: `[offset+1, offset+limit]`. Updated the matching test (which had encoded the 0-based bug as expected behavior). ### formatMissedRange — guard inverted / single-event ranges The naive `lastDeliveredId+1 .. earliestAvailableId-1` formula produced: - `gap === 0`: "missed 6-5" (inverted) - `gap === 1`: "missed 6-6" (single event shown as range) Added `formatMissedRange()` helper with explicit branches: - `last < first` → "no events lost (resync requested without gap)" - `last === first` → "missed 1 daemon event (id N)" - `last > first` → "missed daemon events X-Y" Applied in both `transcript.ts` (status block message) and `terminal.ts` (ANSI projection) — same formula was duplicated. ## doudouOUC R4 nits ### README errorKind list outdated Replaced `expired / transport / server / internal` with pointer to `KNOWN_DEVICE_FLOW_ERROR_KINDS` exported constant — canonical list auto-stays-in-sync. ### README "10 scenarios" stale Was 10, became 11 with subagent-nesting. Removed the count and let the corpus be derived at runtime via `DAEMON_UI_CONFORMANCE_FIXTURES.length`. ### selectTranscriptBlocks danger post lazy-COW With state.blocks now shared across sidechannel snapshots, a misbehaving consumer doing `(state.blocks as DaemonTranscriptBlock[]).sort()` would poison every snapshot sharing the reference. Freeze the blocks array at the dispatch boundary in `reduceDaemonTranscriptEvents`. Internal reducer mutation goes through `takeBlocksOwnership` which copies before mutating, so the frozen reference is never modified in place. ## Validation | | | |---|---| | SDK tests | **162/162** | | WebUI tests | **9/9** | | SDK typecheck | clean | | WebUI typecheck | clean | Generated with AI Co-authored-by: Claude Opus 4.7 * fix(daemon-ui): wenshao R5 review batch — Critical OAuth fragment leak + 10 more Walks 13 inline items from wenshao's 16:46-17:28 reviews. 11 fixed, 1 deduped (lint-no-console flagged in both reviews), 1 reverted/push-back (multi-part deny re-flags the same design-intent territory as R2 #4). ## Critical fixes ### sanitizeUrl: OAuth #fragment leak `sanitizeUrl` cleared query params and Basic Auth userinfo, but `u.toString()` preserved `u.hash`. OAuth 2.0 implicit grant puts `access_token=...` directly in the fragment (e.g., `https://app/#access_token=gho_xxx&token_type=bearer`); some Azure SAS variants similarly. Now `u.hash = ''` before serialize. For rendered output (markdown / HTML / plaintext), the fragment is client- state-only and dropping it removes the entire fragment-side leak surface. ### ESLint no-console on awaitingResync diagnostic Project lint forbids bare `console.*`. Added `eslint-disable-next-line no-console -- intentional diagnostic` per wenshao's suggestion. Behavior unchanged. ### normalizeAuthDeviceFlowCancelled test coverage (still missing post-R4) R4 added tests for one of the five device-flow normalizers; the `cancelled` variant was still uncovered. Added happy + malformed-payload tests. ## Behavior fixes ### Plaintext sanitizeTerminalText parity `daemonBlockToPlainText` + `daemonToolPreviewToPlainText` previously returned ANSI/bidi-control text verbatim, while markdown and HTML paths sanitized via `sanitizeTerminalText`. A daemon emitting bidi overrides survived clean to plaintext output — contradicting the "copy-paste / logs" JSDoc intent. Now routes every text field through `clean()` = `cap(sanitizeTerminalText(raw))`. ### blockquote helper applied to image_generation + subagent_delegation R3 added the helper for thought/debug/error but missed two preview markdown sites (`> ${text(preview.prompt)}` for image_generation, `> ${text(preview.task)}` for subagent_delegation). Multi-line prompts / tasks now stay inside the blockquote. ### Default unrecognized-event branch: single debug block Was emitting `status + debug` (2 blocks) per unknown event type. In long sessions where the daemon adds new types an older SDK doesn't recognize, this doubled block-consumption rate and accelerated `maxBlocks` trimming of real content. Now emit a single `debug` block that prefixes the event-type for adapters that want to pattern-match. ### writeIntent regex underscore-boundary aware R4's `content` alias gate-check used `\b` word boundaries, but `\b` doesn't match between `write` and `_` in `write_file` (both `\w`). Fixed to `(?:^|[_-])verb(?:$|[_-])` which catches the canonical `write_file` naming AND still rejects `prewrite_check`. Verb list extended per wenshao's suggestion (`overwrite`/`modify`/`patch`/`generate`). ### useDaemonPendingPermissions over-subscription Hook used `useDaemonTranscriptState()` which fires on every daemon event (text deltas, tool updates, sidechannel). Switched to `useDaemonTranscriptBlocks()` which only invalidates when the blocks array reference changes — block-mutating dispatches only, thanks to lazy COW. Same selector semantics, ~10x fewer renders in chat-heavy sessions. ### Conformance suite: try/catch adapter JSDoc promised "does not throw" but the loop wrapped adapter calls without try/catch. Buggy adapters aborted the whole suite instead of producing a structured `ConformanceFailure`. Now wrap; on throw, capture the error message in `renderedExcerpt: "[adapter threw: ...]"` and continue. ## Type / Quality fixes ### DaemonTranscriptState.blocks typed readonly Runtime contract is frozen (lazy-COW poison defense), but the type was mutable — consumers got runtime `TypeError` for in-place mutation instead of compile errors. Now `readonly DaemonTranscriptBlock[]` so mutation is caught at the type level. ### formatMissedRange exported / deduplicated Helper was duplicated inline between transcript.ts (full phrasing) and terminal.ts (terser phrasing). Exported from transcript.ts and reused in terminal.ts to prevent future drift. ## Push-back (false-positive — see reply) ### classifySelectedPermissionOption multi-part deny (`selected:deny:access_violation`) Re-flags the same `selected:X` design intent rejected in R2 #4. The caller comment explicitly states a selected option resolves the prompt even when the option id contains `deny`/`cancel`. The existing test `cancelled-substring-permission` (payload `selected:abort`, expected `completed`) codifies this. Daemon expresses true user-cancellation via the `cancelled` PRIMARY token, not `selected:cancel`. Not changing; reply directs to the same R2 #4 reasoning. ## Tests added (+10) - normalizeAuthDeviceFlowCancelled happy + malformed - sanitizeUrl OAuth fragment access_token rejected - sanitizeUrl AWS/GCP/Azure SAS credential params stripped - formatMissedRange no-gap / single-event / multi-event - detectFileDiff content alias rejected for read-like tools - detectFileDiff content alias accepted for write-like tools - writeIntent word boundaries (prewrite_check NOT matched) - conformance captures adapter throw - unrecognized event → single debug block - store.clearAwaitingResync clears latch ## Validation | | | |---|---| | SDK tests | **172/172** (was 162, +10) | | WebUI tests | **9/9** | | SDK typecheck | clean | | WebUI typecheck | clean | Generated with AI Co-authored-by: Claude Opus 4.7 * fix(daemon-ui): wenshao R6 — recovery flow chicken-and-egg + pending pointer Three Criticals from R6 review (4351217188) all pointing at real bugs introduced by R4/R5 work — not false positives. Fixes plus regression tests. ## Critical 1 — same-session reconnect never clears the latch When the daemon emitted `state_resync_required`, the reducer set `awaitingResync = true`. The webui provider dispatched `assistant.done { reason: 'reconnected' }` after re-attaching SSE but never called `store.clearAwaitingResync()`. Result: events flowed in on the fresh stream but every one got dropped by the `applyDaemonTranscriptEvent` passthrough guard. Transcript appeared permanently frozen with no diagnostic clue (the `console.warn` fired on each drop, but the user wouldn't necessarily check DevTools). Fix: in `DaemonSessionProvider.tsx`, after dispatching the synthetic `reconnected` `assistant.done`, check `awaitingResync` and clear it BEFORE the new SSE event loop starts. ## Critical 2 — updateCurrentToolPointer breaks on undefined status In `upsertToolBlock`, a new tool block is created with `status: event.status ?? 'pending'`. But `updateCurrentToolPointer` was called with raw `event.status` — when undefined, the function's own `if (status === undefined) return;` guard short-circuited without ever pointing at the new (visually-pending) block. Result: `selectCurrentTool` returned `undefined` for daemon events that omitted the explicit `status` field, while the block sat at "pending" in the UI — invisible to the current-tool selector. Fix: pass the EFFECTIVE status (`event.status ?? 'pending'`) so the pointer logic mirrors the actual stored status. ## Critical 3 — clearAwaitingResync flow chicken-and-egg The earlier (R4) JSDoc documented the recovery flow as: "re-subscribe with `Last-Event-ID: 0`, then call clearAwaitingResync after replay drains." But while the latch is true, EVERY non-passthrough event is dropped at `applyDaemonTranscriptEvent`. So during the replay drain, zero events made it into state, and clearing the latch afterward did nothing — transcript permanently empty. Correct flow: clear FIRST, then stream events. Updated JSDoc on both `types.ts` interface and `store.ts` impl to document this clearly. Added a regression test (`clearAwaitingResync AFTER dispatching events: events ARE dropped`) that pins the correct flow in code. ## Regression tests (+3) - `undefined status` creates pending block AND sets currentToolCallId - clear-then-dispatch ✓ events flow - dispatch-then-clear ✗ events dropped (correct flow documentation) ## Validation | | | |---|---| | SDK tests | **175/175** (was 172, +3) | | WebUI tests | **9/9** | | SDK typecheck | clean | | WebUI typecheck | clean | ## Note on doudouOUC heads-up #4469 (main → daemon_mode_b_main sync, 45 commits since 2026-05-19) will land soon. doudouOUC's note says rebase should be smooth (no daemon-ui surface conflicts). Will rebase on the cron's next pass after #4469 merges. Generated with AI Co-authored-by: Claude Opus 4.7 * fix(daemon-ui): wenshao R7 — escapeMarkdownText covers `<` + details URL sanitization Two items from wenshao R7 (one inline Suggestion + one Verification-PASS finding). Both gate-checked as real; fixed. ## escapeMarkdownText: add `<` to escape set Markdown rendered through markdown-it with `html: true` would previously pass through raw `` / `', + { now: 2 }, + ); + const html = daemonBlockToHtml(state.blocks[0]!); + expect(html).not.toContain(''), + ), + ).toContain('![image](#)'); + // javascript: → rejected to '#' + expect(daemonBlockToMarkdown(mkBlock('javascript:alert(1)'))).toContain( + '![image](#)', + ); + }); +}); + +describe('R5 review batch — coverage additions', () => { + it('normalizeAuthDeviceFlowCancelled happy path', () => { + const events = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'auth_device_flow_cancelled', + data: { deviceFlowId: 'flow-123' }, + } as never); + expect(events).toEqual([ + expect.objectContaining({ + type: 'auth.device_flow.cancelled', + deviceFlowId: 'flow-123', + }), + ]); + }); + + it('normalizeAuthDeviceFlowCancelled malformed → fallback debug', () => { + const events = normalizeDaemonEvent({ + id: 2, + v: 1, + type: 'auth_device_flow_cancelled', + data: { + /* no deviceFlowId */ + }, + } as never); + expect(events[0]?.type).toBe('debug'); + }); + + it('sanitizeUrl clears OAuth implicit-grant access_token in #fragment', async () => { + const { daemonBlockToMarkdown, createDaemonToolPreview } = await import( + '../../src/daemon/ui/index.js' + ); + const block = { + id: 'b', + kind: 'tool' as const, + toolCallId: 't', + title: 'fetch', + status: 'completed', + preview: createDaemonToolPreview( + { + url: 'https://app.example.com/callback#access_token=gho_FRAGMENT_LEAK&token_type=bearer', + method: 'GET', + }, + { toolName: 'WebFetch', toolKind: 'tool' }, + ), + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }; + const out = daemonBlockToMarkdown(block, { sanitizeUrls: true }); + expect(out).not.toContain('FRAGMENT_LEAK'); + expect(out).not.toContain('access_token='); + }); + + it('sanitizeUrl strips AWS / GCP / Azure SAS credential params', async () => { + const { daemonBlockToMarkdown, createDaemonToolPreview } = await import( + '../../src/daemon/ui/index.js' + ); + const mkBlock = (url: string) => ({ + id: 'b', + kind: 'tool' as const, + toolCallId: 't', + title: 'fetch', + status: 'completed', + preview: createDaemonToolPreview( + { url, method: 'GET' }, + { toolName: 'WebFetch', toolKind: 'tool' }, + ), + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }); + // AWS S3 presigned + const aws = daemonBlockToMarkdown( + mkBlock( + 'https://bucket.s3.amazonaws.com/x?AWSAccessKeyId=AKIA_LEAK&Expires=1234&Signature=SIG_LEAK', + ), + { sanitizeUrls: true }, + ); + expect(aws).not.toContain('AKIA_LEAK'); + expect(aws).not.toContain('SIG_LEAK'); + // GCP signed URL + const gcp = daemonBlockToMarkdown( + mkBlock( + 'https://storage.googleapis.com/b/o?GoogleAccessId=svc_LEAK@proj.iam.gserviceaccount.com&Expires=999&Signature=GCP_LEAK', + ), + { sanitizeUrls: true }, + ); + expect(gcp).not.toContain('svc_LEAK'); + expect(gcp).not.toContain('GCP_LEAK'); + // Azure SAS + const az = daemonBlockToMarkdown( + mkBlock( + 'https://acct.blob.core.windows.net/c/x?sv=2020-08-04&se=2026-12-31&sig=AZ_LEAK&sp=r', + ), + { sanitizeUrls: true }, + ); + expect(az).not.toContain('AZ_LEAK'); + }); + + it('formatMissedRange handles no-gap / single-event / multi-event', async () => { + const { formatMissedRange } = await import( + '../../src/daemon/ui/transcript.js' + ); + expect(formatMissedRange(5, 6)).toContain('no events lost'); + expect(formatMissedRange(5, 7)).toContain('1 daemon event'); + expect(formatMissedRange(5, 10)).toContain('6-9'); + }); + + it('detectFileDiff content alias rejected for non-write tools', async () => { + const { createDaemonToolPreview } = await import( + '../../src/daemon/ui/index.js' + ); + // `{ path, content }` with READ-like tool name → NOT file_diff + const read = createDaemonToolPreview( + { path: '/x', content: 'expected text' }, + { toolName: 'read_file' }, + ); + expect(read.kind).not.toBe('file_diff'); + // Same shape with WRITE-like tool name → IS file_diff + const write = createDaemonToolPreview( + { path: '/x', content: 'new content' }, + { toolName: 'write_file' }, + ); + expect(write.kind).toBe('file_diff'); + }); + + it('writeIntent regex word-boundary: prewrite_check does NOT match write', async () => { + const { createDaemonToolPreview } = await import( + '../../src/daemon/ui/index.js' + ); + const preview = createDaemonToolPreview( + { path: '/x', content: 'data' }, + { toolName: 'prewrite_check' }, + ); + expect(preview.kind).not.toBe('file_diff'); + }); + + it('conformance suite captures adapter throw as fixture failure (does not abort)', async () => { + const { runAdapterConformanceSuite } = await import( + '../../src/daemon/ui/index.js' + ); + const result = runAdapterConformanceSuite( + { + reduce: () => { + throw new Error('adapter bug — intentional'); + }, + renderToText: () => '', + } as never, + { only: ['simple-chat'] }, + ); + expect(result.failed).toHaveLength(1); + expect(result.failed[0]!.renderedExcerpt).toContain('adapter threw'); + expect(result.failed[0]!.renderedExcerpt).toContain('adapter bug'); + // Suite did not throw — caller's assertion contract holds. + }); + + it('unrecognized daemon event emits single debug block (not status+debug)', () => { + const events = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'future_event_in_2027' as never, + data: {}, + } as never); + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe('debug'); + }); + + it('store.clearAwaitingResync clears latch', async () => { + const { createDaemonTranscriptStore } = await import( + '../../src/daemon/ui/index.js' + ); + const store = createDaemonTranscriptStore(); + store.dispatch({ + type: 'session.state_resync_required', + reason: 'sse_eviction', + lastDeliveredId: 5, + earliestAvailableId: 12, + } as never); + expect(store.getSnapshot().awaitingResync).toBe(true); + store.clearAwaitingResync(); + expect(store.getSnapshot().awaitingResync).toBe(false); + }); +}); + +describe('R6 review batch — recovery flow + pending pointer', () => { + it('newly-created tool block with undefined status sets currentToolCallId to its default `pending`', () => { + let state = createDaemonTranscriptState({ now: 1 }); + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'unspecified', + title: 'starting', + // no status — daemon emit without explicit status field + }, + }, + } as never), + { now: 2 }, + ); + // Block has effective status 'pending' AND currentToolCallId points to it. + const block = state.blocks.find( + (b): b is Extract => + b.kind === 'tool' && b.toolCallId === 'unspecified', + )!; + expect(block.status).toBe('pending'); + expect(state.currentToolCallId).toBe('unspecified'); + }); + + it('clearAwaitingResync FIRST then dispatch new events: events flow', async () => { + const { createDaemonTranscriptStore } = await import( + '../../src/daemon/ui/index.js' + ); + const store = createDaemonTranscriptStore(); + // Set the latch. + store.dispatch({ + type: 'session.state_resync_required', + reason: 'sse_eviction', + lastDeliveredId: 5, + earliestAvailableId: 12, + } as never); + expect(store.getSnapshot().awaitingResync).toBe(true); + // Clear BEFORE the new event stream. + store.clearAwaitingResync(); + expect(store.getSnapshot().awaitingResync).toBe(false); + // Now dispatch a normal event — should land in transcript. + store.dispatch( + normalizeDaemonEvent({ + id: 100, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'replay-event-1' }, + }, + }, + } as never), + ); + const text = store + .getSnapshot() + .blocks.map((b) => + b.kind === 'assistant' ? (b as { text: string }).text : '', + ) + .join(''); + expect(text).toContain('replay-event-1'); + }); + + it('clearAwaitingResync AFTER dispatching events: events ARE dropped (documents the flow)', async () => { + // This test pins the correct flow as documented: latch drops everything + // until cleared. If a consumer dispatches events FIRST then clears, the + // events are lost. + const { createDaemonTranscriptStore } = await import( + '../../src/daemon/ui/index.js' + ); + const store = createDaemonTranscriptStore(); + store.dispatch({ + type: 'session.state_resync_required', + reason: 'sse_eviction', + lastDeliveredId: 5, + earliestAvailableId: 12, + } as never); + // WRONG order — dispatch BEFORE clear (replay window). + store.dispatch( + normalizeDaemonEvent({ + id: 101, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'replay-event-2' }, + }, + }, + } as never), + ); + store.clearAwaitingResync(); + // Event was dropped by the latch. + const text = store + .getSnapshot() + .blocks.map((b) => + b.kind === 'assistant' ? (b as { text: string }).text : '', + ) + .join(''); + expect(text).not.toContain('replay-event-2'); + }); +}); + +describe('R7 review batch — markdown escape + details sanitization', () => { + it('escapeMarkdownText escapes < in metadata fields (titles/kinds) for HTML-backed pipelines', async () => { + const { daemonBlockToMarkdown, createDaemonToolPreview } = await import( + '../../src/daemon/ui/index.js' + ); + // `escapeMarkdownText` is applied to METADATA fields (title / + // toolKind / status) — those are reviewer-untrusted and should + // escape `<` to prevent raw HTML pass-through when consumers run + // the markdown through markdown-it with html:true. Assistant / + // user / thought BODIES are intentionally NOT escape-formatted + // (they're markdown content; escaping `<` there would mangle + // legitimate markdown). + const block = { + id: 'b', + kind: 'tool' as const, + toolCallId: 't', + // Reviewer-untrusted title from a malicious daemon emit / tool + // response. Markdown escape must defang `<`. + title: '', + status: 'running', + preview: createDaemonToolPreview( + { command: 'echo hi' }, + { toolName: 'Bash', toolKind: 'tool' }, + ), + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }; + const md = daemonBlockToMarkdown(block); + // The `<` is escaped to `\<` — markdown-it will render that as a + // literal `<` character which then gets HTML-escaped in the + // markdown→HTML pipeline. Verify the escape is present, AND that + // no unescaped ` { + const { daemonBlockToMarkdown, createDaemonToolPreview } = await import( + '../../src/daemon/ui/index.js' + ); + const block = { + id: 'b', + kind: 'tool' as const, + toolCallId: 't', + title: 'Fetch', + status: 'running', + preview: createDaemonToolPreview( + { url: 'https://api.example.com/v1', method: 'GET' }, + { toolName: 'WebFetch', toolKind: 'tool' }, + ), + // details simulates the serialized rawInput JSON containing a URL + // with Basic Auth userinfo, query token, and OAuth fragment token. + details: + '{\n "url": "https://admin:BASIC_LEAK@api.example.com/v1?token=QUERY_LEAK&x-amz-credential=AWS_LEAK#access_token=FRAG_LEAK"\n}', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }; + const md = daemonBlockToMarkdown(block, { sanitizeUrls: true }); + expect(md).not.toContain('BASIC_LEAK'); + expect(md).not.toContain('QUERY_LEAK'); + expect(md).not.toContain('AWS_LEAK'); + expect(md).not.toContain('FRAG_LEAK'); + }); + + it('markdown tool block details preserves URLs verbatim when sanitizeUrls:false (back-compat)', async () => { + const { daemonBlockToMarkdown, createDaemonToolPreview } = await import( + '../../src/daemon/ui/index.js' + ); + const block = { + id: 'b', + kind: 'tool' as const, + toolCallId: 't', + title: 'Fetch', + status: 'running', + preview: createDaemonToolPreview( + { url: 'https://api.example.com/v1', method: 'GET' }, + { toolName: 'WebFetch', toolKind: 'tool' }, + ), + details: '{\n "url": "https://api.example.com/v1?token=visible"\n}', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }; + const md = daemonBlockToMarkdown(block); + // Default (no sanitizeUrls) — details survive verbatim per existing + // contract; consumers must opt in. + expect(md).toContain('token=visible'); + }); +}); + +describe('cross-client event recognition (prompt_cancelled / replay_complete)', () => { + it('normalizes prompt_cancelled to prompt.cancelled (not debug)', () => { + const events = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'prompt_cancelled', + originatorClientId: 'client-X', + data: { sessionId: 's1' }, + } as never); + expect(events).toEqual([ + expect.objectContaining({ + type: 'prompt.cancelled', + originatorClientId: 'client-X', + }), + ]); + // No reason for a plain user cancel. + expect(events[0]).not.toHaveProperty('reason'); + }); + + it('forwards the prompt_cancelled reason (C3 forward_failed)', () => { + const events = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'prompt_cancelled', + data: { sessionId: 's1', reason: 'forward_failed' }, + } as never); + expect(events[0]).toMatchObject({ + type: 'prompt.cancelled', + reason: 'forward_failed', + }); + }); + + it('normalizes replay_complete to session.replay_complete with count', () => { + const events = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'replay_complete', + data: { replayedCount: 3, lastEventId: 7 }, + } as never); + expect(events[0]).toMatchObject({ + type: 'session.replay_complete', + replayedCount: 3, + lastReplayedEventId: 7, + }); + }); + + it('prefers canonical lastReplayedEventId over the deprecated lastEventId alias (D4)', () => { + const events = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'replay_complete', + // Both present, different values — canonical must win. + data: { replayedCount: 2, lastReplayedEventId: 9, lastEventId: 7 }, + } as never); + expect(events[0]).toMatchObject({ + type: 'session.replay_complete', + lastReplayedEventId: 9, + }); + }); + + it('replay_complete with zero replay (empty ring) normalizes cleanly', () => { + const events = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'replay_complete', + data: { replayedCount: 0 }, + } as never); + expect(events[0]).toMatchObject({ + type: 'session.replay_complete', + replayedCount: 0, + }); + expect(events[0]).not.toHaveProperty('lastReplayedEventId'); + }); + + it('prompt.cancelled clears in-flight tool spinners in the reducer', () => { + let state = createDaemonTranscriptState({ now: 1 }); + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'running-tool', + title: 'long task', + status: 'running', + }, + }, + } as never), + { now: 2 }, + ); + // Peer cancel arrives. + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 2, + v: 1, + type: 'prompt_cancelled', + originatorClientId: 'peer', + data: { sessionId: 's1' }, + } as never), + { now: 3 }, + ); + const block = state.blocks.find( + (b): b is Extract => + b.kind === 'tool' && b.toolCallId === 'running-tool', + )!; + expect(block.status).toBe('cancelled'); + expect(state.currentToolCallId).toBeUndefined(); + }); + + it('session.replay_complete is a no-op against blocks', () => { + let state = createDaemonTranscriptState({ now: 1 }); + const before = state.blocks.length; + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'replay_complete', + data: { replayedCount: 5, lastEventId: 5 }, + } as never), + { now: 2 }, + ); + expect(state.blocks.length).toBe(before); + }); +}); + +describe('permission_resolved voterClientId (A4)', () => { + it('exposes voterClientId from data', () => { + const [evt] = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'permission_resolved', + originatorClientId: 'client_B', + data: { + requestId: 'perm-1', + outcome: { outcome: 'selected', optionId: 'allow' }, + voterClientId: 'client_B', + }, + } as never); + expect(evt).toMatchObject({ + type: 'permission.resolved', + requestId: 'perm-1', + voterClientId: 'client_B', + }); + }); + + it('falls back to the envelope originatorClientId when data.voterClientId is absent (old daemon)', () => { + const [evt] = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'permission_resolved', + originatorClientId: 'client_B', + data: { + requestId: 'perm-1', + outcome: { outcome: 'selected', optionId: 'allow' }, + }, + } as never); + expect(evt).toMatchObject({ + type: 'permission.resolved', + voterClientId: 'client_B', + }); + }); + + it('omits voterClientId for a no-voter resolution (neither field present)', () => { + const [evt] = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'permission_resolved', + data: { + requestId: 'perm-1', + outcome: { outcome: 'cancelled' }, + }, + } as never); + expect(evt).toMatchObject({ type: 'permission.resolved' }); + expect(evt).not.toHaveProperty('voterClientId'); + }); + + it('distinguishes the prompt originator (request) from the voter (resolved) when they differ', () => { + // The whole point of A4: client A submits the prompt that triggers the + // permission request; a DIFFERENT client B casts the resolving vote. + // The request carries A as originator; the resolution carries B as voter. + const [request] = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'permission_request', + originatorClientId: 'client_A', + data: { + requestId: 'perm-1', + toolCall: { name: 'Bash', command: 'rm -rf build' }, + options: [{ optionId: 'allow', label: 'Allow', raw: null }], + }, + } as never); + const [resolved] = normalizeDaemonEvent({ + id: 2, + v: 1, + type: 'permission_resolved', + originatorClientId: 'client_B', + data: { + requestId: 'perm-1', + outcome: { outcome: 'selected', optionId: 'allow' }, + voterClientId: 'client_B', + }, + } as never); + expect(request).toMatchObject({ + type: 'permission.request', + originatorClientId: 'client_A', + }); + expect(resolved).toMatchObject({ + type: 'permission.resolved', + voterClientId: 'client_B', + }); + // The voter is NOT the prompt originator — the disambiguation A4 enables. + expect((resolved as { voterClientId?: string }).voterClientId).not.toBe( + (request as { originatorClientId?: string }).originatorClientId, + ); + }); +}); + +describe('daemon assist push: followup_suggestion', () => { + it('normalizes followup_suggestion to followup.suggestion with payload', () => { + const events = normalizeDaemonEvent({ + id: 7, + v: 1, + type: 'followup_suggestion', + originatorClientId: 'client-A', + data: { + sessionId: 's-1', + suggestion: 'Run the build?', + promptId: 's-1########3', + }, + } as never); + expect(events).toEqual([ + expect.objectContaining({ + type: 'followup.suggestion', + sessionId: 's-1', + suggestion: 'Run the build?', + promptId: 's-1########3', + originatorClientId: 'client-A', + eventId: 7, + }), + ]); + }); + + it('routes malformed followup_suggestion to debug fallback', () => { + // Missing `promptId` — the normalizer rejects via fallbackDebug + // rather than synthesizing a typed event with partial data. + const events = normalizeDaemonEvent({ + id: 8, + v: 1, + type: 'followup_suggestion', + data: { sessionId: 's-1', suggestion: 'Hi' }, + } as never); + expect(events).toEqual([ + expect.objectContaining({ + type: 'debug', + text: expect.stringContaining('malformed followup_suggestion'), + }), + ]); + }); + + it('transcript reducer stores lastFollowupSuggestion without appending a block', () => { + let state = createDaemonTranscriptState({ now: 1 }); + const before = state.blocks.length; + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'followup_suggestion', + data: { + sessionId: 's-1', + suggestion: 'What did you find?', + promptId: 's-1########2', + }, + } as never), + { now: 2 }, + ); + // Sidechannel only — no chat-stream block. + expect(state.blocks.length).toBe(before); + expect(state.lastFollowupSuggestion).toEqual({ + suggestion: 'What did you find?', + promptId: 's-1########2', + }); + expect(state.lastEventId).toBe(1); + }); + + it('latest followup_suggestion replaces the prior one for the session', () => { + let state = createDaemonTranscriptState({ now: 1 }); + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'followup_suggestion', + data: { + sessionId: 's-1', + suggestion: 'First suggestion', + promptId: 's-1########1', + }, + } as never), + { now: 2 }, + ); + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 2, + v: 1, + type: 'followup_suggestion', + data: { + sessionId: 's-1', + suggestion: 'Second suggestion', + promptId: 's-1########2', + }, + } as never), + { now: 3 }, + ); + expect(state.lastFollowupSuggestion).toEqual({ + suggestion: 'Second suggestion', + promptId: 's-1########2', + }); + }); + + it('clears lastFollowupSuggestion when a new user prompt starts', () => { + let state = createDaemonTranscriptState({ now: 1 }); + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'followup_suggestion', + data: { + sessionId: 's-1', + suggestion: 'Try this', + promptId: 's-1########1', + }, + } as never), + { now: 2 }, + ); + expect(state.lastFollowupSuggestion).toBeDefined(); + + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 2, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'next question' }, + }, + }, + } as never), + { now: 3 }, + ); + expect(state.lastFollowupSuggestion).toBeUndefined(); + }); + + it('store.clearFollowupSuggestion drops the sidechannel suggestion', () => { + const store = createDaemonTranscriptStore(); + store.dispatch( + normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'followup_suggestion', + data: { + sessionId: 's-1', + suggestion: 'Care to elaborate?', + promptId: 's-1########4', + }, + } as never), + ); + // queueMicrotask flush + return Promise.resolve().then(() => { + expect(store.getSnapshot().lastFollowupSuggestion).toEqual({ + suggestion: 'Care to elaborate?', + promptId: 's-1########4', + }); + store.clearFollowupSuggestion(); + return Promise.resolve().then(() => { + expect(store.getSnapshot().lastFollowupSuggestion).toBeUndefined(); + // Idempotent: calling again is a no-op (no throw). + store.clearFollowupSuggestion(); + }); + }); + }); + + it('terminal renderer surfaces followup suggestion as a debug-style line', () => { + const text = daemonUiEventToTerminalText({ + type: 'followup.suggestion', + sessionId: 's-1', + suggestion: 'Try running the tests', + promptId: 's-1########5', + } as DaemonUiEvent); + expect(text).toContain('suggestion'); + expect(text).toContain('Try running the tests'); + }); +}); + +describe('permission_request normalization for Agent tools', () => { + it('keeps Agent toolCall metadata on permission.request without synthesizing tool.update', () => { + const events = normalizeDaemonEvent({ + id: 100, + v: 1, + type: 'permission_request', + data: { + requestId: 'req-1', + sessionId: 'sess-1', + toolCall: { + toolCallId: 'call_agent_1', + title: 'Query website', + status: 'pending', + rawInput: { + description: 'Query website', + prompt: 'fetch data from site', + subagent_type: 'Explore', + }, + content: [], + kind: 'other', + locations: [], + }, + options: [ + { optionId: 'proceed_once', name: 'Allow', kind: 'allow_once' }, + { optionId: 'cancel', name: 'Reject', kind: 'reject_once' }, + ], + }, + } as never); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe('permission.request'); + const permissionEvent = events[0] as Extract< + DaemonUiEvent, + { type: 'permission.request' } + >; + expect(permissionEvent.toolCall).toMatchObject({ + toolCallId: 'call_agent_1', + title: 'Query website', + rawInput: { subagent_type: 'Explore' }, + }); + }); + + it('emits only permission.request when toolCall has no subagent_type', () => { + const events = normalizeDaemonEvent({ + id: 101, + v: 1, + type: 'permission_request', + data: { + requestId: 'req-2', + toolCall: { + toolCallId: 'call_bash_1', + title: 'Bash: rm -rf build', + status: 'pending', + rawInput: { command: 'rm -rf build' }, + }, + options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }], + }, + } as never); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe('permission.request'); + }); + + it('does not create SDK tool blocks from Agent permission_request', () => { + let state = createDaemonTranscriptState({ now: 1000 }); + + const permEvents = normalizeDaemonEvent({ + id: 200, + v: 1, + type: 'permission_request', + data: { + requestId: 'req-agent', + sessionId: 'sess-1', + toolCall: { + toolCallId: 'call_agent_x', + title: 'Research task', + status: 'pending', + rawInput: { subagent_type: 'Explore', prompt: 'research' }, + content: [], + kind: 'other', + }, + options: [ + { optionId: 'proceed_once', name: 'Allow', kind: 'allow_once' }, + ], + }, + } as never); + + state = reduceDaemonTranscriptEvents(state, permEvents, { now: 1001 }); + + const toolBlocks = state.blocks.filter((b) => b.kind === 'tool'); + expect(toolBlocks).toHaveLength(0); + + const subToolEvents = normalizeDaemonEvent({ + id: 201, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'call_subtool_1', + toolName: 'web_fetch', + title: 'Fetch page', + status: 'pending', + parentToolCallId: 'call_agent_x', + subagentType: 'Explore', + rawInput: { url: 'https://example.com' }, + }, + }, + } as never); + + state = reduceDaemonTranscriptEvents(state, subToolEvents, { now: 1002 }); + + const subToolBlocks = state.blocks.filter( + (b) => b.kind === 'tool' && b.toolCallId === 'call_subtool_1', + ); + expect(subToolBlocks).toHaveLength(1); + expect(subToolBlocks[0]).toMatchObject({ + parentToolCallId: 'call_agent_x', + }); + expect((subToolBlocks[0] as { parentBlockId?: string }).parentBlockId).toBe( + undefined, + ); + }); +}); + +describe('parallel subAgent text interleaving — normalizer', () => { + it('extracts parentToolCallId from _meta on agent_message_chunk', () => { + const events = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hello' }, + _meta: { parentToolCallId: 'task-A', subagentType: 'reviewer' }, + }, + }, + } as never); + expect(events).toEqual([ + expect.objectContaining({ + type: 'assistant.text.delta', + text: 'hello', + parentToolCallId: 'task-A', + }), + ]); + }); + + it('extracts parentToolCallId from _meta on agent_thought_chunk', () => { + const events = normalizeDaemonEvent({ + id: 2, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: 'thinking' }, + _meta: { parentToolCallId: 'task-B' }, + }, + }, + } as never); + expect(events).toEqual([ + expect.objectContaining({ + type: 'thought.text.delta', + text: 'thinking', + parentToolCallId: 'task-B', + }), + ]); + }); + + it('omits parentToolCallId when _meta is absent', () => { + const events = normalizeDaemonEvent({ + id: 3, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'no meta' }, + }, + }, + } as never); + expect(events[0]).not.toHaveProperty('parentToolCallId'); + }); + + it('drops non-string parentToolCallId from _meta', () => { + const events = normalizeDaemonEvent({ + id: 4, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'bad meta' }, + _meta: { parentToolCallId: 12345 }, + }, + }, + } as never); + expect(events[0]).not.toHaveProperty('parentToolCallId'); + }); +}); + +describe('parallel subAgent text interleaving fix', () => { + it('T1: separates text chunks by parentToolCallId into independent blocks', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'tool.update', + toolCallId: 'agent-task-A', + title: 'Agent (code-reviewer)', + status: 'running', + subagentType: 'code-reviewer', + } as DaemonUiEvent, + { + type: 'tool.update', + toolCallId: 'agent-task-B', + title: 'Agent (pr-test-analyzer)', + status: 'running', + subagentType: 'pr-test-analyzer', + } as DaemonUiEvent, + ], + { now: 2 }, + ); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'assistant.text.delta', + text: 'Agent A says: hello ', + parentToolCallId: 'agent-task-A', + }, + { + type: 'assistant.text.delta', + text: 'Agent B says: world ', + parentToolCallId: 'agent-task-B', + }, + { + type: 'assistant.text.delta', + text: 'from agent A', + parentToolCallId: 'agent-task-A', + }, + { + type: 'assistant.text.delta', + text: 'from agent B', + parentToolCallId: 'agent-task-B', + }, + ], + { now: 3 }, + ); + + const assistantBlocks = state.blocks.filter( + (b) => b.kind === 'assistant', + ) as Array<{ text: string; parentToolCallId?: string }>; + + expect(assistantBlocks).toHaveLength(2); + expect(assistantBlocks[0]!.text).toBe('Agent A says: hello from agent A'); + expect(assistantBlocks[0]!.parentToolCallId).toBe('agent-task-A'); + expect(assistantBlocks[1]!.text).toBe('Agent B says: world from agent B'); + expect(assistantBlocks[1]!.parentToolCallId).toBe('agent-task-B'); + }); + + it('T2: scoped clearActiveText — subAgent A tool does not interrupt subAgent B text', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'assistant.text.delta', + text: 'A text ', + parentToolCallId: 'task-A', + }, + { + type: 'assistant.text.delta', + text: 'B text ', + parentToolCallId: 'task-B', + }, + { + type: 'tool.update', + toolCallId: 'child-tool-1', + title: 'Bash', + status: 'running', + parentToolCallId: 'task-A', + } as DaemonUiEvent, + { + type: 'assistant.text.delta', + text: 'B continues', + parentToolCallId: 'task-B', + }, + ], + { now: 2 }, + ); + + const bBlocks = state.blocks.filter( + (b) => + b.kind === 'assistant' && + (b as { parentToolCallId?: string }).parentToolCallId === 'task-B', + ) as Array<{ text: string }>; + expect(bBlocks).toHaveLength(1); + expect(bBlocks[0]!.text).toBe('B text B continues'); + }); + + it('T3: finishAssistant sets streaming=false on all keyed-map blocks', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'assistant.text.delta', + text: 'A streaming', + parentToolCallId: 'task-A', + }, + { + type: 'assistant.text.delta', + text: 'B streaming', + parentToolCallId: 'task-B', + }, + ], + { now: 2 }, + ); + + const before = state.blocks.filter((b) => b.kind === 'assistant') as Array<{ + streaming?: boolean; + }>; + expect(before[0]!.streaming).toBe(true); + expect(before[1]!.streaming).toBe(true); + + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'assistant.done', reason: 'stop' }], + { now: 3 }, + ); + + const after = state.blocks.filter((b) => b.kind === 'assistant') as Array<{ + streaming?: boolean; + }>; + expect(after[0]!.streaming).toBe(false); + expect(after[1]!.streaming).toBe(false); + }); + + it('T3b: finishAssistant sets streaming=false on keyed thought blocks', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'thought.text.delta', + text: 'A thinking', + parentToolCallId: 'task-A', + }, + { + type: 'thought.text.delta', + text: 'B thinking', + parentToolCallId: 'task-B', + }, + ], + { now: 2 }, + ); + + const before = state.blocks.filter((b) => b.kind === 'thought') as Array<{ + streaming?: boolean; + }>; + expect(before[0]!.streaming).toBe(true); + expect(before[1]!.streaming).toBe(true); + + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'assistant.done', reason: 'stop' }], + { now: 3 }, + ); + + const after = state.blocks.filter((b) => b.kind === 'thought') as Array<{ + streaming?: boolean; + updatedAt?: number; + }>; + expect(after[0]!.streaming).toBe(false); + expect(after[1]!.streaming).toBe(false); + expect(after[0]!.updatedAt).toBe(3); + expect(after[1]!.updatedAt).toBe(3); + expect(state.activeThoughtBlockByParent).toEqual({}); + }); + + it('T4: regression — text without parentToolCallId uses scalar path', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { type: 'assistant.text.delta', text: 'first ' }, + { type: 'assistant.text.delta', text: 'second' }, + ], + { now: 2 }, + ); + + const blocks = state.blocks.filter((b) => b.kind === 'assistant'); + expect(blocks).toHaveLength(1); + expect((blocks[0] as { text: string }).text).toBe('first second'); + }); + + it('T5: keyed and scalar paths coexist without interference', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'assistant.text.delta', + text: 'subagent text', + parentToolCallId: 'task-X', + }, + { type: 'assistant.text.delta', text: 'top-level text' }, + { + type: 'assistant.text.delta', + text: ' more subagent', + parentToolCallId: 'task-X', + }, + ], + { now: 2 }, + ); + + const assistantBlocks = state.blocks.filter( + (b) => b.kind === 'assistant', + ) as Array<{ text: string; parentToolCallId?: string }>; + + expect(assistantBlocks).toHaveLength(2); + + const subagentBlock = assistantBlocks.find( + (b) => b.parentToolCallId === 'task-X', + ); + const topLevelBlock = assistantBlocks.find( + (b) => b.parentToolCallId === undefined, + ); + expect(subagentBlock!.text).toBe('subagent text more subagent'); + expect(topLevelBlock!.text).toBe('top-level text'); + }); + + it('T6: trimTranscriptState prunes stale entries from activeAssistantBlockByParent', () => { + let state = createDaemonTranscriptState({ now: 1, maxBlocks: 3 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'assistant.text.delta', + text: 'will be trimmed', + parentToolCallId: 'old-task', + }, + { + type: 'tool.update', + toolCallId: 'tool-1', + title: 'Bash', + status: 'running', + } as DaemonUiEvent, + { type: 'user.text.delta', text: 'user msg' }, + { + type: 'tool.update', + toolCallId: 'tool-2', + title: 'Read', + status: 'running', + } as DaemonUiEvent, + ], + { now: 2 }, + ); + + expect(state.blocks.length).toBeLessThanOrEqual(3); + expect(state.activeAssistantBlockByParent['old-task']).toBeUndefined(); + }); + + it('T7: appendLocalUserTranscriptMessage clears active subAgent text maps', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'assistant.text.delta', + text: 'streaming...', + parentToolCallId: 'task-Y', + }, + ], + { now: 2 }, + ); + + expect(state.activeAssistantBlockByParent['task-Y']).toBeDefined(); + + state = appendLocalUserTranscriptMessage(state, 'user msg', { now: 3 }); + + expect(state.activeAssistantBlockByParent).toEqual({}); + expect(state.activeThoughtBlockByParent).toEqual({}); + expect( + state.blocks.find( + (block) => + block.kind === 'assistant' && block.parentToolCallId === 'task-Y', + ), + ).toMatchObject({ streaming: false, updatedAt: 3 }); + }); + + it('T8: thought evicts assistant block and finalizes streaming for same parent', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'assistant.text.delta', + text: 'assistant streaming', + parentToolCallId: 'task-Z', + }, + ], + { now: 2 }, + ); + + const beforeBlocks = state.blocks.filter( + (b) => b.kind === 'assistant', + ) as Array<{ streaming?: boolean }>; + expect(beforeBlocks[0]!.streaming).toBe(true); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'thought.text.delta', + text: 'now thinking', + parentToolCallId: 'task-Z', + }, + ], + { now: 3 }, + ); + + expect(state.activeAssistantBlockByParent['task-Z']).toBeUndefined(); + const afterBlocks = state.blocks.filter( + (b) => b.kind === 'assistant', + ) as Array<{ streaming?: boolean }>; + expect(afterBlocks[0]!.streaming).toBe(false); + + const thoughtBlocks = state.blocks.filter((b) => b.kind === 'thought'); + expect(thoughtBlocks).toHaveLength(1); + }); + + it('T9: thought text cleared by scoped clearActiveText from tool.update', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'thought.text.delta', + text: 'thinking...', + parentToolCallId: 'task-W', + }, + { + type: 'tool.update', + toolCallId: 'child-tool', + title: 'Bash', + status: 'running', + parentToolCallId: 'task-W', + } as DaemonUiEvent, + { + type: 'thought.text.delta', + text: 'new thought', + parentToolCallId: 'task-W', + }, + ], + { now: 2 }, + ); + + const thoughtBlocks = state.blocks.filter( + (b) => + b.kind === 'thought' && + (b as { parentToolCallId?: string }).parentToolCallId === 'task-W', + ) as Array<{ text: string }>; + expect(thoughtBlocks).toHaveLength(2); + expect(thoughtBlocks[0]!.text).toBe('thinking...'); + expect(thoughtBlocks[1]!.text).toBe('new thought'); + }); + + it('T10: trimTranscriptState prunes activeThoughtBlockByParent', () => { + let state = createDaemonTranscriptState({ now: 1, maxBlocks: 3 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'thought.text.delta', + text: 'will be trimmed', + parentToolCallId: 'old-thought', + }, + { + type: 'tool.update', + toolCallId: 'tool-1', + title: 'Bash', + status: 'running', + } as DaemonUiEvent, + { type: 'user.text.delta', text: 'user msg' }, + { + type: 'tool.update', + toolCallId: 'tool-2', + title: 'Read', + status: 'running', + } as DaemonUiEvent, + ], + { now: 2 }, + ); + + expect(state.blocks.length).toBeLessThanOrEqual(3); + expect(state.activeThoughtBlockByParent['old-thought']).toBeUndefined(); + }); + + it('T11: finishAssistant clears activeThoughtBlockByParent with entries', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'thought.text.delta', + text: 'thinking...', + parentToolCallId: 'task-T', + }, + { + type: 'assistant.text.delta', + text: 'responding...', + parentToolCallId: 'task-T2', + }, + ], + { now: 2 }, + ); + + expect(state.activeThoughtBlockByParent['task-T']).toBeDefined(); + + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'assistant.done', reason: 'stop' }], + { now: 3 }, + ); + + expect(state.activeThoughtBlockByParent).toEqual({}); + expect(state.activeAssistantBlockByParent).toEqual({}); + }); + + it('T12: assistant evicts thought block for same parent', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'thought.text.delta', + text: 'thinking first', + parentToolCallId: 'task-E', + }, + ], + { now: 2 }, + ); + + expect(state.activeThoughtBlockByParent['task-E']).toBeDefined(); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'assistant.text.delta', + text: 'now responding', + parentToolCallId: 'task-E', + }, + ], + { now: 3 }, + ); + + expect(state.activeThoughtBlockByParent['task-E']).toBeUndefined(); + expect(state.activeAssistantBlockByParent['task-E']).toBeDefined(); + expect( + state.blocks.find((block) => block.kind === 'thought'), + ).toMatchObject({ streaming: false, updatedAt: 3 }); + }); + + it('T12b: scalar assistant finalizes previous scalar thought block', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'thought.text.delta', text: 'thinking first' }], + { now: 2 }, + ); + + expect(state.activeThoughtBlockId).toBeDefined(); + + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'assistant.text.delta', text: 'now responding' }], + { now: 3 }, + ); + + expect(state.activeThoughtBlockId).toBeUndefined(); + expect(state.activeAssistantBlockId).toBeDefined(); + expect( + state.blocks.find((block) => block.kind === 'thought'), + ).toMatchObject({ streaming: false, updatedAt: 3 }); + }); + + it('T12c: scalar thought finalizes previous scalar assistant block', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'assistant.text.delta', text: 'answer first' }], + { now: 2 }, + ); + + expect(state.activeAssistantBlockId).toBeDefined(); + + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'thought.text.delta', text: 'thinking next' }], + { now: 3 }, + ); + + expect(state.activeAssistantBlockId).toBeUndefined(); + expect(state.activeThoughtBlockId).toBeDefined(); + expect( + state.blocks.find((block) => block.kind === 'assistant'), + ).toMatchObject({ streaming: false, updatedAt: 3 }); + }); + + it('T12d: scalar user text finalizes previous scalar assistant block', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'assistant.text.delta', text: 'answer first' }], + { now: 2 }, + ); + + expect(state.activeAssistantBlockId).toBeDefined(); + + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'user.text.delta', text: 'new question' }], + { now: 3 }, + ); + + expect(state.activeAssistantBlockId).toBeUndefined(); + expect(state.activeUserBlockId).toBeDefined(); + expect( + state.blocks.find((block) => block.kind === 'assistant'), + ).toMatchObject({ streaming: false, updatedAt: 3 }); + }); + + it('T13: scoped clearActiveText sets streaming=false on cleared block', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'assistant.text.delta', + text: 'A streaming', + parentToolCallId: 'task-A', + }, + { + type: 'assistant.text.delta', + text: 'B streaming', + parentToolCallId: 'task-B', + }, + { + type: 'tool.update', + toolCallId: 'child-tool', + title: 'Bash', + status: 'running', + parentToolCallId: 'task-A', + } as DaemonUiEvent, + ], + { now: 2 }, + ); + + const aBlocks = state.blocks.filter( + (b) => + b.kind === 'assistant' && + (b as { parentToolCallId?: string }).parentToolCallId === 'task-A', + ) as Array<{ streaming?: boolean }>; + expect(aBlocks[0]!.streaming).toBe(false); + + const bBlocks = state.blocks.filter( + (b) => + b.kind === 'assistant' && + (b as { parentToolCallId?: string }).parentToolCallId === 'task-B', + ) as Array<{ streaming?: boolean }>; + expect(bBlocks[0]!.streaming).toBe(true); + }); + + it('T13b: scoped clearActiveText finalizes assistant and thought for the same parent', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'assistant.text.delta', + text: 'assistant streaming', + parentToolCallId: 'task-A', + }, + { + type: 'thought.text.delta', + text: 'thought streaming', + parentToolCallId: 'task-B', + }, + { + type: 'tool.update', + toolCallId: 'child-tool-A', + title: 'Bash', + status: 'running', + parentToolCallId: 'task-A', + } as DaemonUiEvent, + { + type: 'tool.update', + toolCallId: 'child-tool-B', + title: 'Bash', + status: 'running', + parentToolCallId: 'task-B', + } as DaemonUiEvent, + ], + { now: 2 }, + ); + + expect( + state.blocks.find( + (block) => + block.kind === 'assistant' && block.parentToolCallId === 'task-A', + ), + ).toMatchObject({ streaming: false }); + expect( + state.blocks.find( + (block) => + block.kind === 'thought' && block.parentToolCallId === 'task-B', + ), + ).toMatchObject({ streaming: false }); + expect(state.activeAssistantBlockByParent['task-A']).toBeUndefined(); + expect(state.activeThoughtBlockByParent['task-B']).toBeUndefined(); + }); + + it('T14: scoped clearActiveText preserves scalar activeAssistantBlockId', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { type: 'assistant.text.delta', text: 'scalar text' }, + { + type: 'assistant.text.delta', + text: 'keyed text', + parentToolCallId: 'task-K', + }, + { + type: 'tool.update', + toolCallId: 'child-tool', + title: 'Bash', + status: 'running', + parentToolCallId: 'task-K', + } as DaemonUiEvent, + ], + { now: 2 }, + ); + + const scalarBlock = state.blocks.find( + (b) => + b.kind === 'assistant' && + (b as { parentToolCallId?: string }).parentToolCallId === undefined, + ) as { streaming?: boolean } | undefined; + expect(scalarBlock?.streaming).toBe(true); + expect(state.activeAssistantBlockId).toBeDefined(); + + const keyedBlock = state.blocks.find( + (b) => + b.kind === 'assistant' && + (b as { parentToolCallId?: string }).parentToolCallId === 'task-K', + ) as { streaming?: boolean } | undefined; + expect(keyedBlock?.streaming).toBe(false); + expect(state.activeAssistantBlockByParent['task-K']).toBeUndefined(); + }); + + it('T15: finishAssistant finalizes both scalar and keyed blocks', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + [ + { type: 'assistant.text.delta', text: 'scalar streaming' }, + { + type: 'assistant.text.delta', + text: 'keyed streaming', + parentToolCallId: 'task-M', + }, + ], + { now: 2 }, + ); + + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'assistant.done', reason: 'stop' }], + { now: 3 }, + ); + + const allAssistant = state.blocks.filter( + (b) => b.kind === 'assistant', + ) as Array<{ streaming?: boolean }>; + expect(allAssistant).toHaveLength(2); + expect(allAssistant[0]!.streaming).toBe(false); + expect(allAssistant[1]!.streaming).toBe(false); + expect(state.activeAssistantBlockId).toBeUndefined(); + expect(state.activeAssistantBlockByParent).toEqual({}); + }); +}); diff --git a/packages/sdk-typescript/test/unit/serve-bridge.test.ts b/packages/sdk-typescript/test/unit/serve-bridge.test.ts new file mode 100644 index 0000000000..cd2991f02f --- /dev/null +++ b/packages/sdk-typescript/test/unit/serve-bridge.test.ts @@ -0,0 +1,684 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Unit tests for the qwen-serve-bridge MCP server. + * + * Tests cover: server creation, tool registration, handler routing, + * session state management, and error handling. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { createServeBridgeMcpServer } from '../../src/daemon-mcp/serve-bridge/createServeBridgeMcpServer.js'; +import { resolveSessionId, handler } from '../../src/daemon-mcp/serve-bridge/helpers.js'; +import type { + BridgeState, + SessionEventStream, +} from '../../src/daemon-mcp/serve-bridge/types.js'; +import { DaemonClient } from '../../src/daemon/DaemonClient.js'; + +// --- Helpers --- + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +interface CapturedRequest { + url: string; + method: string; + headers: Record; + body: string | null; +} + +function recordingFetch( + reply: (req: CapturedRequest) => Response | Promise, +): { fetch: typeof globalThis.fetch; calls: CapturedRequest[] } { + const calls: CapturedRequest[] = []; + const fetchImpl = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + const method = init?.method ?? 'GET'; + const headers: Record = {}; + if (init?.headers) { + const h = new Headers(init.headers); + h.forEach((v, k) => (headers[k.toLowerCase()] = v)); + } + const body = typeof init?.body === 'string' ? init.body : null; + const captured: CapturedRequest = { url, method, headers, body }; + calls.push(captured); + return reply(captured); + }, + ) as unknown as typeof globalThis.fetch; + return { fetch: fetchImpl, calls }; +} + +function makeMockState(opts?: { + token?: string; + defaultSessionId?: string; + fetchReply?: (req: CapturedRequest) => Response | Promise; +}): { state: BridgeState; calls: CapturedRequest[] } { + const token = opts?.token ?? 'test-token'; + const reply = opts?.fetchReply ?? (() => jsonResponse(200, { status: 'ok' })); + const { fetch, calls } = recordingFetch(reply); + + const state: BridgeState = { + client: new DaemonClient({ + baseUrl: 'http://127.0.0.1:4170', + token, + fetch, + }), + daemonUrl: 'http://127.0.0.1:4170', + token, + defaultSessionId: opts?.defaultSessionId, + workspaceCwd: '/tmp/test-workspace', + eventStreams: new Map(), + allowGlobalScope: false, + }; + + return { state, calls }; +} + +// --- Tests --- + +describe('serve-bridge', () => { + describe('createServeBridgeMcpServer', () => { + it('should create a server with name qwen-serve-bridge', () => { + recordingFetch(() => jsonResponse(200, {})); + const server = createServeBridgeMcpServer({ + daemonUrl: 'http://127.0.0.1:4170', + token: 'test', + }); + + expect(server).toBeDefined(); + expect(server.name).toBe('qwen-serve-bridge'); + expect(server.instance).toBeDefined(); + }); + + it('should strip trailing slashes from daemonUrl', () => { + const server = createServeBridgeMcpServer({ + daemonUrl: 'http://127.0.0.1:4170///', + token: 'test', + }); + expect(server).toBeDefined(); + }); + }); + + describe('resolveSessionId', () => { + it('should return explicit session_id when provided', () => { + const { state } = makeMockState({ defaultSessionId: 'default-123' }); + expect(resolveSessionId(state, 'explicit-456')).toBe('explicit-456'); + }); + + it('should return defaultSessionId when no explicit id', () => { + const { state } = makeMockState({ defaultSessionId: 'default-123' }); + expect(resolveSessionId(state)).toBe('default-123'); + }); + + it('should throw when no session available', () => { + const { state } = makeMockState({ defaultSessionId: undefined }); + expect(() => resolveSessionId(state)).toThrow( + 'No session active. Call session_create first', + ); + }); + }); + + describe('handler', () => { + it('should pass args through and return result', async () => { + const fn = vi + .fn() + .mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }); + const wrapped = handler(fn); + const result = await wrapped({ foo: 'bar' }, {}); + expect(fn).toHaveBeenCalledWith({ foo: 'bar' }); + expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }] }); + }); + + it('should catch errors and return isError response', async () => { + const fn = vi.fn().mockRejectedValue(new Error('something broke')); + const wrapped = handler(fn); + const result = await wrapped({}, {}); + expect(result).toEqual({ + content: [{ type: 'text', text: 'something broke' }], + isError: true, + }); + }); + + it('should handle non-Error throws', async () => { + const fn = vi.fn().mockRejectedValue('string error'); + const wrapped = handler(fn); + const result = await wrapped({}, {}); + expect(result).toEqual({ + content: [{ type: 'text', text: 'string error' }], + isError: true, + }); + }); + }); + + describe('tool handlers', () => { + describe('health', () => { + it('should call GET /health and return result', async () => { + const { state } = makeMockState({ + fetchReply: () => jsonResponse(200, { status: 'ok' }), + }); + + // Import tools dynamically to test with mock state + const { infrastructureTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/infrastructure.js' + ); + const tools = infrastructureTools(state); + const healthTool = tools.find( + (t: { name: string }) => t.name === 'health', + ); + + expect(healthTool).toBeDefined(); + expect(healthTool.name).toBe('health'); + expect(healthTool.description).toContain('daemon'); + }); + }); + + describe('session_create', () => { + it('should set defaultSessionId after successful creation', async () => { + const { state } = makeMockState({ + fetchReply: (req) => { + if (req.url.endsWith('/session') && req.method === 'POST') { + return jsonResponse(200, { + sessionId: 'new-session-id', + workspaceCwd: '/tmp', + attached: false, + }); + } + return jsonResponse(404, {}); + }, + }); + + const { sessionTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/session.js' + ); + const tools = sessionTools(state); + const createTool = tools.find( + (t: { name: string }) => t.name === 'session_create', + ); + expect(createTool).toBeDefined(); + + // Call the handler + const result = await createTool.handler({ workspace_cwd: '/tmp' }, {}); + expect(result.content[0].text).toContain('new-session-id'); + expect(state.defaultSessionId).toBe('new-session-id'); + }); + }); + + describe('session_close', () => { + it('should clear defaultSessionId when closing the default session', async () => { + const { state } = makeMockState({ + defaultSessionId: 'sess-to-close', + fetchReply: () => new Response(null, { status: 204 }), + }); + + const { sessionTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/session.js' + ); + const tools = sessionTools(state); + const closeTool = tools.find( + (t: { name: string }) => t.name === 'session_close', + ); + + await closeTool.handler({ session_id: 'sess-to-close' }, {}); + expect(state.defaultSessionId).toBeUndefined(); + }); + + it('should not clear defaultSessionId when closing a different session', async () => { + const { state } = makeMockState({ + defaultSessionId: 'keep-this', + fetchReply: () => new Response(null, { status: 204 }), + }); + + const { sessionTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/session.js' + ); + const tools = sessionTools(state); + const closeTool = tools.find( + (t: { name: string }) => t.name === 'session_close', + ); + + await closeTool.handler({ session_id: 'other-session' }, {}); + expect(state.defaultSessionId).toBe('keep-this'); + }); + }); + + describe('workspace read tools', () => { + it('should register all 10 read tools', async () => { + const { state } = makeMockState(); + const { workspaceReadTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceRead.js' + ); + const tools = workspaceReadTools(state); + expect(tools).toHaveLength(10); + + const names = tools.map((t: { name: string }) => t.name); + expect(names).toContain('file_read'); + expect(names).toContain('file_read_bytes'); + expect(names).toContain('file_stat'); + expect(names).toContain('dir_list'); + expect(names).toContain('glob'); + expect(names).toContain('workspace_mcp_status'); + expect(names).toContain('workspace_skills'); + expect(names).toContain('workspace_providers'); + expect(names).toContain('workspace_env'); + expect(names).toContain('workspace_preflight'); + }); + }); + + describe('workspace write tools', () => { + it('should register all 9 write tools', async () => { + const { state } = makeMockState(); + const { workspaceWriteTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js' + ); + const tools = workspaceWriteTools(state); + expect(tools).toHaveLength(9); + + const names = tools.map((t: { name: string }) => t.name); + expect(names).toContain('file_write'); + expect(names).toContain('file_edit'); + expect(names).toContain('workspace_init'); + expect(names).toContain('workspace_memory_read'); + expect(names).toContain('workspace_memory_write'); + expect(names).toContain('workspace_agents_manage'); + }); + }); + + describe('agent tools', () => { + it('should register all 2 agent tools', async () => { + const { state } = makeMockState(); + const { agentTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/agent.js' + ); + const tools = agentTools(state); + expect(tools).toHaveLength(2); + + const names = tools.map((t: { name: string }) => t.name); + expect(names).toContain('prompt'); + expect(names).toContain('prompt_cancel'); + }); + }); + + describe('allTools', () => { + it('should aggregate to exactly 31 tools', async () => { + const { state } = makeMockState(); + const { allTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/index.js' + ); + const tools = allTools(state); + expect(tools).toHaveLength(31); + + // Verify no duplicate names + const names = tools.map((t: { name: string }) => t.name); + const uniqueNames = new Set(names); + expect(uniqueNames.size).toBe(31); + }); + }); + }); + + describe('prompt tool with persistent SSE', () => { + it('should collect response text via the persistent event stream', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + fetchReply: (req) => { + if (req.url.includes('/prompt')) { + // Simulate: prompt returns stopReason, but before that the + // persistent SSE stream will have populated the collector. + // We simulate this by filling the collector just before the + // prompt response resolves. + const stream = state.eventStreams.get('test-session')!; + const collector = stream.activeCollector!; + collector.texts.push('hello'); + collector.texts.push(' world'); + collector.resolve(); + return jsonResponse(200, { stopReason: 'end_turn' }); + } + return jsonResponse(404, {}); + }, + }); + + // Set up a fake persistent event stream (normally created by session_create) + const fakeStream: SessionEventStream = { + sessionId: 'test-session', + abortCtrl: new AbortController(), + activeCollector: null, + lastActivityMs: Date.now(), + }; + state.eventStreams.set('test-session', fakeStream); + + const { agentTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/agent.js' + ); + const tools = agentTools(state); + const promptTool = tools.find( + (t: { name: string }) => t.name === 'prompt', + ); + + const result = await promptTool.handler({ prompt: 'test' }, {}); + const parsed = JSON.parse(result.content[0].text); + + expect(parsed.stop_reason).toBe('end_turn'); + expect(parsed.session_id).toBe('test-session'); + expect(parsed.response).toBe('hello world'); + // Collector should be cleared after prompt completes + expect(fakeStream.activeCollector).toBeNull(); + }); + + it('should throw if no SSE stream exists for the session', async () => { + const { state } = makeMockState({ + defaultSessionId: 'no-stream-session', + }); + + const { agentTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/agent.js' + ); + const tools = agentTools(state); + const promptTool = tools.find( + (t: { name: string }) => t.name === 'prompt', + ); + + const result = await promptTool.handler({ prompt: 'test' }, {}); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('No SSE stream'); + }); + + it('should reject concurrent prompts on the same session', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + fetchReply: () => jsonResponse(200, { stopReason: 'end' }), + }); + + const { createPromptCollector } = await import( + '../../src/daemon-mcp/serve-bridge/sse.js' + ); + const fakeStream: SessionEventStream = { + sessionId: 'test-session', + abortCtrl: new AbortController(), + activeCollector: createPromptCollector(), // already has an active collector + lastActivityMs: Date.now(), + }; + state.eventStreams.set('test-session', fakeStream); + + const { agentTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/agent.js' + ); + const tools = agentTools(state); + const promptTool = tools.find( + (t: { name: string }) => t.name === 'prompt', + ); + + const result = await promptTool.handler({ prompt: 'test' }, {}); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('already in progress'); + }); + + it('prompt_cancel should resolve active collector', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + fetchReply: () => jsonResponse(200, {}), + }); + + const { createPromptCollector } = await import( + '../../src/daemon-mcp/serve-bridge/sse.js' + ); + const collector = createPromptCollector(); + const fakeStream: SessionEventStream = { + sessionId: 'test-session', + abortCtrl: new AbortController(), + activeCollector: collector, + lastActivityMs: Date.now(), + }; + state.eventStreams.set('test-session', fakeStream); + + const { agentTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/agent.js' + ); + const tools = agentTools(state); + const cancelTool = tools.find( + (t: { name: string }) => t.name === 'prompt_cancel', + ); + + await cancelTool.handler({}, {}); + expect(collector.resolved).toBe(true); + expect(collector.interrupted).toBe(true); + }); + }); + + describe('safety guards', () => { + it('should reject global scope in workspace_memory_write', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + }); + state.allowGlobalScope = false; + + const { workspaceWriteTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js' + ); + const tools = workspaceWriteTools(state); + const memWriteTool = tools.find( + (t: { name: string }) => t.name === 'workspace_memory_write', + ); + + const result = await memWriteTool.handler( + { scope: 'global', content: 'test', mode: 'append' }, + {}, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Global scope is disabled'); + }); + + it('should reject global scope in workspace_agents_manage', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + }); + state.allowGlobalScope = false; + + const { workspaceWriteTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js' + ); + const tools = workspaceWriteTools(state); + const agentsTool = tools.find( + (t: { name: string }) => t.name === 'workspace_agents_manage', + ); + + const result = await agentsTool.handler( + { action: 'create', scope: 'global', name: 'x', description: 'x', system_prompt: 'x' }, + {}, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Global scope is disabled'); + }); + + it('should reject yolo approval mode without allowGlobalScope', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + }); + state.allowGlobalScope = false; + + const { workspaceWriteTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js' + ); + const tools = workspaceWriteTools(state); + const approvalTool = tools.find( + (t: { name: string }) => t.name === 'session_set_approval_mode', + ); + + const result = await approvalTool.handler( + { mode: 'yolo', session_id: 'test-session' }, + {}, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('restricted for security'); + }); + + it('should reject auto-edit approval mode without allowGlobalScope', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + }); + state.allowGlobalScope = false; + + const { workspaceWriteTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js' + ); + const tools = workspaceWriteTools(state); + const approvalTool = tools.find( + (t: { name: string }) => t.name === 'session_set_approval_mode', + ); + + const result = await approvalTool.handler( + { mode: 'auto-edit', session_id: 'test-session' }, + {}, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('restricted for security'); + }); + + it('should reject persistent approval mode change without allowGlobalScope', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + }); + state.allowGlobalScope = false; + + const { workspaceWriteTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js' + ); + const tools = workspaceWriteTools(state); + const approvalTool = tools.find( + (t: { name: string }) => t.name === 'session_set_approval_mode', + ); + + const result = await approvalTool.handler( + { mode: 'default', persist: true, session_id: 'test-session' }, + {}, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('restricted for security'); + }); + + it('should allow read-only agents_manage actions with global scope', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + fetchReply: () => jsonResponse(200, []), + }); + state.allowGlobalScope = false; + + const { workspaceWriteTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js' + ); + const tools = workspaceWriteTools(state); + const agentsTool = tools.find( + (t: { name: string }) => t.name === 'workspace_agents_manage', + ); + + // list with scope=global should NOT be blocked (read-only) + const result = await agentsTool.handler( + { action: 'list', scope: 'global' }, + {}, + ); + expect(result.isError).toBeUndefined(); + }); + + it('should reject file_write replace mode without expected_hash', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + }); + + const { workspaceWriteTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js' + ); + const tools = workspaceWriteTools(state); + const writeFileTool = tools.find( + (t: { name: string }) => t.name === 'file_write', + ); + + const result = await writeFileTool.handler( + { path: 'test.txt', content: 'hello', mode: 'replace' }, + {}, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('expected_hash is required'); + }); + + it('should reject workspace_tool_toggle without allowGlobalScope', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + }); + state.allowGlobalScope = false; + + const { workspaceWriteTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js' + ); + const tools = workspaceWriteTools(state); + const toggleTool = tools.find( + (t: { name: string }) => t.name === 'workspace_tool_toggle', + ); + + const result = await toggleTool.handler( + { tool_name: 'file_read', enabled: false }, + {}, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('restricted for security'); + }); + + it('should allow workspace_tool_toggle with allowGlobalScope', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + fetchReply: () => jsonResponse(200, { ok: true }), + }); + state.allowGlobalScope = true; + + const { workspaceWriteTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js' + ); + const tools = workspaceWriteTools(state); + const toggleTool = tools.find( + (t: { name: string }) => t.name === 'workspace_tool_toggle', + ); + + const result = await toggleTool.handler( + { tool_name: 'file_read', enabled: false }, + {}, + ); + expect(result.isError).toBeUndefined(); + }); + + it('should reject agents_manage update with no fields', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + }); + state.allowGlobalScope = true; + + const { workspaceWriteTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js' + ); + const tools = workspaceWriteTools(state); + const agentsTool = tools.find( + (t: { name: string }) => t.name === 'workspace_agents_manage', + ); + + const result = await agentsTool.handler( + { action: 'update', agent_type: 'test-agent' }, + {}, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain( + 'At least one field to update must be provided', + ); + }); + }); +}); diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 85548adccf..95912ec04b 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -916,6 +916,27 @@ } } }, + "policy": { + "description": "Daemon multi-client coordination policies. Tool-level allow/deny rules live under `permissions`; this section is for runtime mediation behavior between concurrent HTTP clients sharing one `qwen serve` daemon.", + "type": "object", + "properties": { + "permissionStrategy": { + "description": "How permission requests resolve when multiple clients are attached. `first-responder` (default) = any client decides, first wins. `designated` = only the prompt originator decides; falls back to first-responder if originator is anonymous. NOTE: client identity comes from self-declared X-Qwen-Client-Id with no proof-of-possession (pair-token identity is not implemented yet), so any client observing originatorClientId on SSE frames can register with the same id and impersonate the originator. `consensus` = N-of-M voters must agree. Default N=floor(M/2)+1, which means UNANIMITY for M=2 (quorum=2, both must agree) and supermajority for larger even M (M=4 → quorum=3; M=6 → quorum=4). For M=2 specifically, split votes resolve only via permissionTimeoutMs. `local-only` = only loopback clients can RESOLVE; remote clients can still ABORT a pending permission via the cancel sentinel ({outcome:\"cancelled\"}) — cancel stays cross-policy for consistency. Strict-cancel-too deployments need a dedicated loopback-bound daemon. Requires daemon restart — read once at boot. Options: first-responder, designated, consensus, local-only", + "enum": [ + "first-responder", + "designated", + "consensus", + "local-only" + ], + "default": "first-responder" + }, + "consensusQuorum": { + "type": "integer", + "minimum": 1, + "description": "Optional fixed quorum size for consensus policy. Capped at M (count of registered voters at request issue time) to prevent unreachable quorum. Unset = floor(M/2)+1. Requires daemon restart — read once at boot." + } + } + }, "mcp": { "description": "Settings for Model Context Protocol (MCP) servers.", "type": "object", diff --git a/packages/web-shell/README.md b/packages/web-shell/README.md new file mode 100644 index 0000000000..deb7db0830 --- /dev/null +++ b/packages/web-shell/README.md @@ -0,0 +1,160 @@ +# @qwen-code/web-shell + +Qwen Code Web Shell 是面向浏览器的 daemon 会话终端 UI,可以作为 React +组件嵌入到其他项目中。 + +## 环境要求 + +- React:`^18.0.0 || ^19.0.0` +- React DOM:`^18.0.0 || ^19.0.0` +- `@qwen-code/webui`:`>=0.0.1` +- `@qwen-code/sdk`:`>=0.1.8` +- 浏览器环境需要能访问 Qwen Code daemon serve 的 HTTP 接口。 + +组件包会自动注入自身样式,样式已通过 CSS Modules 和组件作用域隔离; +接入方不需要额外引入全局 CSS。 + +## 安装 + +```bash +npm install @qwen-code/web-shell +``` + +Peer dependencies 需要同时安装: + +```bash +npm install react react-dom @qwen-code/webui @qwen-code/sdk +``` + +## 接入方式 + +WebShell 提供两种接入形态: + +### 1. 独立接入(自带 Provider) + +适合只需要嵌入一个终端视图的场景。组件内部自建 +`DaemonWorkspaceProvider` + `DaemonSessionProvider`。 + +```tsx +import { WebShellWithProviders } from '@qwen-code/web-shell'; + +export function QwenCodePanel() { + return ( + { + console.log('current session:', sessionId); + }} + theme="dark" + language="zh-CN" + /> + ); +} +``` + +### 2. 共享 Provider 接入(纯消费者) + +适合同一个 React 应用中多个视图共享同一个 daemon session 的场景(如 +chat + terminal)。宿主自行提供 Provider,WebShell 只消费 hooks。 + +```tsx +import { + DaemonWorkspaceProvider, + DaemonSessionProvider, +} from '@qwen-code/webui/daemon-react-sdk'; +import { WebShell } from '@qwen-code/web-shell'; + +export function App() { + return ( + + + + + + + ); +} +``` + +> **注意**:不要在已有 `DaemonSessionProvider` 下使用 +> `WebShellWithProviders`,否则会创建嵌套的重复 Provider。 + +## Props + +### WebShellWithProviders + +包含 `WebShell` 的所有 Props,加上 Provider 配置: + +| 属性 | 类型 | 说明 | +| ------------------ | -------- | ---------------------------------------------------- | +| `baseUrl` | `string` | daemon API 地址,未传时使用 `window.location.origin` | +| `token` | `string` | daemon API Bearer token | +| `initialSessionId` | `string` | 初始要连接的 session id | + +### WebShell + +| 属性 | 类型 | 说明 | +| ------------------- | -------------------------------------- | --------------------------------- | +| `onSessionIdChange` | `(sessionId: string) => void` | 当前 session id 变化时触发 | +| `theme` | `'dark' \| 'light'` | UI 主题,默认 `dark` | +| `onThemeChange` | `(theme: WebShellTheme) => void` | `/theme` 命令切换主题后触发 | +| `language` | `'en' \| 'zh-CN' \| 'zh' \| 'zh-cn'` | UI 语言 | +| `onLanguageChange` | `(language: WebShellLanguage) => void` | `/language ui` 切换 UI 语言后触发 | + +## 架构说明 + +```text +@qwen-code/sdk/daemon ← 协议层(SSE, REST, normalizer) +@qwen-code/webui/daemon-react-sdk ← React adapter(Provider, hooks, store) +@qwen-code/web-shell ← 终端 UI 组件 +``` + +- `WebShell` 必须在 `DaemonWorkspaceProvider` 和 `DaemonSessionProvider` 之下使用。 +- `WebShellWithProviders` 是内置 Provider 的便捷 wrapper。 +- 同一个 React 树共享一个 `DaemonSessionProvider` 时只开一条 SSE。 + +## 已支持的斜杠命令 + +下面列出当前 web-shell 已支持的命令。支持方式分为两类: + +- **本地实现**:web-shell 前端直接打开弹窗、调用 daemon REST API,或切换本地状态。 +- **ACP 透传**:web-shell 将命令发送给 daemon,由 daemon/ACP 执行。 + +| 命令 | 支持方式 | 说明 | +| ---------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `/help` | 本地实现 | 打开帮助弹窗,支持键盘浏览命令和快捷键。 | +| `/theme` | 本地实现 | 打开主题选择弹窗;支持 `/theme light`、`/theme dark`。 | +| `/language` | 本地实现 + ACP 透传 | `/language ui ` 会切换 web-shell UI 语言并同步给 daemon;其他语言能力由 daemon 执行。包含 `ui`、`output` 子命令。 | +| `/model` | 本地实现 + 部分透传 | 无参数打开模型弹窗;普通参数直接切换模型;`/model --fast ` 透传给 daemon。 | +| `/plan` | 本地实现 | 切换到 `plan` approval mode,并可继续发送后续 prompt。 | +| `/approval-mode` | 本地实现 | 打开审批模式弹窗或直接切换审批模式。 | +| `/mode` | 本地实现 | web-shell 本地别名,用于切换审批模式。 | +| `/mcp` | 本地实现 | 打开 MCP 管理弹窗。 | +| `/skills` | 本地实现 + ACP 透传 | 无参数打开 skills 弹窗;带参数时透传给 daemon 执行。 | +| `/tools` | 本地实现 | 打开 tools 弹窗,列表展示工具名称、启用状态和 `description`。 | +| `/memory` | 本地实现 | 打开 memory 弹窗,支持 `show`、`refresh`、`add user`、`add project` 等分支。 | +| `/agents` | 本地实现 | 打开 agents 弹窗,支持 `manage`、`create user`、`create project` 等分支。 | +| `/copy` | 本地实现 | 复制最后一条 assistant 输出;支持 `code`、语言名、LaTeX、inline LaTeX 等选择器。 | +| `/release` | 本地实现 | 释放 live session 连接,不删除历史会话记录。 | +| `/clear` | 本地实现 | 清空当前 web-shell transcript store。 | +| `/new` | 本地实现 | 创建新的 daemon session。 | +| `/reset` | 本地实现 | 与 `/new` 一样创建新的 daemon session。 | +| `/rename ` | 本地实现 | 修改当前 daemon session 的展示名称。 | +| `/resume` | 本地实现 | 无参数打开恢复会话弹窗;带 session id 时直接加载。 | +| `/status` | ACP 透传 | daemon 支持,包含 `paths` 子命令。 | +| `/auth` | ACP 透传 | 连接 LLM provider。 | +| `/bug` | ACP 透传 | 提交错误报告。 | +| `/compress` | ACP 透传 | 通过摘要替换来压缩上下文。 | +| `/context` | ACP 透传 | 显示上下文窗口使用情况,包含 `detail` 子命令。 | +| `/diff` | ACP 透传 | 显示工作区相对 `HEAD` 的变更统计。 | +| `/docs` | ACP 透传 | 打开 Qwen Code 文档。 | +| `/doctor` | ACP 透传 | 执行安装与环境诊断,包含 `memory` 子命令。 | +| `/export` | ACP 透传 | 导出当前会话记录,包含 `html`、`md`、`json`、`jsonl` 子命令。 | +| `/goal` | ACP 透传 | 设置目标,并持续工作直到条件满足。 | +| `/init` | ACP 透传 | 分析项目并创建定制的 `QWEN.md`。 | +| `/stats` | ACP 透传 | 显示统计信息,包含 `model`、`tools` 子命令。 | +| `/summary` | ACP 透传 | 生成当前会话摘要。 | +| `/tasks` | ACP 透传 | 列出后台任务。 | +| `/insight` | ACP 透传 | 查看 insight 相关信息。 | diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css new file mode 100644 index 0000000000..a8d40c6b47 --- /dev/null +++ b/packages/web-shell/client/App.module.css @@ -0,0 +1,174 @@ +.app { + --font-mono: + 'SF Mono', 'Fira Code', 'JetBrains Mono', 'Cascadia Code', monospace; + --font-sans: + -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + --radius: 6px; + position: relative; + display: flex; + flex-direction: column; + height: 100%; + background: var(--app-bg); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 14px; + line-height: 1.5; + overflow: hidden; +} + +.app, +.app * { + box-sizing: border-box; +} + +.themeDark { + --app-bg: #111; + --bg-primary: #0d0d0d; + --bg-secondary: #161616; + --bg-tertiary: #1e1e1e; + --bg-surface: #1a1a1a; + --border-color: #2a2a2a; + --text-primary: #e4e4e4; + --text-secondary: #a0a0a0; + --text-dimmed: #666; + --accent-color: #4a9eff; + --accent-hover: #3a8eef; + --success-color: #48bb78; + --warning-color: #ecc94b; + --error-color: #fc8181; + --user-color: #48bb78; + --assist-color: #b794f4; + --welcome-logo-gradient: linear-gradient( + 90deg, + #4796e4 0%, + #847ace 52%, + #c3677f 100% + ); + --editor-bg: #0a0a0a; + --msg-body-color: #e3bcfe; + --thinking-pre-color: #9d9ec6; + --subtle-bg: rgba(255, 255, 255, 0.04); + --subtle-bg-strong: rgba(255, 255, 255, 0.06); + --info-bg: rgba(74, 158, 255, 0.08); + --info-border: rgba(74, 158, 255, 0.2); + --success-bg: rgba(72, 187, 120, 0.12); + --warning-bg: rgba(236, 201, 75, 0.08); + --warning-border: rgba(236, 201, 75, 0.2); + --error-bg: rgba(252, 129, 129, 0.08); + --error-border: rgba(252, 129, 129, 0.2); +} + +.themeLight { + --app-bg: #fff; + --bg-primary: #ffffff; + --bg-secondary: #f7f7f2; + --bg-tertiary: #efeee8; + --bg-surface: #f5f4ed; + --border-color: #d8d6cc; + --text-primary: #22231f; + --text-secondary: #5f6259; + --text-dimmed: #8b8d84; + --accent-color: #0b66c3; + --accent-hover: #0756a6; + --success-color: #1f7a3a; + --warning-color: #9a6a00; + --error-color: #c0362c; + --user-color: #0a7a3d; + --assist-color: #7b4ab8; + --welcome-logo-gradient: linear-gradient( + 90deg, + #0b66c3 0%, + #7357b8 52%, + #bd4f67 100% + ); + --editor-bg: #f7f7f2; + --msg-body-color: #0b6f3a; + --thinking-pre-color: #74788f; + --subtle-bg: rgba(32, 31, 25, 0.05); + --subtle-bg-strong: rgba(32, 31, 25, 0.08); + --info-bg: rgba(11, 102, 195, 0.08); + --info-border: rgba(11, 102, 195, 0.22); + --success-bg: rgba(31, 122, 58, 0.11); + --warning-bg: rgba(154, 106, 0, 0.1); + --warning-border: rgba(154, 106, 0, 0.24); + --error-bg: rgba(192, 54, 44, 0.1); + --error-border: rgba(192, 54, 44, 0.24); +} + +.dialogOverlay { + position: absolute; + inset: 0; + z-index: 10; + display: flex; + flex-direction: column; + background: var(--app-bg); +} + +.content { + flex: 0 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.contentHasMessages { + flex: 1 1 auto; +} + +.footer { + margin: 0 10px; + flex-shrink: 0; +} + +.footerHidden { + display: none; +} + +.bottomPanels { + display: flex; + flex-direction: column; + gap: 4px; + padding-bottom: 4px; +} + +.tasksBottomPanel { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 6px; + padding-bottom: 6px; +} + +.btwPanel { + flex-shrink: 0; + margin: 0 24px; + padding: 4px 0; +} + +.composer { + flex-shrink: 0; + padding: 0; +} + +.queuedPrompts { + display: flex; + flex-direction: column; + gap: 2px; + padding: 0 12px 6px 36px; + color: var(--text-dimmed); + font-size: 12px; + line-height: 1.4; +} + +.queuedPrompt { + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.queuedHint { + font-style: italic; +} diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx new file mode 100644 index 0000000000..f179530ce4 --- /dev/null +++ b/packages/web-shell/client/App.tsx @@ -0,0 +1,2605 @@ +import { + createContext, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + useActions, + useConnection, + useDaemonFollowupSuggestion, + useSessionNotices, + useStreamingState, + useTranscriptBlocks, + useTranscriptStore, + useWorkspaceActions, + type DaemonSessionNotice, + type DaemonStreamingState, +} from '@qwen-code/webui/daemon-react-sdk'; +import { isDaemonTurnError } from '@qwen-code/sdk/daemon'; +import { extractPendingPermission } from './adapters/transcriptAdapter'; +import { MessageList } from './components/MessageList'; +import { Editor, type EditorHandle } from './components/Editor'; +import type { PromptImage } from './adapters/promptTypes'; +import { StatusBar, type StatusBarHandle } from './components/StatusBar'; +import { ShortcutsPanel } from './components/ShortcutsPanel'; +import { StreamingStatus } from './components/StreamingStatus'; +import { + ToastHost, + type ToastTone, + type WebShellToast, +} from './components/ToastHost'; +import { TodoPanel } from './components/panels/TodoPanel'; +import { ActiveAgentsPanel } from './components/panels/ActiveAgentsPanel'; +import { WelcomeHeader } from './components/WelcomeHeader'; +import { + APPROVAL_MODE_ACTIVE_EVENT, + ApprovalModeMessage, +} from './components/messages/ApprovalModeMessage'; +import { ResumeDialog } from './components/dialogs/ResumeDialog'; +import { + AGENTS_ACTIVE_EVENT, + AgentsMessage, + type AgentsInitialMode, +} from './components/messages/AgentsMessage'; +import { + MEMORY_ACTIVE_EVENT, + MemoryMessage, +} from './components/messages/MemoryMessage'; +import { + MODEL_ACTIVE_EVENT, + ModelMessage, + type ModelInlineMode, +} from './components/messages/ModelMessage'; +import { + AUTH_ACTIVE_EVENT, + AuthMessage, +} from './components/messages/AuthMessage'; +import { ToolsDialog } from './components/dialogs/ToolsDialog'; +import { + SETTINGS_ACTIVE_EVENT, + SettingsMessage, +} from './components/messages/SettingsMessage'; +import { HelpDialog } from './components/dialogs/HelpDialog'; +import { + ThemeDialog, + type WebShellTheme, +} from './components/dialogs/ThemeDialog'; +import { DeleteSessionDialog } from './components/dialogs/DeleteSessionDialog'; +import { ReleaseSessionDialog } from './components/dialogs/ReleaseSessionDialog'; +import { getLocalCommands } from './constants/localCommands'; +import { mergeCommands } from './hooks/daemonSessionMappers'; +import { useAnimationFrameValue } from './hooks/useAnimationFrameValue'; +import { useMessages } from './hooks/useMessages'; +import { usePanelActive } from './hooks/usePanelActive'; +import { useShallowMemo, useStableArray } from './hooks/useShallowMemo'; +import { + I18nProvider, + getTranslator, + languageLabel, + normalizeLanguage, + type WebShellLanguage, +} from './i18n'; +import { + copyFromLastAssistantMessage, + COPY_MESSAGES, +} from './utils/copyCommand'; +import type { SkillInfo } from './completions/slashCompletion'; +import { collectSystemInfo } from './utils/systemInfo'; +import { + TasksStatusMessage, + type SerializedTasksMessage, +} from './components/messages/TasksStatusMessage'; +import { handleTasksSlashCommand } from './utils/tasksCommand'; +import { + isBackgroundSubAgentToolCall, + isSubAgentToolCall, +} from './adapters/toolClassification'; +import { + DAEMON_APPROVAL_MODES, + type DaemonApprovalMode, +} from '@qwen-code/webui/daemon-react-sdk'; +import { serializeContextUsageMessage } from './components/messages/ContextUsageMessage'; +import { + serializeStatsMessage, + type StatsView, +} from './components/messages/StatsMessage'; +import { + serializeStatusMessage, + type StatusInfo, +} from './components/messages/StatusMessage'; +import { + MCP_STATUS_ACTIVE_EVENT, + parseMcpStatusMessage, + serializeMcpStatusMessage, +} from './components/messages/McpStatusMessage'; +import { + GOAL_STATUS_ACTIVE_EVENT, + serializeGoalStatusMessage, +} from './components/messages/GoalStatusMessage'; +import { TASKS_STATUS_ACTIVE_EVENT } from './components/messages/TasksStatusMessage'; +import { BtwMessage } from './components/messages/BtwMessage'; +import type { + ACPToolCall, + Message, + PermissionRequest, + TodoItem, +} from './adapters/types'; +import { extractTodosFromToolCall, hasActiveTodos } from './utils/todos'; +import { ThemeProvider } from './themeContext'; +import { + WebShellCustomizationProvider, + type WebShellMarkdownCustomization, + type ToolHeaderExtraRenderer, + type WelcomeHeaderRenderer, +} from './customization'; +import type { CommandDisplayCategoryOrder } from './utils/commandDisplay'; +import styles from './App.module.css'; + +export const CompactModeContext = createContext(false); + +const MODES_CYCLE = DAEMON_APPROVAL_MODES; +const MAX_DISPLAYED_QUEUED_PROMPTS = 3; +const MAX_QUEUED_PROMPT_PREVIEW_CHARS = 240; +const MAX_TOASTS = 4; +const COMPACT_MODE_STORAGE_KEY = 'web-shell:compact-mode'; + +function loadCompactMode(): boolean { + try { + return window.localStorage.getItem(COMPACT_MODE_STORAGE_KEY) === 'true'; + } catch { + return false; + } +} + +function saveCompactMode(enabled: boolean) { + try { + window.localStorage.setItem( + COMPACT_MODE_STORAGE_KEY, + enabled ? 'true' : 'false', + ); + } catch { + // Ignore storage failures in private browsing or restricted contexts. + } +} + +function normalizeHiddenCommand(command: string): string { + return command.trim().replace(/^\/+/, '').toLowerCase(); +} + +// Keep in sync with CLEAR_KEYWORDS in packages/cli/src/ui/commands/goalCommand.ts +const GOAL_CLEAR_KEYWORDS = new Set([ + 'clear', + 'stop', + 'off', + 'reset', + 'none', + 'cancel', +]); + +function isGoalClearCommand(text: string): boolean { + const goalArg = text + .replace(/^\/goal\b/i, '') + .trim() + .toLowerCase(); + return GOAL_CLEAR_KEYWORDS.has(goalArg); +} + +interface QueuedPrompt { + id: number; + text: string; + images?: PromptImage[]; +} + +interface ActiveGoalStatus { + condition: string; + setAt: number; +} + +interface LocalAnchoredMessage { + anchorAfterId?: string; + anchorIndex: number; + message: Message; +} + +interface ModelSwitchSummary { + authType: string; + modelId: string; + baseUrl: string; + apiKey: string; + isRuntime?: boolean; +} + +export interface BugReportInfo { + title: string; + systemInfo: Record; +} + +export interface WebShellProps { + /** Called whenever the attached daemon session id changes. */ + onSessionIdChange?: (sessionId: string) => void; + /** Visual theme for the embedded shell. Defaults to the dark terminal skin. */ + theme?: 'dark' | 'light'; + /** Called when `/theme` changes the web-shell theme. */ + onThemeChange?: (theme: WebShellTheme) => void; + /** UI language for the Web terminal. Defaults to `?language=` or browser language. */ + language?: 'en' | 'zh-CN' | 'zh' | 'zh-cn'; + /** Called when `/language ui` changes the web-shell UI language. */ + onLanguageChange?: (language: WebShellLanguage) => void; + /** Additional CSS class name appended to the root element. */ + className?: string; + /** Inline styles applied to the root element. */ + style?: React.CSSProperties; + /** Called when connection status changes (idle/connecting/connected/disconnected/error). */ + onConnectionChange?: (status: string) => void; + /** Called when prompt status changes (idle/waiting/responding). */ + onStreamingStateChange?: (state: DaemonStreamingState) => void; + /** Called when a critical error occurs (auth failure, session gone, etc). */ + onError?: (error: Error) => void; + /** Called when `/bug` is invoked. Receives system info. If omitted, web-shell opens the report URL itself. */ + onBugReport?: (info: BugReportInfo) => void; + /** Slash command names to hide from completion/help, for example `['approval-mode']`. */ + hiddenSlashCommands?: string[]; + /** Slash command category order. Defaults to custom, skill, system. */ + slashCommandCategoryOrder?: CommandDisplayCategoryOrder; + /** Custom renderer for the tool-card header content after the status icon and tool name. */ + renderToolHeaderExtra?: ToolHeaderExtraRenderer; + /** Custom renderer for the welcome header. Receives version, cwd, model, and mode. */ + renderWelcomeHeader?: WelcomeHeaderRenderer; + /** Collapse thinking blocks to 5 lines with a click-to-expand toggle. */ + compactThinking?: boolean; + /** Enable virtual scrolling only when rendered transcript rows exceed this threshold. Defaults to 200. */ + virtualScrollThreshold?: number; + /** Custom Markdown behavior for assistant content only. */ + markdown?: WebShellMarkdownCustomization; + /** When provided, all toast notifications are forwarded to this callback and the built-in ToastHost is hidden. */ + onToast?: (tone: ToastTone, message: string) => void; +} + +function replaceSessionUrl(sessionId: string): void { + if (typeof window === 'undefined') return; + const url = new URL(window.location.href); + url.pathname = `/session/${encodeURIComponent(sessionId)}`; + if (!import.meta.env.DEV) { + url.searchParams.delete('token'); + url.searchParams.delete('daemon'); + } + window.history.replaceState(null, '', url); +} + +function getInitialLanguage(): WebShellLanguage { + if (typeof window === 'undefined') return 'en'; + const params = new URLSearchParams(window.location.search); + return normalizeLanguage( + params.get('language') ?? params.get('lang') ?? navigator.language, + ); +} + +function formatError(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} + +function isAbortError(error: unknown): boolean { + return ( + (error instanceof DOMException && error.name === 'AbortError') || + (error instanceof Error && error.name === 'AbortError') + ); +} + +interface AlreadyDispatchedError extends Error { + _alreadyDispatched: true; +} + +function isAlreadyDispatched(error: unknown): error is AlreadyDispatchedError { + return ( + typeof error === 'object' && + error !== null && + (error as AlreadyDispatchedError)._alreadyDispatched === true + ); +} + +function logSessionNoticesHook(notices: readonly DaemonSessionNotice[]): void { + if (notices.length > 0) { + console.info('[web-shell] useSessionNotices()', { notices }); + } +} + +function shouldToastNotice(notice: DaemonSessionNotice): boolean { + return ( + notice.category === 'validation' || + notice.category === 'user_action' || + notice.category === 'system' + ); +} + +function toastToneFromNotice(notice: DaemonSessionNotice): ToastTone { + if (notice.severity === 'warning') return 'warning'; + if (notice.severity === 'info') return 'info'; + return 'error'; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function formatModelAuthType(authType: string): string { + const normalized = authType.trim(); + if (normalized.startsWith('USE_')) { + return normalized.slice(4).toLowerCase().replace(/_/g, '-'); + } + return normalized.toLowerCase(); +} + +function getModelSwitchSummary(result: unknown): ModelSwitchSummary | null { + if (!isRecord(result)) return null; + const meta = result._meta; + if (!isRecord(meta)) return null; + const summary = meta.qwenModelSwitch; + if (!isRecord(summary)) return null; + const authType = summary.authType; + const modelId = summary.modelId; + const baseUrl = summary.baseUrl; + const apiKey = summary.apiKey; + if ( + typeof authType !== 'string' || + typeof modelId !== 'string' || + typeof baseUrl !== 'string' || + typeof apiKey !== 'string' + ) { + return null; + } + return { + authType, + modelId, + baseUrl, + apiKey, + ...(typeof summary.isRuntime === 'boolean' + ? { isRuntime: summary.isRuntime } + : {}), + }; +} + +function serializeModelSwitchSummary(summary: ModelSwitchSummary): string { + return ( + `● authType: ${formatModelAuthType(summary.authType)}` + + `\n Using ${summary.isRuntime ? 'runtime ' : ''}model: ${summary.modelId}` + + `\n Base URL: ${summary.baseUrl}` + + `\n API key: ${summary.apiKey}` + ); +} + +function parseModelSwitchStatusModel(content: string): string | null { + const prefix = 'Model switched: '; + if (!content.startsWith(prefix)) return null; + const rawModel = content.slice(prefix.length).trim(); + return rawModel.replace(/\([^()]+\)$/, ''); +} + +function parseModelSwitchSummaryModel(content: string): string | null { + if (!content.startsWith('● authType:')) return null; + const match = content.match(/\n {2}Using (?:runtime )?model: ([^\n]+)/); + return match?.[1]?.trim() || null; +} + +function filterDuplicateModelSwitchMessages( + messages: readonly Message[], +): Message[] { + const summarizedModels = new Set(); + for (const message of messages) { + if (message.role !== 'system' || message.variant !== 'info') continue; + const model = parseModelSwitchSummaryModel(message.content); + if (model) summarizedModels.add(model); + } + if (summarizedModels.size === 0) return [...messages]; + return messages.filter((message) => { + if (message.role !== 'system' || message.variant !== 'info') return true; + const statusModel = parseModelSwitchStatusModel(message.content); + return !statusModel || !summarizedModels.has(statusModel); + }); +} + +function hasMcpStatusPanel(messages: readonly Message[]): boolean { + return messages.some( + (message) => + message.role === 'system' && + message.variant === 'info' && + parseMcpStatusMessage(message.content) !== null, + ); +} + +function isDaemonApprovalMode(mode: string): mode is DaemonApprovalMode { + return DAEMON_APPROVAL_MODES.includes(mode as DaemonApprovalMode); +} + +function isEditToolPermission(request: PermissionRequest): boolean { + return request.toolKind === 'edit'; +} + +function parseRenameArgument( + raw: string, +): + | { type: 'auto' } + | { type: 'manual'; displayName: string } + | { type: 'delegate' } { + const trimmed = raw.trim().replace(/[\r\n]+/g, ' '); + if (!trimmed) return { type: 'auto' }; + if (trimmed === '--') return { type: 'manual', displayName: '' }; + if (trimmed.startsWith('-- ')) { + return { type: 'manual', displayName: trimmed.slice(3).trim() }; + } + if (trimmed.toLowerCase() === '--auto') return { type: 'auto' }; + if (trimmed.startsWith('--')) return { type: 'delegate' }; + return { type: 'manual', displayName: trimmed }; +} + +function isAgentTool(tool: ACPToolCall): boolean { + return isSubAgentToolCall(tool); +} + +function isActiveTool(tool: ACPToolCall): boolean { + return tool.status === 'pending' || tool.status === 'in_progress'; +} + +function isBackgroundShellToolCall(tool: ACPToolCall): boolean { + if (tool.args?.is_background !== true) return false; + const name = tool.toolName.toLowerCase(); + return ( + name === 'shell' || + name === 'bash' || + name === 'run_shell_command' || + name === 'exec' + ); +} + +function getBackgroundTaskActivityKey(messages: readonly Message[]): string { + const parts: string[] = []; + for (const message of messages) { + if (message.role !== 'tool_group') continue; + for (const tool of message.tools) { + if ( + isBackgroundSubAgentToolCall(tool) || + isBackgroundShellToolCall(tool) + ) { + parts.push(`${tool.callId}:${tool.status}`); + } + } + } + return parts.join('|'); +} + +interface FloatingPanels { + todos: TodoItem[]; + agents: ACPToolCall[]; +} + +function getFloatingPanels(messages: readonly Message[]): FloatingPanels { + let todos: TodoItem[] | undefined; + const agents: ACPToolCall[] = []; + + for (const message of messages) { + if (message.role === 'plan') { + if (hasActiveTodos(message.todos)) { + todos = message.todos; + } else { + todos = []; + } + continue; + } + if (message.role !== 'tool_group') continue; + + for (const tool of message.tools) { + const nextTodos = extractTodosFromToolCall(tool); + if (nextTodos) { + todos = hasActiveTodos(nextTodos) ? nextTodos : []; + } + if ( + isAgentTool(tool) && + isActiveTool(tool) && + !isBackgroundSubAgentToolCall(tool) + ) { + agents.push(tool); + } + } + } + + return { todos: todos ?? [], agents }; +} + +function getAgentPanelVersion(agent: ACPToolCall): string { + const raw = agent.rawOutput; + const taskExec = + raw && typeof raw === 'object' && !Array.isArray(raw) + ? (raw as Record) + : undefined; + const summary = taskExec?.executionSummary; + const summaryRecord = + summary && typeof summary === 'object' && !Array.isArray(summary) + ? (summary as Record) + : undefined; + return [ + agent.subTools?.length ?? 0, + agent.subContent?.length ?? 0, + agent.title ?? '', + agent.args?.description ?? '', + agent.args?.prompt ?? '', + taskExec?.tokenCount ?? '', + summaryRecord?.totalTokens ?? '', + summaryRecord?.totalToolCalls ?? '', + summaryRecord?.failedToolCalls ?? '', + taskExec?.terminateReason ?? '', + ].join(':'); +} + +function translateCopyMessage( + message: string, + t: ReturnType, +): string { + if (message === COPY_MESSAGES.NO_OUTPUT) return t('copy.noOutput'); + if (message === COPY_MESSAGES.NO_TEXT) return t('copy.noText'); + if (message === COPY_MESSAGES.CODE_MISSING) return t('copy.codeMissing'); + if (message === COPY_MESSAGES.LATEX_MISSING) return t('copy.latexMissing'); + if (message === COPY_MESSAGES.INLINE_LATEX_MISSING) { + return t('copy.inlineLatexMissing'); + } + if (message === COPY_MESSAGES.OUTPUT_COPIED) return t('copy.outputCopied'); + if (message.startsWith(COPY_MESSAGES.CLIPBOARD_PREFIX)) { + return `${t('copy.failedFallback')}. ${message.slice( + COPY_MESSAGES.CLIPBOARD_PREFIX.length, + )}`; + } + if (message.endsWith(COPY_MESSAGES.COPIED_SUFFIX)) { + return t('copy.toClipboard', { + label: message.slice(0, -COPY_MESSAGES.COPIED_SUFFIX.length), + }); + } + return message; +} + +function QueuedPromptDisplay({ + prompts, + t, +}: { + prompts: readonly QueuedPrompt[]; + t: ReturnType; +}) { + if (prompts.length === 0) return null; + + return ( +
+ {prompts.slice(0, MAX_DISPLAYED_QUEUED_PROMPTS).map((prompt) => { + const normalizedPreview = prompt.text.replace(/\s+/g, ' ').trim(); + const preview = + normalizedPreview.length > MAX_QUEUED_PROMPT_PREVIEW_CHARS + ? `${normalizedPreview.slice(0, MAX_QUEUED_PROMPT_PREVIEW_CHARS)}...` + : normalizedPreview; + const imageCount = prompt.images?.length ?? 0; + return ( +
+ {preview} + {imageCount > 0 + ? ` ${t('queue.imageCount', { count: imageCount })}` + : ''} +
+ ); + })} + {prompts.length > MAX_DISPLAYED_QUEUED_PROMPTS && ( +
+ {t('queue.more', { + count: prompts.length - MAX_DISPLAYED_QUEUED_PROMPTS, + })} +
+ )} +
{t('queue.footer')}
+
+ ); +} + +export function App({ + onSessionIdChange, + theme: providedTheme = 'dark', + onThemeChange, + language: providedLanguage, + onLanguageChange, + className: externalClassName, + style: externalStyle, + onConnectionChange, + onStreamingStateChange, + onError, + onBugReport, + hiddenSlashCommands, + slashCommandCategoryOrder, + renderToolHeaderExtra, + renderWelcomeHeader, + compactThinking = false, + virtualScrollThreshold, + markdown, + onToast, +}: WebShellProps = {}) { + const [selectedLanguage, setSelectedLanguage] = useState( + () => + providedLanguage === undefined + ? getInitialLanguage() + : normalizeLanguage(providedLanguage), + ); + const t = useMemo(() => getTranslator(selectedLanguage), [selectedLanguage]); + const customization = useMemo( + () => ({ + renderToolHeaderExtra, + renderWelcomeHeader, + compactThinking, + markdown, + }), + [renderToolHeaderExtra, renderWelcomeHeader, compactThinking, markdown], + ); + const store = useTranscriptStore(); + const blocks = useTranscriptBlocks(); + const connection = useConnection(); + const sessionActions = useActions(); + const { notices, dismissNotice } = useSessionNotices(); + const workspaceActions = useWorkspaceActions(); + const onToastRef = useRef(onToast); + onToastRef.current = onToast; + const toastIdRef = useRef(0); + const [toasts, setToasts] = useState([]); + const dismissToast = useCallback((id: string) => { + setToasts((current) => current.filter((toast) => toast.id !== id)); + }, []); + const pushToast = useCallback((tone: ToastTone, message: string) => { + if (onToastRef.current) { + onToastRef.current(tone, message); + return; + } + const toast: WebShellToast = { + id: `web-shell-toast-${Date.now()}-${++toastIdRef.current}`, + tone, + message, + }; + setToasts((current) => { + const withoutDuplicate = current.filter( + (item) => item.tone !== tone || item.message !== message, + ); + return [...withoutDuplicate, toast].slice(-MAX_TOASTS); + }); + }, []); + + const messages = useMessages(t); + const messagesRef = useRef(messages); + messagesRef.current = messages; + const [recapMessage, setRecapMessage] = useState( + null, + ); + const [btwMessage, setBtwMessage] = useState(null); + const nextRecapMessageIdRef = useRef(1); + const nextBtwMessageIdRef = useRef(1); + const btwAbortControllerRef = useRef(null); + const activeSessionIdRef = useRef(connection.sessionId); + const displayMessages = useMemo(() => { + const localMessages = [recapMessage].filter( + (message): message is LocalAnchoredMessage => message !== null, + ); + if (localMessages.length === 0) { + return filterDuplicateModelSwitchMessages(messages); + } + + const result = [...messages]; + for (const localMessage of localMessages.sort( + (a, b) => a.anchorIndex - b.anchorIndex, + )) { + const anchorIndex = localMessage.anchorAfterId + ? result.findIndex( + (message) => message.id === localMessage.anchorAfterId, + ) + : -1; + const index = + anchorIndex >= 0 + ? anchorIndex + 1 + : Math.min(localMessage.anchorIndex, result.length); + result.splice(index, 0, localMessage.message); + } + return filterDuplicateModelSwitchMessages(result); + }, [messages, recapMessage]); + const hasMcpPanelMessage = useMemo( + () => hasMcpStatusPanel(displayMessages), + [displayMessages], + ); + useEffect(() => { + if (hasMcpPanelMessage) return; + window.dispatchEvent( + new CustomEvent(MCP_STATUS_ACTIVE_EVENT, { + detail: { active: false }, + }), + ); + }, [hasMcpPanelMessage]); + const messageBlocks = useAnimationFrameValue(blocks); + const rawPendingApproval = useMemo( + () => extractPendingPermission(messageBlocks), + [messageBlocks], + ); + const pendingApproval = useShallowMemo(rawPendingApproval); + const pendingApprovalRef = useRef(pendingApproval); + pendingApprovalRef.current = pendingApproval; + const shouldHideComposer = pendingApproval !== null; + const rawFloatingPanels = useMemo( + () => getFloatingPanels(messages), + [messages], + ); + const floatingTodos = useStableArray( + rawFloatingPanels.todos, + (t) => `${t.id}:${t.status}:${t.content}`, + ); + const floatingAgents = useStableArray( + rawFloatingPanels.agents, + (a) => + `${a.callId}:${a.status}:${a.subTools?.length ?? 0}:${getAgentPanelVersion(a)}`, + ); + const backgroundTaskActivityKey = useMemo( + () => getBackgroundTaskActivityKey(messages), + [messages], + ); + const activeAgentsPanelRef = useRef(null); + const statusBarRef = useRef(null); + const editorRef = useRef(null); + const [activeGoal, setActiveGoal] = useState(null); + const activeGoalRef = useRef(null); + activeGoalRef.current = activeGoal; + const { + followupState, + onAcceptFollowup, + onDismissFollowup, + clear: clearFollowup, + } = useDaemonFollowupSuggestion({ + onAccept: (suggestion) => { + editorRef.current?.insertText(suggestion); + }, + }); + const sendPrompt = useCallback( + ( + text: string, + images?: PromptImage[], + opts?: { optimisticUserMessage?: boolean }, + ) => { + clearFollowup(); + return sessionActions.sendPrompt(text, { + images, + optimisticUserMessage: opts?.optimisticUserMessage, + }); + }, + [clearFollowup, sessionActions], + ); + const streamingState = useStreamingState(); + const streamingStateRef = useRef(streamingState); + const connected = connection.status === 'connected'; + const [loadedSkills, setLoadedSkills] = useState([]); + useEffect(() => { + if (!connected) return; + workspaceActions + .loadSkillsStatus() + .then((status) => { + setLoadedSkills( + (status?.skills ?? []) + .map((s) => ({ name: s.name, description: s.description ?? '' })) + .sort((a, b) => a.name.localeCompare(b.name)), + ); + }) + .catch(() => {}); + }, [connected, workspaceActions]); + + const [modelInlineMode, setModelInlineMode] = + useState(null); + const [approvalModeInlineOpen, setApprovalModeInlineOpen] = useState(false); + const [showResumeDialog, setShowResumeDialog] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showReleaseDialog, setShowReleaseDialog] = useState(false); + const [showHelpDialog, setShowHelpDialog] = useState(false); + const [showThemeDialog, setShowThemeDialog] = useState(false); + const [showToolsDialog, setShowToolsDialog] = useState(false); + const [settingsInlineOpen, setSettingsInlineOpen] = useState(false); + const [memoryInlineOpen, setMemoryInlineOpen] = useState(false); + const [authInlineOpen, setAuthInlineOpen] = useState(false); + const [memoryRefreshSignal, setMemoryRefreshSignal] = useState(0); + const [memoryAddSignal, setMemoryAddSignal] = useState(0); + const [memoryAddScope, setMemoryAddScope] = useState<'workspace' | 'global'>( + 'workspace', + ); + const [agentsInlineMode, setAgentsInlineMode] = + useState(null); + const [memoryPortalHost, setMemoryPortalHost] = + useState(null); + const [showShortcuts, setShowShortcuts] = useState(false); + const [escapeHintVisible, setEscapeHintVisible] = useState(false); + const escPressCountRef = useRef(0); + const escapeTimerRef = useRef | null>(null); + const approvalModePanelActive = usePanelActive(APPROVAL_MODE_ACTIVE_EVENT); + const [tasksPanelMessage, setTasksPanelMessage] = + useState(null); + const mcpPanelActive = usePanelActive(MCP_STATUS_ACTIVE_EVENT); + const tasksPanelActive = usePanelActive(TASKS_STATUS_ACTIVE_EVENT); + const agentsPanelActive = usePanelActive(AGENTS_ACTIVE_EVENT); + const memoryPanelActive = usePanelActive(MEMORY_ACTIVE_EVENT); + const modelPanelActive = usePanelActive(MODEL_ACTIVE_EVENT); + const settingsPanelActive = usePanelActive(SETTINGS_ACTIVE_EVENT); + const authPanelActive = usePanelActive(AUTH_ACTIVE_EVENT); + const [selectedTheme, setSelectedTheme] = + useState(providedTheme); + const [currentModel, setCurrentModel] = useState(''); + const currentModelRef = useRef(currentModel); + currentModelRef.current = currentModel; + const connectionRef = useRef(connection); + connectionRef.current = connection; + const sessionDisplayName = connection.displayName; + const [currentMode, setCurrentMode] = useState('default'); + const [queuedPrompts, setQueuedPrompts] = useState([]); + const queuedPromptsRef = useRef([]); + const nextQueuedPromptIdRef = useRef(1); + const drainingQueueRef = useRef(false); + const dialogOpen = + showResumeDialog || + showDeleteDialog || + showReleaseDialog || + showHelpDialog || + showThemeDialog || + showToolsDialog; + const bottomHidden = + dialogOpen || + approvalModePanelActive || + mcpPanelActive || + tasksPanelActive || + agentsPanelActive || + memoryPanelActive || + modelPanelActive || + settingsPanelActive || + authPanelActive; + + const reportError = useCallback( + (error: unknown, fallback: string) => { + if (isAbortError(error)) return; + if (isDaemonTurnError(error)) { + console.debug('[web-shell] turn error rendered in transcript', error); + return; + } + if (isAlreadyDispatched(error)) { + console.debug('[web-shell] error already handled by notice', error); + return; + } + const message = formatError(error, fallback); + console.error('[web-shell]', message, error); + pushToast('error', message); + }, + [pushToast], + ); + + useEffect(() => { + logSessionNoticesHook(notices); + for (const notice of notices) { + if (shouldToastNotice(notice)) { + pushToast(toastToneFromNotice(notice), notice.message); + } else if (notice.category === 'lifecycle') { + console.debug('[web-shell] daemon notice', notice); + } else { + console.warn('[web-shell] daemon notice', notice); + } + dismissNotice(notice.id); + } + }, [dismissNotice, notices, pushToast]); + + const onBugReportRef = useRef(onBugReport); + onBugReportRef.current = onBugReport; + + useEffect(() => { + activeSessionIdRef.current = connection.sessionId; + btwAbortControllerRef.current?.abort(); + btwAbortControllerRef.current = null; + setRecapMessage(null); + setBtwMessage(null); + setTasksPanelMessage(null); + lastRecapBlockCountRef.current = 0; + }, [connection.sessionId]); + + const runVisibleRecap = useCallback(() => { + const messageId = `local-recap-${nextRecapMessageIdRef.current++}`; + const anchorIndex = messages.length; + const anchorAfterId = messages.at(-1)?.id; + const sessionId = connection.sessionId; + setRecapMessage({ + anchorAfterId, + anchorIndex, + message: { + id: messageId, + role: 'system', + content: `※ recap: ${t('recap.loading')}`, + variant: 'info', + }, + }); + sessionActions.recapSession().then( + (result) => { + if (activeSessionIdRef.current !== sessionId) return; + setRecapMessage({ + anchorAfterId, + anchorIndex, + message: { + id: messageId, + role: 'system', + content: result.recap + ? `※ recap: ${result.recap}` + : t('recap.empty'), + variant: 'info', + }, + }); + }, + (error: unknown) => { + if (activeSessionIdRef.current !== sessionId) return; + setRecapMessage(null); + if (!isAbortError(error) && !isAlreadyDispatched(error)) { + console.warn('[web-shell] unhandled recap failure', error); + } + }, + ); + }, [connection.sessionId, messages, sessionActions, t]); + + const runVisibleBtw = useCallback( + (rawQuestion: string) => { + const question = rawQuestion.trim(); + if (!question) { + pushToast('error', t('btw.empty')); + return; + } + + const messageId = `local-btw-${nextBtwMessageIdRef.current++}`; + const sessionId = connection.sessionId; + btwAbortControllerRef.current?.abort(); + const abortController = new AbortController(); + btwAbortControllerRef.current = abortController; + setBtwMessage({ + id: messageId, + role: 'btw', + question, + answer: '', + isPending: true, + }); + + sessionActions + .btwSession(question, { signal: abortController.signal }) + .then( + (result) => { + if (activeSessionIdRef.current !== sessionId) return; + if (btwAbortControllerRef.current !== abortController) return; + btwAbortControllerRef.current = null; + setBtwMessage({ + id: messageId, + role: 'btw', + question, + answer: result.answer || t('btw.emptyAnswer'), + isPending: false, + }); + }, + (error: unknown) => { + if (activeSessionIdRef.current !== sessionId) return; + if (btwAbortControllerRef.current !== abortController) return; + btwAbortControllerRef.current = null; + setBtwMessage(null); + if (!isAbortError(error) && !isAlreadyDispatched(error)) { + console.warn('[web-shell] unhandled btw failure', error); + } + }, + ); + }, + [connection.sessionId, pushToast, sessionActions, t], + ); + + const dismissBtwMessage = useCallback(() => { + btwAbortControllerRef.current?.abort(); + btwAbortControllerRef.current = null; + setBtwMessage(null); + }, []); + + useEffect(() => { + const onBtwShortcut = (e: KeyboardEvent) => { + if (bottomHidden || pendingApproval) return; + const message = btwMessage; + if (!message || message.role !== 'btw') return; + + const key = e.key.toLowerCase(); + const isPlainEscape = + e.key === 'Escape' && + !e.ctrlKey && + !e.metaKey && + !e.altKey && + !e.shiftKey; + const isCtrlCancel = + e.ctrlKey && + !e.metaKey && + !e.altKey && + !e.shiftKey && + (key === 'c' || key === 'd'); + + if (message.isPending) { + if (!isPlainEscape && !isCtrlCancel) return; + } else { + const editorHasText = + (editorRef.current?.getText().trim().length ?? 0) > 0; + const isPlainDismiss = + !e.ctrlKey && + !e.metaKey && + !e.altKey && + !e.shiftKey && + (e.key === 'Escape' || + (!editorHasText && (e.key === 'Enter' || e.key === ' '))); + if (!isPlainDismiss) return; + } + + e.preventDefault(); + e.stopPropagation(); + dismissBtwMessage(); + }; + + window.addEventListener('keydown', onBtwShortcut, true); + return () => window.removeEventListener('keydown', onBtwShortcut, true); + }, [bottomHidden, btwMessage, dismissBtwMessage, pendingApproval]); + + useEffect(() => { + queuedPromptsRef.current = queuedPrompts; + }, [queuedPrompts]); + + const enqueuePrompt = useCallback((text: string, images?: PromptImage[]) => { + const trimmed = text.trim(); + if (!trimmed) return true; + const nextPrompt: QueuedPrompt = { + id: nextQueuedPromptIdRef.current++, + text: trimmed, + images: images ? [...images] : undefined, + }; + queuedPromptsRef.current = [...queuedPromptsRef.current, nextPrompt]; + setQueuedPrompts(queuedPromptsRef.current); + return true; + }, []); + + const popNextQueuedPrompt = useCallback((): QueuedPrompt | null => { + const [nextPrompt, ...rest] = queuedPromptsRef.current; + if (!nextPrompt) return null; + queuedPromptsRef.current = rest; + setQueuedPrompts(rest); + return nextPrompt; + }, []); + + const popQueuedPromptsForEdit = useCallback((): string | null => { + const current = queuedPromptsRef.current; + if (current.length === 0) return null; + queuedPromptsRef.current = []; + setQueuedPrompts([]); + return current.map((prompt) => prompt.text).join('\n\n'); + }, []); + + const clearQueuedPrompts = useCallback((): boolean => { + if (queuedPromptsRef.current.length === 0) return false; + queuedPromptsRef.current = []; + setQueuedPrompts([]); + store.dispatch([{ type: 'status', text: t('queue.cleared') }]); + return true; + }, [store, t]); + + useEffect(() => { + setSelectedTheme(providedTheme); + }, [providedTheme]); + + const handleThemeChange = useCallback( + (nextTheme: WebShellTheme) => { + setSelectedTheme(nextTheme); + onThemeChange?.(nextTheme); + }, + [onThemeChange], + ); + + useEffect(() => { + if (providedLanguage !== undefined) { + setSelectedLanguage(normalizeLanguage(providedLanguage)); + } + }, [providedLanguage]); + + const handleToggleShortcuts = useCallback(() => { + setShowShortcuts((prev) => !prev); + }, []); + + const [compactMode, setCompactMode] = useState(loadCompactMode); + const compactModeRef = useRef(compactMode); + compactModeRef.current = compactMode; + + const handleClearScreen = useCallback(() => { + if (streamingStateRef.current !== 'idle') { + store.dispatch([{ type: 'status', text: t('clear.blocked') }]); + return; + } + store.reset(); + }, [store, t]); + + const handleToggleCompact = useCallback(() => { + const next = !compactModeRef.current; + setCompactMode(next); + saveCompactMode(next); + }, []); + + const handleSetMode = useCallback( + (modeId: string) => { + if (!isDaemonApprovalMode(modeId)) { + reportError( + new Error(`Unsupported approval mode: ${modeId}`), + t('local.approvalMode'), + ); + return; + } + sessionActions + .setApprovalMode(modeId) + .then((result) => { + const effectiveMode = result.mode || modeId; + setCurrentMode(effectiveMode); + if (effectiveMode === 'auto') { + // TODO: CLI also shows stripped dangerous allow rules via + // PermissionManager.getStrippedDangerousRules(). The daemon + // API (DaemonApprovalModeResult) doesn't expose this info yet. + // Once the daemon returns strippedRules in the response, display + // them here like CLI's emitAutoModeEntryNotices does. + store.dispatch([{ type: 'status', text: t('mode.auto.notice') }]); + } + const approval = pendingApprovalRef.current; + if (!approval) return; + const shouldAutoApprove = + modeId === 'yolo' || + (modeId === 'auto-edit' && isEditToolPermission(approval)); + if (shouldAutoApprove) { + const allowOnce = approval.options.find( + (o) => o.kind === 'allow_once', + ); + if (allowOnce) { + const toolDesc = approval.title || ''; + store.dispatch([ + { + type: 'status', + text: t('mode.autoApproved', { tool: toolDesc }), + }, + ]); + sessionActions + .submitPermission(approval.id, allowOnce.id) + .catch((error: unknown) => { + reportError(error, 'Failed to auto-approve tool call'); + }); + } + } + }) + .catch((error: unknown) => { + reportError(error, t('local.approvalMode')); + }); + }, + [sessionActions, reportError, store, t], + ); + + useEffect(() => { + streamingStateRef.current = streamingState; + }, [streamingState]); + + useEffect(() => { + onStreamingStateChange?.(streamingState); + }, [streamingState, onStreamingStateChange]); + + useEffect(() => { + onConnectionChange?.(connection.status); + }, [connection.status, onConnectionChange]); + + useEffect(() => { + if (connection.error) { + const error = new Error(connection.error); + onError?.(error); + } + }, [connection.error, onError]); + + useEffect(() => { + if (connection.currentModel) { + setCurrentModel(connection.currentModel); + } + }, [connection.currentModel]); + + useEffect(() => { + if (connection.currentMode) { + setCurrentMode(connection.currentMode); + } + }, [connection.currentMode]); + + useEffect(() => { + if (connection.sessionId) { + setActiveGoal(null); + onSessionIdChange?.(connection.sessionId); + if (!onSessionIdChange) { + replaceSessionUrl(connection.sessionId); + } + } + }, [connection.sessionId, onSessionIdChange]); + + useEffect(() => { + const onGoalStatusActive = (event: Event) => { + const detail = ( + event as CustomEvent<{ + active?: boolean; + condition?: string; + setAt?: number; + }> + ).detail; + if (!detail?.active) { + setActiveGoal(null); + return; + } + if (!detail.condition) return; + setActiveGoal({ + condition: detail.condition, + setAt: detail.setAt ?? Date.now(), + }); + }; + + window.addEventListener(GOAL_STATUS_ACTIVE_EVENT, onGoalStatusActive); + return () => + window.removeEventListener(GOAL_STATUS_ACTIVE_EVENT, onGoalStatusActive); + }, []); + + // Auto-recap: fire when the user returns after being away ≥ 3 minutes + const hiddenAtRef = useRef(null); + const lastRecapBlockCountRef = useRef(0); + useEffect(() => { + lastRecapBlockCountRef.current = 0; + }, [connection.sessionId]); + useEffect(() => { + const AWAY_THRESHOLD_MS = 3 * 60 * 1000; + const MIN_NEW_BLOCKS = 4; + function onVisibilityChange() { + if (document.hidden) { + if (hiddenAtRef.current === null) hiddenAtRef.current = Date.now(); + return; + } + const hiddenAt = hiddenAtRef.current; + hiddenAtRef.current = null; + if (hiddenAt === null) return; + if (Date.now() - hiddenAt < AWAY_THRESHOLD_MS) return; + if (streamingStateRef.current !== 'idle') return; + if (!connection.sessionId) return; + const currentCount = store.getSnapshot().blocks.length; + if (currentCount - lastRecapBlockCountRef.current < MIN_NEW_BLOCKS) + return; + lastRecapBlockCountRef.current = currentCount; + sessionActions.recapSession().then( + (result) => { + if (result.recap) { + store.dispatch([ + { type: 'status', text: `※ recap: ${result.recap}` }, + ]); + } + }, + (error: unknown) => { + console.error('[auto-recap] failed:', error); + }, + ); + } + document.addEventListener('visibilitychange', onVisibilityChange); + return () => + document.removeEventListener('visibilitychange', onVisibilityChange); + }, [connection.sessionId, sessionActions, store]); + + const handleCycleMode = useCallback(() => { + const idx = isDaemonApprovalMode(currentMode) + ? MODES_CYCLE.indexOf(currentMode) + : -1; + const next = MODES_CYCLE[(idx + 1) % MODES_CYCLE.length]; + handleSetMode(next); + }, [currentMode, handleSetMode]); + + // Shared by the /context slash command and the status-bar context + // indicator. Echoes the command as a local user message first — that also + // makes the transcript follow the tail (MessageList Rule 4), so the panel + // is revealed even when the click comes while scrolled up. + const showContextUsage = useCallback( + (commandText: string, detail: boolean) => { + store.appendLocalUserMessage(commandText); + sessionActions + .getContextUsage({ detail }) + .then((result) => { + store.dispatch([ + { + type: 'status', + text: serializeContextUsageMessage(result), + }, + ]); + }) + .catch((error: unknown) => { + reportError(error, 'Failed to load context usage'); + }); + }, + [store, sessionActions, reportError], + ); + + // Stable reference: this travels through the memoized MessageList → + // MessageItem chain, so an inline closure would defeat their memo. + const handleShowContextDetail = useCallback(() => { + showContextUsage('/context detail', true); + }, [showContextUsage]); + + const openTasksPanel = useCallback(() => { + sessionActions + .getTasks() + .then((snapshot) => { + setTasksPanelMessage({ snapshot }); + }) + .catch((error: unknown) => { + reportError(error, 'Failed to load tasks'); + }); + }, [reportError, sessionActions]); + + const dispatchGoalSet = useCallback( + (condition: string, setAt: number) => { + setActiveGoal({ condition, setAt }); + store.dispatch([ + { + type: 'status', + text: serializeGoalStatusMessage({ + kind: 'set', + condition, + setAt, + }), + }, + ]); + }, + [store], + ); + + const dispatchGoalCleared = useCallback( + (goal: ActiveGoalStatus | null) => { + if (!goal) return; + store.dispatch([ + { + type: 'status', + text: serializeGoalStatusMessage({ + kind: 'cleared', + condition: goal.condition, + durationMs: Date.now() - goal.setAt, + }), + }, + ]); + setActiveGoal(null); + }, + [store], + ); + + const handleBusyGoalClear = useCallback( + (text: string) => { + const goalToClear = activeGoalRef.current; + store.appendLocalUserMessage(text); + dispatchGoalCleared(goalToClear); + sessionActions.clearGoal().catch((error: unknown) => { + if (goalToClear) { + dispatchGoalSet(goalToClear.condition, goalToClear.setAt); + } + reportError(error, 'Failed to clear /goal'); + }); + return true; + }, + [dispatchGoalCleared, dispatchGoalSet, reportError, sessionActions, store], + ); + + const handleGoalSlashCommand = useCallback( + ( + text: string, + images?: PromptImage[], + opts?: { sendToDaemon?: boolean }, + ) => { + const goalArg = text.replace(/^\/goal\b/i, '').trim(); + const lowerGoalArg = goalArg.toLowerCase(); + const sendToDaemon = opts?.sendToDaemon ?? true; + + if (goalArg && GOAL_CLEAR_KEYWORDS.has(lowerGoalArg)) { + if (!sendToDaemon) { + store.appendLocalUserMessage(text); + dispatchGoalCleared(activeGoalRef.current); + return true; + } + return handleBusyGoalClear(text); + } else if (goalArg) { + const optimisticGoal = { condition: goalArg, setAt: Date.now() }; + store.appendLocalUserMessage(text); + dispatchGoalSet(optimisticGoal.condition, optimisticGoal.setAt); + if (!sendToDaemon) { + return true; + } + sendPrompt(text, images, { optimisticUserMessage: false }).catch( + (error: unknown) => { + reportError(error, 'Failed to send /goal command'); + }, + ); + return true; + } + + store.appendLocalUserMessage(text); + if (sendToDaemon) { + sendPrompt(text, images, { optimisticUserMessage: false }).catch( + (error: unknown) => + reportError(error, 'Failed to send /goal command'), + ); + } + return true; + }, + [ + dispatchGoalCleared, + dispatchGoalSet, + handleBusyGoalClear, + reportError, + sendPrompt, + store, + ], + ); + + const handleSubmit = useCallback( + (text: string, images?: PromptImage[]) => { + const promptBlocked = streamingStateRef.current !== 'idle'; + if (text.startsWith('/')) { + const match = text.match(/^\/([\w-]+)/); + if (match) { + const cmd = match[1]; + if (cmd === 'help') { + setShowHelpDialog(true); + return true; + } + if (cmd === 'tasks') { + store.appendLocalUserMessage(text); + handleTasksSlashCommand({ + cmd, + getTasks: sessionActions.getTasks, + dispatch: (events) => store.dispatch(events), + reportError, + }); + return true; + } + if (cmd === 'goal') { + if (promptBlocked) { + if (isGoalClearCommand(text)) { + return handleBusyGoalClear(text); + } + const goalArg = text.replace(/^\/goal\b/i, '').trim(); + if (goalArg) { + setActiveGoal({ condition: goalArg, setAt: Date.now() }); + } + return enqueuePrompt(text, images); + } + return handleGoalSlashCommand(text, images); + } + if (cmd === 'theme') { + const themeArg = text.slice(match[0].length).trim().toLowerCase(); + if (themeArg === 'dark' || themeArg === 'light') { + handleThemeChange(themeArg); + } else if (!themeArg) { + setShowThemeDialog(true); + } else { + pushToast('error', t('error.unsupportedTheme')); + } + return true; + } + if (cmd === 'language') { + const args = text.slice(match[0].length).trim(); + const [subCommand, languageArg] = args.split(/\s+/); + if (!args) { + store.dispatch([ + { + type: 'status', + text: [ + t('language.current', { + language: languageLabel(selectedLanguage), + }), + t('language.usage'), + t('language.options'), + ' - en: English', + ' - zh-CN: 中文', + ].join('\n'), + }, + ]); + return true; + } + if (subCommand?.toLowerCase() === 'ui') { + if (!languageArg) { + store.dispatch([ + { + type: 'status', + text: [ + t('language.set'), + '', + t('language.usage'), + '', + t('language.options'), + ' - en: English', + ' - zh-CN: 中文', + ].join('\n'), + }, + ]); + return true; + } + const normalizedArg = languageArg.toLowerCase(); + const valid = ['en', 'zh', 'zh-cn', 'zh_cn'].includes( + normalizedArg, + ); + if (!valid) { + pushToast('error', t('language.invalid')); + return true; + } + const nextLanguage = normalizeLanguage(languageArg); + setSelectedLanguage(nextLanguage); + onLanguageChange?.(nextLanguage); + if (!promptBlocked) { + sendPrompt(`/language ui ${nextLanguage}`) + .then(() => sessionActions.refreshCommands()) + .catch((error: unknown) => { + reportError(error, 'Failed to sync /language command'); + }); + } + return true; + } + } + if (cmd === 'copy') { + const copyArg = text.slice(match[0].length).trim(); + copyFromLastAssistantMessage(messagesRef.current, copyArg) + .then((result) => { + store.dispatch([ + { + type: result.status === 'error' ? 'error' : 'status', + text: translateCopyMessage(result.message, t), + }, + ]); + }) + .catch((error: unknown) => { + reportError(error, t('copy.failedFallback')); + }); + return true; + } + if (cmd === 'delete') { + setShowDeleteDialog(true); + return true; + } + if (cmd === 'release') { + setShowReleaseDialog(true); + return true; + } + if (cmd === 'auth') { + store.appendLocalUserMessage(text); + setAuthInlineOpen(true); + return true; + } + if (cmd === 'model') { + const modelArg = text.slice(match[0].length).trim(); + if (modelArg === '--fast') { + store.appendLocalUserMessage(text); + setModelInlineMode('fast'); + return true; + } + if (modelArg.startsWith('--fast ')) { + if (promptBlocked) return enqueuePrompt(text, images); + sendPrompt(text, images).catch((error: unknown) => + reportError(error, 'Failed to send /model --fast'), + ); + return true; + } + if (modelArg) { + sessionActions + .setModel(modelArg) + .then(() => { + setCurrentModel(modelArg); + }) + .catch((error: unknown) => { + reportError(error, t('model.switch')); + }); + } else { + store.appendLocalUserMessage(text); + setModelInlineMode('main'); + } + return true; + } + if (cmd === 'plan') { + if (promptBlocked) return enqueuePrompt(text, images); + const prompt = text.slice(match[0].length).trim(); + sessionActions + .setApprovalMode('plan') + .then(() => { + setCurrentMode('plan'); + if (prompt) { + sendPrompt(prompt, images).catch((error: unknown) => + reportError(error, 'Failed to send plan prompt'), + ); + } + }) + .catch((error: unknown) => { + reportError(error, t('mode.plan')); + }); + return true; + } + if (cmd === 'approval-mode') { + const modeArg = text.slice(match[0].length).trim(); + if (modeArg) { + handleSetMode(modeArg); + } else { + store.appendLocalUserMessage(text); + setApprovalModeInlineOpen(true); + } + return true; + } + if (cmd === 'mcp') { + const mcpArg = text.slice(match[0].length).trim().toLowerCase(); + store.appendLocalUserMessage(text); + workspaceActions + .loadMcpStatus() + .then(async (status) => { + const toolsByServer: Record< + string, + Awaited> + > = {}; + await Promise.all( + (status?.servers ?? []).map(async (server) => { + try { + toolsByServer[server.name] = + await workspaceActions.loadMcpTools(server.name); + } catch { + // Allow partial failure — other servers still render + } + }), + ); + store.dispatch([ + { + type: 'status', + text: serializeMcpStatusMessage({ + status, + toolsByServer, + showDescriptions: mcpArg === 'desc', + showSchema: mcpArg === 'schema', + showTips: !mcpArg, + }), + }, + ]); + }) + .catch((error: unknown) => { + reportError(error, 'Failed to load MCP status'); + }); + return true; + } + if (cmd === 'skills') { + const skillArg = text.slice(match[0].length).trim(); + if (skillArg) { + if (promptBlocked) return enqueuePrompt(text, images); + sendPrompt(text, images).catch((error: unknown) => + reportError(error, 'Failed to send /skills command'), + ); + } else { + store.appendLocalUserMessage(text); + workspaceActions + .loadSkillsStatus() + .then((status) => { + const skills = (status?.skills ?? []) + .map((s) => ({ + name: s.name, + description: s.description ?? '', + })) + .sort((a, b) => a.name.localeCompare(b.name)); + setLoadedSkills(skills); + if (skills.length === 0) { + store.dispatch([ + { type: 'status', text: t('skills.none') }, + ]); + } else { + const list = skills.map((s) => `- ${s.name}`).join('\n'); + store.dispatch([ + { + type: 'status', + text: `${t('skills.available')}\n\n${list}`, + }, + ]); + } + }) + .catch((error: unknown) => { + reportError(error, 'Failed to load skills'); + }); + } + return true; + } + if (cmd === 'tools') { + const toolsArg = text.slice(match[0].length).trim().toLowerCase(); + if (toolsArg === 'desc' || toolsArg === 'descriptions') { + setShowToolsDialog(true); + } else { + store.appendLocalUserMessage(text); + workspaceActions + .loadToolsStatus() + .then((status) => { + const tools = status?.tools ?? []; + if (tools.length === 0) { + store.dispatch([{ type: 'status', text: t('tools.none') }]); + } else { + const list = tools + .map((tool) => `- ${tool.displayName || tool.name}`) + .join('\n'); + store.dispatch([ + { + type: 'status', + text: `${t('tools.available')}\n\n${list}`, + }, + ]); + } + }) + .catch((error: unknown) => { + reportError(error, 'Failed to load tools'); + }); + } + return true; + } + if (cmd === 'settings') { + store.appendLocalUserMessage(text); + setSettingsInlineOpen(true); + return true; + } + if (cmd === 'context') { + const contextArg = text.slice(match[0].length).trim().toLowerCase(); + if ( + contextArg === '' || + contextArg === 'detail' || + contextArg === '-d' + ) { + showContextUsage( + text, + contextArg === 'detail' || contextArg === '-d', + ); + return true; + } + } + if (cmd === 'memory') { + const memoryArg = text.slice(match[0].length).trim().toLowerCase(); + store.appendLocalUserMessage(text); + if (memoryArg === 'refresh') { + setMemoryRefreshSignal((signal) => signal + 1); + } else if (memoryArg === 'add' || memoryArg.startsWith('add ')) { + const addTarget = memoryArg.slice('add'.length).trim(); + setMemoryAddScope( + addTarget === 'user' || addTarget === 'global' + ? 'global' + : 'workspace', + ); + setMemoryAddSignal((signal) => signal + 1); + } + setMemoryInlineOpen(true); + return true; + } + if (cmd === 'agents') { + const subCommand = text.slice(match[0].length).trim().toLowerCase(); + store.appendLocalUserMessage(text); + let agentsMode: AgentsInitialMode = 'menu'; + if (subCommand === 'create') { + agentsMode = 'create'; + } else if ( + subCommand === 'create user' || + subCommand === 'create global' + ) { + agentsMode = 'create-user'; + } else if ( + subCommand === 'create project' || + subCommand === 'create workspace' + ) { + agentsMode = 'create-project'; + } else if (subCommand === 'manage') { + agentsMode = 'manage'; + } + setAgentsInlineMode(agentsMode); + return true; + } + if (cmd === 'clear') { + sessionActions.newSession().catch((error: unknown) => { + reportError(error, 'Failed to create a new session'); + }); + return true; + } + if (cmd === 'new' || cmd === 'reset') { + sessionActions.newSession().catch((error: unknown) => { + reportError(error, 'Failed to create a new session'); + }); + return true; + } + if (cmd === 'rename') { + const renameArg = parseRenameArgument(text.slice(match[0].length)); + if (renameArg.type === 'auto' || renameArg.type === 'delegate') { + if (promptBlocked) return enqueuePrompt(text, images); + sendPrompt(text, images).catch((error: unknown) => + reportError(error, 'Failed to send /rename command'), + ); + return true; + } + const displayName = renameArg.displayName; + if (!displayName) { + pushToast('error', t('rename.empty')); + return true; + } + sessionActions + .renameSession(displayName) + .then(() => { + store.dispatch([ + { + type: 'status', + text: t('rename.success', { name: displayName }), + }, + ]); + }) + .catch((error: unknown) => { + reportError(error, 'Failed to rename session'); + }); + return true; + } + if (cmd === 'resume') { + const sessionId = text.slice(match[0].length).trim(); + if (sessionId) { + sessionActions.loadSession(sessionId).catch((error: unknown) => { + reportError(error, 'Failed to load session'); + }); + } else { + setShowResumeDialog(true); + } + return true; + } + if (cmd === 'recap') { + runVisibleRecap(); + return true; + } + if (cmd === 'btw') { + runVisibleBtw(text.slice(match[0].length)); + return true; + } + if (cmd === 'stats') { + const statsArg = text.slice(match[0].length).trim().toLowerCase(); + let statsView: StatsView = 'overview'; + if (statsArg === 'model') statsView = 'model'; + else if (statsArg === 'tools') statsView = 'tools'; + store.appendLocalUserMessage(text); + sessionActions + .getStats() + .then((result) => { + store.dispatch([ + { + type: 'status', + text: serializeStatsMessage(result, statsView), + }, + ]); + }) + .catch(() => {}); + return true; + } + if (cmd === 'status' || cmd === 'about') { + store.appendLocalUserMessage(text); + Promise.all([ + workspaceActions.loadPreflight().catch(() => null), + workspaceActions.loadProviders().catch(() => null), + workspaceActions.loadEnv().catch(() => null), + ]).then(([preflight, providers, env]) => { + const sys = collectSystemInfo(preflight, env); + + let authSource = sys.authSource; + if (!authSource && providers?.current?.authType) { + authSource = providers.current.authType; + } + + const runtimeParts: string[] = []; + if (sys.nodeVersion) + runtimeParts.push(`Node.js v${sys.nodeVersion}`); + if (sys.npmVersion) runtimeParts.push(`npm ${sys.npmVersion}`); + + let formattedAuth = ''; + if (authSource) { + if ( + authSource.startsWith('oauth') || + authSource === 'qwen-oauth' + ) { + formattedAuth = 'Qwen OAuth'; + } else { + formattedAuth = `API Key - ${authSource}`; + } + } + + const platformStr = `${sys.platform} ${sys.arch}`.trim(); + const curModel = currentModelRef.current; + const conn = connectionRef.current; + const qwenCodeVersion = conn.capabilities?.qwenCodeVersion || ''; + const info: StatusInfo = { + cliVersion: qwenCodeVersion, + runtime: runtimeParts.join(' / '), + platform: platformStr, + auth: formattedAuth, + baseUrl: providers?.current?.baseUrl || '', + model: + curModel || + conn.currentModel || + providers?.current?.modelId || + '', + fastModel: + providers?.current?.fastModelId || + curModel || + conn.currentModel || + providers?.current?.modelId || + '', + sessionId: conn.sessionId || '', + sandbox: sys.sandbox, + proxy: sys.proxy, + memoryUsage: sys.memoryUsage, + }; + + store.dispatch([ + { type: 'status', text: serializeStatusMessage(info) }, + ]); + }); + return true; + } + if (cmd === 'bug') { + const bugTitle = text.slice(match[0].length).trim(); + store.appendLocalUserMessage(text); + Promise.all([ + workspaceActions.loadPreflight().catch(() => null), + workspaceActions.loadEnv().catch(() => null), + ]) + .then(([preflight, env]) => { + const sys = collectSystemInfo(preflight, env); + const qwenCodeVersion = + connectionRef.current.capabilities?.qwenCodeVersion || ''; + const sysInfo: Record = {}; + if (qwenCodeVersion) sysInfo.cliVersion = qwenCodeVersion; + if (sys.nodeVersion) sysInfo.nodeVersion = sys.nodeVersion; + if (sys.npmVersion) sysInfo.npmVersion = sys.npmVersion; + if (sys.platform) sysInfo.platform = sys.platform; + if (sys.arch) sysInfo.arch = sys.arch; + if (sys.sandbox) sysInfo.sandbox = sys.sandbox; + if (sys.memoryUsage) sysInfo.memoryUsage = sys.memoryUsage; + if (onBugReportRef.current) { + onBugReportRef.current({ + title: bugTitle, + systemInfo: sysInfo, + }); + store.dispatch([ + { type: 'status', text: t('bug.submitted') }, + ]); + } else { + const fields = Object.entries(sysInfo) + .filter(([, v]) => v) + .map(([k, v]) => `${k}: ${v}`) + .join('\n'); + const url = + `https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml` + + `&title=${encodeURIComponent(bugTitle)}` + + `&info=${encodeURIComponent('\n' + fields + '\n')}`; + const win = window.open(url, '_blank'); + if (win) { + win.opener = null; + store.dispatch([ + { type: 'status', text: t('bug.submitted') }, + ]); + } else { + pushToast('error', t('bug.popupBlocked')); + } + } + }) + .catch((error: unknown) => { + reportError(error, t('bug.failed')); + }); + return true; + } + } + // Forward slash commands as prompts + if (promptBlocked) return enqueuePrompt(text, images); + sendPrompt(text, images).catch((error: unknown) => + reportError(error, 'Failed to send command'), + ); + return true; + } else if (text.startsWith('!')) { + if (promptBlocked) return enqueuePrompt(text, images); + const cmd = text.slice(1).trim(); + if (!cmd) return false; + sessionActions.sendShellCommand(cmd).catch((error: unknown) => { + reportError(error, 'Failed to execute shell command'); + }); + return true; + } else { + if (promptBlocked) return enqueuePrompt(text, images); + sendPrompt(text, images).catch((error: unknown) => + reportError(error, 'Failed to send message'), + ); + return true; + } + }, + [ + sendPrompt, + sessionActions, + store, + enqueuePrompt, + handleBusyGoalClear, + handleGoalSlashCommand, + handleThemeChange, + handleSetMode, + onLanguageChange, + pushToast, + reportError, + runVisibleRecap, + runVisibleBtw, + selectedLanguage, + showContextUsage, + t, + workspaceActions, + ], + ); + + useEffect(() => { + if (drainingQueueRef.current) return; + if (!connected) return; + if (streamingState !== 'idle') return; + if (bottomHidden) return; + if (pendingApproval) return; + if (queuedPrompts.length === 0) return; + + const nextPrompt = popNextQueuedPrompt(); + if (!nextPrompt) return; + + drainingQueueRef.current = true; + const timer = window.setTimeout(() => { + try { + handleSubmit(nextPrompt.text, nextPrompt.images); + } finally { + drainingQueueRef.current = false; + } + }, 0); + return () => { + window.clearTimeout(timer); + drainingQueueRef.current = false; + }; + }, [ + connected, + bottomHidden, + handleSubmit, + pendingApproval, + popNextQueuedPrompt, + queuedPrompts, + streamingState, + ]); + + const handleConfirm = useCallback( + (id: string, selectedOption: string, answers?: Record) => { + sessionActions + .submitPermission(id, selectedOption, answers) + .catch((error: unknown) => { + reportError(error, 'Failed to submit permission choice'); + }); + }, + [sessionActions, reportError], + ); + + const handleCancel = useCallback(() => { + sessionActions.cancel().catch((error: unknown) => { + reportError(error, 'Failed to cancel request'); + }); + }, [sessionActions, reportError]); + + const handleFocusActiveAgents = useCallback((): boolean => { + if (floatingAgents.length === 0) return false; + editorRef.current?.blur(); + window.setTimeout(() => { + activeAgentsPanelRef.current?.focus({ preventScroll: true }); + }, 0); + return true; + }, [floatingAgents.length]); + + const handleFocusTaskPill = useCallback((): boolean => { + if (bottomHidden) return false; + return statusBarRef.current?.focusTaskPill() ?? false; + }, [bottomHidden]); + + const handleFocusFooterFromEditor = useCallback((): boolean => { + if (handleFocusActiveAgents()) return true; + return handleFocusTaskPill(); + }, [handleFocusActiveAgents, handleFocusTaskPill]); + + const handleReturnToEditor = useCallback((text?: string) => { + if (text) { + editorRef.current?.insertText(text); + return; + } + editorRef.current?.focus(); + }, []); + useEffect(() => { + const onGlobalShortcut = (e: KeyboardEvent) => { + if (bottomHidden) return; + if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { + if (e.key === 'l') { + e.preventDefault(); + handleClearScreen(); + return; + } + if (e.key === 'o') { + e.preventDefault(); + handleToggleCompact(); + return; + } + if (e.key === 'y') { + e.preventDefault(); + editorRef.current?.retryLast(); + return; + } + } + }; + window.addEventListener('keydown', onGlobalShortcut, true); + return () => window.removeEventListener('keydown', onGlobalShortcut, true); + }, [bottomHidden, handleClearScreen, handleToggleCompact]); + + useEffect(() => { + const resetEscapeState = () => { + escPressCountRef.current = 0; + setEscapeHintVisible(false); + if (escapeTimerRef.current) { + clearTimeout(escapeTimerRef.current); + escapeTimerRef.current = null; + } + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.defaultPrevented || e.isComposing) return; + + if (e.key !== 'Escape') { + if (escPressCountRef.current > 0) { + resetEscapeState(); + } + if (e.key === 'Tab' && e.shiftKey && !bottomHidden) { + e.preventDefault(); + handleCycleMode(); + } + return; + } + + if (pendingApproval || bottomHidden) return; + + if (tasksPanelMessage) { + e.preventDefault(); + e.stopPropagation(); + setTasksPanelMessage(null); + handleReturnToEditor(); + resetEscapeState(); + return; + } + + if (clearQueuedPrompts()) { + e.preventDefault(); + resetEscapeState(); + return; + } + + const text = editorRef.current?.getText() ?? ''; + if (text.length > 0) { + e.preventDefault(); + if (escPressCountRef.current === 0) { + escPressCountRef.current = 1; + setEscapeHintVisible(true); + if (escapeTimerRef.current) { + clearTimeout(escapeTimerRef.current); + } + escapeTimerRef.current = setTimeout(() => { + resetEscapeState(); + }, 500); + } else { + editorRef.current?.clearText(); + resetEscapeState(); + } + return; + } + + if (streamingState !== 'idle') { + e.preventDefault(); + handleCancel(); + resetEscapeState(); + return; + } + }; + window.addEventListener('keydown', onKeyDown); + return () => { + window.removeEventListener('keydown', onKeyDown); + escPressCountRef.current = 0; + setEscapeHintVisible(false); + if (escapeTimerRef.current) { + clearTimeout(escapeTimerRef.current); + escapeTimerRef.current = null; + } + }; + }, [ + streamingState, + handleCancel, + handleCycleMode, + pendingApproval, + bottomHidden, + tasksPanelMessage, + handleReturnToEditor, + clearQueuedPrompts, + ]); + + const isDisabled = !connected; + + const handleModelSelect = useCallback( + (modelId: string) => { + sessionActions + .setModel(modelId) + .then((result) => { + const summary = getModelSwitchSummary(result); + setCurrentModel(summary?.modelId ?? modelId); + if (summary) { + store.dispatch({ + type: 'debug', + text: serializeModelSwitchSummary(summary), + }); + } + }) + .catch((error: unknown) => { + reportError(error, t('model.switch')); + }); + }, + [sessionActions, store, reportError, t], + ); + + const handleFastModelSelect = useCallback( + (modelId: string) => { + if (streamingState !== 'idle') return; + sendPrompt(`/model --fast ${modelId}`).catch((error: unknown) => { + reportError(error, 'Failed to switch fast model'); + }); + }, + [sendPrompt, streamingState, reportError], + ); + + const commands = useMemo(() => { + const skillNames = new Set(connection.skills ?? []); + const hidden = new Set( + (hiddenSlashCommands ?? []).map(normalizeHiddenCommand).filter(Boolean), + ); + return mergeCommands(connection.commands ?? [], getLocalCommands(t)) + .filter((command) => !hidden.has(normalizeHiddenCommand(command.name))) + .map((command) => { + if (!skillNames.has(command.name)) return command; + return { + ...command, + displayCategory: 'skill' as const, + description: command.description || t('skills.run'), + }; + }); + }, [connection.commands, connection.skills, hiddenSlashCommands, t]); + + const welcomeHeaderProps = useMemo( + () => ({ + version: connection.capabilities?.qwenCodeVersion || '', + cwd: connection.workspaceCwd || '', + currentModel, + currentMode, + }), + [ + connection.capabilities?.qwenCodeVersion, + connection.workspaceCwd, + currentModel, + currentMode, + ], + ); + + const welcomeHeader = useMemo( + () => + renderWelcomeHeader ? ( + renderWelcomeHeader(welcomeHeaderProps) + ) : ( + + ), + [renderWelcomeHeader, welcomeHeaderProps], + ); + + const appClassName = [ + styles.app, + selectedTheme === 'light' ? styles.themeLight : styles.themeDark, + externalClassName, + ] + .filter(Boolean) + .join(' '); + + return ( + + +
+ {!onToast && } + {dialogOpen && ( +
+ {showResumeDialog && ( + { + sessionActions + .loadSession(sessionId) + .catch((error: unknown) => { + reportError(error, 'Failed to load session'); + }); + }} + onClose={() => setShowResumeDialog(false)} + /> + )} + {showDeleteDialog && ( + { + store.dispatch([ + { + type: 'status', + text: + sessionIds.length === 1 + ? `${t('delete.deleted')} (${sessionIds[0]!.slice(0, 8)})` + : t('delete.deletedCount', { + count: sessionIds.length, + }), + }, + ]); + }} + onError={(error) => { + if (isAlreadyDispatched(error)) return; + const reason = + error instanceof Error ? error.message : String(error); + pushToast('error', t('delete.failed', { reason })); + }} + onClose={() => setShowDeleteDialog(false)} + /> + )} + {showReleaseDialog && ( + { + store.dispatch([ + { + type: 'status', + text: `${t('release.released')} (${sessionId.slice(0, 8)})`, + }, + ]); + }} + onError={(error) => { + if (isAlreadyDispatched(error)) return; + const reason = + error instanceof Error ? error.message : String(error); + pushToast('error', t('release.failed', { reason })); + }} + onClose={() => setShowReleaseDialog(false)} + /> + )} + {showHelpDialog && ( + setShowHelpDialog(false)} + /> + )} + {showThemeDialog && ( + setShowThemeDialog(false)} + /> + )} + {showToolsDialog && ( + setShowToolsDialog(false)} /> + )} +
+ )} + + + +
0 || floatingAgents.length > 0 + ? `${styles.content} ${styles.contentHasMessages}` + : styles.content + } + style={dialogOpen ? { visibility: 'hidden' } : undefined} + > + + {authInlineOpen && ( + { + store.dispatch([ + type === 'error' + ? { type: 'error', text } + : { type: 'status', text }, + ]); + }} + onClose={() => setAuthInlineOpen(false)} + /> + )} + {approvalModeInlineOpen && ( + setApprovalModeInlineOpen(false)} + /> + )} + {modelInlineMode && ( + setModelInlineMode(null)} + /> + )} + {agentsInlineMode && ( + + store.dispatch([{ type: 'status', text }]) + } + onClose={() => setAgentsInlineMode(null)} + /> + )} + {memoryInlineOpen && ( + { + store.dispatch([{ type, text }]); + }} + onClose={() => setMemoryInlineOpen(false)} + /> + )} + {settingsInlineOpen && ( + setSettingsInlineOpen(false)} + onSubDialog={(key) => { + setSettingsInlineOpen(false); + if (key === 'ui.theme') setShowThemeDialog(true); + else if (key === 'fastModel') + setModelInlineMode('fast'); + else if (key === 'tools.approvalMode') + setApprovalModeInlineOpen(true); + }} + /> + )} + + ) : undefined + } + tailKey={ + agentsInlineMode || + memoryInlineOpen || + modelInlineMode || + authInlineOpen || + approvalModeInlineOpen || + settingsInlineOpen + ? `inline-${authInlineOpen ? 'auth' : 'none'}-${modelInlineMode ?? 'none'}-${agentsInlineMode ?? 'none'}-${memoryInlineOpen ? 'memory' : 'none'}-${approvalModeInlineOpen ? 'approval' : 'none'}-${settingsInlineOpen ? 'settings' : 'none'}` + : undefined + } + // The approval-mode/model pickers and the settings panel are + // reachable by mouse from the status bar, so they reveal + // themselves when opened while the user is scrolled up; the + // agents/memory panels keep the user's scroll position. + autoScrollTailIntoView={ + approvalModeInlineOpen || + modelInlineMode !== null || + settingsInlineOpen + } + virtualScrollThreshold={virtualScrollThreshold} + /> + + {btwMessage?.role === 'btw' && ( +
+ +
+ )} + + +
+
+ + + +
+ {floatingTodos.length > 0 && !tasksPanelMessage && ( +
+ +
+ )} + {!shouldHideComposer && ( +
+ + prompt.text)} + onFocusActiveAgents={handleFocusFooterFromEditor} + onPopQueuedMessages={popQueuedPromptsForEdit} + onClearQueuedMessages={clearQueuedPrompts} + currentMode={currentMode} + sessionName={sessionDisplayName} + dialogOpen={bottomHidden || tasksPanelMessage !== null} + followupState={followupState} + onAcceptFollowup={onAcceptFollowup} + onDismissFollowup={onDismissFollowup} + placeholderText={ + !connected + ? t('common.loading') + : streamingState !== 'idle' + ? t('editor.processing') + : t('editor.placeholder') + } + /> +
+ )} + {tasksPanelMessage && ( +
+ { + setTasksPanelMessage(null); + handleReturnToEditor(); + }} + /> +
+ )} + {!shouldHideComposer && + !tasksPanelMessage && + (showShortcuts ? ( + + ) : ( + setApprovalModeInlineOpen((v) => !v)} + onSelectModel={() => + setModelInlineMode((v) => (v ? null : 'main')) + } + onShowContext={() => showContextUsage('/context', false)} + onOpenSettings={() => setSettingsInlineOpen((v) => !v)} + ref={statusBarRef} + onOpenTasks={() => openTasksPanel()} + onReturnToInput={handleReturnToEditor} + taskActivityKey={backgroundTaskActivityKey} + activeGoal={activeGoal} + /> + ))} + + {floatingAgents.length > 0 && !tasksPanelMessage && ( +
+ +
+ )} +
+
+ + + ); +} diff --git a/packages/web-shell/client/adapters/messageTypes.ts b/packages/web-shell/client/adapters/messageTypes.ts new file mode 100644 index 0000000000..be61eaf9ea --- /dev/null +++ b/packages/web-shell/client/adapters/messageTypes.ts @@ -0,0 +1,143 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export type DaemonMessageToolCallStatus = + | 'pending' + | 'in_progress' + | 'completed' + | 'failed'; + +export type DaemonMessageToolKind = + | 'read' + | 'edit' + | 'delete' + | 'move' + | 'search' + | 'execute' + | 'think' + | 'fetch' + | 'switch_mode' + | 'other'; + +export interface DaemonMessageToolCallLocation { + file: string; + line?: number; +} + +export interface DaemonMessageToolCallContent { + type: 'content' | 'diff' | 'terminal'; + content?: { type: string; text?: string; [key: string]: unknown }; + path?: string; + oldText?: string; + newText?: string; + terminalId?: string; +} + +export interface DaemonMessageToolCall { + callId: string; + toolName: string; + args?: Record; + status: DaemonMessageToolCallStatus; + parentToolCallId?: string; + title?: string; + content?: DaemonMessageToolCallContent[]; + rawOutput?: unknown; + locations?: DaemonMessageToolCallLocation[]; + kind?: DaemonMessageToolKind; + startTime?: number; + endTime?: number; + subContent?: string; + subTools?: DaemonMessageToolCall[]; +} + +export interface DaemonMessageTodoItem { + id: string; + content: string; + status: 'pending' | 'in_progress' | 'completed'; + priority?: 'high' | 'medium' | 'low'; +} + +export interface DaemonUserMessage { + id: string; + role: 'user'; + content: string; + images?: Array<{ data: string; mimeType: string }>; +} + +export interface DaemonAssistantMessage { + id: string; + role: 'assistant'; + content: string; + thinking?: string; + isStreaming?: boolean; +} + +export interface DaemonToolGroupMessage { + id: string; + role: 'tool_group'; + tools: DaemonMessageToolCall[]; +} + +export interface DaemonPlanMessage { + id: string; + role: 'plan'; + todos: DaemonMessageTodoItem[]; +} + +export interface DaemonSystemMessage { + id: string; + role: 'system'; + content: string; + variant: 'info' | 'error' | 'warning'; +} + +export interface DaemonUserShellMessage { + id: string; + role: 'user_shell'; + command: string; + output: string; + cwd?: string; +} + +export interface DaemonBtwMessage { + id: string; + role: 'btw'; + question: string; + answer: string; + isPending: boolean; +} + +export interface DaemonInsightProgressMessage { + id: string; + role: 'insight_progress'; + stage: string; + progress: number; + detail?: string; +} + +export interface DaemonInsightReadyMessage { + id: string; + role: 'insight_ready'; + path: string; +} + +export interface DaemonInsightErrorMessage { + id: string; + role: 'insight_error'; + error: string; +} + +export type DaemonMessage = + | DaemonUserMessage + | DaemonAssistantMessage + | DaemonToolGroupMessage + | DaemonPlanMessage + | DaemonSystemMessage + | DaemonUserShellMessage + | DaemonBtwMessage + | DaemonInsightProgressMessage + | DaemonInsightReadyMessage + | DaemonInsightErrorMessage; diff --git a/packages/web-shell/client/adapters/promptTypes.ts b/packages/web-shell/client/adapters/promptTypes.ts new file mode 100644 index 0000000000..ecc0978755 --- /dev/null +++ b/packages/web-shell/client/adapters/promptTypes.ts @@ -0,0 +1,4 @@ +export interface PromptImage { + data: string; + media_type: string; +} diff --git a/packages/web-shell/client/adapters/toolClassification.ts b/packages/web-shell/client/adapters/toolClassification.ts new file mode 100644 index 0000000000..26f9194275 --- /dev/null +++ b/packages/web-shell/client/adapters/toolClassification.ts @@ -0,0 +1,29 @@ +import type { ACPToolCall } from './types'; + +function getRecord(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + return value as Record; +} + +export function isTaskExecutionRaw(raw: unknown): boolean { + return getRecord(raw)?.['type'] === 'task_execution'; +} + +export function isSubAgentToolCall(tool: ACPToolCall): boolean { + const name = tool.toolName.toLowerCase(); + if (name === 'agent' || name === 'task') return true; + if (tool.subTools || tool.subContent) return true; + if (isTaskExecutionRaw(tool.rawOutput)) return true; + return Boolean(tool.args?.subagent_type); +} + +export function isBackgroundSubAgentToolCall(tool: ACPToolCall): boolean { + if (!isSubAgentToolCall(tool)) return false; + const rawOutput = getRecord(tool.rawOutput); + return ( + rawOutput?.['status'] === 'background' || + tool.args?.run_in_background === true + ); +} diff --git a/packages/web-shell/client/adapters/transcriptAdapter.test.ts b/packages/web-shell/client/adapters/transcriptAdapter.test.ts new file mode 100644 index 0000000000..b705ae5395 --- /dev/null +++ b/packages/web-shell/client/adapters/transcriptAdapter.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; +import type { + DaemonTranscriptBlock, + DaemonTranscriptState, +} from '@qwen-code/webui/daemon-react-sdk'; +import { extractPendingPermission } from './transcriptAdapter'; + +function state(blocks: DaemonTranscriptBlock[]): DaemonTranscriptState { + return { + blocks, + blockIndexById: Object.fromEntries( + blocks.map((block, index) => [block.id, index]), + ), + toolBlockByCallId: {}, + trimmedToolNotificationByCallId: {}, + permissionBlockByRequestId: {}, + toolProgress: {}, + nextOrdinal: blocks.length, + now: Date.now(), + maxBlocks: 1000, + awaitingResync: false, + resyncRequiredCount: 0, + }; +} + +describe('extractPendingPermission', () => { + it('extracts pending AskUserQuestion options and raw input', () => { + const permission = { + id: 'perm-1', + kind: 'permission', + requestId: 'request-1', + sessionId: 'session-1', + title: 'Ask user 1 question', + options: [ + { + optionId: 'proceed_once', + label: 'Submit', + raw: { kind: 'allow_once', name: 'Submit' }, + }, + { + optionId: 'cancel', + label: 'Cancel', + raw: { kind: 'reject_once', name: 'Cancel' }, + }, + ], + toolCall: { + rawInput: { + questions: [ + { + header: '姓名', + question: '请问学生姓名是什么?', + options: [{ label: '张三', description: '示例姓名' }], + }, + ], + }, + }, + preview: { kind: 'generic' }, + createdAt: 1, + updatedAt: 1, + } as DaemonTranscriptBlock; + + expect(extractPendingPermission(state([permission]).blocks)).toMatchObject({ + id: 'request-1', + sessionId: 'session-1', + title: 'Ask user 1 question', + options: [ + { id: 'proceed_once', label: 'Submit', kind: 'allow_once' }, + { id: 'cancel', label: 'Cancel', kind: 'reject_once' }, + ], + rawInput: { + questions: [ + { + header: '姓名', + question: '请问学生姓名是什么?', + options: [{ label: '张三', description: '示例姓名' }], + }, + ], + }, + }); + }); + + it('extracts toolCallId from toolCall.toolCallId', () => { + const permission = { + id: 'perm-tc1', + kind: 'permission', + sessionId: 'session-1', + requestId: 'request-tc1', + resolved: undefined, + title: 'Bash: ls', + options: [{ optionId: 'allow', label: 'Allow', raw: {} }], + toolCall: { toolCallId: 'call-abc', rawInput: {} }, + preview: { kind: 'generic' }, + createdAt: 1, + updatedAt: 1, + clientReceivedAt: 1, + } as DaemonTranscriptBlock; + + const result = extractPendingPermission(state([permission]).blocks); + expect(result?.toolCallId).toBe('call-abc'); + }); + + it('falls back to toolCall.id when toolCallId is absent', () => { + const permission = { + id: 'perm-tc2', + kind: 'permission', + sessionId: 'session-1', + requestId: 'request-tc2', + resolved: undefined, + title: 'Bash: pwd', + options: [{ optionId: 'allow', label: 'Allow', raw: {} }], + toolCall: { id: 'call-xyz', rawInput: {} }, + preview: { kind: 'generic' }, + createdAt: 1, + updatedAt: 1, + clientReceivedAt: 1, + } as DaemonTranscriptBlock; + + const result = extractPendingPermission(state([permission]).blocks); + expect(result?.toolCallId).toBe('call-xyz'); + }); + + it('returns undefined toolCallId when toolCall has neither field', () => { + const permission = { + id: 'perm-tc3', + kind: 'permission', + sessionId: 'session-1', + requestId: 'request-tc3', + resolved: undefined, + title: 'Read: file', + options: [{ optionId: 'allow', label: 'Allow', raw: {} }], + toolCall: { rawInput: {} }, + preview: { kind: 'generic' }, + createdAt: 1, + updatedAt: 1, + clientReceivedAt: 1, + } as DaemonTranscriptBlock; + + const result = extractPendingPermission(state([permission]).blocks); + expect(result?.toolCallId).toBeUndefined(); + }); +}); diff --git a/packages/web-shell/client/adapters/transcriptAdapter.ts b/packages/web-shell/client/adapters/transcriptAdapter.ts new file mode 100644 index 0000000000..60bb10c333 --- /dev/null +++ b/packages/web-shell/client/adapters/transcriptAdapter.ts @@ -0,0 +1,91 @@ +import type { DaemonTranscriptBlock } from '@qwen-code/webui/daemon-react-sdk'; +import type { PermissionRequest, PermissionOptionKind } from './types'; + +type PermissionTranscriptBlock = Extract< + DaemonTranscriptBlock, + { kind: 'permission' } +>; + +export function extractPendingPermission( + blocks: readonly DaemonTranscriptBlock[], +): PermissionRequest | null { + for (const block of blocks) { + if (!isPermissionBlock(block)) continue; + const perm = block; + if (perm.resolved) continue; + const toolCallRecord = getRecord(perm.toolCall); + const toolCallId = + typeof toolCallRecord?.['toolCallId'] === 'string' + ? toolCallRecord['toolCallId'] + : typeof toolCallRecord?.['id'] === 'string' + ? toolCallRecord['id'] + : undefined; + const toolKind = + typeof toolCallRecord?.['kind'] === 'string' + ? toolCallRecord['kind'] + : undefined; + return { + id: perm.requestId, + sessionId: perm.sessionId, + toolCallId, + title: perm.title, + toolKind, + content: [ + { + type: 'text', + text: perm.title || 'Tool permission', + }, + ], + options: perm.options.map((opt) => ({ + id: opt.optionId, + label: opt.label, + kind: getPermissionOptionKind(opt.raw), + })), + rawInput: getPermissionRawInput(perm.toolCall), + }; + } + return null; +} + +function isPermissionBlock( + block: DaemonTranscriptBlock, +): block is PermissionTranscriptBlock { + return block.kind === 'permission'; +} + +function getPermissionRawInput( + toolCall: unknown, +): Record | undefined { + const record = getRecord(toolCall); + if (!record) { + return undefined; + } + + const nested = + getRecord(record['rawInput']) ?? + getRecord(record['input']) ?? + getRecord(record['args']); + return nested ?? record; +} + +function getRecord(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + return value as Record; +} + +function getPermissionOptionKind( + raw: unknown, +): PermissionOptionKind | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return undefined; + } + const kind = (raw as Record).kind; + return kind === 'allow_once' || + kind === 'allow_always' || + kind === 'reject_once' || + kind === 'reject_always' + ? kind + : undefined; +} diff --git a/packages/web-shell/client/adapters/transcriptToMessages.test.ts b/packages/web-shell/client/adapters/transcriptToMessages.test.ts new file mode 100644 index 0000000000..d612fcb638 --- /dev/null +++ b/packages/web-shell/client/adapters/transcriptToMessages.test.ts @@ -0,0 +1,2691 @@ +import { describe, expect, it } from 'vitest'; +import type { + DaemonShellTranscriptBlock, + DaemonStatusTranscriptBlock, + DaemonTextTranscriptBlock, + DaemonToolTranscriptBlock, + DaemonTranscriptBlock, + DaemonUserShellTranscriptBlock, +} from '@qwen-code/sdk/daemon'; +import { transcriptBlocksToDaemonMessages } from './transcriptToMessages.js'; + +function textBlock( + id: string, + kind: 'user' | 'assistant' | 'thought', + text: string, + createdAt: number, + streaming = false, + overrides: Partial = {}, +): DaemonTextTranscriptBlock { + return { + id, + kind, + text, + streaming, + clientReceivedAt: createdAt, + createdAt, + updatedAt: createdAt, + ...overrides, + }; +} + +function statusBlock( + id: string, + text: string, + createdAt: number, +): DaemonStatusTranscriptBlock { + return { + id, + kind: 'status', + text, + clientReceivedAt: createdAt, + createdAt, + updatedAt: createdAt, + }; +} + +function promptCancelledBlock( + id: string, + createdAt: number, +): DaemonTranscriptBlock { + return { + id, + kind: 'prompt_cancelled', + clientReceivedAt: createdAt, + createdAt, + updatedAt: createdAt, + }; +} + +function shellBlock( + id: string, + text: string, + createdAt: number, + overrides: Partial = {}, +): DaemonShellTranscriptBlock { + return { + id, + kind: 'shell', + text, + clientReceivedAt: createdAt, + createdAt, + updatedAt: createdAt, + ...overrides, + }; +} + +function userShellBlock( + id: string, + text: string, + command: string, + createdAt: number, + overrides: Partial = {}, +): DaemonUserShellTranscriptBlock { + return { + id, + kind: 'user_shell', + text, + command, + clientReceivedAt: createdAt, + createdAt, + updatedAt: createdAt, + ...overrides, + }; +} + +function toolBlock( + id: string, + toolCallId: string, + status: string, + createdAt: number, + overrides: Partial = {}, +): DaemonToolTranscriptBlock { + return { + id, + kind: 'tool', + toolCallId, + title: overrides.title ?? 'Tool', + status, + toolName: overrides.toolName ?? 'Read', + toolKind: overrides.toolKind, + preview: overrides.preview ?? { kind: 'generic' }, + rawInput: overrides.rawInput, + rawOutput: overrides.rawOutput, + content: overrides.content, + locations: overrides.locations, + details: overrides.details, + parentToolCallId: overrides.parentToolCallId, + subagentType: overrides.subagentType, + clientReceivedAt: createdAt, + createdAt, + updatedAt: overrides.updatedAt ?? createdAt, + }; +} + +describe('transcriptBlocksToDaemonMessages', () => { + it('renders daemon plan status blocks as plan messages', () => { + const plan = { + sessionUpdate: 'plan', + entries: [ + { + content: '检查项目结构', + priority: 'medium', + status: 'pending', + }, + { + content: '运行类型检查', + priority: 'high', + status: 'in_progress', + }, + ], + }; + + const messages = transcriptBlocksToDaemonMessages([ + statusBlock('plan-1', `plan: ${JSON.stringify(plan)}`, 1), + ]); + + expect(messages).toEqual([ + { + id: 'plan-1', + role: 'plan', + todos: [ + { + id: 'plan-0', + content: '检查项目结构', + priority: 'medium', + status: 'pending', + }, + { + id: 'plan-1', + content: '运行类型检查', + priority: 'high', + status: 'in_progress', + }, + ], + }, + ]); + }); + + it('ignores daemon plan entries without content', () => { + const plan = { + sessionUpdate: 'plan', + entries: [ + { content: '', priority: 'high', status: 'in_progress' }, + { content: '运行类型检查', priority: 'high', status: 'in_progress' }, + ], + }; + + const messages = transcriptBlocksToDaemonMessages([ + statusBlock('plan-1', `plan: ${JSON.stringify(plan)}`, 1), + ]); + + expect(messages).toEqual([ + { + id: 'plan-1', + role: 'plan', + todos: [ + { + id: 'plan-1', + content: '运行类型检查', + priority: 'high', + status: 'in_progress', + }, + ], + }, + ]); + }); + + it('keeps each TodoWrite call as a distinct tool entry with its own todos', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('todo-1', 'todo-call-1', 'completed', 1, { + title: 'Update Todos', + toolName: 'TodoWrite', + rawInput: { + todos: [ + { + content: '检查项目结构', + priority: 'medium', + status: 'completed', + }, + ], + }, + }), + toolBlock('todo-2', 'todo-call-2', 'completed', 2, { + title: 'Update Todos', + toolName: 'TodoWrite', + rawInput: { + todos: [ + { + content: '运行类型检查', + priority: 'high', + status: 'in_progress', + }, + ], + }, + }), + ]); + + // Adjacent tool blocks share one tool_group (Native CLI batch parity), + // but each TodoWrite call keeps its own tool entry and todo payload. + expect(messages).toEqual([ + { + id: 'tg-todo-1', + role: 'tool_group', + tools: [ + expect.objectContaining({ + callId: 'todo-call-1', + toolName: 'TodoWrite', + }), + expect.objectContaining({ + callId: 'todo-call-2', + toolName: 'TodoWrite', + }), + ], + }, + ]); + }); + + it('merges adjacent top-level tool blocks into one tool_group', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'tc1', 'completed', 1, { toolName: 'Read' }), + toolBlock('t2', 'tc2', 'completed', 2, { toolName: 'Grep' }), + ]); + + expect(messages).toEqual([ + { + id: 'tg-t1', + role: 'tool_group', + tools: [ + expect.objectContaining({ callId: 'tc1', toolName: 'Read' }), + expect.objectContaining({ callId: 'tc2', toolName: 'Grep' }), + ], + }, + ]); + }); + + it('starts a new tool_group after intervening assistant text', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'tc1', 'completed', 1, { toolName: 'Read' }), + textBlock('a1', 'assistant', 'found it, editing now', 2), + toolBlock('t2', 'tc2', 'completed', 3, { toolName: 'Edit' }), + ]); + + expect(messages).toMatchObject([ + { role: 'tool_group', tools: [{ callId: 'tc1' }] }, + { role: 'assistant', content: 'found it, editing now' }, + { role: 'tool_group', tools: [{ callId: 'tc2' }] }, + ]); + }); + + it('starts a new tool_group after an intervening thought block', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'tc1', 'completed', 1, { toolName: 'Read' }), + textBlock('th1', 'thought', 'next I should grep', 2), + toolBlock('t2', 'tc2', 'completed', 3, { toolName: 'Grep' }), + ]); + + expect(messages).toMatchObject([ + { role: 'tool_group', tools: [{ callId: 'tc1' }] }, + { role: 'assistant', thinking: 'next I should grep' }, + { role: 'tool_group', tools: [{ callId: 'tc2' }] }, + ]); + }); + + it('keeps merged groups intact when a member tool completes later', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'tc1', 'in_progress', 1, { toolName: 'Read' }), + toolBlock('t2', 'tc2', 'in_progress', 2, { toolName: 'Grep' }), + toolBlock('t1-done', 'tc1', 'completed', 3, { + toolName: 'Read', + updatedAt: 4, + }), + ]); + + expect(messages).toHaveLength(1); + const tools = + messages[0].role === 'tool_group' ? messages[0].tools : undefined; + expect(tools).toHaveLength(2); + expect(tools?.[0]).toMatchObject({ callId: 'tc1', status: 'completed' }); + expect(tools?.[1]).toMatchObject({ callId: 'tc2', status: 'in_progress' }); + }); + + it('never merges subagent calls into or after a regular tool_group', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'tc1', 'completed', 1, { toolName: 'Read' }), + toolBlock('agent-1', 'agent-call-1', 'in_progress', 2, { + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + }), + toolBlock('t2', 'tc2', 'completed', 3, { toolName: 'Grep' }), + ]); + + expect(messages).toMatchObject([ + { role: 'tool_group', tools: [{ callId: 'tc1' }] }, + { role: 'tool_group', tools: [{ callId: 'agent-call-1' }] }, + { role: 'tool_group', tools: [{ callId: 'tc2' }] }, + ]); + }); + + it('does not merge real tool calls into synthetic raw-shell groups', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('u1', 'user', 'run it', 1), + shellBlock('sh1', 'raw shell output\n', 2), + toolBlock('t1', 'tc1', 'completed', 3, { toolName: 'Read' }), + ]); + + expect(messages).toMatchObject([ + { role: 'user', content: 'run it' }, + { id: 'sh1', role: 'tool_group', tools: [{ toolName: 'shell' }] }, + { id: 'tg-t1', role: 'tool_group', tools: [{ callId: 'tc1' }] }, + ]); + }); + + it('preserves tool block titles on message tool calls', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('ask-1', 'ask-call-1', 'completed', 1, { + toolName: 'AskUserQuestion', + title: 'Ask user 2 questions', + rawOutput: + 'User has provided the following answers:\n\n**班级**: 一班\n**学号**: 001', + }), + ]); + + expect(messages).toMatchObject([ + { + role: 'tool_group', + tools: [ + { + callId: 'ask-call-1', + toolName: 'AskUserQuestion', + title: 'Ask user 2 questions', + }, + ], + }, + ]); + }); + + it('renders insight progress messages', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock( + 'insight-1', + 'assistant', + '{"insight_progress":{"stage":"scan","progress":0.5,"detail":"reading"}}', + 1, + ), + ]); + + expect(messages).toEqual([ + { + id: 'insight-1-ip', + role: 'insight_progress', + stage: 'scan', + progress: 0.5, + detail: 'reading', + }, + ]); + }); + + it('renders terminal insight messages with surrounding text', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock( + 'insight-1', + 'assistant', + 'before {"insight_ready":{"path":"/tmp/report.md"}} middle {"insight_error":{"error":"boom"}} after', + 1, + ), + ]); + + expect(messages).toEqual([ + { id: 'insight-1-t-0', role: 'assistant', content: 'before' }, + { id: 'insight-1-ir-0', role: 'insight_ready', path: '/tmp/report.md' }, + { id: 'insight-1-t-2', role: 'assistant', content: 'middle' }, + { id: 'insight-1-ie-0', role: 'insight_error', error: 'boom' }, + { id: 'insight-1-t-4', role: 'assistant', content: 'after' }, + ]); + }); + + it('keeps malformed insight JSON as assistant text', () => { + const content = 'before {"insight_ready": bad} after'; + const messages = transcriptBlocksToDaemonMessages([ + textBlock('insight-1', 'assistant', content, 1), + ]); + + expect(messages).toMatchObject([ + { id: 'insight-1', role: 'assistant', content }, + ]); + }); + + it('keeps parented assistant chunks inside a subagent', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('agent-start', 'agent-1', 'in_progress', 10, { + title: 'Agent: 分析项目', + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + }), + textBlock('assistant-sub', 'assistant', 'subagent output', 20, true, { + parentToolCallId: 'agent-1', + }), + toolBlock('read-sub', 'read-1', 'completed', 30, { + title: 'Read file', + toolName: 'Read', + parentToolCallId: 'agent-1', + }), + toolBlock('agent-end', 'agent-1', 'completed', 40, { + title: 'Agent: 分析项目', + toolName: 'agent', + rawOutput: { type: 'task_execution' }, + }), + textBlock('assistant-main', 'assistant', 'main output', 50, false), + ]); + + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ + role: 'tool_group', + tools: [ + { + callId: 'agent-1', + status: 'completed', + subContent: 'subagent output', + subTools: [{ callId: 'read-1', status: 'completed' }], + }, + ], + }); + expect(messages[1]).toMatchObject({ + id: 'assistant-main', + role: 'assistant', + content: 'main output', + }); + }); + + it('keeps parented compacted replay subagent content nested', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('agent-completed', 'agent-1', 'completed', 10, { + title: 'Agent: 查询官网活动', + toolName: 'agent', + rawOutput: { + type: 'task_execution', + result: 'subagent final answer', + }, + updatedAt: 100, + }), + textBlock('sub-thought', 'thought', 'subagent thinking', 20, false, { + parentToolCallId: 'agent-1', + }), + textBlock('sub-assistant', 'assistant', 'subagent answer', 30, false, { + parentToolCallId: 'agent-1', + }), + toolBlock('sub-fetch', 'fetch-1', 'completed', 40, { + title: 'WebFetch', + toolName: 'WebFetch', + parentToolCallId: 'agent-1', + }), + textBlock('main-assistant', 'assistant', 'main answer', 110), + ]); + + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ + role: 'tool_group', + tools: [ + { + callId: 'agent-1', + status: 'completed', + subContent: 'subagent thinkingsubagent answer', + subTools: [{ callId: 'fetch-1', status: 'completed' }], + }, + ], + }); + expect(messages[1]).toMatchObject({ + id: 'main-assistant', + role: 'assistant', + content: 'main answer', + }); + expect( + messages.some( + (message) => + message.role === 'assistant' && + message.content.includes('subagent answer'), + ), + ).toBe(false); + }); + + it('keeps parallel top-level subagents as sibling tool messages', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('agent-1', 'agent-call-1', 'in_progress', 10, { + title: 'Agent: Correctness review agent', + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + }), + toolBlock('agent-2', 'agent-call-2', 'in_progress', 20, { + title: 'Agent: Security review agent', + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + }), + toolBlock('agent-3', 'agent-call-3', 'in_progress', 30, { + title: 'Agent: Performance review agent', + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + }), + ]); + + expect(messages).toHaveLength(3); + expect(messages).toMatchObject([ + { + role: 'tool_group', + tools: [{ callId: 'agent-call-1' }], + }, + { + role: 'tool_group', + tools: [{ callId: 'agent-call-2' }], + }, + { + role: 'tool_group', + tools: [{ callId: 'agent-call-3' }], + }, + ]); + expect( + messages[0]?.role === 'tool_group' && messages[0].tools[0], + ).not.toHaveProperty('subTools'); + expect( + messages[1]?.role === 'tool_group' && messages[1].tools[0], + ).not.toHaveProperty('subTools'); + expect( + messages[2]?.role === 'tool_group' && messages[2].tools[0], + ).not.toHaveProperty('subTools'); + }); + + it('merges parallel subagent completion by callId instead of stack order', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('agent-1-start', 'agent-1', 'in_progress', 10, { + title: 'Agent: first', + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + }), + toolBlock('agent-2-start', 'agent-2', 'in_progress', 20, { + title: 'Agent: second', + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + }), + toolBlock('agent-1-end', 'agent-1', 'completed', 30, { + title: 'Agent: first done', + toolName: 'agent', + rawOutput: { + type: 'task_execution', + result: 'first result', + }, + updatedAt: 35, + }), + toolBlock('agent-2-end', 'agent-2', 'completed', 40, { + title: 'Agent: second done', + toolName: 'agent', + rawOutput: { + type: 'task_execution', + result: 'second result', + }, + updatedAt: 45, + }), + ]); + + expect(messages).toHaveLength(2); + expect(messages).toMatchObject([ + { + id: 'tg-agent-1-start', + role: 'tool_group', + tools: [ + { + callId: 'agent-1', + status: 'completed', + title: 'Agent: first done', + rawOutput: { result: 'first result' }, + }, + ], + }, + { + id: 'tg-agent-2-start', + role: 'tool_group', + tools: [ + { + callId: 'agent-2', + status: 'completed', + title: 'Agent: second done', + rawOutput: { result: 'second result' }, + }, + ], + }, + ]); + }); + + it('merges cancelled parallel subagent by callId without closing sibling', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('agent-1-start', 'agent-1', 'in_progress', 10, { + title: 'Agent: first', + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + }), + toolBlock('agent-2-start', 'agent-2', 'in_progress', 20, { + title: 'Agent: second', + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + }), + toolBlock('agent-1-cancel', 'agent-1', 'cancelled', 30, { + title: 'Agent: first', + toolName: 'agent', + details: 'Agent was cancelled by user', + updatedAt: 35, + }), + toolBlock('agent-2-end', 'agent-2', 'completed', 40, { + title: 'Agent: second done', + toolName: 'agent', + rawOutput: { + type: 'task_execution', + result: 'second result', + }, + updatedAt: 45, + }), + ]); + + expect(messages).toHaveLength(2); + expect(messages).toMatchObject([ + { + id: 'tg-agent-1-start', + role: 'tool_group', + tools: [ + { + callId: 'agent-1', + status: 'completed', + endTime: 35, + rawOutput: { + status: 'cancelled', + reason: 'Agent was cancelled by user', + text: 'Agent was cancelled by user', + }, + }, + ], + }, + { + id: 'tg-agent-2-start', + role: 'tool_group', + tools: [ + { + callId: 'agent-2', + status: 'completed', + title: 'Agent: second done', + endTime: 45, + rawOutput: { result: 'second result' }, + }, + ], + }, + ]); + }); + + it('keeps background subagent launches pending and resumes main thread', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('agent-start', 'agent-1', 'in_progress', 10, { + title: 'Agent: Correctness review', + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + }), + toolBlock('agent-background', 'agent-1', 'completed', 20, { + title: 'Agent: Correctness review', + toolName: 'agent', + rawOutput: { + type: 'task_execution', + taskDescription: 'Agent 1: Correctness review', + status: 'background', + }, + updatedAt: 25, + }), + textBlock('thought-main', 'thought', 'wait for background agent', 30), + textBlock('assistant-main', 'assistant', 'main continues', 40), + ]); + + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ + id: 'tg-agent-start', + role: 'tool_group', + tools: [ + { + callId: 'agent-1', + status: 'pending', + rawOutput: { + status: 'background', + taskDescription: 'Agent 1: Correctness review', + }, + }, + ], + }); + expect( + messages[0].role === 'tool_group' + ? messages[0].tools[0].endTime + : undefined, + ).toBeUndefined(); + expect(messages[1]).toMatchObject({ + id: 'thought-main', + role: 'assistant', + content: 'main continues', + thinking: 'wait for background agent', + }); + }); + + it('keeps merged background subagent blocks from capturing main-thread text', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('agent-background', 'agent-1', 'completed', 20, { + title: 'Agent: Correctness review', + toolName: 'agent', + rawInput: { + description: 'Agent 1: Correctness review', + subagent_type: 'general-purpose', + run_in_background: true, + }, + rawOutput: { + type: 'task_execution', + taskDescription: 'Agent 1: Correctness review', + status: 'background', + }, + updatedAt: 25, + }), + textBlock('thought-main', 'thought', 'server diff looks clean', 30), + textBlock('assistant-main', 'assistant', 'waiting for agents', 40), + ]); + + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ + id: 'tg-agent-background', + role: 'tool_group', + tools: [ + { + callId: 'agent-1', + status: 'pending', + rawOutput: { + status: 'background', + taskDescription: 'Agent 1: Correctness review', + }, + }, + ], + }); + expect(messages[1]).toMatchObject({ + id: 'thought-main', + role: 'assistant', + content: 'waiting for agents', + thinking: 'server diff looks clean', + }); + }); + + it('keeps unparented assistant text in the main transcript', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('agent-1', 'agent-call-1', 'in_progress', 10, { + title: 'Agent: first', + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + }), + toolBlock('agent-2', 'agent-call-2', 'in_progress', 20, { + title: 'Agent: second', + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + }), + textBlock('a1', 'assistant', 'unparented stream', 30), + ]); + + expect(messages).toHaveLength(3); + expect(messages[0]).toMatchObject({ + role: 'tool_group', + tools: [{ callId: 'agent-call-1' }], + }); + expect(messages[1]).toMatchObject({ + role: 'tool_group', + tools: [{ callId: 'agent-call-2' }], + }); + const assistant = messages.find((message) => message.role === 'assistant'); + expect(assistant).toMatchObject({ content: 'unparented stream' }); + expect( + messages[1]?.role === 'tool_group' && messages[1].tools[0], + ).not.toHaveProperty('subContent'); + }); + + it('merges streaming assistant chunks into one message', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('a1', 'assistant', 'hello ', 1, true), + textBlock('a2', 'assistant', 'world', 2, false), + ]); + + expect(messages).toEqual([ + { + id: 'a1', + role: 'assistant', + content: 'hello world', + isStreaming: false, + }, + ]); + }); + + it('creates standalone user_shell message for user shell output', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('u1', 'user', '! ls', 1), + userShellBlock('sh1', 'file1.ts\nfile2.ts\n', 'ls', 2), + statusBlock('st1', 'Shell command exited with code 0', 3), + ]); + + expect(messages).toHaveLength(3); + expect(messages[0]).toMatchObject({ role: 'user', content: '! ls' }); + expect(messages[1]).toMatchObject({ + id: 'sh1', + role: 'user_shell', + command: 'ls', + output: 'file1.ts\nfile2.ts\n', + }); + expect(messages[2]).toMatchObject({ + role: 'system', + content: 'Shell command exited with code 0', + }); + }); + + it('appends shell output to preceding tool_group', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'tc1', 'completed', 1, { + toolName: 'bash', + toolKind: 'execute', + }), + shellBlock('sh1', 'output text', 2), + ]); + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + role: 'tool_group', + tools: [ + { + callId: 'tc1', + rawOutput: 'output text', + }, + ], + }); + }); + + it('keeps shell output attached when the tool later completes', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'tc1', 'in_progress', 1, { + toolName: 'bash', + toolKind: 'execute', + }), + shellBlock('sh1', 'output text', 2), + toolBlock('t2', 'tc1', 'completed', 3, { + toolName: 'bash', + toolKind: 'execute', + title: 'Shell complete', + updatedAt: 4, + }), + ]); + + const tool = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(tool).toMatchObject({ + callId: 'tc1', + title: 'Shell complete', + status: 'completed', + rawOutput: 'output text', + endTime: 4, + }); + }); + + it('does not stringify structured raw output before shell text', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'tc1', 'completed', 1, { + toolName: 'bash', + toolKind: 'execute', + rawOutput: { type: 'structured' }, + }), + shellBlock('sh1', 'output text', 2), + ]); + + const tool = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(tool?.rawOutput).toBe('output text'); + }); + + it('concatenates multiple shell blocks after user message', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('u1', 'user', '! find .', 1), + shellBlock('sh1', 'chunk1\n', 2), + shellBlock('sh2', 'chunk2\n', 3), + ]); + + expect(messages).toHaveLength(2); + expect(messages[1]).toMatchObject({ + role: 'tool_group', + tools: [{ rawOutput: 'chunk1\nchunk2\n' }], + }); + }); + + it('attaches shell output to the running execute tool in a merged group', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'tc-bash', 'running', 1, { + toolName: 'bash', + toolKind: 'execute', + }), + toolBlock('t2', 'tc-read', 'completed', 2, { toolName: 'Read' }), + shellBlock('sh1', 'bash output', 3), + ]); + + expect(messages).toHaveLength(1); + const tools = + messages[0].role === 'tool_group' ? messages[0].tools : undefined; + expect(tools?.[0]).toMatchObject({ + callId: 'tc-bash', + rawOutput: 'bash output', + }); + expect(tools?.[1]?.rawOutput).toBeUndefined(); + }); + + it('attaches shell output to the most recent running execute tool', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'tc-bash-1', 'completed', 1, { + toolName: 'bash', + toolKind: 'execute', + rawOutput: 'first output', + }), + toolBlock('t2', 'tc-bash-2', 'running', 2, { + toolName: 'bash', + toolKind: 'execute', + }), + shellBlock('sh1', 'second output', 3), + ]); + + expect(messages).toHaveLength(1); + const tools = + messages[0].role === 'tool_group' ? messages[0].tools : undefined; + expect(tools?.[0]).toMatchObject({ + callId: 'tc-bash-1', + rawOutput: 'first output', + }); + expect(tools?.[1]).toMatchObject({ + callId: 'tc-bash-2', + rawOutput: 'second output', + }); + }); + + it('falls back to the last execute tool when every status is terminal', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'tc-bash', 'completed', 1, { + toolName: 'bash', + toolKind: 'execute', + }), + toolBlock('t2', 'tc-read', 'completed', 2, { toolName: 'Read' }), + shellBlock('sh1', 'replayed output', 3), + ]); + + expect(messages).toHaveLength(1); + const tools = + messages[0].role === 'tool_group' ? messages[0].tools : undefined; + expect(tools?.[0]).toMatchObject({ + callId: 'tc-bash', + rawOutput: 'replayed output', + }); + expect(tools?.[1]?.rawOutput).toBeUndefined(); + }); + + it('merges thought across interleaved tool blocks but splits content after tools', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('t1', 'thought', 'thinking part 1', 1), + toolBlock('tool1', 'tc1', 'completed', 2, { toolName: 'Read' }), + textBlock('t2', 'thought', ' thinking part 2', 3), + textBlock('a1', 'assistant', 'response part 1', 4), + toolBlock('tool2', 'tc2', 'completed', 5, { toolName: 'Edit' }), + textBlock('a2', 'assistant', ' response part 2', 6), + ]); + + const assistantMsgs = messages.filter((m) => m.role === 'assistant'); + expect(assistantMsgs).toHaveLength(3); + expect(assistantMsgs[0]).toMatchObject({ + role: 'assistant', + content: '', + thinking: 'thinking part 1', + }); + expect(assistantMsgs[1]).toMatchObject({ + role: 'assistant', + content: 'response part 1', + thinking: ' thinking part 2', + }); + expect(assistantMsgs[2]).toMatchObject({ + role: 'assistant', + content: ' response part 2', + }); + + const toolGroups = messages.filter((m) => m.role === 'tool_group'); + expect(toolGroups).toHaveLength(2); + }); + + it('keeps thought blocks after tool groups in transcript order', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('t1', 'thought', 'first thought', 1), + toolBlock('tool1', 'tc1', 'completed', 2, { toolName: 'Read' }), + textBlock('t2', 'thought', 'second thought', 3), + toolBlock('tool2', 'tc2', 'completed', 4, { toolName: 'ListFiles' }), + textBlock('t3', 'thought', 'third thought', 5), + toolBlock('tool3', 'tc3', 'completed', 6, { toolName: 'Glob' }), + textBlock('t4', 'thought', 'final thought', 7), + ]); + + expect(messages).toMatchObject([ + { role: 'assistant', thinking: 'first thought' }, + { role: 'tool_group', tools: [{ callId: 'tc1' }] }, + { role: 'assistant', thinking: 'second thought' }, + { role: 'tool_group', tools: [{ callId: 'tc2' }] }, + { role: 'assistant', thinking: 'third thought' }, + { role: 'tool_group', tools: [{ callId: 'tc3' }] }, + { role: 'assistant', thinking: 'final thought' }, + ]); + }); + + it('preserves title, args, and rawOutput on completed parallel agents', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('agent-1', 'call-1', 'completed', 10, { + title: 'Agent: 分析项目核心原理', + toolName: 'agent', + rawInput: { + description: '分析项目核心原理', + prompt: '请分析...', + subagent_type: 'general-purpose', + run_in_background: true, + }, + rawOutput: { + type: 'task_execution', + subagentName: 'general-purpose', + taskDescription: '分析项目核心原理', + status: 'background', + }, + updatedAt: 11, + }), + toolBlock('agent-2', 'call-2', 'completed', 10, { + title: 'Agent: 分析项目使用场景和性能', + toolName: 'agent', + rawInput: { + description: '分析项目使用场景和性能', + prompt: '请分析...', + subagent_type: 'general-purpose', + run_in_background: true, + }, + rawOutput: { + type: 'task_execution', + subagentName: 'general-purpose', + taskDescription: '分析项目使用场景和性能', + status: 'background', + }, + updatedAt: 11, + }), + ]); + + expect(messages).toHaveLength(2); + const tool1 = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + const tool2 = + messages[1].role === 'tool_group' ? messages[1].tools[0] : undefined; + expect(tool1).toMatchObject({ + callId: 'call-1', + title: 'Agent: 分析项目核心原理', + args: { description: '分析项目核心原理' }, + rawOutput: { taskDescription: '分析项目核心原理' }, + }); + expect(tool2).toMatchObject({ + callId: 'call-2', + title: 'Agent: 分析项目使用场景和性能', + args: { description: '分析项目使用场景和性能' }, + rawOutput: { taskDescription: '分析项目使用场景和性能' }, + }); + }); + + it('keeps cancelled subagent tools complete with cancellation reason', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('agent-start', 'agent-1', 'in_progress', 10, { + title: 'Agent: Analyze TanStack Virtual architecture', + toolName: 'agent', + rawInput: { + description: 'Analyze TanStack Virtual architecture', + subagent_type: 'general-purpose', + }, + }), + toolBlock('agent-end', 'agent-1', 'cancelled', 20, { + title: 'Agent: Analyze TanStack Virtual architecture', + toolName: 'agent', + details: 'Agent was cancelled by user', + updatedAt: 30, + }), + ]); + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + role: 'tool_group', + tools: [ + { + callId: 'agent-1', + status: 'completed', + endTime: 30, + rawOutput: { + status: 'cancelled', + reason: 'Agent was cancelled by user', + text: 'Agent was cancelled by user', + }, + }, + ], + }); + }); + + it('does not merge assistant text across user message boundaries', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('a1', 'assistant', 'first reply', 1), + toolBlock('tool1', 'tc1', 'completed', 2, { toolName: 'Read' }), + textBlock('u1', 'user', 'follow up', 3), + textBlock('a2', 'assistant', 'second reply', 4), + ]); + + const assistantMsgs = messages.filter((m) => m.role === 'assistant'); + expect(assistantMsgs).toHaveLength(2); + expect(assistantMsgs[0]).toMatchObject({ content: 'first reply' }); + expect(assistantMsgs[1]).toMatchObject({ content: 'second reply' }); + }); + + it('skips non-agent permission blocks', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('a1', 'assistant', 'let me run that', 1), + { + id: 'perm-1', + kind: 'permission', + requestId: 'req-1', + sessionId: 'sess-1', + title: 'Allow Bash?', + options: [{ optionId: 'opt-1', label: 'Allow', raw: {} }], + toolCall: { toolCallId: 'tc-1', rawInput: { command: 'ls' } }, + preview: { kind: 'generic' as const }, + clientReceivedAt: 2, + createdAt: 2, + updatedAt: 2, + }, + textBlock('a2', 'assistant', ' done', 3), + ]); + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + role: 'assistant', + content: 'let me run that done', + }); + }); + + it('does not synthesize a generic tool card for AskUserQuestion permissions', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('a1', 'assistant', 'I will ask for student info.', 1), + { + id: 'perm-ask-1', + kind: 'permission', + requestId: 'req-ask-1', + sessionId: 'sess-1', + title: 'Ask user 4 questions', + options: [{ optionId: 'proceed_once', label: 'Submit', raw: {} }], + toolCall: { + toolCallId: 'ask-call-1', + kind: 'think', + status: 'pending', + title: 'Ask user 4 questions', + rawInput: { + questions: [ + { + header: '姓名', + question: '请输入学生的姓名:', + options: [{ label: '张三', description: '示例姓名' }], + }, + ], + }, + }, + preview: { kind: 'generic' as const }, + clientReceivedAt: 2, + createdAt: 2, + updatedAt: 2, + }, + ]); + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + role: 'assistant', + content: 'I will ask for student info.', + }); + }); + + it('uses AskUserQuestion permission title for the completed tool block', () => { + const messages = transcriptBlocksToDaemonMessages([ + { + id: 'perm-ask-1', + kind: 'permission', + requestId: 'req-ask-1', + sessionId: 'sess-1', + title: 'Ask user 4 questions', + options: [{ optionId: 'proceed_once', label: 'Submit', raw: {} }], + toolCall: { + toolCallId: 'ask-call-1', + kind: 'think', + status: 'pending', + title: 'Ask user 4 questions', + rawInput: { + questions: [ + { + header: '姓名', + question: '请输入学生的姓名:', + options: [{ label: '张三', description: '示例姓名' }], + }, + ], + }, + }, + preview: { kind: 'generic' as const }, + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 2, + resolved: 'selected:proceed_once', + }, + toolBlock('ask-tool-1', 'ask-call-1', 'completed', 3, { + toolName: 'ask_user_question', + title: 'ask_user_question', + rawOutput: 'User has provided the following answers:\n\n**姓名**: 张三', + }), + ]); + + expect(messages).toMatchObject([ + { + role: 'tool_group', + tools: [ + { + callId: 'ask-call-1', + title: 'Ask user 4 questions', + args: { + questions: [ + { + header: '姓名', + question: '请输入学生的姓名:', + options: [{ label: '张三', description: '示例姓名' }], + }, + ], + }, + rawOutput: + 'User has provided the following answers:\n\n**姓名**: 张三', + }, + ], + }, + ]); + }); + + it('uses text content as raw output when a tool has no raw output', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('ask-failed', 'ask-call-failed', 'failed', 1, { + toolName: 'ask_user_question', + title: 'ask_user_question', + content: [ + { + type: 'content', + content: { + type: 'text', + text: 'Question 1: "options" must contain between 2 and 4 options.', + }, + }, + ] as DaemonToolTranscriptBlock['content'], + }), + ]); + + expect(messages).toMatchObject([ + { + role: 'tool_group', + tools: [ + { + callId: 'ask-call-failed', + status: 'failed', + rawOutput: + 'Question 1: "options" must contain between 2 and 4 options.', + }, + ], + }, + ]); + }); + + it('prefers AskUserQuestion failure text over echoed input', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('ask-failed', 'ask-call-failed', 'failed', 1, { + toolName: 'ask_user_question', + title: 'ask_user_question', + rawInput: { + questions: [ + { + header: '学生姓名', + question: '请输入学生姓名', + options: [ + { + label: '输入姓名', + description: '请输入学生的完整姓名', + }, + ], + }, + ], + }, + rawOutput: JSON.stringify({ + questions: [ + { + header: '学生姓名', + question: '请输入学生姓名', + }, + ], + }), + details: JSON.stringify({ + questions: [ + { + header: '学生姓名', + question: '请输入学生姓名', + }, + ], + }), + content: [ + { + type: 'content', + content: { + type: 'text', + text: 'Question 1: "options" must contain between 2 and 4 options.', + }, + }, + ] as DaemonToolTranscriptBlock['content'], + }), + ]); + + expect(messages).toMatchObject([ + { + role: 'tool_group', + tools: [ + { + callId: 'ask-call-failed', + status: 'failed', + rawOutput: + 'Question 1: "options" must contain between 2 and 4 options.', + }, + ], + }, + ]); + }); + + it('does not render pending permission blocks as tool messages', () => { + const messages = transcriptBlocksToDaemonMessages([ + { + id: 'perm-agent-1', + kind: 'permission', + requestId: 'req-agent-1', + sessionId: 'sess-1', + title: '查询阿里云官网活动', + options: [{ optionId: 'opt-1', label: 'Allow', raw: {} }], + toolCall: { + toolCallId: 'agent-call-1', + kind: 'other', + status: 'pending', + title: '查询阿里云官网活动', + rawInput: { + description: '查询阿里云官网活动', + prompt: '请查询阿里云官网当前的活动信息。', + subagent_type: 'general-purpose', + }, + }, + preview: { kind: 'generic' as const }, + clientReceivedAt: 2, + createdAt: 2, + updatedAt: 2, + }, + { + id: 'perm-agent-2', + kind: 'permission', + requestId: 'req-agent-2', + sessionId: 'sess-1', + title: '查询百度云官网活动', + options: [{ optionId: 'opt-1', label: 'Allow', raw: {} }], + toolCall: { + toolCallId: 'agent-call-2', + kind: 'other', + status: 'pending', + title: '查询百度云官网活动', + rawInput: { + description: '查询百度云官网活动', + prompt: '请查询百度云官网当前的活动信息。', + subagent_type: 'general-purpose', + }, + }, + preview: { kind: 'generic' as const }, + clientReceivedAt: 2, + createdAt: 2, + updatedAt: 2, + }, + ]); + + expect(messages).toEqual([]); + }); + + it('merges the real subagent tool update after a permission block', () => { + const messages = transcriptBlocksToDaemonMessages([ + { + id: 'perm-agent-1', + kind: 'permission', + requestId: 'req-agent-1', + sessionId: 'sess-1', + title: 'Agent A', + options: [{ optionId: 'opt-1', label: 'Allow', raw: {} }], + toolCall: { + toolCallId: 'agent-1', + rawInput: { subagent_type: 'Explore', prompt: 'a' }, + }, + preview: { kind: 'generic' as const }, + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }, + toolBlock('t1', 'agent-1', 'in_progress', 2, { + toolName: 'Agent', + title: 'Agent A running', + rawInput: { subagent_type: 'Explore', prompt: 'a' }, + }), + toolBlock('t2', 'sub-a1', 'completed', 3, { + toolName: 'web_fetch', + parentToolCallId: 'agent-1', + rawOutput: 'data-a', + }), + ]); + + expect(messages).toHaveLength(1); + const agent = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(agent).toMatchObject({ + callId: 'agent-1', + toolName: 'Agent', + title: 'Agent A running', + status: 'in_progress', + }); + expect(agent?.subTools).toHaveLength(1); + expect(agent?.subTools?.[0]).toMatchObject({ callId: 'sub-a1' }); + }); + + it('does not duplicate a permission block when the synthetic agent tool already exists', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'agent-1', 'confirming', 1, { + toolName: 'Agent', + title: 'Agent A', + rawInput: { subagent_type: 'Explore', prompt: 'a' }, + }), + { + id: 'perm-agent-1', + kind: 'permission', + requestId: 'req-agent-1', + sessionId: 'sess-1', + title: 'Agent A', + options: [{ optionId: 'opt-1', label: 'Allow', raw: {} }], + toolCall: { + toolCallId: 'agent-1', + rawInput: { subagent_type: 'Explore', prompt: 'a' }, + }, + preview: { kind: 'generic' as const }, + clientReceivedAt: 2, + createdAt: 2, + updatedAt: 2, + }, + toolBlock('t2', 'sub-a1', 'completed', 3, { + toolName: 'web_fetch', + parentToolCallId: 'agent-1', + rawOutput: 'data-a', + }), + ]); + + expect(messages).toHaveLength(1); + const agent = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(agent).toMatchObject({ + callId: 'agent-1', + toolName: 'Agent', + title: 'Agent A', + status: 'pending', + }); + expect(agent?.subTools).toHaveLength(1); + expect(agent?.subTools?.[0]).toMatchObject({ callId: 'sub-a1' }); + }); + + it('keeps existing subagent status after approved permission resolves', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'agent-1', 'in_progress', 1, { + toolName: 'Agent', + title: 'Agent A', + rawInput: { subagent_type: 'Explore', prompt: 'a' }, + }), + { + id: 'perm-agent-1', + kind: 'permission', + requestId: 'req-agent-1', + sessionId: 'sess-1', + title: 'Agent A', + options: [{ optionId: 'opt-1', label: 'Allow', raw: {} }], + toolCall: { + toolCallId: 'agent-1', + rawInput: { subagent_type: 'Explore', prompt: 'a' }, + }, + preview: { kind: 'generic' as const }, + resolved: 'selected:allow', + clientReceivedAt: 2, + createdAt: 2, + updatedAt: 2, + }, + ]); + + const agent = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(agent).toMatchObject({ + callId: 'agent-1', + status: 'in_progress', + }); + }); + + it('uses resolved approved permission as agent placeholder until final update', () => { + const messages = transcriptBlocksToDaemonMessages([ + { + id: 'perm-1', + kind: 'permission', + requestId: 'req-1', + sessionId: 'sess-1', + title: 'Agent A', + options: [{ optionId: 'opt-1', label: 'Allow', raw: {} }], + toolCall: { + toolCallId: 'agent-1', + rawInput: { subagent_type: 'Explore', prompt: 'a' }, + }, + preview: { kind: 'generic' as const }, + resolved: 'selected:allow', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 2, + }, + textBlock('thought-1', 'thought', 'inside agent', 3, false, { + parentToolCallId: 'agent-1', + }), + toolBlock('child-1', 'child-tool-1', 'completed', 4, { + toolName: 'web_fetch', + parentToolCallId: 'agent-1', + rawOutput: 'child output', + }), + toolBlock('t1', 'agent-1', 'completed', 5, { + toolName: 'agent', + title: 'Agent A', + rawInput: { subagent_type: 'Explore', prompt: 'a' }, + rawOutput: { type: 'task_execution', result: 'done' }, + updatedAt: 6, + }), + ]); + + expect(messages).toHaveLength(1); + const agent = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(agent?.callId).toBe('agent-1'); + expect(agent?.status).toBe('completed'); + expect(agent?.subContent).toBe('inside agent'); + expect(agent?.subTools?.[0]).toMatchObject({ + callId: 'child-tool-1', + toolName: 'web_fetch', + }); + }); + + it('does not put approved background permission placeholders on the active subagent stack', () => { + const messages = transcriptBlocksToDaemonMessages([ + { + id: 'perm-1', + kind: 'permission', + requestId: 'req-1', + sessionId: 'sess-1', + title: 'Agent A', + options: [{ optionId: 'opt-1', label: 'Allow', raw: {} }], + toolCall: { + toolCallId: 'agent-1', + rawInput: { + subagent_type: 'Explore', + prompt: 'a', + run_in_background: true, + }, + }, + preview: { kind: 'generic' as const }, + resolved: 'selected:allow', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 2, + }, + textBlock('thought-1', 'thought', 'background agent is running', 3), + textBlock('assistant-1', 'assistant', 'main turn continues', 4), + ]); + + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ + role: 'tool_group', + tools: [ + { + callId: 'agent-1', + status: 'pending', + }, + ], + }); + const agent = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(agent?.subContent).toBeUndefined(); + expect(messages[1]).toMatchObject({ + role: 'assistant', + content: 'main turn continues', + thinking: 'background agent is running', + }); + }); + + it('keeps approved regular tool permission visible until tool update arrives', () => { + const pendingMessages = transcriptBlocksToDaemonMessages([ + { + id: 'perm-1', + kind: 'permission', + requestId: 'req-1', + sessionId: 'sess-1', + title: + 'Fetching content from https://www.aliyun.com/activity (format: markdown)', + options: [{ optionId: 'proceed_once', label: 'Allow', raw: {} }], + toolCall: { + toolCallId: 'webfetch-1', + kind: 'fetch', + rawInput: { + url: 'https://www.aliyun.com/activity', + prompt: 'list activities', + format: 'markdown', + }, + }, + preview: { kind: 'generic' as const }, + resolved: 'selected:proceed_once', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 2, + }, + ]); + + expect(pendingMessages).toHaveLength(1); + const pendingTool = + pendingMessages[0].role === 'tool_group' + ? pendingMessages[0].tools[0] + : undefined; + expect(pendingTool).toMatchObject({ + callId: 'webfetch-1', + toolName: 'web_fetch', + status: 'in_progress', + kind: 'fetch', + }); + + const messages = transcriptBlocksToDaemonMessages([ + { + id: 'perm-1', + kind: 'permission', + requestId: 'req-1', + sessionId: 'sess-1', + title: + 'Fetching content from https://www.aliyun.com/activity (format: markdown)', + options: [{ optionId: 'proceed_once', label: 'Allow', raw: {} }], + toolCall: { + toolCallId: 'webfetch-1', + kind: 'fetch', + rawInput: { + url: 'https://www.aliyun.com/activity', + prompt: 'list activities', + format: 'markdown', + }, + }, + preview: { kind: 'generic' as const }, + resolved: 'selected:proceed_once', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 2, + }, + toolBlock('t1', 'webfetch-1', 'completed', 3, { + toolName: 'web_fetch', + toolKind: 'fetch', + title: 'WebFetch result', + rawOutput: 'Content processed successfully.', + updatedAt: 4, + }), + ]); + + expect(messages).toHaveLength(1); + const tool = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(tool).toMatchObject({ + callId: 'webfetch-1', + toolName: 'web_fetch', + status: 'completed', + rawOutput: 'Content processed successfully.', + }); + }); + + it('treats compound allow permission resolutions as approved', () => { + const messages = transcriptBlocksToDaemonMessages([ + { + id: 'perm-1', + kind: 'permission', + requestId: 'req-1', + sessionId: 'sess-1', + title: 'Fetch page', + options: [{ optionId: 'allow_once', label: 'Allow', raw: {} }], + toolCall: { + toolCallId: 'webfetch-1', + kind: 'fetch', + rawInput: { + url: 'https://example.com', + prompt: 'read', + }, + }, + preview: { kind: 'generic' as const }, + resolved: 'selected:allow_once', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 2, + }, + ]); + + const tool = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(tool).toMatchObject({ + callId: 'webfetch-1', + status: 'in_progress', + }); + }); + + it('renders rejected permission as completed agent card', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('t1', 'thought', 'first thinking', 1), + { + id: 'perm-1', + kind: 'permission', + requestId: 'req-1', + sessionId: 'sess-1', + title: '查询阿里云官网活动', + options: [{ optionId: 'opt-1', label: 'Allow', raw: {} }], + toolCall: { + toolCallId: 'agent-1', + rawInput: { subagent_type: 'general-purpose', prompt: 'query' }, + }, + preview: { kind: 'generic' as const }, + resolved: 'selected:cancel', + clientReceivedAt: 2, + createdAt: 2, + updatedAt: 3, + }, + textBlock('t2', 'thought', 'second thinking', 4), + textBlock('a1', 'assistant', 'response text', 5), + ]); + + expect(messages).toHaveLength(3); + // First: thinking-only assistant + expect(messages[0]).toMatchObject({ + role: 'assistant', + thinking: 'first thinking', + content: '', + }); + // Second: completed agent card (not pending) + const agentGroup = messages[1]; + expect(agentGroup.role).toBe('tool_group'); + if (agentGroup.role === 'tool_group') { + expect(agentGroup.tools[0]).toMatchObject({ + callId: 'agent-1', + status: 'failed', + endTime: 3, + }); + } + // Third: second thinking + response (not absorbed into agent subContent) + expect(messages[2]).toMatchObject({ + role: 'assistant', + thinking: 'second thinking', + content: 'response text', + }); + }); + + it('does not treat negative approval words as approved permissions', () => { + const messages = transcriptBlocksToDaemonMessages([ + { + id: 'perm-1', + kind: 'permission', + requestId: 'req-1', + sessionId: 'sess-1', + title: 'Agent A', + options: [{ optionId: 'opt-1', label: 'Deny', raw: {} }], + toolCall: { + toolCallId: 'agent-1', + rawInput: { subagent_type: 'Explore', prompt: 'a' }, + }, + preview: { kind: 'generic' as const }, + resolved: 'selected:not_approved', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 2, + }, + ]); + + const agent = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(agent).toMatchObject({ + callId: 'agent-1', + status: 'failed', + endTime: 2, + }); + }); + + it('splits thought across permission boundary when content already exists', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('t1', 'thought', 'first thinking', 1), + textBlock('a1', 'assistant', 'let me try this', 2), + { + id: 'perm-1', + kind: 'permission', + requestId: 'req-1', + sessionId: 'sess-1', + title: 'Allow Skill?', + options: [{ optionId: 'opt-1', label: 'Allow', raw: {} }], + toolCall: { toolCallId: 'tc-1', rawInput: { skill: 'codegraph' } }, + preview: { kind: 'generic' as const }, + clientReceivedAt: 3, + createdAt: 3, + updatedAt: 3, + }, + textBlock('t2', 'thought', 'second thinking', 4), + textBlock('a2', 'assistant', 'different approach', 5), + ]); + + const assistantMsgs = messages.filter((m) => m.role === 'assistant'); + expect(assistantMsgs).toHaveLength(2); + expect(assistantMsgs[0]).toMatchObject({ + thinking: 'first thinking', + content: 'let me try this', + }); + expect(assistantMsgs[1]).toMatchObject({ + thinking: 'second thinking', + content: 'different approach', + }); + }); + + it('converts error blocks to system error messages', () => { + const messages = transcriptBlocksToDaemonMessages([ + { + id: 'err-1', + kind: 'error' as const, + text: 'Connection lost', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }, + ]); + + expect(messages).toEqual([ + { + id: 'err-1', + role: 'system', + content: 'Connection lost', + variant: 'error', + }, + ]); + }); + + it('converts debug blocks to system messages with info variant', () => { + const messages = transcriptBlocksToDaemonMessages([ + { + id: 'dbg-1', + kind: 'debug' as const, + text: 'Session initialized', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }, + ]); + + expect(messages).toEqual([ + { + id: 'dbg-1', + role: 'system', + content: 'Session initialized', + variant: 'info', + }, + ]); + }); + + it('creates assistant message with empty content for thought-only blocks', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('t1', 'thought', 'let me think about this', 1), + ]); + + expect(messages).toEqual([ + { + id: 't1', + role: 'assistant', + content: '', + thinking: 'let me think about this', + isStreaming: false, + }, + ]); + }); + + it('handles nested subagent via parentToolCallId', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('parent-start', 'parent-1', 'in_progress', 10, { + title: 'Agent: outer', + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + }), + { + id: 'child-start', + kind: 'tool' as const, + toolCallId: 'child-1', + title: 'Agent: inner', + status: 'in_progress', + toolName: 'agent', + preview: { kind: 'generic' as const }, + rawInput: { subagent_type: 'Explore' }, + parentToolCallId: 'parent-1', + clientReceivedAt: 20, + createdAt: 20, + updatedAt: 20, + }, + toolBlock('read-inner', 'read-1', 'completed', 30, { + toolName: 'Read', + title: 'Read file.ts', + parentToolCallId: 'child-1', + }), + { + id: 'child-end', + kind: 'tool' as const, + toolCallId: 'child-1', + title: 'Agent: inner', + status: 'completed', + toolName: 'agent', + preview: { kind: 'generic' as const }, + rawOutput: { type: 'task_execution' }, + parentToolCallId: 'parent-1', + clientReceivedAt: 40, + createdAt: 40, + updatedAt: 40, + }, + toolBlock('parent-end', 'parent-1', 'completed', 50, { + title: 'Agent: outer', + toolName: 'agent', + rawOutput: { type: 'task_execution' }, + }), + ]); + + expect(messages).toHaveLength(1); + const parentTool = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(parentTool?.callId).toBe('parent-1'); + expect(parentTool?.subTools).toHaveLength(1); + const childTool = parentTool?.subTools?.[0]; + expect(childTool?.callId).toBe('child-1'); + expect(childTool?.subTools).toHaveLength(1); + expect(childTool?.subTools?.[0]?.callId).toBe('read-1'); + }); + + it('user message resets assistant merge state', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('a1', 'assistant', 'first', 1), + textBlock('u1', 'user', 'question', 2), + textBlock('a2', 'assistant', 'second', 3), + ]); + + expect(messages).toEqual([ + { id: 'a1', role: 'assistant', content: 'first', isStreaming: false }, + { id: 'u1', role: 'user', content: 'question' }, + { id: 'a2', role: 'assistant', content: 'second', isStreaming: false }, + ]); + }); + + it('merges thought and assistant without tools into single message', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('t1', 'thought', 'analyzing...', 1), + textBlock('a1', 'assistant', 'here is my answer', 2), + ]); + + expect(messages).toEqual([ + { + id: 't1', + role: 'assistant', + content: 'here is my answer', + thinking: 'analyzing...', + isStreaming: false, + }, + ]); + }); + + it('returns empty array for empty blocks', () => { + const messages = transcriptBlocksToDaemonMessages([]); + expect(messages).toEqual([]); + }); + + it('keeps status blocks in the main transcript while subagent is active', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('agent-start', 'agent-1', 'in_progress', 10, { + title: 'Agent: analyze', + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + }), + statusBlock('st1', 'Working on it...', 20), + toolBlock('agent-end', 'agent-1', 'completed', 30, { + title: 'Agent: analyze', + toolName: 'agent', + rawOutput: { type: 'task_execution' }, + }), + ]); + + expect(messages).toHaveLength(2); + const agentTool = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(agentTool?.subContent).toBeUndefined(); + expect(messages[1]).toMatchObject({ + id: 'st1', + role: 'system', + content: 'Working on it...', + variant: 'info', + }); + }); + + it('renders prompt cancellation blocks as system messages', () => { + const messages = transcriptBlocksToDaemonMessages([ + promptCancelledBlock('cancel-1', 20), + ]); + + expect(messages).toEqual([ + { + id: 'cancel-1', + role: 'system', + content: 'Request cancelled.', + variant: 'info', + }, + ]); + }); + + it('renders localized prompt cancellation messages', () => { + const messages = transcriptBlocksToDaemonMessages( + [promptCancelledBlock('cancel-1', 20)], + { labels: { promptCancelled: '请求已取消。' } }, + ); + + expect(messages).toEqual([ + { + id: 'cancel-1', + role: 'system', + content: '请求已取消。', + variant: 'info', + }, + ]); + }); + + it('keeps assistant text after a completed agent in the main transcript', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('agent-1', 'call-1', 'completed', 10, { + title: 'Agent: quick', + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + rawOutput: { type: 'task_execution' }, + updatedAt: 20, + }), + textBlock('a1', 'assistant', 'after agent', 25), + ]); + + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ + role: 'tool_group', + tools: [{ callId: 'call-1', status: 'completed' }], + }); + expect(messages[1]).toMatchObject({ + role: 'assistant', + content: 'after agent', + }); + }); + + it('creates sibling tool groups for top-level subagents', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('agent-1-start', 'agent-1', 'in_progress', 10, { + title: 'Agent: first', + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + }), + toolBlock('agent-2-start', 'agent-2', 'in_progress', 20, { + title: 'Agent: second', + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + }), + ]); + + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ + role: 'tool_group', + tools: [{ callId: 'agent-1' }], + }); + expect(messages[1]).toMatchObject({ + role: 'tool_group', + tools: [{ callId: 'agent-2' }], + }); + }); + + it('cancelled agent without details returns rawOutput as-is', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('agent-start', 'agent-1', 'in_progress', 10, { + title: 'Agent: working', + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + }), + toolBlock('agent-end', 'agent-1', 'cancelled', 20, { + title: 'Agent: working', + toolName: 'agent', + updatedAt: 25, + }), + ]); + + expect(messages).toHaveLength(1); + const tool = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(tool?.status).toBe('completed'); + expect(tool?.endTime).toBe(25); + expect(tool?.rawOutput).toBeUndefined(); + }); + + it('cancelled agent with existing rawOutput object spreads and adds status/reason', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('agent-start', 'agent-1', 'in_progress', 10, { + title: 'Agent: analyze', + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + }), + toolBlock('agent-end', 'agent-1', 'cancelled', 20, { + title: 'Agent: analyze', + toolName: 'agent', + rawOutput: { type: 'task_execution', subagentName: 'general-purpose' }, + details: 'User cancelled', + updatedAt: 30, + }), + ]); + + expect(messages).toHaveLength(1); + const tool = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(tool?.rawOutput).toEqual({ + type: 'task_execution', + subagentName: 'general-purpose', + status: 'cancelled', + reason: 'User cancelled', + }); + }); + + it('inferToolKind uses toolKind over toolName when both present', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'tc1', 'completed', 1, { + toolName: 'bash', + toolKind: 'search', + }), + ]); + + const tool = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(tool?.kind).toBe('search'); + }); + + it('inferToolKind uses exact match for grep and glob', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'tc1', 'completed', 1, { toolName: 'grep' }), + toolBlock('t2', 'tc2', 'completed', 2, { toolName: 'glob' }), + toolBlock('t3', 'tc3', 'completed', 3, { toolName: 'mygrep' }), + ]); + + expect(messages).toHaveLength(1); + const tools = + messages[0].role === 'tool_group' ? messages[0].tools : undefined; + expect(tools?.[0]?.kind).toBe('search'); + expect(tools?.[1]?.kind).toBe('search'); + expect(tools?.[2]?.kind).toBeUndefined(); + }); + + it('getToolRawOutput fallback returns rawOutput ?? details for non-cancelled', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'tc1', 'completed', 1, { + toolName: 'Read', + rawOutput: undefined, + details: 'some detail info', + }), + ]); + + const tool = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(tool?.rawOutput).toBe('some detail info'); + }); + + it('does not use content text as generic raw output', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'tc1', 'completed', 1, { + toolName: 'custom_tool', + content: [ + { + type: 'content', + content: { type: 'text', text: 'rendered elsewhere' }, + }, + ] as DaemonToolTranscriptBlock['content'], + }), + ]); + + const tool = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(tool?.rawOutput).toBeUndefined(); + }); + + it('mergeToolCall updates fields from completion block', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('agent-start', 'agent-1', 'in_progress', 10, { + title: 'Agent: work', + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + }), + toolBlock('agent-end', 'agent-1', 'completed', 20, { + title: 'Agent: work done', + toolName: 'agent', + rawOutput: { type: 'task_execution', totalTokens: 500 }, + updatedAt: 25, + }), + ]); + + expect(messages).toHaveLength(1); + const tool = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(tool?.title).toBe('Agent: work done'); + expect(tool?.status).toBe('completed'); + expect(tool?.endTime).toBe(25); + expect(tool?.rawOutput).toEqual({ + type: 'task_execution', + totalTokens: 500, + }); + }); + + it('does not merge assistant across error block', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('a1', 'assistant', 'analyzing...', 1), + { + id: 'err-1', + kind: 'error' as const, + text: 'Connection lost', + clientReceivedAt: 2, + createdAt: 2, + updatedAt: 2, + }, + textBlock('a2', 'assistant', 'recovered', 3), + ]); + + expect(messages).toHaveLength(3); + expect(messages[0]).toMatchObject({ + role: 'assistant', + content: 'analyzing...', + }); + expect(messages[1]).toMatchObject({ + role: 'system', + content: 'Connection lost', + variant: 'error', + }); + expect(messages[2]).toMatchObject({ + role: 'assistant', + content: 'recovered', + }); + }); + + it('does not merge assistant across plan status block', () => { + const plan = { + sessionUpdate: 'plan', + entries: [{ content: 'Step 1', status: 'in_progress' }], + }; + const messages = transcriptBlocksToDaemonMessages([ + textBlock('a1', 'assistant', 'let me plan', 1), + statusBlock('plan-1', `plan: ${JSON.stringify(plan)}`, 2), + textBlock('a2', 'assistant', 'executing', 3), + ]); + + expect(messages).toHaveLength(3); + expect(messages[0]).toMatchObject({ + role: 'assistant', + content: 'let me plan', + }); + expect(messages[1]).toMatchObject({ role: 'plan' }); + expect(messages[2]).toMatchObject({ + role: 'assistant', + content: 'executing', + }); + }); + + it('does not merge assistant across system status block', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('a1', 'assistant', 'working...', 1), + statusBlock('st1', 'Model switched to opus', 2), + textBlock('a2', 'assistant', 'continuing', 3), + ]); + + expect(messages).toHaveLength(3); + expect(messages[0]).toMatchObject({ + role: 'assistant', + content: 'working...', + }); + expect(messages[1]).toMatchObject({ + role: 'system', + content: 'Model switched to opus', + }); + expect(messages[2]).toMatchObject({ + role: 'assistant', + content: 'continuing', + }); + }); + + it('does not merge assistant across standalone shell block', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('a1', 'assistant', 'before shell', 1), + textBlock('u1', 'user', '! ls', 2), + shellBlock('sh1', 'file.ts\n', 3), + textBlock('a2', 'assistant', 'after shell', 4), + ]); + + const assistantMsgs = messages.filter((m) => m.role === 'assistant'); + expect(assistantMsgs).toHaveLength(2); + expect(assistantMsgs[0]).toMatchObject({ content: 'before shell' }); + expect(assistantMsgs[1]).toMatchObject({ content: 'after shell' }); + }); + + it('failed subAgent keeps parented content and allows assistant to return to main thread', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('agent-start', 'agent-1', 'in_progress', 10, { + title: 'Agent: investigate', + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + }), + textBlock('sub-a1', 'assistant', 'Looking into it', 15, false, { + parentToolCallId: 'agent-1', + }), + toolBlock('agent-end', 'agent-1', 'failed', 20, { + title: 'Agent: investigate', + toolName: 'agent', + rawOutput: { error: 'Context limit exceeded' }, + updatedAt: 25, + }), + textBlock('a1', 'assistant', 'The agent failed, let me try directly', 30), + ]); + + expect(messages).toHaveLength(2); + const agentTool = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(agentTool?.status).toBe('failed'); + expect(agentTool?.endTime).toBe(25); + expect(agentTool?.subContent).toBe('Looking into it'); + expect(messages[1]).toMatchObject({ + role: 'assistant', + content: 'The agent failed, let me try directly', + }); + }); + + it('status mapping covers running, pending, failed, and canceled (alternate spelling)', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'tc1', 'running', 1, { toolName: 'Bash' }), + toolBlock('t2', 'tc2', 'pending', 2, { toolName: 'Read' }), + toolBlock('t3', 'tc3', 'failed', 3, { toolName: 'Edit' }), + toolBlock('t4', 'tc4', 'canceled', 4, { + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + updatedAt: 5, + }), + ]); + + // tc1-tc3 are adjacent regular tools and share one group; the trailing + // subagent call stays in its own single-tool group. + expect(messages).toHaveLength(2); + const tools = + messages[0].role === 'tool_group' ? messages[0].tools : undefined; + const tool4 = + messages[1].role === 'tool_group' ? messages[1].tools[0] : undefined; + expect(tools?.[0]?.status).toBe('in_progress'); + expect(tools?.[1]?.status).toBe('pending'); + expect(tools?.[2]?.status).toBe('failed'); + expect(tool4?.status).toBe('completed'); + expect(tool4?.endTime).toBe(5); + }); + + it('parented blocks after completed subAgent remain nested', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('agent-1', 'call-1', 'completed', 10, { + title: 'Agent: fast', + toolName: 'agent', + rawInput: { subagent_type: 'general-purpose' }, + rawOutput: { type: 'task_execution' }, + updatedAt: 30, + }), + textBlock('a1', 'assistant', 'inside agent', 25, false, { + parentToolCallId: 'call-1', + }), + textBlock('a2', 'assistant', 'after agent', 35), + ]); + + expect(messages).toHaveLength(2); + const agentTool = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(agentTool?.subContent).toBe('inside agent'); + expect(messages[1]).toMatchObject({ + role: 'assistant', + content: 'after agent', + }); + }); + + it('identifies subAgent by toolName task and rawOutput.type task_execution', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('task-start', 'task-1', 'in_progress', 10, { + title: 'Task: build', + toolName: 'task', + }), + textBlock('sub-text', 'assistant', 'Building...', 15, false, { + parentToolCallId: 'task-1', + }), + toolBlock('sub-tool', 'sub-tc', 'completed', 20, { + toolName: 'Bash', + parentToolCallId: 'task-1', + }), + toolBlock('task-end', 'task-1', 'completed', 30, { + title: 'Task: build', + toolName: 'task', + rawOutput: { type: 'task_execution', result: 'Build passed' }, + updatedAt: 35, + }), + ]); + + expect(messages).toHaveLength(1); + const taskTool = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(taskTool?.toolName).toBe('task'); + expect(taskTool?.status).toBe('completed'); + expect(taskTool?.subContent).toBe('Building...'); + expect(taskTool?.subTools).toHaveLength(1); + expect(taskTool?.subTools?.[0].callId).toBe('sub-tc'); + + const taskExecByRawOutput = transcriptBlocksToDaemonMessages([ + toolBlock('exec-start', 'exec-1', 'in_progress', 10, { + title: 'Custom Tool', + toolName: 'my_runner', + rawOutput: { type: 'task_execution' }, + }), + textBlock('sub-text2', 'assistant', 'Running...', 15, false, { + parentToolCallId: 'exec-1', + }), + toolBlock('exec-end', 'exec-1', 'completed', 20, { + toolName: 'my_runner', + rawOutput: { type: 'task_execution', result: 'done' }, + updatedAt: 25, + }), + ]); + + expect(taskExecByRawOutput).toHaveLength(1); + const execTool = + taskExecByRawOutput[0].role === 'tool_group' + ? taskExecByRawOutput[0].tools[0] + : undefined; + expect(execTool?.subContent).toBe('Running...'); + }); + + it('does not pass content, locations, or preview to DaemonMessageToolCall', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'tc1', 'completed', 1, { + toolName: 'Edit', + content: [ + { + type: 'diff', + path: '/path/file.ts', + oldText: 'old', + newText: 'new', + }, + ] as DaemonToolTranscriptBlock['content'], + locations: [{ file: '/path/file.ts', line: 10 }], + preview: { kind: 'generic', summary: 'Edit file.ts' }, + }), + ]); + + const tool = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(tool).toBeDefined(); + expect(tool?.callId).toBe('tc1'); + expect('content' in tool!).toBe(false); + expect('locations' in tool!).toBe(false); + expect('preview' in tool!).toBe(false); + }); + + it('Agent tool with confirming status (from permission_request) nests sub-tools via parentToolCallId', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'agent-call-1', 'confirming', 100, { + toolName: 'Agent', + title: 'Query website', + rawInput: { + description: 'Query website', + prompt: 'fetch data', + subagent_type: 'Explore', + }, + }), + toolBlock('t2', 'sub-tool-1', 'completed', 200, { + toolName: 'web_fetch', + title: 'Fetch page', + parentToolCallId: 'agent-call-1', + rawOutput: 'page content', + }), + toolBlock('t3', 'sub-tool-2', 'in_progress', 300, { + toolName: 'web_fetch', + title: 'Fetch another page', + parentToolCallId: 'agent-call-1', + }), + ]); + + expect(messages).toHaveLength(1); + expect(messages[0].role).toBe('tool_group'); + const agent = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(agent).toBeDefined(); + expect(agent!.callId).toBe('agent-call-1'); + expect(agent!.toolName).toBe('Agent'); + expect(agent!.status).toBe('pending'); + expect(agent!.subTools).toHaveLength(2); + expect(agent!.subTools![0].callId).toBe('sub-tool-1'); + expect(agent!.subTools![1].callId).toBe('sub-tool-2'); + }); + + it('two parallel Agents from permission_request each nest their own sub-tools', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'agent-1', 'confirming', 100, { + toolName: 'Agent', + title: 'Agent A', + rawInput: { subagent_type: 'Explore', prompt: 'a' }, + }), + toolBlock('t2', 'agent-2', 'confirming', 100, { + toolName: 'Agent', + title: 'Agent B', + rawInput: { subagent_type: 'Explore', prompt: 'b' }, + }), + toolBlock('t3', 'sub-a1', 'completed', 200, { + toolName: 'web_fetch', + parentToolCallId: 'agent-1', + rawOutput: 'data-a', + }), + toolBlock('t4', 'sub-b1', 'completed', 200, { + toolName: 'web_fetch', + parentToolCallId: 'agent-2', + rawOutput: 'data-b', + }), + ]); + + expect(messages).toHaveLength(2); + const agentA = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + const agentB = + messages[1].role === 'tool_group' ? messages[1].tools[0] : undefined; + expect(agentA!.callId).toBe('agent-1'); + expect(agentA!.subTools).toHaveLength(1); + expect(agentA!.subTools![0].callId).toBe('sub-a1'); + expect(agentB!.callId).toBe('agent-2'); + expect(agentB!.subTools).toHaveLength(1); + expect(agentB!.subTools![0].callId).toBe('sub-b1'); + }); + + it('nests parented text inside the matching parallel subagent', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'agent-1', 'running', 100, { + toolName: 'Agent', + title: 'Agent A', + rawInput: { subagent_type: 'Explore', prompt: 'a' }, + }), + toolBlock('t2', 'agent-2', 'running', 100, { + toolName: 'Agent', + title: 'Agent B', + rawInput: { subagent_type: 'Security', prompt: 'b' }, + }), + textBlock('th1', 'thought', 'A thinking. ', 150, false, { + parentToolCallId: 'agent-1', + }), + textBlock('msg1', 'assistant', 'B answer. ', 160, false, { + parentToolCallId: 'agent-2', + }), + ]); + + expect(messages).toHaveLength(2); + const agentA = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + const agentB = + messages[1].role === 'tool_group' ? messages[1].tools[0] : undefined; + expect(agentA?.subContent).toBe('A thinking. '); + expect(agentB?.subContent).toBe('B answer. '); + expect(messages.find((message) => message.role === 'assistant')).toBe( + undefined, + ); + }); + + it('nests parented shell tools inside the matching failed subagent', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('agent-failed', 'agent-1', 'failed', 100, { + toolName: 'agent', + title: 'Agent: review PR', + rawOutput: { type: 'task_execution', result: 'failed' }, + }), + toolBlock('shell-child', 'shell-1', 'completed', 110, { + toolName: 'run_shell_command', + title: 'Shell: git diff', + rawInput: { + command: 'git diff FETCH_HEAD...HEAD', + description: '获取 PR diff', + }, + rawOutput: 'No such file or directory', + parentToolCallId: 'agent-1', + }), + ]); + + expect(messages).toHaveLength(1); + const agent = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(agent?.callId).toBe('agent-1'); + expect(agent?.subTools).toHaveLength(1); + expect(agent?.subTools?.[0]).toMatchObject({ + callId: 'shell-1', + toolName: 'run_shell_command', + rawOutput: 'No such file or directory', + }); + }); + + it('nests parented replay text after a completed subagent update', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'agent-1', 'completed', 100, { + toolName: 'Agent', + title: 'Agent A', + rawInput: { subagent_type: 'Explore', prompt: 'a' }, + rawOutput: { type: 'task_execution', result: 'done' }, + updatedAt: 120, + }), + textBlock('th1', 'thought', 'late parented thought', 150, false, { + parentToolCallId: 'agent-1', + }), + ]); + + expect(messages).toHaveLength(1); + const agent = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(agent?.subContent).toBe('late parented thought'); + }); + + it('keeps unparented thought text in the main transcript', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('t1', 'agent-1', 'running', 100, { + toolName: 'Agent', + title: 'Agent A', + rawInput: { subagent_type: 'Explore', prompt: 'a' }, + }), + toolBlock('t2', 'agent-2', 'running', 100, { + toolName: 'Agent', + title: 'Agent B', + rawInput: { subagent_type: 'Security', prompt: 'b' }, + }), + textBlock('th1', 'thought', 'I need to review the PR diff.', 150), + ]); + + expect(messages).toHaveLength(3); + const assistant = messages.find((message) => message.role === 'assistant'); + expect(assistant).toMatchObject({ + thinking: 'I need to review the PR diff.', + }); + const agentA = + messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; + const agentB = + messages[1].role === 'tool_group' ? messages[1].tools[0] : undefined; + expect(agentA!.subContent).toBeUndefined(); + expect(agentB!.subContent).toBeUndefined(); + }); +}); diff --git a/packages/web-shell/client/adapters/transcriptToMessages.ts b/packages/web-shell/client/adapters/transcriptToMessages.ts new file mode 100644 index 0000000000..78fdab7c7b --- /dev/null +++ b/packages/web-shell/client/adapters/transcriptToMessages.ts @@ -0,0 +1,942 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + DaemonTranscriptBlock, + DaemonTextTranscriptBlock, + DaemonToolTranscriptBlock, + DaemonShellTranscriptBlock, + DaemonStatusTranscriptBlock, + DaemonUserShellTranscriptBlock, +} from '@qwen-code/sdk/daemon'; +import type { + DaemonMessage, + DaemonMessageToolCall, + DaemonMessageToolCallStatus, + DaemonMessageToolKind, + DaemonMessageTodoItem, + DaemonUserMessage, +} from './messageTypes.js'; + +interface PermissionToolInfo { + title?: string; + args?: Record; +} + +type DaemonPermissionTranscriptBlock = Extract< + DaemonTranscriptBlock, + { kind: 'permission' } +>; + +interface TranscriptMessageLabels { + promptCancelled?: string; +} + +interface TranscriptMessageOptions { + labels?: TranscriptMessageLabels; +} + +function parseDaemonTodoItemsFromEntries( + entries: readonly unknown[], +): DaemonMessageTodoItem[] | undefined { + const todos = entries.flatMap((entry, index): DaemonMessageTodoItem[] => { + const item = getRecord(entry); + const content = getString(item, 'content'); + if (!content) return []; + const id = getString(item, 'id') ?? `plan-${index}`; + return [ + { + id, + content, + status: getTodoStatus(getString(item, 'status')), + ...(() => { + const priority = getTodoPriority(getString(item, 'priority')); + return priority ? { priority } : {}; + })(), + }, + ]; + }); + return todos.length > 0 ? todos : undefined; +} + +export function transcriptBlocksToDaemonMessages( + blocks: readonly DaemonTranscriptBlock[], + options: TranscriptMessageOptions = {}, +): DaemonMessage[] { + const messages: DaemonMessage[] = []; + const promptCancelledText = + options.labels?.promptCancelled ?? 'Request cancelled.'; + // Replay can contain thousands of blocks. Keep tool calls indexed by callId + // so later tool updates, parented children, and permission placeholders + // merge in O(1) + // instead of scanning the rendered message list for every block. + // Subagent-owned assistant/thought/tool blocks are expected to carry + // parentToolCallId; unparented blocks are rendered as top-level transcript. + const toolsByCallId = new Map(); + const permissionToolInfoByCallId = new Map(); + let currentAssistantIdx: number | null = null; + // Tool cards are standalone transcript turns. Once a tool is emitted, + // the next top-level assistant/thought block must start a fresh assistant + // message instead of being appended to text that appeared before the tool. + let needsNewContentMessage = false; + + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + + switch (block.kind) { + case 'user': { + currentAssistantIdx = null; + needsNewContentMessage = false; + const textBlock = block as DaemonTextTranscriptBlock; + const msg: DaemonUserMessage = { + id: block.id, + role: 'user', + content: textBlock.text, + }; + // Attach images if present + if (textBlock.images && textBlock.images.length > 0) { + msg.images = textBlock.images.map((img) => ({ + data: img.data, + mimeType: img.mimeType || 'image/*', + })); + } + messages.push(msg); + break; + } + + case 'assistant': { + const textBlock = block as DaemonTextTranscriptBlock; + + const parentSubAgent = textBlock.parentToolCallId + ? toolsByCallId.get(textBlock.parentToolCallId) + : undefined; + if (parentSubAgent) { + appendSubContent(parentSubAgent, textBlock.text); + break; + } + + const insightSegments = splitInsightSegments(textBlock.text); + if (insightSegments) { + let lastProgress: ParsedInsight | null = null; + let hasTerminal = false; + let readyCount = 0; + let errorCount = 0; + for (const seg of insightSegments) { + if (seg.kind === 'insight') { + if (seg.data.type === 'insight_progress') { + lastProgress = seg.data; + } else if (seg.data.type === 'insight_ready') { + hasTerminal = true; + messages.push({ + id: `${block.id}-ir-${readyCount++}`, + role: 'insight_ready', + path: seg.data.path, + }); + } else if (seg.data.type === 'insight_error') { + hasTerminal = true; + messages.push({ + id: `${block.id}-ie-${errorCount++}`, + role: 'insight_error', + error: seg.data.error, + }); + } + } else { + messages.push({ + id: `${block.id}-t-${messages.length}`, + role: 'assistant', + content: seg.text, + }); + currentAssistantIdx = messages.length - 1; + } + } + if (lastProgress && !hasTerminal) { + messages.push({ + id: `${block.id}-ip`, + role: 'insight_progress', + stage: lastProgress.stage, + progress: lastProgress.progress, + detail: lastProgress.detail, + }); + } + needsNewContentMessage = true; + break; + } + + const target = + currentAssistantIdx !== null + ? messages[currentAssistantIdx] + : undefined; + if (target && target.role === 'assistant' && !needsNewContentMessage) { + messages[currentAssistantIdx!] = { + ...target, + content: target.content + textBlock.text, + isStreaming: textBlock.streaming, + }; + needsNewContentMessage = false; + } else { + messages.push({ + id: block.id, + role: 'assistant', + content: textBlock.text, + isStreaming: textBlock.streaming, + }); + currentAssistantIdx = messages.length - 1; + needsNewContentMessage = false; + } + break; + } + + case 'thought': { + const textBlock = block as DaemonTextTranscriptBlock; + const parentSubAgent = textBlock.parentToolCallId + ? toolsByCallId.get(textBlock.parentToolCallId) + : undefined; + if (parentSubAgent) { + appendSubContent(parentSubAgent, textBlock.text); + break; + } + const target = + currentAssistantIdx !== null + ? messages[currentAssistantIdx] + : undefined; + if ( + target && + target.role === 'assistant' && + !needsNewContentMessage && + !target.content + ) { + messages[currentAssistantIdx!] = { + ...target, + thinking: (target.thinking || '') + textBlock.text, + isStreaming: textBlock.streaming, + }; + needsNewContentMessage = false; + } else { + messages.push({ + id: block.id, + role: 'assistant', + content: '', + thinking: textBlock.text, + isStreaming: textBlock.streaming, + }); + currentAssistantIdx = messages.length - 1; + needsNewContentMessage = false; + } + break; + } + + case 'tool': { + const toolBlock = block as DaemonToolTranscriptBlock; + const toolCall = daemonToolBlockToToolCall(toolBlock); + const permissionInfo = permissionToolInfoByCallId.get(toolCall.callId); + if (permissionInfo?.title) { + toolCall.title = permissionInfo.title; + } + if (!toolCall.args && permissionInfo?.args) { + toolCall.args = permissionInfo.args; + } + const parentSubAgent = toolCall.parentToolCallId + ? toolsByCallId.get(toolCall.parentToolCallId) + : undefined; + const existingTool = toolsByCallId.get(toolCall.callId); + + if (existingTool) { + mergeToolCall(existingTool, toolCall); + break; + } + + if (parentSubAgent) { + appendSubTool(parentSubAgent, toolCall); + toolsByCallId.set(toolCall.callId, toolCall); + break; + } + + appendToolCallMessage(messages, block.id, toolCall); + toolsByCallId.set(toolCall.callId, toolCall); + needsNewContentMessage = true; + break; + } + + case 'shell': { + const shellBlock = block as DaemonShellTranscriptBlock; + const lastMsg = messages[messages.length - 1]; + if (lastMsg && lastMsg.role === 'tool_group') { + const targetIdx = findShellOutputTargetIndex(lastMsg.tools); + const targetTool = lastMsg.tools[targetIdx]; + if (targetTool) { + const previousOutput = + typeof targetTool.rawOutput === 'string' + ? targetTool.rawOutput + : ''; + const nextTool = { + ...targetTool, + rawOutput: previousOutput + shellBlock.text, + }; + messages[messages.length - 1] = { + ...lastMsg, + tools: [ + ...lastMsg.tools.slice(0, targetIdx), + nextTool, + ...lastMsg.tools.slice(targetIdx + 1), + ], + }; + if (toolsByCallId.get(targetTool.callId) === targetTool) { + toolsByCallId.set(targetTool.callId, nextTool); + } + } + } else { + messages.push({ + id: block.id, + role: 'tool_group', + tools: [ + { + callId: block.id, + toolName: 'shell', + status: 'completed', + kind: 'execute', + rawOutput: shellBlock.text, + }, + ], + }); + needsNewContentMessage = true; + } + break; + } + + case 'user_shell': { + const shellBlock = block as DaemonUserShellTranscriptBlock; + messages.push({ + id: block.id, + role: 'user_shell', + command: shellBlock.command, + output: shellBlock.text, + ...(shellBlock.cwd ? { cwd: shellBlock.cwd } : {}), + }); + needsNewContentMessage = true; + break; + } + + case 'permission': { + const permBlock = block as DaemonPermissionTranscriptBlock; + rememberPermissionToolInfo(permBlock, permissionToolInfoByCallId); + const permissionToolCall = permissionBlockToToolCall(permBlock); + if (!permissionToolCall) break; + const isSubAgentPermission = isSubAgentToolCall(permissionToolCall); + // Pending permissions are rendered by the dedicated permission UI. + if (!permBlock.resolved) { + break; + } + + const existingPermission = toolsByCallId.get(permissionToolCall.callId); + if (existingPermission) { + const previousStatus = existingPermission.status; + permissionToolCall.toolName = existingPermission.toolName; + if (permBlock.resolved) { + if (isApprovedPermissionResolution(permBlock.resolved)) { + permissionToolCall.status = isSubAgentPermission + ? permissionToolCall.status + : 'in_progress'; + } else { + permissionToolCall.status = 'failed'; + permissionToolCall.endTime = permBlock.updatedAt; + } + } + mergeToolCall(existingPermission, permissionToolCall); + if ( + permBlock.resolved && + isSubAgentPermission && + isApprovedPermissionResolution(permBlock.resolved) + ) { + existingPermission.status = previousStatus; + } + break; + } + + if (permBlock.resolved) { + // Resolved permission with no matching real tool block: + // - Approved: the daemon may still skip the initial agent tool_call + // or a regular tool_call. Keep a pending placeholder visible so + // later parented child events and the final update can merge by + // callId. + // - Rejected: render a finished card. Later assistant content stays + // in the main conversation unless it has an explicit parent. + if (isApprovedPermissionResolution(permBlock.resolved)) { + if (!isSubAgentPermission) { + permissionToolCall.status = 'in_progress'; + } + appendToolCallMessage(messages, block.id, permissionToolCall); + toolsByCallId.set(permissionToolCall.callId, permissionToolCall); + needsNewContentMessage = true; + } else { + permissionToolCall.status = 'failed'; + permissionToolCall.endTime = permBlock.updatedAt; + appendToolCallMessage(messages, block.id, permissionToolCall); + toolsByCallId.set(permissionToolCall.callId, permissionToolCall); + needsNewContentMessage = true; + } + break; + } + + break; + } + + case 'status': + case 'debug': { + const text = (block as DaemonStatusTranscriptBlock).text; + const todos = parsePlanTodos(text); + if (todos) { + messages.push({ + id: block.id, + role: 'plan', + todos, + }); + needsNewContentMessage = true; + break; + } + // Status/debug blocks are daemon-level diagnostics, not tool output. + // Keeping them in the main transcript avoids hiding global messages + // such as SSE lag warnings, malformed-event debug lines, or shell + // result notices inside whichever subAgent happened to be active. + messages.push({ + id: block.id, + role: 'system', + content: text, + variant: 'info', + }); + needsNewContentMessage = true; + break; + } + + case 'error': + messages.push({ + id: block.id, + role: 'system', + content: (block as DaemonStatusTranscriptBlock).text, + variant: 'error', + }); + needsNewContentMessage = true; + break; + + case 'prompt_cancelled': + messages.push({ + id: block.id, + role: 'system', + content: promptCancelledText, + variant: 'info', + }); + needsNewContentMessage = true; + break; + + default: + break; + } + } + + return messages; +} + +function appendSubTool( + parent: DaemonMessageToolCall, + toolCall: DaemonMessageToolCall, +): void { + parent.subTools ||= []; + parent.subTools.push(toolCall); +} + +function appendSubContent(parent: DaemonMessageToolCall, text: string): void { + parent.subContent = (parent.subContent || '') + text; +} + +function appendToolCallMessage( + messages: DaemonMessage[], + blockId: string, + toolCall: DaemonMessageToolCall, +): void { + // Native CLI groups every tool call of one scheduler batch into a single + // bordered tool_group (mapToDisplay in useReactToolScheduler). The daemon + // transcript carries no batch marker, so the replay-stable equivalent is + // adjacency: a tool block arriving while a tool_group is still the latest + // visible message joins that group instead of opening a new box. + // + // Sub-agent calls stay in their own single-tool groups — MessageList's + // groupParallelAgents relies on that shape to render consecutive agent + // launches as ParallelAgentsGroup. + // + // Synthetic raw-shell groups (pushed by the `shell` block fallback) use the + // bare block id without the `tg-` prefix and never absorb real tool calls. + const last = messages[messages.length - 1]; + if ( + last && + last.role === 'tool_group' && + last.id.startsWith('tg-') && + !isSubAgentToolCall(toolCall) && + !last.tools.some(isSubAgentToolCall) + ) { + last.tools.push(toolCall); + return; + } + messages.push({ + id: `tg-${blockId}`, + role: 'tool_group', + tools: [toolCall], + }); +} + +/** + * Pick which tool in a group should receive a raw shell output chunk. + * + * Shell transcript blocks carry no toolCallId, so attachment is heuristic. + * Single-tool groups (the only shape before adjacent-merge) keep the old + * "last tool" behavior. In merged groups, prefer the most recent `execute` + * tool that is still running — the scheduler executes one tool at a time, so + * that is the tool producing output. On replay every status is already + * terminal; fall back to the most recent `execute` tool, then to the last + * tool so groups without kind metadata behave exactly as before. + */ +function findShellOutputTargetIndex( + tools: readonly DaemonMessageToolCall[], +): number { + for (let i = tools.length - 1; i >= 0; i--) { + const tool = tools[i]; + if (tool.kind === 'execute' && tool.status === 'in_progress') { + return i; + } + } + for (let i = tools.length - 1; i >= 0; i--) { + if (tools[i].kind === 'execute') { + return i; + } + } + return tools.length - 1; +} + +function mergeToolCall( + target: DaemonMessageToolCall, + source: DaemonMessageToolCall, +): void { + target.status = source.status ?? target.status; + target.title = source.title ?? target.title; + target.toolName = source.toolName ?? target.toolName; + target.kind = source.kind ?? target.kind; + target.content = source.content ?? target.content; + target.endTime = source.endTime ?? target.endTime; + target.rawOutput = source.rawOutput ?? target.rawOutput; + target.args = source.args ?? target.args; + target.locations = source.locations ?? target.locations; +} + +function isSubAgentToolCall(tool: DaemonMessageToolCall): boolean { + const name = tool.toolName.toLowerCase(); + if (name === 'agent' || name === 'task') return true; + if (tool.subTools || tool.subContent) return true; + if (isTaskExecutionRaw(tool.rawOutput)) return true; + return Boolean(tool.args?.subagent_type); +} + +function isTaskExecutionRaw(raw: unknown): boolean { + return ( + !!raw && + typeof raw === 'object' && + (raw as Record).type === 'task_execution' + ); +} + +function parsePlanTodos(text: string): DaemonMessageTodoItem[] | undefined { + const rawJson = text.startsWith('plan: ') + ? text.slice('plan: '.length) + : undefined; + if (!rawJson) { + return undefined; + } + + try { + const parsed = JSON.parse(rawJson) as unknown; + const record = getRecord(parsed); + if ( + record?.['sessionUpdate'] !== 'plan' || + !Array.isArray(record['entries']) + ) { + return undefined; + } + return parseDaemonTodoItemsFromEntries(record['entries']); + } catch { + return undefined; + } +} + +function getRecord(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + return value as Record; +} + +function getString( + record: Record | undefined, + key: string, +): string | undefined { + const value = record?.[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function getTodoStatus( + value: string | undefined, +): DaemonMessageTodoItem['status'] { + return value === 'completed' || value === 'in_progress' || value === 'pending' + ? value + : 'pending'; +} + +function getTodoPriority( + value: string | undefined, +): DaemonMessageTodoItem['priority'] | undefined { + return value === 'high' || value === 'medium' || value === 'low' + ? value + : undefined; +} + +function daemonToolBlockToToolCall( + block: DaemonToolTranscriptBlock, +): DaemonMessageToolCall { + const rawOutput = getToolRawOutput(block); + const isBackgroundAgent = isBackgroundAgentBlock(block, rawOutput); + const statusMap: Record = { + running: 'in_progress', + pending: 'pending', + confirming: 'pending', + background: 'pending', + completed: 'completed', + failed: 'failed', + cancelled: 'completed', + canceled: 'completed', + in_progress: 'in_progress', + }; + const isComplete = + block.status === 'completed' || + block.status === 'failed' || + block.status === 'cancelled' || + block.status === 'canceled'; + + return { + callId: block.toolCallId, + toolName: block.toolName || 'unknown', + title: block.title, + status: + (isBackgroundAgent ? 'pending' : statusMap[block.status]) || + (block.status as DaemonMessageToolCallStatus) || + 'in_progress', + kind: inferToolKind(block.toolName, block.toolKind), + rawOutput, + args: block.rawInput as Record | undefined, + parentToolCallId: block.parentToolCallId, + startTime: block.createdAt, + endTime: isComplete && !isBackgroundAgent ? block.updatedAt : undefined, + }; +} + +function permissionBlockToToolCall( + block: DaemonPermissionTranscriptBlock, +): DaemonMessageToolCall | undefined { + const toolCall = getRecord(block.toolCall); + if (!toolCall) return undefined; + + const rawInput = getToolCallRawInput(toolCall); + // AskUserQuestion permissions are rendered by the shell as a dedicated + // interactive form from the pending permission itself. Emitting a synthetic + // generic tool card here would show the same permission twice, especially + // when older daemon events only expose it as kind: "think". + if (Array.isArray(rawInput?.['questions'])) return undefined; + + const meta = getRecord(toolCall['_meta']); + const kind = getString(toolCall, 'kind'); + const toolName = + getString(meta, 'toolName') ?? + getString(toolCall, 'toolName') ?? + getString(toolCall, 'name') ?? + (rawInput?.['subagent_type'] ? 'agent' : undefined) ?? + (kind === 'fetch' ? 'web_fetch' : kind); + const toolCallId = + getString(toolCall, 'toolCallId') ?? getString(toolCall, 'id'); + if (!toolCallId || !toolName) return undefined; + + const syntheticTool: DaemonMessageToolCall = { + callId: toolCallId, + toolName, + title: getString(toolCall, 'title') ?? block.title, + status: 'pending', + kind: inferToolKind(toolName, kind), + args: rawInput, + startTime: block.createdAt, + }; + + return syntheticTool; +} + +function rememberPermissionToolInfo( + block: DaemonPermissionTranscriptBlock, + infoByCallId: Map, +): void { + const toolCall = getRecord(block.toolCall); + const toolCallId = + getString(toolCall, 'toolCallId') ?? getString(toolCall, 'id'); + if (!toolCallId) return; + const title = getString(toolCall, 'title') ?? block.title; + const rawInput = toolCall ? getToolCallRawInput(toolCall) : undefined; + if (!Array.isArray(rawInput?.['questions'])) return; + infoByCallId.set(toolCallId, { + ...(title ? { title } : {}), + ...(rawInput ? { args: rawInput } : {}), + }); +} + +function isApprovedPermissionResolution(resolved: string): boolean { + const [primary = '', detail = ''] = resolved.toLowerCase().split(':', 2); + if (isApprovalToken(primary)) return true; + if (primary !== 'selected') return false; + return isApprovalToken(detail.trim()); +} + +function isApprovalToken(token: string): boolean { + return ( + token === 'allow' || + token === 'allowed' || + token === 'approve' || + token === 'approved' || + token === 'accept' || + token === 'accepted' || + token === 'confirm' || + token === 'confirmed' || + token === 'proceed' || + token === 'proceed_once' || + token === 'proceed_always_project' || + token === 'proceed_always_user' || + token === 'allow_once' || + token === 'allow_always' || + token === 'success' || + token === 'succeeded' + ); +} + +function getToolCallRawInput( + toolCall: Record, +): Record | undefined { + return ( + getRecord(toolCall['rawInput']) ?? + getRecord(toolCall['input']) ?? + getRecord(toolCall['args']) + ); +} + +function isBackgroundAgentBlock( + block: DaemonToolTranscriptBlock, + rawOutput: unknown, +): boolean { + const name = block.toolName?.toLowerCase(); + if (name !== 'agent' && name !== 'task') return false; + const raw = getRecord(rawOutput); + return raw?.['status'] === 'background'; +} + +function getToolRawOutput(block: DaemonToolTranscriptBlock): unknown { + if (isAskUserQuestionBlock(block) && block.status === 'failed') { + return getToolContentText(block) ?? block.details ?? block.rawOutput; + } + + if (!isCancelledStatus(block.status) || !block.details) { + return block.rawOutput ?? block.details; + } + + if ( + block.rawOutput && + typeof block.rawOutput === 'object' && + !Array.isArray(block.rawOutput) + ) { + return { + ...(block.rawOutput as Record), + status: block.status, + reason: block.details, + }; + } + + return { + status: block.status, + reason: block.details, + text: + typeof block.rawOutput === 'string' && block.rawOutput + ? block.rawOutput + : block.details, + }; +} + +function isAskUserQuestionBlock(block: DaemonToolTranscriptBlock): boolean { + if (!block.toolName) return false; + const normalized = block.toolName.toLowerCase(); + return normalized === 'ask_user_question' || normalized === 'askuserquestion'; +} + +function getToolContentText( + block: DaemonToolTranscriptBlock, +): string | undefined { + if (!Array.isArray(block.content)) return undefined; + const parts = block.content + .map((item) => item?.content?.text) + .filter((text): text is string => Boolean(text)); + if (!parts || parts.length === 0) return undefined; + return parts.join('\n'); +} + +function isCancelledStatus(status: string): boolean { + return status === 'cancelled' || status === 'canceled'; +} + +type ParsedInsight = + | { + type: 'insight_progress'; + stage: string; + progress: number; + detail?: string; + } + | { type: 'insight_ready'; path: string } + | { type: 'insight_error'; error: string }; + +type InsightSegment = + | { kind: 'text'; text: string } + | { kind: 'insight'; data: ParsedInsight }; + +const INSIGHT_PREFIXES = [ + '"insight_progress":', + '"insight_ready":', + '"insight_error":', +]; + +function parseInsightJson(json: string): ParsedInsight | null { + try { + const parsed = JSON.parse(json) as Record; + const prog = getRecord(parsed['insight_progress']); + if ( + prog && + typeof prog['stage'] === 'string' && + typeof prog['progress'] === 'number' + ) { + return { + type: 'insight_progress', + stage: prog['stage'] as string, + progress: prog['progress'] as number, + detail: + typeof prog['detail'] === 'string' + ? (prog['detail'] as string) + : undefined, + }; + } + const ready = getRecord(parsed['insight_ready']); + if (ready && typeof ready['path'] === 'string') { + return { type: 'insight_ready', path: ready['path'] as string }; + } + const insightError = getRecord(parsed['insight_error']); + if (insightError && typeof insightError['error'] === 'string') { + return { type: 'insight_error', error: insightError['error'] as string }; + } + } catch { + // not valid JSON + } + return null; +} + +// Balanced-braces JSON extractor. Handles string escapes but not standalone +// arrays — sufficient for the insight protocol's object-only payloads. +function extractJsonObject(text: string, start: number): string | null { + if (text[start] !== '{') return null; + let depth = 0; + let inString = false; + let escape = false; + for (let i = start; i < text.length; i++) { + const ch = text[i]!; + if (escape) { + escape = false; + continue; + } + if (ch === '\\' && inString) { + escape = true; + continue; + } + if (ch === '"') { + inString = !inString; + continue; + } + if (inString) continue; + if (ch === '{') depth++; + else if (ch === '}') { + depth--; + if (depth === 0) return text.slice(start, i + 1); + } + } + return null; +} + +function splitInsightSegments(text: string): InsightSegment[] | null { + const segments: InsightSegment[] = []; + let lastIndex = 0; + let pos = 0; + let hasInsight = false; + + while (pos < text.length) { + const braceIdx = text.indexOf('{', pos); + if (braceIdx === -1) break; + + const afterBrace = text.slice(braceIdx + 1).trimStart(); + const isInsight = INSIGHT_PREFIXES.some((p) => afterBrace.startsWith(p)); + if (!isInsight) { + pos = braceIdx + 1; + continue; + } + + const json = extractJsonObject(text, braceIdx); + if (!json) { + pos = braceIdx + 1; + continue; + } + + const insight = parseInsightJson(json); + if (!insight) { + pos = braceIdx + 1; + continue; + } + + hasInsight = true; + const before = text.slice(lastIndex, braceIdx).trim(); + if (before) { + segments.push({ kind: 'text', text: before }); + } + segments.push({ kind: 'insight', data: insight }); + lastIndex = braceIdx + json.length; + pos = lastIndex; + } + + if (!hasInsight) return null; + + const after = text.slice(lastIndex).trim(); + if (after) { + segments.push({ kind: 'text', text: after }); + } + + return segments.length > 0 ? segments : null; +} + +function inferToolKind( + toolName?: string, + toolKind?: string, +): DaemonMessageToolKind | undefined { + if (toolKind) return toolKind as DaemonMessageToolKind; + if (!toolName) return undefined; + const name = toolName.toLowerCase(); + if (name === 'bash' || name === 'execute') return 'execute'; + if (name === 'read') return 'read'; + if (name === 'edit' || name === 'write') return 'edit'; + if (name.includes('search') || name === 'grep' || name === 'glob') + return 'search'; + if (name === 'agent' || name === 'task') return 'other'; + return undefined; +} diff --git a/packages/web-shell/client/adapters/types.ts b/packages/web-shell/client/adapters/types.ts new file mode 100644 index 0000000000..164d897bc8 --- /dev/null +++ b/packages/web-shell/client/adapters/types.ts @@ -0,0 +1,92 @@ +import type { + DaemonMessage, + DaemonMessageToolCall, + DaemonMessageToolCallContent, + DaemonMessageToolCallStatus, + DaemonMessageToolKind, + DaemonMessageToolCallLocation, + DaemonMessageTodoItem, + DaemonAssistantMessage, + DaemonInsightErrorMessage, + DaemonInsightProgressMessage, + DaemonInsightReadyMessage, + DaemonPlanMessage, + DaemonSystemMessage, + DaemonToolGroupMessage, + DaemonUserMessage, + DaemonUserShellMessage, +} from './messageTypes'; +import type { DaemonStreamingState } from '@qwen-code/webui/daemon-react-sdk'; + +export type Message = DaemonMessage; +export type ACPToolCall = DaemonMessageToolCall; +export type ToolCallContent = DaemonMessageToolCallContent; +export type ToolCallStatus = DaemonMessageToolCallStatus; +export type ToolKind = DaemonMessageToolKind; +export type ToolCallLocation = DaemonMessageToolCallLocation; +export type TodoItem = DaemonMessageTodoItem; +export type StreamingState = DaemonStreamingState; + +export type UserMessage = DaemonUserMessage; +export type AssistantMessage = DaemonAssistantMessage; +export type InsightErrorMessage = DaemonInsightErrorMessage; +export type InsightProgressMessage = DaemonInsightProgressMessage; +export type InsightReadyMessage = DaemonInsightReadyMessage; +export type ToolGroupMessage = DaemonToolGroupMessage; +export type PlanMessage = DaemonPlanMessage; +export type SystemMessage = DaemonSystemMessage; +export type UserShellMessage = DaemonUserShellMessage; + +export interface ContentBlock { + type: 'text' | 'image'; + text?: string; + source?: { type: string; media_type: string; data: string }; +} + +export type PermissionOptionKind = + | 'allow_once' + | 'allow_always' + | 'reject_once' + | 'reject_always'; + +export interface PermissionOption { + id: string; + label: string; + kind?: PermissionOptionKind; +} + +export interface PermissionRequest { + id: string; + sessionId?: string; + toolCallId?: string; + title?: string; + toolKind?: string; + content: ContentBlock[]; + options: PermissionOption[]; + rawInput?: Record; + kind?: string; +} + +export interface CommandInfo { + name: string; + description: string; + argumentHint?: string; + subcommands?: string[]; + source?: string; + displayCategory?: 'custom' | 'skill' | 'system'; +} + +export interface ModelInfo { + id: string; + baseModelId?: string; + label?: string; + authType?: string; + contextWindow?: number; + modalities?: { + image?: boolean; + pdf?: boolean; + audio?: boolean; + video?: boolean; + }; + isRuntime?: boolean; +} diff --git a/packages/web-shell/client/build-artifact.test.ts b/packages/web-shell/client/build-artifact.test.ts new file mode 100644 index 0000000000..18f190597d --- /dev/null +++ b/packages/web-shell/client/build-artifact.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const DIST_PATH = resolve(__dirname, '../dist/index.js'); + +function readBundle(): string { + return readFileSync(DIST_PATH, 'utf8'); +} + +describe('build artifact — package boundary', () => { + it('externalizes @qwen-code/webui/daemon-react-sdk', () => { + const bundle = readBundle(); + expect(bundle).toContain('from "@qwen-code/webui/daemon-react-sdk"'); + }); + + it('does not inline DaemonSessionProvider source code', () => { + const bundle = readBundle(); + expect(bundle).not.toMatch(/DaemonStoreContext\s*=\s*createContext/); + }); + + it('does not inline createContext from React for provider contexts', () => { + const bundle = readBundle(); + const contextMatches = bundle.match(/createContext\(/g) ?? []; + // WebShell's own ThemeContext is fine; but there should be at most + // a small number of createContext calls (WebShell internal only). + // If webui Provider got bundled, we'd see many more. + expect(contextMatches.length).toBeLessThanOrEqual(3); + }); + + it('externalizes react and react-dom', () => { + const bundle = readBundle(); + expect(bundle).toContain('from "react"'); + expect(bundle).toContain('from "react/jsx-runtime"'); + }); + + it('externalizes @qwen-code/sdk subpaths', () => { + const bundle = readBundle(); + // Should not contain raw SDK implementation + expect(bundle).not.toMatch(/DaemonSessionClient\s*\{/); + }); +}); diff --git a/packages/web-shell/client/completions/atCompletion.ts b/packages/web-shell/client/completions/atCompletion.ts new file mode 100644 index 0000000000..b49b72078f --- /dev/null +++ b/packages/web-shell/client/completions/atCompletion.ts @@ -0,0 +1,57 @@ +import type { + CompletionContext, + CompletionResult, +} from '@codemirror/autocomplete'; + +export type GlobFn = ( + pattern: string, + opts?: { maxResults?: number }, +) => Promise<{ matches: string[] }>; + +export function createAtCompletionSource( + getGlob: () => GlobFn | undefined, +): ( + context: CompletionContext, +) => CompletionResult | null | Promise { + return (context) => atCompletionSource(context, getGlob); +} + +export function atCompletionSource( + context: CompletionContext, + getGlob: () => GlobFn | undefined, +): CompletionResult | null | Promise { + const line = context.state.doc.lineAt(context.pos); + const textBefore = line.text.slice(0, context.pos - line.from); + + const match = textBefore.match(/@([\w./-]*)$/); + if (!match) return null; + + const glob = getGlob(); + if (!glob) return null; + + const prefix = match[1]; + const atPos = context.pos - match[0].length; + + return fetchFiles(prefix, glob).then((files) => { + if (files.length === 0) return null; + return { + from: atPos, + options: files.map((f) => ({ + label: `@${f}`, + apply: `@${f} `, + type: 'file', + })), + filter: false, + }; + }); +} + +async function fetchFiles(prefix: string, glob: GlobFn): Promise { + try { + const pattern = prefix ? `${prefix}*` : '**/*'; + const result = await glob(pattern, { maxResults: 50 }); + return result.matches.filter((file) => file !== '.'); + } catch { + return []; + } +} diff --git a/packages/web-shell/client/completions/slashCompletion.test.ts b/packages/web-shell/client/completions/slashCompletion.test.ts new file mode 100644 index 0000000000..da715d9993 --- /dev/null +++ b/packages/web-shell/client/completions/slashCompletion.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, it } from 'vitest'; +import { CompletionContext } from '@codemirror/autocomplete'; +import { EditorState } from '@codemirror/state'; +import type { CommandInfo } from '../adapters/types'; +import { getTranslator } from '../i18n'; +import { + getSlashCommandArgumentHint, + slashCompletionSource, +} from './slashCompletion'; + +describe('getSlashCommandArgumentHint', () => { + it('returns a command argument hint for a bare slash command', () => { + const commands: CommandInfo[] = [ + { + name: 'stats', + description: 'Show usage stats', + argumentHint: '[model|tools]', + }, + ]; + + expect(getSlashCommandArgumentHint('/stats', commands, 'en')).toBe( + '[model|tools]', + ); + expect(getSlashCommandArgumentHint('/stats ', commands, 'en')).toBe( + '[model|tools]', + ); + }); + + it('falls back to implicit subcommands when no argument hint is provided', () => { + const commands: CommandInfo[] = [ + { + name: 'context', + description: 'Show context usage', + }, + ]; + + expect(getSlashCommandArgumentHint('/context', commands, 'en')).toBe( + '[detail]', + ); + }); + + it('does not return a hint once arguments are being typed', () => { + const commands: CommandInfo[] = [ + { + name: 'stats', + description: 'Show usage stats', + argumentHint: '[model|tools]', + }, + ]; + + expect(getSlashCommandArgumentHint('/stats m', commands, 'en')).toBeNull(); + }); + + it('returns argument hints for Chinese command names', () => { + const commands: CommandInfo[] = [ + { + name: '学生信息', + description: '收集学生信息', + argumentHint: '<姓名>', + }, + ]; + + expect(getSlashCommandArgumentHint('/学生信息', commands, 'zh-CN')).toBe( + '<姓名>', + ); + }); +}); + +describe('slashCompletionSource', () => { + it('completes a top-level slash command from any cursor position in the command', () => { + const commands: CommandInfo[] = [ + { name: 'context', description: 'Show context usage' }, + { name: 'clear', description: 'Clear the screen' }, + ]; + const source = slashCompletionSource( + () => commands, + () => [], + () => 'en', + getTranslator('en'), + ); + + for (const pos of [0, 2, 4]) { + const state = EditorState.create({ doc: '/con' }); + const result = source(new CompletionContext(state, pos, true)); + + expect(result?.from).toBe(0); + expect(result?.to).toBe(4); + expect(result?.options.map((option) => option.label)).toEqual([ + '/context', + ]); + } + }); + + it('shows custom slash commands before built-in commands', () => { + const commands: CommandInfo[] = [ + { + name: 'clear', + description: 'Clear the screen', + source: 'builtin-command', + }, + { + name: 'demo:ping', + description: 'Project command', + source: 'skill-dir-command', + }, + { + name: 'context', + description: 'Show context usage', + source: 'builtin-command', + }, + ]; + const source = slashCompletionSource( + () => commands, + () => [], + () => 'en', + getTranslator('en'), + ); + const state = EditorState.create({ doc: '/' }); + const result = source(new CompletionContext(state, 1, true)); + + expect(result?.options.map((option) => option.label)).toEqual([ + '/demo:ping', + '/clear', + '/context', + ]); + }); + + it('keeps custom commands first when filtering slash commands', () => { + const commands: CommandInfo[] = [ + { + name: 'model', + description: 'Switch model', + source: 'builtin-command', + }, + { + name: 'memory', + description: 'Manage memory', + source: 'builtin-command', + }, + { + name: 'my-command', + description: 'Project command', + source: 'skill-dir-command', + }, + ]; + const source = slashCompletionSource(() => commands); + const state = EditorState.create({ doc: '/m' }); + const result = source(new CompletionContext(state, 2, true)); + + expect(result?.options.map((option) => option.label)).toEqual([ + '/my-command', + '/memory', + '/model', + ]); + }); + + it('orders slash commands by custom, skill, then system categories', () => { + const commands: CommandInfo[] = [ + { + name: 'clear', + description: 'Clear the screen', + source: 'builtin-command', + }, + { + name: 'batch', + description: 'Execute batch operations', + displayCategory: 'skill', + }, + { + name: 'demo:ping', + description: 'Project command', + source: 'skill-dir-command', + }, + ]; + const source = slashCompletionSource( + () => commands, + () => [], + () => 'en', + getTranslator('en'), + ); + const state = EditorState.create({ doc: '/' }); + const result = source(new CompletionContext(state, 1, true)); + + expect(result?.options.map((option) => option.label)).toEqual([ + '/demo:ping', + '/batch', + '/clear', + ]); + expect( + result?.options.map((option) => { + const section = + typeof option.section === 'string' ? undefined : option.section; + return section ? { name: section.name, rank: section.rank } : undefined; + }), + ).toEqual([ + { name: 'Custom commands', rank: 0 }, + { name: 'Skill commands', rank: 1 }, + { name: 'System commands', rank: 2 }, + ]); + }); + + it('localizes slash command category section titles', () => { + const commands: CommandInfo[] = [ + { + name: 'clear', + description: 'Clear the screen', + source: 'builtin-command', + }, + { + name: 'batch', + description: 'Execute batch operations', + displayCategory: 'skill', + }, + { + name: 'demo:ping', + description: 'Project command', + source: 'skill-dir-command', + }, + ]; + const source = slashCompletionSource( + () => commands, + () => [], + () => 'zh-CN', + getTranslator('zh-CN'), + ); + const state = EditorState.create({ doc: '/' }); + const result = source(new CompletionContext(state, 1, true)); + + expect( + result?.options.map((option) => { + const section = + typeof option.section === 'string' ? undefined : option.section; + return section?.name; + }), + ).toEqual(['自定义', 'Skill', '系统']); + }); + + it('only includes sections for categories with matching commands', () => { + const commands: CommandInfo[] = [ + { + name: 'clear', + description: 'Clear the screen', + source: 'builtin-command', + }, + { + name: 'batch', + description: 'Execute batch operations', + displayCategory: 'skill', + }, + { + name: 'demo:ping', + description: 'Project command', + source: 'skill-dir-command', + }, + ]; + const source = slashCompletionSource( + () => commands, + () => [], + () => 'en', + getTranslator('en'), + ); + const state = EditorState.create({ doc: '/cle' }); + const result = source(new CompletionContext(state, 4, true)); + + expect(result?.options.map((option) => option.label)).toEqual(['/clear']); + expect( + result?.options.map((option) => { + const section = + typeof option.section === 'string' ? undefined : option.section; + return section?.name; + }), + ).toEqual(['System commands']); + }); + + it('supports custom slash command category order', () => { + const commands: CommandInfo[] = [ + { + name: 'clear', + description: 'Clear the screen', + source: 'builtin-command', + }, + { + name: 'batch', + description: 'Execute batch operations', + displayCategory: 'skill', + }, + { + name: 'demo:ping', + description: 'Project command', + source: 'skill-dir-command', + }, + ]; + const source = slashCompletionSource( + () => commands, + () => [], + () => 'en', + getTranslator('en'), + () => ['system', 'custom', 'skill'], + ); + const state = EditorState.create({ doc: '/' }); + const result = source(new CompletionContext(state, 1, true)); + + expect(result?.options.map((option) => option.label)).toEqual([ + '/clear', + '/demo:ping', + '/batch', + ]); + expect( + result?.options.map((option) => { + const section = + typeof option.section === 'string' ? undefined : option.section; + return section ? { name: section.name, rank: section.rank } : undefined; + }), + ).toEqual([ + { name: 'System commands', rank: 0 }, + { name: 'Custom commands', rank: 1 }, + { name: 'Skill commands', rank: 2 }, + ]); + }); + + it('completes Chinese custom slash command names', () => { + const commands: CommandInfo[] = [ + { + name: '学生信息', + description: '收集学生信息', + source: 'skill-dir-command', + }, + { + name: 'clear', + description: 'Clear the screen', + source: 'builtin-command', + }, + ]; + const source = slashCompletionSource( + () => commands, + () => [], + () => 'zh-CN', + getTranslator('zh-CN'), + ); + const state = EditorState.create({ doc: '/学' }); + const result = source(new CompletionContext(state, 2, true)); + + expect(result?.from).toBe(0); + expect(result?.to).toBe(2); + expect(result?.options.map((option) => option.label)).toEqual([ + '/学生信息', + ]); + }); + + it('completes implicit /mcp subcommands', () => { + const commands: CommandInfo[] = [ + { + name: 'mcp', + description: 'Manage MCP servers', + argumentHint: 'desc|nodesc|schema|auth|noauth', + }, + ]; + const source = slashCompletionSource(() => commands); + const state = EditorState.create({ doc: '/mcp d' }); + const result = source(new CompletionContext(state, 6, true)); + + expect(result?.options.map((option) => option.label)).toEqual([ + 'desc', + 'nodesc', + ]); + expect(result?.options[0]?.apply).toBe('/mcp desc '); + }); + + it('does not expose third-level /agents create completions', () => { + const commands: CommandInfo[] = [ + { + name: 'agents', + description: 'Manage subagents', + argumentHint: 'manage|create', + }, + ]; + const source = slashCompletionSource(() => commands); + const state = EditorState.create({ doc: '/agents create ' }); + const result = source(new CompletionContext(state, 15, true)); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/web-shell/client/completions/slashCompletion.ts b/packages/web-shell/client/completions/slashCompletion.ts new file mode 100644 index 0000000000..1e9af24548 --- /dev/null +++ b/packages/web-shell/client/completions/slashCompletion.ts @@ -0,0 +1,411 @@ +import type { + Completion, + CompletionContext, + CompletionResult, + CompletionSection, +} from '@codemirror/autocomplete'; +import type { CommandInfo } from '../adapters/types'; +import type { WebShellLanguage } from '../i18n'; +import { + compareCommandsByCategory, + DEFAULT_COMMAND_CATEGORY_ORDER, + getCategoryRank, + getCommandDisplayCategory, + type CommandDisplayCategory, + type CommandDisplayCategoryOrder, +} from '../utils/commandDisplay'; + +export interface SkillInfo { + name: string; + description: string; +} + +type Translate = (key: string) => string; +const COMMAND_NAME_PATTERN = String.raw`([^\s/]+)`; + +interface SubcommandNode { + name: string; + description: string; + children?: SubcommandNode[]; +} + +const SUBCOMMAND_TREE_ZH: Record = { + agents: [ + { name: 'manage', description: '管理现有 subagents' }, + { name: 'create', description: '创建新的 subagent' }, + ], + theme: [ + { name: 'light', description: '切换到浅色主题' }, + { name: 'dark', description: '切换到深色主题' }, + ], + export: [ + { name: 'md', description: '将会话导出为 Markdown 文件' }, + { name: 'html', description: '将会话导出为 HTML 文件' }, + { name: 'json', description: '将会话导出为 JSON 文件' }, + { name: 'jsonl', description: '将会话导出为 JSONL 文件(每行一条消息)' }, + ], + language: [ + { + name: 'ui', + description: '设置 UI 语言', + children: [ + { name: 'en', description: 'English' }, + { name: 'zh-CN', description: '中文' }, + ], + }, + { name: 'output', description: '设置 LLM 输出语言' }, + ], +}; + +const SUBCOMMAND_TREE_EN: Record = { + agents: [ + { name: 'manage', description: 'Manage existing subagents' }, + { name: 'create', description: 'Create a new subagent' }, + ], + theme: [ + { name: 'light', description: 'Switch to light theme' }, + { name: 'dark', description: 'Switch to dark theme' }, + ], + export: [ + { name: 'md', description: 'Export as Markdown' }, + { name: 'html', description: 'Export as HTML' }, + { name: 'json', description: 'Export as JSON' }, + { name: 'jsonl', description: 'Export as JSONL' }, + ], + language: [ + { + name: 'ui', + description: 'Set UI language', + children: [ + { name: 'en', description: 'English' }, + { name: 'zh-CN', description: '中文' }, + ], + }, + { name: 'output', description: 'Set LLM output language' }, + ], +}; + +const IMPLICIT_SUBCOMMAND_TREE_ZH: Record = { + context: [{ name: 'detail', description: '显示详细上下文信息' }], + copy: [ + { name: 'code', description: '复制代码块' }, + { name: 'latex', description: '复制 LaTeX 公式' }, + { name: 'inline-latex', description: '复制行内 LaTeX 公式' }, + ], + tools: [{ name: 'desc', description: '显示工具详细描述' }], + stats: [ + { name: 'model', description: '显示各模型使用统计' }, + { name: 'tools', description: '显示工具使用统计' }, + ], + mcp: [ + { name: 'desc', description: '显示 MCP server 和工具描述' }, + { name: 'nodesc', description: '隐藏 MCP server 和工具描述' }, + { name: 'schema', description: '显示工具参数 schema' }, + ], + memory: [ + { name: 'show', description: '查看 memory 文件' }, + { name: 'add', description: '新增 memory' }, + { name: 'refresh', description: '刷新 memory 文件列表' }, + ], +}; + +const IMPLICIT_SUBCOMMAND_TREE_EN: Record = { + context: [{ name: 'detail', description: 'Show detailed context info' }], + copy: [ + { name: 'code', description: 'Copy code blocks' }, + { name: 'latex', description: 'Copy LaTeX formula' }, + { name: 'inline-latex', description: 'Copy inline LaTeX formula' }, + ], + tools: [{ name: 'desc', description: 'Show tool descriptions' }], + stats: [ + { name: 'model', description: 'Show per-model usage statistics' }, + { name: 'tools', description: 'Show tool usage statistics' }, + ], + mcp: [ + { name: 'desc', description: 'Show MCP server and tool descriptions' }, + { name: 'nodesc', description: 'Hide MCP server and tool descriptions' }, + { name: 'schema', description: 'Show tool parameter schemas' }, + ], + memory: [ + { name: 'show', description: 'Show memory files' }, + { name: 'add', description: 'Add memory' }, + { name: 'refresh', description: 'Refresh memory files' }, + ], +}; + +function resolveSubcommands( + cmdName: string, + parts: string[], + dynamicSkills: SkillInfo[] | undefined, + language: WebShellLanguage, +): SubcommandNode[] | null { + if (cmdName === 'skills' && parts.length === 0) { + if (!dynamicSkills || dynamicSkills.length === 0) return null; + return dynamicSkills.map((s) => ({ + name: s.name, + description: s.description, + })); + } + + const tree = language === 'zh-CN' ? SUBCOMMAND_TREE_ZH : SUBCOMMAND_TREE_EN; + let nodes = tree[cmdName]; + + if (!nodes) { + const implicitTree = + language === 'zh-CN' + ? IMPLICIT_SUBCOMMAND_TREE_ZH + : IMPLICIT_SUBCOMMAND_TREE_EN; + nodes = implicitTree[cmdName]; + } + + if (!nodes) return null; + + for (const part of parts) { + const match = nodes.find((n) => n.name === part); + if (!match?.children) return null; + nodes = match.children; + } + return nodes; +} + +function comparePrefixFirst(a: string, b: string, query: string): number { + const aLower = a.toLowerCase(); + const bLower = b.toLowerCase(); + const aStarts = aLower.startsWith(query); + const bStarts = bLower.startsWith(query); + if (aStarts !== bStarts) return aStarts ? -1 : 1; + return a.localeCompare(b); +} + +function compareSlashCommands( + a: CommandInfo, + b: CommandInfo, + query: string, + categoryOrder: CommandDisplayCategoryOrder, +): number { + const order = compareCommandsByCategory(a, b, categoryOrder); + if (order !== 0) return order; + return query ? comparePrefixFirst(a.name, b.name, query) : 0; +} + +const COMMAND_SECTION_KEYS: Record = { + custom: 'slash.category.custom', + skill: 'slash.category.skill', + system: 'slash.category.system', +}; + +function renderCommandSectionHeader(section: CompletionSection): HTMLElement { + const header = document.createElement('completion-section'); + header.className = 'cm-command-section-header'; + header.setAttribute('aria-label', section.name); + return header; +} + +function getCommandSection( + command: CommandInfo, + translate: Translate, + categoryOrder: CommandDisplayCategoryOrder, +): CompletionSection { + const category = getCommandDisplayCategory(command); + return { + name: translate(COMMAND_SECTION_KEYS[category]), + rank: getCategoryRank(category, categoryOrder), + header: renderCommandSectionHeader, + }; +} + +export function getMissingSlashPrefixCompletion( + text: string, + commands: CommandInfo[], +): string | null { + if (!text || text.includes(' ') || /^[/@!?]/.test(text)) return null; + + const lp = text.toLowerCase(); + const match = commands.find((c) => c.name.toLowerCase().startsWith(lp)); + if (!match) return null; + + return `/${match.name} `; +} + +export function getImplicitTabCompletion( + text: string, + commands: CommandInfo[], + language: WebShellLanguage, +): string | null { + const match = text.match(new RegExp(`^/${COMMAND_NAME_PATTERN}\\s+$`)); + if (!match) return null; + + const cmdName = match[1]; + const cmd = commands.find((c) => c.name === cmdName); + const tree = language === 'zh-CN' ? SUBCOMMAND_TREE_ZH : SUBCOMMAND_TREE_EN; + if (cmd?.subcommands?.length || tree[cmdName] || cmdName === 'skills') { + return null; + } + + const implicitTree = + language === 'zh-CN' + ? IMPLICIT_SUBCOMMAND_TREE_ZH + : IMPLICIT_SUBCOMMAND_TREE_EN; + const nodes = implicitTree[cmdName]; + if (!nodes || nodes.length === 0) return null; + + return `/${cmdName} ${nodes[0].name} `; +} + +export function getSlashCommandArgumentHint( + text: string, + commands: CommandInfo[], + language: WebShellLanguage, +): string | null { + const match = text.match(new RegExp(`^/${COMMAND_NAME_PATTERN}(\\s*)$`)); + if (!match) return null; + + const cmdName = match[1]; + const cmd = commands.find((c) => c.name === cmdName); + if (!cmd) return null; + + const argumentHint = cmd.argumentHint?.trim(); + if (argumentHint) return argumentHint; + + const tree = language === 'zh-CN' ? SUBCOMMAND_TREE_ZH : SUBCOMMAND_TREE_EN; + if (cmd.subcommands?.length || tree[cmdName] || cmdName === 'skills') { + return null; + } + + const implicitTree = + language === 'zh-CN' + ? IMPLICIT_SUBCOMMAND_TREE_ZH + : IMPLICIT_SUBCOMMAND_TREE_EN; + const nodes = implicitTree[cmdName]; + if (!nodes || nodes.length === 0) return null; + + return `[${nodes.map((node) => node.name).join('|')}]`; +} + +export function slashCompletionSource( + getCommands: () => CommandInfo[], + getSkills: () => SkillInfo[] = () => [], + getLanguage: () => WebShellLanguage = () => 'en', + translate: Translate = (key) => key, + getCategoryOrder: () => CommandDisplayCategoryOrder | undefined = () => + DEFAULT_COMMAND_CATEGORY_ORDER, +) { + return (context: CompletionContext): CompletionResult | null => { + const line = context.state.doc.lineAt(context.pos); + const textBefore = line.text.slice(0, context.pos - line.from); + + // Sub-command completion: "/command arg1 arg2..." + const subMatch = textBefore.match( + new RegExp(`^/${COMMAND_NAME_PATTERN}\\s+(.*)$`), + ); + if (subMatch) { + const [, cmdName, rest] = subMatch; + const commands = getCommands(); + const cmd = commands.find((c) => c.name === cmdName); + const language = getLanguage(); + const tree = + language === 'zh-CN' ? SUBCOMMAND_TREE_ZH : SUBCOMMAND_TREE_EN; + const implicitTree = + language === 'zh-CN' + ? IMPLICIT_SUBCOMMAND_TREE_ZH + : IMPLICIT_SUBCOMMAND_TREE_EN; + const hasTree = !!tree[cmdName] || cmdName === 'skills'; + const hasImplicitTree = !!implicitTree[cmdName]; + if (!cmd?.subcommands?.length && !hasTree && !hasImplicitTree) + return null; + + // Split rest into completed parts and current typing + const tokens = rest.split(/\s+/); + const currentTyping = tokens.pop() || ''; + const completedParts = tokens; + + // Implicit sub-commands: only show when user starts typing (not on space alone) + if ( + !cmd?.subcommands?.length && + !hasTree && + hasImplicitTree && + !currentTyping + ) { + return null; + } + + const nodes = resolveSubcommands( + cmdName, + completedParts, + getSkills(), + language, + ); + if (!nodes) return null; + + const lp = currentTyping.toLowerCase(); + const prefix = `/${cmdName} ${completedParts.length > 0 ? completedParts.join(' ') + ' ' : ''}`; + const filteredNodes = nodes + .filter((n) => !currentTyping || n.name.toLowerCase().includes(lp)) + .sort((a, b) => + currentTyping ? comparePrefixFirst(a.name, b.name, lp) : 0, + ); + const isSkillList = cmdName === 'skills' && completedParts.length === 0; + const maxNameLength = isSkillList + ? Math.max(...filteredNodes.map((n) => n.name.length), 0) + : 0; + const options = filteredNodes.map((n): Completion => { + const command = `${prefix}${n.name}`; + const padLength = Math.max(maxNameLength - n.name.length, 0); + return { + label: n.name, + ...(isSkillList + ? { + displayLabel: `${n.name}${'\u00a0'.repeat(padLength)}`, + type: 'skill', + } + : {}), + detail: n.description || undefined, + apply: `${command} `, + }; + }); + + if (options.length === 0) return null; + + return { + from: line.from, + options, + filter: false, + }; + } + + // Top-level command completion: "/" or "/ex". + // Use the whole line so moving the cursor before or inside the command + // still offers the same completions and replaces the full command token. + const match = line.text.match(/^\/([^\s/]*)$/); + if (!match) return null; + + const prefix = match[1]; + const commands = getCommands(); + const categoryOrder = getCategoryOrder() ?? DEFAULT_COMMAND_CATEGORY_ORDER; + const lp = prefix.toLowerCase(); + const filteredCommands = commands + .filter((c) => { + if (!prefix) return true; + return c.name.toLowerCase().includes(lp); + }) + .sort((a, b) => compareSlashCommands(a, b, lp, categoryOrder)); + const options = filteredCommands.map((c): Completion => { + const command = `/${c.name}`; + return { + label: command, + detail: c.description || undefined, + apply: `${command} `, + section: getCommandSection(c, translate, categoryOrder), + }; + }); + + if (options.length === 0) return null; + + return { + from: line.from, + to: line.to, + options, + filter: false, + }; + }; +} diff --git a/packages/web-shell/client/components/Editor.module.css b/packages/web-shell/client/components/Editor.module.css new file mode 100644 index 0000000000..1e2fe1a978 --- /dev/null +++ b/packages/web-shell/client/components/Editor.module.css @@ -0,0 +1,287 @@ +.container { + cursor: text; +} + +.borderTop, +.borderBottom { + height: 1px; + background: var(--text-secondary); +} + +.borderTop { + position: relative; +} + +.borderTopLabel { + position: absolute; + right: 2ch; + top: 50%; + max-width: min(48ch, calc(100% - 4ch)); + padding: 0 1ch; + overflow: hidden; + color: var(--text-secondary); + background: var(--app-bg); + font-size: 12px; + line-height: 1; + text-overflow: ellipsis; + white-space: nowrap; + transform: translateY(-50%); +} + +.line { + display: flex; + align-items: baseline; + padding: 6px 0; +} + +.prefix { + color: var(--assist-color); + font-weight: bold; + flex-shrink: 0; + padding-right: 1ch; + line-height: 1.6; + user-select: none; +} + +.prefixShell { + color: var(--warning-color); +} + +.prefixAutoEdit { + color: var(--warning-color); +} + +.prefixYolo { + color: var(--error-color); +} + +.shellMode .borderTop, +.shellMode .borderBottom { + background: var(--warning-color); + opacity: 0.4; +} + +.modePlan .borderTop, +.modePlan .borderBottom { + background: var(--success-color); +} + +.modeAutoEdit .borderTop, +.modeAutoEdit .borderBottom { + background: #8b7530; +} + +.modeYolo .borderTop, +.modeYolo .borderBottom { + background: #8b3a4a; +} + +.wrapper { + flex: 1; + min-width: 0; +} + +.searchBar { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 0; + font-size: 12px; +} + +.searchLabel { + color: var(--text-dimmed); + flex-shrink: 0; +} + +.searchInput { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 12px; +} + +.searchInput::placeholder { + color: var(--text-dimmed); +} + +.searchHint { + color: var(--text-dimmed); + flex-shrink: 0; + font-size: 11px; +} + +.searchResults { + display: flex; + flex-direction: column; + gap: 1px; + padding: 2px 0 6px; +} + +.searchResult { + display: grid; + grid-template-columns: 16px minmax(0, 1fr); + align-items: start; + gap: 4px; + width: 100%; + min-height: 24px; + padding: 3px 6px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.45; + text-align: left; + cursor: pointer; +} + +.searchResult:hover, +.searchResultActive { + background: var(--bg-tertiary); + color: var(--accent-color); +} + +.searchResultMarker { + color: var(--accent-color); + line-height: 1.45; +} + +.searchResultText { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.searchEmpty { + padding: 4px 0 6px; + color: var(--text-dimmed); + font-size: 12px; +} + +.images { + display: flex; + gap: 6px; + padding: 4px 0; + flex-wrap: wrap; +} + +.imageThumb { + position: relative; + width: 48px; + height: 48px; + border-radius: 4px; + overflow: hidden; + border: 1px solid var(--border-color); +} + +.imageThumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.imageRemove { + position: absolute; + top: 0; + right: 0; + width: 16px; + height: 16px; + font-size: 12px; + line-height: 16px; + text-align: center; + background: rgba(0, 0, 0, 0.7); + color: var(--error-color); + border: none; + cursor: pointer; + border-radius: 0 0 0 4px; +} + +.imageRemove:hover { + background: var(--error-color); + color: #fff; +} + +:global(.cm-tooltip-autocomplete) { + min-width: 500px !important; + max-width: 700px !important; + max-height: 400px !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important; +} + +:global(.cm-tooltip-autocomplete > ul) { + max-height: 380px !important; +} + +:global(.cm-tooltip-autocomplete ul li) { + display: flex !important; + align-items: baseline; + min-width: 0; + padding: 4px 8px !important; + overflow: hidden; +} + +:global(.cm-tooltip-autocomplete completion-section) { + display: block !important; + height: 0; + margin: 6px 10px 3px; + padding: 0 !important; + border-bottom: 1px solid var(--border-color) !important; +} + +:global(.cm-tooltip-autocomplete completion-section:first-of-type) { + display: none !important; +} + +:global(.cm-tooltip-autocomplete .cm-completionLabel) { + flex-shrink: 0; + min-width: 14ch; + max-width: 28ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +:global(.cm-tooltip-autocomplete .cm-completionDetail) { + flex: 1 1 auto; + min-width: 0; + color: var(--text-secondary); + font-size: 13px; + margin-left: 2ch; + opacity: 0.8; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +:global(.cm-tooltip-autocomplete ul li.cm-file-completion .cm-completionLabel) { + flex: 1 1 auto; + min-width: 0; + max-width: none; +} + +:global(.cm-tooltip-autocomplete ul li.cm-skill-completion) { + align-items: flex-start !important; +} + +:global( + .cm-tooltip-autocomplete ul li.cm-skill-completion .cm-completionLabel +) { + align-self: center; +} + +:global( + .cm-tooltip-autocomplete ul li.cm-skill-completion .cm-completionDetail +) { + white-space: normal !important; + display: -webkit-box !important; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + line-height: 1.35; + max-height: 2.7em; +} diff --git a/packages/web-shell/client/components/Editor.test.ts b/packages/web-shell/client/components/Editor.test.ts new file mode 100644 index 0000000000..6aa4f81845 --- /dev/null +++ b/packages/web-shell/client/components/Editor.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { + createLargePastePlaceholder, + expandLargePastePlaceholders, + isLargePaste, + normalizePastedText, + prunePendingPastes, +} from './Editor'; + +describe('Editor large paste helpers', () => { + it('normalizes pasted newlines before threshold checks', () => { + const pasted = 'a\r\nb\rc'; + + expect(normalizePastedText(pasted)).toBe('a\nb\nc'); + }); + + it('treats long or multi-line pasted text as a large paste', () => { + expect(isLargePaste('x'.repeat(1001))).toBe(true); + expect(isLargePaste(Array.from({ length: 11 }, () => 'x').join('\n'))).toBe( + true, + ); + expect(isLargePaste('short\ntext')).toBe(false); + }); + + it('creates stable placeholders and expands them on submit', () => { + const pendingPastes = new Map(); + const firstPaste = 'first pasted block'; + const secondPaste = 'second pasted block'; + + const first = createLargePastePlaceholder(pendingPastes, 1, firstPaste); + const second = createLargePastePlaceholder( + pendingPastes, + first.nextPasteId, + secondPaste, + ); + + expect(first.placeholderText).toBe('[Pasted Content 18 chars]'); + expect(second.placeholderText).toBe('[Pasted Content 19 chars] #2'); + expect(second.nextPasteId).toBe(3); + expect( + expandLargePastePlaceholders( + pendingPastes, + `before ${first.placeholderText} middle ${second.placeholderText} after`, + ), + ).toBe(`before ${firstPaste} middle ${secondPaste} after`); + }); + + it('removes deleted placeholders and resets the counter once none remain', () => { + const pendingPastes = new Map(); + const first = createLargePastePlaceholder(pendingPastes, 1, 'first'); + const second = createLargePastePlaceholder( + pendingPastes, + first.nextPasteId, + 'second', + ); + + expect( + prunePendingPastes(pendingPastes, second.placeholderText), + ).toBeNull(); + expect([...pendingPastes.keys()]).toEqual([second.placeholderText]); + expect(prunePendingPastes(pendingPastes, '')).toBe(1); + expect(pendingPastes.size).toBe(0); + }); + + it('leaves unknown placeholder-shaped text unchanged', () => { + expect( + expandLargePastePlaceholders( + new Map(), + 'keep [Pasted Content 10 chars] as text', + ), + ).toBe('keep [Pasted Content 10 chars] as text'); + }); +}); diff --git a/packages/web-shell/client/components/Editor.tsx b/packages/web-shell/client/components/Editor.tsx new file mode 100644 index 0000000000..66b924d3db --- /dev/null +++ b/packages/web-shell/client/components/Editor.tsx @@ -0,0 +1,1220 @@ +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useCallback, + useState, +} from 'react'; +import { EditorView, keymap, placeholder } from '@codemirror/view'; +import { EditorState, Compartment, Prec } from '@codemirror/state'; +import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; +import { + acceptCompletion, + autocompletion, + closeCompletion, + completionStatus, + moveCompletionSelection, + startCompletion, + type CompletionSource, +} from '@codemirror/autocomplete'; +import { minimalSetup } from 'codemirror'; +import type { CommandInfo } from '../adapters/types'; +import type { PromptImage } from '../adapters/promptTypes'; +import { + useOptionalWorkspace, + type UseDaemonFollowupSuggestionReturn, +} from '@qwen-code/webui/daemon-react-sdk'; +import { + slashCompletionSource, + getImplicitTabCompletion, + getMissingSlashPrefixCompletion, + type SkillInfo, +} from '../completions/slashCompletion'; +import type { CommandDisplayCategoryOrder } from '../utils/commandDisplay'; +import { createAtCompletionSource } from '../completions/atCompletion'; +import { useInputHistory } from '../hooks/useInputHistory'; +import { useI18n } from '../i18n'; +import { + inputHighlight, + inputHighlightTheme, +} from '../extensions/inputHighlight'; +import { isEditableTarget } from '../utils/dom'; +import { PromptChevron } from './PromptChevron'; +import styles from './Editor.module.css'; + +interface EditorProps { + onSubmit: (text: string, images?: PromptImage[]) => boolean | void; + onCycleMode?: () => void; + onToggleShortcuts?: () => void; + disabled?: boolean; + placeholderText?: string; + commands: CommandInfo[]; + skills?: SkillInfo[]; + slashCommandCategoryOrder?: CommandDisplayCategoryOrder; + queuedMessages?: string[]; + onPopQueuedMessages?: () => string | null; + onClearQueuedMessages?: () => boolean; + currentMode?: string; + draftText?: string; + draftVersion?: number; + onFocusActiveAgents?: () => boolean; + dialogOpen?: boolean; + followupState?: UseDaemonFollowupSuggestionReturn['followupState']; + onAcceptFollowup?: UseDaemonFollowupSuggestionReturn['onAcceptFollowup']; + onDismissFollowup?: UseDaemonFollowupSuggestionReturn['onDismissFollowup']; + sessionName?: string; +} + +export interface EditorHandle { + blur(): void; + clearText(): void; + focus(): void; + getText(): string; + insertText(text: string): void; + retryLast(): void; +} + +const editableCompartment = new Compartment(); +const placeholderCompartment = new Compartment(); +const LARGE_PASTE_CHAR_THRESHOLD = 1000; +const LARGE_PASTE_LINE_THRESHOLD = 10; + +export function normalizePastedText(text: string): string { + return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +export function isLargePaste(text: string): boolean { + return ( + [...text].length > LARGE_PASTE_CHAR_THRESHOLD || + text.split('\n').length > LARGE_PASTE_LINE_THRESHOLD + ); +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export interface LargePastePlaceholderResult { + placeholderText: string; + nextPasteId: number; +} + +export function createLargePastePlaceholder( + pendingPastes: Map, + nextPasteId: number, + pasted: string, +): LargePastePlaceholderResult { + const charCount = [...pasted].length; + const base = `[Pasted Content ${charCount} chars]`; + const placeholderText = nextPasteId === 1 ? base : `${base} #${nextPasteId}`; + pendingPastes.set(placeholderText, pasted); + return { placeholderText, nextPasteId: nextPasteId + 1 }; +} + +export function prunePendingPastes( + pendingPastes: Map, + docText: string, +): number | null { + for (const placeholderText of pendingPastes.keys()) { + if (!docText.includes(placeholderText)) { + pendingPastes.delete(placeholderText); + } + } + return pendingPastes.size === 0 ? 1 : null; +} + +export function expandLargePastePlaceholders( + pendingPastes: Map, + text: string, +): string { + if (pendingPastes.size === 0) return text; + const placeholders = [...pendingPastes.keys()].sort( + (a, b) => b.length - a.length, + ); + const pattern = new RegExp(placeholders.map(escapeRegExp).join('|'), 'g'); + return text.replace( + pattern, + (placeholderText) => pendingPastes.get(placeholderText) ?? placeholderText, + ); +} + +function getModeClass(mode: string, shellMode: boolean): string { + if (shellMode) return ''; + switch (mode) { + case 'plan': + return styles.modePlan; + case 'auto-edit': + return styles.modeAutoEdit; + case 'yolo': + return styles.modeYolo; + default: + return ''; + } +} + +export const Editor = forwardRef(function Editor( + { + onSubmit, + onCycleMode, + onToggleShortcuts, + disabled = false, + placeholderText = 'Type a message...', + commands, + skills = [], + slashCommandCategoryOrder, + queuedMessages = [], + onPopQueuedMessages, + onClearQueuedMessages, + currentMode = 'default', + draftText, + draftVersion, + onFocusActiveAgents, + dialogOpen = false, + followupState, + onAcceptFollowup, + onDismissFollowup, + sessionName, + }, + ref, +) { + const workspace = useOptionalWorkspace(); + const { language, t } = useI18n(); + const containerRef = useRef(null); + const viewRef = useRef(null); + const onSubmitRef = useRef(onSubmit); + onSubmitRef.current = onSubmit; + const onCycleModeRef = useRef(onCycleMode); + onCycleModeRef.current = onCycleMode; + const onToggleShortcutsRef = useRef(onToggleShortcuts); + onToggleShortcutsRef.current = onToggleShortcuts; + const disabledRef = useRef(disabled); + disabledRef.current = disabled; + const commandsRef = useRef(commands); + commandsRef.current = commands; + const skillsRef = useRef(skills); + skillsRef.current = skills; + const slashCommandCategoryOrderRef = useRef(slashCommandCategoryOrder); + slashCommandCategoryOrderRef.current = slashCommandCategoryOrder; + const tRef = useRef(t); + tRef.current = t; + const queuedMessagesRef = useRef(queuedMessages); + queuedMessagesRef.current = queuedMessages; + const onPopQueuedMessagesRef = useRef(onPopQueuedMessages); + onPopQueuedMessagesRef.current = onPopQueuedMessages; + const onClearQueuedMessagesRef = useRef(onClearQueuedMessages); + onClearQueuedMessagesRef.current = onClearQueuedMessages; + const followupStateRef = useRef(followupState); + followupStateRef.current = followupState; + const onAcceptFollowupRef = useRef(onAcceptFollowup); + onAcceptFollowupRef.current = onAcceptFollowup; + const onDismissFollowupRef = useRef(onDismissFollowup); + onDismissFollowupRef.current = onDismissFollowup; + const onFocusActiveAgentsRef = useRef(onFocusActiveAgents); + onFocusActiveAgentsRef.current = onFocusActiveAgents; + const languageRef = useRef(language); + languageRef.current = language; + const workspaceActionsRef = useRef(workspace?.actions); + workspaceActionsRef.current = workspace?.actions; + const [shellMode, setShellMode] = useState(false); + const shellModeRef = useRef(shellMode); + shellModeRef.current = shellMode; + const [searchMode, setSearchMode] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [searchMatches, setSearchMatches] = useState([]); + const [searchActiveIndex, setSearchActiveIndex] = useState(0); + const searchInputRef = useRef(null); + const searchDraftRef = useRef(''); + const [pastedImages, setPastedImages] = useState([]); + const pastedImagesRef = useRef([]); + const pendingPastesRef = useRef>(new Map()); + const nextPasteIdRef = useRef(1); + + const promptHistory = useInputHistory(); + const shellHistory = useInputHistory('qwen-web-shell-command-history'); + + const { + push, + navigateUp, + navigateDown, + isNavigating, + reset, + getReverseMatches, + getLastEntry, + resetSearch, + } = promptHistory; + const historyActionsRef = useRef({ + push, + navigateUp, + navigateDown, + isNavigating, + reset, + getReverseMatches, + getLastEntry, + resetSearch, + }); + historyActionsRef.current = { + push, + navigateUp, + navigateDown, + isNavigating, + reset, + getReverseMatches, + getLastEntry, + resetSearch, + }; + const shellHistoryActionsRef = useRef(shellHistory); + shellHistoryActionsRef.current = shellHistory; + pastedImagesRef.current = pastedImages; + + useEffect(() => { + if (!containerRef.current) return; + + const submitText = (view: EditorView, textOverride?: string) => { + const rawText = (textOverride ?? view.state.doc.toString()).trim(); + if (!rawText) return true; + const text = expandLargePastePlaceholders( + pendingPastesRef.current, + rawText, + ); + const images = pastedImagesRef.current; + const isShellMode = shellModeRef.current; + const accepted = onSubmitRef.current( + isShellMode ? `!${text}` : text, + images.length > 0 ? [...images] : undefined, + ); + if (accepted === false) return true; + onDismissFollowupRef.current?.(); + pendingPastesRef.current.clear(); + nextPasteIdRef.current = 1; + if (isShellMode) { + shellHistoryActionsRef.current.push(text); + shellHistoryActionsRef.current.reset(); + } else { + historyActionsRef.current.push(text); + historyActionsRef.current.reset(); + } + setPastedImages([]); + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: '' }, + }); + return true; + }; + + const completionSources: CompletionSource[] = [ + slashCompletionSource( + () => commandsRef.current, + () => skillsRef.current, + () => languageRef.current, + (key) => tRef.current(key), + () => slashCommandCategoryOrderRef.current, + ), + createAtCompletionSource( + () => workspaceActionsRef.current?.globWorkspace, + ), + ]; + + const submitKeymap = keymap.of([ + { + key: 'Enter', + run: (view) => { + if (completionStatus(view.state) === 'active') return false; + const followup = followupStateRef.current; + if ( + view.state.doc.toString().length === 0 && + followup?.isVisible && + followup.suggestion + ) { + onAcceptFollowupRef.current?.('enter', { skipOnAccept: true }); + return submitText(view, followup.suggestion); + } + return submitText(view); + }, + }, + { + key: 'Shift-Enter', + run: (view) => { + view.dispatch(view.state.replaceSelection('\n')); + return true; + }, + }, + { + key: 'Ctrl-j', + run: (view) => { + view.dispatch(view.state.replaceSelection('\n')); + return true; + }, + }, + { + key: 'Escape', + run: () => { + if (shellModeRef.current) { + setShellMode(false); + return true; + } + if (queuedMessagesRef.current.length === 0) return false; + return onClearQueuedMessagesRef.current?.() ?? false; + }, + }, + { + key: 'Ctrl-o', + run: () => true, + }, + { + key: 'Ctrl-l', + run: () => true, + }, + { + key: 'Ctrl-y', + run: () => true, + }, + { + key: 'ArrowUp', + run: (view) => { + const history = shellModeRef.current + ? shellHistoryActionsRef.current + : historyActionsRef.current; + const isBrowsingHistory = history.isNavigating(); + if (completionStatus(view.state) === 'active' && !isBrowsingHistory) { + return moveCompletionSelection(false)(view); + } + if (isBrowsingHistory) { + closeCompletion(view); + } + if (view.state.doc.lines > 1) return false; + if (shellModeRef.current) { + const current = view.state.doc.toString(); + const prev = history.navigateUp(current); + if (prev === null) return true; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: prev }, + selection: { anchor: prev.length }, + }); + return true; + } + if (queuedMessagesRef.current.length > 0) { + const queuedText = onPopQueuedMessagesRef.current?.(); + if (queuedText) { + const current = view.state.doc.toString(); + const next = current.trim() + ? `${queuedText}\n${current}` + : queuedText; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: next }, + selection: { anchor: next.length }, + }); + return true; + } + } + const current = view.state.doc.toString(); + const prev = history.navigateUp(current); + if (prev === null) return false; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: prev }, + selection: { anchor: prev.length }, + }); + return true; + }, + }, + { + key: 'ArrowDown', + run: (view) => { + const history = shellModeRef.current + ? shellHistoryActionsRef.current + : historyActionsRef.current; + const isBrowsingHistory = history.isNavigating(); + if (completionStatus(view.state) === 'active' && !isBrowsingHistory) { + return moveCompletionSelection(true)(view); + } + if (isBrowsingHistory) { + closeCompletion(view); + } + if (view.state.doc.lines > 1) return false; + if (shellModeRef.current) { + const next = history.navigateDown(); + if (next === null) return true; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: next }, + selection: { anchor: next.length }, + }); + return true; + } + const next = history.navigateDown(); + if (next === null) { + return onFocusActiveAgentsRef.current?.() ?? false; + } + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: next }, + selection: { anchor: next.length }, + }); + return true; + }, + }, + { + key: 'Ctrl-r', + run: (view) => { + const query = view.state.doc.toString(); + searchDraftRef.current = query; + setSearchMode(true); + setSearchQuery(query); + const history = shellModeRef.current + ? shellHistoryActionsRef.current + : historyActionsRef.current; + setSearchMatches(history.getReverseMatches(query)); + setSearchActiveIndex(0); + history.resetSearch(); + setTimeout(() => searchInputRef.current?.focus(), 0); + return true; + }, + }, + { + key: 'Tab', + // Priority: active menu > implicit subcommand > missing "/" prefix > followup suggestion + run: (view) => { + if (completionStatus(view.state) === 'active') { + return acceptCompletion(view); + } + const text = view.state.doc.toString(); + const implicitResult = getImplicitTabCompletion( + text, + commandsRef.current, + languageRef.current, + ); + if (implicitResult) { + view.dispatch({ + changes: { + from: 0, + to: view.state.doc.length, + insert: implicitResult, + }, + selection: { anchor: implicitResult.length }, + }); + return true; + } + const missingSlash = getMissingSlashPrefixCompletion( + text, + commandsRef.current, + ); + if (missingSlash) { + view.dispatch({ + changes: { + from: 0, + to: view.state.doc.length, + insert: missingSlash, + }, + selection: { anchor: missingSlash.length }, + }); + return true; + } + const followup = followupStateRef.current; + if (text.length === 0 && followup?.isVisible && followup.suggestion) { + onAcceptFollowupRef.current?.('tab'); + return true; + } + return true; + }, + }, + { + key: 'ArrowRight', + run: (view) => { + const followup = followupStateRef.current; + if ( + completionStatus(view.state) !== 'active' && + view.state.doc.toString().length === 0 && + followup?.isVisible && + followup.suggestion + ) { + onAcceptFollowupRef.current?.('right'); + return true; + } + return false; + }, + }, + { + key: 'Shift-Tab', + run: () => { + onCycleModeRef.current?.(); + return true; + }, + }, + ]); + + const slashCompletionRestarter = EditorView.updateListener.of((update) => { + if (!update.docChanged && !update.selectionSet) { + return; + } + if (update.docChanged && pendingPastesRef.current.size > 0) { + const nextPasteId = prunePendingPastes( + pendingPastesRef.current, + update.state.doc.toString(), + ); + if (nextPasteId !== null) { + nextPasteIdRef.current = nextPasteId; + } + } + const selection = update.state.selection.main; + if (!selection.empty) return; + const line = update.state.doc.lineAt(selection.head); + const shouldCompleteSlash = line.from === 0 && line.text.startsWith('/'); + if (!shouldCompleteSlash) return; + window.setTimeout(() => { + const view = viewRef.current; + if (!view || completionStatus(view.state) === 'active') return; + const nextSelection = view.state.selection.main; + if (!nextSelection.empty) return; + const nextLine = view.state.doc.lineAt(nextSelection.head); + if (nextLine.from === 0 && nextLine.text.startsWith('/')) { + startCompletion(view); + } + }, 0); + }); + + const state = EditorState.create({ + doc: '', + extensions: [ + Prec.highest(submitKeymap), + minimalSetup, + history(), + keymap.of([...defaultKeymap, ...historyKeymap]), + autocompletion({ + override: completionSources, + activateOnTyping: true, + icons: false, + optionClass: (completion) => + completion.type === 'skill' + ? 'cm-skill-completion' + : completion.type === 'file' + ? 'cm-file-completion' + : '', + aboveCursor: true, + activateOnCompletion: (completion) => + typeof completion.apply === 'string' && + completion.apply.endsWith(' '), + }), + placeholderCompartment.of(placeholder('')), + EditorView.lineWrapping, + editableCompartment.of(EditorView.editable.of(true)), + inputHighlight( + () => commandsRef.current, + () => languageRef.current, + ), + inputHighlightTheme, + slashCompletionRestarter, + EditorView.inputHandler.of((view, from, to, insert) => { + if ( + insert.length > 0 && + view.state.doc.toString() === '' && + followupStateRef.current?.isVisible + ) { + onDismissFollowupRef.current?.(); + } + if ( + insert === '!' && + view.state.doc.toString() === '' && + completionStatus(view.state) !== 'active' + ) { + setShellMode((value) => !value); + return true; + } + if ( + insert === '?' && + view.state.doc.toString() === '' && + completionStatus(view.state) !== 'active' + ) { + onToggleShortcutsRef.current?.(); + return true; + } + return false; + }), + EditorView.domEventHandlers({ + paste(event) { + const items = event.clipboardData?.items; + if (!items) return false; + let hasImage = false; + for (const item of items) { + if (item.type.startsWith('image/')) { + hasImage = true; + const file = item.getAsFile(); + if (!file) continue; + const mediaType = item.type; + const reader = new FileReader(); + reader.onload = () => { + const base64 = (reader.result as string).split(',')[1]; + setPastedImages((prev) => [ + ...prev, + { data: base64, media_type: mediaType }, + ]); + }; + reader.readAsDataURL(file); + } + } + if (hasImage) { + event.preventDefault(); + return true; + } + const pasted = normalizePastedText( + event.clipboardData?.getData('text/plain') ?? '', + ); + if (!pasted || !isLargePaste(pasted)) return false; + + event.preventDefault(); + if ( + view.state.doc.toString() === '' && + followupStateRef.current?.isVisible + ) { + onDismissFollowupRef.current?.(); + } + const { placeholderText, nextPasteId } = + createLargePastePlaceholder( + pendingPastesRef.current, + nextPasteIdRef.current, + pasted, + ); + nextPasteIdRef.current = nextPasteId; + const selection = view.state.selection.main; + view.dispatch({ + changes: { + from: selection.from, + to: selection.to, + insert: placeholderText, + }, + selection: { anchor: selection.from + placeholderText.length }, + scrollIntoView: true, + }); + return true; + }, + }), + EditorView.theme({ + '&': { + fontSize: '14px', + background: 'transparent', + border: 'none', + }, + '&.cm-focused': { + outline: 'none', + }, + '.cm-scroller': { + overflow: 'visible', + }, + '.cm-content': { + padding: '0', + fontFamily: 'var(--font-mono, "SF Mono", "Fira Code", monospace)', + color: 'var(--text-primary, #e0e0e0)', + caretColor: 'var(--accent-color, #4a9eff)', + }, + '.cm-line': { + padding: '0', + }, + '.cm-placeholder': { + color: 'var(--text-dimmed, #666)', + }, + '.cm-cursor': { + borderLeftColor: 'var(--accent-color, #4a9eff)', + borderLeftWidth: '2px', + }, + '.cm-tooltip-autocomplete': { + background: 'var(--bg-secondary, #161616)', + border: '1px solid var(--border-color, #2a2a2a)', + borderRadius: '6px', + overflow: 'hidden', + }, + '.cm-tooltip-autocomplete ul': { + fontFamily: 'var(--font-mono, monospace)', + fontSize: '13px', + }, + '.cm-tooltip-autocomplete ul li': { + display: 'flex', + alignItems: 'baseline', + minWidth: '0', + padding: '4px 10px', + color: 'var(--text-primary, #e4e4e4)', + overflow: 'hidden', + }, + '.cm-tooltip-autocomplete ul li[aria-selected]': { + background: 'var(--bg-tertiary, #1e1e1e)', + color: 'var(--accent-color, #4a9eff)', + }, + '.cm-tooltip-autocomplete completion-section': { + display: 'block', + height: '0', + margin: '6px 10px 3px', + padding: '0', + borderBottom: '1px solid var(--border-color, #2a2a2a)', + }, + '.cm-tooltip-autocomplete completion-section:first-of-type': { + display: 'none', + }, + '.cm-completionLabel': { + fontFamily: 'var(--font-mono, monospace)', + flexShrink: '0', + minWidth: '14ch', + maxWidth: '28ch', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + '.cm-completionDetail': { + flex: '1 1 auto', + minWidth: '0', + fontStyle: 'normal', + color: 'var(--text-dimmed, #666)', + marginLeft: '8px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + '.cm-tooltip-autocomplete ul li.cm-file-completion .cm-completionLabel': + { + flex: '1 1 auto', + minWidth: '0', + maxWidth: 'none', + }, + '.cm-tooltip-autocomplete ul li.cm-skill-completion': { + alignItems: 'flex-start', + }, + '.cm-tooltip-autocomplete ul li.cm-skill-completion .cm-completionLabel': + { + alignSelf: 'center', + }, + '.cm-tooltip-autocomplete ul li.cm-skill-completion .cm-completionDetail': + { + whiteSpace: 'normal', + display: '-webkit-box', + WebkitLineClamp: '2', + WebkitBoxOrient: 'vertical', + lineHeight: '1.35', + maxHeight: '2.7em', + }, + }), + ], + }); + + const view = new EditorView({ + state, + parent: containerRef.current, + }); + + viewRef.current = view; + view.focus(); + + return () => { + view.destroy(); + viewRef.current = null; + }; + }, []); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + view.dispatch({ + effects: editableCompartment.reconfigure( + EditorView.editable.of(!disabled), + ), + }); + if (!disabled) { + view.focus(); + } + }, [disabled]); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + const followupSuggestion = + followupState?.isVisible && followupState.suggestion + ? followupState.suggestion + : null; + const nextPlaceholder = followupSuggestion ?? placeholderText; + view.dispatch({ + effects: placeholderCompartment.reconfigure(placeholder(nextPlaceholder)), + }); + }, [placeholderText, followupState?.isVisible, followupState?.suggestion]); + + useEffect(() => { + const view = viewRef.current; + if (!view || draftText === undefined) return; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: draftText }, + selection: { anchor: draftText.length }, + }); + view.focus(); + }, [draftText, draftVersion]); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + if (dialogOpen) { + view.contentDOM.blur(); + } else { + view.focus(); + } + }, [dialogOpen]); + + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (disabledRef.current || searchMode || dialogOpen) return; + if (event.defaultPrevented) return; + const view = viewRef.current; + const followup = followupStateRef.current; + if ( + view && + !view.hasFocus && + followup?.isVisible && + followup.suggestion && + view.state.doc.toString().length === 0 && + !isEditableTarget(event.target) + ) { + if ( + event.key === 'Tab' && + !event.shiftKey && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + completionStatus(view.state) !== 'active' + ) { + event.preventDefault(); + onAcceptFollowupRef.current?.('tab'); + return; + } + if ( + event.key === 'ArrowRight' && + !event.shiftKey && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + completionStatus(view.state) !== 'active' + ) { + event.preventDefault(); + onAcceptFollowupRef.current?.('right'); + return; + } + } + if (event.metaKey || event.ctrlKey || event.altKey) return; + if (event.key.length !== 1) return; + if (isEditableTarget(event.target)) return; + + if (!view || view.hasFocus) return; + + event.preventDefault(); + if (event.key === '!' && view.state.doc.toString() === '') { + if (followupStateRef.current?.isVisible) { + onDismissFollowupRef.current?.(); + } + setShellMode((value) => !value); + view.focus(); + return; + } + const selection = view.state.selection.main; + view.dispatch({ + changes: { from: selection.from, to: selection.to, insert: event.key }, + selection: { anchor: selection.from + event.key.length }, + scrollIntoView: true, + }); + view.focus(); + if (event.key === '/' || event.key === '@') { + window.setTimeout(() => { + const nextView = viewRef.current; + if (nextView && nextView.hasFocus) { + startCompletion(nextView); + } + }, 0); + } + }; + + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [searchMode, dialogOpen]); + + const focus = useCallback(() => { + viewRef.current?.focus(); + }, []); + + const blur = useCallback(() => { + viewRef.current?.contentDOM.blur(); + }, []); + + const insertText = useCallback((text: string) => { + const view = viewRef.current; + if (!view || !text) { + view?.focus(); + return; + } + const selection = view.state.selection.main; + view.dispatch({ + changes: { from: selection.from, to: selection.to, insert: text }, + selection: { anchor: selection.from + text.length }, + scrollIntoView: true, + }); + view.focus(); + if (text === '/' || text === '@') { + window.setTimeout(() => { + const nextView = viewRef.current; + if (nextView && nextView.hasFocus) { + startCompletion(nextView); + } + }, 0); + } + }, []); + + const getText = useCallback(() => { + return viewRef.current?.state.doc.toString() ?? ''; + }, []); + + const clearText = useCallback(() => { + const view = viewRef.current; + if (!view || view.state.doc.length === 0) return; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: '' }, + }); + setPastedImages([]); + pendingPastesRef.current.clear(); + nextPasteIdRef.current = 1; + }, []); + + const retryLast = useCallback(() => { + const last = historyActionsRef.current.getLastEntry( + (e) => !e.startsWith('/') && !e.startsWith('!'), + ); + if (!last) return; + const accepted = onSubmitRef.current(last); + if (accepted === false) return; + setPastedImages([]); + }, []); + + useImperativeHandle( + ref, + () => ({ + blur, + clearText, + focus, + getText, + insertText, + retryLast, + }), + [blur, clearText, focus, getText, insertText, retryLast], + ); + + const replaceEditorText = useCallback((text: string) => { + const view = viewRef.current; + if (!view) return; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: text }, + selection: { anchor: text.length }, + scrollIntoView: true, + }); + }, []); + + const closeSearch = useCallback( + (restoreDraft: boolean) => { + if (restoreDraft) { + replaceEditorText(searchDraftRef.current); + } + setSearchMode(false); + setSearchQuery(''); + setSearchMatches([]); + setSearchActiveIndex(0); + const history = shellModeRef.current + ? shellHistoryActionsRef.current + : historyActionsRef.current; + history.resetSearch(); + viewRef.current?.focus(); + }, + [replaceEditorText], + ); + + const submitSearchMatch = useCallback( + (match: string) => { + const view = viewRef.current; + if (!view) return; + closeSearch(false); + const text = match.trim(); + if (!text) return; + const images = pastedImagesRef.current; + const isShellMode = shellModeRef.current; + const accepted = onSubmitRef.current( + isShellMode ? `!${text}` : text, + images.length > 0 ? [...images] : undefined, + ); + if (accepted === false) { + replaceEditorText(match); + return; + } + onDismissFollowupRef.current?.(); + if (isShellMode) { + shellHistoryActionsRef.current.push(text); + shellHistoryActionsRef.current.reset(); + } else { + historyActionsRef.current.push(text); + historyActionsRef.current.reset(); + } + setPastedImages([]); + replaceEditorText(''); + }, + [closeSearch, replaceEditorText], + ); + + const handleSearchKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + closeSearch(true); + } else if (e.key === 'Tab') { + e.preventDefault(); + const match = searchMatches[searchActiveIndex]; + if (match) { + replaceEditorText(match); + } + closeSearch(false); + } else if (e.key === 'Enter') { + e.preventDefault(); + const match = searchMatches[searchActiveIndex]; + if (match) { + submitSearchMatch(match); + } else { + closeSearch(false); + } + } else if (e.key === 'r' && e.ctrlKey) { + e.preventDefault(); + if (searchMatches.length > 0) { + setSearchActiveIndex((index) => (index + 1) % searchMatches.length); + } + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + if (searchMatches.length > 0) { + setSearchActiveIndex((index) => (index + 1) % searchMatches.length); + } + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (searchMatches.length > 0) { + setSearchActiveIndex( + (index) => (index - 1 + searchMatches.length) % searchMatches.length, + ); + } + } + }; + + const handleSearchInput = (e: React.ChangeEvent) => { + const q = e.target.value; + setSearchQuery(q); + const history = shellModeRef.current + ? shellHistoryActionsRef.current + : historyActionsRef.current; + setSearchMatches(history.getReverseMatches(q)); + setSearchActiveIndex(0); + history.resetSearch(); + }; + + const modeClass = getModeClass(currentMode, shellMode); + const containerClass = [ + styles.container, + shellMode ? styles.shellMode : '', + modeClass, + ] + .filter(Boolean) + .join(' '); + const visibleSearchStart = Math.max( + 0, + Math.min(searchActiveIndex - 2, searchMatches.length - 6), + ); + const visibleSearchMatches = searchMatches.slice( + visibleSearchStart, + visibleSearchStart + 6, + ); + const prefixClass = [ + styles.prefix, + shellMode + ? styles.prefixShell + : currentMode === 'yolo' + ? styles.prefixYolo + : currentMode === 'auto-edit' + ? styles.prefixAutoEdit + : '', + ] + .filter(Boolean) + .join(' '); + const prefixContent = shellMode ? ( + '!' + ) : currentMode === 'yolo' ? ( + '*' + ) : ( + + ); + + return ( +
+
+ {sessionName && ( + {sessionName} + )} +
+ {searchMode && ( +
+ reverse-i-search: + + + ctrl+r next · tab accept · enter send · esc cancel + +
+ )} + {searchMode && searchMatches.length > 0 && ( +
+ {visibleSearchMatches.map((match, index) => { + const matchIndex = visibleSearchStart + index; + return ( + + ); + })} +
+ )} + {searchMode && searchMatches.length === 0 && ( +
{t('editor.noHistory')}
+ )} + {pastedImages.length > 0 && ( +
+ {pastedImages.map((img, i) => ( +
+ + +
+ ))} +
+ )} +
+ {prefixContent} +
+
+
+
+ ); +}); diff --git a/packages/web-shell/client/components/InsightProgress.module.css b/packages/web-shell/client/components/InsightProgress.module.css new file mode 100644 index 0000000000..8b6ca80342 --- /dev/null +++ b/packages/web-shell/client/components/InsightProgress.module.css @@ -0,0 +1,59 @@ +.progress { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 6px; + padding: 10px 0px; + font-size: 13px; + color: var(--text-secondary); + margin-top: -20px; +} + +.spinner { + color: var(--accent-color); + display: inline-block; + width: 1ch; + text-align: center; +} + +.bar { + color: var(--text-secondary); + letter-spacing: 0; + font-size: 11px; +} + +.stage { + color: var(--accent-color); +} + +.icon { + font-weight: 700; + width: 14px; + text-align: center; +} + +.done .icon { + color: var(--success-color); +} + +.done .stage { + color: var(--success-color); +} + +.error .icon { + color: var(--error-color); +} + +.error .stage { + color: var(--error-color); +} + +.detail { + color: var(--text-dimmed); + font-size: 12px; +} + +.path { + color: var(--text-secondary); + font-size: 12px; +} diff --git a/packages/web-shell/client/components/InsightProgress.tsx b/packages/web-shell/client/components/InsightProgress.tsx new file mode 100644 index 0000000000..31b51f1190 --- /dev/null +++ b/packages/web-shell/client/components/InsightProgress.tsx @@ -0,0 +1,65 @@ +import { useState, useEffect } from 'react'; +import styles from './InsightProgress.module.css'; + +export interface InsightProgressData { + stage: string; + progress: number; + detail?: string; + isComplete?: boolean; + error?: string; +} + +interface InsightProgressProps { + progress: InsightProgressData; +} + +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧']; + +export function InsightProgress({ progress }: InsightProgressProps) { + const { stage, progress: percent, detail, isComplete, error } = progress; + const [frame, setFrame] = useState(0); + const width = 30; + const completedWidth = Math.round((percent / 100) * width); + const remainingWidth = width - completedWidth; + const bar = + '█'.repeat(Math.max(0, completedWidth)) + + '░'.repeat(Math.max(0, remainingWidth)); + + useEffect(() => { + if (isComplete || error) return; + const id = setInterval(() => { + setFrame((f) => (f + 1) % SPINNER_FRAMES.length); + }, 120); + return () => clearInterval(id); + }, [isComplete, error]); + + if (error) { + return ( +
+ + {stage} +
{error}
+
+ ); + } + + if (isComplete) { + return ( +
+ + {stage} +
+ ); + } + + return ( +
+ {SPINNER_FRAMES[frame]} + {bar} + + {stage} + {detail ? ` (${detail})` : ''} + +
+ ); +} diff --git a/packages/web-shell/client/components/InsightReady.tsx b/packages/web-shell/client/components/InsightReady.tsx new file mode 100644 index 0000000000..45b0bd869a --- /dev/null +++ b/packages/web-shell/client/components/InsightReady.tsx @@ -0,0 +1,17 @@ +import styles from './InsightProgress.module.css'; +import { useI18n } from '../i18n'; + +interface InsightReadyProps { + path: string; +} + +export function InsightReady({ path }: InsightReadyProps) { + const { t } = useI18n(); + return ( +
+ + {t('insight.ready')} + {path} +
+ ); +} diff --git a/packages/web-shell/client/components/MessageItem.tsx b/packages/web-shell/client/components/MessageItem.tsx new file mode 100644 index 0000000000..d7f0ac26ae --- /dev/null +++ b/packages/web-shell/client/components/MessageItem.tsx @@ -0,0 +1,259 @@ +import { memo } from 'react'; +import type { + ACPToolCall, + Message, + PermissionRequest, + TodoItem, +} from '../adapters/types'; +import { UserMessage } from './messages/UserMessage'; +import { AssistantMessage } from './messages/AssistantMessage'; +import { SystemMessage } from './messages/SystemMessage'; +import { ToolGroup } from './messages/ToolGroup'; +import { PlanMessage } from './messages/PlanMessage'; +import { BtwMessage } from './messages/BtwMessage'; +import { UserShellMessage } from './messages/UserShellMessage'; +import { InsightProgress } from './InsightProgress'; +import { InsightReady } from './InsightReady'; + +interface MessageItemProps { + message: Message; + pendingApproval?: PermissionRequest | null; + onConfirm?: ( + id: string, + selectedOption: string, + answers?: Record, + ) => void; + /** Run /context detail, exactly like typing it (context-usage panels). */ + onShowContextDetail?: () => void; + workspaceCwd?: string; + isLatest?: boolean; +} + +export const MessageItem = memo(function MessageItem({ + message, + pendingApproval, + onConfirm, + onShowContextDetail, + workspaceCwd, + isLatest = false, +}: MessageItemProps) { + switch (message.role) { + case 'user': + return ; + case 'assistant': + return ( + + ); + case 'tool_group': + return ( + + ); + case 'plan': + return ; + case 'system': + return ( + + ); + case 'user_shell': + return ( + + ); + case 'btw': + return ( + + ); + case 'insight_progress': + return ( + + ); + case 'insight_ready': + return ; + case 'insight_error': + return ( +
+ {message.error} +
+ ); + default: + return null; + } +}, areMessageItemPropsEqual); + +function areMessageItemPropsEqual( + prev: MessageItemProps, + next: MessageItemProps, +): boolean { + if (prev.pendingApproval?.id !== next.pendingApproval?.id) return false; + if (prev.onConfirm !== next.onConfirm) return false; + if (prev.onShowContextDetail !== next.onShowContextDetail) return false; + if (prev.workspaceCwd !== next.workspaceCwd) return false; + if (prev.isLatest !== next.isLatest) return false; + return areMessagesEqual(prev.message, next.message); +} + +function areMessagesEqual(prev: Message, next: Message): boolean { + if (prev === next) return true; + if (prev.id !== next.id || prev.role !== next.role) return false; + switch (prev.role) { + case 'user': + return ( + next.role === 'user' && + prev.content === next.content && + stableImagesEqual(prev.images, next.images) + ); + case 'assistant': + return ( + next.role === 'assistant' && + prev.content === next.content && + prev.thinking === next.thinking && + prev.isStreaming === next.isStreaming + ); + case 'system': + return ( + next.role === 'system' && + prev.content === next.content && + prev.variant === next.variant + ); + case 'user_shell': + return ( + next.role === 'user_shell' && + prev.command === next.command && + prev.output === next.output && + prev.cwd === next.cwd + ); + case 'btw': + return ( + next.role === 'btw' && + prev.question === next.question && + prev.answer === next.answer && + prev.isPending === next.isPending + ); + case 'insight_progress': + return ( + next.role === 'insight_progress' && + prev.stage === next.stage && + prev.progress === next.progress && + prev.detail === next.detail + ); + case 'insight_ready': + return next.role === 'insight_ready' && prev.path === next.path; + case 'insight_error': + return next.role === 'insight_error' && prev.error === next.error; + case 'plan': + return next.role === 'plan' && areTodosEqual(prev.todos, next.todos); + case 'tool_group': + return ( + next.role === 'tool_group' && + prev.tools.length === next.tools.length && + prev.tools.every((tool, index) => + areToolCallsEqual(tool, next.tools[index]), + ) + ); + default: + return false; + } +} + +function areTodosEqual(prev: TodoItem[], next: TodoItem[]): boolean { + return ( + prev.length === next.length && + prev.every((todo, index) => { + const other = next[index]; + return ( + other && + todo.id === other.id && + todo.content === other.content && + todo.status === other.status && + todo.priority === other.priority + ); + }) + ); +} + +function areToolCallsEqual( + prev: ACPToolCall, + next: ACPToolCall | undefined, +): boolean { + if (!next) return false; + return ( + prev.callId === next.callId && + prev.toolName === next.toolName && + prev.status === next.status && + prev.title === next.title && + prev.kind === next.kind && + prev.startTime === next.startTime && + prev.endTime === next.endTime && + prev.subContent === next.subContent && + stableJson(prev.args) === stableJson(next.args) && + stableJson(prev.rawOutput) === stableJson(next.rawOutput) && + stableJson(prev.locations) === stableJson(next.locations) && + stableJson(prev.content) === stableJson(next.content) && + areToolListsEqual(prev.subTools, next.subTools) + ); +} + +function areToolListsEqual( + prev: ACPToolCall[] | undefined, + next: ACPToolCall[] | undefined, +): boolean { + if (!prev && !next) return true; + if (!prev || !next || prev.length !== next.length) return false; + return prev.every((tool, index) => areToolCallsEqual(tool, next[index])); +} + +const jsonCache = new WeakMap(); + +function stableImagesEqual( + a: Array<{ data: string; mimeType: string }> | undefined, + b: Array<{ data: string; mimeType: string }> | undefined, +): boolean { + if (a === b) return true; + if (!a || !b || a.length !== b.length) return false; + return a.every( + (img, i) => img.data === b[i].data && img.mimeType === b[i].mimeType, + ); +} + +function stableJson(value: unknown): string { + if (value === undefined) return ''; + if (value !== null && typeof value === 'object') { + let cached = jsonCache.get(value); + if (cached !== undefined) return cached; + try { + cached = JSON.stringify(value); + } catch { + cached = String(value); + } + jsonCache.set(value, cached); + return cached; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} diff --git a/packages/web-shell/client/components/MessageList.module.css b/packages/web-shell/client/components/MessageList.module.css new file mode 100644 index 0000000000..66e77dcb10 --- /dev/null +++ b/packages/web-shell/client/components/MessageList.module.css @@ -0,0 +1,21 @@ +.list { + flex: 1; + overflow-y: auto; + padding: 12px 24px; + scrollbar-width: thin; + scrollbar-color: var(--border-color) transparent; + min-height: 0; +} + +.list::-webkit-scrollbar { + width: 6px; +} + +.list::-webkit-scrollbar-track { + background: transparent; +} + +.list::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; +} diff --git a/packages/web-shell/client/components/MessageList.test.ts b/packages/web-shell/client/components/MessageList.test.ts new file mode 100644 index 0000000000..3bd188d4a6 --- /dev/null +++ b/packages/web-shell/client/components/MessageList.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from 'vitest'; +import type { Message } from '../adapters/types'; +import { + getDisplayItemVirtualKey, + groupParallelAgents, + shouldUseVirtualScroll, + VIRTUAL_SCROLL_THRESHOLD, +} from './MessageList'; + +function makeAgentToolGroup(id: string, toolName = 'Agent'): Message { + return { + id, + role: 'tool_group', + tools: [ + { + callId: `call-${id}`, + toolName, + status: 'completed', + args: { description: `task ${id}` }, + }, + ], + }; +} + +function makeBackgroundAgentToolGroup(id: string): Message { + return { + id, + role: 'tool_group', + tools: [ + { + callId: `call-${id}`, + toolName: 'Agent', + status: 'pending', + args: { + description: `task ${id}`, + run_in_background: true, + }, + rawOutput: { + type: 'task_execution', + taskDescription: `task ${id}`, + status: 'background', + }, + }, + ], + }; +} + +function makeMultiToolGroup(id: string): Message { + return { + id, + role: 'tool_group', + tools: [ + { callId: `call-${id}-a`, toolName: 'Read', status: 'completed' }, + { callId: `call-${id}-b`, toolName: 'Write', status: 'completed' }, + ], + }; +} + +function makeUserMessage(id: string): Message { + return { id, role: 'user', content: 'hello' }; +} + +function makeAssistantMessage(id: string): Message { + return { id, role: 'assistant', content: 'response' }; +} + +function makeThoughtMessage(id: string): Message { + return { + id, + role: 'assistant', + content: '', + thinking: 'launching another agent', + }; +} + +describe('groupParallelAgents', () => { + it('returns empty array for empty input', () => { + expect(groupParallelAgents([])).toEqual([]); + }); + + it('does not group a single agent tool_group', () => { + const msgs = [makeAgentToolGroup('1')]; + const items = groupParallelAgents(msgs); + expect(items).toHaveLength(1); + expect(items[0].type).toBe('message'); + }); + + it('groups 2+ consecutive agent-only tool_groups', () => { + const msgs = [ + makeAgentToolGroup('1'), + makeAgentToolGroup('2'), + makeAgentToolGroup('3'), + ]; + const items = groupParallelAgents(msgs); + expect(items).toHaveLength(1); + expect(items[0].type).toBe('parallel_agents'); + if (items[0].type === 'parallel_agents') { + expect(items[0].agents).toHaveLength(3); + expect(items[0].agents[0].callId).toBe('call-1'); + expect(items[0].agents[2].callId).toBe('call-3'); + } + }); + + it('non-agent message breaks the group', () => { + const msgs = [ + makeAgentToolGroup('1'), + makeAgentToolGroup('2'), + makeAssistantMessage('3'), + makeAgentToolGroup('4'), + makeAgentToolGroup('5'), + ]; + const items = groupParallelAgents(msgs); + expect(items).toHaveLength(3); + expect(items[0].type).toBe('parallel_agents'); + expect(items[1].type).toBe('message'); + expect(items[2].type).toBe('parallel_agents'); + }); + + it('multi-tool tool_group is not grouped as agent', () => { + const msgs = [ + makeAgentToolGroup('1'), + makeMultiToolGroup('2'), + makeAgentToolGroup('3'), + ]; + const items = groupParallelAgents(msgs); + expect(items).toHaveLength(3); + expect(items.every((i) => i.type === 'message')).toBe(true); + }); + + it('non-agent tool names are not grouped', () => { + const msgs: Message[] = [ + { + id: '1', + role: 'tool_group', + tools: [{ callId: 'c1', toolName: 'Read', status: 'completed' }], + }, + { + id: '2', + role: 'tool_group', + tools: [{ callId: 'c2', toolName: 'Write', status: 'completed' }], + }, + ]; + const items = groupParallelAgents(msgs); + expect(items).toHaveLength(2); + expect(items.every((i) => i.type === 'message')).toBe(true); + }); + + it('preserves non-tool_group messages as-is', () => { + const msgs = [ + makeUserMessage('1'), + makeAssistantMessage('2'), + makeUserMessage('3'), + ]; + const items = groupParallelAgents(msgs); + expect(items).toHaveLength(3); + expect(items.every((i) => i.type === 'message')).toBe(true); + }); + + it('groups Task tool calls as sub-agents', () => { + const msgs: Message[] = [ + { + id: '1', + role: 'tool_group', + tools: [{ callId: 'c1', toolName: 'Task', status: 'in_progress' }], + }, + { + id: '2', + role: 'tool_group', + tools: [{ callId: 'c2', toolName: 'Task', status: 'completed' }], + }, + ]; + const items = groupParallelAgents(msgs); + expect(items).toHaveLength(1); + expect(items[0].type).toBe('parallel_agents'); + }); + + it('mixed agent and user messages produce correct order', () => { + const msgs = [ + makeUserMessage('u1'), + makeAgentToolGroup('a1'), + makeAgentToolGroup('a2'), + makeAssistantMessage('r1'), + makeAgentToolGroup('a3'), + ]; + const items = groupParallelAgents(msgs); + expect(items).toHaveLength(4); + expect(items[0].type).toBe('message'); + expect(items[1].type).toBe('parallel_agents'); + expect(items[2].type).toBe('message'); + expect(items[3].type).toBe('message'); + }); + + it('groups background agents separated by thought-only launch narration', () => { + const msgs = [ + makeBackgroundAgentToolGroup('a1'), + makeThoughtMessage('t1'), + makeBackgroundAgentToolGroup('a2'), + ]; + const items = groupParallelAgents(msgs); + expect(items).toHaveLength(1); + expect(items[0].type).toBe('parallel_agents'); + if (items[0].type === 'parallel_agents') { + expect(items[0].agents.map((a) => a.callId)).toEqual([ + 'call-a1', + 'call-a2', + ]); + } + }); + + it('preserves background thought narration when it is not between launches', () => { + const msgs = [ + makeBackgroundAgentToolGroup('a1'), + makeThoughtMessage('t1'), + makeBackgroundAgentToolGroup('a2'), + makeThoughtMessage('t2'), + ]; + const items = groupParallelAgents(msgs); + expect(items).toHaveLength(2); + expect(items[0].type).toBe('parallel_agents'); + expect(items[1].type).toBe('message'); + if (items[1].type === 'message') { + expect(items[1].message.id).toBe('t2'); + } + }); +}); + +describe('getDisplayItemVirtualKey', () => { + it('keeps message and grouped rows in separate key namespaces', () => { + expect( + getDisplayItemVirtualKey({ + type: 'message', + key: 'header', + message: makeUserMessage('header'), + }), + ).toBe('msg:header'); + expect( + getDisplayItemVirtualKey({ + type: 'parallel_agents', + key: 'header', + agents: [makeAgentToolGroup('a').tools[0]], + }), + ).toBe('group:header'); + }); +}); + +describe('shouldUseVirtualScroll', () => { + it('enables virtual scrolling only above the default threshold', () => { + expect(shouldUseVirtualScroll(VIRTUAL_SCROLL_THRESHOLD - 1)).toBe(false); + expect(shouldUseVirtualScroll(VIRTUAL_SCROLL_THRESHOLD)).toBe(false); + expect(shouldUseVirtualScroll(VIRTUAL_SCROLL_THRESHOLD + 1)).toBe(true); + }); + + it('accepts a custom threshold', () => { + expect(shouldUseVirtualScroll(50, 50)).toBe(false); + expect(shouldUseVirtualScroll(51, 50)).toBe(true); + }); +}); diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx new file mode 100644 index 0000000000..5bc2814017 --- /dev/null +++ b/packages/web-shell/client/components/MessageList.tsx @@ -0,0 +1,668 @@ +import { + useContext, + useEffect, + useLayoutEffect, + useRef, + useCallback, + useMemo, + type ReactNode, + type MutableRefObject, +} from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import type { Message, ACPToolCall } from '../adapters/types'; +import type { PermissionRequest } from '../adapters/types'; +import { + isBackgroundSubAgentToolCall, + isSubAgentToolCall, +} from '../adapters/toolClassification'; +import { CompactModeContext } from '../App'; +import { MessageItem } from './MessageItem'; +import { ParallelAgentsGroup } from './messages/tools/ParallelAgentsGroup'; +import { ToolApproval } from './messages/ToolApproval'; +import { AskUserQuestion } from './messages/AskUserQuestion'; +import { toolContainsCallId } from './messages/toolFormatting'; +import styles from './MessageList.module.css'; + +interface MessageListProps { + messages: Message[]; + pendingApproval: PermissionRequest | null; + onConfirm: ( + id: string, + selectedOption: string, + answers?: Record, + ) => void; + /** Run /context detail, exactly like typing it (context-usage panels). */ + onShowContextDetail?: () => void; + catchingUp?: boolean; + welcomeHeader?: ReactNode; + workspaceCwd?: string; + tailContent?: ReactNode; + tailKey?: string; + virtualScrollThreshold?: number; + /** + * When true, scroll the tail content into view the moment it first appears + * even if the user had scrolled up. Opt-in per caller so unrelated inline + * panels don't yank the reader to the bottom. Defaults to false. + */ + autoScrollTailIntoView?: boolean; +} + +function isAskUserQuestion(request: PermissionRequest): boolean { + return ( + !!request.rawInput?.questions && Array.isArray(request.rawInput.questions) + ); +} + +function approvalMatchesToolGroup( + messages: Message[], + approval: PermissionRequest | null, +): boolean { + if (!approval?.toolCallId) return false; + for (const msg of messages) { + if (msg.role === 'tool_group') { + if (msg.tools.some((t) => toolContainsCallId(t, approval.toolCallId!))) + return true; + } + } + return false; +} + +function getLastUserMessageId(messages: Message[]): string | null { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role === 'user') return msg.id; + } + return null; +} + +export type DisplayItem = + | { type: 'message'; key: string; message: Message } + | { type: 'parallel_agents'; key: string; agents: ACPToolCall[] }; + +function isAgentOnlyToolGroup(msg: Message): boolean { + return ( + msg.role === 'tool_group' && + msg.tools.length === 1 && + isSubAgentToolCall(msg.tools[0]) + ); +} + +function isBackgroundAgentOnlyToolGroup(msg: Message): boolean { + return ( + msg.role === 'tool_group' && + msg.tools.length === 1 && + isBackgroundSubAgentToolCall(msg.tools[0]) + ); +} + +function isBackgroundLaunchNarration(msg: Message): boolean { + // The daemon often streams short main-agent thought text between background + // launches, e.g. "agent A is running, now starting agent B". The CLI treats + // those as internal launch narration and shows a single Parallel agents box. + // Only skip thought-only messages here; any user-facing assistant content + // still breaks the group and remains visible. + return msg.role === 'assistant' && Boolean(msg.thinking) && !msg.content; +} + +function isForceExpandGroup( + msg: Message, + pendingApproval: PermissionRequest | null, +): boolean { + if (msg.role !== 'tool_group') return false; + if ( + pendingApproval?.toolCallId && + msg.tools.some((t) => toolContainsCallId(t, pendingApproval.toolCallId!)) + ) + return true; + return false; +} + +function isHiddenInCompactMode(msg: Message): boolean { + if (msg.role === 'assistant' && msg.thinking && !msg.content) return true; + return false; +} + +function mergeCompactToolGroups( + messages: Message[], + pendingApproval: PermissionRequest | null, +): Message[] { + const result: Message[] = []; + let i = 0; + + while (i < messages.length) { + const msg = messages[i]; + + if (msg.role !== 'tool_group' || isForceExpandGroup(msg, pendingApproval)) { + if (!isHiddenInCompactMode(msg)) { + result.push(msg); + } + i++; + continue; + } + + const mergeableGroups: Message[] = [msg]; + let lastMergedIdx = i; + let j = i + 1; + + while (j < messages.length) { + const next = messages[j]; + + if (isHiddenInCompactMode(next)) { + j++; + continue; + } + + if ( + next.role === 'tool_group' && + !isForceExpandGroup(next, pendingApproval) + ) { + mergeableGroups.push(next); + lastMergedIdx = j; + j++; + continue; + } + + break; + } + + if (mergeableGroups.length === 1) { + result.push(msg); + i++; + continue; + } + + const mergedTools = mergeableGroups.flatMap((g) => + g.role === 'tool_group' ? g.tools : [], + ); + result.push({ + id: mergeableGroups[0].id, + role: 'tool_group', + tools: mergedTools, + }); + i = lastMergedIdx + 1; + } + + return result; +} + +export function groupParallelAgents(messages: Message[]): DisplayItem[] { + const items: DisplayItem[] = []; + let i = 0; + while (i < messages.length) { + if (isBackgroundAgentOnlyToolGroup(messages[i])) { + const grouped: Message[] = []; + let j = i; + while (j < messages.length) { + const current = messages[j]; + if (isBackgroundAgentOnlyToolGroup(current)) { + grouped.push(current); + j++; + continue; + } + if (isBackgroundLaunchNarration(current)) { + let nextAgentIdx = j + 1; + while ( + nextAgentIdx < messages.length && + isBackgroundLaunchNarration(messages[nextAgentIdx]) + ) { + nextAgentIdx++; + } + if ( + nextAgentIdx < messages.length && + isBackgroundAgentOnlyToolGroup(messages[nextAgentIdx]) + ) { + j = nextAgentIdx; + continue; + } + } + break; + } + + if (grouped.length >= 2) { + items.push({ + type: 'parallel_agents', + key: `par-${grouped[0].id}`, + agents: grouped.map((m) => (m as { tools: ACPToolCall[] }).tools[0]), + }); + i = j; + continue; + } + } + + if (isAgentOnlyToolGroup(messages[i])) { + const start = i; + while (i < messages.length && isAgentOnlyToolGroup(messages[i])) i++; + if (i - start >= 2) { + const grouped = messages.slice(start, i); + items.push({ + type: 'parallel_agents', + key: `par-${grouped[0].id}`, + agents: grouped.map((m) => (m as { tools: ACPToolCall[] }).tools[0]), + }); + } else { + items.push({ + type: 'message', + key: messages[start].id, + message: messages[start], + }); + } + } else { + items.push({ + type: 'message', + key: messages[i].id, + message: messages[i], + }); + i++; + } + } + return items; +} + +export function getDisplayItemVirtualKey(item: DisplayItem): string { + return item.type === 'parallel_agents' + ? `group:${item.key}` + : `msg:${item.key}`; +} + +const HEADER_INDEX = 0; +const ESTIMATE_HEADER = 120; +const ESTIMATE_MESSAGE = 80; +const ESTIMATE_APPROVAL = 200; +const ESTIMATE_TAIL = 240; +export const VIRTUAL_SCROLL_THRESHOLD = 200; + +export function shouldUseVirtualScroll( + totalCount: number, + threshold = VIRTUAL_SCROLL_THRESHOLD, +): boolean { + return totalCount > threshold; +} + +export function MessageList({ + messages, + pendingApproval, + onConfirm, + onShowContextDetail, + catchingUp, + welcomeHeader, + workspaceCwd, + tailContent, + tailKey = 'tail', + virtualScrollThreshold = VIRTUAL_SCROLL_THRESHOLD, + autoScrollTailIntoView = false, +}: MessageListProps) { + const compactMode = useContext(CompactModeContext); + const mergedMessages = useMemo( + () => + compactMode + ? mergeCompactToolGroups(messages, pendingApproval) + : messages, + [compactMode, messages, pendingApproval], + ); + const displayItems = useMemo( + () => groupParallelAgents(mergedMessages), + [mergedMessages], + ); + const containerRef = useRef(null); + + // ── Scroll-follow state ────────────────────────────────────────────── + // + // The scroll behavior follows 6 rules: + // + // 1. Default follow-bottom — while the user is looking at the bottom, + // new content (streaming tokens, tool cards expanding, approval + // cards appearing, any height change) keeps the viewport pinned + // to the latest output. + // + // 2. Scroll-up pauses follow — if the user scrolls up, the page + // assumes they want to read history and stops auto-scrolling. + // Even if the model is still streaming, the viewport stays put. + // + // 3. Scroll-back-to-bottom resumes — when the user scrolls back + // near the bottom (< 30px from edge), follow mode re-engages + // and new content resumes sticking. + // + // 4. New message resets follow — after the user sends a message, + // follow mode is forced on so the model's reply scrolls in + // naturally. + // + // 5. Session restore / reconnect — during history replay + // (`catchingUp === true`), all auto-scrolling is suppressed to + // avoid fighting the rapidly replaying transcript. Once replay + // finishes (`catchingUp` flips to falsy), a single scroll-to- + // bottom fires so the user lands at the latest content. + // + // 6. Short content — if the content doesn't overflow the container + // (no scrollbar), scrollToBottom is a no-op. This avoids a + // visual flash when the model just started replying with a + // short first chunk. + // + // Implementation: three refs, three effects, one scroll handler. + // + // - `shouldFollow` — whether auto-scroll is active + // - `lastScrollTop` — previous scrollTop for direction detection + // - `prevLastUserMsgId` — tracks when a new user message appears + // - `prevCatchingUp` — tracks the catchingUp → ready transition + // + // The single auto-scroll driver is a `useLayoutEffect` on + // `totalVirtualSize` (the virtualizer's computed content height). + // Every height change — streaming text, card expand, approval + // appearance — flows through this one effect. + // ───────────────────────────────────────────────────────────────────── + + const shouldFollow = useRef(true); + const lastScrollTop = useRef(0); + const scrollCooldown = useRef(false); + const scrollCooldownCount = useRef(0); + const prevLastUserMsgId = useRef(null); + const prevCatchingUp: MutableRefObject = + useRef(catchingUp); + const catchingUpRef = useRef(catchingUp); + const prevHasTailContent = useRef(false); + catchingUpRef.current = catchingUp; + + const hasTailApproval = useMemo(() => { + if (!pendingApproval) return false; + if (isAskUserQuestion(pendingApproval)) return true; + return !approvalMatchesToolGroup(messages, pendingApproval); + }, [pendingApproval, messages]); + + const hasTailContent = tailContent !== undefined && tailContent !== null; + const hasHeader = !!welcomeHeader; + const headerOffset = hasHeader ? 1 : 0; + const tailApprovalIndex = headerOffset + displayItems.length; + const tailContentIndex = tailApprovalIndex + (hasTailApproval ? 1 : 0); + const totalCount = tailContentIndex + (hasTailContent ? 1 : 0); + const useVirtualScroll = shouldUseVirtualScroll( + totalCount, + virtualScrollThreshold, + ); + + const getItemKey = useCallback( + (index: number) => { + if (hasHeader && index === HEADER_INDEX) return 'slot:header'; + if (hasTailApproval && index === tailApprovalIndex) { + return pendingApproval + ? `slot:approval:${pendingApproval.id}` + : 'slot:approval'; + } + if (hasTailContent && index === tailContentIndex) { + return `slot:tail:${tailKey}`; + } + const item = displayItems[index - headerOffset]; + return item ? getDisplayItemVirtualKey(item) : `slot:row:${index}`; + }, + [ + hasHeader, + hasTailApproval, + tailApprovalIndex, + pendingApproval, + hasTailContent, + tailContentIndex, + tailKey, + displayItems, + headerOffset, + ], + ); + + // Rule 6: skip if content doesn't overflow (no scrollbar). + const scrollToBottom = useCallback(() => { + const el = containerRef.current; + if (!el) return; + if (el.scrollHeight <= el.clientHeight) return; + scrollCooldownCount.current += 1; + const gen = scrollCooldownCount.current; + scrollCooldown.current = true; + el.scrollTop = el.scrollHeight; + lastScrollTop.current = el.scrollTop; + requestAnimationFrame(() => { + if (scrollCooldownCount.current === gen) { + scrollCooldown.current = false; + } + }); + }, []); + + const virtualizer = useVirtualizer({ + count: totalCount, + enabled: useVirtualScroll, + getScrollElement: () => containerRef.current, + getItemKey, + estimateSize: (index) => { + if (hasHeader && index === HEADER_INDEX) return ESTIMATE_HEADER; + if (hasTailApproval && index === tailApprovalIndex) { + return ESTIMATE_APPROVAL; + } + if (hasTailContent && index === tailContentIndex) return ESTIMATE_TAIL; + return ESTIMATE_MESSAGE; + }, + overscan: 20, + useFlushSync: false, + useAnimationFrameWithResizeObserver: true, + }); + + // Rules 2 & 3: detect scroll direction to toggle follow mode. + // Runs synchronously in the scroll handler — no rAF needed since + // the browser already coalesces scroll events. + const handleScroll = useCallback(() => { + const el = containerRef.current; + if (!el) return; + if (scrollCooldown.current) { + lastScrollTop.current = el.scrollTop; + return; + } + const prev = lastScrollTop.current; + const curr = el.scrollTop; + lastScrollTop.current = curr; + const distanceFromBottom = el.scrollHeight - curr - el.clientHeight; + + // Rule 2: scrolling up → pause follow + if (curr < prev - 1) { + shouldFollow.current = false; + } + // Rule 3: near bottom → resume follow + // (runs unconditionally so that container-resize-induced scrollTop + // clamping — which looks like scrolling up — doesn't permanently + // disable follow when the viewport is still near the bottom) + if (distanceFromBottom < 30) { + shouldFollow.current = true; + } + }, []); + + // Clear screen (e.g. /clear) → reset to follow mode. + useEffect(() => { + if (messages.length === 0) { + shouldFollow.current = true; + } + }, [messages.length]); + + // Container-resize guard: when floating panels (TodoPanel, + // ActiveAgentsPanel) appear or disappear the scroll container's + // clientHeight changes. Snap back to bottom so the user doesn't + // lose their place while follow mode is active. + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const observer = new ResizeObserver(() => { + if (catchingUpRef.current) return; + if (!shouldFollow.current) return; + requestAnimationFrame(() => { + if (!catchingUpRef.current && shouldFollow.current) { + scrollToBottom(); + } + }); + }); + observer.observe(el); + return () => observer.disconnect(); + }, [scrollToBottom]); + + // Rule 4: new user message → force follow on so the model's reply + // scrolls into view as it streams in. + useEffect(() => { + const lastId = getLastUserMessageId(messages); + if (catchingUp) { + prevLastUserMsgId.current = lastId; + return; + } + if (lastId && lastId !== prevLastUserMsgId.current) { + shouldFollow.current = true; + requestAnimationFrame(scrollToBottom); + } + prevLastUserMsgId.current = lastId; + }, [messages, catchingUp, scrollToBottom]); + + // Rule 5: session restore — when catchingUp flips from true → falsy, + // replay just finished. Scroll to bottom once so the user sees the + // latest content without the viewport fighting the replay. + useEffect(() => { + if (prevCatchingUp.current && !catchingUp) { + shouldFollow.current = true; + requestAnimationFrame(scrollToBottom); + } + prevCatchingUp.current = catchingUp; + }, [catchingUp, scrollToBottom]); + + // Rule 6: an inline picker/dialog (tailContent) just appeared. It renders + // at the very bottom of the virtualized list, so if the user had scrolled + // up it would open below the fold and the action would look like a no-op. + // Only opt-in callers (autoScrollTailIntoView) force-follow it into view, so + // unrelated tail panels keep the reader's scroll position. + useEffect(() => { + if ( + autoScrollTailIntoView && + hasTailContent && + !prevHasTailContent.current + ) { + shouldFollow.current = true; + // Re-check follow inside the frame: if the user scrolls up in the gap + // before it fires (Rule 2 clears the flag), don't fight them. + requestAnimationFrame(() => { + if (shouldFollow.current) scrollToBottom(); + }); + } + prevHasTailContent.current = hasTailContent; + }, [autoScrollTailIntoView, hasTailContent, scrollToBottom]); + + const renderVirtualItem = useCallback( + (index: number) => { + if (hasHeader && index === HEADER_INDEX) { + return welcomeHeader; + } + + if (hasTailApproval && index === tailApprovalIndex) { + if (pendingApproval && isAskUserQuestion(pendingApproval)) { + return ( + + ); + } + if (pendingApproval) { + return ( + + ); + } + return null; + } + + if (hasTailContent && index === tailContentIndex) { + return tailContent; + } + + const itemIndex = index - headerOffset; + const item = displayItems[itemIndex]; + if (!item) return null; + + if (item.type === 'parallel_agents') { + return ( + + ); + } + + return ( + + ); + }, + [ + hasHeader, + welcomeHeader, + hasTailContent, + tailContent, + tailContentIndex, + hasTailApproval, + tailApprovalIndex, + pendingApproval, + onConfirm, + onShowContextDetail, + headerOffset, + displayItems, + workspaceCwd, + ], + ); + + const virtualItems = virtualizer.getVirtualItems(); + const totalVirtualSize = virtualizer.getTotalSize(); + + // ── Single auto-scroll driver (rules 1, 5, 6) ────────────────────── + // Fires whenever the virtualizer's total content height changes — + // this captures every scenario: streaming tokens appending, tool + // cards expanding/collapsing, approval cards appearing, etc. + // + // Rule 5: during replay (catchingUp) → skip, avoid fighting rapid + // transcript replay. The catchingUp→ready transition effect + // above handles the final scroll. + // Rule 1: when shouldFollow is true → scroll to bottom. + // Rule 6: scrollToBottom itself checks scrollHeight <= clientHeight + // and is a no-op when there's no overflow. + useLayoutEffect(() => { + if (catchingUp) return; + if (shouldFollow.current) { + scrollToBottom(); + } + }, [totalVirtualSize, messages, totalCount, catchingUp, scrollToBottom]); + + return ( +
+ {useVirtualScroll ? ( +
+ {virtualItems.map((virtualRow) => ( +
+ {renderVirtualItem(virtualRow.index)} +
+ ))} +
+ ) : ( + Array.from({ length: totalCount }, (_, index) => ( +
+ {renderVirtualItem(index)} +
+ )) + )} +
+ ); +} diff --git a/packages/web-shell/client/components/PromptChevron.tsx b/packages/web-shell/client/components/PromptChevron.tsx new file mode 100644 index 0000000000..6acdccac74 --- /dev/null +++ b/packages/web-shell/client/components/PromptChevron.tsx @@ -0,0 +1,28 @@ +import type { CSSProperties } from 'react'; + +interface PromptChevronProps { + className?: string; + style?: CSSProperties; +} + +export function PromptChevron({ className, style }: PromptChevronProps) { + return ( + + ); +} diff --git a/packages/web-shell/client/components/ShortcutsPanel.module.css b/packages/web-shell/client/components/ShortcutsPanel.module.css new file mode 100644 index 0000000000..75f18c7643 --- /dev/null +++ b/packages/web-shell/client/components/ShortcutsPanel.module.css @@ -0,0 +1,27 @@ +.panel { + display: flex; + gap: 32px; + padding: 4px 2ch; + font-size: 13px; +} + +.column { + display: flex; + flex-direction: column; + gap: 1px; +} + +.item { + display: flex; + align-items: baseline; + gap: 8px; +} + +.key { + color: var(--accent-color); + min-width: 12ch; +} + +.desc { + color: var(--text-secondary); +} diff --git a/packages/web-shell/client/components/ShortcutsPanel.tsx b/packages/web-shell/client/components/ShortcutsPanel.tsx new file mode 100644 index 0000000000..3e0705fe65 --- /dev/null +++ b/packages/web-shell/client/components/ShortcutsPanel.tsx @@ -0,0 +1,72 @@ +import { useI18n } from '../i18n'; +import styles from './ShortcutsPanel.module.css'; + +interface Shortcut { + key: string; + descriptionKey: string; +} + +function isMacPlatform(): boolean { + if (typeof navigator === 'undefined') return false; + const userAgentData = ( + navigator as Navigator & { + userAgentData?: { platform?: string }; + } + ).userAgentData; + const platform = userAgentData?.platform || navigator.platform || ''; + return /mac|iphone|ipad|ipod/i.test(platform); +} + +function getPasteImagesShortcut(): string { + return isMacPlatform() ? 'cmd+v' : 'ctrl+v'; +} + +const SHORTCUTS: Shortcut[] = [ + { key: '/', descriptionKey: 'help.shortcut.commandMenu' }, + { key: '@', descriptionKey: 'help.shortcut.addContext' }, + { key: 'shift+tab', descriptionKey: 'help.shortcut.approvals' }, + { key: 'esc', descriptionKey: 'help.shortcut.cancel' }, + { key: 'shift+enter / ctrl+j', descriptionKey: 'help.shortcut.newline' }, + { key: 'ctrl+l', descriptionKey: 'help.shortcut.clear' }, + { key: 'ctrl+y', descriptionKey: 'help.shortcut.retry' }, + { key: 'ctrl+o', descriptionKey: 'help.shortcut.compact' }, + { key: 'ctrl+r', descriptionKey: 'help.shortcut.history' }, + { key: '↑ / ↓', descriptionKey: 'help.shortcut.history' }, + { key: '?', descriptionKey: 'help.shortcut.togglePanel' }, +]; + +export function ShortcutsPanel() { + const { t } = useI18n(); + const shortcuts = [ + ...SHORTCUTS.slice(0, -1), + { + key: getPasteImagesShortcut(), + descriptionKey: 'help.shortcut.pasteImages', + }, + SHORTCUTS[SHORTCUTS.length - 1], + ]; + const mid = Math.ceil(shortcuts.length / 2); + const col1 = shortcuts.slice(0, mid); + const col2 = shortcuts.slice(mid); + + return ( +
+
+ {col1.map((s) => ( +
+ {s.key} + {t(s.descriptionKey)} +
+ ))} +
+
+ {col2.map((s) => ( +
+ {s.key} + {t(s.descriptionKey)} +
+ ))} +
+
+ ); +} diff --git a/packages/web-shell/client/components/StatusBar.module.css b/packages/web-shell/client/components/StatusBar.module.css new file mode 100644 index 0000000000..bbe5b3ca71 --- /dev/null +++ b/packages/web-shell/client/components/StatusBar.module.css @@ -0,0 +1,184 @@ +.bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 2px 2ch; + font-size: 13px; + color: var(--text-dimmed); + flex-shrink: 0; +} + +.left, +.right { + display: flex; + align-items: center; + gap: 10px; +} + +.left { + position: relative; +} + +.settingsButton { + /* Sits in the bar's 2ch left-padding gutter (plus a few px of the footer + margin) so the mode label keeps its input-text alignment — the gear takes + no flex space. The gutter alone (~15.6px) is narrower than the button, so + the extra -6px buys a visible gap between the icon and the mode label + instead of the two touching. font: inherit so the -2ch resolves against + the same font as the bar's 2ch padding; the 2px padding is hit area, free + since we're out of flow. */ + position: absolute; + left: calc(-2ch - 6px); + top: 50%; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px; + margin: 0; + border: none; + background: none; + font: inherit; + color: var(--text-dimmed); + cursor: pointer; +} + +.settingsButton:hover, +.settingsButton:focus-visible { + color: var(--text-primary); +} + +.modeButton { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 0; + margin: 0; + border: none; + background: none; + font: inherit; + color: inherit; + cursor: pointer; +} + +.modeButton:hover .modeHint, +.modeButton:focus-visible .modeHint { + color: var(--text-secondary); +} + +.modeLabel { + font-size: 13px; +} + +.modeDefault { + color: var(--text-secondary); +} + +.modePlan { + color: var(--success-color); +} + +.modeAutoEdit { + color: var(--warning-color); +} + +.modeAuto { + color: var(--warning-color); +} + +.modeYolo { + color: var(--error-color); +} + +.modeHint { + color: var(--text-dimmed); + font-size: 12px; +} + +.modelButton { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0; + margin: 0; + border: none; + background: none; + font: inherit; + color: inherit; + cursor: pointer; +} + +.modelButton:hover .model, +.modelButton:focus-visible .model { + color: var(--text-primary); + text-decoration: underline; +} + +.escapeHint { + color: var(--text-secondary); + font-size: 13px; +} + +.model { + color: var(--text-dimmed); + font-size: 12px; +} + +.disconnected { + color: var(--error-color); +} + +.contextButton { + display: inline-flex; + align-items: center; + padding: 0; + margin: 0; + border: none; + background: none; + font: inherit; + color: inherit; + cursor: pointer; +} + +.contextButton:hover .context, +.contextButton:focus-visible .context { + color: var(--text-primary); + text-decoration: underline; +} + +.context { + font-size: 11px; + color: var(--text-dimmed); +} + +.goal { + font-size: 12px; + color: var(--accent-color); + white-space: nowrap; +} + +.separator { + color: var(--text-dimmed); +} + +.taskPill { + border: 0; + border-radius: 0; + padding: 0 4px; + background: transparent; + color: var(--text-dimmed); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.taskPill:hover, +.taskPill:focus-visible { + background: var(--text-dimmed); + color: var(--bg-primary); + outline: none; +} + +.taskPill:disabled { + cursor: default; +} diff --git a/packages/web-shell/client/components/StatusBar.tsx b/packages/web-shell/client/components/StatusBar.tsx new file mode 100644 index 0000000000..841aa74ba3 --- /dev/null +++ b/packages/web-shell/client/components/StatusBar.tsx @@ -0,0 +1,430 @@ +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from 'react'; +import type { DaemonSessionTaskStatus } from '@qwen-code/sdk/daemon'; +import { useActions, useConnection } from '@qwen-code/webui/daemon-react-sdk'; +import { useI18n } from '../i18n'; +import { TASKS_STATUS_ACTIVE_EVENT } from './messages/TasksStatusMessage'; +import styles from './StatusBar.module.css'; + +const TASKS_POLL_INTERVAL_MS = 3000; +const MAX_EMPTY_TASK_POLLS = 2; +const GOAL_PILL_INTERVAL_MS = 1000; + +export interface StatusBarHandle { + focusTaskPill(): boolean; +} + +function getModeIndicator( + mode: string, + t: ReturnType['t'], +): { label: string; className: string } | null { + switch (mode) { + case 'default': + return { label: t('mode.default'), className: styles.modeDefault }; + case 'plan': + return { label: t('mode.plan'), className: styles.modePlan }; + case 'auto-edit': + return { label: t('mode.auto-edit'), className: styles.modeAutoEdit }; + case 'auto': + return { label: t('mode.auto'), className: styles.modeAuto }; + case 'yolo': + return { label: t('mode.yolo'), className: styles.modeYolo }; + default: + // Only reached before a mode is known (e.g. while disconnected). + return null; + } +} + +interface StatusBarProps { + escapeHint?: boolean; + onSelectMode: () => void; + /** Open the model picker so the model can be chosen with the mouse. */ + onSelectModel: () => void; + /** Show the context-usage breakdown, exactly like typing /context. */ + onShowContext: () => void; + /** Open the inline settings panel so settings are reachable with the mouse. */ + onOpenSettings: () => void; + onOpenTasks?: () => void; + onReturnToInput?: (text?: string) => void; + taskActivityKey?: string; + activeGoal?: { + condition: string; + setAt: number; + } | null; +} + +// Feather "settings" gear, stroke-based like PromptChevron so it inherits +// the button's currentColor. +function GearIcon() { + return ( + + ); +} + +function formatCount( + count: number, + singularKey: string, + pluralKey: string, + t: ReturnType['t'], +): string { + return t(count === 1 ? singularKey : pluralKey, { count }); +} + +export function getTaskPillLabel( + tasks: readonly DaemonSessionTaskStatus[], + t: ReturnType['t'], +): string { + if (tasks.length === 0) return ''; + + const running = tasks.filter((task) => task.status === 'running'); + if (running.length > 0) { + const counts = { agent: 0, shell: 0, monitor: 0 }; + for (const task of running) { + counts[task.kind]++; + } + const parts: string[] = []; + if (counts.shell > 0) { + parts.push( + formatCount(counts.shell, 'tasks.pill.shell', 'tasks.pill.shells', t), + ); + } + if (counts.agent > 0) { + parts.push( + formatCount(counts.agent, 'tasks.pill.agent', 'tasks.pill.agents', t), + ); + } + if (counts.monitor > 0) { + parts.push( + formatCount( + counts.monitor, + 'tasks.pill.monitor', + 'tasks.pill.monitors', + t, + ), + ); + } + return parts.join(', '); + } + + const pausedAgents = tasks.filter( + (task) => task.kind === 'agent' && task.status === 'paused', + ); + if (pausedAgents.length > 0) { + return t( + pausedAgents.length === 1 + ? 'tasks.pill.agentPaused' + : 'tasks.pill.agentsPaused', + { count: pausedAgents.length }, + ); + } + + return t(tasks.length === 1 ? 'tasks.pill.done' : 'tasks.pill.doneMany', { + count: tasks.length, + }); +} + +function hasActiveTask(tasks: readonly DaemonSessionTaskStatus[]): boolean { + return tasks.some( + (task) => task.status === 'running' || task.status === 'paused', + ); +} + +function formatGoalElapsed(ms: number): string { + if (ms < 1000) return ''; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + return `${hours}h ${minutes % 60}m`; +} + +export const StatusBar = forwardRef( + function StatusBar( + { + escapeHint, + onSelectMode, + onSelectModel, + onShowContext, + onOpenSettings, + onOpenTasks, + onReturnToInput, + taskActivityKey, + activeGoal, + }, + ref, + ) { + const connection = useConnection(); + const actions = useActions(); + const connected = connection.status === 'connected'; + const currentModel = connection.currentModel ?? ''; + const currentMode = connection.currentMode ?? ''; + const tokenCount = connection.tokenCount ?? 0; + const contextWindow = connection.contextWindow ?? 0; + const { t } = useI18n(); + const pct = contextWindow > 0 ? (tokenCount / contextWindow) * 100 : 0; + const pctDisplay = pct.toFixed(1); + const modeIndicator = getModeIndicator(currentMode, t); + const [tasks, setTasks] = useState([]); + const [pollingActive, setPollingActive] = useState(false); + const [tasksPanelActive, setTasksPanelActive] = useState(false); + const [, setGoalTick] = useState(0); + const emptyPollsRef = useRef(0); + const tasksRefreshInFlightRef = useRef(false); + const taskPillRef = useRef(null); + + useEffect(() => { + if (!connected) { + setTasks([]); + setPollingActive(false); + emptyPollsRef.current = 0; + return; + } + }, [connected]); + + useEffect(() => { + if (!connected || !taskActivityKey) return; + emptyPollsRef.current = 0; + setPollingActive(true); + }, [connected, taskActivityKey]); + + useEffect(() => { + if (tasksPanelActive) return; + if (!connected || !pollingActive) return; + + let disposed = false; + const refresh = () => { + if (tasksRefreshInFlightRef.current) return; + tasksRefreshInFlightRef.current = true; + actions + .getTasks() + .then((snapshot) => { + if (disposed) return; + setTasks(snapshot.tasks); + if (snapshot.tasks.length === 0) { + emptyPollsRef.current += 1; + if (emptyPollsRef.current >= MAX_EMPTY_TASK_POLLS) { + setPollingActive(false); + } + return; + } + emptyPollsRef.current = 0; + if (!hasActiveTask(snapshot.tasks)) { + setPollingActive(false); + } + }) + .catch((error: unknown) => { + if (disposed) return; + console.warn('[web-shell] failed to refresh tasks:', error); + }) + .finally(() => { + tasksRefreshInFlightRef.current = false; + }); + }; + + refresh(); + const id = setInterval(refresh, TASKS_POLL_INTERVAL_MS); + return () => { + disposed = true; + clearInterval(id); + }; + }, [actions, connected, pollingActive, tasksPanelActive]); + + useEffect(() => { + const onTasksPanelActive = (event: Event) => { + const detail = (event as CustomEvent<{ active?: boolean }>).detail; + const active = detail?.active === true; + setTasksPanelActive(active); + if (!active && hasActiveTask(tasks)) { + setPollingActive(true); + } + }; + window.addEventListener(TASKS_STATUS_ACTIVE_EVENT, onTasksPanelActive); + return () => + window.removeEventListener( + TASKS_STATUS_ACTIVE_EVENT, + onTasksPanelActive, + ); + }, [tasks]); + + useEffect(() => { + if (!activeGoal) return; + const id = setInterval( + () => setGoalTick((tick) => (tick + 1) % 1_000_000), + GOAL_PILL_INTERVAL_MS, + ); + return () => clearInterval(id); + }, [activeGoal]); + + const taskPillLabel = useMemo(() => getTaskPillLabel(tasks, t), [tasks, t]); + const goalElapsed = activeGoal + ? formatGoalElapsed(Date.now() - activeGoal.setAt) + : ''; + const goalLabel = activeGoal + ? `◎ ${t('goal.statusActive')}${goalElapsed ? ` (${goalElapsed})` : ''}` + : ''; + + useImperativeHandle( + ref, + () => ({ + focusTaskPill() { + if (!taskPillLabel) return false; + taskPillRef.current?.focus({ preventScroll: true }); + return true; + }, + }), + [taskPillLabel], + ); + + const handleTaskPillKeyDown = (event: KeyboardEvent) => { + if ( + event.key === 'Enter' || + event.key === 'ArrowDown' || + (event.key === 'n' && event.ctrlKey) + ) { + event.preventDefault(); + event.stopPropagation(); + onOpenTasks?.(); + return; + } + if ( + event.key === 'ArrowUp' || + event.key === 'Escape' || + (event.key === 'p' && event.ctrlKey) + ) { + event.preventDefault(); + event.stopPropagation(); + onReturnToInput?.(); + return; + } + if ( + event.key.length === 1 && + !event.metaKey && + !event.ctrlKey && + !event.altKey + ) { + event.preventDefault(); + event.stopPropagation(); + onReturnToInput?.(event.key); + } + }; + + return ( +
+
+ {connected && ( + + )} + {escapeHint ? ( + + {t('editor.escClearHint')} + + ) : modeIndicator ? ( + + ) : ( + {t('status.shortcuts')} + )} + {taskPillLabel && ( + <> + · + + + )} +
+ +
+ {!connected && ( + + {t('status.disconnected')} + + )} + {currentModel && ( + + )} + {contextWindow > 0 && tokenCount > 0 && ( + + )} + {goalLabel && ( + + {goalLabel} + + )} +
+
+ ); + }, +); diff --git a/packages/web-shell/client/components/StreamingStatus.module.css b/packages/web-shell/client/components/StreamingStatus.module.css new file mode 100644 index 0000000000..20906cae8e --- /dev/null +++ b/packages/web-shell/client/components/StreamingStatus.module.css @@ -0,0 +1,23 @@ +.status { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 6px; + padding: 4px 0; + font-size: 14px; + margin: 0 24px; + color: var(--text-dimmed); +} + +.spinner { + color: var(--text-primary); +} + +.label { + color: var(--assist-color); +} + +.meta { + color: var(--text-dimmed); + font-size: 13px; +} diff --git a/packages/web-shell/client/components/StreamingStatus.tsx b/packages/web-shell/client/components/StreamingStatus.tsx new file mode 100644 index 0000000000..e8ca48d219 --- /dev/null +++ b/packages/web-shell/client/components/StreamingStatus.tsx @@ -0,0 +1,77 @@ +import { useState, useEffect, useRef } from 'react'; +import { + PHRASE_CHANGE_INTERVAL_MS, + getLoadingPhrases, +} from '../constants/loadingPhrases'; +import { useStreamingState } from '@qwen-code/webui/daemon-react-sdk'; +import { useI18n } from '../i18n'; +import { useStreamingOutputTokens } from '../hooks/useStreamingOutputTokens'; +import { formatTokenCount } from '../utils/formatTokenCount'; +import styles from './StreamingStatus.module.css'; + +export function StreamingStatus() { + const streamingState = useStreamingState(); + const outputTokens = useStreamingOutputTokens(); + const { language, t } = useI18n(); + const [elapsed, setElapsed] = useState(0); + const startTime = useRef(Date.now()); + const [dotFrame, setDotFrame] = useState(0); + const [loadingPhrase, setLoadingPhrase] = useState(() => { + const phrases = getLoadingPhrases(language); + return phrases[0] ?? ''; + }); + + useEffect(() => { + startTime.current = Date.now(); + setElapsed(0); + const interval = setInterval(() => { + setElapsed(Math.floor((Date.now() - startTime.current) / 1000)); + }, 1000); + return () => clearInterval(interval); + }, [streamingState]); + + useEffect(() => { + const phrases = getLoadingPhrases(language); + if (streamingState === 'idle' || phrases.length === 0) { + setLoadingPhrase(phrases[0] ?? ''); + return; + } + + const pickPhrase = () => { + const idx = Math.floor(Math.random() * phrases.length); + setLoadingPhrase(phrases[idx] ?? ''); + }; + + pickPhrase(); + const interval = setInterval(pickPhrase, PHRASE_CHANGE_INTERVAL_MS); + return () => clearInterval(interval); + }, [language, streamingState]); + + useEffect(() => { + if (streamingState === 'idle') return; + const interval = setInterval(() => { + setDotFrame((f) => (f + 1) % 4); + }, 250); + return () => clearInterval(interval); + }, [streamingState]); + + if (streamingState === 'idle') return null; + + const dots = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + const spinnerChar = dots[dotFrame % dots.length]; + const arrow = streamingState === 'responding' ? '↓' : '↑'; + const tokenStr = + outputTokens > 0 + ? ` · ${arrow} ${t('stream.tokens', { count: formatTokenCount(outputTokens) })}` + : ''; + + return ( +
+ {spinnerChar} + {loadingPhrase && {loadingPhrase}} + + ({elapsed}s{tokenStr} · {t('stream.cancel')}) + +
+ ); +} diff --git a/packages/web-shell/client/components/Tips.module.css b/packages/web-shell/client/components/Tips.module.css new file mode 100644 index 0000000000..1d18fd63f6 --- /dev/null +++ b/packages/web-shell/client/components/Tips.module.css @@ -0,0 +1,15 @@ +.line { + flex-shrink: 0; + padding: 4px 0; + padding-left: 2ch; + font-size: 14px; + color: var(--text-dimmed); +} + +.label { + color: var(--text-dimmed); +} + +.text { + color: var(--text-dimmed); +} diff --git a/packages/web-shell/client/components/Tips.tsx b/packages/web-shell/client/components/Tips.tsx new file mode 100644 index 0000000000..30a7dfba0d --- /dev/null +++ b/packages/web-shell/client/components/Tips.tsx @@ -0,0 +1,20 @@ +import { useMemo } from 'react'; +import { useI18n } from '../i18n'; +import styles from './Tips.module.css'; + +function pickTip(tips: string[]): string { + return tips[Math.floor(Math.random() * tips.length)] ?? ''; +} + +export function Tips() { + const { t } = useI18n(); + const tips = useMemo(() => t('tips.items').split('|'), [t]); + const tip = useMemo(() => pickTip(tips), [tips]); + + return ( +
+ {t('welcome.tipLabel')} + {tip} +
+ ); +} diff --git a/packages/web-shell/client/components/ToastHost.module.css b/packages/web-shell/client/components/ToastHost.module.css new file mode 100644 index 0000000000..6199e4602c --- /dev/null +++ b/packages/web-shell/client/components/ToastHost.module.css @@ -0,0 +1,89 @@ +.host { + position: absolute; + top: 12px; + right: 12px; + z-index: 30; + display: flex; + width: min(420px, calc(100% - 24px)); + flex-direction: column; + gap: 8px; + pointer-events: none; +} + +.toast { + display: grid; + grid-template-columns: minmax(0, 1fr) 28px; + align-items: center; + gap: 8px; + min-height: 40px; + padding: 9px 8px 9px 12px; + border: 1px solid + color-mix(in srgb, var(--text-primary) 14%, var(--border-color)); + border-left-width: 4px; + border-radius: var(--radius); + background-color: var(--bg-secondary); + color: var(--text-primary); + box-shadow: + 0 12px 32px rgba(0, 0, 0, 0.34), + inset 0 0 0 1px rgba(255, 255, 255, 0.04); + pointer-events: auto; +} + +.message { + min-width: 0; + overflow-wrap: anywhere; + font-family: var(--font-sans); + font-size: 13px; + line-height: 1.45; +} + +.close { + width: 28px; + height: 28px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font: inherit; + line-height: 1; +} + +.close:hover, +.close:focus-visible { + background: var(--subtle-bg-strong); + color: var(--text-primary); + outline: none; +} + +.info { + border-left-color: var(--accent-color); + background-image: linear-gradient( + color-mix(in srgb, var(--accent-color) 16%, transparent), + color-mix(in srgb, var(--accent-color) 16%, transparent) + ); +} + +.success { + border-left-color: var(--success-color); + background-image: linear-gradient( + color-mix(in srgb, var(--success-color) 16%, transparent), + color-mix(in srgb, var(--success-color) 16%, transparent) + ); +} + +.warning { + border-left-color: var(--warning-color); + background-image: linear-gradient( + color-mix(in srgb, var(--warning-color) 18%, transparent), + color-mix(in srgb, var(--warning-color) 18%, transparent) + ); +} + +.error { + border-left-color: var(--error-color); + background-image: linear-gradient( + color-mix(in srgb, var(--error-color) 18%, transparent), + color-mix(in srgb, var(--error-color) 18%, transparent) + ); +} diff --git a/packages/web-shell/client/components/ToastHost.tsx b/packages/web-shell/client/components/ToastHost.tsx new file mode 100644 index 0000000000..a0d94d8dc5 --- /dev/null +++ b/packages/web-shell/client/components/ToastHost.tsx @@ -0,0 +1,66 @@ +import { useEffect } from 'react'; +import styles from './ToastHost.module.css'; + +export type ToastTone = 'info' | 'warning' | 'error' | 'success'; + +export interface WebShellToast { + id: string; + tone: ToastTone; + message: string; +} + +interface ToastHostProps { + toasts: readonly WebShellToast[]; + onDismiss: (id: string) => void; + autoDismissMs?: number; +} + +export function ToastHost({ + toasts, + onDismiss, + autoDismissMs = 5000, +}: ToastHostProps) { + if (toasts.length === 0) return null; + return ( +
+ {toasts.map((toast) => ( + + ))} +
+ ); +} + +function ToastItem({ + toast, + onDismiss, + autoDismissMs, +}: { + toast: WebShellToast; + onDismiss: (id: string) => void; + autoDismissMs: number; +}) { + useEffect(() => { + const timer = window.setTimeout(() => onDismiss(toast.id), autoDismissMs); + return () => window.clearTimeout(timer); + }, [autoDismissMs, onDismiss, toast.id]); + + return ( +
+
{toast.message}
+ +
+ ); +} diff --git a/packages/web-shell/client/components/WelcomeHeader.module.css b/packages/web-shell/client/components/WelcomeHeader.module.css new file mode 100644 index 0000000000..fef737afa5 --- /dev/null +++ b/packages/web-shell/client/components/WelcomeHeader.module.css @@ -0,0 +1,138 @@ +.header { + padding: 12px 0 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.banner { + display: flex; + align-items: center; + gap: 2ch; + min-width: 0; +} + +.logo { + margin: 0; + flex-shrink: 0; + font-family: var(--font-mono, 'SF Mono', 'Fira Code', monospace); + font-size: 20px; + line-height: 1; + white-space: pre; + color: var(--accent-color); + background: var(--welcome-logo-gradient); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.panel { + min-width: 280px; + max-width: 60ch; + border: 1px solid var(--border-color); + padding: 7px 10px; + display: flex; + flex-direction: column; + gap: 2px; + font-family: var(--font-mono, 'SF Mono', 'Fira Code', monospace); + color: var(--text-secondary); +} + +.titleRow { + display: flex; + align-items: baseline; + gap: 1ch; + min-width: 0; +} + +.title { + font-size: 15px; + font-weight: 700; + color: var(--accent-color); +} + +.version { + font-size: 13px; + color: var(--text-dimmed); +} + +.subtitle { + height: 1em; +} + +.metaLine { + display: flex; + align-items: baseline; + gap: 1ch; + font-size: 13px; + min-width: 0; +} + +.mode { + color: var(--accent-color); + font-weight: 600; +} + +.sep { + color: var(--text-dimmed); +} + +.terminalLabel { + flex-shrink: 0; + white-space: nowrap; +} + +.model { + color: var(--text-secondary); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modelHint { + color: var(--text-dimmed); + font-size: 13px; + white-space: nowrap; +} + +.cwd { + font-size: 13px; + color: var(--text-dimmed); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tip { + font-size: 13px; + color: var(--text-dimmed); + display: flex; + gap: 1ch; + font-family: var(--font-mono, 'SF Mono', 'Fira Code', monospace); +} + +.tipLabel { + color: var(--text-dimmed); +} + +.tipText { + color: var(--text-dimmed); +} + +@media (max-width: 760px) { + .banner { + align-items: stretch; + } + + .logo { + display: none; + } + + .panel { + width: 100%; + max-width: none; + min-width: 0; + } +} diff --git a/packages/web-shell/client/components/WelcomeHeader.tsx b/packages/web-shell/client/components/WelcomeHeader.tsx new file mode 100644 index 0000000000..3083622a4e --- /dev/null +++ b/packages/web-shell/client/components/WelcomeHeader.tsx @@ -0,0 +1,123 @@ +import { useMemo } from 'react'; +import { useI18n } from '../i18n'; +import styles from './WelcomeHeader.module.css'; + +const TIPS_EN = [ + 'Type / to open commands; Tab completes slash commands and saved prompts.', + 'Add a QWEN.md file to give Qwen Code durable project context.', + 'Use ! to run shell commands through Qwen Code, for example !ls.', + 'When a chat gets long, use /compress to free context.', + 'Use Shift+Tab or /approval-mode to switch approval modes quickly.', + 'Use /clear or /new to start fresh; previous sessions stay resumable.', +]; + +const TIPS_ZH = [ + '输入 / 打开命令弹窗;Tab 可以补全斜杠命令和已保存的 prompt。', + '添加 QWEN.md 文件,为 Qwen Code 提供持久的项目上下文。', + '可以使用 ! 从 Qwen Code 运行 shell 命令,例如 !ls。', + '对话变长时,使用 /compress 压缩历史并释放上下文。', + '使用 Shift+Tab 或 /approval-mode 快速切换权限模式。', + '使用 /clear 或 /new 开始新想法;之前的会话仍可从历史恢复。', +]; + +const ASCII_LOGO = ` + ▄▄▄▄▄▄ ▄▄ ▄▄ ▄▄▄▄▄▄▄ ▄▄▄ ▄▄ +██╔═══██╗██║ ██║██╔════╝████╗ ██║ +██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║ +██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║ +╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║ + ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝ +`.trim(); + +function pickTip(language: string): string { + const tips = language === 'zh-CN' ? TIPS_ZH : TIPS_EN; + return tips[Math.floor(Math.random() * tips.length)]; +} + +function shortenPath(path: string, maxLength = 72): string { + if (!path || path.length <= maxLength) { + return path; + } + const headLength = Math.max(12, Math.floor((maxLength - 3) * 0.38)); + const tailLength = Math.max(18, maxLength - headLength - 3); + return `${path.slice(0, headLength)}...${path.slice(-tailLength)}`; +} + +function formatMode(mode: string, t: ReturnType['t']): string { + switch (mode) { + case 'plan': + return t('mode.plan'); + case 'auto-edit': + return t('mode.auto-edit'); + case 'yolo': + return t('mode.yolo'); + case 'default': + return t('mode.default'); + default: + return mode || t('mode.unknown'); + } +} + +export interface WelcomeHeaderProps { + version: string; + cwd: string; + currentModel: string; + currentMode: string; +} + +export function WelcomeHeader({ + version, + cwd, + currentModel, + currentMode, +}: WelcomeHeaderProps) { + const { language, t } = useI18n(); + const tip = useMemo(() => pickTip(language), [language]); + const displayPath = useMemo(() => shortenPath(cwd), [cwd]); + const model = currentModel || t('welcome.defaultModel'); + const mode = formatMode(currentMode, t); + + return ( +
+
+ + +
+
+ {'>_ Qwen Code'} + {version && (v{version})} +
+ + + +
+ Web terminal + | + {model} + {t('welcome.changeModel')} +
+ +
+ {mode} + {t('welcome.modeHint')} +
+ + {displayPath && ( +
+ {displayPath} +
+ )} +
+
+ +
+ {t('welcome.tipLabel')} + {tip} +
+
+ ); +} diff --git a/packages/web-shell/client/components/dialogs/ApprovalModeDialog.tsx b/packages/web-shell/client/components/dialogs/ApprovalModeDialog.tsx new file mode 100644 index 0000000000..ffa144ffc1 --- /dev/null +++ b/packages/web-shell/client/components/dialogs/ApprovalModeDialog.tsx @@ -0,0 +1,128 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { dp } from './dialogStyles'; +import { DAEMON_APPROVAL_MODES } from '@qwen-code/webui/daemon-react-sdk'; +import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown'; +import { useI18n } from '../../i18n'; + +interface ApprovalModeDialogProps { + currentMode: string; + onSelect: (modeId: string) => void; + onClose: () => void; +} + +export function ApprovalModeDialog({ + currentMode, + onSelect, + onClose, +}: ApprovalModeDialogProps) { + const { t } = useI18n(); + const approvalModes = DAEMON_APPROVAL_MODES.map((id) => ({ + id, + label: t(`mode.${id}`), + description: t(`mode.${id}.desc`), + })); + const [selectedIdx, setSelectedIdx] = useState(() => { + const idx = approvalModes.findIndex((m) => m.id === currentMode); + return idx >= 0 ? idx : 0; + }); + const listRef = useRef(null); + + useEffect(() => { + const el = listRef.current?.children[selectedIdx] as + | HTMLElement + | undefined; + el?.scrollIntoView({ block: 'nearest' }); + }, [selectedIdx]); + + const handleSelect = useCallback(() => { + const mode = approvalModes[selectedIdx]; + if (mode) { + onSelect(mode.id); + onClose(); + } + }, [approvalModes, selectedIdx, onSelect, onClose]); + + useDelayedGlobalKeyDown( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + return; + } + if (e.key === 'ArrowDown' || e.key === 'j') { + e.preventDefault(); + setSelectedIdx((i) => Math.min(i + 1, approvalModes.length - 1)); + return; + } + if (e.key === 'ArrowUp' || e.key === 'k') { + e.preventDefault(); + setSelectedIdx((i) => Math.max(i - 1, 0)); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + handleSelect(); + return; + } + }, + [approvalModes.length, selectedIdx, onClose, handleSelect], + ); + + return ( +
+
+ + {t('local.approvalMode')} + + + {t('common.current')}:{' '} + {approvalModes.find((m) => m.id === currentMode)?.label || + currentMode} + + +
+ +
+ +
+ {approvalModes.map((m, i) => ( +
{ + onSelect(m.id); + onClose(); + }} + onMouseEnter={() => setSelectedIdx(i)} + > +
+ + {i === selectedIdx ? '›' : ' '} + + {m.label} + {m.id === currentMode && ( + + )} +
+
{m.description}
+
+ ))} +
+ +
+ +
+ {t('dialog.footer.navSelectCancel')} +
+
+ ); +} diff --git a/packages/web-shell/client/components/dialogs/DeleteSessionDialog.tsx b/packages/web-shell/client/components/dialogs/DeleteSessionDialog.tsx new file mode 100644 index 0000000000..301199aa09 --- /dev/null +++ b/packages/web-shell/client/components/dialogs/DeleteSessionDialog.tsx @@ -0,0 +1,415 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { dp } from './dialogStyles'; +import { useConnection, useSessions } from '@qwen-code/webui/daemon-react-sdk'; +import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown'; +import { useI18n } from '../../i18n'; +import { formatRelativeTime } from '../../utils/formatRelativeTime'; + +interface DeleteSessionDialogProps { + onDeleted: (sessionIds: string[]) => void; + onError: (error: unknown) => void; + onClose: () => void; +} + +export function DeleteSessionDialog({ + onDeleted, + onError, + onClose, +}: DeleteSessionDialogProps) { + const { t } = useI18n(); + const connection = useConnection(); + const { + sessions, + loading, + error: sessionsError, + deleteSession, + deleteSessions, + } = useSessions({ autoLoad: true }); + const currentSessionId = connection.sessionId; + const [deleting, setDeleting] = useState(false); + const [selectedIdx, setSelectedIdx] = useState(0); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [searchMode, setSearchMode] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [message, setMessage] = useState(null); + const listRef = useRef(null); + + useEffect(() => { + if (sessionsError) setMessage(sessionsError.message); + }, [sessionsError]); + + const filtered = useMemo( + () => + searchQuery + ? sessions.filter((s) => { + const q = searchQuery.toLowerCase(); + return ( + (s.displayName || s.title || '').toLowerCase().includes(q) || + s.sessionId.toLowerCase().includes(q) + ); + }) + : sessions, + [sessions, searchQuery], + ); + + useEffect(() => { + if (searchQuery && selectedIds.size > 0) { + const filteredSet = new Set(filtered.map((s) => s.sessionId)); + setSelectedIds((prev) => { + const pruned = new Set([...prev].filter((id) => filteredSet.has(id))); + return pruned.size === prev.size ? prev : pruned; + }); + } + }, [searchQuery, filtered, selectedIds.size]); + + useEffect(() => { + if (selectedIdx >= filtered.length && filtered.length > 0) { + setSelectedIdx(filtered.length - 1); + } + }, [filtered.length, selectedIdx]); + + useEffect(() => { + const el = listRef.current?.children[selectedIdx] as + | HTMLElement + | undefined; + el?.scrollIntoView({ block: 'nearest' }); + }, [selectedIdx]); + + const toggleSelection = useCallback( + (sessionId: string) => { + if (sessionId === currentSessionId) return; + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(sessionId)) { + next.delete(sessionId); + } else { + next.add(sessionId); + } + return next; + }); + }, + [currentSessionId], + ); + + const handleDelete = useCallback(() => { + if (deleting) return; + + if (selectedIds.size > 0) { + const filteredSet = new Set(filtered.map((s) => s.sessionId)); + const idsToDelete = Array.from(selectedIds).filter((id) => + filteredSet.has(id), + ); + if (idsToDelete.length === 0) return; + setDeleting(true); + deleteSessions(idsToDelete) + .then((res) => { + const succeeded = res.removed.length + res.notFound.length; + const failed = res.errors.length; + + if (failed > 0 && succeeded > 0) { + onError( + new Error( + t('delete.partialFail', { + removed: succeeded, + failed, + detail: res.errors[0].error, + }), + ), + ); + onClose(); + return; + } + + if (failed > 0) { + setMessage( + t('delete.allFailed', { + count: failed, + reason: res.errors[0].error, + }), + ); + setDeleting(false); + setSelectedIds(new Set()); + return; + } + + if (succeeded === 0) { + setMessage(t('delete.nonRemoved')); + setDeleting(false); + setSelectedIds(new Set()); + return; + } + + onDeleted([...res.removed, ...res.notFound]); + onClose(); + }) + .catch((error: unknown) => { + onError(error); + setDeleting(false); + }); + return; + } + + const session = filtered[selectedIdx]; + if (!session) return; + if (session.sessionId === currentSessionId) { + setMessage(t('delete.cannotCurrent')); + return; + } + setDeleting(true); + deleteSession(session.sessionId) + .then((removed) => { + if (!removed) { + setMessage(t('delete.notFound')); + setDeleting(false); + return; + } + onDeleted([session.sessionId]); + onClose(); + }) + .catch((error: unknown) => { + onError(error); + setDeleting(false); + }); + }, [ + currentSessionId, + deleteSession, + deleteSessions, + deleting, + filtered, + onClose, + onDeleted, + onError, + selectedIdx, + selectedIds, + t, + ]); + + useDelayedGlobalKeyDown( + (e: KeyboardEvent) => { + if (deleting) return; + if (searchMode) { + if (e.key === 'Escape') { + e.preventDefault(); + if (searchQuery) { + setSearchQuery(''); + } else { + setSearchMode(false); + } + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + if (filtered.length > 0) { + setSearchMode(false); + setSelectedIdx(0); + } + return; + } + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault(); + setSearchMode(false); + if (e.key === 'ArrowDown') { + setSelectedIdx((i) => Math.min(i + 1, filtered.length - 1)); + } + } + return; + } + + if (e.key === 'Escape') { + e.preventDefault(); + if (searchQuery) { + setSearchQuery(''); + setSelectedIdx(0); + } else { + onClose(); + } + return; + } + if (e.key === 'ArrowDown' || e.key === 'j') { + e.preventDefault(); + setSelectedIdx((i) => Math.min(i + 1, filtered.length - 1)); + return; + } + if (e.key === 'ArrowUp' || e.key === 'k') { + e.preventDefault(); + if (selectedIdx === 0) { + setSearchMode(true); + } else { + setSelectedIdx((i) => Math.max(i - 1, 0)); + } + return; + } + if (e.key === ' ') { + e.preventDefault(); + const session = filtered[selectedIdx]; + if (session) toggleSelection(session.sessionId); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + handleDelete(); + return; + } + if (e.key === '/') { + e.preventDefault(); + setSearchMode(true); + } + }, + [ + deleting, + filtered, + handleDelete, + onClose, + searchMode, + searchQuery, + selectedIdx, + toggleSelection, + ], + ); + + const hasSelection = selectedIds.size > 0; + + return ( + // Hover selection is intentionally disabled here: otherwise a stationary + // mouse can override the row selected by keyboard ↑↓ navigation. +
+
+ {t('delete.title')} + {hasSelection && ( + + ({t('delete.selected', { count: selectedIds.size })}) + + )} + {!hasSelection && searchQuery && ( + + ({t('delete.matches', { count: filtered.length })}) + + )} + +
+ +
+ {searchMode ? ( + <> + + {t('resume.search')}:{' '} + + { + setSearchQuery(e.target.value); + setSelectedIdx(0); + }} + autoFocus + placeholder="" + /> + + ) : searchQuery ? ( + <> + + {t('resume.filter')}:{' '} + + + {searchQuery} + + + ) : ( + + {message || + (deleting + ? t('delete.deleting') + : loading + ? t('common.loading') + : t('delete.pressSearch'))} + + )} +
+ +
+ +
+ {loading && ( +
{t('common.loading')}
+ )} + {!loading && sessionsError && ( +
+ {sessionsError.message} +
+ )} + {!loading && !sessionsError && filtered.length === 0 && ( +
+ {searchQuery + ? t('delete.noMatch', { query: searchQuery }) + : t('delete.none')} +
+ )} + {!loading && + filtered.map((s, i) => { + const isCurrent = s.sessionId === currentSessionId; + const isChecked = selectedIds.has(s.sessionId); + const checkbox = isChecked ? '[x] ' : '[ ] '; + return ( +
{ + setSelectedIdx(i); + if (!isCurrent) toggleSelection(s.sessionId); + }} + > +
+ + {i === selectedIdx && !searchMode ? '›' : ' '} + + + {checkbox} + + + {s.displayName || s.title || s.sessionId.slice(0, 8)} + + {isCurrent && ( + + {t('resume.current')} + + )} +
+
+ + {(s.updatedAt || s.createdAt) && + formatRelativeTime(s.updatedAt || s.createdAt || '', t)} + + + {t('common.clients', { count: s.clientCount ?? 0 })} + + {s.hasActivePrompt && ( + + {t('resume.activePrompt')} + + )} +
+
+ ); + })} +
+ +
+ +
+ {searchMode ? t('dialog.footer.search') : t('delete.footer')} +
+
+ ); +} diff --git a/packages/web-shell/client/components/dialogs/DialogPrimitives.module.css b/packages/web-shell/client/components/dialogs/DialogPrimitives.module.css new file mode 100644 index 0000000000..1152a5a7e7 --- /dev/null +++ b/packages/web-shell/client/components/dialogs/DialogPrimitives.module.css @@ -0,0 +1,440 @@ +/* Shared dialog and picker primitives reused by dialog components. */ +/* ===== Dialog Overlay & Panel ===== */ +/* ===== CLI-style Picker (Model, Mode, Resume) ===== */ +.resume-picker { + display: flex; + flex-direction: column; + height: 100%; + border: 1px solid var(--border-color); + border-radius: var(--radius); + overflow: hidden; +} + +.resume-picker-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; +} + +.resume-picker-close { + margin-left: auto; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-secondary); + color: var(--text-secondary); + cursor: pointer; + font-family: var(--font-mono); + font-size: 12px; + padding: 2px 8px; + flex-shrink: 0; +} + +.resume-picker-close:hover { + color: var(--text-primary); + border-color: var(--accent-color); +} + +.resume-picker-title { + font-weight: 700; + color: var(--text-primary); + font-size: 14px; +} + +.resume-picker-count { + font-size: 12px; + color: var(--text-secondary); +} + +.resume-picker-search { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 12px; + font-size: 13px; + min-height: 24px; +} + +.resume-picker-search-label { + color: var(--text-secondary); +} + +.resume-picker-search-input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 13px; +} + +.resume-picker-search-value { + color: var(--text-primary); +} + +.resume-picker-search-hint { + color: var(--text-dimmed); +} + +.resume-picker-sep { + height: 1px; + background: var(--border-color); +} + +.resume-picker-list { + flex: 1; + overflow-y: auto; + padding: 4px 0; +} + +.resume-picker-empty { + padding: 16px 12px; + color: var(--text-dimmed); + font-size: 13px; +} + +.resume-picker-item { + display: flex; + align-items: baseline; + padding: 0px 12px; + cursor: pointer; +} + +.resume-picker-session-item { + align-items: stretch; + flex-direction: column; + padding-top: 2px; + padding-bottom: 6px; +} + +.resume-picker-item.selected { + background: var(--bg-tertiary); +} + +.resume-picker-item:hover { + background: var(--bg-tertiary); +} + +.resume-picker-keyboard-only .resume-picker-item:hover { + background: transparent; +} + +.resume-picker-keyboard-only .resume-picker-item.selected:hover { + background: var(--bg-tertiary); +} + +.resume-picker-item.disabled { + cursor: default; + opacity: 0.55; +} + +.resume-picker-item-row { + display: flex; + align-items: baseline; + flex: 1; + gap: 4px; + min-width: 0; + width: 100%; +} + +.resume-picker-item-prefix { + min-width: 14px; + flex-shrink: 0; + color: var(--accent-color); + font-weight: 700; + font-size: 20px; + margin-right: 8px; + white-space: pre; +} + +.resume-picker-item-title { + flex: 1 1 auto; + min-width: 0; + color: var(--text-primary); + flex: 1 1 auto; + font-size: 13px; + font-weight: 500; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.resume-picker-item-checkbox { + color: var(--text-secondary); + flex-shrink: 0; + font-size: 13px; + white-space: pre; +} + +.resume-picker-item.selected .resume-picker-item-checkbox { + color: var(--accent-color); + font-weight: 700; +} + +.resume-picker-item-number { + flex-shrink: 0; + min-width: 28px; + color: var(--text-secondary); + font-size: 13px; + text-align: right; +} + +.resume-picker-item-provider { + flex-shrink: 0; + color: var(--accent-color); + font-size: 13px; + font-weight: 700; + white-space: nowrap; +} + +.resume-picker-item.selected .resume-picker-item-title { + color: var(--accent-color); + font-weight: 700; +} + +.resume-picker-item-meta { + display: flex; + align-items: center; + gap: 8px; + padding-left: 26px; + font-size: 12px; + color: var(--text-secondary); +} + +.resume-picker-item-detail { + color: var(--text-dimmed); +} + +.resume-picker-item-detail::before { + content: '·'; + margin-right: 8px; + color: var(--text-dimmed); +} + +.resume-picker-item-check { + color: var(--accent-color, #4a9eff); + font-weight: bold; + margin-left: 4px; +} + +.resume-picker-item-badge { + flex-shrink: 0; + margin-left: 8px; + font-size: 11px; + color: var(--accent-color, #4a9eff); + opacity: 0.8; + white-space: nowrap; +} + +.mcp-status { + font-weight: 700; +} + +.mcp-status-connected { + color: var(--success-color); +} + +.mcp-status-connecting { + color: var(--warning-color); +} + +.mcp-status-disconnected { + color: var(--error-color); +} + +.mcp-status-disabled, +.mcp-status-unknown { + color: var(--text-dimmed); +} + +.resume-picker-item-current { + border-left: 2px solid var(--accent-color, #4a9eff); + padding-left: 10px; +} + +.resume-picker-footer { + padding: 6px 12px; + font-size: 12px; + color: var(--text-dimmed); +} + +.resume-picker-detail-panel { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 12px; + padding-left: 32px; + font-size: 13px; + flex-shrink: 0; +} + +.resume-picker-detail-row { + display: grid; + grid-template-columns: 128px minmax(0, 1fr); + gap: 8px; + align-items: start; +} + +.resume-picker-detail-label { + color: var(--text-secondary); + white-space: nowrap; +} + +.resume-picker-detail-value { + color: var(--text-primary); + overflow-wrap: anywhere; +} + +.dialog-inline-button, +.dialog-primary-button, +.dialog-danger-button { + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-secondary); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 12px; + padding: 4px 8px; + cursor: pointer; +} + +.dialog-inline-button:hover, +.dialog-primary-button:hover { + border-color: var(--accent-color); +} + +.dialog-inline-button:disabled, +.dialog-primary-button:disabled, +.dialog-danger-button:disabled { + cursor: default; + opacity: 0.5; +} + +.dialog-inline-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.dialog-primary-button { + align-self: flex-start; + color: var(--accent-color); +} + +.dialog-danger-button { + align-self: flex-start; + color: var(--error-color); +} + +.dialog-form { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + overflow-y: auto; +} + +.dialog-form label { + display: flex; + flex-direction: column; + gap: 4px; + color: var(--text-secondary); + font-size: 12px; +} + +.dialog-form input, +.dialog-form select, +.dialog-form textarea { + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 13px; + padding: 6px 8px; +} + +.dialog-form-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(120px, 180px); + gap: 10px; +} + +.dialog-textarea { + min-height: 120px; + resize: vertical; +} + +.dialog-split { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(220px, 38%) minmax(0, 1fr); +} + +.dialog-split-list { + border-right: 1px solid var(--border-color); +} + +.tools-desc-expanded { + display: flex; + flex-direction: column; + gap: 4px; + margin-left: 26px; + margin-top: 4px; + padding: 6px 8px; + border-left: 2px solid var(--accent-color); + background: var(--bg-secondary); + border-radius: 0 4px 4px 0; +} + +.tools-desc-body { + color: var(--text-secondary); + font-size: 12px; + white-space: pre-wrap; + line-height: 1.5; +} + +.dialog-detail { + padding: 12px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; +} + +.dialog-detail-title { + display: flex; + align-items: center; + justify-content: space-between; + color: var(--text-primary); + font-weight: 700; +} + +.dialog-detail-shortcut { + font-size: 11px; + font-weight: 400; + color: var(--error-color); + opacity: 0.8; +} + +.dialog-detail-meta { + color: var(--text-secondary); + font-size: 12px; +} + +.dialog-detail-body { + color: var(--text-primary); + font-size: 12px; + white-space: pre-wrap; + line-height: 1.5; + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 8px; + background: var(--bg-secondary); +} diff --git a/packages/web-shell/client/components/dialogs/HelpDialog.module.css b/packages/web-shell/client/components/dialogs/HelpDialog.module.css new file mode 100644 index 0000000000..bf995dbdf1 --- /dev/null +++ b/packages/web-shell/client/components/dialogs/HelpDialog.module.css @@ -0,0 +1,180 @@ +.dialog { + min-height: 0; +} + +.body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding: 10px 12px; + gap: 10px; +} + +.tabs { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.brand { + color: var(--accent-color); + font-weight: 700; +} + +.tab { + color: var(--text-primary); + border: 1px solid transparent; + border-radius: 4px; + padding: 1px 7px; +} + +.tabActive { + color: var(--bg-primary); + background: var(--accent-color); +} + +.general { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; +} + +.intro { + color: var(--text-primary); + max-width: 780px; +} + +.sectionTitle { + color: var(--text-primary); + font-weight: 700; +} + +.shortcuts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px 18px; +} + +.shortcut { + display: grid; + grid-template-columns: minmax(92px, 140px) minmax(0, 1fr); + gap: 8px; + align-items: start; + min-width: 0; + line-height: 1.35; +} + +.shortcutKey { + color: var(--accent-color); + min-width: 0; + overflow-wrap: anywhere; + white-space: normal; +} + +.shortcutDesc { + color: var(--text-primary); + min-width: 0; + overflow-wrap: anywhere; +} + +.commandList { + flex: 1; + min-height: 0; + overflow-y: auto; + padding-right: 4px; +} + +.commandListIntro { + color: var(--text-primary); + margin-bottom: 8px; +} + +.commandGroup { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 14px; +} + +.commandGroupTitle { + color: var(--text-primary); + font-weight: 700; +} + +.commandGroupTitle span { + color: var(--text-secondary); + font-weight: 400; + margin-left: 6px; +} + +.command { + display: flex; + flex-direction: column; + gap: 2px; + padding-left: 12px; +} + +.commandRow { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.commandName { + color: var(--accent-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 48%; +} + +.commandMeta { + color: var(--text-secondary); + font-size: 12px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.commandDescription { + color: var(--text-primary); + font-size: 12px; + padding-left: 12px; + overflow-wrap: anywhere; +} + +.commandSubcommands { + color: var(--text-secondary); + font-size: 12px; + padding-left: 12px; + overflow-wrap: anywhere; +} + +.empty { + color: var(--text-dimmed); + padding: 16px 0; +} + +@media (max-width: 720px) { + .shortcuts { + grid-template-columns: minmax(0, 1fr); + } + + .shortcut { + grid-template-columns: minmax(82px, 110px) minmax(0, 1fr); + } + + .commandRow { + flex-direction: column; + gap: 1px; + } + + .commandName { + max-width: 100%; + } +} diff --git a/packages/web-shell/client/components/dialogs/HelpDialog.tsx b/packages/web-shell/client/components/dialogs/HelpDialog.tsx new file mode 100644 index 0000000000..84cda3b255 --- /dev/null +++ b/packages/web-shell/client/components/dialogs/HelpDialog.tsx @@ -0,0 +1,336 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { dp } from './dialogStyles'; +import type { CommandInfo } from '../../adapters/types'; +import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown'; +import { useI18n } from '../../i18n'; +import styles from './HelpDialog.module.css'; + +type HelpTab = 'general' | 'commands' | 'custom-commands'; + +interface HelpDialogProps { + commands: readonly CommandInfo[]; + onClose: () => void; +} + +interface CommandGroup { + key: string; + title: string; + commands: CommandInfo[]; +} + +const TABS: Array<{ id: HelpTab; labelKey: string }> = [ + { id: 'general', labelKey: 'help.tab.general' }, + { id: 'commands', labelKey: 'help.tab.commands' }, + { id: 'custom-commands', labelKey: 'help.tab.custom' }, +]; + +const DOCS_URL = 'https://qwenlm.github.io/qwen-code-docs/'; +const BUILT_IN_COMMANDS = new Set([ + 'about', + 'agents', + 'approval-mode', + 'arena', + 'auth', + 'branch', + 'btw', + 'bug', + 'clear', + 'compress', + 'context', + 'copy', + 'release', + 'diff', + 'directory', + 'docs', + 'doctor', + 'dream', + 'editor', + 'export', + 'extensions', + 'forget', + 'goal', + 'help', + 'hooks', + 'ide', + 'init', + 'insight', + 'language', + 'lsp', + 'mcp', + 'memory', + 'model', + 'new', + 'permissions', + 'plan', + 'quit', + 'recap', + 'remember', + 'rename', + 'reset', + 'restore', + 'resume', + 'rewind', + 'settings', + 'setup-github', + 'skills', + 'stats', + 'status', + 'statusline', + 'summary', + 'tasks', + 'terminal-setup', + 'theme', + 'tools', + 'trust', + 'vim', +]); + +const GENERAL_SHORTCUTS: Array<[string, string]> = [ + ['@', 'help.shortcut.addContext'], + ['!', 'help.shortcut.shell'], + ['/', 'help.shortcut.commandMenu'], + ['Tab', 'help.shortcut.completion'], + ['Esc', 'help.shortcut.cancel'], + ['Ctrl+J', 'help.shortcut.newline'], + ['Ctrl+L', 'help.shortcut.clear'], + ['Ctrl+Y', 'help.shortcut.retry'], + ['Ctrl+O', 'help.shortcut.compact'], + ['Shift+Tab', 'help.shortcut.approvals'], + ['Alt+Left/Right', 'help.shortcut.altWords'], + ['Up/Down', 'help.shortcut.history'], +]; + +function commandSignature(command: CommandInfo): string { + return [`/${command.name}`, command.argumentHint].filter(Boolean).join(' '); +} + +function commandMeta( + command: CommandInfo, + t: ReturnType['t'], +): string { + const parts = [ + BUILT_IN_COMMANDS.has(command.name) + ? t('help.commandMeta.builtIn') + : t('help.commandMeta.custom'), + command.subcommands?.length + ? t('help.commandMeta.subcommands', { + count: command.subcommands.length, + }) + : undefined, + ]; + return parts.filter(Boolean).join(' · '); +} + +function groupCommands( + commands: readonly CommandInfo[], + customOnly: boolean, + t: ReturnType['t'], +): CommandGroup[] { + const visible = commands + .filter((command) => command.name && command.description !== undefined) + .sort((a, b) => a.name.localeCompare(b.name)); + const builtIn = visible.filter((command) => + BUILT_IN_COMMANDS.has(command.name), + ); + const custom = visible.filter( + (command) => !BUILT_IN_COMMANDS.has(command.name), + ); + + if (customOnly) { + return custom.length + ? [ + { + key: 'custom', + title: t('help.customGroup'), + commands: custom, + }, + ] + : []; + } + + return builtIn.length + ? [{ key: 'built-in', title: t('help.builtIn'), commands: builtIn }] + : []; +} + +function HelpTabs({ activeTab }: { activeTab: HelpTab }) { + const { t } = useI18n(); + return ( +
+ Qwen Code + {TABS.map((tab) => ( + + {t(tab.labelKey)} + + ))} +
+ ); +} + +function GeneralHelp() { + const { t } = useI18n(); + return ( +
+
{t('help.intro')}
+
{t('help.section.shortcuts')}
+
+ {GENERAL_SHORTCUTS.map(([key, description]) => ( +
+ {key} + {t(description)} +
+ ))} +
+
+ ); +} + +function CommandsHelp({ + commands, + customOnly, +}: { + commands: readonly CommandInfo[]; + customOnly: boolean; +}) { + const { t } = useI18n(); + const groups = useMemo( + () => groupCommands(commands, customOnly, t), + [commands, customOnly, t], + ); + const listRef = useRef(null); + + useDelayedGlobalKeyDown((event: KeyboardEvent) => { + const el = listRef.current; + if (!el) return; + if (event.key === 'ArrowDown' || event.key === 'j') { + event.preventDefault(); + el.scrollBy({ top: 32, behavior: 'smooth' }); + } else if (event.key === 'ArrowUp' || event.key === 'k') { + event.preventDefault(); + el.scrollBy({ top: -32, behavior: 'smooth' }); + } else if (event.key === 'PageDown') { + event.preventDefault(); + el.scrollBy({ top: el.clientHeight, behavior: 'smooth' }); + } else if (event.key === 'PageUp') { + event.preventDefault(); + el.scrollBy({ top: -el.clientHeight, behavior: 'smooth' }); + } else if (event.key === 'Home') { + event.preventDefault(); + el.scrollTo({ top: 0, behavior: 'smooth' }); + } else if (event.key === 'End') { + event.preventDefault(); + el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); + } + }, []); + + useEffect(() => { + listRef.current?.scrollTo({ top: 0 }); + }, [customOnly, commands]); + + if (groups.length === 0) { + return ( +
+ {customOnly ? t('help.emptyCustom') : t('help.empty')} +
+ ); + } + + return ( +
+
+ {customOnly ? t('help.customIntro') : t('help.commandsIntro')} +
+ {groups.map((group) => ( +
+
+ {group.title} + {group.commands.length} +
+ {group.commands.map((command) => ( +
+
+ + {commandSignature(command)} + + + {commandMeta(command, t)} + +
+ {command.description && ( +
+ {command.description} +
+ )} + {!!command.subcommands?.length && ( +
+ {t('help.subcommands')}: {command.subcommands.join(', ')} +
+ )} +
+ ))} +
+ ))} +
+ ); +} + +export function HelpDialog({ commands, onClose }: HelpDialogProps) { + const { t } = useI18n(); + const [activeTab, setActiveTab] = useState('general'); + + useDelayedGlobalKeyDown( + (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + return; + } + if (event.key === 'Tab') { + event.preventDefault(); + const current = TABS.findIndex((tab) => tab.id === activeTab); + const direction = event.shiftKey ? -1 : 1; + const next = (current + direction + TABS.length) % TABS.length; + setActiveTab(TABS[next].id); + } + }, + [activeTab, onClose], + ); + + return ( +
+
+ {t('help.title')} + + {t('help.commandCount', { count: commands.length })} + + +
+
+
+ + {activeTab === 'general' && } + {activeTab === 'commands' && ( + + )} + {activeTab === 'custom-commands' && ( + + )} +
+
+
+ {t('help.footer')} · {DOCS_URL} +
+
+ ); +} diff --git a/packages/web-shell/client/components/dialogs/McpDialog.tsx b/packages/web-shell/client/components/dialogs/McpDialog.tsx new file mode 100644 index 0000000000..53375a08c1 --- /dev/null +++ b/packages/web-shell/client/components/dialogs/McpDialog.tsx @@ -0,0 +1,625 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { dp } from './dialogStyles'; +import { + useMcp, + type DaemonWorkspaceMcpServerStatus, + type DaemonWorkspaceMcpToolStatus, + type DaemonWorkspaceMcpToolsStatus, +} from '@qwen-code/webui/daemon-react-sdk'; +import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown'; +import { useI18n } from '../../i18n'; + +interface McpDialogProps { + onClose: () => void; +} + +type McpView = 'servers' | 'server' | 'tools' | 'tool'; + +interface ServerAction { + label: string; + hint?: string; + disabled?: boolean; + run: () => void; +} + +function getServerStatus(server: DaemonWorkspaceMcpServerStatus): string { + if (server.disabled) { + return server.disabledReason + ? `disabled:${server.disabledReason}` + : 'disabled'; + } + return server.mcpStatus || 'unknown'; +} + +function statusText( + status: string, + t: ReturnType['t'], +): string { + if (status.startsWith('disabled:')) { + return `✗ ${t('mcp.status.disabled')}:${status.slice('disabled:'.length)}`; + } + switch (status) { + case 'connected': + return `✓ ${t('mcp.status.connected')}`; + case 'connecting': + return `… ${t('mcp.status.connecting')}`; + case 'disconnected': + return `✗ ${t('mcp.status.disconnected')}`; + case 'disabled': + return `✗ ${t('mcp.status.disabled')}`; + default: + return status || t('mcp.status.unknown'); + } +} + +function statusClass(status: string) { + if (status === 'connected') return 'mcp-status-connected'; + if (status === 'connecting') return 'mcp-status-connecting'; + if (status === 'disconnected') return 'mcp-status-disconnected'; + if (status === 'disabled' || status.startsWith('disabled:')) { + return 'mcp-status-disabled'; + } + return 'mcp-status-unknown'; +} + +function StatusLabel({ + status, + t, +}: { + status: string; + t: ReturnType['t']; +}) { + return ( + + {statusText(status, t)} + + ); +} + +function sourceLabel( + server: DaemonWorkspaceMcpServerStatus, + t: ReturnType['t'], +): string { + return server.extensionName + ? t('mcp.extension', { name: server.extensionName }) + : t('mcp.settings'); +} + +function schemaSummary( + schema: Record | undefined, + t: ReturnType['t'], +): string[] { + if (!schema) return [t('mcp.noSchema')]; + const lines: string[] = []; + const properties = + schema.properties && + typeof schema.properties === 'object' && + !Array.isArray(schema.properties) + ? (schema.properties as Record) + : undefined; + const required = Array.isArray(schema.required) + ? new Set( + schema.required.filter( + (item): item is string => typeof item === 'string', + ), + ) + : new Set(); + + if (!properties || Object.keys(properties).length === 0) { + return [JSON.stringify(schema, null, 2)]; + } + + for (const [name, value] of Object.entries(properties)) { + const shape = + value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; + const type = typeof shape.type === 'string' ? shape.type : 'value'; + const desc = + typeof shape.description === 'string' && shape.description.length > 0 + ? ` - ${shape.description}` + : ''; + lines.push(`${required.has(name) ? '*' : ' '} ${name}: ${type}${desc}`); + } + return lines; +} + +function toolAnnotationText( + tool: DaemonWorkspaceMcpToolStatus, + t: ReturnType['t'], +): string { + const annotations = tool.annotations ?? {}; + const hints: string[] = []; + if (annotations.destructiveHint) hints.push(t('mcp.annotation.destructive')); + if (annotations.readOnlyHint) hints.push(t('mcp.annotation.readOnly')); + if (annotations.openWorldHint) hints.push(t('mcp.annotation.openWorld')); + if (annotations.idempotentHint) hints.push(t('mcp.annotation.idempotent')); + return hints.join(', '); +} + +export function McpDialog({ onClose }: McpDialogProps) { + const { t } = useI18n(); + const { status, loading, error, reload, loadTools, restartServer } = useMcp({ + autoLoad: true, + }); + const [toolsByServer, setToolsByServer] = useState< + Record + >({}); + const [view, setView] = useState('servers'); + const [selectedIdx, setSelectedIdx] = useState(0); + const [actionIdx, setActionIdx] = useState(0); + const [toolIdx, setToolIdx] = useState(0); + const [loadingTools, setLoadingTools] = useState(null); + const [busyServer, setBusyServer] = useState(null); + const [message, setMessage] = useState(null); + const listRef = useRef(null); + + const servers: DaemonWorkspaceMcpServerStatus[] = status?.servers ?? []; + const selected = servers[selectedIdx]; + const selectedTools = selected + ? (toolsByServer[selected.name]?.tools ?? []) + : []; + const selectedTool = selectedTools[toolIdx]; + + const loadServerTools = useCallback( + (serverName: string) => { + setLoadingTools(serverName); + loadTools(serverName) + .then((next) => { + setToolsByServer((cur) => ({ ...cur, [serverName]: next })); + setMessage(next.errors?.[0]?.error ?? null); + }) + .catch((err: unknown) => { + setMessage(err instanceof Error ? err.message : String(err)); + }) + .finally(() => setLoadingTools(null)); + }, + [loadTools], + ); + + useEffect(() => { + if (error) setMessage(error.message); + else if (status?.errors?.[0]?.error) setMessage(status.errors[0].error); + else if (status) setMessage(null); + }, [status, error]); + + useEffect(() => { + if (selectedIdx >= servers.length && servers.length > 0) { + setSelectedIdx(servers.length - 1); + } + }, [selectedIdx, servers.length]); + + useEffect(() => { + const activeIndex = + view === 'server' ? actionIdx : view === 'tools' ? toolIdx : selectedIdx; + const el = listRef.current?.children[activeIndex] as + | HTMLElement + | undefined; + el?.scrollIntoView({ block: 'nearest' }); + }, [actionIdx, selectedIdx, toolIdx, view]); + + const budgetText = useMemo(() => { + if (!status) return ''; + if (status.clientBudget === undefined) + return t('common.clients', { count: status.clientCount ?? 0 }); + return t('mcp.clientBudget', { + count: status.clientCount ?? 0, + budget: status.clientBudget, + mode: status.budgetMode ?? 'off', + }); + }, [status, t]); + + const footerText = useMemo(() => { + if (view === 'servers') { + return servers.length === 0 + ? t('dialog.footer.close') + : t('dialog.footer.mcpServers'); + } + if (view === 'tool') return t('dialog.footer.back'); + return t('dialog.footer.mcpSelect'); + }, [servers.length, t, view]); + + const handleRestart = useCallback( + (serverName: string) => { + setBusyServer(serverName); + setMessage(null); + restartServer(serverName) + .then((result) => { + if (result.restarted) { + setMessage( + t('mcp.restarted', { + name: result.serverName, + duration: result.durationMs, + }), + ); + } else { + setMessage( + t('mcp.restartSkipped', { + name: result.serverName, + reason: result.reason ?? '', + }), + ); + } + reload(); + loadServerTools(serverName); + }) + .catch((error: unknown) => { + setMessage(error instanceof Error ? error.message : String(error)); + }) + .finally(() => setBusyServer(null)); + }, + [loadServerTools, reload, restartServer, t], + ); + + const openServer = useCallback( + (server: DaemonWorkspaceMcpServerStatus) => { + setView('server'); + setActionIdx(0); + if (!toolsByServer[server.name]) { + loadServerTools(server.name); + } + }, + [loadServerTools, toolsByServer], + ); + + const actions: ServerAction[] = useMemo(() => { + if (!selected) return []; + const toolStatus = toolsByServer[selected.name]; + const toolCount = toolStatus?.tools.length ?? 0; + const nextActions: ServerAction[] = []; + if (!selected.disabled && (toolStatus || loadingTools === selected.name)) { + nextActions.push({ + label: t('mcp.action.tools'), + hint: + loadingTools === selected.name + ? t('common.loading') + : `${toolCount} ${t('mcp.tools')}`, + disabled: loadingTools === selected.name || toolCount === 0, + run: () => { + setView('tools'); + setToolIdx(0); + if (!toolStatus) loadServerTools(selected.name); + }, + }); + } else if (!selected.disabled) { + nextActions.push({ + label: t('mcp.action.tools'), + hint: t('mcp.action.toolsHint'), + run: () => { + setView('tools'); + setToolIdx(0); + loadServerTools(selected.name); + }, + }); + } + nextActions.push({ + label: selected.disabled + ? t('tools.update.enable') + : t('tools.update.disable'), + hint: t('mcp.action.authHint'), + disabled: true, + run: () => setMessage(t('mcp.action.enableMessage')), + }); + if (!selected.disabled) { + nextActions.push({ + label: t('mcp.action.auth'), + hint: t('mcp.action.authHint'), + disabled: true, + run: () => setMessage(t('mcp.action.authMessage')), + }); + } + if (!selected.disabled && getServerStatus(selected) === 'disconnected') { + nextActions.push({ + label: t('mcp.action.reconnect'), + disabled: busyServer === selected.name, + run: () => handleRestart(selected.name), + }); + } + return nextActions; + }, [ + busyServer, + handleRestart, + loadServerTools, + loadingTools, + selected, + toolsByServer, + t, + ]); + + useEffect(() => { + if (actionIdx >= actions.length && actions.length > 0) { + setActionIdx(actions.length - 1); + } + }, [actionIdx, actions.length]); + + useEffect(() => { + if (toolIdx >= selectedTools.length && selectedTools.length > 0) { + setToolIdx(selectedTools.length - 1); + } + }, [selectedTools.length, toolIdx]); + + useDelayedGlobalKeyDown( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + if (view === 'tool') setView('tools'); + else if (view === 'tools') setView('server'); + else if (view === 'server') setView('servers'); + else onClose(); + return; + } + if (e.key === 'ArrowDown' || e.key === 'j') { + e.preventDefault(); + if (view === 'server') { + setActionIdx((i) => Math.min(i + 1, Math.max(actions.length - 1, 0))); + } else if (view === 'tools') { + setToolIdx((i) => + Math.min(i + 1, Math.max(selectedTools.length - 1, 0)), + ); + } else if (view === 'servers') { + setSelectedIdx((i) => + Math.min(i + 1, Math.max(servers.length - 1, 0)), + ); + } + return; + } + if (e.key === 'ArrowUp' || e.key === 'k') { + e.preventDefault(); + if (view === 'server') setActionIdx((i) => Math.max(i - 1, 0)); + else if (view === 'tools') setToolIdx((i) => Math.max(i - 1, 0)); + else if (view === 'servers') setSelectedIdx((i) => Math.max(i - 1, 0)); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + if (view === 'servers' && selected) { + openServer(selected); + } else if (view === 'server') { + actions[actionIdx]?.run(); + } else if (view === 'tools' && selectedTool) { + setView('tool'); + } + return; + } + if (e.key === 'r') { + e.preventDefault(); + if (view === 'servers') reload(); + else if (selected && view === 'server' && busyServer !== selected.name) + handleRestart(selected.name); + } + }, + [ + actionIdx, + actions, + handleRestart, + onClose, + openServer, + reload, + selected, + selectedTool, + selectedTools.length, + servers.length, + view, + ], + ); + + return ( +
+
+ + {view === 'servers' + ? t('local.mcp') + : view === 'tools' + ? t('mcp.toolsForServer', { + name: selected?.name ?? 'MCP', + }) + : view === 'tool' + ? selectedTool?.name + : selected?.name} + + {view !== 'servers' && ( + {budgetText} + )} + +
+ +
+ + {message || + (loading + ? t('mcp.loadingStatus') + : view === 'servers' + ? t('mcp.servers', { count: servers.length }) + : loadingTools === selected?.name + ? t('mcp.loadingTools') + : t('common.enterSelect'))} + +
+ +
+ + {view === 'servers' && ( +
+ {!loading && servers.length === 0 && ( +
{t('mcp.empty')}
+ )} + {servers.map((server, i) => ( +
openServer(server)} + onMouseEnter={() => setSelectedIdx(i)} + > +
+ + {i === selectedIdx ? '›' : ' '} + + + {server.name} + + + + +
+
+ {server.transport} + {server.extensionName ? ` · ${server.extensionName}` : ''} +
+
+ ))} +
+ )} + + {view === 'server' && selected && ( +
+
+
+ {t('mcp.status')}:{' '} + +
+
+ {t('mcp.source')}: {sourceLabel(selected, t)} +
+
+ {t('mcp.transport')}: {selected.transport} +
+
+ {t('mcp.tools')}:{' '} + {loadingTools === selected.name + ? t('common.loading') + : `${toolsByServer[selected.name]?.tools.length ?? 0} ${t('mcp.tools')}`} +
+
+ {actions.map((action, i) => ( +
{ + if (!action.disabled) action.run(); + }} + onMouseEnter={() => setActionIdx(i)} + > + + {i === actionIdx ? '›' : ' '} + + + {action.label} + + {action.hint && ( + + {action.hint} + + )} +
+ ))} +
+ )} + + {view === 'tools' && selected && ( +
+ {!loadingTools && selectedTools.length === 0 && ( +
+ {t('mcp.emptyTools')} +
+ )} + {selectedTools.map((tool, i) => { + const annotations = toolAnnotationText(tool, t); + return ( +
setView('tool')} + onMouseEnter={() => setToolIdx(i)} + > +
+ + {i === toolIdx ? '›' : ' '} + + + {tool.name} + + {!tool.isValid ? ( + + {t('common.invalid')} + + ) : annotations ? ( + + {annotations} + + ) : null} +
+ {!tool.isValid && ( +
+ {tool.invalidReason || t('common.invalid')} +
+ )} +
+ ); + })} +
+ )} + + {view === 'tool' && selectedTool && } + +
+ +
{footerText}
+
+ ); +} + +function ToolDetail({ tool }: { tool: DaemonWorkspaceMcpToolStatus }) { + const { t } = useI18n(); + return ( +
+
+
+ {t('mcp.name')}: {tool.name} +
+ {tool.serverToolName && ( +
+ {t('mcp.serverTool')}: {tool.serverToolName} +
+ )} + {!tool.isValid && ( +
+ {t('mcp.status')}: {tool.invalidReason || t('common.invalid')} +
+ )} + {tool.description && ( +
+ {t('mcp.description')}: {tool.description} +
+ )} +
+
+
{t('mcp.inputSchema')}
+ {schemaSummary(tool.schema, t).map((line, i) => ( +
{line}
+ ))} +
+ {tool.annotations && ( +
+
{t('mcp.annotations')}
+
{JSON.stringify(tool.annotations, null, 2)}
+
+ )} +
+ ); +} diff --git a/packages/web-shell/client/components/dialogs/MemoryDialog.module.css b/packages/web-shell/client/components/dialogs/MemoryDialog.module.css new file mode 100644 index 0000000000..1d3e6929ad --- /dev/null +++ b/packages/web-shell/client/components/dialogs/MemoryDialog.module.css @@ -0,0 +1,25 @@ +.editorForm { + min-height: 180px; +} + +.editorTextarea { + min-height: 160px; +} + +.filePreview { + gap: 8px; +} + +.fileContent { + margin: 0; + min-height: 220px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + color: var(--text-primary); + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 12px; + font: inherit; +} diff --git a/packages/web-shell/client/components/dialogs/MemoryDialog.tsx b/packages/web-shell/client/components/dialogs/MemoryDialog.tsx new file mode 100644 index 0000000000..bd742f3ad2 --- /dev/null +++ b/packages/web-shell/client/components/dialogs/MemoryDialog.tsx @@ -0,0 +1,555 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, +} from 'react'; +import { dp } from './dialogStyles'; +import { + useMemory, + type DaemonContextFileScope, + type DaemonWorkspaceMemoryFile, +} from '@qwen-code/webui/daemon-react-sdk'; +import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown'; +import { useI18n } from '../../i18n'; +import styles from './MemoryDialog.module.css'; + +export type MemoryDialogInitialMode = + | 'menu' + | 'show' + | 'refresh' + | 'add' + | 'add-user' + | 'add-project'; + +interface MemoryDialogProps { + initialMode?: MemoryDialogInitialMode; + onMessage?: (message: string, type?: 'status' | 'error') => void; + onClose: () => void; +} + +type MemoryView = 'menu' | 'show' | 'detail' | 'scope' | 'edit'; + +interface MenuItem { + label: string; + description: string; + onSelect?: () => void; +} + +interface ScopeItem { + label: string; + description: string; + scope: DaemonContextFileScope; +} + +function initialView(mode: MemoryDialogInitialMode): MemoryView { + if (mode === 'show' || mode === 'refresh') return 'show'; + if (mode === 'add-user' || mode === 'add-project') return 'edit'; + if (mode === 'add') return 'scope'; + return 'menu'; +} + +function initialScope(mode: MemoryDialogInitialMode): DaemonContextFileScope { + if (mode === 'add-user') return 'global'; + return 'workspace'; +} + +function scopeLabel( + scope: DaemonContextFileScope, + t: ReturnType['t'], +): string { + return scope === 'global' ? t('memory.global') : t('memory.project'); +} + +export function MemoryDialog({ + initialMode = 'menu', + onMessage, + onClose, +}: MemoryDialogProps) { + const { t } = useI18n(); + const { + status: memoryStatus, + loading: memoryLoading, + error: memoryError, + reload: reloadMemory, + readFile, + writeMemory, + } = useMemory({ autoLoad: true }); + const scopes: ScopeItem[] = useMemo( + () => [ + { + label: t('memory.global'), + description: t('memory.global.desc'), + scope: 'global', + }, + { + label: t('memory.project'), + description: t('memory.project.desc'), + scope: 'workspace', + }, + ], + [t], + ); + const [view, setView] = useState(() => initialView(initialMode)); + const [selectedIdx, setSelectedIdx] = useState(0); + const [fileIdx, setFileIdx] = useState(0); + const [selectedFile, setSelectedFile] = + useState(null); + const [fileContent, setFileContent] = useState(''); + const [fileLoading, setFileLoading] = useState(false); + const [scopeIdx, setScopeIdx] = useState( + initialScope(initialMode) === 'global' ? 0 : 1, + ); + const [scope, setScope] = useState(() => + initialScope(initialMode), + ); + const [content, setContent] = useState(''); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(null); + const listRef = useRef(null); + const textareaRef = useRef(null); + const directEditMode = + initialMode === 'add-user' || initialMode === 'add-project'; + const directMode = initialMode !== 'menu'; + + const status = memoryStatus; + const loading = memoryLoading; + const files: DaemonWorkspaceMemoryFile[] = status?.files ?? []; + + const reload = useCallback( + async (successMessage?: string) => { + await reloadMemory(); + if (successMessage) setMessage(successMessage); + }, + [reloadMemory], + ); + + useEffect(() => { + if (memoryError) setMessage(memoryError.message); + else if (initialMode === 'refresh' && memoryStatus) + setMessage(t('memory.refreshed')); + }, [memoryError, memoryStatus, initialMode, t]); + + useEffect(() => { + if (view === 'edit') { + setTimeout(() => textareaRef.current?.focus(), 0); + } + }, [view]); + + const openScopePicker = useCallback(() => { + setView('scope'); + setScopeIdx(scope === 'global' ? 0 : 1); + setMessage(null); + }, [scope]); + + const openShow = useCallback(() => { + setView('show'); + setMessage(null); + }, []); + + const refreshAndShow = useCallback(() => { + setView('show'); + reload(t('memory.refreshed')); + }, [reload, t]); + + const openFile = useCallback( + (file: DaemonWorkspaceMemoryFile) => { + setSelectedFile(file); + setFileContent(''); + setMessage(null); + setView('detail'); + if (file.scope === 'global') { + setFileLoading(false); + setFileContent(t('memory.globalReadUnsupported')); + setMessage(t('memory.globalReadUnsupported')); + return; + } + setFileLoading(true); + readFile(file.path) + .then((result) => { + setFileContent(result.content); + setMessage( + result.truncated ? t('memory.fileTruncated') : t('memory.fileOpen'), + ); + }) + .catch((error: unknown) => { + const errorMessage = + error instanceof Error ? error.message : String(error); + setFileContent(errorMessage); + setMessage(errorMessage); + }) + .finally(() => setFileLoading(false)); + }, + [readFile, t], + ); + + const menuItems = useMemo( + () => [ + { + label: t('memory.add'), + description: t('memory.add.desc'), + onSelect: openScopePicker, + }, + { + label: t('memory.show'), + description: t('memory.show.desc'), + onSelect: openShow, + }, + { + label: t('memory.refresh'), + description: t('memory.refresh.desc'), + onSelect: refreshAndShow, + }, + ], + [openScopePicker, openShow, refreshAndShow, t], + ); + + useEffect(() => { + const activeIndex = + view === 'scope' ? scopeIdx : view === 'show' ? fileIdx : selectedIdx; + const el = listRef.current?.children[activeIndex] as + | HTMLElement + | undefined; + el?.scrollIntoView({ block: 'nearest' }); + }, [fileIdx, scopeIdx, selectedIdx, view]); + + useDelayedGlobalKeyDown( + (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + const editingText = + target?.tagName === 'TEXTAREA' || target?.tagName === 'INPUT'; + if (e.key === 'Escape') { + e.preventDefault(); + if (view === 'detail') { + setView('show'); + setMessage(null); + } else if ( + view === 'menu' || + directMode || + (view === 'edit' && directEditMode) + ) { + onClose(); + } else { + setView('menu'); + setMessage(null); + } + return; + } + if (editingText) return; + if (e.key === 'ArrowDown' || e.key === 'j') { + e.preventDefault(); + if (view === 'menu') { + setSelectedIdx((idx) => + Math.min(idx + 1, Math.max(menuItems.length - 1, 0)), + ); + } else if (view === 'scope') { + setScopeIdx((idx) => + Math.min(idx + 1, Math.max(scopes.length - 1, 0)), + ); + } else if (view === 'show') { + setFileIdx((idx) => Math.min(idx + 1, Math.max(files.length - 1, 0))); + } + return; + } + if (e.key === 'ArrowUp' || e.key === 'k') { + e.preventDefault(); + if (view === 'menu') setSelectedIdx((idx) => Math.max(idx - 1, 0)); + else if (view === 'scope') setScopeIdx((idx) => Math.max(idx - 1, 0)); + else if (view === 'show') setFileIdx((idx) => Math.max(idx - 1, 0)); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + if (view === 'menu') { + menuItems[selectedIdx]?.onSelect?.(); + } else if (view === 'scope') { + const nextScope = scopes[scopeIdx]?.scope ?? 'workspace'; + setScope(nextScope); + setView('edit'); + setMessage(null); + } else if (view === 'show') { + const file = files[fileIdx]; + if (file) openFile(file); + } + } + }, + [ + directEditMode, + directMode, + fileIdx, + menuItems, + onClose, + files, + openFile, + scopeIdx, + scopes, + selectedIdx, + view, + ], + ); + + const handleSubmit = useCallback(() => { + const text = content.trim(); + if (!text) { + setMessage(t('memory.contentEmpty')); + return; + } + setSaving(true); + setMessage(null); + writeMemory({ scope, mode: 'append', content: text }) + .then((result) => { + const savedMessage = t('memory.saved', { + scope: scopeLabel(scope, t), + bytes: result.bytesWritten, + path: result.filePath, + }); + setContent(''); + onMessage?.(savedMessage); + if (directEditMode) { + onClose(); + return; + } + setMessage(savedMessage); + setView('show'); + reload(savedMessage); + }) + .catch((error: unknown) => { + const errorMessage = + error instanceof Error ? error.message : String(error); + setMessage(errorMessage); + if (directEditMode) { + onMessage?.(errorMessage, 'error'); + } + }) + .finally(() => setSaving(false)); + }, [ + content, + directEditMode, + onClose, + onMessage, + reload, + scope, + t, + writeMemory, + ]); + + const handleEditKeyDown = useCallback( + (e: ReactKeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + if (!saving) handleSubmit(); + } + }, + [handleSubmit, saving], + ); + + const title = + view === 'show' + ? t('memory.files') + : view === 'detail' + ? selectedFile + ? scopeLabel(selectedFile.scope, t) + : t('memory.file') + : view === 'scope' + ? t('memory.add') + : view === 'edit' + ? scopeLabel(scope, t) + : t('memory.menu'); + + return ( +
+
+ {title} + + {status + ? `${status.fileCount} files · ${status.totalBytes} bytes` + : ''} + + +
+ +
+ + {message || + (loading + ? t('memory.loading') + : view === 'menu' + ? t('agent.selectAction') + : view === 'scope' + ? t('memory.chooseScope') + : view === 'detail' + ? fileLoading + ? t('memory.loadingFile') + : t('memory.fileOpen') + : view === 'edit' + ? t('memory.write') + : t('memory.status'))} + +
+ +
+ + {view === 'menu' && ( +
+ {menuItems.map((item, index) => ( +
item.onSelect?.()} + onMouseEnter={() => setSelectedIdx(index)} + > +
+ + {index === selectedIdx ? '›' : ' '} + + + {item.label} + +
+
+ {item.description} +
+
+ ))} +
+ )} + + {view === 'scope' && ( +
+ {scopes.map((item, index) => ( +
{ + setScope(item.scope); + setView('edit'); + }} + onMouseEnter={() => setScopeIdx(index)} + > +
+ + {index === scopeIdx ? '›' : ' '} + + + {item.label} + +
+
+ {item.description} +
+
+ ))} +
+ )} + + {view === 'show' && ( +
+ {!loading && files.length === 0 && ( +
+ {t('memory.noFiles')} +
+ )} + {files.map((file, index) => ( +
openFile(file)} + onMouseEnter={() => setFileIdx(index)} + > +
+ + {index === fileIdx ? '›' : ' '} + + + {scopeLabel(file.scope, t)} + + + {file.bytes} bytes + +
+
{file.path}
+
+ ))} +
+ )} + + {view === 'detail' && ( +
+
+
+ + + {selectedFile?.path ?? t('memory.file')} + + {selectedFile && ( + + {selectedFile.bytes} bytes + + )} +
+
+
+            {fileLoading ? t('memory.loadingFile') : fileContent}
+          
+
+ )} + + {view === 'edit' && ( +
+