diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 919f6039..1b067ea5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index eabde791..97e4cf90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/README.md b/README.md index aeaa7667..efb257c3 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ -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.

Desktop Preview · Install · Highlights · More Docs · Sponsorship · User Group @@ -63,16 +63,6 @@ This project is maintained in the author's spare time. Corporate or individual s - - - - TeamoRouter - - - - Thanks to TeamoRouter 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 this link get 10% off their first top-up. - - @@ -103,6 +93,17 @@ This project is maintained in the author's spare time. Corporate or individual s Thanks to Qiniu Cloud AI 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 this link gets enterprise users 12M free tokens and developers 3M free tokens. + + + + Atlas Cloud + Atlas Cloud + + + + Thanks to Atlas Cloud 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 coding plan promotion for more budget-friendly API access. + + @@ -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). diff --git a/README.zh-CN.md b/README.zh-CN.md index 2e387793..562e30e6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -22,7 +22,7 @@ -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 里。

桌面端预览 · 安装桌面端 · 桌面端亮点 · 更多文档 · 赞助与合作 · 用户交流群 @@ -63,16 +63,6 @@ Claude Code Haha 是一个**桌面端 Claude Code 工作台**:多会话与全 - - - - TeamoRouter - - - - 感谢 TeamoRouter 赞助本项目!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 一键接入。新用户通过 专属链接 注册,首次充值可享 10% 折扣。 - - @@ -103,6 +93,17 @@ Claude Code Haha 是一个**桌面端 Claude Code 工作台**:多会话与全 感谢 七牛云 AI 赞助本项目!七牛云 AI 是七牛云(02567.HK)旗下企业级大模型 MaaS 平台,一站式调用全球 150+ 主流模型,兼容全球主流模型厂商协议,覆盖文本、图像、音频、视频、文件处理等全模态处理能力,服务超过 169 万企业及开发者用户。作为 cc-haha 用户专属福利,通过 专属链接 注册,企业用户免费领 1200 万 Token,开发者免费领 300 万 Token。 + + + + Atlas Cloud + Atlas Cloud + + + + 感谢 Atlas Cloud 赞助本项目!Atlas Cloud 是一个全模态 AI 推理平台,让开发者通过统一的 AI API 访问视频生成、图像生成和 LLM API,无需分别维护多个厂商集成,即可调用 300+ 精选模型。cc-haha 已内置 Atlas Cloud 供应商预设,在设置里选择后填入 API Key 即可直接使用。Atlas Cloud 最新推出 coding plan 优惠,为开发者提供更具性价比的 API 访问预算。 + + @@ -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 在授权后截图、点击、输入并控制桌面应用。 - **桌面宠物**:搭搭、弧弧、补补、回回随任务状态换动作,也能自己做一只(默认关闭)。 diff --git a/adapters/common/__tests__/adapter-client.test.ts b/adapters/common/__tests__/adapter-client.test.ts new file mode 100644 index 00000000..ea2eef5d --- /dev/null +++ b/adapters/common/__tests__/adapter-client.test.ts @@ -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): ReturnType { + 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 { + 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/) + }) + } +}) diff --git a/adapters/common/__tests__/config.test.ts b/adapters/common/__tests__/config.test.ts index 3f8d0f20..0b3be66d 100644 --- a/adapters/common/__tests__/config.test.ts +++ b/adapters/common/__tests__/config.test.ts @@ -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(file: Record, 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] diff --git a/adapters/common/__tests__/http-client.test.ts b/adapters/common/__tests__/http-client.test.ts index d2ab0b17..13f647ac 100644 --- a/adapters/common/__tests__/http-client.test.ts +++ b/adapters/common/__tests__/http-client.test.ts @@ -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-')) diff --git a/adapters/common/__tests__/project-selection-flow.test.ts b/adapters/common/__tests__/project-selection-flow.test.ts new file mode 100644 index 00000000..9f2abaa0 --- /dev/null +++ b/adapters/common/__tests__/project-selection-flow.test.ts @@ -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 }) + } + }) +}) diff --git a/adapters/common/adapter-client.ts b/adapters/common/adapter-client.ts new file mode 100644 index 00000000..adfcd390 --- /dev/null +++ b/adapters/common/adapter-client.ts @@ -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, + } +} diff --git a/adapters/common/config.ts b/adapters/common/config.ts index 5b095a7b..81f819bf 100644 --- a/adapters/common/config.ts +++ b/adapters/common/config.ts @@ -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() + 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 diff --git a/adapters/common/http-client.ts b/adapters/common/http-client.ts index 6984ba7e..52f5ca85 100644 --- a/adapters/common/http-client.ts +++ b/adapters/common/http-client.ts @@ -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 => diff --git a/adapters/common/project-selection-router.ts b/adapters/common/project-selection-router.ts new file mode 100644 index 00000000..aaa69f70 --- /dev/null +++ b/adapters/common/project-selection-router.ts @@ -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 + defaultWorkDir: string + prepareNewSession: (chatId: string) => void | Promise + createSession: (chatId: string, workDir: string) => Promise +} + +const NON_PROJECT_COMMANDS = new Set([ + '/help', + '帮助', + '/status', + '状态', + '/clear', + '清空', + '/stop', + '停止', + '/projects', + '项目列表', +]) + +export class ProjectSelectionRouter { + private readonly pendingChats = new Set() + + 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 { + 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 { + 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}` + } +} diff --git a/adapters/dingtalk/index.ts b/adapters/dingtalk/index.ts index c74f15ca..f90e9098 100644 --- a/adapters/dingtalk/index.ts +++ b/adapters/dingtalk/index.ts @@ -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() -const pendingProjectSelection = new Map() +const projectSelectionController = new ProjectSelectionController({ + httpClient, + defaultWorkDir, + prepareNewSession, + createSession: createSessionForChat, +}) const runtimeStates = new Map() const aiCardBuffers = new Map() const aiCardTargets = new Map() @@ -323,47 +332,22 @@ function formatProjectList(projects: RecentProject[]): string { async function showProjectPicker(chatId: string): Promise { 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 { +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 { @@ -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 === '帮助')) { diff --git a/adapters/feishu/index.ts b/adapters/feishu/index.ts index 566fe338..9512281a 100644 --- a/adapters/feishu/index.ts +++ b/adapters/feishu/index.ts @@ -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() -const pendingProjectSelection = new Map() +const projectSelectionController = new ProjectSelectionController({ + httpClient, + defaultWorkDir, + prepareNewSession, + createSession: createSessionForChat, +}) const runtimeStates = new Map() const pendingPermissions = new Map>() @@ -661,13 +670,12 @@ async function createSessionForChat(chatId: string, workDir: string): Promise { 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 { } } -async function startNewSession(chatId: string, query?: string): Promise { +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 { } 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 { 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 { 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 { 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) { diff --git a/adapters/telegram/index.ts b/adapters/telegram/index.ts index cba2ed8e..4dd81779 100644 --- a/adapters/telegram/index.ts +++ b/adapters/telegram/index.ts @@ -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) => { diff --git a/adapters/wechat/index.ts b/adapters/wechat/index.ts index 16dfe468..15db8417 100644 --- a/adapters/wechat/index.ts +++ b/adapters/wechat/index.ts @@ -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() diff --git a/adapters/whatsapp/index.ts b/adapters/whatsapp/index.ts index 42e7ece6..d1d07065 100644 --- a/adapters/whatsapp/index.ts +++ b/adapters/whatsapp/index.ts @@ -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) diff --git a/bun.lock b/bun.lock index 8f4c5574..7e4ee14a 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/desktop/electron/services/petWindow.test.ts b/desktop/electron/services/petWindow.test.ts index 24f6f039..27853ae0 100644 --- a/desktop/electron/services/petWindow.test.ts +++ b/desktop/electron/services/petWindow.test.ts @@ -658,7 +658,10 @@ describe('Electron pet window service', () => { }, } - function panelController(petWindow: ReturnType) { + function panelController( + petWindow: ReturnType, + 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 | 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', () => { diff --git a/desktop/electron/services/petWindow.ts b/desktop/electron/services/petWindow.ts index 5a21e3f0..89c465c2 100644 --- a/desktop/electron/services/petWindow.ts +++ b/desktop/electron/services/petWindow.ts @@ -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) } diff --git a/desktop/electron/services/serverRuntime.test.ts b/desktop/electron/services/serverRuntime.test.ts index 0f792978..e3f35163 100644 --- a/desktop/electron/services/serverRuntime.test.ts +++ b/desktop/electron/services/serverRuntime.test.ts @@ -44,7 +44,9 @@ function createRuntime(options: { appRoot?: string diagnosticsFile?: string env?: NodeJS.ProcessEnv + now?: () => number resolveSystemProxy?: (url: string) => Promise + sleep?: (delayMs: number) => Promise 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 { expect(sidecarMocks.serverChildren).toHaveLength(count) } +async function waitForMockCalls(mock: ReturnType, count: number): Promise { + 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(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(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) diff --git a/desktop/electron/services/serverRuntime.ts b/desktop/electron/services/serverRuntime.ts index 79eabd70..c6a991d6 100644 --- a/desktop/electron/services/serverRuntime.ts +++ b/desktop/electron/services/serverRuntime.ts @@ -43,8 +43,10 @@ type ServerRuntimeOptions = { type ServerRuntimeDeps = { appendHostDiagnostic: typeof appendHostDiagnostic + now: () => number preferredServerPorts: typeof preferredServerPorts reserveServerPort: typeof reserveServerPort + sleep: (delayMs: number) => Promise 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 | null = null private lifecycleGeneration = 0 private startingServer: ServerStartState | null = null @@ -132,10 +145,12 @@ export class ElectronServerRuntime { async startServer(): Promise { 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 { 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 { + if (delayMs > 0) await this.deps.sleep(delayMs) + this.assertCurrentGeneration(generation) + return await this.startServerOnce(generation) + } + private async startServerOnce(generation: number): Promise { // 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) { diff --git a/desktop/package.json b/desktop/package.json index 2a392c0f..9fbca540 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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": { diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 784cd25c..7ec0013e 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -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() + 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() + + 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() @@ -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() + 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"') diff --git a/desktop/src/__tests__/worktreeMetadataLifecycle.test.tsx b/desktop/src/__tests__/worktreeMetadataLifecycle.test.tsx new file mode 100644 index 00000000..93eb734d --- /dev/null +++ b/desktop/src/__tests__/worktreeMetadataLifecycle.test.tsx @@ -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>, + materialized: false, +})) + +vi.mock('../api/sessions', async (importOriginal) => { + const actual = await importOriginal() + 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: () =>

, +})) +vi.mock('../components/controls/PermissionModeSelector', () => ({ + PermissionModeSelector: () => , +})) +vi.mock('../components/controls/ModelSelector', async () => { + const React = await vi.importActual('react') + return { + ModelSelector: React.forwardRef(() => ), + } +}) + +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() + + 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() + }) + }) +}) diff --git a/desktop/src/api/agents.test.ts b/desktop/src/api/agents.test.ts index 415ab20b..2df6f1fa 100644 --- a/desktop/src/api/agents.test.ts +++ b/desktop/src/api/agents.test.ts @@ -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?') diff --git a/desktop/src/api/agents.ts b/desktop/src/api/agents.ts index 05c00d50..ae638c1d 100644 --- a/desktop/src/api/agents.ts +++ b/desktop/src/api/agents.ts @@ -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(`/api/agents/${encodeURIComponent(name)}?${query.toString()}`) }, + setOverride: (name: string, input: AgentOverrideInput) => + api.put( + `/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( + `/api/agents/${encodeURIComponent(name)}/override${suffix}`, + ) + }, reload: (sessionId: string) => api.post( `/api/agents/reload?sessionId=${encodeURIComponent(sessionId)}`, diff --git a/desktop/src/api/client.test.ts b/desktop/src/api/client.test.ts index 90f63f89..2f3a650d 100644 --- a/desktop/src/api/client.test.ts +++ b/desktop/src/api/client.test.ts @@ -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(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((_, 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 () => { diff --git a/desktop/src/api/client.ts b/desktop/src/api/client.ts index 52e4e2e4..7e5de92e 100644 --- a/desktop/src/api/client.ts +++ b/desktop/src/api/client.ts @@ -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 | 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(method: string, path: string, body?: unknown, options?: ApiRequestOptions): Promise { - const url = `${baseUrl}${path}` const headers = buildHeaders() const controller = new AbortController() @@ -89,12 +91,27 @@ async function request(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(method: string, path: string, body?: unknown, options? } } +async function recoverDesktopServerUrl(): Promise { + 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 diff --git a/desktop/src/api/desktopUiPreferences.test.ts b/desktop/src/api/desktopUiPreferences.test.ts index b5e283f9..7815c4a7 100644 --- a/desktop/src/api/desktopUiPreferences.test.ts +++ b/desktop/src/api/desktopUiPreferences.test.ts @@ -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') diff --git a/desktop/src/api/desktopUiPreferences.ts b/desktop/src/api/desktopUiPreferences.ts index 08af748c..966e454f 100644 --- a/desktop/src/api/desktopUiPreferences.ts +++ b/desktop/src/api/desktopUiPreferences.ts @@ -30,6 +30,7 @@ export type DesktopUiPreferences = { sidebar: SidebarProjectPreferences profile: DesktopProfilePreferences pet: DesktopPetPreferences + projectDisplayNames: Record } 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('/api/desktop-ui/preferences') @@ -62,6 +69,13 @@ export const desktopUiPreferencesApi = { ) }, + updateProjectDisplayName(projectKey: string, displayName: string | null) { + return api.put( + '/api/desktop-ui/preferences/project-display-name', + { projectKey, displayName }, + ) + }, + updateProfilePreferences(profile: Pick) { return api.put<{ ok: true; preferences: DesktopUiPreferences }>( '/api/desktop-ui/preferences/profile', diff --git a/desktop/src/api/openTargets.test.ts b/desktop/src/api/openTargets.test.ts index fce73948..14cfed10 100644 --- a/desktop/src/api/openTargets.test.ts +++ b/desktop/src/api/openTargets.test.ts @@ -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 ``, 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), + ) + }) }) diff --git a/desktop/src/api/openTargets.ts b/desktop/src/api/openTargets.ts index 4cce684a..3dc6d69e 100644 --- a/desktop/src/api/openTargets.ts +++ b/desktop/src/api/openTargets.ts @@ -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 ``. */ 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('/api/open-targets')) + return api.get('/api/open-targets') + }, + async listForPath(path: string) { + return api.get(`/api/open-targets?path=${encodeURIComponent(path)}`) }, open(targetId: string, path: string) { return api.post('/api/open-targets/open', { targetId, path }) diff --git a/desktop/src/api/sessions.test.ts b/desktop/src/api/sessions.test.ts index 5c660166..b17959f8 100644 --- a/desktop/src/api/sessions.test.ts +++ b/desktop/src/api/sessions.test.ts @@ -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((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({ diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index 4e1015e8..82ee6f1c 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -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>() + +function getSessionGitInfo(sessionId: string) { + const pending = gitInfoRequests.get(sessionId) + if (pending) return pending + + const request = api.get(`/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(`/api/sessions/repository-context?${query.toString()}`) }, - getGitInfo(sessionId: string) { - return api.get(`/api/sessions/${sessionId}/git-info`) + createRepositoryBranch(body: CreateRepositoryBranchRequest) { + return api.post('/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(buildWorkspacePath(sessionId, 'diff', workspacePath)) }, - getTurnCheckpoints(sessionId: string) { - return api.get(`/api/sessions/${sessionId}/turn-checkpoints`) + getTurnCheckpoints(sessionId: string, options?: ApiRequestOptions) { + return api.get( + `/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(`/api/sessions/${sessionId}/rewind`, body, { timeout: 60_000, diff --git a/desktop/src/api/subagents.test.ts b/desktop/src/api/subagents.test.ts index 802b6a1e..351dd0b9 100644 --- a/desktop/src/api/subagents.test.ts +++ b/desktop/src/api/subagents.test.ts @@ -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) + }) }) diff --git a/desktop/src/api/subagents.ts b/desktop/src/api/subagents.ts index 96164880..bb971ef4 100644 --- a/desktop/src/api/subagents.ts +++ b/desktop/src/api/subagents.ts @@ -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( + `/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( `/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 }, + ) + }, } diff --git a/desktop/src/api/teams.ts b/desktop/src/api/teams.ts index ec7c668b..88560afa 100644 --- a/desktop/src/api/teams.ts +++ b/desktop/src/api/teams.ts @@ -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(`/api/teams/${encodeURIComponent(name)}`) }, + getWorkbench(name: string) { + return api.get( + `/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( + `/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) { diff --git a/desktop/src/api/workflows.ts b/desktop/src/api/workflows.ts new file mode 100644 index 00000000..153919b2 --- /dev/null +++ b/desktop/src/api/workflows.ts @@ -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( + `/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( + `/api/workflows/runs/${encodeURIComponent(sessionId)}/${encodeURIComponent(runId)}`, + ) + }, + + validate(script: string) { + return api.post('/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()}`, + ) + }, +} diff --git a/desktop/src/assets/agent-teams/data-analyst.png b/desktop/src/assets/agent-teams/data-analyst.png new file mode 100644 index 00000000..1a8a98e2 Binary files /dev/null and b/desktop/src/assets/agent-teams/data-analyst.png differ diff --git a/desktop/src/assets/agent-teams/docs-coordinator.png b/desktop/src/assets/agent-teams/docs-coordinator.png new file mode 100644 index 00000000..1b728ee6 Binary files /dev/null and b/desktop/src/assets/agent-teams/docs-coordinator.png differ diff --git a/desktop/src/assets/agent-teams/qa-engineer.png b/desktop/src/assets/agent-teams/qa-engineer.png new file mode 100644 index 00000000..7f9f6885 Binary files /dev/null and b/desktop/src/assets/agent-teams/qa-engineer.png differ diff --git a/desktop/src/assets/agent-teams/release-engineer.png b/desktop/src/assets/agent-teams/release-engineer.png new file mode 100644 index 00000000..4d8e20ca Binary files /dev/null and b/desktop/src/assets/agent-teams/release-engineer.png differ diff --git a/desktop/src/assets/agent-teams/security-reviewer.png b/desktop/src/assets/agent-teams/security-reviewer.png new file mode 100644 index 00000000..45df2e62 Binary files /dev/null and b/desktop/src/assets/agent-teams/security-reviewer.png differ diff --git a/desktop/src/assets/agent-teams/server-engineer.png b/desktop/src/assets/agent-teams/server-engineer.png new file mode 100644 index 00000000..12df48af Binary files /dev/null and b/desktop/src/assets/agent-teams/server-engineer.png differ diff --git a/desktop/src/assets/agent-teams/team-lead.png b/desktop/src/assets/agent-teams/team-lead.png new file mode 100644 index 00000000..ed21ec53 Binary files /dev/null and b/desktop/src/assets/agent-teams/team-lead.png differ diff --git a/desktop/src/assets/agent-teams/ui-designer.png b/desktop/src/assets/agent-teams/ui-designer.png new file mode 100644 index 00000000..2b3d7952 Binary files /dev/null and b/desktop/src/assets/agent-teams/ui-designer.png differ diff --git a/desktop/src/components/activity/SessionActivityPanel.test.tsx b/desktop/src/components/activity/SessionActivityPanel.test.tsx index f24518b9..ec84458b 100644 --- a/desktop/src/components/activity/SessionActivityPanel.test.tsx +++ b/desktop/src/components/activity/SessionActivityPanel.test.tsx @@ -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 = {}): 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 = {}): SessionActivityMo describe('SessionActivityPanel', () => { afterEach(cleanup) + it('tints the row icon tile by status so a one-line row still states what happened', () => { + render( + , + ) + + 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( { 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( + , + ) + + 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( { expect(onOpenSubagent).not.toHaveBeenCalled() }) + it('renders a workflow as phase headers with their agents, each opening the subagent page', () => { + const onOpenSubagent = vi.fn() + render( + , + ) + + // 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( + , + ) + + expect(screen.getByText('Cached')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /open run a · cached/i })).toBeInTheDocument() + }) + it('does not render when closed', () => { render() diff --git a/desktop/src/components/activity/SessionActivityPanel.tsx b/desktop/src/components/activity/SessionActivityPanel.tsx index d1f5b652..8287ea41 100644 --- a/desktop/src/components/activity/SessionActivityPanel.tsx +++ b/desktop/src/components/activity/SessionActivityPanel.tsx @@ -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 } 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 ( - + ) @@ -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 ( +
+ + {label} + +
+ ) +} + 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 ( + + ) + } 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({ )} {label} {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 ? ( {content} @@ -450,7 +556,7 @@ function ActivityRowView({ if (stopButton) { return (
-
+
{content}
{stopButton} @@ -459,7 +565,7 @@ function ActivityRowView({ } return ( -
+
{content}
) @@ -495,7 +601,7 @@ function BackgroundTaskDetail({ row }: { row: ActivityRow }) { if (details.length === 0) return null return ( -
+
{t('session.activity.details.title')}
@@ -634,9 +740,9 @@ export function SessionActivityPanel({ aria-label={sectionTitle} className={index > 0 ? 'border-t border-[var(--color-border)] pt-3' : undefined} > -
+
-

+

{sectionTitle}

{section.rows.length > 0 ? ( diff --git a/desktop/src/components/activity/sessionActivityModel.test.ts b/desktop/src/components/activity/sessionActivityModel.test.ts index ccb821a2..a8ec0ec0 100644 --- a/desktop/src/components/activity/sessionActivityModel.test.ts +++ b/desktop/src/components/activity/sessionActivityModel.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + buildMainSessionActivityModel, buildSessionActivityModel, getVisibleActivitySections, hasVisibleSessionActivity, @@ -165,10 +166,11 @@ describe('buildSessionActivityModel', () => { }) expect(hasVisibleSessionActivity(model)).toBe(true) + // Plan, then the agents working it, then the processes it left running. expect(getVisibleActivitySections(model).map((section) => section.id)).toEqual([ 'tasks', - 'backgroundTasks', 'subagents', + 'backgroundTasks', ]) expect(model.badgeCount).toBe(3) }) @@ -390,6 +392,626 @@ describe('buildSessionActivityModel', () => { expect(model.badgeCount).toBe(1) }) + it('puts Team member spawns in Team while preserving direct Agent calls in SubAgents', () => { + const model = buildSessionActivityModel({ + sessionId: 'session-1', + messages: [ + { + id: 'team-agent-tool', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'team-agent-tool', + input: { + description: '分析桌面端 UI 变更', + name: 'desktop-analyzer', + team_name: 'v053-release-audit', + }, + timestamp: 1000, + }, + { + id: 'team-agent-result', + type: 'tool_result', + toolUseId: 'team-agent-tool', + content: 'Spawned successfully.', + isError: false, + timestamp: 1001, + }, + { + id: 'direct-agent-tool', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'direct-agent-tool', + input: { description: '检查普通 SubAgent 路径' }, + timestamp: 1002, + }, + { + id: 'blank-team-agent-tool', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'blank-team-agent-tool', + input: { description: '检查空 Team 名路径', team_name: ' ' }, + timestamp: 1003, + }, + { + id: 'unnamed-team-agent-tool', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'unnamed-team-agent-tool', + input: { description: '检查未命名普通 SubAgent 路径', team_name: 'v053-release-audit' }, + timestamp: 1004, + }, + ], + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(model.sections.subagents.rows).toEqual([ + expect.objectContaining({ id: 'direct-agent-tool', label: '检查普通 SubAgent 路径' }), + expect.objectContaining({ id: 'blank-team-agent-tool', label: '检查空 Team 名路径' }), + expect.objectContaining({ id: 'unnamed-team-agent-tool', label: '检查未命名普通 SubAgent 路径' }), + ]) + expect(model.sections.team.rows).toEqual([ + expect.objectContaining({ + id: 'team-agent-tool', + label: 'desktop-analyzer', + section: 'team', + status: 'running', + teamName: 'v053-release-audit', + teamMemberName: 'desktop-analyzer', + }), + ]) + expect(model.badgeCount).toBe(4) + }) + + it('keeps Team launch rows out of main Activity without hiding direct SubAgents', () => { + const teamLaunch: UIMessage = { + id: 'team-agent-tool', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'team-agent-tool', + input: { + description: '分析桌面端 UI 变更', + name: 'desktop-analyzer', + team_name: 'v053-release-audit', + }, + timestamp: 1000, + } + const directLaunch: UIMessage = { + id: 'direct-agent-tool', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'direct-agent-tool', + input: { description: '检查普通 SubAgent 路径' }, + timestamp: 1001, + } + const buildMainModel = (messages: UIMessage[]) => buildMainSessionActivityModel({ + sessionId: 'session-1', + messages, + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + const teamOnly = buildMainModel([teamLaunch]) + expect(hasVisibleSessionActivity(teamOnly)).toBe(false) + expect(teamOnly.sections.team.rows).toEqual([]) + + const guardedTeamInputs = buildSessionActivityModel({ + sessionId: 'session-1', + messages: [teamLaunch], + tasks: [], + teamTasks: [task({ id: 'team-task', subject: 'Shared Team DAG' })], + teamMembers: [{ agentId: 'desktop-analyzer@v053-release-audit', role: 'reviewer', status: 'running' }], + includeTeamActivity: false, + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + expect(hasVisibleSessionActivity(guardedTeamInputs)).toBe(false) + expect(guardedTeamInputs.sections.tasks.rows).toEqual([]) + expect(guardedTeamInputs.sections.team.rows).toEqual([]) + + const withDirectSubagent = buildMainModel([teamLaunch, directLaunch]) + expect(getVisibleActivitySections(withDirectSubagent).map(section => section.id)).toEqual(['subagents']) + expect(withDirectSubagent.sections.subagents.rows).toEqual([ + expect.objectContaining({ id: 'direct-agent-tool', label: '检查普通 SubAgent 路径' }), + ]) + }) + + it('waits for a pending Agent input to finish before projecting an unknown owner', () => { + const buildMainModel = (message: UIMessage) => buildMainSessionActivityModel({ + sessionId: 'session-1', + messages: [message], + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + const partialAgent: UIMessage = { + id: 'streaming-agent-tool', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'streaming-agent-tool', + input: { description: '正在流式解析任务' }, + timestamp: 1000, + isPending: true, + partialInput: '{"description":"正在流式解析任务","name":', + } + + expect(hasVisibleSessionActivity(buildMainModel(partialAgent))).toBe(false) + + const completedDirectAgent = buildMainModel({ + ...partialAgent, + input: { description: '正在流式解析任务' }, + isPending: false, + partialInput: undefined, + }) + expect(completedDirectAgent.sections.subagents.rows).toEqual([ + expect.objectContaining({ + id: 'streaming-agent-tool', + label: '正在流式解析任务', + status: 'running', + }), + ]) + }) + + it('uses the durable Team window to classify name-only Agent launches in both directions', () => { + const messages: UIMessage[] = [ + { + id: 'create-tool', + type: 'tool_use', + toolName: 'TeamCreate', + toolUseId: 'create-tool', + input: { team_name: 'durable-team' }, + timestamp: 100, + }, + { + id: 'create-result', + type: 'tool_result', + toolUseId: 'create-tool', + content: { success: true, team_name: 'durable-team' }, + isError: false, + timestamp: 101, + }, + { + id: 'window-member', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'window-member', + input: { name: 'reviewer', description: 'Team member from durable scope' }, + timestamp: 150, + isPending: false, + }, + { + id: 'window-direct', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'window-direct', + input: { description: 'Unnamed direct Agent inside Team scope' }, + timestamp: 160, + isPending: false, + }, + { + id: 'post-window-direct', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'post-window-direct', + input: { name: 'standalone-reviewer', description: 'Named direct Agent after Team scope' }, + timestamp: 250, + isPending: false, + }, + { + id: 'explicit-team-member', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'explicit-team-member', + input: { + name: 'explicit-reviewer', + team_name: 'archived-team', + description: 'Explicit Team member outside durable scope', + }, + timestamp: 260, + isPending: false, + }, + ] + + const model = buildMainSessionActivityModel({ + sessionId: 'session-1', + messages, + teamTaskWindows: [{ startedAt: 100, endedAt: 200 }], + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(model.sections.subagents.rows).toEqual([ + expect.objectContaining({ + id: 'window-direct', + label: 'Unnamed direct Agent inside Team scope', + }), + expect.objectContaining({ + id: 'post-window-direct', + label: 'Named direct Agent after Team scope', + }), + ]) + expect(model.sections.team.rows).toEqual([]) + }) + + it('does not classify a named direct Agent as a teammate after TeamCreate fails', () => { + const createTool: UIMessage = { + id: 'create-tool', + type: 'tool_use', + toolName: 'TeamCreate', + toolUseId: 'create-tool', + input: { team_name: 'review-team' }, + timestamp: 100, + } + const namedAgent: UIMessage = { + id: 'named-agent-tool', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'named-agent-tool', + input: { name: 'reviewer', description: 'Review independently' }, + timestamp: 200, + } + const buildMainModel = (success: boolean, agentResult?: UIMessage) => buildMainSessionActivityModel({ + sessionId: 'session-1', + messages: [ + createTool, + { + id: 'create-result', + type: 'tool_result', + toolUseId: 'create-tool', + content: { success, team_name: 'review-team' }, + isError: false, + timestamp: 101, + }, + namedAgent, + ...(agentResult ? [agentResult] : []), + ], + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(buildMainModel(false).sections.subagents.rows).toEqual([ + expect.objectContaining({ + id: 'named-agent-tool', + label: 'Review independently', + }), + ]) + expect(hasVisibleSessionActivity(buildMainModel(true))).toBe(false) + + const completedTeammate = buildMainModel(true, { + id: 'named-agent-result', + type: 'tool_result', + toolUseId: 'named-agent-tool', + content: { status: 'teammate_spawned', name: 'reviewer', team_name: 'review-team' }, + isError: false, + timestamp: 201, + }) + expect(hasVisibleSessionActivity(completedTeammate)).toBe(false) + }) + + it('lets a durable Team end close task scope when TeamDelete is absent from history', () => { + const messages: UIMessage[] = [ + { + id: 'create-tool', type: 'tool_use', toolName: 'TeamCreate', toolUseId: 'create', + input: { team_name: 'audit-team' }, timestamp: 100, + }, + { + id: 'create-result', type: 'tool_result', toolUseId: 'create', + content: { success: true }, isError: false, timestamp: 101, + }, + { + id: 'team-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'team-task', + input: { subject: 'Shared Team task' }, timestamp: 150, + }, + { + id: 'main-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'main-task', + input: { subject: 'Lead follow-up task' }, timestamp: 250, + }, + ] + + const model = buildMainSessionActivityModel({ + sessionId: 'session-1', + messages, + teamTaskWindows: [{ startedAt: 100, endedAt: 200 }], + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(model.sections.tasks.rows.map(row => row.label)).toEqual(['Lead follow-up task']) + }) + + it('uses a newer durable Team window after an older explicit TeamDelete', () => { + const messages: UIMessage[] = [ + { + id: 'old-delete', type: 'tool_use', toolName: 'TeamDelete', toolUseId: 'old-delete', + input: { team_name: 'old-team' }, timestamp: 90, + }, + { + id: 'old-delete-result', type: 'tool_result', toolUseId: 'old-delete', + content: { success: true }, isError: false, timestamp: 91, + }, + { + id: 'team-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'team-task', + input: { subject: 'Compacted Team task' }, timestamp: 150, + }, + { + id: 'main-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'main-task', + input: { subject: 'After compacted Team' }, timestamp: 250, + }, + ] + + const model = buildMainSessionActivityModel({ + sessionId: 'session-1', + messages, + teamTaskWindows: [{ startedAt: 100, endedAt: 200 }], + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(model.sections.tasks.rows.map(row => row.label)).toEqual(['After compacted Team']) + }) + + it('uses successful Team lifecycle results to hide implicit member spawns without hiding ordinary Agents', () => { + const model = buildSessionActivityModel({ + sessionId: 'session-1', + messages: [ + { + id: 'team-create-tool', + type: 'tool_use', + toolName: 'TeamCreate', + toolUseId: 'team-create-tool', + input: { team_name: 'v053-release-audit', description: '并行审计' }, + timestamp: 1000, + }, + { + id: 'team-create-result', + type: 'tool_result', + toolUseId: 'team-create-tool', + content: [{ type: 'text', text: '{"team_name":"v053-release-audit","lead_agent_id":"team-lead@v053-release-audit"}' }], + isError: false, + timestamp: 1001, + }, + { + id: 'implicit-member-tool', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'implicit-member-tool', + input: { description: '隐式团队成员', name: 'desktop-analyzer' }, + timestamp: 1002, + }, + { + id: 'ordinary-in-team-tool', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'ordinary-in-team-tool', + input: { description: '团队中的普通 SubAgent' }, + timestamp: 1003, + }, + { + id: 'failed-team-delete-tool', + type: 'tool_use', + toolName: 'TeamDelete', + toolUseId: 'failed-team-delete-tool', + input: {}, + timestamp: 1004, + }, + { + id: 'failed-team-delete-result', + type: 'tool_result', + toolUseId: 'failed-team-delete-tool', + content: [{ type: 'text', text: '{"success":false,"message":"members still active","team_name":"v053-release-audit"}' }], + isError: false, + timestamp: 1005, + }, + { + id: 'implicit-member-after-failed-delete-tool', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'implicit-member-after-failed-delete-tool', + input: { description: '删除失败后的团队成员', name: 'provider-analyzer' }, + timestamp: 1006, + }, + { + id: 'team-delete-tool', + type: 'tool_use', + toolName: 'TeamDelete', + toolUseId: 'team-delete-tool', + input: {}, + timestamp: 1007, + }, + { + id: 'team-delete-result', + type: 'tool_result', + toolUseId: 'team-delete-tool', + content: [{ type: 'text', text: '{"success":true,"message":"cleaned","team_name":"v053-release-audit"}' }], + isError: false, + timestamp: 1008, + }, + { + id: 'ordinary-named-agent-tool', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'ordinary-named-agent-tool', + input: { description: '团队结束后的普通 Agent', name: 'standalone-reviewer' }, + timestamp: 1009, + }, + ], + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(model.sections.subagents.rows).toEqual([ + expect.objectContaining({ id: 'ordinary-in-team-tool', label: '团队中的普通 SubAgent' }), + expect.objectContaining({ id: 'ordinary-named-agent-tool', label: '团队结束后的普通 Agent' }), + ]) + expect(model.sections.team.rows).toEqual([ + expect.objectContaining({ + id: 'implicit-member-tool', + label: 'desktop-analyzer', + teamName: 'v053-release-audit', + }), + expect.objectContaining({ + id: 'implicit-member-after-failed-delete-tool', + label: 'provider-analyzer', + teamName: 'v053-release-audit', + }), + ]) + expect(model.badgeCount).toBe(4) + }) + + it('recognizes an implicit member from its own structured spawn metadata after earlier Team history is compacted', () => { + const model = buildSessionActivityModel({ + sessionId: 'session-1', + messages: [ + { + id: 'implicit-member-tool', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'implicit-member-tool', + input: { description: '隐式团队成员', name: 'desktop-analyzer' }, + timestamp: 1000, + }, + { + id: 'implicit-member-result', + type: 'tool_result', + toolUseId: 'implicit-member-tool', + content: 'Spawned successfully.\nagent_id: desktop-analyzer@audit\nname: desktop-analyzer\nteam_name: audit', + isError: false, + timestamp: 1001, + }, + { + id: 'ordinary-named-tool', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'ordinary-named-tool', + input: { description: '普通命名 Agent', name: 'standalone-reviewer' }, + timestamp: 1002, + }, + { + id: 'ordinary-named-result', + type: 'tool_result', + toolUseId: 'ordinary-named-tool', + content: 'Review complete.', + isError: false, + timestamp: 1003, + }, + ], + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(model.sections.subagents.rows).toEqual([ + expect.objectContaining({ id: 'ordinary-named-tool', label: '普通命名 Agent' }), + ]) + expect(model.sections.team.rows).toEqual([ + expect.objectContaining({ + id: 'implicit-member-tool', + label: 'desktop-analyzer', + teamName: 'audit', + teamMemberName: 'desktop-analyzer', + }), + ]) + expect(model.badgeCount).toBe(1) + }) + + it('prefers the authoritative Team member over its transcript launch row', () => { + const model = buildSessionActivityModel({ + sessionId: 'session-1', + messages: [{ + id: 'team-agent-tool', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'team-agent-tool', + input: { + team_name: 'audit', + name: 'desktop-analyzer', + description: '分析桌面端变更', + }, + timestamp: 1000, + }], + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + teamMembers: [{ + agentId: 'desktop-analyzer@audit', + name: 'desktop-analyzer', + role: 'reviewer', + status: 'completed', + }], + }) + + expect(model.sections.team.rows).toEqual([ + expect.objectContaining({ + id: 'desktop-analyzer@audit', + label: 'reviewer', + status: 'completed', + }), + ]) + expect(model.sections.subagents.rows).toEqual([]) + }) + + it('hides teammate runtime containers without changing other activity classes', () => { + const model = buildSessionActivityModel({ + sessionId: 'session-1', + tasks: [], + completedAndDismissed: false, + backgroundTasks: [ + background({ + taskId: 'in_process_teammate-1', + toolUseId: 'team-agent-tool', + taskType: 'in_process_teammate', + description: 'desktop-analyzer', + }), + background({ + taskId: 'local-agent-1', + toolUseId: 'local-agent-tool', + taskType: 'local_agent', + description: 'Direct SubAgent', + }), + background({ + taskId: 'local-bash-1', + toolUseId: 'local-bash-tool', + taskType: 'local_bash', + description: 'bun test', + }), + background({ + taskId: 'local-workflow-1', + toolUseId: 'local-workflow-tool', + taskType: 'local_workflow', + description: 'Release audit workflow', + }), + ], + agentNotifications: [], + }) + + expect(model.sections.subagents.rows).toEqual([ + expect.objectContaining({ id: 'local-agent-tool', taskType: 'local_agent' }), + ]) + expect(model.sections.backgroundTasks.rows).toEqual([ + expect.objectContaining({ id: 'local-bash-tool', taskType: 'local_bash' }), + expect.objectContaining({ id: 'local-workflow-tool', taskType: 'local_workflow' }), + ]) + expect(model.badgeCount).toBe(3) + }) + it('restores task rows from the latest TodoWrite message', () => { const model = buildSessionActivityModel({ sessionId: 'session-1', @@ -663,7 +1285,7 @@ describe('buildSessionActivityModel', () => { expect(model.badgeCount).toBe(1) }) - it('keeps parent-linked SubAgent tasks out of the session task section', () => { + it('does not confuse a main task with a child task that has the same list-local id', () => { const model = buildSessionActivityModel({ sessionId: 'session-1', messages: [ @@ -697,7 +1319,7 @@ describe('buildSessionActivityModel', () => { toolName: 'TaskCreate', toolUseId: 'agent-tool-call/child-task-create-call', originalToolUseId: 'child-task-create-call', - input: { subject: '审查最近七天全部提交' }, + input: { subject: '子代理内部检查' }, parentToolUseId: 'agent-tool-call', timestamp: 1003, }, @@ -706,7 +1328,7 @@ describe('buildSessionActivityModel', () => { type: 'tool_result', toolUseId: 'agent-tool-call/child-task-create-call', originalToolUseId: 'child-task-create-call', - content: 'Task #2 created successfully: 审查最近七天全部提交', + content: 'Task #1 created successfully: 子代理内部检查', isError: false, parentToolUseId: 'agent-tool-call', timestamp: 1004, @@ -714,7 +1336,6 @@ describe('buildSessionActivityModel', () => { ], tasks: [ task({ id: '1', subject: '审查最近七天全部 Git 提交' }), - task({ id: '2', subject: '审查最近七天全部提交' }), ], completedAndDismissed: false, backgroundTasks: [], @@ -730,6 +1351,37 @@ describe('buildSessionActivityModel', () => { expect(model.badgeCount).toBe(2) }) + it('does not let a child deletion remove a main task with the same list-local id', () => { + const model = buildSessionActivityModel({ + sessionId: 'session-1', + messages: [ + { + id: 'child-task-delete', + type: 'tool_use', + toolName: 'TaskUpdate', + toolUseId: 'agent-tool-call/child-task-delete-call', + originalToolUseId: 'child-task-delete-call', + input: { taskId: '1', status: 'deleted' }, + parentToolUseId: 'agent-tool-call', + timestamp: 1000, + }, + { + ...successfulTaskUpdateResult('agent-tool-call/child-task-delete-call', '1', 1001, 'deleted'), + parentToolUseId: 'agent-tool-call', + }, + ], + tasks: [task({ id: '1', subject: '主会话验收' })], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(model.sections.tasks.rows).toEqual([ + expect.objectContaining({ id: '1', label: '主会话验收' }), + ]) + expect(model.badgeCount).toBe(1) + }) + it('does not restore parent-linked SubAgent TodoWrite rows as session tasks', () => { const model = buildSessionActivityModel({ sessionId: 'session-1', @@ -755,6 +1407,366 @@ describe('buildSessionActivityModel', () => { expect(model.badgeCount).toBe(0) }) + it('keeps parent-linked TodoWrite rows in an agent run', () => { + const model = buildSessionActivityModel({ + sessionId: 'agent-1', + runScope: 'agent', + messages: [{ + id: 'child-todo', + type: 'tool_use', + toolName: 'TodoWrite', + toolUseId: 'agent-tool-call/child-todo-call', + originalToolUseId: 'child-todo-call', + input: { + todos: [{ content: '子代理内部检查项', status: 'in_progress' }], + }, + parentToolUseId: 'agent-tool-call', + timestamp: 1000, + }], + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(model.sections.tasks.rows).toEqual([ + expect.objectContaining({ label: '子代理内部检查项', status: 'in_progress' }), + ]) + expect(model.badgeCount).toBe(1) + }) + + it('uses explicit member tasks and TodoWrite without rebuilding shared team Task events', () => { + const model = buildSessionActivityModel({ + sessionId: 'team-member:agent-1', + runScope: 'agent', + taskScope: 'team', + messages: [ + { + id: 'shared-task-create', + type: 'tool_use', + toolName: 'TaskCreate', + toolUseId: 'team-agent/shared-task-create-call', + input: { subject: '其他成员的共享任务' }, + parentToolUseId: 'team-agent', + timestamp: 1000, + }, + { + id: 'shared-task-create-result', + type: 'tool_result', + toolUseId: 'team-agent/shared-task-create-call', + content: 'Task #9 created successfully: 其他成员的共享任务', + isError: false, + parentToolUseId: 'team-agent', + timestamp: 1001, + }, + { + id: 'shared-task-delete', + type: 'tool_use', + toolName: 'TaskUpdate', + toolUseId: 'team-agent/shared-task-delete-call', + input: { taskId: '1', status: 'deleted' }, + parentToolUseId: 'team-agent', + timestamp: 1002, + }, + { + ...successfulTaskUpdateResult('team-agent/shared-task-delete-call', '1', 1003, 'deleted'), + parentToolUseId: 'team-agent', + }, + { + id: 'member-todo', + type: 'tool_use', + toolName: 'TodoWrite', + toolUseId: 'team-agent/member-todo-call', + input: { + todos: [{ content: '成员自己的检查项', status: 'in_progress' }], + }, + parentToolUseId: 'team-agent', + timestamp: 1004, + }, + ], + tasks: [task({ id: '1', subject: '分配给当前成员的任务' })], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(model.sections.tasks.rows).toEqual([ + expect.objectContaining({ label: '成员自己的检查项', status: 'in_progress' }), + expect.objectContaining({ id: '1', label: '分配给当前成员的任务', status: 'pending' }), + ]) + expect(model.sections.tasks.rows).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ label: '其他成员的共享任务' }), + ])) + expect(model.badgeCount).toBe(2) + }) + + it('maps authoritative team task failure and completion by structured task id', () => { + const messages: UIMessage[] = [ + { + id: 'failed-update-a', + type: 'tool_use', + toolName: 'TaskUpdate', + toolUseId: 'failed-update-a-call', + input: { taskId: 'A', status: 'completed' }, + timestamp: 1004, + }, + { + id: 'failed-update-a-result', + type: 'tool_result', + toolUseId: 'failed-update-a-call', + content: 'same result text', + isError: true, + timestamp: 1005, + }, + { + id: 'stale-update-b', + type: 'tool_use', + toolName: 'TaskUpdate', + toolUseId: 'stale-update-b-call', + input: { taskId: 'B', status: 'in_progress' }, + timestamp: 1006, + }, + { + id: 'stale-update-b-result', + type: 'tool_result', + toolUseId: 'stale-update-b-call', + content: 'same result text', + isError: false, + timestamp: 1007, + }, + ] + const teamTasks = [ + task({ id: 'A', subject: 'Review shared surface', taskListId: 'team-list' }), + task({ id: 'B', subject: 'Review shared surface', taskListId: 'team-list', status: 'completed' }), + ] + + const failedState = buildSessionActivityModel({ + sessionId: 'session-1', + messages, + tasks: [], + teamTasks, + taskScope: 'team-session', + teamTaskWindows: [{ startedAt: 1000 }], + isForegroundTurnActive: false, + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(failedState.sections.tasks.rows).toEqual([ + expect.objectContaining({ id: 'team-task:team-list:A', taskId: 'A', status: 'pending' }), + expect.objectContaining({ id: 'team-task:team-list:B', taskId: 'B', status: 'completed' }), + ]) + + const completedState = buildSessionActivityModel({ + sessionId: 'session-1', + messages, + tasks: [], + teamTasks: teamTasks.map(current => ({ ...current, status: 'completed' })), + taskScope: 'team-session', + teamTaskWindows: [{ startedAt: 1000 }], + isForegroundTurnActive: false, + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(completedState.sections.tasks.rows).toEqual([ + expect.objectContaining({ id: 'team-task:team-list:A', taskId: 'A', status: 'completed' }), + expect.objectContaining({ id: 'team-task:team-list:B', taskId: 'B', status: 'completed' }), + ]) + }) + + it('filters shared tasks only inside a successful TeamCreate to TeamDelete lifecycle', () => { + const taskCall = (id: string, subject: string, timestamp: number): UIMessage[] => [{ + id: `${id}-use`, + type: 'tool_use', + toolName: 'TaskCreate', + toolUseId: id, + input: { subject }, + timestamp, + }, { + id: `${id}-result`, + type: 'tool_result', + toolUseId: id, + content: `Task #${id} created successfully: ${subject}`, + isError: false, + timestamp: timestamp + 1, + }] + const model = buildSessionActivityModel({ + sessionId: 'team-lifecycle-session', + taskScope: 'team-session', + // The workbench can close a little after TeamDelete succeeds. Once the + // transcript has an authoritative lifecycle marker, it must win over + // this still-open discovery window. + teamTaskWindows: [{ startedAt: 1500 }], + messages: [ + ...taskCall('1', 'Keep the pre-team task', 1000), + { + id: 'team-create', + type: 'tool_use', + toolName: 'TeamCreate', + toolUseId: 'team-create-call', + input: { team_name: 'review-team' }, + timestamp: 2000, + }, + { + id: 'team-create-result', + type: 'tool_result', + toolUseId: 'team-create-call', + content: { team_name: 'review-team' }, + isError: false, + timestamp: 2001, + }, + ...taskCall('2', 'Hide the shared team task', 3000), + { + id: 'team-delete', + type: 'tool_use', + toolName: 'TeamDelete', + toolUseId: 'team-delete-call', + input: { team_name: 'review-team' }, + timestamp: 4000, + }, + { + id: 'team-delete-result', + type: 'tool_result', + toolUseId: 'team-delete-call', + content: { team_name: 'review-team' }, + isError: false, + timestamp: 4001, + }, + ...taskCall('3', 'Keep the post-team task', 5000), + ], + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(model.sections.tasks.rows).toEqual(expect.arrayContaining([ + expect.objectContaining({ label: 'Keep the pre-team task' }), + expect.objectContaining({ label: 'Keep the post-team task' }), + ])) + expect(model.sections.tasks.rows).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ label: 'Hide the shared team task' }), + ])) + }) + + it('does not enter team task scope after a failed TeamCreate', () => { + const model = buildSessionActivityModel({ + sessionId: 'failed-team-create-session', + taskScope: 'team-session', + messages: [ + { + id: 'failed-team-create', + type: 'tool_use', + toolName: 'TeamCreate', + toolUseId: 'failed-team-create-call', + input: { team_name: 'review-team' }, + timestamp: 1000, + }, + { + id: 'failed-team-create-result', + type: 'tool_result', + toolUseId: 'failed-team-create-call', + content: 'Team creation failed', + isError: true, + timestamp: 1001, + }, + { + id: 'session-task', + type: 'tool_use', + toolName: 'TaskCreate', + toolUseId: 'session-task-call', + input: { subject: 'Keep the session task' }, + timestamp: 2000, + }, + { + id: 'session-task-result', + type: 'tool_result', + toolUseId: 'session-task-call', + content: 'Task #1 created successfully: Keep the session task', + isError: false, + timestamp: 2001, + }, + ], + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(model.sections.tasks.rows).toEqual([ + expect.objectContaining({ label: 'Keep the session task' }), + ]) + }) + + it('keeps team scope active when TeamDelete returns success false', () => { + const model = buildSessionActivityModel({ + sessionId: 'failed-team-delete-session', + taskScope: 'team-session', + messages: [ + { + id: 'team-create', + type: 'tool_use', + toolName: 'TeamCreate', + toolUseId: 'team-create-call', + input: { team_name: 'review-team' }, + timestamp: 1000, + }, + { + id: 'team-create-result', + type: 'tool_result', + toolUseId: 'team-create-call', + content: { success: true, team_name: 'review-team' }, + isError: false, + timestamp: 1001, + }, + { + id: 'failed-team-delete', + type: 'tool_use', + toolName: 'TeamDelete', + toolUseId: 'team-delete-call', + input: { team_name: 'review-team' }, + timestamp: 2000, + }, + { + id: 'failed-team-delete-result', + type: 'tool_result', + toolUseId: 'team-delete-call', + content: [{ + type: 'text', + text: JSON.stringify({ success: false, message: 'Active members remain' }), + }], + isError: false, + timestamp: 2001, + }, + { + id: 'shared-task', + type: 'tool_use', + toolName: 'TaskCreate', + toolUseId: 'shared-task-call', + input: { subject: 'Keep this in the team task list' }, + timestamp: 3000, + }, + { + id: 'shared-task-result', + type: 'tool_result', + toolUseId: 'shared-task-call', + content: 'Task #1 created successfully: Keep this in the team task list', + isError: false, + timestamp: 3001, + }, + ], + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(model.sections.tasks.rows).toEqual([]) + }) + it('keeps the last successful status when a later TaskUpdate fails', () => { const model = buildSessionActivityModel({ sessionId: 'session-1', @@ -1719,3 +2731,110 @@ describe('buildSessionActivityModel', () => { expect(model.badgeCount).toBe(1) }) }) + +describe('workflow section', () => { + const AGENTS = [ + { type: 'workflow_agent', index: 1, label: 'survey response.js', state: 'done', phaseIndex: 1, phaseTitle: 'Survey', agentId: 'a11', tokens: 24_100 }, + { type: 'workflow_agent', index: 2, label: 'survey request.js', state: 'done', phaseIndex: 1, phaseTitle: 'Survey', agentId: 'a12' }, + { type: 'workflow_agent', index: 3, label: 'check response #1', state: 'progress', phaseIndex: 2, phaseTitle: 'Cross-check', agentId: 'a13' }, + // Queued: accepted by the runtime but never given a slot, so no transcript. + { type: 'workflow_agent', index: 4, label: 'check response #2', state: 'start', phaseIndex: 2, phaseTitle: 'Cross-check' }, + ] + + function run(overrides: Record = {}) { + return { + taskId: 'w1', + sessionId: 'session-1', + workflowName: 'route-survey', + status: 'running', + startedAt: 0, + updatedAt: 0, + agentCount: 4, + totalTokens: 0, + toolCalls: 0, + progress: [ + { type: 'workflow_phase', index: 1, title: 'Survey' }, + { type: 'workflow_phase', index: 2, title: 'Cross-check' }, + ...AGENTS, + ], + ...overrides, + } as never + } + + function build() { + return buildSessionActivityModel({ + sessionId: 'session-1', + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + workflowRuns: [run()], + }) + } + + it('lays each phase out as a header followed by its agents', () => { + const rows = build().sections.workflow.rows + expect(rows.map((row) => [row.label, row.groupProgress ? 'phase' : row.group])).toEqual([ + ['Survey', 'phase'], + ['survey response.js', 'Survey'], + ['survey request.js', 'Survey'], + ['Cross-check', 'phase'], + ['check response #1', 'Cross-check'], + ['check response #2', 'Cross-check'], + ]) + }) + + it('counts settled agents on the phase header', () => { + const headers = build().sections.workflow.rows.filter((row) => row.groupProgress) + expect(headers[0]!.groupProgress).toEqual({ done: 2, total: 2 }) + expect(headers[0]!.status).toBe('completed') + expect(headers[1]!.groupProgress).toEqual({ done: 0, total: 2 }) + expect(headers[1]!.status).toBe('running') + }) + + it('opens each agent through the ordinary subagent route', () => { + // A workflow agent is a subagent run by the same runner, so the row carries + // the reference the existing page opens with rather than anything bespoke. + const rows = build().sections.workflow.rows + const running = rows.find((row) => row.label === 'check response #1')! + expect(running.openable).toBe(true) + expect(running.toolUseId).toBe('agent:a13') + + // Queued agents have no transcript yet — offering to open one would 404. + const queued = rows.find((row) => row.label === 'check response #2')! + expect(queued.openable).toBe(false) + expect(queued.toolUseId).toBeUndefined() + }) + + it('labels an unphased group with the run name instead of "Phase 0"', () => { + // Runs recorded before phases were persisted come back ungrouped. The + // workflow name identifies them; a bare index does not. + const model = buildSessionActivityModel({ + sessionId: 'session-1', + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + workflowRuns: [run({ + workflowName: 'review-last-month', + progress: [ + { type: 'workflow_agent', index: 1, label: 'review:security', state: 'done', phaseIndex: 0, agentId: 'a1' }, + ], + })], + }) + const header = model.sections.workflow.rows.find((row) => row.groupProgress)! + expect(header.label).toBe('review-last-month') + }) + + it('badges only the agents, never the phase headers', () => { + // One running plus one queued agent. The Cross-check header is also + // "running", but counting it would double-count the very agents beneath + // it — the badge is a count of work, not of headings. + expect(build().badgeCount).toBe(2) + }) + + it('shows the workflow above the individual subagents it spawned', () => { + const order = getVisibleActivitySections(build()).map((section) => section.id) + expect(order).toEqual(['workflow']) + }) +}) diff --git a/desktop/src/components/activity/sessionActivityModel.ts b/desktop/src/components/activity/sessionActivityModel.ts index 2a320e69..2c616e6d 100644 --- a/desktop/src/components/activity/sessionActivityModel.ts +++ b/desktop/src/components/activity/sessionActivityModel.ts @@ -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 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 { 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 { - const taskIds = new Set() - 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 | 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() + 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): str ) } -function buildAgentRowsFromMessages(messages: UIMessage[]): ActivityRow[] { +function parseToolResultRecord( + result: Extract | undefined, +): Record | 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, + result: Extract | 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, + result: Extract | 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() + 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>() + const toolCallsByToolUseId = new Map>() 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() + const agentsByPhase = new Map() + + 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() const subagentKeyByTaskId = new Map() const outputRowsByKey = new Map() @@ -799,12 +1199,39 @@ export function buildSessionActivityModel(input: BuildSessionActivityModelInput) const dismissedNotificationKeys = new Set() const dismissedNotificationTaskIds = new Set() const visibleBackgroundTaskIds = new Set() - - for (const row of buildAgentRowsFromMessages(input.messages ?? [])) { + const hiddenChildTaskIds = new Set() + 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() + 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, + }) +} diff --git a/desktop/src/components/agentTeams/AgentTeamsCanvas.test.tsx b/desktop/src/components/agentTeams/AgentTeamsCanvas.test.tsx new file mode 100644 index 00000000..95649802 --- /dev/null +++ b/desktop/src/components/agentTeams/AgentTeamsCanvas.test.tsx @@ -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 { + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + expect(screen.getByTestId('agent-teams-active-flight').getAttribute('data-flight-channel')).toBe('lead') + expect(screen.getByTestId('agent-teams-active-flight').textContent).toContain('Assignments') + + rerender() + 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() + + 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() + 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() + + 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() + + 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;/, + ) + }) +}) diff --git a/desktop/src/components/agentTeams/AgentTeamsCanvas.tsx b/desktop/src/components/agentTeams/AgentTeamsCanvas.tsx new file mode 100644 index 00000000..384d0bcf --- /dev/null +++ b/desktop/src/components/agentTeams/AgentTeamsCanvas.tsx @@ -0,0 +1,1109 @@ +import { useMemo, useState, type CSSProperties } from 'react' +import { useTranslation, type TranslationKey } from '../../i18n' +import type { + TeamMember, + TeamWorkbenchMessage, + TeamWorkbenchSnapshot, + TeamWorkbenchTask, +} from '../../types/team' +import { MEMBER_AVATARS, memberAccentColor } from './agentTeamsAvatars' +import { + currentTaskForMember, + getMemberAvatarKey, + getMemberWorkState, + getWorkbenchTaskState, + inferTaskOwner, + layoutWorkbenchTasks, + parseWorkbenchMessageBody, + resolveTeamMemberIdentity, + taskOwnedByMember, + type MemberWorkState, + type PositionedWorkbenchTask, + type WorkbenchTaskState, +} from './agentTeamsModel' + +export type AgentTeamsCanvasProps = { + snapshots: TeamWorkbenchSnapshot[] + selectedIndex: number + snapshot: TeamWorkbenchSnapshot + previousSnapshot?: TeamWorkbenchSnapshot + leadIsStreaming: boolean + activeMessageId: string | null + focusedTaskId?: string | null + selectedMemberId?: string | null + onSelectMember: (member: TeamMember, isLead: boolean) => void + onSelectTask: (task: TeamWorkbenchTask) => void +} + +type TranslationFn = ReturnType + +type MemberPosition = { + member: TeamMember + isLead: boolean + centerX: number + accent: string + workState: MemberWorkState + currentTask?: TeamWorkbenchTask + completed: number + total: number + percent: number + inbox: number + recentTasks: TeamWorkbenchTask[] +} + +type OwnerVisual = { + member: TeamMember + isLead: boolean + name: string + avatar: string + accent: string + inferred: boolean +} + +type FlightRoute = { + key: string + path: string + channel: 'claim' | 'lead' | 'peer' + color: string + label: string +} + +const CANVAS_MIN_WIDTH = 1280 +const FORMATION_TITLE_X = 14 +const FORMATION_TITLE_Y = 12 +const TASKS_TITLE_Y = 388 +const LEAD_TOP = 20 +const LEAD_FOOT = 150 +const LEAD_BUS_Y = 196 +const MEMBER_TOP = 222 +const MEMBER_FOOT = 352 +const PEER_BUS_Y = 372 +const MEMBER_PITCH = 200 +const MEMBER_SLOT_WIDTH = 176 +const TASK_WIDTH = 200 +const TASK_HEIGHT = 92 +const LANE_TOP = 410 + +function aliases(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 memberName(member: TeamMember): string { + return member.name || member.role || member.agentId.split('@')[0] || member.agentId +} + +function memberMatches(member: TeamMember, identity: string | undefined): boolean { + const identityAliases = aliases(identity) + if (identityAliases.length === 0) return false + const memberAliases = [ + ...aliases(member.agentId), + ...aliases(member.name), + ] + return identityAliases.some(alias => memberAliases.includes(alias)) +} + +function isSelectedMember(member: TeamMember, selectedMemberId: string | null | undefined): boolean { + return memberMatches(member, selectedMemberId ?? undefined) +} + +function memberStatusColor(state: MemberWorkState): string { + if (state === 'error') return 'var(--color-error)' + if (state === 'working') return 'var(--color-text-primary)' + if (state === 'exited' || state === 'stopped') return 'var(--color-text-tertiary)' + return 'var(--color-text-secondary)' +} + +function taskStateLabel(state: WorkbenchTaskState, t: TranslationFn): string { + return t(`agentTeams.task.${state}` as TranslationKey) +} + +function memberStateLabel(state: MemberWorkState, t: TranslationFn): string { + return t(`agentTeams.member.${state}` as TranslationKey) +} + +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 taskStateColors(state: WorkbenchTaskState, accent: string) { + if (state === 'completed') { + return { + background: 'var(--color-surface-container-low)', + border: 'var(--color-border)', + pillBackground: 'var(--color-success-container)', + pillForeground: 'var(--color-on-success-container)', + title: 'var(--color-text-secondary)', + progress: 'var(--color-success)', + } + } + if (state === 'running') { + return { + background: 'var(--color-surface-container-lowest)', + border: accent, + pillBackground: 'var(--color-brand-soft)', + pillForeground: 'var(--color-brand)', + title: 'var(--color-text-primary)', + progress: accent, + } + } + if (state === 'open') { + return { + background: 'var(--color-surface-container-lowest)', + border: 'var(--color-outline)', + pillBackground: 'var(--color-surface-container-high)', + pillForeground: 'var(--color-text-secondary)', + title: 'var(--color-text-primary)', + progress: accent, + } + } + return { + background: 'var(--color-background)', + border: 'var(--color-border)', + pillBackground: 'var(--color-background)', + pillForeground: 'var(--color-text-tertiary)', + title: 'var(--color-text-tertiary)', + progress: accent, + } +} + +function metadataNumber(metadata: Record | undefined, key: string): number | null { + const value = metadata?.[key] + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +/** Only renders determinate work when the task source supplied a real value. */ +function taskProgress(task: TeamWorkbenchTask): number | null { + if (task.status === 'completed') return 100 + if (task.status !== 'in_progress') return 0 + + const explicit = metadataNumber(task.metadata, 'progressPercent') + ?? metadataNumber(task.metadata, 'percentComplete') + ?? metadataNumber(task.metadata, 'percent') + if (explicit !== null) return Math.max(0, Math.min(100, explicit)) + + const progress = metadataNumber(task.metadata, 'progress') + if (progress !== null) { + const normalized = progress >= 0 && progress <= 1 ? progress * 100 : progress + return Math.max(0, Math.min(100, normalized)) + } + + const completedSteps = metadataNumber(task.metadata, 'completedSteps') + const totalSteps = metadataNumber(task.metadata, 'totalSteps') + if (completedSteps !== null && totalSteps !== null && totalSteps > 0) { + return Math.max(0, Math.min(100, (completedSteps / totalSteps) * 100)) + } + return null +} + +function taskOwnerVisual( + task: TeamWorkbenchTask, + snapshot: TeamWorkbenchSnapshot, + members: TeamMember[], + fallbackIndex: number, +): OwnerVisual | undefined { + const attribution = inferTaskOwner(task, snapshot) + if (!attribution) return undefined + const { member, isLead } = resolveTeamMemberIdentity(snapshot.team, attribution.identity) + const memberIndex = members.findIndex(candidate => memberMatches(candidate, member.agentId)) + return { + member, + isLead, + name: memberName(member), + avatar: MEMBER_AVATARS[getMemberAvatarKey(member, isLead)], + accent: memberAccentColor(member.color, memberIndex >= 0 ? memberIndex : fallbackIndex), + inferred: attribution.inferred, + } +} + +function taskBelongsToMember( + task: TeamWorkbenchTask, + member: TeamMember, + snapshot: TeamWorkbenchSnapshot, +): boolean { + if (taskOwnedByMember(task, member)) return true + const attribution = inferTaskOwner(task, snapshot) + return Boolean(attribution && memberMatches(member, attribution.identity)) +} + +function memberInboxCount(member: TeamMember, messages: TeamWorkbenchMessage[]): number { + return messages.filter(message => ( + memberMatches(member, message.to) || + message.recipients.some(recipient => memberMatches(member, recipient)) + )).length +} + +function workState( + member: TeamMember, + snapshot: TeamWorkbenchSnapshot, + isLead: boolean, + leadIsStreaming: boolean, +): MemberWorkState { + if (snapshot.deletedAt) return 'exited' + return getMemberWorkState(member, { isLead, leadIsStreaming }) +} + +function dependencyChain(taskId: string | null, tasks: TeamWorkbenchTask[]): Set | null { + if (!taskId) return null + const byId = new Map(tasks.map(task => [task.id, task])) + const reverse = new Map() + for (const task of tasks) { + for (const dependencyId of task.blockedBy) { + const downstream = reverse.get(dependencyId) + if (downstream) downstream.push(task.id) + else reverse.set(dependencyId, [task.id]) + } + } + + const chain = new Set() + const queue = [taskId] + while (queue.length > 0) { + const current = queue.shift()! + if (chain.has(current)) continue + chain.add(current) + const task = byId.get(current) + if (task) queue.push(...task.blockedBy) + queue.push(...(reverse.get(current) ?? [])) + } + return chain +} + +function cubicTether(memberX: number, task: PositionedWorkbenchTask): string { + const taskX = task.x + TASK_WIDTH / 2 + return `M ${memberX},${MEMBER_FOOT} C ${memberX},${MEMBER_FOOT + 70} ${taskX},${task.y - 80} ${taskX},${task.y}` +} + +function claimFlightPath(memberX: number, task: PositionedWorkbenchTask): string { + const taskX = task.x + TASK_WIDTH / 2 + return `M ${taskX},${task.y} C ${taskX},${task.y - 80} ${memberX},${MEMBER_FOOT + 70} ${memberX},${MEMBER_FOOT}` +} + +function dependencyPath(from: PositionedWorkbenchTask, to: PositionedWorkbenchTask): string { + const fromX = from.x + TASK_WIDTH + const fromY = from.y + TASK_HEIGHT / 2 + const toX = to.x + const toY = to.y + TASK_HEIGHT / 2 + const middle = fromX + (toX - fromX) / 2 + return `M ${fromX},${fromY} C ${middle},${fromY} ${middle},${toY} ${toX - 5},${toY}` +} + +function polylinePath(points: Array<[number, number]>): string { + return points.map(([x, y], index) => `${index === 0 ? 'M' : 'L'} ${x},${y}`).join(' ') +} + +function memberForIdentity(members: MemberPosition[], identity: string | undefined): MemberPosition | undefined { + return members.find(position => memberMatches(position.member, identity)) +} + +function semanticFlightLabel( + message: TeamWorkbenchMessage, + body: ReturnType, + sender: MemberPosition, + recipient: MemberPosition, + t: TranslationFn, +): string { + if (body.kind === 'assignment') return t('agentTeams.communication.assignment') + if (body.kind === 'lifecycle' || message.kind === 'system' || message.protocolType) { + return t('agentTeams.communication.system') + } + if (sender.isLead) return t('agentTeams.communication.assignment') + if (recipient.isLead || message.kind === 'broadcast') { + return t('agentTeams.communication.report') + } + return t('agentTeams.communication.direct') +} + +function flightRouteToRecipient( + message: TeamWorkbenchMessage | undefined, + positions: MemberPosition[], + layoutById: Map, + lead: MemberPosition | undefined, + recipientIdentity: string | undefined, + t: TranslationFn, +): FlightRoute | null { + if (!message) return null + const body = parseWorkbenchMessageBody(message) + const taskId = body.kind === 'assignment' ? (body.taskId ?? message.taskId) : message.taskId + const sender = memberForIdentity(positions, message.from) + const recipient = memberForIdentity(positions, recipientIdentity) + + if (body.kind === 'assignment' && body.selfClaim && taskId && sender && !sender.isLead) { + const task = layoutById.get(taskId) + if (!task) return null + return { + key: `claim-${sender.member.agentId}-${taskId}`, + path: claimFlightPath(sender.centerX, task), + channel: 'claim', + color: sender.accent, + label: `#${taskId}`, + } + } + + if (lead && sender && recipient && (sender.isLead || recipient.isLead)) { + const worker = sender.isLead ? recipient : sender + if (worker.isLead) return null + const leadToWorker: Array<[number, number]> = [ + [lead.centerX, LEAD_FOOT], + [lead.centerX, LEAD_BUS_Y], + [worker.centerX, LEAD_BUS_Y], + [worker.centerX, MEMBER_TOP], + ] + const points = sender.isLead ? leadToWorker : leadToWorker.slice().reverse() + return { + key: `lead-${sender.member.agentId}-${recipient.member.agentId}`, + path: polylinePath(points), + channel: 'lead', + color: sender.accent, + label: semanticFlightLabel(message, body, sender, recipient, t), + } + } + + if (sender && recipient && !sender.isLead && !recipient.isLead) { + return { + key: `peer-${sender.member.agentId}-${recipient.member.agentId}`, + path: polylinePath([ + [sender.centerX, MEMBER_FOOT], + [sender.centerX, PEER_BUS_Y], + [recipient.centerX, PEER_BUS_Y], + [recipient.centerX, MEMBER_FOOT], + ]), + channel: 'peer', + color: sender.accent, + label: semanticFlightLabel(message, body, sender, recipient, t), + } + } + return null +} + +function flightRoutes( + message: TeamWorkbenchMessage | undefined, + positions: MemberPosition[], + layoutById: Map, + lead: MemberPosition | undefined, + t: TranslationFn, +): FlightRoute[] { + if (!message) return [] + const body = parseWorkbenchMessageBody(message) + if (body.kind === 'assignment' && body.selfClaim) { + const route = flightRouteToRecipient( + message, + positions, + layoutById, + lead, + message.from, + t, + ) + return route ? [route] : [] + } + + const isBroadcast = message.kind === 'broadcast' || message.to === '*' + const explicitRecipients = isBroadcast + ? message.recipients.filter(identity => identity !== '*') + : [message.to] + const recipientPositions = explicitRecipients.length > 0 + ? explicitRecipients + .map(identity => memberForIdentity(positions, identity)) + .filter((position): position is MemberPosition => Boolean(position)) + : positions.filter(position => !memberMatches(position.member, message.from)) + const uniqueRecipients = Array.from(new Map( + recipientPositions + .filter(position => !memberMatches(position.member, message.from)) + .map(position => [position.member.agentId, position]), + ).values()) + + return uniqueRecipients.flatMap((position) => { + const route = flightRouteToRecipient( + message, + positions, + layoutById, + lead, + position.member.agentId, + t, + ) + return route ? [route] : [] + }) +} + +function MemberNode({ + position, + selected, + hasActiveMessage, + waitingDependency, + leadStatus, + onSelect, + t, +}: { + position: MemberPosition + selected: boolean + hasActiveMessage: boolean + waitingDependency?: string + leadStatus?: string + onSelect: () => void + t: TranslationFn +}) { + const { member, isLead, centerX, accent, workState: state } = position + const top = isLead ? LEAD_TOP : MEMBER_TOP + const avatarSize = isLead ? 96 : 84 + const identityWidth = isLead ? 28 : 24 + const stateLabel = isLead && leadStatus + ? leadStatus + : state === 'working' && position.currentTask + ? t('agentTeams.member.executingTask', { task: position.currentTask.id }) + : state === 'idle' && !isLead + ? position.total > 0 && position.completed === position.total + ? t('agentTeams.member.waitingForClose') + : waitingDependency + ? t('agentTeams.member.waitingForDependency', { task: waitingDependency }) + : t('agentTeams.member.waitingForTask') + : memberStateLabel(state, t) + const characterClass = state === 'working' + ? 'agent-teams-character-working' + : state === 'idle' + ? 'agent-teams-character-idle' + : state === 'exited' + ? 'agent-teams-character-archived' + : '' + + return ( + + ) +} + +function TaskCard({ + positioned, + snapshot, + members, + focused, + dimmed, + justUnlocked, + onHover, + onHoverEnd, + onFocus, + onBlur, + onSelect, + t, +}: { + positioned: PositionedWorkbenchTask + snapshot: TeamWorkbenchSnapshot + members: TeamMember[] + focused: boolean + dimmed: boolean + justUnlocked: boolean + onHover: () => void + onHoverEnd: () => void + onFocus: () => void + onBlur: () => void + onSelect: () => void + t: TranslationFn +}) { + const { task, state, depth, x, y } = positioned + const owner = taskOwnerVisual(task, snapshot, members, depth) + const accent = owner?.accent ?? 'var(--color-brand)' + const colors = taskStateColors(state, accent) + const progress = taskProgress(task) + const dependencies = task.blockedBy.map(id => `#${id}`).join(' ') + const ownerLabel = owner + ? owner.inferred + ? t('agentTeams.task.inferredOwner', { name: owner.name }) + : owner.name + : task.status === 'completed' + ? t('agentTeams.task.completedNoOwner') + : t('agentTeams.task.unclaimed') + + return ( + + ) +} + +export function AgentTeamsCanvas({ + snapshots, + selectedIndex, + snapshot, + previousSnapshot, + leadIsStreaming, + activeMessageId, + focusedTaskId: externallyFocusedTaskId, + selectedMemberId, + onSelectMember, + onSelectTask, +}: AgentTeamsCanvasProps) { + const t = useTranslation() + const [hoveredTaskId, setHoveredTaskId] = useState(null) + const [keyboardFocusedTaskId, setKeyboardFocusedTaskId] = useState(null) + const uniqueMembers = useMemo( + () => Array.from(new Map(snapshot.team.members.map(member => [member.agentId, member])).values()), + [snapshot.team.members], + ) + const leadMember = uniqueMembers.find(member => member.agentId === snapshot.team.leadAgentId) + ?? uniqueMembers.find(member => aliases(member.name).includes('team-lead')) + ?? uniqueMembers[0] + const workers = uniqueMembers.filter(member => member !== leadMember) + const naturalWidth = Math.max(CANVAS_MIN_WIDTH, 460 + Math.max(0, workers.length - 1) * MEMBER_PITCH) + const layout = useMemo( + () => layoutWorkbenchTasks(snapshot.tasks, naturalWidth), + [naturalWidth, snapshot.tasks], + ) + const timeline = snapshots.length > 0 ? snapshots : [snapshot] + const timelineIndex = snapshots.length > 0 + ? Math.max(0, Math.min(selectedIndex, snapshots.length - 1)) + : 0 + const membersByPosition = useMemo(() => { + const result: MemberPosition[] = [] + const leadCenter = layout.width / 2 + const workerStart = leadCenter - Math.max(0, workers.length - 1) * MEMBER_PITCH / 2 + const addMember = (member: TeamMember, isLead: boolean, centerX: number, index: number) => { + const ownedTasks = snapshot.tasks.filter(task => taskBelongsToMember(task, member, snapshot)) + const completed = ownedTasks.filter(task => task.status === 'completed').length + const current = isLead + ? undefined + : currentTaskForMember(timeline, timelineIndex, member) + const currentTask = current ? snapshot.tasks.find(task => task.id === current.id) ?? current : undefined + result.push({ + member, + isLead, + centerX, + accent: memberAccentColor(member.color, index), + workState: workState(member, snapshot, isLead, leadIsStreaming), + currentTask, + completed, + total: ownedTasks.length, + percent: ownedTasks.length === 0 ? 0 : Math.round((completed / ownedTasks.length) * 100), + inbox: memberInboxCount(member, snapshot.messages), + recentTasks: ownedTasks.filter(task => task.status !== 'pending').slice(-4), + }) + } + + if (leadMember) addMember(leadMember, true, leadCenter, uniqueMembers.indexOf(leadMember)) + workers.forEach((member, index) => addMember(member, false, workerStart + index * MEMBER_PITCH, uniqueMembers.indexOf(member))) + return result + }, [ + layout.width, + leadIsStreaming, + leadMember, + snapshot, + timeline, + timelineIndex, + uniqueMembers, + workers, + ]) + const leadPosition = membersByPosition.find(position => position.isLead) + const workerPositions = membersByPosition.filter(position => !position.isLead) + const focusedTaskId = hoveredTaskId ?? keyboardFocusedTaskId ?? externallyFocusedTaskId ?? null + const focusedChain = useMemo( + () => dependencyChain(focusedTaskId, snapshot.tasks), + [focusedTaskId, snapshot.tasks], + ) + const previousTasksById = useMemo( + () => new Map(previousSnapshot?.tasks.map(task => [task.id, task]) ?? []), + [previousSnapshot], + ) + const previousStatesById = useMemo(() => { + const states = new Map() + for (const task of previousSnapshot?.tasks ?? []) { + states.set(task.id, getWorkbenchTaskState(task, previousTasksById)) + } + return states + }, [previousSnapshot, previousTasksById]) + const justUnlockedIds = useMemo(() => new Set(layout.tasks + .filter(positioned => ( + previousStatesById.get(positioned.task.id) === 'blocked' && positioned.state === 'open' + )) + .map(positioned => positioned.task.id)), [layout.tasks, previousStatesById]) + const activeMessage = activeMessageId + ? snapshot.messages.find(message => message.id === activeMessageId) + : undefined + const routes = flightRoutes(activeMessage, membersByPosition, layout.byId, leadPosition, t) + const activeParticipants = new Set() + if (activeMessage) { + const broadcastsToAll = (activeMessage.kind === 'broadcast' || activeMessage.to === '*') && + activeMessage.recipients.every(recipient => recipient === '*') + for (const position of membersByPosition) { + if ( + broadcastsToAll || + memberMatches(position.member, activeMessage.from) || + memberMatches(position.member, activeMessage.to) || + activeMessage.recipients.some(recipient => memberMatches(position.member, recipient)) + ) { + activeParticipants.add(position.member.agentId) + } + } + } + const maxDepth = Math.max(0, ...layout.lanes.map(lane => lane.depth)) + const activeTethers = workerPositions + .filter(position => ( + position.workState === 'working' && + position.currentTask?.status === 'in_progress' && + layout.byId.has(position.currentTask.id) + )) + + return ( +
+
+
+
+ + + +
+ {t('agentTeams.canvas.formation')} +
+
+ {t('agentTeams.canvas.tasks')} +
+ + {layout.lanes.map(lane => { + const tasks = layout.tasks.filter(task => task.depth === lane.depth) + const completed = tasks.filter(task => task.state === 'completed').length + const title = lane.depth === 0 + ? t('agentTeams.canvas.laneRoot') + : lane.depth === maxDepth + ? t('agentTeams.canvas.laneWrapUp') + : t('agentTeams.canvas.laneDepth', { layer: lane.depth + 1 }) + return ( +
+ + {title} + + + {completed}/{lane.count} + +
+ ) + })} + + {membersByPosition.map(position => { + const blockedTask = position.isLead + ? undefined + : snapshot.tasks.find(task => ( + taskBelongsToMember(task, position.member, snapshot) && + getWorkbenchTaskState(task, new Map(snapshot.tasks.map(entry => [entry.id, entry]))) === 'blocked' + )) + const waitingDependency = blockedTask?.blockedBy.find(id => snapshot.tasks.find(task => task.id === id)?.status !== 'completed') + return ( + onSelectMember(position.member, position.isLead)} + t={t} + /> + ) + })} + + {layout.tasks.map(positioned => ( + setHoveredTaskId(positioned.task.id)} + onHoverEnd={() => setHoveredTaskId(current => current === positioned.task.id ? null : current)} + onFocus={() => setKeyboardFocusedTaskId(positioned.task.id)} + onBlur={() => setKeyboardFocusedTaskId(current => current === positioned.task.id ? null : current)} + onSelect={() => onSelectTask(positioned.task)} + t={t} + /> + ))} + + {routes.map(route => ( + + ))} + +
+ + + {t('agentTeams.legend.unmet')} + + + + {t('agentTeams.legend.satisfied')} + + + + {t('agentTeams.legend.tether')} + + + + + +
+
+
+ ) +} diff --git a/desktop/src/components/agentTeams/AgentTeamsCommunicationFeed.test.tsx b/desktop/src/components/agentTeams/AgentTeamsCommunicationFeed.test.tsx new file mode 100644 index 00000000..9ef6291c --- /dev/null +++ b/desktop/src/components/agentTeams/AgentTeamsCommunicationFeed.test.tsx @@ -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 & { 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + }) +}) diff --git a/desktop/src/components/agentTeams/AgentTeamsCommunicationFeed.tsx b/desktop/src/components/agentTeams/AgentTeamsCommunicationFeed.tsx new file mode 100644 index 00000000..af643855 --- /dev/null +++ b/desktop/src/components/agentTeams/AgentTeamsCommunicationFeed.tsx @@ -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 +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, + 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, + 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, + 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('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>( + (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 = [] + 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 ( +
+
+
+

+ {t('agentTeams.communication.title')} +

+ + {t('agentTeams.communication.count', { count: rows.length })} + +
+ +
+ {filters.map((option) => { + const selected = filter === option.key + return ( + + ) + })} +
+
+ +
+ {visibleRows.length === 0 ? ( +
+ {t('agentTeams.communication.empty')} +
+ ) : ( +
+ {visibleRows.map(({ message, body, category, repeats }, index) => ( + + ))} +
+ )} +
+
+ ) +} + +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 ( +
+
+
+
+ ) + } + + 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 ( +
+ {isLatest ? ( +
+ ) +} + +function ParticipantFigure({ + participant, + name, + nameTestId, + avatarTestId, +}: { + participant: MessageParticipant + name: string + nameTestId: string + avatarTestId: string +}) { + return ( + + + ) +} diff --git a/desktop/src/components/agentTeams/AgentTeamsMemberInspector.test.tsx b/desktop/src/components/agentTeams/AgentTeamsMemberInspector.test.tsx new file mode 100644 index 00000000..41e5fe38 --- /dev/null +++ b/desktop/src/components/agentTeams/AgentTeamsMemberInspector.test.tsx @@ -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 & { 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( + , + ) +} + +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) + }) +}) diff --git a/desktop/src/components/agentTeams/AgentTeamsMemberInspector.tsx b/desktop/src/components/agentTeams/AgentTeamsMemberInspector.tsx new file mode 100644 index 00000000..6a8c5ba4 --- /dev/null +++ b/desktop/src/components/agentTeams/AgentTeamsMemberInspector.tsx @@ -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 + +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() + 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 ( +
+
+
+ +
+ +
+ + + +
+

+ {name} +

+

+ {t('agentTeams.inspector.independentContext', { role: member.role })} +

+
+
+ +
+
+
+ {t('agentTeams.inspector.current')} +
+
+ + {currentStatusLabel} +
+
+
+
+ {t('agentTeams.inspector.completedTasks')} +
+
{completedTasks}
+
+
+
+ {t('agentTeams.inspector.messages')} +
+
{messages.length}
+
+
+
+ +
+
+

+ {t('agentTeams.inspector.taskHistory')} + + · {t('agentTeams.inspector.taskHistoryHint')} + +

+ {taskHistory.length > 0 ? ( +
    + {taskHistory.map(entry => { + const span = formatTaskSpan(entry) + return ( +
  1. + + #{entry.task.id} + + + {entry.task.subject} + + + {t(`agentTeams.task.${entry.state}` as TranslationKey)} + + +
  2. + ) + })} +
+ ) : ( +

+ {t('agentTeams.noMemberTasks')} +

+ )} +
+ +
+

+ {t('agentTeams.inspector.messages')} +

+ {messages.length > 0 ? ( +
    + {messages.map(row => { + const sent = row.direction === 'sent' + return ( +
  1. +
    + + {t(sent ? 'agentTeams.inspector.sent' : 'agentTeams.inspector.received')} + + {sent + ?
    + {row.body.kind === 'text' ? ( + + ) : ( +

    + {protocolNarration(row, snapshot, t)} +

    + )} +
  2. + ) + })} +
+ ) : ( +

+ {t('agentTeams.noMemberMessages')} +

+ )} +
+
+ +
+ +
+
+ ) +} diff --git a/desktop/src/components/agentTeams/AgentTeamsSummary.tsx b/desktop/src/components/agentTeams/AgentTeamsSummary.tsx new file mode 100644 index 00000000..8be8c423 --- /dev/null +++ b/desktop/src/components/agentTeams/AgentTeamsSummary.tsx @@ -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 ( + + ) +} + +/** + * 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 ( + + ) +} + +/** + * 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 ( +
+ + {children} +
+ ) +} diff --git a/desktop/src/components/agentTeams/AgentTeamsWorkbench.test.tsx b/desktop/src/components/agentTeams/AgentTeamsWorkbench.test.tsx new file mode 100644 index 00000000..1099f8eb --- /dev/null +++ b/desktop/src/components/agentTeams/AgentTeamsWorkbench.test.tsx @@ -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() + + 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() + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + }) +}) diff --git a/desktop/src/components/agentTeams/AgentTeamsWorkbench.tsx b/desktop/src/components/agentTeams/AgentTeamsWorkbench.tsx new file mode 100644 index 00000000..d8fc4571 --- /dev/null +++ b/desktop/src/components/agentTeams/AgentTeamsWorkbench.tsx @@ -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 + +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 = { + 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 ( +
+
{label}
+
+ {value} +
+
+ ) +} + +/** + * 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(null) + const [selectedTask, setSelectedTask] = useState(null) + const [replaySpeed, setReplaySpeed] = useState<(typeof REPLAY_SPEEDS)[number]>(1) + const [playing, setPlaying] = useState(false) + const [messageFlightQueue, setMessageFlightQueue] = useState([]) + const [feedFocusedTaskId, setFeedFocusedTaskId] = useState(null) + const [now, setNow] = useState(() => Date.now()) + const seenMessageIdsRef = useRef | 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 ( +
+ {timeline?.error || t('agentTeams.loading')} +
+ ) + } + + 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 ( +
+
+
+
+
+ Agent Teams · {t('agentTeams.sharedTaskList')} +
+
+ + {snapshot.team.name} + + + {t('agentTeams.experimental')} + +
+
+ +
+
+
+ {t('agentTeams.stats.phase')} +
+
+ {phaseLabel(phase, t)} +
+
+ + + + +
+ + {followingLive ? ( +
+
+
+ + {liveHint} + + +
+ +
+
+ +
+
+ ) : ( +
+ +
+ {REPLAY_SPEEDS.map((speed) => { + const selected = replaySpeed === speed + return ( + + ) + })} +
+
+
+ {t('agentTeams.replay.timeline')} + + {formatDuration(cursorTime - startTime)} / {formatDuration(totalElapsed)} + +
+
+
+
+ {timelineTickPositions.map((position, index) => ( +
+
+ {!latestSnapshot?.deletedAt ? ( + + ) : null} +
+ +
+
+ )} +
+
+ +
+
+ selectMember(member)} + onSelectTask={setSelectedTask} + /> +
+ + {communicationOpen ? ( + + ) : ( + + )} + + {selectedTask && snapshot.tasks.some(task => task.id === selectedTask.id) ? ( + task.id === selectedTask.id)!} + snapshot={snapshot} + communicationOpen={communicationOpen} + onClose={() => setSelectedTask(null)} + t={t} + /> + ) : null} +
+
+ ) +} + +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 ( + + ) +} diff --git a/desktop/src/components/agentTeams/AgentTeamsWorkbenchTab.test.tsx b/desktop/src/components/agentTeams/AgentTeamsWorkbenchTab.test.tsx new file mode 100644 index 00000000..49adc042 --- /dev/null +++ b/desktop/src/components/agentTeams/AgentTeamsWorkbenchTab.test.tsx @@ -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 { + 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() + + 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 ( + + ) + } + return + } + + render() + 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') + }) +}) diff --git a/desktop/src/components/agentTeams/AgentTeamsWorkbenchTab.tsx b/desktop/src/components/agentTeams/AgentTeamsWorkbenchTab.tsx new file mode 100644 index 00000000..cd7fe0a3 --- /dev/null +++ b/desktop/src/components/agentTeams/AgentTeamsWorkbenchTab.tsx @@ -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 ( +
+
+ +
+ +
+ ) +} diff --git a/desktop/src/components/agentTeams/agentTeamsAvatars.ts b/desktop/src/components/agentTeams/agentTeamsAvatars.ts new file mode 100644 index 00000000..81afba39 --- /dev/null +++ b/desktop/src/components/agentTeams/agentTeamsAvatars.ts @@ -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 = { + '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]! +} diff --git a/desktop/src/components/agentTeams/agentTeamsModel.test.ts b/desktop/src/components/agentTeams/agentTeamsModel.test.ts new file mode 100644 index 00000000..03571b19 --- /dev/null +++ b/desktop/src/components/agentTeams/agentTeamsModel.test.ts @@ -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('') + }) +}) diff --git a/desktop/src/components/agentTeams/agentTeamsModel.ts b/desktop/src/components/agentTeams/agentTeamsModel.ts new file mode 100644 index 00000000..231dc746 --- /dev/null +++ b/desktop/src/components/agentTeams/agentTeamsModel.ts @@ -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 + 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 + 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, +): 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 { + const byId = new Map(tasks.map((task) => [task.id, task])) + const depths = new Map() + const visiting = new Set() + + 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() + + 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() + 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 | 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 + : null + } catch { + return null + } +} + +function firstNonEmptyString( + record: Record, + 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 & + Partial>, +): 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 diff --git a/desktop/src/components/chat/ActivityGroup.test.tsx b/desktop/src/components/chat/ActivityGroup.test.tsx new file mode 100644 index 00000000..45cd41be --- /dev/null +++ b/desktop/src/components/chat/ActivityGroup.test.tsx @@ -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 +type ToolResult = Extract + +function toolCall(overrides: Partial & Pick): ToolCall { + return { + type: 'tool_use', + input: {}, + timestamp: 0, + ...overrides, + } +} + +function toolResult(overrides: Partial & Pick): 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 { + return new Map(results.map((result) => [result.toolUseId, result])) +} + +const t = (key: Parameters[1], params?: Record) => + 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( + , + ) + 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( + , + ) + 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) => ( + + ) + + 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() + 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() + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + expect(screen.getByText(t('toolGroup.failedCount', { count: 1 }))).toBeTruthy() + }) + + it('reports the run as running while any step is still unresolved', () => { + render( + , + ) + + // 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( + , + ) + + 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)']) + }) +}) diff --git a/desktop/src/components/chat/ActivityGroup.tsx b/desktop/src/components/chat/ActivityGroup.tsx new file mode 100644 index 00000000..34bc82a0 --- /dev/null +++ b/desktop/src/components/chat/ActivityGroup.tsx @@ -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 +type ToolResult = Extract + +type Props = { + steps: ActivityStep[] + resultMap: Map + childToolCallsByParent: Map + 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(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 ( +
+
+ +
+
+ ) + } + + 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 ( +
+
+ {/* + 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. + */} + + + {/* 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 && ( +
+ {steps.map((step) => step.kind === 'thinking' ? ( + + ) : ( + + ))} +
+ )} +
+
+ ) +}) + +/** A tool row plus, indented under it, the rows of anything it dispatched. */ +function ActivityToolRow({ + toolCall, + resultMap, + childToolCallsByParent, +}: { + toolCall: ToolCall + resultMap: Map + childToolCallsByParent: Map +}) { + const result = resultMap.get(toolCall.toolUseId) + const childToolCalls = childToolCallsByParent.get(toolCall.toolUseId) ?? [] + + return ( +
+ + {childToolCalls.length > 0 && ( +
+ {childToolCalls.map((childToolCall) => ( + + ))} +
+ )} +
+ ) +} diff --git a/desktop/src/components/chat/AskUserQuestion.tsx b/desktop/src/components/chat/AskUserQuestion.tsx index 0c2750da..e11b210e 100644 --- a/desktop/src/components/chat/AskUserQuestion.tsx +++ b/desktop/src/components/chat/AskUserQuestion.tsx @@ -245,7 +245,7 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props) if (!activeQuestion) return null return ( -
({ })) 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') }) diff --git a/desktop/src/components/chat/AssistantMessage.linkrouting.test.tsx b/desktop/src/components/chat/AssistantMessage.linkrouting.test.tsx index 3c4c03f9..9cf1c31a 100644 --- a/desktop/src/components/chat/AssistantMessage.linkrouting.test.tsx +++ b/desktop/src/components/chat/AssistantMessage.linkrouting.test.tsx @@ -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( + , + ) + + expect(container.querySelectorAll('img')).toHaveLength(0) + + rerender( + , + ) + + 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( + , + ) + + expect(container.querySelectorAll('img')).toHaveLength(0) + }) + it('renders a relative video inline (a