merge: bring main into the computer-use worktree

main is 87 commits ahead and carries a large amount of fixed behaviour this
branch should not be re-deciding. The rule applied throughout: this worktree
owns Computer Use, main owns everything else.

Only 12 files were touched on both sides, and Git merged all of them without
reporting a conflict — but two of those silent merges were wrong, and neither
was visible until the checks ran.

`desktop/src/api/client.ts` ended up with two `apiGetBlob` implementations.
Both sides had independently hit the same problem (an `<img src>` pointed at an
API endpoint is a cross-origin subresource, so it carries no Authorization
header and the server's fetch-metadata policy refuses it) and both had written
the same fix. Git saw two additions in different places and kept both, which
does not even compile. main's version survives: it builds its headers through
the shared `buildHeaders()` rather than assembling them inline, so it inherits
whatever main adds there later.

`src/server/api/computer-use.ts` still imported `runtime/mac_helper.py` and
`runtime/requirements.txt` as compile-time text, both deleted on this branch.
Nothing at runtime referenced them, which is why the deletion looked clean; the
bundler resolves those imports when the server module is loaded, so the failure
surfaced only when the tests actually imported it. That path is now Windows-only
in the same sense the rest of the Python bridge is, and it also ships
`win_cursor_badge.py`, which the badge needs because it runs as its own process.

`computer-use-requirements.test.ts` drops its darwin half for the same reason —
the pins it guards still matter, but only one requirements file is left.

Verified: server 3869 tests / 331 files, desktop 4612 tests / 319 files
(lint + tsc + build), Swift 272 XCTest + 14 Swift Testing, Python 25.

Claude-Session: https://claude.ai/code/session_015j1yxxaoonyAS2iZ7qGnTS
This commit is contained in:
程序员阿江(Relakkes)
2026-08-23 18:39:47 +08:00
513 changed files with 73008 additions and 4148 deletions
+2 -1
View File
@@ -9,7 +9,8 @@ For every feature, bugfix, refactor, or workflow change:
- Identify the changed surface before coding: `desktop`, `server/runtime`, `adapter`, `native`, `docs`, `provider/runtime`, `agent-loop`, `persistence`, `policy/ci`, or `release`.
- Add same-area tests with the production change. Do not leave production behavior untested unless the PR explicitly carries the maintainer override `allow-missing-tests`.
- Preserve or improve the coverage ratchet. New or changed executable production lines must pass the changed-line coverage threshold in `scripts/quality-gate/coverage-thresholds.json`; do not edit coverage baselines or thresholds without maintainer approval via `allow-coverage-baseline-change`.
- Use unit tests for pure logic, API/request-shape tests for server/provider/runtime behavior, Testing Library/Vitest for desktop UI and stores, and E2E or agent-browser smoke for user-visible cross-boundary flows.
- Use unit tests for pure logic, API/request-shape tests for server/provider/runtime behavior, Testing Library/Vitest for desktop UI and stores, and E2E or desktop UI smoke for user-visible cross-boundary flows.
- Ad-hoc browser automation (manual verification, screenshots, exploratory UI checks) goes through the `ego-browser` skill. The `agent-browser` binary is reserved for the committed `check:desktop-ui-smoke` lane and `desktop/scripts/e2e-*-agent-browser.sh`; do not reach for it as a general browser tool.
- Provider/auth/runtime-env/model-window/proxy changes require offline `bun run check:provider-contract`; desktop chat/WebSocket/session-runtime changes require `bun run check:chat-contract`.
- Required PR evidence must be deterministic: use fake credentials, temporary config/home paths, mocked or loopback transports, explicit cleanup, and restored environment state. Never call a real provider or use saved machine credentials in required tests.
- For agent loop, tool execution, provider routing, model selection, file editing, permissions, session resume, and desktop chat changes, include mock/fixture/contract tests. Live smoke is trusted-maintainer evidence only and requires explicit authorization; finding local credentials is not authorization.
+1
View File
@@ -83,6 +83,7 @@ Additional invariants:
- Required PR checks must be deterministic and work on an untrusted fork: no real models, public network, repository secrets, saved providers, or real user home/config. Use fake credentials, fixtures, mocked/loopback transports, temporary directories, and explicit cleanup.
- `bun run check:agent-flow` is the deterministic end-to-end agent lane: it drives the real server and WebSocket through session creation, runtime selection, streaming, tool permission allow/deny, tool failure, API error, interrupt, reconnect replay, and session recovery using the repository's mock SDK CLI. It needs no provider, credentials, or network, so every contributor can run it.
- `bun run check:desktop-ui-smoke` drives the real desktop UI against that same mock runtime and answers the permission dialog by clicking the real button. It skips with a printed reason when `agent-browser` or desktop dependencies are missing.
- `agent-browser` is an implementation detail of that committed lane (which runs headless on Linux CI) and of the maintainer-run `desktop/scripts/e2e-*-agent-browser.sh` scripts. It is not the tool for ad-hoc browser work: manual verification, screenshots, and exploratory UI checks go through the `ego-browser` skill instead.
- Quality-gate lanes that boot the real server must run in a sandbox config dir (`scripts/quality-gate/sandbox.ts`) and fail if they wrote to the developer's real `~/.claude`.
- Provider/auth/proxy/runtime changes may select `bun run check:provider-contract`; desktop chat/WebSocket/session changes may select `bun run check:chat-contract`. These contracts are offline and do not replace their selected surface checks.
- Any persisted JSON, `localStorage`, or app-config shape change requires a forward migration, an old-fixture regression test, and `bun run check:persistence-upgrade`.
+16 -11
View File
@@ -22,7 +22,7 @@
</div>
Claude Code Haha is a **desktop Claude Code workspace** for macOS, Windows, and Linux: multi-session workspaces, global search, branch / Worktree launch, diff review, built-in browser preview, GUI permission approval, any model — Claude, ChatGPT, Grok, presets, or local endpoints — image generation, visual MCP & SubAgent managers, model trace, Computer Use, skill marketplace, colour themes, desktop pets, H5 remote access, IM integration, and scheduled tasks, all in one app.
Claude Code Haha is a **desktop Claude Code workspace** for macOS, Windows, and Linux: multi-session workspaces, global search, branch / Worktree launch, diff review, built-in browser preview, GUI permission approval, any model — Claude, ChatGPT, Grok, presets, or local endpoints — image generation, visual MCP & SubAgent managers, an Agent Teams workbench, dynamic Workflow orchestration, model trace, Computer Use, skill marketplace, colour themes, desktop pets, H5 remote access, IM integration, and scheduled tasks, all in one app.
<p align="center">
<a href="#desktop-preview">Desktop Preview</a> · <a href="#install-the-desktop-app">Install</a> · <a href="#desktop-highlights">Highlights</a> · <a href="#more-documentation">More Docs</a> · <a href="#sponsorship--partnership">Sponsorship</a> · <a href="#user-group">User Group</a>
@@ -63,16 +63,6 @@ This project is maintained in the author's spare time. Corporate or individual s
</tr>
</thead>
<tbody>
<tr>
<td align="center" valign="middle">
<a href="https://teamorouter.com/?utm_source=cc_haha&utm_medium=referral&utm_campaign=ai_directory">
<img src="docs/images/sponsors/teamorouter-logo.svg" width="180" alt="TeamoRouter">
</a>
</td>
<td valign="middle">
Thanks to <a href="https://teamorouter.com/?utm_source=cc_haha&utm_medium=referral&utm_campaign=ai_directory">TeamoRouter</a> for sponsoring this project. TeamoRouter is an enterprise-grade Agentic LLM gateway: run Claude Code, Codex, Gemini CLI, and other popular AI agents through one unified API with no subscription and up to 90% off official pricing. It aggregates hundreds of official model providers (OpenAI, Anthropic, Vertex, Azure, AWS Bedrock) verified for Agent protocol compatibility, with near-official TTFT, a 99.6% SLA, and industry-leading cache hit rates. It also adds centralized billing, team management, BYOK, usage analytics, and one-click Teamo Desktop. New users who register through <a href="https://teamorouter.com/?utm_source=cc_haha&utm_medium=referral&utm_campaign=ai_directory">this link</a> get 10% off their first top-up.
</td>
</tr>
<tr>
<td align="center" valign="middle">
<a href="https://www.xuanshuapi.com/register?aff=CC-HAHA&promo=CC-HAHA">
@@ -103,6 +93,17 @@ This project is maintained in the author's spare time. Corporate or individual s
Thanks to <a href="https://s.qiniu.com/IZbyya">Qiniu Cloud AI</a> for sponsoring this project. Qiniu Cloud AI is the enterprise-grade LLM MaaS platform from Qiniu Cloud (02567.HK): one entry point to 150+ leading models worldwide, compatible with the major vendors' protocols, covering text, image, audio, video, and file processing across every modality, and serving more than 1.69 million enterprises and developers. As a cc-haha user perk, registering through <a href="https://s.qiniu.com/IZbyya">this link</a> gets enterprise users 12M free tokens and developers 3M free tokens.
</td>
</tr>
<tr>
<td align="center" valign="middle">
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=cc-haha">
<img src="docs/images/sponsors/atlascloud-logo-black.png#gh-light-mode-only" width="180" alt="Atlas Cloud">
<img src="docs/images/sponsors/atlascloud-logo-white.png#gh-dark-mode-only" width="180" alt="Atlas Cloud">
</a>
</td>
<td valign="middle">
Thanks to <a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=cc-haha">Atlas Cloud</a> for sponsoring this project. Atlas Cloud is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. Atlas Cloud is already built into the cc-haha provider list, so you can pick it in settings and start using it with just an API key. Check out Atlas Cloud's new <a href="https://www.atlascloud.ai/console/coding-plan">coding plan promotion</a> for more budget-friendly API access.
</td>
</tr>
</tbody>
</table>
@@ -116,6 +117,8 @@ This project is maintained in the author's spare time. Corporate or individual s
2. On first launch, configure your model provider, API key, and default model in Settings.
3. Public macOS releases require signing and notarization. Draft or unsigned temporary builds may still need one-time manual approval. Unsigned Windows installers may show SmartScreen; click "More info" -> "Run anyway". See the [desktop installation guide](docs/en/start/install.md).
Release trust and privacy: [Code signing policy](docs/en/start/code-signing.md) · [Privacy and network access](docs/en/start/privacy.md)
## Run the CLI from Source
For users who want to debug the underlying CLI, server, or local development flow:
@@ -145,6 +148,8 @@ See [environment variables](docs/en/cli/env.md) and [CLI setup](docs/en/cli/inde
- **Skill marketplace**: discover, preview, and install third-party skills from ClawHub / SkillHub, with source and safety status shown up front.
- **Session activity panel**: track task progress, background tasks, SubAgents, and sources in one side panel.
- **Visual SubAgent manager**: create and tune SubAgents in a GUI — model, tools, and permission mode.
- **Agent Teams workbench**: visualize multi-agent collaboration in the GUI — members, tasks, a communication feed, and a dependency-lane canvas.
- **Dynamic Workflow orchestration**: the model writes and runs orchestration scripts on the fly, driving subagents concurrently or in pipelines, with phase views, interrupts, and resume.
- **Model trace**: every model request is logged locally with status and timing — search and filter to diagnose stuck or failed calls.
- **Computer Use**: let the agent take screenshots, click, type, and control desktop apps after authorization.
- **Desktop pets**: Dada, Huhu, Bubu, and Huihui change what they do with the task at hand — or raise one of your own (off by default).
+16 -11
View File
@@ -22,7 +22,7 @@
</div>
Claude Code Haha 是一个**桌面端 Claude Code 工作台**:多会话与全局搜索、分支 / Worktree 启动、Diff 审阅、内置浏览器预览、图形化权限审批、模型自选(Claude / ChatGPT / Grok / 预设 / 本地端点)、图片生成、MCP 与 SubAgent 可视化管理、模型请求追踪、Computer Use、技能市场、多主题、桌面宠物、H5 远程访问、IM 接入和定时任务,集中在一个 macOS / Windows / Linux APP 里。
Claude Code Haha 是一个**桌面端 Claude Code 工作台**:多会话与全局搜索、分支 / Worktree 启动、Diff 审阅、内置浏览器预览、图形化权限审批、模型自选(Claude / ChatGPT / Grok / 预设 / 本地端点)、图片生成、MCP 与 SubAgent 可视化管理、Agent Teams 协作工作台、动态 Workflow 编排、模型请求追踪、Computer Use、技能市场、多主题、桌面宠物、H5 远程访问、IM 接入和定时任务,集中在一个 macOS / Windows / Linux APP 里。
<p align="center">
<a href="#桌面端预览">桌面端预览</a> · <a href="#安装桌面端">安装桌面端</a> · <a href="#桌面端亮点">桌面端亮点</a> · <a href="#更多文档">更多文档</a> · <a href="#赞助与合作">赞助与合作</a> · <a href="#用户交流群">用户交流群</a>
@@ -63,16 +63,6 @@ Claude Code Haha 是一个**桌面端 Claude Code 工作台**:多会话与全
</tr>
</thead>
<tbody>
<tr>
<td align="center" valign="middle">
<a href="https://teamorouter.com/?utm_source=cc_haha&utm_medium=referral&utm_campaign=ai_directory">
<img src="docs/images/sponsors/teamorouter-logo.svg" width="180" alt="TeamoRouter">
</a>
</td>
<td valign="middle">
感谢 <a href="https://teamorouter.com/?utm_source=cc_haha&utm_medium=referral&utm_campaign=ai_directory">TeamoRouter</a> 赞助本项目!TeamoRouter 是面向开发者、AI 团队与企业的企业级 Agentic LLM 网关:无需订阅,通过一个统一 API 即可使用 Claude Code、Codex、Gemini CLI 等热门 AI Agent,价格最高可享 90% 折扣。平台聚合 OpenAI、Anthropic、Vertex、Azure、AWS Bedrock 等数百家官方模型提供商,全部经过 Agent 协议兼容验证,官方直连、非逆向,提供接近官方的 TTFT、99.6% SLA 与行业领先的缓存命中率;同时支持集中账单、团队管理、BYOK、用量分析,并可用 Teamo Desktop 一键接入。新用户通过 <a href="https://teamorouter.com/?utm_source=cc_haha&utm_medium=referral&utm_campaign=ai_directory">专属链接</a> 注册,首次充值可享 10% 折扣。
</td>
</tr>
<tr>
<td align="center" valign="middle">
<a href="https://www.xuanshuapi.com/register?aff=CC-HAHA&promo=CC-HAHA">
@@ -103,6 +93,17 @@ Claude Code Haha 是一个**桌面端 Claude Code 工作台**:多会话与全
感谢 <a href="https://s.qiniu.com/IZbyya">七牛云 AI</a> 赞助本项目!七牛云 AI 是七牛云(02567.HK)旗下企业级大模型 MaaS 平台,一站式调用全球 150+ 主流模型,兼容全球主流模型厂商协议,覆盖文本、图像、音频、视频、文件处理等全模态处理能力,服务超过 169 万企业及开发者用户。作为 cc-haha 用户专属福利,通过 <a href="https://s.qiniu.com/IZbyya">专属链接</a> 注册,企业用户免费领 1200 万 Token,开发者免费领 300 万 Token。
</td>
</tr>
<tr>
<td align="center" valign="middle">
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=cc-haha">
<img src="docs/images/sponsors/atlascloud-logo-black.png#gh-light-mode-only" width="180" alt="Atlas Cloud">
<img src="docs/images/sponsors/atlascloud-logo-white.png#gh-dark-mode-only" width="180" alt="Atlas Cloud">
</a>
</td>
<td valign="middle">
感谢 <a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=cc-haha">Atlas Cloud</a> 赞助本项目!Atlas Cloud 是一个全模态 AI 推理平台,让开发者通过统一的 AI API 访问视频生成、图像生成和 LLM API,无需分别维护多个厂商集成,即可调用 300+ 精选模型。cc-haha 已内置 Atlas Cloud 供应商预设,在设置里选择后填入 API Key 即可直接使用。Atlas Cloud 最新推出 <a href="https://www.atlascloud.ai/console/coding-plan">coding plan 优惠</a>,为开发者提供更具性价比的 API 访问预算。
</td>
</tr>
</tbody>
</table>
@@ -116,6 +117,8 @@ Claude Code Haha 是一个**桌面端 Claude Code 工作台**:多会话与全
2. 首次启动后,在桌面端设置里配置模型提供商、API Key 和默认模型。
3. 正式 macOS Release 需要经过签名和公证;如果安装的是 draft/unsigned 临时包,首次打开可能仍需手动放行。Windows 未签名安装包可能出现 SmartScreen 提示,点「更多信息」→「仍要运行」即可。详见 [桌面端安装指南](docs/start/install.md)。
发布可信度与隐私:[Code signing policy](docs/start/code-signing.md) · [隐私与联网说明](docs/start/privacy.md)
## 从源码启动 CLI
适合想调试底层 CLI、服务端或自行开发的用户:
@@ -145,6 +148,8 @@ cp .env.example .env
- **技能市场**:发现、预览、安装 ClawHub / SkillHub 的第三方技能,来源和安全状态摆在明处。
- **会话活动面板**:集中查看任务进度、后台任务、SubAgent 与来源。
- **可视化 SubAgent 管理**:图形界面创建和调校子代理,选择模型、工具与权限模式。
- **Agent Teams 协作工作台**:桌面端可视化多 Agent 协作团队——成员、任务、通信流和依赖泳道一目了然。
- **动态 Workflow 编排**:模型当场编写并运行编排脚本,并发或流水线调度多个子代理,支持阶段视图、中断与断点续跑。
- **模型请求追踪**:本地记录每轮模型请求的状态与耗时,可搜索筛选,快速定位卡死或失败调用。
- **Computer Use**:让 Agent 在授权后截图、点击、输入并控制桌面应用。
- **桌面宠物**:搭搭、弧弧、补补、回回随任务状态换动作,也能自己做一只(默认关闭)。
@@ -0,0 +1,174 @@
import { afterEach, describe, expect, it, mock } from 'bun:test'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { fileURLToPath } from 'node:url'
import { createAdapterClient } from '../adapter-client.js'
import { loadConfig } from '../config.js'
const ADAPTERS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
const PLATFORMS = ['telegram', 'feishu', 'wechat', 'dingtalk', 'whatsapp'] as const
const HOME = fs.realpathSync(os.homedir())
const ORIGINAL_ENV = {
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
ADAPTER_ALLOWED_PROJECT_ROOTS: process.env.ADAPTER_ALLOWED_PROJECT_ROOTS,
ADAPTER_DEFAULT_PROJECT_DIR: process.env.ADAPTER_DEFAULT_PROJECT_DIR,
CLAUDE_ADAPTER_DEFAULT_WORK_DIR: process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR,
PWD: process.env.PWD,
}
const ORIGINAL_CWD = process.cwd()
const ORIGINAL_FETCH = globalThis.fetch
afterEach(() => {
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
process.chdir(ORIGINAL_CWD)
globalThis.fetch = ORIGINAL_FETCH
})
/** Boot an adapter config from a throwaway config dir with a clean env. */
function bootConfig(file: Record<string, unknown>): ReturnType<typeof loadConfig> {
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-client-cfg-'))
fs.writeFileSync(path.join(configDir, 'adapters.json'), JSON.stringify(file))
process.env.CLAUDE_CONFIG_DIR = configDir
delete process.env.ADAPTER_ALLOWED_PROJECT_ROOTS
delete process.env.ADAPTER_DEFAULT_PROJECT_DIR
delete process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR
return loadConfig()
}
/** What the bot would actually show for /projects, given what the server returns. */
async function listedProjects(
client: { listRecentProjects: () => Promise<{ projectName: string }[]> },
projects: { projectName: string; realPath: string }[],
): Promise<string[]> {
globalThis.fetch = mock(() => Promise.resolve(Response.json({ projects }))) as any
return (await client.listRecentProjects()).map((p) => p.projectName)
}
describe('createAdapterClient', () => {
// The regression that started #1191, now pinned behaviourally rather than by
// grepping the entrypoints.
it('keeps every project under the allowed root reachable when a default project is set', async () => {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-client-roots-'))
const allowedRoot = path.join(sandbox, 'allowed')
const outside = path.join(sandbox, 'outside')
try {
const myApp = path.join(allowedRoot, 'work', 'my-app')
const sibling = path.join(allowedRoot, 'side', 'blog')
for (const dir of [myApp, sibling, outside]) {
fs.mkdirSync(dir, { recursive: true })
}
for (const platform of PLATFORMS) {
const config = bootConfig({
defaultProjectDir: myApp,
allowedProjectRoots: [allowedRoot],
})
const { httpClient, defaultWorkDir } = createAdapterClient(config, config[platform])
expect(defaultWorkDir).toBe(fs.realpathSync(myApp))
const names = await listedProjects(httpClient, [
{ projectName: 'my-app', realPath: myApp },
{ projectName: 'blog', realPath: sibling },
{ projectName: 'not-mine', realPath: outside },
])
expect(names).toEqual(['my-app', 'blog'])
}
} finally {
fs.rmSync(sandbox, { recursive: true, force: true })
}
})
// A GUI-launched sidecar inherits cwd "/" (Electron passes no cwd). Inheriting
// that as a boundary would allow the whole filesystem while the docs and the
// settings UI both promise "your home directory".
it('never inherits a filesystem root as the boundary', async () => {
process.chdir('/')
delete process.env.PWD
for (const platform of PLATFORMS) {
const config = bootConfig({})
const { httpClient, defaultWorkDir } = createAdapterClient(config, config[platform])
const names = await listedProjects(httpClient, [
{ projectName: 'etc', realPath: '/etc' },
{ projectName: 'home-project', realPath: HOME },
])
expect(names).toEqual(['home-project'])
expect(defaultWorkDir).toBe(HOME)
}
})
// Narrowing the roots must not brick /new: the client rejects a workDir outside
// the boundary, and every adapter passes defaultWorkDir straight to createSession.
it('always yields a default work dir inside the allowed roots', async () => {
const allowed = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-allowed-'))
const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-elsewhere-'))
try {
process.chdir('/')
delete process.env.PWD
for (const platform of PLATFORMS) {
// Boundary narrowed to one dir, default project pointing somewhere else.
const config = bootConfig({ allowedProjectRoots: [allowed], defaultProjectDir: elsewhere })
const { httpClient, defaultWorkDir } = createAdapterClient(config, config[platform])
expect(defaultWorkDir).toBe(fs.realpathSync(allowed))
globalThis.fetch = mock(() => Promise.resolve(Response.json({ sessionId: 'ok' }))) as any
await expect(httpClient.createSession(defaultWorkDir)).resolves.toBe('ok')
}
} finally {
fs.rmSync(allowed, { recursive: true, force: true })
fs.rmSync(elsewhere, { recursive: true, force: true })
}
})
it('honours an explicitly narrowed boundary', async () => {
const allowed = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-allowed-'))
const denied = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-denied-'))
try {
const config = bootConfig({ allowedProjectRoots: [allowed] })
const { httpClient } = createAdapterClient(config, config.feishu)
const names = await listedProjects(httpClient, [
{ projectName: 'allowed', realPath: allowed },
{ projectName: 'denied', realPath: denied },
{ projectName: 'home', realPath: HOME },
])
expect(names).toEqual(['allowed'])
} finally {
fs.rmSync(allowed, { recursive: true, force: true })
fs.rmSync(denied, { recursive: true, force: true })
}
})
})
/**
* Structural guard for the five entrypoints. They boot a live bot on import
* (credentials are read and process.exit is called), so they cannot be imported
* in a test. The behaviour above is covered by exercising the factory directly;
* this only pins that each entrypoint actually delegates to it.
*/
describe('IM adapter entrypoint wiring', () => {
for (const platform of PLATFORMS) {
it(`${platform} builds its client through createAdapterClient`, () => {
const source = fs.readFileSync(path.join(ADAPTERS_DIR, platform, 'index.ts'), 'utf-8')
// Strip comments so a mention in prose cannot satisfy the assertions.
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1')
expect(source).toMatch(
new RegExp(`createAdapterClient\\s*\\(\\s*config\\s*,\\s*config\\.${platform}\\s*\\)`),
)
// Constructing a client here would bypass the resolved boundary entirely,
// which is exactly how all five adapters shared the #1191 defect.
expect(source).not.toMatch(/new\s+AdapterHttpClient/)
// Nor may an entrypoint re-derive the boundary or the work dir itself.
expect(source).not.toMatch(/resolveAllowedProjectRoots|getConfiguredWorkDir/)
})
}
})
+184 -1
View File
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'bun:test'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { getConfiguredWorkDir, loadConfig } from '../config.js'
import { getConfiguredWorkDir, loadConfig, resolveAllowedProjectRoots } from '../config.js'
describe('adapter config defaults', () => {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
@@ -130,6 +130,189 @@ describe('adapter config defaults', () => {
})
})
describe('resolveAllowedProjectRoots', () => {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
const originalEnvRoots = process.env.ADAPTER_ALLOWED_PROJECT_ROOTS
const originalAdapterDefaultWorkDir = process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR
const originalPwd = process.env.PWD
const home = fs.realpathSync(os.homedir())
afterEach(() => {
restoreEnv('CLAUDE_CONFIG_DIR', originalConfigDir)
restoreEnv('ADAPTER_ALLOWED_PROJECT_ROOTS', originalEnvRoots)
restoreEnv('CLAUDE_ADAPTER_DEFAULT_WORK_DIR', originalAdapterDefaultWorkDir)
restoreEnv('PWD', originalPwd)
})
function withConfig<T>(file: Record<string, unknown>, run: (configDir: string) => T): T {
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-'))
try {
fs.writeFileSync(path.join(configDir, 'adapters.json'), JSON.stringify(file))
process.env.CLAUDE_CONFIG_DIR = configDir
delete process.env.ADAPTER_ALLOWED_PROJECT_ROOTS
return run(configDir)
} finally {
fs.rmSync(configDir, { recursive: true, force: true })
}
}
// The #1191 regression: `defaultProjectDir` is the default work dir for NEW
// sessions, not the boundary. Deriving the only allowed root from it hid every
// other project from /projects on all five IM channels.
it('does not collapse the boundary onto the configured default project', () => {
const defaultProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-project-'))
try {
withConfig({ defaultProjectDir }, () => {
const config = loadConfig()
for (const platform of [config.telegram, config.feishu, config.wechat, config.dingtalk, config.whatsapp]) {
const roots = resolveAllowedProjectRoots(config, platform)
expect(roots).not.toEqual([fs.realpathSync(defaultProjectDir)])
expect(roots).toContain(home)
expect(roots).toContain(fs.realpathSync(defaultProjectDir))
}
})
} finally {
fs.rmSync(defaultProjectDir, { recursive: true, force: true })
}
})
it('defaults to the home directory so sibling projects stay reachable', () => {
withConfig({}, () => {
const config = loadConfig()
expect(resolveAllowedProjectRoots(config, config.feishu)).toContain(home)
})
})
it('uses explicitly configured global roots instead of the default', () => {
const rootA = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-root-a-'))
const rootB = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-root-b-'))
try {
withConfig({ allowedProjectRoots: [rootA, rootB] }, () => {
const config = loadConfig()
expect(resolveAllowedProjectRoots(config, config.feishu)).toEqual([
fs.realpathSync(rootA),
fs.realpathSync(rootB),
])
})
} finally {
fs.rmSync(rootA, { recursive: true, force: true })
fs.rmSync(rootB, { recursive: true, force: true })
}
})
it('lets a platform narrow the global roots', () => {
const globalRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-root-global-'))
const feishuRoot = fs.mkdtempSync(path.join(globalRoot, 'feishu-'))
try {
withConfig({ allowedProjectRoots: [globalRoot], feishu: { allowedProjectRoots: [feishuRoot] } }, () => {
const config = loadConfig()
expect(resolveAllowedProjectRoots(config, config.feishu)).toEqual([fs.realpathSync(feishuRoot)])
// Other platforms keep the global roots.
expect(resolveAllowedProjectRoots(config, config.telegram)).toEqual([fs.realpathSync(globalRoot)])
})
} finally {
fs.rmSync(globalRoot, { recursive: true, force: true })
}
})
// A relative entry would resolve against the sidecar's cwd — "/" for a
// GUI-launched app — making the boundary depend on how the app was started.
it('rejects relative roots', () => {
const realRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-root-'))
try {
withConfig({ allowedProjectRoots: ['..', 'relative/path', realRoot] }, () => {
const config = loadConfig()
expect(resolveAllowedProjectRoots(config, config.feishu)).toEqual([fs.realpathSync(realRoot)])
})
} finally {
fs.rmSync(realRoot, { recursive: true, force: true })
}
})
it('does not warn about duplicates as if they were missing', () => {
const realRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-root-'))
const warnings: string[] = []
const originalWarn = console.warn
console.warn = (...args: unknown[]) => { warnings.push(args.join(' ')) }
try {
withConfig({ allowedProjectRoots: [realRoot, realRoot, '~', os.homedir()] }, () => {
const config = loadConfig()
expect(resolveAllowedProjectRoots(config, config.feishu)).toEqual([
fs.realpathSync(realRoot),
home,
])
})
expect(warnings.filter((line) => line.includes('do not exist') || line.includes('does not exist')))
.toEqual([])
} finally {
console.warn = originalWarn
fs.rmSync(realRoot, { recursive: true, force: true })
}
})
it('expands ~ and drops entries that do not exist', () => {
const realRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-root-'))
try {
withConfig({ allowedProjectRoots: [realRoot, path.join(os.tmpdir(), 'definitely-missing-root'), '~'] }, () => {
const config = loadConfig()
expect(resolveAllowedProjectRoots(config, config.feishu)).toEqual([fs.realpathSync(realRoot), home])
})
} finally {
fs.rmSync(realRoot, { recursive: true, force: true })
}
})
// Failing closed here would brick every IM command on a typo. The pairing gate
// is the primary authorization control; these roots are defense-in-depth.
it('falls back to the default when no configured root exists', () => {
withConfig({ allowedProjectRoots: [path.join(os.tmpdir(), 'missing-a'), path.join(os.tmpdir(), 'missing-b')] }, () => {
const config = loadConfig()
const roots = resolveAllowedProjectRoots(config, config.feishu)
expect(roots).toContain(home)
expect(roots.length).toBeGreaterThan(0)
})
})
it('reads roots from ADAPTER_ALLOWED_PROJECT_ROOTS for standalone runs', () => {
const rootA = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-env-root-a-'))
const rootB = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-env-root-b-'))
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-'))
try {
process.env.CLAUDE_CONFIG_DIR = configDir
process.env.ADAPTER_ALLOWED_PROJECT_ROOTS = [rootA, rootB].join(path.delimiter)
const config = loadConfig()
expect(resolveAllowedProjectRoots(config, config.feishu)).toEqual([
fs.realpathSync(rootA),
fs.realpathSync(rootB),
])
} finally {
fs.rmSync(rootA, { recursive: true, force: true })
fs.rmSync(rootB, { recursive: true, force: true })
fs.rmSync(configDir, { recursive: true, force: true })
}
})
it('lets the env override win over both file scopes', () => {
const envRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-env-root-'))
const fileRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-file-root-'))
try {
withConfig(
{ allowedProjectRoots: [fileRoot], feishu: { allowedProjectRoots: [fileRoot] } },
() => {
process.env.ADAPTER_ALLOWED_PROJECT_ROOTS = envRoot
const config = loadConfig()
expect(resolveAllowedProjectRoots(config, config.feishu)).toEqual([fs.realpathSync(envRoot)])
expect(resolveAllowedProjectRoots(config, config.telegram)).toEqual([fs.realpathSync(envRoot)])
},
)
} finally {
fs.rmSync(envRoot, { recursive: true, force: true })
fs.rmSync(fileRoot, { recursive: true, force: true })
}
})
})
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key]
@@ -85,6 +85,50 @@ describe('AdapterHttpClient', () => {
}
})
// #1191: /projects showed only the default project on every IM channel because
// the allowed root was the default work dir itself. With the boundary resolved
// from the user's home instead, sibling projects must survive the filter.
it('keeps sibling projects that live outside the default work dir', async () => {
const homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'im-home-'))
try {
const defaultWorkDir = path.join(homeRoot, 'work', 'my-app')
const sibling = path.join(homeRoot, 'work', 'other-app')
const elsewhere = path.join(homeRoot, 'side', 'blog')
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'im-outside-'))
for (const dir of [defaultWorkDir, sibling, elsewhere]) fs.mkdirSync(dir, { recursive: true })
// The roots an adapter now gets from resolveAllowedProjectRoots(): the home
// directory, not the default work dir.
client = new AdapterHttpClient('ws://127.0.0.1:3456', {
allowedProjectRoots: [homeRoot, defaultWorkDir],
})
globalThis.fetch = mock(() =>
Promise.resolve(Response.json({
projects: [
{ projectName: 'my-app', realPath: defaultWorkDir, sessionCount: 9 },
{ projectName: 'other-app', realPath: sibling, sessionCount: 4 },
{ projectName: 'blog', realPath: elsewhere, sessionCount: 2 },
{ projectName: 'not-mine', realPath: outside, sessionCount: 1 },
],
}))
) as any
const projects = await client.listRecentProjects()
expect(projects.map((p) => p.projectName)).toEqual(['my-app', 'other-app', 'blog'])
// Picking any of them by name must work too — matchProject shares the filter.
await expect(client.matchProject('blog')).resolves.toMatchObject({
project: { projectName: 'blog' },
})
// The boundary still holds for anything outside it.
await expect(client.matchProject('not-mine')).resolves.toEqual({})
fs.rmSync(outside, { recursive: true, force: true })
} finally {
fs.rmSync(homeRoot, { recursive: true, force: true })
}
})
it('filters recent projects before index, name, and fuzzy matching', async () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-'))
const allowedDir = fs.mkdtempSync(path.join(rootDir, 'allowed-'))
@@ -0,0 +1,347 @@
import { afterEach, describe, expect, it, mock } from 'bun:test'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { fileURLToPath } from 'node:url'
import { extractDingTalkText, type DingTalkRobotMessage } from '../../dingtalk/helpers.js'
import { extractInboundPayload } from '../../feishu/extract-payload.js'
import { AdapterHttpClient, type RecentProject } from '../http-client.js'
import {
formatProjectSelectionOutcome,
ProjectSelectionController,
ProjectSelectionRouter,
} from '../project-selection-router.js'
const ORIGINAL_FETCH = globalThis.fetch
const ADAPTERS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
afterEach(() => {
globalThis.fetch = ORIGINAL_FETCH
})
type TransportFixture = {
name: string
extractText: (command: string) => string
}
const TRANSPORTS: TransportFixture[] = [
{
name: 'DingTalk',
extractText: (command) => extractDingTalkText({
msgtype: 'text',
conversationType: '1',
senderStaffId: 'ding-user',
text: { content: command },
} satisfies DingTalkRobotMessage),
},
{
name: 'Feishu',
extractText: (command) => extractInboundPayload(
JSON.stringify({ text: command }),
'text',
).text,
},
]
function recentProject(projectName: string, realPath: string): RecentProject {
return {
projectPath: realPath,
realPath,
projectName,
isGit: false,
repoName: null,
branch: null,
modifiedAt: '2026-08-10T00:00:00.000Z',
sessionCount: 1,
}
}
function createProjectController(
client: AdapterHttpClient,
defaultWorkDir: string,
): ProjectSelectionController {
return new ProjectSelectionController({
httpClient: client,
defaultWorkDir,
prepareNewSession: () => {},
createSession: async (_chatId, workDir) => {
await client.createSession(workDir)
return true
},
})
}
describe('IM project selection flow', () => {
it('keeps both adapter entrypoints wired to the tested controller', () => {
for (const platform of ['dingtalk', 'feishu']) {
const source = fs.readFileSync(path.join(ADAPTERS_DIR, platform, 'index.ts'), 'utf8')
expect(source).toContain('projectSelectionController.listProjects(chatId)')
expect(source).toContain('projectSelectionController.handleInput(chatId,')
}
})
it('keeps bare picker replies while explicit commands retain priority', () => {
const router = new ProjectSelectionRouter()
router.markPickerShown('chat-1')
expect(router.route('chat-1', '/new express')).toEqual({ kind: 'new', query: 'express' })
expect(router.route('chat-1', '1')).toEqual({ kind: 'picker_reply', query: '1' })
expect(router.route('chat-1', '/projects')).toBeNull()
expect(router.route('chat-1', '/help')).toBeNull()
router.clear('chat-1')
expect(router.route('chat-1', '1')).toBeNull()
})
it('drives picker state through listing and clears it after the selected project is created', async () => {
const events: string[] = []
const project = recentProject('express', '/allowed/express')
const controller = new ProjectSelectionController({
httpClient: {
listRecentProjects: async () => {
events.push('list')
return [project]
},
matchProject: async (query) => {
events.push(`match:${query}`)
return { project }
},
},
defaultWorkDir: '/allowed/default',
prepareNewSession: (chatId) => {
events.push(`prepare:${chatId}`)
},
createSession: async (chatId, workDir) => {
events.push(`create:${chatId}:${workDir}`)
return true
},
})
expect(await controller.listProjects('chat-1')).toEqual([project])
expect(await controller.handleInput('chat-1', '1')).toEqual({ kind: 'created', project })
expect(events).toEqual([
'list',
'prepare:chat-1',
'match:1',
'create:chat-1:/allowed/express',
])
expect(await controller.handleInput('chat-1', '1')).toBeNull()
})
it('keeps default, failed, and errored session outcomes explicit', async () => {
const createdWorkDirs: string[] = []
const controller = new ProjectSelectionController({
httpClient: {
listRecentProjects: async () => [],
matchProject: async () => {
throw new Error('project lookup failed')
},
},
defaultWorkDir: '/allowed/default',
prepareNewSession: () => {},
createSession: async (_chatId, workDir) => {
createdWorkDirs.push(workDir)
return true
},
})
expect(await controller.handleInput('chat-1', 'ordinary message')).toBeNull()
expect(await controller.listProjects('chat-1')).toEqual([])
expect(await controller.handleInput('chat-1', '1')).toBeNull()
const created = await controller.handleInput('chat-1', '/new')
expect(created).toEqual({ kind: 'created' })
expect(formatProjectSelectionOutcome(created!)).toBe('✅ 已新建会话,可以开始对话了。')
expect(createdWorkDirs).toEqual(['/allowed/default'])
const errored = await controller.handleInput('chat-1', '/new express')
expect(errored).toEqual({ kind: 'error', message: 'project lookup failed' })
expect(formatProjectSelectionOutcome(errored!)).toBe('❌ project lookup failed')
const failingController = new ProjectSelectionController({
httpClient: {
listRecentProjects: async () => [],
matchProject: async () => ({ project: recentProject('express', '/allowed/express') }),
},
defaultWorkDir: '/allowed/default',
prepareNewSession: () => {},
createSession: async () => false,
})
const failed = await failingController.handleInput('chat-2', '/new express')
expect(failed).toEqual({ kind: 'creation_failed' })
expect(formatProjectSelectionOutcome(failed!)).toBeNull()
})
it('routes DingTalk and Feishu /new name and index commands to the same canonical cwd after /projects', async () => {
const allowedRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'im-project-flow-'))
const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'im-project-outside-'))
const expressDir = path.join(allowedRoot, 'express')
const taskBoardDir = path.join(allowedRoot, 'task-board')
const expressApiDir = path.join(allowedRoot, 'express-api')
const expressWebDir = path.join(allowedRoot, 'express-web')
fs.mkdirSync(expressDir)
fs.mkdirSync(taskBoardDir)
fs.mkdirSync(expressApiDir)
fs.mkdirSync(expressWebDir)
const canonicalExpressDir = fs.realpathSync(expressDir)
const canonicalTaskBoardDir = fs.realpathSync(taskBoardDir)
const canonicalExpressApiDir = fs.realpathSync(expressApiDir)
const canonicalExpressWebDir = fs.realpathSync(expressWebDir)
const canonicalOutsideRoot = fs.realpathSync(outsideRoot)
const projects = [
recentProject('outside', canonicalOutsideRoot),
recentProject('express', canonicalExpressDir),
recentProject('task-board', canonicalTaskBoardDir),
recentProject('express-api', canonicalExpressApiDir),
recentProject('express-web', canonicalExpressWebDir),
]
const createdWorkDirs: string[] = []
globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input)
if (url.endsWith('/api/sessions/recent-projects')) {
return Response.json({ projects })
}
if (url.endsWith('/api/sessions') && init?.method === 'POST') {
const body = JSON.parse(String(init.body)) as { workDir: string }
createdWorkDirs.push(body.workDir)
return Response.json({ sessionId: `session-${createdWorkDirs.length}` }, { status: 201 })
}
throw new Error(`Unexpected request: ${url}`)
}) as unknown as typeof fetch
try {
const client = new AdapterHttpClient('ws://127.0.0.1:3456', {
allowedProjectRoots: [allowedRoot],
})
for (const transport of TRANSPORTS) {
for (const command of [
'/new express',
'/new 1',
' /new ExPrEsS ',
] as const) {
const chatId = `${transport.name}:${command}`
const controller = createProjectController(client, canonicalExpressDir)
const listed = await controller.listProjects(chatId)
expect(listed.map((project) => project.projectName)).toEqual([
'express',
'task-board',
'express-api',
'express-web',
])
const outcome = await controller.handleInput(chatId, transport.extractText(command))
expect(outcome).toMatchObject({
kind: 'created',
project: {
projectName: 'express',
realPath: canonicalExpressDir,
},
})
expect(formatProjectSelectionOutcome(outcome!)).toContain('**express**')
}
}
expect(createdWorkDirs).toEqual(Array(6).fill(canonicalExpressDir))
for (const transport of TRANSPORTS) {
const chatId = `${transport.name}:ambiguous`
const controller = createProjectController(client, canonicalExpressDir)
const listed = await controller.listProjects(chatId)
expect(listed).toHaveLength(4)
const outcome = await controller.handleInput(chatId, transport.extractText('/new express-'))
expect(outcome?.kind).toBe('ambiguous')
if (outcome?.kind !== 'ambiguous') throw new Error('Expected ambiguous project outcome')
expect(outcome.projects.map((project) => project.projectName)).toEqual([
'express-api',
'express-web',
])
const prompt = formatProjectSelectionOutcome(outcome) ?? ''
expect(prompt).toContain('/new <更完整名称或路径>')
expect(prompt).toContain(`**express-api** — ${canonicalExpressApiDir}`)
expect(prompt).toContain(`**express-web** — ${canonicalExpressWebDir}`)
}
for (const transport of TRANSPORTS) {
for (const query of ['outside', canonicalOutsideRoot]) {
const chatId = `${transport.name}:outside:${query}`
const controller = createProjectController(client, canonicalExpressDir)
const listed = await controller.listProjects(chatId)
expect(listed.some((project) => project.projectName === 'outside')).toBe(false)
const outcome = await controller.handleInput(
chatId,
transport.extractText(`/new ${query}`),
)
expect(outcome).toEqual({ kind: 'not_found', query })
expect(formatProjectSelectionOutcome(outcome!)).toContain(`未找到匹配 "${query}"`)
}
}
expect(createdWorkDirs).toEqual(Array(6).fill(canonicalExpressDir))
} finally {
fs.rmSync(allowedRoot, { recursive: true, force: true })
fs.rmSync(outsideRoot, { recursive: true, force: true })
}
})
it('prompts instead of choosing arbitrarily when exact project names are duplicated', async () => {
const allowedRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'im-project-duplicates-'))
const firstDir = path.join(allowedRoot, 'team-a', 'express')
const secondDir = path.join(allowedRoot, 'team-b', 'express')
fs.mkdirSync(firstDir, { recursive: true })
fs.mkdirSync(secondDir, { recursive: true })
const canonicalFirstDir = fs.realpathSync(firstDir)
const canonicalSecondDir = fs.realpathSync(secondDir)
const projects = [
recentProject('express', canonicalFirstDir),
recentProject('express', canonicalSecondDir),
]
let createRequests = 0
globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input)
if (url.endsWith('/api/sessions/recent-projects')) {
return Response.json({ projects })
}
if (url.endsWith('/api/sessions') && init?.method === 'POST') {
createRequests += 1
return Response.json({ sessionId: 'unexpected-session' }, { status: 201 })
}
throw new Error(`Unexpected request: ${url}`)
}) as unknown as typeof fetch
try {
const client = new AdapterHttpClient('ws://127.0.0.1:3456', {
allowedProjectRoots: [allowedRoot],
})
for (const transport of TRANSPORTS) {
const chatId = `${transport.name}:duplicate-express`
const controller = createProjectController(client, canonicalFirstDir)
await controller.listProjects(chatId)
const outcome = await controller.handleInput(
chatId,
transport.extractText('/new express'),
)
expect(outcome?.kind).toBe('ambiguous')
if (outcome?.kind !== 'ambiguous') throw new Error('Expected ambiguous project outcome')
expect(outcome.projects.map((project) => project.realPath)).toEqual([
canonicalFirstDir,
canonicalSecondDir,
])
const prompt = formatProjectSelectionOutcome(outcome) ?? ''
expect(prompt).toContain(canonicalFirstDir)
expect(prompt).toContain(canonicalSecondDir)
}
expect(createRequests).toBe(0)
} finally {
fs.rmSync(allowedRoot, { recursive: true, force: true })
}
})
})
+34
View File
@@ -0,0 +1,34 @@
import {
resolveAdapterWorkspace,
type AdapterConfig,
type AdapterPlatformConfig,
} from './config.js'
import { AdapterHttpClient } from './http-client.js'
export type AdapterWorkspace = {
httpClient: AdapterHttpClient
/** Where a new IM session starts. Guaranteed to sit inside the allowed roots. */
defaultWorkDir: string
}
/**
* Build the HTTP client and the default work dir for an IM adapter.
*
* Every adapter entrypoint goes through here instead of constructing the client
* itself. The five entrypoints previously repeated the wiring, and all five
* repeated the same defect (#1191): they passed the default work dir as the only
* allowed project root, so /projects listed a single project. Keeping the
* construction in one importable place makes that class of mistake unreachable
* without editing this file, and makes it testable — the entrypoints boot a live
* bot on import and cannot be exercised directly.
*/
export function createAdapterClient(
config: AdapterConfig,
platformConfig: AdapterPlatformConfig,
): AdapterWorkspace {
const { defaultWorkDir, allowedProjectRoots } = resolveAdapterWorkspace(config, platformConfig)
return {
httpClient: new AdapterHttpClient(config.serverUrl, { allowedProjectRoots }),
defaultWorkDir,
}
}
+167 -3
View File
@@ -25,6 +25,7 @@ export type TelegramConfig = {
allowedUsers: number[]
pairedUsers: PairedUser[]
defaultWorkDir: string
allowedProjectRoots: string[]
}
export type FeishuConfig = {
@@ -36,6 +37,7 @@ export type FeishuConfig = {
pairedUsers: PairedUser[]
defaultWorkDir: string
streamingCard: boolean
allowedProjectRoots: string[]
}
export type WechatConfig = {
@@ -46,6 +48,7 @@ export type WechatConfig = {
allowedUsers: string[]
pairedUsers: PairedUser[]
defaultWorkDir: string
allowedProjectRoots: string[]
}
export type DingtalkConfig = {
@@ -56,6 +59,7 @@ export type DingtalkConfig = {
defaultWorkDir: string
endpoint: string
permissionCardTemplateId: string
allowedProjectRoots: string[]
}
export type WhatsAppConfig = {
@@ -64,12 +68,14 @@ export type WhatsAppConfig = {
allowedUsers: string[]
pairedUsers: PairedUser[]
defaultWorkDir: string
allowedProjectRoots: string[]
}
export type AdapterConfig = {
serverUrl: string
defaultProjectDir: string
pairing: PairingState
allowedProjectRoots: string[]
telegram: TelegramConfig
feishu: FeishuConfig
wechat: WechatConfig
@@ -121,11 +127,15 @@ export function loadConfig(): AdapterConfig {
expiresAt: pairing.expiresAt ?? null,
createdAt: pairing.createdAt ?? null,
},
// File scope only. ADAPTER_ALLOWED_PROJECT_ROOTS is applied by
// resolveAllowedProjectRoots so this field keeps one meaning.
allowedProjectRoots: readProjectRoots(file.allowedProjectRoots),
telegram: {
botToken: process.env.TELEGRAM_BOT_TOKEN || tg.botToken || '',
allowedUsers: tg.allowedUsers ?? [],
pairedUsers: tg.pairedUsers ?? [],
defaultWorkDir: tg.defaultWorkDir || fallbackWorkDir,
allowedProjectRoots: readProjectRoots(tg.allowedProjectRoots),
},
feishu: {
appId: process.env.FEISHU_APP_ID || fs_.appId || '',
@@ -136,6 +146,7 @@ export function loadConfig(): AdapterConfig {
pairedUsers: fs_.pairedUsers ?? [],
defaultWorkDir: fs_.defaultWorkDir || fallbackWorkDir,
streamingCard: fs_.streamingCard ?? false,
allowedProjectRoots: readProjectRoots(fs_.allowedProjectRoots),
},
wechat: {
accountId: process.env.WECHAT_ACCOUNT_ID || wc.accountId || '',
@@ -145,6 +156,7 @@ export function loadConfig(): AdapterConfig {
allowedUsers: wc.allowedUsers ?? [],
pairedUsers: wc.pairedUsers ?? [],
defaultWorkDir: wc.defaultWorkDir || fallbackWorkDir,
allowedProjectRoots: readProjectRoots(wc.allowedProjectRoots),
},
dingtalk: {
clientId: process.env.DINGTALK_CLIENT_ID || dt.clientId || '',
@@ -154,6 +166,7 @@ export function loadConfig(): AdapterConfig {
defaultWorkDir: dt.defaultWorkDir || fallbackWorkDir,
endpoint: process.env.DINGTALK_STREAM_ENDPOINT || dt.endpoint || 'https://api.dingtalk.com',
permissionCardTemplateId: process.env.DINGTALK_PERMISSION_CARD_TEMPLATE_ID || dt.permissionCardTemplateId || '',
allowedProjectRoots: readProjectRoots(dt.allowedProjectRoots),
},
whatsapp: {
accountJid: process.env.WHATSAPP_ACCOUNT_JID || wa.accountJid || '',
@@ -161,6 +174,7 @@ export function loadConfig(): AdapterConfig {
allowedUsers: wa.allowedUsers ?? [],
pairedUsers: wa.pairedUsers ?? [],
defaultWorkDir: wa.defaultWorkDir || fallbackWorkDir,
allowedProjectRoots: readProjectRoots(wa.allowedProjectRoots),
},
}
}
@@ -169,21 +183,167 @@ export function getConfiguredWorkDir(config: AdapterConfig, platformConfig: Adap
return config.defaultProjectDir || platformConfig.defaultWorkDir
}
/**
* Resolve the directories an IM adapter is allowed to reach.
*
* This is deliberately NOT derived from `defaultWorkDir` (#1191). That field is
* documented as the *default* work dir for new IM sessions, not a boundary, and
* using it as the sole allowed root broke both directions:
*
* - configured → /projects listed only that one project, and picking any other
* recent project by name or path failed;
* - blank → it falls back to PWD/cwd(), which for a Finder-launched .app
* is "/", so the boundary silently allowed the entire filesystem.
*
* Precedence: ADAPTER_ALLOWED_PROJECT_ROOTS > platform-specific roots > global
* roots > default (home ∪ default work dir). The pairing gate is the primary
* authorization control; these roots are defense-in-depth, so a misconfigured
* value falls back to the default with a warning instead of bricking the bot.
*
* Explicitly configured roots are honoured verbatim — if someone types "/" they
* own the machine and mean it. The *default* branch refuses to inherit such a
* root, because that is how the boundary silently became vacuous before.
*/
export function resolveAllowedProjectRoots(
config: AdapterConfig,
platformConfig: AdapterPlatformConfig,
): string[] {
// Env wins over both file scopes, matching how every other field in this
// module resolves.
const configured = readEnvProjectRoots()
?? (platformConfig.allowedProjectRoots.length > 0
? platformConfig.allowedProjectRoots
: config.allowedProjectRoots)
if (configured.length > 0) {
const candidates = configured.map(resolveExistingDirectory)
// Count the misses before dedup — duplicates are not missing directories.
const missing = candidates.filter((value) => !value).length
const resolved = dedupePaths(candidates)
if (resolved.length > 0) {
if (missing > 0) {
console.warn(
missing === 1
? '[Config] Ignoring 1 allowedProjectRoots entry that does not exist'
: `[Config] Ignoring ${missing} allowedProjectRoots entries that do not exist`,
)
}
return resolved
}
console.warn(
'[Config] None of the configured allowedProjectRoots exist; ' +
'falling back to the default roots (home directory + default project dir)',
)
}
const home = resolveExistingDirectory(os.homedir())
const defaults = dedupePaths([
home,
// Only inherit the default work dir as a boundary when it is a real project
// directory. "/" and "/Users" reach every project on the machine, so taking
// them from the PWD/cwd() fallback would make the boundary meaningless.
usableAsBoundary(resolveExistingDirectory(getConfiguredWorkDir(config, platformConfig))),
])
if (defaults.length > 0) return defaults
// Only reachable if the home directory itself does not resolve. The adapter
// client drops unresolvable roots, so this is a best effort, not a guarantee.
return [os.homedir()]
}
/**
* Directories the IM boundary must never inherit implicitly: a filesystem root,
* or any strict ancestor of the home directory (`/`, `/Users`, `/home`).
*/
function usableAsBoundary(dir: string | null): string | null {
if (!dir) return null
if (path.parse(dir).root === dir) return null
return isStrictAncestor(dir, os.homedir()) ? null : dir
}
function isStrictAncestor(candidate: string, target: string): boolean {
const relative = path.relative(candidate, target)
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
}
/**
* The work dir a new IM session starts in, paired with the boundary it must sit
* inside. Resolving them together is the point: the two are configured
* separately, and a default project outside the allowed roots would otherwise
* make every `/new` (and every first message in a fresh chat) fail the client's
* own boundary check.
*/
export function resolveAdapterWorkspace(
config: AdapterConfig,
platformConfig: AdapterPlatformConfig,
): { defaultWorkDir: string; allowedProjectRoots: string[] } {
const allowedProjectRoots = resolveAllowedProjectRoots(config, platformConfig)
const configured = resolveExistingDirectory(getConfiguredWorkDir(config, platformConfig))
if (configured && isPathWithinRoots(configured, allowedProjectRoots)) {
return { defaultWorkDir: configured, allowedProjectRoots }
}
const fallback = allowedProjectRoots[0] ?? os.homedir()
if (configured) {
console.warn(
`[Config] Default project ${configured} is outside the allowed project roots; ` +
`new sessions will start in ${fallback}`,
)
}
return { defaultWorkDir: fallback, allowedProjectRoots }
}
function isPathWithinRoots(target: string, roots: string[]): boolean {
return roots.some((root) => {
const relative = path.relative(root, target)
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
})
}
function readProjectRoots(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter(Boolean)
}
function readEnvProjectRoots(): string[] | null {
const raw = process.env.ADAPTER_ALLOWED_PROJECT_ROOTS?.trim()
if (!raw) return null
const roots = readProjectRoots(raw.split(path.delimiter))
// A delimiter-only value (an unset "$A:$B" in a launcher script) must not read
// as "the env configured an empty boundary" and discard the file config.
return roots.length > 0 ? roots : null
}
function dedupePaths(values: (string | null)[]): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const value of values) {
if (!value || seen.has(value)) continue
seen.add(value)
result.push(value)
}
return result
}
function resolveUserDefaultWorkDir(): string {
const candidates = [
process.env.ADAPTER_DEFAULT_PROJECT_DIR,
process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR,
process.env.PWD,
process.cwd(),
os.homedir(),
]
for (const candidate of candidates) {
const resolved = resolveExistingDirectory(candidate)
// A GUI-launched sidecar inherits cwd "/" (Electron passes no cwd), which is
// useless as a place to start a session and unusable as a boundary.
const resolved = usableAsBoundary(resolveExistingDirectory(candidate))
if (resolved) return resolved
}
return os.homedir()
return resolveExistingDirectory(os.homedir()) ?? os.homedir()
}
function resolveExistingDirectory(value: string | undefined): string | null {
@@ -196,6 +356,10 @@ function resolveExistingDirectory(value: string | undefined): string | null {
? path.join(os.homedir(), trimmed.slice(2))
: trimmed
// Relative entries would resolve against the sidecar's cwd ("/" for a packaged
// app), making the boundary depend on how the app was launched.
if (!path.isAbsolute(expanded)) return null
try {
const realPath = fs.realpathSync(expanded)
return fs.statSync(realPath).isDirectory() ? realPath : null
+6 -3
View File
@@ -214,9 +214,12 @@ export class AdapterHttpClient {
const q = query.toLowerCase()
// Exact project name match
const exact = projects.find(p => p.projectName.toLowerCase() === q)
if (exact) return { project: exact }
// Exact project name match. Different roots can contain repositories with
// the same basename, so an exact name is still ambiguous when it identifies
// more than one canonical project.
const exact = projects.filter(p => p.projectName.toLowerCase() === q)
if (exact.length === 1) return { project: exact[0] }
if (exact.length > 1) return { ambiguous: exact }
// Fuzzy: name or path contains query
const matches = projects.filter(p =>
+127
View File
@@ -0,0 +1,127 @@
import type { AdapterHttpClient, RecentProject } from './http-client.js'
export type ProjectSelectionRoute = {
kind: 'new' | 'picker_reply'
query?: string
}
export type ProjectSelectionOutcome =
| { kind: 'created'; project?: RecentProject }
| { kind: 'creation_failed' }
| { kind: 'ambiguous'; projects: RecentProject[] }
| { kind: 'not_found'; query: string }
| { kind: 'error'; message: string }
type ProjectSelectionControllerDeps = {
httpClient: Pick<AdapterHttpClient, 'listRecentProjects' | 'matchProject'>
defaultWorkDir: string
prepareNewSession: (chatId: string) => void | Promise<void>
createSession: (chatId: string, workDir: string) => Promise<boolean>
}
const NON_PROJECT_COMMANDS = new Set([
'/help',
'帮助',
'/status',
'状态',
'/clear',
'清空',
'/stop',
'停止',
'/projects',
'项目列表',
])
export class ProjectSelectionRouter {
private readonly pendingChats = new Set<string>()
markPickerShown(chatId: string): void {
this.pendingChats.add(chatId)
}
clear(chatId: string): void {
this.pendingChats.delete(chatId)
}
route(chatId: string, text: string): ProjectSelectionRoute | null {
const trimmed = text.trim()
// Explicit commands must win while a picker is pending; otherwise
// `/new express` is forwarded verbatim as the project query.
if (trimmed === '/new' || trimmed === '新会话' || trimmed.startsWith('/new ')) {
const query = trimmed.startsWith('/new ') ? trimmed.slice(5).trim() : ''
return { kind: 'new', query: query || undefined }
}
if (this.pendingChats.has(chatId) && trimmed && !NON_PROJECT_COMMANDS.has(trimmed)) {
return { kind: 'picker_reply', query: trimmed }
}
return null
}
}
export class ProjectSelectionController {
private readonly router = new ProjectSelectionRouter()
constructor(private readonly deps: ProjectSelectionControllerDeps) {}
async listProjects(chatId: string): Promise<RecentProject[]> {
const projects = await this.deps.httpClient.listRecentProjects()
if (projects.length > 0) this.router.markPickerShown(chatId)
return projects
}
clear(chatId: string): void {
this.router.clear(chatId)
}
async handleInput(chatId: string, text: string): Promise<ProjectSelectionOutcome | null> {
const route = this.router.route(chatId, text)
if (!route) return null
await this.deps.prepareNewSession(chatId)
this.router.clear(chatId)
if (!route.query) {
const created = await this.deps.createSession(chatId, this.deps.defaultWorkDir)
return created ? { kind: 'created' } : { kind: 'creation_failed' }
}
try {
const { project, ambiguous } = await this.deps.httpClient.matchProject(route.query)
if (project) {
const created = await this.deps.createSession(chatId, project.realPath)
return created ? { kind: 'created', project } : { kind: 'creation_failed' }
}
if (ambiguous) return { kind: 'ambiguous', projects: ambiguous }
return { kind: 'not_found', query: route.query }
} catch (err) {
return { kind: 'error', message: err instanceof Error ? err.message : String(err) }
}
}
}
export function formatAmbiguousProjectSelection(projects: RecentProject[]): string {
const choices = projects
.map((project, index) => `${index + 1}. **${project.projectName}** — ${project.realPath}`)
.join('\n')
return `匹配到多个项目,请发送 /new <更完整名称或路径> 选择:\n\n${choices}`
}
export function formatProjectSelectionOutcome(outcome: ProjectSelectionOutcome): string | null {
switch (outcome.kind) {
case 'created':
return outcome.project
? `✅ 已新建会话:**${outcome.project.projectName}**${outcome.project.branch ? ` (${outcome.project.branch})` : ''}`
: '✅ 已新建会话,可以开始对话了。'
case 'creation_failed':
return null
case 'ambiguous':
return formatAmbiguousProjectSelection(outcome.projects)
case 'not_found':
return `未找到匹配 "${outcome.query}" 的项目。发送 /projects 查看完整列表。`
case 'error':
return `❌ ${outcome.message}`
}
}
+22 -40
View File
@@ -11,7 +11,7 @@ import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-b
import { MessageDedup } from '../common/message-dedup.js'
import { MessageBuffer } from '../common/message-buffer.js'
import { enqueue } from '../common/chat-queue.js'
import { getConfiguredWorkDir, loadConfig } from '../common/config.js'
import { loadConfig } from '../common/config.js'
import { formatImHelp, formatImStatus, formatPermissionRequest, splitMessage } from '../common/format.js'
import {
formatPermissionDecisionStatus,
@@ -20,7 +20,12 @@ import {
type PermissionDecision,
} from '../common/permission.js'
import { SessionStore } from '../common/session-store.js'
import { AdapterHttpClient, type RecentProject } from '../common/http-client.js'
import { type RecentProject } from '../common/http-client.js'
import { createAdapterClient } from '../common/adapter-client.js'
import {
formatProjectSelectionOutcome,
ProjectSelectionController,
} from '../common/project-selection-router.js'
import { restoreStoredSessionBinding } from '../common/session-recovery.js'
import { isAllowedUser, tryPair } from '../common/pairing.js'
import { AttachmentStore } from '../common/attachment/attachment-store.js'
@@ -54,17 +59,21 @@ if (!config.dingtalk.clientId || !config.dingtalk.clientSecret) {
console.error('[DingTalk] Missing DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET. Bind with QR auth in Desktop Settings or set env.')
process.exit(1)
}
const defaultWorkDir = getConfiguredWorkDir(config, config.dingtalk)
const { httpClient, defaultWorkDir } = createAdapterClient(config, config.dingtalk)
const bridge = new WsBridge(config.serverUrl, 'dingtalk')
const dedup = new MessageDedup()
const sessionStore = new SessionStore()
const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] })
const attachmentStore = new AttachmentStore()
const media = new DingTalkMediaService(attachmentStore)
const aiCards = new DingTalkAiCardService(getAccessToken, config.dingtalk.clientId)
const sessionWebhooks = new Map<string, string>()
const pendingProjectSelection = new Map<string, boolean>()
const projectSelectionController = new ProjectSelectionController({
httpClient,
defaultWorkDir,
prepareNewSession,
createSession: createSessionForChat,
})
const runtimeStates = new Map<string, ChatRuntimeState>()
const aiCardBuffers = new Map<string, MessageBuffer>()
const aiCardTargets = new Map<string, DingTalkAiCardTarget>()
@@ -323,47 +332,22 @@ function formatProjectList(projects: RecentProject[]): string {
async function showProjectPicker(chatId: string): Promise<void> {
try {
const projects = await httpClient.listRecentProjects()
const projects = await projectSelectionController.listProjects(chatId)
if (projects.length === 0) {
await sendText(chatId, `没有找到最近的项目。发送 /new 会使用默认工作目录:${defaultWorkDir}\n也可以发送 /new /path/to/project 指定项目。`)
return
}
pendingProjectSelection.set(chatId, true)
await sendText(chatId, formatProjectList(projects))
} catch (err) {
await sendText(chatId, `❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`)
}
}
async function startNewSession(chatId: string, query?: string): Promise<void> {
function prepareNewSession(chatId: string): void {
bridge.resetSession(chatId)
sessionStore.delete(chatId)
clearTransientChatState(chatId)
pendingProjectSelection.delete(chatId)
runtimeStates.delete(chatId)
if (query) {
try {
const { project, ambiguous } = await httpClient.matchProject(query)
if (project) {
const ok = await createSessionForChat(chatId, project.realPath)
if (ok) await sendText(chatId, `✅ 已新建会话:**${project.projectName}**${project.branch ? ` (${project.branch})` : ''}`)
return
}
if (ambiguous) {
const list = ambiguous.map((project, index) => `${index + 1}. **${project.projectName}** — ${project.realPath}`).join('\n')
await sendText(chatId, `匹配到多个项目,请更精确:\n\n${list}`)
return
}
await sendText(chatId, `未找到匹配 "${query}" 的项目。发送 /projects 查看完整列表。`)
} catch (err) {
await sendText(chatId, `❌ ${err instanceof Error ? err.message : String(err)}`)
}
return
}
const ok = await createSessionForChat(chatId, defaultWorkDir)
if (ok) await sendText(chatId, '✅ 已新建会话,可以开始对话了。')
}
async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<void> {
@@ -485,14 +469,12 @@ async function routeUserMessage(chatId: string, text: string, attachments: Attac
if (!hasAttachments && handlePermissionCommand(chatId, trimmed)) return
if (!hasAttachments && pendingProjectSelection.has(chatId)) {
if (trimmed) await startNewSession(chatId, trimmed)
return
}
if (!hasAttachments && (trimmed === '/new' || trimmed === '新会话' || trimmed.startsWith('/new '))) {
const arg = trimmed.startsWith('/new ') ? trimmed.slice(5).trim() : ''
await startNewSession(chatId, arg || undefined)
const projectOutcome = !hasAttachments
? await projectSelectionController.handleInput(chatId, trimmed)
: null
if (projectOutcome) {
const response = formatProjectSelectionOutcome(projectOutcome)
if (response) await sendText(chatId, response)
return
}
if (!hasAttachments && (trimmed === '/help' || trimmed === '帮助')) {
+23 -51
View File
@@ -13,7 +13,7 @@ import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-b
import { MessageDedup } from '../common/message-dedup.js'
import { StreamingCard } from './streaming-card.js'
import { enqueue } from '../common/chat-queue.js'
import { getConfiguredWorkDir, loadConfig } from '../common/config.js'
import { loadConfig } from '../common/config.js'
import {
formatImHelp,
formatImStatus,
@@ -26,7 +26,12 @@ import {
type PermissionDecision,
} from '../common/permission.js'
import { SessionStore } from '../common/session-store.js'
import { AdapterHttpClient, type RecentProject } from '../common/http-client.js'
import { type RecentProject } from '../common/http-client.js'
import { createAdapterClient } from '../common/adapter-client.js'
import {
formatProjectSelectionOutcome,
ProjectSelectionController,
} from '../common/project-selection-router.js'
import { restoreStoredSessionBinding } from '../common/session-recovery.js'
import { isAllowedUser, tryPair } from '../common/pairing.js'
import { extractInboundPayload } from './extract-payload.js'
@@ -56,8 +61,7 @@ const larkClient = new Lark.Client({
const bridge = new WsBridge(config.serverUrl, 'feishu')
const dedup = new MessageDedup()
const sessionStore = new SessionStore()
const defaultWorkDir = getConfiguredWorkDir(config, config.feishu)
const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] })
const { httpClient, defaultWorkDir } = createAdapterClient(config, config.feishu)
// Attachment plumbing — shared by inbound (download) and outbound (upload) paths.
const attachmentStore = new AttachmentStore()
@@ -68,7 +72,12 @@ attachmentStore.gc().catch((err) => {
// One streaming card lifecycle per chatId (CardKit main + patch fallback).
const streamingCards = new Map<string, StreamingCard>()
const pendingProjectSelection = new Map<string, boolean>()
const projectSelectionController = new ProjectSelectionController({
httpClient,
defaultWorkDir,
prepareNewSession,
createSession: createSessionForChat,
})
const runtimeStates = new Map<string, ChatRuntimeState>()
const pendingPermissions = new Map<string, Set<string>>()
@@ -661,13 +670,12 @@ async function createSessionForChat(chatId: string, workDir: string): Promise<bo
async function showProjectPicker(chatId: string): Promise<void> {
try {
const projects = await httpClient.listRecentProjects()
const projects = await projectSelectionController.listProjects(chatId)
if (projects.length === 0) {
await sendText(chatId,
`没有找到最近的项目。发送 /new 会使用默认工作目录:${defaultWorkDir}\n也可以发送 /new /path/to/project 指定项目。`)
return
}
pendingProjectSelection.set(chatId, true)
const cardId = await sendCard(chatId, buildProjectPickerCard(projects))
if (!cardId) {
// Fallback to text picker if card delivery failed (permissions, etc.)
@@ -681,7 +689,7 @@ async function showProjectPicker(chatId: string): Promise<void> {
}
}
async function startNewSession(chatId: string, query?: string): Promise<void> {
function prepareNewSession(chatId: string): void {
bridge.resetSession(chatId)
sessionStore.delete(chatId)
// Abort any in-flight streaming card for the previous session
@@ -692,41 +700,8 @@ async function startNewSession(chatId: string, query?: string): Promise<void> {
}
imageWatchers.delete(chatId)
uploadedImageKeys.delete(chatId)
pendingProjectSelection.delete(chatId)
pendingPermissions.delete(chatId)
runtimeStates.delete(chatId)
if (query) {
try {
const { project, ambiguous } = await httpClient.matchProject(query)
if (project) {
const ok = await createSessionForChat(chatId, project.realPath)
if (ok) {
await sendText(chatId,
`✅ 已新建会话:**${project.projectName}**${project.branch ? ` (${project.branch})` : ''}`)
}
return
}
if (ambiguous) {
const list = ambiguous.map((p, i) => `${i + 1}. **${p.projectName}** — ${p.realPath}`).join('\n')
await sendText(chatId, `匹配到多个项目,请更精确:\n\n${list}`)
return
}
await sendText(chatId, `未找到匹配 "${query}" 的项目。发送 /projects 查看完整列表。`)
} catch (err) {
await sendText(chatId, `❌ ${err instanceof Error ? err.message : String(err)}`)
}
} else {
const workDir = defaultWorkDir
if (workDir) {
const ok = await createSessionForChat(chatId, workDir)
if (ok) {
await sendText(chatId, '✅ 已新建会话,可以开始对话了。')
}
} else {
await showProjectPicker(chatId)
}
}
}
// ---------- server message handler ----------
@@ -980,9 +955,12 @@ async function handleMessage(data: any): Promise<void> {
return
}
if (!hasAttachments && (msgText === '/new' || msgText === '新会话' || msgText.startsWith('/new '))) {
const arg = msgText.startsWith('/new ') ? msgText.slice(5).trim() : ''
await startNewSession(chatId, arg || undefined)
const projectOutcome = !hasAttachments
? await projectSelectionController.handleInput(chatId, msgText)
: null
if (projectOutcome) {
const response = formatProjectSelectionOutcome(projectOutcome)
if (response) await sendText(chatId, response)
return
}
if (!hasAttachments && (msgText === '/help' || msgText === '帮助')) {
@@ -1023,12 +1001,6 @@ async function handleMessage(data: any): Promise<void> {
return
}
// User is replying to a project picker prompt
if (!hasAttachments && pendingProjectSelection.has(chatId)) {
await startNewSession(chatId, msgText.trim())
return
}
// ----- Normal message flow (with optional inbound attachments) -----
const ready = await ensureSession(chatId)
@@ -1195,7 +1167,7 @@ async function handleCardAction(data: any): Promise<any> {
const projectName = event.action?.value?.projectName ?? realPath ?? '(unknown)'
if (!realPath) return
pendingProjectSelection.delete(chatId)
projectSelectionController.clear(chatId)
// createSessionForChat handles its own error messaging on failure
const ok = await createSessionForChat(chatId, realPath)
if (ok) {
+3 -4
View File
@@ -11,7 +11,7 @@ import { WsBridge, type ServerMessage } from '../common/ws-bridge.js'
import { MessageBuffer } from '../common/message-buffer.js'
import { MessageDedup } from '../common/message-dedup.js'
import { enqueue } from '../common/chat-queue.js'
import { getConfiguredWorkDir, loadConfig } from '../common/config.js'
import { loadConfig } from '../common/config.js'
import {
formatImStatus,
formatPermissionRequest,
@@ -31,7 +31,7 @@ import {
type PermissionDecision,
} from '../common/permission.js'
import { SessionStore } from '../common/session-store.js'
import { AdapterHttpClient } from '../common/http-client.js'
import { createAdapterClient } from '../common/adapter-client.js'
import { restoreStoredSessionBinding } from '../common/session-recovery.js'
import { isAllowedUser, tryPair } from '../common/pairing.js'
import { TelegramMediaService } from './media.js'
@@ -59,8 +59,7 @@ const bot = new Bot(config.telegram.botToken)
const bridge = new WsBridge(config.serverUrl, 'tg')
const dedup = new MessageDedup()
const sessionStore = new SessionStore()
const defaultWorkDir = getConfiguredWorkDir(config, config.telegram)
const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] })
const { httpClient, defaultWorkDir } = createAdapterClient(config, config.telegram)
const attachmentStore = new AttachmentStore()
const media = new TelegramMediaService(bot, attachmentStore)
attachmentStore.gc().catch((err) => {
+3 -4
View File
@@ -3,7 +3,7 @@ import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-b
import { MessageDedup } from '../common/message-dedup.js'
import { MessageBuffer } from '../common/message-buffer.js'
import { enqueue } from '../common/chat-queue.js'
import { getConfiguredWorkDir, loadConfig } from '../common/config.js'
import { loadConfig } from '../common/config.js'
import {
formatImHelp,
formatImStatus,
@@ -16,7 +16,7 @@ import {
parsePermissionCommand,
} from '../common/permission.js'
import { SessionStore } from '../common/session-store.js'
import { AdapterHttpClient } from '../common/http-client.js'
import { createAdapterClient } from '../common/adapter-client.js'
import { restoreStoredSessionBinding } from '../common/session-recovery.js'
import { isAllowedUser, tryPair } from '../common/pairing.js'
import { AttachmentStore } from '../common/attachment/attachment-store.js'
@@ -48,8 +48,7 @@ const botToken = config.wechat.botToken
const bridge = new WsBridge(config.serverUrl, 'wechat')
const dedup = new MessageDedup()
const sessionStore = new SessionStore()
const defaultWorkDir = getConfiguredWorkDir(config, config.wechat)
const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] })
const { httpClient, defaultWorkDir } = createAdapterClient(config, config.wechat)
const attachmentStore = new AttachmentStore()
const media = new WechatMediaService(attachmentStore)
const pendingProjectSelection = new Map<string, boolean>()
+3 -4
View File
@@ -13,7 +13,7 @@ import {
import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-bridge.js'
import { MessageDedup } from '../common/message-dedup.js'
import { enqueue } from '../common/chat-queue.js'
import { getConfiguredWorkDir, loadConfig } from '../common/config.js'
import { loadConfig } from '../common/config.js'
import {
formatImHelp,
formatImStatus,
@@ -26,7 +26,7 @@ import {
type PermissionDecision,
} from '../common/permission.js'
import { SessionStore } from '../common/session-store.js'
import { AdapterHttpClient } from '../common/http-client.js'
import { createAdapterClient } from '../common/adapter-client.js'
import { restoreStoredSessionBinding } from '../common/session-recovery.js'
import { isAllowedUser, tryPair } from '../common/pairing.js'
import { AttachmentStore } from '../common/attachment/attachment-store.js'
@@ -62,8 +62,7 @@ if (!hasWhatsAppAuth(authDir)) {
const bridge = new WsBridge(config.serverUrl, 'whatsapp')
const dedup = new MessageDedup()
const sessionStore = new SessionStore()
const defaultWorkDir = getConfiguredWorkDir(config, config.whatsapp)
const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] })
const { httpClient, defaultWorkDir } = createAdapterClient(config, config.whatsapp)
const attachmentStore = new AttachmentStore()
attachmentStore.gc().catch((err) => {
console.warn('[WhatsApp] AttachmentStore.gc failed:', err instanceof Error ? err.message : err)
+4
View File
@@ -18,6 +18,8 @@
"@opentelemetry/sdk-metrics": "^2.6.1",
"@opentelemetry/sdk-trace-base": "^2.6.1",
"@opentelemetry/semantic-conventions": "^1.40.0",
"acorn": "^8.16.0",
"acorn-walk": "^8.3.4",
"ajv": "^8.18.0",
"asciichart": "^1.5.25",
"auto-bind": "^5.0.1",
@@ -370,6 +372,8 @@
"acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-walk": ["acorn-walk@8.3.5", "https://registry.npmmirror.com/acorn-walk/-/acorn-walk-8.3.5.tgz", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="],
"agent-base": ["agent-base@8.0.0", "https://registry.npmmirror.com/agent-base/-/agent-base-8.0.0.tgz", {}, "sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg=="],
"ajv": ["ajv@8.18.0", "https://registry.npmmirror.com/ajv/-/ajv-8.18.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
+138 -20
View File
@@ -658,7 +658,10 @@ describe('Electron pet window service', () => {
},
}
function panelController(petWindow: ReturnType<typeof createFakeWindow>) {
function panelController(
petWindow: ReturnType<typeof createFakeWindow>,
platform: NodeJS.Platform = 'darwin',
) {
const onPanelPlacementChanged = vi.fn()
// No cursor sampler, so the drag follows the payload coordinates the way
// the other edge tests drive it.
@@ -668,7 +671,7 @@ describe('Electron pet window service', () => {
getWorkAreaForPoint: () => panelDrag.workArea,
load: vi.fn().mockResolvedValue(undefined),
onPanelPlacementChanged,
platform: 'darwin',
platform,
preloadPath: '/app/electron-dist/preload.cjs',
})
return { controller, onPanelPlacementChanged }
@@ -689,7 +692,7 @@ describe('Electron pet window service', () => {
above.mascot,
above.card,
above.toggle,
])).toEqual({ vertical: 'above' })
])).toEqual({ vertical: 'above', horizontal: 'center' })
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
const dragged = controller.dragWindow(petWindow as never, { phase: 'end', x: 150, y: -400 })
@@ -699,15 +702,18 @@ describe('Electron pet window service', () => {
expect(petWindow.getBounds().y).toBe(workArea.y - above.mascot.y)
// The panel would be behind the menu bar there, so it has to change sides.
expect(petWindow.getBounds().y + above.card.y).toBeLessThan(workArea.y)
expect(dragged).toEqual({ vertical: 'below' })
expect(onPanelPlacementChanged).toHaveBeenCalledWith(petWindow, { vertical: 'below' })
expect(dragged).toEqual({ vertical: 'below', horizontal: 'center' })
expect(onPanelPlacementChanged).toHaveBeenCalledWith(petWindow, {
vertical: 'below',
horizontal: 'center',
})
// The renderer re-lays out and reports the flipped boxes.
expect(controller.setInteractiveRegions(petWindow as never, [
below.mascot,
below.card,
below.toggle,
])).toEqual({ vertical: 'below' })
])).toEqual({ vertical: 'below', horizontal: 'center' })
// Flipping moved the mascot up inside the window, so the window has to drop
// by the same amount: the mascot stays on the menu bar rather than jumping
@@ -717,6 +723,78 @@ describe('Electron pet window service', () => {
expect(petWindow.getBounds().y + below.toggle.y).toBeGreaterThanOrEqual(workArea.y)
})
it.each([
{
edge: 'right',
pointerEndX: 2_000,
horizontal: 'left' as const,
shiftedMascotX: 260,
},
{
edge: 'left',
pointerEndX: -2_000,
horizontal: 'right' as const,
shiftedMascotX: 12,
},
])('keeps the Windows task panel visible at the $edge edge without moving the mascot', async ({
pointerEndX,
horizontal,
shiftedMascotX,
}) => {
const petWindow = createFakeWindow({
x: 100,
y: 120,
width: PET_WINDOW_WIDTH,
height: PET_WINDOW_HEIGHT,
})
const { controller, onPanelPlacementChanged } = panelController(petWindow, 'win32')
await controller.show()
const { above, workArea } = panelDrag
controller.setInteractiveRegions(petWindow as never, [
above.mascot,
above.card,
above.toggle,
])
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
const dragged = controller.dragWindow(petWindow as never, {
phase: 'end',
x: pointerEndX,
y: 180,
})
const mascotScreenX = petWindow.getBounds().x + above.mascot.x
expect(dragged).toEqual({ vertical: 'above', horizontal })
expect(onPanelPlacementChanged).toHaveBeenCalledWith(petWindow, {
vertical: 'above',
horizontal,
})
// The renderer keeps the card centred in the fixed window and moves the
// mascot to the outside. The host absorbs that internal shift by moving
// the whole window in the opposite direction.
const shiftedMascot = { ...above.mascot, x: shiftedMascotX }
expect(controller.setInteractiveRegions(petWindow as never, [
shiftedMascot,
above.card,
above.toggle,
])).toEqual({ vertical: 'above', horizontal })
const shiftedBounds = petWindow.getBounds()
expect(shiftedBounds.x + shiftedMascot.x).toBe(mascotScreenX)
expect(shiftedBounds.x + above.card.x).toBeGreaterThanOrEqual(workArea.x)
expect(shiftedBounds.x + above.card.x + above.card.width)
.toBeLessThanOrEqual(workArea.x + workArea.width)
onPanelPlacementChanged.mockClear()
controller.setInteractiveRegions(petWindow as never, [
shiftedMascot,
above.card,
above.toggle,
])
expect(onPanelPlacementChanged).not.toHaveBeenCalled()
})
it('holds the mascot still through a flip that happens short of the menu bar', async () => {
// The panel runs out of room before the mascot reaches the edge, so most
// flips happen mid-screen where the clamp has nothing to say. Without the
@@ -739,7 +817,7 @@ describe('Electron pet window service', () => {
const dragged = controller.dragWindow(petWindow as never, { phase: 'end', x: 150, y: -55 })
const mascotScreenY = petWindow.getBounds().y + above.mascot.y
expect(mascotScreenY).toBe(workArea.y + 100)
expect(dragged).toEqual({ vertical: 'below' })
expect(dragged).toEqual({ vertical: 'below', horizontal: 'center' })
controller.setInteractiveRegions(petWindow as never, [below.mascot, below.card, below.toggle])
@@ -778,7 +856,10 @@ describe('Electron pet window service', () => {
// an event instead.
cursor = { x: 150, y: -55 }
vi.advanceTimersByTime(16)
expect(onPanelPlacementChanged).toHaveBeenCalledWith(petWindow, { vertical: 'below' })
expect(onPanelPlacementChanged).toHaveBeenCalledWith(petWindow, {
vertical: 'below',
horizontal: 'center',
})
controller.setInteractiveRegions(petWindow as never, [below.mascot, below.card, below.toggle])
expect(petWindow.getBounds().y + below.mascot.y).toBe(workArea.y + 100)
@@ -816,7 +897,7 @@ describe('Electron pet window service', () => {
below.mascot,
below.card,
below.toggle,
])).toEqual({ vertical: 'below' })
])).toEqual({ vertical: 'below', horizontal: 'center' })
expect(petWindow.getBounds().y + below.mascot.y).toBe(panelDrag.workArea.y)
})
@@ -856,7 +937,44 @@ describe('Electron pet window service', () => {
expect(petWindow?.getBounds().y).toBe(workArea.y - above.mascot.y)
expect((petWindow?.getBounds().y ?? 0) + above.mascot.y).toBe(workArea.y)
// And it is still out of room up there, so it flips straight back.
expect(placement).toEqual({ vertical: 'below' })
expect(placement).toEqual({ vertical: 'below', horizontal: 'center' })
})
it('reopens a side-shifted pet where the mascot was, not where the window was', async () => {
const { above, workArea } = panelDrag
const shiftedMascot = { ...above.mascot, x: 260 }
const saved = {
x: workArea.x + workArea.width - shiftedMascot.x - shiftedMascot.width,
y: 120,
region: shiftedMascot,
}
let petWindow: ReturnType<typeof createFakeWindow> | undefined
const controller = new PetWindowController({
createWindow: vi.fn((bounds) => {
petWindow = createFakeWindow(
bounds as { x: number, y: number, width: number, height: number },
)
return petWindow
}) as never,
getCurrentWorkArea: () => workArea,
getWorkAreaForPoint: () => workArea,
load: vi.fn().mockResolvedValue(undefined),
platform: 'win32',
preloadPath: '/app/electron-dist/preload.cjs',
readPosition: () => saved,
})
await controller.show()
const placement = controller.setInteractiveRegions(petWindow as never, [
above.mascot,
above.card,
above.toggle,
])
const bounds = petWindow?.getBounds()
expect((bounds?.x ?? 0) + above.mascot.x)
.toBe(saved.x + shiftedMascot.x)
expect(placement).toEqual({ vertical: 'above', horizontal: 'left' })
})
it('leaves the panel above the mascot when only the mascot is reported', async () => {
@@ -874,7 +992,7 @@ describe('Electron pet window service', () => {
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
const dragged = controller.dragWindow(petWindow as never, { phase: 'end', x: 150, y: -400 })
expect(dragged).toEqual({ vertical: 'above' })
expect(dragged).toEqual({ vertical: 'above', horizontal: 'center' })
expect(onPanelPlacementChanged).not.toHaveBeenCalled()
expect(petWindow.getBounds().y).toBe(panelDrag.workArea.y - panelDrag.above.mascot.y)
})
@@ -883,7 +1001,7 @@ describe('Electron pet window service', () => {
const mascot = { x: 136, y: 240, width: 112, height: 128 }
const panel = { x: 16, y: 88, width: 352, height: 171 }
const workArea = { x: 0, y: 25, width: 800, height: 575 }
const above = { vertical: 'above' } as const
const above = { vertical: 'above', horizontal: 'center' } as const
// 335px of room, 183px needed.
expect(resolvePetPanelPlacement({
@@ -892,7 +1010,7 @@ describe('Electron pet window service', () => {
mascot,
panel,
previous: above,
})).toEqual({ vertical: 'above' })
})).toEqual({ vertical: 'above', horizontal: 'center' })
// Mascot on the menu bar: no room at all.
expect(resolvePetPanelPlacement({
@@ -901,7 +1019,7 @@ describe('Electron pet window service', () => {
mascot,
panel,
previous: above,
})).toEqual({ vertical: 'below' })
})).toEqual({ vertical: 'below', horizontal: 'center' })
// One pixel short still flips; exactly enough does not.
expect(resolvePetPanelPlacement({
@@ -910,14 +1028,14 @@ describe('Electron pet window service', () => {
mascot,
panel,
previous: above,
})).toEqual({ vertical: 'below' })
})).toEqual({ vertical: 'below', horizontal: 'center' })
expect(resolvePetPanelPlacement({
windowPosition: { x: 100, y: workArea.y - mascot.y + panel.height + 12 },
workArea,
mascot,
panel,
previous: above,
})).toEqual({ vertical: 'above' })
})).toEqual({ vertical: 'above', horizontal: 'center' })
// Coming back the other way costs extra, so a mascot parked on the boundary
// does not flutter.
@@ -926,15 +1044,15 @@ describe('Electron pet window service', () => {
workArea,
mascot,
panel,
previous: { vertical: 'below' },
})).toEqual({ vertical: 'below' })
previous: { vertical: 'below', horizontal: 'center' },
})).toEqual({ vertical: 'below', horizontal: 'center' })
expect(resolvePetPanelPlacement({
windowPosition: { x: 100, y: workArea.y - mascot.y + panel.height + 36 },
workArea,
mascot,
panel,
previous: { vertical: 'below' },
})).toEqual({ vertical: 'above' })
previous: { vertical: 'below', horizontal: 'center' },
})).toEqual({ vertical: 'above', horizontal: 'center' })
})
it('bounds the panel by everything hanging off the mascot, not just the card', () => {
+69 -21
View File
@@ -62,18 +62,18 @@ export type PetWindowDragPayload = PetWindowPosition & {
* window position and the work area, so it picks the side and the renderer
* follows.
*
* Left and right are deliberately not handled here. The panel is wider than the
* mascot by more than the padding that remains beside it, so sliding it back
* on-screen inside a fixed-size window just moves the clipping from the display
* edge to the window edge. Fixing those needs the window itself to grow or
* move, which is a different change.
* Horizontally the renderer moves the mascot, rather than the wider panel, to
* the outside of the fixed window. This process then moves the window in the
* opposite direction to keep the mascot still and bring the panel on-screen.
*/
export type PetPanelPlacement = {
vertical: 'above' | 'below'
horizontal: 'center' | 'left' | 'right'
}
export const PET_PANEL_DEFAULT_PLACEMENT: PetPanelPlacement = Object.freeze({
vertical: 'above',
horizontal: 'center',
})
/** Gap the renderer keeps between the panel and the mascot. */
@@ -86,6 +86,7 @@ const PET_PANEL_GAP = 12
* test would then find room again and flip straight back, once per frame.
*/
const PET_PANEL_FLIP_HYSTERESIS = 24
const PET_PANEL_HORIZONTAL_HYSTERESIS = 24
function isFiniteScreenCoordinate(value: unknown): value is number {
return typeof value === 'number'
@@ -271,7 +272,40 @@ export function resolvePetPanelPlacement({
const threshold = previous.vertical === 'above'
? required
: required + PET_PANEL_FLIP_HYSTERESIS
return { vertical: spaceAbove >= threshold ? 'above' : 'below' }
const vertical = spaceAbove >= threshold ? 'above' : 'below'
const workAreaRight = workArea.x + workArea.width
const mascotScreenLeft = windowPosition.x + mascot.x
const mascotScreenRight = mascotScreenLeft + mascot.width
const mascotScreenCenter = mascotScreenLeft + mascot.width / 2
const centeredPanelLeft = mascotScreenCenter - panel.width / 2
const centeredPanelRight = mascotScreenCenter + panel.width / 2
const centeredFits = centeredPanelLeft >= workArea.x
&& centeredPanelRight <= workAreaRight
let horizontal = previous.horizontal
if (previous.horizontal === 'center') {
if (centeredPanelLeft < workArea.x) horizontal = 'right'
else if (centeredPanelRight > workAreaRight) horizontal = 'left'
} else if (previous.horizontal === 'left') {
const leftAlignedFits = mascotScreenRight - panel.width >= workArea.x
const rightAlignedFits = mascotScreenLeft + panel.width <= workAreaRight
if (!leftAlignedFits && rightAlignedFits) horizontal = 'right'
else if (
centeredFits
&& centeredPanelRight <= workAreaRight - PET_PANEL_HORIZONTAL_HYSTERESIS
) horizontal = 'center'
} else {
const rightAlignedFits = mascotScreenLeft + panel.width <= workAreaRight
const leftAlignedFits = mascotScreenRight - panel.width >= workArea.x
if (!rightAlignedFits && leftAlignedFits) horizontal = 'left'
else if (
centeredFits
&& centeredPanelLeft >= workArea.x + PET_PANEL_HORIZONTAL_HYSTERESIS
) horizontal = 'center'
}
return { vertical, horizontal }
}
type PetWindowExtent = { width: number; height: number }
@@ -436,15 +470,17 @@ export class PetWindowController {
private panelBounds: Rectangle | null = null
private panelPlacement: PetPanelPlacement = PET_PANEL_DEFAULT_PLACEMENT
/**
* Screen y the mascot has to keep once the renderer reports its next layout.
* Screen position the mascot has to keep once the renderer reports its next
* layout.
*
* Set whenever the mascot is about to move inside the window while the user
* expects it to stay put on screen: a flip moves it by the panel's height, and
* a restart hands the renderer a saved position whose mascot offset belongs to
* whichever side the panel was on when it was saved. Both cases need the
* window to move the opposite way by the same amount.
* whichever side the panel was on when it was saved. Horizontal placement
* moves the mascot inside the window too. Every case needs the window to move
* the opposite way by the same amount.
*/
private pendingMascotAnchorScreenY: number | null = null
private pendingMascotAnchorScreen: Point | null = null
private pendingRestoredPosition: PetWindowState | null = null
private readonly options: PetWindowControllerOptions
@@ -462,7 +498,10 @@ export class PetWindowController {
// otherwise drop the mascot by the panel's height on the next launch.
// Restoring where the *mascot* was survives that, and a resized mascot too.
if (restoredPosition?.region) {
this.pendingMascotAnchorScreenY = restoredPosition.y + restoredPosition.region.y
this.pendingMascotAnchorScreen = {
x: restoredPosition.x + restoredPosition.region.x,
y: restoredPosition.y + restoredPosition.region.y,
}
}
const currentWorkArea = restoredPosition && this.options.getWorkAreaForPoint
? this.options.getWorkAreaForPoint(petWindowAnchor(restoredPosition))
@@ -544,7 +583,7 @@ export class PetWindowController {
this.visibleDragRegion = null
this.panelBounds = null
this.panelPlacement = PET_PANEL_DEFAULT_PLACEMENT
this.pendingMascotAnchorScreenY = null
this.pendingMascotAnchorScreen = null
}
owns(window: PetWindow | null): boolean {
@@ -614,25 +653,28 @@ export class PetWindowController {
* Rebases the window on the screen position the mascot has to keep.
*
* The reported region is the first news of where the mascot actually sits
* inside the window, so this is the point where a flip or a restore can be
* turned into a window move that leaves the mascot where the user last saw it.
* inside the window, so this is the point where a placement change or restore
* becomes a window move that leaves the mascot where the user last saw it.
*/
private holdMascotAnchor(
mascot: Rectangle,
requestedPosition: PetWindowPosition,
): PetWindowPosition {
const anchorScreenY = this.pendingMascotAnchorScreenY
if (anchorScreenY === null) return requestedPosition
this.pendingMascotAnchorScreenY = null
const anchorScreen = this.pendingMascotAnchorScreen
if (anchorScreen === null) return requestedPosition
this.pendingMascotAnchorScreen = null
const compensated = { x: requestedPosition.x, y: anchorScreenY - mascot.y }
const compensated = {
x: anchorScreen.x - mascot.x,
y: anchorScreen.y - mascot.y,
}
const drag = this.drag
if (drag) {
// A drag maps pointer travel from a fixed window origin, so the origin has
// to absorb the flip too — otherwise the next tick recomputes the pre-flip
// position and drags the mascot straight back.
drag.windowStart = {
...drag.windowStart,
x: drag.windowStart.x + compensated.x - requestedPosition.x,
y: drag.windowStart.y + compensated.y - requestedPosition.y,
}
}
@@ -653,9 +695,15 @@ export class PetWindowController {
panel: this.panelBounds,
previous,
})
if (next.vertical === previous.vertical) return
if (
next.vertical === previous.vertical
&& next.horizontal === previous.horizontal
) return
this.pendingMascotAnchorScreenY = windowPosition.y + mascot.y
this.pendingMascotAnchorScreen = {
x: windowPosition.x + mascot.x,
y: windowPosition.y + mascot.y,
}
this.panelPlacement = next
this.options.onPanelPlacementChanged?.(window, next)
}
+125 -2
View File
@@ -44,7 +44,9 @@ function createRuntime(options: {
appRoot?: string
diagnosticsFile?: string
env?: NodeJS.ProcessEnv
now?: () => number
resolveSystemProxy?: (url: string) => Promise<string>
sleep?: (delayMs: number) => Promise<void>
proxyBridge?: SystemProxyBridgeLike
} = {}) {
return new ElectronServerRuntime({
@@ -55,8 +57,10 @@ function createRuntime(options: {
resolveSystemProxy: options.resolveSystemProxy,
deps: {
appendHostDiagnostic: sidecarMocks.appendHostDiagnostic,
...(options.now ? { now: options.now } : {}),
preferredServerPorts: () => [],
reserveServerPort: async () => sidecarMocks.nextPort++,
...(options.sleep ? { sleep: options.sleep } : {}),
spawnSidecar: sidecarMocks.spawnSidecar,
waitForServer: async () => await sidecarMocks.waitForServerImpl(),
writeLastServerPort: () => undefined,
@@ -74,6 +78,13 @@ async function waitForServerChildren(count: number): Promise<void> {
expect(sidecarMocks.serverChildren).toHaveLength(count)
}
async function waitForMockCalls(mock: ReturnType<typeof vi.fn>, count: number): Promise<void> {
for (let attempt = 0; attempt < 20 && mock.mock.calls.length !== count; attempt++) {
await new Promise(resolve => setTimeout(resolve, 0))
}
expect(mock).toHaveBeenCalledTimes(count)
}
describe('ElectronServerRuntime', () => {
beforeEach(() => {
isolatedConfigDir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-runtime-'))
@@ -309,17 +320,127 @@ describe('ElectronServerRuntime', () => {
expect(sidecarMocks.serverChildren[0]!.kill).toHaveBeenCalledTimes(1)
})
it('stops active adapters immediately when the server exits without restart demand', async () => {
it('stops active adapters and waits for the replacement server to become healthy', async () => {
const runtime = createRuntime()
await runtime.startServer()
const activeAdapters = [...sidecarMocks.adapterChildren]
let releaseReplacementHealth!: () => void
sidecarMocks.waitForServerImpl = () => new Promise<void>(resolve => {
releaseReplacementHealth = resolve
})
sidecarMocks.serverChildren[0]!.emit('exit', 19, null)
await waitForServerChildren(2)
for (const adapter of activeAdapters) {
expect(adapter.kill).toHaveBeenCalledTimes(1)
}
expect(sidecarMocks.serverChildren).toHaveLength(1)
let recoveredUrl: string | null = null
const recovery = runtime.getServerUrl().then((url) => {
recoveredUrl = url
})
await Promise.resolve()
expect(recoveredUrl).toBeNull()
releaseReplacementHealth()
await recovery
expect(recoveredUrl).toBe('http://127.0.0.1:49322')
expect(sidecarMocks.adapterChildren).toHaveLength(10)
})
it('keeps demand recovery available after an immediate restart fails transiently', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const runtime = createRuntime()
await runtime.startServer()
let replacementAttempts = 0
sidecarMocks.waitForServerImpl = () => {
replacementAttempts += 1
return replacementAttempts === 1
? Promise.reject(new Error('port release race'))
: Promise.resolve()
}
sidecarMocks.serverChildren[0]!.emit('exit', 24, null)
await waitForServerChildren(2)
await waitForMockCalls(sidecarMocks.serverChildren[1]!.kill, 1)
await expect(runtime.getServerUrl()).resolves.toBe('http://127.0.0.1:49323')
expect(sidecarMocks.serverChildren).toHaveLength(3)
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('failed to restart server sidecar after exit'),
)
})
it('opens a circuit after three consecutive automatic restarts', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
let now = 0
const restartDelays: number[] = []
const runtime = createRuntime({
now: () => now,
sleep: async (delayMs) => {
restartDelays.push(delayMs)
now += delayMs
},
})
await runtime.startServer()
for (let crash = 0; crash < 3; crash++) {
sidecarMocks.serverChildren[crash]!.emit('exit', 30 + crash, null)
await waitForServerChildren(crash + 2)
}
sidecarMocks.serverChildren[3]!.emit('exit', 33, null)
await new Promise(resolve => setTimeout(resolve, 0))
expect(sidecarMocks.serverChildren).toHaveLength(4)
await expect(runtime.getServerUrl()).rejects.toThrow('automatic restart paused')
expect(restartDelays).toEqual([250, 1_000])
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('automatic restart paused after 3 consecutive crashes'),
)
now += 60_000
await expect(runtime.getServerUrl()).resolves.toBe('http://127.0.0.1:49325')
expect(sidecarMocks.serverChildren).toHaveLength(5)
})
it('resets the automatic restart budget after a stable server window', async () => {
let now = 0
const restartDelays: number[] = []
const runtime = createRuntime({
now: () => now,
sleep: async (delayMs) => {
restartDelays.push(delayMs)
},
})
await runtime.startServer()
sidecarMocks.serverChildren[0]!.emit('exit', 40, null)
await waitForServerChildren(2)
now = 60_000
sidecarMocks.serverChildren[1]!.emit('exit', 41, null)
await waitForServerChildren(3)
expect(restartDelays).toEqual([])
})
it('cancels a delayed automatic restart when the runtime stops', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
let releaseBackoff!: () => void
const sleep = vi.fn(() => new Promise<void>(resolve => {
releaseBackoff = resolve
}))
const runtime = createRuntime({ now: () => 0, sleep })
await runtime.startServer()
sidecarMocks.serverChildren[0]!.emit('exit', 42, null)
await waitForServerChildren(2)
sidecarMocks.serverChildren[1]!.emit('exit', 43, null)
await waitForMockCalls(sleep, 1)
runtime.stopAll()
releaseBackoff()
await new Promise(resolve => setTimeout(resolve, 0))
expect(sidecarMocks.serverChildren).toHaveLength(2)
})
it('stops active adapters immediately when the server emits a process error', async () => {
@@ -328,6 +449,7 @@ describe('ElectronServerRuntime', () => {
const activeAdapters = [...sidecarMocks.adapterChildren]
sidecarMocks.serverChildren[0]!.emit('error', new Error('active server failed'))
await waitForServerChildren(2)
for (const adapter of activeAdapters) {
expect(adapter.kill).toHaveBeenCalledTimes(1)
@@ -358,6 +480,7 @@ describe('ElectronServerRuntime', () => {
await runtime.restartAdaptersSidecars()
const restartedAdapters = sidecarMocks.adapterChildren.slice(5)
sidecarMocks.serverChildren[0]!.emit('exit', 22, null)
await waitForServerChildren(2)
for (const adapter of firstAdapters) {
expect(adapter.kill).toHaveBeenCalledTimes(1)
+77 -2
View File
@@ -43,8 +43,10 @@ type ServerRuntimeOptions = {
type ServerRuntimeDeps = {
appendHostDiagnostic: typeof appendHostDiagnostic
now: () => number
preferredServerPorts: typeof preferredServerPorts
reserveServerPort: typeof reserveServerPort
sleep: (delayMs: number) => Promise<void>
spawnSidecar: typeof spawnSidecar
waitForServer: typeof waitForServer
writeLastServerPort: typeof writeLastServerPort
@@ -53,14 +55,21 @@ type ServerRuntimeDeps = {
const DEFAULT_SERVER_RUNTIME_DEPS: ServerRuntimeDeps = {
appendHostDiagnostic,
now: Date.now,
preferredServerPorts,
reserveServerPort,
sleep: delayMs => new Promise(resolve => setTimeout(resolve, delayMs)),
spawnSidecar,
waitForServer,
writeLastServerPort,
createSystemProxyBridge: resolveSystemProxy => new SystemProxyBridge(resolveSystemProxy),
}
const AUTOMATIC_RESTART_LIMIT = 3
const AUTOMATIC_RESTART_STABLE_MS = 60_000
const AUTOMATIC_RESTART_COOLDOWN_MS = 60_000
const AUTOMATIC_RESTART_BACKOFF_MS = [0, 250, 1_000] as const
type ServerStartState = {
child: SidecarChild
adapterChildren: SidecarChild[]
@@ -74,6 +83,7 @@ type ActiveServer = {
url: string
child: SidecarChild
adapterChildren: SidecarChild[]
startedAt: number
}
function createServerStartState(child: SidecarChild): ServerStartState {
@@ -114,6 +124,9 @@ export class ElectronServerRuntime {
private adapters: SidecarChild[] = []
private startupError: string | null = null
private restartAfterExit = false
private automaticRestartAttempts = 0
private restartBlockedUntil = 0
private restartNotBefore = 0
private startPromise: Promise<string> | null = null
private lifecycleGeneration = 0
private startingServer: ServerStartState | null = null
@@ -132,10 +145,12 @@ export class ElectronServerRuntime {
async startServer(): Promise<string> {
if (this.server) return this.server.url
if (this.startPromise) return this.startPromise
this.assertRestartCircuitAllowsStart()
this.restartAfterExit = false
const generation = this.lifecycleGeneration
this.startPromise = this.startServerOnce(generation)
const restartDelayMs = Math.max(0, this.restartNotBefore - this.deps.now())
this.startPromise = this.startServerAfterDelay(generation, restartDelayMs)
try {
return await this.startPromise
} finally {
@@ -146,6 +161,7 @@ export class ElectronServerRuntime {
async getServerUrl(): Promise<string> {
if (this.server) return this.server.url
if (this.startPromise) return await this.startServer()
this.assertRestartCircuitAllowsStart()
if (this.startupError && !this.restartAfterExit) throw new Error(this.startupError)
return await this.startServer()
}
@@ -182,6 +198,7 @@ export class ElectronServerRuntime {
stopAll(sync = false) {
++this.lifecycleGeneration
this.restartNotBefore = 0
const starting = this.startingServer
if (starting) {
this.startingServer = null
@@ -201,6 +218,12 @@ export class ElectronServerRuntime {
this.stopSystemProxyBridge()
}
private async startServerAfterDelay(generation: number, delayMs: number): Promise<string> {
if (delayMs > 0) await this.deps.sleep(delayMs)
this.assertCurrentGeneration(generation)
return await this.startServerOnce(generation)
}
private async startServerOnce(generation: number): Promise<string> {
// Prefer the configured fixed port, then the previous run's port, so
// phone bookmarks / QR codes / reverse proxies survive restarts (#767).
@@ -238,7 +261,12 @@ export class ElectronServerRuntime {
])
if (startState.failure) throw startState.failure
this.deps.writeLastServerPort(port, this.baseEnv)
this.server = { url, child, adapterChildren: startState.adapterChildren }
this.server = {
url,
child,
adapterChildren: startState.adapterChildren,
startedAt: this.deps.now(),
}
const activeServer = this.server
this.startupError = null
this.stopAdaptersSidecars()
@@ -418,6 +446,7 @@ export class ElectronServerRuntime {
const active = this.server?.child === child
const starting = this.startingServer?.child === child
if (!active && !starting) return
const failedServer = active ? this.server : null
if (active) {
const adapterChildren = this.server!.adapterChildren
this.server = null
@@ -426,6 +455,52 @@ export class ElectronServerRuntime {
this.restartAfterExit = true
this.startupError = formatStartupError(message, logs)
if (starting) this.startingServer?.fail(new Error(message))
if (failedServer && !starting) {
const now = this.deps.now()
if (now - failedServer.startedAt >= AUTOMATIC_RESTART_STABLE_MS) {
this.automaticRestartAttempts = 0
}
if (this.automaticRestartAttempts >= AUTOMATIC_RESTART_LIMIT) {
this.openAutomaticRestartCircuit(message, logs, now)
return
}
const attempt = ++this.automaticRestartAttempts
const backoffMs = AUTOMATIC_RESTART_BACKOFF_MS[attempt - 1] ?? 0
this.restartNotBefore = now + backoffMs
const restartGeneration = this.lifecycleGeneration
void this.startServer().catch((error) => {
if (this.lifecycleGeneration === restartGeneration) {
// Keep a later renderer recovery request eligible to retry if this
// immediate restart lost a port-release race or failed transiently.
this.restartAfterExit = true
}
const detail = sanitizeHostDiagnostic(error instanceof Error ? error.message : String(error))
console.error(`[desktop] failed to restart server sidecar after exit: ${detail}`)
})
}
}
private openAutomaticRestartCircuit(message: string, logs: string[], now: number) {
this.restartAfterExit = false
this.restartNotBefore = 0
this.restartBlockedUntil = now + AUTOMATIC_RESTART_COOLDOWN_MS
const circuitMessage = `automatic restart paused after ${AUTOMATIC_RESTART_LIMIT} consecutive crashes; retry in ${AUTOMATIC_RESTART_COOLDOWN_MS / 1_000} seconds`
this.startupError = formatStartupError(`${message}; ${circuitMessage}`, logs)
this.deps.appendHostDiagnostic(
this.diagnosticsFile,
`[claude-server] [restart-circuit-open] ${circuitMessage}`,
)
console.error(`[desktop] ${circuitMessage}`)
}
private assertRestartCircuitAllowsStart() {
if (this.restartBlockedUntil === 0) return
if (this.deps.now() < this.restartBlockedUntil) {
throw new Error(this.startupError ?? 'automatic restart paused')
}
this.restartBlockedUntil = 0
this.automaticRestartAttempts = 0
this.restartAfterExit = true
}
private stopAdapterChildren(children: SidecarChild[], sync = false) {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "claude-code-desktop",
"private": true,
"version": "0.5.3",
"version": "0.5.5",
"description": "Desktop coding agent workbench for Claude Code Haha.",
"homepage": "https://github.com/NanmiCoder/cc-haha",
"author": {
+105 -7
View File
@@ -10,6 +10,7 @@ import type { ProviderModelsResult, SavedProvider } from '../types/provider'
import type { ProviderPreset } from '../types/providerPreset'
import type { AppMode, ChatSendBehavior, PermissionMode, ThemeMode, UpdateProxySettings } from '../types/settings'
import { browserHost } from '../lib/desktopHost/browserHost'
import { settingsApi } from '../api/settings'
const MOCK_DELETE_PROVIDER = vi.fn()
const MOCK_GET_SETTINGS = vi.fn()
@@ -236,14 +237,25 @@ describe('Settings > General tab', () => {
useSettingsStore.setState({
locale: 'en',
permissionMode: 'default',
currentModel: {
id: 'claude-opus-4-8',
name: 'Opus 4.8',
description: 'Highest capability for long-running tasks',
context: '1m',
defaultReasoningEffort: 'high',
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
},
effortLevel: 'max',
autoModeOptInAccepted: false,
thinkingEnabled: true,
workflowKeywordTriggerEnabled: true,
autoDreamEnabled: false,
skipWebFetchPreflight: true,
desktopNotificationsEnabled: true,
traceCapture: { enabled: true, storageDir: '/Users/test/.claude/cc-haha/traces' },
chatSendBehavior: 'enter',
responseLanguage: '',
proxyManagedSettingsWarning: false,
uiZoom: 1,
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
network: {
@@ -281,6 +293,9 @@ describe('Settings > General tab', () => {
setThinkingEnabled: vi.fn().mockImplementation(async (enabled: boolean) => {
useSettingsStore.setState({ thinkingEnabled: enabled })
}),
setEffort: vi.fn().mockImplementation(async (effortLevel) => {
useSettingsStore.setState({ effortLevel })
}),
setAutoDreamEnabled: vi.fn().mockImplementation(async (enabled: boolean) => {
useSettingsStore.setState({ autoDreamEnabled: enabled })
}),
@@ -897,6 +912,68 @@ describe('Settings > General tab', () => {
expect(useSettingsStore.getState().setThinkingEnabled).toHaveBeenCalledWith(false)
})
it('sets the new-session effort through the current model capability profile', async () => {
useSettingsStore.setState({
currentModel: {
id: 'claude-sonnet-4-6',
name: 'Sonnet 4.6',
description: 'Balanced Claude model',
context: '1m',
defaultReasoningEffort: 'medium',
supportedReasoningEfforts: ['low', 'medium', 'high'],
},
// A default from another provider must not create a stop that this
// model cannot actually use. The visible value falls back to the
// model default until the user selects a supported level.
effortLevel: 'max',
})
render(<Settings />)
fireEvent.click(screen.getByText('General'))
const trigger = screen.getByRole('button', { name: 'Default reasoning effort: Medium' })
fireEvent.click(trigger)
const slider = screen.getByRole('slider', { name: 'Default reasoning effort' })
expect(slider).toHaveAttribute('aria-valuemax', '2')
expect(slider).toHaveAttribute('aria-valuenow', '1')
expect(screen.getAllByTestId('reasoning-effort-stop')).toHaveLength(3)
fireEvent.keyDown(slider, { key: 'ArrowRight' })
await waitFor(() => {
expect(useSettingsStore.getState().setEffort).toHaveBeenCalledWith('high')
})
expect(useSettingsStore.getState().effortLevel).toBe('high')
})
it('lets the user disable and restore the Ultracode keyword trigger', async () => {
const updateUser = vi.spyOn(settingsApi, 'updateUser').mockResolvedValue({ ok: true })
try {
render(<Settings />)
fireEvent.click(screen.getByText('General'))
const toggle = screen.getByRole('switch', { name: 'Enable Ultracode keyword trigger' })
expect(toggle).toBeChecked()
await act(async () => {
fireEvent.click(toggle)
})
expect(toggle).not.toBeChecked()
expect(updateUser).toHaveBeenLastCalledWith({ workflowKeywordTriggerEnabled: false })
await act(async () => {
fireEvent.click(toggle)
})
expect(toggle).toBeChecked()
expect(updateUser).toHaveBeenLastCalledWith({ workflowKeywordTriggerEnabled: true })
} finally {
updateUser.mockRestore()
}
})
it('lets the user choose a default permission mode for new sessions', async () => {
render(<Settings />)
@@ -1681,6 +1758,17 @@ describe('Settings > General tab', () => {
expect(screen.getByText('MCP')).toBeInTheDocument()
expect(screen.getByText('Plugins')).toBeInTheDocument()
})
it('warns when the user settings contain only a proxy-managed placeholder', async () => {
useSettingsStore.setState({ proxyManagedSettingsWarning: true })
render(<Settings />)
fireEvent.click(screen.getByRole('button', { name: 'General' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'Your user settings contain only a PROXY_MANAGED proxy placeholder',
)
})
})
describe('Settings > Providers tab', () => {
@@ -2226,7 +2314,7 @@ describe('Settings > Providers tab', () => {
})
})
it('defaults Tool Search on and persists an explicit disable from the provider form', async () => {
it('defaults Tool Search off and requires confirmation before persisting an explicit enable', async () => {
MOCK_GET_SETTINGS.mockResolvedValue({ env: { EXISTING_ENV: '1' } })
providerStoreState.createProvider = vi.fn().mockResolvedValue({
id: 'provider-new',
@@ -2235,7 +2323,7 @@ describe('Settings > Providers tab', () => {
apiKey: 'sk-test',
baseUrl: 'https://api.example.com/anthropic',
apiFormat: 'anthropic',
toolSearchEnabled: false,
toolSearchEnabled: true,
models: {
main: 'custom-main',
haiku: 'custom-main',
@@ -2266,18 +2354,25 @@ describe('Settings > Providers tab', () => {
const dialog = screen.getByRole('dialog')
const toolSearchCheckbox = within(dialog).getByRole('checkbox', { name: 'Enable Tool Search' })
expect(toolSearchCheckbox).toBeChecked()
expect(toolSearchCheckbox).not.toBeChecked()
await waitFor(() => {
expect(within(dialog).getByDisplayValue((value) => (
typeof value === 'string' && value.includes('"ENABLE_TOOL_SEARCH": "true"')
typeof value === 'string' && value.includes('"ENABLE_TOOL_SEARCH": "false"')
))).toBeInTheDocument()
})
fireEvent.click(toolSearchCheckbox)
expect(toolSearchCheckbox).not.toBeChecked()
const confirmDialog = screen.getByRole('dialog', { name: 'Enable Tool Search?' })
expect(within(confirmDialog).getByText(/final LLM upstream or gateway explicitly supports/)).toBeInTheDocument()
expect(within(confirmDialog).getByText(/may return HTTP 400/)).toBeInTheDocument()
fireEvent.click(within(confirmDialog).getByRole('button', { name: 'Enable anyway' }))
expect(toolSearchCheckbox).toBeChecked()
await waitFor(() => {
expect(within(dialog).getByDisplayValue((value) => (
typeof value === 'string' && value.includes('"ENABLE_TOOL_SEARCH": "false"')
typeof value === 'string' && value.includes('"ENABLE_TOOL_SEARCH": "true"')
))).toBeInTheDocument()
})
@@ -2286,13 +2381,13 @@ describe('Settings > Providers tab', () => {
await waitFor(() => {
expect(providerStoreState.createProvider).toHaveBeenCalledWith(expect.objectContaining({
toolSearchEnabled: false,
toolSearchEnabled: true,
}))
})
expect(MOCK_UPDATE_SETTINGS).toHaveBeenCalledWith(expect.objectContaining({
env: expect.objectContaining({
EXISTING_ENV: '1',
ENABLE_TOOL_SEARCH: 'false',
ENABLE_TOOL_SEARCH: 'true',
}),
}))
})
@@ -2336,6 +2431,9 @@ describe('Settings > Providers tab', () => {
fireEvent.click(screen.getByRole('button', { name: /Add Provider/i }))
const dialog = screen.getByRole('dialog')
const disableBetasCheckbox = within(dialog).getByRole('checkbox', { name: 'Disable experimental beta headers' })
expect(within(dialog).getByText(
/GPT and o-series models still receive the reasoning effort selected for the Session/i,
)).toBeInTheDocument()
const settingsTextarea = await waitFor(() => {
const textarea = dialog.querySelector('textarea')
expect(textarea?.value).toContain('"ANTHROPIC_MODEL"')
@@ -0,0 +1,277 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
const server = vi.hoisted(() => ({
create: vi.fn(),
list: vi.fn(),
getGitInfo: vi.fn(),
getMessages: vi.fn(),
getSlashCommands: vi.fn(),
getInspection: vi.fn(),
getRepositoryContext: vi.fn(),
getRecentProjects: vi.fn(),
rows: [] as Array<Record<string, unknown>>,
materialized: false,
}))
vi.mock('../api/sessions', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/sessions')>()
return {
...actual,
sessionsApi: {
...actual.sessionsApi,
create: server.create,
list: server.list,
getGitInfo: server.getGitInfo,
getMessages: server.getMessages,
getSlashCommands: server.getSlashCommands,
getInspection: server.getInspection,
getRepositoryContext: server.getRepositoryContext,
getRecentProjects: server.getRecentProjects,
},
}
})
vi.mock('../api/agents', () => ({
agentsApi: { list: vi.fn(async () => ({ activeAgents: [], allAgents: [] })) },
}))
vi.mock('../api/skills', () => ({
skillsApi: { list: vi.fn(async () => ({ skills: [] })) },
}))
vi.mock('../api/providers', () => ({
providersApi: { list: vi.fn(async () => ({ providers: [], activeId: null })) },
}))
vi.mock('../api/mcp', () => ({
mcpApi: { list: vi.fn(async () => ({ servers: [] })), status: vi.fn() },
}))
vi.mock('../api/teams', () => ({
teamsApi: {
list: vi.fn(async () => ({ teams: [] })),
getWorkbenchForSession: vi.fn(async () => null),
},
}))
vi.mock('../api/cliTasks', () => ({
cliTasksApi: {
getTasksForList: vi.fn(async () => ({ tasks: [] })),
resetTaskList: vi.fn(async () => ({ ok: true })),
},
}))
vi.mock('../api/websocket', () => ({
wsManager: {
connect: vi.fn(),
disconnect: vi.fn(),
clearHandlers: vi.fn(),
onConnectionState: vi.fn((_sessionId: string, handler: (state: string) => void) => {
handler('connected')
return () => {}
}),
onMessage: vi.fn(() => () => {}),
send: vi.fn(),
},
}))
vi.mock('../hooks/useMobileViewport', () => ({
useMobileViewport: () => false,
}))
vi.mock('../components/chat/MessageList', () => ({
MessageList: () => <div data-testid="message-list" />,
}))
vi.mock('../components/controls/PermissionModeSelector', () => ({
PermissionModeSelector: () => <button type="button">Permissions</button>,
}))
vi.mock('../components/controls/ModelSelector', async () => {
const React = await vi.importActual<typeof import('react')>('react')
return {
ModelSelector: React.forwardRef(() => <button type="button">Model</button>),
}
})
import { ActiveSession } from '../pages/ActiveSession'
import { useChatStore } from '../stores/chatStore'
import { useSessionStore } from '../stores/sessionStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useTabStore } from '../stores/tabStore'
import { useWorkspaceChatContextStore } from '../stores/workspaceChatContextStore'
const WORKTREE_SESSION_ID = 'worktree-lifecycle-session'
const REGULAR_SESSION_ID = 'regular-lifecycle-session'
const SOURCE_WORK_DIR = '/tmp/express'
const PLANNED_WORKTREE_PATH = '/tmp/express/.claude/worktrees/desktop-main-planned'
const ACTUAL_WORKTREE_CWD = '/private/tmp/express/.claude/worktrees/desktop-main-planned'
const REGULAR_WORK_DIR = '/tmp/plain-project'
function sessionRow(id: string, workDir: string, messageCount = 0) {
return {
id,
title: id === WORKTREE_SESSION_ID ? 'Worktree Session' : 'Regular Session',
createdAt: '2026-08-10T00:00:00.000Z',
modifiedAt: '2026-08-10T00:00:00.000Z',
messageCount,
projectPath: workDir,
projectRoot: workDir,
workDir,
workDirExists: true,
workspaceState: 'available',
}
}
async function finishTurn(sessionId: string, text: string) {
await act(async () => {
const chat = useChatStore.getState()
chat.handleServerMessage(sessionId, { type: 'status', state: 'thinking' })
chat.handleServerMessage(sessionId, { type: 'content_start', blockType: 'text' })
chat.handleServerMessage(sessionId, { type: 'content_delta', text })
chat.handleServerMessage(sessionId, {
type: 'message_complete',
usage: { input_tokens: 1, output_tokens: 1 },
})
await Promise.resolve()
})
}
async function expectTooltipPath(trigger: HTMLElement, path: string) {
fireEvent.focus(trigger)
const tooltip = await screen.findByRole('tooltip')
expect(tooltip).toHaveTextContent(path)
fireEvent.blur(trigger)
await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument())
}
describe('worktree metadata across the live session lifecycle', () => {
const initialChatState = useChatStore.getInitialState()
const initialSessionState = useSessionStore.getInitialState()
const initialTabState = useTabStore.getInitialState()
const initialWorkspaceContextState = useWorkspaceChatContextStore.getInitialState()
beforeEach(() => {
vi.clearAllMocks()
server.rows = []
server.materialized = false
useSettingsStore.setState({ locale: 'en', permissionMode: 'default' })
useChatStore.setState(initialChatState, true)
useSessionStore.setState(initialSessionState, true)
useTabStore.setState(initialTabState, true)
useWorkspaceChatContextStore.setState(initialWorkspaceContextState, true)
server.create.mockImplementation(async (input: {
workDir?: string
repository?: { worktree?: boolean }
}) => {
const isolated = input.repository?.worktree === true
const id = isolated ? WORKTREE_SESSION_ID : REGULAR_SESSION_ID
const workDir = input.workDir || REGULAR_WORK_DIR
server.rows = [
sessionRow(id, workDir),
...server.rows.filter((row) => row.id !== id),
]
return { sessionId: id, workDir }
})
server.list.mockImplementation(async () => ({
sessions: server.rows,
total: server.rows.length,
}))
server.getGitInfo.mockImplementation(async (sessionId: string) => {
if (sessionId === REGULAR_SESSION_ID) {
return {
branch: 'main',
repoName: 'plain-project',
workDir: REGULAR_WORK_DIR,
changedFiles: 0,
worktree: null,
}
}
return {
branch: 'main',
repoName: 'express',
workDir: server.materialized ? ACTUAL_WORKTREE_CWD : SOURCE_WORK_DIR,
changedFiles: 0,
worktree: {
enabled: true,
path: server.materialized ? ACTUAL_WORKTREE_CWD : null,
plannedPath: PLANNED_WORKTREE_PATH,
sourceWorkDir: SOURCE_WORK_DIR,
slug: 'desktop-main-planned',
branch: 'worktree-desktop-main-planned',
},
}
})
server.getMessages.mockResolvedValue({ messages: [] })
server.getSlashCommands.mockResolvedValue({ commands: [] })
server.getInspection.mockImplementation(async (sessionId: string) => ({
active: false,
status: {
sessionId,
workDir: sessionId === WORKTREE_SESSION_ID ? ACTUAL_WORKTREE_CWD : REGULAR_WORK_DIR,
permissionMode: 'default',
},
}))
server.getRepositoryContext.mockImplementation(async (workDir: string) => ({
state: 'ok',
workDir,
repoRoot: workDir,
repoName: workDir.split('/').filter(Boolean).at(-1) || null,
currentBranch: 'main',
defaultBranch: 'main',
dirty: false,
branches: [{
name: 'main',
current: true,
local: true,
remote: false,
checkedOut: true,
worktreePath: workDir,
}],
worktrees: [{ path: workDir, branch: 'main', current: true }],
}))
server.getRecentProjects.mockResolvedValue({ projects: [] })
})
afterEach(() => {
cleanup()
})
it('keeps header and composer on the actual cwd after create, materialize, and a regular-session switch', async () => {
let sessionId = ''
await act(async () => {
sessionId = await useSessionStore.getState().createSession(SOURCE_WORK_DIR, {
repository: { branch: 'main', worktree: true },
})
})
act(() => {
useTabStore.getState().openTab(sessionId, 'Worktree Session', 'session')
})
render(<ActiveSession />)
await waitFor(() => {
expect(server.getGitInfo).toHaveBeenCalledWith(sessionId)
})
expect(screen.queryByTestId('session-worktree-indicator')).not.toBeInTheDocument()
const callsBeforeMaterialize = server.getGitInfo.mock.calls.length
server.materialized = true
await finishTurn(sessionId, 'worktree ready')
await waitFor(() => {
expect(server.getGitInfo.mock.calls.length).toBeGreaterThan(callsBeforeMaterialize)
expect(screen.getByTestId('worktree-details-trigger')).toBeInTheDocument()
})
await expectTooltipPath(screen.getByTestId('session-worktree-indicator'), ACTUAL_WORKTREE_CWD)
await expectTooltipPath(screen.getByTestId('worktree-details-trigger'), ACTUAL_WORKTREE_CWD)
let regularSessionId = ''
await act(async () => {
regularSessionId = await useSessionStore.getState().createSession(REGULAR_WORK_DIR)
})
act(() => {
useTabStore.getState().openTab(regularSessionId, 'Regular Session', 'session')
})
await finishTurn(regularSessionId, 'plain checkout ready')
await waitFor(() => {
expect(screen.queryByTestId('session-worktree-indicator')).not.toBeInTheDocument()
expect(screen.queryByTestId('worktree-details-trigger')).not.toBeInTheDocument()
})
})
})
+26
View File
@@ -67,6 +67,32 @@ describe('agentsApi', () => {
expect(apiDeleteMock).toHaveBeenCalledWith('/api/agents/reviewer?scope=user')
})
it('sends an override to the sub-resource, keeping explicit nulls', () => {
// `null` is the wire form of "clear this field"; dropping it would silently
// turn a reset into a no-op.
agentsApi.setOverride('Explore', { cwd: '/workspace/one', model: null, effort: 'low' })
expect(apiPutMock).toHaveBeenCalledWith('/api/agents/Explore/override', {
cwd: '/workspace/one',
model: null,
effort: 'low',
})
})
it('URL-encodes the override clear path and cwd', () => {
agentsApi.clearOverride('reviewer/name?', '/workspace/project one')
expect(apiDeleteMock).toHaveBeenCalledWith(
'/api/agents/reviewer%2Fname%3F/override?cwd=%2Fworkspace%2Fproject+one',
)
})
it('omits an empty cwd from an override clear', () => {
agentsApi.clearOverride('Explore')
expect(apiDeleteMock).toHaveBeenCalledWith('/api/agents/Explore/override')
})
it('reloads the exact active session with the control timeout', () => {
agentsApi.reload('session/one?')
+30
View File
@@ -23,7 +23,24 @@ export type AgentDefinition = {
target?: string
overriddenBy?: AgentSource
isActive: boolean
/** The backing file can be rewritten. Never true for built-in agents. */
editable?: boolean
/** Built-in agents only: model and effort can be changed via setOverride. */
overridable?: boolean
/**
* Built-in agents only: what this build ships with, so the UI can name and
* restore the default. Never hardcode it — it varies per agent and per build.
*/
defaults?: { model?: string; effort?: string | number }
/** Built-in agents only: the override currently in effect, if any. */
override?: { model?: string; effort?: string | number; source: AgentSource }
}
/** `null` clears that field; an omitted field is left unchanged. */
export type AgentOverrideInput = {
cwd?: string
model?: string | null
effort?: string | number | null
}
export type AgentScope = 'user' | 'project'
@@ -82,6 +99,19 @@ export const agentsApi = {
if (target) query.set('target', target)
return api.delete<void>(`/api/agents/${encodeURIComponent(name)}?${query.toString()}`)
},
setOverride: (name: string, input: AgentOverrideInput) =>
api.put<AgentMutationResponse>(
`/api/agents/${encodeURIComponent(name)}/override`,
input,
),
clearOverride: (name: string, cwd?: string) => {
const query = new URLSearchParams()
if (cwd) query.set('cwd', cwd)
const suffix = query.toString() ? `?${query.toString()}` : ''
return api.delete<AgentMutationResponse>(
`/api/agents/${encodeURIComponent(name)}/override${suffix}`,
)
},
reload: (sessionId: string) =>
api.post<AgentReloadResponse>(
`/api/agents/reload?sessionId=${encodeURIComponent(sessionId)}`,
+108
View File
@@ -7,15 +7,112 @@ import {
setAuthToken,
setBaseUrl,
} from './client'
import { browserHost } from '../lib/desktopHost/browserHost'
describe('api diagnostics reporting', () => {
afterEach(() => {
vi.useRealTimers()
setAuthToken(null)
setBaseUrl(getDefaultBaseUrl())
Reflect.deleteProperty(window, 'desktopHost')
vi.restoreAllMocks()
})
it('recovers a desktop GET after the sidecar restarts on a new port', async () => {
const firstUrl = 'http://127.0.0.1:49231'
const recoveredUrl = 'http://127.0.0.1:49232'
const getServerUrl = vi.fn().mockResolvedValue(recoveredUrl)
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
runtime: {
...browserHost.runtime,
getServerUrl,
},
}
setBaseUrl(firstUrl)
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
const url = String(input)
if (url === `${firstUrl}/api/sessions/session-1/messages`) {
return Promise.reject(new TypeError('Failed to fetch'))
}
if (url === `${recoveredUrl}/api/sessions/session-1/messages`) {
return Promise.resolve(Response.json({ messages: [] }))
}
return Promise.resolve(new Response(null, { status: 204 }))
})
await expect(api.get('/api/sessions/session-1/messages')).resolves.toEqual({ messages: [] })
expect(getServerUrl).toHaveBeenCalledTimes(1)
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([
`${firstUrl}/api/sessions/session-1/messages`,
`${recoveredUrl}/api/sessions/session-1/messages`,
])
})
it('does not replay a desktop mutation after a transport failure', async () => {
const getServerUrl = vi.fn().mockResolvedValue('http://127.0.0.1:49232')
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
runtime: {
...browserHost.runtime,
getServerUrl,
},
}
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
if (String(input).endsWith('/api/providers/test')) {
return Promise.reject(new TypeError('Failed to fetch'))
}
return Promise.resolve(new Response(null, { status: 204 }))
})
await expect(api.post('/api/providers/test', { value: 'once' })).rejects.toThrow('Failed to fetch')
expect(getServerUrl).not.toHaveBeenCalled()
expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/api/providers/test')))
.toHaveLength(1)
})
it('coalesces recovery when concurrent desktop GETs observe the same sidecar exit', async () => {
const firstUrl = 'http://127.0.0.1:49241'
const recoveredUrl = 'http://127.0.0.1:49242'
let releaseRecovery!: (url: string) => void
const getServerUrl = vi.fn(() => new Promise<string>(resolve => {
releaseRecovery = resolve
}))
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
runtime: {
...browserHost.runtime,
getServerUrl,
},
}
setBaseUrl(firstUrl)
vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
const url = String(input)
if (url.startsWith(firstUrl)) return Promise.reject(new TypeError('Failed to fetch'))
return Promise.resolve(Response.json({ url }))
})
const firstRequest = api.get<{ url: string }>('/api/sessions/session-1/messages')
const secondRequest = api.get<{ url: string }>('/api/sessions/session-2/messages')
await vi.waitFor(() => expect(getServerUrl).toHaveBeenCalledTimes(1))
releaseRecovery(recoveredUrl)
await expect(Promise.all([firstRequest, secondRequest])).resolves.toEqual([
{ url: `${recoveredUrl}/api/sessions/session-1/messages` },
{ url: `${recoveredUrl}/api/sessions/session-2/messages` },
])
expect(getServerUrl).toHaveBeenCalledTimes(1)
})
it('does not send Authorization for default local requests', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), {
@@ -117,6 +214,16 @@ describe('api diagnostics reporting', () => {
it('propagates caller cancellation without reporting an API failure', async () => {
const controller = new AbortController()
const getServerUrl = vi.fn().mockResolvedValue('http://127.0.0.1:49252')
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
runtime: {
...browserHost.runtime,
getServerUrl,
},
}
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockImplementation((_url, init) => new Promise<Response>((_, reject) => {
init?.signal?.addEventListener('abort', () => {
@@ -131,6 +238,7 @@ describe('api diagnostics reporting', () => {
await expect(request).rejects.toMatchObject({ name: 'AbortError' })
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(getServerUrl).not.toHaveBeenCalled()
})
it('defaults local API requests to a 120 second timeout', async () => {
+38 -2
View File
@@ -1,3 +1,5 @@
import { getDesktopHost } from '../lib/desktopHost'
const ENV_BASE_URL =
typeof import.meta !== 'undefined' &&
typeof import.meta.env?.VITE_DESKTOP_SERVER_URL === 'string' &&
@@ -9,6 +11,7 @@ const DEFAULT_BASE_URL = ENV_BASE_URL || 'http://127.0.0.1:3456'
let baseUrl = DEFAULT_BASE_URL
let authToken: string | null = null
let desktopServerRecovery: Promise<string> | null = null
const DIAGNOSTICS_PATH = '/api/diagnostics/events'
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000
const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 5_000
@@ -75,7 +78,6 @@ export type ApiRequestOptions = {
}
async function request<T>(method: string, path: string, body?: unknown, options?: ApiRequestOptions): Promise<T> {
const url = `${baseUrl}${path}`
const headers = buildHeaders()
const controller = new AbortController()
@@ -89,12 +91,27 @@ async function request<T>(method: string, path: string, body?: unknown, options?
if (options?.signal?.aborted) abortFromCaller()
else options?.signal?.addEventListener('abort', abortFromCaller, { once: true })
try {
const res = await fetch(url, {
const fetchOnce = () => fetch(`${baseUrl}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: controller.signal,
})
let res: Response
try {
res = await fetchOnce()
} catch (error) {
if (
method !== 'GET' ||
timedOut ||
options?.signal?.aborted ||
!(error instanceof TypeError) ||
!await recoverDesktopServerUrl()
) {
throw error
}
res = await fetchOnce()
}
if (!res.ok) {
const errorBody = await res.json().catch(() => res.text())
throw new ApiError(res.status, errorBody)
@@ -121,6 +138,25 @@ async function request<T>(method: string, path: string, body?: unknown, options?
}
}
async function recoverDesktopServerUrl(): Promise<boolean> {
const host = getDesktopHost()
if (!host.isDesktop) return false
if (!desktopServerRecovery) {
const recovery = host.runtime.getServerUrl().then((serverUrl) => {
setBaseUrl(serverUrl)
return serverUrl
})
const trackedRecovery = recovery.finally(() => {
if (desktopServerRecovery === trackedRecovery) desktopServerRecovery = null
})
desktopServerRecovery = trackedRecovery
}
await desktopServerRecovery
return true
}
function reportApiFailure(method: string, path: string, error: unknown) {
if (path.startsWith('/api/diagnostics')) return
@@ -4,6 +4,7 @@ import { desktopUiPreferencesApi, getProfileAvatarUrl } from './desktopUiPrefere
const preferences = {
schemaVersion: 3,
projectDisplayNames: {},
profile: {
displayName: 'cc-haha',
subtitle: 'github.com/NanmiCoder/cc-haha',
@@ -58,6 +59,62 @@ describe('desktopUiPreferencesApi', () => {
)
})
it('updates project display names and sends null to reset one', async () => {
setBaseUrl('http://127.0.0.1:49237')
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
ok: true,
projectKey: '/workspace/apps/../project',
displayName: 'Project alias',
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
ok: true,
projectKey: '/workspace/apps/../project',
displayName: null,
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
await expect(desktopUiPreferencesApi.updateProjectDisplayName(
'/workspace/apps/../project',
'Project alias',
)).resolves.toEqual({
ok: true,
projectKey: '/workspace/apps/../project',
displayName: 'Project alias',
})
await expect(desktopUiPreferencesApi.updateProjectDisplayName(
'/workspace/apps/../project',
null,
)).resolves.toEqual({
ok: true,
projectKey: '/workspace/apps/../project',
displayName: null,
})
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'http://127.0.0.1:49237/api/desktop-ui/preferences/project-display-name',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ projectKey: '/workspace/apps/../project', displayName: 'Project alias' }),
}),
)
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'http://127.0.0.1:49237/api/desktop-ui/preferences/project-display-name',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ projectKey: '/workspace/apps/../project', displayName: null }),
}),
)
})
it('reads only the pet projection through the scoped preference endpoint', async () => {
setBaseUrl('http://127.0.0.1:49237')
const fetchMock = vi.spyOn(globalThis, 'fetch')
+14
View File
@@ -30,6 +30,7 @@ export type DesktopUiPreferences = {
sidebar: SidebarProjectPreferences
profile: DesktopProfilePreferences
pet: DesktopPetPreferences
projectDisplayNames: Record<string, string>
}
export type DesktopUiPreferencesResponse = {
@@ -46,6 +47,12 @@ export type DesktopPetPreferencesUpdateResponse =
| { ok: true; preferences: DesktopUiPreferences }
| { ok: true; pet: DesktopPetPreferences }
export type ProjectDisplayNameUpdateResponse = {
ok: true
projectKey: string
displayName: string | null
}
export const desktopUiPreferencesApi = {
getPreferences() {
return api.get<DesktopUiPreferencesResponse>('/api/desktop-ui/preferences')
@@ -62,6 +69,13 @@ export const desktopUiPreferencesApi = {
)
},
updateProjectDisplayName(projectKey: string, displayName: string | null) {
return api.put<ProjectDisplayNameUpdateResponse>(
'/api/desktop-ui/preferences/project-display-name',
{ projectKey, displayName },
)
},
updateProfilePreferences(profile: Pick<DesktopProfilePreferences, 'displayName' | 'subtitle'>) {
return api.put<{ ok: true; preferences: DesktopUiPreferences }>(
'/api/desktop-ui/preferences/profile',
+23 -2
View File
@@ -8,7 +8,10 @@ describe('openTargetsApi', () => {
vi.restoreAllMocks()
})
it('normalizes relative icon URLs to the configured desktop server URL', async () => {
it('keeps the icon path server-relative so it is fetched through the credential path', async () => {
// Absolute URLs only existed to feed an `<img src>`, and that shape is exactly
// what the server's fetch-metadata policy rejects: a cross-origin no-cors
// subresource carries no Authorization header and comes back 401.
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
platform: 'darwin',
@@ -36,9 +39,27 @@ describe('openTargetsApi', () => {
targets: [
{
id: 'vscode',
iconUrl: 'http://127.0.0.1:49237/api/open-targets/icons/vscode',
iconUrl: '/api/open-targets/icons/vscode',
},
],
})
})
it('requests open targets for the concrete file path', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({
platform: 'darwin',
targets: [],
primaryTargetId: null,
cachedAt: 1,
ttlMs: 30_000,
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
setBaseUrl('http://127.0.0.1:49237')
await openTargetsApi.listForPath('/tmp/My Report.docx')
expect(fetchMock).toHaveBeenCalledWith(
'http://127.0.0.1:49237/api/open-targets?path=%2Ftmp%2FMy%20Report.docx',
expect.any(Object),
)
})
})
+10 -13
View File
@@ -1,14 +1,18 @@
import { api, getApiUrl } from './client'
import { api } from './client'
export type OpenTargetKind = 'ide' | 'file_manager'
export type OpenTargetKind = 'application' | 'system_default' | 'ide' | 'file_manager'
export type OpenTarget = {
id: string
kind: OpenTargetKind
label: string
icon: string
/** Server-relative path. Load it through {@link loadTargetIconUrl}, not an `<img src>`. */
iconUrl?: string
platform: string
appPath?: string
bundleId?: string | null
isDefault?: boolean
}
export type OpenTargetList = {
@@ -25,19 +29,12 @@ export type OpenTargetOpenResponse = {
path: string
}
function normalizeOpenTargetList(result: OpenTargetList): OpenTargetList {
return {
...result,
targets: result.targets.map((target) => ({
...target,
iconUrl: target.iconUrl ? getApiUrl(target.iconUrl) : undefined,
})),
}
}
export const openTargetsApi = {
async list() {
return normalizeOpenTargetList(await api.get<OpenTargetList>('/api/open-targets'))
return api.get<OpenTargetList>('/api/open-targets')
},
async listForPath(path: string) {
return api.get<OpenTargetList>(`/api/open-targets?path=${encodeURIComponent(path)}`)
},
open(targetId: string, path: string) {
return api.post<OpenTargetOpenResponse>('/api/open-targets/open', { targetId, path })
+39
View File
@@ -40,6 +40,45 @@ describe('sessionsApi', () => {
})
})
it('deduplicates concurrent Git info requests for the same session', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
let resolveFetch!: (response: Response) => void
fetchMock.mockReturnValueOnce(new Promise<Response>((resolve) => {
resolveFetch = resolve
}))
const first = sessionsApi.getGitInfo('session-1')
const second = sessionsApi.getGitInfo('session-1')
expect(fetchMock).toHaveBeenCalledOnce()
resolveFetch(new Response(JSON.stringify({
branch: 'main',
repoName: 'repo',
workDir: '/repo',
changedFiles: 0,
worktree: null,
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
const [firstResult, secondResult] = await Promise.all([first, second])
expect(firstResult).toEqual(secondResult)
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
branch: 'main',
repoName: 'repo',
workDir: '/repo',
changedFiles: 1,
worktree: null,
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
await sessionsApi.getGitInfo('session-1')
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('fetches a single trace call from the call detail endpoint', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
+57 -5
View File
@@ -1,4 +1,4 @@
import { api } from './client'
import { api, type ApiRequestOptions } from './client'
import type { SlashCommandOption } from '../types/slashCommand'
import type { AgentTaskNotification } from '../types/chat'
import type { LocalIndexStatus, SessionListItem, MessageEntry } from '../types/session'
@@ -72,6 +72,8 @@ export type RepositoryBranchInfo = {
remoteRef?: string
checkedOut: boolean
worktreePath?: string
/** Commit the branch points at; absent on servers predating the field. */
commit?: string
}
export type RepositoryWorktreeInfo = {
path: string
@@ -85,11 +87,24 @@ export type RepositoryContextResult = {
repoName: string | null
currentBranch: string | null
defaultBranch: string | null
/** Commit `HEAD` resolves to; absent on servers predating the field. */
headCommit?: string | null
dirty: boolean
branches: RepositoryBranchInfo[]
worktrees: RepositoryWorktreeInfo[]
error?: string
}
export type CreateRepositoryBranchRequest = {
workDir: string
name: string
/** Branch the new one starts from, as named in `branches`. Defaults to HEAD. */
from?: string | null
}
export type CreateRepositoryBranchResult = {
branch: string
baseRef: string
context: RepositoryContextResult
}
export type SessionRewindResponse = {
target: {
targetUserMessageId: string
@@ -108,8 +123,22 @@ export type SessionRewindResponse = {
deletions: number
}
restoreAvailable?: boolean
/**
* Tool names whose file effects the checkpoint could not capture (a writing
* shell command, a tool with no change extractor). Undo still works and still
* restores every file it lists — these are the changes it will leave behind.
*/
unverifiedChangeSources?: string[]
/** What the executed rewind touched. Absent on dry-run previews. */
mode?: SessionRewindMode
}
/**
* `both` restores files and trims the transcript; `conversation` only trims,
* which stays possible even when the files cannot be restored.
*/
export type SessionRewindMode = 'both' | 'conversation'
export type RecentProject = {
projectPath: string
realPath: string
@@ -301,6 +330,7 @@ export type SessionTurnCheckpoint = {
code: SessionRewindResponse['code']
workDir?: string
restoreAvailable?: boolean
unverifiedChangeSources?: string[]
}
export type SessionTurnCheckpointsResponse = {
@@ -312,6 +342,22 @@ export type TurnCheckpointDiffResult = WorkspaceDiffResult & {
workDir?: string
}
const gitInfoRequests = new Map<string, Promise<SessionGitInfo>>()
function getSessionGitInfo(sessionId: string) {
const pending = gitInfoRequests.get(sessionId)
if (pending) return pending
const request = api.get<SessionGitInfo>(`/api/sessions/${sessionId}/git-info`)
const trackedRequest = request.finally(() => {
if (gitInfoRequests.get(sessionId) === trackedRequest) {
gitInfoRequests.delete(sessionId)
}
})
gitInfoRequests.set(sessionId, trackedRequest)
return trackedRequest
}
function buildWorkspacePath(
sessionId: string,
resource: 'status' | 'tree' | 'file' | 'diff',
@@ -385,10 +431,12 @@ export const sessionsApi = {
return api.get<RepositoryContextResult>(`/api/sessions/repository-context?${query.toString()}`)
},
getGitInfo(sessionId: string) {
return api.get<SessionGitInfo>(`/api/sessions/${sessionId}/git-info`)
createRepositoryBranch(body: CreateRepositoryBranchRequest) {
return api.post<CreateRepositoryBranchResult>('/api/sessions/repository-branch', body)
},
getGitInfo: getSessionGitInfo,
getSlashCommands(sessionId: string) {
return api.get<{ commands: SlashCommandOption[] }>(`/api/sessions/${sessionId}/slash-commands`)
},
@@ -428,8 +476,11 @@ export const sessionsApi = {
return api.get<WorkspaceDiffResult>(buildWorkspacePath(sessionId, 'diff', workspacePath))
},
getTurnCheckpoints(sessionId: string) {
return api.get<SessionTurnCheckpointsResponse>(`/api/sessions/${sessionId}/turn-checkpoints`)
getTurnCheckpoints(sessionId: string, options?: ApiRequestOptions) {
return api.get<SessionTurnCheckpointsResponse>(
`/api/sessions/${sessionId}/turn-checkpoints`,
options,
)
},
getTurnCheckpointDiff(
@@ -454,6 +505,7 @@ export const sessionsApi = {
userMessageIndex?: number
expectedContent?: string
dryRun?: boolean
mode?: SessionRewindMode
}) {
return api.post<SessionRewindResponse>(`/api/sessions/${sessionId}/rewind`, body, {
timeout: 60_000,
+8 -1
View File
@@ -8,7 +8,7 @@ vi.mock('./client', () => ({
},
}))
import { subagentsApi } from './subagents'
import { isAgentIdRef, subagentsApi } from './subagents'
describe('subagentsApi', () => {
afterEach(() => {
@@ -34,4 +34,11 @@ describe('subagentsApi', () => {
'/api/sessions/session-1/subagents/by-tool/tool-1?taskId=agent-one_2',
)
})
it('does not mistake a nested canonical tool ref for a workflow agent id', () => {
expect(isAgentIdRef('agent:wf')).toBe(true)
expect(isAgentIdRef('agent:')).toBe(false)
expect(isAgentIdRef('agent:wf/wf/B')).toBe(false)
expect(isAgentIdRef('tool-1')).toBe(false)
})
})
+53
View File
@@ -1,5 +1,6 @@
import { api } from './client'
import type { MessageEntry } from '../types/session'
import type { AgentTaskNotification } from '../types/chat'
export type SubagentRunStatus = 'running' | 'completed' | 'failed' | 'stopped' | 'unknown'
export type SubagentRunSource = 'subagent-jsonl' | 'session-history' | 'live-task' | 'none'
@@ -23,16 +24,68 @@ export type SubagentRunResponse = {
outputFile?: string
usage?: SubagentRunUsage
messages: MessageEntry[]
/** Full Activity projection; conversation messages may be truncated. */
activityMessages?: MessageEntry[]
/** Structured terminal events hidden from the visible transcript. */
taskNotifications?: AgentTaskNotification[]
/** Terminal events with the same fragment-scoped ids as activityMessages. */
activityTaskNotifications?: AgentTaskNotification[]
truncated: boolean
updatedAt?: string
source: SubagentRunSource
/**
* Whether a follow-up can still reach this agent. Only named teammates and
* in-flight background agents have an inbox — a one-shot subagent answers
* once and is done, so the page shows its record without a composer.
* Optional so a response from an older server still parses.
*/
canSendMessage?: boolean
}
/**
* Marks a subagent addressed by agent id rather than by the `Agent` tool call
* that spawned it.
*
* Workflow agents are spawned by the workflow runtime, so no such tool call
* exists. Carrying the distinction in the identifier means the tab id, the
* page, and the return path all stay exactly as they are for every other
* subagent — only the fetch differs.
*/
export const AGENT_ID_REF_PREFIX = 'agent:'
export function isAgentIdRef(ref: string): boolean {
if (!ref.startsWith(AGENT_ID_REF_PREFIX)) return false
const agentId = readAgentIdRef(ref)
return agentId.length > 0 && !agentId.includes('/')
}
export function toAgentIdRef(agentId: string): string {
return `${AGENT_ID_REF_PREFIX}${agentId}`
}
export function readAgentIdRef(ref: string): string {
return ref.slice(AGENT_ID_REF_PREFIX.length)
}
export const subagentsApi = {
getRunByAgent(sessionId: string, agentId: string) {
return api.get<SubagentRunResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}/subagents/by-agent/${encodeURIComponent(agentId)}`,
)
},
getRunByTool(sessionId: string, toolUseId: string, taskId?: string) {
const query = taskId ? `?taskId=${encodeURIComponent(taskId)}` : ''
return api.get<SubagentRunResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}/subagents/by-tool/${encodeURIComponent(toolUseId)}${query}`,
)
},
sendMessage(sessionId: string, toolUseId: string, content: string, taskId?: string) {
const query = taskId ? `?taskId=${encodeURIComponent(taskId)}` : ''
return api.post<{ ok: true; delivery?: 'queued' | 'resumed'; agent_id?: string }>(
`/api/sessions/${encodeURIComponent(sessionId)}/subagents/by-tool/${encodeURIComponent(toolUseId)}/messages${query}`,
{ content },
)
},
}
+45 -1
View File
@@ -1,5 +1,11 @@
import { api } from './client'
import type { TeamSummary, TeamDetail } from '../types/team'
import type {
TeamSummary,
TeamDetail,
TeamWorkbenchSnapshot,
TeamWorkbenchSessionTimeline,
} from '../types/team'
import type { AgentTaskNotification } from '../types/chat'
type TeamsResponse = { teams: TeamSummary[] }
@@ -10,10 +16,17 @@ type TranscriptMessage = {
timestamp: string
model?: string
parentToolUseId?: string
toolUseResult?: unknown
}
type TranscriptResponse = {
messages: TranscriptMessage[]
/** Terminal notifications from this cursor page; merge by toolUseId. */
taskNotifications?: AgentTaskNotification[]
/** Physical transcript fragments that own this member's nested activity. */
ownerAgentIds?: string[]
/** Where this member's work on each team task starts and ends. */
taskAnchors?: TeamTaskAnchor[]
signature?: string
cursor?: string
afterOrdinal?: number
@@ -24,6 +37,15 @@ type TranscriptOptions = {
signature?: string
cursor?: string
afterOrdinal?: number
leadSessionId?: string
incarnationId?: string
}
export type TeamTaskAnchor = {
taskId: string
status: 'pending' | 'in_progress' | 'completed'
messageId: string
timestamp: string
}
export type { TranscriptMessage }
@@ -37,6 +59,26 @@ export const teamsApi = {
return api.get<TeamDetail>(`/api/teams/${encodeURIComponent(name)}`)
},
getWorkbench(name: string) {
return api.get<TeamWorkbenchSnapshot>(
`/api/teams/${encodeURIComponent(name)}/workbench`,
)
},
getWorkbenchForSession(
sessionId: string,
options?: { teamName?: string; at?: number; incarnationId?: string },
) {
const params = new URLSearchParams()
if (options?.teamName) params.set('teamName', options.teamName)
if (options?.at !== undefined) params.set('at', String(options.at))
if (options?.incarnationId) params.set('incarnationId', options.incarnationId)
const query = params.toString()
return api.get<TeamWorkbenchSessionTimeline>(
`/api/teams/session/${encodeURIComponent(sessionId)}/workbench${query ? `?${query}` : ''}`,
)
},
getMemberTranscript(
teamName: string,
agentId: string,
@@ -45,6 +87,8 @@ export const teamsApi = {
const params = new URLSearchParams()
if (options) {
params.set('incremental', 'true')
if (options.leadSessionId) params.set('leadSessionId', options.leadSessionId)
if (options.incarnationId) params.set('incarnationId', options.incarnationId)
if (options.signature) params.set('signature', options.signature)
if (options.cursor) params.set('cursor', options.cursor)
if (options.afterOrdinal !== undefined) {
+76
View File
@@ -0,0 +1,76 @@
import { api } from './client'
import type {
ReconstructedWorkflowRun,
WorkflowDefinition,
WorkflowRunDetail,
WorkflowRunSummary,
} from '../types/workflow'
export type WorkflowValidateResult = {
ok: boolean
error?: string
name?: string
description?: string
phases?: { title: string; detail?: string; model?: string }[]
}
export const workflowsApi = {
list(cwd?: string) {
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
return api.get<{ workflows: WorkflowDefinition[] }>(`/api/workflows${query}`)
},
get(name: string, cwd?: string) {
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
return api.get<WorkflowDefinition>(
`/api/workflows/${encodeURIComponent(name)}${query}`,
)
},
/** Finished runs for one session, rebuilt from what the CLI left on disk. */
sessionRuns(sessionId: string) {
return api.get<{ runs: ReconstructedWorkflowRun[] }>(
`/api/workflows/session-runs/${encodeURIComponent(sessionId)}`,
)
},
listRuns(options?: { sessionId?: string; limit?: number }) {
const params = new URLSearchParams()
if (options?.sessionId) params.set('sessionId', options.sessionId)
if (options?.limit) params.set('limit', String(options.limit))
const query = params.toString()
return api.get<{ runs: WorkflowRunSummary[] }>(
`/api/workflows/runs${query ? `?${query}` : ''}`,
)
},
getRun(sessionId: string, runId: string) {
return api.get<WorkflowRunDetail>(
`/api/workflows/runs/${encodeURIComponent(sessionId)}/${encodeURIComponent(runId)}`,
)
},
validate(script: string) {
return api.post<WorkflowValidateResult>('/api/workflows/validate', { script })
},
save(
script: string,
scope: 'user' | 'project',
cwd?: string,
name?: string,
) {
return api.post<{ ok: true; name: string; filePath: string }>(
'/api/workflows/save',
{ script, scope, cwd, name },
)
},
remove(name: string, scope: 'user' | 'project', cwd?: string) {
const params = new URLSearchParams({ scope })
if (cwd) params.set('cwd', cwd)
return api.delete<{ ok: true }>(
`/api/workflows/${encodeURIComponent(name)}?${params.toString()}`,
)
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

@@ -34,6 +34,7 @@ vi.mock('../../i18n', () => ({
'session.activity.details.usage': 'Usage',
'session.activity.section.tasks': 'Tasks',
'session.activity.section.team': 'Team',
'session.activity.section.workflow': 'Workflow',
'session.activity.section.backgroundTasks': 'Background Tasks',
'session.activity.section.subagents': 'SubAgents',
'session.activity.section.sources': 'Sources',
@@ -57,6 +58,7 @@ vi.mock('../../i18n', () => ({
'session.activity.status.stopped': 'Stopped',
'session.activity.status.idle': 'Idle',
'session.activity.status.error': 'Error',
'workflows.agent.cached': 'Cached',
}
let text = translations[key] ?? key
@@ -75,6 +77,7 @@ function model(overrides: Partial<SessionActivityModel> = {}): SessionActivityMo
badgeCount: 1,
sections: {
output: { id: 'output', title: 'Output', emptyLabel: 'No output', rows: [] },
workflow: { id: 'workflow', title: 'Workflow', emptyLabel: 'No workflow running', rows: [] },
tasks: {
id: 'tasks',
title: 'Tasks',
@@ -105,6 +108,42 @@ function model(overrides: Partial<SessionActivityModel> = {}): SessionActivityMo
describe('SessionActivityPanel', () => {
afterEach(cleanup)
it('tints the row icon tile by status so a one-line row still states what happened', () => {
render(
<SessionActivityPanel
model={model({
sections: {
...model().sections,
backgroundTasks: {
id: 'backgroundTasks',
title: 'Background Tasks',
emptyLabel: 'No background tasks',
rows: [
{ id: 'bg-run', section: 'backgroundTasks', label: 'Vite dev server', status: 'running', taskId: 'bg-run', openable: false },
{ id: 'bg-fail', section: 'backgroundTasks', label: 'pytest -q', status: 'failed', taskId: 'bg-fail', openable: false },
],
},
},
})}
open
onClose={vi.fn()}
onOpenSubagent={vi.fn()}
/>,
)
const tiles = screen.getAllByTestId('activity-row-icon')
const running = tiles.find((tile) => tile.getAttribute('data-tone-status') === 'running')
const failed = tiles.find((tile) => tile.getAttribute('data-tone-status') === 'failed')
// 30px matches AgentMascot, so SubAgent and background rows share a text column.
expect(running?.className).toContain('h-[30px]')
expect(running?.className).toContain('bg-[var(--color-brand-soft)]')
// Paired tokens, never a raw accent on its own container (AGENTS.md 3.2).
expect(running?.className).toContain('text-[var(--color-on-brand-soft)]')
expect(failed?.className).toContain('bg-[var(--color-error-container)]')
expect(failed?.className).toContain('text-[var(--color-on-error-container)]')
})
it('renders populated tasks section without empty visible section labels', () => {
render(
<SessionActivityPanel
@@ -172,7 +211,9 @@ describe('SessionActivityPanel', () => {
expect(screen.getByLabelText('Task in progress')).toHaveClass('motion-reduce:animate-none')
expect(screen.getByLabelText('Task in progress')).toHaveClass('rounded-full')
expect(screen.getByLabelText('Task in progress').querySelector('svg')).toBeNull()
expect(screen.getByText('Active task').closest('button,div')).toHaveClass('py-2.5')
// Dense row rhythm: the panel is an index of what is happening, so a row is
// a line, not a card. Pinned because it is easy to lose to a stray `py-2.5`.
expect(screen.getByText('Active task').closest('button,div')).toHaveClass('py-1.5')
expect(screen.getByText('Finished task')).toHaveClass('line-through')
expect(screen.queryByText('Completed')).not.toBeInTheDocument()
expect(screen.queryByText('Pending')).not.toBeInTheDocument()
@@ -212,6 +253,49 @@ describe('SessionActivityPanel', () => {
expect(screen.getByText('2/4')).toBeInTheDocument()
})
it('keeps run-local rows visible without adding them to canonical team progress', () => {
render(
<SessionActivityPanel
model={model({
sections: {
...model().sections,
tasks: {
id: 'tasks',
title: 'Tasks',
emptyLabel: 'No tasks',
rows: [
{ id: 'local-a', section: 'tasks', label: 'Lead follow-up', status: 'stopped', openable: false },
{
id: 'team:one',
section: 'tasks',
label: 'Team task one',
status: 'completed',
teamTaskListId: 'team-list',
openable: false,
},
{
id: 'team:two',
section: 'tasks',
label: 'Team task two',
status: 'completed',
teamTaskListId: 'team-list',
openable: false,
},
],
},
},
})}
open
onClose={vi.fn()}
onOpenSubagent={vi.fn()}
/>,
)
expect(screen.getByText('Lead follow-up')).toBeInTheDocument()
expect(screen.getByRole('progressbar', { name: 'Task progress 2/2' })).toHaveAttribute('aria-valuenow', '100')
expect(screen.queryByText('2/3')).not.toBeInTheDocument()
})
it('leaves sections other than tasks without a progress rail', () => {
render(
<SessionActivityPanel
@@ -806,6 +890,107 @@ describe('SessionActivityPanel', () => {
expect(onOpenSubagent).not.toHaveBeenCalled()
})
it('renders a workflow as phase headers with their agents, each opening the subagent page', () => {
const onOpenSubagent = vi.fn()
render(
<SessionActivityPanel
model={model({
sections: {
...model().sections,
tasks: { id: 'tasks', title: 'Tasks', emptyLabel: 'No tasks', rows: [] },
workflow: {
id: 'workflow',
title: 'Workflow',
emptyLabel: 'No workflow running',
rows: [
{
id: 'w1-phase-1',
section: 'workflow',
label: 'Survey',
status: 'completed',
groupProgress: { done: 2, total: 2 },
openable: false,
},
{
id: 'w1-agent-1',
section: 'workflow',
label: 'survey response.js',
status: 'completed',
group: 'Survey',
toolUseId: 'agent:a11',
openable: true,
},
{
id: 'w1-agent-4',
section: 'workflow',
label: 'check response #2',
status: 'pending',
group: 'Cross-check',
openable: false,
},
],
},
},
})}
open
onClose={vi.fn()}
onOpenSubagent={onOpenSubagent}
/>,
)
// The phase is a heading, not something you can open.
const header = screen.getByTestId('workflow-phase-header')
expect(header).toHaveTextContent('Survey')
expect(header).toHaveTextContent('2/2')
// Its agent opens the ordinary subagent page, addressed by agent id
// because a workflow agent has no parent Agent tool call.
fireEvent.click(screen.getByRole('button', { name: /open run survey response\.js/i }))
expect(onOpenSubagent).toHaveBeenCalledWith(
expect.objectContaining({ toolUseId: 'agent:a11', title: 'survey response.js' }),
)
// A queued agent has no transcript yet, so it must not offer to open one.
expect(
screen.queryByRole('button', { name: /open run check response #2/i }),
).not.toBeInTheDocument()
expect(screen.getByText('check response #2')).toBeInTheDocument()
})
it('labels a cached workflow agent explicitly instead of calling it merely completed', () => {
render(
<SessionActivityPanel
model={model({
sections: {
...model().sections,
tasks: { id: 'tasks', title: 'Tasks', emptyLabel: 'No tasks', rows: [] },
workflow: {
id: 'workflow',
title: 'Workflow',
emptyLabel: 'No workflow running',
rows: [{
id: 'cached-a',
section: 'workflow',
label: 'A',
status: 'completed',
group: 'Run',
cached: true,
toolUseId: 'agent:a1',
openable: true,
}],
},
},
})}
open
onClose={vi.fn()}
onOpenSubagent={vi.fn()}
/>,
)
expect(screen.getByText('Cached')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /open run a · cached/i })).toBeInTheDocument()
})
it('does not render when closed', () => {
render(<SessionActivityPanel model={model()} open={false} onClose={vi.fn()} onOpenSubagent={vi.fn()} />)
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronRight, Circle, FileText, LoaderCircle, Square, Terminal, Users, X } from 'lucide-react'
import { Check, ChevronRight, Circle, FileText, LoaderCircle, Square, Terminal, Users, X, Zap } from 'lucide-react'
import { Badge, StatusDot, type Tone } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { IconButton } from '@/components/ui/IconButton'
@@ -17,6 +17,9 @@ export type OpenSubagentPayload = {
taskId?: string
toolUseId: string
title: string
teamName?: string
teamMemberName?: string
teamStartedAt?: number
}
type SessionActivityPanelPlacement = 'overlay' | 'rail'
@@ -72,6 +75,8 @@ function getSectionTitle(sectionId: ActivitySectionId, t: TranslationFn): string
return t('session.activity.section.tasks')
case 'team':
return t('session.activity.section.team')
case 'workflow':
return t('session.activity.section.workflow')
case 'backgroundTasks':
return t('session.activity.section.backgroundTasks')
case 'subagents':
@@ -84,7 +89,7 @@ function getSectionTitle(sectionId: ActivitySectionId, t: TranslationFn): string
}
function getSectionRowsClassName(sectionId: ActivitySectionId, rowCount: number): string {
const base = 'space-y-1.5'
const base = 'space-y-0.5'
if (rowCount === 0) return base
switch (sectionId) {
@@ -92,6 +97,8 @@ function getSectionRowsClassName(sectionId: ActivitySectionId, rowCount: number)
return base
case 'team':
return base
case 'workflow':
return base
case 'backgroundTasks':
return base
case 'subagents':
@@ -201,6 +208,8 @@ function getRowIcon(row: ActivityRow) {
switch (row.section) {
case 'team':
return Users
case 'workflow':
return Zap
case 'backgroundTasks':
return Terminal
case 'subagents':
@@ -220,11 +229,37 @@ function getStatusTone(status: ActivityRow['status']): Tone {
return 'neutral'
}
/** Visible rows only, so the ratio always matches what the section shows. */
/** A canonical Team DAG owns the section ratio when present. Run-local rows
* remain visible beside it without changing the shared workbench progress. */
function getTaskProgress(rows: ActivityRow[]): { completed: number; total: number; percent: number } | null {
if (rows.length === 0) return null
const completed = rows.filter((row) => row.status === 'completed').length
return { completed, total: rows.length, percent: Math.round((completed / rows.length) * 100) }
const teamRows = rows.filter((row) => row.teamTaskListId !== undefined)
const progressRows = teamRows.length > 0 ? teamRows : rows
const completed = progressRows.filter((row) => row.status === 'completed').length
return {
completed,
total: progressRows.length,
percent: Math.round((completed / progressRows.length) * 100),
}
}
/**
* The tile's fill states what happened without spending a word on it, which is
* what lets the row itself stay one line. Tone pairs come from the token scale
* (see components/AGENTS.md §3.2) — never a raw accent as foreground on its own
* container.
*/
function getRowIconToneClass(status: ActivityRow['status']): string {
if (status === 'running' || status === 'in_progress') {
return 'bg-[var(--color-brand-soft)] text-[var(--color-on-brand-soft)]'
}
if (status === 'completed' || status === 'idle') {
return 'bg-[var(--color-success-container)] text-[var(--color-on-success-container)]'
}
if (status === 'failed' || status === 'error') {
return 'bg-[var(--color-error-container)] text-[var(--color-on-error-container)]'
}
return 'bg-[var(--color-surface-container)] text-[var(--color-text-secondary)]'
}
function ActivityRowIcon({
@@ -236,14 +271,22 @@ function ActivityRowIcon({
sessionId: string
status?: ActivityRow['status']
}) {
if (row.section === 'subagents') {
// Workflow agents get the same mascot as any other subagent — they are the
// same thing, and giving them a different glyph would imply otherwise.
if (row.section === 'subagents' || (row.section === 'workflow' && row.group)) {
return <AgentMascot seed={`${sessionId}:${row.toolUseId ?? row.taskId ?? row.id}`} status={status} />
}
const Icon = getRowIcon(row)
// 30px to match `AgentMascot`, so a SubAgent row and a background-task row
// start their text on the same column.
return (
<span className="inline-flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-[var(--radius-md)] text-[var(--color-text-tertiary)]">
<span
data-testid="activity-row-icon"
data-tone-status={status}
className={`inline-flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-[var(--radius-md)] ${getRowIconToneClass(status)}`}
>
<Icon size={15} strokeWidth={2} aria-hidden="true" />
</span>
)
@@ -303,6 +346,41 @@ function BackgroundTaskStopButton({
)
}
/**
* A phase heading inside the workflow section.
*
* Deliberately not a card: the section is already a bordered list, and boxing
* each phase inside it turned three stages into three nested frames. A rule
* plus the settled count carries the grouping on its own.
*/
function WorkflowPhaseHeader({
label,
status,
done,
total,
}: {
label: string
status: ActivityRow['status']
done: number
total: number
}) {
return (
<div
data-testid="workflow-phase-header"
data-status={status}
className="flex items-center gap-2 px-2 pb-1 pt-2.5 first:pt-1"
>
<span className="truncate text-[11px] font-semibold uppercase tracking-wide text-[var(--color-text-secondary)]">
{label}
</span>
<span className="h-px flex-1 bg-[var(--color-border)]" aria-hidden="true" />
<span className="shrink-0 font-mono text-[11px] tabular-nums text-[var(--color-text-tertiary)]">
{done}/{total}
</span>
</div>
)
}
function ActivityRowView({
row,
sessionId,
@@ -323,12 +401,27 @@ function ActivityRowView({
selected?: boolean
}) {
const t = useTranslation()
// A workflow phase is a heading over its agents, not a row you can open.
// Rendering it as one made a fan-out read as a flat list where the stage
// boundaries were invisible.
if (row.groupProgress) {
return (
<WorkflowPhaseHeader
label={row.label}
status={row.status}
done={row.groupProgress.done}
total={row.groupProgress.total}
/>
)
}
const isTask = row.section === 'tasks'
const isStoppingSubagent = row.section === 'subagents' && row.status === 'running' && stoppingBackgroundTask
const displayStatus: ActivityRow['status'] = isStoppingSubagent ? 'pending' : row.status
const statusLabel = isStoppingSubagent
? t('session.activity.status.stopping')
: getActivityStatusLabel(row.status, t)
: row.cached
? t('workflows.agent.cached')
: getActivityStatusLabel(row.status, t)
const label = row.taskHistory
? t('session.activity.tasks.earlier')
: row.label
@@ -338,6 +431,10 @@ function ActivityRowView({
total: row.taskHistory.total,
turns: row.taskHistory.turnCount,
})
// Tasks only. SubAgent / team / background rows deliberately stay one line:
// their `description` is free-form agent prose, not the compact identity
// string the prototype's second line shows, and three tests pin that a row
// never previews it. Details live in the expandable panel instead.
: isTask && row.description && row.description !== row.label
? row.description
: isTask && row.summary && row.summary !== row.label
@@ -352,14 +449,14 @@ function ActivityRowView({
)}
<span className="min-w-0 flex-1 truncate text-left">
<span
className={`block truncate font-semibold leading-5 ${isTask ? 'text-[14px]' : 'text-[13px]'} ${isTask && row.status === 'completed' ? 'text-[var(--color-text-secondary)] line-through decoration-[var(--color-text-tertiary)]' : 'text-[var(--color-text-primary)]'}`}
className={`block truncate text-[13px] font-medium leading-5 ${isTask && row.status === 'completed' ? 'text-[var(--color-text-tertiary)] line-through decoration-[var(--color-text-tertiary)]' : 'text-[var(--color-text-primary)]'}`}
title={label}
>
{label}
</span>
{detail ? (
<span
className={`mt-0.5 block truncate leading-4 text-[var(--color-text-tertiary)] ${isTask ? 'text-[12.5px]' : 'text-[12px]'}`}
className="mt-px block truncate text-[11.5px] leading-4 text-[var(--color-text-tertiary)]"
title={detail}
>
{detail}
@@ -379,7 +476,7 @@ function ActivityRowView({
</>
)
const interactiveRowClassName =
'flex min-w-0 items-center gap-3 rounded-[var(--radius-md)] px-2.5 py-2.5 text-left transition-[background-color,transform] duration-150 ease-out hover:bg-[var(--color-surface-hover)] active:translate-y-px motion-reduce:active:translate-y-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]'
'flex min-w-0 items-center gap-2.5 rounded-[var(--radius-md)] px-2 py-1.5 text-left transition-[background-color,transform] duration-150 ease-out hover:bg-[var(--color-surface-hover)] active:translate-y-px motion-reduce:active:translate-y-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]'
const stopButton = row.section === 'backgroundTasks' && onStopBackgroundTask ? (
<BackgroundTaskStopButton
row={row}
@@ -401,7 +498,13 @@ function ActivityRowView({
)
}
if (row.section === 'subagents' && row.openable && row.toolUseId) {
// A workflow agent is an ordinary subagent, so it opens the same page by the
// same handler — there is nothing workflow-specific to render for one.
if (
(row.section === 'subagents' || row.section === 'workflow' || row.section === 'team') &&
row.openable &&
row.toolUseId
) {
const openButton = (
<button
type="button"
@@ -411,8 +514,11 @@ function ActivityRowView({
...(row.taskId ? { taskId: row.taskId } : {}),
toolUseId: row.toolUseId!,
title: row.label,
...(row.teamName ? { teamName: row.teamName } : {}),
...(row.teamMemberName ? { teamMemberName: row.teamMemberName } : {}),
...(row.teamStartedAt !== undefined ? { teamStartedAt: row.teamStartedAt } : {}),
})}
className={`${interactiveRowClassName} ${stopButton ? 'flex-1' : 'w-full'}`}
className={`${interactiveRowClassName} ${stopButton ? 'flex-1' : 'w-full'} ${row.group ? 'pl-3' : ''}`}
>
{content}
</button>
@@ -450,7 +556,7 @@ function ActivityRowView({
if (stopButton) {
return (
<div className="flex w-full items-center gap-1">
<div className="flex min-w-0 flex-1 items-center gap-3 rounded-[var(--radius-md)] px-2.5 py-2.5">
<div className="flex min-w-0 flex-1 items-center gap-2.5 rounded-[var(--radius-md)] px-2 py-1.5">
{content}
</div>
{stopButton}
@@ -459,7 +565,7 @@ function ActivityRowView({
}
return (
<div className="flex items-center gap-3 rounded-[var(--radius-md)] px-2.5 py-2.5">
<div className="flex items-center gap-2.5 rounded-[var(--radius-md)] px-2 py-1.5">
{content}
</div>
)
@@ -495,7 +601,7 @@ function BackgroundTaskDetail({ row }: { row: ActivityRow }) {
if (details.length === 0) return null
return (
<div className="mx-2.5 mb-1.5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3">
<div className="mx-2 mb-1.5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3">
<div className="mb-2 text-[11px] font-semibold text-[var(--color-text-tertiary)]">
{t('session.activity.details.title')}
</div>
@@ -634,9 +740,9 @@ export function SessionActivityPanel({
aria-label={sectionTitle}
className={index > 0 ? 'border-t border-[var(--color-border)] pt-3' : undefined}
>
<div className="mb-2 flex items-center justify-between gap-2 px-2.5">
<div className="mb-1.5 flex items-center justify-between gap-2 px-2">
<div className="flex min-w-0 items-center gap-2">
<h3 className="text-[13.5px] font-semibold text-[var(--color-text-secondary)]">
<h3 className="text-[12px] font-semibold text-[var(--color-text-secondary)]">
{sectionTitle}
</h3>
{section.rows.length > 0 ? (
File diff suppressed because it is too large Load Diff
@@ -2,23 +2,49 @@ import type { BackgroundAgentTask, AgentTaskNotification, BackgroundAgentTaskUsa
import type { TaskSummaryItem, UIMessage } from '../../types/chat'
import type { CLITask, TaskStatus } from '../../types/cliTask'
import type { TeamMember } from '../../types/team'
import { createBackgroundTaskDismissKey } from '../../lib/backgroundTasks'
import {
EMPTY_TEAM_LIFECYCLE_CURSOR,
isTeamLifecycleScopedAt,
updateTeamLifecycleCursor,
} from '../../lib/teamLifecycleScope'
import {
createBackgroundTaskDismissKey,
isVisibleSessionBackgroundTask,
} from '../../lib/backgroundTasks'
import { toAgentIdRef } from '../../api/subagents'
import type { WorkflowAgentEvent, WorkflowRun } from '../../types/workflow'
export type ActivityStatus = TaskStatus | BackgroundAgentTask['status'] | TeamMember['status']
export type ActivitySectionId = 'output' | 'tasks' | 'team' | 'backgroundTasks' | 'subagents' | 'sources'
export type ActivitySectionId = 'output' | 'tasks' | 'team' | 'workflow' | 'backgroundTasks' | 'subagents' | 'sources'
export type ActivityRow = {
id: string
section: ActivitySectionId
label: string
status: ActivityStatus
cached?: boolean
description?: string
summary?: string
toolUseId?: string
/**
* Phase this row belongs to, for sections that group. A workflow is phases
* of N agents, and the grouping is the only thing that makes a fan-out
* readable — twelve flat rows say nothing about which stage they belong to.
*/
group?: string
/** Set on a group's header row; agents under it carry `group` instead. */
groupProgress?: { done: number; total: number }
taskId?: string
/** Structured owner for a canonical Agent Teams DAG row. */
teamTaskListId?: string
taskType?: BackgroundAgentTask['taskType']
workflowName?: string
/** Persisted Agent Teams launch identity for routing this row to the
* incarnation-scoped member run instead of the ordinary subagent endpoint. */
teamName?: string
teamMemberName?: string
teamStartedAt?: number
dismissKey?: string
outputFile?: string
usage?: BackgroundAgentTaskUsage
@@ -48,20 +74,58 @@ export type SessionActivityModel = {
export type BuildSessionActivityModelInput = {
sessionId: string
messages?: UIMessage[]
/**
* Session transcripts can contain forwarded child-tool activity. A session
* owns only root messages, while an agent detail transcript owns the
* parent-linked messages forwarded for that agent run.
*/
runScope?: 'session' | 'agent'
/**
* Agent Teams coordinates through one shared task list. In that scope the
* caller supplies the member-owned projection via `tasks`; Task* transcript
* events must not recreate the whole shared list in this run.
*/
taskScope?: 'run' | 'team' | 'team-session'
/** Explicit lifecycle windows cover the live gap before TeamCreate appears
* in history and truncated transcripts that no longer contain that call. */
teamTaskWindows?: Array<{ startedAt: number; endedAt?: number }>
tasks: CLITask[]
/** Canonical shared DAG for the team owned by this session. These rows sit
* beside run-local tasks while bypassing transcript reconstruction, so
* member updates cannot leak into the lead run and stale lead events cannot
* override runtime task status. */
teamTasks?: CLITask[]
completedAndDismissed: boolean
isForegroundTurnActive?: boolean
backgroundTasks: BackgroundAgentTask[]
dismissedBackgroundTaskKeys?: Set<string>
agentNotifications: AgentTaskNotification[]
teamMembers?: TeamMember[]
/** AgentTeam has its own strip/workbench in a main session. Set false at
* that ownership boundary so transcript spawn rows cannot affect Activity. */
includeTeamActivity?: boolean
/** Live workflow runs for this session, newest first. */
workflowRuns?: WorkflowRun[]
}
export type BuildMainSessionActivityModelInput = Omit<
BuildSessionActivityModelInput,
'runScope' | 'taskScope' | 'teamTasks' | 'teamMembers' | 'includeTeamActivity'
>
/**
* Ordered by how directly each section answers "what is this turn doing":
* the plan first, then the agents working it, then the processes it left
* running. Background tasks outlive the turn, so they sit last.
*/
export const VISIBLE_ACTIVITY_SECTION_ORDER = [
'tasks',
// A running workflow is the turn's whole shape, so it sits above the
// individual agents it spawned rather than among them.
'workflow',
'subagents',
'team',
'backgroundTasks',
'subagents',
'sources',
] as const satisfies readonly ActivitySectionId[]
@@ -71,6 +135,7 @@ const SECTION_META: Record<ActivitySectionId, Pick<ActivitySection, 'title' | 'e
output: { title: 'Output', emptyLabel: 'No output' },
tasks: { title: 'Tasks', emptyLabel: 'No tasks' },
team: { title: 'Team', emptyLabel: 'No team members' },
workflow: { title: 'Workflow', emptyLabel: 'No workflow running' },
backgroundTasks: { title: 'Background Tasks', emptyLabel: 'No background tasks' },
subagents: { title: 'SubAgents', emptyLabel: 'No SubAgents' },
sources: { title: 'Sources', emptyLabel: 'No sources' },
@@ -81,6 +146,7 @@ function createEmptySections(): Record<ActivitySectionId, ActivitySection> {
output: createSection('output'),
tasks: createSection('tasks'),
team: createSection('team'),
workflow: createSection('workflow'),
backgroundTasks: createSection('backgroundTasks'),
subagents: createSection('subagents'),
sources: createSection('sources'),
@@ -142,6 +208,14 @@ function buildTaskRow(task: CLITask): ActivityRow {
}
}
function buildTeamTaskRow(task: CLITask): ActivityRow {
return {
...buildTaskRow(task),
id: `team-task:${task.taskListId}:${task.id}`,
teamTaskListId: task.taskListId,
}
}
function buildTaskSummaryRow(task: TaskSummaryItem, index: number): ActivityRow {
return {
id: task.id || `summary-task-${index + 1}`,
@@ -279,35 +353,105 @@ function collectToolResults(
return resultsByToolUseId
}
function collectSubagentCreatedTaskIds(messages: UIMessage[]): Set<string> {
const taskIds = new Set<string>()
const resultsByToolUseId = collectToolResults(messages)
for (const message of messages) {
if (
message.type !== 'tool_use' ||
message.toolName !== 'TaskCreate' ||
!message.parentToolUseId
) {
continue
}
const result = resultsByToolUseId.get(message.toolUseId)
if (!result || result.isError) continue
const createdTask = parseCreatedTaskResult(result.content)
if (createdTask) taskIds.add(createdTask.id)
}
return taskIds
}
function keepSessionLevelTaskMessage(message: UIMessage): boolean {
function keepSessionRunMessage(message: UIMessage): boolean {
return !(
(message.type === 'tool_use' || message.type === 'tool_result') &&
message.parentToolUseId
)
}
function projectMessagesToRun(messages: UIMessage[], runScope: 'session' | 'agent'): UIMessage[] {
return runScope === 'agent' ? messages : messages.filter(keepSessionRunMessage)
}
function explicitSuccessFlag(value: unknown): boolean | undefined {
if (Array.isArray(value)) {
for (const item of value) {
const nested = explicitSuccessFlag(item)
if (nested !== undefined) return nested
}
return undefined
}
if (isRecordValue(value)) {
if (typeof value.success === 'boolean') return value.success
if ('content' in value) return explicitSuccessFlag(value.content)
if ('text' in value) return explicitSuccessFlag(value.text)
return undefined
}
if (typeof value !== 'string') return undefined
const text = value.trim()
if (!text.startsWith('{') && !text.startsWith('[')) return undefined
try {
return explicitSuccessFlag(JSON.parse(text))
} catch {
return undefined
}
}
function teamLifecycleSucceeded(
result: Extract<UIMessage, { type: 'tool_result' }> | undefined,
): boolean {
if (!result || result.isError) return false
return explicitSuccessFlag(result.content) !== false
}
function projectMessagesToTaskScope(
messages: UIMessage[],
taskScope: 'run' | 'team' | 'team-session',
teamTaskWindows: Array<{ startedAt: number; endedAt?: number }>,
): UIMessage[] {
if (taskScope === 'run') return messages
const sharedTaskToolUseIds = new Set<string>()
const resultsByToolUseId = collectToolResults(messages)
let transcriptTeamCursor = EMPTY_TEAM_LIFECYCLE_CURSOR
for (const message of messages) {
if (taskScope === 'team-session' && message.type === 'tool_use') {
const lifecycleSucceeded = teamLifecycleSucceeded(
resultsByToolUseId.get(message.toolUseId),
)
if (message.toolName === 'TeamCreate' && lifecycleSucceeded) {
transcriptTeamCursor = updateTeamLifecycleCursor(
true,
message.timestamp,
)
}
if (message.toolName === 'TeamDelete' && lifecycleSucceeded) {
transcriptTeamCursor = updateTeamLifecycleCursor(
false,
message.timestamp,
)
}
}
if (
message.type === 'tool_use' &&
(message.toolName === 'TaskCreate' || message.toolName === 'TaskUpdate') &&
(
taskScope === 'team' ||
(
isTeamLifecycleScopedAt(
message.timestamp,
transcriptTeamCursor,
teamTaskWindows,
)
)
)
) {
sharedTaskToolUseIds.add(message.toolUseId)
}
}
return messages.filter((message) => {
if (
message.type === 'tool_use' &&
(message.toolName === 'TaskCreate' || message.toolName === 'TaskUpdate')
) {
return !sharedTaskToolUseIds.has(message.toolUseId)
}
return message.type !== 'tool_result' || !sharedTaskToolUseIds.has(message.toolUseId)
})
}
/**
* TaskUpdate 的 deleted 是删除动作而非状态,删除可能发生在创建它的那一轮之后,
* 所以要跨轮次收集,避免已删任务留在历史统计里。
@@ -474,20 +618,164 @@ function agentToolLabel(toolCall: Extract<UIMessage, { type: 'tool_use' }>): str
)
}
function buildAgentRowsFromMessages(messages: UIMessage[]): ActivityRow[] {
function parseToolResultRecord(
result: Extract<UIMessage, { type: 'tool_result' }> | undefined,
): Record<string, unknown> | null {
if (!result || result.isError) return null
if (isRecordValue(result.content)) return result.content
const text = extractTextContent(result.content).trim()
if (!text.startsWith('{')) return null
try {
const parsed = JSON.parse(text) as unknown
return isRecordValue(parsed) ? parsed : null
} catch {
return null
}
}
function updatedActiveTeamName(
toolCall: Extract<UIMessage, { type: 'tool_use' }>,
result: Extract<UIMessage, { type: 'tool_result' }> | undefined,
): string | null | undefined {
const output = parseToolResultRecord(result)
if (!output) return undefined
if (toolCall.toolName === 'TeamCreate') {
if (output.success === false) return undefined
return stringField(output, 'team_name') || undefined
}
if (toolCall.toolName === 'TeamDelete' && output.success === true) {
return null
}
return undefined
}
type TeamSpawnIdentity = {
memberName: string
teamName?: string
}
function teamSpawnIdentity(
toolCall: Extract<UIMessage, { type: 'tool_use' }>,
result: Extract<UIMessage, { type: 'tool_result' }> | undefined,
activeTeamName: string | undefined,
teamScoped: boolean,
): TeamSpawnIdentity | null {
const input = isRecordValue(toolCall.input) ? toolCall.input : {}
const inputMemberName = stringField(input, 'name')
const inputTeamName = stringField(input, 'team_name')
if (inputMemberName && (inputTeamName || teamScoped)) {
return {
memberName: inputMemberName,
...((inputTeamName || (teamScoped && activeTeamName))
? { teamName: inputTeamName || activeTeamName }
: {}),
}
}
const output = parseToolResultRecord(result)
if (stringField(output ?? {}, 'status') === 'teammate_spawned') {
const memberName = stringField(output ?? {}, 'name') || inputMemberName
if (!memberName) return null
const outputTeamName = stringField(output ?? {}, 'team_name')
return {
memberName,
...((outputTeamName || activeTeamName)
? { teamName: outputTeamName || activeTeamName }
: {}),
}
}
const fields = new Map<string, string>()
for (const line of extractTextContent(result?.content).split(/\r?\n/)) {
const match = /^\s*([a-z_]+):\s*(\S.*?)\s*$/.exec(line)
const key = match?.[1]
const value = match?.[2]
if (key && value) fields.set(key, value)
}
const resultTeamName = fields.get('team_name')
if (!fields.get('agent_id') || !fields.get('name') || !resultTeamName) return null
return {
memberName: fields.get('name')!,
teamName: resultTeamName,
}
}
function buildAgentRowsFromMessages(
messages: UIMessage[],
teamTaskWindows: Array<{ startedAt: number; endedAt?: number }>,
): ActivityRow[] {
const resultsByToolUseId = new Map<string, Extract<UIMessage, { type: 'tool_result' }>>()
const toolCallsByToolUseId = new Map<string, Extract<UIMessage, { type: 'tool_use' }>>()
for (const message of messages) {
if (message.type === 'tool_result') {
resultsByToolUseId.set(message.toolUseId, message)
} else if (message.type === 'tool_use') {
toolCallsByToolUseId.set(message.toolUseId, message)
}
}
const rows: ActivityRow[] = []
let activeTeamName: string | undefined
let transcriptTeamCursor = EMPTY_TEAM_LIFECYCLE_CURSOR
for (const message of messages) {
if (message.type !== 'tool_use' || message.toolName !== 'Agent') continue
if (message.type === 'tool_result') {
const toolCall = toolCallsByToolUseId.get(message.toolUseId)
if (toolCall) {
const updatedTeamName = updatedActiveTeamName(toolCall, message)
if (updatedTeamName !== undefined) activeTeamName = updatedTeamName ?? undefined
}
continue
}
if (message.type !== 'tool_use') continue
const result = resultsByToolUseId.get(message.toolUseId)
const lifecycleSucceeded = teamLifecycleSucceeded(result)
if (message.toolName === 'TeamCreate' && lifecycleSucceeded) {
transcriptTeamCursor = updateTeamLifecycleCursor(true, message.timestamp)
} else if (message.toolName === 'TeamDelete' && lifecycleSucceeded) {
transcriptTeamCursor = updateTeamLifecycleCursor(false, message.timestamp)
}
if (message.toolName !== 'Agent') continue
const resultText = result ? stripAgentMetadata(extractTextContent(result.content)) : ''
const teamIdentity = teamSpawnIdentity(
message,
result,
activeTeamName,
isTeamLifecycleScopedAt(
message.timestamp,
transcriptTeamCursor,
teamTaskWindows,
),
)
// Agent inputs stream before their ownership fields. Until the input is
// complete, treating an unknown owner as direct makes Team members flash
// through the main session's SubAgents section.
if (message.isPending && !teamIdentity) continue
if (teamIdentity) {
rows.push({
id: message.toolUseId,
section: 'team',
label: teamIdentity.memberName,
status: message.status === 'stopped'
? 'stopped'
: result?.isError
? 'failed'
: 'running',
description: agentToolLabel(message),
summary: resultText ? compactText(resultText) : undefined,
toolUseId: message.toolUseId,
taskType: 'in_process_teammate',
...(teamIdentity.teamName ? { teamName: teamIdentity.teamName } : {}),
teamMemberName: teamIdentity.memberName,
...(teamIdentity.teamName ? { teamStartedAt: message.timestamp } : {}),
updatedAt: result?.timestamp ?? message.timestamp,
openable: Boolean(teamIdentity.teamName),
})
continue
}
rows.push({
id: message.toolUseId,
section: 'subagents',
@@ -669,17 +957,21 @@ function buildHistoricalTasksRow(groups: TaskTurnRows[]): ActivityRow | null {
}
}
function buildTaskRowsFromMessages(messages: UIMessage[], liveTasks: CLITask[]): ActivityRow[] {
const subagentCreatedTaskIds = collectSubagentCreatedTaskIds(messages)
const sessionMessages = messages.filter(keepSessionLevelTaskMessage)
const deletedTaskIds = collectDeletedTaskIds(messages)
const resultsByToolUseId = collectToolResults(sessionMessages)
function buildTaskRowsFromMessages(
runMessages: UIMessage[],
liveTasks: CLITask[],
taskScope: 'run' | 'team' | 'team-session',
teamTaskWindows: Array<{ startedAt: number; endedAt?: number }>,
): ActivityRow[] {
const taskMessages = projectMessagesToTaskScope(runMessages, taskScope, teamTaskWindows)
const deletedTaskIds = collectDeletedTaskIds(taskMessages)
const resultsByToolUseId = collectToolResults(taskMessages)
const isSessionTaskRow = (row: ActivityRow) => row.taskId
? !deletedTaskIds.has(row.taskId) && !subagentCreatedTaskIds.has(row.taskId)
? !deletedTaskIds.has(row.taskId)
: true
// 任务列表要等 tool_result 到达后才异步刷新,这中间 liveTasks 里还留着已删的任务
const liveRows = liveTasks.map(buildTaskRow).filter(isSessionTaskRow)
const taskTurnRows = splitMessagesIntoTurns(sessionMessages)
const taskTurnRows = splitMessagesIntoTurns(taskMessages)
.map((turn) => {
const builtRows = buildTaskRowsFromTurnMessages(turn.messages, resultsByToolUseId)
return {
@@ -759,6 +1051,97 @@ function mergeNotificationRow(existing: ActivityRow | undefined, notification: A
}
}
/**
* Flatten a workflow run into phase headers each followed by its agents.
*
* The agents are ordinary subagents, so every row carries the reference the
* existing subagent page opens with — there is nothing workflow-specific to
* render for one of them. An agent that has not been given a concurrency slot
* yet has no transcript to open, so it is listed but not openable.
*/
function buildWorkflowRows(run: WorkflowRun): ActivityRow[] {
const phaseTitles = new Map<number, string>()
const agentsByPhase = new Map<number, WorkflowAgentEvent[]>()
for (const event of run.progress) {
if (event.type === 'workflow_phase') {
if (!phaseTitles.has(event.index)) phaseTitles.set(event.index, event.title)
if (!agentsByPhase.has(event.index)) agentsByPhase.set(event.index, [])
continue
}
const phaseIndex = event.phaseIndex ?? 0
if (!phaseTitles.has(phaseIndex)) {
phaseTitles.set(phaseIndex, event.phaseTitle ?? '')
}
const bucket = agentsByPhase.get(phaseIndex) ?? []
bucket.push(event)
agentsByPhase.set(phaseIndex, bucket)
}
const rows: ActivityRow[] = []
for (const [phaseIndex, title] of [...phaseTitles.entries()].sort(([a], [b]) => a - b)) {
const agents = (agentsByPhase.get(phaseIndex) ?? [])
.slice()
.sort((a, b) => a.index - b.index)
if (agents.length === 0 && !title) continue
// Agents emitted before any `phase()` call — and every agent of a run
// recorded before phases were persisted — have no title. The run's name
// says more about them than "Phase 0" does, and it also tells two runs in
// the same session apart.
const groupLabel = title || run.workflowName
const done = agents.filter(
agent => agent.state === 'done' || agent.state === 'error',
).length
rows.push({
id: `${run.taskId}-phase-${phaseIndex}`,
section: 'workflow',
label: groupLabel,
status: workflowPhaseStatus(agents),
groupProgress: { done, total: agents.length },
workflowName: run.workflowName,
openable: false,
})
for (const agent of agents) {
rows.push({
id: `${run.taskId}-agent-${agent.index}`,
section: 'workflow',
label: agent.label,
status: workflowAgentStatus(agent),
cached: agent.cached,
group: groupLabel,
summary: agent.resultPreview,
toolUseId: agent.agentId ? toAgentIdRef(agent.agentId) : undefined,
taskType: 'local_agent',
workflowName: run.workflowName,
usage: agent.tokens ? { totalTokens: agent.tokens } : undefined,
openable: Boolean(agent.agentId),
})
}
}
return rows
}
function workflowAgentStatus(agent: WorkflowAgentEvent): ActivityStatus {
if (agent.state === 'done') return 'completed'
if (agent.state === 'error') return 'failed'
if (agent.state === 'progress') return 'running'
return 'pending'
}
function workflowPhaseStatus(agents: WorkflowAgentEvent[]): ActivityStatus {
if (agents.length === 0) return 'pending'
if (agents.some(agent => agent.state === 'progress')) return 'running'
if (agents.every(agent => agent.state === 'done' || agent.state === 'error')) {
return agents.some(agent => agent.state === 'error') ? 'failed' : 'completed'
}
return agents.some(agent => agent.state === 'done' || agent.state === 'error')
? 'running'
: 'pending'
}
function buildOutputRow(key: string, outputFile: string): ActivityRow {
return {
id: `output-${key}`,
@@ -773,25 +1156,42 @@ function buildOutputRow(key: string, outputFile: string): ActivityRow {
export function buildSessionActivityModel(input: BuildSessionActivityModelInput): SessionActivityModel {
const sections = createEmptySections()
let badgeCount = 0
const taskRows = buildTaskRowsFromMessages(input.messages ?? [], input.tasks)
sections.tasks.rows = input.isForegroundTurnActive === false
? sealUnfinishedTaskRows(taskRows)
: taskRows
const includeTeamActivity = input.includeTeamActivity !== false
const runMessages = projectMessagesToRun(input.messages ?? [], input.runScope ?? 'session')
const runTaskRows = buildTaskRowsFromMessages(
runMessages,
input.tasks,
input.taskScope ?? 'run',
input.teamTaskWindows ?? [],
)
const settledRunTaskRows = input.isForegroundTurnActive === false
? sealUnfinishedTaskRows(runTaskRows)
: runTaskRows
const teamTaskRows = includeTeamActivity
? input.teamTasks?.map(buildTeamTaskRow) ?? []
: []
sections.tasks.rows = [...settledRunTaskRows, ...teamTaskRows]
for (const row of sections.tasks.rows) {
if (isBadgeStatus(row.status)) {
badgeCount += 1
}
}
for (const member of input.teamMembers ?? []) {
sections.team.rows.push(buildTeamRow(member))
for (const run of input.workflowRuns ?? []) {
sections.workflow.rows.push(...buildWorkflowRows(run))
}
for (const row of sections.team.rows) {
if (isBadgeStatus(row.status)) {
for (const row of sections.workflow.rows) {
if (isBadgeStatus(row.status) && !row.groupProgress) {
badgeCount += 1
}
}
if (includeTeamActivity) {
for (const member of input.teamMembers ?? []) {
sections.team.rows.push(buildTeamRow(member))
}
}
const subagentRowsByKey = new Map<string, ActivityRow>()
const subagentKeyByTaskId = new Map<string, string>()
const outputRowsByKey = new Map<string, ActivityRow>()
@@ -799,12 +1199,39 @@ export function buildSessionActivityModel(input: BuildSessionActivityModelInput)
const dismissedNotificationKeys = new Set<string>()
const dismissedNotificationTaskIds = new Set<string>()
const visibleBackgroundTaskIds = new Set<string>()
for (const row of buildAgentRowsFromMessages(input.messages ?? [])) {
const hiddenChildTaskIds = new Set<string>()
const knownTeamMemberNames = new Set(
(input.teamMembers ?? []).flatMap((member) => [
member.name,
member.agentId.split('@')[0],
]).filter((name): name is string => Boolean(name)),
)
const teamLaunchRowsByMember = new Map<string, ActivityRow>()
for (const row of buildAgentRowsFromMessages(runMessages, input.teamTaskWindows ?? [])) {
if (row.section === 'team') {
if (!includeTeamActivity) continue
if (row.teamMemberName && knownTeamMemberNames.has(row.teamMemberName)) continue
const key = row.teamName && row.teamMemberName
? `${row.teamName}:${row.teamMemberName}`
: row.id
teamLaunchRowsByMember.set(key, row)
continue
}
subagentRowsByKey.set(row.id, mergeSubagentRow(subagentRowsByKey.get(row.id), row))
}
sections.team.rows.push(...teamLaunchRowsByMember.values())
for (const row of sections.team.rows) {
if (isBadgeStatus(row.status)) {
badgeCount += 1
}
}
for (const task of input.backgroundTasks) {
if (!isVisibleSessionBackgroundTask(task)) {
hiddenChildTaskIds.add(task.taskId)
continue
}
const dismissKey = createBackgroundTaskDismissKey(task)
if (task.status !== 'running' && dismissedBackgroundTaskKeys.has(dismissKey)) {
const key = activityKey(task)
@@ -833,6 +1260,8 @@ export function buildSessionActivityModel(input: BuildSessionActivityModelInput)
}
for (const notification of input.agentNotifications) {
if (hiddenChildTaskIds.has(notification.taskId)) continue
const key = notificationKey(notification)
if (
dismissedNotificationKeys.has(key) ||
@@ -887,3 +1316,19 @@ export function buildSessionActivityModel(input: BuildSessionActivityModelInput)
sections,
}
}
/**
* Main-session Activity is the lead agent's run projection. AgentTeam owns a
* separate strip/workbench, so its shared DAG, roster and launch rows cannot
* enter this model or affect the toolbar badge/auto-open state.
*/
export function buildMainSessionActivityModel(
input: BuildMainSessionActivityModelInput,
): SessionActivityModel {
return buildSessionActivityModel({
...input,
runScope: 'session',
taskScope: 'team-session',
includeTeamActivity: false,
})
}
@@ -0,0 +1,357 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSettingsStore } from '../../stores/settingsStore'
import type { TeamWorkbenchSnapshot, TeamWorkbenchTask } from '../../types/team'
import { AgentTeamsCanvas, type AgentTeamsCanvasProps } from './AgentTeamsCanvas'
function task(
id: string,
status: TeamWorkbenchTask['status'],
blockedBy: string[] = [],
owner?: string,
): TeamWorkbenchTask {
return {
id,
subject: `Task ${id}`,
description: `Task ${id} detail`,
activeForm: status === 'in_progress' ? `Working on task ${id}` : undefined,
owner,
status,
blocks: [],
blockedBy,
taskListId: 'canvas-team',
}
}
function snapshot(version: 'previous' | 'current'): TeamWorkbenchSnapshot {
const current = version === 'current'
return {
version,
generatedAt: current ? '2026-08-12T02:30:00.000Z' : '2026-08-12T02:29:00.000Z',
team: {
name: 'canvas-team',
leadAgentId: 'team-lead@canvas-team',
members: [
{ agentId: 'team-lead@canvas-team', name: 'team-lead', role: 'Lead', status: 'running' },
{ agentId: 'builder@canvas-team', name: 'builder', role: 'Frontend', status: 'running', activity: 'active' },
{ agentId: 'reviewer@canvas-team', name: 'reviewer', role: 'Review', status: 'idle', activity: 'idle' },
{ agentId: 'qa@canvas-team', name: 'qa', role: 'QA', status: current ? 'completed' : 'running', activity: current ? 'exited' : 'idle' },
],
},
tasks: current
? [
task('1', 'completed', [], 'builder'),
task('2', 'in_progress', ['1'], 'builder'),
task('3', 'pending', ['2'], 'reviewer'),
task('4', 'completed', ['1'], 'qa'),
]
: [
task('1', 'in_progress', [], 'builder'),
task('2', 'pending', ['1'], 'builder'),
task('3', 'pending', ['2'], 'reviewer'),
task('4', 'pending', ['1'], 'qa'),
],
messages: [
{
id: 'peer-message',
from: 'builder',
to: 'reviewer',
recipients: ['reviewer'],
kind: 'direct',
text: 'The first task is ready for review.',
timestamp: '2026-08-12T02:29:30.000Z',
},
{
id: 'lead-message',
from: 'team-lead',
to: 'builder',
recipients: ['builder'],
kind: 'system',
text: '{"type":"task_assignment","taskId":"2","subject":"Task 2"}',
protocolType: 'task_assignment',
taskId: '2',
timestamp: '2026-08-12T02:29:40.000Z',
},
{
id: 'claim-message',
from: 'builder',
to: 'builder',
recipients: ['builder'],
kind: 'system',
text: '{"type":"task_assignment","taskId":"2","subject":"Task 2"}',
protocolType: 'task_assignment',
taskId: '2',
timestamp: '2026-08-12T02:29:50.000Z',
},
],
}
}
function props(overrides: Partial<AgentTeamsCanvasProps> = {}): AgentTeamsCanvasProps {
const previousSnapshot = snapshot('previous')
const currentSnapshot = snapshot('current')
return {
snapshots: [previousSnapshot, currentSnapshot],
selectedIndex: 1,
snapshot: currentSnapshot,
previousSnapshot,
leadIsStreaming: true,
activeMessageId: 'peer-message',
selectedMemberId: null,
onSelectMember: vi.fn(),
onSelectTask: vi.fn(),
...overrides,
}
}
describe('AgentTeamsCanvas', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
})
it('renders the fixed formation over horizontal dependency lanes with one node per member', () => {
const { container } = render(<AgentTeamsCanvas {...props()} />)
expect(screen.getByTestId('agent-teams-formation-layer')).toBeTruthy()
expect(screen.getByTestId('agent-teams-task-layer')).toBeTruthy()
expect(screen.getByText('1 · Formation — lead centered, teammates below')).toBeTruthy()
expect(screen.getByText('2 · Shared task list — dependency layers, left → right')).toBeTruthy()
const lead = screen.getByTestId('agent-teams-canvas-member-team-lead@canvas-team')
const builder = screen.getByTestId('agent-teams-canvas-member-builder@canvas-team')
expect(lead.style.top).toBe('20px')
expect(builder.style.top).toBe('222px')
expect(lead.textContent).toContain('Coordinating · awaiting reports')
expect(builder.textContent).toContain('Executing #2')
expect(container.querySelectorAll('[data-testid="agent-teams-canvas-member-builder@canvas-team"]')).toHaveLength(1)
expect(container.querySelectorAll('[data-testid^="agent-teams-canvas-member-"]')).toHaveLength(4)
expect(container.querySelectorAll('[data-testid^="agent-teams-canvas-lane-"]')).toHaveLength(3)
expect(screen.getByTestId('agent-teams-canvas-task-1').style.top).toBe('442px')
expect(screen.getByTestId('agent-teams-canvas-task-1').className).toContain('h-[92px]')
expect(screen.getByTestId('agent-teams-canvas-task-1').className).toContain('w-[200px]')
expect(screen.getByTestId('agent-teams-legend').style.top).toBe('684px')
})
it('draws dependency state, a maximum-one-per-active-member tether, and indeterminate real work', () => {
const { container } = render(<AgentTeamsCanvas {...props()} />)
expect(container.querySelectorAll('[data-testid^="agent-teams-canvas-edge-"]')).toHaveLength(3)
const unlocked = screen.getByTestId('agent-teams-canvas-edge-1-2')
expect(unlocked.getAttribute('data-edge-satisfied')).toBe('true')
expect(unlocked.getAttribute('data-edge-fresh')).toBe('true')
expect(unlocked.getAttribute('stroke')).toBe('var(--color-success)')
expect(screen.getByTestId('agent-teams-canvas-edge-2-3').getAttribute('data-edge-satisfied')).toBe('false')
expect(screen.getByTestId('agent-teams-canvas-tether-builder@canvas-team').getAttribute('data-task-id')).toBe('2')
expect(screen.queryByTestId('agent-teams-canvas-tether-reviewer@canvas-team')).toBeNull()
expect(
screen.getByTestId('agent-teams-canvas-task-2').querySelector('[data-progress="indeterminate"]')?.className,
).toContain('agent-teams-task-running-fill')
fireEvent.mouseEnter(screen.getByTestId('agent-teams-canvas-task-3'))
expect(container.querySelectorAll('[data-edge-active="true"]').length).toBeGreaterThan(0)
fireEvent.mouseLeave(screen.getByTestId('agent-teams-canvas-task-3'))
expect(container.querySelectorAll('[data-edge-active="true"]')).toHaveLength(0)
})
it('uses the transient unlocked label only while the task is actually open', () => {
const previousSnapshot = snapshot('previous')
const openSnapshot = snapshot('current')
openSnapshot.tasks = openSnapshot.tasks.map(entry => (
entry.id === '2' ? { ...entry, status: 'pending' as const } : entry
))
const { rerender } = render(<AgentTeamsCanvas {...props({
snapshots: [previousSnapshot, openSnapshot],
snapshot: openSnapshot,
previousSnapshot,
activeMessageId: null,
})} />)
const unlockedTask = screen.getByTestId('agent-teams-canvas-task-2')
expect(unlockedTask.textContent).toContain('Just unlocked')
const unlockedPill = Array.from(unlockedTask.querySelectorAll('span'))
.find(element => element.textContent === 'Just unlocked') as HTMLElement
expect(unlockedPill.style.color).toBe('var(--color-on-success-container)')
const completedTask = screen.getByTestId('agent-teams-canvas-task-1')
const completedPill = Array.from(completedTask.querySelectorAll('span'))
.find(element => element.textContent === 'Completed') as HTMLElement
expect(completedPill.style.color).toBe('var(--color-on-success-container)')
const runningSnapshot = snapshot('current')
rerender(<AgentTeamsCanvas {...props({
snapshots: [previousSnapshot, runningSnapshot],
snapshot: runningSnapshot,
previousSnapshot,
activeMessageId: null,
})} />)
expect(screen.getByTestId('agent-teams-canvas-task-2').textContent).toContain('In progress')
expect(screen.getByTestId('agent-teams-canvas-task-2').textContent).not.toContain('Just unlocked')
})
it('uses accessible controls and calls the task and member selection contracts', () => {
const onSelectMember = vi.fn()
const onSelectTask = vi.fn()
render(<AgentTeamsCanvas {...props({ onSelectMember, onSelectTask, selectedMemberId: 'builder' })} />)
const builder = screen.getByRole('button', { name: 'builder' })
expect(builder.getAttribute('aria-pressed')).toBe('true')
fireEvent.click(builder)
expect(onSelectMember).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'builder@canvas-team' }),
false,
)
fireEvent.click(screen.getByRole('button', { name: 'team-lead' }))
expect(onSelectMember).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'team-lead@canvas-team' }),
true,
)
const runningTask = screen.getByRole('button', { name: 'Task 2, In progress' })
fireEvent.focus(runningTask)
fireEvent.click(runningTask)
expect(onSelectTask).toHaveBeenCalledWith(expect.objectContaining({ id: '2' }))
})
it('routes the active capsule through peer, lead, and self-claim channels', () => {
const base = props()
const { rerender } = render(<AgentTeamsCanvas {...base} />)
expect(screen.getByTestId('agent-teams-active-flight').getAttribute('data-flight-channel')).toBe('peer')
expect(screen.getByTestId('agent-teams-active-flight').textContent).toContain('DIRECT')
expect(screen.getByTestId('agent-teams-active-flight').className).toContain('agent-teams-flight')
expect(screen.getByTestId('agent-teams-canvas-member-builder@canvas-team').querySelector('.agent-teams-member-ring')).toBeTruthy()
expect(screen.getByTestId('agent-teams-canvas-member-reviewer@canvas-team').querySelector('.agent-teams-member-ring')).toBeTruthy()
rerender(<AgentTeamsCanvas {...base} activeMessageId="lead-message" />)
expect(screen.getByTestId('agent-teams-active-flight').getAttribute('data-flight-channel')).toBe('lead')
expect(screen.getByTestId('agent-teams-active-flight').textContent).toContain('Assignments')
rerender(<AgentTeamsCanvas {...base} activeMessageId="claim-message" />)
expect(screen.getByTestId('agent-teams-active-flight').getAttribute('data-flight-channel')).toBe('claim')
expect(screen.getByTestId('agent-teams-active-flight-path').getAttribute('data-flight-channel')).toBe('claim')
})
it('routes a broadcast to every recipient on the matching lead or peer channel', () => {
const current = snapshot('current')
current.messages = [
...current.messages,
{
id: 'lead-broadcast',
from: 'team-lead',
to: '*',
recipients: ['builder', 'reviewer', 'qa'],
kind: 'broadcast',
text: 'Check in now.',
timestamp: '2026-08-12T02:29:55.000Z',
},
{
id: 'worker-broadcast',
from: 'builder',
to: '*',
recipients: ['team-lead', 'reviewer', 'qa'],
kind: 'broadcast',
text: 'The implementation is ready.',
timestamp: '2026-08-12T02:29:56.000Z',
},
]
const base = props({
snapshots: [current],
selectedIndex: 0,
snapshot: current,
previousSnapshot: undefined,
})
const { rerender } = render(<AgentTeamsCanvas {...base} activeMessageId="lead-broadcast" />)
expect(screen.getAllByTestId('agent-teams-active-flight')).toHaveLength(3)
expect(screen.getAllByTestId('agent-teams-active-flight').map(element => (
element.getAttribute('data-flight-channel')
))).toEqual(['lead', 'lead', 'lead'])
expect(screen.getAllByTestId('agent-teams-active-flight').every(element => (
element.textContent?.includes('Assignments')
))).toBe(true)
rerender(<AgentTeamsCanvas {...base} activeMessageId="worker-broadcast" />)
expect(screen.getAllByTestId('agent-teams-active-flight')).toHaveLength(3)
expect(screen.getAllByTestId('agent-teams-active-flight').map(element => (
element.getAttribute('data-flight-channel')
)).sort()).toEqual(['lead', 'peer', 'peer'])
expect(screen.getAllByTestId('agent-teams-active-flight').every(element => (
element.textContent?.includes('Reports')
))).toBe(true)
})
it('keeps external task focus until an internal card hover temporarily takes over', () => {
const current = snapshot('current')
current.tasks = [...current.tasks, task('5', 'pending', [], 'qa')]
const base = props({
snapshots: [current],
selectedIndex: 0,
snapshot: current,
previousSnapshot: undefined,
activeMessageId: null,
focusedTaskId: '3',
})
render(<AgentTeamsCanvas {...base} />)
const externalFocus = screen.getByTestId('agent-teams-canvas-task-3')
const independentTask = screen.getByTestId('agent-teams-canvas-task-5')
expect(externalFocus.getAttribute('data-chain-active')).toBe('true')
expect(independentTask.style.opacity).toBe('0.34')
fireEvent.mouseEnter(independentTask)
expect(independentTask.getAttribute('data-chain-active')).toBe('true')
expect(externalFocus.style.opacity).toBe('0.34')
fireEvent.focus(independentTask)
fireEvent.mouseLeave(independentTask)
expect(independentTask.getAttribute('data-chain-active')).toBe('true')
expect(externalFocus.style.opacity).toBe('0.34')
fireEvent.blur(independentTask)
expect(externalFocus.getAttribute('data-chain-active')).toBe('true')
expect(independentTask.style.opacity).toBe('0.34')
})
it('draws every active member tether even when more than five workers are running', () => {
const current = snapshot('current')
const workers = Array.from({ length: 6 }, (_, index) => ({
agentId: `worker-${index + 1}@canvas-team`,
name: `worker-${index + 1}`,
role: 'Reviewer',
status: 'running' as const,
activity: 'active' as const,
}))
current.team.members = [current.team.members[0]!, ...workers]
current.tasks = workers.map((member, index) => task(
String(index + 1),
'in_progress',
[],
member.name,
))
current.messages = []
const { container } = render(<AgentTeamsCanvas {...props({
snapshots: [current],
selectedIndex: 0,
snapshot: current,
previousSnapshot: undefined,
activeMessageId: null,
})} />)
expect(container.querySelectorAll('[data-testid^="agent-teams-canvas-member-worker-"]')).toHaveLength(6)
expect(container.querySelectorAll('[data-testid^="agent-teams-canvas-tether-worker-"]')).toHaveLength(6)
})
it('hides indeterminate fill under reduced motion instead of implying a fixed percentage', () => {
const css = readFileSync(join(__dirname, '../../theme/globals.css'), 'utf8')
expect(css).toMatch(
/@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.agent-teams-task-running-fill\s*\{[^}]*opacity:\s*0;/,
)
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,269 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSettingsStore } from '../../stores/settingsStore'
import type { TeamMember, TeamWorkbenchMessage, TeamWorkbenchSnapshot } from '../../types/team'
import { AgentTeamsCommunicationFeed } from './AgentTeamsCommunicationFeed'
function message(overrides: Partial<TeamWorkbenchMessage> & { id: string }): TeamWorkbenchMessage {
return {
from: 'builder',
to: 'reviewer',
recipients: ['reviewer'],
kind: 'direct',
text: 'body',
timestamp: '2026-08-08T07:42:16.666Z',
...overrides,
}
}
const MEMBERS: TeamMember[] = [
{ agentId: 'lead@team-a', name: 'lead', role: 'orchestrator', status: 'running' },
{ agentId: 'builder@team-a', name: 'builder', role: 'server engineer', status: 'running' },
{ agentId: 'reviewer@team-a', name: 'reviewer', role: 'qa engineer', status: 'idle' },
]
function snapshot(
messages: TeamWorkbenchMessage[],
members: TeamMember[] = MEMBERS,
): TeamWorkbenchSnapshot {
return {
version: 'v1',
generatedAt: '2026-08-08T08:00:00.000Z',
team: {
name: 'team-a',
leadAgentId: 'lead@team-a',
leadSessionId: 'lead-session',
members,
},
tasks: [],
messages,
}
}
function categoryOf(id: string): string | null {
return screen.getByTestId(`agent-teams-message-${id}`).getAttribute('data-message-category')
}
describe('AgentTeamsCommunicationFeed', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
})
it('maps real CLI routes into assignment, peer, report, and system categories', () => {
render(<AgentTeamsCommunicationFeed snapshot={snapshot([
message({
id: 'protocol-assignment',
from: 'lead',
to: 'builder@team-a',
recipients: ['builder@team-a'],
kind: 'system',
protocolType: 'task_assignment',
taskId: '2',
text: '{"type":"task_assignment","taskId":"2","subject":"Build it"}',
}),
message({
id: 'self-claim',
from: 'builder',
to: 'builder@team-a',
recipients: ['builder@team-a'],
kind: 'system',
protocolType: 'task_assignment',
taskId: '3',
text: '{"type":"task_assignment","taskId":"3","subject":"Claim it"}',
}),
message({ id: 'lead-direct', from: 'lead', to: 'builder@team-a', recipients: ['builder@team-a'], text: 'Please own the API.' }),
message({ id: 'peer-direct', from: 'builder@team-a', to: 'reviewer', recipients: ['reviewer'], text: 'Can you verify the route?' }),
message({ id: 'worker-report', from: 'builder', to: 'lead@team-a', recipients: ['lead@team-a'], text: 'API implementation is ready.' }),
message({
id: 'lifecycle',
from: 'builder@team-a',
to: 'team-lead@team-a',
recipients: ['team-lead@team-a'],
kind: 'system',
text: '{"type":"idle_notification","idleReason":"available"}',
}),
message({ id: 'lead-broadcast', from: 'lead@team-a', kind: 'broadcast', to: '*', recipients: ['*'], text: 'Everyone take the next ready task.' }),
message({ id: 'worker-broadcast', from: 'reviewer@team-a', kind: 'broadcast', to: '*', recipients: ['*'], text: 'Review summary is published.' }),
])} />)
expect(categoryOf('protocol-assignment')).toBe('assignment')
expect(categoryOf('self-claim')).toBe('system')
expect(categoryOf('lead-direct')).toBe('assignment')
expect(categoryOf('lead-broadcast')).toBe('assignment')
expect(categoryOf('peer-direct')).toBe('peer')
expect(categoryOf('worker-report')).toBe('report')
expect(categoryOf('worker-broadcast')).toBe('report')
expect(categoryOf('lifecycle')).toBe('system')
expect(within(screen.getByTestId('agent-teams-message-worker-report')).getByText('Reports')).toBeTruthy()
expect(within(screen.getByTestId('agent-teams-message-peer-direct')).getByText('Direct').className).toContain('color-tertiary')
expect(within(screen.getByTestId('agent-teams-message-worker-report')).getByText('Reports').className).toContain('color-text-secondary')
expect(screen.getByText('Review summary is published.')).toBeTruthy()
})
it('always shows the five semantic filters and filters without using transport kind', () => {
render(<AgentTeamsCommunicationFeed snapshot={snapshot([
message({ id: 'assignment', from: 'lead', to: 'builder', recipients: ['builder'], text: 'Take task one.' }),
message({ id: 'peer', from: 'builder', to: 'reviewer', recipients: ['reviewer'], text: 'Private review.' }),
message({ id: 'report', from: 'builder', to: 'lead', recipients: ['lead'], text: 'Task one is done.' }),
message({ id: 'system', kind: 'system', protocolType: 'custom_event', text: '{"type":"custom_event","message":"Context loaded"}' }),
])} />)
expect(screen.getByTestId('agent-teams-message-count').textContent).toBe('4 messages')
expect(screen.getByTestId('agent-teams-filter-all')).toBeTruthy()
expect(screen.getByTestId('agent-teams-filter-assignment').getAttribute('data-count')).toBe('1')
expect(screen.getByTestId('agent-teams-filter-peer').getAttribute('data-count')).toBe('1')
expect(screen.getByTestId('agent-teams-filter-report').getAttribute('data-count')).toBe('1')
expect(screen.getByTestId('agent-teams-filter-system').getAttribute('data-count')).toBe('1')
for (const category of ['assignment', 'peer', 'report', 'system'] as const) {
fireEvent.click(screen.getByTestId(`agent-teams-filter-${category}`))
expect(screen.getByTestId(`agent-teams-message-${category}`)).toBeTruthy()
for (const other of ['assignment', 'peer', 'report', 'system']) {
if (other !== category) expect(screen.queryByTestId(`agent-teams-message-${other}`)).toBeNull()
}
}
})
it('narrates assignment and lifecycle protocols in one line without leaking JSON', () => {
render(<AgentTeamsCommunicationFeed snapshot={snapshot([
message({
id: 'assignment',
from: 'lead',
to: 'builder',
recipients: ['builder'],
kind: 'system',
protocolType: 'task_assignment',
taskId: '7',
text: '{"type":"task_assignment","taskId":"7","subject":"Repair queue"}',
}),
message({
id: 'idle',
from: 'builder',
to: 'lead',
recipients: ['lead'],
kind: 'system',
text: '{"type":"idle_notification","idleReason":"available"}',
}),
])} />)
expect(screen.getByText(/Assigned #7 Repair queue to builder/)).toBeTruthy()
expect(screen.getByText(/Went idle, waiting for work · available/)).toBeTruthy()
expect(screen.getByTestId('agent-teams-message-assignment-body').className).toContain('truncate')
expect(screen.getByTestId('agent-teams-message-idle-body').className).toContain('truncate')
expect(document.body.textContent).not.toContain('"taskId"')
expect(document.body.textContent).not.toContain('idle_notification')
})
it('localizes an unknown structured protocol instead of inventing an English label', () => {
useSettingsStore.setState({ locale: 'zh' })
render(<AgentTeamsCommunicationFeed snapshot={snapshot([
message({
id: 'custom',
kind: 'system',
protocolType: 'custom_event',
text: '{"type":"custom_event","payload":42}',
}),
])} />)
expect(screen.getByTestId('agent-teams-message-custom-body').textContent).toBe('系统')
expect(document.body.textContent).not.toContain('Custom event')
})
it('keeps authored Markdown readable and uses compact 22px routing figures', () => {
render(<AgentTeamsCommunicationFeed snapshot={snapshot([
message({
id: 'markdown',
from: 'builder@team-a',
to: 'reviewer',
text: '## Finding A\n\n**Key evidence**\n\n```ts\nconst fixed = true\n```',
}),
])} />)
const row = screen.getByTestId('agent-teams-message-markdown')
expect(row.querySelector('h2')?.textContent).toBe('Finding A')
expect(row.querySelector('strong')?.textContent).toBe('Key evidence')
expect(row.textContent).not.toContain('## Finding A')
expect(screen.getByTestId('agent-teams-message-markdown-from').textContent).toBe('builder@team-a')
expect(screen.getByTestId('agent-teams-message-markdown-to').textContent).toBe('reviewer')
expect(screen.getByTestId('agent-teams-message-markdown-from-avatar').className).toContain('h-[22px]')
expect(screen.getByTestId('agent-teams-message-markdown-from-avatar').getAttribute('data-avatar-key')).toBe('server-engineer')
expect(screen.getByTestId('agent-teams-message-markdown-to-avatar').getAttribute('data-avatar-key')).toBe('security-reviewer')
})
it('keeps a long human message behind an explicit expander', () => {
render(<AgentTeamsCommunicationFeed snapshot={snapshot([
message({ id: 'long', kind: 'broadcast', to: '*', recipients: ['*'], text: 'x'.repeat(400) }),
])} />)
expect(screen.getByTestId('agent-teams-message-long-body').getAttribute('data-collapsed')).toBe('true')
fireEvent.click(screen.getByRole('button', { name: 'Show more' }))
expect(screen.getByTestId('agent-teams-message-long-body').getAttribute('data-collapsed')).toBe('false')
fireEvent.click(screen.getByRole('button', { name: 'Show less' }))
expect(screen.getByTestId('agent-teams-message-long-body').getAttribute('data-collapsed')).toBe('true')
})
it('folds adjacent duplicate lifecycle signals into the newest row', () => {
const idle = (id: string) => message({
id,
from: 'builder',
to: 'lead',
recipients: ['lead'],
kind: 'system',
text: '{"type":"idle_notification","idleReason":"available"}',
})
render(<AgentTeamsCommunicationFeed snapshot={snapshot([
message({ id: 'report', from: 'builder', to: 'lead', recipients: ['lead'], text: 'Ready for more work.' }),
idle('i1'),
idle('i2'),
idle('i3'),
])} />)
expect(screen.getByTestId('agent-teams-message-count').textContent).toBe('4 messages')
expect(screen.getByTestId('agent-teams-message-i3-repeats').textContent).toBe('×3')
expect(screen.queryByTestId('agent-teams-message-i2')).toBeNull()
expect(screen.queryByTestId('agent-teams-message-i1')).toBeNull()
})
it('keeps task hover focus and its reset callback', () => {
const onFocusTask = vi.fn()
render(<AgentTeamsCommunicationFeed
snapshot={snapshot([message({ id: 'task', taskId: '42', text: 'Please review task 42.' })])}
onFocusTask={onFocusTask}
/>)
const row = screen.getByTestId('agent-teams-message-task')
fireEvent.mouseEnter(row)
fireEvent.mouseLeave(row)
expect(onFocusTask.mock.calls).toEqual([['42'], [null]])
})
it('keeps per-message wall time but removes the local early/middle/late controls', () => {
render(<AgentTeamsCommunicationFeed snapshot={snapshot([
message({ id: 'first', text: 'first', timestamp: '2026-08-08T07:42:00.000Z' }),
message({ id: 'second', text: 'second', timestamp: '2026-08-08T09:15:00.000Z' }),
])} />)
const times = ['first', 'second'].map((id) => (
screen.getByTestId(`agent-teams-message-${id}`).querySelector('time')?.textContent
))
expect(times[0]).toBeTruthy()
expect(times[0]).not.toBe(times[1])
expect(screen.queryByTestId('agent-teams-time-range')).toBeNull()
expect(screen.queryByTestId('agent-teams-time-early')).toBeNull()
expect(screen.queryByTestId('agent-teams-time-middle')).toBeNull()
expect(screen.queryByTestId('agent-teams-time-late')).toBeNull()
expect(screen.queryByTestId('agent-teams-lifecycle-toggle')).toBeNull()
})
it('keeps route figures available when archived member metadata is missing', () => {
render(<AgentTeamsCommunicationFeed snapshot={snapshot([
message({ id: 'unknown', from: 'unknown-builder', to: 'unknown-reviewer' }),
], [])} />)
expect(screen.getByTestId('agent-teams-message-unknown-from-avatar').getAttribute('data-avatar-key')).toBeTruthy()
expect(screen.getByTestId('agent-teams-message-unknown-to-avatar').getAttribute('data-avatar-key')).toBeTruthy()
})
})
@@ -0,0 +1,527 @@
import { useMemo, useState } from 'react'
import { ArrowRight, Megaphone, MessageSquare, Settings2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { useTranslation, type TranslationKey } from '../../i18n'
import type { TeamMember, TeamWorkbenchMessage, TeamWorkbenchSnapshot } from '../../types/team'
import { MEMBER_AVATARS, memberAccentColor } from './agentTeamsAvatars'
import {
formatWorkbenchMessageTime,
getMemberAvatarKey,
parseWorkbenchMessageBody,
resolveTeamMemberIdentity,
type MemberAvatarKey,
type WorkbenchMessageBody,
} from './agentTeamsModel'
type TranslationFn = ReturnType<typeof useTranslation>
type FeedCategory = 'assignment' | 'peer' | 'report' | 'system'
type FeedFilter = 'all' | FeedCategory
/** Beyond this the body collapses behind a "show more" toggle. */
const COLLAPSED_BODY_CHARS = 260
type ParsedMessageRow = {
message: TeamWorkbenchMessage
body: WorkbenchMessageBody
category: FeedCategory
}
type MessageParticipant = {
member: TeamMember
avatarKey: MemberAvatarKey
accent: string
}
function resolveParticipant(
value: string,
snapshot: TeamWorkbenchSnapshot,
fallbackIndex: number,
): MessageParticipant {
const { member, isLead } = resolveTeamMemberIdentity(snapshot.team, value)
return {
member,
avatarKey: getMemberAvatarKey(member, isLead),
accent: memberAccentColor(member.color, fallbackIndex),
}
}
function isLeadIdentity(value: string, snapshot: TeamWorkbenchSnapshot): boolean {
if (!value || value === '*') return false
return resolveTeamMemberIdentity(snapshot.team, value).isLead
}
/**
* CLI transport kinds describe how an envelope moved, not what the team was
* doing. Derive the v2 communication groups from parsed protocol bodies and
* the actual route while accepting both bare names and `name@team` ids.
*/
function classifyMessage(
message: TeamWorkbenchMessage,
body: WorkbenchMessageBody,
snapshot: TeamWorkbenchSnapshot,
): FeedCategory {
if (body.kind === 'assignment') return body.selfClaim ? 'system' : 'assignment'
if (body.kind === 'lifecycle') return 'system'
if (message.protocolType || message.kind === 'system') return 'system'
const senderIsLead = isLeadIdentity(message.from, snapshot)
if (message.kind === 'broadcast') return senderIsLead ? 'assignment' : 'report'
const recipients = Array.from(new Set([message.to, ...message.recipients])).filter(Boolean)
const targetsLead = recipients.some((recipient) => isLeadIdentity(recipient, snapshot))
const targetsMember = recipients.some((recipient) => !isLeadIdentity(recipient, snapshot))
if (senderIsLead && targetsMember) return 'assignment'
if (!senderIsLead && targetsLead) return 'report'
if (message.kind === 'direct') return 'peer'
return 'system'
}
function transportIcon(kind: TeamWorkbenchMessage['kind']): LucideIcon {
if (kind === 'broadcast') return Megaphone
if (kind === 'direct') return MessageSquare
return Settings2
}
function categoryLabel(category: FeedCategory, t: TranslationFn): string {
if (category === 'assignment') return t('agentTeams.communication.assignment')
if (category === 'peer') return t('agentTeams.communication.peer')
if (category === 'report') return t('agentTeams.communication.report')
return t('agentTeams.communication.system')
}
function categoryTagClass(category: FeedCategory): string {
if (category === 'assignment') {
return 'border-[var(--color-primary-fixed-dim)] bg-[var(--color-brand-soft)] text-[var(--color-on-brand-soft)]'
}
if (category === 'peer') {
return 'border-[var(--color-memory-border)] bg-[var(--color-tertiary-container)] text-[var(--color-tertiary)]'
}
if (category === 'system') {
return 'border-[var(--color-success)] bg-[var(--color-success-container)] text-[var(--color-on-success-container)]'
}
return 'border-[var(--color-outline)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-secondary)]'
}
function categoryAccent(category: FeedCategory): string {
if (category === 'assignment') return 'var(--color-brand)'
if (category === 'peer') return 'var(--color-tertiary)'
if (category === 'system') return 'var(--color-success)'
return 'var(--color-text-secondary)'
}
function lifecycleNarration(
body: Extract<WorkbenchMessageBody, { kind: 'lifecycle' }>,
t: TranslationFn,
): string {
const narration = t(`agentTeams.communication.lifecycle.${body.type}` as TranslationKey)
return body.detail ? `${narration} · ${body.detail}` : narration
}
/** Picking work up and being handed it are different events, so they read differently. */
function assignmentNarration(
body: Extract<WorkbenchMessageBody, { kind: 'assignment' }>,
message: TeamWorkbenchMessage,
t: TranslationFn,
): string {
const taskId = body.taskId ?? message.taskId
return t(
body.selfClaim
? 'agentTeams.communication.taskClaimed'
: 'agentTeams.communication.taskAssignment',
{
task: taskId ? `#${taskId}` : '',
subject: body.subject ?? '',
name: body.selfClaim ? message.from : (message.recipients[0] ?? message.to),
},
)
}
function readableSystemText(
body: Extract<WorkbenchMessageBody, { kind: 'text' }>,
t: TranslationFn,
): string {
const text = body.text.trim()
if (!text.startsWith('{') || !text.endsWith('}')) return text
return t('agentTeams.communication.system')
}
export function AgentTeamsCommunicationFeed({
snapshot,
fill = false,
onFocusTask,
}: {
snapshot: TeamWorkbenchSnapshot
/** Full-height layouts let the feed own its column instead of a fixed strip. */
fill?: boolean
/** Lights up the task a row is about on the map, reusing its focus channel. */
onFocusTask?: (taskId: string | null) => void
}) {
const t = useTranslation()
const [filter, setFilter] = useState<FeedFilter>('all')
const rows = useMemo(() => snapshot.messages
.map((message): ParsedMessageRow => {
const body = parseWorkbenchMessageBody(message)
return {
message,
body,
category: classifyMessage(message, body, snapshot),
}
})
.reverse(), [snapshot])
const categoryCounts = useMemo(() => rows.reduce<Record<FeedCategory, number>>(
(counts, row) => {
counts[row.category] += 1
return counts
},
{ assignment: 0, peer: 0, report: 0, system: 0 },
), [rows])
const filters: Array<{ key: FeedFilter; label: string; count: number }> = [
{ key: 'all', label: t('agentTeams.communication.all'), count: rows.length },
{ key: 'assignment', label: categoryLabel('assignment', t), count: categoryCounts.assignment },
{ key: 'peer', label: categoryLabel('peer', t), count: categoryCounts.peer },
{ key: 'report', label: categoryLabel('report', t), count: categoryCounts.report },
{ key: 'system', label: categoryLabel('system', t), count: categoryCounts.system },
]
const filteredRows = filter === 'all'
? rows
: rows.filter(({ category }) => category === filter)
// A teammate waiting for work repeats the same idle notice every few seconds.
// Keep the newest signal and fold adjacent duplicates into its repeat count.
const visibleRows = useMemo(() => {
const collapsed: Array<ParsedMessageRow & { repeats: number }> = []
for (const row of filteredRows) {
const previous = collapsed[collapsed.length - 1]
if (
previous &&
previous.body.kind === 'lifecycle' &&
row.body.kind === 'lifecycle' &&
previous.body.type === row.body.type &&
previous.message.from === row.message.from
) {
previous.repeats += 1
continue
}
collapsed.push({ ...row, repeats: 1 })
}
return collapsed
}, [filteredRows])
return (
<section
data-testid="agent-teams-communication"
className={[
'flex min-h-0 flex-col border-[var(--color-border)] bg-[var(--color-surface)]',
fill
? 'h-full w-full min-w-0'
: 'h-[240px] shrink-0 border-t',
].join(' ')}
>
<div className="shrink-0 border-b border-[var(--color-border)] px-3.5 py-3">
<div className="flex min-w-0 items-baseline gap-2">
<h3 className="text-[15px] font-extrabold tracking-[-0.2px]">
{t('agentTeams.communication.title')}
</h3>
<span
data-testid="agent-teams-message-count"
className="font-mono text-[12px] tabular-nums text-[var(--color-text-tertiary)]"
>
{t('agentTeams.communication.count', { count: rows.length })}
</span>
</div>
<div
role="group"
aria-label={t('agentTeams.communication.title')}
className="mt-2.5 flex min-w-0 flex-wrap items-center gap-1.5"
>
{filters.map((option) => {
const selected = filter === option.key
return (
<button
key={option.key}
type="button"
data-testid={`agent-teams-filter-${option.key}`}
data-count={option.count}
aria-pressed={selected}
onClick={() => setFilter(option.key)}
className={[
'inline-flex shrink-0 items-center rounded-full border px-2.5 py-1 text-[11px] font-bold outline-none transition-[transform,background-color,border-color,color] duration-200 focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] active:scale-[0.98]',
selected
? 'border-[var(--color-text-primary)] bg-[var(--color-text-primary)] text-[var(--color-surface)]'
: 'border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]',
].join(' ')}
>
{option.label}
</button>
)
})}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{visibleRows.length === 0 ? (
<div className="flex min-h-28 items-center justify-center px-6 text-center text-[11px] leading-5 text-[var(--color-text-tertiary)]">
{t('agentTeams.communication.empty')}
</div>
) : (
<div className="divide-y divide-[var(--color-border)]">
{visibleRows.map(({ message, body, category, repeats }, index) => (
<FeedRow
key={message.id}
message={message}
body={body}
category={category}
repeats={repeats}
isLatest={index === 0}
snapshot={snapshot}
onFocusTask={onFocusTask}
t={t}
/>
))}
</div>
)}
</div>
</section>
)
}
function FeedRow({
message,
body,
category,
repeats,
isLatest,
snapshot,
onFocusTask,
t,
}: {
message: TeamWorkbenchMessage
body: WorkbenchMessageBody
category: FeedCategory
/** How many identical signals in a row this one stands for. */
repeats: number
isLatest: boolean
snapshot: TeamWorkbenchSnapshot
onFocusTask?: (taskId: string | null) => void
t: TranslationFn
}) {
const [expanded, setExpanded] = useState(false)
const focusedTaskId = (body.kind === 'assignment' ? body.taskId : undefined) ?? message.taskId
const time = formatWorkbenchMessageTime(message.timestamp)
const text = body.kind === 'lifecycle'
? lifecycleNarration(body, t)
: body.kind === 'assignment'
? assignmentNarration(body, message, t)
: category === 'system'
? readableSystemText(body, t)
: body.text
const compact = body.kind !== 'text' || category === 'system'
const rowProps = {
'data-testid': `agent-teams-message-${message.id}`,
'data-message-body': body.kind,
'data-message-kind': message.kind,
'data-message-category': category,
onMouseEnter: focusedTaskId ? () => onFocusTask?.(focusedTaskId) : undefined,
onMouseLeave: focusedTaskId ? () => onFocusTask?.(null) : undefined,
}
if (compact) {
return (
<article
{...rowProps}
className="bg-[var(--color-surface-container-low)] px-3.5 py-2"
>
<div className="flex min-w-0 items-center gap-2">
<span
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: categoryAccent(category) }}
aria-hidden="true"
/>
<span
className="shrink-0 text-[9.5px] font-extrabold uppercase tracking-[0.08em]"
style={{ color: categoryAccent(category) }}
>
{categoryLabel(category, t)}
</span>
<p
data-testid={`agent-teams-message-${message.id}-body`}
className="min-w-0 flex-1 truncate text-[11.5px] leading-[1.4] text-[var(--color-text-secondary)]"
title={text}
>
{text}
</p>
{message.taskId ? (
<span className="shrink-0 rounded-full bg-[var(--color-surface-container-high)] px-1.5 py-0.5 font-mono text-[9px] text-[var(--color-text-tertiary)]">
#{message.taskId}
</span>
) : null}
{repeats > 1 ? (
<span
data-testid={`agent-teams-message-${message.id}-repeats`}
className="shrink-0 rounded-full bg-[var(--color-surface-container-high)] px-1.5 py-0.5 font-mono text-[9px] tabular-nums text-[var(--color-text-tertiary)]"
>
×{repeats}
</span>
) : null}
<time
dateTime={message.timestamp}
className="shrink-0 font-mono text-[9px] tabular-nums text-[var(--color-text-tertiary)]"
>
{time}
</time>
</div>
</article>
)
}
const isTruncatable = text.length > COLLAPSED_BODY_CHARS
const collapsed = isTruncatable && !expanded
const recipient = message.kind === 'broadcast'
? t('agentTeams.communication.everyone')
: message.to
const senderParticipant = resolveParticipant(message.from, snapshot, 0)
const recipientParticipant = message.kind === 'direct'
? resolveParticipant(message.to, snapshot, 1)
: null
const Icon = transportIcon(message.kind)
return (
<article {...rowProps} className="relative px-3.5 py-3">
{isLatest ? (
<span className="absolute bottom-3 left-0 top-3 w-0.5 rounded-r-full bg-[var(--color-brand)]" aria-hidden="true" />
) : null}
<div className="flex min-w-0 items-center gap-2">
<span
className={[
'inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-[9.5px] font-extrabold uppercase tracking-[0.08em]',
categoryTagClass(category),
].join(' ')}
>
<Icon size={12} strokeWidth={2} aria-hidden="true" />
{categoryLabel(category, t)}
</span>
{message.taskId ? (
<span className="shrink-0 rounded-full bg-[var(--color-surface-container-high)] px-1.5 py-0.5 font-mono text-[9px] text-[var(--color-text-tertiary)]">
#{message.taskId}
</span>
) : null}
<time
dateTime={message.timestamp}
className="ml-auto shrink-0 font-mono text-[9px] tabular-nums text-[var(--color-text-tertiary)]"
>
{time}
</time>
</div>
<div
data-testid={`agent-teams-message-${message.id}-route`}
className="mt-1.5 flex min-w-0 items-center gap-1.5"
>
<ParticipantFigure
participant={senderParticipant}
name={message.from}
nameTestId={`agent-teams-message-${message.id}-from`}
avatarTestId={`agent-teams-message-${message.id}-from-avatar`}
/>
<ArrowRight size={12} strokeWidth={2} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
{recipientParticipant ? (
<ParticipantFigure
participant={recipientParticipant}
name={recipient}
nameTestId={`agent-teams-message-${message.id}-to`}
avatarTestId={`agent-teams-message-${message.id}-to-avatar`}
/>
) : (
<span
data-testid={`agent-teams-message-${message.id}-to`}
className="inline-flex min-w-0 flex-1 items-center gap-1.5"
title={recipient}
>
<span className="inline-flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-brand)]">
<Megaphone size={11} strokeWidth={2} aria-hidden="true" />
</span>
<span className="min-w-0 truncate font-mono text-[10px] font-semibold text-[var(--color-text-secondary)]">
{recipient}
</span>
</span>
)}
</div>
<div
data-testid={`agent-teams-message-${message.id}-body`}
data-collapsed={collapsed ? 'true' : 'false'}
className="relative mt-[7px] text-[12.5px] leading-[1.55]"
>
<div className={collapsed ? 'max-h-[148px] overflow-hidden' : ''}>
<MarkdownRenderer
content={text}
variant="compact"
className="prose-p:first:mt-0 prose-p:last:mb-0 prose-p:text-[12.5px] prose-p:leading-[1.55] prose-li:text-[12.5px] prose-li:leading-[1.55] prose-headings:first:mt-0 prose-headings:text-[var(--color-text-primary)] prose-code:text-[11px]"
/>
</div>
{collapsed ? (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 bottom-0 h-12 bg-gradient-to-b from-transparent to-[var(--color-surface)]"
/>
) : null}
</div>
{isTruncatable ? (
<button
type="button"
onClick={() => setExpanded((current) => !current)}
className="relative mt-2 rounded-[var(--radius-sm)] text-[10px] font-semibold text-[var(--color-brand)] outline-none transition-[transform,color] duration-200 hover:underline focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] active:scale-[0.98]"
>
{t(expanded ? 'agentTeams.communication.collapse' : 'agentTeams.communication.expand')}
</button>
) : null}
</article>
)
}
function ParticipantFigure({
participant,
name,
nameTestId,
avatarTestId,
}: {
participant: MessageParticipant
name: string
nameTestId: string
avatarTestId: string
}) {
return (
<span className="inline-flex min-w-0 flex-1 items-center gap-1.5" title={name}>
<span
data-testid={avatarTestId}
data-avatar-key={participant.avatarKey}
aria-hidden="true"
className="relative h-[22px] w-[22px] shrink-0"
>
<img
src={MEMBER_AVATARS[participant.avatarKey]}
alt=""
draggable={false}
className="h-full w-full select-none object-contain drop-shadow-[0_1px_1px_rgba(0,0,0,0.14)]"
/>
<span
className="absolute bottom-0 left-1/2 h-0.5 w-3 -translate-x-1/2 rounded-full border border-[var(--color-surface)]"
style={{ background: participant.accent }}
/>
</span>
<span
data-testid={nameTestId}
className="min-w-0 truncate font-mono text-[10px] font-bold text-[var(--color-text-secondary)]"
>
{name}
</span>
</span>
)
}
@@ -0,0 +1,173 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentTeamsMemberInspector } from '@/components/agentTeams/AgentTeamsMemberInspector'
import { formatWorkbenchMessageTime } from '@/components/agentTeams/agentTeamsModel'
import { useSettingsStore } from '@/stores/settingsStore'
import type {
TeamMember,
TeamWorkbenchMessage,
TeamWorkbenchSnapshot,
TeamWorkbenchTask,
} from '@/types/team'
const builder: TeamMember = {
agentId: 'builder@team-a',
name: 'builder',
role: 'server engineer',
status: 'running',
activity: 'idle',
}
const reviewer: TeamMember = {
agentId: 'reviewer@team-a',
name: 'reviewer',
role: 'qa engineer',
status: 'running',
activity: 'active',
}
function task(status: TeamWorkbenchTask['status']): TeamWorkbenchTask {
return {
id: '7',
subject: 'Build API routes',
description: '',
owner: 'builder',
status,
blocks: [],
blockedBy: [],
taskListId: 'team-a',
}
}
function message(overrides: Partial<TeamWorkbenchMessage> & { id: string }): TeamWorkbenchMessage {
return {
from: 'builder',
to: 'reviewer',
recipients: ['reviewer'],
kind: 'direct',
text: 'body',
timestamp: '2026-08-08T07:10:00.000Z',
...overrides,
}
}
function snapshot(
generatedAt: string,
status: TeamWorkbenchTask['status'],
messages: TeamWorkbenchMessage[] = [],
): TeamWorkbenchSnapshot {
return {
version: 'v1',
generatedAt,
team: {
name: 'team-a',
leadAgentId: 'lead@team-a',
leadSessionId: 'lead-session',
members: [builder, reviewer],
},
tasks: [task(status)],
messages,
}
}
function renderInspector(options: {
snapshots?: TeamWorkbenchSnapshot[]
selectedIndex?: number
snapshot?: TeamWorkbenchSnapshot
onBack?: () => void
onClose?: () => void
onOpenExecution?: () => void
} = {}) {
const snapshots = options.snapshots ?? [snapshot('2026-08-08T07:00:00.000Z', 'pending')]
const selectedIndex = options.selectedIndex ?? snapshots.length - 1
const selected = options.snapshot ?? snapshots[selectedIndex]!
return render(
<AgentTeamsMemberInspector
snapshots={snapshots}
selectedIndex={selectedIndex}
snapshot={selected}
member={builder}
isLead={false}
leadIsStreaming={false}
onBack={options.onBack ?? vi.fn()}
onClose={options.onClose ?? vi.fn()}
onOpenExecution={options.onOpenExecution ?? vi.fn()}
/>,
)
}
describe('AgentTeamsMemberInspector', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
})
it('derives the first start and elapsed duration from task state transitions', () => {
const snapshots = [
snapshot('2026-08-08T07:00:00.000Z', 'pending'),
snapshot('2026-08-08T07:05:00.000Z', 'in_progress'),
snapshot('2026-08-08T07:12:00.000Z', 'completed'),
]
renderInspector({ snapshots })
const row = screen.getByTestId('agent-teams-member-task-7')
const expectedStart = formatWorkbenchMessageTime('2026-08-08T07:05:00.000Z')
expect(row.textContent).toContain('#7')
expect(row.textContent).toContain('Build API routes')
expect(row.textContent).toContain('Completed')
expect(row.textContent).toContain(`${expectedStart} +7:00`)
expect(row.getAttribute('data-task-state')).toBe('completed')
expect(screen.getByText('1', { selector: 'dd' })).toBeTruthy()
})
it('shows message direction, renders human Markdown, and narrates protocol payloads', () => {
const messages = [
message({
id: 'human',
text: '## Finding A\n\n**Key evidence**',
}),
message({
id: 'protocol',
from: 'reviewer',
to: 'builder',
recipients: ['builder'],
kind: 'system',
protocolType: 'idle_notification',
text: '{"type":"idle_notification","idleReason":"available"}',
timestamp: '2026-08-08T07:11:00.000Z',
}),
]
const selected = snapshot('2026-08-08T07:12:00.000Z', 'in_progress', messages)
renderInspector({ snapshots: [selected] })
const human = screen.getByTestId('agent-teams-member-message-human')
expect(human.getAttribute('data-message-direction')).toBe('sent')
expect(human.textContent).toContain('Sent')
expect(human.textContent).toContain('reviewer')
expect(human.querySelector('h2')?.textContent).toBe('Finding A')
expect(human.querySelector('strong')?.textContent).toBe('Key evidence')
const protocol = screen.getByTestId('agent-teams-member-message-protocol')
expect(protocol.getAttribute('data-message-direction')).toBe('received')
expect(protocol.getAttribute('data-message-body')).toBe('lifecycle')
expect(protocol.className).toContain('color-tertiary')
expect(protocol.textContent).toContain('Received')
expect(protocol.textContent).toContain('Went idle, waiting for work · available')
expect(protocol.textContent).not.toContain('idle_notification')
})
it('calls back for the feed, close control, and explicit execution handoff', () => {
const onBack = vi.fn()
const onClose = vi.fn()
const onOpenExecution = vi.fn()
renderInspector({ onBack, onClose, onOpenExecution })
fireEvent.click(screen.getByRole('button', { name: 'Back to communication' }))
fireEvent.click(screen.getByRole('button', { name: 'Close communication panel' }))
fireEvent.click(screen.getByRole('button', { name: 'View builder execution' }))
expect(onBack).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1)
expect(onOpenExecution).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,524 @@
import { useId, useMemo } from 'react'
import { ArrowLeft, ArrowRight, ChevronLeft, ChevronRight } from 'lucide-react'
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer'
import { MEMBER_AVATARS, memberAccentColor } from '@/components/agentTeams/agentTeamsAvatars'
import {
formatWorkbenchMessageTime,
getMemberAvatarKey,
getMemberWorkState,
getWorkbenchTaskState,
inferTaskOwner,
parseWorkbenchMessageBody,
resolveTeamMemberIdentity,
taskOwnedByMember,
type MemberWorkState,
type WorkbenchMessageBody,
type WorkbenchTaskState,
} from '@/components/agentTeams/agentTeamsModel'
import { Badge, StatusDot, type Tone } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { IconButton } from '@/components/ui/IconButton'
import { useTranslation, type TranslationKey } from '@/i18n'
import type {
TeamMember,
TeamWorkbenchMessage,
TeamWorkbenchSnapshot,
TeamWorkbenchTask,
} from '@/types/team'
export type AgentTeamsMemberInspectorProps = {
snapshots: TeamWorkbenchSnapshot[]
selectedIndex: number
snapshot: TeamWorkbenchSnapshot
member: TeamMember
isLead: boolean
leadIsStreaming: boolean
onBack: () => void
onClose: () => void
onOpenExecution: () => void
}
type TranslationFn = ReturnType<typeof useTranslation>
type TaskHistoryEntry = {
task: TeamWorkbenchTask
state: WorkbenchTaskState
startedAt: number | null
durationMs: number | null
}
type MemberMessage = {
message: TeamWorkbenchMessage
body: WorkbenchMessageBody
direction: 'sent' | 'received'
peerName: string
}
function timestampMs(value: string | undefined): number | null {
if (!value) return null
const numeric = /^\d+$/.test(value) ? Number(value) : Number.NaN
const parsed = Number.isFinite(numeric) ? numeric : Date.parse(value)
return Number.isFinite(parsed) ? parsed : null
}
function identityAliases(value: string | undefined): string[] {
const normalized = value?.trim().toLowerCase()
if (!normalized) return []
const short = normalized.split('@')[0] ?? normalized
return normalized === short ? [normalized] : [normalized, short]
}
function memberAliases(member: TeamMember): string[] {
return [
...identityAliases(member.agentId),
...identityAliases(member.name),
]
}
function memberMatchesIdentity(member: TeamMember, identity: string | undefined): boolean {
const aliases = identityAliases(identity)
return aliases.some(alias => memberAliases(member).includes(alias))
}
function memberName(member: TeamMember): string {
return member.name || member.role || member.agentId.split('@')[0] || member.agentId
}
function taskBelongsToMember(
task: TeamWorkbenchTask,
member: TeamMember,
snapshot: TeamWorkbenchSnapshot,
): boolean {
if (taskOwnedByMember(task, member)) return true
const attribution = inferTaskOwner(task, snapshot)
return attribution ? memberMatchesIdentity(member, attribution.identity) : false
}
function visibleSnapshots(
snapshots: TeamWorkbenchSnapshot[],
selectedIndex: number,
snapshot: TeamWorkbenchSnapshot,
): TeamWorkbenchSnapshot[] {
if (snapshots.length === 0) return [snapshot]
const clampedIndex = Math.max(0, Math.min(selectedIndex, snapshots.length - 1))
const visible = snapshots.slice(0, clampedIndex + 1)
// `snapshotWithHistoricalMembers` returns a reconstructed object. Keep that
// roster while retaining the original earlier frames used for timing.
visible[visible.length - 1] = snapshot
return visible
}
function deriveTaskHistory(
snapshots: TeamWorkbenchSnapshot[],
selectedIndex: number,
snapshot: TeamWorkbenchSnapshot,
member: TeamMember,
): TaskHistoryEntry[] {
type MutableTaskHistory = {
task: TeamWorkbenchTask
startedAt: number | null
completedAt: number | null
}
const history = new Map<string, MutableTaskHistory>()
const frames = visibleSnapshots(snapshots, selectedIndex, snapshot)
frames.forEach((frame) => {
const frameTime = timestampMs(frame.generatedAt)
frame.tasks.forEach((task) => {
if (!taskBelongsToMember(task, member, frame)) return
const entry = history.get(task.id) ?? {
task,
startedAt: null,
completedAt: null,
}
entry.task = task
if (entry.startedAt === null && task.status !== 'pending') entry.startedAt = frameTime
if (entry.completedAt === null && task.status === 'completed') entry.completedAt = frameTime
history.set(task.id, entry)
})
})
const tasksById = new Map(snapshot.tasks.map(task => [task.id, task]))
const selectedTime = timestampMs(snapshot.generatedAt)
return Array.from(history.values())
.map((entry): TaskHistoryEntry => {
const currentTask = tasksById.get(entry.task.id)
const task = currentTask && taskBelongsToMember(currentTask, member, snapshot)
? currentTask
: entry.task
const durationEnd = entry.completedAt ?? selectedTime
const durationMs = entry.startedAt !== null && durationEnd !== null
? Math.max(0, durationEnd - entry.startedAt)
: null
return {
task,
state: getWorkbenchTaskState(task, tasksById),
startedAt: entry.startedAt,
durationMs,
}
})
.sort((left, right) => {
// A task authored up-front is merely future work until it actually
// leaves `pending`; do not place it before work the member already ran.
const leftTime = left.startedAt ?? Number.MAX_SAFE_INTEGER
const rightTime = right.startedAt ?? Number.MAX_SAFE_INTEGER
return leftTime - rightTime || left.task.id.localeCompare(right.task.id, undefined, { numeric: true })
})
}
function formatDuration(durationMs: number): string {
const seconds = Math.max(0, Math.round(durationMs / 1000))
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
}
function formatTaskSpan(entry: TaskHistoryEntry): string {
if (entry.startedAt === null || entry.durationMs === null) return '—'
return `${formatWorkbenchMessageTime(new Date(entry.startedAt).toISOString())} +${formatDuration(entry.durationMs)}`
}
function taskTone(state: WorkbenchTaskState): Tone {
if (state === 'running') return 'brand'
if (state === 'completed') return 'success'
if (state === 'open') return 'warning'
return 'neutral'
}
function memberTone(state: MemberWorkState): Tone {
if (state === 'working') return 'brand'
if (state === 'error') return 'danger'
if (state === 'exited' || state === 'stopped') return 'neutral'
return 'info'
}
function leadStatusLabel(snapshot: TeamWorkbenchSnapshot, t: TranslationFn): string {
if (snapshot.deletedAt) return t('agentTeams.lead.archived')
if (snapshot.tasks.length === 0) return t('agentTeams.lead.forming')
if (snapshot.tasks.every(task => task.status === 'completed')) {
return t('agentTeams.lead.finishing')
}
return t('agentTeams.lead.coordinating')
}
function relatedToMember(message: TeamWorkbenchMessage, member: TeamMember): boolean {
return memberMatchesIdentity(member, message.from)
|| memberMatchesIdentity(member, message.to)
|| message.recipients.some(recipient => memberMatchesIdentity(member, recipient))
|| message.to === '*'
}
function displayIdentity(
identity: string,
snapshot: TeamWorkbenchSnapshot,
t: TranslationFn,
): string {
if (identity === '*') return t('agentTeams.communication.everyone')
return memberName(resolveTeamMemberIdentity(snapshot.team, identity).member)
}
function deriveMemberMessages(
snapshot: TeamWorkbenchSnapshot,
member: TeamMember,
t: TranslationFn,
): MemberMessage[] {
return snapshot.messages
.filter(message => relatedToMember(message, member))
.map((message): MemberMessage => {
const direction = memberMatchesIdentity(member, message.from) ? 'sent' : 'received'
const recipient = message.to === '*'
? '*'
: message.recipients[0] ?? message.to
return {
message,
body: parseWorkbenchMessageBody(message),
direction,
peerName: direction === 'sent'
? displayIdentity(recipient, snapshot, t)
: displayIdentity(message.from, snapshot, t),
}
})
.reverse()
}
function protocolNarration(
row: MemberMessage,
snapshot: TeamWorkbenchSnapshot,
t: TranslationFn,
): string {
if (row.body.kind === 'lifecycle') {
const label = t(`agentTeams.communication.lifecycle.${row.body.type}` as TranslationKey)
return row.body.detail ? `${label} · ${row.body.detail}` : label
}
if (row.body.kind === 'assignment') {
const taskId = row.body.taskId ?? row.message.taskId
const name = row.body.selfClaim
? displayIdentity(row.message.from, snapshot, t)
: displayIdentity(row.message.recipients[0] ?? row.message.to, snapshot, t)
return t(
row.body.selfClaim
? 'agentTeams.communication.taskClaimed'
: 'agentTeams.communication.taskAssignment',
{
task: taskId ? `#${taskId}` : '',
subject: row.body.subject ?? '',
name,
},
)
}
return row.body.text
}
/**
* Right-hand transcript drawer for one Agent Teams participant. The board
* remains visible; this panel explains the teammate's serial task history and
* all communication at the currently selected replay frame.
*/
export function AgentTeamsMemberInspector({
snapshots,
selectedIndex,
snapshot,
member,
isLead,
leadIsStreaming,
onBack,
onClose,
onOpenExecution,
}: AgentTeamsMemberInspectorProps) {
const t = useTranslation()
const headingId = useId()
const name = memberName(member)
const memberIndex = Math.max(0, snapshot.team.members.findIndex(candidate => (
memberMatchesIdentity(candidate, member.agentId)
)))
const avatarKey = getMemberAvatarKey(member, isLead)
const accent = memberAccentColor(member.color, memberIndex)
const workState = snapshot.deletedAt
? 'exited'
: getMemberWorkState(member, { isLead, leadIsStreaming })
const taskHistory = useMemo(
() => deriveTaskHistory(snapshots, selectedIndex, snapshot, member),
[member, selectedIndex, snapshot, snapshots],
)
const messages = useMemo(
() => deriveMemberMessages(snapshot, member, t),
[member, snapshot, t],
)
const completedTasks = taskHistory.filter(entry => entry.state === 'completed').length
const runningTask = taskHistory.find(entry => entry.state === 'running')?.task
const blockedTask = taskHistory.find(entry => entry.state === 'blocked')?.task
const waitingDependency = blockedTask?.blockedBy.find(dependencyId => (
snapshot.tasks.find(task => task.id === dependencyId)?.status !== 'completed'
))
const currentStatusLabel = isLead
? leadStatusLabel(snapshot, t)
: workState === 'working' && runningTask
? t('agentTeams.member.executingTask', { task: runningTask.id })
: workState === 'idle' && taskHistory.length > 0 && completedTasks === taskHistory.length
? t('agentTeams.member.waitingForClose')
: workState === 'idle' && waitingDependency
? t('agentTeams.member.waitingForDependency', { task: waitingDependency })
: workState === 'idle'
? t('agentTeams.member.waitingForTask')
: t(`agentTeams.member.${workState}` as TranslationKey)
return (
<section
data-testid="agent-teams-member-inspector"
data-member-id={member.agentId}
aria-labelledby={headingId}
className="flex h-full min-h-0 w-full flex-col bg-[var(--color-surface)] text-[var(--color-text-primary)]"
>
<header className="shrink-0 border-b border-[var(--color-border)] px-3.5 py-3">
<div className="flex items-center justify-between gap-2">
<Button
variant="secondary"
size="sm"
icon={<ChevronLeft aria-hidden="true" />}
onClick={onBack}
>
{t('agentTeams.communication.backToFeed')}
</Button>
<IconButton
icon={<ChevronRight aria-hidden="true" />}
label={t('agentTeams.communication.close')}
size="sm"
tone="muted"
bordered
onClick={onClose}
/>
</div>
<div className="mt-2.5 flex min-w-0 items-center gap-2.5">
<span className="relative h-11 w-11 shrink-0" data-avatar-key={avatarKey}>
<img
src={MEMBER_AVATARS[avatarKey]}
alt=""
draggable={false}
className="h-11 w-11 select-none object-contain drop-shadow-[0_3px_3px_var(--color-border-strong)]"
/>
<span
aria-hidden="true"
className="absolute bottom-0 left-1/2 h-1.5 w-5 -translate-x-1/2 rounded-full border border-[var(--color-surface)]"
style={{ background: accent }}
/>
</span>
<div className="min-w-0">
<h2 id={headingId} className="truncate font-mono text-[14px] font-extrabold">
{name}
</h2>
<p className="truncate text-[10.5px] text-[var(--color-text-secondary)]">
{t('agentTeams.inspector.independentContext', { role: member.role })}
</p>
</div>
</div>
<dl className="mt-3 grid grid-cols-3 gap-4 text-[11.5px]">
<div className="min-w-0">
<dt className="text-[10px] font-semibold text-[var(--color-text-tertiary)]">
{t('agentTeams.inspector.current')}
</dt>
<dd className="mt-0.5 flex min-w-0 items-center gap-1.5 font-extrabold">
<StatusDot tone={memberTone(workState)} pulse={workState === 'working'} />
<span className="truncate">{currentStatusLabel}</span>
</dd>
</div>
<div>
<dt className="text-[10px] font-semibold text-[var(--color-text-tertiary)]">
{t('agentTeams.inspector.completedTasks')}
</dt>
<dd className="mt-0.5 font-extrabold tabular-nums">{completedTasks}</dd>
</div>
<div>
<dt className="text-[10px] font-semibold text-[var(--color-text-tertiary)]">
{t('agentTeams.inspector.messages')}
</dt>
<dd className="mt-0.5 font-extrabold tabular-nums">{messages.length}</dd>
</div>
</dl>
</header>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
<section
aria-labelledby={`${headingId}-tasks`}
className="border-b border-[var(--color-border)] px-3.5 py-3"
>
<h3
id={`${headingId}-tasks`}
className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]"
>
{t('agentTeams.inspector.taskHistory')}
<span className="ml-1 normal-case tracking-normal">
· {t('agentTeams.inspector.taskHistoryHint')}
</span>
</h3>
{taskHistory.length > 0 ? (
<ol className="mt-2" data-testid="agent-teams-member-task-history">
{taskHistory.map(entry => {
const span = formatTaskSpan(entry)
return (
<li
key={entry.task.id}
data-testid={`agent-teams-member-task-${entry.task.id}`}
data-task-state={entry.state}
className="flex min-w-0 items-center gap-2 border-b border-[var(--color-border)] py-1.5 last:border-b-0"
>
<span className="w-[26px] shrink-0 font-mono text-[10px] font-extrabold text-[var(--color-text-tertiary)]">
#{entry.task.id}
</span>
<span className="min-w-0 flex-1 truncate text-[11.5px] leading-[1.3]" title={entry.task.subject}>
{entry.task.subject}
</span>
<Badge
data-testid={`agent-teams-member-task-${entry.task.id}-state`}
tone={taskTone(entry.state)}
size="xs"
bordered
>
{t(`agentTeams.task.${entry.state}` as TranslationKey)}
</Badge>
<time
dateTime={entry.startedAt === null ? undefined : new Date(entry.startedAt).toISOString()}
className="w-[82px] shrink-0 text-right font-mono text-[9.5px] tabular-nums text-[var(--color-text-tertiary)]"
>
{span}
</time>
</li>
)
})}
</ol>
) : (
<p className="mt-2 text-[11px] text-[var(--color-text-tertiary)]">
{t('agentTeams.noMemberTasks')}
</p>
)}
</section>
<section aria-labelledby={`${headingId}-messages`} className="px-3.5 py-3">
<h3
id={`${headingId}-messages`}
className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]"
>
{t('agentTeams.inspector.messages')}
</h3>
{messages.length > 0 ? (
<ol className="mt-2" data-testid="agent-teams-member-messages">
{messages.map(row => {
const sent = row.direction === 'sent'
return (
<li
key={row.message.id}
data-testid={`agent-teams-member-message-${row.message.id}`}
data-message-direction={row.direction}
data-message-body={row.body.kind}
className={`mb-2 border-l-2 pb-2 pl-2.5 last:mb-0 ${sent ? 'border-l-[var(--color-brand)]' : 'border-l-[var(--color-tertiary)]'}`}
>
<div className="flex min-w-0 items-center gap-1.5">
<span className={`shrink-0 text-[9.5px] font-extrabold ${sent ? 'text-[var(--color-brand)]' : 'text-[var(--color-tertiary)]'}`}>
{t(sent ? 'agentTeams.inspector.sent' : 'agentTeams.inspector.received')}
</span>
{sent
? <ArrowRight size={11} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
: <ArrowLeft size={11} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />}
<span className="min-w-0 truncate font-mono text-[10px] text-[var(--color-text-tertiary)]">
{row.peerName}
</span>
<time
dateTime={row.message.timestamp}
className="ml-auto shrink-0 font-mono text-[10px] tabular-nums text-[var(--color-text-tertiary)]"
>
{formatWorkbenchMessageTime(row.message.timestamp)}
</time>
</div>
{row.body.kind === 'text' ? (
<MarkdownRenderer
content={row.body.text}
variant="compact"
className="mt-1 text-[12.5px] leading-[1.55] text-[var(--color-text-primary)]"
/>
) : (
<p className="mt-1 text-[11.5px] leading-[1.5] text-[var(--color-text-secondary)]">
{protocolNarration(row, snapshot, t)}
</p>
)}
</li>
)
})}
</ol>
) : (
<p className="mt-2 text-[11px] text-[var(--color-text-tertiary)]">
{t('agentTeams.noMemberMessages')}
</p>
)}
</section>
</div>
<footer className="shrink-0 border-t border-[var(--color-border)] bg-[var(--color-surface)] px-3.5 py-3">
<Button variant="primary" size="base" block onClick={onOpenExecution}>
{t('agentTeams.openExecution', { name })}
</Button>
</footer>
</section>
)
}
@@ -0,0 +1,198 @@
import { ChevronRight, UsersRound } from 'lucide-react'
import type { ReactNode } from 'react'
import { Badge, StatusDot, type Tone } from '@/components/ui/Badge'
import { useTranslation, type TranslationKey } from '../../i18n'
import type { TeamMember, TeamWorkbenchSnapshot } from '../../types/team'
import { MEMBER_AVATARS } from './agentTeamsAvatars'
import {
getMemberAvatarKey,
getWorkbenchPhase,
getWorkbenchProgress,
type WorkbenchPhase,
} from './agentTeamsModel'
/** Faces beyond this collapse into a `+N`, so the strip never wraps. */
const MAX_STACKED_AVATARS = 5
function phaseTone(phase: WorkbenchPhase): Tone {
if (phase === 'forming') return 'warning'
if (phase === 'running') return 'brand'
if (phase === 'finishing') return 'info'
return 'success'
}
function AvatarStack({
members,
leadAgentId,
size,
}: {
members: TeamMember[]
leadAgentId: string | undefined
size: number
}) {
const shown = members.slice(0, MAX_STACKED_AVATARS)
const overflow = members.length - shown.length
return (
<span className="flex shrink-0 items-center" aria-hidden="true">
{shown.map((member, index) => (
<span
key={member.agentId}
className="inline-flex items-center justify-center rounded-full bg-[var(--color-surface-container)] ring-1 ring-[var(--color-surface)]"
style={{
width: size,
height: size,
marginLeft: index === 0 ? 0 : -Math.round(size * 0.32),
zIndex: shown.length - index,
}}
>
<img
src={MEMBER_AVATARS[getMemberAvatarKey(member, member.agentId === leadAgentId)]}
alt=""
draggable={false}
className="select-none object-contain"
style={{ width: size - 2, height: size - 2 }}
/>
</span>
))}
{overflow > 0 ? (
<span
className="inline-flex items-center justify-center rounded-full bg-[var(--color-surface-container)] font-mono text-[9px] font-extrabold text-[var(--color-text-secondary)] ring-1 ring-[var(--color-surface)]"
style={{ width: size, height: size, marginLeft: -Math.round(size * 0.32) }}
>
+{overflow}
</span>
) : null}
</span>
)
}
/**
* The always-visible trace of a running team in the main session header. It
* opens the full workbench directly; a team never takes over the chat's
* right-hand panel.
*/
export function AgentTeamsStrip({
snapshot,
onOpen,
compact,
}: {
snapshot: TeamWorkbenchSnapshot
onOpen: () => void
compact: boolean
}) {
const t = useTranslation()
const phase = getWorkbenchPhase(snapshot)
const progress = getWorkbenchProgress(snapshot)
const members = snapshot.team.members
return (
<button
type="button"
data-testid="agent-teams-strip"
onClick={onOpen}
title={snapshot.team.name}
className={[
'mt-2 flex w-full max-w-full items-center gap-2 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5 py-1.5 text-left transition-colors',
'hover:border-[var(--color-border-strong)] hover:bg-[var(--color-surface-hover)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]',
compact ? 'text-[11px]' : 'text-[12px]',
].join(' ')}
>
<UsersRound size={compact ? 13 : 14} strokeWidth={2.25} aria-hidden="true" className="shrink-0 text-[var(--color-brand)]" />
<span className="shrink-0 font-semibold text-[var(--color-text-primary)]">
{t('agentTeams.strip.label')}
</span>
<AvatarStack members={members} leadAgentId={snapshot.team.leadAgentId} size={compact ? 18 : 20} />
<span className="flex shrink-0 items-center gap-1 text-[var(--color-text-tertiary)]">
<StatusDot tone={phaseTone(phase)} pulse={phase === 'running'} />
{t(`agentTeams.phase.${phase}` as TranslationKey)}
</span>
{progress.total > 0 ? (
<span className="shrink-0 tabular-nums text-[var(--color-text-secondary)]">
{t('agentTeams.inline.tasks', { completed: progress.completed, total: progress.total })}
</span>
) : null}
<span className="ml-auto flex shrink-0 items-center gap-0.5 font-medium text-[var(--color-brand)]">
{t('agentTeams.inline.open')}
<ChevronRight size={12} strokeWidth={2.4} aria-hidden="true" />
</span>
</button>
)
}
/**
* The in-transcript record of the team being formed. It sits where the
* TeamCreate call happened so scrolling back through the conversation still
* shows that this turn handed work to a team.
*/
export function AgentTeamsInlineCard({
snapshot,
teamName,
fallbackPhase = 'forming',
phaseOverride,
onOpen,
children,
}: {
snapshot?: TeamWorkbenchSnapshot
teamName: string
fallbackPhase?: WorkbenchPhase
phaseOverride?: WorkbenchPhase
onOpen?: () => void
children?: ReactNode
}) {
const t = useTranslation()
const phase = phaseOverride ?? (snapshot ? getWorkbenchPhase(snapshot) : fallbackPhase)
const progress = snapshot ? getWorkbenchProgress(snapshot) : { completed: 0, total: 0 }
const members = snapshot?.team.members ?? []
const canOpen = Boolean(snapshot && onOpen)
return (
<div className="mb-5 px-1">
<button
type="button"
data-testid="agent-teams-inline-card"
onClick={onOpen}
disabled={!canOpen}
className="flex w-full items-center gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3.5 py-2.5 text-left shadow-[var(--shadow-card)] transition-colors hover:border-[var(--color-border-strong)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
{members.length > 0 ? (
<AvatarStack members={members} leadAgentId={snapshot?.team.leadAgentId} size={30} />
) : (
<span className="inline-flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-full bg-[var(--color-surface-container)] text-[var(--color-brand)] ring-1 ring-[var(--color-border)]">
<UsersRound size={16} strokeWidth={2.2} aria-hidden="true" />
</span>
)}
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 truncate font-mono text-[12px] font-extrabold text-[var(--color-text-primary)]">
{teamName}
</span>
<Badge tone={phaseTone(phase)} size="xs" bordered>
{t(`agentTeams.phase.${phase}` as TranslationKey)}
</Badge>
</span>
{snapshot ? (
<span className="mt-0.5 flex items-center gap-1.5 text-[11px] text-[var(--color-text-tertiary)]">
<span>{t('agentTeams.inline.created', { count: members.length })}</span>
{progress.total > 0 ? (
<>
<span aria-hidden="true">·</span>
<span className="tabular-nums">
{t('agentTeams.inline.tasks', { completed: progress.completed, total: progress.total })}
</span>
</>
) : null}
</span>
) : null}
</span>
{canOpen ? (
<span className="flex shrink-0 items-center gap-0.5 text-[11.5px] font-medium text-[var(--color-brand)]">
{t('agentTeams.inline.open')}
<ChevronRight size={13} strokeWidth={2.4} aria-hidden="true" />
</span>
) : null}
</button>
{children}
</div>
)
}
@@ -0,0 +1,545 @@
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useChatStore } from '@/stores/chatStore'
import { useSettingsStore } from '@/stores/settingsStore'
import { useTeamStore } from '@/stores/teamStore'
import { useTabStore } from '@/stores/tabStore'
import type { TeamWorkbenchSnapshot, TeamWorkbenchTask } from '@/types/team'
import { AgentTeamsWorkbench } from './AgentTeamsWorkbench'
const { getWorkbenchForSessionMock, getWorkbenchMock } = vi.hoisted(() => ({
getWorkbenchForSessionMock: vi.fn(),
getWorkbenchMock: vi.fn(),
}))
vi.mock('@/api/teams', () => ({
teamsApi: {
list: vi.fn(),
get: vi.fn(),
getWorkbenchForSession: getWorkbenchForSessionMock,
getWorkbench: getWorkbenchMock,
getMemberTranscript: vi.fn(),
sendMemberMessage: vi.fn(),
delete: vi.fn(),
},
}))
function task(
id: string,
status: TeamWorkbenchTask['status'],
blockedBy: string[] = [],
owner?: string,
): TeamWorkbenchTask {
return {
id,
subject: `Task ${id}`,
description: `Task ${id} detail`,
activeForm: status === 'in_progress' ? `Working on task ${id}` : undefined,
owner,
status,
blocks: [],
blockedBy,
taskListId: 'visual-team',
}
}
function workbench(
version: string,
statuses: [TeamWorkbenchTask['status'], TeamWorkbenchTask['status'], TeamWorkbenchTask['status']],
): TeamWorkbenchSnapshot {
return {
version,
generatedAt: `2026-08-08T00:00:0${version.slice(-1)}.000Z`,
team: {
name: 'visual-team',
leadAgentId: 'team-lead@visual-team',
leadSessionId: 'lead-session',
members: [
{ agentId: 'team-lead@visual-team', name: 'team-lead', role: 'lead', status: 'running' },
{ agentId: 'builder@visual-team', name: 'builder', role: 'frontend', status: 'running' },
{ agentId: 'reviewer@visual-team', name: 'reviewer', role: 'reviewer', status: 'idle' },
],
},
tasks: [
task('1', statuses[0], [], 'builder'),
task('2', statuses[1], ['1'], 'reviewer'),
task('3', statuses[2], ['1', '2']),
],
messages: [{
id: `message-${version}`,
from: 'builder',
to: 'reviewer',
recipients: ['reviewer'],
kind: 'direct',
text: `Snapshot ${version} ready`,
timestamp: `2026-08-08T00:00:0${version.slice(-1)}.000Z`,
}],
}
}
function unownedWorkbench(
version: string,
builderStatus: 'running' | 'idle',
): TeamWorkbenchSnapshot {
const snapshot = workbench(version, ['pending', 'pending', 'pending'])
snapshot.tasks = snapshot.tasks.map((entry) => ({ ...entry, owner: undefined }))
snapshot.team.members = snapshot.team.members.map((member) => (
member.name === 'builder'
? { ...member, status: builderStatus }
: member
))
return snapshot
}
describe('AgentTeamsWorkbench', () => {
beforeEach(() => {
getWorkbenchMock.mockReset()
getWorkbenchForSessionMock.mockReset()
useTeamStore.getState().clearTeam()
useTabStore.setState({ tabs: [], activeTabId: null })
useSettingsStore.setState({ locale: 'en' })
})
it('keeps live controls simple, then supports seeking, speed, playback, and returning live', async () => {
getWorkbenchMock
.mockResolvedValueOnce(workbench('v1', ['completed', 'in_progress', 'pending']))
.mockResolvedValueOnce(workbench('v2', ['completed', 'completed', 'in_progress']))
.mockResolvedValueOnce(workbench('v3', ['completed', 'completed', 'completed']))
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
await useTeamStore.getState().fetchWorkbench('visual-team')
})
render(<AgentTeamsWorkbench sessionId="lead-session" />)
expect(screen.getByTestId('agent-teams-live-controls')).toBeTruthy()
expect(screen.queryByTestId('agent-teams-replay-controls')).toBeNull()
expect(screen.queryByRole('slider', { name: 'Replay timeline · click to seek' })).toBeNull()
expect(screen.queryByRole('button', { name: /^(Play|Pause|Replay)$/ })).toBeNull()
expect(screen.getByTestId('agent-teams-communication-rail').className).toContain('w-14')
expect(screen.getByTestId('agent-teams-canvas-task-2').getAttribute('data-state')).toBe('completed')
expect(screen.getByTestId('agent-teams-canvas-task-3').getAttribute('data-state')).toBe('running')
fireEvent.click(screen.getByRole('button', { name: 'Review history' }))
expect(screen.queryByTestId('agent-teams-live-controls')).toBeNull()
expect(screen.getByTestId('agent-teams-replay-controls')).toBeTruthy()
const timeline = screen.getByRole('slider', { name: 'Replay timeline · click to seek' }) as HTMLInputElement
expect(timeline.value).toBe('1000')
fireEvent.change(timeline, { target: { value: '0' } })
expect(screen.getByTestId('agent-teams-canvas-task-2').getAttribute('data-state')).toBe('running')
expect(screen.getByTestId('agent-teams-canvas-task-3').getAttribute('data-state')).toBe('blocked')
const quadrupleSpeed = screen.getByRole('button', { name: '4×' })
fireEvent.click(quadrupleSpeed)
expect(quadrupleSpeed.getAttribute('aria-pressed')).toBe('true')
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
})
expect(timeline.max).toBe('2000')
expect(timeline.value).toBe('0')
expect(screen.getByTestId('agent-teams-canvas-task-2').getAttribute('data-state')).toBe('running')
vi.useFakeTimers()
try {
fireEvent.click(screen.getByRole('button', { name: 'Play' }))
expect(screen.getByRole('button', { name: 'Pause' })).toBeTruthy()
act(() => {
vi.advanceTimersByTime(251)
})
expect(screen.getByTestId('agent-teams-canvas-task-2').getAttribute('data-state')).toBe('completed')
expect(screen.getByTestId('agent-teams-canvas-task-3').getAttribute('data-state')).toBe('running')
fireEvent.click(screen.getByRole('button', { name: 'Pause' }))
} finally {
vi.useRealTimers()
}
fireEvent.click(screen.getByRole('button', { name: 'Back to live' }))
expect(screen.getByTestId('agent-teams-live-controls')).toBeTruthy()
expect(screen.getByTestId('agent-teams-canvas-task-3').getAttribute('data-state')).toBe('completed')
})
it('positions and plays replay snapshots by their real timestamps', async () => {
vi.useFakeTimers()
try {
const first = workbench('v1', ['in_progress', 'pending', 'pending'])
const middle = workbench('v2', ['completed', 'in_progress', 'pending'])
const last = workbench('v3', ['completed', 'completed', 'completed'])
first.generatedAt = '2026-08-08T00:00:00.000Z'
middle.generatedAt = '2026-08-08T00:00:01.000Z'
last.generatedAt = '2026-08-08T00:01:40.000Z'
middle.messages = [...first.messages, ...middle.messages]
last.messages = [...middle.messages, ...last.messages]
getWorkbenchMock
.mockResolvedValueOnce(first)
.mockResolvedValueOnce(middle)
.mockResolvedValueOnce(last)
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
await useTeamStore.getState().fetchWorkbench('visual-team')
await useTeamStore.getState().fetchWorkbench('visual-team')
})
render(<AgentTeamsWorkbench sessionId="lead-session" />)
fireEvent.click(screen.getByRole('button', { name: 'Review history' }))
const timeline = screen.getByRole('slider', { name: 'Replay timeline · click to seek' }) as HTMLInputElement
expect(timeline.max).toBe('100000')
fireEvent.change(timeline, { target: { value: '1000' } })
expect(screen.getByTestId('agent-teams-replay-progress').getAttribute('style')).toContain('width: 1%')
expect(screen.getByTestId('agent-teams-replay-thumb').getAttribute('style')).toContain('left: 1%')
fireEvent.change(timeline, { target: { value: '0' } })
fireEvent.click(screen.getByRole('button', { name: 'Play' }))
act(() => vi.advanceTimersByTime(1001))
expect(screen.getByTestId('agent-teams-canvas-task-2').getAttribute('data-state')).toBe('running')
act(() => vi.advanceTimersByTime(98_000))
expect(screen.getByTestId('agent-teams-canvas-task-3').getAttribute('data-state')).toBe('blocked')
act(() => vi.advanceTimersByTime(1001))
expect(screen.getByTestId('agent-teams-canvas-task-3').getAttribute('data-state')).toBe('completed')
} finally {
vi.useRealTimers()
}
})
it('follows server-driven idle to running to idle member lifecycle without an owned task', async () => {
getWorkbenchMock
.mockResolvedValueOnce(unownedWorkbench('lifecycle-1', 'idle'))
.mockResolvedValueOnce(unownedWorkbench('lifecycle-2', 'running'))
.mockResolvedValueOnce(unownedWorkbench('lifecycle-3', 'idle'))
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
})
render(<AgentTeamsWorkbench sessionId="lead-session" />)
const builder = () => screen.getByTestId('agent-teams-canvas-member-builder@visual-team')
expect(builder().getAttribute('data-member-state')).toBe('idle')
act(() => {
useChatStore.getState().handleServerMessage('lead-session', {
type: 'team_workbench_updated',
teamName: 'visual-team',
})
})
await waitFor(() => {
expect(builder().getAttribute('data-member-state')).toBe('working')
})
act(() => {
useChatStore.getState().handleServerMessage('lead-session', {
type: 'team_workbench_updated',
teamName: 'visual-team',
})
})
await waitFor(() => {
expect(builder().getAttribute('data-member-state')).toBe('idle')
})
})
it('keeps a member idle while it still owns an in-progress task', async () => {
const snapshot = workbench('v1', ['in_progress', 'pending', 'pending'])
snapshot.team.members = snapshot.team.members.map((member) => (
member.name === 'builder'
? { ...member, status: 'running' as const, activity: 'idle' as const }
: member
))
getWorkbenchMock.mockResolvedValueOnce(snapshot)
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
})
render(<AgentTeamsWorkbench sessionId="lead-session" />)
expect(
screen.getByTestId('agent-teams-canvas-member-builder@visual-team').getAttribute('data-member-state'),
).toBe('idle')
expect(screen.getByTestId('agent-teams-canvas-task-1').getAttribute('data-state')).toBe('running')
})
it('shows a member working while it owns no task at all', async () => {
const snapshot = unownedWorkbench('v1', 'idle')
snapshot.team.members = snapshot.team.members.map((member) => (
member.name === 'builder'
? { ...member, activity: 'active' as const }
: member
))
getWorkbenchMock.mockResolvedValueOnce(snapshot)
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
})
render(<AgentTeamsWorkbench sessionId="lead-session" />)
expect(
screen.getByTestId('agent-teams-canvas-member-builder@visual-team').getAttribute('data-member-state'),
).toBe('working')
})
it('opens a 400px member inspector before the explicit execution handoff', async () => {
getWorkbenchMock.mockResolvedValueOnce(workbench('v1', ['completed', 'in_progress', 'pending']))
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
})
render(<AgentTeamsWorkbench sessionId="lead-session" />)
const reviewer = screen.getByTestId('agent-teams-canvas-member-reviewer@visual-team')
fireEvent.click(reviewer)
expect(useTabStore.getState().activeTabId).toBeNull()
expect(reviewer.getAttribute('aria-pressed')).toBe('true')
expect(screen.getByTestId('agent-teams-member-inspector')).toBeTruthy()
expect(screen.getByTestId('agent-teams-communication-pane').className).toContain('w-[400px]')
fireEvent.click(screen.getByRole('button', { name: 'View reviewer execution' }))
expect(useTabStore.getState().activeTabId).toBe('team-member:reviewer@visual-team')
expect(useTabStore.getState().tabs.find((tab) => tab.sessionId === 'team-member:reviewer@visual-team')).toMatchObject({
type: 'team-member',
teamLeadSessionId: 'lead-session',
})
})
it('closes an open member inspector when the selected member is clicked again', async () => {
getWorkbenchMock.mockResolvedValueOnce(workbench('v1', ['completed', 'in_progress', 'pending']))
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
})
render(<AgentTeamsWorkbench sessionId="lead-session" />)
const reviewer = screen.getByTestId('agent-teams-canvas-member-reviewer@visual-team')
fireEvent.click(reviewer)
expect(screen.getByTestId('agent-teams-member-inspector')).toBeTruthy()
fireEvent.click(reviewer)
expect(screen.queryByTestId('agent-teams-member-inspector')).toBeNull()
expect(screen.getByTestId('agent-teams-communication-rail')).toBeTruthy()
})
it('returns to the real lead session instead of creating a synthetic lead member run', async () => {
getWorkbenchMock.mockResolvedValueOnce(workbench('v1', ['completed', 'in_progress', 'pending']))
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
})
render(<AgentTeamsWorkbench sessionId="lead-session" />)
fireEvent.click(screen.getByTestId('agent-teams-canvas-member-team-lead@visual-team'))
fireEvent.click(screen.getByRole('button', { name: 'View team-lead execution' }))
expect(useTabStore.getState().activeTabId).toBe('lead-session')
expect(useTabStore.getState().tabs).toContainEqual(expect.objectContaining({
sessionId: 'lead-session',
type: 'session',
}))
expect(useTabStore.getState().tabs.some(tab => tab.type === 'team-member')).toBe(false)
})
it('opens task details without navigating to a member execution', async () => {
getWorkbenchMock.mockResolvedValueOnce(workbench('v1', ['completed', 'in_progress', 'pending']))
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
})
render(<AgentTeamsWorkbench sessionId="lead-session" />)
fireEvent.click(screen.getByTestId('agent-teams-canvas-task-1'))
expect(useTabStore.getState().activeTabId).toBeNull()
expect(screen.getByTestId('agent-teams-task-detail').getAttribute('data-task-id')).toBe('1')
expect(screen.getByTestId('agent-teams-task-detail').textContent).toContain('Task 1 detail')
fireEvent.click(screen.getByTestId('agent-teams-canvas-task-2'))
expect(useTabStore.getState().activeTabId).toBeNull()
expect(screen.getByTestId('agent-teams-task-detail').getAttribute('data-task-id')).toBe('2')
})
it('does not leak a selected task into replay frames before that task existed', async () => {
const beforeCreation = workbench('v1', ['completed', 'pending', 'pending'])
beforeCreation.tasks = beforeCreation.tasks.filter(entry => entry.id !== '3')
const afterCreation = workbench('v2', ['completed', 'completed', 'in_progress'])
getWorkbenchMock.mockResolvedValueOnce(beforeCreation).mockResolvedValueOnce(afterCreation)
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
await useTeamStore.getState().fetchWorkbench('visual-team')
})
render(<AgentTeamsWorkbench sessionId="lead-session" />)
fireEvent.click(screen.getByTestId('agent-teams-canvas-task-3'))
expect(screen.getByTestId('agent-teams-task-detail')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Review history' }))
fireEvent.change(screen.getByRole('slider', { name: 'Replay timeline · click to seek' }), {
target: { value: '0' },
})
expect(screen.queryByTestId('agent-teams-task-detail')).toBeNull()
})
it('draws a teammate once while retaining its owner labels across several tasks', async () => {
const snapshot = workbench('v1', ['in_progress', 'in_progress', 'pending'])
snapshot.tasks = snapshot.tasks.map((entry) => ({ ...entry, owner: 'builder' }))
getWorkbenchMock.mockResolvedValueOnce(snapshot)
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
})
const { container } = render(<AgentTeamsWorkbench sessionId="lead-session" />)
expect(
container.querySelectorAll('[data-testid="agent-teams-canvas-member-builder@visual-team"]'),
).toHaveLength(1)
expect(within(screen.getByTestId('agent-teams-canvas-task-2')).getByText('builder')).toBeTruthy()
})
it('restores archived roster members and task owners across replay snapshots', async () => {
const beforeRemoval = workbench('roster-1', ['in_progress', 'in_progress', 'pending'])
beforeRemoval.team.members.push({
agentId: 'observer@visual-team',
name: 'observer',
role: 'researcher',
status: 'idle',
})
const afterRemoval: TeamWorkbenchSnapshot = {
...workbench('roster-2', ['completed', 'completed', 'completed']),
deletedAt: '2026-08-08T00:10:00.000Z',
team: {
...beforeRemoval.team,
members: beforeRemoval.team.members
.filter((member) => member.name !== 'builder')
.map((member) => ({ ...member, status: 'completed' as const })),
},
tasks: workbench('roster-2', ['completed', 'completed', 'completed']).tasks.map((entry) => (
entry.id === '3' ? { ...entry, owner: 'legacy-owner' } : entry
)),
}
getWorkbenchForSessionMock.mockResolvedValueOnce({
sessionId: 'lead-session',
teamName: 'visual-team',
source: 'archive',
snapshots: [beforeRemoval, afterRemoval],
})
await act(async () => {
await useTeamStore.getState().fetchTeamForSession('lead-session', { force: true })
})
render(<AgentTeamsWorkbench sessionId="lead-session" />)
expect(within(screen.getByTestId('agent-teams-canvas-task-1')).getByText('builder')).toBeTruthy()
expect(within(screen.getByTestId('agent-teams-canvas-task-3')).getByText('legacy-owner')).toBeTruthy()
const restoredBuilder = screen.getByTestId('agent-teams-canvas-member-builder@visual-team')
const observer = screen.getByTestId('agent-teams-canvas-member-observer@visual-team')
expect(restoredBuilder.getAttribute('data-member-state')).toBe('exited')
expect(observer.getAttribute('data-member-state')).toBe('exited')
expect(restoredBuilder.querySelector('img')).toBeTruthy()
fireEvent.click(restoredBuilder)
expect(useTabStore.getState().activeTabId).toBeNull()
expect(screen.getByTestId('agent-teams-member-inspector').getAttribute('data-member-id')).toBe('builder@visual-team')
fireEvent.click(screen.getByRole('button', { name: 'View builder execution' }))
expect(useTabStore.getState().activeTabId).toBe('team-member:builder@visual-team')
})
it('expands the 56px communication rail to a fixed panel and closes it again', async () => {
getWorkbenchMock.mockResolvedValueOnce(workbench('v1', ['completed', 'in_progress', 'pending']))
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
})
render(<AgentTeamsWorkbench sessionId="lead-session" />)
const rail = screen.getByTestId('agent-teams-communication-rail')
expect(rail.className).toContain('w-14')
expect(screen.queryByTestId('agent-teams-communication-pane')).toBeNull()
fireEvent.click(rail)
const pane = screen.getByTestId('agent-teams-communication-pane')
expect(pane.className).toContain('w-[400px]')
expect(screen.getByTestId('agent-teams-communication').className).toContain('h-full')
expect(screen.queryByTestId('agent-teams-communication-rail')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Close communication panel' }))
expect(screen.queryByTestId('agent-teams-communication-pane')).toBeNull()
expect(screen.getByTestId('agent-teams-communication-rail')).toBeTruthy()
})
it('lights the dependency chain while a task-linked communication row is hovered', async () => {
const snapshot = workbench('v1', ['completed', 'in_progress', 'pending'])
snapshot.messages[0] = { ...snapshot.messages[0]!, taskId: '2' }
getWorkbenchMock.mockResolvedValueOnce(snapshot)
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
})
render(<AgentTeamsWorkbench sessionId="lead-session" />)
fireEvent.click(screen.getByTestId('agent-teams-communication-rail'))
const row = screen.getByTestId('agent-teams-message-message-v1')
fireEvent.mouseEnter(row)
expect(screen.getByTestId('agent-teams-canvas-task-1').getAttribute('data-chain-active')).toBe('true')
expect(screen.getByTestId('agent-teams-canvas-task-2').getAttribute('data-chain-active')).toBe('true')
fireEvent.mouseLeave(row)
expect(screen.getByTestId('agent-teams-canvas-task-2').getAttribute('data-chain-active')).toBe('false')
fireEvent.mouseEnter(row)
fireEvent.click(screen.getByRole('button', { name: 'Close communication panel' }))
expect(screen.getByTestId('agent-teams-canvas-task-2').getAttribute('data-chain-active')).toBe('false')
})
it('animates every message in a CLI polling batch in arrival order', async () => {
const first = workbench('v1', ['completed', 'in_progress', 'pending'])
const second = workbench('v2', ['completed', 'in_progress', 'pending'])
const peerMessage = {
...first.messages[0]!,
id: 'batch-peer',
from: 'reviewer',
to: 'builder',
recipients: ['builder'],
taskId: '2',
}
const reportMessage = {
...first.messages[0]!,
id: 'batch-report',
from: 'builder',
to: 'team-lead',
recipients: ['team-lead'],
taskId: '1',
}
second.messages = [...first.messages, peerMessage, reportMessage]
getWorkbenchMock.mockResolvedValueOnce(first).mockResolvedValueOnce(second)
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
})
render(<AgentTeamsWorkbench sessionId="lead-session" />)
vi.useFakeTimers()
try {
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
})
expect(screen.getByTestId('agent-teams-active-flight-path').getAttribute('data-flight-channel')).toBe('peer')
act(() => vi.advanceTimersByTime(1501))
expect(screen.getByTestId('agent-teams-active-flight-path').getAttribute('data-flight-channel')).toBe('lead')
act(() => vi.advanceTimersByTime(1501))
expect(screen.queryByTestId('agent-teams-active-flight-path')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('never describes an ownerless completed task as waiting to be claimed', async () => {
const snapshot = workbench('v1', ['completed', 'pending', 'completed'])
snapshot.tasks = snapshot.tasks.map((entry) => (
entry.id === '1' || entry.id === '2'
? { ...entry, owner: undefined }
: entry
))
getWorkbenchMock.mockResolvedValueOnce(snapshot)
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
})
render(<AgentTeamsWorkbench sessionId="lead-session" />)
const completed = screen.getByTestId('agent-teams-canvas-task-1')
expect(within(completed).getByText('Completed · owner not recorded')).toBeTruthy()
expect(within(completed).queryByText('Waiting for a teammate')).toBeNull()
const available = screen.getByTestId('agent-teams-canvas-task-2')
expect(within(available).getByText('Waiting for a teammate')).toBeTruthy()
expect(within(available).queryByText('Completed · owner not recorded')).toBeNull()
})
})
@@ -0,0 +1,639 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronLeft, ChevronRight, Pause, Play, Radio, X } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { IconButton } from '@/components/ui/IconButton'
import { useTranslation, type TranslationKey } from '../../i18n'
import { useChatStore } from '../../stores/chatStore'
import { useTeamStore } from '../../stores/teamStore'
import { useTabStore } from '../../stores/tabStore'
import type {
TeamMember,
TeamWorkbenchSnapshot,
TeamWorkbenchTask,
} from '../../types/team'
import { AgentTeamsCanvas } from './AgentTeamsCanvas'
import { AgentTeamsCommunicationFeed } from './AgentTeamsCommunicationFeed'
import { AgentTeamsMemberInspector } from './AgentTeamsMemberInspector'
import {
getMemberWorkState,
getWorkbenchPhase,
getWorkbenchTaskState,
inferTaskOwner,
resolveTeamMemberIdentity,
snapshotWithHistoricalMembers,
type WorkbenchPhase,
type WorkbenchTaskState,
} from './agentTeamsModel'
type TranslationFn = ReturnType<typeof useTranslation>
const REPLAY_SPEEDS = [0.5, 1, 2, 4] as const
const REPLAY_FALLBACK_FRAME_MS = 720
const MESSAGE_FLIGHT_MS = 1500
function timestamp(value: string | undefined): number | null {
if (!value) return null
const numeric = /^\d+$/.test(value) ? Number(value) : Number.NaN
const parsed = Number.isFinite(numeric) ? numeric : Date.parse(value)
return Number.isFinite(parsed) ? parsed : null
}
function formatDuration(valueMs: number): string {
const seconds = Math.max(0, Math.round(valueMs / 1000))
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const rest = seconds % 60
if (hours > 0) {
return [hours, minutes, rest].map((part) => String(part).padStart(2, '0')).join(':')
}
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`
}
function formatClock(value: string | undefined): string {
const time = timestamp(value)
if (time === null) return '--:--:--'
return new Date(time).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
function replayFrameDelay(
snapshots: TeamWorkbenchSnapshot[],
selectedIndex: number,
speed: number,
): number {
const currentTime = timestamp(snapshots[selectedIndex]?.generatedAt)
const nextTime = timestamp(snapshots[selectedIndex + 1]?.generatedAt)
const sourceDelay = currentTime !== null && nextTime !== null && nextTime > currentTime
? nextTime - currentTime
: REPLAY_FALLBACK_FRAME_MS
return Math.max(16, sourceDelay / speed)
}
function phaseLabel(phase: WorkbenchPhase, t: TranslationFn): string {
return t(`agentTeams.phase.${phase}` as TranslationKey)
}
function stateCounts(snapshot: TeamWorkbenchSnapshot) {
const tasksById = new Map(snapshot.tasks.map((task) => [task.id, task]))
const counts: Record<WorkbenchTaskState, number> = {
blocked: 0,
open: 0,
running: 0,
completed: 0,
}
for (const task of snapshot.tasks) counts[getWorkbenchTaskState(task, tasksById)] += 1
return counts
}
function TimelineStat({ label, value, tone }: {
label: string
value: string | number
tone?: 'brand' | 'muted'
}) {
return (
<div className="min-w-[52px]">
<div className="text-[10px] font-semibold text-[var(--color-text-tertiary)]">{label}</div>
<div
className={[
'whitespace-nowrap font-mono text-[14px] font-extrabold tabular-nums',
tone === 'brand'
? 'text-[var(--color-brand)]'
: tone === 'muted'
? 'text-[var(--color-text-tertiary)]'
: 'text-[var(--color-text-primary)]',
].join(' ')}
>
{value}
</div>
</div>
)
}
/**
* The full Agent Teams surface. The snapshot stream is the only source of
* truth for both live mode and replay; replay merely moves the cursor through
* that same stream, so the canvas, counters, member history and communication
* feed cannot drift into different stories.
*/
export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
const t = useTranslation()
const timeline = useTeamStore((state) => state.workbenchesBySession[sessionId])
const historyIndex = useTeamStore(
(state) => state.workbenchHistoryIndexBySession[sessionId] ?? null,
)
const setHistoryIndex = useTeamStore((state) => state.setWorkbenchHistoryIndex)
const openMemberSession = useTeamStore((state) => state.openMemberSession)
const leadIsStreaming = useChatStore(
(state) => (state.sessions[sessionId]?.chatState ?? 'idle') !== 'idle',
)
const [communicationOpen, setCommunicationOpen] = useState(false)
const [selectedMemberId, setSelectedMemberId] = useState<string | null>(null)
const [selectedTask, setSelectedTask] = useState<TeamWorkbenchTask | null>(null)
const [replaySpeed, setReplaySpeed] = useState<(typeof REPLAY_SPEEDS)[number]>(1)
const [playing, setPlaying] = useState(false)
const [messageFlightQueue, setMessageFlightQueue] = useState<string[]>([])
const [feedFocusedTaskId, setFeedFocusedTaskId] = useState<string | null>(null)
const [now, setNow] = useState(() => Date.now())
const seenMessageIdsRef = useRef<Set<string> | null>(null)
const snapshots = timeline?.snapshots ?? []
const latestIndex = snapshots.length - 1
const latestSnapshot = snapshots[latestIndex]
const followingLive = historyIndex === null && !latestSnapshot?.deletedAt
const selectedIndex = historyIndex === null
? latestIndex
: Math.max(0, Math.min(latestIndex, historyIndex))
const snapshot = useMemo(
() => snapshotWithHistoricalMembers(snapshots, selectedIndex),
[selectedIndex, snapshots],
)
const previousSnapshot = useMemo(
() => snapshotWithHistoricalMembers(snapshots, selectedIndex - 1),
[selectedIndex, snapshots],
)
useEffect(() => {
if (!followingLive) return
const timer = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [followingLive])
const visibleMessageIds = useMemo(
() => snapshot?.messages.map(message => message.id) ?? [],
[snapshot?.messages],
)
useEffect(() => {
const current = new Set(visibleMessageIds)
const previous = seenMessageIdsRef.current
seenMessageIdsRef.current = current
if (previous === null) return
const movedBackward = Array.from(previous).some(id => !current.has(id))
const added = visibleMessageIds.filter(id => !previous.has(id))
setMessageFlightQueue(queue => {
const retained = movedBackward ? [] : queue.filter(id => current.has(id))
const known = new Set(retained)
return [...retained, ...added.filter(id => !known.has(id))]
})
}, [visibleMessageIds])
const activeMessageId = messageFlightQueue[0] ?? null
useEffect(() => {
if (!activeMessageId) return
const timer = window.setTimeout(() => {
setMessageFlightQueue(queue => (
queue[0] === activeMessageId ? queue.slice(1) : queue.filter(id => id !== activeMessageId)
))
}, MESSAGE_FLIGHT_MS)
return () => window.clearTimeout(timer)
}, [activeMessageId])
useEffect(() => {
if (!playing) return
if (latestIndex <= 0 || selectedIndex >= latestIndex) {
setPlaying(false)
return
}
const timer = window.setTimeout(() => {
setHistoryIndex(sessionId, selectedIndex + 1)
}, replayFrameDelay(snapshots, selectedIndex, replaySpeed))
return () => window.clearTimeout(timer)
}, [latestIndex, playing, replaySpeed, selectedIndex, sessionId, setHistoryIndex, snapshots])
useEffect(() => {
if (followingLive) setPlaying(false)
}, [followingLive])
if (!snapshot) {
return (
<section
aria-label={t('agentTeams.title')}
className="flex h-full min-h-0 items-center justify-center bg-[var(--color-surface)] p-8 text-sm text-[var(--color-text-tertiary)]"
>
{timeline?.error || t('agentTeams.loading')}
</section>
)
}
const phase = getWorkbenchPhase(snapshot)
const counts = stateCounts(snapshot)
const startTime = timestamp(snapshot.team.createdAt) ?? timestamp(snapshots[0]?.generatedAt) ?? now
const cursorTime = timestamp(snapshot.generatedAt) ?? startTime
const endTime = timestamp(latestSnapshot?.deletedAt) ?? timestamp(latestSnapshot?.generatedAt) ?? now
const elapsed = followingLive ? now - startTime : cursorTime - startTime
const totalElapsed = Math.max(0, endTime - startTime)
const workerMembers = snapshot.team.members.filter(
(member) => member.agentId !== snapshot.team.leadAgentId,
)
const workingCount = workerMembers.filter((member) => (
getMemberWorkState(member) === 'working'
)).length
const liveHint = phase === 'forming'
? t('agentTeams.live.formingHint')
: workingCount > 0
? t('agentTeams.live.runningHint', { count: workingCount })
: t('agentTeams.live.waitingHint')
const selectedMember = selectedMemberId
? snapshot.team.members.find((member) => member.agentId === selectedMemberId)
: undefined
const selectedMemberIsLead = Boolean(
selectedMember && selectedMember.agentId === snapshot.team.leadAgentId,
)
const timelineTickPositions = snapshots.map((entry, index) => {
const at = timestamp(entry.generatedAt)
const position = totalElapsed > 0 && at !== null
? ((at - startTime) / totalElapsed) * 100
: latestIndex > 0
? (index / latestIndex) * 100
: 0
return Math.max(0, Math.min(100, position))
})
const timelineExtent = totalElapsed > 0 ? totalElapsed : Math.max(1, latestIndex)
const timelineValue = totalElapsed > 0
? Math.max(0, Math.min(totalElapsed, cursorTime - startTime))
: Math.max(0, selectedIndex)
const timelinePosition = latestIndex <= 0
? 100
: Math.max(0, Math.min(100, (timelineValue / timelineExtent) * 100))
const enterReplay = () => {
setPlaying(false)
setHistoryIndex(sessionId, Math.max(0, selectedIndex))
}
const returnToLive = () => {
setPlaying(false)
setHistoryIndex(sessionId, null)
}
const togglePlayback = () => {
if (selectedIndex >= latestIndex) {
setHistoryIndex(sessionId, 0)
setPlaying(latestIndex > 0)
return
}
setPlaying((current) => !current)
}
const closeCommunication = () => {
setCommunicationOpen(false)
setSelectedMemberId(null)
setFeedFocusedTaskId(null)
}
const selectMember = (member: TeamMember) => {
if (communicationOpen && selectedMemberId === member.agentId) {
closeCommunication()
return
}
setFeedFocusedTaskId(null)
setSelectedMemberId(member.agentId)
setCommunicationOpen(true)
}
const seekTimeline = (value: number) => {
setPlaying(false)
if (totalElapsed <= 0) {
setHistoryIndex(sessionId, Math.max(0, Math.min(latestIndex, Math.round(value))))
return
}
const targetTime = startTime + value
let targetIndex = 0
snapshots.forEach((entry, index) => {
const at = timestamp(entry.generatedAt)
if (at !== null && at <= targetTime) targetIndex = index
})
setHistoryIndex(sessionId, targetIndex)
}
const openSelectedExecution = () => {
if (!selectedMember) return
if (selectedMemberIsLead) {
useTabStore.getState().openTab(
snapshot.team.leadSessionId ?? sessionId,
selectedMember.name || selectedMember.role,
'session',
)
return
}
openMemberSession(selectedMember, snapshot.team, snapshot)
}
return (
<section
aria-label={t('agentTeams.title')}
className="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[var(--color-surface)] text-[var(--color-text-primary)]"
>
<header className="shrink-0 overflow-x-auto border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<div className="flex min-w-[1180px] items-center gap-5 px-[18px] py-2.5">
<div className="min-w-[260px]">
<div className="text-[10px] font-bold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
Agent Teams · {t('agentTeams.sharedTaskList')}
</div>
<div className="mt-0.5 flex min-w-0 items-center gap-2">
<span
className="max-w-[300px] truncate font-mono text-[16px] font-extrabold"
title={snapshot.team.name}
>
{snapshot.team.name}
</span>
<span className="shrink-0 rounded-full border border-[var(--color-primary-fixed-dim)] bg-[var(--color-brand-soft)] px-2 py-0.5 text-[10px] font-bold text-[var(--color-on-brand-soft)]">
{t('agentTeams.experimental')}
</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-[18px] border-x border-[var(--color-border)] px-[18px]">
<div className="min-w-[74px]">
<div className="text-[10px] font-semibold text-[var(--color-text-tertiary)]">
{t('agentTeams.stats.phase')}
</div>
<div className="whitespace-nowrap text-[14px] font-extrabold">
{phaseLabel(phase, t)}
</div>
</div>
<TimelineStat
label={t('agentTeams.stats.completed')}
value={`${counts.completed}/${snapshot.tasks.length}`}
/>
<TimelineStat label={t('agentTeams.stats.running')} value={counts.running} tone="brand" />
<TimelineStat label={t('agentTeams.stats.available')} value={counts.open} />
<TimelineStat label={t('agentTeams.stats.blocked')} value={counts.blocked} tone="muted" />
</div>
{followingLive ? (
<div data-testid="agent-teams-live-controls" className="flex min-w-0 flex-1 items-center gap-3.5">
<div className="flex shrink-0 items-center gap-2 rounded-full border border-[var(--color-primary-fixed-dim)] bg-[var(--color-brand-soft)] px-3 py-[7px]">
<span className="agent-teams-live-dot h-2 w-2 rounded-full bg-[var(--color-brand)]" aria-hidden="true" />
<span className="whitespace-nowrap text-[12.5px] font-extrabold text-[var(--color-on-brand-soft)]">
{t('agentTeams.live.following')}
</span>
</div>
<span className="min-w-0 flex-1 truncate text-[11.5px] text-[var(--color-text-secondary)]">
{liveHint}
</span>
<Button variant="secondary" size="sm" onClick={enterReplay}>
{t('agentTeams.reviewHistory')}
</Button>
<div className="shrink-0 border-l border-[var(--color-border)] pl-3.5">
<TimelineStat label={t('agentTeams.live.elapsed')} value={formatDuration(elapsed)} />
</div>
<div className="shrink-0 border-l border-[var(--color-border)] pl-3.5">
<TimelineStat label={t('agentTeams.live.sessionClock')} value={formatClock(snapshot.generatedAt)} />
</div>
</div>
) : (
<div data-testid="agent-teams-replay-controls" className="flex min-w-0 flex-1 items-center gap-3.5">
<Button
variant="accent"
size="base"
className="w-[86px]"
icon={playing ? <Pause size={13} aria-hidden="true" /> : <Play size={13} aria-hidden="true" />}
onClick={togglePlayback}
>
{playing
? t('agentTeams.replay.pause')
: selectedIndex >= latestIndex
? t('agentTeams.replay.replay')
: t('agentTeams.replay.play')}
</Button>
<div
role="group"
aria-label={t('agentTeams.replay.speed')}
className="flex shrink-0 overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-outline)]"
>
{REPLAY_SPEEDS.map((speed) => {
const selected = replaySpeed === speed
return (
<button
key={speed}
type="button"
aria-pressed={selected}
onClick={() => setReplaySpeed(speed)}
className={[
'h-7 border-r border-[var(--color-border)] px-2.5 font-mono text-[11px] font-bold outline-none transition-colors last:border-r-0 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]',
selected
? 'bg-[var(--color-brand)] text-[var(--color-on-primary)]'
: 'bg-[var(--color-surface-container-lowest)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]',
].join(' ')}
>
{speed}×
</button>
)
})}
</div>
<div className="min-w-[200px] flex-1">
<div className="mb-1 flex justify-between text-[10px] font-semibold text-[var(--color-text-tertiary)]">
<span>{t('agentTeams.replay.timeline')}</span>
<span className="font-mono tabular-nums">
{formatDuration(cursorTime - startTime)} / {formatDuration(totalElapsed)}
</span>
</div>
<div className="relative h-[18px]">
<div className="absolute inset-x-0 top-2 h-1 rounded-full bg-[var(--color-surface-container-high)]" />
<div
data-testid="agent-teams-replay-progress"
className="absolute left-0 top-2 h-1 rounded-full bg-[var(--color-brand)]"
style={{ width: `${timelinePosition}%` }}
/>
{timelineTickPositions.map((position, index) => (
<span
key={`${snapshots[index]?.version ?? index}-${index}`}
aria-hidden="true"
className="absolute top-[5px] h-[10px] w-px bg-[var(--color-text-tertiary)] opacity-30"
style={{ left: `${position}%` }}
/>
))}
<span
aria-hidden="true"
data-testid="agent-teams-replay-thumb"
className="absolute top-1 h-3 w-3 -translate-x-1/2 rounded-full border-[3px] border-[var(--color-brand)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-card)]"
style={{ left: `${timelinePosition}%` }}
/>
<input
type="range"
min={0}
max={timelineExtent}
step={1}
value={timelineValue}
aria-label={t('agentTeams.replay.timeline')}
onChange={event => seekTimeline(Number(event.target.value))}
onKeyDown={(event) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
event.preventDefault()
setPlaying(false)
setHistoryIndex(
sessionId,
Math.max(0, Math.min(latestIndex, selectedIndex + (event.key === 'ArrowRight' ? 1 : -1))),
)
}}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0 focus-visible:opacity-100 focus-visible:accent-[var(--color-brand)]"
/>
</div>
</div>
{!latestSnapshot?.deletedAt ? (
<Button
variant="secondary"
size="sm"
icon={<Radio size={12} aria-hidden="true" />}
onClick={returnToLive}
>
{t('agentTeams.backToLive')}
</Button>
) : null}
<div className="shrink-0 border-l border-[var(--color-border)] pl-3.5">
<TimelineStat label={t('agentTeams.live.sessionClock')} value={formatClock(snapshot.generatedAt)} />
</div>
</div>
)}
</div>
</header>
<div className="relative flex min-h-0 flex-1">
<div
data-testid="agent-teams-office-viewport"
className="min-h-0 min-w-0 flex-1 overflow-auto bg-[var(--color-surface)]"
>
<AgentTeamsCanvas
snapshots={snapshots}
selectedIndex={selectedIndex}
snapshot={snapshot}
previousSnapshot={previousSnapshot}
leadIsStreaming={leadIsStreaming}
activeMessageId={activeMessageId}
focusedTaskId={feedFocusedTaskId}
selectedMemberId={selectedMemberId}
onSelectMember={(member) => selectMember(member)}
onSelectTask={setSelectedTask}
/>
</div>
{communicationOpen ? (
<aside
data-testid="agent-teams-communication-pane"
className="h-full w-[400px] shrink-0 border-l border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]"
>
{selectedMember ? (
<AgentTeamsMemberInspector
snapshots={snapshots}
selectedIndex={selectedIndex}
snapshot={snapshot}
member={selectedMember}
isLead={selectedMemberIsLead}
leadIsStreaming={leadIsStreaming}
onBack={() => setSelectedMemberId(null)}
onClose={closeCommunication}
onOpenExecution={openSelectedExecution}
/>
) : (
<div className="relative h-full min-h-0">
<AgentTeamsCommunicationFeed
snapshot={snapshot}
fill
onFocusTask={setFeedFocusedTaskId}
/>
<div className="absolute right-3 top-2.5 z-[var(--z-raised)]">
<IconButton
icon={<ChevronRight aria-hidden="true" />}
label={t('agentTeams.communication.close')}
size="sm"
tone="muted"
bordered
onClick={closeCommunication}
/>
</div>
</div>
)}
</aside>
) : (
<button
type="button"
data-testid="agent-teams-communication-rail"
aria-label={t('agentTeams.communication.open')}
onClick={() => setCommunicationOpen(true)}
className="flex h-full w-14 shrink-0 flex-col items-center gap-3 border-l border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-3 outline-none transition-colors hover:bg-[var(--color-surface-container-low)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]"
>
<span className="flex h-[30px] w-[30px] items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-outline)] text-[var(--color-text-secondary)]">
<ChevronLeft size={14} strokeWidth={2.4} aria-hidden="true" />
</span>
<span className="text-[11px] font-extrabold tracking-[0.28em] text-[var(--color-text-secondary)] [writing-mode:vertical-rl]">
{t('agentTeams.communication.title')}
</span>
<span className="rounded-full bg-[var(--color-brand)] px-1.5 py-0.5 font-mono text-[10px] font-extrabold text-[var(--color-on-primary)]">
{snapshot.messages.length}
</span>
<span
className={[
'h-2 w-2 rounded-full',
activeMessageId
? 'agent-teams-live-dot bg-[var(--color-brand)]'
: 'bg-[var(--color-outline)]',
].join(' ')}
aria-hidden="true"
/>
</button>
)}
{selectedTask && snapshot.tasks.some(task => task.id === selectedTask.id) ? (
<TaskDetailPanel
task={snapshot.tasks.find(task => task.id === selectedTask.id)!}
snapshot={snapshot}
communicationOpen={communicationOpen}
onClose={() => setSelectedTask(null)}
t={t}
/>
) : null}
</div>
</section>
)
}
function TaskDetailPanel({
task,
snapshot,
communicationOpen,
onClose,
t,
}: {
task: TeamWorkbenchTask
snapshot: TeamWorkbenchSnapshot
communicationOpen: boolean
onClose: () => void
t: TranslationFn
}) {
const owner = inferTaskOwner(task, snapshot)
const ownerMember = owner
? resolveTeamMemberIdentity(snapshot.team, owner.identity).member
: undefined
return (
<aside
data-testid="agent-teams-task-detail"
data-task-id={task.id}
className="agent-teams-drawer absolute bottom-4 z-[var(--z-drawer)] w-[360px] rounded-[var(--radius-lg)] border border-[var(--color-outline)] bg-[var(--color-surface-container-lowest)] p-4 shadow-[var(--shadow-overlay)]"
style={{ right: communicationOpen ? 416 : 72 }}
>
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div className="font-mono text-[10px] font-extrabold text-[var(--color-text-tertiary)]">
#{task.id}
</div>
<h3 className="mt-1 text-[13px] font-extrabold leading-snug">{task.subject}</h3>
</div>
<IconButton
icon={<X aria-hidden="true" />}
label={t('agentTeams.task.closeDetail')}
size="sm"
tone="muted"
onClick={onClose}
/>
</div>
{task.description ? (
<p className="mt-3 whitespace-pre-wrap text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
{task.description}
</p>
) : null}
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 border-t border-[var(--color-border)] pt-3 text-[10.5px] text-[var(--color-text-secondary)]">
<span>{t('agentTeams.task.ownerLabel')}: {ownerMember?.name ?? owner?.identity ?? '—'}</span>
<span>{t('agentTeams.task.dependsOn')}: {task.blockedBy.map((id) => `#${id}`).join(', ') || '—'}</span>
<span>{t('agentTeams.task.unblocks')}: {task.blocks.map((id) => `#${id}`).join(', ') || '—'}</span>
</div>
</aside>
)
}
@@ -0,0 +1,160 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTabStore } from '../../stores/tabStore'
import { useTeamStore } from '../../stores/teamStore'
import type { TeamMember, TeamWorkbenchSnapshot } from '../../types/team'
import { AgentTeamsWorkbenchTab } from './AgentTeamsWorkbenchTab'
vi.mock('../../api/teams', () => ({
teamsApi: {
list: vi.fn(), get: vi.fn(),
getWorkbenchForSession: vi.fn().mockRejectedValue(new Error('no')),
getWorkbench: vi.fn(), getMemberTranscript: vi.fn(), sendMemberMessage: vi.fn(), delete: vi.fn(),
},
}))
const lead: TeamMember = {
agentId: 'lead@t',
name: 'lead',
role: 'orchestrator',
status: 'running',
}
const reviewer: TeamMember = {
agentId: 'reviewer@t',
name: 'reviewer',
role: 'reviewer',
status: 'running',
}
function workbenchSnapshot(overrides: Partial<TeamWorkbenchSnapshot> = {}): TeamWorkbenchSnapshot {
return {
version: 'v1',
generatedAt: '2026-08-08T00:00:00.000Z',
team: {
name: 't',
leadAgentId: lead.agentId,
leadSessionId: 'lead-session',
members: [lead, reviewer],
},
tasks: [{
id: '1',
subject: 'Review the implementation',
description: '',
status: 'in_progress',
owner: reviewer.name,
blocks: [],
blockedBy: [],
taskListId: 't',
}],
messages: [{
id: 'm1',
from: reviewer.name!,
to: lead.name!,
recipients: [lead.name!],
kind: 'direct',
text: 'Review is in progress.',
timestamp: '2026-08-08T00:00:00.000Z',
}],
...overrides,
}
}
function installTimeline(snapshot = workbenchSnapshot()) {
useTeamStore.setState({
workbenchesBySession: {
'lead-session': {
teamName: 't',
loading: false,
error: null,
snapshots: [snapshot],
},
},
})
}
describe('AgentTeamsWorkbenchTab', () => {
beforeEach(() => {
useTeamStore.getState().clearTeam()
useTabStore.setState({ tabs: [], activeTabId: null })
useSettingsStore.setState({ locale: 'en' })
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
it('opens the 400px communication feed from a collapsed 56px rail', () => {
installTimeline()
render(<AgentTeamsWorkbenchTab tabId="__team__lead-session" leadSessionId="lead-session" />)
expect(screen.getByTestId('agent-teams-canvas')).toBeTruthy()
expect(screen.queryByTestId('agent-teams-communication')).toBeNull()
expect(screen.queryByTestId('agent-teams-communication-pane')).toBeNull()
const rail = screen.getByTestId('agent-teams-communication-rail')
expect(rail.className).toContain('w-14')
fireEvent.click(rail)
const pane = screen.getByTestId('agent-teams-communication-pane')
expect(pane.className).toContain('w-[400px]')
expect(screen.queryByTestId('agent-teams-communication-rail')).toBeNull()
expect(screen.getByTestId('agent-teams-communication').className).toContain('h-full')
expect(screen.getByText('Review is in progress.')).toBeTruthy()
expect(screen.queryByRole('button', { name: /Open the workbench full screen/i })).toBeNull()
expect(screen.getByRole('button', { name: 'Back to session' })).toBeTruthy()
})
it('opens a member inspector before routing to execution and restores the Canvas on return', async () => {
installTimeline()
useTabStore.getState().openTab('lead-session', 'Lead session')
const teamTabId = useTabStore.getState().openTeamWorkbenchTab('lead-session', 't')
function RoutedWorkbench() {
const activeTab = useTabStore((state) => (
state.tabs.find((tab) => tab.sessionId === state.activeTabId)
))
if (activeTab?.type === 'team-member') {
return (
<button
type="button"
onClick={() => useTabStore.getState().returnFromTeamMember(activeTab.sessionId)}
>
Return to workbench
</button>
)
}
return <AgentTeamsWorkbenchTab tabId={teamTabId} leadSessionId="lead-session" />
}
render(<RoutedWorkbench />)
const member = await screen.findByTestId('agent-teams-canvas-member-reviewer@t')
fireEvent.click(member)
const inspector = screen.getByTestId('agent-teams-member-inspector')
expect(inspector.getAttribute('data-member-id')).toBe('reviewer@t')
expect(screen.getByTestId('agent-teams-communication-pane').className).toContain('w-[400px]')
expect(useTabStore.getState().activeTabId).toBe(teamTabId)
expect(screen.queryByRole('button', { name: 'Return to workbench' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'View reviewer execution' }))
await waitFor(() => {
expect(useTabStore.getState().activeTabId).toBe('team-member:reviewer@t')
})
expect(screen.getByRole('button', { name: 'Return to workbench' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Return to workbench' }))
await waitFor(() => {
expect(useTabStore.getState().activeTabId).toBe(teamTabId)
expect(screen.getByTestId('agent-teams-canvas')).toBeTruthy()
})
expect(screen.getByTestId('agent-teams-canvas-member-reviewer@t')).toBeTruthy()
expect(screen.getByTestId('agent-teams-communication-rail').className).toContain('w-14')
})
})
@@ -0,0 +1,49 @@
import { useEffect } from 'react'
import { ArrowLeft } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { useTranslation } from '../../i18n'
import { useTabStore } from '../../stores/tabStore'
import { useTeamStore } from '../../stores/teamStore'
import { AgentTeamsWorkbench } from './AgentTeamsWorkbench'
/**
* The detached, full-window form of the workbench. It reads the same
* per-session timeline the docked panel does, so switching between the two
* keeps history position and the selected member.
*/
export function AgentTeamsWorkbenchTab({
tabId,
leadSessionId,
}: {
tabId: string
leadSessionId: string
}) {
const t = useTranslation()
const hasTimeline = useTeamStore((state) =>
Boolean(state.workbenchesBySession[leadSessionId]?.snapshots.length),
)
const fetchTeamForSession = useTeamStore((state) => state.fetchTeamForSession)
// Restoring straight into this tab (or opening it for a session whose team
// was never fetched) leaves the store empty, so pull the timeline here too.
useEffect(() => {
if (hasTimeline) return
void fetchTeamForSession(leadSessionId)
}, [fetchTeamForSession, hasTimeline, leadSessionId])
return (
<div className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)]">
<div className="flex shrink-0 items-center gap-2 border-b border-[var(--color-border)] px-3 py-1.5">
<Button
variant="ghost"
size="sm"
icon={<ArrowLeft size={14} strokeWidth={2} aria-hidden="true" />}
onClick={() => useTabStore.getState().returnFromTeamWorkbench(tabId)}
>
{t('agentTeams.backToSession')}
</Button>
</div>
<AgentTeamsWorkbench sessionId={leadSessionId} />
</div>
)
}
@@ -0,0 +1,36 @@
import dataAnalystAvatar from '../../assets/agent-teams/data-analyst.png'
import docsCoordinatorAvatar from '../../assets/agent-teams/docs-coordinator.png'
import qaEngineerAvatar from '../../assets/agent-teams/qa-engineer.png'
import releaseEngineerAvatar from '../../assets/agent-teams/release-engineer.png'
import securityReviewerAvatar from '../../assets/agent-teams/security-reviewer.png'
import serverEngineerAvatar from '../../assets/agent-teams/server-engineer.png'
import teamLeadAvatar from '../../assets/agent-teams/team-lead.png'
import uiDesignerAvatar from '../../assets/agent-teams/ui-designer.png'
import type { MemberAvatarKey } from './agentTeamsModel'
export const MEMBER_AVATARS: Record<MemberAvatarKey, string> = {
'team-lead': teamLeadAvatar,
'server-engineer': serverEngineerAvatar,
'ui-designer': uiDesignerAvatar,
'qa-engineer': qaEngineerAvatar,
'security-reviewer': securityReviewerAvatar,
'data-analyst': dataAnalystAvatar,
'release-engineer': releaseEngineerAvatar,
'docs-coordinator': docsCoordinatorAvatar,
}
export const MEMBER_ACCENTS = [
'var(--color-brand)',
'var(--color-warning)',
'var(--color-success)',
'var(--color-info)',
'var(--color-text-secondary)',
'var(--color-error)',
] as const
const ACCENT_COLOR_ORDER = ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'cyan']
export function memberAccentColor(color: string | undefined, index: number): string {
const colorIndex = color ? ACCENT_COLOR_ORDER.indexOf(color) : -1
return MEMBER_ACCENTS[(colorIndex >= 0 ? colorIndex : index) % MEMBER_ACCENTS.length]!
}
@@ -0,0 +1,444 @@
import { describe, expect, it } from 'vitest'
import type { TeamMember, TeamWorkbenchSnapshot, TeamWorkbenchTask } from '../../types/team'
import {
formatWorkbenchMessageTime,
getWorkbenchPhase,
getWorkbenchProgress,
getWorkbenchTaskState,
getMemberAvatarKey,
inferTaskOwner,
layoutWorkbenchTasks,
parseWorkbenchMessageBody,
resolveTeamMemberIdentity,
runningTaskForMember,
snapshotWithHistoricalMembers,
taskOwnedByMember,
WORKBENCH_TASK_WIDTH,
} from './agentTeamsModel'
function task(
id: string,
status: TeamWorkbenchTask['status'],
blockedBy: string[] = [],
owner?: string,
): TeamWorkbenchTask {
return {
id,
subject: `Task ${id}`,
description: `Description ${id}`,
status,
owner,
blocks: [],
blockedBy,
taskListId: 'team-a',
}
}
function snapshot(tasks: TeamWorkbenchTask[]): TeamWorkbenchSnapshot {
return {
version: 'v1',
generatedAt: '2026-08-08T00:00:00.000Z',
team: {
name: 'team-a',
leadAgentId: 'lead@team-a',
leadSessionId: 'session-a',
members: [],
},
tasks,
messages: [],
}
}
describe('Agent Teams workbench model', () => {
it('lays out dependency depth as horizontal lanes and keeps edge states honest', () => {
const tasks = [
task('1', 'completed'),
task('2', 'in_progress'),
task('3', 'pending', ['1', '2']),
task('4', 'pending', ['missing']),
]
const layout = layoutWorkbenchTasks(tasks, 604)
const byId = new Map(tasks.map((entry) => [entry.id, entry]))
expect(layout.columns).toBe(2)
expect(layout.tasks.every((entry) => entry.x >= 0 && entry.x + WORKBENCH_TASK_WIDTH <= layout.width)).toBe(true)
expect(layout.byId.get('3')!.x).toBeGreaterThan(layout.byId.get('1')!.x)
expect(layout.byId.get('3')!.x).toBeGreaterThan(layout.byId.get('2')!.x)
expect(layout.byId.get('1')!.row).toBe(0)
expect(layout.byId.get('2')!.row).toBe(1)
expect(layout.byId.get('4')!.row).toBe(2)
expect(layout.byId.get('3')!.row).toBe(0)
expect(layout.height).toBe(824)
expect(layout.lanes).toEqual([
{ depth: 0, x: 76, y: 410, width: 216, height: 368, count: 3 },
{ depth: 1, x: 312, y: 410, width: 216, height: 368, count: 1 },
])
expect(layout.legendY).toBe(790)
expect(getWorkbenchTaskState(tasks[2]!, byId)).toBe('blocked')
// A blocker that is no longer in the task list was deleted. `claimTask`
// only counts blockers it can still find and that are not completed
// (src/utils/tasks.ts), so this task is claimable rather than stranded --
// and `layoutWorkbenchTasks` already ignores dependencies outside the list.
expect(getWorkbenchTaskState(tasks[3]!, byId)).toBe('open')
tasks[1] = task('2', 'completed')
const completedParents = new Map(tasks.map((entry) => [entry.id, entry]))
expect(getWorkbenchTaskState(tasks[2]!, completedParents)).toBe('open')
})
it('keeps a task blocked while a dependency it can still see is unfinished', () => {
const tasks = [task('1', 'in_progress'), task('2', 'pending', ['1'])]
const byId = new Map(tasks.map((entry) => [entry.id, entry]))
expect(getWorkbenchTaskState(tasks[1]!, byId)).toBe('blocked')
})
it('recovers an ownerless task attribution from its assignment envelope', () => {
const orphan = task('7', 'completed')
const base = snapshot([task('2', 'completed', [], 'backend-dev'), orphan])
const withAssignment: TeamWorkbenchSnapshot = {
...base,
messages: [{
id: 'mailbox-1',
from: 'backend-dev',
to: 'backend-dev',
recipients: ['backend-dev'],
kind: 'system',
protocolType: 'task_assignment',
taskId: '7',
text: '{"type":"task_assignment","taskId":"7"}',
timestamp: '2026-08-08T00:00:01.000Z',
}],
}
expect(inferTaskOwner(orphan, withAssignment)).toEqual({
identity: 'backend-dev',
inferred: true,
})
// A recorded owner is fact and must never be relabelled as a guess.
expect(inferTaskOwner(withAssignment.tasks[0]!, withAssignment)).toEqual({
identity: 'backend-dev',
inferred: false,
})
// Nothing to recover from leaves the task unattributed rather than guessing.
expect(inferTaskOwner(orphan, base)).toBeUndefined()
})
it('uses the latest assignment when an ownerless task was reassigned', () => {
const orphan = task('7', 'completed')
const withReassignment: TeamWorkbenchSnapshot = {
...snapshot([orphan]),
messages: [
{
id: 'mailbox-first-owner',
from: 'team-lead',
to: 'backend-dev',
recipients: ['backend-dev'],
kind: 'system',
protocolType: 'task_assignment',
taskId: '7',
text: 'Build the API',
timestamp: '2026-08-08T00:00:01.000Z',
},
{
id: 'mailbox-final-owner',
from: 'team-lead',
to: 'reviewer',
recipients: ['reviewer'],
kind: 'system',
protocolType: 'task_assignment',
taskId: '7',
text: 'Build the API',
timestamp: '2026-08-08T00:00:02.000Z',
},
],
}
expect(inferTaskOwner(orphan, withReassignment)).toEqual({
identity: 'reviewer',
inferred: true,
})
})
it('falls back deterministically for cyclic dependencies instead of recursing forever', () => {
const tasks = [
task('a', 'pending', ['b']),
task('b', 'pending', ['a']),
]
const layout = layoutWorkbenchTasks(tasks, 440)
const reversed = layoutWorkbenchTasks([...tasks].reverse(), 440)
expect(layout.tasks.map((entry) => entry.task.id).sort()).toEqual(['a', 'b'])
expect(layout.columns).toBeGreaterThan(0)
expect(layout.tasks.map(({ task: entry, depth, row, x }) => ({
id: entry.id,
depth,
row,
x,
}))).toEqual(reversed.tasks.map(({ task: entry, depth, row, x }) => ({
id: entry.id,
depth,
row,
x,
})))
expect(layout.tasks.every((entry) => entry.x >= 0 && entry.x + WORKBENCH_TASK_WIDTH <= layout.width)).toBe(true)
expect(layout.height).toBeGreaterThan(0)
})
it('uses the six prototype lanes at their natural 1460px width', () => {
const tasks = [
task('1', 'completed'),
task('2', 'completed', ['1']),
task('3', 'completed', ['2']),
task('4', 'completed', ['3']),
task('5', 'completed', ['4']),
task('6', 'pending', ['5']),
]
const layout = layoutWorkbenchTasks(tasks, 604)
expect(layout.columns).toBe(6)
expect(layout.width).toBe(1460)
expect(layout.lanes.map(({ depth, x, count }) => ({ depth, x, count }))).toEqual([
{ depth: 0, x: 32, count: 1 },
{ depth: 1, x: 268, count: 1 },
{ depth: 2, x: 504, count: 1 },
{ depth: 3, x: 740, count: 1 },
{ depth: 4, x: 976, count: 1 },
{ depth: 5, x: 1212, count: 1 },
])
expect(layout.tasks.map(({ depth, row, x, y }) => ({ depth, row, x, y }))).toEqual([
{ depth: 0, row: 0, x: 40, y: 442 },
{ depth: 1, row: 0, x: 276, y: 442 },
{ depth: 2, row: 0, x: 512, y: 442 },
{ depth: 3, row: 0, x: 748, y: 442 },
{ depth: 4, row: 0, x: 984, y: 442 },
{ depth: 5, row: 0, x: 1220, y: 442 },
])
})
it('stacks same-depth tasks naturally and centers lanes inside a requested minimum width', () => {
const tasks = [
task('task-10', 'pending'),
task('task-2', 'pending'),
task('task-1', 'pending'),
task('next', 'pending', ['task-1']),
]
const layout = layoutWorkbenchTasks(tasks, 800)
expect(layout.width).toBe(800)
expect(layout.lanes.map(({ x }) => x)).toEqual([174, 410])
expect(layout.tasks.map(({ task: entry, depth, row, x, y }) => ({
id: entry.id,
depth,
row,
x,
y,
}))).toEqual([
{ id: 'task-1', depth: 0, row: 0, x: 182, y: 442 },
{ id: 'task-2', depth: 0, row: 1, x: 182, y: 548 },
{ id: 'task-10', depth: 0, row: 2, x: 182, y: 654 },
{ id: 'next', depth: 1, row: 0, x: 418, y: 442 },
])
expect(layout.height).toBe(824)
})
it('normalizes bare owner names and full agent ids for active work', () => {
const member: TeamMember = {
agentId: 'reviewer@team-a',
name: 'reviewer',
role: 'security-reviewer',
status: 'running',
}
const bareOwner = task('1', 'in_progress', [], 'reviewer')
const fullOwner = task('2', 'pending', [], 'reviewer@team-a')
expect(taskOwnedByMember(bareOwner, member)).toBe(true)
expect(taskOwnedByMember(fullOwner, member)).toBe(true)
expect(runningTaskForMember([fullOwner, bareOwner], member)?.id).toBe('1')
})
it('does not treat a shared role label as a member ownership identity', () => {
const first: TeamMember = {
agentId: 'reviewer-a@team-a',
name: 'reviewer-a',
role: 'security-reviewer',
status: 'running',
}
const second: TeamMember = {
agentId: 'reviewer-b@team-a',
name: 'reviewer-b',
role: 'security-reviewer',
status: 'running',
}
const ambiguous = task('role-owned', 'in_progress', [], 'security-reviewer')
expect(taskOwnedByMember(ambiguous, first)).toBe(false)
expect(taskOwnedByMember(ambiguous, second)).toBe(false)
})
it('assigns generated occupational characters and preserves unknown-member identity', () => {
const member = (agentId: string, role: string): TeamMember => ({
agentId,
name: agentId.split('@')[0],
role,
status: 'running',
})
expect(getMemberAvatarKey(member('lead@team-a', 'orchestrator'), true)).toBe('team-lead')
expect(getMemberAvatarKey(member('watcher-runtime@team-a', 'backend'))).toBe('server-engineer')
expect(getMemberAvatarKey(member('desktop-workbench@team-a', 'frontend'))).toBe('ui-designer')
expect(getMemberAvatarKey(member('test-engineer@team-a', 'quality'))).toBe('qa-engineer')
expect(getMemberAvatarKey(member('security-reviewer@team-a', 'reviewer'))).toBe('security-reviewer')
expect(getMemberAvatarKey(member('release-auditor@team-a', 'release'))).toBe('release-engineer')
expect(getMemberAvatarKey(member('unknown-specialist@team-a', 'general-purpose')))
.toBe(getMemberAvatarKey(member('unknown-specialist@team-a', 'general-purpose')))
})
it('resolves bare transcript senders to the same full teammate identity as the workbench', () => {
const team = {
name: 'team-a',
leadAgentId: 'team-lead@team-a',
members: [
{ agentId: 'team-lead@team-a', name: 'team-lead', role: 'orchestrator', status: 'running' as const },
{ agentId: 'server-reviewer@team-a', name: 'server-reviewer', role: 'server engineer', status: 'idle' as const },
],
}
const lead = resolveTeamMemberIdentity(team, 'team-lead')
const reviewer = resolveTeamMemberIdentity(team, 'server-reviewer@team-a')
expect(lead.member.agentId).toBe('team-lead@team-a')
expect(lead.isLead).toBe(true)
expect(getMemberAvatarKey(lead.member, lead.isLead)).toBe('team-lead')
expect(reviewer.member.agentId).toBe('server-reviewer@team-a')
expect(getMemberAvatarKey(reviewer.member, reviewer.isLead)).toBe('security-reviewer')
})
it('replays roster transitions so shutdown cannot orphan a completed task owner', () => {
const lead: TeamMember = {
agentId: 'team-lead@team-a',
name: 'team-lead',
role: 'orchestrator',
status: 'running',
}
const owner: TeamMember = {
agentId: 'builder@team-a',
name: 'builder',
role: 'frontend',
status: 'running',
}
const observer: TeamMember = {
agentId: 'observer@team-a',
name: 'observer',
role: 'reviewer',
status: 'idle',
}
const beforeRemoval = {
...snapshot([task('1', 'in_progress', [], 'builder')]),
version: 'roster-1',
team: { ...snapshot([]).team, members: [lead, owner, observer] },
}
const afterRemoval = {
...snapshot([task('1', 'completed', [], 'builder')]),
version: 'roster-2',
deletedAt: '2026-08-08T00:01:00.000Z',
team: {
...snapshot([]).team,
members: [
{ ...lead, status: 'completed' as const },
{ ...observer, status: 'completed' as const },
],
},
}
const repaired = snapshotWithHistoricalMembers([beforeRemoval, afterRemoval], 1)!
const repairedOwner = repaired.team.members.find(member => taskOwnedByMember(repaired.tasks[0]!, member))
expect(repaired.team.members.map(member => ({
name: member.name,
status: member.status,
}))).toEqual([
{ name: 'team-lead', status: 'completed' },
{ name: 'builder', status: 'completed' },
{ name: 'observer', status: 'completed' },
])
expect(repairedOwner?.agentId).toBe('builder@team-a')
})
it('derives forming, running, finishing, and completed phases from real transitions', () => {
expect(getWorkbenchPhase(snapshot([]))).toBe('forming')
expect(getWorkbenchPhase(snapshot([task('1', 'in_progress')]))).toBe('running')
const finished = snapshot([task('1', 'completed'), task('2', 'completed')])
expect(getWorkbenchPhase(finished)).toBe('finishing')
expect(getWorkbenchProgress(finished)).toEqual({ completed: 2, total: 2, percent: 100 })
expect(getWorkbenchPhase({
...finished,
deletedAt: '2026-08-08T00:01:00.000Z',
})).toBe('completed')
})
it('narrates protocol payloads instead of leaking their raw JSON into the feed', () => {
// This exact shape was rendering verbatim in the communication feed.
const idle = parseWorkbenchMessageBody({
text: '{"type":"idle_notification","from":"release-engineer","timestamp":"2026-08-08T07:42:16.666Z","idleReason":"available"}',
})
expect(idle).toEqual({ kind: 'lifecycle', type: 'idle_notification', detail: 'available' })
// protocolType wins over the body, and is enough on its own.
expect(parseWorkbenchMessageBody({ text: 'shutting down', protocolType: 'shutdown_request' }))
.toEqual({ kind: 'lifecycle', type: 'shutdown_request', detail: undefined })
// `TeamService.toWorkbenchMessage` replaces the assignment JSON body with
// its subject while retaining the protocol metadata and routing fields.
expect(parseWorkbenchMessageBody({
text: 'Repair queue',
protocolType: 'task_assignment',
from: 'team-lead',
recipients: ['builder'],
})).toEqual({
kind: 'assignment',
selfClaim: false,
subject: 'Repair queue',
})
expect(parseWorkbenchMessageBody({
text: '{"type":"task_assignment","taskId":"7"}',
protocolType: 'task_assignment',
from: 'team-lead',
recipients: ['builder'],
})).toEqual({
kind: 'assignment',
selfClaim: false,
taskId: '7',
})
// Authored prose is never reclassified.
expect(parseWorkbenchMessageBody({ text: 'Race condition confirmed in queue.ts' }))
.toEqual({ kind: 'text', text: 'Race condition confirmed in queue.ts' })
})
it('surfaces readable fields from unrecognised JSON rather than printing braces', () => {
expect(parseWorkbenchMessageBody({ text: '{"type":"custom_event","message":"handoff ready"}' }))
.toEqual({ kind: 'text', text: 'handoff ready' })
// Nothing prose-like inside: keep the payload rather than silently dropping it.
const opaque = '{"type":"custom_event","count":3}'
expect(parseWorkbenchMessageBody({ text: opaque })).toEqual({ kind: 'text', text: opaque })
// Malformed JSON must not throw or be mistaken for a protocol signal.
expect(parseWorkbenchMessageBody({ text: '{not json' })).toEqual({ kind: 'text', text: '{not json' })
})
it('stamps messages with their own send time, not a shared snapshot index', () => {
const first = formatWorkbenchMessageTime('2026-08-08T07:42:16.666Z')
const second = formatWorkbenchMessageTime('2026-08-08T09:15:00.000Z')
expect(first).toBeTruthy()
// Two messages inside one snapshot used to render an identical `T+0`.
expect(first).not.toBe(second)
expect(formatWorkbenchMessageTime('not-a-date')).toBe('')
})
})
@@ -0,0 +1,593 @@
import { AGENT_LIFECYCLE_TYPES } from '../../types/team'
import type {
TeamMember,
TeamWorkbenchMessage,
TeamWorkbenchSnapshot,
TeamWorkbenchTask,
} from '../../types/team'
export type WorkbenchTaskState = 'blocked' | 'open' | 'running' | 'completed'
export type WorkbenchPhase = 'forming' | 'running' | 'finishing' | 'completed'
export type MemberAvatarKey =
| 'team-lead'
| 'server-engineer'
| 'ui-designer'
| 'qa-engineer'
| 'security-reviewer'
| 'data-analyst'
| 'release-engineer'
| 'docs-coordinator'
export type PositionedWorkbenchTask = {
task: TeamWorkbenchTask
state: WorkbenchTaskState
/**
* How many dependencies deep the task sits, counted along its longest path.
* This is the number that describes where a task falls in the plan; the task
* id only records the order the lead happened to write the tasks down. Each
* depth owns one left-to-right lane regardless of that authored order.
*/
depth: number
/** Stable vertical slot inside the dependency lane. */
row: number
x: number
y: number
}
export type WorkbenchLane = {
depth: number
x: number
y: number
width: number
height: number
count: number
}
export type WorkbenchLayout = {
tasks: PositionedWorkbenchTask[]
byId: Map<string, PositionedWorkbenchTask>
lanes: WorkbenchLane[]
width: number
height: number
legendY: number
columns: number
}
const LANE_WIDTH = 216
const LANE_GAP = 20
const TASK_WIDTH = 200
const TASK_HEIGHT = 92
const HORIZONTAL_PADDING = 32
const LANE_TOP = 410
const TASK_TOP = 442
const ROW_GAP = 14
const LEGEND_GAP = 12
const CANVAS_BOTTOM_PADDING = 46
const TASK_ID_COLLATOR = new Intl.Collator('en', {
numeric: true,
sensitivity: 'base',
})
function compareTaskIds(left: string, right: string): number {
const naturalOrder = TASK_ID_COLLATOR.compare(left, right)
if (naturalOrder !== 0) return naturalOrder
return left < right ? -1 : left > right ? 1 : 0
}
const WORKER_AVATARS: Array<{
key: Exclude<MemberAvatarKey, 'team-lead'>
matches: RegExp
}> = [
{ key: 'release-engineer', matches: /release|\bbuild\b|package|deploy|\bops\b|\bci\b|ship/ },
{ key: 'security-reviewer', matches: /security|secure|audit|risk|threat|review/ },
{ key: 'qa-engineer', matches: /\bqa\b|test|quality|verify|verification/ },
{ key: 'ui-designer', matches: /\bui\b|\bux\b|frontend|desktop|design|theme|accessib/ },
{ key: 'server-engineer', matches: /server|backend|\bapi\b|runtime|watcher|service|contract/ },
{ key: 'data-analyst', matches: /data|research|analyst|replay|state|investigat|explor/ },
{ key: 'docs-coordinator', matches: /docs?|product|spec|writer|coordinat/ },
]
function stableHash(value: string): number {
let hash = 0
for (let index = 0; index < value.length; index += 1) {
hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0
}
return Math.abs(hash)
}
/**
* Keeps a teammate's visual identity stable across snapshots while still
* assigning the purpose-built occupational art when its role is known.
*/
export function getMemberAvatarKey(member: TeamMember, isLead = false): MemberAvatarKey {
if (isLead) return 'team-lead'
const identity = [member.name, member.role, member.agentId, member.currentTask]
.filter(Boolean)
.join(' ')
.toLowerCase()
const matched = WORKER_AVATARS.find(({ matches }) => matches.test(identity))
if (matched) return matched.key
return WORKER_AVATARS[stableHash(member.agentId) % WORKER_AVATARS.length]!.key
}
function identityAliases(value: string): string[] {
const normalized = value.trim().toLowerCase()
const short = normalized.split('@')[0] ?? normalized
return normalized === short ? [normalized] : [normalized, short]
}
function sameTeamMember(left: TeamMember, right: TeamMember): boolean {
const leftAliases = [
...identityAliases(left.agentId),
...(left.name ? identityAliases(left.name) : []),
]
const rightAliases = [
...identityAliases(right.agentId),
...(right.name ? identityAliases(right.name) : []),
]
return leftAliases.some(alias => rightAliases.includes(alias))
}
/**
* Team config is a mutable live roster, while the workbench is a run history.
* Rebuild the roster by replaying snapshots so a teammate removed during
* shutdown still owns its completed task and can reopen its transcript.
*/
export function snapshotWithHistoricalMembers(
snapshots: TeamWorkbenchSnapshot[],
selectedIndex: number,
): TeamWorkbenchSnapshot | undefined {
const selected = snapshots[selectedIndex]
if (!selected) return undefined
let members: TeamMember[] = []
for (const snapshot of snapshots.slice(0, selectedIndex + 1)) {
const remaining = [...snapshot.team.members]
members = members.map((historicalMember) => {
const currentIndex = remaining.findIndex(member => sameTeamMember(historicalMember, member))
if (currentIndex < 0) {
return {
...historicalMember,
status: historicalMember.status === 'error' ? 'error' : 'completed',
}
}
const [currentMember] = remaining.splice(currentIndex, 1)
return { ...historicalMember, ...currentMember! }
})
members.push(...remaining)
}
return {
...selected,
team: {
...selected.team,
members,
},
}
}
/**
* Resolves the same persisted teammate identity for the DAG, communication
* feed, and member transcript. A sender can be serialized as either its bare
* name or full `name@team` id, but it must keep one visual character.
*/
export function resolveTeamMemberIdentity(
team: TeamWorkbenchSnapshot['team'],
value: string,
): { member: TeamMember; isLead: boolean } {
const aliases = identityAliases(value)
const member = team.members.find((candidate) => {
const candidateAliases = [
...identityAliases(candidate.agentId),
...(candidate.name ? identityAliases(candidate.name) : []),
]
return aliases.some((alias) => candidateAliases.includes(alias))
}) ?? {
agentId: value,
name: value,
role: value,
status: 'idle' as const,
}
const leadAliases = team.leadAgentId ? identityAliases(team.leadAgentId) : []
const memberAliases = [
...identityAliases(member.agentId),
...(member.name ? identityAliases(member.name) : []),
]
const isLead = aliases.includes('team-lead')
|| leadAliases.some((alias) => aliases.includes(alias) || memberAliases.includes(alias))
return { member, isLead }
}
export type MemberWorkState = 'working' | 'idle' | 'stopped' | 'exited' | 'error'
/**
* What the member itself is doing, which is never what its tasks say. A task
* stays `in_progress` from the moment a teammate claims it until the teammate
* remembers to close it -- across turn boundaries, and for umbrella tasks
* across the whole run -- so reading activity off the task list reported every
* member as permanently working.
*
* The lead has no runner writing turn markers for it, so its caller supplies
* whether its session is streaming.
*/
export function getMemberWorkState(
member: TeamMember,
options: { isLead?: boolean; leadIsStreaming?: boolean } = {},
): MemberWorkState {
if (member.status === 'completed' || member.activity === 'exited') return 'exited'
if (member.status === 'error') return 'error'
if (options.isLead) return options.leadIsStreaming ? 'working' : 'idle'
if (member.activity === 'active') return 'working'
if (member.activity === 'idle') return 'idle'
// `unknown` means the backend records no turn markers and left no transcript
// to date, so fall back to the coarser roster status.
return member.status === 'running' ? 'working' : 'idle'
}
export type TaskOwnerAttribution = {
identity: string
/** True when the name was recovered from the mailbox rather than recorded. */
inferred: boolean
}
/**
* Who did a task. `task.owner` is authoritative, but runs archived before
* batch-closed tasks recorded an owner left some finished work attributed to
* nobody. The assignment envelope that reached a teammate's inbox names the
* same person, so it recovers the answer for that history. The result is marked
* inferred so the UI can present it as a reconstruction rather than as fact.
*/
export function inferTaskOwner(
task: TeamWorkbenchTask,
snapshot: TeamWorkbenchSnapshot,
): TaskOwnerAttribution | undefined {
const owner = task.owner?.trim()
if (owner) return { identity: owner, inferred: false }
// Messages are stored in send order. A task can be reassigned before it is
// completed, so the last matching envelope is the only one that describes
// its final owner in this frame.
for (let index = snapshot.messages.length - 1; index >= 0; index -= 1) {
const assignment = snapshot.messages[index]!
if (
assignment.protocolType !== 'task_assignment' ||
assignment.taskId !== task.id
) continue
const recipient = assignment.recipients[0]?.trim()
if (recipient) return { identity: recipient, inferred: true }
}
return undefined
}
export function getWorkbenchTaskState(
task: TeamWorkbenchTask,
tasksById: Map<string, TeamWorkbenchTask>,
): WorkbenchTaskState {
if (task.status === 'completed') return 'completed'
if (task.status === 'in_progress') return 'running'
// A dependency the task list no longer contains was deleted, and the runtime
// treats a blocker it cannot find as resolved -- it only refuses to claim a
// task while a blocker is still open. Reading a missing blocker as unfinished
// stranded such a task in `blocked` forever, and disagreed with `taskDepths`,
// which has always ignored dependencies outside the list.
const hasOpenDependency = task.blockedBy.some((dependencyId) => {
const dependency = tasksById.get(dependencyId)
return dependency !== undefined && dependency.status !== 'completed'
})
return hasOpenDependency ? 'blocked' : 'open'
}
/** Longest dependency path with deterministic fallbacks for missing nodes/cycles. */
function taskDepths(tasks: TeamWorkbenchTask[]): Map<string, number> {
const byId = new Map(tasks.map((task) => [task.id, task]))
const depths = new Map<string, number>()
const visiting = new Set<string>()
const depthOf = (taskId: string): number => {
const cached = depths.get(taskId)
if (cached !== undefined) return cached
if (visiting.has(taskId)) return 0
const task = byId.get(taskId)
if (!task) return 0
visiting.add(taskId)
const dependencies = task.blockedBy
.filter((dependencyId) => byId.has(dependencyId))
.sort(compareTaskIds)
const depth = dependencies.length === 0
? 0
: 1 + Math.max(...dependencies.map(depthOf))
visiting.delete(taskId)
depths.set(taskId, depth)
return depth
}
for (const task of tasks) depthOf(task.id)
return depths
}
export function layoutWorkbenchTasks(
tasks: TeamWorkbenchTask[],
requestedWidth: number,
): WorkbenchLayout {
const sortedTasks = tasks
.map((task, index) => ({ task, index }))
.sort((left, right) => (
compareTaskIds(left.task.id, right.task.id) || left.index - right.index
))
.map(({ task }) => task)
const depths = taskDepths(sortedTasks)
const byLayer = new Map<number, TeamWorkbenchTask[]>()
for (const task of sortedTasks) {
const depth = depths.get(task.id) ?? 0
const layer = byLayer.get(depth)
if (layer) layer.push(task)
else byLayer.set(depth, [task])
}
const columns = sortedTasks.length === 0
? 0
: Math.max(...Array.from(depths.values())) + 1
const naturalWidth = HORIZONTAL_PADDING * 2
+ columns * LANE_WIDTH
+ Math.max(0, columns - 1) * LANE_GAP
const minimumWidth = Number.isFinite(requestedWidth)
? Math.max(0, Math.round(requestedWidth))
: 0
const width = Math.max(naturalWidth, minimumWidth)
const startX = (width - naturalWidth) / 2 + HORIZONTAL_PADDING
const maxRows = Math.max(0, ...Array.from(byLayer.values(), layer => layer.length))
const taskStackHeight = maxRows === 0
? 0
: maxRows * TASK_HEIGHT + (maxRows - 1) * ROW_GAP
const laneHeight = (TASK_TOP - LANE_TOP) * 2 + taskStackHeight
const lanes: WorkbenchLane[] = Array.from({ length: columns }, (_, depth) => ({
depth,
x: startX + depth * (LANE_WIDTH + LANE_GAP),
y: LANE_TOP,
width: LANE_WIDTH,
height: laneHeight,
count: byLayer.get(depth)?.length ?? 0,
}))
const tasksById = new Map(sortedTasks.map((task) => [task.id, task]))
const positioned: PositionedWorkbenchTask[] = []
for (const lane of lanes) {
const layer = byLayer.get(lane.depth) ?? []
layer.forEach((task, row) => {
positioned.push({
task,
state: getWorkbenchTaskState(task, tasksById),
depth: lane.depth,
row,
x: lane.x + (LANE_WIDTH - TASK_WIDTH) / 2,
y: TASK_TOP + row * (TASK_HEIGHT + ROW_GAP),
})
})
}
return {
tasks: positioned,
byId: new Map(positioned.map((task) => [task.task.id, task])),
lanes,
width,
height: LANE_TOP + laneHeight + CANVAS_BOTTOM_PADDING,
legendY: LANE_TOP + laneHeight + LEGEND_GAP,
columns,
}
}
function memberNames(member: TeamMember): string[] {
return [
member.agentId,
member.agentId.split('@')[0] ?? '',
member.name ?? '',
].filter(Boolean)
}
export function taskOwnedByMember(
task: TeamWorkbenchTask,
member: TeamMember,
): boolean {
if (!task.owner) return false
return memberNames(member).includes(task.owner)
}
export function runningTaskForMember(
tasks: TeamWorkbenchTask[],
member: TeamMember,
): TeamWorkbenchTask | undefined {
return tasks.find((task) => task.status === 'in_progress' && taskOwnedByMember(task, member))
}
/**
* The one task a member is on right now, which is what decides where its
* character stands on the map.
*
* A member routinely owns several open tasks at once: the umbrella task its
* lead assigned stays `in_progress` for the whole run while the member works
* through the smaller tasks it created underneath. Picking the first owned task
* therefore parked every member on its umbrella, because the task list is
* ordered by id and the umbrella was created first. Reading the snapshot
* history instead -- which task most recently *became* `in_progress` -- follows
* the member through its actual work.
*/
export function currentTaskForMember(
snapshots: TeamWorkbenchSnapshot[],
selectedIndex: number,
member: TeamMember,
): TeamWorkbenchTask | undefined {
const selected = snapshots[selectedIndex]
if (!selected) return undefined
const running = selected.tasks.filter((task) => (
task.status === 'in_progress' && taskOwnedByMember(task, member)
))
if (running.length <= 1) return running[0]
const startedBefore = new Set<string>()
let latest: TeamWorkbenchTask | undefined
for (const snapshot of snapshots.slice(0, selectedIndex + 1)) {
for (const task of snapshot.tasks) {
if (task.status !== 'in_progress') continue
if (!startedBefore.has(task.id)) {
startedBefore.add(task.id)
const match = running.find((candidate) => candidate.id === task.id)
if (match) latest = match
}
}
}
if (latest) return latest
// A timeline that starts mid-run (an archive opened cold) never witnessed the
// transitions. A task that blocks nothing is a leaf the member is doing now,
// whereas an umbrella task exists to hold others up.
return running.find((task) => task.blocks.length === 0) ??
running[running.length - 1]
}
export function getWorkbenchPhase(snapshot: TeamWorkbenchSnapshot): WorkbenchPhase {
if (snapshot.deletedAt) return 'completed'
if (snapshot.tasks.length === 0) return 'forming'
if (snapshot.tasks.every((task) => task.status === 'completed')) return 'finishing'
return 'running'
}
export function getWorkbenchProgress(snapshot: TeamWorkbenchSnapshot) {
const total = snapshot.tasks.length
const completed = snapshot.tasks.filter((task) => task.status === 'completed').length
return {
completed,
total,
percent: total === 0 ? 0 : Math.round((completed / total) * 100),
}
}
/**
* A workbench message is either something a teammate wrote or a protocol
* signal the runtime emitted. The feed used to print the latter as raw JSON
* (`{"type":"idle_notification",...}`), which is both unreadable and the
* opposite of what the transcript does — `extractVisibleTeammateMessageContents`
* has always dropped these payloads from chat. Classifying here lets the feed
* render a sentence and de-emphasise it instead.
*/
export type WorkbenchMessageBody =
| { kind: 'text'; text: string }
| { kind: 'assignment'; taskId?: string; subject?: string; selfClaim: boolean }
| { kind: 'lifecycle'; type: string; detail?: string }
/**
* Protocol payloads the feed states in words rather than dumping verbatim.
*
* `task_assignment` is deliberately absent: it records a task being picked up,
* which is the first half of everything a team does. Filing it with shutdown
* and idle chatter hid it behind a collapsed toggle and left the feed reporting
* "0 messages" for a team that had just handed out all of its work.
*/
const NARRATED_PROTOCOL_TYPES = new Set([
...AGENT_LIFECYCLE_TYPES,
'shutdown_response',
])
const LIFECYCLE_DETAIL_FIELDS = ['idleReason', 'reason', 'detail', 'message'] as const
const READABLE_BODY_FIELDS = ['message', 'content', 'text', 'summary', 'reason'] as const
function parseJsonObject(raw: string): Record<string, unknown> | null {
if (!raw.startsWith('{') || !raw.endsWith('}')) return null
try {
const parsed: unknown = JSON.parse(raw)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: null
} catch {
return null
}
}
function firstNonEmptyString(
record: Record<string, unknown>,
fields: readonly string[],
): string | undefined {
for (const field of fields) {
const value = record[field]
if (typeof value === 'string' && value.trim()) return value.trim()
}
return undefined
}
export function parseWorkbenchMessageBody(
message: Pick<TeamWorkbenchMessage, 'text' | 'protocolType'> &
Partial<Pick<TeamWorkbenchMessage, 'from' | 'recipients'>>,
): WorkbenchMessageBody {
const raw = message.text?.trim() ?? ''
const senderAliases = identityAliases(message.from ?? '')
const selfClaim = Boolean(message.recipients?.some((recipient) => (
identityAliases(recipient).some((alias) => senderAliases.includes(alias))
)))
const payload = parseJsonObject(raw)
const payloadType = typeof payload?.type === 'string' ? payload.type : undefined
const type = message.protocolType ?? payloadType
if (type === 'task_assignment') {
// A teammate that claims its own next task addresses the envelope to
// itself, which is what separates picking work up from being handed it.
// The server projects a structured assignment to `text = subject` while
// retaining `protocolType`; archives can still contain the original JSON.
const structuredSubject = payloadType === 'task_assignment' && typeof payload?.subject === 'string'
? payload.subject.trim()
: ''
const subject = structuredSubject || (
message.protocolType === 'task_assignment' && payloadType !== 'task_assignment'
? raw
: ''
)
return {
kind: 'assignment',
selfClaim,
...(typeof payload?.taskId === 'string' ? { taskId: payload.taskId } : {}),
...(subject ? { subject } : {}),
}
}
if (type && NARRATED_PROTOCOL_TYPES.has(type)) {
return {
kind: 'lifecycle',
type,
...(payload ? { detail: firstNonEmptyString(payload, LIFECYCLE_DETAIL_FIELDS) } : {}),
}
}
// An unrecognised JSON payload still beats raw braces: surface whichever
// field actually carries prose, and only fall back to the literal text when
// nothing readable is in there.
if (payload) {
return { kind: 'text', text: firstNonEmptyString(payload, READABLE_BODY_FIELDS) ?? raw }
}
return { kind: 'text', text: raw }
}
/**
* Wall-clock time the message was sent. The feed previously stamped every row
* with `T+{snapshotIndex}`, which is identical for every message in a snapshot
* and therefore carries no information at all.
*/
export function formatWorkbenchMessageTime(timestamp: string): string {
const time = new Date(timestamp)
return Number.isNaN(time.getTime())
? ''
: time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
export function memberInitials(member: TeamMember): string {
const source = member.name || member.role || member.agentId.split('@')[0] || 'AG'
const words = source.split(/[^\p{L}\p{N}]+/u).filter(Boolean)
return (words.length > 1
? words.slice(0, 2).map((word) => word[0]).join('')
: source.slice(0, 2)
).toUpperCase()
}
export const WORKBENCH_TASK_WIDTH = TASK_WIDTH
export const WORKBENCH_TASK_HEIGHT = TASK_HEIGHT
@@ -0,0 +1,304 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { ActivityGroup } from './ActivityGroup'
import { buildActivitySegments, type ActivityStep } from './activityGroupModel'
import { useSettingsStore } from '../../stores/settingsStore'
import { translate } from '../../i18n'
import type { UIMessage } from '../../types/chat'
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
function toolCall(overrides: Partial<ToolCall> & Pick<ToolCall, 'id' | 'toolUseId' | 'toolName'>): ToolCall {
return {
type: 'tool_use',
input: {},
timestamp: 0,
...overrides,
}
}
function toolResult(overrides: Partial<ToolResult> & Pick<ToolResult, 'id' | 'toolUseId'>): ToolResult {
return {
type: 'tool_result',
content: 'ok',
isError: false,
timestamp: 0,
...overrides,
}
}
function thinkingStep(id: string, content: string, timestamp = 0): ActivityStep {
return { kind: 'thinking', message: { id, type: 'thinking', content, timestamp } }
}
function resultsOf(results: ToolResult[]): Map<string, ToolResult> {
return new Map(results.map((result) => [result.toolUseId, result]))
}
const t = (key: Parameters<typeof translate>[1], params?: Record<string, string | number>) =>
translate('en', key, params)
describe('ActivityGroup', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
})
const readCall = toolCall({
id: 'use-read',
toolUseId: 'read-1',
toolName: 'Read',
input: { file_path: '/repo/src/MessageList.tsx' },
timestamp: 1_000,
})
const bashCall = toolCall({
id: 'use-bash',
toolUseId: 'bash-1',
toolName: 'Bash',
input: { command: 'bun run lint' },
timestamp: 2_000,
})
it('plays open while live, then folds itself into a counted summary', () => {
const steps: ActivityStep[] = [
thinkingStep('think-1', '- Locate the five call sites first.\nThen patch each file.', 500),
thinkingStep('think-2', 'Then check the retry path.', 800),
{ kind: 'tool', toolCall: readCall },
{ kind: 'tool', toolCall: bashCall },
]
const settled = resultsOf([
toolResult({ id: 'res-read', toolUseId: 'read-1', timestamp: 2_400 }),
toolResult({ id: 'res-bash', toolUseId: 'bash-1', timestamp: 5_700 }),
])
const { rerender } = render(
<ActivityGroup steps={steps} resultMap={settled} childToolCallsByParent={new Map()} isLive />,
)
let group = screen.getByTestId('activity-group')
expect(group.getAttribute('data-expanded')).toBe('true')
expect(within(group).getByText('MessageList.tsx')).toBeTruthy()
// The turn moves on: rows fold away and the counted digest stands in for
// them, so finished machinery stops competing with the prose around it.
rerender(
<ActivityGroup steps={steps} resultMap={settled} childToolCallsByParent={new Map()} isLive={false} />,
)
group = screen.getByTestId('activity-group')
expect(group.getAttribute('data-expanded')).toBe('false')
expect(within(group).queryByText('MessageList.tsx')).toBeNull()
// Counted per family, thinking included — scale is the point of the line.
const summary = group.querySelector('[data-chat-disclosure="true"]')!
expect(summary.textContent).toContain('Thought 2 times')
expect(summary.textContent).toContain('Read 1 file')
expect(summary.textContent).toContain('ran a command')
})
it('stays open across the gaps between one tool resolving and the next starting', () => {
// The bug this pins: a live run folds and reopens on every step boundary,
// because "a tool is executing right now" goes false in each gap. Six tools
// meant six open/shut cycles under a reader trying to watch the work.
const steps: ActivityStep[] = [
{ kind: 'tool', toolCall: readCall },
{ kind: 'tool', toolCall: bashCall },
]
const renderWith = (resultMap: ReturnType<typeof resultsOf>) => (
<ActivityGroup steps={steps} resultMap={resultMap} childToolCallsByParent={new Map()} isLive />
)
const { rerender } = render(renderWith(resultsOf([])))
expect(screen.getByTestId('activity-group').getAttribute('data-expanded')).toBe('true')
for (const resultMap of [
// Everything resolved — the lull the group used to collapse into.
resultsOf([
toolResult({ id: 'res-read', toolUseId: 'read-1', timestamp: 2_400 }),
toolResult({ id: 'res-bash', toolUseId: 'bash-1', timestamp: 5_700 }),
]),
// The next step starts, and the cycle would have repeated.
resultsOf([toolResult({ id: 'res-read', toolUseId: 'read-1', timestamp: 2_400 })]),
]) {
rerender(renderWith(resultMap))
expect(screen.getByTestId('activity-group').getAttribute('data-expanded')).toBe('true')
}
})
it('keeps the reader\'s choice once they open a settled run', () => {
const settled = {
steps: [
thinkingStep('think-1', 'Check the call sites.', 500),
{ kind: 'tool' as const, toolCall: readCall },
],
resultMap: resultsOf([toolResult({ id: 'res-read', toolUseId: 'read-1', timestamp: 2_400 })]),
childToolCallsByParent: new Map(),
}
const { rerender } = render(<ActivityGroup {...settled} />)
const group = screen.getByTestId('activity-group')
expect(group.getAttribute('data-expanded')).toBe('false')
fireEvent.click(group.querySelector('[data-chat-disclosure="true"]')!)
expect(screen.getByTestId('activity-group').getAttribute('data-expanded')).toBe('true')
// A later refresh must not snap it shut again under the reader.
rerender(<ActivityGroup {...settled} />)
expect(screen.getByTestId('activity-group').getAttribute('data-expanded')).toBe('true')
fireEvent.click(screen.getByTestId('activity-group').querySelector('[data-chat-disclosure="true"]')!)
expect(screen.getByTestId('activity-group').getAttribute('data-expanded')).toBe('false')
})
it('keeps each row openable for its own detail', () => {
render(
<ActivityGroup
steps={[{ kind: 'tool', toolCall: bashCall }, { kind: 'tool', toolCall: readCall }]}
resultMap={resultsOf([
toolResult({ id: 'res-bash', toolUseId: 'bash-1', content: 'ok', timestamp: 3_000 }),
toolResult({ id: 'res-read', toolUseId: 'read-1', timestamp: 1_400 }),
])}
childToolCallsByParent={new Map()}
/>,
)
const group = screen.getByTestId('activity-group')
// Settled, so it starts folded; open the run before reaching for a row.
fireEvent.click(group.querySelector('[data-chat-disclosure="true"]')!)
expect(group.querySelectorAll('[data-tool-call-details]')).toHaveLength(0)
fireEvent.click(within(group).getByText('bun run lint'))
// Opening one row reveals that row's detail and leaves the others closed.
expect(group.querySelectorAll('[data-tool-call-details]')).toHaveLength(1)
expect(within(group).getByText('MessageList.tsx')).toBeTruthy()
})
it('makes a lone tool call its own header row rather than hiding it behind a summary', () => {
render(
<ActivityGroup
steps={[{ kind: 'tool', toolCall: readCall }]}
resultMap={resultsOf([toolResult({ id: 'res-read', toolUseId: 'read-1', timestamp: 2_400 })])}
childToolCallsByParent={new Map()}
/>,
)
const group = screen.getByTestId('activity-group')
expect(group.getAttribute('data-single-step')).toBe('true')
// The file is readable without a click; "Read 1 file" would say less.
expect(within(group).getByText('MessageList.tsx')).toBeTruthy()
expect(within(group).queryByText('Read 1 file')).toBeNull()
})
it('counts failed steps in the header while the run still reports its duration', () => {
render(
<ActivityGroup
steps={[
{ kind: 'tool', toolCall: readCall },
{ kind: 'tool', toolCall: bashCall },
]}
resultMap={resultsOf([
toolResult({ id: 'res-read', toolUseId: 'read-1', timestamp: 1_400 }),
toolResult({
id: 'res-bash',
toolUseId: 'bash-1',
content: 'IndentationError',
isError: true,
timestamp: 3_000,
}),
])}
childToolCallsByParent={new Map()}
/>,
)
expect(screen.getByText(t('toolGroup.failedCount', { count: 1 }))).toBeTruthy()
})
it('reports the run as running while any step is still unresolved', () => {
render(
<ActivityGroup
steps={[
{ kind: 'tool', toolCall: readCall },
{ kind: 'tool', toolCall: bashCall },
]}
resultMap={resultsOf([toolResult({ id: 'res-read', toolUseId: 'read-1', timestamp: 1_400 })])}
childToolCallsByParent={new Map()}
/>,
)
// There is no whole-run duration to get wrong any more: each row times
// itself, so an unfinished step simply has no time yet rather than
// suppressing a total that covered finished steps too.
expect(screen.getByTestId('activity-group').getAttribute('data-running')).toBe('true')
})
it('reports a run as finished once every step has a result', () => {
render(
<ActivityGroup
steps={[
{ kind: 'tool', toolCall: readCall },
{ kind: 'tool', toolCall: bashCall },
]}
resultMap={resultsOf([
toolResult({ id: 'res-read', toolUseId: 'read-1', timestamp: 1_400 }),
toolResult({ id: 'res-bash', toolUseId: 'bash-1', timestamp: 3_000 }),
])}
childToolCallsByParent={new Map()}
/>,
)
expect(screen.getByTestId('activity-group').getAttribute('data-running')).toBe('false')
})
})
describe('buildActivitySegments', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
})
it('keeps families in first-appearance order and counts repeats', () => {
const segments = buildActivitySegments(
[
{ kind: 'tool', toolCall: toolCall({ id: 'a', toolUseId: 'a', toolName: 'Bash' }) },
{ kind: 'tool', toolCall: toolCall({ id: 'b', toolUseId: 'b', toolName: 'Read' }) },
{ kind: 'tool', toolCall: toolCall({ id: 'c', toolUseId: 'c', toolName: 'Read' }) },
],
t,
)
expect(segments.map((segment) => segment.label)).toEqual(['ran a command', 'Read 2 files'])
})
it('places thinking where it happened and counts it like any other family', () => {
const segments = buildActivitySegments(
[
{ kind: 'tool', toolCall: toolCall({ id: 'a', toolUseId: 'a', toolName: 'Bash' }) },
thinkingStep('t1', 'first'),
thinkingStep('t2', 'second'),
],
t,
)
// Counted now that this line is all a settled run shows: going back and
// forth twice is a different run from going straight through, and that is
// exactly the kind of shape the summary exists to convey.
expect(segments.map((segment) => segment.label)).toEqual(['ran a command', 'Thought 2 times'])
})
it('leaves a single thought uncounted', () => {
const segments = buildActivitySegments([thinkingStep('t1', 'only once')], t)
expect(segments.map((segment) => segment.label)).toEqual(['Thinking'])
})
it('names a verbless tool rather than hiding it behind a generic count', () => {
const segments = buildActivitySegments(
[
{ kind: 'tool', toolCall: toolCall({ id: 'a', toolUseId: 'a', toolName: 'TaskUpdate' }) },
{ kind: 'tool', toolCall: toolCall({ id: 'b', toolUseId: 'b', toolName: 'SendMessage' }) },
{ kind: 'tool', toolCall: toolCall({ id: 'c', toolUseId: 'c', toolName: 'SendMessage' }) },
],
t,
)
// Which tool ran is the point; a single call drops the redundant "(1)".
expect(segments.map((segment) => segment.label)).toEqual(['TaskUpdate', 'SendMessage (2)'])
})
})
@@ -0,0 +1,204 @@
import { memo, useMemo, useState } from 'react'
import { CircleX } from 'lucide-react'
import { ToolCallBlock, formatDuration } from './ToolCallBlock'
import { ThinkingBlock } from './ThinkingBlock'
import {
activityDurationMs,
activityStepToolCalls,
buildActivitySegments,
countFailedToolCalls,
hasUnresolvedToolCalls,
toolCallDurationMs,
type ActivityStep,
} from './activityGroupModel'
import { useTranslation } from '../../i18n'
import type { UIMessage } from '../../types/chat'
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
type Props = {
steps: ActivityStep[]
resultMap: Map<string, ToolResult>
childToolCallsByParent: Map<string, ToolCall[]>
activeThinkingId?: string | null
/** When true, the last step is still executing. */
isStreaming?: boolean
/**
* This run is the tail of a turn that is still going, so more steps may land
* in it. Distinct from `isStreaming`: that one dips false in the gap between
* one tool resolving and the next starting, many times inside a single run.
*/
isLive?: boolean
}
/**
* One contiguous run of thinking + tool calls.
*
* It plays open while it runs, so the reader can watch the work, then folds
* itself into a single counted line the moment it finishes: `thought 5 times,
* read 2 files, ran 1 command`. Standing rows open forever flattened the
* transcript — machinery took as much room as the sentences it produced, and
* nothing looked more important than anything else. A counted digest is small
* enough to skip and specific enough to be worth reading, which a vaguer
* "ran some commands" never was.
*
* Clicking pins the reader's choice: from then on that run stays as they left
* it, instead of snapping shut under them when the next step resolves.
*
* A run holding exactly one tool call never summarises: "Read 1 file" over a
* hidden `Read MessageList.tsx` row is strictly less than the row itself.
*/
export const ActivityGroup = memo(function ActivityGroup({
steps,
resultMap,
childToolCallsByParent,
activeThinkingId,
isStreaming,
isLive = false,
}: Props) {
const t = useTranslation()
/** null = follow the run's own state; set = the reader decided. */
const [pinnedCollapsed, setPinnedCollapsed] = useState<boolean | null>(null)
const toolCalls = useMemo(() => activityStepToolCalls(steps), [steps])
const failedCount = countFailedToolCalls(toolCalls, resultMap, childToolCallsByParent)
const hasActiveThinking = Boolean(activeThinkingId) && steps.some(
(step) => step.kind === 'thinking' && step.message.id === activeThinkingId,
)
const isRunning =
Boolean(isStreaming) ||
hasActiveThinking ||
hasUnresolvedToolCalls(toolCalls, resultMap, childToolCallsByParent)
// Keyed off "still being written to", never off "a tool is executing right
// now". The latter flickers: a run of six tools resolves and restarts six
// times, and folding on each gap made the whole block open and shut under the
// reader while they were trying to watch it.
const collapsed = pinnedCollapsed ?? !isLive
const soleToolCall = steps.length === 1 && steps[0]?.kind === 'tool' ? steps[0].toolCall : null
if (soleToolCall) {
return (
<div>
<div
data-testid="activity-group"
data-single-step="true"
>
<ActivityToolRow
toolCall={soleToolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
/>
</div>
</div>
)
}
const segments = buildActivitySegments(steps, t)
const elapsed = activityDurationMs(steps, resultMap)
const durationLabel = !isRunning && typeof elapsed === 'number' ? formatDuration(elapsed) : ''
const summaryText = segments.map((segment) => segment.label).join(', ')
return (
<div>
<div
data-testid="activity-group"
data-expanded={collapsed ? 'false' : 'true'}
data-running={isRunning ? 'true' : 'false'}
className="rounded-[var(--radius-md)]"
>
{/*
Deliberately a whisper: 12px, tertiary, no icons, commas rather than
middots. Its whole job is to be skippable — the prose above and below
it is the conversation, and anything with weight here competes with
that. The counts are what make it worth having at all.
*/}
<button
type="button"
data-chat-disclosure="true"
onClick={() => setPinnedCollapsed(!collapsed)}
aria-expanded={!collapsed}
title={summaryText}
className="-mx-2 flex w-[calc(100%+1rem)] items-center gap-2 rounded-[var(--radius-md)] px-2 py-1 text-left text-[12px] leading-[1.6] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-secondary)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
>
<span className="min-w-0 flex-1 truncate">{summaryText}</span>
<span className="flex shrink-0 items-center gap-2">
{failedCount > 0 && (
<span className="flex items-center gap-[5px] whitespace-nowrap font-medium text-[var(--color-error)]">
<CircleX size={12} strokeWidth={2} aria-hidden="true" />
{t('toolGroup.failedCount', { count: failedCount })}
</span>
)}
{durationLabel && (
<span className="whitespace-nowrap font-mono tabular-nums">{durationLabel}</span>
)}
<span aria-hidden="true" className={`w-3 text-center text-[8px] ${collapsed ? '' : 'rotate-90'}`}>▸</span>
</span>
</button>
{/* Rows hang off the summary that names them, so they take the guide
line and its indent. That is also why they can afford it here and not
when there is no summary — nothing to hang from, nothing to indent. */}
{!collapsed && (
<div className="ml-[3px] flex flex-col border-l border-[var(--color-border)] pl-3">
{steps.map((step) => step.kind === 'thinking' ? (
<ThinkingBlock
key={step.message.id}
content={step.message.content}
isActive={step.message.id === activeThinkingId}
/>
) : (
<ActivityToolRow
key={step.toolCall.id}
toolCall={step.toolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
/>
))}
</div>
)}
</div>
</div>
)
})
/** A tool row plus, indented under it, the rows of anything it dispatched. */
function ActivityToolRow({
toolCall,
resultMap,
childToolCallsByParent,
}: {
toolCall: ToolCall
resultMap: Map<string, ToolResult>
childToolCallsByParent: Map<string, ToolCall[]>
}) {
const result = resultMap.get(toolCall.toolUseId)
const childToolCalls = childToolCallsByParent.get(toolCall.toolUseId) ?? []
return (
<div>
<ToolCallBlock
chrome="row"
toolName={toolCall.toolName}
input={toolCall.input}
result={result ? { content: result.content, isError: result.isError } : null}
isPending={toolCall.isPending}
status={toolCall.status}
partialInput={toolCall.partialInput}
durationMs={toolCallDurationMs(toolCall, result)}
/>
{childToolCalls.length > 0 && (
<div className="ml-2 border-l border-[var(--color-border)] pl-3">
{childToolCalls.map((childToolCall) => (
<ActivityToolRow
key={childToolCall.id}
toolCall={childToolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
/>
))}
</div>
)}
</div>
)
}
@@ -245,7 +245,7 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props)
if (!activeQuestion) return null
return (
<div className={`mb-4 rounded-[var(--radius-lg)] border overflow-hidden ${
<div className={`rounded-[var(--radius-lg)] border overflow-hidden ${
submitted
? 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] opacity-70'
: 'border-[var(--color-secondary)] bg-[var(--color-surface-container-lowest)]'
@@ -12,14 +12,16 @@ vi.mock('../../lib/desktopRuntime', async (orig) => ({
}))
const ensureTargets = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
const getTargetsForPath = vi.hoisted(() => vi.fn())
const openTargetFn = vi.hoisted(() => vi.fn())
const openTargets = vi.hoisted(() => [
{ id: 'code', kind: 'ide', label: 'VS Code', icon: '', platform: 'darwin' },
{ id: 'finder', kind: 'file_manager', label: 'Finder', icon: '', platform: 'darwin' },
])
getTargetsForPath.mockResolvedValue(openTargets)
vi.mock('../../stores/openTargetStore', () => ({
useOpenTargetStore: {
getState: () => ({ ensureTargets, targets: openTargets, openTarget: openTargetFn }),
getState: () => ({ ensureTargets, getTargetsForPath, targets: openTargets, openTarget: openTargetFn }),
},
}))
@@ -63,6 +65,7 @@ import { AssistantMessage } from './AssistantMessage'
afterEach(() => {
openBrowser.mockReset()
ensureTargets.mockReset().mockResolvedValue(undefined)
getTargetsForPath.mockReset().mockResolvedValue(openTargets)
openTargetFn.mockReset()
openPreviewFn.mockReset().mockResolvedValue(undefined)
copyTextToClipboard.mockReset().mockResolvedValue(true)
@@ -95,7 +98,7 @@ describe('AssistantMessage file references', () => {
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument())
const labels = screen.getAllByRole('menuitem').map((el) => el.textContent)
expect(labels).toContain('openWith.openInTarget:VS Code')
expect(labels).toContain('openWith.revealInTarget:Finder')
expect(labels).toContain('openWith.revealIn.darwin')
expect(labels).toContain('openWith.copyPath')
expect(labels).toContain('openWith.copyFileContent')
})
@@ -169,6 +169,46 @@ describe('AssistantMessage output-target cards', () => {
expect(screen.queryByText('assistantOutputs.kind.image')).toBeNull()
})
it('hands a Markdown image to the inline gallery without leaving a source-less duplicate', () => {
const { container, rerender } = render(
<AssistantMessage
sessionId="s1"
content={'![preview](outputs/foo/preview_frame.png "Rendered frame")'}
isStreaming
/>,
)
expect(container.querySelectorAll('img')).toHaveLength(0)
rerender(
<AssistantMessage
sessionId="s1"
content={'![preview](outputs/foo/preview_frame.png "Rendered frame")'}
isStreaming={false}
/>,
)
const images = Array.from(container.querySelectorAll('img'))
expect(images).toHaveLength(1)
expect(images[0]).toHaveAttribute(
'src',
'http://127.0.0.1:4321/preview-fs/s1/outputs/foo/preview_frame.png',
)
expect(container.querySelector('img:not([src])')).toBeNull()
})
it('does not leave image nodes for remote or loopback sources rejected by assistant Markdown', () => {
const { container } = render(
<AssistantMessage
sessionId="s1"
content={'![loopback](http://127.0.0.1:3456/status) ![remote](https://attacker.example/track.png)'}
isStreaming={false}
/>,
)
expect(container.querySelectorAll('img')).toHaveLength(0)
})
it('renders a relative video inline (a <video>) but NOT as a card', () => {
const { container } = render(
<AssistantMessage
@@ -21,17 +21,26 @@ type Props = {
isStreaming?: boolean
branchAction?: MessageBranchAction
sessionId?: string
timestamp?: number
/** This turn's real changed files (absolute), used to anchor output chips onto
* files that were actually written instead of guessing from the prose. */
turnChangedFiles?: string[]
/** Only one assistant message per turn owns fallback cards for unmentioned changed files. */
isTurnOutputOwner?: boolean
/** Set only on the last reply of a finished turn: when it ended and how long it took. */
turnCompletion?: TurnCompletion
}
const MAX_CARDS = 3
export const AssistantMessage = memo(function AssistantMessage({ content, isStreaming, branchAction, sessionId, timestamp, turnChangedFiles, turnCompletion }: Props) {
export const AssistantMessage = memo(function AssistantMessage({
content,
isStreaming,
branchAction,
sessionId,
turnChangedFiles,
isTurnOutputOwner = true,
turnCompletion,
}: Props) {
const t = useTranslation()
const workDir = useWorkspacePanelStore((s) => (sessionId ? s.statusBySession[sessionId]?.workDir : undefined))
@@ -79,10 +88,14 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
isStreaming || !sessionId
? []
: // Image/video targets render inline (InlineImageGallery/InlineVideoGallery); never also as a card.
extractAssistantOutputTargets(content, { workDir, changedFiles: turnChangedFiles }).filter(
extractAssistantOutputTargets(content, {
workDir,
changedFiles: turnChangedFiles,
includeChangedFileFallback: isTurnOutputOwner,
}).filter(
(target) => target.kind !== 'image' && target.kind !== 'video',
),
[content, isStreaming, sessionId, workDir, turnChangedFiles],
[content, isStreaming, isTurnOutputOwner, sessionId, workDir, turnChangedFiles],
)
if (!content.trim()) return null
@@ -91,21 +104,25 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
const showTurnCompletion = !isStreaming && Boolean(turnCompletion)
return (
<div className="mb-5 flex justify-start">
<div className="flex justify-start">
<div
data-message-shell="assistant"
data-layout={documentLayout ? 'document' : 'bubble'}
className={`group flex min-w-0 flex-col items-start ${
documentLayout
? 'w-full max-w-full'
: 'max-w-[88%] sm:max-w-[80%] lg:max-w-[720px]'
}`}
// Always the full column. A reply that hugs its text turns every short
// answer into a differently-shaped block, so a scrolled transcript reads
// as a ragged pile; one width makes the replies a single column the eye
// can run down. The user bubble stays hugged — that asymmetry is what
// says which side is speaking, so it does not need width to say it too.
className="group flex w-full min-w-0 max-w-full flex-col items-start"
>
<div
onContextMenu={sessionId ? handleContextMenu : undefined}
className={`rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-4 text-[14.5px] text-[var(--color-text-primary)] shadow-[var(--shadow-card)] ${
documentLayout ? 'w-full' : 'max-w-full'
}`}
// No card. Left-aligned, full-column prose against the page is already
// unmistakably the reply — the hugged, tinted bubble on the right is
// what says who is speaking (see the note above), so a border here
// repeats that at the cost of ~50px per reply and makes prose look
// like the tool rows it sits between. The turn rail groups it now.
className="w-full text-[14.5px] text-[var(--color-text-primary)]"
>
<MarkdownRenderer
content={content}
@@ -156,17 +173,26 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
/>
)}
{showTurnCompletion ? <TurnCompletionStamp completion={turnCompletion!} /> : null}
<MessageActionBar
copyText={isStreaming ? undefined : content}
copyLabel={t('chat.copyReply')}
branchAction={branchAction}
align="start"
// The stamp above already carries this turn's end time; a hover chip
// repeating it a line below reads as two different times.
timestamp={showTurnCompletion ? undefined : timestamp}
/>
{/*
Only the reply that closes a turn gets this footer. A turn emits many
intermediate replies ("checking the lint spread now") and nobody copies
or forks from those — yet the bar reserved 36px on each one whether or
not it was hovered, which on a one-line reply outweighed the text.
The action buttons and completion metadata share one compact row so a
finished answer has one visual close instead of two stacked footers.
Being rare now, it stays visible instead of waiting for a hover.
Mid-turn text is still copyable by selecting it (SelectableChatMessage).
*/}
{showTurnCompletion && (
<MessageActionBar
copyText={content}
copyLabel={t('chat.copyReply')}
branchAction={branchAction}
align="start"
alwaysVisible
metadata={<TurnCompletionStamp completion={turnCompletion!} />}
/>
)}
</div>
</div>
)
@@ -58,6 +58,17 @@ const localhostTarget: AssistantOutputTarget = {
source: 'plain-url',
}
const documentTarget: AssistantOutputTarget = {
id: 'file:outputs/brief.docx',
kind: 'file',
title: 'brief.docx',
subtitle: 'outputs/brief.docx',
href: 'outputs/brief.docx',
normalizedPath: 'outputs/brief.docx',
confidence: 'high',
source: 'changed-file',
}
afterEach(() => {
openBrowser.mockReset()
ensureTargets.mockReset().mockResolvedValue(undefined)
@@ -81,6 +92,13 @@ describe('AssistantOutputTargetCard', () => {
expect(screen.getByText('assistantOutputs.kind.localhost')).toBeInTheDocument()
})
it('renders an Office artifact with its concrete document type', () => {
render(<AssistantOutputTargetCard target={documentTarget} sessionId="s1" />)
expect(screen.getByText('brief.docx')).toBeInTheDocument()
expect(screen.getByText('openWith.fileType.document')).toBeInTheDocument()
expect(screen.getByText('outputs/brief.docx')).toBeInTheDocument()
})
it('routes Open to workspace preview for a markdown target', () => {
render(<AssistantOutputTargetCard target={markdownTarget} sessionId="s1" />)
fireEvent.click(screen.getByLabelText('assistantOutputs.open'))
@@ -30,7 +30,9 @@ export function AssistantOutputTargetCard({ target, sessionId, workDir }: Props)
? t('assistantOutputs.kind.html')
: target.kind === 'markdown'
? t('assistantOutputs.kind.markdown')
: t('assistantOutputs.kind.image')
: target.kind === 'file'
? t(typeInfo.categoryKey as TranslationKey)
: t('assistantOutputs.kind.image')
const subtitle = target.subtitle ?? target.normalizedPath ?? target.href
const showSubtitle = subtitle !== target.title
@@ -24,6 +24,11 @@ describe('AttachmentGallery', () => {
fetchedAt: Date.now(),
loading: false,
error: null,
getTargetsForPath: vi.fn().mockResolvedValue([
{ id: 'code', kind: 'ide', label: 'VS Code', icon: 'vscode', platform: 'darwin' },
{ id: 'system-default', kind: 'system_default', label: 'System default', icon: 'system', platform: 'darwin' },
{ id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', platform: 'darwin' },
]),
})
window.desktopHost = {
...browserHost,
@@ -196,6 +201,25 @@ describe('AttachmentGallery', () => {
})
it('reuses the Open With menu for IDE and file-manager destinations', async () => {
const view = render(
<AttachmentGallery
attachments={[{
type: 'file',
name: 'notes.md',
path: '/Users/example/Desktop/notes.md',
}]}
/>,
)
fireEvent.click(view.getByRole('button', { name: 'Open with' }))
expect(await view.findByText('Open in VS Code')).toBeInTheDocument()
expect(view.getByText('Default application')).toBeInTheDocument()
expect(view.getByText('Reveal in Finder')).toBeInTheDocument()
expect(openPath).not.toHaveBeenCalled()
})
it('leaves the editor out for an attachment no editor can open', async () => {
const view = render(
<AttachmentGallery
attachments={[{
@@ -208,9 +232,8 @@ describe('AttachmentGallery', () => {
fireEvent.click(view.getByRole('button', { name: 'Open with' }))
expect(await view.findByText('Open in VS Code')).toBeInTheDocument()
expect(view.getByText('Reveal in Finder')).toBeInTheDocument()
expect(openPath).not.toHaveBeenCalled()
expect(await view.findByText('Default application')).toBeInTheDocument()
expect(view.queryByText('Open in VS Code')).not.toBeInTheDocument()
})
it('removes a quoted workspace attachment by id', () => {
@@ -306,6 +329,72 @@ describe('AttachmentGallery', () => {
expect(view.queryByRole('button', { name: 'Open <h1>' })).not.toBeInTheDocument()
})
it('keeps one ordinary message image readable at its natural aspect ratio and opens it', async () => {
const view = render(
<AttachmentGallery
variant="message"
attachments={[{
id: 'image-1',
type: 'image',
name: 'single.png',
data: 'data:image/png;base64,AAAA',
}]}
/>,
)
const button = view.getByRole('button', { name: 'Open single.png' })
const image = button.querySelector('img')
expect(image).toHaveClass('max-h-[340px]', 'w-full', 'max-w-[360px]')
expect(image).not.toHaveClass('h-28', 'w-28')
fireEvent.click(button)
expect(await view.findByText('1 / 1')).toBeInTheDocument()
})
it.each([2, 5])('keeps %i ordinary message images in a bounded thumbnail gallery and opens the selected image', async (imageCount) => {
const attachments = Array.from({ length: imageCount }, (_, index) => ({
id: `image-${index + 1}`,
type: 'image' as const,
name: `image-${index + 1}.png`,
data: `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><rect width="16" height="16" fill="hsl(${index * 60} 60% 50%)"/></svg>`)}`,
}))
const view = render(
<AttachmentGallery
variant="message"
attachments={attachments}
/>,
)
const imageButtons = attachments.map(({ name }) =>
view.getByRole('button', { name: `Open ${name}` }))
const gallery = imageButtons[0]?.parentElement?.parentElement
expect(gallery).toHaveClass('max-w-full', 'flex-wrap')
for (const button of imageButtons) {
expect(button.parentElement?.parentElement).toBe(gallery)
expect(button.querySelector('img')).toHaveClass('h-28', 'w-28')
}
fireEvent.click(imageButtons[imageCount - 1]!)
expect(await view.findByText(`${imageCount} / ${imageCount}`)).toBeInTheDocument()
})
it('keeps the surviving image compact when another image in the original multi-image set fails', () => {
const view = render(
<AttachmentGallery
variant="message"
attachments={[
{ id: 'image-1', type: 'image', name: 'missing.png', data: 'data:image/png;base64,AAAA' },
{ id: 'image-2', type: 'image', name: 'kept.png', data: 'data:image/png;base64,BBBB' },
]}
/>,
)
fireEvent.error(view.getByRole('img', { name: 'missing.png' }))
expect(view.getByRole('img', { name: 'kept.png' })).toHaveClass('h-28', 'w-28')
})
it('previews a path-only pasted image instead of a file chip', () => {
const path = '/Users/nanmi/Desktop/6代码仓库.png'
const view = render(
@@ -331,7 +420,9 @@ describe('AttachmentGallery', () => {
/>,
)
fireEvent.click(view.getByRole('button', { name: 'Open shot.png' }))
const button = view.getByRole('button', { name: 'Open shot.png' })
expect(button.querySelector('img')).toHaveClass('h-16', 'w-16')
fireEvent.click(button)
expect(await view.findByText('1 / 1')).toBeInTheDocument()
})
@@ -78,6 +78,15 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
}),
[attachments, unloadableImageSources],
)
// Keep the layout decision tied to the authored attachment set, not to
// transient load failures. Otherwise a two-image thumbnail row jumps to a
// large single-image layout when one source errors and falls back to a card.
const previewableImageCount = useMemo(
() => attachments.filter((attachment) => (
attachment.type === 'image' && Boolean(attachmentImageSource(attachment))
)).length,
[attachments],
)
const markImageUnloadable = (src: string) => {
setUnloadableImageSources((previous) => {
@@ -120,10 +129,10 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
const triggerEl = event.currentTarget
const anchor = triggerEl.getBoundingClientRect()
void (async () => {
await useOpenTargetStore.getState().ensureTargets()
const targets = await useOpenTargetStore.getState().getTargetsForPath(attachment.path!)
const items = buildOpenWithItems(
{ kind: 'file', absolutePath: attachment.path! },
useOpenTargetStore.getState().targets,
targets,
{
openInAppBrowser: () => {},
openSystem: (path) => {
@@ -204,7 +213,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
})}
</div>
) : (
<div className={isComposer ? 'flex flex-wrap items-center gap-2' : 'flex flex-wrap justify-end gap-2'}>
<div className={isComposer ? 'flex flex-wrap items-center gap-2' : 'flex max-w-full flex-wrap justify-end gap-2'}>
{attachments.map((attachment, index) => {
const imageSrc = attachment.type === 'image' ? attachmentImageSource(attachment) : undefined
@@ -234,7 +243,9 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
className={
isComposer
? 'h-16 w-16 object-cover'
: 'max-h-[340px] w-full max-w-[360px] object-cover'
: previewableImageCount === 1
? 'max-h-[340px] w-full max-w-[360px] object-cover'
: 'h-28 w-28 object-cover'
}
/>
</button>
+389 -4
View File
@@ -1,7 +1,7 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
import { act } from 'react'
import { act, StrictMode } from 'react'
const viewportMocks = vi.hoisted(() => ({
isMobile: false,
@@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({
getSlashCommands: vi.fn(),
listAgents: vi.fn(),
getRepositoryContext: vi.fn(),
createRepositoryBranch: vi.fn(),
getRecentProjects: vi.fn(),
search: vi.fn(),
browse: vi.fn(),
@@ -38,6 +39,7 @@ vi.mock('../../api/sessions', () => ({
getGitInfo: mocks.getGitInfo,
getSlashCommands: mocks.getSlashCommands,
getRepositoryContext: mocks.getRepositoryContext,
createRepositoryBranch: mocks.createRepositoryBranch,
getRecentProjects: mocks.getRecentProjects,
},
}))
@@ -107,7 +109,14 @@ import { useSessionStore } from '../../stores/sessionStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTabStore } from '../../stores/tabStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
import { useWorkflowStore } from '../../stores/workflowStore'
import { workflowsApi } from '../../api/workflows'
import { browserHost } from '../../lib/desktopHost/browserHost'
import { settingsApi } from '../../api/settings'
import {
captureProjectDisplayNameHydrationRevision,
hydrateProjectDisplayNames,
} from '../../stores/projectDisplayNameStore'
/**
* Opens the run-location pill's menu. Directory, branch and worktree all live
@@ -191,6 +200,10 @@ describe('ChatInput file mentions', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.createRepositoryBranch.mockReset()
act(() => {
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
})
mocks.webviewDragHandlers.length = 0
Reflect.deleteProperty(window, 'desktopHost')
delete (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__
@@ -200,6 +213,7 @@ describe('ChatInput file mentions', () => {
useSessionStore.setState(initialSessionState, true)
useTabStore.setState(initialTabState, true)
useWorkspaceChatContextStore.setState(initialWorkspaceContextState, true)
useWorkflowStore.setState({ runs: {}, openRunId: null })
useTabStore.setState({
activeTabId: sessionId,
@@ -277,6 +291,10 @@ describe('ChatInput file mentions', () => {
})
afterEach(() => {
vi.restoreAllMocks()
act(() => {
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
})
vi.unstubAllGlobals()
if (originalOffsetWidth) {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', originalOffsetWidth)
@@ -883,6 +901,64 @@ describe('ChatInput file mentions', () => {
expect(screen.queryByRole('button', { name: /^Location/ })).not.toBeInTheDocument()
})
it('reactively uses the active session project root display name while retaining the active worktree path in context', async () => {
const worktreePath = '/repo/.claude/worktrees/desktop-main-12345678'
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Project',
createdAt: '2026-05-01T00:00:00.000Z',
modifiedAt: '2026-05-01T00:00:00.000Z',
messageCount: 1,
projectPath: '/repo',
projectRoot: '/repo',
workDir: worktreePath,
workDirExists: true,
}],
activeSessionId: sessionId,
})
mocks.getGitInfo.mockResolvedValue({
branch: 'main',
repoName: 'repo',
workDir: worktreePath,
changedFiles: 0,
worktree: {
enabled: true,
path: worktreePath,
plannedPath: null,
sourceWorkDir: '/repo',
slug: 'desktop-main-12345678',
branch: 'main',
},
})
render(<ChatInput />)
const chip = await screen.findByTestId('run-location-readonly')
expect(chip).toHaveTextContent('repo')
act(() => {
hydrateProjectDisplayNames(
{ '/repo': 'Personal repo' },
captureProjectDisplayNameHydrationRevision(),
)
})
expect(chip).toHaveTextContent('Personal repo')
// Once the alias takes over the label, the worktree details tooltip is the
// only place still naming the real project root and the active worktree
// path — the toolbar variant carries no `title` for a worktree.
expect(chip).not.toHaveAttribute('title')
fireEvent.mouseEnter(screen.getByTestId('worktree-details-trigger'))
const tooltip = await screen.findByRole('tooltip')
// Exact-text lookups: '/repo' is a prefix of the worktree path, so a
// substring assertion would pass even if the project root row went missing.
expect(within(tooltip).getByText('/repo')).toBeInTheDocument()
expect(within(tooltip).getByText(worktreePath)).toBeInTheDocument()
})
// The narrow layouts never adopted the in-toolbar pill: there is no room for
// it beside the model selector, so they keep the location on its own line
// below the panel.
@@ -1115,7 +1191,10 @@ describe('ChatInput file mentions', () => {
})
it('starts an empty active session on the selected branch without an isolated worktree', async () => {
mocks.create.mockResolvedValueOnce({ sessionId: 'created-direct', workDir: '/repo' })
let resolveCreate!: (value: { sessionId: string; workDir: string }) => void
mocks.create.mockReturnValueOnce(new Promise((resolve) => {
resolveCreate = resolve
}))
useSessionStore.setState({
sessions: [{
id: sessionId,
@@ -1166,10 +1245,124 @@ describe('ChatInput file mentions', () => {
repository: { branch: 'feature/a', worktree: false },
})
})
// Session creation can take seconds. The click must cross the store
// boundary immediately so the transcript can replace its empty hero with
// a live placeholder before the new session id exists.
expect(useChatStore.getState().sessions[sessionId]?.isPreparingTurn).toBe(true)
resolveCreate({ sessionId: 'created-direct', workDir: '/repo' })
await waitFor(() => {
expect(mocks.wsSend).toHaveBeenCalledWith('created-direct', {
type: 'user_message',
content: 'run on feature branch',
attachments: [],
})
})
expect(useChatStore.getState().sessions['created-direct']?.isPreparingTurn).toBe(false)
expect(mocks.delete).toHaveBeenCalledWith(sessionId)
expect(mocks.wsSend).toHaveBeenCalledWith('created-direct', {
})
it('launches on a branch created from the picker instead of falling back to the current branch', async () => {
const headCommit = 'a'.repeat(40)
const initialContext = {
...okRepositoryContext(),
headCommit,
dirty: true,
branches: okRepositoryContext().branches.map((branch) => ({ ...branch, commit: headCommit })),
}
const createdContext = {
...initialContext,
branches: [
...initialContext.branches,
{
name: 'qa/launch-picker',
current: false,
local: true,
remote: false,
checkedOut: false,
commit: headCommit,
},
],
}
mocks.getRepositoryContext.mockResolvedValue(initialContext)
mocks.createRepositoryBranch.mockImplementation(() => new Promise((resolve) => {
window.setTimeout(() => resolve({
branch: 'qa/launch-picker',
baseRef: 'main',
context: createdContext,
}), 0)
}))
mocks.create.mockResolvedValueOnce({ sessionId: 'created-picker-branch', workDir: '/repo' })
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Project',
createdAt: '2026-05-01T00:00:00.000Z',
modifiedAt: '2026-05-01T00:00:00.000Z',
messageCount: 0,
projectPath: '/repo',
workDir: '/repo',
workDirExists: true,
}],
activeSessionId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
streamingResponseChars: 0,
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
render(
<StrictMode>
<ChatInput variant="hero" />
</StrictMode>,
)
await openBranchList()
fireEvent.click(screen.getByRole('button', { name: 'Create branch…' }))
fireEvent.change(await screen.findByLabelText('Branch name'), {
target: { value: 'qa/launch-picker' },
})
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(await screen.findByRole('button', { name: 'Location: repo / qa/launch-picker' }))
.toBeInTheDocument()
expect(useChatStore.getState().sessions[sessionId]?.repositoryLaunchDraft?.branch)
.toBe('qa/launch-picker')
expect(screen.queryByRole('status', { name: /Uncommitted changes/ })).not.toBeInTheDocument()
setComposerText('show the current branch', 23)
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
await waitFor(() => {
expect(mocks.create).toHaveBeenCalledWith({
workDir: '/repo',
repository: { branch: 'qa/launch-picker', worktree: false },
})
})
expect(mocks.wsSend).toHaveBeenCalledWith('created-picker-branch', {
type: 'user_message',
content: 'run on feature branch',
content: 'show the current branch',
attachments: [],
})
})
@@ -1806,6 +1999,30 @@ describe('ChatInput file mentions', () => {
})
})
it('preserves Feishu-style paragraph breaks when pasting text', async () => {
render(<ChatInput compact />)
const input = getComposerElement()
fireEvent.paste(input, {
clipboardData: {
files: [],
items: [],
types: ['text/plain', 'text/html'],
getData: (type: string) => {
if (type === 'text/plain') return '第一行\r\n第二行\r\n\r\n第四行'
if (type === 'text/html') {
return '<div><span>第一行</span></div><div><span>第二行</span></div><div><br></div><div><span>第四行</span></div>'
}
return ''
},
},
})
await waitFor(() => {
expect(getComposerText()).toBe('第一行\n第二行\n\n第四行')
})
})
it('ignores pasted files that finish loading after the prompt was sent', async () => {
class DeferredFileReader {
result: string | ArrayBuffer | null = null
@@ -2050,6 +2267,113 @@ describe('ChatInput file mentions', () => {
expect(getComposerText()).toBe('')
})
it('opens actionable /save-workflow help locally without creating a silent user turn', async () => {
useSettingsStore.setState({ chatSendBehavior: 'enter' })
render(<ChatInput />)
const beforeMessages = useChatStore.getState().sessions[sessionId]!.messages
setComposerText('/save-workflow', '/save-workflow'.length)
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
expect(mocks.wsSend).not.toHaveBeenCalled()
expect(await screen.findByText('Save workflow')).toBeInTheDocument()
expect(screen.getByText(/Complete a workflow in this session/)).toBeInTheDocument()
expect(useChatStore.getState().sessions[sessionId]!.messages).toBe(beforeMessages)
expect(getComposerText()).toBe('')
})
it('saves a completed runtime workflow through the composer command', async () => {
useSettingsStore.setState({ chatSendBehavior: 'enter' })
const runId = 'wf_save-flow-abc'
act(() => {
const handle = useChatStore.getState().handleServerMessage
handle(sessionId, {
type: 'system_notification',
subtype: 'task_started',
data: {
task_id: 'workflow-save-flow',
task_type: 'local_workflow',
workflow_name: 'generated-audit',
workflow_run_id: runId,
},
})
handle(sessionId, {
type: 'system_notification',
subtype: 'task_progress',
data: {
task_id: 'workflow-save-flow',
workflow_run_id: runId,
workflow_progress: [
{ type: 'workflow_phase', index: 1, title: 'Scan' },
{
type: 'workflow_agent',
index: 1,
label: 'scan routes',
state: 'done',
phaseIndex: 1,
agentId: 'agent-save-flow',
},
],
},
})
handle(sessionId, {
type: 'system_notification',
subtype: 'task_notification',
data: {
task_id: 'workflow-save-flow',
workflow_run_id: runId,
status: 'completed',
},
})
})
const script = [
"export const meta = { name: 'generated-audit', description: 'Audit routes' }",
"return await agent('scan routes')",
].join('\n')
const getRun = vi.spyOn(workflowsApi, 'getRun').mockResolvedValue({
runId,
sessionId,
workflowName: 'generated-audit',
scriptPath: '/tmp/generated-audit.js',
startedAt: 1,
completedAgents: 1,
status: 'completed',
script,
agents: [],
})
const save = vi.spyOn(workflowsApi, 'save').mockResolvedValue({
ok: true,
name: 'release-audit',
filePath: '/repo/.claude/workflows/release-audit.js',
})
render(<ChatInput />)
setComposerText('/save-workflow', '/save-workflow'.length)
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
await waitFor(() => expect(getRun).toHaveBeenCalledWith(sessionId, runId))
fireEvent.change(
await screen.findByRole('textbox', { name: 'Command name' }),
{ target: { value: 'release-audit' } },
)
fireEvent.click(screen.getByRole('button', { name: 'Save workflow' }))
await waitFor(() => {
expect(save).toHaveBeenCalledWith(
script,
'project',
'/repo',
'release-audit',
)
})
expect(await screen.findByText(/Start a new session and run \/release-audit/)).toBeInTheDocument()
expect(mocks.wsSend).not.toHaveBeenCalledWith(
sessionId,
expect.objectContaining({ type: 'user_message' }),
)
})
it('prioritizes active-session slash commands by command name when filtering', async () => {
useChatStore.setState({
sessions: {
@@ -2227,4 +2551,65 @@ describe('ChatInput file mentions', () => {
attachments: [],
})
})
it('highlights only actionable ultracode tokens and follows the persisted setting in both directions', async () => {
const updateUser = vi.spyOn(settingsApi, 'updateUser').mockResolvedValue({ ok: true })
try {
render(<ChatInput />)
const highlightedKeywords = () =>
Array.from(document.querySelectorAll('[data-workflow-keyword="true"]'))
.map((element) => element.textContent)
const complexPrompt = 'ultracode:audit routes, error propagation, and missing tests with several agents'
setComposerText(complexPrompt, complexPrompt.length)
await waitFor(() => {
expect(highlightedKeywords()).toEqual(['ultracode'])
})
for (const literalPrompt of [
'```text\nultracode\n```',
'explain "ultracode" without running it',
'open docs/ultracode/readme.md',
'pass --ultracode to the CLI',
'inspect the ultracode-runner package',
]) {
setComposerText(literalPrompt, literalPrompt.length)
await waitFor(() => {
expect(highlightedKeywords()).toEqual([])
})
}
setComposerText(complexPrompt, complexPrompt.length)
await waitFor(() => {
expect(highlightedKeywords()).toEqual(['ultracode'])
})
await act(async () => {
await useSettingsStore.getState().setWorkflowKeywordTriggerEnabled(false)
})
await waitFor(() => {
expect(highlightedKeywords()).toEqual([])
})
await act(async () => {
await useSettingsStore.getState().setWorkflowKeywordTriggerEnabled(true)
})
await waitFor(() => {
expect(highlightedKeywords()).toEqual(['ultracode'])
})
fireEvent.click(screen.getByRole('button', { name: 'Run' }))
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
type: 'user_message',
content: complexPrompt,
attachments: [],
})
expect(updateUser).toHaveBeenNthCalledWith(1, { workflowKeywordTriggerEnabled: false })
expect(updateUser).toHaveBeenNthCalledWith(2, { workflowKeywordTriggerEnabled: true })
} finally {
updateUser.mockRestore()
}
})
})
+27 -7
View File
@@ -185,14 +185,19 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
updateQueuedUserMessage,
removeQueuedUserMessage,
sendQueuedUserMessage,
setPreparingTurn,
} = useChatStore()
const activeTabId = useTabStore((s) => s.activeTabId)
const activeTabType = useTabStore((s) =>
s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type,
)
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
const repositoryLaunchDraft = sessionState?.repositoryLaunchDraft
const launchWorkDir = repositoryLaunchDraft?.workDir ?? ''
const launchBranch = repositoryLaunchDraft?.branch ?? null
const launchUseWorktree = repositoryLaunchDraft?.useWorktree ?? false
const chatState = sessionState?.chatState ?? 'idle'
const isPreparingTurn = Boolean(sessionState?.isPreparingTurn)
const slashCommands = sessionState?.slashCommands ?? []
const composerPrefill = sessionState?.composerPrefill ?? null
const composerInsertion = sessionState?.composerInsertion ?? null
@@ -252,7 +257,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
pasteGenerationRef.current += 1
}, [])
const isMemberSession = !!memberInfo
const isMemberSession = !!memberInfo || activeTabType === 'subagent'
const isActive = chatState !== 'idle'
const hasRunningSubagents = hasRunningSubagentTasks(sessionState?.backgroundAgentTasks)
const workspaceState = getSessionWorkspaceState(activeSession)
@@ -295,6 +300,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
: null
const canSubmit = !isWorkspaceMissing &&
!launchTransitioning &&
!isPreparingTurn &&
(!showLaunchControls || launchReady || !!pendingSlashUiAction) &&
(input.trim().length > 0 || (!isMemberSession && (attachments.length > 0 || hasWorkspaceReferences)))
const composerAttachments = useMemo(
@@ -790,14 +796,23 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
(gitInfo?.branch ? launchBranch !== gitInfo.branch : true)
if (shouldReplaceForRepositoryLaunch) {
setLaunchTransitioning(true)
const placeholderSessionId = targetSessionId
setPreparingTurn(placeholderSessionId, true)
try {
const newSessionId = await replaceEmptySession(activeLaunchWorkDir, {
branch: launchBranch,
worktree: launchUseWorktree,
})
if (!newSessionId) return
if (!newSessionId) {
setPreparingTurn(placeholderSessionId, false)
return
}
targetSessionId = newSessionId
// Keep the placeholder continuous across the old-id -> new-id swap.
// sendMessage clears it in the same turn that appends the user row.
setPreparingTurn(targetSessionId, true)
} catch (error) {
setPreparingTurn(placeholderSessionId, false)
useUIStore.getState().addToast({
type: 'error',
message: error instanceof Error ? error.message : t('empty.failedToCreate'),
@@ -1291,7 +1306,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
placeholder={composerPlaceholder}
disabled={isWorkspaceMissing}
disabled={isWorkspaceMissing || launchTransitioning || isPreparingTurn}
className="flex-1"
editorClassName="max-h-[200px] overflow-y-auto py-2 leading-relaxed text-[var(--color-text-primary)]"
aria={{
@@ -1317,7 +1332,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
placeholder={composerPlaceholder}
disabled={isWorkspaceMissing}
disabled={isWorkspaceMissing || launchTransitioning || isPreparingTurn}
editorClassName={`max-h-[200px] overflow-y-auto text-sm leading-relaxed text-[var(--color-text-primary)] ${
useCompactChrome ? 'py-1.5' : 'py-2'
}`}
@@ -1348,7 +1363,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
`-mx-3` has to cancel the panel's `p-3` exactly, and the panel is
padded by the same chrome rule.
*/}
<div data-testid="chat-input-toolbar" className={`flex items-center justify-between border-t border-[var(--color-border-separator)] ${
<div data-testid="chat-input-toolbar" className={`flex items-center justify-between ${
isHeroComposer
? 'pt-3'
: useCompactChrome
@@ -1373,7 +1388,10 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
onClick={() => setPlusMenuOpen((value) => !value)}
aria-label={t('chat.composerTools')}
aria-expanded={plusMenuOpen}
className={`inline-flex items-center justify-center rounded-[var(--radius-md)] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${isMobileComposer ? 'h-11 w-11' : 'h-8 w-8'}`}
// Bordered on desktop so the tools affordance reads as a
// control at rest, not only on hover — it sits next to
// the permission chip, which is bordered too.
className={`inline-flex items-center justify-center rounded-[var(--radius-md)] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${isMobileComposer ? 'h-11 w-11' : 'h-8 w-8 border border-[var(--color-border)]'}`}
>
<span className="material-symbols-outlined text-[18px]">add</span>
</button>
@@ -1416,6 +1434,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
) : (
<ProjectContextChip
workDir={resolvedWorkDir}
projectRoot={activeSession?.projectRoot}
repoName={gitInfo?.repoName || null}
branch={gitInfo?.branch || null}
sourceWorkDir={gitInfo?.worktree?.sourceWorkDir || null}
@@ -1487,7 +1506,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
send without a word next to it. Dropping the label is why the
name now lives only in `aria-label`, on both breakpoints. */}
<Button
variant={!isMemberSession && isActive ? 'danger' : 'primary'}
variant={!isMemberSession && isActive ? 'danger' : 'accent'}
size="base"
shape="circle"
onClick={!isMemberSession && isActive ? () => stopGeneration(activeTabId!) : handleSubmit}
@@ -1521,6 +1540,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
{messageCount > 0 ? (
<ProjectContextChip
workDir={resolvedWorkDir}
projectRoot={activeSession?.projectRoot}
repoName={gitInfo?.repoName || null}
branch={gitInfo?.branch || null}
sourceWorkDir={gitInfo?.worktree?.sourceWorkDir || null}
+37 -5
View File
@@ -3,12 +3,16 @@ import { Highlight, type PrismTheme } from 'prism-react-renderer'
import { CopyButton } from '@/components/ui/CopyButton'
import { useTranslation } from '../../i18n'
export type CodeViewerChrome = 'card' | 'embedded'
type Props = {
code: string
language?: string
maxLines?: number
showLineNumbers?: boolean
wrapLongLines?: boolean
chrome?: CodeViewerChrome
label?: string
}
const warmPrismTheme: PrismTheme = {
@@ -282,9 +286,18 @@ function CodeArea({
)
}
export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = false, wrapLongLines = false }: Props) {
export function CodeViewer({
code,
language,
maxLines = 20,
showLineNumbers = false,
wrapLongLines = false,
chrome = 'card',
label,
}: Props) {
const t = useTranslation()
const [expanded, setExpanded] = useState(false)
const isEmbedded = chrome === 'embedded'
const allLines = code.split('\n')
const isTruncated = !expanded && allLines.length > maxLines
@@ -296,16 +309,35 @@ export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = fa
const showExpandToggle = allLines.length > maxLines
return (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-code-bg)]">
<div
data-code-viewer-chrome={chrome}
className={
isEmbedded
? 'overflow-hidden bg-[var(--color-code-bg)]'
: 'overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-code-bg)]'
}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-surface-container)] px-3 py-1.5 text-[11px] text-[var(--color-text-tertiary)]">
<div className="flex items-center gap-3">
<span className="font-semibold uppercase tracking-[0.14em]">{languageLabel}</span>
<div className="flex items-center gap-2.5">
{label ? (
<>
<span className="font-semibold uppercase tracking-[0.14em]">{label}</span>
<span aria-hidden="true">·</span>
</>
) : null}
<span className={`${label ? 'font-medium' : 'font-semibold'} uppercase tracking-[0.14em]`}>
{languageLabel}
</span>
<span>{lineCountLabel}</span>
</div>
<CopyButton
text={code}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]"
className={
isEmbedded
? 'rounded-[var(--radius-sm)] px-2 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]'
: 'rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]'
}
/>
</div>
@@ -6,23 +6,29 @@ import { act } from 'react'
// ──────────────────────────────────────────────────────────────────────────────
// Hoisted mocks (vi.hoisted runs before module evaluation)
// ──────────────────────────────────────────────────────────────────────────────
const { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock, panelState } = vi.hoisted(() => {
const { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock, getTargetsForPathMock, openSystemFileSpy, panelState } = vi.hoisted(() => {
const openPreviewSpy = vi.fn().mockResolvedValue(undefined)
const browserOpenSpy = vi.fn()
const openTargetSpy = vi.fn().mockResolvedValue(undefined)
const ensureTargetsMock = vi.fn().mockResolvedValue(undefined)
const getTargetsForPathMock = vi.fn().mockResolvedValue([
{ id: 'code', kind: 'ide', label: 'VS Code', icon: '', platform: 'darwin' },
{ id: 'system-default', kind: 'system_default', label: 'System default', icon: '', platform: 'darwin' },
])
const openSystemFileSpy = vi.fn().mockResolvedValue(undefined)
const panelState = { isOpen: false }
return { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock, panelState }
return { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock, getTargetsForPathMock, openSystemFileSpy, panelState }
})
// Mock openTargetStore
vi.mock('../../stores/openTargetStore', () => ({
useOpenTargetStore: Object.assign(
// Selector hook form: useOpenTargetStore((s) => s.xxx)
(selector: (s: { targets: unknown[]; ensureTargets: () => Promise<void>; openTarget: () => Promise<void> }) => unknown) =>
(selector: (s: { targets: unknown[]; ensureTargets: () => Promise<void>; getTargetsForPath: () => Promise<unknown[]>; openTarget: () => Promise<void> }) => unknown) =>
selector({
targets: [{ id: 'code', kind: 'ide', label: 'VS Code', icon: '', platform: 'darwin' }],
ensureTargets: ensureTargetsMock,
getTargetsForPath: getTargetsForPathMock,
openTarget: openTargetSpy,
}),
{
@@ -30,6 +36,7 @@ vi.mock('../../stores/openTargetStore', () => ({
getState: vi.fn(() => ({
targets: [{ id: 'code', kind: 'ide', label: 'VS Code', icon: '', platform: 'darwin' }],
ensureTargets: ensureTargetsMock,
getTargetsForPath: getTargetsForPathMock,
openTarget: openTargetSpy,
})),
},
@@ -68,6 +75,13 @@ vi.mock('../../lib/desktopRuntime', () => ({
getServerBaseUrl: vi.fn(() => 'http://127.0.0.1:4321'),
}))
vi.mock('../../lib/systemFileOpen', () => ({
openLocalFileWithSystem: openSystemFileSpy,
resolveAbsoluteOpenPath: (path: string, workDir?: string) => (
path.startsWith('/') || !workDir ? path : `${workDir}/${path}`
),
}))
// Mock useTranslation: returns identity-ish t function
vi.mock('../../i18n', () => ({
useTranslation: () => (key: string, params?: Record<string, string | number>) => {
@@ -87,6 +101,11 @@ vi.mock('../../i18n', () => ({
import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
import { localFileUrl } from '../../lib/handlePreviewLink'
import type { SessionTurnCheckpoint } from '../../api/sessions'
import { en } from '../../i18n/locales/en'
import { zh } from '../../i18n/locales/zh'
import { zh as zhTW } from '../../i18n/locales/zh-TW'
import { jp } from '../../i18n/locales/jp'
import { kr } from '../../i18n/locales/kr'
// ──────────────────────────────────────────────────────────────────────────────
// Helpers
@@ -94,6 +113,7 @@ import type { SessionTurnCheckpoint } from '../../api/sessions'
function makeCheckpoint(
filesChanged: string[],
restoreAvailable?: boolean,
unverifiedChangeSources?: string[],
): SessionTurnCheckpoint {
return {
code: {
@@ -111,6 +131,7 @@ function makeCheckpoint(
messagesRemoved: 0,
},
...(restoreAvailable === undefined ? {} : { restoreAvailable }),
...(unverifiedChangeSources === undefined ? {} : { unverifiedChangeSources }),
}
}
@@ -118,8 +139,10 @@ function renderCard(
filesChanged: string[],
isLatest = true,
restoreAvailable?: boolean,
unverifiedChangeSources?: string[],
onUndo: () => void = vi.fn(),
) {
const checkpoint = makeCheckpoint(filesChanged, restoreAvailable)
const checkpoint = makeCheckpoint(filesChanged, restoreAvailable, unverifiedChangeSources)
return render(
<CurrentTurnChangeCard
sessionId="s1"
@@ -128,7 +151,7 @@ function renderCard(
error={null}
isUndoing={false}
isLatest={isLatest}
onUndo={vi.fn()}
onUndo={onUndo}
/>,
)
}
@@ -189,14 +212,78 @@ describe('CurrentTurnChangeCard – rich file row (icon / name / type)', () => {
expect(screen.getByText(/HTML/)).toBeInTheDocument()
})
it('keeps incomplete checkpoint files visible but disables unsafe rewind', () => {
renderCard(['/w/proj/src/main.ts', '/outside/generated.ts'], true, false)
it('keeps incomplete checkpoint files visible and still offers undo for the conversation', () => {
const onUndo = vi.fn()
renderCard(['/w/proj/src/main.ts', '/outside/generated.ts'], true, false, undefined, onUndo)
expect(screen.getByText('main.ts')).toBeInTheDocument()
expect(screen.getByText('generated.ts')).toBeInTheDocument()
expect(screen.getByText('chat.turnChangesPreviewOnlySubtitle')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'chat.turnChangesRestoreUnavailable' }))
.toBeDisabled()
expect(screen.getByText('chat.turnChangesConversationOnlySubtitle')).toBeInTheDocument()
// An unrestorable checkpoint must not cost the user the conversation
// rollback too — the dialog is where the remaining action is chosen.
const undoButton = screen.getByRole('button', { name: 'chat.turnChangesLatestUndoAria' })
expect(undoButton).toBeEnabled()
fireEvent.click(undoButton)
expect(onUndo).toHaveBeenCalledTimes(1)
})
it('keeps undo usable and warns instead of blocking when coverage is partial', () => {
const onUndo = vi.fn()
renderCard(['/w/proj/src/main.ts'], true, true, ['Bash', 'TaskCreate'], onUndo)
// Warn about what undo will NOT reverse...
expect(screen.getByText('chat.turnChangesPartialCoverageSubtitle')).toBeInTheDocument()
expect(screen.queryByText('chat.turnChangesConversationOnlySubtitle')).toBeNull()
// ...while still letting the user reverse the files it did capture.
const undoButton = screen.getByRole('button', { name: 'chat.turnChangesLatestUndoAria' })
expect(undoButton).toBeEnabled()
fireEvent.click(undoButton)
expect(onUndo).toHaveBeenCalledTimes(1)
})
it('names the unverified sources in every locale message', () => {
// Every surface that warns about partial coverage feeds the joined tool list
// in as {sources}; a locale that drops the placeholder would warn without
// saying what undo is leaving behind.
const keys = [
'chat.turnChangesPartialCoverageSubtitle',
'chat.turnChangesPartialCoverageConfirmBody',
'chat.rewindSuccessPartialCoverage',
] as const
for (const [name, messages] of Object.entries({ en, zh, zhTW, jp, kr })) {
for (const key of keys) {
expect(messages[key], `${name}/${key} must interpolate {sources}`)
.toContain('{sources}')
}
}
// The post-undo message also has to say how much of the conversation went.
for (const [name, messages] of Object.entries({ en, zh, zhTW, jp, kr })) {
expect(
messages['chat.rewindSuccessPartialCoverage'],
`${name} must interpolate {count}`,
).toContain('{count}')
}
})
it('shows no coverage warning when every change source is accounted for', () => {
renderCard(['/w/proj/src/main.ts'], true, true, [])
expect(screen.queryByText('chat.turnChangesPartialCoverageSubtitle', { exact: false }))
.toBeNull()
expect(screen.getByText('chat.turnChangesLatestSubtitle')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'chat.turnChangesLatestUndoAria' })).toBeEnabled()
})
it('prefers the conversation-only message over the coverage warning when restore is unavailable', () => {
renderCard(['/w/proj/src/main.ts'], true, false, ['Bash'])
// Both conditions hold, but "files cannot be restored at all" is the one
// that changes what the user can do, so it wins the subtitle.
expect(screen.getByText('chat.turnChangesConversationOnlySubtitle')).toBeInTheDocument()
expect(screen.queryByText('chat.turnChangesPartialCoverageSubtitle', { exact: false }))
.toBeNull()
expect(screen.getByRole('button', { name: 'chat.turnChangesLatestUndoAria' })).toBeEnabled()
})
})
@@ -272,22 +359,21 @@ describe('CurrentTurnChangeCard – open-with buttons', () => {
expect(buttons).toHaveLength(2)
})
it('does NOT render an "open-with" button for a source file (row still opens workspace)', () => {
it('renders an "open-with" button for a source file while its row still opens workspace', () => {
renderCard(['/w/proj/src/main.ts'])
expect(screen.queryAllByRole('button', { name: 'openWith.title' })).toHaveLength(0)
// source files keep their workspace-open row — only the open-with pill is dropped
expect(screen.getAllByRole('button', { name: 'openWith.title' })).toHaveLength(1)
expect(screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ })).toBeInTheDocument()
})
it('mixed turn: only previewable rows (md/html) get the open-with button, not .ts', () => {
it('mixed turn: every real changed file gets the open-with button', () => {
renderCard(['/w/proj/README.md', '/w/proj/src/main.ts', '/w/proj/index.html'])
expect(screen.getAllByRole('button', { name: 'openWith.title' })).toHaveLength(2)
expect(screen.getAllByRole('button', { name: 'openWith.title' })).toHaveLength(3)
})
it('keeps open-with secondary while every row retains its workspace chevron', () => {
renderCard(['/w/proj/README.md', '/w/proj/index.html', '/w/proj/src/main.ts'])
expect(screen.getAllByRole('button', { name: 'openWith.title' })).toHaveLength(2)
expect(screen.getAllByRole('button', { name: 'openWith.title' })).toHaveLength(3)
const rows = screen.getAllByRole('button', { name: /turnChangesOpenInWorkspaceAria/ })
expect(rows.every((row) => row.querySelector('.lucide-chevron-right'))).toBe(true)
})
@@ -310,6 +396,21 @@ describe('CurrentTurnChangeCard – open-with buttons', () => {
expect(await screen.findByText('openWith.workspacePreview')).toBeInTheDocument()
})
it('offers the copy entries every other open-with surface has', async () => {
// This card built its dependencies by hand instead of using the shared
// factory, so it silently lacked the two clipboard rows the prose links and
// the file tree both offer.
renderCard(['/w/proj/README.md'])
const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' })
await act(async () => {
fireEvent.click(openWithBtn!)
})
expect(await screen.findByText('openWith.copyPath')).toBeInTheDocument()
expect(screen.getByText('openWith.copyFileContent')).toBeInTheDocument()
})
it('clicking workspace preview item in README.md menu calls openPreview', async () => {
renderCard(['/w/proj/README.md'])
const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' })
@@ -366,7 +467,7 @@ describe('CurrentTurnChangeCard – open-with buttons', () => {
expect(await screen.findByText('openWith.inAppBrowser')).toBeInTheDocument()
})
it('ensureTargets is called when open-with button is clicked', async () => {
it('loads targets for the concrete file when open-with is clicked', async () => {
renderCard(['/w/proj/README.md'])
const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' })
@@ -374,7 +475,16 @@ describe('CurrentTurnChangeCard – open-with buttons', () => {
fireEvent.click(openWithBtn!)
})
expect(ensureTargetsMock).toHaveBeenCalledTimes(1)
expect(getTargetsForPathMock).toHaveBeenCalledWith('/w/proj/README.md')
})
it('opens an office changed file with the system application instead of the binary workspace preview', () => {
renderCard(['/w/proj/reports/brief.docx'])
fireEvent.click(screen.getByRole('button', { name: /turnChangesOpenFileAria/ }))
expect(openSystemFileSpy).toHaveBeenCalledWith('/w/proj/reports/brief.docx')
expect(openPreviewSpy).not.toHaveBeenCalled()
})
it('open-with button does not also trigger the row workspace-open (stopPropagation)', async () => {
@@ -5,15 +5,17 @@ import type { SessionTurnCheckpoint } from '../../api/sessions'
import { useTranslation, type TranslationKey } from '../../i18n'
import { Button } from '@/components/ui/Button'
import { OpenWithMenu } from '@/components/composite/OpenWithMenu'
import { buildOpenWithItems, describeFileType, isPreviewableChangedFile, type OpenWithItem } from '../../lib/openWithItems'
import { describeFileType, isPreviewableChangedFile, type OpenWithItem } from '../../lib/openWithItems'
import { buildOpenWithMenuItems } from '../../lib/openWithMenuItems'
import { openWithContextForWorkspaceFile } from '../../lib/openWithContextForHref'
import { isAbsoluteLocalPath, localFileUrl } from '../../lib/handlePreviewLink'
import { shouldOfferStaticHtmlPreview } from '../../lib/htmlPreviewPolicy'
import { getServerBaseUrl } from '../../lib/desktopRuntime'
import { getDesktopHost } from '../../lib/desktopHost'
import { useOpenTargetStore } from '../../stores/openTargetStore'
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
import { isWorkspacePreviewableFile } from '../../lib/fileCapabilities'
import { openLocalFileWithSystem, reportOpenFailure } from '../../lib/systemFileOpen'
type CurrentTurnChangeCardProps = {
sessionId: string
@@ -60,6 +62,11 @@ export function CurrentTurnChangeCard({
? files.slice(0, COLLAPSED_COUNT)
: files
const restoreAvailable = checkpoint.restoreAvailable !== false
// Undo restores every file listed above, but a turn that also ran a writing
// shell command may have touched files no checkpoint captured. Say so instead
// of withholding the undo — the listed files are still exactly reversible.
const unverifiedChangeSources = checkpoint.unverifiedChangeSources ?? []
const hasUnverifiedChanges = restoreAvailable && unverifiedChangeSources.length > 0
const openChangedFile = useCallback((event: ReactMouseEvent<HTMLButtonElement>, fileEntry: ChangedFileEntry) => {
const renderItem = event.currentTarget.closest<HTMLElement>('[data-chat-render-item-key]')
@@ -67,6 +74,10 @@ export function CurrentTurnChangeCard({
sourceTurnKey: renderItem?.dataset.chatRenderItemKey ?? checkpoint.target.targetUserMessageId,
sourceElementId: event.currentTarget.id,
}
if (!isWorkspacePreviewableFile(fileEntry.displayPath)) {
void openLocalFileWithSystem(fileEntry.apiPath).catch(() => reportOpenFailure(fileEntry.apiPath))
return
}
// A changed file outside the workdir (absolute displayPath — e.g. another
// drive) has no checkpoint baseline, so a diff is meaningless. Render html in
// the in-app browser and everything else as a file preview (served by its
@@ -97,18 +108,17 @@ export function CurrentTurnChangeCard({
const triggerEl = event.currentTarget
const rect = triggerEl.getBoundingClientRect()
void (async () => {
await useOpenTargetStore.getState().ensureTargets()
const targets = useOpenTargetStore.getState().targets
const targets = await useOpenTargetStore.getState().getTargetsForPath(fileEntry.apiPath)
const ctx = openWithContextForWorkspaceFile(fileEntry.displayPath, fileEntry.apiPath, {
sessionId,
serverBaseUrl: getServerBaseUrl(),
siblingFiles: files.map((entry) => entry.displayPath),
})
const items = buildOpenWithItems(ctx, targets, {
openInAppBrowser: (url) => useBrowserPanelStore.getState().open(sessionId, url),
openSystem: (p) => { void getDesktopHost().shell.openPath(p).catch(() => {}) },
openWorkspacePreview: (rel) => { void useWorkspacePanelStore.getState().openPreview(sessionId, rel, 'file') },
openTarget: (id, abs) => { void useOpenTargetStore.getState().openTarget(id, abs) },
// The shared dependency factory, not a fourth hand-copied set: this call
// site was the one that never adopted it, which is why the changed-file
// menu was missing the copy entries every other surface has.
const items = buildOpenWithMenuItems(ctx, targets, {
sessionId,
t: (k, v) => t(k as TranslationKey, v),
})
setOpenWith({ items, anchor: rect, triggerEl })
@@ -119,10 +129,14 @@ export function CurrentTurnChangeCard({
? t('chat.turnChangesLatestCardLabel')
: t('chat.turnChangesHistoricalCardLabel')
const subtitle = !restoreAvailable
? t('chat.turnChangesPreviewOnlySubtitle')
: isLatest
? t('chat.turnChangesLatestSubtitle')
: t('chat.turnChangesCurrentWorkspaceDiff')
? t('chat.turnChangesConversationOnlySubtitle')
: hasUnverifiedChanges
? t('chat.turnChangesPartialCoverageSubtitle', {
sources: unverifiedChangeSources.join(', '),
})
: isLatest
? t('chat.turnChangesLatestSubtitle')
: t('chat.turnChangesCurrentWorkspaceDiff')
const undoLabel = isLatest
? t('chat.turnChangesLatestUndo')
: t('chat.turnChangesHistoricalUndo')
@@ -132,7 +146,10 @@ export function CurrentTurnChangeCard({
return (
<section
className="mx-auto mb-5 w-full max-w-[900px] overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-card)]"
// Follows the message it belongs to inside the same rail box, so it takes a
// top margin and no width of its own — `max-w-[900px]` here would have
// overflowed the column once the rail indented it.
className="mt-2 w-full overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-card)]"
aria-label={cardLabel}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
@@ -148,27 +165,29 @@ export function CurrentTurnChangeCard({
-{checkpoint.code.deletions}
</span>
</div>
<div className="mt-0.5 text-xs text-[var(--color-text-tertiary)]">
<div
className={`mt-0.5 text-xs ${
hasUnverifiedChanges
? 'text-[var(--color-warning)]'
: 'text-[var(--color-text-tertiary)]'
}`}
>
{subtitle}
</div>
</div>
{/* Never disabled: rolling the conversation back is always possible, even
when the files are not restorable. The dialog picks what to touch. */}
<Button
variant="secondary"
size="base"
loading={isUndoing}
disabled={!restoreAvailable}
onClick={onUndo}
aria-label={restoreAvailable ? undoAria : t('chat.turnChangesRestoreUnavailable')}
title={restoreAvailable ? undefined : t('chat.turnChangesRestoreUnavailable')}
aria-label={undoAria}
className="shrink-0"
icon={<span className="material-symbols-outlined text-[15px]" aria-hidden="true">undo</span>}
>
{isUndoing
? t('chat.turnChangesUndoing')
: restoreAvailable
? undoLabel
: t('chat.turnChangesRestoreUnavailable')}
{isUndoing ? t('chat.turnChangesUndoing') : undoLabel}
</Button>
</div>
@@ -176,7 +195,7 @@ export function CurrentTurnChangeCard({
{visibleFiles.map((fileEntry) => {
const fileName = fileEntry.displayPath.split('/').pop() || fileEntry.displayPath
const typeInfo = describeFileType(fileEntry.displayPath)
const previewable = isPreviewableChangedFile(fileEntry.displayPath)
const workspacePreviewable = isWorkspacePreviewableFile(fileEntry.displayPath)
return (
<div key={fileEntry.apiPath} className="flex items-center gap-2">
<button
@@ -184,7 +203,12 @@ export function CurrentTurnChangeCard({
id={`turn-change-opener-${checkpoint.target.targetUserMessageId}-${encodeURIComponent(fileEntry.apiPath)}`}
data-source-turn-key={checkpoint.target.targetUserMessageId}
onClick={(event) => openChangedFile(event, fileEntry)}
aria-label={t('chat.turnChangesOpenInWorkspaceAria', { path: fileEntry.displayPath })}
aria-label={t(
workspacePreviewable
? 'chat.turnChangesOpenInWorkspaceAria'
: 'chat.turnChangesOpenFileAria',
{ path: fileEntry.displayPath },
)}
title={fileEntry.displayPath}
className="flex min-h-[52px] min-w-0 flex-1 items-center gap-3 rounded-[var(--radius-md)] px-4 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]"
>
@@ -195,19 +219,17 @@ export function CurrentTurnChangeCard({
</span>
<ChevronRight size={17} strokeWidth={1.9} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
</button>
{previewable && (
<Button
variant="secondary"
size="base"
aria-label={t('openWith.title')}
onClick={(event) => handleOpenWith(event, fileEntry)}
className="mr-2 shrink-0"
icon={<ChevronDown size={14} strokeWidth={1.9} aria-hidden="true" />}
iconPosition="end"
>
{t('openWith.title')}
</Button>
)}
<Button
variant="secondary"
size="base"
aria-label={t('openWith.title')}
onClick={(event) => handleOpenWith(event, fileEntry)}
className="mr-2 shrink-0"
icon={<ChevronDown size={14} strokeWidth={1.9} aria-hidden="true" />}
iconPosition="end"
>
{t('openWith.title')}
</Button>
</div>
)
})}
@@ -19,7 +19,7 @@ export function InlineTaskSummary({ tasks }: { tasks: TaskSummaryItem[] }) {
const total = tasks.length
return (
<div className="mb-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] overflow-hidden">
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] overflow-hidden">
<div className="flex items-center gap-3 px-4 py-2 bg-[var(--color-surface-container)]">
<div className="flex items-center justify-center w-5 h-5 rounded-[var(--radius-md)] bg-[var(--color-success-container)]">
<span className="material-symbols-outlined text-[13px] text-[var(--color-success)]" style={{ fontVariationSettings: "'FILL' 1" }}>
@@ -10,7 +10,6 @@ import {
import { Badge } from '@/components/ui/Badge'
import { EmptyState } from '@/components/ui/EmptyState'
import { ErrorState } from '@/components/ui/ErrorState'
import { IconButton } from '@/components/ui/IconButton'
import { LoadingState } from '@/components/ui/LoadingState'
import { mcpStatusTone } from '@/lib/mcpStatus'
import { useTranslation, type TranslationKey } from '../../i18n'
@@ -21,8 +20,17 @@ import { useSkillStore } from '../../stores/skillStore'
import type { McpServerRecord } from '../../types/mcp'
import type { SkillMeta } from '../../types/skill'
import type { SlashCommandOption } from './composerUtils'
import { SlashCommandPanelShell as PanelShell } from './SlashCommandPanelShell'
import { WorkflowSavePanel } from './WorkflowSavePanel'
export type LocalSlashCommandName = 'mcp' | 'skills' | 'help' | 'status' | 'cost' | 'context'
export type LocalSlashCommandName =
| 'mcp'
| 'skills'
| 'help'
| 'status'
| 'cost'
| 'context'
| 'save-workflow'
type Props = {
command: LocalSlashCommandName
@@ -54,39 +62,6 @@ function projectBadge(path?: string, t?: ReturnType<typeof useTranslation>) {
return t('slash.mcp.projectBadge', { name: label })
}
function PanelShell({
title,
subtitle,
children,
onClose,
}: {
title: string
subtitle: string
children: React.ReactNode
onClose: () => void
}) {
const t = useTranslation()
return (
<div className="absolute bottom-full left-0 right-0 z-[var(--z-dropdown)] mb-3 overflow-hidden rounded-[var(--radius-2xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-overlay)]">
<div className="flex items-start justify-between gap-4 border-b border-[var(--color-border)] px-5 py-4">
<div>
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">{title}</h3>
<p className="mt-1 text-sm text-[var(--color-text-tertiary)]">{subtitle}</p>
</div>
<IconButton
icon="close"
label={t('tabs.close')}
size="lg"
tone="secondary"
shape="circle"
onClick={onClose}
/>
</div>
<div className="max-h-[min(620px,72vh)] overflow-y-auto px-5 py-4">{children}</div>
</div>
)
}
function formatNumber(value: number | undefined) {
return new Intl.NumberFormat().format(value ?? 0)
}
@@ -991,7 +966,7 @@ const COMMAND_GROUPS = [
},
{
titleKey: 'slash.help.group.desktop',
names: ['mcp', 'skills', 'plugin', 'help'],
names: ['mcp', 'skills', 'save-workflow', 'plugin', 'help'],
},
] satisfies Array<{ titleKey: TranslationKey; names: string[] }>
@@ -1075,6 +1050,16 @@ function HelpPanel({
export function LocalSlashCommandPanel({ command, sessionId, cwd, commands, onClose }: Props) {
if (command === 'mcp') return <McpPanel cwd={cwd} onClose={onClose} />
if (command === 'skills') return <SkillsPanel cwd={cwd} onClose={onClose} />
if (command === 'save-workflow') {
return (
<WorkflowSavePanel
sessionId={sessionId}
cwd={cwd}
commands={commands}
onClose={onClose}
/>
)
}
if (command === 'status' || command === 'cost' || command === 'context') {
return <SessionInspectorPanel command={command} sessionId={sessionId} commands={commands} onClose={onClose} />
}
@@ -16,11 +16,13 @@
* textarea handlers calling preventDefault).
*/
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import { Slice, type Node as PMNode } from 'prosemirror-model'
import { EditorState, Plugin, PluginKey, TextSelection } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
import { Decoration, DecorationSet, EditorView } from 'prosemirror-view'
import { keymap } from 'prosemirror-keymap'
import { history, redo, undo } from 'prosemirror-history'
import { baseKeymap, splitBlock } from 'prosemirror-commands'
import { findKeywordRanges } from '../../../../src/utils/workflows/keyword'
import {
buildComposerDoc,
composerSchema,
@@ -33,6 +35,7 @@ import {
} from './composerEditor'
import type { ComposerMention } from '../../lib/composerMentions'
import { mentionsEqual } from '../../lib/composerMentions'
import { useSettingsStore } from '../../stores/settingsStore'
export type MentionComposerHandle = {
focus: () => void
@@ -72,6 +75,7 @@ export type MentionComposerProps = {
* editable element (`[data-composer-editor]`).
*/
const composerViewRegistry = new WeakMap<HTMLElement, EditorView>()
const workflowKeywordPluginKey = new PluginKey('workflow-keyword-highlight')
export function getComposerViewForTesting(element: HTMLElement | null): EditorView | undefined {
return element ? composerViewRegistry.get(element) : undefined
@@ -91,6 +95,23 @@ function syncEmptyState(view: EditorView, placeholder?: string) {
}
}
function workflowKeywordDecorations(doc: PMNode, enabled: boolean): DecorationSet {
if (!enabled) return DecorationSet.empty
const text = projectComposerDoc(doc).text
const decorations = findKeywordRanges(text).map((range) =>
Decoration.inline(
textOffsetToPmPos(doc, range.start),
textOffsetToPmPos(doc, range.end),
{
class: 'composer-workflow-keyword',
'data-workflow-keyword': 'true',
},
),
)
return DecorationSet.create(doc, decorations)
}
export const MentionComposer = forwardRef<MentionComposerHandle, MentionComposerProps>(
function MentionComposer(props, ref) {
const {
@@ -107,6 +128,11 @@ export const MentionComposer = forwardRef<MentionComposerHandle, MentionComposer
const containerRef = useRef<HTMLDivElement | null>(null)
const viewRef = useRef<EditorView | null>(null)
const workflowKeywordTriggerEnabled = useSettingsStore(
(state) => state.workflowKeywordTriggerEnabled,
)
const workflowKeywordTriggerEnabledRef = useRef(workflowKeywordTriggerEnabled)
workflowKeywordTriggerEnabledRef.current = workflowKeywordTriggerEnabled
const propsRef = useRef(props)
propsRef.current = props
const lastProjectedRef = useRef({ text: value, mentions })
@@ -124,7 +150,30 @@ export const MentionComposer = forwardRef<MentionComposerHandle, MentionComposer
key: new PluginKey('mention-composer-props'),
props: {
handleKeyDown: (_view, event) => propsRef.current.onKeyDown?.(event) ?? false,
handlePaste: (_view, event, _slice) => propsRef.current.onPaste?.(event) ?? false,
handlePaste: (editorView, event, _slice) => {
if (propsRef.current.onPaste?.(event)) return true
const clipboard = event.clipboardData
if (!clipboard) return false
const html = clipboard.getData('text/html')
// Preserve ProseMirror's own slice metadata so copied mention
// atoms remain atoms. External rich text only projects to
// plain composer text, so its text/plain representation is
// the authoritative source for exact line and blank-line
// boundaries (Feishu uses empty HTML blocks for blank lines).
if (html.includes('data-pm-slice')) return false
const text = clipboard.getData('text/plain')
if (!text) return false
const normalizedText = text.replace(/\r\n?/g, '\n')
const pastedDoc = buildComposerDoc(normalizedText, [])
editorView.dispatch(
editorView.state.tr
.replaceSelection(Slice.maxOpen(pastedDoc.content))
.scrollIntoView(),
)
return true
},
handleDOMEvents: {
// File drops belong to the composer's outer drop zone, which
// turns them into attachments. Left to ProseMirror, a drop
@@ -134,6 +183,15 @@ export const MentionComposer = forwardRef<MentionComposerHandle, MentionComposer
},
},
}),
new Plugin({
key: workflowKeywordPluginKey,
props: {
decorations: (state) => workflowKeywordDecorations(
state.doc,
workflowKeywordTriggerEnabledRef.current,
),
},
}),
history(),
// Whole-pill deletion, ahead of baseKeymap's structural commands.
keymap({
@@ -203,6 +261,18 @@ export const MentionComposer = forwardRef<MentionComposerHandle, MentionComposer
syncEmptyState(view, propsRef.current.placeholder)
}, [value, mentions])
// The setting can change while the current draft stays untouched. An
// empty transaction makes ProseMirror recompute decorations immediately,
// so the visible opt-in warning and the persisted runtime gate stay joined.
useEffect(() => {
const view = viewRef.current
if (!view) return
view.dispatch(view.state.tr.setMeta(
workflowKeywordPluginKey,
workflowKeywordTriggerEnabled,
))
}, [workflowKeywordTriggerEnabled])
// Dynamic editor chrome: classes and aria attributes are not part of the
// document, so they are applied straight to the editable element.
useEffect(() => {
@@ -1,4 +1,5 @@
import { Check, Copy, GitFork } from 'lucide-react'
import type { ReactNode } from 'react'
import { useSettingsStore } from '../../stores/settingsStore'
import { formatExactMessageTimestamp, formatMessageHoverTime } from '../../lib/formatMessageTimestamp'
import { CopyButton } from '@/components/ui/CopyButton'
@@ -28,6 +29,14 @@ type Props = {
branchAction?: MessageBranchAction
align?: 'start' | 'end'
timestamp?: number
/** Inline metadata that shares the same compact row as the actions. */
metadata?: ReactNode
/**
* Skip the hover gate. For bars that are already rare and deliberate — the
* reply that closes a turn — where hiding them until hover only makes a
* present affordance hard to find.
*/
alwaysVisible?: boolean
}
export function MessageActionBar({
@@ -36,6 +45,8 @@ export function MessageActionBar({
branchAction,
align = 'start',
timestamp,
metadata,
alwaysVisible = false,
}: Props) {
const locale = useSettingsStore((state) => state.locale)
const hasCopy = Boolean(copyText?.trim())
@@ -46,17 +57,19 @@ export function MessageActionBar({
? formatExactMessageTimestamp(timestamp, locale)
: ''
if (!hasCopy && !branchAction) return null
if (!hasCopy && !branchAction && !metadata) return null
return (
<div
data-message-actions
data-align={align}
className={`pointer-events-none mt-2 flex h-7 w-full opacity-0 transition-opacity duration-150 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100 ${
align === 'end' ? 'justify-end' : 'justify-start'
}`}
className={`mt-2 flex h-7 w-full transition-opacity duration-150 ${
alwaysVisible
? ''
: 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100'
} ${align === 'end' ? 'justify-end' : 'justify-start'}`}
>
<div className="flex min-h-7 items-center gap-1.5">
<div className="flex min-h-7 min-w-0 items-center gap-1.5">
{hasCopy ? (
<CopyButton
text={copyText!}
@@ -79,6 +92,11 @@ export function MessageActionBar({
onPointerUp={(event) => event.currentTarget.blur()}
/>
) : null}
{metadata ? (
<span className={hasCopy || branchAction ? 'ml-3 min-w-0' : 'min-w-0'}>
{metadata}
</span>
) : null}
{hoverTimeLabel ? (
<span
className="ml-1 inline-flex items-center text-[11px] font-medium tabular-nums text-[var(--color-text-tertiary)]"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,10 +1,31 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
import {
captureProjectDisplayNameHydrationRevision,
hydrateProjectDisplayNames,
} from '../../stores/projectDisplayNameStore'
import { ProjectContextChip } from './ProjectContextChip'
afterEach(() => {
vi.useRealTimers()
})
describe('ProjectContextChip', () => {
beforeEach(() => {
act(() => {
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
})
})
afterEach(() => {
act(() => {
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
})
})
it('shows only the source project label and worktree marker for isolated worktrees', () => {
vi.useFakeTimers()
render(
<ProjectContextChip
workDir="/workspace/OpenCutSkill/.claude/worktrees/desktop-main-54a09f85"
@@ -13,6 +34,7 @@ describe('ProjectContextChip', () => {
branch="main"
isWorktree
worktreeSlug="desktop-main-54a09f85"
worktreePath="/workspace/OpenCutSkill/.claude/worktrees/desktop-main-54a09f85"
/>,
)
@@ -20,6 +42,87 @@ describe('ProjectContextChip', () => {
expect(screen.getByText('worktree')).toBeInTheDocument()
expect(screen.queryByText('main')).not.toBeInTheDocument()
expect(screen.queryByText('desktop-main-54a09f85')).not.toBeInTheDocument()
fireEvent.mouseEnter(screen.getByTestId('worktree-details-trigger'))
act(() => { vi.advanceTimersByTime(400) })
expect(screen.getByRole('tooltip')).toHaveTextContent('desktop-main-54a09f85')
expect(screen.getByRole('tooltip')).toHaveTextContent('/workspace/OpenCutSkill/.claude/worktrees/desktop-main-54a09f85')
})
it('reveals the worktree name and directory from the compact marker', () => {
vi.useFakeTimers()
render(
<ProjectContextChip
variant="toolbar"
workDir="/workspace/OpenCutSkill/.claude/worktrees/desktop-main-54a09f85"
sourceWorkDir="/workspace/OpenCutSkill"
repoName="OpenCutSkill"
branch="main"
isWorktree
worktreeSlug={null}
worktreePath={'C:\\workspace\\OpenCutSkill\\.claude\\worktrees\\desktop-main-54a09f85'}
/>,
)
const trigger = screen.getByTestId('worktree-details-trigger')
expect(screen.getByTestId('run-location-readonly')).not.toHaveAttribute('title')
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
fireEvent.focus(trigger)
act(() => { vi.advanceTimersByTime(400) })
const tooltip = screen.getByRole('tooltip')
expect(tooltip).toHaveTextContent('desktop-main-54a09f85')
expect(tooltip).toHaveTextContent('C:\\workspace\\OpenCutSkill\\.claude\\worktrees\\desktop-main-54a09f85')
expect(trigger).toHaveAttribute('aria-describedby', tooltip.id)
})
it('prioritizes the project-root display name and retains raw worktree context in its tooltip', () => {
vi.useFakeTimers()
const projectRoot = '/workspace/canonical-project'
const sourceWorkDir = '/workspace/OpenCutSkill'
const worktreePath = '/workspace/OpenCutSkill/.claude/worktrees/desktop-main-54a09f85'
render(
<ProjectContextChip
workDir={worktreePath}
projectRoot={projectRoot}
repoName="OpenCutSkill"
branch="main"
sourceWorkDir={sourceWorkDir}
isWorktree
worktreeSlug="desktop-main-54a09f85"
worktreePath={worktreePath}
/>,
)
const chip = screen.getByTestId('run-location-outside')
expect(chip).toHaveTextContent('OpenCutSkill')
act(() => {
hydrateProjectDisplayNames(
{
[projectRoot]: 'OpenCut Studio',
[sourceWorkDir]: 'Source project name',
},
captureProjectDisplayNameHydrationRevision(),
)
})
expect(chip).toHaveTextContent('OpenCut Studio')
expect(chip).not.toHaveTextContent('Source project name')
// The alias replaces the folder name on the chip, so the real directory has
// to stay reachable somewhere. For a worktree that is the details tooltip —
// the chip itself carries no `title` in this mode.
expect(chip).not.toHaveAttribute('title')
fireEvent.mouseEnter(screen.getByTestId('worktree-details-trigger'))
act(() => { vi.advanceTimersByTime(400) })
const tooltip = screen.getByRole('tooltip')
expect(tooltip).toHaveTextContent(projectRoot)
expect(tooltip).toHaveTextContent(worktreePath)
})
it('does not show worktree details for a normal checkout', () => {
@@ -1,8 +1,13 @@
import { GitFork } from 'lucide-react'
import { Tooltip } from '@/components/ui/Tooltip'
import { useTranslation } from '../../i18n'
import { getFileNameFromPath } from '../../lib/composerAttachments'
import { useProjectDisplayName } from '../../stores/projectDisplayNameStore'
import { getWorktreeDisplayName, WorktreeDetails } from './WorktreeDetails'
type Props = {
workDir?: string | null
projectRoot?: string | null
repoName?: string | null
branch?: string | null
sourceWorkDir?: string | null
@@ -22,11 +27,12 @@ type Props = {
}
function basename(path: string | null | undefined): string {
return path?.split('/').filter(Boolean).pop() || ''
return path ? getFileNameFromPath(path) : ''
}
export function ProjectContextChip({
workDir,
projectRoot,
repoName,
branch,
sourceWorkDir,
@@ -38,8 +44,10 @@ export function ProjectContextChip({
}: Props) {
const t = useTranslation()
const labelRoot = isWorktree ? (sourceWorkDir || workDir) : workDir
const label = branch ? (repoName || basename(labelRoot)) : (basename(labelRoot) || repoName || '')
const worktreeName = worktreeSlug || basename(worktreePath) || 'isolated'
const displayNameKey = projectRoot ?? sourceWorkDir ?? workDir ?? ''
const displayName = useProjectDisplayName(displayNameKey)
const label = displayName || (branch ? (repoName || basename(labelRoot)) : (basename(labelRoot) || repoName || ''))
const worktreeName = getWorktreeDisplayName(worktreeSlug, worktreePath) || 'isolated'
const isToolbar = variant === 'toolbar'
// The chip variant hides the branch for worktrees — the worktree name already
// encodes it. The toolbar variant keeps it, because it has to stay
@@ -47,11 +55,17 @@ export function ProjectContextChip({
const showBranch = !!branch && (isToolbar || !isWorktree)
const title = [
label,
displayName ? `project root: ${displayNameKey}` : null,
branch ? `branch: ${branch}` : null,
isWorktree ? `worktree: ${worktreeName}` : null,
worktreePath ? `worktree cwd: ${worktreePath}` : null,
workDir ? `cwd: ${workDir}` : null,
].filter(Boolean).join('\n')
const worktreeDetails = (
<WorktreeDetails
name={worktreeName}
path={worktreePath}
projectRoot={displayName ? displayNameKey : null}
/>
)
if (!label) return null
@@ -71,7 +85,7 @@ export function ProjectContextChip({
if (isToolbar) {
return (
<div
title={title}
title={isWorktree ? undefined : title}
data-testid="run-location-readonly"
className="inline-flex h-9 min-w-0 max-w-full shrink items-center gap-1.5 text-[13px] font-medium leading-none"
>
@@ -93,10 +107,16 @@ export function ProjectContextChip({
</>
)}
{isWorktree && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-[5px] bg-[var(--color-brand-soft)] px-1.5 py-1 text-[10px] font-bold leading-none text-[var(--color-brand)]">
<GitFork size={11} aria-hidden="true" />
{t('repoLaunch.worktreeBadge')}
</span>
<Tooltip content={worktreeDetails} placement="top-start">
<span
data-testid="worktree-details-trigger"
tabIndex={0}
className="inline-flex shrink-0 cursor-help items-center gap-1 rounded-[5px] bg-[var(--color-brand-soft)] px-1.5 py-1 text-[10px] font-bold leading-none text-[var(--color-brand)] outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)]"
>
<GitFork size={11} aria-hidden="true" />
{t('repoLaunch.worktreeBadge')}
</span>
</Tooltip>
)}
</div>
)
@@ -104,7 +124,7 @@ export function ProjectContextChip({
return (
<div
title={title}
title={isWorktree ? undefined : title}
data-testid="run-location-outside"
className={`inline-flex max-w-full items-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-secondary)] ${
compact ? 'gap-1.5 px-3 py-1.5 text-xs' : 'gap-2 px-4 py-2 text-sm'
@@ -123,9 +143,15 @@ export function ProjectContextChip({
{isWorktree ? (
<>
<span className="text-[var(--color-text-tertiary)]">|</span>
<span className="shrink-0 rounded-full border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] font-medium uppercase leading-none text-[var(--color-text-tertiary)]">
worktree
</span>
<Tooltip content={worktreeDetails} placement="top-start">
<span
data-testid="worktree-details-trigger"
tabIndex={0}
className="shrink-0 cursor-help rounded-full border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] font-medium uppercase leading-none text-[var(--color-text-tertiary)] outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)]"
>
worktree
</span>
</Tooltip>
</>
) : null}
</div>
@@ -1,6 +1,9 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { useState, type ComponentProps } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, useState, type ComponentProps } from 'react'
import { flushSync } from 'react-dom'
import { createRoot } from 'react-dom/client'
import { create } from 'zustand'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
const viewportMocks = vi.hoisted(() => ({
@@ -10,6 +13,11 @@ const viewportMocks = vi.hoisted(() => ({
const apiMocks = vi.hoisted(() => ({
getRepositoryContext: vi.fn(),
createRepositoryBranch: vi.fn(),
}))
const uiMocks = vi.hoisted(() => ({
addToast: vi.fn(),
}))
vi.mock('../../hooks/useMobileViewport', () => ({
@@ -24,9 +32,16 @@ vi.mock('../../lib/desktopRuntime', () => ({
vi.mock('../../api/sessions', () => ({
sessionsApi: {
getRepositoryContext: apiMocks.getRepositoryContext,
createRepositoryBranch: apiMocks.createRepositoryBranch,
},
}))
vi.mock('../../stores/uiStore', () => ({
useUIStore: (selector: (state: { addToast: typeof uiMocks.addToast }) => unknown) => (
selector({ addToast: uiMocks.addToast })
),
}))
// Must match the component's own import specifier, or the mock silently does
// nothing and the real panel renders. It moved to composite/ in the directory
// reshuffle and this path was left behind.
@@ -41,7 +56,9 @@ vi.mock('@/components/composite/DirectoryPicker', () => ({
}))
vi.mock('../../i18n', () => ({
useTranslation: () => (key: string) => ({
useTranslation: () => (key: string, params?: Record<string, string | number>) => {
const text = {
'common.cancel': 'Cancel',
'common.loading': 'Loading',
'dirPicker.directory': 'Directory',
'dirPicker.selectProject': 'Select a project',
@@ -55,6 +72,18 @@ vi.mock('../../i18n', () => ({
'repoLaunch.launchLocation': 'Location',
'repoLaunch.localBranch': 'Local branch',
'repoLaunch.missingWorkdir': 'Missing working directory',
'repoLaunch.newBranch': 'Create branch…',
'repoLaunch.newBranchErrorNoCommits': 'This repository has no commits yet.',
'repoLaunch.newBranchErrorExists': 'A branch with this name already exists.',
'repoLaunch.newBranchErrorFailed': 'Could not create the branch.',
'repoLaunch.newBranchErrorInvalid': 'Git will not accept this branch name.',
'repoLaunch.newBranchFrom': 'Starts from {branch}',
'repoLaunch.newBranchNameLabel': 'Branch name',
'repoLaunch.newBranchPlaceholder': 'feature/my-change',
'repoLaunch.newBranchSubmit': 'Create',
'repoLaunch.newBranchSuccessCurrent': 'Created and selected “{branch}”. It will be checked out when the session starts.',
'repoLaunch.newBranchSuccessIsolated': 'Created and selected “{branch}”. An isolated worktree will be created from it when the session starts.',
'repoLaunch.newBranchTitle': 'New branch',
'repoLaunch.noBranch': 'No branch',
'repoLaunch.noBranchMatch': 'No matching branches',
'repoLaunch.remoteBranch': 'Remote branch',
@@ -67,10 +96,21 @@ vi.mock('../../i18n', () => ({
'repoLaunch.worktreeIsolated': 'Isolated worktree',
'repoLaunch.worktreeIsolatedHint': 'New isolated copy',
'tabs.close': 'Close',
}[key] ?? key),
}[key] ?? key
return params
? Object.entries(params).reduce((acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)), text)
: text
},
}))
import { RepositoryLaunchControls } from './RepositoryLaunchControls'
import {
captureProjectDisplayNameHydrationRevision,
hydrateProjectDisplayNames,
} from '../../stores/projectDisplayNameStore'
const HEAD_COMMIT = 'a'.repeat(40)
const OTHER_COMMIT = 'b'.repeat(40)
const okRepositoryContext = {
state: 'ok' as const,
@@ -79,6 +119,7 @@ const okRepositoryContext = {
repoName: 'cc-haha',
currentBranch: 'main',
defaultBranch: 'main',
headCommit: HEAD_COMMIT,
dirty: false,
worktrees: [],
branches: [
@@ -90,6 +131,7 @@ const okRepositoryContext = {
checkedOut: false,
remoteRef: null,
worktreePath: null,
commit: HEAD_COMMIT,
},
{
name: 'feature/h5',
@@ -99,10 +141,34 @@ const okRepositoryContext = {
checkedOut: false,
remoteRef: null,
worktreePath: null,
commit: OTHER_COMMIT,
},
],
}
/** `okRepositoryContext` plus a branch created off `main`, so still at HEAD. */
const contextWithCreatedBranch = {
...okRepositoryContext,
branches: [
...okRepositoryContext.branches,
{
name: 'feature/new',
current: false,
local: true,
remote: false,
checkedOut: false,
remoteRef: null,
worktreePath: null,
commit: HEAD_COMMIT,
},
],
}
/** Rejection shaped like the API client's `ApiError`, which carries the body. */
function apiError(code: string, message = 'boom') {
return Object.assign(new Error(message), { body: { error: code, message } })
}
function notGitContext(workDir: string) {
return {
state: 'not_git_repo' as const,
@@ -135,9 +201,16 @@ function renderControls(props: Partial<ComponentProps<typeof RepositoryLaunchCon
* happens *after* a directory is picked needs the prop to actually come back
* down changed.
*/
function ControlledHarness({ initialWorkDir = '' }: { initialWorkDir?: string }) {
function ControlledHarness({
initialWorkDir = '',
initialUseWorktree = false,
}: {
initialWorkDir?: string
initialUseWorktree?: boolean
}) {
const [workDir, setWorkDir] = useState(initialWorkDir)
const [branch, setBranch] = useState<string | null>(null)
const [useWorktree, setUseWorktree] = useState(initialUseWorktree)
return (
<RepositoryLaunchControls
@@ -145,8 +218,8 @@ function ControlledHarness({ initialWorkDir = '' }: { initialWorkDir?: string })
onWorkDirChange={setWorkDir}
branch={branch}
onBranchChange={setBranch}
useWorktree={false}
onUseWorktreeChange={vi.fn()}
useWorktree={useWorktree}
onUseWorktreeChange={setUseWorktree}
/>
)
}
@@ -174,13 +247,29 @@ async function openBranchView() {
describe('RepositoryLaunchControls', () => {
beforeEach(() => {
act(() => {
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
})
viewportMocks.isMobile = false
viewportMocks.isTauri = false
apiMocks.getRepositoryContext.mockReset()
apiMocks.getRepositoryContext.mockResolvedValue(okRepositoryContext)
apiMocks.createRepositoryBranch.mockReset()
uiMocks.addToast.mockReset()
apiMocks.createRepositoryBranch.mockResolvedValue({
branch: 'feature/new',
baseRef: 'main',
context: contextWithCreatedBranch,
})
Element.prototype.scrollIntoView = vi.fn()
})
afterEach(() => {
act(() => {
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
})
})
it('collapses directory, branch and worktree into a single pill', async () => {
renderControls()
@@ -194,6 +283,28 @@ describe('RepositoryLaunchControls', () => {
expect(screen.queryByRole('button', { name: /Select worktree mode:/ })).not.toBeInTheDocument()
})
it('uses the repository-root display name when repository context canonicalizes the requested path', async () => {
const workDir = '/repo/subdirectory'
apiMocks.getRepositoryContext.mockResolvedValue({
...okRepositoryContext,
workDir: '/repo',
repoRoot: '/repo',
})
renderControls({ workDir })
await screen.findByRole('button', { name: 'Location: cc-haha / main' })
act(() => {
hydrateProjectDisplayNames(
{ '/repo': 'Custom repository' },
captureProjectDisplayNameHydrationRevision(),
)
})
const pill = await screen.findByRole('button', { name: 'Location: Custom repository / main' })
expect(pill).toHaveAttribute('title', `${workDir}\nBranch: main`)
expect(within(pill).getByText('main')).toBeInTheDocument()
})
it('truncates the branch from the start so its tail survives', async () => {
renderControls()
@@ -430,6 +541,496 @@ describe('RepositoryLaunchControls', () => {
expect(screen.queryByRole('menu', { name: 'Location' })).not.toBeInTheDocument()
})
/**
* Switching branches has always been possible here; creating one meant
* leaving for a terminal (#1190). The entry lives at the foot of the branch
* list, and the branch it creates is selected but — like every other pick in
* this menu — only checked out when the session launches.
*/
describe('new branch', () => {
async function openNewBranchForm() {
await openBranchView()
fireEvent.click(screen.getByRole('button', { name: 'Create branch…' }))
return screen.findByLabelText('Branch name')
}
it('offers the entry under the branch list without putting it in the listbox', async () => {
renderControls()
const listbox = await openBranchView()
const entry = screen.getByRole('button', { name: 'Create branch…' })
expect(entry).toBeInTheDocument()
// An action among the `option`s would be announced as a branch you could
// pick, and Enter on the search field could land on it.
expect(listbox).not.toContainElement(entry)
expect(within(listbox).queryByRole('option', { name: /New branch/ })).not.toBeInTheDocument()
})
it('seeds the name with whatever was typed into the branch filter', async () => {
renderControls()
await openBranchView()
fireEvent.change(await screen.findByPlaceholderText('Search branches'), {
target: { value: ' feature/new ' },
})
fireEvent.click(screen.getByRole('button', { name: 'Create branch…' }))
expect(await screen.findByLabelText('Branch name')).toHaveValue('feature/new')
})
it('creates the branch from the selected one and adopts the returned context', async () => {
render(<ControlledHarness initialWorkDir="/repo" />)
const input = await openNewBranchForm()
expect(screen.getByText('Starts from main')).toBeInTheDocument()
fireEvent.change(input, { target: { value: 'feature/new' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() => {
expect(apiMocks.createRepositoryBranch).toHaveBeenCalledWith({
workDir: '/repo',
name: 'feature/new',
from: 'main',
})
})
// Success closes the menu so the selected branch is immediately visible
// on the pill instead of leaving the user inside an apparently unchanged
// menu. The returned list is adopted without a second request.
const pill = await screen.findByRole('button', { name: 'Location: cc-haha / feature/new' })
expect(screen.queryByRole('menu', { name: 'Location' })).not.toBeInTheDocument()
expect(apiMocks.getRepositoryContext).toHaveBeenCalledTimes(1)
expect(uiMocks.addToast).toHaveBeenCalledWith({
type: 'success',
message: 'Created and selected “feature/new”. It will be checked out when the session starts.',
})
fireEvent.click(pill)
fireEvent.click(await screen.findByRole('menuitem', { name: /Branch/ }))
const listbox = await screen.findByRole('listbox', { name: 'Select branch' })
expect(within(listbox).getByRole('option', { name: /feature\/new/ })).toBeInTheDocument()
})
it('keeps a created branch selected across the external launch-store boundary', async () => {
const launch = vi.fn()
const useLaunchTarget = create<{
branch: string | null
setBranch: (branch: string | null) => void
}>((set) => ({
branch: 'main',
setBranch: (branch) => set({ branch }),
}))
const setLaunchBranch = (branch: string | null) => {
if (branch === 'qa/launch-picker') {
// Production React publishes the external-store selection before
// this component's local context update. Vitest's development build
// batches them, so force that real ordering at the store boundary.
flushSync(() => useLaunchTarget.getState().setBranch(branch))
return
}
useLaunchTarget.getState().setBranch(branch)
}
const createdContext = {
...contextWithCreatedBranch,
branches: contextWithCreatedBranch.branches.map((candidate) => (
candidate.name === 'feature/new'
? { ...candidate, name: 'qa/launch-picker' }
: candidate
)),
}
apiMocks.createRepositoryBranch.mockImplementation(() => new Promise((resolve) => {
window.setTimeout(() => resolve({
branch: 'qa/launch-picker',
baseRef: 'main',
context: createdContext,
}), 0)
}))
function ExternalLaunchHarness() {
const branch = useLaunchTarget((state) => state.branch)
return (
<>
<RepositoryLaunchControls
workDir="/repo"
onWorkDirChange={vi.fn()}
branch={branch}
onBranchChange={setLaunchBranch}
useWorktree={false}
onUseWorktreeChange={vi.fn()}
/>
<button
type="button"
onClick={() => launch({
workDir: '/repo',
repository: {
branch: useLaunchTarget.getState().branch,
worktree: false,
},
})}
>
Launch
</button>
</>
)
}
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const testGlobals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
const actEnvironment = testGlobals.IS_REACT_ACT_ENVIRONMENT
testGlobals.IS_REACT_ACT_ENVIRONMENT = false
async function eventually<T>(read: () => T): Promise<T> {
let lastError: unknown
for (let attempt = 0; attempt < 200; attempt++) {
try {
return read()
} catch (error) {
lastError = error
await new Promise((resolve) => window.setTimeout(resolve, 10))
}
}
throw lastError
}
try {
root.render(<ExternalLaunchHarness />)
const pill = await eventually(() => (
screen.getByRole('button', { name: 'Location: cc-haha / main' })
))
pill.click()
const branchEntry = await eventually(() => screen.getByRole('menuitem', { name: /Branch/ }))
branchEntry.click()
const createEntry = await eventually(() => screen.getByRole('button', { name: 'Create branch…' }))
createEntry.click()
const input = await eventually(() => screen.getByLabelText('Branch name'))
const setInputValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
setInputValue?.call(input, 'qa/launch-picker')
input.dispatchEvent(new window.Event('input', { bubbles: true }))
const createButton = await eventually(() => {
const button = screen.getByRole('button', { name: 'Create' }) as HTMLButtonElement
if (button.disabled) throw new Error('Create branch button is still disabled')
return button
})
createButton.click()
await eventually(() => (
screen.getByRole('button', { name: 'Location: cc-haha / qa/launch-picker' })
))
await eventually(() => {
if (screen.queryByRole('menu', { name: 'Location' })) {
throw new Error('Location menu is still open')
}
return true
})
screen.getByRole('button', { name: 'Launch' }).click()
expect(launch).toHaveBeenCalledWith({
workDir: '/repo',
repository: { branch: 'qa/launch-picker', worktree: false },
})
expect(useLaunchTarget.getState().branch).toBe('qa/launch-picker')
expect(screen.getByRole('button', { name: 'Location: cc-haha / qa/launch-picker' }))
.toBeInTheDocument()
expect(screen.queryByRole('menu', { name: 'Location' })).not.toBeInTheDocument()
} finally {
root.unmount()
container.remove()
testGlobals.IS_REACT_ACT_ENVIRONMENT = actEnvironment
}
})
it('explains that an isolated worktree starts from the new branch', async () => {
render(<ControlledHarness initialWorkDir="/repo" initialUseWorktree />)
const input = await openNewBranchForm()
fireEvent.change(input, { target: { value: 'feature/new' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() => {
expect(uiMocks.addToast).toHaveBeenCalledWith({
type: 'success',
message: 'Created and selected “feature/new”. An isolated worktree will be created from it when the session starts.',
})
})
expect(await screen.findByRole('button', { name: 'Location: cc-haha / feature/new' }))
.toBeInTheDocument()
expect(screen.getByText('Isolated')).toBeInTheDocument()
})
// Creating off `main` is also what `from: context.currentBranch` would do,
// so the default-selection test cannot tell the two apart — it stayed green
// under exactly that mutation. This drives a non-default selection, which is
// the contract the "Starts from {branch}" hint advertises.
it('creates from the branch that is actually selected, not the current one', async () => {
render(<ControlledHarness initialWorkDir="/repo" />)
await openBranchView()
fireEvent.click(screen.getByRole('option', { name: /feature\/h5/ }))
fireEvent.click(await screen.findByRole('menuitem', { name: /Branch/ }))
fireEvent.click(await screen.findByRole('button', { name: 'Create branch…' }))
expect(screen.getByText('Starts from feature/h5')).toBeInTheDocument()
fireEvent.change(await screen.findByLabelText('Branch name'), {
target: { value: 'feature/new' },
})
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() => {
expect(apiMocks.createRepositoryBranch).toHaveBeenCalledWith(
expect.objectContaining({ from: 'feature/h5' }),
)
})
})
it('trims the typed name before sending it', async () => {
renderControls()
const input = await openNewBranchForm()
fireEvent.change(input, { target: { value: ' feature/new ' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() => {
expect(apiMocks.createRepositoryBranch).toHaveBeenCalledWith(
expect.objectContaining({ name: 'feature/new' }),
)
})
})
it('translates a blank name without requesting branch creation', async () => {
renderControls()
const input = await openNewBranchForm()
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(await screen.findByText('Git will not accept this branch name.')).toBeInTheDocument()
fireEvent.change(input, { target: { value: ' ' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(await screen.findByText('Git will not accept this branch name.')).toBeInTheDocument()
expect(apiMocks.createRepositoryBranch).not.toHaveBeenCalled()
})
it('translates a rejected name and keeps the form open', async () => {
apiMocks.createRepositoryBranch.mockRejectedValue(apiError('REPOSITORY_BRANCH_EXISTS'))
const onBranchChange = vi.fn()
renderControls({ onBranchChange })
const input = await openNewBranchForm()
fireEvent.change(input, { target: { value: 'feature/h5' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(await screen.findByText('A branch with this name already exists.')).toBeInTheDocument()
expect(screen.getByLabelText('Branch name')).toHaveValue('feature/h5')
expect(onBranchChange).not.toHaveBeenCalledWith('feature/h5')
})
it('translates an invalid git ref and keeps the form open', async () => {
apiMocks.createRepositoryBranch.mockRejectedValue(apiError('REPOSITORY_BRANCH_NAME_INVALID'))
renderControls()
const input = await openNewBranchForm()
fireEvent.change(input, { target: { value: 'qa invalid' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(await screen.findByText('Git will not accept this branch name.')).toBeInTheDocument()
expect(screen.getByLabelText('Branch name')).toHaveValue('qa invalid')
})
it('explains an empty repository in the user language', async () => {
apiMocks.createRepositoryBranch.mockRejectedValue(apiError('REPOSITORY_NO_COMMITS'))
renderControls()
const input = await openNewBranchForm()
fireEvent.change(input, { target: { value: 'first' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(await screen.findByText('This repository has no commits yet.')).toBeInTheDocument()
})
it('shows the raw git message alone for an unrecognized failure', async () => {
apiMocks.createRepositoryBranch.mockRejectedValue(
apiError('REPOSITORY_BRANCH_CREATE_FAILED', 'Failed to create branch: fatal: cannot lock ref'),
)
renderControls()
const input = await openNewBranchForm()
fireEvent.change(input, { target: { value: 'feature' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
// A translated shrug would hide the one line that says what went wrong —
// but the server message already opens with "Failed to create branch:",
// so prefixing the generic line onto it said the same thing twice.
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent('Failed to create branch: fatal: cannot lock ref')
expect(alert).not.toHaveTextContent('Could not create the branch.')
})
it('falls back to the generic line when the failure carries no message', async () => {
apiMocks.createRepositoryBranch.mockRejectedValue(new Error(''))
renderControls()
const input = await openNewBranchForm()
fireEvent.change(input, { target: { value: 'feature' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(await screen.findByText('Could not create the branch.')).toBeInTheDocument()
})
/**
* The pill is a per-tab control, so switching tabs swaps `workDir` under an
* in-flight create. Adopting that response would file one repo's branch list
* and selection under another repo's path.
*/
it('discards a response that arrives after the directory changed', async () => {
let settle: (value: unknown) => void = () => {}
apiMocks.createRepositoryBranch.mockImplementation(() => new Promise((resolve) => {
settle = resolve
}))
apiMocks.getRepositoryContext.mockImplementation(async (dir: string) => (
dir === '/repo' ? okRepositoryContext : { ...okRepositoryContext, workDir: dir, repoName: 'other' }
))
const { rerender } = render(
<RepositoryLaunchControls
workDir="/repo"
onWorkDirChange={vi.fn()}
branch="main"
onBranchChange={vi.fn()}
useWorktree={false}
onUseWorktreeChange={vi.fn()}
/>,
)
const input = await openNewBranchForm()
fireEvent.change(input, { target: { value: 'feature/new' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
const onBranchChange = vi.fn()
rerender(
<RepositoryLaunchControls
workDir="/other"
onWorkDirChange={vi.fn()}
branch="main"
onBranchChange={onBranchChange}
useWorktree={false}
onUseWorktreeChange={vi.fn()}
/>,
)
settle({ branch: 'feature/new', baseRef: 'main', context: contextWithCreatedBranch })
await waitFor(() => {
expect(screen.getByRole('button', { name: /Location: other/ })).toBeInTheDocument()
})
expect(onBranchChange).not.toHaveBeenCalledWith('feature/new')
})
it('returns to the branch list on cancel without creating anything', async () => {
renderControls()
await openNewBranchForm()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(await screen.findByRole('listbox', { name: 'Select branch' })).toBeInTheDocument()
expect(apiMocks.createRepositoryBranch).not.toHaveBeenCalled()
})
/**
* The sheet has no inner scroller of its own — `branchList` drops its
* `max-h`/`overflow-y-auto` on mobile — so rendering the entry as a sibling
* of the list in `children` lets it scroll off the bottom with the branches.
* `MobileBottomSheet`'s `footer` slot is `shrink-0` outside the scrolling
* body, which is the only place it stays reachable.
*/
it('pins the entry to the sheet footer, outside the scrolling body, on mobile', async () => {
viewportMocks.isMobile = true
renderControls()
await openPill()
fireEvent.click(await screen.findByRole('menuitem', { name: /Branch/ }))
const entry = await screen.findByRole('button', { name: 'Create branch…' })
const scroller = screen.getByRole('listbox', { name: 'Select branch' }).parentElement
expect(scroller).toHaveClass('overflow-y-auto')
expect(scroller).not.toContainElement(entry)
})
it('reaches the create form from the mobile sheet and back again', async () => {
viewportMocks.isMobile = true
renderControls()
await openPill()
fireEvent.click(await screen.findByRole('menuitem', { name: /Branch/ }))
fireEvent.click(await screen.findByRole('button', { name: 'Create branch…' }))
expect(await screen.findByLabelText('Branch name')).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'New branch' })).toBeInTheDocument()
// The footer belongs to the branch list, not the form under it.
expect(screen.queryByRole('button', { name: 'Create branch…' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Select branch/ }))
expect(await screen.findByRole('listbox', { name: 'Select branch' })).toBeInTheDocument()
})
/**
* Creating a branch off the one you are standing on is the whole point of
* doing it with work in progress, and that switch only moves the ref — git
* never blocks it. Warning there sent people to an isolated worktree they
* did not need. Driven through the real create rather than hand-built state,
* because that transition is the thing being claimed (AGENTS.md).
*/
it('drops the dirty warning once a branch is created off the current one', async () => {
apiMocks.getRepositoryContext.mockResolvedValue({ ...okRepositoryContext, dirty: true })
apiMocks.createRepositoryBranch.mockResolvedValue({
branch: 'feature/new',
baseRef: 'main',
context: { ...contextWithCreatedBranch, dirty: true },
})
render(<ControlledHarness initialWorkDir="/repo" />)
const input = await openNewBranchForm()
fireEvent.change(input, { target: { value: 'feature/new' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await screen.findByRole('button', { name: 'Location: cc-haha / feature/new' })
expect(screen.queryByRole('status', { name: 'Dirty worktree' })).not.toBeInTheDocument()
})
it('does not warn about uncommitted changes for a branch already at HEAD', async () => {
apiMocks.getRepositoryContext.mockResolvedValue({
...contextWithCreatedBranch,
dirty: true,
})
renderControls({ branch: 'feature/new' })
await screen.findByRole('button', { name: 'Location: cc-haha / feature/new' })
expect(screen.queryByRole('status', { name: 'Dirty worktree' })).not.toBeInTheDocument()
})
it('still warns when the selected branch would rewrite files', async () => {
apiMocks.getRepositoryContext.mockResolvedValue({
...contextWithCreatedBranch,
dirty: true,
})
renderControls({ branch: 'feature/h5' })
expect(await screen.findByRole('status', { name: 'Dirty worktree' })).toBeInTheDocument()
})
it('keeps warning when the server reports no commits at all', async () => {
apiMocks.getRepositoryContext.mockResolvedValue({
...okRepositoryContext,
dirty: true,
headCommit: undefined,
branches: okRepositoryContext.branches.map(({ commit: _commit, ...rest }) => rest),
})
renderControls({ branch: 'feature/h5' })
expect(await screen.findByRole('status', { name: 'Dirty worktree' })).toBeInTheDocument()
})
})
/**
* The pill's menu was built around a repo it already had: directory, branch
* and worktree. In a fresh session `isGitReady` is false, both of the latter
@@ -9,6 +9,7 @@ import {
GitBranch,
GitFork,
Loader2,
Plus,
Search,
} from 'lucide-react'
import {
@@ -17,10 +18,14 @@ import {
type RepositoryContextResult,
} from '../../api/sessions'
import { useTranslation } from '../../i18n'
import { useUIStore } from '../../stores/uiStore'
import { useProjectDisplayName } from '../../stores/projectDisplayNameStore'
import { RecentProjectsPanel } from '@/components/composite/DirectoryPicker'
import { useDismissable } from '@/hooks/useDismissable'
import { useMobileViewport } from '../../hooks/useMobileViewport'
import { isDesktopRuntime } from '../../lib/desktopRuntime'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { MobileBottomSheet } from '@/components/ui/MobileBottomSheet'
type Props = {
@@ -46,17 +51,31 @@ const VIEWPORT_GUTTER = 12
/**
* `root` lists directory, branch and the worktree modes; `directory` and
* `branch` are its two drill-downs. The menu opens on `root` only when there
* is a repo to describe — see `viewOnOpen`.
* is a repo to describe — see `viewOnOpen`. `newBranch` is reached from the
* branch list; cancelling returns there, while success closes the menu so the
* newly selected branch is immediately visible in the pill.
*/
type MenuView = 'directory' | 'root' | 'branch'
type MenuView = 'directory' | 'root' | 'branch' | 'newBranch'
/** Approximate heights, used only to decide whether the menu flips upward. */
const VIEW_HEIGHTS: Record<MenuView, number> = {
directory: 380,
root: 340,
branch: 400,
newBranch: 240,
}
/**
* Server codes the create-branch form can explain in the user's own language.
* Anything else falls through to the generic failure plus the raw git message,
* which is more use than a translated shrug.
*/
const BRANCH_CREATE_ERROR_KEYS = {
REPOSITORY_BRANCH_NAME_INVALID: 'repoLaunch.newBranchErrorInvalid',
REPOSITORY_BRANCH_EXISTS: 'repoLaunch.newBranchErrorExists',
REPOSITORY_NO_COMMITS: 'repoLaunch.newBranchErrorNoCommits',
} as const
const GIT_MARK_PATH = 'M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z'
/**
@@ -123,9 +142,11 @@ export function RepositoryLaunchControls({
placement = 'outside',
}: Props) {
const t = useTranslation()
const addToast = useUIStore((state) => state.addToast)
const isMobileBrowser = useMobileViewport() && !isDesktopRuntime()
const isToolbar = placement === 'toolbar' && !isMobileBrowser
const [context, setContext] = useState<RepositoryContextResult | null>(null)
const [contextSourceWorkDir, setContextSourceWorkDir] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
@@ -138,12 +159,29 @@ export function RepositoryLaunchControls({
const [revealAfterPick, setRevealAfterPick] = useState<string | null>(null)
const [branchFilter, setBranchFilter] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const [newBranchName, setNewBranchName] = useState('')
const [creatingBranch, setCreatingBranch] = useState(false)
const [createBranchError, setCreateBranchError] = useState<string | null>(null)
const [menuPos, setMenuPos] = useState<{ top: number; left: number; direction: 'up' | 'down' } | null>(null)
const rootRef = useRef<HTMLDivElement>(null)
const pillRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
/** Read after an await to tell whether the directory moved on under us. */
const latestWorkDirRef = useRef(workDir)
latestWorkDirRef.current = workDir
/**
* Creating a branch updates this component's context and the parent-owned
* launch target together. An external store can publish the target first,
* so remember the exact context that makes that new target valid until the
* local state commit catches up.
*/
const createdBranchTransitionRef = useRef<{
workDir: string
branch: string
context: RepositoryContextResult
} | null>(null)
const searchInputId = useId()
const listboxId = useId()
const menuId = useId()
@@ -171,23 +209,28 @@ export function RepositoryLaunchControls({
useEffect(() => {
if (!workDir) {
setContext(null)
setContextSourceWorkDir(null)
setError(null)
setLoading(false)
onBranchChange(null)
return
}
const requestedWorkDir = workDir
let cancelled = false
setContextSourceWorkDir(null)
setLoading(true)
setError(null)
sessionsApi.getRepositoryContext(workDir)
sessionsApi.getRepositoryContext(requestedWorkDir)
.then((result) => {
if (cancelled) return
setContext(result)
setContextSourceWorkDir(requestedWorkDir)
})
.catch((err) => {
if (cancelled) return
setContext(null)
setContextSourceWorkDir(null)
setError(err instanceof Error ? err.message : String(err))
})
.finally(() => {
@@ -199,12 +242,28 @@ export function RepositoryLaunchControls({
}
}, [workDir, onBranchChange])
useEffect(() => {
createdBranchTransitionRef.current = null
}, [workDir])
useEffect(() => {
if (context?.state !== 'ok') {
if (context && branch !== null) onBranchChange(null)
return
}
const createdBranchTransition = createdBranchTransitionRef.current
if (createdBranchTransition?.workDir === workDir) {
if (context === createdBranchTransition.context) {
createdBranchTransitionRef.current = null
} else if (branch === createdBranchTransition.branch) {
// The parent selection reached us before the refreshed branch list.
// It is valid against the in-flight create response, so do not replace
// it with the old context's current branch during this single render.
return
}
}
const branchExists = branch && context.branches.some((candidate) => candidate.name === branch)
if (branchExists) return
@@ -215,13 +274,15 @@ export function RepositoryLaunchControls({
].find((name) => name && context.branches.some((candidate) => candidate.name === name))
onBranchChange(fallbackBranch || null)
}, [branch, context, onBranchChange])
}, [branch, context, onBranchChange, workDir])
const closeMenu = useCallback(() => {
setMenuOpen(false)
setView('root')
setBranchFilter('')
setRevealAfterPick(null)
setNewBranchName('')
setCreateBranchError(null)
}, [])
// The directory list is one of this menu's own views now, so there is no
@@ -276,7 +337,13 @@ export function RepositoryLaunchControls({
const warning = useMemo(() => {
if (context?.state !== 'ok' || !selectedBranch || useWorktree) return null
if (selectedBranch.name !== context.currentBranch && context.dirty) {
// A branch that points where HEAD already points cannot be blocked by
// uncommitted changes — git only moves the ref, it rewrites nothing. That
// is exactly the branch you just created off the one you are standing on,
// so warning about it there is a false alarm. Missing commit info (an older
// server) keeps the warning rather than dropping it.
const movesFiles = !(selectedBranch.commit && selectedBranch.commit === context.headCommit)
if (selectedBranch.name !== context.currentBranch && context.dirty && movesFiles) {
return {
message: t('repoLaunch.dirtyWarning'),
compactLabel: t('repoLaunch.dirtyWarningCompact'),
@@ -297,6 +364,83 @@ export function RepositoryLaunchControls({
setBranchFilter('')
}
const openNewBranchView = () => {
// Whatever was typed into the filter is almost always the name being looked
// for, so it seeds the field instead of being thrown away.
setNewBranchName(branchFilter.trim())
setCreateBranchError(null)
setView('newBranch')
}
const leaveNewBranchView = () => {
setNewBranchName('')
setCreateBranchError(null)
setView('branch')
}
const createBranch = async () => {
const name = newBranchName.trim()
if (creatingBranch || context?.state !== 'ok') return
if (!name) {
setCreateBranchError(t('repoLaunch.newBranchErrorInvalid'))
return
}
const requestWorkDir = workDir
setCreatingBranch(true)
setCreateBranchError(null)
try {
const result = await sessionsApi.createRepositoryBranch({
workDir: requestWorkDir,
name,
from: selectedBranch?.name ?? null,
})
// Adopting a result for a directory the user has since left would put one
// repo's branch list and selection under another repo's path — the same
// reason the `workDir` effect carries a `cancelled` flag.
if (latestWorkDirRef.current !== requestWorkDir) return
// The response carries the context the branch was created against, so the
// list is correct without a second round trip.
createdBranchTransitionRef.current = {
workDir: requestWorkDir,
branch: result.branch,
context: result.context,
}
setContext(result.context)
setError(null)
onBranchChange(result.branch)
addToast({
type: 'success',
message: t(
useWorktree
? 'repoLaunch.newBranchSuccessIsolated'
: 'repoLaunch.newBranchSuccessCurrent',
{ branch: result.branch },
),
})
// Closing exposes the pill, which now names the branch selected for launch.
// Keeping the menu open made a successful create look like a no-op.
closeMenu()
} catch (err) {
if (latestWorkDirRef.current !== requestWorkDir) return
const code = err instanceof Error && 'body' in err
? (err as { body?: { error?: string } }).body?.error
: undefined
const known = code && code in BRANCH_CREATE_ERROR_KEYS
? BRANCH_CREATE_ERROR_KEYS[code as keyof typeof BRANCH_CREATE_ERROR_KEYS]
: null
// The server's own message already reads "Failed to create branch: …", so
// prefixing the generic line onto it says the same thing twice. The
// generic line is for failures that arrive without one, e.g. a timeout.
const raw = err instanceof Error ? err.message.trim() : String(err).trim()
setCreateBranchError(known ? t(known) : raw || t('repoLaunch.newBranchErrorFailed'))
} finally {
// Unconditional: a stale response must still clear the spinner, or the
// form stays disabled for the directory the user moved to.
setCreatingBranch(false)
}
}
const selectWorktreeMode = (enabled: boolean) => {
onUseWorktreeChange(enabled)
closeMenu()
@@ -363,14 +507,18 @@ export function RepositoryLaunchControls({
// reading either one unguarded decides on the wrong repo.
useEffect(() => {
if (!revealAfterPick || loading) return
const settled = context?.workDir === revealAfterPick
const settled = contextSourceWorkDir === revealAfterPick
|| (!!error && workDir === revealAfterPick)
if (!settled) return
setRevealAfterPick(null)
if (context?.state !== 'ok') closeMenu()
}, [revealAfterPick, loading, context, error, workDir, closeMenu])
}, [revealAfterPick, loading, context, contextSourceWorkDir, error, workDir, closeMenu])
const repoLabel = context?.repoName || basename(context?.repoRoot) || basename(workDir)
const projectKey = contextSourceWorkDir === workDir && context
? (context.repoRoot || context.workDir)
: workDir
const displayName = useProjectDisplayName(projectKey)
const repoLabel = displayName || context?.repoName || basename(context?.repoRoot) || basename(workDir)
const selectedBranchName = isGitReady ? (selectedBranch?.name ?? null) : null
// The worktree cards name the branch their changes would land on.
const branchLabel = selectedBranch?.name ?? context?.currentBranch ?? t('repoLaunch.noBranch')
@@ -483,6 +631,70 @@ export function RepositoryLaunchControls({
</div>
)
/**
* Sits under the list, never inside it: the list is a `role="listbox"`, and an
* action button among its `option`s would be read as a branch you could pick
* (components/AGENTS.md §6).
*
* The separator is the host's job, because the two hosts pin this differently
* — the dropdown puts it after `branchList`'s own `overflow-y-auto` box, the
* sheet hands it to `MobileBottomSheet`'s `footer` slot, which is `shrink-0`
* outside the scrolling body and brings its own top border. Rendering it as a
* sibling in the sheet's `children` instead would let it scroll away with the
* list, which is the one thing it must not do.
*/
const branchCreateRow = (
<button
type="button"
onClick={openNewBranchView}
className={rowClassName}
>
<Plus size={17} className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 flex-1 truncate text-[13.5px] font-semibold text-[var(--color-text-primary)]">
{t('repoLaunch.newBranch')}
</span>
</button>
)
const newBranchForm = (
<form
className="flex flex-col gap-3 p-3"
onSubmit={(event) => {
event.preventDefault()
void createBranch()
}}
>
<Input
label={t('repoLaunch.newBranchNameLabel')}
placeholder={t('repoLaunch.newBranchPlaceholder')}
value={newBranchName}
onChange={(event) => {
setNewBranchName(event.target.value)
setCreateBranchError(null)
}}
error={createBranchError ?? undefined}
hint={t('repoLaunch.newBranchFrom', { branch: branchLabel })}
autoFocus
autoComplete="off"
spellCheck={false}
disabled={creatingBranch}
/>
<div className="flex items-center justify-end gap-2">
<Button type="button" variant="ghost" size="base" onClick={leaveNewBranchView}>
{t('common.cancel')}
</Button>
<Button
type="submit"
variant="primary"
size="base"
loading={creatingBranch}
>
{t('repoLaunch.newBranchSubmit')}
</Button>
</div>
</form>
)
const branchSearch = (
<div className="flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2">
<Search size={15} className="shrink-0 text-[var(--color-text-tertiary)]" />
@@ -733,13 +945,16 @@ export function RepositoryLaunchControls({
title={
view === 'branch'
? t('repoLaunch.selectBranch')
: view === 'directory'
? t('dirPicker.directory')
: t('repoLaunch.launchLocation')
: view === 'newBranch'
? t('repoLaunch.newBranchTitle')
: view === 'directory'
? t('dirPicker.directory')
: t('repoLaunch.launchLocation')
}
closeLabel={t('tabs.close')}
panelRef={menuRef}
id={menuId}
footer={view === 'branch' ? <div className="p-1.5">{branchCreateRow}</div> : undefined}
headerExtra={view === 'branch' ? (
<div className="flex flex-col gap-2">
<button
@@ -752,6 +967,15 @@ export function RepositoryLaunchControls({
</button>
{branchSearch}
</div>
) : view === 'newBranch' ? (
<button
type="button"
onClick={leaveNewBranchView}
className={sheetBackClassName}
>
<ChevronLeft size={14} />
{t('repoLaunch.selectBranch')}
</button>
) : view === 'directory' && directoryBackLabel ? (
<button
type="button"
@@ -763,7 +987,11 @@ export function RepositoryLaunchControls({
</button>
) : undefined}
>
{view === 'branch' ? branchList : view === 'directory' ? directoryList : rootView}
{view === 'branch'
? branchList
: view === 'newBranch'
? newBranchForm
: view === 'directory' ? directoryList : rootView}
</MobileBottomSheet>
) : menuPos && createPortal(
<div ref={menuRef} id={menuId} className={menuClassName} style={menuStyle}>
@@ -781,6 +1009,23 @@ export function RepositoryLaunchControls({
{branchSearch}
</div>
{branchList}
<div className="border-t border-[var(--color-border-separator)] p-1.5">
{branchCreateRow}
</div>
</>
) : view === 'newBranch' ? (
<>
<div className="border-b border-[var(--color-border)] px-3 py-2.5">
<button
type="button"
onClick={leaveNewBranchView}
className={dropdownBackClassName}
>
<ChevronLeft size={13} />
{t('repoLaunch.newBranchTitle')}
</button>
</div>
{newBranchForm}
</>
) : view === 'directory' ? (
<>
@@ -0,0 +1,57 @@
import { render, screen, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { describe, expect, it } from 'vitest'
import { SessionChatHeader, SessionChatSurface } from './SessionChatSurface'
describe('SessionChatSurface', () => {
it('keeps every conversation on the main session column measure', () => {
render(
<SessionChatSurface
surfaceKind="agent"
agentRunKind="subagent"
isMobileLayout={false}
activityRailOpen
activityRail={<aside>Activity</aside>}
>
<SessionChatHeader
title="Child run"
metadata={[
{ key: 'status', content: <span>Running</span> },
{ key: 'messages', content: <span>3 messages</span> },
]}
/>
<main>Transcript</main>
</SessionChatSurface>,
)
const surface = screen.getByTestId('session-chat-surface')
expect(surface).toHaveAttribute('data-session-chat-kind', 'agent')
expect(surface).toHaveAttribute('data-agent-run-kind', 'subagent')
expect(screen.getByTestId('session-chat-column')).toHaveClass('min-w-[360px]', 'pr-[352px]')
expect(screen.getByText('Activity')).toBeInTheDocument()
const header = screen.getByTestId('session-header')
expect(header.firstElementChild).toHaveClass('max-w-[900px]')
expect(within(header).getByRole('heading', { name: 'Child run' })).toHaveClass('truncate')
expect(header.querySelectorAll('[aria-hidden="true"]')).toHaveLength(1)
})
it('uses the same compact chat column contract when a side panel is present', () => {
render(
<SessionChatSurface
surfaceKind="main"
isMobileLayout={false}
compact
sidePanel={<aside>Workbench</aside>}
>
<div>Conversation</div>
</SessionChatSurface>,
)
expect(screen.getByTestId('session-chat-column')).toHaveClass(
'min-w-[400px]',
'bg-[var(--color-surface)]',
)
expect(screen.getByText('Workbench')).toBeInTheDocument()
})
})
@@ -0,0 +1,124 @@
import { Fragment, type ReactNode } from 'react'
export type SessionHeaderMetaItem = {
key: string
content: ReactNode
}
export function SessionChatHeader({
title,
compact = false,
leading,
titleAddon,
actions,
metadata = [],
children,
}: {
title: string
compact?: boolean
leading?: ReactNode
titleAddon?: ReactNode
actions?: ReactNode
metadata?: SessionHeaderMetaItem[]
children?: ReactNode
}) {
return (
<div
data-testid="session-header"
className={[
'w-full border-b border-[var(--color-border)]',
compact ? 'px-4 py-2.5' : 'px-9 py-3',
].join(' ')}
>
<div className="mx-auto w-full min-w-0 max-w-[900px]">
<div className="flex min-w-0 items-start gap-3">
{leading}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<h1
className={`min-w-0 truncate font-bold leading-tight tracking-[-0.2px] text-[var(--color-text-primary)] ${
compact ? 'text-[15px]' : 'text-[17px]'
}`}
style={{ fontFamily: 'var(--font-headline)' }}
title={title}
>
{title}
</h1>
{titleAddon}
</div>
{metadata.length > 0 ? (
<div className="mt-1 flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap text-[11px] text-[var(--color-text-tertiary)]">
{metadata.map((item, index) => (
<Fragment key={item.key}>
{index > 0 ? <span aria-hidden="true" className="shrink-0">·</span> : null}
{item.content}
</Fragment>
))}
</div>
) : null}
{children}
</div>
{actions ? <div className="flex shrink-0 items-center gap-1">{actions}</div> : null}
</div>
</div>
</div>
)
}
export function SessionChatSurface({
surfaceKind,
agentRunKind,
isMobileLayout,
compact = false,
activityRailOpen = false,
contentRowTestId = 'session-chat-content-row',
chatColumnTestId = 'session-chat-column',
compactColumnClassName,
children,
activityRail,
sidePanel,
overlay,
}: {
surfaceKind: 'main' | 'agent'
agentRunKind?: 'subagent' | 'team-member'
isMobileLayout: boolean
compact?: boolean
activityRailOpen?: boolean
contentRowTestId?: string
chatColumnTestId?: string
compactColumnClassName?: string
children: ReactNode
activityRail?: ReactNode
sidePanel?: ReactNode
overlay?: ReactNode
}) {
return (
<div
data-testid="session-chat-surface"
data-session-chat-kind={surfaceKind}
data-agent-run-kind={agentRunKind}
className="relative flex min-h-0 flex-1 overflow-hidden bg-background text-on-surface"
>
<div data-testid={contentRowTestId} className="flex min-h-0 min-w-0 flex-1">
<div
data-testid={chatColumnTestId}
className={[
'relative flex min-h-0 min-w-0 flex-col overflow-hidden',
'transition-[padding] duration-200 ease-out motion-reduce:transition-none',
activityRailOpen ? 'pr-[352px]' : '',
compact
? compactColumnClassName ?? 'min-w-[400px] flex-1 bg-[var(--color-surface)]'
: isMobileLayout
? 'flex-1'
: 'min-w-[360px] flex-1',
].filter(Boolean).join(' ')}
>
{children}
</div>
{activityRail}
{sidePanel}
</div>
{overlay}
</div>
)
}
@@ -0,0 +1,36 @@
import type { ReactNode } from 'react'
import { IconButton } from '@/components/ui/IconButton'
import { useTranslation } from '../../i18n'
export function SlashCommandPanelShell({
title,
subtitle,
children,
onClose,
}: {
title: string
subtitle: string
children: ReactNode
onClose: () => void
}) {
const t = useTranslation()
return (
<div className="absolute bottom-full left-0 right-0 z-[var(--z-dropdown)] mb-3 overflow-hidden rounded-[var(--radius-2xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-overlay)]">
<div className="flex items-start justify-between gap-4 border-b border-[var(--color-border)] px-5 py-4">
<div>
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">{title}</h3>
<p className="mt-1 text-sm text-[var(--color-text-tertiary)]">{subtitle}</p>
</div>
<IconButton
icon="close"
label={t('tabs.close')}
size="lg"
tone="secondary"
shape="circle"
onClick={onClose}
/>
</div>
<div className="max-h-[min(620px,72vh)] overflow-y-auto px-5 py-4">{children}</div>
</div>
)
}
@@ -16,6 +16,28 @@ function translateServerVerb(
return translated === key ? verb : translated
}
/**
* What the turn is currently doing, in the user's language.
*
* This is the only place in the UI that reports the verb, the elapsed time and
* the tokens so far — the composer keeps just a Stop button. Do not delete this
* component on the assumption the composer covers it, and do not fold it into
* the turn rail either: during `isPreparingTurn` the session is still being
* created, so the transcript holds no render items and there is no rail to hang
* it on. That gap is exactly what regressed once before.
*/
export function resolveTurnStatusVerb(
t: (key: TranslationKey) => string,
chatState: string,
statusVerb: string,
): string {
if (statusVerb) return translateServerVerb(t, statusVerb)
if (chatState === 'thinking') return t('serverVerb.Thinking')
if (chatState === 'compacting') return t('serverVerb.Compacting conversation')
if (chatState === 'tool_executing') return t('serverVerb.Running')
return t('serverVerb.Working')
}
function formatRetrySeconds(ms: number): number {
return Math.max(0, Math.ceil(ms / 1000))
}
@@ -33,7 +55,9 @@ export function StreamingIndicator() {
const [now, setNow] = useState(() => Date.now())
const activeTabId = useTabStore((s) => s.activeTabId)
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
const chatState = sessionState?.chatState ?? 'idle'
const chatState = sessionState?.isPreparingTurn
? 'thinking'
: sessionState?.chatState ?? 'idle'
const statusVerb = sessionState?.statusVerb ?? ''
const apiRetry = sessionState?.apiRetry ?? null
const streamingFallback = sessionState?.streamingFallback ?? null
@@ -98,7 +122,7 @@ export function StreamingIndicator() {
data-testid="streaming-fallback-indicator"
role="status"
aria-live="polite"
className="mb-2 flex w-fit items-center gap-[9px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-4 py-2 text-[13.5px] text-[var(--color-text-secondary)]"
className="flex w-fit items-center gap-[9px] py-1 text-[13.5px] text-[var(--color-text-secondary)]"
>
<RefreshCw size={13} strokeWidth={2.2} className="shrink-0 animate-spin text-[var(--color-text-secondary)]" aria-hidden="true" />
<span className="font-medium text-[var(--color-text-primary)]">
@@ -116,21 +140,18 @@ export function StreamingIndicator() {
)
}
let verb: string
if (statusVerb) {
verb = translateServerVerb(t, statusVerb)
} else {
verb = chatState === 'thinking'
? t('serverVerb.Thinking')
: chatState === 'compacting'
? t('serverVerb.Compacting conversation')
: chatState === 'tool_executing'
? t('serverVerb.Running')
: t('serverVerb.Working')
}
const verb = resolveTurnStatusVerb(t, chatState, statusVerb)
return (
<div className="mb-2 flex w-fit items-center gap-[9px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-4 py-2 text-[13.5px] text-[var(--color-text-secondary)]">
<div
data-testid="turn-status-indicator"
role="status"
aria-live="polite"
// Bare line, not a pill: it now sits at the end of the live turn rail, and
// the rail already says "still going". A bordered chip here would read as
// a second, competing status object next to the one that is lit.
className="flex w-fit items-center gap-[9px] py-1 text-[13.5px] text-[var(--color-text-secondary)]"
>
<span className="animate-pulse-dot text-[var(--color-brand)]" aria-hidden="true">✦</span>
<span className="font-medium text-[var(--color-text-primary)]">{verb}...</span>
{elapsedSeconds > 0 && (
@@ -2,9 +2,37 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { ThinkingBlock } from './ThinkingBlock'
import { ThinkingBlock, thinkingPreview } from './ThinkingBlock'
import { useSettingsStore } from '../../stores/settingsStore'
describe('thinkingPreview', () => {
it('follows the tail while streaming so the row says what it is thinking now', () => {
const content = 'Diagnosis complete:\nlint is clean\nnow checking the retry path'
expect(thinkingPreview(content, { streaming: true })).toBe('now checking the retry path')
})
it('settles back onto the opening line once the block is done', () => {
const content = 'The user ran the lifecycle suite and hit real failures.\nSo I will reset #7.'
expect(thinkingPreview(content)).toBe('The user ran the lifecycle suite and hit real failures.')
})
it('skips an opening that is only a heading for what follows', () => {
// `Diagnosis complete:` is the one line in the block that carries nothing.
expect(thinkingPreview('Diagnosis complete:\nlint is clean, 2 files left')).toBe('lint is clean, 2 files left')
})
it('keeps a long opening that merely happens to end in a colon', () => {
const content = 'The user wants me to handle GitHub issue #498 about the new KS signature, specifically:\n1. read the issue'
expect(thinkingPreview(content)).toBe(
'The user wants me to handle GitHub issue #498 about the new KS signature, specifically:',
)
})
it('keeps a bare heading when it is all there is', () => {
expect(thinkingPreview('Diagnosis complete:')).toBe('Diagnosis complete:')
})
})
describe('ThinkingBlock', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'zh' })
+104 -9
View File
@@ -1,13 +1,36 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import { Brain } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
export function ThinkingBlock({ content, isActive = false }: { content: string; isActive?: boolean }) {
/**
* One line: the label, a preview of the reasoning, and the full text on demand.
*
* There used to be a second, larger `standalone` form for a thinking block that
* was the only thing in its run — it made sense when rows lived behind a
* collapsed summary and a lone block needed to be findable. Now that every step
* is a permanent row, the split only inverted the information: a turn's opening
* reasoning is usually its most substantial, and it landed in the form that
* showed no content at all, purely because no tool call happened to follow it in
* the same run. Whether a thought is followed by a tool is not a fact about the
* thought, so it no longer changes how one is drawn.
*/
export function ThinkingBlock({
content,
isActive = false,
}: {
content: string
isActive?: boolean
}) {
const t = useTranslation()
const [expanded, setExpanded] = useState(false)
const contentRef = useRef<HTMLDivElement>(null)
const displayContent = useMemo(() => content.replace(/\r\n?/g, '\n').trimEnd(), [content])
const hasDisplayContent = displayContent.trim().length > 0
const preview = useMemo(
() => thinkingPreview(displayContent, { streaming: isActive }),
[displayContent, isActive],
)
useEffect(() => {
if (expanded && isActive && contentRef.current) {
@@ -15,28 +38,49 @@ export function ThinkingBlock({ content, isActive = false }: { content: string;
}
}, [displayContent, expanded, isActive])
const label = (
<>
{isActive ? t('thinking.label') : t('thinking.labelDone')}
{isActive && <span className="thinking-dots" />}
</>
)
return (
<div className="mb-1">
<div>
<style>{thinkingStyles}</style>
<button
type="button"
data-chat-disclosure="true"
data-thinking-row="true"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
className="flex w-full items-center gap-[7px] rounded-[var(--radius-sm)] px-1 py-0.5 text-left text-[13.5px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
className="-mx-2 flex w-[calc(100%+1rem)] items-baseline gap-2 rounded-[var(--radius-md)] px-2 py-1 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
>
<span className="text-[10px]">
{expanded ? '\u25BE' : '\u25B8'}
<Brain
size={13}
strokeWidth={1.8}
aria-hidden="true"
className="mt-[3px] shrink-0 self-start text-[var(--color-text-tertiary)]"
/>
<span className="shrink-0 text-[12.5px] italic text-[var(--color-text-tertiary)]">
{label}
</span>
<span className="shrink-0 font-medium italic">
{isActive ? t('thinking.label') : t('thinking.labelDone')}
{isActive && <span className="thinking-dots" />}
{preview ? (
<span className="min-w-0 flex-1 truncate text-[12.5px] italic leading-[1.7] text-[var(--color-text-tertiary)]">
{preview}
</span>
) : (
<span className="flex-1" />
)}
<span aria-hidden="true" className="shrink-0 text-[8px] text-[var(--color-text-tertiary)]">
{expanded ? '▾' : '▸'}
</span>
</button>
{expanded && hasDisplayContent && (
<div
ref={contentRef}
data-thinking-content="expanded"
className="relative mt-1 max-h-[300px] overflow-y-auto rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-2.5 text-[11px] text-[var(--color-text-secondary)]"
className="relative mb-2 mt-1 max-h-[300px] overflow-y-auto rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 py-2.5 text-[11px] text-[var(--color-text-secondary)]"
>
<MarkdownRenderer
content={displayContent}
@@ -52,6 +96,57 @@ export function ThinkingBlock({ content, isActive = false }: { content: string;
)
}
const THINKING_PREVIEW_MAX_CHARS = 160
/** A short line ending in a colon is a heading for what comes after it. */
const THINKING_OPENER_MAX_CHARS = 24
function cleanThinkingLines(content: string): string[] {
const lines: string[] = []
for (const rawLine of content.split('\n')) {
const line = rawLine
.trim()
.replace(/^#{1,6}\s+/, '')
.replace(/^[-*+]\s+/, '')
.replace(/^>\s*/, '')
.replace(/^\d+\.\s+/, '')
.trim()
if (!line || line === '---') continue
lines.push(line)
}
return lines
}
/**
* One line of reasoning for the collapsed row, stripped of the markdown that
* would otherwise show as literal `##` / `-` noise. Always a hint — the full
* block is one click away, so truncating here loses nothing.
*
* While the block is still streaming this follows the tail, because the useful
* question then is "what is it thinking about *now*"; a first line pinned for
* the thirty seconds a long deliberation takes answers nothing. Once it settles
* the opening line becomes the summary again — except when that opening is a
* bare heading like `Diagnosis complete:`, which is the one line in the block
* that says least, so the substance under it is shown instead.
*/
export function thinkingPreview(content: string, options: { streaming?: boolean } = {}): string {
const lines = cleanThinkingLines(content)
if (lines.length === 0) return ''
const picked = options.streaming
? lines[lines.length - 1]!
: pickSettledPreviewLine(lines)
return picked.length > THINKING_PREVIEW_MAX_CHARS
? `${picked.slice(0, THINKING_PREVIEW_MAX_CHARS)}…`
: picked
}
function pickSettledPreviewLine(lines: string[]): string {
const first = lines[0]!
const isBareHeading = first.length <= THINKING_OPENER_MAX_CHARS && /[::]$/.test(first)
return isBareHeading ? lines[1] ?? first : first
}
const thinkingStyles = `
@keyframes thinking-cursor-blink {
0%, 100% { opacity: 1; }
+203 -37
View File
@@ -1,5 +1,6 @@
import { memo, useMemo, useState } from 'react'
import { CircleStop, LoaderCircle } from 'lucide-react'
import { CircleStop, CircleX, LoaderCircle } from 'lucide-react'
import { activitySegmentIcon } from './activityGroupModel'
import { CodeViewer } from './CodeViewer'
import { DiffViewer } from './DiffViewer'
import { TerminalChrome } from './TerminalChrome'
@@ -17,12 +18,21 @@ import {
isExitPlanModeTool,
} from './PlanModePreview'
/**
* `card` is the standalone bordered block. `row` strips the border, the ink icon
* square and the bold name so the call reads as one line inside an expanded
* activity group. Expanded row details stay inline on a nested guide instead of
* rebuilding the card stack that the activity-group treatment removed (#1177).
*/
export type ToolCallChrome = 'card' | 'row'
type Props = {
toolName: string
input: unknown
result?: { content: unknown; isError: boolean } | null
agentTaskNotification?: AgentTaskNotification
compact?: boolean
chrome?: ToolCallChrome
isPending?: boolean
status?: 'stopped'
partialInput?: string
@@ -119,7 +129,8 @@ type ContentStats = {
windowed?: boolean
}
export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, isPending = false, status, partialInput, defaultExpanded = false, durationMs }: Props) {
export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, chrome = 'card', isPending = false, status, partialInput, defaultExpanded = false, durationMs }: Props) {
const isRow = chrome === 'row'
const isExitPlanTool = isExitPlanModeTool(toolName)
const isEnterPlanTool = isEnterPlanModeTool(toolName)
const [expanded, setExpanded] = useState(defaultExpanded || isExitPlanTool)
@@ -128,6 +139,9 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
const icon = TOOL_ICONS[toolName] || 'build'
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
const summary = getToolSummary(toolName, obj, t)
// Prose reads as prose. Monospace is for the things that are literally code —
// a command, a glob, a path — not for a sentence describing one.
const summaryIsProse = isProseToolSummary(toolName, obj)
const outputSummary = getToolResultSummary(
toolName,
result?.content,
@@ -146,8 +160,14 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
)
const liveStatsSummary = liveStats ? formatContentStats(liveStats, t) : ''
const preview = useMemo(() => renderPreview(toolName, obj, result, t), [obj, result, toolName, t])
const details = useMemo(() => renderDetails(toolName, obj, t, isPending ? partialInput : undefined), [isPending, obj, partialInput, toolName, t])
const preview = useMemo(
() => renderPreview(toolName, obj, result, t, isRow),
[isRow, obj, result, toolName, t],
)
const details = useMemo(
() => renderDetails(toolName, obj, t, isPending ? partialInput : undefined, isRow),
[isPending, isRow, obj, partialInput, toolName, t],
)
const hasResultDetails = Boolean(result && extractTextContent(result.content))
const hasEditPreview = toolName === 'Edit' && typeof obj.old_string === 'string' && typeof obj.new_string === 'string'
const hasWritePreview = toolName === 'Write' && typeof obj.content === 'string'
@@ -201,21 +221,40 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
}
return (
<div className={`overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] ${
compact ? 'mb-0' : 'mb-2'
}`}>
<div
data-tool-call-chrome={chrome}
className={
isRow
? ''
: `overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] ${
compact ? 'mb-0' : 'mb-2'
}`
}
>
<button
type="button"
data-chat-disclosure="true"
aria-expanded={expandable ? expanded : undefined}
onClick={() => {
if (expandable) {
setExpanded((value) => !value)
}
}}
className={`flex w-full items-center text-left transition-colors hover:bg-[var(--color-surface-hover)] ${
compact ? 'gap-[11px] px-3.5 py-2.5' : 'gap-3 px-4 py-3'
className={`flex items-center text-left transition-colors hover:bg-[var(--color-surface-hover)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
isRow
// The negative margin lets the hover highlight breathe past the
// timeline rule without the row itself being inset from it.
? '-mx-2 w-[calc(100%+1rem)] gap-2 rounded-[var(--radius-md)] px-2 py-1'
: compact ? 'w-full gap-[11px] px-3.5 py-2.5' : 'w-full gap-3 px-4 py-3'
}`}
>
{compact ? (
{isRow ? (
/* Rows had no icon at all, so every step began with a bare word and
the eye had nothing to run down. A leading glyph gives the run a
left edge, separates one tool family from the next at a glance, and
marks the whole line as machinery rather than speech. */
<RowToolIcon toolName={toolName} active={Boolean(pendingSummary)} />
) : compact ? (
<span className="material-symbols-outlined shrink-0 text-[16px] text-[var(--color-text-secondary)]">{icon}</span>
) : (
/* The ink square is the design's tool badge: solid `--t1` with the page
@@ -224,15 +263,28 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
<span className="material-symbols-outlined text-[16px]">{icon}</span>
</span>
)}
<span className={`shrink-0 font-bold text-[var(--color-text-primary)] ${compact ? 'text-[13px]' : 'text-[14px]'}`}>
<span className={
isRow
// The running step is the one the reader is waiting on, so it is the
// one that gets colour. Finished steps stay grey and the eye lands on
// where the run actually is without a separate progress strip.
//
// Grey even when finished, and a step below the prose it sits among:
// the layers have to be separable at a glance, or a run of machinery
// reads as loudly as the sentence that concludes it.
? `shrink-0 text-[12.5px] font-medium ${
pendingSummary ? 'text-[var(--color-brand)]' : 'text-[var(--color-text-tertiary)]'
}`
: `shrink-0 font-bold text-[var(--color-text-primary)] ${compact ? 'text-[13px]' : 'text-[14px]'}`
}>
{toolName}
</span>
{filePath ? (
<span className={`min-w-0 flex-1 truncate font-mono text-[var(--color-text-secondary)] ${compact ? 'text-[12.5px]' : 'text-[13px]'}`}>
<span className={`min-w-0 flex-1 truncate font-mono ${isRow ? 'text-[12px] text-[var(--color-text-tertiary)]' : compact ? 'text-[12.5px] text-[var(--color-text-secondary)]' : 'text-[13px] text-[var(--color-text-secondary)]'}`}>
{filePath.split('/').pop()}
</span>
) : summary ? (
<span className={`min-w-0 flex-1 truncate font-mono text-[var(--color-text-secondary)] ${compact ? 'text-[12.5px]' : 'text-[13px]'}`}>
<span className={`min-w-0 flex-1 truncate ${summaryIsProse ? '' : 'font-mono'} ${isRow ? 'text-[12px] text-[var(--color-text-tertiary)]' : compact ? 'text-[12.5px] text-[var(--color-text-secondary)]' : 'text-[13px] text-[var(--color-text-secondary)]'}`}>
{summary}
</span>
) : (
@@ -261,13 +313,14 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
</span>
) : result && outputSummary ? (
<span
className={`min-w-0 shrink truncate text-[12.5px] ${
className={`inline-flex min-w-0 shrink items-center gap-1.5 text-[12.5px] ${
result.isError
? 'text-[var(--color-error)]'
? 'font-medium text-[var(--color-error)]'
: 'text-[var(--color-text-tertiary)]'
}`}
>
{outputSummary}
{result.isError && <CircleX size={13} strokeWidth={2} className="shrink-0" aria-hidden="true" />}
<span className="min-w-0 truncate">{outputSummary}</span>
</span>
) : liveStatsSummary ? (
<span className="shrink-0 font-mono text-[12.5px] tabular-nums text-[var(--color-text-tertiary)]">
@@ -275,22 +328,37 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
</span>
) : null}
{durationSummary && (
<span className="shrink-0 font-mono text-[12px] tabular-nums text-[var(--color-text-tertiary)]">
<span className={`shrink-0 font-mono text-[12px] tabular-nums ${
result?.isError ? 'font-medium text-[var(--color-error)]' : 'text-[var(--color-text-tertiary)]'
}`}>
{durationSummary}
</span>
)}
{result?.isError && (
<span className="material-symbols-outlined shrink-0 text-[15px] text-[var(--color-error)]">error</span>
)}
{expandable && (
<span className="shrink-0 text-[11px] leading-none text-[var(--color-text-tertiary)]" aria-hidden="true">
{expanded ? '▴' : '▾'}
<span className={`shrink-0 leading-none text-[var(--color-text-tertiary)] ${isRow ? 'text-[8px]' : 'text-[11px]'}`} aria-hidden="true">
{isRow ? (expanded ? '▾' : '▸') : (expanded ? '▴' : '▾')}
</span>
)}
</button>
{expandable && expanded && (
<div className="space-y-2.5 border-t border-[var(--color-border)] px-4 py-3.5">
<div
data-tool-call-details={isRow ? 'inline' : 'panel'}
data-tool-output-error={result?.isError ? 'true' : undefined}
className={
isRow
? `mb-2 ml-2 mt-1 space-y-2.5 border-l py-1 pl-3 ${
result?.isError
? 'border-[var(--color-error-soft-hover)]'
: 'border-[var(--color-border)]'
}`
: `space-y-2.5 border-t px-4 py-3.5 ${
result?.isError
? 'border-[var(--color-error-soft-hover)] bg-[var(--color-error-soft)]'
: 'border-[var(--color-border)]'
}`
}
>
{preview}
{details}
</div>
@@ -425,6 +493,7 @@ function renderPreview(
obj: Record<string, unknown>,
result?: { content: unknown; isError: boolean } | null,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
embedded = false,
) {
const filePath = typeof obj.file_path === 'string' ? obj.file_path : 'file'
// Must match the terminal-card condition below exactly. When they diverged, a
@@ -434,7 +503,7 @@ function renderPreview(
const shellCommand = isShellTool(toolName) && typeof obj.command === 'string' ? obj.command : null
const echoesInTerminal = shellCommand !== null
const resultText = getVisibleResultText(toolName, result, echoesInTerminal)
const resultOutput = result && resultText ? renderResultOutput(result, resultText, t) : null
const resultOutput = result && resultText ? renderResultOutput(result, resultText, t, embedded) : null
if (toolName === 'Edit' && typeof obj.old_string === 'string' && typeof obj.new_string === 'string') {
return (
@@ -706,17 +775,49 @@ function renderResultOutput(
result: { content: unknown; isError: boolean },
text: string,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
embedded = false,
) {
const label = result.isError
? t?.('tool.errorOutput') ?? 'Error Output'
: t?.('tool.toolOutput') ?? 'Tool Output'
if (embedded) {
return (
<>
<InlineImageGallery text={text} />
{result.isError ? (
<div data-tool-detail-surface="embedded" className="overflow-hidden bg-[var(--color-error-soft)]">
<div className="flex items-center justify-between border-b border-[var(--color-error-soft-hover)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-error)]">
<span>{label}</span>
<CopyButton
text={text}
className="rounded-[var(--radius-sm)] px-2 py-1 text-[11px] normal-case tracking-normal text-[var(--color-error)] transition-colors hover:bg-[var(--color-error-soft-hover)]"
/>
</div>
<pre className="max-h-[420px] overflow-auto whitespace-pre-wrap break-words px-3 py-2 font-mono text-[12px] leading-[1.45] text-[var(--color-error)]">
{text}
</pre>
</div>
) : (
<CodeViewer code={text} language="plaintext" maxLines={18} chrome="embedded" label={label} />
)}
</>
)
}
return (
<>
<InlineImageGallery text={text} />
<div className={`overflow-hidden rounded-[var(--radius-lg)] border ${
result.isError
? 'border-[var(--color-error)] bg-[var(--color-error-container)]'
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
}`}>
<div
data-tool-detail-surface="card"
className={`overflow-hidden rounded-[var(--radius-lg)] border ${
result.isError
? 'border-[var(--color-error)] bg-[var(--color-error-container)]'
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
}`}
>
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
<span>{result.isError ? t?.('tool.errorOutput') ?? 'Error Output' : t?.('tool.toolOutput') ?? 'Tool Output'}</span>
<span>{label}</span>
<CopyButton
text={text}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] px-2 py-1 text-[11px] normal-case tracking-normal text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
@@ -739,15 +840,16 @@ function renderDetails(
obj: Record<string, unknown>,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
partialInput?: string,
embedded = false,
) {
if (partialInput) {
if (toolName === 'Write') {
const writerContent = extractPartialJsonStringField(partialInput, 'content')
if (writerContent !== null) {
return renderWriterPreview(writerContent, t)
return renderWriterPreview(writerContent, t, embedded)
}
}
return renderPartialInput(partialInput, t)
return renderPartialInput(partialInput, t, embedded)
}
if (toolName === 'Edit' || toolName === 'Write') {
@@ -765,10 +867,15 @@ function renderDetails(
}
const text = JSON.stringify(displayed, null, 2)
const label = t?.('tool.toolInput') ?? 'Tool Input'
if (embedded) {
return <CodeViewer code={text} language="json" maxLines={18} chrome="embedded" label={label} />
}
return (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
<div data-tool-detail-surface="card" className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
<span>{t?.('tool.toolInput') ?? 'Tool Input'}</span>
<span>{label}</span>
<CopyButton
text={text}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] px-2 py-1 text-[11px] normal-case tracking-normal text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
@@ -938,6 +1045,7 @@ function formatCount(count: number): string {
function renderWriterPreview(
content: string,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
embedded = false,
) {
const contentStats = countContentStats(content)
const lines = content.length === 0 ? [] : content.split('\n')
@@ -961,7 +1069,14 @@ function renderWriterPreview(
}, t)
return (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
<div
data-tool-detail-surface={embedded ? 'embedded' : 'card'}
className={
embedded
? 'overflow-hidden bg-[var(--color-surface)]'
: 'overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]'
}
>
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
<span>{t?.('tool.writerPreview') ?? 'Writer'}</span>
<span className="font-mono normal-case tracking-normal tabular-nums">
@@ -978,13 +1093,28 @@ function renderWriterPreview(
function renderPartialInput(
partialInput: string,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
embedded = false,
) {
const formattedInput = formatPartialJsonInput(partialInput)
const label = t?.('tool.partialInput') ?? 'Partial input'
if (embedded) {
return (
<CodeViewer
code={formattedInput}
language="json"
maxLines={8}
wrapLongLines
chrome="embedded"
label={label}
/>
)
}
return (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
<div data-tool-detail-surface="card" className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
{t?.('tool.partialInput') ?? 'Partial input'}
{label}
</div>
<CodeViewer code={formattedInput} language="json" maxLines={8} wrapLongLines />
</div>
@@ -1132,10 +1262,46 @@ function stripAnsi(value: string): string {
.replace(/\x1B\[[0-9;?]*[ -/]*[@-~]/g, '')
}
function RowToolIcon({ toolName, active }: { toolName: string; active: boolean }) {
const Icon = activitySegmentIcon(toolName)
return (
<Icon
size={13}
strokeWidth={1.8}
aria-hidden="true"
className={`mt-[3px] shrink-0 self-start ${
active ? 'text-[var(--color-brand)]' : 'text-[var(--color-text-tertiary)]'
}`}
/>
)
}
/** Whether the row's summary is a sentence rather than something code-shaped. */
function isProseToolSummary(toolName: string, obj: Record<string, unknown>): boolean {
switch (toolName) {
case 'Bash':
case 'PowerShell':
case 'Agent':
return typeof obj.description === 'string' && obj.description.trim().length > 0
case 'Read':
case 'Write':
case 'Edit':
return true
default:
return false
}
}
function getToolSummary(toolName: string, obj: Record<string, unknown>, t?: (key: TranslationKey, params?: Record<string, string | number>) => string): string {
switch (toolName) {
case 'Bash':
case 'PowerShell':
// The model sends a short description of what the command is for, in the
// conversation's language ("查看最近 commit 的改动文件"). Prefer it: a row
// saying that is readable at a glance, where the raw command it stands for
// is a pipeline that truncates into noise. The command itself is still one
// click away in the expanded row.
if (typeof obj.description === 'string' && obj.description.trim()) return obj.description
return typeof obj.command === 'string' ? obj.command : ''
case 'Read':
return t?.('tool.readFileContents') ?? 'Read file contents'
+145 -205
View File
@@ -1,10 +1,18 @@
import { memo, useCallback, useState } from 'react'
import { memo, useCallback, useMemo, useState } from 'react'
import { BookMarked, ChevronDown, ChevronRight, CircleCheck, Settings } from 'lucide-react'
import { ToolCallBlock } from './ToolCallBlock'
import { ToolCallBlock, type ToolCallChrome } from './ToolCallBlock'
import { ActivityGroup } from './ActivityGroup'
import { ThinkingBlock } from './ThinkingBlock'
import {
activityStepToolCalls,
toActivitySteps,
toolCallDurationMs,
type ActivityStep,
} from './activityGroupModel'
import { ImageGenerationGroup, type ImageGenerationItem } from './ImageGenerationBlock'
import { isImageGenerationToolName } from './imageGenerationTools'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { Badge, StatusDot, type Tone } from '@/components/ui/Badge'
import { Badge, type Tone } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { IconButton } from '@/components/ui/IconButton'
import { Modal } from '@/components/ui/Modal'
@@ -32,20 +40,7 @@ type MemoryToolActivity = {
files: MemoryToolFile[]
}
/**
* Wall-clock gap between the tool_use and its tool_result, used for the "524ms"
* badge (#1149). The CLI does not report a real execution duration over the wire
* — BashProgress never leaves the ink renderer — so this is the transcript
* timestamp delta and therefore includes any permission-approval wait.
*/
export function toolCallDurationMs(
toolCall: Pick<ToolCall, 'timestamp'>,
result?: Pick<ToolResult, 'timestamp'>,
): number | undefined {
if (!result) return undefined
const elapsed = result.timestamp - toolCall.timestamp
return Number.isFinite(elapsed) && elapsed >= 0 ? elapsed : undefined
}
export { toolCallDurationMs } from './activityGroupModel'
function imageGenerationItems(
toolCalls: ToolCall[],
@@ -74,105 +69,58 @@ function useExpandableCardState() {
type Props = {
sessionId?: string | null
onOpenAgentRun?: (payload: OpenAgentRunPayload) => void
toolCalls: ToolCall[]
/**
* The run in transcript order, including any thinking blocks that happened
* between the tool calls. Optional: callers that only have tool calls (and
* every test predating the activity-group rollup) get an equivalent
* tools-only run derived from `toolCalls`.
*/
steps?: ActivityStep[]
resultMap: Map<string, ToolResult>
childToolCallsByParent: Map<string, ToolCall[]>
agentTaskNotifications: Record<string, AgentTaskNotification>
agentTaskStatuses?: Record<string, BackgroundAgentTask['status']>
activeThinkingId?: string | null
showOpenRun?: boolean
/** When true, the last tool is still executing. */
isStreaming?: boolean
/** This run is the tail of a turn that is still producing into it. */
isLive?: boolean
}
const TOOL_VERBS: Record<string, (count: number, t: (key: TranslationKey, params?: Record<string, string | number>) => string) => string> = {
Read: (n, t) => n === 1 ? t('toolGroup.readOne') : t('toolGroup.readMany', { count: n }),
Write: (n, t) => n === 1 ? t('toolGroup.createdOne') : t('toolGroup.createdMany', { count: n }),
Edit: (n, t) => n === 1 ? t('toolGroup.editedOne') : t('toolGroup.editedMany', { count: n }),
Bash: (n, t) => n === 1 ? t('toolGroup.ranOne') : t('toolGroup.ranMany', { count: n }),
Glob: (_n, t) => t('toolGroup.foundFiles'),
Grep: (n, t) => n === 1 ? t('toolGroup.searchedOne') : t('toolGroup.searchedMany', { count: n }),
Agent: (n, t) => n === 1 ? t('toolGroup.agentOne') : t('toolGroup.agentMany', { count: n }),
WebSearch: (_n, t) => t('toolGroup.searchedWeb'),
WebFetch: (n, t) => n === 1 ? t('toolGroup.fetchedOne') : t('toolGroup.fetchedMany', { count: n }),
}
function generateSummary(toolCalls: ToolCall[], t: (key: TranslationKey, params?: Record<string, string | number>) => string): string {
const counts = new Map<string, number>()
for (const tc of toolCalls) {
counts.set(tc.toolName, (counts.get(tc.toolName) ?? 0) + 1)
}
const parts: string[] = []
for (const [name, count] of counts) {
const verbFn = TOOL_VERBS[name]
parts.push(verbFn ? verbFn(count, t) : `${name} (${count})`)
}
return parts.join(', ')
}
function toolCallHasError(
toolCall: ToolCall,
resultMap: Map<string, ToolResult>,
childToolCallsByParent: Map<string, ToolCall[]>,
): boolean {
const result = resultMap.get(toolCall.toolUseId)
if (result?.isError) return true
return (childToolCallsByParent.get(toolCall.toolUseId) ?? []).some((childToolCall) =>
toolCallHasError(childToolCall, resultMap, childToolCallsByParent),
)
}
function groupHasErrors(
toolCalls: ToolCall[],
resultMap: Map<string, ToolResult>,
childToolCallsByParent: Map<string, ToolCall[]>,
): boolean {
return toolCalls.some((tc) => {
return toolCallHasError(tc, resultMap, childToolCallsByParent)
})
}
function isToolCallResolved(
toolCall: ToolCall,
resultMap: Map<string, ToolResult>,
childToolCallsByParent: Map<string, ToolCall[]>,
): boolean {
if (toolCall.status === 'stopped') return true
if (!resultMap.has(toolCall.toolUseId)) return false
return (childToolCallsByParent.get(toolCall.toolUseId) ?? []).every((childToolCall) =>
isToolCallResolved(childToolCall, resultMap, childToolCallsByParent),
)
}
function hasUnresolvedToolCalls(
toolCalls: ToolCall[],
resultMap: Map<string, ToolResult>,
childToolCallsByParent: Map<string, ToolCall[]>,
): boolean {
return toolCalls.some((toolCall) =>
!isToolCallResolved(toolCall, resultMap, childToolCallsByParent),
)
export type OpenAgentRunPayload = {
sessionId: string
toolUseId: string
title: string
}
export const ToolCallGroup = memo(function ToolCallGroup({
sessionId,
onOpenAgentRun,
toolCalls,
steps,
resultMap,
childToolCallsByParent,
agentTaskNotifications,
agentTaskStatuses,
activeThinkingId,
showOpenRun = true,
isStreaming,
isLive = false,
}: Props) {
const resolvedSteps = useMemo(() => steps ?? toActivitySteps(toolCalls), [steps, toolCalls])
const memoryActivity = getMemoryToolActivity(toolCalls, resultMap)
if (memoryActivity) {
const memoryToolCalls = toolCalls.filter(isMemoryToolCall)
const regularToolCalls = toolCalls.filter((toolCall) => !isMemoryToolCall(toolCall))
// Thinking stays with the remainder: the memory card is a summary of files
// touched, not a place reasoning belongs.
const regularSteps = resolvedSteps.filter(
(step) => step.kind === 'thinking' || !isMemoryToolCall(step.toolCall),
)
return (
<div className={regularToolCalls.length > 0 ? 'mb-2 space-y-2' : ''}>
<div className={regularSteps.length > 0 ? 'space-y-2' : ''}>
<MemoryToolActivityGroup
activity={memoryActivity}
toolCalls={memoryToolCalls}
@@ -180,14 +128,16 @@ export const ToolCallGroup = memo(function ToolCallGroup({
childToolCallsByParent={childToolCallsByParent}
isStreaming={isStreaming}
/>
{regularToolCalls.length > 0 ? (
{regularSteps.length > 0 ? (
<ToolCallGroupContent
sessionId={sessionId}
toolCalls={regularToolCalls}
onOpenAgentRun={onOpenAgentRun}
steps={regularSteps}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
agentTaskStatuses={agentTaskStatuses}
activeThinkingId={activeThinkingId}
showOpenRun={showOpenRun}
isStreaming={isStreaming}
/>
@@ -199,39 +149,51 @@ export const ToolCallGroup = memo(function ToolCallGroup({
return (
<ToolCallGroupContent
sessionId={sessionId}
toolCalls={toolCalls}
onOpenAgentRun={onOpenAgentRun}
steps={resolvedSteps}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
agentTaskStatuses={agentTaskStatuses}
activeThinkingId={activeThinkingId}
showOpenRun={showOpenRun}
isStreaming={isStreaming}
isLive={isLive}
/>
)
})
type ContentProps = Omit<Props, 'toolCalls' | 'steps'> & { steps: ActivityStep[] }
function ToolCallGroupContent({
sessionId,
toolCalls,
onOpenAgentRun,
steps,
resultMap,
childToolCallsByParent,
agentTaskNotifications,
agentTaskStatuses,
activeThinkingId,
showOpenRun = true,
isStreaming,
}: Props) {
isLive = false,
}: ContentProps) {
const toolCalls = activityStepToolCalls(steps)
const hasImageGeneration = toolCalls.some((toolCall) => isImageGenerationToolName(toolCall.toolName))
if (hasImageGeneration && !toolCalls.every((toolCall) => isImageGenerationToolName(toolCall.toolName))) {
const hasNonImageSteps = steps.some(
(step) => step.kind === 'thinking' || !isImageGenerationToolName(step.toolCall.toolName),
)
if (hasImageGeneration && hasNonImageSteps) {
const segments: Array<
| { kind: 'images'; toolCalls: ToolCall[] }
| { kind: 'regular'; toolCalls: ToolCall[] }
| { kind: 'regular'; steps: ActivityStep[] }
> = []
let regularToolCalls: ToolCall[] = []
let regularSteps: ActivityStep[] = []
let imageToolCalls: ToolCall[] = []
const flushRegularCalls = () => {
if (regularToolCalls.length === 0) return
segments.push({ kind: 'regular', toolCalls: regularToolCalls })
regularToolCalls = []
const flushRegularSteps = () => {
if (regularSteps.length === 0) return
segments.push({ kind: 'regular', steps: regularSteps })
regularSteps = []
}
const flushImageCalls = () => {
if (imageToolCalls.length === 0) return
@@ -239,16 +201,16 @@ function ToolCallGroupContent({
imageToolCalls = []
}
for (const toolCall of toolCalls) {
if (isImageGenerationToolName(toolCall.toolName)) {
flushRegularCalls()
imageToolCalls.push(toolCall)
for (const step of steps) {
if (step.kind === 'tool' && isImageGenerationToolName(step.toolCall.toolName)) {
flushRegularSteps()
imageToolCalls.push(step.toolCall)
} else {
flushImageCalls()
regularToolCalls.push(toolCall)
regularSteps.push(step)
}
}
flushRegularCalls()
flushRegularSteps()
flushImageCalls()
return (
@@ -262,11 +224,13 @@ function ToolCallGroupContent({
<ToolCallGroupContent
key={`regular-${index}`}
sessionId={sessionId}
toolCalls={segment.toolCalls}
onOpenAgentRun={onOpenAgentRun}
steps={segment.steps}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
agentTaskStatuses={agentTaskStatuses}
activeThinkingId={activeThinkingId}
showOpenRun={showOpenRun}
isStreaming={isStreaming}
/>
@@ -275,12 +239,27 @@ function ToolCallGroupContent({
)
}
const allAgents = toolCalls.every((toolCall) => toolCall.toolName === 'Agent')
if (toolCalls.length === 0) {
return (
<>
{steps.map((step) => step.kind === 'thinking' ? (
<ThinkingBlock
key={step.message.id}
content={step.message.content}
isActive={step.message.id === activeThinkingId}
/>
) : null)}
</>
)
}
const allAgents = toolCalls.length > 0 && toolCalls.every((toolCall) => toolCall.toolName === 'Agent')
if (allAgents) {
return (
<AgentToolGroup
sessionId={sessionId}
onOpenAgentRun={onOpenAgentRun}
toolCalls={toolCalls}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
@@ -298,25 +277,14 @@ function ToolCallGroupContent({
)
}
// Single tool call — render directly without group wrapper
if (toolCalls.length === 1) {
const tc = toolCalls[0]!
return (
<ToolCallTree
toolCall={tc}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
/>
)
}
return (
<ToolCallGroupMulti
toolCalls={toolCalls}
<ActivityGroup
steps={steps}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
activeThinkingId={activeThinkingId}
isStreaming={isStreaming}
isLive={isLive}
/>
)
}
@@ -344,13 +312,15 @@ function MemoryToolActivityGroup({
const hiddenCount = Math.max(0, activity.files.length - visibleFiles.length)
return (
<div className="mb-2">
<div>
<div
data-testid="memory-tool-activity-card"
className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-memory-border)] bg-[var(--color-memory-surface)]"
>
<button
type="button"
data-chat-disclosure="true"
aria-expanded={expanded}
onClick={toggleExpanded}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
@@ -442,6 +412,7 @@ function MemoryToolActivityGroup({
function AgentToolGroup({
sessionId,
onOpenAgentRun,
toolCalls,
resultMap,
childToolCallsByParent,
@@ -466,9 +437,11 @@ function AgentToolGroup({
const anyStopped = statuses.some((status) => status === 'stopped')
return (
<div className="mb-2 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<button
type="button"
data-chat-disclosure="true"
aria-expanded={expanded}
onClick={toggleExpanded}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
@@ -498,25 +471,20 @@ function AgentToolGroup({
</button>
{expanded && (
<div className="relative border-t border-[var(--color-border)] py-3 pl-5 pr-3.5">
<div className="absolute bottom-6 left-[11px] top-4 w-px rounded-full bg-[var(--color-border)]" />
<div className="space-y-2">
<div className="px-3.5 pb-2.5 pt-0.5">
<div className="ml-1.5 flex flex-col border-l border-[var(--color-border)] pl-4">
{toolCalls.map((toolCall) => (
<div key={toolCall.id} className="relative pl-7">
<div className="absolute left-0 top-1/2 -translate-y-1/2">
<div className="absolute left-[11px] top-1/2 h-px w-4 -translate-y-1/2 bg-[var(--color-border)]" />
<div className="absolute left-[8px] top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[0_0_0_2px_var(--color-surface)]" />
</div>
<AgentCallCard
sessionId={sessionId}
toolCall={toolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotification={agentTaskNotifications[toolCall.toolUseId]}
agentTaskStatus={agentTaskStatuses?.[toolCall.toolUseId]}
showOpenRun={showOpenRun}
/>
</div>
<AgentCallCard
key={toolCall.id}
sessionId={sessionId}
onOpenAgentRun={onOpenAgentRun}
toolCall={toolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotification={agentTaskNotifications[toolCall.toolUseId]}
agentTaskStatus={agentTaskStatuses?.[toolCall.toolUseId]}
showOpenRun={showOpenRun}
/>
))}
</div>
</div>
@@ -525,58 +493,9 @@ function AgentToolGroup({
)
}
/** Separated so the useState hook is never called conditionally. */
function ToolCallGroupMulti({ toolCalls, resultMap, childToolCallsByParent, isStreaming }: Props) {
const { expanded, toggleExpanded } = useExpandableCardState()
const t = useTranslation()
const summary = generateSummary(toolCalls, t)
const errorPresent = groupHasErrors(toolCalls, resultMap, childToolCallsByParent)
const hasUnresolvedTools = hasUnresolvedToolCalls(toolCalls, resultMap, childToolCallsByParent)
const isRunning = !!isStreaming || hasUnresolvedTools
return (
<div className="mb-2 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<button
type="button"
onClick={toggleExpanded}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="shrink-0 text-[11px] leading-none text-[var(--color-text-tertiary)]" aria-hidden="true">
{expanded ? '▾' : '▸'}
</span>
<span className="flex-1 truncate text-[14px] font-semibold text-[var(--color-text-primary)]">
{summary}
</span>
{!isRunning && !errorPresent && (
<CircleCheck size={19} strokeWidth={1.6} className="shrink-0 text-[var(--color-success)]" aria-hidden="true" />
)}
{!isRunning && errorPresent && (
<span className="material-symbols-outlined shrink-0 text-[17px] text-[var(--color-error)]">error</span>
)}
{isRunning && <StatusDot tone="brand" pulse />}
</button>
{expanded && (
<div className="flex flex-col gap-2.5 border-t border-[var(--color-border)] px-3.5 py-2.5">
{toolCalls.map((tc) => {
return (
<ToolCallTree
key={tc.id}
toolCall={tc}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
compact
/>
)
})}
</div>
)}
</div>
)
}
function AgentCallCard({
sessionId,
onOpenAgentRun,
toolCall,
resultMap,
childToolCallsByParent,
@@ -585,6 +504,7 @@ function AgentCallCard({
showOpenRun = true,
}: {
sessionId?: string | null
onOpenAgentRun?: (payload: OpenAgentRunPayload) => void
toolCall: ToolCall
resultMap: Map<string, ToolResult>
childToolCallsByParent: Map<string, ToolCall[]>
@@ -632,8 +552,8 @@ function AgentCallCard({
const canOpenRun = showOpenRun && !!sessionId && !!toolCall.toolUseId
return (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<div className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)]">
<div data-agent-call-layout="row">
<div className="-mx-2 flex w-[calc(100%+1rem)] items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]">
<span className="material-symbols-outlined text-[18px] text-[var(--color-outline)]">smart_toy</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
@@ -687,6 +607,14 @@ function AgentCallCard({
aria-label={t('toolGroup.openRunNamed', { title: openRunTitle })}
onClick={(event) => {
event.stopPropagation()
if (onOpenAgentRun) {
onOpenAgentRun({
sessionId,
toolUseId: toolCall.toolUseId,
title: openRunTitle,
})
return
}
useTabStore.getState().openSubagentTab(sessionId, toolCall.toolUseId, openRunTitle)
}}
className="shrink-0 border border-[var(--color-border)]"
@@ -713,14 +641,14 @@ function AgentCallCard({
</div>
{expanded && (
<div className="border-t border-[var(--color-border)] px-3 py-3">
<div className="mb-2 ml-2 mt-1 border-l border-[var(--color-border)] py-1 pl-3">
{errorText && (
<div className="mb-3 rounded-[var(--radius-lg)] border border-[var(--color-error)] bg-[var(--color-error-container)] px-3 py-2 text-[11px] text-[var(--color-on-error-container)]">
<div className="mb-2 bg-[var(--color-error-soft)] px-3 py-2 text-[11px] text-[var(--color-error)]">
{errorText}
</div>
)}
{childToolCalls.length > 0 ? (
<div className="space-y-1">
<div className="space-y-0.5">
{childToolCalls.map((childToolCall) => (
<ToolCallTree
key={childToolCall.id}
@@ -728,15 +656,16 @@ function AgentCallCard({
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
compact
chrome="row"
/>
))}
</div>
) : outputSummary ? (
<div className="text-[11px] text-[var(--color-text-tertiary)]">
<div className="px-2 py-1 text-[11px] text-[var(--color-text-tertiary)]">
{t('agentStatus.noActivity')}
</div>
) : (
<div className="text-[11px] text-[var(--color-text-tertiary)]">
<div className="px-2 py-1 text-[11px] text-[var(--color-text-tertiary)]">
{status === 'starting' ? t('agentStatus.starting') : t('agentStatus.noActivity')}
</div>
)}
@@ -761,30 +690,40 @@ function ToolCallTree({
resultMap,
childToolCallsByParent,
compact = false,
chrome = 'card',
}: {
toolCall: ToolCall
resultMap: Map<string, ToolResult>
childToolCallsByParent: Map<string, ToolCall[]>
compact?: boolean
chrome?: ToolCallChrome
}) {
const result = resultMap.get(toolCall.toolUseId)
const childToolCalls = childToolCallsByParent.get(toolCall.toolUseId) ?? []
const isRow = chrome === 'row'
return (
<div className={compact ? 'space-y-1' : ''}>
<div className={!isRow && compact ? 'space-y-1' : ''}>
<ToolCallBlock
toolName={toolCall.toolName}
input={toolCall.input}
result={result ? { content: result.content, isError: result.isError } : null}
compact={compact}
chrome={chrome}
isPending={toolCall.isPending}
status={toolCall.status}
partialInput={toolCall.partialInput}
durationMs={toolCallDurationMs(toolCall, result)}
/>
{childToolCalls.length > 0 && (
<div className={compact ? 'ml-4 border-l border-[var(--color-border)] pl-3' : 'mb-2 ml-16 border-l border-[var(--color-border)] pl-3'}>
<div className="space-y-1">
<div className={
isRow
? 'ml-2 border-l border-[var(--color-border)] pl-3'
: compact
? 'ml-4 border-l border-[var(--color-border)] pl-3'
: 'mb-2 ml-16 border-l border-[var(--color-border)] pl-3'
}>
<div className={isRow ? 'space-y-0.5' : 'space-y-1'}>
{childToolCalls.map((childToolCall) => (
<ToolCallTree
key={childToolCall.id}
@@ -792,6 +731,7 @@ function ToolCallTree({
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
compact
chrome={chrome}
/>
))}
</div>
@@ -27,7 +27,7 @@ export const ToolResultBlock = memo(function ToolResultBlock({ content, isError,
const hasMore = text.length > 200
return (
<div className={`mb-2 overflow-hidden rounded-[var(--radius-lg)] border ${
<div className={`overflow-hidden rounded-[var(--radius-lg)] border ${
isError
? 'border-[var(--color-error)]'
: 'border-[var(--color-border)]'
@@ -11,10 +11,10 @@ describe('TurnCompletionStamp', () => {
useSettingsStore.setState({ locale: 'en' })
})
it('reads as an end time plus how long the turn took', () => {
it('reads as a compact clock time plus how long the turn took', () => {
render(<TurnCompletionStamp completion={{ completedAt: COMPLETED_AT, durationMs: 739_000 }} />)
const stamp = screen.getByText(`Done ${formatMessageHoverTime(COMPLETED_AT, 'en')}`)
const stamp = screen.getByText(formatMessageHoverTime(COMPLETED_AT, 'en'))
expect(stamp).toBeTruthy()
expect(screen.getByText('took 12m 19s')).toBeTruthy()
})
@@ -22,14 +22,14 @@ describe('TurnCompletionStamp', () => {
it('shows the end time alone when the duration is not trustworthy', () => {
const { container } = render(<TurnCompletionStamp completion={{ completedAt: COMPLETED_AT }} />)
expect(screen.getByText(`Done ${formatMessageHoverTime(COMPLETED_AT, 'en')}`)).toBeTruthy()
expect(screen.getByText(formatMessageHoverTime(COMPLETED_AT, 'en'))).toBeTruthy()
expect(container.querySelector('[data-turn-completion-duration]')).toBeNull()
})
it('carries the exact timestamp as a title for the rounded clock label', () => {
render(<TurnCompletionStamp completion={{ completedAt: COMPLETED_AT, durationMs: 1_000 }} />)
expect(screen.getByText(`Done ${formatMessageHoverTime(COMPLETED_AT, 'en')}`).getAttribute('title')).toBe(
expect(screen.getByText(formatMessageHoverTime(COMPLETED_AT, 'en')).getAttribute('title')).toBe(
formatExactMessageTimestamp(COMPLETED_AT, 'en'),
)
})
@@ -38,7 +38,6 @@ describe('TurnCompletionStamp', () => {
useSettingsStore.setState({ locale: 'zh' })
const { container } = render(<TurnCompletionStamp completion={{ completedAt: COMPLETED_AT, durationMs: 739_000 }} />)
expect(container.textContent).toContain('完成于')
expect(container.textContent).toContain('耗时 12 分 19 秒')
})
@@ -48,10 +47,11 @@ describe('TurnCompletionStamp', () => {
expect(container.firstElementChild).toBeNull()
})
it('stays outside the hover-gated action bar styling', () => {
// The whole point of the stamp is that it survives without a pointer.
it('does not add a second row or its own hover gate', () => {
const { container } = render(<TurnCompletionStamp completion={{ completedAt: COMPLETED_AT }} />)
expect(container.firstElementChild?.tagName).toBe('SPAN')
expect(container.firstElementChild?.className).not.toContain('mt-')
expect(container.firstElementChild?.className).not.toContain('opacity-0')
expect(container.firstElementChild?.className).not.toContain('group-hover')
})

Some files were not shown because too many files have changed in this diff Show More