refactor(agents): split web shell from execution

This commit is contained in:
yiliang114 2026-09-27 21:12:30 +08:00
parent d2181bb41c
commit 8366c8d907
47 changed files with 175 additions and 10134 deletions

View file

@ -192,7 +192,7 @@ jobs:
continue-on-error: true
env:
WEB_SHELL_VISUALS_OUTPUT_DIR: '${{ runner.temp }}/web-shell-before'
run: 'npm run test:e2e:visuals --workspace=packages/web-shell -- screenshots.spec.ts workflow-page.spec.ts git-branch-picker.spec.ts session-workflow.spec.ts collaboration.spec.ts'
run: 'npm run test:e2e:visuals --workspace=packages/web-shell -- screenshots.spec.ts workflow-page.spec.ts git-branch-picker.spec.ts session-workflow.spec.ts'
- name: 'Compose before/after and keep only changed views'
env:

File diff suppressed because it is too large Load diff

View file

@ -1,501 +0,0 @@
# Workspace agents implementation — step-by-step acceptance criteria
> Companion to [`2026-09-06-multi-agent-board-collaboration.md`](./2026-09-06-multi-agent-board-collaboration.md) §5.2 (ten steps, as numbered on the `codex/multi-agent-mesh-foundation` branch) and [`2026-09-07-workspace-agents-review-round2-handoff.md`](./2026-09-07-workspace-agents-review-round2-handoff.md).
> Delivery shape: **one implementation and delivery PR** (#11206). Runtime changes #11200 / #11202 / #11204 are merged into its branch. Because that branch was their PR base, GitHub records them as merged draft references; their review history remains available and none is merged separately to `main`.
> Nothing in this file was executed by its author. "Evidence" means what the implementer reports, with observed values, in the PR description or a `docs/verification/workspace agents/` package.
## 0. Corrections to the round-2 hand-off
- **C2 was wrong as stated.** `AgentHeadless.executeTurn` emits `EXTERNAL_MESSAGE` for a continuation `task_prompt` (`agent-headless.ts:279-288` at `703678136a`), and the transcript writer records that event. The reviewer read `:290-300` and missed the branch above it. The implementer reproduced cold-revive history with both continuation prompts present. What remains true: bare `task_prompt` has no delivery id and gets the parent-agent prefix, so workspace agents turns use structured input (#11202). The hand-off on this branch already carries the correction.
- **C1 is confirmed and fixed** (`[1, 1]` → `[1, 2]`, #11200).
- **Observation 1** (idle residents do not hold slots) settles the round-2 "verify" item on the concurrency cap.
## 1. How to read the steps
Each step lists: what lands, the acceptance gate, and the evidence to report. The owner's later demo-first instruction overrides the old local-build/CI-wait and child-PR workflow: changes go directly to #11206, with scoped source checks and actual daemon/browser observations. No unrun CI or test gate is represented as passing. Steps 1-6 need no model; 7 is the first live gate; 8-10 need the daemon or a browser.
### Current ACP-session evidence boundary (2026-09-08)
The numbered steps below retain the historical background-agent implementation
and its observations. They are not proof of the replacement session adapter.
Current run ids, timestamps and limitations are recorded in
[`2026-09-08-workspace-agents-vs-multica-gap-and-plan.md`](./2026-09-08-workspace-agents-vs-multica-gap-and-plan.md).
| Requirement | Current evidence / remaining work |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Persistent identities and separate sessions | Existing leader/worker reused across tasks; leader continuation preserves its session id. A fresh no-definition Agent reproduced its durable marker and independent workspace-Agent role from the corrected live system instruction. A later task appeared as `identity-proof · Session title acceptance` in the ordinary session list. Sessions share one ACP process. |
| Concurrent same-thread handoff and human acceptance | Clean three-run peer handoff; 8,999 ms overlapping run lifetimes; both results submitted and Chrome Mark done succeeded. |
| Child delegation and parent report | Clean first-attempt ACP run: one assigned child, worker review, parent report, same-session leader continuation, and root review. |
| Live human input | Same-run mid-turn transcript and consumed window verified; final review contains the correction. Late-drain/crash cases remain open. |
| Initial input receipt | A real assigned run persisted its initial prompt, consumed the matching trigger and reached review. The transcript-before-receipt crash window remains to be force-killed. |
| Cancellation | Working → stopping → cancelled observed; usage retained; unresponsive-child case remains open. |
| Running task after daemon restart | SIGKILL after a durable checkpoint exposed a startup-discovery defect; after its fix, the same run/session recovered as attempt 2 and reached human acceptance. Other crash windows remain open. |
| Human resolves a blocker | Real thread_block question, human selection, same-session continuation, JSON review, and Chrome Mark done verified. Other blocker-acknowledgement scopes remain an owner decision. |
| Automatic-turn gate | Isolated actual-source storage run: 12 running coalesces, posts 13/14 rejected, one pending turn-gate notification; human reply reset to 0, next agent delivery charged 1. Not a model experiment. |
| Read-only boundary and ambient ownership | Guard wiring and direct source checks exist; full model-driven negative matrix has not been demonstrated. |
| Storage and reliability gates in steps 3/7/8 | Historical tests/observations remain below; no blanket revalidation claim for the current ACP path, nor a completed failure-injection matrix. |
| External notifications | Consumer exists, but no recipient is configured for current acceptance; no external send authorized or observed. |
The 2026-09-08 delegation run after the Agent Builder reuse change exposed one
routing false positive: the leader's summary containing the scoped package
`@qwen-code/qwen-code` produced `agent_unknown(qwen-code)`. Mention parsing now
ignores `@token` immediately followed by `/`; agent addresses followed by normal
punctuation are unchanged. This was found by the real model path, not by a test
exercise.
The same run exposed an acceptance loop: the parent had already consumed the
child's `in_review` report and submitted its own review, but marking the child
done emitted another parent report and spent a third leader run. Child-done
reports are now suppressed when the parent is already `in_review` or `done`;
the parent panel reads the child's final state directly. Open, in-progress and
blocked parents still receive the durable report. A direct daemon-route check
with an `in_review` parent observed the child become `done` with zero
`child_done` outbox events and the parent retain zero posts/runs; the temporary
records were deleted afterward.
Gate check (2026-09-08): before the fix, the same 12-turn experiment persisted
the skip outcomes but produced zero notifications while both runs stayed live.
Gate admission now writes its notification in the same transaction, without
requiring an aggregate status change. Replaying the rejected post's
`originEventId` left the entire stored record unchanged. A separate 10-token
usage / 10-token cap check rejected two admissions and retained one pending
token-gate notification. With no destination, the consumer made zero sender
calls, delivered zero events, and retained both gate reasons as pending.
These observations come from direct source assertions in temporary storage;
no model calls, build, lint, or test suite were run. The existing turn-gate test
now also asserts the pending notification and repeat suppression, but was not
executed through Vitest. Design §5.2 step 10 no longer describes gate events as
depending on state transitions: a rejected admission need not change status.
This ledger is not an overall completion claim. The explicit failure cases below
remain requirements; the demo-first workflow changes how work is sequenced, not
whether missing evidence can be called a pass.
Every step also updates the design doc: any sentence the implementation contradicts is changed in the same commit, with the reason. The doc is the contract; the code does not silently redefine it.
## 2. Steps
### Step 1 — Admission foundation (landed on this branch)
Gate: the three workspace agents test files pass on CI; `THREAD_STATUSES` includes `blocked`; a missing or invalid root fails closed; `countQueuedElsewhere` counts `queued` only; a human post on a child does not touch the root's `autoTurnsUsed`; unknown `@name` yields `agent_unknown` and does not wake the assignee; a post with no target yields `no_target`; `coalesce(running)` from an agent charges one turn.
Evidence: the test names covering each clause. Currently 38 tests; keep the count in §5.3 current.
### Step 2 — Read-only capability boundary
Lands: a built-in workspace file-reading allowlist intersected with the agent definition's tool list; shell, MCP, `save_memory`, `write_file`, `edit`, and every persistent or host-wide tool are denied; `thread_*` tools are added on top of the definition, never taken from it.
Gate: a table-driven test enumerates every registered tool name in core and asserts it is either allowed, denied, or `thread_*`; adding a new tool to core without classifying it fails the test. The tool configuration and invocation guard both deny shell by name.
Evidence: the classification table, committed as data, not prose.
Supporting local observation before the shell boundary correction: `capability.test.ts`, 10 tests passed. The step
is not complete until #11206 CI passes after the child PR merges.
### Step 3 — Versioned storage protocol
Lands: `schemaVersion` on every file; fail-closed on unknown version; migration under the workspace lock with the old file retained until the new one validates; the workspace mutation lock; lock-issued `queueSequence` and message `sequence`; `runs[].usageByRound` on the run record; outbox for parent reports and notifications only; deletion refusal for non-terminal runs, descendants, and unacknowledged outbox events.
Gate: (a) writing a file with `schemaVersion + 1` makes every read throw, never return empty state; (b) a crash injected between "write source" and "acknowledge" replays exactly once, proven by an apply that writes the target and throws before reconciliation is re-run; (c) two `child_process` workers allocating concurrently receive unique `queueSequence` values and each observes a strictly increasing series, not two promises in one process; (d) token gate at admission equals the sum of `usageByRound` across the tree, tested with a tree of depth 3 where the root file carries a deliberately stale `tokensUsed`.
Evidence: the four tests named, plus the migration test with a hand-written v1 fixture.
Supporting local observation: `store.test.ts`, `workspace-lock.test.ts`,
`thread-actions.test.ts`, `dispatch-policy.test.ts`, and `mentions.test.ts` pass
59 tests. This is not the step gate until the child merges and the resulting
#11206 whole-branch CI is green.
### Step 4 — Hidden host session, keepalive, launcher
Lands: one hidden `Config` + registry per workspace; keepalive registration reusing `scheduled-task-keepalive.ts`; `launchWorkspaceAgent(agent)` that builds the persona through `convertToRuntimeConfig` and starts a background agent; typed launch results `started | capacity_wait | agent_unavailable | launch_failed`.
Gate: (a) with `QWEN_CODE_MAX_BACKGROUND_AGENTS=1`, launching a second agent returns `capacity_wait` and books nothing; (b) an agent whose `agentType` names no definition returns `agent_unavailable` and no runtime is created; (c) the host session does not appear in the session list API; (d) after the real bridge reaper closes the host session, keepalive reloads it and the next launch succeeds without recreating the bridge; (e) `continueResidentAgent` returns `continued` for a completed resident and `capacity_wait` never triggers a cold revive (#11204's tests, now on this branch).
Evidence: report the reaper timeout and observed reload latency from an in-process `AcpSessionBridge` with a fake ACP child. Step 4 deliberately has no server-bootstrap caller before the dispatcher exists, so step 7 repeats this observation through the daemon dispatcher instead of adding unused wiring here.
Supporting local observations on the stacked step branch: `capability.test.ts`
and `launcher.test.ts` pass 16 tests; `background-agent-resume.test.ts` passes
52 tests including cold-revive capability restoration; `background-tasks.test.ts`
passes 150 tests including typed resident continuation; `agent-host-session.test.ts`
and `scheduled-task-keepalive.test.ts` pass 34 tests; `bridge.test.ts` passes
914 tests; and `acpAgent.test.ts` passes 629 tests. A targeted `acp-bridge`
package build also succeeds. The real `AcpSessionBridge` reaper was configured
to 20 ms in-process with a fake ACP child; it closed the host, a second channel
resumed the same session, and the next launch returned `started` after a
measured 4.3 ms reload (1,000 ms resume deadline), without recreating the
bridge. The child merge and #11206's whole-branch CI remain the step gate; the
daemon-process observation is part of step 7 for the reason above.
### Step 5 — Run envelope, tools, runtime correlation
Lands: `runWithAgentRunContext` at the per-turn seam; `thread_post`, `thread_wait`, `thread_block`, `thread_review`, `thread_create`, `thread_read`; the prompt assembler; structured external input with `deliveryId` (#11202, folded); `consumedMessageIds` recorded from the correlated event; `usageByRound` upserts from `USAGE_METADATA` with cumulative rounds (#11200, folded).
Gate: (a) a mutating tool invoked with a model-supplied `threadId` argument is rejected by schema, and one invoked outside a run context is rejected at execution; (b) `thread_create` under thread A from a body whose previous turn was on thread B creates the child under A, tested by running two turns on one `AgentHeadless` instance; (c) `thread_wait` without a live dependency returns a typed rejection; (d) the assembled prompt for a second wake contains title, body, status, the last N posts, and the delta after `committedThroughSequence`, and a retention gap renders the GAP line; (e) a `USAGE_METADATA` sequence across a `finishingInputs` continuation records rounds `[1, 2]` on one run.
Evidence: the assembled prompt text for cases first-entry / delta / gap / retry, committed as snapshot fixtures.
**5a landed (ambient binding and prompt envelope).** Gate (d) is met: `prompt.test.ts` covers first entry, delta after a watermark without dropping the recent window, a labelled gap with its size, a retry that does not hide the gap, a first entry into an already-trimmed thread, peer tokens for enabled peers excluding self, per-post elision, and a bounded recent window. `run-context.test.ts` covers absence outside a turn, the bound triple, two interleaved turns keeping their own threads across `await`, identical re-entry, and refusal to nest a different run. `resolveTargets`'s defaulted third parameter is now required, so a caller that omits it can no longer reinstate the unknown-mention fallback. Observed locally: `prompt.test.ts`, `run-context.test.ts`, `dispatch-policy.test.ts` → 3 files, 33 tests passed; `thread-actions.test.ts`, `store.test.ts`, `capability.test.ts` → 3 files, 44 tests passed; targeted ESLint clean. Gates (a), (b), (c) and (e) belong to 5b and remain unexecuted.
**Aggregate status landed.** `thread-status.ts` derives the status from every run's close obligation rather than letting the last run to finish stamp it, and the three round-2 findings are each pinned by a test: a same-thread wait is discharged by a later close (I1), any later successful booking discharges an earlier failure or unclosed return (I2), and a quiescent thread whose last admission booked nothing becomes `blocked` (I6). Also covered: a live run outranks another agent's review, a blocker outranks a review, a wait is `in_progress` only while a child can wake it, `done` is sticky against a late post, and a failed run reports as a failure even when it recorded a close kind. Observed locally: `thread-status.test.ts` → 1 file, 13 tests passed; targeted ESLint clean. The producers that write `closeKind` are the thread tools in 5b, so nothing calls this resolver yet.
**Run close and status application landed.** `run-lifecycle.ts` splits closing into two writes: the tool records `closeKind` and moves the run to `finishing`, ending the agent's turn, and the runtime callback records the terminal state — the only place the aggregate status is recomputed. A `waiting` close is refused when nothing could wake it, and a live _descendant_ counts while a mere sibling under the same root does not. Any close discharges peers' waits on the same thread. A clean exit with no closing tool is recorded as `unclosed`, never as implicit success. `finishRun` now delegates to this one path, and `postMessage` discharges outstanding obligations when it books work and then applies the aggregate status, so the I2 and I6 fixes have producers rather than only a resolver. Observed locally: `run-lifecycle.test.ts`, `thread-actions.test.ts`, `thread-status.test.ts`, `store.test.ts` → 4 files, 57 tests passed; targeted ESLint clean. The six thread tools that call `closeRun` are still to come, so gates (a), (b), (c) and (e) remain unexecuted.
**Thread tools landed.** `tools/thread-tools.ts` adds `thread_post`, `thread_wait`, `thread_block`, `thread_review`, `thread_create` and `thread_read`. Gate (a) is met twice over: a table-driven test asserts every mutating schema is `additionalProperties: false` and carries no thread, author, run or idempotency id — `thread_read`'s single `thread_id` is the read-only exception — and a call outside a run frame is refused at execution. Gate (b) is met: two frames on different threads each create their sub-thread under their own ambient thread; the test asserts the parent ids rather than the call order. Gate (c) is met: a wait with nothing to wait for is refused with the text that tells the model what to do instead. Every mutating tool verifies workspace, root, run, agent and attempt inside the same transaction as its write, so cancellation or revival cannot slip between authorization and mutation. Assignment goes through admission in the same transaction as the child's creation, system-authored but carrying the causing run.
Observed locally across this subsystem module and the tools: 10 files, 118 tests passed; targeted ESLint clean.
Gate (e) is only half done: #11200 pins the cumulative round, but nothing calls `upsertRunUsage` from a live `USAGE_METADATA` stream until the dispatcher exists. Still unexecuted for step 5: wiring `runWithAgentRunContext` at the real turn seam, `acceptedMessageIds`/`consumedMessageIds` from the correlated `EXTERNAL_MESSAGE`, and registering these tools in a workspace agent's registry.
### Step 6 — Minimal in-process dispatcher, no recovery
Lands: pick the lowest `queueSequence` queued run per agent; branch on registry state `completed+resident → continue`, `completed → revive`, `paused → resume`, `unbound → launch`; atomically claim before starting the runtime, bind the returned session on success, release the claim on `capacity_wait`, and consume the parent-report outbox.
Gate: with a fake `AgentHeadless` that answers scripted text, (a) an assignment trigger on an open thread results in exactly one `startRun` and one `finishRun`; (b) two threads queued for one agent start in `queueSequence` order regardless of file enumeration order (test by creating the later thread with a lexically smaller id); (c) a child `thread_review` produces exactly one parent post carrying the event id, and re-running the consumer produces zero more.
Evidence: the three tests. No live model.
**Step 6 landed (core half).** `dispatcher.ts` selects each idle agent's oldest queued run by the lock-issued `queueSequence` — never `queuedAt`, never file order — and atomically claims it before the fire-and-forget runtime can execute. A second dispatcher therefore cannot launch the same run, and the ambient attempt is already durable before a first tool call. The prompt is assembled from the claimed record; a successful start binds the session and delivery watermark, while `capacity_wait` releases the claim and restores the unspent attempt. A failed or unavailable start is terminal and releases the queue slot. Parent reports drain through the outbox with the event id as the idempotency key; a second pass posts nothing more, and a notification no consumer claims stays pending rather than being acknowledged into silence.
Gate (a): one `startRun`/`finishRun` per assignment — covered. Gate (b): two threads queued for one agent start in `queueSequence` order regardless of file order — covered by `selectCandidates`. Gate (c): a child review produces exactly one parent post and a replay produces none — covered.
Observed locally: 11 files, 127 tests passed; targeted ESLint clean.
Writing the production port corrected the design's four-branch idle path to three. Whether a completed body still has a resident runtime is not a choice the dispatcher can make — only the registry knows, and #11204 already reports its own fallback — so a dispatcher choosing between "continue resident" and "cold revive" would be guessing at state it cannot see and would cold-revive a live body. The three entry points it does choose between are `launch`, `resume` (a restart-recovered `paused` entry, which the revive path rejects) and `continue_completed`.
`dispatch-port.ts` binds those to the runtime and is the single place a non-local runtime would be substituted (§9.12). Its tests pin the registry-state mapping, the hot path not touching the transcript, capacity reported before any mutation, a state change under it not being forced, and a thrown runtime error becoming a typed failure rather than a start.
**Runtime wiring landed for the demo path.** Every launch, resident continuation, resume, and revive persists the next run binding, and both real background-turn seams establish it inside the turn body. Workspace agents see the six thread tools while ordinary subagents do not. Every agent turn carries the run id in structured external input; runtime acceptance records the accepted ids, the correlated consumed event advances consumed ids and the watermark, usage events upsert cumulative rounds, and body completion terminalizes the agent run. A recovered delivery's initial input is cleared before a later resident turn. This correction was deliberately not expanded with new test code or a local CI/build pass; step 7's live model run is the next evidence gate.
### Step 7 — Live vertical slice (first integration gate)
Lands: normally nothing; the first run may carry only defects that directly
block the slice. This run corrected the resident-continuation sidecar lookup to
use the same storage root as the background-agent launcher.
Gate: the §8 demo steps 1-5 complete against two real agents on a build-capable machine, plus: a forced `queueExternalInput` miss (kill the agent between its last tool round and finish) is rebooked and delivered on the next run; a synthetic ping-pong between two _running_ agents on one thread stops at 12 with `turn_budget_exhausted` in the thread and one channel-less notification record.
Evidence: the thread JSON files after the run, the two agents' transcript slices, the observed wall-clock between the child's `thread_review` and the parent's wake, and the host reaper timeout plus daemon-observed reload latency. If any prompt in §6 had to change to make the model close its run explicitly, the changed prompt and the failure it fixed.
**Happy-path observation (2026-09-07).** On a normal non-bare host, Alice was
the only target of the root post, created and assigned Bob's child, and closed
`waiting`. Bob posted three concrete checks and closed `review`. The parent
report applied after 13 ms; the same resident Alice body continued, summarized
the child, and closed the root `review`. Final status was `in_review` on both
threads; Alice's two runs were `completed/waiting` and `completed/review`, and
Bob's was `completed/review`. All six thread tools were in the real model tool
surface. No §6 prompt change was needed. The first continuation attempt failed
because `dispatch-port.ts` derived the sidecar path from the checkout rather
than `Config.storage.getProjectDir()`; the correction above made the next live
run pass. A first demo input also mentioned Bob directly and therefore woke him
according to the real routing rule; addressing only Alice fixed the driver, not
the product.
This proves the demo steps 1-5 only. The forced `queueExternalInput` miss,
12-turn ping-pong, daemon reaper replacement, and bare-mode exposure remain
unrun, so the full step-7 gate is not yet claimed.
**ACP-session delegation observation (2026-09-08).** Root
`th_8c68f839-637d-4472-9f8d-8544d13646f3` assigned the existing
`demo-leader`, which created child
`th_9cec20a2-0bd8-41fc-94e7-3de1a6fa495a`, assigned the existing
`demo-worker`, and closed its first run with `thread_wait`. The worker read the
repository root `package.json`, posted `@qwen-code/qwen-code` and `>=22.0.0`,
and closed with `thread_review`. Its run ended at `1788859290728`; the durable
parent report was posted at `1788859290779` (51 ms), and the leader's next run
started at `1788859291693` (965 ms after the child ended). Both leader runs used
session `63465788-a16b-5328-92fc-8020330969a9`; the worker used
`b48bad11-6e7c-5110-92a1-e560bf56eec6`. The leader posted both values and
closed the parent with `thread_review`. Chrome first refused parent acceptance
with `descendants_not_done`; after the person accepted the child, both records
were marked `done`. No new agent was created. The run also exposed the scoped
package mention and redundant child-done wake defects recorded above.
**Clean ACP-session close and delegation observation (2026-09-10).** A
one-action run first exposed that `thread_review` made its run durably stale but
the ACP session only honored `ToolResult.terminateTurn` for Goal turns. The
model consequently entered later rounds and its repeated mutations were
rejected by the ambient run guard. The session turn loop now honors the tool
contract for every non-channel turn. Session
`a68bd0d4-e2ad-5420-8503-ac407e4ecd8d` then ended after exactly one model round
and one successful `thread_review`, with no later transcript entry.
Two live leader attempts also omitted the optional `assignee` argument while
trying to delegate, creating an inert child before correcting themselves.
Agent-side `thread_create` now requires an enabled peer; human task creation
still permits no assignee. A clean retry created exactly one child,
`th_2c237856-918b-4a4a-a50e-d9e1abc8a06b`, assigned it to `demo-worker`, and
closed the first leader run with `waiting`. The worker reported
`@qwen-code/qwen-code` and `>=22.0.0` and closed with `review`. The parent report
was persisted 52 ms after the worker run ended, and the leader continuation
started 834 ms after that end. Both leader runs reused session
`58fe22b4-3da5-504e-9ca6-3ce51567621e`; the worker used
`9bdfdf83-c3ab-5e22-8c3a-de5047e936cc`. Root
`th_bc67cbb7-fc68-4257-b27c-678e99f218b7` ended `in_review`. The live source
daemon and Chrome supplied this evidence; no test suite, build, lint,
typecheck, or CI was run.
The same daemon then exercised human unblock on root
`th_8a7640ba-58fb-4106-87d0-f7c7e73ad5f5`. The first run asked exactly
`Which root file should I inspect?`, closed `blocked`, and stopped after its
single `thread_block` call. A human reply of `package.json` booked one successor
run, which read the file and closed `review`. Both runs used session
`c8dcd281-8403-5499-9c52-2ede456b94cf`; the reply id appears in the successor's
trigger and consumed sets, and no duplicate run was created.
**Direct steering and concurrency observation (2026-09-07).** In thread
`th_6c12d77c-7d8a-4cd5-a535-2030a4c06d45`, a human post made while Alice's run
was `running` coalesced into that same run. Its delivery id appeared in both
`acceptedMessageIds` and `consumedMessageIds`, and Alice's final review included
the newly requested `/etc` result. In thread
`th_7aee128a-78f3-44be-80a9-ebb676937de6`, Alice and Bob entered `running` 138
ms apart, posted separately attributed reviews, completed independently, and
the aggregate thread reached `in_review`. This proves the accepted direct-input
path and concurrent agents on one shared thread; it does not prove the forced
enqueue-miss recovery path.
**Deliberate ping-pong observation (2026-09-07).** Thread
`th_ea937a51-1e41-406c-b328-878fcab0b06e` launched Alice and Bob concurrently
and recorded three unattended deliveries. The second Alice post coalesced into
Bob's running attempt; its id appears in both `acceptedMessageIds` and
`consumedMessageIds`. Bob then closed without emitting another reply. The three
runs accounted 428,636 tokens, so the 200,000-token gate pre-empts a 12-turn
live loop with this model footprint. The run therefore does not satisfy the
12-turn gate. Reaching that gate without changing product semantics requires a
minimal model frame or a scenario-only token-limit override; the ordinary
runtime correctly keeps the settled token ceiling in force.
### Step 8 — Dispatcher reliability
Lands: `delivery_race` detach/rebook; `launch_failed` with `failureStage`; done/cancel (`cancelling` state, runtime abort); restart recovery (`running` → reconcile → resume once → terminal on second failure); stale host-session binding replacement after a definitive resume failure; stall sweeper; full outbox replay on startup.
Gate: failure injection at each named point, as separate tests: enqueue returns false; process exit after `acceptedMessageIds` write; process exit after transcript record but before `consumedMessageIds` write; process exit after parent apply but before acknowledge; daemon restart with one `running` and one `queued` run; a stored host session that cannot be resumed is replaced once; N-minute stall. Each test asserts the thread file's final state and that no message id is both unconsumed and unbooked.
Evidence: the injection matrix as a table in the PR, one row per test, with the asserted final state.
**Production path implemented; gate not run.** The dispatcher now sends a
structured, correlated input only to the exact ambient run binding; a rejected
or raced delivery detaches its unaccepted trigger ids into the queued
successor. Accepted-but-unconsumed input survives restart and is replayed once;
late callbacks are attempt-guarded. Stored running work is resumed once and a
second failure becomes terminal. The hidden host binding is replaced only
after the resume promise definitively rejects, never merely on its timeout.
An aggregate child-review report carries the review summary's source run id,
so the parent wake remains auditable across the system-authored hop.
Workspace agents reuses the existing three-minute workflow watchdog, including its
tool-in-flight suspension, and requeues the first stalled attempt. Per the
demo-first instruction, no local tests, lint, typecheck, build, or CI wait was
performed for this implementation. Route startup now reopens owners for durable
live work or pending outbox events. Cancellation is source-first: the run is
persisted as `cancelling`, the dispatcher verifies the ambient runtime binding,
then stops the body and records terminal `cancelled`; queued cancellation also
wakes the dispatcher so the next FIFO item is not stranded. A racing runtime
completion observes `cancelling` and also settles as `cancelled`.
Prompt replay now detects internal retention holes by counting missing message
sequences, rather than trusting the first retained message, because referenced
old posts can survive trimming. The frame no longer tells an agent to recover
physically deleted posts through `thread_read`; it marks the gap unrecoverable
and tells the agent to ask a person when the missing context is required.
Undelivered triggers are rebooked only from a running, finishing, or completed
attempt where delivery can genuinely have raced completion. Failed and
cancelled runs remain terminal, so a later dispatcher pass cannot undo an
explicit cancellation or retry a definition/start failure forever. The
rebooking transaction checks that status again rather than trusting its scan
snapshot, closing the cancellation-versus-delivery race.
Cancellation admission now reads and changes the run under one workspace lock.
A queued run cannot be claimed between the route's observation and its write,
and the response is based on the stored post-dispatch state rather than the
stale status that initiated the request.
The hidden host owner is also bound to the selected workspace runtime
generation. A replaced or drained generation stops its keepalive loop and is
rejected before later launch or dispatch; generation checks bracket host claims
and releases, and a raced stale spawn is cleaned up. Reusing the same bridge
object cannot keep the old owner alive. This path was source-inspected only
under the same demo-first constraint.
**Crash-recovery observation (2026-09-07).** A real daemon was killed with
`SIGKILL` while run `rn_4bd80535-7fe6-4024-aeac-70dbaf338fea` was `running`.
Restart recovery kept the run id, advanced it to attempt 2, consumed its durable
trigger, and completed it with `thread_review`. A second run,
`rn_04d3aa17-39d1-4ddc-958f-38c5f003c537`, was killed after message
`ms_d4fe89c5-ecaa-4a87-a7c2-6c6f451c2476` appeared in
`acceptedMessageIds` but before it appeared in `consumedMessageIds`. On restart,
the same run advanced to attempt 2, consumed that delivery, and its final review
contained both requested replay markers. This manually proves running-run
restart and accepted-but-unconsumed replay; it does not prove enqueue returning
false, transcript-written-before-consumed recovery, stale-host replacement, or
the watchdog. The long replay run also exposed the conservative accounting
policy visibly: it closed at `666,749 / 200,000` tokens because the limit is
checked at admission, not between tool rounds.
**Initial-receipt observation (2026-09-08).** Real ACP run
`rn_bcd7f190-5958-4dcd-9c92-ea207d4aad16` posted
`INITIAL-RECEIPT-8391` and closed with `thread_review`. Its durable trigger
`ms_c7dcb7be-dda0-496f-90f7-e3663dec5130` is the sole consumed id and the
committed watermark is sequence 1. Reloading session
`b48bad11-6e7c-5110-92a1-e560bf56eec6` from disk found the assigned prompt and
marker in its transcript. The session path now flushes that initial user record
before writing the receipt; dispatch no longer marks it consumed before
activation. This proves the normal path and source order, not the named
process-exit injection between those two writes. No build, lint, test suite, or
CI ran.
### Step 9 — REST and Web Shell
Design direction is settled ahead of the build in [`2026-09-07-workspace-agents-web-shell-design.md`](./2026-09-07-workspace-agents-web-shell-design.md): the thread view is a ledger of outstanding obligations with the conversation as evidence, not a chat log with a status badge. It inherits Web Shell's existing tokens and adds no new colour or typeface. Step 9 renders `resolveThreadStatus`'s `status` and `reason` rather than inventing a second status vocabulary, shows one lane per agent that has worked the thread, and bounds every transcript view to the run's own slice.
Lands: routes for agents, threads, posts, runs; roster, thread list, thread view with run slices, busy reason, gate/failure display, cancel; #11140's sidebar entry absorbed.
Gate: Playwright visuals for roster, thread view with two agents' posts attributed by name snapshot, a `blocked` thread with its question, and a run slice rendered from `transcriptStartOffset..EndOffset` showing only that run; deleting an agent keeps old posts readable with the tombstoned name.
Evidence: screenshots from the visuals config, in CI.
**Demo-path observation (2026-09-07).** A real `qwen serve` daemon and Web
Shell created two fresh persistent identities and an assigned root thread from
the Agents page. `alice-demo` created one child for `bob-demo`, closed
`waiting`, received the durable child report, resumed the same body, and closed
the root `review`. `bob-demo` closed the child `review`. The page updated from
the resolver reason while the runs were active and finished with the root in
`in_review`, Alice's two past runs collapsed behind their count, and Alice's
attributed result visible in the ledger. The tree accounted 186,317 of 200,000
tokens.
The first browser-driven attempt exposed an actual prompt failure: agents put
peer mentions in ordinary result prose, which booked unintended runs back and
forth and exhausted the tree budget before the parent could resume. The turn
envelope now states that an at-sign address books work, forbids it in status or
result prose unless another wake is intended, and states that child completion
already reports to the parent. A fresh run with that wording completed without
the extra bookings. A separate draft containing the literal word `@mentions`
was correctly rejected as an unknown agent name. The new-thread form now runs
the selected assignee through the same admission rule before creation, so a
full queue or disabled/unknown target is visible before the durable write.
**Latest-source smoke (2026-09-07).** From the Web Shell, the new-thread form
previewed `Will start @alice`, then durably created and assigned thread
`th_eca773de-4efa-4c82-bdeb-9bdafc521077`. After a daemon restart, the queued
run `rn_077865eb-b3da-4ab0-8521-ab8c96882818` was replayed, Alice inspected the
workspace, posted an attributed conclusion, and explicitly closed with
`thread_review`. The API and browser both showed the run as
`completed/review` and the thread as `in_review`. This was a manual demo-path
observation only: no test suite, lint, typecheck, or CI was run. The source-mode
daemon required the ACP bridge package output to be refreshed because this
worktree shared a `node_modules` link whose existing bridge build predated the
workspace agents dispatch method.
The same live daemon then covered the remaining visible step-9 paths. A fresh
run recorded transcript offsets `16990..22071`; opening its history row showed
only that byte range beside the still-visible thread. A second agent called
`thread_block` and the page rendered both `Which target file should I inspect?`
and the resolver reason that the run was waiting for a person. Cancelling a
running agent changed the row from `working` to `stopping`, reached terminal
`cancelled`, and the thread could then be marked done. Deleting `alice-demo`
removed it from the roster while its existing post remained attributed as
`alice-demo (removed)`. The first-class Agents sidebar entry from #11140 and
inline child-thread navigation were also exercised in the same browser.
Assigned creation now writes the new thread, assignment, admission outcome, and
first run in one replacement; a human post also invokes the dispatcher for a
running coalesce, not only for a newly queued run. These last production-path
changes were inspected from source only. No local unit tests, lint, typecheck,
build, or CI wait were run. Roster and header activity copy now follows the
actual active run state instead of calling a queued or cancelling run
"working", and an unacknowledged cancellation is shown as an outstanding
obligation. The dead `agent_unavailable` composer branch was removed: missing
definitions remain typed dispatcher launch failures because the pure admission
rule has no runtime definition loader. The roster now exposes the designed
enable/disable state. Disabling is one workspace-locked mutation that rejects
new admissions while already-booked work keeps draining; deletion still
refuses live or queued work. A booking cannot race either roster change.
Root and child creation revalidate the chosen assignee under that same lock, so
a concurrent disable or delete cannot leave a new thread pointing at a stale
identity.
Agent creation reuses the storage protocol's mention-name validator at the REST
boundary and reports case-insensitive duplicates as a conflict instead of a
generic server failure.
Deleting an idle identity now makes the hidden host forget its resident body
and removes that deterministic body's transcript and sidecar from every host
session directory under the selected workspace runtime; historical thread posts
keep their name snapshot. Removing the last identity also stops the owner,
releases its workspace claim, and closes the now-unused hidden host session.
Creating the first identity starts that host immediately; daemon startup only
restores it for a non-empty roster, so retained notification events cannot
resurrect a host after every identity was removed.
The thread header also supports atomic human reassignment: changing the default
assignee writes a structured assignment through admission without cancelling
work already booked for another agent; choosing no assignee only clears the
future fallback. Marking a thread done now scans its complete descendant tree,
refuses live children, cancels its own work, and writes the terminal state in
one workspace transaction, so a racing agent cannot create an open child after
the check. Repeating the command does not enqueue another `child_done` report.
The Web Shell also surfaces a background-processing failure after a durable
mutation as "saved, but background processing failed"; periodic refreshes no
longer erase that action error a second later. These paths were source-inspected
only.
The daemon's ordinary session catalog already returned agent sessions, but the
sidebar exposed only Tasks and Channels and therefore hid them behind the
default-source filter. A temporary source switch added an Agents tab that fed
`sourceType: agent` into the same `WorkspaceSection`; it added no second
conversation list or renderer. That tab was removed on 2026-09-14: an agent
session holds one run's transcript and is already reachable from the run row
inside its own conversation, so presenting runs beside conversations made the
sidebar read as if they were conversations. The switch is Tasks and Channels
again. While it existed, that tab listed the existing `demo-leader` and
`demo-worker` sessions, and opening `demo-leader` loaded session
`63465788-a16b-5328-92fc-8020330969a9` in the ordinary conversation page
with its full ten-turn transcript. A hard reload preserved that transcript.
No test suite, build, lint, typecheck, or CI ran.
The Agents navigation now opens runnable workspace Agents and their shared tasks
instead of leading with reusable subagent definition files. New Agent reuses the
existing manual/model-assisted builder but writes one roster identity directly;
Definitions and linking an existing definition remain secondary compatibility
paths.
The same entry now separates Agents and Tasks into two views. Browser HMR
confirmed the view switch, task-detail return preserving the Tasks view, and
the task-scoped transcript entry staying on the run rather than the Agent row.
The already-running daemon was not restarted, so the idle-without-session
status was source checked but not claimed as a browser observation. No test
suite, build, lint, typecheck or CI ran.
**Product-linking observation (2026-09-08).** The primary New Agent action now
starts with model-assisted or manual creation and no longer competes with a
second inline definition-linking form. Expanding `identity-proof` in the roster
showed both of its assigned tasks and opened either task in the shared ledger.
The root task list showed the seven root completions once and omitted the
delegated child from the top level; opening that child from its parent displayed
a parent breadcrumb. The worker's bullet list and inline code rendered through
the existing Markdown component. A fresh assigned task reached `in_review`, and
the existing Agents session tab displayed its ordinary conversation as
`identity-proof · Session title acceptance`; older sessions kept their prior
titles, and manual-title preservation is enforced by the adapter but was not
changed in this browser pass. No local test suite, build, lint, typecheck or CI
ran.
**Local host observation (2026-09-08).** After a daemon restart, the non-empty
roster restored the host without a task mutation and reused host session
`f210855f-45ab-4624-a858-bf11785e22d0`. No build, lint, typecheck, test suite
or CI was run.
Tool responses now report booking as queued work rather than claiming the peer
has already started, and `thread_block` reports the durable blocked state
without promising channel delivery while §9.12 remains open.
An unassigned `open` child no longer satisfies `thread_wait`: only a live run,
pending parent report, or non-open descendant state is a future wake path, so a
parent cannot silently sleep behind an inert child.
An accepted delivery replay that repeats `thread_create` with the same parent
and normalized title now reuses the existing child instead of duplicating the
delegated work.
**Step 10 landed (the consumer; the destination is a product choice).** `run-lifecycle.ts` had been writing `notification` outbox events at four points since step 5, and nothing read them: `deliverParentReports` filters to `parent_report`, so every blocker, review, gate and failure notification sat pending forever — which also meant a thread that ever raised one could never be deleted, because deletion refuses pending events.
`deliverNotifications` is the missing consumer. It drains only `notification` events, through the same persist-attempt → apply → acknowledge protocol, and sends through an injected sender so core never reaches the channel worker. `routes/workspace-agents.ts` supplies that sender from the daemon's `deliverChannelMessage` and flushes after every mutation that dispatches, because the worker lives in the daemon while the dispatch loop runs in the host session.
**No default destination.** `AgentWorkspaceState.notifyTarget` is absent until someone sets it with `setAgentNotifyTarget`, and while it is absent the events stay pending rather than being acknowledged into silence — the same rule every unconsumed event kind follows. Guessing a channel would send a person's work somewhere nobody chose. Who sets it, and whether it belongs per workspace or per agent, is the product decision this leaves open.
Observed: four new cases in `dispatcher.test.ts` — pending while unconfigured, sent exactly once with the event id as the delivery id, a failed send left pending with its attempt counted and retried, and parent reports untouched by the notification pass. Suite counts before and after this change are identical at 16 pre-existing failures; passing tests went 113 → 117.
### Step 10 — Channel notifications
Lands: four events to the channel workers.
Gate: with one configured channel, each event produces exactly one message, and a replayed outbox after a simulated crash produces at most one duplicate and never zero.
Evidence: the channel transcript.
## 3. Product decisions the implementer must not make
Open in §9 of the design: envelope role transport (§9.9), parent-to-child replies (§9.10), human blocker acknowledgement scope (§9.11), channel notification destination (§9.12), token reservation vs accounting (§9.5), and persona drift policy (§9.4). Until each is decided the implementation takes the conservative reading: user-role envelope, ambient-thread-only mutation, acknowledgement of every open blocker on a human post that books, notification events retained without broadcasting, accounting limit with overshoot, and definition read at revive only.
The owner settled three step-3 inputs: v1 denies every MCP tool; the v1 schema declares the full §3 shape in one migration; and runtime is a first-class concept, represented by generic `runtimeId` beside the local `backgroundAgentId`. Step 4 keeps the launcher surface minimal and supplies the local implementation first.
The relationship to the Agent Board (#9402) also remains the owner's call. §7.1's conservative v1 default keeps separate stores and distinct names and imports nothing from `board-*.ts` in step 3. The settled first-class runtime shape makes a later foreign-runtime adapter possible without deciding whether #9402 becomes its seed. MCP names fail closed unless a future policy can prove an individual tool preserves the read-only ceiling.
## 4. Current single-PR workflow
- #11206 is the only delivery PR. Append work directly to `codex/multi-agent-mesh-foundation`; do not create more child PRs or issues for these steps.
- Do not run local CI/build/lint or make remote CI waiting the critical path. Record the actual source checks and live observations performed, with missing verification named explicitly.
- Runtime preparation was merged in the order #11200 → #11204 → #11202. The expected final conflict keeps both contracts: structured external input and typed continuation outcomes. GitHub automatically records those draft PRs as merged because their base is this branch; no PR was merged separately to `main` or manually closed.
- Merge `main` into the agents branch when it falls behind; never rebase (repo policy, and the force-push bot).
- Keep the design doc and this file current in the same commit as the code that changes them.
## 5. Watch list — what the implementer keeps in view at every step
Ordered by how much damage a miss does. Each item names the step where it is proven.
1. **The close contract (§6) is the first thing a live model can break.** A run must end with `thread_wait`, `thread_block`, or `thread_review`; a plain final answer is `unclosed`. Nothing before step 7 proves a model will do this. Run step 7 as early as the plan allows, and record every prompt change together with the failure it fixed.
2. **Step 3 is where later bugs get blamed.** Sequence counter written before the thread file; outbox persisted before apply and acknowledged after; migration keeps the `.v0.json` backup until the migrated file reads back through the validator. Each has a crash-injection test in step 3's gate; do not weaken them to make the step land sooner.
3. **Ambient binding lives inside `runBody`, and mutating tools re-check it.** The per-turn `runWithAgentRunContext` frame is the only hard boundary against wrong-thread actions; the prompt frame is advisory. Every mutating tool reads the ambient triple and then verifies the run is still `running` on that thread before writing (step 5).
4. **No silent path.** Every admission result is persisted on the message and rendered; a quiescent thread with nothing runnable becomes `blocked`, never idle `in_progress`. Round 2 found more defects of this class than any other.
5. **Runtime hot paths need narrow changes, not more PRs.** Shared runtime changes stay minimal and go directly to #11206 under the owner's current workflow; check their ordinary-session consumers as well as agent callers.
6. **Trust labels are not boundaries.** Until §9.9 is decided, no prompt heading is called "trusted" and no code treats one as a policy input. Provenance is derived from the ambient run, never from model or HTTP input.
7. **Product decisions stay open until decided.** §9.4, §9.5, §9.9, §9.10, §9.11 and the #9402 relationship; the conservative defaults in §3 above apply meanwhile. Runtime shape, schema batching, and v1 MCP denial are settled in §3.
8. **Keep acceptance evidence honest.** Follow the current demo-first workflow in §4. Never turn a source check into a claimed live observation, or historical background-runtime evidence into an ACP-session pass.

View file

@ -1,152 +0,0 @@
# Workspace agents design review, round 2 — hand-off to the implementing agent
> Reviewed: [`2026-09-06-multi-agent-board-collaboration.md`](./2026-09-06-multi-agent-board-collaboration.md) at `af7fed7021e94b83df3aa013fd4dae3b2a0357e1` (PR #11072)
> Runtime facts checked against `origin/main` @ `703678136a`; Multica against `multica-ai/multica@7a438bd5b`
> Original review method: source reading only. The implementation update and §4 observations explicitly identify the later checks that were executed. Every `file:line` below was read at the commits named above; line numbers drift, symbols do not.
> Audience: the agent that implements §5.2. Read §0 of the design first, then this file.
## 0. Verdict
**Keep the root model; change the contract.** Long-lived body + shared thread + admission/dispatcher split + at-least-once delivery survives source inspection. Round 2 identified three contract problems (C1–C3), not a reason to change the execution model; the implementation update below supersedes C1 and corrects C2's premise.
State at the reviewed commit: the seven files under `packages/core/src/agents/workspace-agents/` at `af7fed70` are byte-identical to the round-1 revision. The §5.1 "verified but uncommitted" patch was not on that branch, and the four named store defects were still present. The implementation update below records the later correction.
### Implementation update (2026-09-07)
- The missing §5.1 patch is committed on #11206; its three named files pass 37 tests, plus targeted lint and core typecheck.
- C1 reproduced as usage rounds `[1, 1]` across two execute segments and is fixed to `[1, 2]` by the runtime preparation merged from draft #11200.
- C2 contained a false premise: current `AgentHeadless.executeTurn` already emits `EXTERNAL_MESSAGE` for resident `task_prompt` continuations, and cold revival seeds `initialUserPrompt`. What was actually missing was durable correlation. The runtime preparation merged from draft #11202 adds `deliveryId` to structured external input, the consumed event, resident structured continuation, and the transcript record.
- I5's boolean ambiguity is fixed by the runtime preparation merged from draft #11204. The design now inserts a minimal dispatcher before the live slice and gives `paused` its actual `resumeBackgroundAgent` path.
- I1, I2, I4, I6, I7, I8, and I10 are corrected in the authoritative design. Their aggregate/storage/dispatcher producers do not exist yet, so no dead optional fields were added to the foundation.
- C3, I3, and S5 remain explicit product decisions. No implementation choice was made for them.
## 1. Runtime facts the implementation must not re-derive
These are the load-bearing seams. Each was read directly.
| Fact | Where | Consequence for this subsystem |
| ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `resumeBackgroundAgent` returns an already-running entry without consuming the continuation message | `background-agent-resume.ts:783-791`; the `queueMessage` at `:609` fires only when a resume is already in flight | Mid-run delivery is `registry.queueExternalInput`; idle-resident is `continueResidentAgent`; cold is revive/resume. Same split as `tools/send-message.ts:295-341` |
| `queueExternalInput` returns `false` when not `running` or after `beginFinishing` | `background-tasks.ts:1515-1522` | `delivery_race` in the design is the right shape |
| Final drain and `beginFinishing` are adjacent synchronous calls | `agent.ts:3576-3586`, `background-agent-resume.ts:1294-1300` | The finishing window is closed at the runtime; no extra guard needed |
| `EXTERNAL_MESSAGE` is emitted when input is appended to the next request, **before** the model call | `agent-core.ts:1249-1262`, `:1269-1274`, `:1296` | "consumed" means _durably in history_, not _answered_. Write the definition that way |
| The transcript writer records external messages synchronously with `fs.writeSync` | `agent-transcript.ts:711`, `:838-840` | A crash after drain does not lose the message: cold revive replays it |
| `AgentExternalMessageEvent` carries only `kind` and `text` | `agent-events.ts:195-202`, `agent-core.ts:1405-1412` | A `deliveryId` must be added to the structured `AgentExternalInput` and threaded through `emitExternalInputEvents` |
| String inputs get the `[Message from parent agent]:` prefix; structured inputs do not | `agent-core.ts:1382` | Use the structured form |
| Resident continuation re-enters `runBackgroundTurn` → `runWithAgentContext` on every turn | `background-agent-resume.ts:1389-1401`, `:1418-1487`; launch path `agent.ts:3738-3760` | A nested `runWithAgentRunContext` per turn is valid. `finishingInputs` continuation runs inside the same closure, same run |
| `continueResidentAgent` requires `status === 'completed'`; `continue()` returns `false` for five different reasons | `background-tasks.ts` (`continueResidentAgent`), `background-agent-resume.ts:1419-1450` | The dispatcher cannot tell `capacity_wait` from "fall back to revive" from a boolean. Add a typed result |
| Restart-recovered running agents are registered as `paused`, not `completed` | `background-agent-resume.ts:561` | Sweeper "revive once" must call `resumeBackgroundAgent` for `paused`; `reviveCompletedBackgroundAgent` rejects them |
| The concurrency cap **throws** from `register` | `background-tasks.ts:559`, `:579` | The launcher must catch to produce `capacity_wait`. Whether idle resident agents hold a "claimed" slot was **not** verified — read `getClaimedBackgroundSlotCount` |
| The chat is created once and reused; system instruction is fixed at `createChat` | `agent-headless.ts:296-299`, `agent-core.ts:588-595` | There is no per-turn system-role seam. Every continuation enters as user role via `task_prompt` |
| Resident continuation prompts are written through `EXTERNAL_MESSAGE`; cold revival also seeds `initialUserPrompt` | `agent-headless.ts:279-288`; `background-agent-resume.ts` (`writerInitialPrompt`, `buildAgentTranscriptAttach`) | The original C2 transcript-absence claim was wrong. Workspace agents still needs structured input with `deliveryId` for correlation |
| `USAGE_METADATA.round` is `turnCounter`, which restarts at 0 per `runReasoningLoop`; `roundOffset` only feeds `executionStats.rounds` | `agent-core.ts:920`, `:1198`, `:2695` | See C1 |
| Usage is recorded once per round from the last chunk, after `ROUND_TEXT` (which carries `usageMetadata` into the transcript) and before tool execution | `agent-core.ts:1099`, `:1195-1210` | Transcript-first, event-second: the design's "reconstruct from transcript" direction holds |
| Retry inside a stream resets `lastUsage`; compaction's own call emits no usage | `agent-core.ts:1050`, `:1058-1067` | Token gate under-counts. Call it an accounting limit |
| `runReasoningLoop` is invoked a second time inside one run when the final drain finds pending input (`executeExternalInputs(..., {resetStats:false})`) | `background-agent-resume.ts:1255-1261`; `agent-headless.ts:246-255` | Any per-run key that uses `round` collides across the two segments |
| The system prompt is assembled with QWEN.md and auto-memory | `agent-core.ts:2646-2650` (`assembleSystemPrompt` with `getUserMemory`, `getAutoMemoryPrompt`) | See I8 and open question 8 |
| `save_memory` exists as a tool | `tool-names.ts:29` | Must be excluded by the read-only boundary |
| Multica's prior session is per `(agent, issue)` | `handler/daemon.go` `GetLastTaskSession{AgentID, IssueID}`; `daemon/types.go:108` | Already corrected in the design |
| Multica allows a run to comment on **another** issue, carrying lineage | `handler/comment.go:1775-1790` (`source_task_id` deliberately not scoped to the run's own issue) | See I3 |
| Multica gates every hop on attribution and invocation policy | `comment.go:2261`, `:2352` (`ReasonAttributionBlocked`), `:3158-3212` (`ReasonInvocationNotAllowed`) | Design §7 records the looser choice; fine |
## 2. Findings
Status labels: **closed** (design now matches source), **defect** (design or code is wrong), **product** (a choice the owner must make), **e2e** (cannot be settled by reading).
### Critical
**C1 — defect. `(runId, attempt, round)` charge key collides and silently drops token charges.**
`USAGE_METADATA.round` restarts per `runReasoningLoop`. The runtime itself invokes the loop twice inside one agent run when the final drain finds pending input (the normal mid-run delivery path). Second-segment rounds 1..n carry the same key as the first segment; the idempotent apply discards them. `agentRound` in the transcript is the same counter, so reconstruction collides too.
Fix (one of): emit `roundOffset + turnCounter` in the event (stats already compute it), or add an execute-segment ordinal (count `START` events per run) to the key.
**C2 — corrected defect. Resident prompts already reach the transcript; durable delivery correlation was missing.**
The original review overlooked the continuation branch in `AgentHeadless.executeTurn` and the cold-revive `initialUserPrompt`. Runtime verification found both prompts in their expected records. Workspace agents nevertheless must deliver each durable turn window as a structured `AgentExternalInput` with a `deliveryId`, because bare `task_prompt` has no stable consumed watermark and receives the parent-agent prefix. The integrated runtime preparation adds that structured path and correlation metadata without rewriting ordinary background-agent continuation.
**C3 — product + runtime. The "trusted envelope in system/developer role" has no injection point.**
Either state that the envelope is a fixed prefix of a user-role message and that trust comes from ambient binding and provenance, not from role; or schedule a per-turn system-instruction update on `LlmChat` and record the prompt-cache cost (Multica works to keep that cache warm, `daemon/prompt.go` around `PriorSessionID`). Do not claim both.
### Important
**I1 — defect. Same-thread `thread_wait` has no acknowledgement rule.** Only a booked parent dependency event acknowledges a wait. A waits for B on the same thread; B closes with `thread_review` and does not `@A`. At quiescence A's wait counts as "dependency vanished", blocked-class outranks review-class, thread becomes `blocked` instead of `in_review`. Add: a same-thread wait is acknowledged by any later close record or human post on that thread.
**I2 — defect. Failure and unclosed records are acknowledged only by human feedback.** One terminal launch failure pins the thread to `blocked` even after another agent later reviews. The acknowledgement boundary must include any later successful booking, not only a human post.
**I3 — product. A parent agent cannot answer its child.** Mutating tools act only on the ambient thread; `thread_create` only nests under the current one. A child that `thread_block`s wakes the parent assignee, who can only post on the parent. Multica lets a run comment on another issue with lineage (`comment.go:1775-1790`). Decide: allow posting into descendants the run created (still provenance-stamped), or write down that children are unblocked by people only.
**I4 — defect / simplification. Token outbox is both underspecified and unnecessary.** "Admission drains pending charge events for the root" needs a tree scan because source-first events live in the run's thread file. Smaller model: write per-round usage on the run record (same file, atomic), sum `runs[].usage` across the tree under the workspace lock at admission, keep root `tokensUsed` as a cache. `appliedTokenChargeIds`, `chargedUsageRounds`, and the token outbox disappear. Keep the outbox for parent reports and notifications only.
**I5 — defect in the plan. §5.2 step 6 cannot run.** The vertical slice needs a run picker, launch/continue/resume/revive dispatch, `finishRun`, and an outbox consumer to apply the child's report to the parent. Step 5 has none; step 7 is the full reliability build. Insert "6a — minimal in-process dispatcher, no recovery". Also give `continueResidentAgent` a typed result (see §1) so `capacity_wait` is distinguishable.
**I6 — defect. A quiescent thread with no close record and no runnable target stays `in_progress` forever.** The matrix maps only turn/token/queue/unavailable gates to `blocked`. A human post whose assignee is disabled/unknown, or `no_target`, books nothing and changes nothing. Rule: any admission on a non-done thread that books nothing, at quiescence, yields `blocked`.
**I7 — defect. Dispatcher "idle → continue / revive / launch" lacks `paused`.** See §1. The sweeper path in §4 is wrong as written for restart recovery.
**I8 — defect. Read-only boundary omits `save_memory` and the context files.** Auto-memory is a persistent write channel shared with every session; a workspace agent writing it is a cross-agent, cross-session injection path that bypasses decision 1. Decision 2 must list it as excluded.
**I9 — process, resolved.** At the reviewed commit, §5.3's "3 files, 37 tests passed" referred to a patch that was not on the branch. The patch and evidence are now on #11206.
**I10 — defect. FIFO key `queuedAt` is caller wall-clock.** `postMessage` uses `options.now ?? Date.now()`; tool calls run in the agent process, REST in the daemon. Use a sequence issued under the workspace lock as the primary key.
### Suggestions
- **S1** Infer waiting: clean exit with a live dependency = waiting, without = unclosed. Keep `thread_wait` as explicit intent but drop the rejection path; a rejected wait costs another model turn.
- **S2** Deduplicate `thread_create` after replay by (parent, normalised title) and return the existing child. Cheaper than UI collapse and avoids double budget.
- **S3** Record in §9.5 that compaction calls and pre-retry stream usage are outside `USAGE_METADATA`.
- **S4** Decision 17 lets a parent at 11 turns hand a child 1 turn; the assignment trigger then trips the gate immediately. Floor it or document it.
- **S5** A human `@bob` on a thread blocked by alice's question acknowledges alice's blocker; the question can be dropped silently. Consider acknowledging only blockers from the targeted agents or the assignee.
- **S6** = C2 fix.
- **S7** If `appliedTokenChargeIds` survives I4, store a high-water mark per (runId, attempt, segment) instead of an unbounded id set.
### Closed since round 1 (why)
- `resumeBackgroundAgent` entry point: §0.1/§1 now name the three-way split used by `send-message.ts`.
- Finishing window: closed at the runtime (adjacent drain + `beginFinishing`) and handled as `delivery_race`.
- Multica per-(agent, issue) session, polling fallback, `decidePostMergeMiss` replay: all stated correctly now.
- Turn gate per thread / token per tree, `coalesce(running)` charging, system-authored parent event bypassing `self_trigger`, assignment through admission, no-defer justification, global FIFO: correct in the design; none of it is in the PR code.
- ALS per-turn seam: verified valid; this subsystem launcher must own its copy of `runBackgroundTurn` or a hook, since the existing one is a closure.
## 3. Minimal change set, in order
1. C1: cumulative round in `USAGE_METADATA` or a segment ordinal in the key.
2. C2/S6: workspace agents deliveries use structured external input with `deliveryId`; ordinary resident prompts were already transcripted.
3. C3: product decision pending; do not implement either transport yet.
4. I4: derive tokens from run records; delete the token outbox and both id lists.
5. I1, I2, I6: three lines in the aggregation rules.
6. I5, I7: add step 6a; typed result from `continueResidentAgent`; `paused` branch.
7. I8: exclude `save_memory`.
8. I3 and S5: product decisions pending; keep both explicit in §9.
9. I9: fixed by keeping the patch and its evidence on #11206 and updating §0.2.
10. I10: allocate `queueSequence` under the workspace lock in storage step 3; never sort by `queuedAt`.
## 4. What only an end-to-end run can settle
Run these on a machine that can build. Report the observed value, not "passed".
1. `getClaimedBackgroundSlotCount` with N idle resident agents: does an idle `completed` resident hold a slot? Decides whether the cap is roster-size or throughput.
2. `continueResidentAgent` → `false` while the resident is still registered, followed by `reviveCompletedBackgroundAgent`: does a second runtime get instantiated for the same agent? Check `registry.get(agentId)` identity and the resident map before/after.
3. Deliver via `queueExternalInput` immediately after the model's last tool round: confirm the final drain picks it up and `executeExternalInputs` runs as a second segment; capture the `USAGE_METADATA.round` sequence across both segments (this is the C1 reproduction).
4. Cold revive after two resident continuations: dump the replayed history and confirm which user turns are missing (C2 reproduction).
5. The two negative cases the design already lists: ping-pong between two _running_ agents on one thread tripping the turn gate, and `queueExternalInput(false)` being rebooked.
6. Targeted tests for the parked patch, named files only: `src/agents/workspace-agents/mentions.test.ts`, `dispatch-policy.test.ts`, `thread-actions.test.ts` under `packages/core`. Do not run directory sweeps.
### Observations from the implementation pass
These are the values observed in targeted runtime harnesses. They are not a claim that the still-unbuilt workspace agents launcher/dispatcher has run end to end.
1. With cap `1`, three `completed` resident runtimes leave `canStartBackgroundAgent() === true`; registering one new running agent succeeds. Idle residents therefore do not claim throughput slots.
2. The ambiguous boolean path was removed before a duplicate-runtime failure could be made a supported contract. The observed typed outcomes are: `capacity_wait` does not attempt cold revival; `continued` reuses the same resident (`createAgentHeadless` remains at one call); `fallback` is reserved for an unavailable resident and permits reconstruction. The exact old "false while still registered, then revive" race remains unobserved rather than blessed.
3. The reproduced two-segment usage-round sequence was `[1, 1]`; after the integrated runtime fix it is `[1, 2]`.
4. Cold-revive reconstruction produced, in order, `original task`, the tool call/result, `working`, `and another thing`, `still working`, `one final constraint`. Both external user turns were present; zero were missing. Structured agent delivery additionally records its `deliveryId`. This corrects C2 rather than confirming it.
5. Admission tests observed the running coalesce increase the local turn count to the limit, and the following agent trigger returned `turn_budget_exhausted`. The `queueExternalInput(false)` detach/rebook half cannot execute until §5.2 step 6 creates the dispatcher, so it remains an explicit vertical-slice assertion rather than a reported pass.
6. The three named workspace agents test files produced exactly `3 files, 37 tests passed`.
## 5. Open question 8
**Who controls a workspace agent's system prompt.** Decision 4 scopes trust to the workspace and §3 keeps thread text out of the repo because it is an injection surface. But every agent's system role is assembled from repo-controlled QWEN.md, the agent definition file, and auto-memory written by other sessions (`agent-core.ts:2646-2650`), at higher trust than any thread post. A `git pull` in another terminal silently changes every agent's system prompt while the resident body still holds the old one. §9.4 hashes the definition only; QWEN.md and auto-memory have no version, no gap marker, no provenance. I8 is the write side of this; the read side needs a version stamp in the run record.
<details>
<summary>中文摘要</summary>
结论:根模型保留,改契约。C1 已复现为 `[1, 1]` 并修成 `[1, 2]`;C2 的原前提被运行验证推翻,resident 输入本来就会进入 transcript,真正缺的是 `deliveryId` 关联;C3 仍需产品拍板。Important 项已写回权威设计:聚合规则补齐三处,token outbox 删除,第 6 步前补最小 dispatcher,`paused` 单独恢复,排除 `save_memory`,FIFO 改用锁内序号。§5.1 patch 已进入拆分后的 workspace agents 分支。开放问题扩展为 11 个,其中 C3、I3、S5 明确保留给人决定。§4 的六项检查已记录实际观测值;尚无 dispatcher 的部分明确标为未执行。
</details>

View file

@ -1,67 +0,0 @@
# Workspace agents steps 2-3 — implementation brief
> For the agent implementing §5.2 steps 2 (capability boundary) and 3 (versioned storage protocol) of [`2026-09-06-multi-agent-board-collaboration.md`](./2026-09-06-multi-agent-board-collaboration.md). Gates and evidence are in [`2026-09-07-workspace-agents-implementation-acceptance.md`](./2026-09-07-workspace-agents-implementation-acceptance.md); this file records what a source read of the branch found that the implementer should not have to rediscover, plus three places where the design and the runtime disagree.
> Branch head when written: `fc869431f9` (PR #11206). Runtime facts at `origin/main @ 703678136a`. Nothing here was executed except the baseline test run in §5.
## 1. Seams to reuse — do not build parallel ones
| Need | Existing | Where | Note |
| ----------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Execution-layer tool allowlist | `ToolConfig.executionAllowedTools` | `agents/runtime/agent-types.ts:88-100`; enforced `agent-core.ts:1599`; parsed `:505-518` | Exact names plus MCP `server*` patterns. Calls outside it are rejected before scheduling or approval. This is the seam for "definition may narrow, never widen" |
| Declaration filtering and blocklist | `toolConfig.tools`, `toolConfig.disallowedTools` | `agent-core.ts` `prepareTools` | Explicit lists are already filtered through `EXCLUDED_TOOLS_FOR_SUBAGENTS` (`agent-core.ts:203`, 20 control-plane tools) |
| Definition → runtime config merge | `convertToRuntimeConfig` | `subagents/subagent-manager.ts:893` | Accepts a `toolConfigOverride`; this subsystem launcher (step 4) passes the boundary here |
| Read-only shell classification | `classifyShellCommandSafetyInDirectory(command, cwd)` → `'read-only' \| 'write' \| 'unknown'` | `utils/shellAstParser.ts:1186`; root table `:83`, git/npm/yarn/pnpm/docker/pip/cargo/kubectl subcommand tables `:169-538` | tree-sitter WASM; tests call `initParser()` in `beforeAll`. `shell.ts:2140` uses it only to skip confirmation, never to refuse, so refusal is new wiring |
| Tool name enumeration | `ToolNames` | `tools/tool-names.ts:21-69` | The classification test enumerates `Object.values(ToolNames)` |
| Store primitives | `proper-lockfile`, `async-mutex`, `atomicWriteJSON(..., {noFollow})`, `Storage.setRuntimeBaseDir` | `store.ts`, tests | Keep |
| Two-process tests | `tsx` | `node_modules/.bin/tsx` | Spawn `tsx <script.ts>` twice against one runtime dir passed by env |
## 2. Step 2 — capability boundary
**Shape.** One module, `workspace agents/capability.ts`:
- `AGENT_TOOL_CLASSIFICATION: Record<string, 'allow' | 'deny' | 'thread'>` keyed by tool name, covering every `ToolNames` value; `classifyAgentTool(name)` returns the entry or `'deny'` for anything unlisted (MCP tools included).
- `buildAgentToolConfig(definitionTools?)` → `ToolConfig` with `tools = (definition ∩ allow) ∪ thread_*` (a `'*'` or absent definition list means the full allow set), `executionAllowedTools` equal to that list, `disallowedTools` equal to the deny set. The launcher passes it as `toolConfigOverride`.
**Confirmed v1 classification:** allow `read_file`, `grep_search`, `glob`, `list_directory`, `zoom_image`, `display_image`, `skill`, `tool_search`, `structured_output`, and `get_goal`. Deny `run_shell_command`, `edit`, `write_file`, `notebook_edit`, `save_memory`, `web_fetch`, `web_search`, `lsp`, `monitor`, `read_mcp_resource`, `ask_user_question`, `enter_plan_mode`, `exit_plan_mode`, `image_gen`, `update_goal`, `propose_goal`, `report_findings`, and everything in `EXCLUDED_TOOLS_FOR_SUBAGENTS`. MCP names also fail closed. Shell can return only with a filesystem sandbox that confines reads to the canonical workspace root; classifying a command as read-only does not provide that boundary.
**Resolved by the owner.** V1 workspace agents get no MCP tools. Future admission requires an explicit policy that proves the individual tool is read-only; the definition may narrow that policy but cannot widen it.
## 3. Step 3 — versioned storage protocol
**Files.** `workspace agents/workspace.json` (singleton: `schemaVersion`, `workspaceId`, optional `hostSessionId`, `nextRunSequence`); `workspace agents/agents.json` becomes `{schemaVersion, agents}` — it is a bare array today, so a v0→v1 migration exists from the first release; `workspace agents/threads/<id>.json` gains `schemaVersion`.
**Lock.** One workspace mutex (in-process `Mutex` keyed by workspace agents dir) plus `proper-lockfile` on `workspace.json`. Every public entry point acquires it exactly once and hands internals a transaction object; the per-file locks go away. The mutex is not reentrant: add an `AsyncLocalStorage` flag so a nested acquisition throws a clear error instead of deadlocking.
**Sequences.** `ThreadMessage.sequence` from `Thread.nextMessageSequence`. `ThreadRun.queueSequence` from `workspace.nextRunSequence`, written to `workspace.json` _before_ the thread file so a crash between the two leaves a gap, never a duplicate. `queuedAt` stays diagnostic.
**Tokens.** `ThreadRun.usageByRound: {attempt, round, tokens}[]`, upserted by key; `Thread.tokensUsed` becomes a derived cache of its own runs, recomputed on every write; the tree total is a scan of threads sharing `rootThreadId` under the lock. No token outbox, no `appliedTokenChargeIds`.
**Outbox.** `Thread.outbox: ThreadEvent[]` on the _source_ thread: `{id, kind: 'parent_report' | 'notification', causedByRunId?, payload, status: 'pending' | 'acknowledged', attempts, createdAt}`. Protocol per event: persist `attempts + 1` → apply to target → persist `acknowledged`. Target idempotency for parent reports: `ThreadMessage.originEventId`; `postMessage` called with an `originEventId` that already exists returns the persisted message and its outcomes without booking (the design's "retry with the same key" rule). `deleteThread` refuses while any event is pending.
**Outcomes on the message.** `ThreadMessage.outcomes: MessageOutcome[]` (flat record, not the `DispatchDecision` type, to avoid a types↔policy import cycle). The `otherThreads` option on `postMessage` is removed; the queue count is read from disk inside the lock.
**Migration.** `ensureMigrated(projectRoot)` at every entry: fast path when `workspace.json` is current; otherwise take the lock, for each v0 file write `<name>.v0.json`, write the migrated file atomically, read it back through the validator, then delete the backup. A file with a newer version throws a typed schema error. A file with no version after migration has run is a hand edit and fails closed with a message naming the migration. The legacy aggregate `tokensUsed` is preserved as synthetic round 0 on its final legacy run; a non-zero aggregate without a run fails closed instead of being discarded.
**Schema batching — resolved by the owner.** V1 declares and validates the whole §3 shape now, including the fields whose producers land in steps 5-8. Those fields remain optional until their producer exists. This is an explicit storage-version decision: it avoids serial migrations for one already-designed contract and does not claim the later runtime behavior is implemented.
**Named tests and what each proves.**
- `capability.test.ts`: every `ToolNames` value is classified; unlisted name → deny; `'*'` and narrowing definitions; shell is absent from tool configuration and refused by the invocation guard.
- `store.test.ts`: newer `schemaVersion` on thread, agents, and workspace each fail closed; v0 fixtures (bare agents array; thread without version but with messages and runs) migrate with sequences assigned in order and the backup removed; deletion refused with a pending event; a thread-write crash after allocation leaves a run-sequence gap; crash injection — `apply` writes the target then throws, second run finds the message by `originEventId`, event acknowledged with `attempts === 2`, exactly one target message; depth-3 tree with a stale `tokensUsed` on the root gates on the true sum.
- `workspace-lock.test.ts`: two `tsx` child processes allocate N `queueSequence` each; all 2N unique, each process strictly increasing.
- `thread-actions.test.ts`: fixtures gain the new fields; message `sequence` monotonic; `queueSequence` increasing across two threads; outcomes persisted on the message; `queue_full` computed from disk.
## 4. Runtime preparation merged into #11206
- #11200 (cumulative `USAGE_METADATA.round`): `usageByRound` is keyed on it. Without it the key collides across a `finishingInputs` segment.
- #11202 (`deliveryId` on structured external input): step 5's `consumedMessageIds`.
- #11204 (typed resident continuation): step 6's dispatcher branch.
All three are merged into `codex/multi-agent-workspace agents-foundation` in the order #11200 → #11204 → #11202. The expected final conflict was resolved by keeping both contracts: structured `AgentExternalInput` delivery and typed continuation outcomes. GitHub records the draft PRs as merged because this branch was their base; their review history remains available, and #11206 is the only implementation and delivery PR.
## 5. Test harness on a build-less box (observed)
A worktree at the branch head with `node_modules` and `packages/core/node_modules` symlinked to a main checkout's, plus fourteen `export {};` stubs for the `./dist/*` entries in `packages/core/package.json` `exports` (gitignored), satisfies `scripts/vitest-global-setup.js`. Step 3 observed: `store.test.ts`, `workspace-lock.test.ts`, `thread-actions.test.ts`, `dispatch-policy.test.ts`, and `mentions.test.ts` → 5 files, 59 tests passed. Run named files only.
## 6. Still unexecuted after steps 2-3
`queueExternalInput(false)` detach/rebook (step 6/7); assignment trigger through admission (decision 21, step 5/6); creating and reviving the hidden host session recorded by `workspace.json` (step 4).

View file

@ -1,213 +0,0 @@
# Workspace agents in Web Shell — design direction
> For §5.2 step 9, written before any UI exists so the build does not start from a blank page or from whichever list component was nearest to hand.
> Grounded in Multica's shipped UI, read at `multica-ai/multica@7a438bd5b`: `packages/views/issues/components/{issue-detail,execution-log-section,comment-trigger-chips,thread-nav-panel}.tsx` and `packages/views/issues/blocked-trigger-copy.ts`. Where this design diverges from theirs, the reason is stated.
> Companion to [`2026-09-06-multi-agent-board-collaboration.md`](./2026-09-06-multi-agent-board-collaboration.md) and [`2026-09-07-workspace-agents-implementation-acceptance.md`](./2026-09-07-workspace-agents-implementation-acceptance.md).
> The live demo path was rendered on 2026-09-07 through a real daemon and Web Shell. It covers roster/create/list/detail, reply routing preview, live dispatch, parent-report continuation, final review, blocked questions, cancellation, per-run transcript slices, tombstoned names, inline children, and the first-class sidebar entry. Branch CI visual evidence remains step 9 work.
## 1. Who this is for
One developer supervising two to five persistent agents, returning to a machine they walked away from. In priority order:
1. **What needs me?**
2. **What is running, and on what?**
3. **What is stuck, and why?**
History, transcripts and budgets are evidence for those three and stay subordinate to them.
## 2. What to take from Multica, and what it cannot give us
Multica has shipped this product shape, and four of its decisions are worth adopting outright rather than rediscovering.
**Runs belong in a side panel, active first.** `execution-log-section.tsx` lists every agent run for an issue: active runs pinned at top, terminal runs collapsed behind a _Show past runs (N)_ toggle. Each row is agent, then the **trigger** — why this run exists — flexing and truncating, then status in a fixed right column that is _replaced in place_ by actions on hover rather than covered. Their note is worth keeping verbatim in spirit: the row carries no agent-availability dot, because availability is not the story on a run row; the run's own status is.
**The live signal belongs in the header, not the body.** They moved "an agent is working" out of an in-body card into a header chip. A body card competes with the content for the reader's eye and scrolls away.
**Preview routing before sending, not after.** `comment-trigger-chips.tsx` shows, while you type, which agents this comment will wake — lit for will-trigger, dimmed for suppressed — and names each mention that will _not_ fire, with its reason. This is the single best idea in their UI and it transfers exactly, because `decideDispatch` is a pure function: the composer can run the real rules against a draft and show the true outcome before a token is spent.
**A list beats a rail for finding.** Their `ThreadNavPanel` replaced a minimap rail for jumping between comment threads: a tick carries no text, so finding a specific one costs a hover per candidate. They kept the rail for position and added a list for finding, and deliberately did not mark "currently on screen" because on-screen is a set, not a point.
**What Multica cannot give us.** Its issue status is set by a person and its runs are per-`(agent, issue)` tasks, so it never has to answer "one agent submitted a summary while another is still working". Ours does: `resolveThreadStatus` derives the thread's state from every run's outstanding obligation, and three of the round-two defects were threads stuck in a state nobody could explain. That is the one place this UI has to invent rather than adopt, and §4 spends its design budget there.
## 3. The idea this design rests on
**The thread view is a ledger of outstanding obligations; the conversation is the evidence underneath it.**
A chat log with a status badge is what Slack, Linear and Multica's issue view all look like, and built here it would hide the thing this system knows that they do not: who owes what, and what the thread is waiting on. A badge reading `blocked` above twelve messages makes the reader scroll to find out why. Three consequences:
- The thread header is a **sentence**, not a badge. `resolveThreadStatus` already returns a `reason` — _"an Agent asked a question and is waiting for you"_, _"2 Agent runs active"_. Render it. Exact run ids stay in the run inspector; they are not useful collection-page labels. The UI must not derive a second status vocabulary; the resolver is the only place a thread's state is decided.
- Every run row carries its **close obligation** where Multica carries a task status. Same shape, more information: _asked a question_, _submitted for review_, _waiting on a sub-thread_, _ended without a hand-off_, _failed at launch_.
- **System triggers are not chat messages.** An assignment and a child report are `authorKind: 'system'` carrying the run that caused them. They are ledger entries and must not be dressed as someone talking.
## 4. Surfaces
The existing Agents entry has two views: **Agents** and **Tasks**. They share
one backend snapshot but are not stacked into one long page. This mirrors Multica's product boundaries without adding another shell
navigation system.
### 4.1 Roster, inside the existing Agents page
Rows, not cards: a roster's job is comparison, and cards break the columns that make comparison free.
```
Agents
● alice reads CI logs working Investigate flake 2 waiting
● bob reads code idle —
○ retired old log reader disabled —
+ Add an agent
```
The dot carries `WorkspaceAgent.color`. A disabled agent's dot is hollow and its row drops to `--muted-foreground` but is never hidden: disabling keeps identity and history, and a roster that hides it contradicts the model. The working column names the thread, because "which thread is my agent on" is what the owner of a queued thread is actually asking. The backlog reads _waiting_, matching `queueLimit`'s meaning of pending runs only.
Empty: **No agents yet. An agent is a persistent identity with its own instructions; each task keeps its own conversation.**
### 4.2 Thread list, grouped by what they need
Recency sorting is the default and it buries the two threads that need you under twenty that do not.
```
Threads
Needs you 2
┃ Investigate the web-shell smoke-test flake
┃ alice asked a question · 4m
┃ Retry-path audit
┃ bob submitted a summary for review · 1h
Running 1
Trace the daemon restart loop
2 runs in flight · started 12m
Idle 1
Notes on channel workers · nobody assigned
Done 14 ›
```
`blocked` and `in_review` share one attention treatment — a 2px left edge in `--status-attention-fg`, carried by nothing else on the page. They are opposite in valence but they are the same query for the reader: _this is waiting on me_. Two colours would split that scan in two. They are told apart by the sentence, which is where the difference actually lives. Done collapses behind its count; finished work is evidence, not a task.
### 4.3 Runtime
There is no Runtime view. Every Agent runs on the serving daemon, which is
always available while the page is. The demo does not invent remote placement,
heartbeat history, or process isolation. Those belong to a later runtime
registry.
### 4.4 Thread view
Two columns, following Multica's proportions: content left, runs right.
```
Investigate the web-shell smoke-test flake [alice working]
┌──────────────────────────────────────────────┐ ┌───────────────────────────┐
│ alice asked a question and is waiting for │ │ Runs │
│ a person │ │ │
└──────────────────────────────────────────────┘ │ ▎bob working │
│ read the retry helper │
The web-shell smoke test is flaky. Find out │ 6m · transcript │
why. │ │
│ ▎alice asked a question│
3 you 10:02 │ assigned by you │
The web-shell smoke test is flaky. │ 4m · transcript │
Find out why. │ │
│ Show past runs (3) › │
4 assigned alice 10:02 │ │
│ Budget │
5 alice 10:14 │ 4 of 12 unattended turns │
The failure is in the retry path, not │ 31.2k of 200k tokens │
the fixture. @bob can you read the │ across this thread tree │
retry helper? └───────────────────────────┘
6 alice asked 10:15
Should I treat a leaked temp dir as a
failure, or just log it?
┌────────────────────────────────────────────┐
│ Reply to this thread │
│ ───────────────────────────────────────── │
│ will wake ● bob mentioned │
│ won't wake ○ carol assignee, superseded │
│ ⚠ @dave no agent by that name│
└────────────────────────────────────────────┘
```
- **Header sentence.** The resolver's `reason`, in a block wide enough for a full sentence. It is the only element allowed to change while the page is open, and the only place motion is spent: a 150ms cross-fade when it changes, removed under `prefers-reduced-motion`. Nothing else animates — no per-section entrances, no hover transitions on rows.
- **Working chip** beside the title, from Multica: the live signal in the header, never a body card.
- **Runs panel.** Multica's shape with our obligation in the status column, and the 2px identity bar from `WorkspaceAgent.color`. Active runs pinned, past runs collapsed behind their count. The second line is the run's trigger — _assigned by you_, _mentioned by alice_, _sub-thread reported back_ — because "why does this run exist" is what a reader asks first. `transcript` opens the run's slice.
- **Budget** is a line, not a bar. It is a limit you want to notice before it trips, not a goal you are filling. "across this thread tree" is stated because sub-threads share it and that surprises people.
- **Posts** carry the message sequence in mono at the left: sequence is how a person and an agent refer to the same post and how a duplicate after a replay is recognised, so it is data, not ornament. System triggers render as a verb phrase with no body — `assigned alice`, `sub-thread th_91c ready for review` — so they read as ledger entries.
- **Sub-threads** appear inline where they were created, indented one level, with their own status sentence. A parent whose child is blocked shows that without a click.
- **Composer preview**, adapted from `comment-trigger-chips`. As the draft changes, run `parseMentions` and `decideDispatch` against it and show the true outcome: who will be woken, who will not, and why. Lit versus dimmed carries will-trigger versus suppressed, exactly as Multica does it, and an unknown mention is a named warning rather than a silent no-op after sending. Our rules give this more to say than theirs: a mention that will hit `queue_full`, or a thread whose turn budget is spent, is visible before the post rather than after.
### 4.4 Run transcript slice
Opened from a run row into a side panel, not a modal: it is reading material and the thread must stay visible beside it. The header states what is being read — **run rn_7 · alice · this thread only** — because the underlying file is that agent's whole cross-thread transcript and the slice is a window into it. Byte offsets are plumbing and are not shown.
## 5. Tokens, type, and what not to add
Inherit `App.module.css`; add no colour and no typeface. A downloaded display face would cost startup, break offline use in a local-first tool, and clash with every neighbouring panel.
| Role | Token |
| --------------------------------------- | -------------------------------------------------------------------------- |
| Running | `--status-running-fg` / `--status-running-bg` |
| Needs a person (`blocked`, `in_review`) | `--status-attention-fg` / `--status-attention-bg` |
| Done | `--status-done-fg` / `--status-done-bg` |
| Idle, open | `--status-idle-fg` / `--status-idle-bg` |
| Agent identity bar | `WorkspaceAgent.color`, falling back to `--accent-red/orange/yellow/green` |
Two existing families, split by function rather than hierarchy. `--font-sans` for prose: the header sentence, post bodies, descriptions, empty states. `--font-mono` only where characters must align in a column or be copied exactly — message sequences, run and thread ids, token counts. Run rows need tabular figures to line up; that is information, not texture. Mono never appears on labels, headings or status words.
From the shell's 14px base: 20px/1.3 semibold thread title, 15px/1.5 header sentence, 14px/1.6 post bodies capped at 72 characters, 13px run rows, 12px sequences and metadata.
**Not to be used, because they are template chrome rather than decisions:** tracked-out all-caps eyebrows above sections; metadata joined with middle dots; `→` appended to link text; one border-radius and one soft grey shadow around every block regardless of what it holds; `01 / 02 / 03` markers on anything that is not a real sequence.
## 6. Copy rules, and one borrowed law
Multica's `blocked-trigger-copy.ts` carries a rule this design adopts wholesale: **a label must not assert a cause the reason code does not carry.** They keep `runtime_offline`, `agent_runtime_required` and `runtime_unusable` apart because the _fix_ differs, and copy that conflated them sent people to reconnect a machine that was already connected. The same discipline applies to our eight admission skip reasons, each of which has a different fix:
| Reason | What the reader is told | The fix it points at |
| ------------------------ | ----------------------------------------- | --------------------------------------- |
| `agent_unknown` | no agent named "dave" in this workspace | check the spelling, or add the agent |
| `agent_disabled` | alice is disabled and cannot take work | enable alice |
| `no_target` | your reply reached nobody | mention an agent, or set an assignee |
| `queue_full` | alice already has 5 runs waiting | wait, or give the work to another agent |
| `turn_budget_exhausted` | 12 unattended turns spent on this thread | reply yourself to continue |
| `token_budget_exhausted` | 200k tokens spent across this thread tree | this one is never reset |
| `thread_done` | this thread is done and takes no new work | open a new thread |
| `self_trigger` | an agent cannot wake itself | mention someone else |
The composer preview and the post-send result share this table, so a reason reads the same in both places. Missing or invalid agent definitions are not admission reasons: only the runtime loader can know them, and they appear as a typed launch failure on the run row.
A mutation may be durable even when the immediate dispatcher wake fails. The UI says **the change was saved, but the agent could not start**, includes the typed dispatcher error, and keeps that action error visible across background refreshes. It must not report the whole mutation as failed or silently leave a queued run looking merely slow.
Everything else follows from naming the actor and the act: _alice asked a question_, never _Blocked_. One vocabulary end to end — the button that says **Mark done** produces a thread that reads **done**, and `thread_review` surfaces as _submitted for review_ everywhere. Failures state the stage and the fix in the interface's voice and never apologise: **alice's run failed at launch: agent definition "log-reader" is unavailable.** Refusals name the alternative and link it: **this thread has 1 sub-thread that is not done. Finish or close th_91c first.**
## 7. What step 9 must not do
- Do not add a second status vocabulary. Render `resolveThreadStatus`'s `status` and `reason`; derive nothing.
- Do not render an agent's whole transcript as a thread's log. A run is a slice and the rest of that file belongs to other threads.
- Do not sort threads by recency at the top level.
- Do not show a quiescent thread with nothing runnable as idle. That is `blocked`, and it has a reason attached.
- Do not put the live working signal in the body. Multica moved it to the header for a reason.
## 8. Accessibility floor
Attention is never carried by colour alone: the left edge is paired with the sentence, the identity dot with the name, the composer preview's lit/dimmed state with its label. Visible focus on every row and link using `--ring`. Thread list and run rows keyboard-navigable in reading order. `prefers-reduced-motion` removes the header cross-fade. Contrast checked against both themes, since `App.module.css` ships light and dark.
<details>
<summary>中文说明</summary>
**这份文档做什么**:在第 9 步动工前定下 workspace agents 在 Web Shell 的设计方向,并且对着 Multica 已上线的实现(`issue-detail`、`execution-log-section`、`comment-trigger-chips`、`thread-nav-panel`、`blocked-trigger-copy`)来定,而不是凭想象。
**从 Multica 直接采用四点**:运行记录放右侧面板,活跃在上、历史折叠在「Show past runs (N)」后面,行内不放 agent 在线点(在线与否不是这一行的主题,run 的状态才是);「某 agent 正在工作」的实时信号放页头而不是正文卡片;**发送前预览路由**——边打字边显示这条会唤醒谁、谁被跳过及原因,亮=会触发、暗=被抑制,未知 @ 显示具名警告;查找用列表而不是右侧缩略轴。
**Multica 给不了的一点**:它的 issue 状态是人设的,run 是 (agent, issue) 维度,所以它从不需要回答「一个 agent 交了总结但另一个还在跑」。我们需要,`resolveThreadStatus` 就是为此存在的,上一轮三个缺陷都是「线程卡在没人能解释的状态」。所以设计预算花在这里:页头是解析器返回的那句 `reason` 而不是徽章,每一行 run 在 Multica 放状态的位置放我们的「未结义务」,system 触发是账目条不是有人说话。
**借来的一条法则**:`blocked-trigger-copy.ts` 写着「标签不能断言 reason code 没有携带的原因」,并且当修复动作不同时相近原因必须分开——他们把 offline / 未绑定 / 不可用分开,因为混为一谈会让人去重连一台本来就连着的机器。我们九个 skip 原因照此各配一句话和一个明确的修复动作,且组件预览与发送结果共用同一张表。
**不新造设计语言**:沿用 `App.module.css` 的 token,不加颜色不加字体。等宽只用于需要对齐或精确复制的字符。动效只花在页头那句话变化时的 150ms 交叉淡入。
</details>

View file

@ -1,720 +0,0 @@
# What is missing against Multica, and how to close it
## Architecture correction — persistent identity, task-scoped sessions
The previous implementation score confused a working collaboration engine with
the requested Multica-shaped product. Against that product the branch is about
one working collaboration kernel, not a mostly complete product: routing and
shared-thread mechanics are substantial, while Agent creation, Runtime and task
presentation remain partial or absent. No percentage is meaningful until those
different layers have one explicit acceptance checklist.
One implementation decision was wrong, not merely incomplete. A deterministic
session keyed only by agent made every task share one transcript and left
`maxConcurrentRuns > 1` ineffective because the runtime port held one execution
slot per agent. Multica's `PriorSessionID` is selected by
`GetLastTaskSession(agent_id, issue_id)`. Qwen Code now follows the same scope:
the Agent identity is durable across the workspace, and its ACP session is
durable only for one `(agent, thread)` pair.
One structural gap remains and is not hidden behind UI glue:
- `runtimeId` and the hidden host session are not a first-class Runtime with
provider, health and placement semantics.
The primary Agent Builder now writes one workspace Agent record containing its
identity instructions, model and concurrency. It does not create a subagent
definition first. Existing definitions remain a secondary linking/template path
for compatibility, not a prerequisite for a new persistent Agent.
The existing Agents navigation now opens the runnable Agent roster and shared
tasks first; reusable definition files are a secondary Definitions view.
New Agents run on the local daemon. An Agent with no task session is idle; a
task/run, not the Agent row, owns the link to its ordinary conversation
transcript.
## Current correction — task orchestration before process isolation
### Browser/source acceptance observations, 2026-09-08
#### Daemon restart — recovered after fixing runtime discovery
Task `th_00437664-ff68-4582-a053-949dc6ac53e1` first completed too quickly for
manual interruption; that first run is not crash-recovery evidence. On a second
human-requested run, a bounded observer waited for a durable checkpoint and
verified no unrelated live work before sending SIGKILL to the local daemon.
At interruption, run `rn_a04df8a1-8bcc-4a53-91c7-2940ce584c07` was running,
attempt 1, session `b48bad11-6e7c-5110-92a1-e560bf56eec6`; message
`ms_67e84e03-15c6-4382-a9d9-78569db56c6b` contained recovery checkpoint new-1.
The first daemon restart left this run stranded. The route registration only
performed a one-shot registry scan and discarded startup errors. It now scans
active, trusted workspace runtimes every five seconds for durable live work or
pending outbox events, reusing the existing owner and serialized dispatch. The
scan is stopped during server cleanup. The exact cause of the initial missed
one-shot scan was not logged, so readiness timing is not claimed as proven.
After that patch, starting the daemon recovered the same run as attempt 2,
without a human post, and reused the same session. Its review contained
RESTART-RECOVER-7263 and identified new-1 as the surviving checkpoint; it did not
repeat the checkpoint loop. Chrome Mark done succeeded. This verifies running
run recovery, not every accepted-input/transcript/outbox crash window.
Recovery also overwrote the prior usage baseline before settling that attempt:
2,462,933 before the kill became 2,495,350 on resume, with no attempt-1 usage
entry. The dispatcher now records the positive prior-attempt delta before
rebinding. A direct source check verified 100 → 125 produces a 25-token entry
for attempt 1 and baseline 125 for attempt 2. The real task's old accounting
was not rewritten; its displayed 1,030.1k tokens omit that observed 32,417 delta.
The accounting patch has source-check evidence, not a second live crash run.
No build, lint, typecheck, test suite, or additional PR was used.
#### Human unblock — same-session continuation
Chrome task `th_2987a14f-0765-4a32-81e2-c0c12b28dd9a` asked demo-leader to
request an output format before proceeding. Run
`rn_7f036523-31ca-4c07-9e86-9e0e11387e5e` closed blocked, and the panel showed
"Which output format should I use: JSON or CSV?" plus the waiting-for-a-person
reason. A human JSON selection at sequence 3 booked
`rn_cd66955c-4d53-4646-8834-90d1d01603b3` and acknowledged the earlier blocker
at that same sequence. Both runs used session `63465788-a16b-5328-92fc-8020330969a9`.
The second run submitted `{"ready": true, "marker": "HUMAN-UNBLOCK-6149"}`
through thread_review, and Chrome Mark done succeeded. The panel displayed
280.9k tokens. No manual retry or additional agent identity was needed.
This covers a human answering the assigned agent's own question, not every
open product choice about acknowledging other agents' blockers. The acceptance
companion now distinguishes current ACP evidence from historical background
observations and follows the owner's direct-to-#11206/no-local-CI workflow.
The Agent page's description also now names reusable definitions and shared
tasks rather than presenting Agent Team as the only collaboration path.
Updating that translation triggered a React removeChild error once during hot
reload. A full page reload recovered the UI and showed the new description;
the hot-reload error itself has not been diagnosed or claimed fixed.
#### Cancellation — wait for the runtime, then account for usage
The dispatcher previously ignored the cancellation result and immediately wrote
cancelled, even while the runtime still reported running. It now keeps cancelling
until a fresh runtime inspection no longer reports running; refusal and accepted
but incomplete cancellation have distinct dispatch details. The session adapter
also checks thread/run/attempt before cancelling, and terminal reconciliation
charges usage before writing cancelled.
A direct source check observed both rejected and accepted-but-running requests
remain cancelling. When the stub runtime stopped, the run became cancelled and
its 125-minus-100 usage delta was recorded as 25 tokens. A single regression
case was added to the existing dispatcher test file; the suite was not run.
Chrome task `th_9b557bad-6888-4695-a0ed-b72abfd70c98` initially completed before
the cancellation click could reach it; that first run is not cancellation
evidence. A second human-requested run,
`rn_2b38c719-44d5-46c8-92fd-f9b0af25e785`, posted sequential checkpoints.
Chrome showed working → stopping → no active run after Cancel. The store
confirmed cancelled, startedAt=1788848724048, endedAt=1788848741171, and 594,802
tokens accounted to that run. Checkpoints 1–7 exist; checkpoint 8 and a review
from the cancelled run do not. The thread is blocked with an explicit cancelled
run reason and no successor, not in_progress. Its two-run history remains in the
local demo workspace. This does not verify an unresponsive child or forced kill.
#### Same-thread peer handoff — clean first-attempt demo
Chrome task `th_00df8e71-3a7f-4c83-876c-290a7a22d878` reused demo-leader and
demo-worker, with no child threads. Leader posted one addressed request; worker
returned 31 × 37 = 1147 addressed to leader. Leader independently posted
41 × 43 = 1763, closed with thread_wait, then automatically resumed and submitted
both results plus `PEER-HANDOFF-5931` through thread_review. Chrome Mark done
succeeded, and a store read confirmed done, three completed runs, all attempt 1,
zero child threads, and no manual retry or extra human message.
Leader run `rn_3d9be992-e39b-4c11-a5d6-c132595651c8` ran from 1788848276487
to 1788848297506; worker `rn_a4c2bf69-fc1e-4c29-a032-e391711b8f22` ran from
1788848288507 to 1788848299517: 8,999 ms overlap. Leader's continuation
`rn_8c7ba977-9320-4c4f-bd34-a8fe5a5649f6` ran from 1788848297506 to 1788848305513. Both leader runs used session `63465788-a16b-5328-92fc-8020330969a9`;
worker used `b48bad11-6e7c-5110-92a1-e560bf56eec6`, also reused from the earlier
demo. The panel displayed 636.3k tokens. Overlap proves concurrent run lifetimes,
not separate OS processes or simultaneous provider computation.
The browser exposed a wording defect: a same-thread wait was described as
"waiting on a sub-thread" although closeKind carries no such distinction.
The shared run-label helper now says "waiting for other work". A direct source
assertion checked that label along with the persisted task/run evidence above.
No build, lint, typecheck, test suite, or new PR was needed. This is a clean
shared-thread orchestration demo, not a crash/restart reliability sign-off.
#### ACP same-run input — live evidence
The session adapter now uses the existing queue-only mid-turn channel, carrying
daemon-owned run metadata and the delivery id. The child checks that metadata
against the active ambient run before injection, flushes its mid-turn transcript
record, then records the consumed context window under the workspace lock.
Queue acceptance alone still does not acknowledge consumption. A stale run is
rejected; an unrecorded receipt remains eligible for durable follow-up.
Chrome task `th_028860cd-ee68-48e4-a792-1869a281fbe1` used the existing demo-worker
session. While it was working, a human correction requested
`MIDTURN-RECEIPT-4827`. One run, `rn_d1d4a0ec-f71b-4314-b944-c73379c499a2`,
started at 1788847915707 and completed at 1788847952361 with closeKind=review.
The correction (`ms_6e5516a8-20c7-409d-8d46-fcd719eac773`, sequence 3) is in
that run's consumed ids, and its committed watermark is 3. The active transcript
contains one matching mid_turn_user_message at 2026-09-08T06:12:17.797Z,
UUID `130194d1-bc5a-4e6c-8dda-186b10288f06`. The final thread_review includes the
marker and says this is partial demo acceptance. No successor run was created.
The model also called thread_read, so the final marker alone does not isolate
the queue path; the persisted mid-turn record and receipt provide that evidence.
Its summary repeated historical, superseded startup limitations from this doc;
that prose is not accepted as a factual architecture audit. The task remains
in_review. The panel displayed 398.4k tokens across this task's run.
Direct source checks rejected a stale attempt and a mismatched watermark and
recorded a valid repeated receipt once. No build, lint, typecheck or test suite
ran. Crash/late-drain/close races on the new ACP path still require live checks;
this supersedes earlier statements below that live input is unconnected.
#### ACP initial input — transcript before receipt
The initial launch path no longer reports its input consumed while the session
is merely prepared. `Session` records and flushes the initial user message
before advancing the durable consumed watermark; a failed receipt leaves the
input eligible for replay.
Real task `th_c7646c7b-7f4c-4996-9560-42721f125d0d` ran on the existing
demo-worker session. Run `rn_bcd7f190-5958-4dcd-9c92-ea207d4aad16` posted
`INITIAL-RECEIPT-8391`, called `thread_review`, and finished with trigger
`ms_c7dcb7be-dda0-496f-90f7-e3663dec5130` consumed at watermark 1. Reloading
the session from disk found both the assigned prompt and marker in its
transcript. This observes the normal model path and checks the persisted result;
the exact process-exit window between transcript flush and receipt remains an
unrun failure injection. No build, lint, typecheck, test suite, CI, or new PR
was used.
#### Unread input at close
Source inspection found that explicit close used to promote every accepted id
to consumed, while successor booking considered only unaccepted triggers.
That would silently acknowledge an input queued just before close without any
consumption receipt. Terminal bookkeeping now preserves consumed ids, and
finishing/completed runs rebook triggers without a consumption receipt.
A direct source check in an isolated temporary store observed a finishing run
with one accepted/unread correction become completed, retain zero consumed ids
and a zero committed watermark, and start a successor carrying that correction
in the same dispatch sweep. A single regression case was added to the existing
dispatcher test file; the source check ran, not the local CI/test suite.
This is storage/dispatcher evidence, not an ACP live-drain acceptance claim.
#### Post-send routing visibility
Thread details now expose each message's stored admission outcomes, and the
panel renders them below the message. Skip explanations use the same mapping
as draft preview. Running coalescence explicitly says it is not a read receipt.
Chrome on the source daemon displayed the persisted leader/worker bookings in
the completed acceptance task below; no new model run was needed for this check.
To repeat: open Shared threads, select that completed task, and check its Routing
lines against the stored message outcomes. Skip/coalescence rendering still needs
a browser scenario; only dispatch outcomes were observed in this pass.
This UI change itself did not implement mid-turn steering. The subsequent ACP
implementation and live evidence are recorded above; queue admission labels
still intentionally make no claim about consumption.
#### Session capability wiring follow-up
The replacement session path previously applied only the resolved prompt.
It now also passes the existing `toolConfig.executionAllowedTools` to Config;
the scheduler-facing guard intersects that set with the existing capability
classification and preserves host-policy denials. Ordinary sessions retain
their previous guard. This is tool-call policy, not OS isolation or a claim
about initialization hooks/MCP discovery.
A direct source check observed read_file/thread_review allowed and write_file,
run_shell_command, save_memory, an unknown MCP name, and an omitted glob denied.
It also observed an upstream denial preserved and an ordinary Config unchanged.
The scheduler's existing pre-execution call site was inspected; model-driven
negative-path and full reliability acceptance are still outstanding. No build
or local CI ran.
Session startup now applies the resolved definition/identity model selector
through the existing model resolver and Config APIs before publication.
`inherit` leaves the workspace model unchanged; `fast` uses the configured
selector; same-provider selections use setModel (including raw model IDs),
and cross-provider selections use switchModel with cached-credential requirements
for OAuth. This does not update an already-live agent after a roster edit.
Direct resolver checks covered inherit, fast and explicit same-provider IDs;
live provider-request verification and live configuration refresh remain pending.
#### Live follow-up (supersedes the startup blocker below)
The source dev loader now resolves ACP bridge exports from this worktree, not
the checkout behind shared node_modules. Real browser task submission then
exposed and drove fixes for three more execution-path defects: prefixed session
IDs rejected by ACP's UUID contract, persona resolution before Config created
its definition manager, and attempting session creation for live or persisted
sessions. Agent IDs now map to stable UUID v5 session IDs; persona resolution
runs after initialization but before publication; start reuses a live session,
resumes an active on-disk transcript, or creates a genuinely new session.
Protocol error objects use the existing error formatter instead of rendering
`[object Object]`.
Local task `th_b425fc9b-719a-4456-99a9-13fae4c960ea` used two test identities
(`demo-leader`, `demo-worker`) backed by the existing general-purpose definition.
The model created child `th_cc67e630-b3e8-42b8-b27e-3429adcde924` and submitted
323 through `thread_review`; the leader posted 667 and closed with `thread_wait`.
Persisted run intervals overlap for 8,006 ms: leader
1788846040198–1788846063450, worker 1788846054442–1788846062448.
After the startup/restore corrections and explicit human retry, the leader
read the child and submitted both results through `thread_review`. Chrome
confirmed parent completion is refused with `descendants_not_done` before child
acceptance; marking the child done succeeded and automatically started another
leader run from the parent-report event, without a new human message.
That run (`rn_5d81f465-0dd0-41c5-b3cf-f9ce4289d812`) completed and submitted
another summary. The browser then successfully marked the parent done; both
parent and child are now done. The two local test identities and full failure/
retry history remain available in this acceptance workspace.
This is real execution evidence, not a clean first-attempt acceptance run:
the same task preserves earlier failures and manual retries. Immediate mid-run
steering, capability enforcement on this session path, and the full original
acceptance matrix remain unverified/incomplete. No build or local CI was run.
- Direct source execution of `Config.createToolRegistry` now reports all six
`thread_*` tools for a top-level `agent` session and zero for an ordinary
session. Previously registration required `forSubAgent`, so replacement
sessions could not split work or explicitly close runs. The existing ambient
store checks remain the authority for every tool invocation.
- Chrome against Vite on 5173 and the source daemon on 4170 reproduced the
shared-task page failing to parse an HTML response. The backend's plural
`/workspaces/:workspace/agents` prefix also collided with the existing
agent-definition `/:agentType` route. The collaboration backend now matches
the client's separate `/workspaces/:workspace/agent` prefix. After restart,
the page loads the real empty roster and server capability description with
no parse or missing-subagent error. No agents or tasks were created.
- Task selection now clears the previous detail/draft, ignores stale refresh
and preview responses, and does not render task A's controls with task B's
ID. This race fix is source-reviewed, not yet browser race-injection verified.
- Live model acceptance is **not passed**: the ACP child exits during startup
because the locally resolved bridge package lacks `DAEMON_AGENT_RUN_META_KEY`.
No build or local CI was run. The tool-registry observation and empty-panel
observation do not establish concurrent model work or child-task acceptance.
The owner's clarified goal is existing agents collaborating on tasks, with
assignment, child tasks, reports and human acceptance visible in the panel.
Separate OS processes are not a prerequisite for this slice. Historical claims
below that a session is a process, or that Stage A delivered crash isolation,
are incorrect: the current ACP bridge multiplexes sessions in one process.
Source inspection found that `sendPrompt` resolves at turn completion. Awaiting
it inside dispatch delayed the HTTP assignment response, blocked peer starts,
and sampled usage only after the work was done. Session dispatch now prepares
the session, persists its run binding and usage baseline, then activates the
prompt without waiting for the model. The adapter exposes the active run's
identity for cancellation, and asynchronous errors settle as visible failures.
Timer and HTTP dispatch passes share one in-flight pass to avoid reconciling
a run between claim and activation.
The claim that `deliver` already uses live mid-turn input is also incorrect.
It used the normal prompt FIFO. Replies now explicitly take the existing durable
rebooking path; true mid-turn input and drain acknowledgements remain unconnected.
This is not evidence of a complete leader → worker → human acceptance demo.
Verification for this correction: source/call-site inspection only; the named
session-dispatch-port test was updated with an unresolved model promise and
asynchronous failure case, but was not run. No build, lint, typecheck or local
CI was run. Next entry: exercise panel assignment with two existing agents,
then connect live input acknowledgement and verify child-report/acceptance flow.
> Written after reading `multica-ai/multica@7a438bd5b` properly: its migrations
> (`agent`, `agent_runtime`, `agent_task_queue`, `issue`, `comment`, `squad`,
> `agent_invocation_target`, `inbox_item`) and its product routes.
> Companion to [`2026-09-06-multi-agent-board-collaboration.md`](./2026-09-06-multi-agent-board-collaboration.md).
> Goal restated by the owner: **not a literal copy.** Get as close to Multica's
> model as Qwen Code's grain allows, and integrate its board and its
> conversation into Qwen Code rather than beside it.
## 1. What Multica actually is
An issue tracker in Linear's shape, where an assignee may be an agent, plus a
runtime registry and daemon that dispatch tasks to registered machines.
| Entity | What it carries |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agent` | workspace-scoped identity: `runtime_mode` (local/cloud), `runtime_config`, `visibility`, `status` (idle/working/blocked/error/offline), `max_concurrent_tasks`, owner |
| `agent_runtime` | **a machine**: workspace + `daemon_id` + provider, `status` online/offline, `last_seen_at`, `device_info` |
| `agent_task_queue` | agent × issue × `status` (queued/dispatched/running/completed/failed/cancelled) + priority |
| `issue` | title, description, 7 statuses, priority, `assignee_type` (member/agent), `parent_issue_id`, `acceptance_criteria`, `due_date`, labels, project |
| `comment` | the conversation, on the issue |
| `squad` | a leader agent plus members |
| `agent_invocation_target` | who may invoke this agent |
Routes: `agents`, `agents/[id]` (instructions / env / MCP / custom args /
integrations / activity tabs), `agents/new` (manual or AI-authored),
**`runtimes`, `runtimes/[id]`**, `issues`, `issues/[id]`, `my-issues`, `inbox`,
`squads`, `projects`, `skills`, `chat`, `autopilots`.
The load-bearing idea, and the one we inverted: **an agent is a persistent
identity bound to a registered runtime, and dispatch hands its queued work to
that runtime.** The runtime daemon then launches the provider backend for the
task. For Qwen, `qwenBackend.Execute` starts a new CLI process and
`PriorSessionID` resumes the provider session from the previous task on that
issue. Multica therefore does not keep one permanent OS process per Agent;
runtime registration, task-process isolation, and conversation continuity are
separate properties.
## 2. What we have, honestly
Two layers, and they are in very different states.
**Sound, and worth keeping whatever happens above it.** The store's versioned
schema and workspace-lock transactions; admission with its twelve outcomes;
the per-thread turn gate and the per-tree token gate; status as an aggregate
over every run's close obligation; the two-write run close; the outbox with
idempotent apply; the six thread tools with ambient-only identity; the prompt
envelope; interrupted-run recovery; mid-run steering with rebook. None of this
knows how a body is started. It addresses agents by id and threads by file, so
it survives the change below.
**Implemented, but narrower than Multica.** Each identity owns a task-scoped
top-level ACP session with its own persona, model setting and transcript. It is
no longer a background subagent, and work on another task gets another session.
The ACP bridge still multiplexes those sessions in one process, owned by Qwen
Code's existing workspace Runtime and its durable host-session claim.
**Missing entirely.** Remote placement and execution; labels and due date on the
work item; squads; inbox; projects. Priority, acceptance criteria,
per-Agent instructions/model/concurrency and direct persistent-Agent creation
have landed. Reusable definitions are now an optional compatibility path.
## 3. Why the percentages I gave were wrong
I was scoring implementation against our own design document — which itself put
the wrong execution model in §1 and listed real process isolation as out of
scope in §10. That measures "how much of a plan we finished", not "how close
this is to what was asked for". Against your goal, the runtime layer is zero,
not eighty percent. I should not have used that number.
## 4. The plan
Four stages. Each is usable on its own; none needs the next to be worth having.
### Stage A — an agent is a top-level session
Multica binds an agent to a runtime and resumes a session per agent and issue.
This demo binds an identity to top-level ACP sessions keyed by agent and thread
on the current local daemon. That preserves task-local conversation without
claiming a registered runtime or OS-process boundary.
- No durable runtime record is invented for the local-only demo. An agent's
deterministic session id and the bridge's live-session record are the source
of truth. A persisted binding belongs with a real runtime registry, not with
a label that always means "this daemon".
- An agent session is spawned with `sourceType: 'agent'`,
`sourceId: <agent id>`. The child recognises itself at `newSession`, reads the
roster, and applies its own persona — `Config.systemPrompt` for the prompt
(which `getMainSessionBaseSystemPrompt` already honours), `deriveConfig` for
`getToolRegistry` / `getToolInvocationGuard` / `getModel`. **This hook already
exists**; the claim in §1 that it did not is what sent the design to
subagents.
- Agent status stops being derived and becomes Multica's: `offline` when no
session, `idle` when the session is live and free, `working` while a run is
bound, `blocked` when it owes a question, `error` after a terminal failure.
- `dispatch-port.ts` is rewritten against sessions: `inspect` reads the bridge's
live-session record, `start` is spawn-or-attach plus a prompt, `deliver` is
the session's existing mid-prompt input path. **The dispatcher, its rules and
every outcome it records are untouched** — the port was always the only thing
that knew what a body is.
- `launcher.ts` and `runtime-bridge.ts` are replaced: no
`launchProgrammaticBackgroundAgent`, no `BackgroundTaskRegistry`, no
in-process `AgentEventEmitter`.
Delivers: separate agent identity, persona and transcript. It does not deliver
process crash isolation or remote runtime placement.
### Stage B — the conversation is Qwen Code's, not a second one
I built a bespoke `ThreadView` with its own message rendering. Some of that UI
can reuse the existing conversation shell, but the shared thread itself cannot
be replaced by one ordinary session: it spans several agent sessions and owns
assignment, status, child work, routing outcomes and acceptance.
- The thread page keeps what only it knows: the status sentence, the run rows
with their close obligations, the budget line, assignment and status actions,
and the composer with its routing preview.
- Each run links into that agent's existing session view for the full model
transcript. Agent sessions remain visible in the normal conversation list.
- Thread posts stay the durable coordination record — who was asked, who
answered, what was booked — because no individual session owns that shared
history. The remaining UI work is reuse of the existing list shell and
message primitives, not deletion of the thread model.
### Stage C — the work item catches up
Today a thread is title, body, five statuses, assignee, parent. Multica's issue
carries priority, seven statuses, acceptance criteria, due date, labels,
project. In order of how much each changes behaviour rather than display:
1. **`acceptance_criteria`** — this is what an agent is checked against and what
`thread_review` should report against. It changes what the agent is told.
2. **Priority** — the dispatcher already picks by FIFO sequence; priority is the
one field that should be allowed to override that order.
3. **Labels and project** — grouping and filtering; display only.
4. **Due date** — display only until something schedules on it.
Statuses stay at five unless a use appears: `backlog` and `todo` are tracker
bookkeeping, and our `open` covers both.
### Stage D — the agent is configurable
Multica's agent detail page has instructions, env, MCP servers, custom args,
integrations and activity. Qwen Code reuses its rich Agent Builder UI, including
model-assisted generation, but the primary collaboration flow submits directly
to one workspace Agent record. The read-only v1 hides tool, MCP and hook options
that cannot take effect. Existing definitions remain an optional compatibility
path rather than a second required create step.
Squads, inbox and projects come after this, and only if you want them; none is
load-bearing for two agents collaborating on one thread.
## 5. What this costs, and what it breaks
Stage A rewrites three files — `launcher.ts`, `dispatch-port.ts`,
`runtime-bridge.ts` — and the `AgentMeta.agentRun` per-turn binding moves from an
in-process AsyncLocalStorage frame in the host to the agent session's turn seam.
Every test that mocks `BackgroundTaskRegistry` for workspace agents goes with
them. Nothing in the store, the rules, the tools or the REST surface changes.
N session contexts share one ACP process. A roster is two to five agents, so
the machine's memory is still the practical limit. A roster size limit belongs
in the UI, and `max_concurrent_tasks` is a per-agent field rather than a blanket
rule. A later runtime layer may place those sessions on separate processes or
hosts without changing the thread rules.
## 6. Decisions, made 2026-09-08
1. **An agent may work several threads at once.** `WorkspaceAgent.maxConcurrentRuns`,
default 1, mirroring Multica's `max_concurrent_tasks`. Decision 10 is
rewritten: serial was a consequence of a subagent owning one chat inside a
shared chat. A task-scoped top-level session makes concurrency a policy rather
than a fact. The default keeps today's behaviour until someone raises it, and
`queueLimit` stays a separate bound — throughput and backlog are different
questions.
_Where it lands:_ `selectCandidates` counts an agent's live runs against its
own limit instead of treating any live run as busy; `claimRun`'s
already-live check does the same. Both are single conditions.
2. **An agent's task sessions appear in the normal session list.** A person
opens them the way they open any other conversation. This reuses the existing
transcript and renderer for each task. The shared thread remains
the cross-agent coordination record because no one agent session owns it.
Only the dispatch host stays hidden, because it is infrastructure with no
conversation of its own.
_Where it lands:_ this subsystem-agent source type is excluded from the
host-session filters in `session-list.ts` and `acpAgent.ts`, not added to
them. The session is labelled by the agent so a list of five sessions reads
as five agents.
3. **Deleting an agent retires it; it never rewrites history.** Multica's shape.
The roster entry stops being addressable and reads `offline`, the session
closes, and every post the agent made keeps its name — those posts are
evidence other agents reasoned from, and erasing the author makes a thread
unreadable after the fact. Disable-and-drain remains the reversible middle.
_Where it lands:_ decision 8 rewritten; the tombstone snapshot becomes
unnecessary because the identity is retained rather than removed.
## 7. Stage A, concretely
In dependency order. Each item is small; the sequence is what matters.
1. `WorkspaceAgent` gains `maxConcurrentRuns`; its local session and status are
derived from the bridge rather than duplicated in the roster.
2. `workspace agents-agent` session source type, and persona resolution in the child at
`newSession` — roster lookup, `Config.systemPrompt`, `deriveConfig` for tool
registry, invocation guard and model.
3. `dispatch-port.ts` rewritten against the bridge: `inspect` from the live
session record, `start` as spawn-or-attach plus prompt, `deliver` as the
session's mid-prompt input path.
4. `launcher.ts` and `runtime-bridge.ts` deleted; their callers move to 3.
5. `selectCandidates` and `claimRun` honour `maxConcurrentRuns`.
6. The per-turn `(agent, run, thread)` binding moves from the host's
AsyncLocalStorage frame to the agent session, read at its turn seam.
The dispatcher's rules, the twelve admission outcomes, the store, the tools,
the prompt envelope and the REST surface are not touched by any of this.
## 8. What landed, 2026-09-08
All four stages are on `codex/multi-agent-mesh-foundation`. Nothing here was
built, typechecked or tested on the author's machine; ESLint is clean across
the changed surface and CI is the verification.
| Commit | What |
| ------------ | -------------------------------------------------------------------- |
| `2a8e23cb30` | Renamed the subsystem from mesh to workspace agents |
| `77578abab7` | Repaired the import paths the rename broke; persona applied at spawn |
| `cbc8958379` | Dispatch against one top-level ACP session per agent |
| `f663f779a1` | Token accounting from the session's own counter |
| `39da00b8f6` | Removed the subagent execution path |
| `b4055c3690` | Agent sessions named after their agent; runs link to them |
| `6616312c67` | Threads gained acceptance criteria and priority |
| `8d6e199cc4` | Deleting an agent retires it instead of erasing it |
| `59d326e422` | Agents are configurable; the capability ceiling is shown |
Stage A's initial sessionization is complete: `launcher.ts`, `dispatch-port.ts` and
`runtime-bridge.ts` are gone, along with `launchWorkspaceAgent`,
`dispatchAgentRuns` and the two ACP control methods behind them. Dispatch runs
in the daemon, where the sessions are. The follow-up correction scopes those
sessions to `(agent, thread)` instead of one transcript per identity.
The local host follow-up deliberately reused existing infrastructure rather
than adding a parallel host store. A non-empty roster restores the persisted
host-session owner after daemon restart. The observed restart reused host
session `f210855f-45ab-4624-a858-bf11785e22d0`. This is one real local host; it
is not the remote Runtime registry, heartbeat transport or placement layer
Multica has.
### Original-goal acceptance audit, 2026-09-08
This is a bounded acceptance pass against the original request, not a
percentage estimate.
| Original requirement | Acceptance evidence on this branch | Result |
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| Declare persistent Agent identities | The roster survives daemon restart; a fresh no-definition Agent applied its own durable instructions and model in its task session | Passed locally |
| Agent is not a background subagent or Agent Team member | Dispatch creates a top-level ACP session with `sourceType: agent`; the workspace-agent execution path no longer uses `BackgroundTaskRegistry` or Agent Team | Passed; sessions still share one ACP daemon |
| Assign and orchestrate tasks in a panel | The Web Shell creates root tasks with assignee, priority and acceptance criteria, nests child tasks, shows aggregate status, and lets a person mark work done | Passed locally |
| Agents collaborate through a shared thread | Live runs covered peer mentions, child delegation, parent reports, waiting, blocking and review | Passed locally |
| A person can intervene while an Agent is working | A live mid-turn message was persisted in the same run's transcript and consumed window, and changed the submitted review | Passed for the normal path; late-drain crash windows remain open |
| Reuse Qwen Code conversations | Agent task sessions appear under the existing sidebar's Agents source and open in the ordinary session view; the shared task keeps only cross-Agent coordination state | Passed locally |
| Multica/Harness-style Host layer | Registered Hosts, remote placement and remote execution are not part of this PR | Out of scope for this PR |
| Full Multica tracker breadth | Agent instructions/model/concurrency and task priority/acceptance criteria exist | Partial: labels, projects, inbox, squads, due dates and invocation policy are absent |
A later source audit found that sessionization alone had not delivered the
claimed persona: persona fields were assigned after `Config.initialize()` had
already bound the live chat, and an Agent with no linked definition fell back
to the built-in `general-purpose` subagent prompt. The correction refreshes the
live system instruction after persona/model resolution and gives the primary
no-definition path its own independent workspace-Agent identity. A fresh Web
Shell task reproduced a durable marker found only in that Agent's instructions
and explicitly identified itself as an independent workspace Agent, then
reached human acceptance. This overturns the earlier inference that writing
`Config.systemPrompt` before the first task prompt was sufficient.
Stage B turned out to be smaller than written. Agent sessions were already in
the ordinary session catalog, but the sidebar's Tasks filter hid them. The
session-source switch briefly carried an Agents tab backed by the same
`WorkspaceSection` and ordinary session page, adding no second conversation
list; it was removed on 2026-09-14 in favour of the run row's link to the
ordinary task session, which is now the only entry, and the switch is Tasks and
Channels again. The automatic title is now `Agent · Task`, while a person's
`/rename` remains authoritative. The
shared ledger still exists because it is the only record spanning several
Agents, but it reuses the existing Markdown renderer and the ordinary session
list instead of duplicating either. Root tasks appear once, child tasks stay
under their parent, and the child links back to that parent. The store carries
transcript-offset fields, but the session adapter does not produce them and the
REST/UI path does not consume them, so a run row currently opens the whole task
session rather than a proven run slice.
Stage C landed items 1 and 2 of the four. Labels, project and due date are
still display-only work and are not done.
Stage D landed instructions, model and concurrency as per-identity fields, plus
the capability ceiling as something a person can read. MCP
servers were deliberately not added: `classifyAgentTool` denies every name not
in its table and no MCP tool is in it, so the setting would do nothing.
Reaching MCP means moving the read-only ceiling, which is a separate decision.
The creation endpoint accepts those identity fields in its first roster write,
and the list reports status from live task sessions. The primary New Agent
action first offers model-assisted generation or manual configuration, then
submits Qwen Code's existing builder directly to the roster. Linking an existing
definition is a secondary compatibility action, not a competing create form.
This is one product object and one durable write.
### How this branch was verified
Everything below was run from `scripts/audit/` (since removed), and every one of them was
calibrated by breaking the thing it checks and watching it go red. A green run
that has never failed is not evidence.
| Check | What it covers |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `workspace-agent-orphans.py` | Exports whose only callers are tests, record fields nothing reads, baseline entries naming nothing, and the design doc's record diagrams against the real types |
| `tsconfig.workspace-agents-{core,cli}.json` | Narrow typechecks with `paths` pointed at package source rather than stale `dist` |
| `run-workspace-agents.mjs` | 139 assertions over the rules: store round-trip, the eleven admission outcomes, priority, retirement, budget boundaries, a run's whole life, all six thread tools under real run frames, delegation, blocking, waiting, the status aggregate, the parent-report outbox, crash recovery, the panel's view logic, and in-process concurrency |
| `run-workspace-agents-concurrency.mjs` | Ten real processes contending the file lock. Removing `lockfile.lock` loses 7 of 10 posts |
| `run-workspace-agents-crash.mjs` | A writer SIGKILLed holding the lock: the store stays readable and writes return unaided |
| `fuzz-workspace-agents.mjs` | Random operation sequences against the invariants. 25 seeds × 600 steps — 15,000 operations — with no violation |
How much those checks are worth was measured rather than assumed. Disabling
each of the 86 single-line guards in the subsystem one at a time and re-running
the harness caught 8 at first and 28 now. The remaining survivors fall into
three kinds, and the distinction matters more than the number:
- **Unobservable.** Removing `if (pending.length === 0) return` walks an empty
list to the same end; removing the unset-assignee check re-assigns undefined
over undefined. No assertion can catch these and writing one would be
theatre.
- **Unreachable.** `deliverParentReports` guards a report whose parent thread
is gone, but `deleteThread` refuses to delete a thread that has sub-threads,
so no supported operation produces that state. The refusal is asserted; the
guard behind it stays as defence.
- **Somebody else's.** Most of store.ts's 39 survivors are per-field
validators. store.test.ts covers fail-closed at the record level — checked,
not assumed — but a single field check can be disabled with every suite
still green. That is a real if minor gap.
Retired 2026-09-09: `run-workspace-agents-concurrency.mjs`,
`run-workspace-agents-crash.mjs` and `fuzz-workspace-agents.mjs`. Their results above
stand as recorded; the questions they answered were one-time. The orphan sweep, the
two tsconfigs and `run-workspace-agents.mjs` were removed later with the rest of
`scripts/audit/`, so none of the checks above can be re-run from this branch.
What none of it covers: the vitest suites, which are larger and still need CI;
partial-write recovery, since the file lock means two writers never touch one
file and killing an idle holder never interrupts a write (`store.test.ts`
covers that with fault injection). Decisions 4 and 12 of §2 are covered as of
ae7deeeaf9's successor: an agent has no agent-creating tool in reach and the
guard refuses it anyway, and a run frame naming another workspace cannot write
here.
### Found on this branch, outside this subsystem
`client/App.tsx` reads `teamName` off `DaemonSessionAgentTaskStatus` in four
places, and the wire type does not declare it — the only `teamName` in core is
in the team test harness, so the daemon does not appear to send it either.
Measured, not guessed: `packages/web-shell` typechecks to 635 errors on
`origin/main` and to the same 635 on this branch, but the sets differ — the
branch fixes three of main's and adds these four. It belongs to the Agent Team
track rather than to workspace agents, so it is reported here rather than
fixed: whether the field should be added to the type or the reads removed is a
question for whoever owns that surface.
### Still open
- **Definition drift is designed in but never fed.** `bindRunSession` stores a
`definitionVersion` and the turn envelope renders it, but `definitionVersion`
is an optional port method and the session dispatch port does not implement
it, so every envelope reads `definition=unversioned`. Supplying it means
hashing the agent definition, which the port cannot do today: it holds the
bridge and a workspace path, while definitions load through core's
`SubagentManager` against a `Config`. Either the port gains that reach or the
child stamps the hash it already resolved at boot — a decision, not a wiring
fix, which is why this tick recorded it rather than guessing.
- Labels, project and due date on a thread (Stage C, items 3 and 4).
- §9.9 envelope role transport, §9.10 parent-to-child replies, §9.11 human
blocker acknowledgement scope — all owner decisions, unchanged.
- Squads, inbox and projects, which remain out of scope until asked for.
<details>
<summary>中文说明</summary>
**Multica 实际是什么**:Linear 形态的 issue tracker,assignee 可以是 agent,外加一层 runtime 注册。`agent_runtime` 是独立实体(workspace + daemon_id + provider,带在线状态和心跳),agent 绑定到它上面;`agent_task_queue` 是 agent × issue 的派发队列;`issue` 有优先级、7 种状态、验收标准、截止日期、标签、项目;对话就是 issue 上的 comment。页面里有独立的 runtimes 和 runtimes/[id]。
**我们的状态**:底层协作规则已经具备;每个 `(agent, task)` 都是独立的顶层 ACP session,不再是 background subagent,同一 Agent 处理不同任务也不会共用 transcript。这些 session 仍共享一个 ACP daemon;远程 Host 注册、放置、远程执行和进程级故障隔离都不在本 PR 范围内。工作项已有优先级和验收标准;标签、截止日期、project、squad、inbox 仍未实现。
**之前那个七八成错在哪**:我拿自己那份设计文档当卷子打分,而文档 §1 当时就把执行模型定错了。现在修正的是本地 demo 主链路;若按 Multica 完整产品计算,远程 Runtime、权限、项目和收件箱仍然不存在,不能再用百分比掩盖不同分母。
**方案四步**:A 让 agent 变成顶层 session(靠 sourceType 认领身份并加载人格),但不谎称它已有独立进程;B agent 的完整执行记录复用 Qwen Code 原有会话,shared thread 继续保存跨 agent 的指派、状态、@ 与验收,再复用现有列表壳和消息组件;C 工作项补上验收标准和优先级,标签和截止日期次之;D agent 变成可配置对象,并把已有 definition builder 与 persistent roster 合成一条创建路径。squad/inbox/projects 排在最后,且不是两个 agent 协作的必要条件。
**已定的三件事**:每个 Agent 可按 `maxConcurrentRuns` 并发多个任务;每个 task-scoped session 进入普通会话列表;删除采用退役语义,停止接活但保留任务内历史署名。
</details>

View file

@ -203,11 +203,6 @@ import {
saveManagedSelection,
} from './components/managed/managed-session-storage';
import { AgentsManagerPage } from './components/agents/AgentsManagerPage';
import { ThreadsRoute } from './components/workspace-agents/ThreadsRoute';
import {
conversationContext,
useAgentChatEntry,
} from './components/workspace-agents/useAgentChatEntry';
import { MemoryMessage } from './components/messages/MemoryMessage';
import { AuthMessage } from './components/messages/AuthMessage';
import { ToolsDialog } from './components/dialogs/ToolsDialog';
@ -1912,8 +1907,7 @@ type PersistedArtifactPanelTab =
| Pick<
Extract<ArtifactPanelTab, { kind: 'workflow' }>,
'id' | 'kind' | 'title' | 'sessionId'
>
| Extract<ArtifactPanelTab, { kind: 'agent_activity' }>;
>;
function parsePersistedArtifactPanelTab(
value: unknown,
@ -2116,18 +2110,6 @@ function parsePersistedArtifactPanelTab(
sessionId: tab['sessionId'],
closeWithPane: tab['closeWithPane'],
} as PersistedArtifactPanelTab;
case 'agent_activity':
if (
typeof tab['threadId'] !== 'string' ||
typeof tab['workspaceCwd'] !== 'string'
)
return;
return {
...common,
kind: 'agent_activity',
threadId: tab['threadId'],
workspaceCwd: tab['workspaceCwd'],
};
case 'workflow':
return {
...common,
@ -2293,16 +2275,6 @@ function serializeArtifactPanelTabs(
},
]
: [];
case 'agent_activity':
return [
{
id,
kind: tab.kind,
title,
threadId: tab.threadId,
workspaceCwd: tab.workspaceCwd,
},
];
case 'workflow':
return [{ id, kind: tab.kind, title, sessionId: tab.sessionId }];
case 'pending': {
@ -2778,9 +2750,7 @@ function derivedTaskIdForTool(tool: ACPToolCall): string | undefined {
const subagentName =
typeof rawOutput?.['subagentName'] === 'string'
? rawOutput['subagentName']
: typeof tool.args?.name === 'string'
? tool.args.name
: undefined;
: undefined;
const subagentType =
typeof tool.args?.subagent_type === 'string'
? tool.args.subagent_type
@ -2864,9 +2834,7 @@ export function getEnvironmentAgentTasks(
const subagentName =
typeof rawOutput?.['subagentName'] === 'string'
? rawOutput['subagentName']
: typeof tool.args?.name === 'string'
? tool.args.name
: undefined;
: undefined;
const taskId = taskIdsByToolUseId.get(tool.callId);
const derivedTaskId = derivedTaskIdForTool(tool);
// Completed background agents can lose their toolUseId / derived-id
@ -6659,8 +6627,6 @@ export function App({
const { taskId: _taskId, ...rest } = tab;
return { ...rest, task, sessionActions } as ArtifactPanelTab;
}
case 'agent_activity':
return tab;
case 'side_task':
return tab.sessionId ? tab : undefined;
case 'terminal':
@ -9167,55 +9133,6 @@ export function App({
const navigateToMessage = useMessageNavigation(messageListRef, chatActive);
const activePanelRef = useRef(activePanel);
const [collaborationThread, setCollaborationThread] = useState<
{ id: string; cwd: string; server: string } | undefined
>(() => {
try {
const saved = JSON.parse(
sessionStorage.getItem('qwen:team-conversation') ?? 'null',
);
return saved &&
typeof saved.id === 'string' &&
typeof saved.cwd === 'string' &&
typeof saved.server === 'string'
? saved
: undefined;
} catch {
return undefined;
}
});
const collaborationThreadId =
collaborationThread !== undefined &&
collaborationThread.server === workspace.baseUrl
? collaborationThread.id
: undefined;
const [collaborationTitle, setCollaborationTitle] = useState<{
id: string;
title: string;
}>();
const [collaborationHeaderActions, setCollaborationHeaderActions] =
useState<HTMLDivElement | null>(null);
const updateCollaborationTitle = useCallback((id: string, title: string) => {
setCollaborationTitle((current) =>
current?.id === id && current.title === title ? current : { id, title },
);
}, []);
const [agentsNav, setAgentsNav] = useState<{
view: 'agents' | 'tasks' | 'new-agent';
request: number;
}>({ view: 'agents', request: 0 });
useEffect(() => {
try {
if (collaborationThread)
sessionStorage.setItem(
'qwen:team-conversation',
JSON.stringify(collaborationThread),
);
else sessionStorage.removeItem('qwen:team-conversation');
} catch {
/* Storage may be unavailable in embedded hosts. */
}
}, [collaborationThread]);
// Deep-link target for the Settings panel (e.g. 'Daemon' from the Local
// Control QR popover). Cleared on any panel close/switch, not just
// closePanel — several paths call setActivePanel directly (approval
@ -10280,11 +10197,6 @@ export function App({
connection.sessionContext?.kind === 'standalone'
? (sessionStatusDisplayName ?? connection.displayName)
: (connection.displayName ?? sessionStatusDisplayName);
const chatHeaderTitle = collaborationThreadId
? collaborationTitle?.id === collaborationThreadId
? collaborationTitle.title
: '协作对话'
: sessionDisplayName;
useEffect(() => {
onSessionInfoChange?.({
sessionId: connection.sessionId,
@ -13675,7 +13587,6 @@ export function App({
pushToast('warning', t('session.recoveryBlocksAction'));
return false;
}
setCollaborationThread(undefined);
pendingManualTitleRef.current = opts?.carryManualTitle
? { displayName: opts.carryManualTitle }
: undefined;
@ -14686,7 +14597,6 @@ export function App({
workspaceCwd?: string,
sessionContext?: DaemonProductSessionContext,
) => {
setCollaborationThread(undefined);
pendingManualTitleRef.current = undefined;
splitClassificationGenerationRef.current += 1;
const invocation = ++sessionOpenInvocationRef.current;
@ -18630,49 +18540,6 @@ export function App({
!showFloatingTodos &&
!pendingApproval &&
!btwMessage;
const handleCollaborationThreadOpen = useCallback(
(id: string, cwd: string) => {
setCollaborationThread({ id, cwd, server: workspace.baseUrl });
setMainView('chat');
setActivePanel(null);
},
[workspace.baseUrl],
);
const handleCollaborationThreadError = useCallback(
(message: string) => pushToast('error', message),
[pushToast],
);
const displayMessagesRef = useRef(displayMessages);
displayMessagesRef.current = displayMessages;
const getMentionContext = useCallback(
() => conversationContext(displayMessagesRef.current),
[],
);
const agentChatEntry = useAgentChatEntry({
enabled: Boolean(
workspace.capabilities?.features?.includes('agent_collaboration_v1'),
),
getContext: getMentionContext,
t,
cwd: legacyWorkspaceContextCwd,
baseUrl: workspace.baseUrl,
token: workspace.token,
onSubmit: handleEditorSubmit,
onOpen: handleCollaborationThreadOpen,
onError: handleCollaborationThreadError,
onCreateAgent: () => {
setAgentsNav((current) => ({
view: 'new-agent',
request: current.request + 1,
}));
setAgentsCreateScope(null);
openPanel('agents');
},
});
const composerAtProviders = useMemo(
() => [...(atProviders ?? []), ...agentChatEntry.providers],
[atProviders, agentChatEntry.providers],
);
const visibleComposerToolbarActions = useMemo<
readonly ComposerToolbarAction[]
>(() => {
@ -18915,9 +18782,7 @@ export function App({
const appClassName = [
styles.app,
styles.appChat,
isChatEmptyState && !collaborationThreadId
? styles.appChatEmpty
: undefined,
isChatEmptyState ? styles.appChatEmpty : undefined,
sidebarOptions.enabled ? styles.appWithSidebar : undefined,
selectedTheme === WebShellThemeId.Light
? styles.themeLight
@ -19217,8 +19082,6 @@ export function App({
// Shared by the drawer and docked render sites below; only the genuine
// per-variant props (variant / panelWidth) stay at each site.
const artifactPanelSharedProps = {
onOpenCollaborationSession: (sessionId: string, workspaceCwd: string) =>
void loadSidebarSession(sessionId, workspaceCwd),
onSelectTurnCallsPrompt: openTurnCalls,
artifacts: artifactPanelArtifacts,
tabs: artifactPanelTabs,
@ -19819,12 +19682,6 @@ export function App({
aria-hidden="true"
/>
<WebShellSidebar
selectedCollaborationId={collaborationThreadId}
onOpenCollaboration={(id, cwd) => {
setCollaborationThread({ id, cwd, server: workspace.baseUrl });
setMainView('chat');
closePanel();
}}
collapsed={
(sidebarCollapsed ||
(mainView === 'split' && !splitSidebarHasRoom)) &&
@ -19835,12 +19692,6 @@ export function App({
closeMobileDrawer();
openPanel('settings');
}}
onOpenAgents={(view = 'agents') => {
setAgentsNav(current => ({view, request: current.request + 1}));
closeMobileDrawer();
setAgentsCreateScope(null);
openPanel('agents');
}}
onOpenPlugins={() => {
closeMobileDrawer();
openPanel('plugins');
@ -20054,7 +19905,7 @@ export function App({
aria-hidden={artifactPanelFullscreen || undefined}
>
{chatHeaderEnabled &&
(!isChatEmptyState || Boolean(collaborationThreadId)) &&
!isChatEmptyState &&
!activePanel &&
(mainView === 'chat' || mainView === 'cockpit') && (
<div className={styles.chatHeaderRow}>
@ -20089,7 +19940,7 @@ export function App({
<div className={styles.customChatHeader}>
{renderChatHeader({
sessionId: connection.sessionId,
sessionName: chatHeaderTitle,
sessionName: sessionDisplayName,
workspaceCwd: workspaceContextActive
? connection.workspaceCwd
: undefined,
@ -20127,7 +19978,7 @@ export function App({
<ChatContextHeader
content={
titleHeaderItemVisible
? (chatHeaderTitle ?? t('session.new'))
? (sessionDisplayName ?? t('session.new'))
: null
}
workspaceName={headerWorkspaceName}
@ -20175,7 +20026,6 @@ export function App({
}
/>
)}
{collaborationThreadId && <div ref={setCollaborationHeaderActions} className="flex shrink-0 items-center pr-3" />}
{sessionWorkflowEnabled &&
(sessionWorkflowTodos.length > 0 ||
mainView === 'cockpit') && (
@ -20219,14 +20069,14 @@ export function App({
>
{sidebarOptions.enabled &&
sidebarOptions.showCompactToggle &&
(!chatHeaderEnabled || (isChatEmptyState && !collaborationThreadId)) &&
(!chatHeaderEnabled || isChatEmptyState) &&
!activePanel &&
mainView === 'chat' && (
<button
type="button"
className={[
styles.hamburgerButton,
!chatHeaderEnabled || (isChatEmptyState && !collaborationThreadId)
!chatHeaderEnabled || isChatEmptyState
? styles.hamburgerButtonFloating
: undefined,
]
@ -20509,25 +20359,11 @@ export function App({
/>
) : activePanel === 'agents' ? (
<AgentsManagerPage
key={agentsNav.request}
initialAgentView={agentsNav.view}
onOpenThreadChat={(threadId, cwd) => {
setCollaborationThread({ id: threadId, cwd, server: workspace.baseUrl });
setMainView('chat');
closePanel();
}}
onClose={() => {
setAgentsCreateScope(null);
closePanel();
}}
initialCreateScope={agentsCreateScope}
onOpenAgentSession={(sessionId) => {
// An agent is its own session, so a run opens the
// ordinary session view. `loadSidebarSession`
// already closes this panel on its way there.
setAgentsCreateScope(null);
void loadSidebarSession(sessionId);
}}
/>
) : activePanel === 'plugins' ? (
<PluginManagerPage
@ -20973,22 +20809,7 @@ export function App({
: undefined
}
>
{collaborationThreadId && (
<ThreadsRoute key={`${collaborationThread?.cwd}:${collaborationThreadId}`} chat initialThreadId={collaborationThreadId}
workspaceCwd={collaborationThread?.cwd}
headerActionsContainer={collaborationHeaderActions}
onTitleChange={updateCollaborationTitle}
onOpenActivity={(threadId, workspaceCwd) => {
const tab: ArtifactPanelTab = { id: `agent-activity:${workspaceCwd}:${threadId}`, kind: 'agent_activity', title: t('collab.team.title'), threadId, workspaceCwd };
setArtifactPanelTabs((tabs) => tabs.some((item) => item.id === tab.id) ? tabs : [...tabs, tab]);
setActiveArtifactPanelTabId(tab.id);
setArtifactPanelWidth((width) => artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth());
setArtifactPanelOpen(true);
}}
onOpenThreadChat={(id, cwd) => setCollaborationThread({ id, cwd, server: workspace.baseUrl })}
onOpenAgentSession={(sessionId) => void loadSidebarSession(sessionId, collaborationThread?.cwd)} />
)}
{!collaborationThreadId && showMissingSessionState && (
{showMissingSessionState && (
<div className={styles.missingSessionState}>
<div className={styles.missingSessionMessage}>
{t('session.missing')}
@ -21005,7 +20826,7 @@ export function App({
)}
<div
className={
showMissingSessionState || collaborationThreadId
showMissingSessionState
? styles.chatSubtreeHidden
: styles.chatSubtree
}
@ -21675,7 +21496,7 @@ export function App({
<ChatEditor
ref={setEditorHandle}
compactOverlays={compactComposerOverlays}
onSubmit={agentChatEntry.submit}
onSubmit={handleEditorSubmit}
onInputTextChange={handleComposerTextChange}
onAttachmentsChange={
handleComposerAttachmentsChange
@ -21700,7 +21521,7 @@ export function App({
}
cancelArmed={cancelArmed}
disabled={
agentChatEntry.pending || isDisabled ||
isDisabled ||
isStartingNewSessionSuggestion ||
interactionBlocked ||
approvalOverlayActive ||
@ -21738,7 +21559,7 @@ export function App({
builtinAtProviders={
workspaceContextActive ? builtinAtProviders : false
}
atProviders={composerAtProviders}
atProviders={atProviders}
composerTagIcons={composerTagIcons}
voiceTarget={
activePanel !== null || mainView !== 'chat'

View file

@ -3,7 +3,6 @@ import { readFileSync, readdirSync } from 'node:fs';
import { resolve } from 'node:path';
import postcss, { type Rule } from 'postcss';
import { LIVE_MESSAGES_EN } from './live/messages';
import { COLLAB_MESSAGES_EN } from './components/workspace-agents/messages';
const DIST_DIR = resolve(__dirname, '../dist');
const DIST_PATH = resolve(DIST_DIR, 'index.js');
@ -427,21 +426,6 @@ describe('build artifact — transcript entry (#11031)', () => {
expect(js).not.toContain('Talk in this browser');
});
it('carries no collaboration strings and looks none up', () => {
// Same arrangement as Live Voice: the collaboration dictionary is stubbed
// in this build, which is only safe while no transcript surface asks for
// one of its keys.
const js = readTranscriptBundle().replace(
/^const __qwenWebShellCss=[^\n]*\n/,
'',
);
const keys = Object.keys(COLLAB_MESSAGES_EN);
expect(keys.length).toBeGreaterThan(50);
expect(
keys.filter((key) => js.includes(`"${key}"`) || js.includes(`'${key}'`)),
).toEqual([]);
});
it('carries no settings strings and looks none up', () => {
// Same contract as the Live Voice stub above, for
// client/settings/messages.ts: a settings.* lookup here would render as

View file

@ -46,19 +46,10 @@ import {
} from './agent-tool-options';
interface AgentCreatePageProps {
workspaceCwd?: string;
initialScope?: 'workspace' | 'global';
agent?: DaemonWorkspaceAgentDetail;
onCancel: () => void;
onCreated: (name: string) => void;
onSaveWorkspaceAgent?: (input: {
name: string;
description?: string;
instructions?: string;
agentType?: string;
model?: string;
maxConcurrentRuns: number;
}) => Promise<void>;
}
type Translate = ReturnType<typeof useI18n>['t'];
@ -95,7 +86,6 @@ function toggleSelection(
}
const approvalModes = ['inherit', ...DAEMON_APPROVAL_MODES];
const NO_ROLE = '__none__';
const MCP_DISCOVERY_POLL_MS = 1_500;
const MCP_DISCOVERY_MAX_ATTEMPTS = 40;
@ -104,25 +94,14 @@ export function AgentCreatePage({
agent,
onCancel,
onCreated,
workspaceCwd,
onSaveWorkspaceAgent,
}: AgentCreatePageProps) {
const { t } = useI18n();
const workspaceAgentMode = onSaveWorkspaceAgent !== undefined;
const {
createAgent,
updateAgent,
generateContent,
agents: roles,
} = useAgents({
// The role list is only offered when creating a workspace Agent.
autoLoad: workspaceAgentMode,
const { createAgent, updateAgent, generateContent } = useAgents({
autoLoad: false,
});
const toolsResource = useTools({ autoLoad: false });
const mcpResource = useMcp({ autoLoad: false });
const settingsResource = useSettings({
autoLoad: !workspaceAgentMode,
});
const settingsResource = useSettings({ autoLoad: true });
const loadMcpTools = mcpResource.loadTools;
const initializeMcp = mcpResource.initialize;
const reloadMcpConfig = mcpResource.reloadConfig;
@ -149,8 +128,6 @@ export function AgentCreatePage({
const selectableApprovalModes =
approvalMode === 'bubble' ? [...approvalModes, 'bubble'] : approvalModes;
const [maxTurns, setMaxTurns] = useState(agent?.maxTurns?.toString() ?? '');
const [maxConcurrentRuns, setMaxConcurrentRuns] = useState('1');
const [role, setRole] = useState('');
const [color, setColor] = useState(agent?.color ?? 'inherit');
const [selectedMcpServers, setSelectedMcpServers] = useState(
() => new Set(Object.keys(agent?.mcpServers ?? {})),
@ -213,10 +190,8 @@ export function AgentCreatePage({
);
const activeMcpServerKey = activeMcpServerNames.join('\0');
// A workspace Agent needs only a name: a role or the defaults cover the rest.
const canSave = Boolean(
name.trim() &&
(workspaceAgentMode || (description.trim() && systemPrompt.trim())),
name.trim() && description.trim() && systemPrompt.trim(),
);
useEffect(
@ -229,10 +204,6 @@ export function AgentCreatePage({
);
useEffect(() => {
if (workspaceAgentMode) {
setCatalogLoading(false);
return;
}
let active = true;
const initializeCatalogs = async () => {
setCatalogLoading(true);
@ -300,18 +271,9 @@ export function AgentCreatePage({
return () => {
active = false;
};
}, [
initializeMcp,
preheatAcp,
reloadMcp,
reloadMcpConfig,
reloadTools,
t,
workspaceAgentMode,
]);
}, [initializeMcp, preheatAcp, reloadMcp, reloadMcpConfig, reloadTools, t]);
useEffect(() => {
if (workspaceAgentMode) return;
if (catalogLoading) return;
if (!activeMcpServerKey) {
setMcpTools({});
@ -357,13 +319,7 @@ export function AgentCreatePage({
return () => {
active = false;
};
}, [
activeMcpServerKey,
activeMcpServerNames,
catalogLoading,
loadMcpTools,
workspaceAgentMode,
]);
}, [activeMcpServerKey, activeMcpServerNames, catalogLoading, loadMcpTools]);
function setGenerationDialogOpen(open: boolean): void {
if (!open) {
@ -412,8 +368,8 @@ export function AgentCreatePage({
try {
const suffix =
field === 'description'
? `Return only a concise one-sentence ${workspaceAgentMode ? 'persistent Agent' : 'subagent'} description explaining when this agent should be used. Do not return JSON, Markdown, a label, or commentary.`
: `Return only the complete ${workspaceAgentMode ? 'persistent Agent' : 'subagent'} system prompt. Do not return JSON, a Markdown code block, a name, a description, or commentary.`;
? 'Return only a concise one-sentence subagent description explaining when this subagent should be used. Do not return JSON, Markdown, a label, or commentary.'
: 'Return only the complete subagent system prompt. Do not return JSON, a Markdown code block, a name, a description, or commentary.';
let generated = '';
for await (const event of generateContent(`${request}\n\n${suffix}`, {
signal: controller.signal,
@ -482,27 +438,6 @@ export function AgentCreatePage({
setBusy(true);
setError(null);
try {
if (onSaveWorkspaceAgent) {
const concurrency = Number(maxConcurrentRuns);
if (
!Number.isInteger(concurrency) ||
concurrency < 1 ||
concurrency > 8
) {
throw new Error(t('collab.agent.concurrencyInvalid'));
}
const trimmedName = name.trim();
await onSaveWorkspaceAgent({
name: trimmedName,
...(description.trim() ? { description: description.trim() } : {}),
...(systemPrompt.trim() ? { instructions: systemPrompt.trim() } : {}),
...(role ? { agentType: role } : {}),
...(model.trim() ? { model: model.trim() } : {}),
maxConcurrentRuns: concurrency,
});
onCreated(trimmedName);
return;
}
const parsedMaxTurns = maxTurns.trim()
? Number(maxTurns.trim())
: undefined;
@ -570,11 +505,7 @@ export function AgentCreatePage({
<div className="flex w-full max-w-5xl flex-col gap-6">
<div className="flex items-start justify-between gap-4">
<h1 className="text-xl font-semibold text-balance">
{workspaceAgentMode
? t('collab.agent.new')
: agent
? t('agent.edit')
: t('agent.create')}
{agent ? t('agent.edit') : t('agent.create')}
</h1>
<Button type="button" variant="outline" onClick={openGenerationDialog}>
<SparklesIcon data-icon="inline-start" />
@ -593,56 +524,45 @@ export function AgentCreatePage({
</ManagementNotice>
) : null}
{workspaceAgentMode && (
<p className="text-sm text-muted-foreground">
{t('collab.agent.joins', {
project: workspaceCwd?.split(/[\\/]/).filter(Boolean).at(-1) ?? '',
})}
</p>
)}
<Tabs defaultValue="overview">
{!workspaceAgentMode && (
<TabsList className="max-w-full overflow-x-auto">
<TabsTrigger value="overview">
{t('agent.detail.overview')}
</TabsTrigger>
<TabsTrigger value="prompt">
{t('agent.detail.systemPrompt')}
</TabsTrigger>
<TabsTrigger value="tools">{t('agent.detail.tools')}</TabsTrigger>
<TabsTrigger value="mcp">{t('agent.detail.mcp')}</TabsTrigger>
<TabsTrigger value="hooks">{t('agent.detail.hooks')}</TabsTrigger>
</TabsList>
)}
<TabsList className="max-w-full overflow-x-auto">
<TabsTrigger value="overview">
{t('agent.detail.overview')}
</TabsTrigger>
<TabsTrigger value="prompt">
{t('agent.detail.systemPrompt')}
</TabsTrigger>
<TabsTrigger value="tools">{t('agent.detail.tools')}</TabsTrigger>
<TabsTrigger value="mcp">{t('agent.detail.mcp')}</TabsTrigger>
<TabsTrigger value="hooks">{t('agent.detail.hooks')}</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="pt-4">
<FieldGroup className="grid grid-cols-1 gap-5 lg:grid-cols-2">
{!workspaceAgentMode ? (
<Field>
<FieldLabel htmlFor="agent-scope">
{t('agent.create.scope')}
</FieldLabel>
<Select
value={scope}
disabled={Boolean(agent)}
onValueChange={(value) =>
setScope(value as 'workspace' | 'global')
}
>
<SelectTrigger id="agent-scope" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="workspace">
{t('agent.create.project.cli')}
</SelectItem>
<SelectItem value="global">
{t('agent.create.user.cli')}
</SelectItem>
</SelectContent>
</Select>
</Field>
) : null}
<Field>
<FieldLabel htmlFor="agent-scope">
{t('agent.create.scope')}
</FieldLabel>
<Select
value={scope}
disabled={Boolean(agent)}
onValueChange={(value) =>
setScope(value as 'workspace' | 'global')
}
>
<SelectTrigger id="agent-scope" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="workspace">
{t('agent.create.project.cli')}
</SelectItem>
<SelectItem value="global">
{t('agent.create.user.cli')}
</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="agent-name">
@ -655,62 +575,12 @@ export function AgentCreatePage({
placeholder={t('agent.create.namePlaceholder')}
disabled={Boolean(agent)}
/>
<FieldDescription>
{workspaceAgentMode
? t('collab.agent.nameHelp')
: t('agent.create.nameHelp')}
</FieldDescription>
<FieldDescription>{t('agent.create.nameHelp')}</FieldDescription>
</Field>
{workspaceAgentMode && (
<Field>
<FieldLabel htmlFor="agent-role">
{t('collab.agent.role')}
</FieldLabel>
<Select
value={role || NO_ROLE}
onValueChange={(value) =>
setRole(value === NO_ROLE ? '' : value)
}
>
<SelectTrigger id="agent-role" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_ROLE}>
{t('collab.agent.roleNone')}
</SelectItem>
{roles.map((entry) => (
<SelectItem key={entry.name} value={entry.name}>
{entry.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldDescription>
{t('collab.agent.roleHint')}
</FieldDescription>
</Field>
)}
{workspaceAgentMode && (
<Field className="lg:col-span-2">
<FieldLabel htmlFor="agent-workspace-prompt">
{t('collab.agent.instructions')}
</FieldLabel>
<Textarea
id="agent-workspace-prompt"
value={systemPrompt}
onChange={(event) => setSystemPrompt(event.target.value)}
rows={6}
placeholder={t('collab.agent.instructionsHint')}
/>
</Field>
)}
<Field className="lg:col-span-2">
<FieldLabel htmlFor="agent-description">
{workspaceAgentMode
? t('collab.agent.description')
: t('agent.create.description')}
{t('agent.create.description')}
</FieldLabel>
<Textarea
id="agent-description"
@ -733,127 +603,95 @@ export function AgentCreatePage({
/>
</Field>
{workspaceAgentMode ? (
<>
<Field>
<FieldLabel htmlFor="agent-concurrency">
{t('collab.agent.concurrency')}
</FieldLabel>
<Input
id="agent-concurrency"
type="number"
min="1"
max="8"
step="1"
value={maxConcurrentRuns}
onChange={(event) =>
setMaxConcurrentRuns(event.target.value)
}
/>
</Field>
<Field className="lg:col-span-2">
<FieldDescription>
{t('collab.agent.afterCreate')}
</FieldDescription>
</Field>
</>
) : (
<>
<Field>
<FieldLabel htmlFor="agent-approval">
{t('agent.create.approvalMode')}
</FieldLabel>
<Select value={approvalMode} onValueChange={setApprovalMode}>
<SelectTrigger id="agent-approval" className="w-full">
<SelectValue>
{approvalModeLabel(approvalMode, t)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{selectableApprovalModes.map((value) => (
<SelectItem
key={value}
value={value}
disabled={value === 'bubble'}
>
{approvalModeLabel(value, t)}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldDescription>
{approvalModeDescription(approvalMode, t)}
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="agent-approval">
{t('agent.create.approvalMode')}
</FieldLabel>
<Select value={approvalMode} onValueChange={setApprovalMode}>
<SelectTrigger id="agent-approval" className="w-full">
<SelectValue>
{approvalModeLabel(approvalMode, t)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{selectableApprovalModes.map((value) => (
<SelectItem
key={value}
value={value}
disabled={value === 'bubble'}
>
{approvalModeLabel(value, t)}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldDescription>
{approvalModeDescription(approvalMode, t)}
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="agent-max-turns">
{t('agent.create.maxTurns')}
</FieldLabel>
<Input
id="agent-max-turns"
type="number"
min="1"
step="1"
value={maxTurns}
onChange={(event) => setMaxTurns(event.target.value)}
/>
<FieldDescription>
{t('agent.create.maxTurnsHelp')}
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="agent-max-turns">
{t('agent.create.maxTurns')}
</FieldLabel>
<Input
id="agent-max-turns"
type="number"
min="1"
step="1"
value={maxTurns}
onChange={(event) => setMaxTurns(event.target.value)}
/>
<FieldDescription>
{t('agent.create.maxTurnsHelp')}
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="agent-color">
{t('agent.create.color')}
</FieldLabel>
<Select value={color} onValueChange={setColor}>
<SelectTrigger id="agent-color" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[
'inherit',
'auto',
'red',
'blue',
'green',
'yellow',
'purple',
'orange',
'pink',
'cyan',
].map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</>
)}
<Field>
<FieldLabel htmlFor="agent-color">
{t('agent.create.color')}
</FieldLabel>
<Select value={color} onValueChange={setColor}>
<SelectTrigger id="agent-color" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[
'inherit',
'auto',
'red',
'blue',
'green',
'yellow',
'purple',
'orange',
'pink',
'cyan',
].map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</FieldGroup>
</TabsContent>
{!workspaceAgentMode && (
<TabsContent value="prompt" className="pt-4">
<Field>
<Textarea
id="agent-prompt"
aria-label={t('agent.create.prompt')}
value={systemPrompt}
onChange={(event) => setSystemPrompt(event.target.value)}
placeholder={t('agent.create.promptPlaceholder.cli')}
rows={16}
className="min-h-80 max-h-[60vh] overflow-y-auto"
/>
<FieldDescription>
{t('agent.create.promptHelp')}
</FieldDescription>
</Field>
</TabsContent>
)}
<TabsContent value="prompt" className="pt-4">
<Field>
<Textarea
id="agent-prompt"
aria-label={t('agent.create.prompt')}
value={systemPrompt}
onChange={(event) => setSystemPrompt(event.target.value)}
placeholder={t('agent.create.promptPlaceholder.cli')}
rows={16}
className="min-h-80 max-h-[60vh] overflow-y-auto"
/>
<FieldDescription>{t('agent.create.promptHelp')}</FieldDescription>
</Field>
</TabsContent>
<TabsContent value="tools" className="pt-4">
<FieldGroup>

View file

@ -12,7 +12,6 @@ import {
import {
DAEMON_APPROVAL_MODES,
useAgents,
useWorkspace,
type DaemonWorkspaceAgentDetail,
} from '@qwen-code/web-shell/daemon-react-sdk';
import { useI18n } from '../../i18n';
@ -26,12 +25,6 @@ import {
type AgentLevelFilter,
} from './agents-manager-logic';
import { AgentCreatePage } from './AgentCreatePage';
/**
* Advertised only while the daemon has the collaboration opt-in on; see
* `CONDITIONAL_SERVE_FEATURES` in packages/cli/src/serve/capabilities.ts.
*/
const AGENT_COLLABORATION_FEATURE = 'agent_collaboration_v1';
import { ThreadsRoute } from '../workspace-agents/ThreadsRoute';
import {
AlertDialog,
AlertDialogAction,
@ -82,13 +75,9 @@ import type { EmbeddedManagerPage } from '../plugins/manager-page';
import styles from './AgentsManagerPage.module.css';
interface AgentsManagerPageProps {
initialAgentView?: 'agents' | 'tasks' | 'new-agent';
onClose: () => void;
embedded?: EmbeddedManagerPage;
initialCreateScope?: 'workspace' | 'global' | null;
/** Opens an agent's own session in the shell's session view. */
onOpenAgentSession?: (sessionId: string) => void;
onOpenThreadChat?: (threadId: string, workspaceCwd: string) => void;
}
function levelLabel(level: string, t: ReturnType<typeof useI18n>['t']): string {
@ -136,12 +125,9 @@ function unwrapPlainText(value: string): string {
}
export function AgentsManagerPage({
initialAgentView,
onClose,
embedded,
initialCreateScope,
onOpenAgentSession,
onOpenThreadChat,
}: AgentsManagerPageProps) {
const { t } = useI18n();
const {
@ -164,23 +150,6 @@ export function AgentsManagerPage({
Boolean(initialCreateScope),
);
const [editOpen, setEditOpen] = useState(false);
// Shared threads are the collaboration surface, and the daemon only mounts
// its routes when `experimental.agentCollaboration` is on. Read the capability
// rather than rendering the entry and letting every call 404: the tag is
// absent precisely when the routes are, so this hides the door instead of
// leaving one that opens onto nothing. Definition CRUD below is unaffected —
// it is a different, unconditional feature.
const workspace = useWorkspace();
const collaborationAvailable =
workspace.capabilities?.features.includes(AGENT_COLLABORATION_FEATURE) ===
true;
const [agentsOpen, setAgentsOpen] = useState(
() => !initialCreateScope && collaborationAvailable,
);
// The daemon can answer late, or be replaced by one with a different answer.
useEffect(() => {
if (!collaborationAvailable) setAgentsOpen(false);
}, [collaborationAvailable]);
const [listNotice, setListNotice] = useState<string | null>(null);
const [mutationError, setMutationError] = useState<string | null>(null);
const [detailError, setDetailError] = useState<string | null>(null);
@ -202,10 +171,8 @@ export function AgentsManagerPage({
}, [agents]);
useEffect(() => {
embedded?.onDetailChange(
Boolean(selectedName || createOpen || editOpen || agentsOpen),
);
}, [createOpen, editOpen, embedded, agentsOpen, selectedName]);
embedded?.onDetailChange(Boolean(selectedName || createOpen || editOpen));
}, [createOpen, editOpen, embedded, selectedName]);
useEffect(() => {
if (!selection) {
@ -237,14 +204,10 @@ export function AgentsManagerPage({
}, [agentsError]);
useEffect(() => {
if (initialCreateScope) {
setAgentsOpen(false);
setCreateOpen(true);
}
if (initialCreateScope) setCreateOpen(true);
}, [initialCreateScope]);
function returnToList(): void {
setAgentsOpen(false);
setCreateOpen(false);
setEditOpen(false);
setSelection(null);
@ -346,20 +309,6 @@ export function AgentsManagerPage({
standaloneNavigation
);
if (agentsOpen && collaborationAvailable) {
return (
<div className="flex w-full flex-col gap-6 pb-8">
{navigation}
<ThreadsRoute
initialView={initialAgentView}
onOpenThreadChat={onOpenThreadChat}
{...(onOpenAgentSession ? { onOpenAgentSession } : {})}
onOpenDefinitions={() => setAgentsOpen(false)}
/>
</div>
);
}
// ── Create view ──
if (createOpen) {
return (
@ -661,18 +610,10 @@ export function AgentsManagerPage({
{t('agents.title')}
</h1>
<p className="mt-1 text-sm text-muted-foreground tabular-nums">
{t('agents.description')}
</p>
<p className="mt-1 text-xs text-muted-foreground tabular-nums">
{t('agent.count', { count: agents.length })}
</p>
</div>
<div className="flex gap-2">
{collaborationAvailable ? (
<Button variant="outline" onClick={() => setAgentsOpen(true)}>
Shared threads
</Button>
) : null}
<Button
variant="outline"
disabled={loading}

View file

@ -37,7 +37,6 @@ import {
NetworkIcon,
} from 'lucide-react';
import { Skeleton } from '../ui/skeleton';
import { ThreadsRoute } from '../workspace-agents/ThreadsRoute';
import { Button } from '../ui/button';
import {
useCallback,
@ -335,13 +334,6 @@ export type ArtifactPanelTab =
title: string;
sessionId?: string;
}
| {
id: string;
kind: 'agent_activity';
title: string;
threadId: string;
workspaceCwd: string;
}
| {
id: 'turn_calls';
kind: 'turn_calls';
@ -466,10 +458,6 @@ interface ArtifactPanelProps {
agentTraceLoading?: boolean;
agentTraceError?: string;
onOpenWorkflowAgent?: (task: EnvironmentAgentTask) => void;
onOpenCollaborationSession?: (
sessionId: string,
workspaceCwd: string,
) => void;
onError?: (error: unknown, fallback: string) => void;
sessionWorkflowEnabled?: boolean;
modelManagement?: WebShellModelManagementOptions;
@ -535,7 +523,6 @@ export function ArtifactPanel({
agentTraceLoading = false,
agentTraceError,
onOpenWorkflowAgent,
onOpenCollaborationSession,
onError,
sessionWorkflowEnabled,
modelManagement,
@ -684,8 +671,7 @@ export function ArtifactPanel({
>
{getArtifactPanelTabKind(tab) === 'review' ? (
<TabReviewIcon />
) : tab.kind === 'workflow' ||
tab.kind === 'agent_activity' ? (
) : tab.kind === 'workflow' ? (
<NetworkIcon
className={styles.tabIconSvg}
strokeWidth={1.6}
@ -1355,23 +1341,6 @@ export function ArtifactPanel({
{activeTab.loadError ?? t('common.loading')}
</div>
)
) : activeTab.kind === 'agent_activity' ? (
<ThreadsRoute
key={activeTab.id}
chat
activityOnly
initialThreadId={activeTab.threadId}
workspaceCwd={activeTab.workspaceCwd}
onOpenAgentSession={
onOpenCollaborationSession
? (sessionId) =>
onOpenCollaborationSession(
sessionId,
activeTab.workspaceCwd,
)
: undefined
}
/>
) : activeTab.kind === 'context_usage' ? (
<ContextUsagePanel
key={activeTab.id}

View file

@ -128,9 +128,6 @@ const HIDDEN_SETTING_KEYS = new Set([
'ui.compactMode',
'mcpServers',
'model.reasoningEffort',
// Read once when the daemon starts, so a toggle here would appear to do
// nothing until a restart; it is set in settings.json or via the env var.
'experimental.agentCollaboration',
]);
const LIVE_SETTING_KEYS = new Set([
'experimental.liveVoice.enabled',

View file

@ -12,15 +12,6 @@ export { isActiveToolStatus } from '../../adapters/toolClassification';
* write, …) are web-shell-only conveniences with no core equivalent.
*/
export const TOOL_DISPLAY_NAMES: Record<string, string> = {
// Workspace-agent collaboration surface. Named the same way core names them
// so the drift test above stays a real check rather than two lists that
// happen to agree.
thread_post: 'ThreadPost',
thread_wait: 'ThreadWait',
thread_block: 'ThreadBlock',
thread_review: 'ThreadReview',
thread_create: 'ThreadCreate',
thread_read: 'ThreadRead',
exec: 'Exec',
edit: 'Edit',
write_file: 'WriteFile',

View file

@ -60,7 +60,6 @@ const { connection, workspace, workspaceActions, active, pinned, archived } =
});
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'],
useConnection: () => connection,
useActions: () => ({ renameSession: vi.fn() }),
useWorkspace: () => workspace,

View file

@ -92,7 +92,6 @@ const useSessionCatalogQueries = vi.hoisted(() => vi.fn(() => []));
const loadSession = vi.hoisted(() => vi.fn());
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'],
useConnection: () => connection,
useActions: () => ({ renameSession: vi.fn() }),
useWorkspace: () => workspace,

View file

@ -89,7 +89,6 @@ const useSessionCatalogQueries = vi.hoisted(() => vi.fn(() => []));
const loadSession = vi.hoisted(() => vi.fn());
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'],
useConnection: () => connection,
useActions: () => ({ renameSession: vi.fn() }),
useWorkspace: () => workspace,

View file

@ -67,7 +67,6 @@ const { connection, workspace, workspaceActions, active, pinned, archived } =
});
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'],
useConnection: () => connection,
useActions: () => ({ renameSession: vi.fn() }),
useWorkspace: () => workspace,

View file

@ -80,7 +80,6 @@ vi.mock('../../local-files/useLocalFilesBridge', () => ({
}));
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'],
useConnection: () => connection,
useActions: () => ({ renameSession: vi.fn() }),
useWorkspace: () => workspace,

View file

@ -84,7 +84,6 @@ const useSessionCatalogQueries = vi.hoisted(() => vi.fn(() => []));
const loadSession = vi.hoisted(() => vi.fn());
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'],
useConnection: () => connection,
useActions: () => ({ renameSession: vi.fn() }),
useWorkspace: () => workspace,

View file

@ -18,10 +18,6 @@ import {
useWorkspace,
useWorkspaceActions,
} from '@qwen-code/web-shell/daemon-react-sdk';
import {
COLLABORATION_SOURCE,
useProjectConversations,
} from '../workspace-agents/useProjectConversations';
import {
STANDALONE_SESSIONS_CAPABILITY,
type DaemonSessionGroup,
@ -262,7 +258,6 @@ export interface WebShellSidebarLockedWorkspace {
export type WebShellSidebarPrimaryNavItem =
| 'newTask'
| 'agents'
| 'plugins'
| 'channels'
| 'scheduledTasks'
@ -305,7 +300,6 @@ const DESKTOP_DEFAULT_FOOTER_ITEMS: readonly WebShellSidebarFooterItem[] =
const DEFAULT_PRIMARY_NAV_ITEMS: readonly WebShellSidebarPrimaryNavItem[] = [
'newTask',
'agents',
'plugins',
'channels',
'scheduledTasks',
@ -410,12 +404,9 @@ export interface WebShellSidebarWorkspaceOverviewOptions {
export type { WorkspaceManagementTarget, WorkspaceOverviewItem };
interface WebShellSidebarProps {
selectedCollaborationId?: string;
onOpenCollaboration?: (id: string, cwd: string) => void;
collapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void;
onOpenSettings: () => void;
onOpenAgents?: (view?: 'agents' | 'tasks') => void;
onOpenPlugins: () => void;
onOpenChannels: () => void;
onOpenManagedSessions?: () => void;
@ -957,12 +948,9 @@ function SidebarSessionSurface({
}
export function WebShellSidebar({
selectedCollaborationId,
onOpenCollaboration,
collapsed,
onCollapsedChange,
onOpenSettings,
onOpenAgents,
onOpenPlugins,
onOpenChannels,
onOpenManagedSessions,
@ -1042,7 +1030,6 @@ export function WebShellSidebar({
const hasScrollingPrimaryNav =
(projectFeaturesEnabled &&
(primaryNavItems.has('plugins') ||
(primaryNavItems.has('agents') && Boolean(onOpenAgents)) ||
primaryNavItems.has('channels') ||
primaryNavItems.has('scheduledTasks') ||
primaryNavItems.has('workflows') ||
@ -1793,16 +1780,6 @@ export function WebShellSidebar({
() => displayedWorkspaces.filter((entry) => entry.kind !== 'live'),
[displayedWorkspaces],
);
const projectConversations = useProjectConversations(
projectWorkspaces
.filter((ws) => ws.primary || ws.trusted)
.map((ws) => ws.cwd),
);
const collaborationSessions = useMemo(
() =>
selectedSessionSource === 'channel' ? [] : projectConversations.sessions,
[selectedSessionSource, projectConversations.sessions],
);
const resolveSessionWorkspaceScope = useCallback(
(session: DaemonSessionSummary): SessionWorkspaceScope => {
const explicitCwd = session.workspaceCwd;
@ -3859,19 +3836,11 @@ export function WebShellSidebar({
const searchedSessions = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
// Keep the daemon catalog order: the organized bucketing below relies on
// it (pinned rows sort first there). Collaboration sessions append after
// it; the flat view re-sorts by recency on its own, so no sort here.
const sourceScopedSessions = [
...sessions
.map(applyOptimisticPin)
.filter((session) =>
matchesSessionSource(session, selectedSessionSource),
),
...collaborationSessions.filter(
(session) => session.workspaceCwd === primaryWorkspaceCwd,
),
];
const sourceScopedSessions = sessions
.map(applyOptimisticPin)
.filter((session) =>
matchesSessionSource(session, selectedSessionSource),
);
if (!query) return sourceScopedSessions;
const localMatches = sourceScopedSessions.filter((session) => {
const label = getSessionLabel(session).toLowerCase();
@ -3893,8 +3862,6 @@ export function WebShellSidebar({
}, [
applyOptimisticPin,
contentSearchHits,
collaborationSessions,
primaryWorkspaceCwd,
searchQuery,
selectedSessionSource,
sessions,
@ -4292,42 +4259,6 @@ export function WebShellSidebar({
: undefined,
standalone,
} = options;
if (session.sourceType === COLLABORATION_SOURCE) {
return (
<button
key={session.sessionId}
type="button"
className={cx(
styles.sessionRow,
'w-full min-h-8 border-0 bg-transparent text-sm',
selectedCollaborationId === session.sourceId &&
styles.currentSession,
)}
aria-current={
selectedCollaborationId === session.sourceId ? 'page' : undefined
}
title={session.displayName}
onClick={() => {
if (session.sourceId)
onOpenCollaboration?.(session.sourceId, session.workspaceCwd);
}}
>
<span className={styles.sessionStatusSlot}>
{session.hasActivePrompt && (
<span
className={cx(
styles.sessionStatusDot,
styles.sessionStatusDotRunning,
)}
/>
)}
</span>
<span className="min-w-0 flex-1 truncate text-left">
{session.displayName}
</span>
</button>
);
}
const sessionIdentity = getIdentityForSession(session);
const liveStarting =
livePresence?.state === 'starting' &&
@ -4981,8 +4912,6 @@ export function WebShellSidebar({
},
[
busySessionIds,
selectedCollaborationId,
onOpenCollaboration,
canDeleteSession,
canShowDeleteSession,
canOrganizeSession,
@ -5749,24 +5678,6 @@ export function WebShellSidebar({
>
{hasScrollingPrimaryNav && (
<div className={styles.primaryNav}>
{projectFeaturesEnabled &&
onOpenAgents &&
primaryNavItems.has('agents') && (
<div>
<button
className={styles.pluginButton}
type="button"
title={t('agents.title')}
aria-label={t('agents.title')}
onClick={() => onOpenAgents('agents')}
>
<span className={styles.navIcon}>
<BotIcon size={16} strokeWidth={1.2} />
</span>
{!collapsed && <span>{t('agents.title')}</span>}
</button>
</div>
)}
{projectFeaturesEnabled && primaryNavItems.has('plugins') && (
<button
className={styles.pluginButton}
@ -5922,13 +5833,6 @@ export function WebShellSidebar({
)}
</>
)}
{projectConversations.error && (
<p role="status" className={styles.notice}>
{t('collab.sidebar.loadFailed', {
name: projectConversations.error,
})}
</p>
)}
{showLive && livePresence && liveWorkspaces.length === 0 && (
<div
className={styles.livePendingWorkspace}
@ -6166,9 +6070,6 @@ export function WebShellSidebar({
<Fragment key={ws.id}>
<WorkspaceSection
workspace={ws}
additionalSessions={collaborationSessions.filter(
(session) => session.workspaceCwd === ws.cwd,
)}
remote={!isPageOriginDaemon(workspace.baseUrl)}
renderHeader={
lockedWorkspaceCwd && lockedWorkspaceOptions?.render

View file

@ -201,7 +201,6 @@ const {
});
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'],
useConnection: () => connection,
useActions: () => sessionActions,
useStreamingState: () => 'idle',

View file

@ -120,7 +120,6 @@ export interface WorkspaceHeaderActionsContext {
}
interface WorkspaceSectionProps {
additionalSessions?: readonly DaemonSessionSummary[];
workspace: DaemonWorkspaceCapability;
remote?: boolean;
renderHeader?: (expanded: boolean) => ReactNode;
@ -234,7 +233,6 @@ interface WorkspaceSectionProps {
}
export function WorkspaceSection({
additionalSessions,
workspace,
remote = false,
renderHeader,
@ -676,14 +674,7 @@ export function WorkspaceSection({
);
const searchedSessions = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
const scoped = [
...sessions.map((session) => mapSession?.(session) ?? session),
...(additionalSessions ?? []),
].sort(
(a, b) =>
Date.parse(b.updatedAt ?? b.createdAt ?? '') -
Date.parse(a.updatedAt ?? a.createdAt ?? ''),
);
const scoped = sessions.map((session) => mapSession?.(session) ?? session);
if (!query) return scoped;
const localMatches = scoped.filter((session) => {
const label = (session.displayName || '').toLowerCase();
@ -702,14 +693,7 @@ export function WorkspaceSection({
sourceType,
mapSession,
);
}, [
additionalSessions,
contentSearchHits,
mapSession,
searchQuery,
sessions,
sourceType,
]);
}, [contentSearchHits, mapSession, searchQuery, sessions, sourceType]);
const renderSessionWithSnippet = (session: DaemonSessionSummary) =>
renderSession(session, {
// Explicit options override renderSessionRow's guarded default, so

View file

@ -35,13 +35,13 @@ export function sessionMatchesGitQuery(
/**
* The sidebar's session-source scope: the "channel" tab lists only
* channel-source sessions, the "default" tab lists unattributed (legacy)
* and default/Qwen Live tasks, every other source lists exact matches, and
* no filter lists everything.
* and default/Qwen Live tasks, and no filter lists everything.
*/
export function sessionMatchesSource(
session: DaemonSessionSummary,
source: string | undefined,
): boolean {
if (source === 'channel') return session.sourceType === 'channel';
if (source === 'default') {
return (
session.sourceType === undefined ||
@ -49,7 +49,7 @@ export function sessionMatchesSource(
session.sourceType === 'qwen-live'
);
}
return source === undefined || session.sourceType === source;
return true;
}
/**

View file

@ -1,855 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import {
Activity,
Check,
ListTodo,
LoaderCircle,
ShieldQuestion,
X,
} from 'lucide-react';
import { createPortal } from 'react-dom';
import { MessageList } from '../MessageList';
import { ChatEditor } from '../ChatEditor';
import { Button } from '../ui/button';
import type { Message } from '../../adapters/types';
import { RunRowView, type ThreadDetailView } from './ThreadView';
import {
buildRunRows,
CONVERSATION_CONTEXT_PREFIX,
formatElapsed,
statusReasonLabel,
type RoutingPreviewTarget,
} from './agents-view-logic';
import {
useWebShellCustomization,
WebShellCustomizationProvider,
} from '../../customization';
import { useI18n } from '../../i18n';
import type { RunView } from './agents-view-logic';
/** No agent activity for this long: say it may be stuck and offer Stop. */
const STALL_NOTICE_MS = 5 * 60_000;
/** A clock that ticks once a second while `active`, for elapsed times. */
function useNow(active: boolean): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!active) return;
const timer = setInterval(() => setNow(Date.now()), 1_000);
return () => clearInterval(timer);
}, [active]);
return now;
}
/**
* One line saying what a live run is doing right now. Returns `stalled` so the
* caller can offer Stop; an approval is rendered as a card instead.
*/
function describeLiveRun(
run: RunView,
now: number,
t: (key: string, vars?: Record<string, string | number>) => string,
): { text: string; stalled: boolean } {
const agent = run.agentName;
if (run.status === 'queued') {
return {
text: run.queueAhead
? t('collab.run.queuedBehind', { agent, count: run.queueAhead })
: t('collab.run.queued', { agent }),
stalled: false,
};
}
if (run.status === 'cancelling') {
return { text: t('collab.run.stopping', { agent }), stalled: false };
}
const progress = run.progress;
if (!progress) {
return { text: t('collab.run.starting', { agent }), stalled: false };
}
const idle = now - progress.activityAt;
if (idle >= STALL_NOTICE_MS) {
return {
text: t('collab.run.stalled', { agent, elapsed: formatElapsed(idle, t) }),
stalled: true,
};
}
const elapsed = formatElapsed(now - (run.startedAt ?? now), t);
switch (progress.stage) {
case 'starting':
return { text: t('collab.run.starting', { agent }), stalled: false };
case 'resuming':
return { text: t('collab.run.resuming', { agent }), stalled: false };
case 'thinking':
return {
text: t('collab.run.thinking', { agent, elapsed }),
stalled: false,
};
case 'responding':
return {
text: t('collab.run.responding', { agent, elapsed }),
stalled: false,
};
case 'tool':
return {
text: progress.detail
? t('collab.run.toolNamed', { agent, tool: progress.detail, elapsed })
: t('collab.run.tool', { agent, elapsed }),
stalled: false,
};
case 'stream_lost':
return { text: t('collab.run.streamLost', { agent }), stalled: false };
default:
return {
text: t('collab.run.working', { agent, elapsed }),
stalled: false,
};
}
}
interface TeamMember {
name: string;
agentId?: string;
color?: string;
lead: boolean;
/** The live run if there is one, otherwise the latest. */
run?: RunView;
}
const SKIP_REASONS = new Set([
'agent_unknown',
'agent_disabled',
'agent_retired',
'no_target',
'queue_full',
'turn_budget_exhausted',
'token_budget_exhausted',
'thread_done',
'self_trigger',
]);
/**
* Who a reply will reach, said before it is sent. Naming who is left out
* matters as much: an @ to one member must not read like a broadcast.
*/
function describePreview(
targets: readonly RoutingPreviewTarget[],
members: readonly TeamMember[],
t: ReturnType<typeof useI18n>['t'],
): string {
const names = targets
.filter((target) => target.willWake)
.map((target) => target.agentName);
if (names.length === 0) return t('collab.preview.nobody');
const others = members.filter((member) => !names.includes(member.name));
return others.length > 0
? t('collab.preview.only', { names: names.join(', ') })
: t('collab.preview.to', { names: names.join(', ') });
}
/** Everyone who has worked on or been handed this thread, lead first. */
function teamMembers(
thread: ThreadDetailView,
agents: readonly { id: string; name: string }[],
): TeamMember[] {
const byName = new Map<string, TeamMember>();
const add = (name: string, patch: Partial<TeamMember> = {}) => {
const current = byName.get(name) ?? { name, lead: false };
byName.set(name, { ...current, ...patch });
};
if (thread.assigneeName) add(thread.assigneeName, { lead: true });
for (const post of thread.posts) {
if (post.authorKind === 'agent') add(post.authorName);
}
for (const run of thread.runs) {
const current = byName.get(run.agentName)?.run;
const live = (r?: RunView) =>
r !== undefined &&
['queued', 'running', 'finishing', 'cancelling'].includes(r.status);
const newer =
!current ||
(live(run) && !live(current)) ||
(live(run) === live(current) &&
(run.startedAt ?? 0) >= (current.startedAt ?? 0));
add(run.agentName, {
agentId: run.agentId,
...(run.agentColor ? { color: run.agentColor } : {}),
...(newer ? { run } : {}),
});
}
for (const member of byName.values()) {
member.agentId ??= agents.find((agent) => agent.name === member.name)?.id;
}
return [...byName.values()].sort((a, b) => Number(b.lead) - Number(a.lead));
}
/** A member's one-word state, for the team list. */
function memberStatus(
member: TeamMember,
now: number,
t: (key: string, vars?: Record<string, string | number>) => string,
): { text: string; tone: string } {
const run = member.run;
const muted = 'text-muted-foreground';
const attention = 'text-[var(--status-attention-fg)]';
const running = 'text-[var(--status-running-fg)]';
if (!run) return { text: t('collab.member.idle'), tone: muted };
switch (run.status) {
case 'queued':
return { text: t('collab.member.queued'), tone: muted };
case 'running':
case 'finishing':
case 'cancelling': {
const progress = run.progress;
if (progress?.permission)
return { text: t('collab.member.approval'), tone: attention };
if (progress && now - progress.activityAt >= STALL_NOTICE_MS)
return { text: t('collab.member.stalled'), tone: attention };
if (progress?.stage === 'tool' && progress.detail)
return {
text: t('collab.member.tool', { tool: progress.detail }),
tone: running,
};
if (progress?.stage === 'thinking')
return { text: t('collab.member.thinking'), tone: running };
if (progress?.stage === 'responding')
return { text: t('collab.member.responding'), tone: running };
return { text: t('collab.member.starting'), tone: running };
}
case 'failed':
return {
text:
run.error === 'agent_run_stalled'
? t('collab.member.timedOut')
: t('collab.member.failed'),
tone: attention,
};
default:
return { text: t('collab.member.done'), tone: muted };
}
}
export function ThreadChat({
thread,
agents,
preview,
pending,
onSend,
onDraftChange,
onDetails,
onOpenAgentSession,
onCancelRun,
onMarkDone,
onOpenThread,
onRespondPermission,
activityOnly = false,
onOpenActivity,
headerActionsContainer,
}: {
headerActionsContainer?: HTMLElement | null;
activityOnly?: boolean;
onOpenActivity?: () => void;
preview?: readonly RoutingPreviewTarget[];
agents: readonly {
id: string;
name: string;
enabled: boolean;
retiredAt?: number;
}[];
thread: ThreadDetailView;
pending: boolean;
onSend: (text: string) => Promise<boolean>;
onDraftChange: (text: string) => void;
onDetails: () => void;
onOpenAgentSession?: (sessionId: string) => void;
onCancelRun: (runId: string) => void;
onMarkDone: () => void;
onOpenThread: (threadId: string) => void;
onRespondPermission?: (
sessionId: string,
requestId: string,
optionId: string,
) => Promise<unknown>;
}) {
const customization = useWebShellCustomization();
const { t } = useI18n();
const [sending, setSending] = useState(false);
const [answered, setAnswered] = useState<ReadonlySet<string>>(new Set());
// "Keep waiting" on a quiet run hides the stall warning for a while.
const [snoozedUntil, setSnoozedUntil] = useState<Record<string, number>>({});
const { live, past } = buildRunRows(thread.runs, t);
const now = useNow(live.length > 0);
// Offer a retry for an agent whose latest run failed and that is not
// already working again on this thread.
const retryable = useMemo(() => {
const latest = new Map<string, RunView>();
for (const run of thread.runs) {
const seen = latest.get(run.agentId);
if (!seen || (run.startedAt ?? 0) >= (seen.startedAt ?? 0))
latest.set(run.agentId, run);
}
// A retried run waits in the queue without a start time, so "is it
// working again" is read from the live runs, not from ordering. Retry
// sends `@name …`, which admission skips for a retired or disabled
// agent, and routes by name into a same-named re-added identity — so
// resolve the roster entry by id or name and suppress both cases.
return [...latest.values()].filter((run) => {
if (
run.status !== 'failed' ||
thread.status === 'done' ||
thread.status === 'cancelled'
) {
return false;
}
const rosterEntry = agents.find(
(agent) => agent.id === run.agentId || agent.name === run.agentName,
);
if (
!rosterEntry ||
!rosterEntry.enabled ||
rosterEntry.retiredAt !== undefined
) {
return false;
}
return !live.some(
(row) =>
row.run.agentId === rosterEntry.id ||
row.run.agentName === rosterEntry.name,
);
});
}, [thread.runs, thread.status, live, agents]);
const messages = useMemo<Message[]>(
() =>
[
...(thread.body && !thread.body.startsWith(CONVERSATION_CONTEXT_PREFIX)
? [
{
id: `${thread.id}:description`,
role: 'user' as const,
content: thread.body,
},
]
: []),
...thread.posts.map(
(post): Message => ({
id: post.id,
role: post.authorKind === 'human' ? 'user' : 'assistant',
content:
post.authorKind === 'human'
? post.text
: `**${post.authorName}**\n\n${post.text}`,
timestamp: post.at,
}),
),
...thread.runs
.filter((run) => run.progress?.thoughtText)
.map(
(run): Message => ({
id: `${run.id}:thought`,
role: 'thinking',
content: `${run.agentName}\n\n${run.progress!.thoughtText}`,
// Last update, not start: text being written now belongs
// after anything the run posted along the way.
timestamp: run.progress?.receivedAt ?? run.startedAt,
isStreaming:
run.status === 'running' && run.progress?.stage === 'thinking',
}),
),
...thread.runs
// Once the run's answer is a post, the post is the record; the live
// preview is only for text still being written. A status post the
// run made along the way is not its answer.
.filter(
(run) =>
run.progress?.outputText &&
!thread.posts.some(
(post) =>
post.sourceRunId === run.id &&
(run.status !== 'running' ||
post.text.trim() === run.progress?.outputText?.trim()),
),
)
.map(
(run): Message => ({
id: `${run.id}:output`,
role: 'assistant',
content: `**${run.agentName}**\n\n${run.progress?.outputText}`,
// Last update, not start: text being written now belongs
// after anything the run posted along the way.
timestamp: run.progress?.receivedAt ?? run.startedAt,
isStreaming: run.status === 'running',
}),
),
].sort(
(a: Message, b: Message) => (a.timestamp ?? 0) - (b.timestamp ?? 0),
),
[thread.id, thread.body, thread.posts, thread.runs],
);
if (activityOnly) {
const members = teamMembers(thread, agents);
return (
<section
className="h-full overflow-y-auto p-4"
aria-label={t('collab.team.title')}
>
<h2 className="mb-1 text-sm font-medium">{t('collab.team.title')}</h2>
<p className="mb-4 truncate text-xs text-muted-foreground">
{thread.title}
</p>
<h3 className="mb-1 text-xs text-muted-foreground">
{t('collab.team.members', { count: members.length })}
</h3>
{members.length === 0 && (
<p className="mb-3 text-xs text-muted-foreground">
{t('collab.team.empty')}
</p>
)}
<ul className="mb-4">
{members.map((member) => {
const { text, tone } = memberStatus(member, now, t);
const sessionId = member.run?.sessionId;
return (
<li key={member.name}>
<button
type="button"
disabled={!sessionId || !onOpenAgentSession}
onClick={() => sessionId && onOpenAgentSession?.(sessionId)}
className="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left enabled:hover:bg-muted disabled:cursor-default"
>
<span
aria-hidden="true"
className="mt-1.5 size-2 shrink-0 rounded-full bg-muted-foreground"
style={
member.color ? { background: member.color } : undefined
}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">
{member.name}
{member.lead && (
<span className="ml-1.5 text-xs text-muted-foreground">
{t('collab.team.lead')}
</span>
)}
</span>
<span className={`block truncate text-xs ${tone}`}>
{text}
</span>
</span>
</button>
</li>
);
})}
</ul>
{live.length > 0 && (
<>
<h3 className="mb-1 text-xs text-muted-foreground">
{t('collab.team.live')}
</h3>
<div className="mb-4">
{live.map((row) => (
<RunRowView
key={row.run.id}
row={row}
onOpenAgentSession={onOpenAgentSession}
onCancelRun={pending ? undefined : onCancelRun}
/>
))}
</div>
</>
)}
{!!thread.children?.length && (
<>
<h3 className="mb-1 text-xs text-muted-foreground">
{t('collab.team.tasks', { count: thread.children.length })}
</h3>
<ul className="mb-4">
{thread.children.map((child) => (
<li key={child.id}>
<button
type="button"
className="block w-full rounded-md px-2 py-1.5 text-left text-sm hover:bg-muted"
onClick={() => onOpenThread(child.id)}
>
<span className="block truncate">{child.title}</span>
<span className="block truncate text-xs text-muted-foreground">
{[child.assigneeName, statusReasonLabel(child.reason, t)]
.filter(Boolean)
.join(' · ')}
</span>
</button>
</li>
))}
</ul>
</>
)}
{thread.parent && (
<Button
variant="link"
className="mb-3 h-auto p-0"
onClick={() => onOpenThread(thread.parent!.id)}
>
{t('collab.team.parent', { title: thread.parent.title })}
</Button>
)}
{past.length > 0 && (
<details>
<summary className="cursor-pointer text-xs text-muted-foreground">
{t('collab.team.history', { count: past.length })}
</summary>
{past.map((row) => (
<RunRowView
key={row.run.id}
row={row}
onOpenAgentSession={onOpenAgentSession}
/>
))}
</details>
)}
</section>
);
}
const actions = (
<div className="flex shrink-0 items-center gap-1">
{thread.status === 'in_review' && (
<Button
variant="ghost"
size="icon"
title={t('collab.markDone')}
aria-label={t('collab.markDone')}
disabled={pending}
onClick={onMarkDone}
>
<Check className="size-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
title={t('collab.details')}
aria-label={t('collab.details')}
onClick={onDetails}
>
<ListTodo className="size-4" />
</Button>
{onOpenActivity && (
<Button
variant="ghost"
size="icon"
title={t('collab.team.title')}
aria-label={t('collab.team.title')}
onClick={onOpenActivity}
>
<Activity className="size-4" />
</Button>
)}
</div>
);
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{headerActionsContainer ? (
createPortal(actions, headerActionsContainer)
) : (
<header className="flex items-center gap-2 border-b border-border px-4 py-2">
<div className="min-w-0 flex-1">
<h1 className="truncate text-base font-semibold">{thread.title}</h1>
</div>
{actions}
</header>
)}
<p className="px-4 py-2 text-xs text-muted-foreground">
{statusReasonLabel(thread.reason, t)}
</p>
<div className="flex min-h-0 flex-1">
<div className="flex min-w-0 flex-1 flex-col">
{thread.body.startsWith(CONVERSATION_CONTEXT_PREFIX) && (
<details className="mx-4 mb-2 rounded-md border border-border px-3 py-2 text-xs text-muted-foreground">
<summary className="cursor-pointer">
{t('collab.thread.context')}
</summary>
<div className="mt-2 max-h-48 overflow-y-auto whitespace-pre-wrap break-words">
{thread.body.slice(CONVERSATION_CONTEXT_PREFIX.length)}
</div>
</details>
)}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<WebShellCustomizationProvider
value={{ ...customization, collapseCompletedTurns: false }}
>
<MessageList
messages={messages}
pendingApproval={null}
sessionKey={thread.id}
hideSessionTimeline
/>
</WebShellCustomizationProvider>
</div>
<div className="p-4">
{sending && (
<div
role="status"
className="mb-2 flex items-center gap-2 text-sm text-muted-foreground"
>
<LoaderCircle
aria-hidden="true"
className="size-4 animate-spin motion-reduce:animate-none"
/>
{t('collab.sending')}
</div>
)}
{live.map(({ run }) => {
const permission = run.progress?.permission;
if (
permission &&
run.sessionId &&
onRespondPermission &&
!answered.has(permission.requestId)
) {
const sessionId = run.sessionId;
return (
<div
key={run.id}
role="group"
aria-label={t('collab.approval.title', {
agent: run.agentName,
})}
className="mb-2 rounded-md border border-border bg-[var(--status-attention-bg)] p-3 text-sm"
>
<div className="mb-2 flex items-center gap-2 font-medium">
<ShieldQuestion
aria-hidden="true"
className="size-4 shrink-0 text-[var(--status-attention-fg)]"
/>
{t('collab.approval.title', { agent: run.agentName })}
</div>
{permission.title && (
<code className="mb-2 block truncate rounded bg-muted px-2 py-1 text-xs">
{permission.title}
</code>
)}
<div className="flex flex-wrap gap-2">
{permission.options.map((option) => (
<Button
key={option.optionId}
size="sm"
variant={
option.kind?.startsWith('allow')
? 'default'
: 'outline'
}
onClick={() => {
setAnswered(
(current) =>
new Set([...current, permission.requestId]),
);
const reopen = () =>
setAnswered((current) => {
const next = new Set(current);
next.delete(permission.requestId);
return next;
});
void onRespondPermission(
sessionId,
permission.requestId,
option.optionId,
).then((ok) => ok === false && reopen(), reopen);
}}
>
{/* The agent names its options in English; the
common ones read as the main approval card does. */}
{option.kind === 'allow_once'
? t('approval.option.allowOnce')
: option.kind === 'reject_once'
? t('approval.option.rejectOnce')
: option.name}
</Button>
))}
</div>
</div>
);
}
const described = describeLiveRun(run, now, t);
const stalled =
described.stalled && now >= (snoozedUntil[run.id] ?? 0);
const text = stalled
? described.text
: described.stalled
? t('collab.run.working', {
agent: run.agentName,
elapsed: formatElapsed(now - (run.startedAt ?? now), t),
})
: described.text;
const steps = run.progress?.steps ?? [];
return (
<div key={run.id} className="mb-2">
<div
role="status"
className={`flex items-center gap-2 text-sm ${
stalled
? 'text-[var(--status-attention-fg)]'
: 'text-muted-foreground'
}`}
>
<LoaderCircle
aria-hidden="true"
className={`size-4 shrink-0 ${
stalled ? '' : 'animate-spin motion-reduce:animate-none'
}`}
/>
<span className="min-w-0 flex-1 truncate">{text}</span>
{stalled && (
<Button
size="sm"
variant="ghost"
onClick={() =>
setSnoozedUntil((current) => ({
...current,
[run.id]: now + STALL_NOTICE_MS,
}))
}
>
{t('collab.run.keepWaiting')}
</Button>
)}
{(stalled || run.status === 'queued') && !pending && (
<Button
size="sm"
variant="ghost"
onClick={() => onCancelRun(run.id)}
>
{t('collab.run.stop')}
</Button>
)}
</div>
{steps.length > 0 && (
// One line per tool call, like a CI job's step list.
<ol
aria-label={t('collab.run.steps', {
agent: run.agentName,
})}
className="mt-1 ml-6 flex flex-col gap-0.5 text-xs text-muted-foreground"
>
{steps.map((step) => (
<li key={step.id} className="flex items-center gap-1.5">
{step.status === 'running' ? (
<LoaderCircle
aria-label={t('collab.step.running')}
className="size-3 shrink-0 animate-spin motion-reduce:animate-none"
/>
) : step.status === 'done' ? (
<Check
aria-label={t('collab.step.done')}
className="size-3 shrink-0"
/>
) : (
<X
aria-label={t('collab.step.failed')}
className="size-3 shrink-0 text-destructive"
/>
)}
<span
className={`min-w-0 truncate ${
step.status === 'running' ? 'text-foreground' : ''
}`}
>
{step.title || t('collab.step.untitled')}
</span>
</li>
))}
</ol>
)}
</div>
);
})}
{retryable.map((run) => (
<div
key={run.id}
role="status"
className="mb-2 flex items-center gap-2 text-sm text-[var(--status-attention-fg)]"
>
<span className="min-w-0 flex-1 truncate">
{run.error === 'agent_run_stalled'
? t('collab.run.timedOut', { agent: run.agentName })
: t('collab.run.failed', { agent: run.agentName })}
</span>
{!pending && (
<Button
size="sm"
variant="ghost"
onClick={() =>
void onSend(
`@${run.agentName} ${t('collab.run.retryPrompt')}`,
)
}
>
{t('collab.run.retry')}
</Button>
)}
</div>
))}
{preview && (
<div role="status" className="mb-2 text-xs text-muted-foreground">
{describePreview(preview, teamMembers(thread, agents), t)}
{preview
.filter((target) => !target.willWake)
.map((target) => (
<p key={`${target.agentName}:${target.reason}`}>
{t(
SKIP_REASONS.has(target.reason ?? '')
? `collab.skip.${target.reason}`
: 'collab.skip.other',
{ name: target.agentName },
)}
</p>
))}
</div>
)}
<ChatEditor
commands={[]}
builtinAtProviders={false}
visibleToolbarActions={[]}
atProviders={[
{
id: 'agents',
label: t('collab.mention.provider'),
search: async ({ query }) =>
agents
.filter(
(agent) =>
agent.enabled &&
!agent.retiredAt &&
agent.name
.toLowerCase()
.includes(query.toLowerCase()),
)
.map((agent) => ({
id: agent.id,
label: agent.name,
insertText: `@${agent.name} `,
})),
},
]}
placeholderText={t('collab.composer.placeholder')}
disabled={
pending ||
thread.status === 'done' ||
thread.status === 'cancelled'
}
onInputTextChange={onDraftChange}
onSubmit={(text, images, files, commitAccepted) => {
if (images?.length || files?.length || !text.trim())
return false;
setSending(true);
void onSend(text)
.then((accepted) => {
if (accepted) commitAccepted?.();
})
.finally(() => setSending(false));
return false;
}}
/>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,477 +0,0 @@
/* Thread view — a ledger of outstanding obligations, with the conversation as
the evidence underneath it. */
.page {
display: flex;
min-height: 100%;
flex-direction: column;
margin: -16px -20px 0;
}
.pageHeader {
display: flex;
min-height: 46px;
flex: 0 0 auto;
align-items: center;
gap: 10px;
padding: 10px 20px;
border-block-end: 1px solid var(--border);
}
.backButton {
width: 30px;
height: 30px;
flex: 0 0 30px;
color: var(--muted-foreground);
}
.parentLink {
min-width: 0;
overflow: hidden;
padding: 0;
border: 0;
background: none;
color: var(--muted-foreground);
font: inherit;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.parentLink:hover {
color: var(--foreground);
}
.title {
min-width: 0;
overflow: hidden;
color: var(--foreground);
font-size: 14px;
font-weight: 600;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
outline: none;
}
.assigneeSelect {
min-height: 30px;
padding: 4px 7px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--background);
color: var(--foreground);
font: inherit;
font-size: 12px;
}
/* The live signal lives in the header, never in a body card that competes
with the content and scrolls away. */
.workingChip {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 999px;
background: var(--status-running-bg);
color: var(--status-running-fg);
font-size: 12px;
}
.workingDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentcolor;
}
.doneButton {
margin-inline-start: auto;
}
.body {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 24px;
padding: 20px 24px 32px;
align-items: start;
}
@media (max-width: 900px) {
.body {
grid-template-columns: minmax(0, 1fr);
}
}
.main {
display: flex;
min-width: 0;
flex-direction: column;
gap: 16px;
}
/* The resolver's sentence. Wide enough to hold a full sentence, because it is
a sentence and not a badge. This is the only element allowed to change while
the page is open, and the only place motion is spent. */
.reason {
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--foreground);
font-size: 15px;
line-height: 1.5;
}
.reasonAttention {
border-color: var(--status-attention-fg);
background: var(--status-attention-bg);
color: var(--status-attention-fg);
}
@media (prefers-reduced-motion: no-preference) {
.reason {
transition:
background-color 150ms ease,
color 150ms ease;
}
}
.threadBody {
max-width: 72ch;
color: var(--muted-foreground);
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
}
.priorityChip {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--muted-foreground);
font-size: 12px;
}
.priorityUrgent {
border-color: var(--status-attention-fg);
background: var(--status-attention-bg);
color: var(--status-attention-fg);
}
/* The standard the work is judged against, given its own edge so it does not
read as more of the body. The rule sits on the inline start so it aligns
with the run rows below, which are marked the same way. */
.criteria {
max-width: 72ch;
padding-inline-start: 12px;
border-inline-start: 2px solid var(--border);
}
.criteriaTitle {
margin: 0 0 4px;
color: var(--foreground);
font-size: 13px;
font-weight: 600;
}
.criteriaText {
margin: 0;
color: var(--muted-foreground);
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
}
.children {
display: flex;
flex-direction: column;
gap: 4px;
}
.childRow {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: none;
color: var(--foreground);
font: inherit;
font-size: 13px;
text-align: start;
cursor: pointer;
}
.childRow span {
color: var(--muted-foreground);
font-size: 12px;
}
.childRow:hover:not(:disabled) {
background: var(--secondary);
}
.posts {
display: flex;
flex-direction: column;
gap: 14px;
}
.post {
display: grid;
grid-template-columns: 2.5em minmax(0, 1fr) auto;
gap: 2px 10px;
}
/* Sequence is how a person and an agent refer to the same post, and how a
duplicate after a replay is recognised. It is data, so it is monospaced for
alignment — not texture. */
.sequence {
grid-row: 1 / span 2;
color: var(--muted-foreground);
font-family: var(--font-mono);
font-size: 12px;
font-variant-numeric: tabular-nums;
text-align: end;
}
.author {
color: var(--foreground);
font-size: 13px;
font-weight: 600;
}
.time {
color: var(--muted-foreground);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.postText {
grid-column: 2 / span 2;
max-width: 72ch;
color: var(--foreground);
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
}
.postOutcome {
grid-column: 2 / span 2;
color: var(--muted-foreground);
font-size: 12px;
}
/* A system trigger is a ledger entry, not somebody talking: one verb phrase,
no body, quieter than a post. */
.systemPost .author {
color: var(--muted-foreground);
font-weight: 400;
}
.sidebar {
display: flex;
min-width: 0;
flex-direction: column;
gap: 20px;
}
.sectionTitle {
margin: 0 0 8px;
color: var(--muted-foreground);
font-size: 13px;
font-weight: 600;
}
.runRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 1px 8px;
padding: 6px 8px 7px 6px;
border-inline-start: 2px solid var(--border);
border-radius: 0 var(--radius) var(--radius) 0;
}
.runRow:hover {
background: var(--secondary);
}
.runProgress {
grid-column: 1 / -1;
color: var(--muted-foreground);
font-size: 12px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.runAgent {
min-width: 0;
overflow: hidden;
color: var(--foreground);
font-family: var(--font-mono);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
/* What this run is doing, or what it left behind. Sits where a task status
would, and carries more: the close obligation, not just the run state. */
.runState {
grid-column: 2;
grid-row: 1;
flex: 0 0 auto;
color: var(--muted-foreground);
font-size: 12px;
}
.runStateOutstanding {
color: var(--status-attention-fg);
}
.runStateLive {
color: var(--status-running-fg);
}
/* Why this run exists. Asked first, so it is on the row rather than a tooltip. */
.runTrigger {
grid-column: 1 / span 2;
min-width: 0;
overflow: hidden;
color: var(--muted-foreground);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.runLink {
padding: 0;
border: 0;
background: none;
color: var(--muted-foreground);
font: inherit;
font-size: 12px;
text-decoration: underline;
cursor: pointer;
}
.runLink:hover {
color: var(--foreground);
}
.runCancel {
margin-inline-start: 6px;
padding: 0;
border: 0;
background: none;
color: var(--status-attention-fg);
font: inherit;
cursor: pointer;
}
.runError {
grid-column: 1 / span 2;
color: var(--status-attention-fg);
font-size: 12px;
white-space: pre-wrap;
}
.pastToggle {
margin-top: 4px;
padding: 4px 6px;
border: 0;
border-radius: var(--radius);
background: none;
color: var(--muted-foreground);
font: inherit;
font-size: 12px;
text-align: start;
cursor: pointer;
}
.pastToggle:hover {
color: var(--foreground);
}
/* A line, not a bar: a budget is a limit you want to notice before it trips,
not a goal you are filling. */
.budgetLine {
color: var(--muted-foreground);
font-size: 12px;
font-variant-numeric: tabular-nums;
line-height: 1.6;
}
.composer {
display: flex;
flex-direction: column;
border: 1px solid var(--border);
border-radius: var(--radius);
}
.composerInput {
padding: 10px 12px;
border: 0;
border-radius: var(--radius) var(--radius) 0 0;
background: none;
color: var(--foreground);
font: inherit;
font-size: 14px;
line-height: 1.6;
resize: vertical;
min-height: 72px;
}
.composerInput:focus-visible {
outline: 2px solid var(--ring);
outline-offset: -2px;
}
.preview {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px 10px;
border-block-start: 1px solid var(--border);
}
.previewLead {
color: var(--foreground);
font-size: 13px;
}
.previewLeadNobody {
color: var(--status-attention-fg);
}
.previewTarget {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
}
.previewSkip {
color: var(--muted-foreground);
}
.previewUnknown {
color: var(--status-attention-fg);
}
.previewName {
font-family: var(--font-mono);
}
.composerActions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 8px 12px;
border-block-start: 1px solid var(--border);
}

View file

@ -1,641 +0,0 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useMemo, useState } from 'react';
import { ArrowLeftIcon } from 'lucide-react';
import { Button } from '../ui/button';
import { Markdown } from '../messages/Markdown';
import { useI18n } from '../../i18n';
import {
buildRunRows,
CONVERSATION_CONTEXT_PREFIX,
explainSkip,
formatBudget,
formatElapsed,
statusReasonLabel,
summarizePreview,
triggerLabel,
type RoutingPreviewTarget,
type RunRow,
type RunView,
} from './agents-view-logic';
import styles from './ThreadView.module.css';
export interface ThreadPostView {
sourceRunId?: string;
id: string;
sequence: number;
authorKind: 'human' | 'agent' | 'system';
authorName: string;
authorDeleted?: boolean;
text: string;
at: number;
outcomes?: readonly {
agentName?: string;
kind: 'dispatch' | 'coalesce' | 'skip';
reason?: string;
into?: 'queued' | 'running';
}[];
}
export interface ThreadDetailView {
id: string;
title: string;
body: string;
/** What "done" means here, in the author's words. */
acceptanceCriteria?: string;
priority?: 'urgent' | 'high' | 'normal' | 'low';
parent?: { id: string; title: string };
assigneeName?: string;
status:
| 'open'
| 'in_progress'
| 'blocked'
| 'in_review'
| 'done'
| 'cancelled';
/** The resolver's sentence. Rendered verbatim. */
reason: string;
posts: readonly ThreadPostView[];
runs: readonly RunView[];
children?: readonly ThreadChildView[];
budget: {
turnsUsed: number;
turnLimit: number;
tokensUsed: number;
tokenLimit: number;
};
}
export interface ThreadChildView {
id: string;
title: string;
status: ThreadDetailView['status'];
reason: string;
assigneeName?: string;
}
export interface ThreadViewProps {
thread: ThreadDetailView;
agents: readonly {
name: string;
enabled: boolean;
/** Retired identities stay in the list and are never offered new work. */
retiredAt?: number;
}[];
/** Server-computed routing for the current draft. */
preview?: readonly RoutingPreviewTarget[];
draft: string;
onDraftChange: (draft: string) => void;
onReply: () => void;
onBack: () => void;
onOpenThread?: (threadId: string) => void;
/**
* Opens the agent session a run ran in. Absent where the shell has no
* session view to switch to, which is why every use of it is guarded.
*/
onOpenAgentSession?: (sessionId: string) => void;
onCancelRun?: (runId: string) => void;
onMarkDone?: () => void;
/** Back to the chat form of this conversation. */
onOpenConversation?: () => void;
onAssign?: (assignee?: string) => void;
replyPending?: boolean;
}
function formatTime(at: number): string {
return new Date(at).toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
});
}
const RUN_STAGES = new Set([
'starting',
'resuming',
'waiting',
'thinking',
'tool',
'responding',
]);
export function RunRowView({
row,
onOpenAgentSession,
onCancelRun,
}: {
row: RunRow;
onOpenAgentSession?: (sessionId: string) => void;
onCancelRun?: (runId: string) => void;
}) {
const { t } = useI18n();
const sessionId = row.run.sessionId;
const progress = row.run.progress;
const [now, setNow] = useState(Date.now);
useEffect(() => {
if (!row.live) return;
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [row.live]);
// Liveness is judged by the agent's own activity, not by when the last
// snapshot arrived: a long tool call writes nothing and is still working.
const quiet = progress && now - progress.activityAt > 15000;
const state =
row.run.status === 'queued'
? t('collab.runRow.queued')
: row.run.status === 'running'
? progress
? progress.stage === 'awaiting_approval'
? t('collab.runRow.stage.awaiting_approval')
: quiet
? t('collab.runRow.quiet')
: RUN_STAGES.has(progress.stage)
? t(`collab.runRow.stage.${progress.stage}`)
: t('collab.runRow.working')
: t('collab.runRow.unconfirmed')
: row.state;
const stateClass = row.outstanding
? `${styles.runState} ${styles.runStateOutstanding}`
: row.live
? `${styles.runState} ${styles.runStateLive}`
: styles.runState;
return (
<div
className={styles.runRow}
style={
row.run.agentColor
? { borderInlineStartColor: row.run.agentColor }
: undefined
}
>
<span className={styles.runAgent}>{row.run.agentName}</span>
<span className={stateClass}>
{state}
{row.live && row.run.status !== 'cancelling' && onCancelRun ? (
<button
type="button"
className={styles.runCancel}
onClick={() => onCancelRun(row.run.id)}
>
{t('collab.runRow.cancel')}
</button>
) : null}
</span>
{row.live && (
<div className={styles.runProgress} role="status">
{row.run.startedAt && (
<div>
{t('collab.runRow.elapsed', {
elapsed: formatElapsed(now - row.run.startedAt, t),
})}
</div>
)}
{progress ? (
<div>
{t('collab.runRow.lastActivity', {
elapsed: formatElapsed(now - progress.activityAt, t),
})}
</div>
) : (
<div>
{row.run.status === 'queued'
? t('collab.runRow.queuedHint')
: t('collab.runRow.unconfirmedHint')}
</div>
)}
</div>
)}
{progress?.detail && (
<details className={styles.runProgress} open={row.live}>
<summary>{t('collab.runRow.activity')}</summary>
<div>{progress.detail}</div>
</details>
)}
{progress?.thoughtText && (
<details className={styles.runProgress} open={row.live}>
<summary>{t('collab.runRow.thinking')}</summary>
<div className="max-h-64 overflow-y-auto whitespace-pre-wrap break-words">
{progress.thoughtText}
</div>
{progress.thoughtText.length >= 65536 && (
<p>{t('collab.runRow.thinkingCap')}</p>
)}
</details>
)}
{progress?.outputText && (
<details className={styles.runProgress}>
<summary>{t('collab.runRow.output')}</summary>
<Markdown
content={progress.outputText}
isStreaming={row.run.status === 'running'}
/>
{progress.outputText.length >= 262144 && (
<p>{t('collab.runRow.outputCap')}</p>
)}
</details>
)}
<span className={styles.runTrigger}>
{triggerLabel(row.run.trigger, t)}
{sessionId && onOpenAgentSession ? (
<>
{' · '}
<button
type="button"
className={styles.runLink}
onClick={() => onOpenAgentSession(sessionId)}
>
{t('collab.runRow.openSession', { agent: row.run.agentName })}
</button>
</>
) : null}
</span>
{row.run.error ? (
<span className={styles.runError}>
{row.run.error === 'agent_run_stalled'
? t('collab.runRow.stalled')
: row.run.error}
</span>
) : null}
</div>
);
}
/**
* Previews what pressing send will actually do.
*
* The rules are a pure function on the server, so this is the real outcome
* rather than a guess: who will be woken, who will not, and the fix for each
* refusal. The case worth designing for is "nobody" — that is the one the
* system used to swallow silently, so it is the headline when it happens.
*/
function RoutingPreview({
targets,
}: {
targets: readonly RoutingPreviewTarget[];
}) {
const { t } = useI18n();
const lead = summarizePreview(targets, t);
const nobody = !targets.some((target) => target.willWake);
return (
<div className={styles.preview}>
<p
className={
nobody
? `${styles.previewLead} ${styles.previewLeadNobody}`
: styles.previewLead
}
>
{lead}
</p>
{targets
.filter((target) => !target.willWake)
.map((target) => {
const explained = explainSkip(
target.reason ?? '',
target.agentName,
t,
);
return (
<p
key={`${target.agentName}:${target.reason ?? 'unknown'}`}
className={`${styles.previewTarget} ${
target.unknown ? styles.previewUnknown : styles.previewSkip
}`}
>
<span className={styles.previewName}>@{target.agentName}</span>
<span>{explained}</span>
</p>
);
})}
</div>
);
}
/**
* A thread as a ledger of outstanding obligations, with the conversation as
* the evidence underneath it.
*
* A chat log with a status badge is the obvious shape, and it would hide what
* this system knows that a chat app does not: who owes what, and what the
* thread is waiting on. So the header is the resolver's sentence, and every
* run carries its close obligation where a task list would carry a status.
*/
export function ThreadView({
thread,
agents,
preview,
draft,
onDraftChange,
onReply,
onBack,
onOpenThread,
onOpenAgentSession,
onCancelRun,
onMarkDone,
onOpenConversation,
onAssign,
replyPending,
}: ThreadViewProps) {
const { t } = useI18n();
const { live, past } = useMemo(
() => buildRunRows(thread.runs, t),
[thread.runs, t],
);
const [showPast, setShowPast] = useState(false);
const budget = useMemo(
() => formatBudget(thread.budget, t),
[thread.budget, t],
);
const attention =
thread.status === 'blocked' || thread.status === 'in_review';
const active = live.find((row) => row.run.status !== 'queued') ?? live[0];
const currentAssignee = agents.find(
(agent) => agent.name === thread.assigneeName,
);
return (
<div className={styles.page}>
<header className={styles.pageHeader}>
<Button
variant="ghost"
size="icon"
className={styles.backButton}
onClick={onBack}
aria-label={t('collab.thread.back')}
>
<ArrowLeftIcon />
</Button>
{thread.parent && onOpenThread ? (
<button
type="button"
className={styles.parentLink}
onClick={() => onOpenThread(thread.parent!.id)}
>
{thread.parent.title}
<span aria-hidden="true"> /</span>
</button>
) : null}
<h1 className={styles.title}>{thread.title}</h1>
{onAssign ? (
<select
className={styles.assigneeSelect}
value={thread.assigneeName ?? ''}
onChange={(event) => onAssign(event.target.value || undefined)}
disabled={thread.status === 'done' || replyPending}
aria-label={t('collab.form.assignee')}
>
<option value="">{t('collab.detail.noAssignee')}</option>
{thread.assigneeName &&
(!currentAssignee?.enabled || currentAssignee?.retiredAt) ? (
// The thread keeps naming whoever it was assigned to, and says
// in what way they are unavailable rather than dropping them.
<option value={thread.assigneeName}>
{thread.assigneeName}{' '}
{currentAssignee?.retiredAt
? t('collab.detail.assigneeRetired')
: currentAssignee
? t('collab.detail.assigneeDisabled')
: t('collab.detail.assigneeRemoved')}
</option>
) : null}
{agents
.filter((agent) => agent.enabled && !agent.retiredAt)
.map((agent) => (
<option key={agent.name} value={agent.name}>
{agent.name}
</option>
))}
</select>
) : null}
{thread.priority && thread.priority !== 'normal' ? (
// Only when it is not the default: a chip on every thread saying
// "Normal" would be a label where no decision was made.
<span
className={
thread.priority === 'urgent'
? `${styles.priorityChip} ${styles.priorityUrgent}`
: styles.priorityChip
}
>
{t(`collab.priority.${thread.priority}`)}
</span>
) : null}
{active ? (
<span className={styles.workingChip}>
<span className={styles.workingDot} aria-hidden="true" />
{active.run.agentName} {active.state}
</span>
) : null}
{onOpenConversation ? (
<Button
variant="outline"
size="sm"
className={styles.doneButton}
onClick={onOpenConversation}
>
{t('collab.detail.openConversation')}
</Button>
) : null}
{thread.status !== 'done' && onMarkDone ? (
<Button
variant="outline"
size="sm"
// The first action pushes the group right; one is enough.
className={onOpenConversation ? undefined : styles.doneButton}
onClick={onMarkDone}
>
{t('collab.markDone')}
</Button>
) : null}
</header>
<div className={styles.body}>
<div className={styles.main}>
<p
className={
attention
? `${styles.reason} ${styles.reasonAttention}`
: styles.reason
}
aria-live="polite"
>
{statusReasonLabel(thread.reason, t)}
</p>
{thread.body.startsWith(CONVERSATION_CONTEXT_PREFIX) ? (
// Folded as in the chat: it is background, not the request.
<details className={styles.threadBody}>
<summary className="cursor-pointer text-muted-foreground">
{t('collab.thread.context')}
</summary>
<div className="mt-2 max-h-48 overflow-y-auto whitespace-pre-wrap break-words">
{thread.body.slice(CONVERSATION_CONTEXT_PREFIX.length)}
</div>
</details>
) : thread.body ? (
<div className={styles.threadBody}>
<Markdown content={thread.body} />
</div>
) : null}
{thread.acceptanceCriteria ? (
<section className={styles.criteria}>
<h2 className={styles.criteriaTitle}>
{t('collab.detail.doneWhen')}
</h2>
<div className={styles.criteriaText}>
<Markdown content={thread.acceptanceCriteria} />
</div>
</section>
) : null}
{thread.children && thread.children.length > 0 ? (
<section className={styles.children}>
<h2 className={styles.sectionTitle}>
{t('collab.detail.subtasks')}
</h2>
{thread.children.map((child) => (
<button
key={child.id}
type="button"
className={styles.childRow}
onClick={() => onOpenThread?.(child.id)}
disabled={!onOpenThread}
>
<strong>{child.title}</strong>
<span>{statusReasonLabel(child.reason, t)}</span>
</button>
))}
</section>
) : null}
<div className={styles.posts}>
{thread.posts.map((post) => (
<article
key={post.id}
className={
post.authorKind === 'system'
? `${styles.post} ${styles.systemPost}`
: styles.post
}
>
<span className={styles.sequence}>{post.sequence}</span>
<span className={styles.author}>
{post.authorName}
{post.authorDeleted ? t('collab.detail.authorRemoved') : ''}
</span>
<span className={styles.time}>{formatTime(post.at)}</span>
<div className={styles.postText}>
<Markdown
content={post.text}
{...(post.authorKind === 'agent'
? { source: 'assistant' as const }
: {})}
/>
</div>
{post.outcomes?.map((outcome, index) => {
const skipped =
outcome.kind === 'skip'
? explainSkip(
outcome.reason ?? '',
outcome.agentName ?? '',
t,
)
: undefined;
const result = skipped
? skipped
: outcome.kind === 'dispatch'
? t('collab.detail.outcome.dispatch')
: outcome.into === 'running'
? t('collab.detail.outcome.running')
: t('collab.detail.outcome.queued');
return (
<p className={styles.postOutcome} key={index}>
{t('collab.detail.routing')}{' '}
{outcome.agentName ? `@${outcome.agentName} · ` : ''}
{result}
</p>
);
})}
</article>
))}
</div>
<div className={styles.composer}>
<textarea
className={styles.composerInput}
value={draft}
onChange={(event) => onDraftChange(event.target.value)}
placeholder={t('collab.detail.reply')}
aria-label={t('collab.detail.reply')}
/>
{preview && draft.trim() ? (
<RoutingPreview targets={preview} />
) : null}
<div className={styles.composerActions}>
<Button
size="sm"
onClick={onReply}
disabled={!draft.trim() || replyPending}
>
{t('collab.detail.send')}
</Button>
</div>
</div>
</div>
<aside className={styles.sidebar}>
<section>
<h2 className={styles.sectionTitle}>{t('collab.detail.runs')}</h2>
{live.length === 0 && past.length === 0 ? (
<p className={styles.budgetLine}>{t('collab.detail.noRuns')}</p>
) : null}
{live.map((row) => (
<RunRowView
key={row.run.id}
row={row}
{...(onOpenAgentSession ? { onOpenAgentSession } : {})}
{...(onCancelRun ? { onCancelRun } : {})}
/>
))}
{past.length > 0 ? (
<>
{showPast
? past.map((row) => (
<RunRowView
key={row.run.id}
row={row}
{...(onOpenAgentSession ? { onOpenAgentSession } : {})}
{...(onCancelRun ? { onCancelRun } : {})}
/>
))
: null}
<button
type="button"
className={styles.pastToggle}
onClick={() => setShowPast((open) => !open)}
aria-expanded={showPast}
>
{showPast
? t('collab.detail.hidePast')
: t('collab.detail.showPast', { count: past.length })}
</button>
</>
) : null}
</section>
<section>
<h2 className={styles.sectionTitle}>{t('collab.detail.budget')}</h2>
<p className={styles.budgetLine}>{budget.turns}</p>
<p className={styles.budgetLine}>{budget.tokens}</p>
<p className={styles.budgetLine}>{budget.scope}</p>
</section>
</aside>
</div>
</div>
);
}

View file

@ -1,372 +0,0 @@
/* Threads list — grouped by what each thread needs, never by recency. */
.page {
display: flex;
min-height: 100%;
flex-direction: column;
margin: -16px -20px 0;
}
.pageHeader {
display: flex;
min-height: 46px;
flex: 0 0 auto;
align-items: center;
gap: 10px;
padding: 10px 20px;
border-block-end: 1px solid var(--border);
}
.headerActions,
.formActions {
display: flex;
gap: 8px;
margin-inline-start: auto;
}
.viewTabs {
display: flex;
gap: 2px;
}
.field {
min-height: 36px;
padding: 7px 9px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--background);
color: var(--foreground);
font: inherit;
}
.field:focus-visible {
outline: 2px solid var(--ring);
outline-offset: -2px;
}
.roster {
display: flex;
flex-direction: column;
gap: 2px;
padding-bottom: 18px;
border-bottom: 1px solid var(--border);
}
.sectionTitle {
margin-bottom: 6px;
color: var(--muted-foreground);
font-size: 13px;
font-weight: 600;
}
.agentRow {
display: grid;
/* Every row is its own grid, so the waiting count gets a fixed track: an
auto one let a row with "1 waiting" shift its columns off the others. */
grid-template-columns:
10px minmax(80px, 0.5fr) minmax(120px, 1fr) minmax(120px, 1fr)
6.5rem auto auto;
gap: 10px;
align-items: center;
min-height: 34px;
padding: 4px 8px;
font-size: 13px;
}
.agentRowDisabled {
color: var(--muted-foreground);
}
.agentDot,
.agentDotDisabled {
width: 8px;
height: 8px;
border: 2px solid currentcolor;
border-radius: 50%;
background: currentcolor;
color: var(--status-running-fg);
}
.agentDotDisabled {
background: transparent;
color: var(--muted-foreground);
}
.agentDescription,
.agentActivity,
.agentWaiting,
.emptyRoster {
overflow: hidden;
color: var(--muted-foreground);
text-overflow: ellipsis;
white-space: nowrap;
}
.agentName,
.agentActivityLink {
overflow: hidden;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
text-align: start;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.agentName {
font-weight: 600;
}
.agentName:hover,
.agentActivityLink:hover {
color: var(--foreground);
text-decoration: underline;
}
.agentWaiting {
font-variant-numeric: tabular-nums;
text-align: end;
}
.agentAction {
white-space: nowrap;
padding: 2px 4px;
border: 0;
background: none;
color: var(--muted-foreground);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.agentAction:hover:not(:disabled) {
color: var(--foreground);
}
.agentAction:disabled {
opacity: 0.4;
cursor: default;
}
.emptyRoster {
padding: 8px;
font-size: 13px;
}
@media (max-width: 720px) {
.agentRow {
grid-template-columns: 10px minmax(80px, 1fr) auto;
}
.agentAction {
grid-column: 2 / -1;
justify-self: end;
}
.agentDescription,
.agentActivity {
display: none;
}
}
.pageBody {
display: flex;
min-width: 0;
flex-direction: column;
gap: 20px;
padding: 20px 24px 32px;
}
.group {
display: flex;
flex-direction: column;
gap: 2px;
}
/* Sentence case, not a tracked-out eyebrow. The count is part of the
heading because "how many need me" is the question being answered. */
.groupHeading {
display: flex;
align-items: baseline;
gap: 8px;
margin: 0 0 6px;
padding: 0;
border: 0;
background: none;
color: var(--muted-foreground);
font: inherit;
font-size: 13px;
font-weight: 600;
text-align: start;
cursor: default;
}
.groupHeadingToggle {
cursor: pointer;
}
.groupHeadingToggle:hover {
color: var(--foreground);
}
.groupCount {
color: var(--muted-foreground);
font-size: 12px;
font-weight: 400;
font-variant-numeric: tabular-nums;
}
.row {
display: grid;
grid-template-columns: 1fr;
gap: 2px 12px;
padding: 8px 10px 9px;
border: 0;
border-radius: var(--radius);
background: none;
color: inherit;
font: inherit;
text-align: start;
cursor: pointer;
}
.row:hover {
background: var(--secondary);
}
.row:focus-visible {
outline: 2px solid var(--ring);
outline-offset: -2px;
}
/* The single attention treatment. Carried by nothing else on the page, and
always paired with the sentence below the title so it is never colour
alone. `blocked` and `in_review` share it: they are opposite in valence but
they are the same query — this is waiting on me. */
.attention {
border-inline-start: 2px solid var(--status-attention-fg);
border-start-start-radius: 0;
border-end-start-radius: 0;
padding-inline-start: 8px;
}
.rowTitle {
min-width: 0;
overflow: hidden;
color: var(--foreground);
font-size: 14px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The resolver's own sentence, rendered verbatim. */
.rowReason {
grid-column: 1;
min-width: 0;
overflow: hidden;
color: var(--muted-foreground);
font-size: 13px;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.emptyState {
padding: 40px 0;
color: var(--muted-foreground);
font-size: 13px;
line-height: 1.6;
text-align: center;
}
.emptyLead {
color: var(--foreground);
font-size: 14px;
}
/* The configuration form opens inside the row it belongs to, spanning it, so
it reads as that agent's settings and not as a second panel about nothing
in particular. */
.agentConfig {
display: grid;
grid-column: 1 / -1;
gap: 10px;
margin: 8px 0 4px;
padding: 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
}
.agentWorkspace {
display: flex;
grid-column: 1 / -1;
flex-direction: column;
gap: 2px;
margin: 8px 0 4px;
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
}
.agentWorkspaceHeader {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 0 8px 6px;
color: var(--muted-foreground);
font-size: 12px;
}
.agentWorkspaceHeader strong {
color: var(--foreground);
font-size: 13px;
}
.configLabel {
display: grid;
gap: 4px;
color: var(--muted-foreground);
font-size: 12px;
}
.configNote {
margin: 0;
color: var(--muted-foreground);
font-size: 12px;
}
/* The boundary every agent runs under. Stated once at the foot of the roster
because it belongs to the workspace, not to any one identity. */
.ceiling {
margin-top: 14px;
padding: 12px;
border: 1px dashed var(--border);
border-radius: var(--radius);
}
.ceilingTitle {
margin: 0 0 4px;
color: var(--foreground);
font-size: 13px;
font-weight: 600;
}
.ceilingText {
max-width: 72ch;
margin: 0;
color: var(--muted-foreground);
font-size: 13px;
line-height: 1.6;
}
.ceilingTools {
margin: 6px 0 0;
overflow-wrap: anywhere;
color: var(--muted-foreground);
font-family: var(--font-mono);
font-size: 12px;
}

View file

@ -1,777 +0,0 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useI18n } from '../../i18n';
import { useMemo, useState, type FormEvent } from 'react';
import { MoreHorizontalIcon, PlusIcon } from 'lucide-react';
import { Button } from '../ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '../ui/dialog';
import {
explainSkip,
groupThreads,
needsAttention,
statusReasonLabel,
summarizePreview,
type RoutingPreviewTarget,
type ThreadGroup,
type ThreadSummaryView,
} from './agents-view-logic';
import styles from './ThreadsPage.module.css';
/**
* A change to one agent's configuration. Absent leaves a field alone; `null`
* clears the override and returns the agent to what its definition says.
*/
export interface AgentConfigPatch {
description?: string | null;
color?: string | null;
model?: string | null;
instructions?: string | null;
agentType?: string | null;
maxConcurrentRuns?: number | null;
}
/** What every agent in this workspace may do. A property of the subsystem. */
export interface AgentCapabilitiesView {
readOnly: boolean;
allowed: readonly string[];
threadTools: readonly string[];
}
export interface ThreadsPageProps {
createError?: string;
agents: readonly WorkspaceAgentSummaryView[];
threads: readonly ThreadSummaryView[];
view: AgentWorkspaceView;
onViewChange: (view: AgentWorkspaceView) => void;
onOpenThread: (threadId: string) => void;
onDeleteAgent: (agentId: string) => void;
onSetAgentEnabled: (agentId: string, enabled: boolean) => void;
onUpdateAgent?: (agentId: string, patch: AgentConfigPatch) => void;
onOpenAgentBuilder?: () => void;
onOpenDefinitions?: () => void;
capabilities?: AgentCapabilitiesView;
onCreateThread: (input: NewThread) => Promise<boolean> | void;
workspaceCwd?: string;
workspaces?: readonly { cwd: string }[];
onWorkspaceChange?: (cwd: string) => void;
onPreviewThread?: (assignee?: string) => void;
createPreview?: readonly RoutingPreviewTarget[];
pending?: boolean;
}
export interface WorkspaceAgentSummaryView {
id: string;
name: string;
description?: string;
color?: string;
/** Definition supplying the persona. Absent uses the workspace default. */
agentType?: string;
model?: string;
/** What this identity is told on top of its definition's prompt. */
instructions?: string;
maxConcurrentRuns?: number;
enabled: boolean;
status: 'offline' | 'idle' | 'working' | 'blocked' | 'error';
/** Set once the identity is retired: it keeps its posts and takes no work. */
retiredAt?: number;
workingOn?: {
id: string;
title: string;
state: 'working' | 'finishing' | 'stopping';
};
waiting: number;
}
export type AgentWorkspaceView = 'agents' | 'tasks';
export interface NewWorkspaceAgent {
name: string;
description?: string;
agentType?: string;
model?: string;
instructions?: string;
maxConcurrentRuns?: number;
}
export type ThreadPriorityChoice = 'urgent' | 'high' | 'normal' | 'low';
export interface NewThread {
title: string;
body: string;
/** What "done" means. Sent only when written, so a blank stays absent. */
acceptanceCriteria?: string;
priority?: ThreadPriorityChoice;
assignee?: string;
/** The first post; the assignee starts from it. */
message?: string;
}
const AGENT_STATUSES = new Set([
'idle',
'working',
'blocked',
'offline',
'error',
'finishing',
'stopping',
]);
function ThreadRow({
thread,
onOpen,
}: {
thread: ThreadSummaryView;
onOpen: (threadId: string) => void;
}) {
const { t } = useI18n();
return (
<button
type="button"
className={
needsAttention(thread)
? `${styles.row} ${styles.attention}`
: styles.row
}
onClick={() => onOpen(thread.id)}
>
<span className={styles.rowTitle}>{thread.title}</span>
{/* The status sentence comes from the server's resolver. The UI only
translates it one for one; it must not derive a second, shorter
vocabulary, which would win because it is the one on screen. */}
<span className={styles.rowReason}>
{statusReasonLabel(thread.reason, t)}
</span>
</button>
);
}
function Group({
group,
onOpenThread,
hidden,
}: {
group: ThreadGroup;
onOpenThread: (threadId: string) => void;
hidden?: boolean;
}) {
const { t } = useI18n();
const [collapsed, setCollapsed] = useState(group.collapsedByDefault);
const collapsible = group.collapsedByDefault;
return (
<section className={styles.group} hidden={hidden}>
<button
type="button"
className={
collapsible
? `${styles.groupHeading} ${styles.groupHeadingToggle}`
: styles.groupHeading
}
onClick={collapsible ? () => setCollapsed((open) => !open) : undefined}
aria-expanded={collapsible ? !collapsed : undefined}
disabled={!collapsible}
>
{t(`collab.group.${group.key}`)}
<span className={styles.groupCount}>{group.threads.length}</span>
</button>
{!collapsed &&
group.threads.map((thread) => (
<ThreadRow key={thread.id} thread={thread} onOpen={onOpenThread} />
))}
</section>
);
}
/**
* The thread list, grouped by what each thread needs.
*
* Recency sorting is the obvious default and it buries the two threads that
* need a person under twenty that do not. Grouping answers the question
* someone actually opens this page with.
*/
export function ThreadsPage({
agents,
threads,
view,
onViewChange,
onOpenThread,
onDeleteAgent,
onSetAgentEnabled,
onUpdateAgent,
onOpenAgentBuilder,
onOpenDefinitions,
capabilities,
onCreateThread,
workspaceCwd,
workspaces,
onWorkspaceChange,
createError,
onPreviewThread,
createPreview,
pending,
}: ThreadsPageProps) {
const groups = useMemo(() => groupThreads(threads), [threads]);
const [creating, setCreating] = useState<'thread'>();
const [configuring, setConfiguring] = useState<string>();
const [openAgentId, setOpenAgentId] = useState<string>();
const { t } = useI18n();
const [taskAssignee, setTaskAssignee] = useState('');
const statusLabel = (status: string) =>
AGENT_STATUSES.has(status) ? t(`collab.agentStatus.${status}`) : status;
const submitConfig =
(agentId: string) => (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!onUpdateAgent) return;
const data = new FormData(event.currentTarget);
const field = (name: string): string | null => {
const value = String(data.get(name) ?? '').trim();
// A field emptied on purpose clears the override rather than being
// ignored, which is the difference between "no opinion" and "back to
// the definition".
return value === '' ? null : value;
};
const runs = String(data.get('maxConcurrentRuns') ?? '').trim();
onUpdateAgent(agentId, {
description: field('description'),
model: field('model'),
agentType: field('agentType'),
instructions: field('instructions'),
maxConcurrentRuns: runs === '' ? null : Number(runs),
});
setConfiguring(undefined);
};
const submitThread = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const assignee = String(data.get('assignee') ?? '');
const acceptanceCriteria = String(
data.get('acceptanceCriteria') ?? '',
).trim();
const priority = String(data.get('priority') ?? '');
const created = await onCreateThread({
title: String(data.get('title') ?? '').trim(),
body: String(data.get('body') ?? '').trim(),
// Left out when blank or ordinary, so the thread records a decision
// only where one was made.
...(acceptanceCriteria ? { acceptanceCriteria } : {}),
...(priority && priority !== 'normal'
? { priority: priority as ThreadPriorityChoice }
: {}),
...(assignee ? { assignee } : {}),
});
if (created === false) return;
onPreviewThread?.(undefined);
setCreating(undefined);
};
const openView = (next: AgentWorkspaceView) => {
onViewChange(next);
setCreating(undefined);
setConfiguring(undefined);
setOpenAgentId(undefined);
onPreviewThread?.(undefined);
};
return (
<div className={styles.page}>
<header className={styles.pageHeader}>
<nav className={styles.viewTabs} aria-label={t('agents.title')}>
{(['agents', 'tasks'] as const).map((item) => (
<Button
key={item}
variant={view === item ? 'secondary' : 'ghost'}
size="sm"
aria-pressed={view === item}
onClick={() => openView(item)}
>
{t(`collab.tabs.${item}`)}
</Button>
))}
</nav>
<div className={styles.headerActions}>
{view === 'agents' && onOpenDefinitions ? (
<Button variant="ghost" size="sm" onClick={onOpenDefinitions}>
{t('collab.agent.roles')}
</Button>
) : null}
{view === 'agents' && onOpenAgentBuilder ? (
<Button
variant="outline"
size="sm"
onClick={() => onOpenAgentBuilder()}
>
<PlusIcon data-icon="inline-start" />
{t('collab.agent.new')}
</Button>
) : null}
{view === 'tasks' ? (
<Button
size="sm"
onClick={() => {
setCreating('thread');
setTaskAssignee('');
onPreviewThread?.(undefined);
}}
>
<PlusIcon data-icon="inline-start" />
{t('collab.thread.new')}
</Button>
) : null}
</div>
</header>
<div className={styles.pageBody}>
<p className="mb-5 text-sm text-muted-foreground">
{t(`collab.tabs.${view}Hint`)}
</p>
<Dialog
open={view === 'tasks' && creating === 'thread'}
onOpenChange={(open) => {
if (!open && !pending) setCreating(undefined);
}}
>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t('collab.thread.new')}</DialogTitle>
<DialogDescription>
{t('collab.thread.newHint')}
</DialogDescription>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={(event) => void submitThread(event)}
>
{createError && (
<p role="alert" className="text-sm text-destructive">
{createError}
</p>
)}
<label className="text-xs text-muted-foreground">
{t('collab.form.project')}
<select
className={styles.field}
value={workspaceCwd ?? ''}
disabled={pending}
onChange={(event) => {
setTaskAssignee('');
onWorkspaceChange?.(event.target.value);
}}
>
{workspaceCwd &&
!workspaces?.some(
(entry) => entry.cwd === workspaceCwd,
) && (
<option value={workspaceCwd}>
{workspaceCwd.split(/[\\/]/).filter(Boolean).at(-1)}
</option>
)}
{workspaces?.map((entry) => (
<option key={entry.cwd} value={entry.cwd}>
{entry.cwd.split(/[\\/]/).filter(Boolean).at(-1)}
</option>
))}
</select>
</label>
<p className="text-xs text-muted-foreground">
<span className="block break-all">{workspaceCwd}</span>
{t('collab.form.projectHint')}
</p>
<input
className="w-full border-0 bg-transparent text-xl font-medium outline-none"
name="title"
aria-label={t('collab.form.titleLabel')}
placeholder={t('collab.form.title')}
required
/>
<textarea
className={styles.field}
name="body"
aria-label={t('collab.form.bodyLabel')}
rows={6}
placeholder={t('collab.form.body')}
required
/>
<textarea
className={styles.field}
name="acceptanceCriteria"
aria-label={t('collab.form.criteriaLabel')}
placeholder={t('collab.form.criteria')}
/>
<select
className={styles.field}
name="priority"
aria-label={t('collab.form.priority')}
defaultValue="normal"
>
{(['urgent', 'high', 'normal', 'low'] as const).map((level) => (
<option key={level} value={level}>
{t(`collab.priority.${level}`)}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
{t('collab.form.priorityHint')}
</p>
<select
className={styles.field}
name="assignee"
key={workspaceCwd}
aria-label={t('collab.form.assignee')}
defaultValue={taskAssignee}
onChange={(event) => {
setTaskAssignee(event.target.value);
onPreviewThread?.(event.target.value || undefined);
}}
>
<option value="">{t('collab.form.noAssignee')}</option>
{agents
.filter((agent) => agent.enabled && !agent.retiredAt)
.map((agent) => (
<option key={agent.id} value={agent.name}>
{agent.name}
</option>
))}
</select>
{!taskAssignee && (
<p className="text-xs text-muted-foreground">
{t('collab.form.noAssigneeHint')}
</p>
)}
{createPreview ? (
<div
role="status"
className="space-y-1 text-xs text-muted-foreground"
>
<strong>
{taskAssignee
? summarizePreview(createPreview, t)
: t('collab.form.saveOnly')}
</strong>
{createPreview
.filter((target) => !target.willWake)
.map((target) => {
const explained = explainSkip(
target.reason ?? '',
target.agentName,
t,
);
return (
<p
key={`${target.agentName}:${target.reason ?? 'unknown'}`}
>
{explained}
</p>
);
})}
</div>
) : null}
<div className={styles.formActions}>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setCreating(undefined);
onPreviewThread?.(undefined);
}}
>
{t('collab.form.cancel')}
</Button>
<Button type="submit" size="sm" disabled={pending}>
{pending
? t('collab.form.creating')
: t('collab.form.create')}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
<section className={styles.roster} hidden={view !== 'agents'}>
<h2 className={styles.sectionTitle}>{t('collab.tabs.agents')}</h2>
{agents.length === 0 ? (
<p className={styles.emptyRoster}>{t('collab.agent.empty')}</p>
) : (
agents.map((agent) => (
<div
key={agent.id}
className={
agent.enabled && !agent.retiredAt
? styles.agentRow
: `${styles.agentRow} ${styles.agentRowDisabled}`
}
>
<span
className={
agent.enabled && !agent.retiredAt
? styles.agentDot
: styles.agentDotDisabled
}
style={agent.color ? { color: agent.color } : undefined}
aria-hidden="true"
/>
<button
type="button"
className={styles.agentName}
aria-expanded={openAgentId === agent.id}
onClick={() =>
setOpenAgentId(
openAgentId === agent.id ? undefined : agent.id,
)
}
>
{agent.name}
</button>
<span className={styles.agentDescription}>
{agent.description}
</span>
{agent.workingOn ? (
<button
type="button"
className={`${styles.agentActivity} ${styles.agentActivityLink}`}
onClick={() => onOpenThread(agent.workingOn!.id)}
>
{statusLabel(agent.workingOn.state)} ·{' '}
{agent.workingOn.title}
</button>
) : (
<span className={styles.agentActivity}>
{agent.retiredAt
? t('collab.agentStatus.retired')
: !agent.enabled
? t('collab.agentStatus.paused')
: statusLabel(agent.status)}
</span>
)}
<span className={styles.agentWaiting}>
{agent.waiting
? t('collab.agent.waiting', { count: agent.waiting })
: '—'}
</span>
{agent.retiredAt ? null : (
<>
<button
type="button"
className={styles.agentAction}
disabled={!agent.enabled || pending}
onClick={() => {
openView('tasks');
setTaskAssignee(agent.name);
setCreating('thread');
onPreviewThread?.(agent.name);
}}
>
{t('collab.agent.mentionIt')}
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={styles.agentAction}
aria-label={t('collab.agent.more', {
name: agent.name,
})}
>
<MoreHorizontalIcon size={16} aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
{onUpdateAgent ? (
<DropdownMenuItem
onSelect={() => setConfiguring(agent.id)}
>
{t('collab.agent.configure')}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
disabled={pending}
onSelect={() =>
onSetAgentEnabled(agent.id, !agent.enabled)
}
>
{agent.enabled
? t('collab.agent.pause')
: t('collab.agent.resume')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={Boolean(agent.workingOn || agent.waiting)}
onSelect={() => {
if (
window.confirm(
t('collab.agent.retireConfirm', {
name: agent.name,
}),
)
) {
onDeleteAgent(agent.id);
}
}}
>
{t('collab.agent.retire')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
{configuring === agent.id && onUpdateAgent ? (
<form
className={styles.agentConfig}
onSubmit={submitConfig(agent.id)}
>
<label className={styles.configLabel}>
{t('collab.config.description')}
<input
className={styles.field}
name="description"
defaultValue={agent.description ?? ''}
placeholder={t('collab.config.descriptionHint')}
/>
</label>
<label className={styles.configLabel}>
{t('collab.config.instructions')}
<textarea
className={styles.field}
name="instructions"
rows={4}
defaultValue={agent.instructions ?? ''}
placeholder={t('collab.config.instructionsHint')}
/>
</label>
<label className={styles.configLabel}>
{t('collab.config.agentType')}
<input
className={styles.field}
name="agentType"
defaultValue={agent.agentType ?? ''}
placeholder={t('collab.config.workspaceDefault')}
/>
</label>
<label className={styles.configLabel}>
{t('collab.config.model')}
<input
className={styles.field}
name="model"
defaultValue={agent.model ?? ''}
placeholder={t('collab.config.workspaceDefault')}
/>
</label>
<label className={styles.configLabel}>
{t('collab.config.maxRuns')}
<input
className={styles.field}
name="maxConcurrentRuns"
type="number"
min={1}
max={8}
defaultValue={agent.maxConcurrentRuns ?? 1}
/>
</label>
<p className={styles.configNote}>
{t('collab.config.note')}
</p>
<div className={styles.formActions}>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setConfiguring(undefined)}
>
{t('collab.form.cancel')}
</Button>
<Button type="submit" size="sm" disabled={pending}>
{t('collab.config.save')}
</Button>
</div>
</form>
) : null}
{openAgentId === agent.id ? (
<section className={styles.agentWorkspace}>
<div className={styles.agentWorkspaceHeader}>
<strong>{t('collab.agent.assigned')}</strong>
<span>{statusLabel(agent.status)}</span>
</div>
{threads.some(
(thread) => thread.assigneeName === agent.name,
) ? (
threads
.filter((thread) => thread.assigneeName === agent.name)
.sort((a, b) => b.updatedAt - a.updatedAt)
.map((thread) => (
<ThreadRow
key={thread.id}
thread={thread}
onOpen={onOpenThread}
/>
))
) : (
<p className={styles.emptyRoster}>
{t('collab.agent.noneAssigned')}
</p>
)}
</section>
) : null}
</div>
))
)}
{capabilities ? (
<div className={styles.ceiling}>
<h3 className={styles.ceilingTitle}>
{t('collab.ceiling.title')}
</h3>
<p className={styles.ceilingText}>
{capabilities.readOnly
? t('collab.ceiling.readOnly')
: t('collab.ceiling.open')}
</p>
<details>
<summary className="cursor-pointer text-xs text-muted-foreground">
{t('collab.ceiling.tools')}
</summary>
<p className={styles.ceilingTools}>
{capabilities.allowed.join(', ')}
</p>
</details>
</div>
) : null}
</section>
{groups.length === 0 ? (
<div className={styles.emptyState} hidden={view !== 'tasks'}>
{/* An empty screen is an invitation, not a shrug. */}
<p className={styles.emptyLead}>{t('collab.empty.lead')}</p>
<p>{t('collab.empty.hint')}</p>
</div>
) : (
groups.map((group) => (
<Group
key={group.key}
group={group}
onOpenThread={onOpenThread}
hidden={view !== 'tasks'}
/>
))
)}
</div>
</div>
);
}

View file

@ -1,633 +0,0 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
useConnection,
useWorkspace,
} from '@qwen-code/web-shell/daemon-react-sdk';
import {
ThreadsPage,
type WorkspaceAgentSummaryView,
type AgentWorkspaceView,
type NewWorkspaceAgent,
type NewThread,
type AgentConfigPatch,
type AgentCapabilitiesView,
} from './ThreadsPage';
import { ThreadView, type ThreadDetailView } from './ThreadView';
import { ThreadChat } from './ThreadChat';
import { AgentCreatePage } from '../agents/AgentCreatePage';
import { useI18n } from '../../i18n';
import type {
RoutingPreviewTarget,
ThreadSummaryView,
} from './agents-view-logic';
import {
subscribeAgentStream,
type AgentLiveEvent,
type AgentRunProgressEvent,
type AgentStreamState,
} from './agent-events';
interface CreateThreadResult {
id: string;
}
export interface ThreadsApi {
listAgents(): Promise<{
agents: WorkspaceAgentSummaryView[];
capabilities?: AgentCapabilitiesView;
}>;
listThreads(): Promise<{ threads: ThreadSummaryView[] }>;
getThread(id: string): Promise<ThreadDetailView>;
createAgent(input: NewWorkspaceAgent): Promise<unknown>;
deleteAgent(id: string): Promise<unknown>;
setAgentEnabled(id: string, enabled: boolean): Promise<unknown>;
updateAgent(id: string, patch: AgentConfigPatch): Promise<unknown>;
createThread(input: NewThread): Promise<CreateThreadResult>;
previewThread(
assignee?: string,
): Promise<{ targets: RoutingPreviewTarget[] }>;
assignThread(id: string, assignee?: string): Promise<unknown>;
previewReply(
id: string,
text: string,
): Promise<{ targets: RoutingPreviewTarget[] }>;
postReply(id: string, text: string): Promise<unknown>;
markDone(id: string): Promise<unknown>;
cancelRun(threadId: string, runId: string): Promise<unknown>;
/** Live events; absent in tests and older daemons, which then poll. */
subscribe?(
onEvent: (event: AgentLiveEvent) => void,
onState: (state: AgentStreamState) => void,
): () => void;
/** Answers a tool approval an agent is waiting on. */
respondToPermission?(
sessionId: string,
requestId: string,
optionId: string,
): Promise<unknown>;
}
export function createThreadsHttpApi(
baseUrl: string,
token: string | undefined,
workspaceCwd: string,
): ThreadsApi {
const serverUrl = baseUrl.replace(/\/+$/, '');
const root = `${serverUrl}/workspaces/${encodeURIComponent(workspaceCwd)}/agent`;
const request = async <T,>(path: string, init?: RequestInit): Promise<T> => {
const response = await fetch(`${root}${path}`, {
...init,
headers: {
...(init?.body ? { 'content-type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
const body = (await response.json()) as T & { error?: string };
if (!response.ok) {
throw new Error(
body.error || `Agent request failed (${response.status})`,
);
}
return body;
};
const post = <T,>(path: string, body: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(body) });
return {
listAgents: () => request('/agents'),
listThreads: () => request('/threads'),
getThread: (id) => request(`/threads/${encodeURIComponent(id)}`),
createAgent: (input) => post('/agents', input),
deleteAgent: (id) =>
request(`/agents/${encodeURIComponent(id)}`, { method: 'DELETE' }),
setAgentEnabled: (id, enabled) =>
request(`/agents/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify({ enabled }),
}),
updateAgent: (id, patch) =>
request(`/agents/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify(patch),
}),
createThread: (input) => post('/threads', input),
previewThread: (assignee) => post('/threads/preview', { assignee }),
assignThread: (id, assignee) =>
request(`/threads/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify({ assignee: assignee ?? null }),
}),
previewReply: (id, text) =>
post(`/threads/${encodeURIComponent(id)}/preview`, { text }),
postReply: (id, text) =>
post(`/threads/${encodeURIComponent(id)}/posts`, { text }),
markDone: (id) => post(`/threads/${encodeURIComponent(id)}/done`, {}),
cancelRun: (threadId, runId) =>
post(
`/threads/${encodeURIComponent(threadId)}/runs/${encodeURIComponent(runId)}/cancel`,
{},
),
subscribe: (onEvent, onState) =>
subscribeAgentStream(`${root}/events`, token, onEvent, onState),
respondToPermission: async (sessionId, requestId, optionId) => {
const response = await fetch(
`${serverUrl}/session/${encodeURIComponent(sessionId)}/permission/${encodeURIComponent(requestId)}`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ outcome: { outcome: 'selected', optionId } }),
},
);
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(body.error || `Approval failed (${response.status})`);
}
},
};
}
/** Folds one streamed progress frame into the open thread, if it is that thread's. */
function applyProgress(
detail: ThreadDetailView | undefined,
event: AgentRunProgressEvent,
): ThreadDetailView | undefined {
if (!detail || detail.id !== event.threadId) return detail;
let changed = false;
const runs = detail.runs.map((run) => {
// A late frame from an earlier attempt of the same run is dropped.
if (run.id !== event.runId || (run.progress?.attempt ?? 0) > event.attempt)
return run;
changed = true;
return {
...run,
status: run.status === 'queued' ? 'running' : run.status,
progress: {
attempt: event.attempt,
receivedAt: Date.now(),
activityAt: event.activityAt,
stage: event.stage,
detail: event.detail,
outputText: event.outputText,
thoughtText: event.thoughtText,
...(event.permission ? { permission: event.permission } : {}),
...(event.steps ? { steps: event.steps } : {}),
},
};
});
return changed ? { ...detail, runs } : detail;
}
const PREVIEW_DEBOUNCE_MS = 250;
/** Polling cadence while the live stream is down or unsupported. */
const REFRESH_MS = 1_000;
/** Bursts of store writes collapse into one refetch. */
const CHANGE_REFETCH_MS = 150;
export interface ThreadsRouteProps {
/** `new-agent` opens straight into the New agent page. */
initialView?: AgentWorkspaceView | 'new-agent';
initialThreadId?: string;
workspaceCwd?: string;
chat?: boolean;
activityOnly?: boolean;
onOpenActivity?: (threadId: string, workspaceCwd: string) => void;
headerActionsContainer?: HTMLElement | null;
onTitleChange?: (threadId: string, title: string) => void;
onOpenThreadChat?: (threadId: string, workspaceCwd: string) => void;
/** Switches the shell to an agent's own session. Absent when embedded
* somewhere with no session view to switch to. */
onOpenAgentSession?: (sessionId: string) => void;
onOpenDefinitions?: () => void;
}
export function ThreadsRoute({
initialView,
initialThreadId,
workspaceCwd: boundWorkspaceCwd,
chat = false,
activityOnly = false,
onOpenActivity,
headerActionsContainer,
onTitleChange,
onOpenThreadChat,
onOpenAgentSession,
onOpenDefinitions,
}: ThreadsRouteProps) {
const workspace = useWorkspace();
const connection = useConnection();
const [selectedWorkspaceCwd, setSelectedWorkspaceCwd] = useState<string>();
const workspaceCwd =
boundWorkspaceCwd ??
selectedWorkspaceCwd ??
connection.workspaceCwd ??
workspace.capabilities?.workspaces?.find((entry) => entry.primary)?.cwd;
const client = useMemo(
() =>
workspaceCwd
? createThreadsHttpApi(workspace.baseUrl, workspace.token, workspaceCwd)
: undefined,
[workspace.baseUrl, workspace.token, workspaceCwd],
);
const { t } = useI18n();
const [agents, setAgents] = useState<WorkspaceAgentSummaryView[]>([]);
const [view, setView] = useState<AgentWorkspaceView>(
initialView === undefined || initialView === 'new-agent'
? 'agents'
: initialView,
);
const [capabilities, setCapabilities] = useState<AgentCapabilitiesView>();
const [threads, setThreads] = useState<ThreadSummaryView[]>([]);
const [openId, setOpenId] = useState<string | undefined>(initialThreadId);
const [showDetails, setShowDetails] = useState(false);
const [detail, setDetail] = useState<ThreadDetailView | undefined>();
useEffect(() => {
if (chat && !activityOnly && detail)
onTitleChange?.(detail.id, detail.title);
}, [chat, activityOnly, detail, onTitleChange]);
const [draft, setDraft] = useState('');
const [preview, setPreview] = useState<RoutingPreviewTarget[] | undefined>();
const [createPreview, setCreatePreview] = useState<
RoutingPreviewTarget[] | undefined
>();
const [pending, setPending] = useState(false);
// Set while the New agent page is open.
const [creatingAgent, setCreatingAgent] = useState(
initialView === 'new-agent',
);
const [refreshError, setRefreshError] = useState<string | undefined>();
const [actionError, setActionError] = useState<string | undefined>();
const error = actionError ?? refreshError;
const draftRef = useRef(draft);
draftRef.current = draft;
const createAssigneeRef = useRef<string | undefined>(undefined);
const scope = useMemo(() => ({ client, openId }), [client, openId]);
const activeScope = useRef(scope);
activeScope.current = scope;
const refreshSequence = useRef(0);
const appliedRefresh = useRef(0);
useEffect(() => {
if (initialView && initialView !== 'new-agent') setView(initialView);
}, [initialView]);
const openThread = (id?: string) => {
setOpenId(id);
setDetail(undefined);
setDraft('');
setPreview(undefined);
setActionError(undefined);
};
const refresh = useCallback(async () => {
if (!client) return;
const sequence = ++refreshSequence.current;
try {
const [nextAgents, nextThreads, nextDetail] = await Promise.all([
client.listAgents(),
client.listThreads(),
openId ? client.getThread(openId) : undefined,
]);
if (activeScope.current !== scope || sequence < appliedRefresh.current) {
return;
}
appliedRefresh.current = sequence;
setAgents(nextAgents.agents);
if (nextAgents.capabilities) setCapabilities(nextAgents.capabilities);
setThreads(nextThreads.threads);
setDetail(nextDetail);
setRefreshError(undefined);
} catch (cause) {
if (activeScope.current !== scope || sequence < appliedRefresh.current) {
return;
}
appliedRefresh.current = sequence;
setRefreshError(cause instanceof Error ? cause.message : String(cause));
}
}, [client, openId, scope]);
const refreshRef = useRef(refresh);
refreshRef.current = refresh;
useEffect(() => {
void refresh();
}, [refresh]);
// Live updates: refetch on `changed`, fold `progress` straight into the open
// thread so replies stream. Polls only while the stream is down.
useEffect(() => {
if (!client) return;
let poll: ReturnType<typeof setInterval> | undefined;
let pendingRefetch: ReturnType<typeof setTimeout> | undefined;
const startPolling = () => {
poll ??= setInterval(() => void refreshRef.current(), REFRESH_MS);
};
if (!client.subscribe) {
startPolling();
return () => clearInterval(poll);
}
const stop = client.subscribe(
(event) => {
if (event.type === 'progress') {
setDetail((current) => applyProgress(current, event));
return;
}
pendingRefetch ??= setTimeout(() => {
pendingRefetch = undefined;
void refreshRef.current();
}, CHANGE_REFETCH_MS);
},
(state) => {
if (state === 'closed') {
startPolling();
return;
}
clearInterval(poll);
poll = undefined;
},
);
return () => {
stop();
clearInterval(poll);
clearTimeout(pendingRefetch);
};
}, [client]);
useEffect(() => {
if (!client || !openId || !draft.trim()) {
setPreview(undefined);
return;
}
const asked = draft;
let cancelled = false;
const timer = setTimeout(() => {
void client
.previewReply(openId, asked)
.then((result) => {
if (!cancelled && draftRef.current === asked) {
setPreview(result.targets);
}
})
.catch(() => {
if (!cancelled) setPreview(undefined);
});
}, PREVIEW_DEBOUNCE_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [client, draft, openId]);
const previewThread = useCallback(
(assignee?: string) => {
createAssigneeRef.current = assignee;
setCreatePreview(undefined);
if (!client) return;
void client
.previewThread(assignee)
.then((result) => {
if (createAssigneeRef.current === assignee) {
setCreatePreview(result.targets);
}
})
.catch(() => {
if (createAssigneeRef.current === assignee) {
setCreatePreview(undefined);
}
});
},
[client],
);
const mutate = useCallback(
async (action: () => Promise<unknown>) => {
setPending(true);
setActionError(undefined);
try {
const result = await action();
await refresh();
if (
result !== null &&
typeof result === 'object' &&
'dispatchError' in result &&
typeof result.dispatchError === 'string'
) {
setActionError(
`The change was saved, but background processing failed: ${result.dispatchError}`,
);
}
} catch (cause) {
setActionError(cause instanceof Error ? cause.message : String(cause));
return false;
} finally {
setPending(false);
}
return true;
},
[refresh],
);
if (!client) {
return <p role="alert">{t('collab.noWorkspace')}</p>;
}
if (creatingAgent) {
return (
<AgentCreatePage
initialScope="workspace"
workspaceCwd={workspaceCwd}
onCancel={() => setCreatingAgent(false)}
onCreated={() => setCreatingAgent(false)}
onSaveWorkspaceAgent={async (input) => {
await client.createAgent(input);
await refresh();
}}
/>
);
}
if (openId && detail?.id !== openId) {
return (
<div>
<button type="button" onClick={() => openThread()}>
{t('collab.thread.back')}
</button>
<p role="status">{error ?? t('collab.thread.loading')}</p>
</div>
);
}
if (openId && detail) {
if (chat && !showDetails) {
return (
<>
{error && (
<p role="alert" className="text-destructive">
{error}
</p>
)}
<ThreadChat
key={detail.id}
activityOnly={activityOnly}
headerActionsContainer={headerActionsContainer}
onOpenActivity={
onOpenActivity && workspaceCwd
? () => onOpenActivity(detail.id, workspaceCwd)
: undefined
}
thread={detail}
agents={agents}
preview={preview}
pending={pending}
onDraftChange={setDraft}
onDetails={() => setShowDetails(true)}
onOpenAgentSession={onOpenAgentSession}
onCancelRun={(runId) =>
void mutate(() => client.cancelRun(openId, runId))
}
onMarkDone={() => void mutate(() => client.markDone(openId))}
{...(client.respondToPermission
? {
onRespondPermission: (
sessionId: string,
requestId: string,
optionId: string,
) =>
mutate(() =>
client.respondToPermission!(
sessionId,
requestId,
optionId,
),
),
}
: {})}
onOpenThread={(id) => {
if (workspaceCwd && onOpenThreadChat)
onOpenThreadChat(id, workspaceCwd);
else openThread(id);
}}
onSend={(text) =>
mutate(async () => {
const result = await client.postReply(openId, text);
setDraft('');
setPreview(undefined);
return result;
})
}
/>
</>
);
}
return (
<>
{error ? (
<p role="alert" className="mb-3 text-sm text-destructive">
{error}
</p>
) : null}
<ThreadView
key={openId}
thread={detail}
{...(chat || (onOpenThreadChat && workspaceCwd)
? {
onOpenConversation: () => {
if (chat) setShowDetails(false);
else if (workspaceCwd)
onOpenThreadChat?.(openId, workspaceCwd);
},
}
: {})}
agents={agents}
draft={draft}
onDraftChange={setDraft}
onReply={() =>
void mutate(async () => {
if (!draft.trim()) return;
const result = await client.postReply(openId, draft);
if (activeScope.current === scope && draftRef.current === draft) {
setDraft('');
setPreview(undefined);
}
return result;
})
}
onBack={() => openThread()}
onOpenThread={openThread}
{...(onOpenAgentSession ? { onOpenAgentSession } : {})}
onCancelRun={(runId) =>
void mutate(() => client.cancelRun(openId, runId))
}
onMarkDone={() =>
void mutate(async () => {
const result = await client.markDone(openId);
return result;
})
}
onAssign={(assignee) =>
void mutate(() => client.assignThread(openId, assignee))
}
replyPending={pending}
{...(preview ? { preview } : {})}
/>
</>
);
}
return (
<>
{error ? (
<p role="alert" className="mb-3 text-sm text-destructive">
{error}
</p>
) : null}
<ThreadsPage
agents={agents}
threads={threads}
view={view}
onViewChange={setView}
createPreview={createPreview}
pending={pending}
onOpenThread={openThread}
onDeleteAgent={(id) => void mutate(() => client.deleteAgent(id))}
onSetAgentEnabled={(id, enabled) =>
void mutate(() => client.setAgentEnabled(id, enabled))
}
onUpdateAgent={(id, patch) =>
void mutate(() => client.updateAgent(id, patch))
}
onOpenAgentBuilder={() => setCreatingAgent(true)}
{...(onOpenDefinitions ? { onOpenDefinitions } : {})}
{...(capabilities ? { capabilities } : {})}
workspaceCwd={workspaceCwd}
workspaces={workspace.capabilities?.workspaces ?? []}
onWorkspaceChange={(cwd) => {
setSelectedWorkspaceCwd(cwd);
setAgents([]);
setCreatePreview(undefined);
createAssigneeRef.current = undefined;
}}
createError={error}
onCreateThread={(input) =>
mutate(async () => {
const created = await client.createThread(input);
setCreatePreview(undefined);
openThread(created.id);
if (workspaceCwd) onOpenThreadChat?.(created.id, workspaceCwd);
return created;
})
}
onPreviewThread={previewThread}
/>
</>
);
}

View file

@ -1,177 +0,0 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Client for `GET /workspaces/:ws/agent/events`, the collaboration stream.
*
* `fetch` rather than `EventSource`: the daemon wants a bearer header, which
* `EventSource` cannot send. Frames are plain `event:` + one `data:` line.
*/
export interface AgentPermissionPromptView {
requestId: string;
title: string;
options: Array<{ optionId: string; name: string; kind?: string }>;
}
export interface AgentRunStepView {
id: string;
title: string;
status: 'running' | 'done' | 'failed';
}
export interface AgentRunProgressEvent {
type: 'progress';
threadId: string;
runId: string;
attempt: number;
sessionId: string;
stage: string;
detail: string;
outputText: string;
thoughtText: string;
activityAt: number;
permission?: AgentPermissionPromptView;
steps?: AgentRunStepView[];
}
export type AgentLiveEvent =
| { type: 'changed'; threadId?: string }
| AgentRunProgressEvent;
export type AgentStreamState = 'open' | 'closed';
const MAX_RETRY_MS = 15_000;
interface SharedStream {
listeners: Set<{
onEvent: (event: AgentLiveEvent) => void;
onState: (state: AgentStreamState) => void;
}>;
state: AgentStreamState;
stop: () => void;
}
const shared = new Map<string, SharedStream>();
/**
* Subscribes to a workspace's stream. Subscribers of the same URL share one
* connection: browsers allow only a handful per origin, and the sidebar plus
* an open conversation would otherwise hold several for the same workspace.
*/
export function subscribeAgentStream(
url: string,
token: string | undefined,
onEvent: (event: AgentLiveEvent) => void,
onState: (state: AgentStreamState) => void,
): () => void {
const key = `${url}\n${token ?? ''}`;
let entry = shared.get(key);
const joining = entry !== undefined;
if (!entry) {
const created: SharedStream = {
listeners: new Set(),
state: 'closed',
stop: () => {},
};
created.stop = openStream(
url,
token,
(event) => {
for (const listener of created.listeners) listener.onEvent(event);
},
(state) => {
created.state = state;
for (const listener of created.listeners) listener.onState(state);
},
);
shared.set(key, created);
entry = created;
}
const listener = { onEvent, onState };
entry.listeners.add(listener);
// A late joiner learns where the shared stream is, so it polls while the
// stream is down just as the first subscriber does.
if (joining) onState(entry.state);
const current = entry;
return () => {
current.listeners.delete(listener);
if (current.listeners.size > 0 || shared.get(key) !== current) return;
shared.delete(key);
current.stop();
};
}
function openStream(
url: string,
token: string | undefined,
onEvent: (event: AgentLiveEvent) => void,
onState: (state: AgentStreamState) => void,
): () => void {
let stopped = false;
let controller: AbortController | undefined;
let retryMs = 1_000;
const readOnce = async () => {
controller = new AbortController();
const response = await fetch(url, {
headers: {
Accept: 'text/event-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
signal: controller.signal,
});
if (!response.ok || !response.body) {
throw new Error(`Agent event stream failed (${response.status})`);
}
onState('open');
retryMs = 1_000;
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
let buffer = '';
for (;;) {
const { value, done } = await reader.read();
if (done) return;
buffer += value;
let end: number;
while ((end = buffer.indexOf('\n\n')) >= 0) {
const frame = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
const data = frame
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart())
.join('\n');
if (!data) continue;
try {
onEvent(JSON.parse(data) as AgentLiveEvent);
} catch {
// A malformed frame is skipped; the next `changed` resyncs anyway.
}
}
}
};
void (async () => {
while (!stopped) {
try {
await readOnce();
} catch {
// Fall through to the retry below.
}
if (stopped) return;
onState('closed');
await new Promise((resolve) => setTimeout(resolve, retryMs));
retryMs = Math.min(retryMs * 2, MAX_RETRY_MS);
}
})();
return () => {
stopped = true;
controller?.abort();
};
}

View file

@ -1,432 +0,0 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Presentation logic for the agents-and-threads surface.
*
* Everything here is pure and testable without a browser, following the same
* split as `agents-manager-logic.ts`. Two rules it exists to enforce:
*
* 1. **The server decides a thread's state; this file only renders it.** The
* thread resolver already returns a status *and* the sentence explaining it.
* Deriving a second, shorter vocabulary here would give the product two
* answers to "why is this blocked", and the shorter one would win because it
* is the one on screen.
* 2. **A label never asserts a cause its reason code does not carry.** Copy
* that conflates two causes sends people to fix the wrong thing — Multica
* learned this when "runtime offline" wording sent users to reconnect a
* machine that was already connected. Each refusal below names its own fix.
*/
import type {
AgentPermissionPromptView,
AgentRunStepView,
} from './agent-events';
/**
* Opens a thread body that carries the conversation it was started from. The
* agents read it as-is; the chat view shows it folded instead of as a message.
*/
export const CONVERSATION_CONTEXT_PREFIX =
'Context from the conversation this was sent from:\n\n';
/** Wire shape of a thread row, as the REST layer returns it. */
export interface ThreadSummaryView {
id: string;
title: string;
status:
| 'open'
| 'in_progress'
| 'blocked'
| 'in_review'
| 'done'
| 'cancelled';
/** The resolver's own sentence. Rendered verbatim; never re-derived. */
reason: string;
updatedAt: number;
liveRunCount: number;
assigneeName?: string;
parentThreadId?: string;
}
export interface ThreadGroup {
key: 'needs_you' | 'running' | 'idle' | 'done';
threads: ThreadSummaryView[];
/** Finished work is evidence, not a task, so it starts collapsed. */
collapsedByDefault: boolean;
}
/**
* Groups threads by what they need, not by recency.
*
* Recency is the default sort and it buries the two threads that need a person
* under twenty that do not. `blocked` and `in_review` share one group because
* they are the same query for the reader — this is waiting on me — even though
* one is a question and the other is finished work. What tells them apart is
* each thread's own sentence, which is already on the row.
*/
export function groupThreads(
threads: readonly ThreadSummaryView[],
): ThreadGroup[] {
const needsYou: ThreadSummaryView[] = [];
const running: ThreadSummaryView[] = [];
const idle: ThreadSummaryView[] = [];
const done: ThreadSummaryView[] = [];
for (const thread of threads) {
if (thread.parentThreadId) continue;
// Cancelled files with done rather than idle: both are over, and an idle
// group is a list of things still waiting for someone, which a withdrawn
// task is not.
if (thread.status === 'done' || thread.status === 'cancelled') {
done.push(thread);
} else if (thread.status === 'blocked' || thread.status === 'in_review') {
needsYou.push(thread);
} else if (thread.liveRunCount > 0) running.push(thread);
else idle.push(thread);
}
const byRecency = (a: ThreadSummaryView, b: ThreadSummaryView) =>
b.updatedAt - a.updatedAt;
// Annotated before `.filter`, which otherwise strips the contextual type and
// widens each `key` to `string` — so a typo in one would only be caught by
// whatever reads it.
const groups: ThreadGroup[] = [
{
key: 'needs_you',
threads: needsYou.sort(byRecency),
collapsedByDefault: false,
},
{
key: 'running',
threads: running.sort(byRecency),
collapsedByDefault: false,
},
{
key: 'idle',
threads: idle.sort(byRecency),
collapsedByDefault: false,
},
{
key: 'done',
threads: done.sort(byRecency),
collapsedByDefault: true,
},
];
return groups.filter((group) => group.threads.length > 0);
}
/** Which threads carry the single attention treatment. */
export function needsAttention(thread: ThreadSummaryView): boolean {
return thread.status === 'blocked' || thread.status === 'in_review';
}
/** Wire shape of one run, as the REST layer returns it. */
export interface RunView {
progress?: {
attempt?: number;
receivedAt: number;
activityAt: number;
/** A code: starting, thinking, responding, tool, awaiting_approval, stream_lost. */
stage: string;
/** Raw text such as a tool title; never UI copy. */
detail: string;
outputText?: string;
thoughtText?: string;
permission?: AgentPermissionPromptView;
/** The turn's latest tool calls, oldest first. */
steps?: AgentRunStepView[];
};
id: string;
agentId: string;
agentName: string;
agentColor?: string;
status:
| 'queued'
| 'running'
| 'finishing'
| 'cancelling'
| 'completed'
| 'failed'
| 'cancelled';
closeKind?: 'waiting' | 'blocked' | 'review' | 'unclosed' | 'stranded';
closeAcknowledged: boolean;
failureStage?: string;
error?: string;
/** Why this run exists, e.g. "assigned by you", "mentioned by alice". */
trigger: string;
/** Queued runs of the same agent that start before this one. */
queueAhead?: number;
startedAt?: number;
endedAt?: number;
/** The agent session this run took its turn in, once one is bound. */
sessionId?: string;
}
export interface RunRow {
run: RunView;
/** What this run is doing or left behind, in the reader's words. */
state: string;
/** Live runs pin to the top; terminal runs collapse behind a count. */
live: boolean;
/** True when a person still owes this run an answer. */
outstanding: boolean;
}
/**
* Describes a run the way the reader asks about it.
*
* A failed run reports its failure whatever close it managed to record first,
* because the failure is the thing a person has to act on.
*/
export function describeRun(run: RunView, t: Translate): string {
if (run.status === 'failed') {
return run.failureStage
? t('collab.runState.failedAt', { stage: run.failureStage })
: t('collab.runState.failed');
}
if (run.status === 'cancelled') return t('collab.runState.cancelled');
if (run.status === 'queued') return t('collab.runState.queued');
if (run.status === 'running') return t('collab.runState.working');
if (run.status === 'cancelling') return t('collab.runState.stopping');
switch (run.closeKind) {
case 'blocked':
return t('collab.runState.blocked');
case 'review':
return t('collab.runState.review');
case 'waiting':
return t('collab.runState.waiting');
case 'unclosed':
return t('collab.runState.unclosed');
case 'stranded':
// Says what happened to it, not what the agent did — nothing the agent
// did ended this run, and a person has to decide what happens next.
return t('collab.runState.stranded');
default:
return run.status === 'finishing'
? t('collab.runState.finishing')
: t('collab.runState.idle');
}
}
const LIVE_RUN_STATUSES = new Set([
'queued',
'running',
'finishing',
'cancelling',
]);
/**
* Orders runs for the side panel: live first in start order, then terminal
* runs newest-first behind their count.
*
* The row carries no agent-availability indicator. Whether an agent is
* reachable is not this row's story — the run's own state is, and a second
* signal beside it competes for the same glance.
*/
export function buildRunRows(
runs: readonly RunView[],
t: Translate,
): {
live: RunRow[];
past: RunRow[];
} {
const rows = runs.map((run) => ({
run,
state: describeRun(run, t),
live: LIVE_RUN_STATUSES.has(run.status),
outstanding:
!run.closeAcknowledged &&
!LIVE_RUN_STATUSES.has(run.status) &&
(run.status === 'cancelled' ||
run.status === 'failed' ||
run.closeKind === 'blocked' ||
run.closeKind === 'review' ||
run.closeKind === 'unclosed' ||
run.closeKind === 'stranded'),
}));
return {
live: rows
.filter((row) => row.live)
.sort((a, b) => (a.run.startedAt ?? 0) - (b.run.startedAt ?? 0)),
past: rows
.filter((row) => !row.live)
.sort((a, b) => (b.run.endedAt ?? 0) - (a.run.endedAt ?? 0)),
};
}
/**
* One line, not a bar. A budget is a limit you want to notice before it trips,
* not a goal you are filling, and a bar invites the second reading.
*/
export function formatBudget(
budget: {
turnsUsed: number;
turnLimit: number;
tokensUsed: number;
tokenLimit: number;
},
t: Translate,
): { turns: string; tokens: string; scope: string } {
const compact = (value: number) =>
value >= 1000 ? `${(value / 1000).toFixed(1)}k` : String(value);
return {
turns: t('collab.budget.turns', {
used: budget.turnsUsed,
limit: budget.turnLimit,
}),
tokens: t('collab.budget.tokens', {
used: compact(budget.tokensUsed),
limit: compact(budget.tokenLimit),
}),
scope: t('collab.budget.scope'),
};
}
/** One target's fate for a draft reply, as the server previews it. */
export interface RoutingPreviewTarget {
agentName: string;
willWake: boolean;
kind?: 'dispatch' | 'coalesce' | 'skip';
into?: 'queued' | 'running';
/** Present when `willWake` is false. A skip reason from the rules layer. */
reason?: string;
/** True when the name matched no agent, so it renders as a warning. */
unknown?: boolean;
}
/**
* What a refusal means, and what to do about it.
*
* Each entry names its own fix. Two reasons that look alike but need different
* fixes stay apart: a missing definition is repaired by pointing the agent at
* one that exists, while a disabled agent is repaired by enabling it, and copy
* that merged them would send the reader to the wrong screen.
*/
export function explainSkip(
reason: string,
target: string,
t: Translate,
): string {
const known = new Set([
'agent_unknown',
'agent_disabled',
'agent_retired',
'no_target',
'queue_full',
'turn_budget_exhausted',
'token_budget_exhausted',
'thread_done',
'self_trigger',
]);
// Never invent a cause the code did not carry.
return t(known.has(reason) ? `collab.skip.${reason}` : 'collab.skip.other', {
name: target,
});
}
/**
* A one-line summary of a preview, for the composer's collapsed state.
*
* Says who *will* run, because that is the consequence of pressing send. When
* nobody will, that is the headline, since it is the case the system used to
* swallow silently.
*/
export function summarizePreview(
targets: readonly RoutingPreviewTarget[],
t: Translate,
): string {
const waking = targets.filter((target) => target.willWake);
if (waking.length === 0) return t('collab.preview.nobody');
return waking
.map((target) =>
target.kind === 'coalesce'
? t(
target.into === 'running'
? 'collab.preview.intoRunning'
: 'collab.preview.intoQueued',
{ name: target.agentName },
)
: t('collab.preview.dispatch', { name: target.agentName }),
)
.join(' ');
}
type Translate = (
key: string,
vars?: Record<string, string | number>,
) => string;
/** "45 秒" or "6 分 45 秒": the one duration format the collaboration UI uses. */
export function formatElapsed(ms: number, t: Translate): string {
const seconds = Math.max(0, Math.floor(ms / 1_000));
if (seconds < 60) return t('collab.elapsed.seconds', { count: seconds });
return t('collab.elapsed.minutes', {
minutes: Math.floor(seconds / 60),
seconds: seconds % 60,
});
}
/**
* Why a run exists, in the reader's language. The server words this in
* English from a fixed set; anything outside it is shown as sent.
*/
export function triggerLabel(trigger: string, t: Translate): string {
switch (trigger) {
case 'started by the dispatcher':
return t('collab.trigger.dispatcher');
case 'assigned to this thread':
return t('collab.trigger.assigned');
case 'a sub-thread reported back':
return t('collab.trigger.childReport');
case 'mentioned by you':
return t('collab.trigger.mentionedByYou');
case 'assigned by you':
return t('collab.trigger.assignedByYou');
default: {
const by = /^mentioned by (.+)$/.exec(trigger)?.[1];
return by ? t('collab.trigger.mentionedBy', { name: by }) : trigger;
}
}
}
const STATUS_REASON_KEYS: Record<string, string> = {
'this thread was cancelled': 'collab.reason.cancelled',
'a person marked this thread done': 'collab.reason.done',
'an Agent asked a question and is waiting for you': 'collab.reason.question',
'an Agent run was cancelled and no successor is runnable':
'collab.reason.runCancelled',
'an Agent run failed and no successor is runnable': 'collab.reason.runFailed',
'an Agent ended without a hand-off': 'collab.reason.noHandoff',
'an Agent is waiting on work that no longer exists':
'collab.reason.strandedWait',
'an Agent submitted a summary for review': 'collab.reason.review',
'an Agent is waiting on a live subtask': 'collab.reason.waitingSubtask',
'no outstanding close obligation': 'collab.reason.idle',
};
/**
* A thread's status reason in the reader's language. The server words it
* from a fixed set (the live-run line in Chinese, the rest in English);
* anything outside it is shown as sent.
*/
export function statusReasonLabel(reason: string, t: Translate): string {
const key = STATUS_REASON_KEYS[reason];
if (key) return t(key);
if (reason.startsWith('the last post booked no work')) {
return t('collab.reason.bookedNothing');
}
const queued = /^(\d+) 个智能体排队中/.exec(reason);
if (queued) return t('collab.reason.queued', { count: Number(queued[1]) });
const working = /^(\d+) 个智能体执行中(?:,(\d+) 个排队中)?$/.exec(reason);
if (working) {
return working[2]
? t('collab.reason.workingQueued', {
count: Number(working[1]),
queued: Number(working[2]),
})
: t('collab.reason.working', { count: Number(working[1]) });
}
return reason;
}

View file

@ -1,73 +0,0 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { getTranslator } from '../../i18n';
import {
formatElapsed,
statusReasonLabel,
triggerLabel,
} from './agents-view-logic';
import { COLLAB_MESSAGES_EN, COLLAB_MESSAGES_ZH } from './messages';
import * as transcriptStub from './messages.transcript-stub';
const CLIENT_DIR = join(dirname(fileURLToPath(import.meta.url)), '../..');
describe('collaboration messages', () => {
it('still reach the app through the main dictionary, in both languages', () => {
expect(getTranslator('en')('collab.agent.new')).toBe('New agent');
expect(getTranslator('zh-CN')('collab.agent.new')).toBe('新建 Agent');
expect(
getTranslator('zh-CN')('collab.run.queuedBehind', {
agent: 'lead',
count: 2,
}),
).toBe('lead 排队中,前面还有 2 个');
});
it("translate the server's triggers, status reasons and durations", () => {
// The server words triggers in English; a Chinese page showed them raw.
const zh = getTranslator('zh-CN');
expect(triggerLabel('mentioned by you', zh)).toBe('你 @ 了它');
expect(triggerLabel('mentioned by lead', zh)).toBe('lead @ 了它');
expect(triggerLabel('something new', zh)).toBe('something new');
expect(formatElapsed(405_000, zh)).toBe('6 分 45 秒');
// Status reasons: the live line comes in Chinese, the rest in English.
const en = getTranslator('en');
expect(statusReasonLabel('3 个智能体执行中,1 个排队中', en)).toBe(
'3 working, 1 queued',
);
expect(statusReasonLabel('no outstanding close obligation', zh)).toBe(
'没有待处理的事',
);
});
it('hold only collab keys, translated one for one', () => {
const en = Object.keys(COLLAB_MESSAGES_EN);
expect(en.length).toBeGreaterThan(0);
expect(en.every((key) => key.startsWith('collab.'))).toBe(true);
expect(Object.keys(COLLAB_MESSAGES_ZH).sort()).toEqual([...en].sort());
});
it('are replaced by a stub of the same shape in the transcript build', () => {
expect(Object.keys(transcriptStub).sort()).toEqual([
'COLLAB_MESSAGES_EN',
'COLLAB_MESSAGES_ZH',
]);
expect(transcriptStub.COLLAB_MESSAGES_EN).toEqual({});
expect(transcriptStub.COLLAB_MESSAGES_ZH).toEqual({});
});
it('are defined only here, not back in the main dictionary', () => {
// The transcript build embeds i18n.tsx; a collab key added there instead
// of here would ship to every exported document.
const dictionary = readFileSync(join(CLIENT_DIR, 'i18n.tsx'), 'utf8');
expect(dictionary).not.toMatch(/^ {2}'collab\./m);
});
});

View file

@ -1,13 +0,0 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Stands in for `./messages` in the transcript build (`--mode transcript`).
* The transcript renderer has no collaboration surface, so its dictionary
* carries none of those strings.
*/
export const COLLAB_MESSAGES_EN = {};
export const COLLAB_MESSAGES_ZH = {};

View file

@ -1,546 +0,0 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Agent collaboration strings (the Agent page, collaboration conversations,
* the @ picker's agent entries). Kept out of the main dictionary like Live
* Voice's: an exported transcript never renders these surfaces, so
* `vite.lib.config.ts` resolves this module to `messages.transcript-stub.ts`
* in `--mode transcript` and the transcript bundle stays within its budget.
*/
type CollabMessage =
| string
| ((vars?: Record<string, string | number>) => string);
export const COLLAB_MESSAGES_EN: Record<string, CollabMessage> = {
'collab.sending': 'Sending…',
'collab.composer.placeholder': 'Reply, or @ an agent to bring it in…',
'collab.elapsed.seconds': (v) => `${v?.count ?? 0}s`,
'collab.elapsed.minutes': (v) => `${v?.minutes ?? 0}m ${v?.seconds ?? 0}s`,
'collab.run.queued': (v) =>
`${v?.agent} is queued and starts when it is free`,
'collab.run.queuedBehind': (v) => `${v?.agent} is queued, ${v?.count} ahead`,
'collab.run.starting': (v) => `Starting ${v?.agent}…`,
'collab.run.resuming': (v) => `${v?.agent} is resuming its session…`,
'collab.run.stopping': (v) => `Stopping ${v?.agent}…`,
'collab.run.thinking': (v) => `${v?.agent} is thinking · ${v?.elapsed}`,
'collab.run.responding': (v) => `${v?.agent} is replying · ${v?.elapsed}`,
'collab.run.tool': (v) => `${v?.agent} is running a tool · ${v?.elapsed}`,
'collab.run.toolNamed': (v) =>
`${v?.agent} is running ${v?.tool} · ${v?.elapsed}`,
'collab.run.working': (v) => `${v?.agent} is working · ${v?.elapsed}`,
'collab.run.streamLost': (v) =>
`Lost the live view of ${v?.agent}; its result will still appear here`,
'collab.run.stalled': (v) =>
`${v?.agent} has shown no progress for ${v?.elapsed}. It may be stuck.`,
'collab.run.stop': 'Stop',
'collab.tabs.agents': 'Agents',
'collab.tabs.tasks': 'Conversations',
'collab.tabs.agentsHint':
'Agents are teammates you create. @ an agent in any conversation to bring it in; it can bring in others.',
'collab.tabs.tasksHint':
'Conversations where agents are working, grouped by what they need from you.',
'collab.agent.new': 'New agent',
'collab.agent.roles': 'Role templates',
'collab.agent.mentionIt': '@ Mention',
'collab.agent.more': (v) => `More actions for ${v?.name}`,
'collab.agent.configure': 'Configure',
'collab.agent.pause': 'Pause',
'collab.agent.resume': 'Resume',
'collab.agent.retire': 'Retire',
'collab.agent.retireConfirm': (v) =>
`Retire ${v?.name}? It takes no more work. Its messages stay, and its name stays reserved so no one else can post as it.`,
'collab.agent.joins': (v) => `Joins the team for ${v?.project}.`,
'collab.agent.nameHelp':
'Shown in conversations. Mention it with @name to bring it in.',
'collab.agent.role': 'Start from a role',
'collab.agent.roleNone': 'No role',
'collab.agent.roleHint':
'A role from .qwen/agents supplies base instructions and tools; what you write below adds to it.',
'collab.agent.instructions': 'Working instructions (optional)',
'collab.agent.instructionsHint':
'What it is responsible for and how it should hand back results.',
'collab.agent.description':
'When should other agents bring it in? (optional)',
'collab.agent.concurrency': 'Conversations at once',
'collab.agent.concurrencyInvalid':
'Conversations at once must be between 1 and 8.',
'collab.agent.afterCreate':
'It starts working when you mention it in a conversation.',
'collab.thread.new': 'New conversation',
'collab.thread.context': 'Context from the conversation this started in',
'collab.noWorkspace': 'Open a workspace to work with agents.',
'collab.thread.back': 'Back',
'collab.thread.loading': 'Loading conversation…',
'collab.thread.newHint':
'Say what you need and who should take it. The conversation opens once it is created.',
'collab.agent.empty':
'No agents yet. Create one, then @ it in any conversation.',
'collab.run.keepWaiting': 'Keep waiting',
'collab.run.timedOut': (v) =>
`${v?.agent} was stopped after 15 minutes without progress`,
'collab.run.failed': (v) => `${v?.agent} stopped with an error`,
'collab.run.retry': 'Retry',
'collab.run.steps': (v) => `Steps by ${v?.agent}`,
'collab.step.running': 'Running',
'collab.step.done': 'Done',
'collab.step.failed': 'Failed',
'collab.step.untitled': 'Tool call',
'collab.run.retryPrompt': 'please continue from where you stopped.',
'collab.mention.provider': 'Agents',
'collab.mention.newAgent': 'New agent…',
'collab.runRow.stage.starting': 'Starting',
'collab.runRow.stage.resuming': 'Resuming the session',
'collab.runRow.stage.waiting': 'Waiting for the model',
'collab.runRow.stage.thinking': 'Thinking',
'collab.runRow.stage.tool': 'Running a tool',
'collab.runRow.stage.responding': 'Replying',
'collab.runRow.stage.awaiting_approval': 'Waiting for your approval',
'collab.runRow.working': 'Working',
'collab.runRow.quiet': 'Waiting for new output',
'collab.runRow.queued': 'Received, waiting to start',
'collab.runRow.unconfirmed': 'Waiting for the run to start',
'collab.runRow.cancel': 'Cancel',
'collab.runRow.elapsed': (v) => `Running for ${v?.elapsed}`,
'collab.runRow.lastActivity': (v) => `Last activity ${v?.elapsed} ago`,
'collab.trigger.dispatcher': 'started by the dispatcher',
'collab.trigger.assigned': 'assigned to this thread',
'collab.trigger.childReport': 'a sub-thread reported back',
'collab.trigger.mentionedByYou': 'mentioned by you',
'collab.trigger.assignedByYou': 'assigned by you',
'collab.trigger.mentionedBy': (v) => `mentioned by ${v?.name}`,
'collab.reason.cancelled': 'Cancelled',
'collab.reason.done': 'Marked done',
'collab.reason.queued': (v) => `${v?.count} queued, not started yet`,
'collab.reason.working': (v) => `${v?.count} working`,
'collab.reason.workingQueued': (v) =>
`${v?.count} working, ${v?.queued} queued`,
'collab.reason.question': 'An agent is waiting for your answer',
'collab.reason.runCancelled': 'A run was cancelled. Reply to continue.',
'collab.reason.runFailed': 'A run failed. Reply to try again.',
'collab.reason.noHandoff': 'An agent stopped without handing off',
'collab.reason.strandedWait': 'An agent is waiting on work that is gone',
'collab.reason.bookedNothing': 'Your last message reached no agent',
'collab.reason.review': 'Ready for your review',
'collab.reason.waitingSubtask': 'Waiting on a subtask',
'collab.reason.idle': 'Nothing pending',
'collab.runRow.queuedHint':
'The model has not started yet. The message stays queued; no need to resend.',
'collab.runRow.unconfirmedHint':
'No start signal yet. No need to send it again.',
'collab.runRow.activity': 'Latest activity',
'collab.runRow.thinking': 'Thinking',
'collab.runRow.thinkingCap': 'The thinking preview reached its length limit.',
'collab.runRow.output': 'Output so far',
'collab.runRow.outputCap':
'The live preview reached its length limit; the full reply is in the conversation.',
'collab.runRow.openSession': (v) => `Open ${v?.agent}'s session`,
'collab.runRow.stalled': 'Stopped after 15 minutes without progress',
'collab.preview.nobody': 'This message will not reach any agent.',
'collab.preview.to': (v) => `Goes to ${v?.names}.`,
'collab.preview.only': (v) =>
`Only ${v?.names} will get this. Other members are not interrupted.`,
'collab.skip.agent_unknown': (v) =>
`No agent is named ${v?.name}. Check the spelling, or create it.`,
'collab.skip.agent_disabled': (v) =>
`${v?.name} is paused. Resume it on the Agents page.`,
'collab.skip.agent_retired': (v) =>
`${v?.name} is retired. Hand this to another agent.`,
'collab.skip.no_target':
'This reaches nobody. Mention an agent to send it to them.',
'collab.skip.queue_full': (v) =>
`${v?.name} has a full backlog. Wait, or ask another agent.`,
'collab.skip.turn_budget_exhausted':
'The agents have used their unattended turns. Your reply lets them continue.',
'collab.skip.token_budget_exhausted':
'This conversation has used its token budget, so agents no longer wake each other. A reply from you can still wake them.',
'collab.skip.thread_done':
'This conversation is done. Start a new one to continue.',
'collab.skip.self_trigger': (v) => `${v?.name} cannot wake itself.`,
'collab.skip.other': (v) => `${v?.name} will not be woken.`,
'collab.mention.noAttachments':
'Attachments cannot be sent to an agent yet. Send text to start.',
'collab.agentStatus.idle': 'Idle',
'collab.agentStatus.working': 'Working',
'collab.agentStatus.blocked': 'Waiting for you',
'collab.agentStatus.offline': 'Runtime offline',
'collab.agentStatus.error': 'Needs attention',
'collab.agentStatus.paused': 'Paused',
'collab.agentStatus.retired': 'Retired',
'collab.team.title': 'Team',
'collab.team.members': (v) => `Members (${v?.count ?? 0})`,
'collab.team.empty': 'No agent has joined yet.',
'collab.team.lead': 'lead',
'collab.team.live': 'Working now',
'collab.team.tasks': (v) => `Subtasks (${v?.count ?? 0})`,
'collab.team.parent': (v) => `Parent task: ${v?.title}`,
'collab.team.history': (v) => `Past runs (${v?.count ?? 0})`,
'collab.member.idle': 'Idle',
'collab.member.queued': 'Queued',
'collab.member.starting': 'Starting',
'collab.member.thinking': 'Thinking',
'collab.member.responding': 'Replying',
'collab.member.tool': (v) => `Running ${v?.tool}`,
'collab.member.approval': 'Waiting for your approval',
'collab.member.stalled': 'May be stuck',
'collab.member.timedOut': 'Stopped: no progress for 15 minutes',
'collab.member.failed': 'Failed',
'collab.member.done': 'Done',
'collab.markDone': 'Accept and mark done',
'collab.details': 'Task details',
'collab.approval.title': (v) => `${v?.agent} wants to run a tool`,
'collab.runState.failedAt': (v) => `failed at ${v?.stage}`,
'collab.runState.failed': 'failed',
'collab.runState.cancelled': 'cancelled',
'collab.runState.queued': 'waiting to start',
'collab.runState.working': 'working',
'collab.runState.stopping': 'stopping',
'collab.runState.blocked': 'asked a question',
'collab.runState.review': 'submitted for review',
'collab.runState.waiting': 'waiting for other work',
'collab.runState.unclosed': 'ended without a hand-off',
'collab.runState.stranded': 'stranded when collaboration was turned off',
'collab.runState.finishing': 'finishing',
'collab.runState.idle': 'nothing outstanding',
'collab.budget.turns': (v) => `${v?.used} of ${v?.limit} unattended turns`,
'collab.budget.tokens': (v) => `${v?.used} of ${v?.limit} tokens`,
'collab.budget.scope': 'across this conversation and its subtasks',
'collab.preview.intoRunning': (v) =>
`${v?.name} gets this in the run it is doing now.`,
'collab.preview.intoQueued': (v) =>
`${v?.name} gets this with its queued run.`,
'collab.preview.dispatch': (v) => `${v?.name} will start on this.`,
'collab.priority.urgent': 'Urgent',
'collab.priority.high': 'High priority',
'collab.priority.normal': 'Normal priority',
'collab.priority.low': 'Low priority',
'collab.group.needs_you': 'Needs you',
'collab.group.running': 'Running',
'collab.group.idle': 'Not started',
'collab.group.done': 'Finished',
'collab.agentStatus.finishing': 'Finishing',
'collab.agentStatus.stopping': 'Stopping',
'collab.agent.waiting': (v) => `${v?.count} waiting`,
'collab.agent.assigned': 'Assigned conversations',
'collab.agent.noneAssigned':
'Nothing is assigned to it yet. Use @ Mention, or @ it in any conversation.',
'collab.form.project': 'Project',
'collab.form.projectHint':
'The same projects as the sidebar. It decides where the conversation lives and which agents can join. Defaults to the current project.',
'collab.form.titleLabel': 'Title',
'collab.form.title': 'What needs doing?',
'collab.form.bodyLabel': 'Description',
'collab.form.body': 'Background, requirements and limits for the team',
'collab.form.criteriaLabel': 'Done when',
'collab.form.criteria': 'Done when: what has to be true to call it finished?',
'collab.form.priority': 'Priority',
'collab.form.priorityHint':
'Priority only changes the queue order. It never interrupts work in progress.',
'collab.form.assignee': 'Assignee',
'collab.form.noAssignee': 'Nobody yet (save without starting)',
'collab.form.noAssigneeHint':
'Nothing starts yet. Assign an agent later, or @ one in the conversation.',
'collab.form.saveOnly': 'Saves the conversation without starting an agent.',
'collab.form.cancel': 'Cancel',
'collab.form.creating': 'Creating…',
'collab.form.create': 'Create',
'collab.config.description': 'Role',
'collab.config.descriptionHint':
'For example: review code and report problems to the lead',
'collab.config.instructions': 'Instructions',
'collab.config.instructionsHint':
'How to work and what to deliver. Instructions cannot widen tool access.',
'collab.config.agentType': 'Role template',
'collab.config.workspaceDefault': 'Workspace default',
'collab.config.model': 'Model',
'collab.config.maxRuns': 'Conversations at once',
'collab.config.note':
"Leave a field empty to use the role template's default.",
'collab.config.save': 'Save',
'collab.ceiling.title': 'What agents may do',
'collab.ceiling.readOnly':
'Agents can read, delegate and summarize, but cannot change files. Instructions cannot widen tool access.',
'collab.ceiling.open': 'No read-only limit is on.',
'collab.ceiling.tools': 'Show allowed tools',
'collab.empty.lead': 'No conversations yet.',
'collab.empty.hint': 'Start one and pick an agent to work on it.',
'collab.detail.noAssignee': 'No assignee',
'collab.detail.assigneeRetired': '(retired)',
'collab.detail.assigneeDisabled': '(paused)',
'collab.detail.assigneeRemoved': '(removed)',
'collab.detail.doneWhen': 'Done when',
'collab.detail.subtasks': 'Subtasks',
'collab.detail.authorRemoved': ' (removed)',
'collab.detail.outcome.dispatch': 'Started a run',
'collab.detail.outcome.running':
'Added to a run in progress; not a read receipt',
'collab.detail.outcome.queued': 'Added to a queued run',
'collab.detail.routing': 'Routing:',
'collab.detail.reply': 'Reply in this conversation',
'collab.detail.send': 'Send',
'collab.detail.runs': 'Runs',
'collab.detail.noRuns': 'Nothing has run here yet.',
'collab.detail.hidePast': 'Hide past runs',
'collab.detail.showPast': (v) => `Show past runs (${v?.count})`,
'collab.detail.budget': 'Budget',
'collab.detail.openConversation': 'Open conversation',
'collab.sidebar.loadFailed': (v) =>
`Could not load the conversations of ${v?.name}.`,
};
export const COLLAB_MESSAGES_ZH: Record<string, CollabMessage> = {
'collab.sending': '正在发送…',
'collab.composer.placeholder': '回复,或 @ 一个 Agent 让它加入…',
'collab.elapsed.seconds': (v) => `${v?.count ?? 0} 秒`,
'collab.elapsed.minutes': (v) =>
`${v?.minutes ?? 0} 分 ${v?.seconds ?? 0} 秒`,
'collab.run.queued': (v) => `${v?.agent} 排队中,空出来就开始`,
'collab.run.queuedBehind': (v) =>
`${v?.agent} 排队中,前面还有 ${v?.count} 个`,
'collab.run.starting': (v) => `正在唤起 ${v?.agent}…`,
'collab.run.resuming': (v) => `${v?.agent} 正在继续原会话…`,
'collab.run.stopping': (v) => `正在停止 ${v?.agent}…`,
'collab.run.thinking': (v) => `${v?.agent} 正在思考,${v?.elapsed}`,
'collab.run.responding': (v) => `${v?.agent} 正在回复,${v?.elapsed}`,
'collab.run.tool': (v) => `${v?.agent} 正在运行工具,${v?.elapsed}`,
'collab.run.toolNamed': (v) =>
`${v?.agent} 正在运行 ${v?.tool},${v?.elapsed}`,
'collab.run.working': (v) => `${v?.agent} 正在工作,${v?.elapsed}`,
'collab.run.streamLost': (v) =>
`${v?.agent} 的实时输出中断了,结果出来后仍会显示在这里`,
'collab.run.stalled': (v) =>
`${v?.agent} 已经 ${v?.elapsed} 没有进展,可能卡住了`,
'collab.run.stop': '停止',
'collab.tabs.agents': 'Agent',
'collab.tabs.tasks': '协作对话',
'collab.tabs.agentsHint':
'Agent 是你新建的队友。在任何对话里 @ 它就能叫它来,它也能再叫别的 Agent。',
'collab.tabs.tasksHint': 'Agent 正在工作的对话,按需要你做什么来分组。',
'collab.agent.new': '新建 Agent',
'collab.agent.roles': '角色模板',
'collab.agent.mentionIt': '@ 它',
'collab.agent.more': (v) => `${v?.name} 的更多操作`,
'collab.agent.configure': '配置',
'collab.agent.pause': '停用',
'collab.agent.resume': '启用',
'collab.agent.retire': '退役',
'collab.agent.retireConfirm': (v) =>
`退役 ${v?.name}?它不会再接任务。已有消息保留,名字也会保留,别人不能冒用。`,
'collab.agent.joins': (v) => `加入 ${v?.project} 的团队。`,
'collab.agent.nameHelp': '显示在对话里,用 @名字 把它叫进来。',
'collab.agent.role': '从角色开始',
'collab.agent.roleNone': '不使用角色',
'collab.agent.roleHint':
'.qwen/agents 里的角色提供基础指令和工具,下面写的内容在它之上补充。',
'collab.agent.instructions': '工作说明(可选)',
'collab.agent.instructionsHint': '它负责什么,结果怎么交回来。',
'collab.agent.description': '其他 Agent 什么时候该找它(可选)',
'collab.agent.concurrency': '同时处理的对话数',
'collab.agent.concurrencyInvalid': '同时处理的对话数必须在 1 到 8 之间。',
'collab.agent.afterCreate': '在对话里 @ 它,它就开始工作。',
'collab.thread.new': '新建协作对话',
'collab.thread.context': '来自原对话的上下文',
'collab.noWorkspace': '先打开一个工作区,才能和 Agent 协作。',
'collab.thread.back': '返回',
'collab.thread.loading': '正在加载对话…',
'collab.thread.newHint': '写下要做什么、交给谁。创建后会打开这个对话。',
'collab.agent.empty': '还没有 Agent。新建一个,然后在任意对话里 @ 它。',
'collab.run.keepWaiting': '再等等',
'collab.run.timedOut': (v) => `${v?.agent} 15 分钟没有进展,已停止`,
'collab.run.failed': (v) => `${v?.agent} 出错停止了`,
'collab.run.retry': '重试',
'collab.run.steps': (v) => `${v?.agent} 的步骤`,
'collab.step.running': '进行中',
'collab.step.done': '已完成',
'collab.step.failed': '失败',
'collab.step.untitled': '工具调用',
'collab.run.retryPrompt': '请从中断的地方继续。',
'collab.mention.provider': 'Agent',
'collab.mention.newAgent': '新建 Agent…',
'collab.runRow.stage.starting': '正在启动',
'collab.runRow.stage.resuming': '继续会话中',
'collab.runRow.stage.waiting': '等待模型',
'collab.runRow.stage.thinking': '思考中',
'collab.runRow.stage.tool': '调用工具中',
'collab.runRow.stage.responding': '正在回复',
'collab.runRow.stage.awaiting_approval': '等你批准',
'collab.runRow.working': '执行中',
'collab.runRow.quiet': '等待新输出',
'collab.runRow.queued': '消息已接收,排队等待启动',
'collab.runRow.unconfirmed': '等待开始运行',
'collab.runRow.cancel': '取消',
'collab.runRow.elapsed': (v) => `已运行 ${v?.elapsed}`,
'collab.runRow.lastActivity': (v) => `最近活动:${v?.elapsed}前`,
'collab.trigger.dispatcher': '由调度器启动',
'collab.trigger.assigned': '分配到这个对话',
'collab.trigger.childReport': '子任务回报',
'collab.trigger.mentionedByYou': '你 @ 了它',
'collab.trigger.assignedByYou': '你分配给它',
'collab.trigger.mentionedBy': (v) => `${v?.name} @ 了它`,
'collab.reason.cancelled': '已取消',
'collab.reason.done': '已标记完成',
'collab.reason.queued': (v) => `${v?.count} 个 Agent 排队中,还没开始`,
'collab.reason.working': (v) => `${v?.count} 个 Agent 正在工作`,
'collab.reason.workingQueued': (v) =>
`${v?.count} 个 Agent 正在工作,${v?.queued} 个排队中`,
'collab.reason.question': 'Agent 在等你回答',
'collab.reason.runCancelled': '有一次运行被取消了,回复即可继续',
'collab.reason.runFailed': '有一次运行失败了,回复即可重试',
'collab.reason.noHandoff': 'Agent 停下了,没有交接',
'collab.reason.strandedWait': 'Agent 在等一个已经不存在的任务',
'collab.reason.bookedNothing': '你上一条消息没有发给任何 Agent',
'collab.reason.review': '等你查看结果',
'collab.reason.waitingSubtask': '在等子任务完成',
'collab.reason.idle': '没有待处理的事',
'collab.runRow.queuedHint': '模型还没启动,消息保留在队列里,无需重发。',
'collab.runRow.unconfirmedHint': '还没收到启动信号,无需重复发送。',
'collab.runRow.activity': '最近执行活动',
'collab.runRow.thinking': '思考过程',
'collab.runRow.thinkingCap': '思考预览已达长度上限。',
'collab.runRow.output': '执行输出(含中间回复)',
'collab.runRow.outputCap': '实时预览已达长度上限;完整回复见对话正文。',
'collab.runRow.openSession': (v) => `打开 ${v?.agent} 的会话`,
'collab.runRow.stalled': '15 分钟没有进展,已停止',
'collab.preview.nobody': '这条消息不会发给任何 Agent。',
'collab.preview.to': (v) => `发给 ${v?.names}。`,
'collab.preview.only': (v) => `只发给 ${v?.names},其他成员不会被打断。`,
'collab.skip.agent_unknown': (v) =>
`没有名为 ${v?.name} 的 Agent。检查拼写,或者先新建它。`,
'collab.skip.agent_disabled': (v) =>
`${v?.name} 已停用。在 Agent 页启用后才能接任务。`,
'collab.skip.agent_retired': (v) => `${v?.name} 已退役,请交给别的 Agent。`,
'collab.skip.no_target': '这条消息没有发给任何人。@ 一个 Agent 就能发给它。',
'collab.skip.queue_full': (v) =>
`${v?.name} 手上的任务已满。稍等,或者交给别的 Agent。`,
'collab.skip.turn_budget_exhausted':
'Agent 已用完无人值守的轮数。你回复一句,它们就能继续。',
'collab.skip.token_budget_exhausted':
'这个对话的 token 预算已用完,Agent 之间不再互相唤醒。你自己的回复仍然可以唤醒它们。',
'collab.skip.thread_done': '这个对话已经结束,请新开一个继续。',
'collab.skip.self_trigger': (v) => `${v?.name} 不能唤醒自己。`,
'collab.skip.other': (v) => `${v?.name} 这次不会被唤醒。`,
'collab.mention.noAttachments': '暂时不能把附件发给 Agent,请先用文字发起。',
'collab.agentStatus.idle': '空闲',
'collab.agentStatus.working': '正在工作',
'collab.agentStatus.blocked': '等你处理',
'collab.agentStatus.offline': 'Runtime 离线',
'collab.agentStatus.error': '需要处理',
'collab.agentStatus.paused': '已停用',
'collab.agentStatus.retired': '已退役',
'collab.team.title': '团队',
'collab.team.members': (v) => `成员(${v?.count ?? 0})`,
'collab.team.empty': '还没有 Agent 加入。',
'collab.team.lead': '负责人',
'collab.team.live': '正在进行',
'collab.team.tasks': (v) => `子任务(${v?.count ?? 0})`,
'collab.team.parent': (v) => `父任务:${v?.title}`,
'collab.team.history': (v) => `历史运行(${v?.count ?? 0})`,
'collab.member.idle': '空闲',
'collab.member.queued': '排队中',
'collab.member.starting': '正在唤起',
'collab.member.thinking': '正在思考',
'collab.member.responding': '正在回复',
'collab.member.tool': (v) => `正在运行 ${v?.tool}`,
'collab.member.approval': '等你批准',
'collab.member.stalled': '可能卡住了',
'collab.member.timedOut': '已停止:15 分钟没有进展',
'collab.member.failed': '失败',
'collab.member.done': '已完成',
'collab.markDone': '验收并完成',
'collab.details': '任务详情',
'collab.approval.title': (v) => `${v?.agent} 想运行一个工具`,
'collab.runState.failedAt': (v) => `在 ${v?.stage} 阶段失败`,
'collab.runState.failed': '失败',
'collab.runState.cancelled': '已取消',
'collab.runState.queued': '等待开始',
'collab.runState.working': '正在工作',
'collab.runState.stopping': '正在停止',
'collab.runState.blocked': '提了一个问题',
'collab.runState.review': '已提交结果',
'collab.runState.waiting': '在等别的工作',
'collab.runState.unclosed': '结束了但没有交接',
'collab.runState.stranded': '协作关闭时被搁置',
'collab.runState.finishing': '正在收尾',
'collab.runState.idle': '没有待办',
'collab.budget.turns': (v) => `自动轮次 ${v?.used} / ${v?.limit}`,
'collab.budget.tokens': (v) => `Token ${v?.used} / ${v?.limit}`,
'collab.budget.scope': '按这个对话及其子任务合计',
'collab.preview.intoRunning': (v) =>
`${v?.name} 会在正在进行的这一轮里收到。`,
'collab.preview.intoQueued': (v) => `${v?.name} 会在排队的那一轮里收到。`,
'collab.preview.dispatch': (v) => `${v?.name} 会开始处理。`,
'collab.priority.urgent': '紧急',
'collab.priority.high': '高优先级',
'collab.priority.normal': '普通优先级',
'collab.priority.low': '低优先级',
'collab.group.needs_you': '待你处理',
'collab.group.running': '执行中',
'collab.group.idle': '待安排',
'collab.group.done': '已结束',
'collab.agentStatus.finishing': '正在收尾',
'collab.agentStatus.stopping': '正在停止',
'collab.agent.waiting': (v) => `${v?.count} 项等待中`,
'collab.agent.assigned': '分配给它的对话',
'collab.agent.noneAssigned':
'还没有分配给它的对话。点「@ 它」,或在任意对话里 @ 它。',
'collab.form.project': '所属项目',
'collab.form.projectHint':
'和侧边栏的项目一致,决定对话放在哪个项目、哪些 Agent 能参与。默认当前项目。',
'collab.form.titleLabel': '标题',
'collab.form.title': '需要完成什么?',
'collab.form.bodyLabel': '描述',
'collab.form.body': '告诉团队背景、要求和限制',
'collab.form.criteriaLabel': '验收标准',
'collab.form.criteria': '验收标准:满足哪些条件才算完成?',
'collab.form.priority': '优先级',
'collab.form.priorityHint': '优先级只影响排队顺序,不会打断正在进行的工作。',
'collab.form.assignee': '负责 Agent',
'collab.form.noAssignee': '暂不指定(只保存,不执行)',
'collab.form.noAssigneeHint':
'现在不会开始执行。之后可以指定 Agent,或在对话里 @ 一个 Agent。',
'collab.form.saveOnly': '只保存对话,暂不启动 Agent。',
'collab.form.cancel': '取消',
'collab.form.creating': '创建中…',
'collab.form.create': '创建',
'collab.config.description': '职责描述',
'collab.config.descriptionHint': '例如:检查代码并向负责人汇报问题',
'collab.config.instructions': '工作指令',
'collab.config.instructionsHint':
'说明工作方式和输出要求;指令不能扩大工具权限',
'collab.config.agentType': '角色模板',
'collab.config.workspaceDefault': '使用工作区默认配置',
'collab.config.model': '模型',
'collab.config.maxRuns': '同时处理的对话数',
'collab.config.note': '清空字段后使用角色模板的默认配置。',
'collab.config.save': '保存配置',
'collab.ceiling.title': '当前协作权限',
'collab.ceiling.readOnly':
'Agent 只能读取、派单和汇总结果,不能修改文件。职责指令不能扩大工具权限。',
'collab.ceiling.open': '当前未启用只读限制。',
'collab.ceiling.tools': '查看工具范围',
'collab.empty.lead': '还没有协作对话。',
'collab.empty.hint': '新建一个协作对话并指定 Agent,它就会开始工作。',
'collab.detail.noAssignee': '未指定负责人',
'collab.detail.assigneeRetired': '(已退役)',
'collab.detail.assigneeDisabled': '(已停用)',
'collab.detail.assigneeRemoved': '(已删除)',
'collab.detail.doneWhen': '验收标准',
'collab.detail.subtasks': '子任务',
'collab.detail.authorRemoved': '(已删除)',
'collab.detail.outcome.dispatch': '已开始一轮运行',
'collab.detail.outcome.running': '已加入进行中的运行(不代表已读)',
'collab.detail.outcome.queued': '已加入排队中的运行',
'collab.detail.routing': '分派:',
'collab.detail.reply': '在这个对话里回复',
'collab.detail.send': '发送',
'collab.detail.runs': '运行记录',
'collab.detail.noRuns': '这里还没有运行过。',
'collab.detail.hidePast': '收起历史运行',
'collab.detail.showPast': (v) => `查看历史运行(${v?.count})`,
'collab.detail.budget': '预算',
'collab.detail.openConversation': '打开对话',
'collab.sidebar.loadFailed': (v) => `无法加载 ${v?.name} 的协作对话`,
};

View file

@ -1,242 +0,0 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ComponentProps,
} from 'react';
import type { ChatEditor } from '../ChatEditor';
import type { WebShellAtProvider } from '../../customization';
import type { useI18n } from '../../i18n';
import { createThreadsHttpApi } from './ThreadsRoute';
import { CONVERSATION_CONTEXT_PREFIX } from './agents-view-logic';
import type { WorkspaceAgentSummaryView } from './ThreadsPage';
type Submit = ComponentProps<typeof ChatEditor>['onSubmit'];
/** The mention picker reuses one roster read for this long. */
const ROSTER_TTL_MS = 5_000;
const CONTEXT_MESSAGES = 6;
const CONTEXT_CHARS = 4_000;
/**
* The tail of the conversation an agent was mentioned from, so a thread
* started mid-conversation does not begin blind.
*/
export function conversationContext(
messages: readonly { role: string; content?: unknown }[],
): string {
const text = messages
.filter(
(message) =>
(message.role === 'user' || message.role === 'assistant') &&
typeof message.content === 'string' &&
message.content.trim(),
)
.slice(-CONTEXT_MESSAGES)
.map(
(message) =>
`${message.role === 'user' ? 'User' : 'Assistant'}: ${(message.content as string).trim()}`,
)
.join('\n\n');
return text.length > CONTEXT_CHARS ? `…${text.slice(-CONTEXT_CHARS)}` : text;
}
export function useAgentChatEntry({
enabled,
cwd,
baseUrl,
token,
onSubmit,
onOpen,
onError,
getContext,
onCreateAgent,
t,
}: {
enabled: boolean;
cwd?: string;
baseUrl: string;
token?: string;
onSubmit: Submit;
onOpen: (id: string, cwd: string) => void;
onError: (message: string) => void;
/** The conversation so far, when mentioning from a chat that has one. */
getContext?: () => string;
/** Offered as the picker's last item: open the New agent page. */
onCreateAgent?: () => void;
/**
* The caller's translator. App calls this hook above its I18nProvider, where
* useI18n() would hand back the default that echoes keys.
*/
t: ReturnType<typeof useI18n>['t'];
}) {
// Read through a ref so a new handler each render keeps `providers` stable.
const createAgentRef = useRef(onCreateAgent);
createAgentRef.current = onCreateAgent;
const canCreateAgent = onCreateAgent !== undefined;
const roster = useRef<
| { api: unknown; at: number; agents: Promise<WorkspaceAgentSummaryView[]> }
| undefined
>(undefined);
const api = useMemo(
() =>
enabled && cwd ? createThreadsHttpApi(baseUrl, token, cwd) : undefined,
[enabled, cwd, baseUrl, token],
);
// Names known so far, so a typed @query can be claimed without waiting.
const agentNames = useRef<string[]>([]);
const [pending, setPending] = useState(false);
const busy = useRef(false);
// The composer receives these straight as props, so both identities have to
// survive re-renders: `atProviders` feeds a memoized ChatEditor comparison
// and `submit` a memoized onSubmit prop.
const listAgents = useCallback(() => {
if (!api) return Promise.resolve([]);
const cached = roster.current;
if (cached?.api === api && Date.now() - cached.at < ROSTER_TTL_MS)
return cached.agents;
const agents = api.listAgents().then((result) => result.agents);
roster.current = { api, at: Date.now(), agents };
void agents.then(
(list) => {
agentNames.current = list
.filter((agent) => agent.enabled && !agent.retiredAt)
.map((agent) => agent.name.toLowerCase());
},
() => {},
);
agents.catch(() => {
if (roster.current?.agents === agents) roster.current = undefined;
});
return agents;
}, [api]);
// Load the roster up front so the first typed @name already resolves.
useEffect(() => {
listAgents().catch(() => {});
}, [listAgents]);
const providers = useMemo<WebShellAtProvider[]>(
() =>
api
? [
{
id: 'workspace-collaborators',
label: t('collab.mention.provider'),
claimsTypedQuery: (query) => {
const lower = query.toLowerCase();
return agentNames.current.some((name) =>
name.startsWith(lower),
);
},
search: async ({ query }) => [
...(await listAgents())
.filter(
(agent) =>
agent.enabled &&
!agent.retiredAt &&
agent.name.toLowerCase().includes(query.toLowerCase()),
)
.map((agent) => ({
id: agent.id,
label: agent.name,
description: t(`collab.agentStatus.${agent.status}`),
...(agent.color ? { iconColor: agent.color } : {}),
insertText: `@${agent.name} `,
})),
...(canCreateAgent
? [
{
id: 'collab:new-agent',
label: t('collab.mention.newAgent'),
onSelect: () => createAgentRef.current?.(),
},
]
: []),
],
},
]
: [],
[api, listAgents, canCreateAgent, t],
);
const submit = useCallback<Submit>(
(text, images, files, commit, metadata) => {
// Same rules as core's parseMentions: no ASCII word character before
// `@`, so "请@迁移助手" counts and "a@b.dev" does not.
const mentions = [
...text.matchAll(
/(?<![A-Za-z0-9_.])@([\p{L}\p{N}][\p{L}\p{N}_-]{0,47})/gu,
),
]
.filter((match) => text[(match.index ?? 0) + match[0].length] !== '/')
.map((match) => match[1].toLowerCase());
if (!api || !cwd || mentions.length === 0)
return onSubmit(text, images, files, commit, metadata);
if (busy.current) return false;
busy.current = true;
setPending(true);
void (async () => {
try {
const agents = await listAgents();
// The first agent addressed leads the thread: a follow-up without an
// @ goes to it, and sub-thread reports wake it.
// A name may run into the next word in scripts without spaces
// ("@迁移助手看一下"); the longest name the token starts with wins.
const lead = mentions
.map(
(token) =>
agents
.filter(
// Same addressability predicate as the picker above:
// interception must not fire for an agent admission
// would skip.
(agent) =>
agent.enabled &&
!agent.retiredAt &&
token.startsWith(agent.name.toLowerCase()) &&
!/^[a-z0-9_-]/.test(token.slice(agent.name.length)),
)
.sort((a, b) => b.name.length - a.name.length)[0],
)
.find((agent) => agent !== undefined);
if (!lead) {
let committed = false;
const accepted = onSubmit(
text,
images,
files,
() => {
committed = true;
commit?.();
},
metadata,
);
if (accepted !== false && !committed) commit?.();
return;
}
if (images?.length || files?.length)
throw new Error(t('collab.mention.noAttachments'));
// One request: the message is the assignment, so the lead starts
// from it instead of receiving it mid-turn.
const context = getContext?.() ?? '';
const { id } = await api.createThread({
title: text.trim().slice(0, 80),
body: context ? `${CONVERSATION_CONTEXT_PREFIX}${context}` : '',
assignee: lead.name,
message: text,
});
commit?.();
onOpen(id, cwd);
} catch (error) {
onError(error instanceof Error ? error.message : String(error));
} finally {
busy.current = false;
setPending(false);
}
})();
return false;
},
[api, cwd, onSubmit, onOpen, onError, getContext, listAgents, t],
);
return { providers, submit, pending };
}

View file

@ -1,114 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useWorkspace } from '@qwen-code/web-shell/daemon-react-sdk';
import type { DaemonSessionSummary } from '@qwen-code/sdk/daemon';
import { createThreadsHttpApi } from './ThreadsRoute';
export const COLLABORATION_SOURCE = 'workspace_collaboration';
/** Live streams the sidebar may hold; the chat needs connections too. */
const MAX_STREAMS = 2;
export function useProjectConversations(cwds: readonly string[]) {
const workspace = useWorkspace();
const key = JSON.stringify([...new Set(cwds)].sort());
const enabled = workspace.capabilities?.features?.includes(
'agent_collaboration_v1',
);
const [snapshot, setSnapshot] = useState<{
scope: string;
sessions: DaemonSessionSummary[];
error?: string;
}>();
const sessionsByCwd = useRef(new Map<string, DaemonSessionSummary[]>());
const scope = `${workspace.baseUrl}:${key}`;
useEffect(() => {
if (!enabled) return;
let disposed = false;
let busy = false;
let again = false;
const refresh = async (): Promise<void> => {
if (busy) {
again = true;
return;
}
busy = true;
const results = await Promise.all(
(JSON.parse(key) as string[]).map(async (cwd) => {
try {
const { threads } = await createThreadsHttpApi(
workspace.baseUrl,
workspace.token,
cwd,
).listThreads();
const sessions = threads
.filter((t) => !t.parentThreadId)
.map((t) => ({
sessionId: `collaboration:${t.id}`,
sourceId: t.id,
sourceType: COLLABORATION_SOURCE,
workspaceCwd: cwd,
displayName: t.title,
createdAt: new Date(t.updatedAt).toISOString(),
updatedAt: new Date(t.updatedAt).toISOString(),
hasActivePrompt: t.liveRunCount > 0,
}));
sessionsByCwd.current.set(cwd, sessions);
return { sessions };
} catch {
return {
sessions: sessionsByCwd.current.get(cwd) ?? [],
// The project's name; the sidebar words the failure.
error: cwd.split(/[\\/]/).at(-1),
};
}
}),
);
if (!disposed)
setSnapshot({
scope,
sessions: results.flatMap((r) => r.sessions),
error: results.find((r) => r.error)?.error,
});
busy = false;
if (again && !disposed) {
again = false;
void refresh();
}
};
void refresh();
// Refetch when a workspace's store changes; poll only while a stream is down.
// Each stream holds one of the browser's six HTTP/1.1 connections to the
// daemon, so workspaces past the first few are polled instead.
const cwds = JSON.parse(key) as string[];
const down = new Set(cwds.slice(MAX_STREAMS));
let poll: ReturnType<typeof setInterval> | undefined =
down.size > 0 ? setInterval(() => void refresh(), 5000) : undefined;
const stops = cwds.slice(0, MAX_STREAMS).map((cwd) =>
createThreadsHttpApi(workspace.baseUrl, workspace.token, cwd).subscribe!(
(event) => {
if (event.type === 'changed') void refresh();
},
(state) => {
if (state === 'closed') down.add(cwd);
else down.delete(cwd);
if (down.size > 0) {
poll ??= setInterval(() => void refresh(), 5000);
} else {
clearInterval(poll);
poll = undefined;
}
},
),
);
return () => {
disposed = true;
for (const stop of stops) stop();
clearInterval(poll);
};
}, [enabled, key, scope, workspace.baseUrl, workspace.token]);
return useMemo(
() =>
enabled && snapshot?.scope === scope
? snapshot
: { sessions: [], error: undefined },
[enabled, snapshot, scope],
);
}

View file

@ -492,11 +492,6 @@ export interface WebShellAtItem {
iconTooltip?: string;
insertText?: string;
composerTag?: WebShellComposerTag;
/**
* Makes the item an action instead of a reference: choosing it removes the
* typed `@query` and calls this, inserting nothing.
*/
onSelect?: () => void;
}
export type WebShellBuiltinAtProviderId =
@ -538,11 +533,6 @@ export interface WebShellAtProvider {
order?: number;
tabs?: readonly WebShellAtProviderTab[];
renderItem?: WebShellAtItemRenderer;
/**
* Claims a typed `@query` that names no category, so it searches this
* provider instead of falling back to files.
*/
claimsTypedQuery?(query: string): boolean;
search(params: {
query: string;
signal: AbortSignal;

View file

@ -1,81 +0,0 @@
# Mesh 实时回复:可重复执行的浏览器验收
测试文件:`web-shell.mesh-streaming.spec.ts`。复用仓库已有 Node + Playwright、Chromium 和 mockDaemon,不新增依赖,不跑目录扫描。需要 Node 22+、已安装的仓库依赖及 Playwright Chromium(首次可在本包执行 `npx playwright install chromium`)。
## 1. 日常页面回归(无模型费用)
从仓库根目录执行:
```bash
cd packages/web-shell
node ../../node_modules/@playwright/test/cli.js test client/e2e/web-shell.mesh-streaming.spec.ts --project=chromium --workers=1 --grep-invert @mesh-live
```
现有 Playwright 配置会启动或复用本地 Vite。运行真实 App、聊天输入框、轮询和消息渲染,只有 daemon HTTP 响应由现有 mockDaemon 与测试内的协作接口替身提供。替身状态留在 Node 测试进程中,页面刷新不会清空它。
一条连续流程检查:
- 从聊天输入框发送 `@stream-worker`,只发送一次。
- 排队时显示排队等待;启动后看到两段思考内容的累计增长,刷新仍可读。
- run 仍 running、正式结果未产生时,正文先后显示两段累计文本。
- 刷新页面后仍显示中间正文,不重新提交任务。
- Agent 超过 15 秒没有新活动时显示等待新输出(不再误报连接中断),已显示的正文不丢失。
- 正式结果替代实时预览,正文只出现一次,刷新后仍只有一次。
敏感性:如果把正文渲染改回“仅 completed 才显示”,本测试会在第一段正文断言失败;删除 sourceRunId 去重会使最终单条断言失败。**此模式不证明模型输出、真实服务落盘或跨机器互通。**
为专注正文恢复,测试通过浏览器初始化脚本选中协作对话,每次刷新都指定同一个对话;不把它算作“侧边栏选择记忆”的验收。
## 2. 真实 Agent + 浏览器(显式启用,会使用模型额度)
先启动这条分支的协作 daemon,并确认目标工作区中已有启用的 Agent、模型已登录。不要用日常忙碌中的 Agent;建议使用专门的 demo Agent。脚本不会创建 Agent、切换权限、登录模型或改写工程文件。
例如在仓库根目录启动 daemon:
```bash
QWEN_CODE_ENABLE_AGENT_COLLABORATION=1 npm run dev -- serve --hostname 127.0.0.1 --port 4171 --workspace "$PWD" --no-open
```
另开终端,在 `packages/web-shell` 启动前端:
```bash
QWEN_DAEMON_URL=http://127.0.0.1:4171 npm run dev -- --host 127.0.0.1 --port 5174 --strictPort
```
再从 `packages/web-shell` 执行(目录必须与服务注册的工作区完全一致,Agent 名称必须已存在):
```bash
MESH_E2E_CWD='/absolute/path/to/registered/project' \
MESH_E2E_AGENT='demo-worker' \
node ../../node_modules/@playwright/test/cli.js test client/e2e/web-shell.mesh-streaming.spec.ts --project=chromium --workers=1 --grep @mesh-live --retries=0
```
这里使用 loopback 免登录演示服务;不自动处理远程鉴权。前端地址不同时用 `PLAYWRIGHT_BASE_URL` 指向已启动的前端,并确认其代理指向正确 daemon。不要把凭证写进脚本或截图。
此模式通过真实 API 新建一个 `E2E live stream ...` 对话,随后在真实聊天框中发送消息。必须在结束前看到至少两次增长的正文;第一次增长后刷新验证恢复;完成后从真实 API 确认一条正式结果,并再次刷新确认页面没有重复。不会把“只出现最终结果”算通过。
本地 Qwen 必须调用 `thread_review` 才算交回验收,因此测试允许该协作工具,仅禁止文件修改、命令和联网;不要把提示词改成笼统的“禁止所有工具”。
为保留失败现场,测试对话不会删除;报告附有其 ID。测试 finally 仅请求取消本次新建对话里仍活跃的 run,不触碰其他任务。进程被强杀时 finally 无法保证执行,请根据报告中的对话 ID 手动检查。
## 结果与边界
截图和附件在 `client/e2e/test-results/`;HTML 报告在 `client/e2e/playwright-report/`。打开报告:
```bash
node ../../node_modules/@playwright/test/cli.js show-report client/e2e/playwright-report
```
成功流程也保存 growing/completed 截图;真实模式附 `created-thread` 和 `live-observations`(每次正文增长的耗时与字符数)。现有配置会在失败时保留截图、视频。需要完整首次 trace 时在运行命令追加 `--trace on`。这些产物可能包含对话信息,不要直接上传公开 PR。
没有设置真实模式的两个环境变量时,真实模式明确显示 skipped,不代表已验收。真实模型可能在首段前等待较久,或一次性返回太短文本;若无法观测到两次增长,此项必须失败而不是放宽成“最终能回复”。
本次记录(2026-09-14):默认页面回归 `1 passed (13.5s)`,真实模式未执行。首次运行因状态文字旁包含取消按钮导致精确文本定位失败,修正为定位 Agent activity 面板后重跑通过;没有放宽正文增长或去重断言。
后续补充(同日):加入排队与思考增量断言后,默认回归 `1 passed (13.0s)`;真实本地 Qwen 模式 `1 passed (1.0m)`。同步观察到 31 次思考增长、17 次正文增长,正式结果为一条。真实运行的思考采样和截图操作也补入本脚本。
本次记录(2026-09-25,`6ab6d92e1a35`):独立工作树完整构建通过后,在专用工作区启动真实 daemon 和 Web Shell,使用已登录的 `kimi-k3` 模型运行 `@mesh-live`,结果为 `1 passed (38.2s)`,未修改断言。记录到 2 次思考增长和 6 次正文增长(405 → 853 → 1412 → 1889 → 2334 → 2353 字符);首次正文增长后刷新恢复,完成后仅有一条正式结果,再次刷新仍无重复。保存了 thinking/growing/completed 截图与 Playwright trace。
同轮真实页面验证了仅填名称创建 Agent、从普通新对话的 @ 选择器召唤、无 @ 追问、停用/恢复,以及排队位置。排队计数只统计同一 Agent 前面仍在排队的任务,不包含正在运行的任务:`maxConcurrentRuns: 1` 时创建三个对话,第一条运行、第二条排队且前方为 0、第三条显示“前面还有 1 个”。验收后取消本轮剩余任务,并保留对话记录。
本轮未覆盖已有消息的普通对话上下文迁移、角色模板创建、双 Agent 子任务交接和真实审批卡片;15 分钟停滞与审批豁免只核查了实现及现有测试,没有实等触发。首次流式截图存在横向裁切,完成态未复现;后续布局探查消息没有路由到 Agent(`no_target`),不能算作运行态复验,未据此修改布局。本轮结果不代表全 PR 验收通过。

View file

@ -1,342 +0,0 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { expect, test, type Page } from '@playwright/test';
import type { ThreadDetailView } from '../../components/workspace-agents/ThreadView';
import { createWebShellDaemonScenario } from '../utils/mockDaemon';
import {
captureScreenshot,
clearFocus,
FIXED_CAPTURE_TIME,
gotoNewSession,
installScenario,
resolveBaseURL,
VISUAL_VIEWPORT,
type VisualTheme,
} from './harness';
/**
* Agent collaboration: a conversation several agents work in, its team panel
* and details, the @ picker in an ordinary chat, and the Agents page.
*
* One thread carries every run state the screens distinguish -- queued behind
* others, quiet long enough to look stuck, working through tool steps, and
* waiting on an approval -- so a change to any of them shows in one capture.
* Statuses and reasons are the server's own sentences, so the translation of
* them is what gets rendered.
*/
const THEMES: readonly VisualTheme[] = ['dark', 'light'];
const NOW = FIXED_CAPTURE_TIME.getTime();
const MIN = 60_000;
test.use({ viewport: { ...VISUAL_VIEWPORT } });
const agents = [
{
id: 'ag_lead',
name: 'lead',
description: 'Plans the work and brings in the right people.',
enabled: true,
status: 'working',
workingOn: {
id: 'th_main',
title: 'Speed up the test suite',
state: 'working',
},
waiting: 0,
},
{
id: 'ag_reviewer',
name: 'reviewer',
description: 'Reviews changes for correctness.',
enabled: true,
status: 'working',
workingOn: {
id: 'th_main',
title: 'Speed up the test suite',
state: 'working',
},
waiting: 1,
},
{
id: 'ag_docs',
name: 'docs',
description: 'Writes user-facing docs.',
enabled: true,
status: 'idle',
waiting: 0,
},
{
id: 'ag_archivist',
name: 'archivist',
description: 'Paused while the archive moves.',
enabled: false,
status: 'offline',
waiting: 0,
},
];
function mainThread(): ThreadDetailView {
return {
id: 'th_main',
title: 'Speed up the test suite',
body: 'Context from the conversation this was sent from:\n\nUser: unit tests now take 14 minutes.',
status: 'in_progress',
reason: '3 个智能体执行中,1 个排队中',
assigneeName: 'lead',
posts: [
{
id: 'p1',
sequence: 1,
authorKind: 'human',
authorName: 'user',
text: '@lead the unit tests take 14 minutes. Find the slowest suites and propose fixes.',
at: NOW - 12 * MIN,
},
{
id: 'p2',
sequence: 2,
authorKind: 'agent',
authorName: 'lead',
sourceRunId: 'run_lead_1',
text: 'I split this into two parts. @reviewer please check whether the barrel imports in packages/cli slow collection down. I will profile the core suites myself.',
at: NOW - 10 * MIN,
},
],
runs: [
{
id: 'run_lead_2',
agentId: 'ag_lead',
agentName: 'lead',
status: 'running',
closeAcknowledged: false,
trigger: 'mentioned by you',
startedAt: NOW - 3 * MIN,
progress: {
receivedAt: NOW,
activityAt: NOW - 2_000,
stage: 'tool',
detail: 'Shell: npx vitest run --reporter=json packages/core',
outputText: '',
steps: [
{
id: 's1',
title: 'Read packages/core/vitest.config.ts',
status: 'done',
},
{
id: 's2',
title: 'Shell: npx vitest list packages/core',
status: 'failed',
},
{
id: 's3',
title: 'Shell: npx vitest run --reporter=json packages/core',
status: 'running',
},
],
},
},
{
id: 'run_reviewer_1',
agentId: 'ag_reviewer',
agentName: 'reviewer',
status: 'running',
closeAcknowledged: false,
trigger: 'mentioned by lead',
startedAt: NOW - 9 * MIN,
progress: {
receivedAt: NOW,
activityAt: NOW - 6 * MIN,
stage: 'thinking',
detail: '',
},
},
{
id: 'run_docs_1',
agentId: 'ag_docs',
agentName: 'docs',
status: 'running',
closeAcknowledged: false,
trigger: 'mentioned by lead',
sessionId: 'sess_docs',
startedAt: NOW - MIN,
progress: {
receivedAt: NOW,
activityAt: NOW - 5_000,
stage: 'awaiting_approval',
detail: 'WriteFile: docs/testing.md',
permission: {
requestId: 'perm_1',
title: 'WriteFile: docs/testing.md',
options: [
{
optionId: 'allow_once',
name: 'Allow once',
kind: 'allow_once',
},
{ optionId: 'reject_once', name: 'Reject', kind: 'reject_once' },
],
},
},
},
{
id: 'run_tester_1',
agentId: 'ag_tester',
agentName: 'tester',
status: 'queued',
queueAhead: 2,
closeAcknowledged: false,
trigger: 'mentioned by lead',
},
{
id: 'run_lead_1',
agentId: 'ag_lead',
agentName: 'lead',
status: 'completed',
closeAcknowledged: true,
trigger: 'mentioned by you',
startedAt: NOW - 12 * MIN,
endedAt: NOW - 10 * MIN,
},
],
children: [
{
id: 'th_child_1',
title: 'Check barrel imports in packages/cli',
status: 'in_progress',
reason: '1 个智能体执行中',
assigneeName: 'reviewer',
},
],
budget: {
turnsUsed: 3,
turnLimit: 12,
tokensUsed: 184_000,
tokenLimit: 1_000_000,
},
};
}
async function setup(page: Page, baseURL: string): Promise<string> {
const scenario = createWebShellDaemonScenario({
capabilities: { features: ['session_events', 'agent_collaboration_v1'] },
});
await installScenario(page, scenario, baseURL);
const thread = mainThread();
await page.route('**/workspaces/*/agent/**', async (route) => {
const path = new URL(route.request().url()).pathname;
// Without the live stream the page falls back to reads, which is all a
// still capture needs.
if (path.endsWith('/events')) return route.abort();
if (path.endsWith('/agents')) return route.fulfill({ json: { agents } });
if (path.endsWith(`/threads/${thread.id}`))
return route.fulfill({ json: thread });
if (path.endsWith('/threads'))
return route.fulfill({
json: {
threads: [
{ ...thread, updatedAt: NOW, liveRunCount: 4 },
{
id: 'th_done',
title: 'Rename the settings keys',
status: 'done',
reason: 'a person marked this thread done',
updatedAt: NOW - 60 * MIN,
liveRunCount: 0,
},
],
},
});
return route.fulfill({ status: 404, json: { error: 'not in fixture' } });
});
return scenario.workspaceCwd;
}
/** Opens the collaboration conversation the way the sidebar does. */
async function openConversation(
page: Page,
theme: VisualTheme,
cwd: string,
): Promise<void> {
await page.addInitScript(
({ id, cwd }) => {
sessionStorage.setItem(
'qwen:team-conversation',
JSON.stringify({ id, cwd, server: location.origin }),
);
},
{ id: 'th_main', cwd },
);
await gotoNewSession(page, theme);
await expect(
page.getByText('WriteFile: docs/testing.md').first(),
).toBeVisible();
}
async function openAgents(page: Page, theme: VisualTheme): Promise<void> {
await gotoNewSession(page, theme);
await page
.getByRole('button', { name: 'Agents', exact: true })
.first()
.click();
await expect(page.getByText('archivist', { exact: true })).toBeVisible();
}
for (const theme of THEMES) {
test(`collaboration conversation (${theme})`, async ({ page }, testInfo) => {
const cwd = await setup(page, resolveBaseURL(testInfo));
await openConversation(page, theme, cwd);
await clearFocus(page);
await captureScreenshot(page, `collab-conversation-${theme}`);
await page.getByRole('button', { name: 'Team', exact: true }).click();
await expect(page.getByRole('tab', { name: 'Team' })).toBeVisible();
await clearFocus(page);
await captureScreenshot(page, `collab-team-panel-${theme}`);
});
test(`collaboration details (${theme})`, async ({ page }, testInfo) => {
const cwd = await setup(page, resolveBaseURL(testInfo));
await openConversation(page, theme, cwd);
await page
.getByRole('button', { name: 'Task details', exact: true })
.click();
await expect(page.getByRole('heading', { name: 'Runs' })).toBeVisible();
await clearFocus(page);
await captureScreenshot(page, `collab-details-${theme}`);
});
test(`collaboration mention picker (${theme})`, async ({
page,
}, testInfo) => {
await setup(page, resolveBaseURL(testInfo));
await gotoNewSession(page, theme);
await page
.locator('[data-web-shell-composer-editor]:visible .cm-content')
.click();
await page.keyboard.type('@');
await page.keyboard.press('Enter');
// The picker lists the agents; an empty one is the regression to catch.
await expect(page.getByText('reviewer', { exact: true })).toBeVisible();
await captureScreenshot(page, `collab-mention-picker-${theme}`);
});
test(`collaboration agents page (${theme})`, async ({ page }, testInfo) => {
await setup(page, resolveBaseURL(testInfo));
await openAgents(page, theme);
await clearFocus(page);
await captureScreenshot(page, `collab-agents-${theme}`);
await page
.getByRole('button', { name: 'Conversations', exact: true })
.click();
await expect(page.getByText('3 working, 1 queued')).toBeVisible();
await clearFocus(page);
await captureScreenshot(page, `collab-conversations-${theme}`);
});
}

View file

@ -1,505 +0,0 @@
import { expect, test, type Page } from '@playwright/test';
import type { ThreadDetailView } from '../components/workspace-agents/ThreadView';
import {
createWebShellDaemonScenario,
installMockDaemon,
} from './utils/mockDaemon';
// Every assertion is the Chinese copy. The browser locale, not a ?language=
// param, keeps it Chinese across the reloads below.
test.use({ locale: 'zh-CN' });
async function openChat(page: Page, id: string, cwd: string) {
await page.addInitScript(
({ id, cwd }) => {
sessionStorage.setItem(
'qwen:team-conversation',
JSON.stringify({
id,
cwd,
server: location.origin,
}),
);
},
{ id, cwd },
);
await page.goto('/');
}
async function send(page: Page, text: string) {
const editor = page.locator(
'[data-web-shell-composer-editor]:visible .cm-content',
);
await editor.fill(text);
await page.locator('[data-web-shell-composer-submit]:visible').click();
}
test('mesh shows growing replies before completion, survives reload, and replaces the preview once', async ({
page,
}, info) => {
const scenario = createWebShellDaemonScenario({
capabilities: { features: ['session_events', 'agent_collaboration_v1'] },
});
await installMockDaemon(page, scenario, {
baseURL: String(info.project.use.baseURL),
});
const thread: ThreadDetailView = {
id: 'mesh-stream-e2e',
title: 'Mesh streaming regression',
body: '',
status: 'open',
reason: 'Waiting for a message',
posts: [],
runs: [],
budget: { turnsUsed: 0, turnLimit: 12, tokensUsed: 0, tokenLimit: 10000 },
};
const agent = {
id: 'ag_stream',
name: 'stream-worker',
enabled: true,
status: 'idle',
};
let sent = 0;
let releaseReply!: () => void;
const replyGate = new Promise<void>((resolve) => {
releaseReply = resolve;
});
await page.route('**/workspaces/*/agent/**', async (route) => {
const pathname = new URL(route.request().url()).pathname;
// No live stream here: the page falls back to polling, which this test
// drives by editing the thread between reads.
if (pathname.endsWith('/events')) return route.abort();
if (pathname.endsWith('/agents'))
return route.fulfill({ json: { agents: [agent] } });
if (pathname.endsWith('/preview'))
return route.fulfill({ json: { targets: [] } });
if (pathname.endsWith('/posts') && route.request().method() === 'POST') {
const { text } = route.request().postDataJSON();
expect(text).toBe('@stream-worker Please explain streaming.');
sent++;
await replyGate;
thread.posts = [
{
id: 'human-1',
sequence: 1,
authorKind: 'human',
authorName: 'user',
text,
at: Date.now(),
},
];
thread.status = 'in_progress';
thread.runs = [
{
id: 'run-stream',
agentId: agent.id,
agentName: agent.name,
status: 'queued',
closeAcknowledged: false,
trigger: 'mentioned by you',
startedAt: Date.now(),
progress: {
receivedAt: Date.now(),
activityAt: Date.now(),
stage: 'thinking',
detail: 'Qwen Code 正在思考',
},
},
];
return route.fulfill({ json: { outcomes: [] } });
}
if (pathname.endsWith(`/threads/${thread.id}`))
return route.fulfill({ json: thread });
if (pathname.endsWith('/threads'))
return route.fulfill({
json: {
threads: [
{
...thread,
updatedAt: Date.now(),
liveRunCount: thread.status === 'in_progress' ? 1 : 0,
},
],
},
});
throw new Error(
`Unexpected mesh request: ${route.request().method()} ${pathname}`,
);
});
await openChat(page, thread.id, scenario.workspaceCwd);
await expect(page.getByTestId('chat-context-header')).toContainText(
thread.title,
);
await expect(
page.getByRole('button', { name: '任务详情', exact: true }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Task details', exact: true }),
).toHaveCount(0);
await send(page, '@stream-worker Please explain streaming.');
await expect.poll(() => sent).toBe(1);
await expect(
page.getByRole('status').filter({ hasText: '正在发送…' }),
).toBeVisible();
releaseReply();
await expect(
page
.getByRole('status')
.filter({ hasText: 'stream-worker 排队中,空出来就开始' }),
).toBeVisible();
const activity = page.getByRole('region', {
name: '团队',
exact: true,
});
await expect(activity).toHaveCount(0);
await page.getByRole('button', { name: '团队', exact: true }).click();
await expect(
page.getByRole('tab', { name: '团队', exact: true }),
).toBeVisible();
await expect(activity).toContainText('消息已接收,排队等待启动');
const run = thread.runs[0];
run.status = 'running';
const initialProgress = run.progress!;
run.progress = undefined;
await expect(
page.getByRole('status').filter({ hasText: '正在唤起 stream-worker…' }),
).toBeVisible();
await expect(activity).toContainText('等待开始运行');
await expect(activity).not.toContainText('暂无过程上报');
await expect(activity).not.toContainText('思考中');
run.progress = {
...initialProgress,
stage: 'starting',
receivedAt: Date.now(),
};
const starting = page.getByRole('status').filter({
hasText: '正在唤起 stream-worker…',
});
await expect(starting).toBeVisible();
await page.getByRole('button', { name: 'Close 团队', exact: true }).click();
await expect(activity).toBeHidden();
await expect(starting).toBeVisible();
await page.getByRole('button', { name: '团队', exact: true }).click();
await expect(activity).toBeVisible();
run.progress = { ...run.progress, stage: 'resuming' };
await expect(activity).toContainText('继续会话中');
await expect(
page
.getByRole('status')
.filter({ hasText: 'stream-worker 正在继续原会话…' }),
).toBeVisible();
run.progress = { ...run.progress, stage: 'thinking' };
await expect(activity).toContainText('思考中');
await expect(starting).toHaveCount(0);
for (const thought of [
'Checking the task.',
'Checking the task. Choosing a collaborator.',
]) {
run.progress = { ...run.progress!, thoughtText: thought };
await expect(activity).toContainText(thought);
}
await page.reload();
await page.getByRole('button', { name: '团队', exact: true }).click();
await expect(activity).toContainText(
'Checking the task. Choosing a collaborator.',
);
const transcript = page.locator('[data-web-shell-message-list]:visible');
await page.getByRole('button', { name: 'Close 团队', exact: true }).click();
for (const text of ['First fragment.', 'First fragment. Second fragment.']) {
run.progress = {
...run.progress,
receivedAt: Date.now(),
activityAt: Date.now(),
stage: 'responding',
detail: '正在回复',
outputText: text,
};
await expect(transcript).toContainText(text);
expect(run.status).toBe('running');
expect(thread.posts).toHaveLength(1);
}
await expect(activity).toBeHidden();
await page.getByRole('button', { name: '团队', exact: true }).click();
await page.screenshot({ path: info.outputPath('01-growing.png') });
await page.reload();
await page.getByRole('button', { name: '团队', exact: true }).click();
await expect(transcript).toContainText('First fragment. Second fragment.');
expect(sent).toBe(1);
// A quiet agent reads as waiting for output, not as a lost connection: a
// long tool call writes no snapshot and is still working.
run.progress = {
...run.progress!,
receivedAt: Date.now() - 25000,
activityAt: Date.now() - 25000,
};
await expect(activity).toContainText('等待新输出');
await expect(activity).not.toContainText('连接中断');
await expect(transcript).toContainText('First fragment. Second fragment.');
run.status = 'completed';
run.closeKind = 'review';
thread.status = 'in_review';
thread.posts = [
...thread.posts,
{
id: 'final-1',
sequence: 2,
sourceRunId: run.id,
authorKind: 'agent',
authorName: agent.name,
text: 'First fragment. Second fragment.',
at: Date.now(),
},
];
await expect(
transcript.getByText('First fragment. Second fragment.', { exact: true }),
).toHaveCount(1);
await expect(page.getByRole('button', { name: '验收并完成' })).toBeVisible();
await page.reload();
await expect(
transcript.getByText('First fragment. Second fragment.', { exact: true }),
).toHaveCount(1);
await page.screenshot({ path: info.outputPath('02-completed.png') });
});
test('mesh shows a reply pushed over the live stream that no REST read carries', async ({
page,
}, info) => {
const scenario = createWebShellDaemonScenario({
capabilities: { features: ['session_events', 'agent_collaboration_v1'] },
});
await installMockDaemon(page, scenario, {
baseURL: String(info.project.use.baseURL),
});
const pushed = 'Only the live stream carries this sentence.';
const thread: ThreadDetailView = {
id: 'mesh-sse-e2e',
title: 'Mesh live stream',
body: '',
status: 'in_progress',
reason: 'stream-worker is working',
posts: [],
runs: [
{
id: 'run-sse',
agentId: 'ag_stream',
agentName: 'stream-worker',
status: 'running',
closeAcknowledged: false,
trigger: 'mentioned by you',
startedAt: Date.now(),
progress: {
attempt: 1,
receivedAt: Date.now(),
activityAt: Date.now(),
stage: 'thinking',
detail: '',
},
},
],
budget: { turnsUsed: 0, turnLimit: 12, tokensUsed: 0, tokenLimit: 10000 },
};
let detailServed!: () => void;
const detailRead = new Promise<void>((resolve) => {
detailServed = resolve;
});
await page.route('**/workspaces/*/agent/**', async (route) => {
const pathname = new URL(route.request().url()).pathname;
if (pathname.endsWith('/events')) {
// Hold the stream until the page has the thread, so the frame has
// somewhere to land; REST never returns `pushed`.
await detailRead;
const frame = {
type: 'progress',
threadId: thread.id,
runId: 'run-sse',
attempt: 1,
sessionId: 'session-sse',
stage: 'responding',
detail: '',
outputText: pushed,
thoughtText: '',
activityAt: Date.now(),
};
return route.fulfill({
status: 200,
headers: { 'content-type': 'text/event-stream' },
body: `event: progress\ndata: ${JSON.stringify(frame)}\n\n`,
});
}
if (pathname.endsWith('/agents'))
return route.fulfill({
json: {
agents: [
{
id: 'ag_stream',
name: 'stream-worker',
enabled: true,
status: 'working',
waiting: 0,
},
],
},
});
if (pathname.endsWith(`/threads/${thread.id}`)) {
detailServed();
return route.fulfill({ json: thread });
}
if (pathname.endsWith('/threads'))
return route.fulfill({
json: {
threads: [{ ...thread, updatedAt: Date.now(), liveRunCount: 1 }],
},
});
return route.fulfill({ json: { targets: [] } });
});
await openChat(page, thread.id, scenario.workspaceCwd);
const transcript = page.locator('[data-web-shell-message-list]:visible');
await expect(transcript).toContainText(pushed);
});
test('mesh real agent streams into the browser @mesh-live', async ({
page,
request,
}, info) => {
const cwd = process.env['MESH_E2E_CWD'];
const name = process.env['MESH_E2E_AGENT'];
test.skip(
!cwd && !name,
'Opt in with MESH_E2E_CWD and an existing MESH_E2E_AGENT; uses real model credits.',
);
expect(cwd, 'MESH_E2E_CWD is required for live mode').toBeTruthy();
expect(name, 'MESH_E2E_AGENT is required for live mode').toBeTruthy();
test.setTimeout(180000);
const prefix = `/workspaces/${encodeURIComponent(cwd!)}/agent`;
const agentsResponse = await request.get(`${prefix}/agents`);
expect(
agentsResponse.ok(),
'Start a collaboration-enabled loopback daemon first',
).toBeTruthy();
const { agents } = await agentsResponse.json();
expect(
agents.some(
(agent: { name: string; enabled: boolean; retiredAt?: number }) =>
agent.name === name && agent.enabled && !agent.retiredAt,
),
).toBeTruthy();
const created = await request.post(`${prefix}/threads`, {
data: { title: `E2E live stream ${new Date().toISOString()}`, body: '' },
});
expect(created.ok()).toBeTruthy();
const { id } = await created.json();
await info.attach('created-thread', {
body: JSON.stringify({ id, cwd }),
contentType: 'application/json',
});
try {
await openChat(page, id, cwd!);
await page.getByRole('button', { name: '团队', exact: true }).click();
await send(
page,
`@${name} Do not inspect or change files, run commands, or browse the web. Explain the water cycle in one plain-text paragraph of about 400 words. No Markdown, lists, numbering, headings or formatting. Stream your answer as text. If thread_review is available, you MUST then call thread_review with that answer as the summary to hand it back for review; this collaboration closing tool is explicitly allowed.`,
);
const transcript = page.locator('[data-web-shell-message-list]:visible');
const replies = transcript.locator('[data-web-shell-message-row]').filter({
has: page.locator('strong').filter({ hasText: name! }),
});
const samples: { elapsedMs: number; chars: number }[] = [];
const thoughtSamples: { elapsedMs: number; chars: number }[] = [];
const started = Date.now();
let finalText = '';
await expect
.poll(
async () => {
const response = await request.get(`${prefix}/threads/${id}`);
expect(response.ok()).toBeTruthy();
const detail: ThreadDetailView = await response.json();
const run = detail.runs[0];
if (!run) return false;
expect(
['failed', 'cancelled'].includes(run.status),
JSON.stringify(run),
).toBe(false);
const text = run.progress?.outputText ?? '';
const thought = run.progress?.thoughtText ?? '';
if (
run.status === 'running' &&
thought.length > (thoughtSamples.at(-1)?.chars ?? 0)
) {
const activity = page.getByRole('region', {
name: '团队',
exact: true,
});
await expect(activity).toContainText(thought.slice(-80));
thoughtSamples.push({
elapsedMs: Date.now() - started,
chars: thought.length,
});
if (thoughtSamples.length === 1)
await page.screenshot({
path: info.outputPath('live-thinking.png'),
});
}
if (
run.status === 'running' &&
text.length > (samples.at(-1)?.chars ?? 0)
) {
await expect(replies).toContainText(text.slice(-80));
samples.push({
elapsedMs: Date.now() - started,
chars: text.length,
});
if (samples.length === 1) {
await page.screenshot({
path: info.outputPath('live-growing.png'),
});
await page.reload();
await expect(replies).toContainText(text.slice(-80));
}
}
if (run.status !== 'completed') return false;
const finals = detail.posts.filter(
(post) => post.sourceRunId === run.id,
);
expect(finals).toHaveLength(1);
finalText = finals[0].text;
return true;
},
{ timeout: 150000, intervals: [500] },
)
.toBe(true);
expect(
samples.length,
'Must see growing browser output before completion, not just a final result',
).toBeGreaterThanOrEqual(2);
await page.reload();
await expect(replies).toContainText(finalText.slice(-80));
await expect(replies).toHaveCount(1);
await info.attach('live-observations', {
body: JSON.stringify(
{ thoughts: thoughtSamples, replies: samples },
null,
2,
),
contentType: 'application/json',
});
await page.screenshot({ path: info.outputPath('live-completed.png') });
} finally {
// Preserve this test's conversation for inspection; stop only its active work.
const response = await request.get(`${prefix}/threads/${id}`);
if (response.ok()) {
const detail: ThreadDetailView = await response.json();
for (const run of detail.runs.filter((run) =>
['queued', 'running'].includes(run.status),
)) {
const cancelled = await request.post(
`${prefix}/threads/${id}/runs/${run.id}/cancel`,
{ data: {} },
);
expect(
cancelled.ok(),
`Could not cancel E2E run ${run.id}`,
).toBeTruthy();
}
}
}
});

View file

@ -774,36 +774,6 @@ describe('useAtMentionMenu', () => {
});
});
it('runs an action item instead of inserting it, dropping the typed query', async () => {
vi.useFakeTimers();
const onSelect = vi.fn();
const view = makeView('ask @custom:ne');
mount({
view,
providers: [
{
id: 'custom',
label: 'Custom',
order: 0,
search: vi
.fn()
.mockResolvedValue([{ id: 'new', label: 'New agent…', onSelect }]),
},
],
});
act(() => latest!.refreshForView(view));
await runDebounce();
act(() => expect(latest!.accept(0)).toBe(true));
expect(onSelect).toHaveBeenCalledTimes(1);
expect(view.dispatch).toHaveBeenCalledWith({
changes: { from: 4, to: 14, insert: '' },
selection: { anchor: 4 },
});
expect(latest!.state).toBeNull();
});
it('strips the mcp prefix while refreshing MCP server searches', async () => {
vi.useFakeTimers();
const loadMcpStatus = vi.fn().mockResolvedValue({
@ -1000,69 +970,6 @@ describe('useAtMentionMenu', () => {
]);
});
it('lets a custom provider claim a typed query before the file fallback', async () => {
vi.useFakeTimers();
const listDirectory = vi.fn().mockResolvedValue({
kind: 'list',
path: '.',
entries: [],
truncated: false,
});
const search = vi
.fn()
.mockResolvedValue([
{ id: 'ag_1', label: 'reviewer', insertText: '@reviewer ' },
]);
mount({
actions: { listDirectory },
providers: [
{
id: 'people',
label: 'People',
search,
claimsTypedQuery: (query) => 'reviewer'.startsWith(query),
},
],
});
act(() => latest!.refreshForView(makeView('@rev')));
await runDebounce();
expect(search).toHaveBeenCalledWith(
expect.objectContaining({ query: 'rev' }),
);
expect(listDirectory).not.toHaveBeenCalled();
expect(latest!.state).toMatchObject({
level: 'items',
selectedProviderId: 'people',
query: 'rev',
});
});
it('falls back to files when no custom provider claims the typed query', async () => {
vi.useFakeTimers();
const listDirectory = vi.fn().mockResolvedValue({
kind: 'list',
path: '.',
entries: [],
truncated: false,
});
mount({
actions: { listDirectory },
providers: [
{
id: 'people',
label: 'People',
search: vi.fn().mockResolvedValue([]),
claimsTypedQuery: (query) => 'reviewer'.startsWith(query),
},
],
});
act(() => latest!.refreshForView(makeView('@src/')));
await runDebounce();
expect(latest!.state).toMatchObject({ selectedProviderId: 'files' });
});
it('searches matching files across the workspace', async () => {
vi.useFakeTimers();
const listDirectory = vi.fn();

View file

@ -1060,27 +1060,6 @@ export function useAtMentionMenu({
});
return true;
}
const claimingProvider = providerViewsRef.current.find(
(view) =>
!isBuiltinProviderId(view.id) &&
view.provider.claimsTypedQuery?.(parsed.query),
);
if (claimingProvider) {
scheduleLoadItems(claimingProvider.id, parsed.query, {
from: parsed.from,
to: parsed.to,
query: parsed.query,
level: 'items',
selectedProviderId: claimingProvider.id,
selectedIndex: 0,
providers: providerViewsRef.current,
itemMode: 'default',
mcpServerName: undefined,
fileDirectory: undefined,
inputMode: 'context',
});
return true;
}
if (
providerViewsRef.current.some(
(provider) => provider.id === FILE_PROVIDER_ID,
@ -1364,15 +1343,6 @@ export function useAtMentionMenu({
close();
return true;
}
if (item.onSelect) {
view.dispatch({
changes: { from: current.from, to: current.to, insert: '' },
selection: { anchor: current.from },
});
close();
item.onSelect();
return true;
}
if (
current.selectedProviderId === FILE_PROVIDER_ID &&
item.kind === 'directory' &&

View file

@ -6,10 +6,6 @@ import {
} from 'react';
import { LIVE_MESSAGES_EN, LIVE_MESSAGES_ZH } from './live/messages.js';
import {
COLLAB_MESSAGES_EN,
COLLAB_MESSAGES_ZH,
} from './components/workspace-agents/messages.js';
import {
SETTINGS_MESSAGES_EN,
SETTINGS_MESSAGES_ZH,
@ -626,8 +622,6 @@ const EN: Messages = {
'agent.view': 'View',
'agents.closed': 'Agents panel closed.',
'agents.title': 'Agents',
'agents.description':
'Manage reusable agent definitions for tasks, Agent Teams, and shared-thread collaboration.',
'subagent.result': 'Result',
'subagent.tools': (v) => `Tools (${v?.count ?? 0})`,
'subagent.toolsCount': (v) => `${v?.count ?? 0} tools`,
@ -4115,7 +4109,6 @@ const EN: Messages = {
'welcome.titlePrefix': 'Welcome to',
'welcome.tipLabel': 'Tips:',
...LIVE_MESSAGES_EN,
...COLLAB_MESSAGES_EN,
...SETTINGS_MESSAGES_EN,
};
@ -4569,12 +4562,6 @@ const ZH: Messages = {
'toolName.savememory': '保存记忆',
'toolName.askuserquestion': '询问用户',
'toolName.toolsearch': '工具搜索',
'toolName.thread_post': '发帖到线程',
'toolName.thread_wait': '等待协作方',
'toolName.thread_block': '提出阻塞问题',
'toolName.thread_review': '提交待评审',
'toolName.thread_create': '创建子线程',
'toolName.thread_read': '读取线程',
'about.auth': '认证',
'about.baseUrl': 'Base URL',
'about.fastModel': '快速模型',
@ -4766,8 +4753,6 @@ const ZH: Messages = {
'agent.view': '查看',
'agents.closed': '智能体面板已关闭。',
'agents.title': '智能体',
'agents.description':
'管理可复用的智能体定义,用于任务执行、Agent Team 或共享任务协作。',
'subagent.result': '结果',
'subagent.tools': (v) => `工具 (${v?.count ?? 0})`,
'subagent.toolsCount': (v) => `${v?.count ?? 0} 个工具`,
@ -7979,7 +7964,6 @@ const ZH: Messages = {
'welcome.titlePrefix': '欢迎使用',
'welcome.tipLabel': '提示:',
...LIVE_MESSAGES_ZH,
...COLLAB_MESSAGES_ZH,
...SETTINGS_MESSAGES_ZH,
};

View file

@ -393,9 +393,6 @@ export const SETTINGS_MESSAGES_ZH: Record<string, SettingsMessage> = {
'settings.label.experimental.agentTeam': '启用 Agent Team',
'settings.description.experimental.agentTeam':
'启用 agent 团队协作工具(实验性)。启用后,模型可以创建 agent 团队,并用 team_create、team_delete、send_message、task_create、task_update 和 task_list 协调工作。也可通过 QWEN_CODE_ENABLE_AGENT_TEAM=1 环境变量启用。',
'settings.label.experimental.agentCollaboration': '启用 Agent 协作',
'settings.description.experimental.agentCollaboration':
'启用持久化的工作区 Agent 在共享任务线程上协作(实验性)。与 Agent Team 相互独立:两个开关互不隐含。启用只表示允许协作;把 Agent 开放给外部调用方、信任某个连接、注册 host 仍各自需要显式配置。也可通过 QWEN_CODE_ENABLE_AGENT_COLLABORATION=1 环境变量启用。',
'settings.label.experimental.artifact': '启用 Artifacts',
'settings.description.experimental.artifact':
'启用 artifact 工具,默认开启。在交互式非 SDK 会话中,模型可以把自包含 HTML 页面发布为交互式 Artifact 并在浏览器中打开;非 SDK 的 daemon 会话可使用仅记录元数据的 record_artifact 工具。设为 false 或用 QWEN_CODE_DISABLE_ARTIFACT=1 可同时禁用两者。',

View file

@ -196,7 +196,6 @@ export default defineConfig(({ command }) => ({
'/standalone/sessions': daemonProxy,
'/session': daemonProxy,
'/permission': daemonProxy,
'^/workspaces/[^/]+/agent(?:/|$)': daemonProxy,
[QUALIFIED_VOICE_STREAM_PROXY]: { ...daemonProxy, ws: true },
[QUALIFIED_ACP_WS_PROXY]: { ...daemonProxy, ws: true },
'/workspace': daemonProxy,

View file

@ -159,22 +159,22 @@ function injectCssModules(): Plugin {
// `path.resolve` returns backslashes on Windows, so both sides go through
// `normalizePath`: compared raw, the stub would never apply there and the
// Windows build would blow the budget this exists to protect.
const TRANSCRIPT_DEAD_MESSAGES = new Map(
[
['./client/live/messages.ts', './client/live/messages.transcript-stub.ts'],
[
'./client/components/workspace-agents/messages.ts',
'./client/components/workspace-agents/messages.transcript-stub.ts',
],
[
'./client/settings/messages.ts',
'./client/settings/messages.transcript-stub.ts',
],
].map(([module, stub]) => [
normalizePath(resolve(__dirname, module)),
normalizePath(resolve(__dirname, stub)),
]),
const LIVE_MESSAGES_MODULE = normalizePath(
resolve(__dirname, './client/live/messages.ts'),
);
const LIVE_MESSAGES_TRANSCRIPT_STUB = normalizePath(
resolve(__dirname, './client/live/messages.transcript-stub.ts'),
);
const TRANSCRIPT_STUBS = new Map([
[LIVE_MESSAGES_MODULE, LIVE_MESSAGES_TRANSCRIPT_STUB],
[
normalizePath(resolve(__dirname, './client/settings/messages.ts')),
normalizePath(
resolve(__dirname, './client/settings/messages.transcript-stub.ts'),
),
],
]);
function stubTranscriptDeadMessages(): Plugin {
return {
@ -186,7 +186,7 @@ function stubTranscriptDeadMessages(): Plugin {
skipSelf: true,
});
return resolved
? (TRANSCRIPT_DEAD_MESSAGES.get(normalizePath(resolved.id)) ?? null)
? (TRANSCRIPT_STUBS.get(normalizePath(resolved.id)) ?? null)
: null;
},
};