merge: bring main into the computer-use worktree
main is 87 commits ahead and carries a large amount of fixed behaviour this branch should not be re-deciding. The rule applied throughout: this worktree owns Computer Use, main owns everything else. Only 12 files were touched on both sides, and Git merged all of them without reporting a conflict — but two of those silent merges were wrong, and neither was visible until the checks ran. `desktop/src/api/client.ts` ended up with two `apiGetBlob` implementations. Both sides had independently hit the same problem (an `<img src>` pointed at an API endpoint is a cross-origin subresource, so it carries no Authorization header and the server's fetch-metadata policy refuses it) and both had written the same fix. Git saw two additions in different places and kept both, which does not even compile. main's version survives: it builds its headers through the shared `buildHeaders()` rather than assembling them inline, so it inherits whatever main adds there later. `src/server/api/computer-use.ts` still imported `runtime/mac_helper.py` and `runtime/requirements.txt` as compile-time text, both deleted on this branch. Nothing at runtime referenced them, which is why the deletion looked clean; the bundler resolves those imports when the server module is loaded, so the failure surfaced only when the tests actually imported it. That path is now Windows-only in the same sense the rest of the Python bridge is, and it also ships `win_cursor_badge.py`, which the badge needs because it runs as its own process. `computer-use-requirements.test.ts` drops its darwin half for the same reason — the pins it guards still matter, but only one requirements file is left. Verified: server 3869 tests / 331 files, desktop 4612 tests / 319 files (lint + tsc + build), Swift 272 XCTest + 14 Swift Testing, Python 25. Claude-Session: https://claude.ai/code/session_015j1yxxaoonyAS2iZ7qGnTS
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
Claude Code Haha is a **desktop Claude Code workspace** for macOS, Windows, and Linux: multi-session workspaces, global search, branch / Worktree launch, diff review, built-in browser preview, GUI permission approval, any model — Claude, ChatGPT, Grok, presets, or local endpoints — image generation, visual MCP & SubAgent managers, model trace, Computer Use, skill marketplace, colour themes, desktop pets, H5 remote access, IM integration, and scheduled tasks, all in one app.
|
||||
Claude Code Haha is a **desktop Claude Code workspace** for macOS, Windows, and Linux: multi-session workspaces, global search, branch / Worktree launch, diff review, built-in browser preview, GUI permission approval, any model — Claude, ChatGPT, Grok, presets, or local endpoints — image generation, visual MCP & SubAgent managers, an Agent Teams workbench, dynamic Workflow orchestration, model trace, Computer Use, skill marketplace, colour themes, desktop pets, H5 remote access, IM integration, and scheduled tasks, all in one app.
|
||||
|
||||
<p align="center">
|
||||
<a href="#desktop-preview">Desktop Preview</a> · <a href="#install-the-desktop-app">Install</a> · <a href="#desktop-highlights">Highlights</a> · <a href="#more-documentation">More Docs</a> · <a href="#sponsorship--partnership">Sponsorship</a> · <a href="#user-group">User Group</a>
|
||||
@@ -63,16 +63,6 @@ This project is maintained in the author's spare time. Corporate or individual s
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://teamorouter.com/?utm_source=cc_haha&utm_medium=referral&utm_campaign=ai_directory">
|
||||
<img src="docs/images/sponsors/teamorouter-logo.svg" width="180" alt="TeamoRouter">
|
||||
</a>
|
||||
</td>
|
||||
<td valign="middle">
|
||||
Thanks to <a href="https://teamorouter.com/?utm_source=cc_haha&utm_medium=referral&utm_campaign=ai_directory">TeamoRouter</a> for sponsoring this project. TeamoRouter is an enterprise-grade Agentic LLM gateway: run Claude Code, Codex, Gemini CLI, and other popular AI agents through one unified API with no subscription and up to 90% off official pricing. It aggregates hundreds of official model providers (OpenAI, Anthropic, Vertex, Azure, AWS Bedrock) verified for Agent protocol compatibility, with near-official TTFT, a 99.6% SLA, and industry-leading cache hit rates. It also adds centralized billing, team management, BYOK, usage analytics, and one-click Teamo Desktop. New users who register through <a href="https://teamorouter.com/?utm_source=cc_haha&utm_medium=referral&utm_campaign=ai_directory">this link</a> get 10% off their first top-up.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://www.xuanshuapi.com/register?aff=CC-HAHA&promo=CC-HAHA">
|
||||
@@ -103,6 +93,17 @@ This project is maintained in the author's spare time. Corporate or individual s
|
||||
Thanks to <a href="https://s.qiniu.com/IZbyya">Qiniu Cloud AI</a> for sponsoring this project. Qiniu Cloud AI is the enterprise-grade LLM MaaS platform from Qiniu Cloud (02567.HK): one entry point to 150+ leading models worldwide, compatible with the major vendors' protocols, covering text, image, audio, video, and file processing across every modality, and serving more than 1.69 million enterprises and developers. As a cc-haha user perk, registering through <a href="https://s.qiniu.com/IZbyya">this link</a> gets enterprise users 12M free tokens and developers 3M free tokens.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=cc-haha">
|
||||
<img src="docs/images/sponsors/atlascloud-logo-black.png#gh-light-mode-only" width="180" alt="Atlas Cloud">
|
||||
<img src="docs/images/sponsors/atlascloud-logo-white.png#gh-dark-mode-only" width="180" alt="Atlas Cloud">
|
||||
</a>
|
||||
</td>
|
||||
<td valign="middle">
|
||||
Thanks to <a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=cc-haha">Atlas Cloud</a> for sponsoring this project. Atlas Cloud is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. Atlas Cloud is already built into the cc-haha provider list, so you can pick it in settings and start using it with just an API key. Check out Atlas Cloud's new <a href="https://www.atlascloud.ai/console/coding-plan">coding plan promotion</a> for more budget-friendly API access.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -116,6 +117,8 @@ This project is maintained in the author's spare time. Corporate or individual s
|
||||
2. On first launch, configure your model provider, API key, and default model in Settings.
|
||||
3. Public macOS releases require signing and notarization. Draft or unsigned temporary builds may still need one-time manual approval. Unsigned Windows installers may show SmartScreen; click "More info" -> "Run anyway". See the [desktop installation guide](docs/en/start/install.md).
|
||||
|
||||
Release trust and privacy: [Code signing policy](docs/en/start/code-signing.md) · [Privacy and network access](docs/en/start/privacy.md)
|
||||
|
||||
## Run the CLI from Source
|
||||
|
||||
For users who want to debug the underlying CLI, server, or local development flow:
|
||||
@@ -145,6 +148,8 @@ See [environment variables](docs/en/cli/env.md) and [CLI setup](docs/en/cli/inde
|
||||
- **Skill marketplace**: discover, preview, and install third-party skills from ClawHub / SkillHub, with source and safety status shown up front.
|
||||
- **Session activity panel**: track task progress, background tasks, SubAgents, and sources in one side panel.
|
||||
- **Visual SubAgent manager**: create and tune SubAgents in a GUI — model, tools, and permission mode.
|
||||
- **Agent Teams workbench**: visualize multi-agent collaboration in the GUI — members, tasks, a communication feed, and a dependency-lane canvas.
|
||||
- **Dynamic Workflow orchestration**: the model writes and runs orchestration scripts on the fly, driving subagents concurrently or in pipelines, with phase views, interrupts, and resume.
|
||||
- **Model trace**: every model request is logged locally with status and timing — search and filter to diagnose stuck or failed calls.
|
||||
- **Computer Use**: let the agent take screenshots, click, type, and control desktop apps after authorization.
|
||||
- **Desktop pets**: Dada, Huhu, Bubu, and Huihui change what they do with the task at hand — or raise one of your own (off by default).
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
Claude Code Haha 是一个**桌面端 Claude Code 工作台**:多会话与全局搜索、分支 / Worktree 启动、Diff 审阅、内置浏览器预览、图形化权限审批、模型自选(Claude / ChatGPT / Grok / 预设 / 本地端点)、图片生成、MCP 与 SubAgent 可视化管理、模型请求追踪、Computer Use、技能市场、多主题、桌面宠物、H5 远程访问、IM 接入和定时任务,集中在一个 macOS / Windows / Linux APP 里。
|
||||
Claude Code Haha 是一个**桌面端 Claude Code 工作台**:多会话与全局搜索、分支 / Worktree 启动、Diff 审阅、内置浏览器预览、图形化权限审批、模型自选(Claude / ChatGPT / Grok / 预设 / 本地端点)、图片生成、MCP 与 SubAgent 可视化管理、Agent Teams 协作工作台、动态 Workflow 编排、模型请求追踪、Computer Use、技能市场、多主题、桌面宠物、H5 远程访问、IM 接入和定时任务,集中在一个 macOS / Windows / Linux APP 里。
|
||||
|
||||
<p align="center">
|
||||
<a href="#桌面端预览">桌面端预览</a> · <a href="#安装桌面端">安装桌面端</a> · <a href="#桌面端亮点">桌面端亮点</a> · <a href="#更多文档">更多文档</a> · <a href="#赞助与合作">赞助与合作</a> · <a href="#用户交流群">用户交流群</a>
|
||||
@@ -63,16 +63,6 @@ Claude Code Haha 是一个**桌面端 Claude Code 工作台**:多会话与全
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://teamorouter.com/?utm_source=cc_haha&utm_medium=referral&utm_campaign=ai_directory">
|
||||
<img src="docs/images/sponsors/teamorouter-logo.svg" width="180" alt="TeamoRouter">
|
||||
</a>
|
||||
</td>
|
||||
<td valign="middle">
|
||||
感谢 <a href="https://teamorouter.com/?utm_source=cc_haha&utm_medium=referral&utm_campaign=ai_directory">TeamoRouter</a> 赞助本项目!TeamoRouter 是面向开发者、AI 团队与企业的企业级 Agentic LLM 网关:无需订阅,通过一个统一 API 即可使用 Claude Code、Codex、Gemini CLI 等热门 AI Agent,价格最高可享 90% 折扣。平台聚合 OpenAI、Anthropic、Vertex、Azure、AWS Bedrock 等数百家官方模型提供商,全部经过 Agent 协议兼容验证,官方直连、非逆向,提供接近官方的 TTFT、99.6% SLA 与行业领先的缓存命中率;同时支持集中账单、团队管理、BYOK、用量分析,并可用 Teamo Desktop 一键接入。新用户通过 <a href="https://teamorouter.com/?utm_source=cc_haha&utm_medium=referral&utm_campaign=ai_directory">专属链接</a> 注册,首次充值可享 10% 折扣。
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://www.xuanshuapi.com/register?aff=CC-HAHA&promo=CC-HAHA">
|
||||
@@ -103,6 +93,17 @@ Claude Code Haha 是一个**桌面端 Claude Code 工作台**:多会话与全
|
||||
感谢 <a href="https://s.qiniu.com/IZbyya">七牛云 AI</a> 赞助本项目!七牛云 AI 是七牛云(02567.HK)旗下企业级大模型 MaaS 平台,一站式调用全球 150+ 主流模型,兼容全球主流模型厂商协议,覆盖文本、图像、音频、视频、文件处理等全模态处理能力,服务超过 169 万企业及开发者用户。作为 cc-haha 用户专属福利,通过 <a href="https://s.qiniu.com/IZbyya">专属链接</a> 注册,企业用户免费领 1200 万 Token,开发者免费领 300 万 Token。
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=cc-haha">
|
||||
<img src="docs/images/sponsors/atlascloud-logo-black.png#gh-light-mode-only" width="180" alt="Atlas Cloud">
|
||||
<img src="docs/images/sponsors/atlascloud-logo-white.png#gh-dark-mode-only" width="180" alt="Atlas Cloud">
|
||||
</a>
|
||||
</td>
|
||||
<td valign="middle">
|
||||
感谢 <a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=cc-haha">Atlas Cloud</a> 赞助本项目!Atlas Cloud 是一个全模态 AI 推理平台,让开发者通过统一的 AI API 访问视频生成、图像生成和 LLM API,无需分别维护多个厂商集成,即可调用 300+ 精选模型。cc-haha 已内置 Atlas Cloud 供应商预设,在设置里选择后填入 API Key 即可直接使用。Atlas Cloud 最新推出 <a href="https://www.atlascloud.ai/console/coding-plan">coding plan 优惠</a>,为开发者提供更具性价比的 API 访问预算。
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -116,6 +117,8 @@ Claude Code Haha 是一个**桌面端 Claude Code 工作台**:多会话与全
|
||||
2. 首次启动后,在桌面端设置里配置模型提供商、API Key 和默认模型。
|
||||
3. 正式 macOS Release 需要经过签名和公证;如果安装的是 draft/unsigned 临时包,首次打开可能仍需手动放行。Windows 未签名安装包可能出现 SmartScreen 提示,点「更多信息」→「仍要运行」即可。详见 [桌面端安装指南](docs/start/install.md)。
|
||||
|
||||
发布可信度与隐私:[Code signing policy](docs/start/code-signing.md) · [隐私与联网说明](docs/start/privacy.md)
|
||||
|
||||
## 从源码启动 CLI
|
||||
|
||||
适合想调试底层 CLI、服务端或自行开发的用户:
|
||||
@@ -145,6 +148,8 @@ cp .env.example .env
|
||||
- **技能市场**:发现、预览、安装 ClawHub / SkillHub 的第三方技能,来源和安全状态摆在明处。
|
||||
- **会话活动面板**:集中查看任务进度、后台任务、SubAgent 与来源。
|
||||
- **可视化 SubAgent 管理**:图形界面创建和调校子代理,选择模型、工具与权限模式。
|
||||
- **Agent Teams 协作工作台**:桌面端可视化多 Agent 协作团队——成员、任务、通信流和依赖泳道一目了然。
|
||||
- **动态 Workflow 编排**:模型当场编写并运行编排脚本,并发或流水线调度多个子代理,支持阶段视图、中断与断点续跑。
|
||||
- **模型请求追踪**:本地记录每轮模型请求的状态与耗时,可搜索筛选,快速定位卡死或失败调用。
|
||||
- **Computer Use**:让 Agent 在授权后截图、点击、输入并控制桌面应用。
|
||||
- **桌面宠物**:搭搭、弧弧、补补、回回随任务状态换动作,也能自己做一只(默认关闭)。
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test'
|
||||
import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createAdapterClient } from '../adapter-client.js'
|
||||
import { loadConfig } from '../config.js'
|
||||
|
||||
const ADAPTERS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
|
||||
const PLATFORMS = ['telegram', 'feishu', 'wechat', 'dingtalk', 'whatsapp'] as const
|
||||
const HOME = fs.realpathSync(os.homedir())
|
||||
|
||||
const ORIGINAL_ENV = {
|
||||
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
||||
ADAPTER_ALLOWED_PROJECT_ROOTS: process.env.ADAPTER_ALLOWED_PROJECT_ROOTS,
|
||||
ADAPTER_DEFAULT_PROJECT_DIR: process.env.ADAPTER_DEFAULT_PROJECT_DIR,
|
||||
CLAUDE_ADAPTER_DEFAULT_WORK_DIR: process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR,
|
||||
PWD: process.env.PWD,
|
||||
}
|
||||
const ORIGINAL_CWD = process.cwd()
|
||||
const ORIGINAL_FETCH = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
process.chdir(ORIGINAL_CWD)
|
||||
globalThis.fetch = ORIGINAL_FETCH
|
||||
})
|
||||
|
||||
/** Boot an adapter config from a throwaway config dir with a clean env. */
|
||||
function bootConfig(file: Record<string, unknown>): ReturnType<typeof loadConfig> {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-client-cfg-'))
|
||||
fs.writeFileSync(path.join(configDir, 'adapters.json'), JSON.stringify(file))
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
delete process.env.ADAPTER_ALLOWED_PROJECT_ROOTS
|
||||
delete process.env.ADAPTER_DEFAULT_PROJECT_DIR
|
||||
delete process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR
|
||||
return loadConfig()
|
||||
}
|
||||
|
||||
/** What the bot would actually show for /projects, given what the server returns. */
|
||||
async function listedProjects(
|
||||
client: { listRecentProjects: () => Promise<{ projectName: string }[]> },
|
||||
projects: { projectName: string; realPath: string }[],
|
||||
): Promise<string[]> {
|
||||
globalThis.fetch = mock(() => Promise.resolve(Response.json({ projects }))) as any
|
||||
return (await client.listRecentProjects()).map((p) => p.projectName)
|
||||
}
|
||||
|
||||
describe('createAdapterClient', () => {
|
||||
// The regression that started #1191, now pinned behaviourally rather than by
|
||||
// grepping the entrypoints.
|
||||
it('keeps every project under the allowed root reachable when a default project is set', async () => {
|
||||
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-client-roots-'))
|
||||
const allowedRoot = path.join(sandbox, 'allowed')
|
||||
const outside = path.join(sandbox, 'outside')
|
||||
try {
|
||||
const myApp = path.join(allowedRoot, 'work', 'my-app')
|
||||
const sibling = path.join(allowedRoot, 'side', 'blog')
|
||||
for (const dir of [myApp, sibling, outside]) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
for (const platform of PLATFORMS) {
|
||||
const config = bootConfig({
|
||||
defaultProjectDir: myApp,
|
||||
allowedProjectRoots: [allowedRoot],
|
||||
})
|
||||
const { httpClient, defaultWorkDir } = createAdapterClient(config, config[platform])
|
||||
|
||||
expect(defaultWorkDir).toBe(fs.realpathSync(myApp))
|
||||
const names = await listedProjects(httpClient, [
|
||||
{ projectName: 'my-app', realPath: myApp },
|
||||
{ projectName: 'blog', realPath: sibling },
|
||||
{ projectName: 'not-mine', realPath: outside },
|
||||
])
|
||||
expect(names).toEqual(['my-app', 'blog'])
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(sandbox, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// A GUI-launched sidecar inherits cwd "/" (Electron passes no cwd). Inheriting
|
||||
// that as a boundary would allow the whole filesystem while the docs and the
|
||||
// settings UI both promise "your home directory".
|
||||
it('never inherits a filesystem root as the boundary', async () => {
|
||||
process.chdir('/')
|
||||
delete process.env.PWD
|
||||
|
||||
for (const platform of PLATFORMS) {
|
||||
const config = bootConfig({})
|
||||
const { httpClient, defaultWorkDir } = createAdapterClient(config, config[platform])
|
||||
|
||||
const names = await listedProjects(httpClient, [
|
||||
{ projectName: 'etc', realPath: '/etc' },
|
||||
{ projectName: 'home-project', realPath: HOME },
|
||||
])
|
||||
expect(names).toEqual(['home-project'])
|
||||
expect(defaultWorkDir).toBe(HOME)
|
||||
}
|
||||
})
|
||||
|
||||
// Narrowing the roots must not brick /new: the client rejects a workDir outside
|
||||
// the boundary, and every adapter passes defaultWorkDir straight to createSession.
|
||||
it('always yields a default work dir inside the allowed roots', async () => {
|
||||
const allowed = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-allowed-'))
|
||||
const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-elsewhere-'))
|
||||
try {
|
||||
process.chdir('/')
|
||||
delete process.env.PWD
|
||||
|
||||
for (const platform of PLATFORMS) {
|
||||
// Boundary narrowed to one dir, default project pointing somewhere else.
|
||||
const config = bootConfig({ allowedProjectRoots: [allowed], defaultProjectDir: elsewhere })
|
||||
const { httpClient, defaultWorkDir } = createAdapterClient(config, config[platform])
|
||||
|
||||
expect(defaultWorkDir).toBe(fs.realpathSync(allowed))
|
||||
globalThis.fetch = mock(() => Promise.resolve(Response.json({ sessionId: 'ok' }))) as any
|
||||
await expect(httpClient.createSession(defaultWorkDir)).resolves.toBe('ok')
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(allowed, { recursive: true, force: true })
|
||||
fs.rmSync(elsewhere, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('honours an explicitly narrowed boundary', async () => {
|
||||
const allowed = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-allowed-'))
|
||||
const denied = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-denied-'))
|
||||
try {
|
||||
const config = bootConfig({ allowedProjectRoots: [allowed] })
|
||||
const { httpClient } = createAdapterClient(config, config.feishu)
|
||||
|
||||
const names = await listedProjects(httpClient, [
|
||||
{ projectName: 'allowed', realPath: allowed },
|
||||
{ projectName: 'denied', realPath: denied },
|
||||
{ projectName: 'home', realPath: HOME },
|
||||
])
|
||||
expect(names).toEqual(['allowed'])
|
||||
} finally {
|
||||
fs.rmSync(allowed, { recursive: true, force: true })
|
||||
fs.rmSync(denied, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Structural guard for the five entrypoints. They boot a live bot on import
|
||||
* (credentials are read and process.exit is called), so they cannot be imported
|
||||
* in a test. The behaviour above is covered by exercising the factory directly;
|
||||
* this only pins that each entrypoint actually delegates to it.
|
||||
*/
|
||||
describe('IM adapter entrypoint wiring', () => {
|
||||
for (const platform of PLATFORMS) {
|
||||
it(`${platform} builds its client through createAdapterClient`, () => {
|
||||
const source = fs.readFileSync(path.join(ADAPTERS_DIR, platform, 'index.ts'), 'utf-8')
|
||||
// Strip comments so a mention in prose cannot satisfy the assertions.
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/.*$/gm, '$1')
|
||||
|
||||
expect(source).toMatch(
|
||||
new RegExp(`createAdapterClient\\s*\\(\\s*config\\s*,\\s*config\\.${platform}\\s*\\)`),
|
||||
)
|
||||
// Constructing a client here would bypass the resolved boundary entirely,
|
||||
// which is exactly how all five adapters shared the #1191 defect.
|
||||
expect(source).not.toMatch(/new\s+AdapterHttpClient/)
|
||||
// Nor may an entrypoint re-derive the boundary or the work dir itself.
|
||||
expect(source).not.toMatch(/resolveAllowedProjectRoots|getConfiguredWorkDir/)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { getConfiguredWorkDir, loadConfig } from '../config.js'
|
||||
import { getConfiguredWorkDir, loadConfig, resolveAllowedProjectRoots } from '../config.js'
|
||||
|
||||
describe('adapter config defaults', () => {
|
||||
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
@@ -130,6 +130,189 @@ describe('adapter config defaults', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveAllowedProjectRoots', () => {
|
||||
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
const originalEnvRoots = process.env.ADAPTER_ALLOWED_PROJECT_ROOTS
|
||||
const originalAdapterDefaultWorkDir = process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR
|
||||
const originalPwd = process.env.PWD
|
||||
const home = fs.realpathSync(os.homedir())
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv('CLAUDE_CONFIG_DIR', originalConfigDir)
|
||||
restoreEnv('ADAPTER_ALLOWED_PROJECT_ROOTS', originalEnvRoots)
|
||||
restoreEnv('CLAUDE_ADAPTER_DEFAULT_WORK_DIR', originalAdapterDefaultWorkDir)
|
||||
restoreEnv('PWD', originalPwd)
|
||||
})
|
||||
|
||||
function withConfig<T>(file: Record<string, unknown>, run: (configDir: string) => T): T {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-'))
|
||||
try {
|
||||
fs.writeFileSync(path.join(configDir, 'adapters.json'), JSON.stringify(file))
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
delete process.env.ADAPTER_ALLOWED_PROJECT_ROOTS
|
||||
return run(configDir)
|
||||
} finally {
|
||||
fs.rmSync(configDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
// The #1191 regression: `defaultProjectDir` is the default work dir for NEW
|
||||
// sessions, not the boundary. Deriving the only allowed root from it hid every
|
||||
// other project from /projects on all five IM channels.
|
||||
it('does not collapse the boundary onto the configured default project', () => {
|
||||
const defaultProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-project-'))
|
||||
try {
|
||||
withConfig({ defaultProjectDir }, () => {
|
||||
const config = loadConfig()
|
||||
for (const platform of [config.telegram, config.feishu, config.wechat, config.dingtalk, config.whatsapp]) {
|
||||
const roots = resolveAllowedProjectRoots(config, platform)
|
||||
expect(roots).not.toEqual([fs.realpathSync(defaultProjectDir)])
|
||||
expect(roots).toContain(home)
|
||||
expect(roots).toContain(fs.realpathSync(defaultProjectDir))
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
fs.rmSync(defaultProjectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('defaults to the home directory so sibling projects stay reachable', () => {
|
||||
withConfig({}, () => {
|
||||
const config = loadConfig()
|
||||
expect(resolveAllowedProjectRoots(config, config.feishu)).toContain(home)
|
||||
})
|
||||
})
|
||||
|
||||
it('uses explicitly configured global roots instead of the default', () => {
|
||||
const rootA = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-root-a-'))
|
||||
const rootB = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-root-b-'))
|
||||
try {
|
||||
withConfig({ allowedProjectRoots: [rootA, rootB] }, () => {
|
||||
const config = loadConfig()
|
||||
expect(resolveAllowedProjectRoots(config, config.feishu)).toEqual([
|
||||
fs.realpathSync(rootA),
|
||||
fs.realpathSync(rootB),
|
||||
])
|
||||
})
|
||||
} finally {
|
||||
fs.rmSync(rootA, { recursive: true, force: true })
|
||||
fs.rmSync(rootB, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('lets a platform narrow the global roots', () => {
|
||||
const globalRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-root-global-'))
|
||||
const feishuRoot = fs.mkdtempSync(path.join(globalRoot, 'feishu-'))
|
||||
try {
|
||||
withConfig({ allowedProjectRoots: [globalRoot], feishu: { allowedProjectRoots: [feishuRoot] } }, () => {
|
||||
const config = loadConfig()
|
||||
expect(resolveAllowedProjectRoots(config, config.feishu)).toEqual([fs.realpathSync(feishuRoot)])
|
||||
// Other platforms keep the global roots.
|
||||
expect(resolveAllowedProjectRoots(config, config.telegram)).toEqual([fs.realpathSync(globalRoot)])
|
||||
})
|
||||
} finally {
|
||||
fs.rmSync(globalRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// A relative entry would resolve against the sidecar's cwd — "/" for a
|
||||
// GUI-launched app — making the boundary depend on how the app was started.
|
||||
it('rejects relative roots', () => {
|
||||
const realRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-root-'))
|
||||
try {
|
||||
withConfig({ allowedProjectRoots: ['..', 'relative/path', realRoot] }, () => {
|
||||
const config = loadConfig()
|
||||
expect(resolveAllowedProjectRoots(config, config.feishu)).toEqual([fs.realpathSync(realRoot)])
|
||||
})
|
||||
} finally {
|
||||
fs.rmSync(realRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not warn about duplicates as if they were missing', () => {
|
||||
const realRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-root-'))
|
||||
const warnings: string[] = []
|
||||
const originalWarn = console.warn
|
||||
console.warn = (...args: unknown[]) => { warnings.push(args.join(' ')) }
|
||||
try {
|
||||
withConfig({ allowedProjectRoots: [realRoot, realRoot, '~', os.homedir()] }, () => {
|
||||
const config = loadConfig()
|
||||
expect(resolveAllowedProjectRoots(config, config.feishu)).toEqual([
|
||||
fs.realpathSync(realRoot),
|
||||
home,
|
||||
])
|
||||
})
|
||||
expect(warnings.filter((line) => line.includes('do not exist') || line.includes('does not exist')))
|
||||
.toEqual([])
|
||||
} finally {
|
||||
console.warn = originalWarn
|
||||
fs.rmSync(realRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('expands ~ and drops entries that do not exist', () => {
|
||||
const realRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-root-'))
|
||||
try {
|
||||
withConfig({ allowedProjectRoots: [realRoot, path.join(os.tmpdir(), 'definitely-missing-root'), '~'] }, () => {
|
||||
const config = loadConfig()
|
||||
expect(resolveAllowedProjectRoots(config, config.feishu)).toEqual([fs.realpathSync(realRoot), home])
|
||||
})
|
||||
} finally {
|
||||
fs.rmSync(realRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// Failing closed here would brick every IM command on a typo. The pairing gate
|
||||
// is the primary authorization control; these roots are defense-in-depth.
|
||||
it('falls back to the default when no configured root exists', () => {
|
||||
withConfig({ allowedProjectRoots: [path.join(os.tmpdir(), 'missing-a'), path.join(os.tmpdir(), 'missing-b')] }, () => {
|
||||
const config = loadConfig()
|
||||
const roots = resolveAllowedProjectRoots(config, config.feishu)
|
||||
expect(roots).toContain(home)
|
||||
expect(roots.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('reads roots from ADAPTER_ALLOWED_PROJECT_ROOTS for standalone runs', () => {
|
||||
const rootA = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-env-root-a-'))
|
||||
const rootB = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-env-root-b-'))
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-'))
|
||||
try {
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
process.env.ADAPTER_ALLOWED_PROJECT_ROOTS = [rootA, rootB].join(path.delimiter)
|
||||
|
||||
const config = loadConfig()
|
||||
expect(resolveAllowedProjectRoots(config, config.feishu)).toEqual([
|
||||
fs.realpathSync(rootA),
|
||||
fs.realpathSync(rootB),
|
||||
])
|
||||
} finally {
|
||||
fs.rmSync(rootA, { recursive: true, force: true })
|
||||
fs.rmSync(rootB, { recursive: true, force: true })
|
||||
fs.rmSync(configDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('lets the env override win over both file scopes', () => {
|
||||
const envRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-env-root-'))
|
||||
const fileRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-file-root-'))
|
||||
try {
|
||||
withConfig(
|
||||
{ allowedProjectRoots: [fileRoot], feishu: { allowedProjectRoots: [fileRoot] } },
|
||||
() => {
|
||||
process.env.ADAPTER_ALLOWED_PROJECT_ROOTS = envRoot
|
||||
const config = loadConfig()
|
||||
expect(resolveAllowedProjectRoots(config, config.feishu)).toEqual([fs.realpathSync(envRoot)])
|
||||
expect(resolveAllowedProjectRoots(config, config.telegram)).toEqual([fs.realpathSync(envRoot)])
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(envRoot, { recursive: true, force: true })
|
||||
fs.rmSync(fileRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
|
||||
@@ -85,6 +85,50 @@ describe('AdapterHttpClient', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// #1191: /projects showed only the default project on every IM channel because
|
||||
// the allowed root was the default work dir itself. With the boundary resolved
|
||||
// from the user's home instead, sibling projects must survive the filter.
|
||||
it('keeps sibling projects that live outside the default work dir', async () => {
|
||||
const homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'im-home-'))
|
||||
try {
|
||||
const defaultWorkDir = path.join(homeRoot, 'work', 'my-app')
|
||||
const sibling = path.join(homeRoot, 'work', 'other-app')
|
||||
const elsewhere = path.join(homeRoot, 'side', 'blog')
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'im-outside-'))
|
||||
for (const dir of [defaultWorkDir, sibling, elsewhere]) fs.mkdirSync(dir, { recursive: true })
|
||||
|
||||
// The roots an adapter now gets from resolveAllowedProjectRoots(): the home
|
||||
// directory, not the default work dir.
|
||||
client = new AdapterHttpClient('ws://127.0.0.1:3456', {
|
||||
allowedProjectRoots: [homeRoot, defaultWorkDir],
|
||||
})
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(Response.json({
|
||||
projects: [
|
||||
{ projectName: 'my-app', realPath: defaultWorkDir, sessionCount: 9 },
|
||||
{ projectName: 'other-app', realPath: sibling, sessionCount: 4 },
|
||||
{ projectName: 'blog', realPath: elsewhere, sessionCount: 2 },
|
||||
{ projectName: 'not-mine', realPath: outside, sessionCount: 1 },
|
||||
],
|
||||
}))
|
||||
) as any
|
||||
|
||||
const projects = await client.listRecentProjects()
|
||||
expect(projects.map((p) => p.projectName)).toEqual(['my-app', 'other-app', 'blog'])
|
||||
|
||||
// Picking any of them by name must work too — matchProject shares the filter.
|
||||
await expect(client.matchProject('blog')).resolves.toMatchObject({
|
||||
project: { projectName: 'blog' },
|
||||
})
|
||||
// The boundary still holds for anything outside it.
|
||||
await expect(client.matchProject('not-mine')).resolves.toEqual({})
|
||||
|
||||
fs.rmSync(outside, { recursive: true, force: true })
|
||||
} finally {
|
||||
fs.rmSync(homeRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('filters recent projects before index, name, and fuzzy matching', async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-'))
|
||||
const allowedDir = fs.mkdtempSync(path.join(rootDir, 'allowed-'))
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test'
|
||||
import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { extractDingTalkText, type DingTalkRobotMessage } from '../../dingtalk/helpers.js'
|
||||
import { extractInboundPayload } from '../../feishu/extract-payload.js'
|
||||
import { AdapterHttpClient, type RecentProject } from '../http-client.js'
|
||||
import {
|
||||
formatProjectSelectionOutcome,
|
||||
ProjectSelectionController,
|
||||
ProjectSelectionRouter,
|
||||
} from '../project-selection-router.js'
|
||||
|
||||
const ORIGINAL_FETCH = globalThis.fetch
|
||||
const ADAPTERS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = ORIGINAL_FETCH
|
||||
})
|
||||
|
||||
type TransportFixture = {
|
||||
name: string
|
||||
extractText: (command: string) => string
|
||||
}
|
||||
|
||||
const TRANSPORTS: TransportFixture[] = [
|
||||
{
|
||||
name: 'DingTalk',
|
||||
extractText: (command) => extractDingTalkText({
|
||||
msgtype: 'text',
|
||||
conversationType: '1',
|
||||
senderStaffId: 'ding-user',
|
||||
text: { content: command },
|
||||
} satisfies DingTalkRobotMessage),
|
||||
},
|
||||
{
|
||||
name: 'Feishu',
|
||||
extractText: (command) => extractInboundPayload(
|
||||
JSON.stringify({ text: command }),
|
||||
'text',
|
||||
).text,
|
||||
},
|
||||
]
|
||||
|
||||
function recentProject(projectName: string, realPath: string): RecentProject {
|
||||
return {
|
||||
projectPath: realPath,
|
||||
realPath,
|
||||
projectName,
|
||||
isGit: false,
|
||||
repoName: null,
|
||||
branch: null,
|
||||
modifiedAt: '2026-08-10T00:00:00.000Z',
|
||||
sessionCount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
function createProjectController(
|
||||
client: AdapterHttpClient,
|
||||
defaultWorkDir: string,
|
||||
): ProjectSelectionController {
|
||||
return new ProjectSelectionController({
|
||||
httpClient: client,
|
||||
defaultWorkDir,
|
||||
prepareNewSession: () => {},
|
||||
createSession: async (_chatId, workDir) => {
|
||||
await client.createSession(workDir)
|
||||
return true
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('IM project selection flow', () => {
|
||||
it('keeps both adapter entrypoints wired to the tested controller', () => {
|
||||
for (const platform of ['dingtalk', 'feishu']) {
|
||||
const source = fs.readFileSync(path.join(ADAPTERS_DIR, platform, 'index.ts'), 'utf8')
|
||||
expect(source).toContain('projectSelectionController.listProjects(chatId)')
|
||||
expect(source).toContain('projectSelectionController.handleInput(chatId,')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps bare picker replies while explicit commands retain priority', () => {
|
||||
const router = new ProjectSelectionRouter()
|
||||
router.markPickerShown('chat-1')
|
||||
|
||||
expect(router.route('chat-1', '/new express')).toEqual({ kind: 'new', query: 'express' })
|
||||
expect(router.route('chat-1', '1')).toEqual({ kind: 'picker_reply', query: '1' })
|
||||
expect(router.route('chat-1', '/projects')).toBeNull()
|
||||
expect(router.route('chat-1', '/help')).toBeNull()
|
||||
|
||||
router.clear('chat-1')
|
||||
expect(router.route('chat-1', '1')).toBeNull()
|
||||
})
|
||||
|
||||
it('drives picker state through listing and clears it after the selected project is created', async () => {
|
||||
const events: string[] = []
|
||||
const project = recentProject('express', '/allowed/express')
|
||||
const controller = new ProjectSelectionController({
|
||||
httpClient: {
|
||||
listRecentProjects: async () => {
|
||||
events.push('list')
|
||||
return [project]
|
||||
},
|
||||
matchProject: async (query) => {
|
||||
events.push(`match:${query}`)
|
||||
return { project }
|
||||
},
|
||||
},
|
||||
defaultWorkDir: '/allowed/default',
|
||||
prepareNewSession: (chatId) => {
|
||||
events.push(`prepare:${chatId}`)
|
||||
},
|
||||
createSession: async (chatId, workDir) => {
|
||||
events.push(`create:${chatId}:${workDir}`)
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
expect(await controller.listProjects('chat-1')).toEqual([project])
|
||||
expect(await controller.handleInput('chat-1', '1')).toEqual({ kind: 'created', project })
|
||||
expect(events).toEqual([
|
||||
'list',
|
||||
'prepare:chat-1',
|
||||
'match:1',
|
||||
'create:chat-1:/allowed/express',
|
||||
])
|
||||
expect(await controller.handleInput('chat-1', '1')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps default, failed, and errored session outcomes explicit', async () => {
|
||||
const createdWorkDirs: string[] = []
|
||||
const controller = new ProjectSelectionController({
|
||||
httpClient: {
|
||||
listRecentProjects: async () => [],
|
||||
matchProject: async () => {
|
||||
throw new Error('project lookup failed')
|
||||
},
|
||||
},
|
||||
defaultWorkDir: '/allowed/default',
|
||||
prepareNewSession: () => {},
|
||||
createSession: async (_chatId, workDir) => {
|
||||
createdWorkDirs.push(workDir)
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
expect(await controller.handleInput('chat-1', 'ordinary message')).toBeNull()
|
||||
expect(await controller.listProjects('chat-1')).toEqual([])
|
||||
expect(await controller.handleInput('chat-1', '1')).toBeNull()
|
||||
|
||||
const created = await controller.handleInput('chat-1', '/new')
|
||||
expect(created).toEqual({ kind: 'created' })
|
||||
expect(formatProjectSelectionOutcome(created!)).toBe('✅ 已新建会话,可以开始对话了。')
|
||||
expect(createdWorkDirs).toEqual(['/allowed/default'])
|
||||
|
||||
const errored = await controller.handleInput('chat-1', '/new express')
|
||||
expect(errored).toEqual({ kind: 'error', message: 'project lookup failed' })
|
||||
expect(formatProjectSelectionOutcome(errored!)).toBe('❌ project lookup failed')
|
||||
|
||||
const failingController = new ProjectSelectionController({
|
||||
httpClient: {
|
||||
listRecentProjects: async () => [],
|
||||
matchProject: async () => ({ project: recentProject('express', '/allowed/express') }),
|
||||
},
|
||||
defaultWorkDir: '/allowed/default',
|
||||
prepareNewSession: () => {},
|
||||
createSession: async () => false,
|
||||
})
|
||||
const failed = await failingController.handleInput('chat-2', '/new express')
|
||||
expect(failed).toEqual({ kind: 'creation_failed' })
|
||||
expect(formatProjectSelectionOutcome(failed!)).toBeNull()
|
||||
})
|
||||
|
||||
it('routes DingTalk and Feishu /new name and index commands to the same canonical cwd after /projects', async () => {
|
||||
const allowedRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'im-project-flow-'))
|
||||
const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'im-project-outside-'))
|
||||
const expressDir = path.join(allowedRoot, 'express')
|
||||
const taskBoardDir = path.join(allowedRoot, 'task-board')
|
||||
const expressApiDir = path.join(allowedRoot, 'express-api')
|
||||
const expressWebDir = path.join(allowedRoot, 'express-web')
|
||||
fs.mkdirSync(expressDir)
|
||||
fs.mkdirSync(taskBoardDir)
|
||||
fs.mkdirSync(expressApiDir)
|
||||
fs.mkdirSync(expressWebDir)
|
||||
const canonicalExpressDir = fs.realpathSync(expressDir)
|
||||
const canonicalTaskBoardDir = fs.realpathSync(taskBoardDir)
|
||||
const canonicalExpressApiDir = fs.realpathSync(expressApiDir)
|
||||
const canonicalExpressWebDir = fs.realpathSync(expressWebDir)
|
||||
const canonicalOutsideRoot = fs.realpathSync(outsideRoot)
|
||||
|
||||
const projects = [
|
||||
recentProject('outside', canonicalOutsideRoot),
|
||||
recentProject('express', canonicalExpressDir),
|
||||
recentProject('task-board', canonicalTaskBoardDir),
|
||||
recentProject('express-api', canonicalExpressApiDir),
|
||||
recentProject('express-web', canonicalExpressWebDir),
|
||||
]
|
||||
const createdWorkDirs: string[] = []
|
||||
|
||||
globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/api/sessions/recent-projects')) {
|
||||
return Response.json({ projects })
|
||||
}
|
||||
if (url.endsWith('/api/sessions') && init?.method === 'POST') {
|
||||
const body = JSON.parse(String(init.body)) as { workDir: string }
|
||||
createdWorkDirs.push(body.workDir)
|
||||
return Response.json({ sessionId: `session-${createdWorkDirs.length}` }, { status: 201 })
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
try {
|
||||
const client = new AdapterHttpClient('ws://127.0.0.1:3456', {
|
||||
allowedProjectRoots: [allowedRoot],
|
||||
})
|
||||
|
||||
for (const transport of TRANSPORTS) {
|
||||
for (const command of [
|
||||
'/new express',
|
||||
'/new 1',
|
||||
' /new ExPrEsS ',
|
||||
] as const) {
|
||||
const chatId = `${transport.name}:${command}`
|
||||
const controller = createProjectController(client, canonicalExpressDir)
|
||||
const listed = await controller.listProjects(chatId)
|
||||
expect(listed.map((project) => project.projectName)).toEqual([
|
||||
'express',
|
||||
'task-board',
|
||||
'express-api',
|
||||
'express-web',
|
||||
])
|
||||
|
||||
const outcome = await controller.handleInput(chatId, transport.extractText(command))
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'created',
|
||||
project: {
|
||||
projectName: 'express',
|
||||
realPath: canonicalExpressDir,
|
||||
},
|
||||
})
|
||||
expect(formatProjectSelectionOutcome(outcome!)).toContain('**express**')
|
||||
}
|
||||
}
|
||||
|
||||
expect(createdWorkDirs).toEqual(Array(6).fill(canonicalExpressDir))
|
||||
|
||||
for (const transport of TRANSPORTS) {
|
||||
const chatId = `${transport.name}:ambiguous`
|
||||
const controller = createProjectController(client, canonicalExpressDir)
|
||||
const listed = await controller.listProjects(chatId)
|
||||
expect(listed).toHaveLength(4)
|
||||
|
||||
const outcome = await controller.handleInput(chatId, transport.extractText('/new express-'))
|
||||
expect(outcome?.kind).toBe('ambiguous')
|
||||
if (outcome?.kind !== 'ambiguous') throw new Error('Expected ambiguous project outcome')
|
||||
expect(outcome.projects.map((project) => project.projectName)).toEqual([
|
||||
'express-api',
|
||||
'express-web',
|
||||
])
|
||||
const prompt = formatProjectSelectionOutcome(outcome) ?? ''
|
||||
expect(prompt).toContain('/new <更完整名称或路径>')
|
||||
expect(prompt).toContain(`**express-api** — ${canonicalExpressApiDir}`)
|
||||
expect(prompt).toContain(`**express-web** — ${canonicalExpressWebDir}`)
|
||||
}
|
||||
|
||||
for (const transport of TRANSPORTS) {
|
||||
for (const query of ['outside', canonicalOutsideRoot]) {
|
||||
const chatId = `${transport.name}:outside:${query}`
|
||||
const controller = createProjectController(client, canonicalExpressDir)
|
||||
const listed = await controller.listProjects(chatId)
|
||||
expect(listed.some((project) => project.projectName === 'outside')).toBe(false)
|
||||
|
||||
const outcome = await controller.handleInput(
|
||||
chatId,
|
||||
transport.extractText(`/new ${query}`),
|
||||
)
|
||||
expect(outcome).toEqual({ kind: 'not_found', query })
|
||||
expect(formatProjectSelectionOutcome(outcome!)).toContain(`未找到匹配 "${query}"`)
|
||||
}
|
||||
}
|
||||
|
||||
expect(createdWorkDirs).toEqual(Array(6).fill(canonicalExpressDir))
|
||||
} finally {
|
||||
fs.rmSync(allowedRoot, { recursive: true, force: true })
|
||||
fs.rmSync(outsideRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('prompts instead of choosing arbitrarily when exact project names are duplicated', async () => {
|
||||
const allowedRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'im-project-duplicates-'))
|
||||
const firstDir = path.join(allowedRoot, 'team-a', 'express')
|
||||
const secondDir = path.join(allowedRoot, 'team-b', 'express')
|
||||
fs.mkdirSync(firstDir, { recursive: true })
|
||||
fs.mkdirSync(secondDir, { recursive: true })
|
||||
const canonicalFirstDir = fs.realpathSync(firstDir)
|
||||
const canonicalSecondDir = fs.realpathSync(secondDir)
|
||||
const projects = [
|
||||
recentProject('express', canonicalFirstDir),
|
||||
recentProject('express', canonicalSecondDir),
|
||||
]
|
||||
let createRequests = 0
|
||||
|
||||
globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/api/sessions/recent-projects')) {
|
||||
return Response.json({ projects })
|
||||
}
|
||||
if (url.endsWith('/api/sessions') && init?.method === 'POST') {
|
||||
createRequests += 1
|
||||
return Response.json({ sessionId: 'unexpected-session' }, { status: 201 })
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
try {
|
||||
const client = new AdapterHttpClient('ws://127.0.0.1:3456', {
|
||||
allowedProjectRoots: [allowedRoot],
|
||||
})
|
||||
|
||||
for (const transport of TRANSPORTS) {
|
||||
const chatId = `${transport.name}:duplicate-express`
|
||||
const controller = createProjectController(client, canonicalFirstDir)
|
||||
await controller.listProjects(chatId)
|
||||
|
||||
const outcome = await controller.handleInput(
|
||||
chatId,
|
||||
transport.extractText('/new express'),
|
||||
)
|
||||
expect(outcome?.kind).toBe('ambiguous')
|
||||
if (outcome?.kind !== 'ambiguous') throw new Error('Expected ambiguous project outcome')
|
||||
expect(outcome.projects.map((project) => project.realPath)).toEqual([
|
||||
canonicalFirstDir,
|
||||
canonicalSecondDir,
|
||||
])
|
||||
const prompt = formatProjectSelectionOutcome(outcome) ?? ''
|
||||
expect(prompt).toContain(canonicalFirstDir)
|
||||
expect(prompt).toContain(canonicalSecondDir)
|
||||
}
|
||||
|
||||
expect(createRequests).toBe(0)
|
||||
} finally {
|
||||
fs.rmSync(allowedRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export type TelegramConfig = {
|
||||
allowedUsers: number[]
|
||||
pairedUsers: PairedUser[]
|
||||
defaultWorkDir: string
|
||||
allowedProjectRoots: string[]
|
||||
}
|
||||
|
||||
export type FeishuConfig = {
|
||||
@@ -36,6 +37,7 @@ export type FeishuConfig = {
|
||||
pairedUsers: PairedUser[]
|
||||
defaultWorkDir: string
|
||||
streamingCard: boolean
|
||||
allowedProjectRoots: string[]
|
||||
}
|
||||
|
||||
export type WechatConfig = {
|
||||
@@ -46,6 +48,7 @@ export type WechatConfig = {
|
||||
allowedUsers: string[]
|
||||
pairedUsers: PairedUser[]
|
||||
defaultWorkDir: string
|
||||
allowedProjectRoots: string[]
|
||||
}
|
||||
|
||||
export type DingtalkConfig = {
|
||||
@@ -56,6 +59,7 @@ export type DingtalkConfig = {
|
||||
defaultWorkDir: string
|
||||
endpoint: string
|
||||
permissionCardTemplateId: string
|
||||
allowedProjectRoots: string[]
|
||||
}
|
||||
|
||||
export type WhatsAppConfig = {
|
||||
@@ -64,12 +68,14 @@ export type WhatsAppConfig = {
|
||||
allowedUsers: string[]
|
||||
pairedUsers: PairedUser[]
|
||||
defaultWorkDir: string
|
||||
allowedProjectRoots: string[]
|
||||
}
|
||||
|
||||
export type AdapterConfig = {
|
||||
serverUrl: string
|
||||
defaultProjectDir: string
|
||||
pairing: PairingState
|
||||
allowedProjectRoots: string[]
|
||||
telegram: TelegramConfig
|
||||
feishu: FeishuConfig
|
||||
wechat: WechatConfig
|
||||
@@ -121,11 +127,15 @@ export function loadConfig(): AdapterConfig {
|
||||
expiresAt: pairing.expiresAt ?? null,
|
||||
createdAt: pairing.createdAt ?? null,
|
||||
},
|
||||
// File scope only. ADAPTER_ALLOWED_PROJECT_ROOTS is applied by
|
||||
// resolveAllowedProjectRoots so this field keeps one meaning.
|
||||
allowedProjectRoots: readProjectRoots(file.allowedProjectRoots),
|
||||
telegram: {
|
||||
botToken: process.env.TELEGRAM_BOT_TOKEN || tg.botToken || '',
|
||||
allowedUsers: tg.allowedUsers ?? [],
|
||||
pairedUsers: tg.pairedUsers ?? [],
|
||||
defaultWorkDir: tg.defaultWorkDir || fallbackWorkDir,
|
||||
allowedProjectRoots: readProjectRoots(tg.allowedProjectRoots),
|
||||
},
|
||||
feishu: {
|
||||
appId: process.env.FEISHU_APP_ID || fs_.appId || '',
|
||||
@@ -136,6 +146,7 @@ export function loadConfig(): AdapterConfig {
|
||||
pairedUsers: fs_.pairedUsers ?? [],
|
||||
defaultWorkDir: fs_.defaultWorkDir || fallbackWorkDir,
|
||||
streamingCard: fs_.streamingCard ?? false,
|
||||
allowedProjectRoots: readProjectRoots(fs_.allowedProjectRoots),
|
||||
},
|
||||
wechat: {
|
||||
accountId: process.env.WECHAT_ACCOUNT_ID || wc.accountId || '',
|
||||
@@ -145,6 +156,7 @@ export function loadConfig(): AdapterConfig {
|
||||
allowedUsers: wc.allowedUsers ?? [],
|
||||
pairedUsers: wc.pairedUsers ?? [],
|
||||
defaultWorkDir: wc.defaultWorkDir || fallbackWorkDir,
|
||||
allowedProjectRoots: readProjectRoots(wc.allowedProjectRoots),
|
||||
},
|
||||
dingtalk: {
|
||||
clientId: process.env.DINGTALK_CLIENT_ID || dt.clientId || '',
|
||||
@@ -154,6 +166,7 @@ export function loadConfig(): AdapterConfig {
|
||||
defaultWorkDir: dt.defaultWorkDir || fallbackWorkDir,
|
||||
endpoint: process.env.DINGTALK_STREAM_ENDPOINT || dt.endpoint || 'https://api.dingtalk.com',
|
||||
permissionCardTemplateId: process.env.DINGTALK_PERMISSION_CARD_TEMPLATE_ID || dt.permissionCardTemplateId || '',
|
||||
allowedProjectRoots: readProjectRoots(dt.allowedProjectRoots),
|
||||
},
|
||||
whatsapp: {
|
||||
accountJid: process.env.WHATSAPP_ACCOUNT_JID || wa.accountJid || '',
|
||||
@@ -161,6 +174,7 @@ export function loadConfig(): AdapterConfig {
|
||||
allowedUsers: wa.allowedUsers ?? [],
|
||||
pairedUsers: wa.pairedUsers ?? [],
|
||||
defaultWorkDir: wa.defaultWorkDir || fallbackWorkDir,
|
||||
allowedProjectRoots: readProjectRoots(wa.allowedProjectRoots),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -169,21 +183,167 @@ export function getConfiguredWorkDir(config: AdapterConfig, platformConfig: Adap
|
||||
return config.defaultProjectDir || platformConfig.defaultWorkDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the directories an IM adapter is allowed to reach.
|
||||
*
|
||||
* This is deliberately NOT derived from `defaultWorkDir` (#1191). That field is
|
||||
* documented as the *default* work dir for new IM sessions, not a boundary, and
|
||||
* using it as the sole allowed root broke both directions:
|
||||
*
|
||||
* - configured → /projects listed only that one project, and picking any other
|
||||
* recent project by name or path failed;
|
||||
* - blank → it falls back to PWD/cwd(), which for a Finder-launched .app
|
||||
* is "/", so the boundary silently allowed the entire filesystem.
|
||||
*
|
||||
* Precedence: ADAPTER_ALLOWED_PROJECT_ROOTS > platform-specific roots > global
|
||||
* roots > default (home ∪ default work dir). The pairing gate is the primary
|
||||
* authorization control; these roots are defense-in-depth, so a misconfigured
|
||||
* value falls back to the default with a warning instead of bricking the bot.
|
||||
*
|
||||
* Explicitly configured roots are honoured verbatim — if someone types "/" they
|
||||
* own the machine and mean it. The *default* branch refuses to inherit such a
|
||||
* root, because that is how the boundary silently became vacuous before.
|
||||
*/
|
||||
export function resolveAllowedProjectRoots(
|
||||
config: AdapterConfig,
|
||||
platformConfig: AdapterPlatformConfig,
|
||||
): string[] {
|
||||
// Env wins over both file scopes, matching how every other field in this
|
||||
// module resolves.
|
||||
const configured = readEnvProjectRoots()
|
||||
?? (platformConfig.allowedProjectRoots.length > 0
|
||||
? platformConfig.allowedProjectRoots
|
||||
: config.allowedProjectRoots)
|
||||
|
||||
if (configured.length > 0) {
|
||||
const candidates = configured.map(resolveExistingDirectory)
|
||||
// Count the misses before dedup — duplicates are not missing directories.
|
||||
const missing = candidates.filter((value) => !value).length
|
||||
const resolved = dedupePaths(candidates)
|
||||
if (resolved.length > 0) {
|
||||
if (missing > 0) {
|
||||
console.warn(
|
||||
missing === 1
|
||||
? '[Config] Ignoring 1 allowedProjectRoots entry that does not exist'
|
||||
: `[Config] Ignoring ${missing} allowedProjectRoots entries that do not exist`,
|
||||
)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
console.warn(
|
||||
'[Config] None of the configured allowedProjectRoots exist; ' +
|
||||
'falling back to the default roots (home directory + default project dir)',
|
||||
)
|
||||
}
|
||||
|
||||
const home = resolveExistingDirectory(os.homedir())
|
||||
const defaults = dedupePaths([
|
||||
home,
|
||||
// Only inherit the default work dir as a boundary when it is a real project
|
||||
// directory. "/" and "/Users" reach every project on the machine, so taking
|
||||
// them from the PWD/cwd() fallback would make the boundary meaningless.
|
||||
usableAsBoundary(resolveExistingDirectory(getConfiguredWorkDir(config, platformConfig))),
|
||||
])
|
||||
if (defaults.length > 0) return defaults
|
||||
// Only reachable if the home directory itself does not resolve. The adapter
|
||||
// client drops unresolvable roots, so this is a best effort, not a guarantee.
|
||||
return [os.homedir()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Directories the IM boundary must never inherit implicitly: a filesystem root,
|
||||
* or any strict ancestor of the home directory (`/`, `/Users`, `/home`).
|
||||
*/
|
||||
function usableAsBoundary(dir: string | null): string | null {
|
||||
if (!dir) return null
|
||||
if (path.parse(dir).root === dir) return null
|
||||
return isStrictAncestor(dir, os.homedir()) ? null : dir
|
||||
}
|
||||
|
||||
function isStrictAncestor(candidate: string, target: string): boolean {
|
||||
const relative = path.relative(candidate, target)
|
||||
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
|
||||
}
|
||||
|
||||
/**
|
||||
* The work dir a new IM session starts in, paired with the boundary it must sit
|
||||
* inside. Resolving them together is the point: the two are configured
|
||||
* separately, and a default project outside the allowed roots would otherwise
|
||||
* make every `/new` (and every first message in a fresh chat) fail the client's
|
||||
* own boundary check.
|
||||
*/
|
||||
export function resolveAdapterWorkspace(
|
||||
config: AdapterConfig,
|
||||
platformConfig: AdapterPlatformConfig,
|
||||
): { defaultWorkDir: string; allowedProjectRoots: string[] } {
|
||||
const allowedProjectRoots = resolveAllowedProjectRoots(config, platformConfig)
|
||||
const configured = resolveExistingDirectory(getConfiguredWorkDir(config, platformConfig))
|
||||
|
||||
if (configured && isPathWithinRoots(configured, allowedProjectRoots)) {
|
||||
return { defaultWorkDir: configured, allowedProjectRoots }
|
||||
}
|
||||
|
||||
const fallback = allowedProjectRoots[0] ?? os.homedir()
|
||||
if (configured) {
|
||||
console.warn(
|
||||
`[Config] Default project ${configured} is outside the allowed project roots; ` +
|
||||
`new sessions will start in ${fallback}`,
|
||||
)
|
||||
}
|
||||
return { defaultWorkDir: fallback, allowedProjectRoots }
|
||||
}
|
||||
|
||||
function isPathWithinRoots(target: string, roots: string[]): boolean {
|
||||
return roots.some((root) => {
|
||||
const relative = path.relative(root, target)
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
})
|
||||
}
|
||||
|
||||
function readProjectRoots(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function readEnvProjectRoots(): string[] | null {
|
||||
const raw = process.env.ADAPTER_ALLOWED_PROJECT_ROOTS?.trim()
|
||||
if (!raw) return null
|
||||
const roots = readProjectRoots(raw.split(path.delimiter))
|
||||
// A delimiter-only value (an unset "$A:$B" in a launcher script) must not read
|
||||
// as "the env configured an empty boundary" and discard the file config.
|
||||
return roots.length > 0 ? roots : null
|
||||
}
|
||||
|
||||
function dedupePaths(values: (string | null)[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const result: string[] = []
|
||||
for (const value of values) {
|
||||
if (!value || seen.has(value)) continue
|
||||
seen.add(value)
|
||||
result.push(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function resolveUserDefaultWorkDir(): string {
|
||||
const candidates = [
|
||||
process.env.ADAPTER_DEFAULT_PROJECT_DIR,
|
||||
process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR,
|
||||
process.env.PWD,
|
||||
process.cwd(),
|
||||
os.homedir(),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const resolved = resolveExistingDirectory(candidate)
|
||||
// A GUI-launched sidecar inherits cwd "/" (Electron passes no cwd), which is
|
||||
// useless as a place to start a session and unusable as a boundary.
|
||||
const resolved = usableAsBoundary(resolveExistingDirectory(candidate))
|
||||
if (resolved) return resolved
|
||||
}
|
||||
|
||||
return os.homedir()
|
||||
return resolveExistingDirectory(os.homedir()) ?? os.homedir()
|
||||
}
|
||||
|
||||
function resolveExistingDirectory(value: string | undefined): string | null {
|
||||
@@ -196,6 +356,10 @@ function resolveExistingDirectory(value: string | undefined): string | null {
|
||||
? path.join(os.homedir(), trimmed.slice(2))
|
||||
: trimmed
|
||||
|
||||
// Relative entries would resolve against the sidecar's cwd ("/" for a packaged
|
||||
// app), making the boundary depend on how the app was launched.
|
||||
if (!path.isAbsolute(expanded)) return null
|
||||
|
||||
try {
|
||||
const realPath = fs.realpathSync(expanded)
|
||||
return fs.statSync(realPath).isDirectory() ? realPath : null
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { AdapterHttpClient, RecentProject } from './http-client.js'
|
||||
|
||||
export type ProjectSelectionRoute = {
|
||||
kind: 'new' | 'picker_reply'
|
||||
query?: string
|
||||
}
|
||||
|
||||
export type ProjectSelectionOutcome =
|
||||
| { kind: 'created'; project?: RecentProject }
|
||||
| { kind: 'creation_failed' }
|
||||
| { kind: 'ambiguous'; projects: RecentProject[] }
|
||||
| { kind: 'not_found'; query: string }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
type ProjectSelectionControllerDeps = {
|
||||
httpClient: Pick<AdapterHttpClient, 'listRecentProjects' | 'matchProject'>
|
||||
defaultWorkDir: string
|
||||
prepareNewSession: (chatId: string) => void | Promise<void>
|
||||
createSession: (chatId: string, workDir: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
const NON_PROJECT_COMMANDS = new Set([
|
||||
'/help',
|
||||
'帮助',
|
||||
'/status',
|
||||
'状态',
|
||||
'/clear',
|
||||
'清空',
|
||||
'/stop',
|
||||
'停止',
|
||||
'/projects',
|
||||
'项目列表',
|
||||
])
|
||||
|
||||
export class ProjectSelectionRouter {
|
||||
private readonly pendingChats = new Set<string>()
|
||||
|
||||
markPickerShown(chatId: string): void {
|
||||
this.pendingChats.add(chatId)
|
||||
}
|
||||
|
||||
clear(chatId: string): void {
|
||||
this.pendingChats.delete(chatId)
|
||||
}
|
||||
|
||||
route(chatId: string, text: string): ProjectSelectionRoute | null {
|
||||
const trimmed = text.trim()
|
||||
|
||||
// Explicit commands must win while a picker is pending; otherwise
|
||||
// `/new express` is forwarded verbatim as the project query.
|
||||
if (trimmed === '/new' || trimmed === '新会话' || trimmed.startsWith('/new ')) {
|
||||
const query = trimmed.startsWith('/new ') ? trimmed.slice(5).trim() : ''
|
||||
return { kind: 'new', query: query || undefined }
|
||||
}
|
||||
|
||||
if (this.pendingChats.has(chatId) && trimmed && !NON_PROJECT_COMMANDS.has(trimmed)) {
|
||||
return { kind: 'picker_reply', query: trimmed }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export class ProjectSelectionController {
|
||||
private readonly router = new ProjectSelectionRouter()
|
||||
|
||||
constructor(private readonly deps: ProjectSelectionControllerDeps) {}
|
||||
|
||||
async listProjects(chatId: string): Promise<RecentProject[]> {
|
||||
const projects = await this.deps.httpClient.listRecentProjects()
|
||||
if (projects.length > 0) this.router.markPickerShown(chatId)
|
||||
return projects
|
||||
}
|
||||
|
||||
clear(chatId: string): void {
|
||||
this.router.clear(chatId)
|
||||
}
|
||||
|
||||
async handleInput(chatId: string, text: string): Promise<ProjectSelectionOutcome | null> {
|
||||
const route = this.router.route(chatId, text)
|
||||
if (!route) return null
|
||||
|
||||
await this.deps.prepareNewSession(chatId)
|
||||
this.router.clear(chatId)
|
||||
|
||||
if (!route.query) {
|
||||
const created = await this.deps.createSession(chatId, this.deps.defaultWorkDir)
|
||||
return created ? { kind: 'created' } : { kind: 'creation_failed' }
|
||||
}
|
||||
|
||||
try {
|
||||
const { project, ambiguous } = await this.deps.httpClient.matchProject(route.query)
|
||||
if (project) {
|
||||
const created = await this.deps.createSession(chatId, project.realPath)
|
||||
return created ? { kind: 'created', project } : { kind: 'creation_failed' }
|
||||
}
|
||||
if (ambiguous) return { kind: 'ambiguous', projects: ambiguous }
|
||||
return { kind: 'not_found', query: route.query }
|
||||
} catch (err) {
|
||||
return { kind: 'error', message: err instanceof Error ? err.message : String(err) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAmbiguousProjectSelection(projects: RecentProject[]): string {
|
||||
const choices = projects
|
||||
.map((project, index) => `${index + 1}. **${project.projectName}** — ${project.realPath}`)
|
||||
.join('\n')
|
||||
return `匹配到多个项目,请发送 /new <更完整名称或路径> 选择:\n\n${choices}`
|
||||
}
|
||||
|
||||
export function formatProjectSelectionOutcome(outcome: ProjectSelectionOutcome): string | null {
|
||||
switch (outcome.kind) {
|
||||
case 'created':
|
||||
return outcome.project
|
||||
? `✅ 已新建会话:**${outcome.project.projectName}**${outcome.project.branch ? ` (${outcome.project.branch})` : ''}`
|
||||
: '✅ 已新建会话,可以开始对话了。'
|
||||
case 'creation_failed':
|
||||
return null
|
||||
case 'ambiguous':
|
||||
return formatAmbiguousProjectSelection(outcome.projects)
|
||||
case 'not_found':
|
||||
return `未找到匹配 "${outcome.query}" 的项目。发送 /projects 查看完整列表。`
|
||||
case 'error':
|
||||
return `❌ ${outcome.message}`
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-b
|
||||
import { MessageDedup } from '../common/message-dedup.js'
|
||||
import { MessageBuffer } from '../common/message-buffer.js'
|
||||
import { enqueue } from '../common/chat-queue.js'
|
||||
import { getConfiguredWorkDir, loadConfig } from '../common/config.js'
|
||||
import { loadConfig } from '../common/config.js'
|
||||
import { formatImHelp, formatImStatus, formatPermissionRequest, splitMessage } from '../common/format.js'
|
||||
import {
|
||||
formatPermissionDecisionStatus,
|
||||
@@ -20,7 +20,12 @@ import {
|
||||
type PermissionDecision,
|
||||
} from '../common/permission.js'
|
||||
import { SessionStore } from '../common/session-store.js'
|
||||
import { AdapterHttpClient, type RecentProject } from '../common/http-client.js'
|
||||
import { type RecentProject } from '../common/http-client.js'
|
||||
import { createAdapterClient } from '../common/adapter-client.js'
|
||||
import {
|
||||
formatProjectSelectionOutcome,
|
||||
ProjectSelectionController,
|
||||
} from '../common/project-selection-router.js'
|
||||
import { restoreStoredSessionBinding } from '../common/session-recovery.js'
|
||||
import { isAllowedUser, tryPair } from '../common/pairing.js'
|
||||
import { AttachmentStore } from '../common/attachment/attachment-store.js'
|
||||
@@ -54,17 +59,21 @@ if (!config.dingtalk.clientId || !config.dingtalk.clientSecret) {
|
||||
console.error('[DingTalk] Missing DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET. Bind with QR auth in Desktop Settings or set env.')
|
||||
process.exit(1)
|
||||
}
|
||||
const defaultWorkDir = getConfiguredWorkDir(config, config.dingtalk)
|
||||
const { httpClient, defaultWorkDir } = createAdapterClient(config, config.dingtalk)
|
||||
|
||||
const bridge = new WsBridge(config.serverUrl, 'dingtalk')
|
||||
const dedup = new MessageDedup()
|
||||
const sessionStore = new SessionStore()
|
||||
const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] })
|
||||
const attachmentStore = new AttachmentStore()
|
||||
const media = new DingTalkMediaService(attachmentStore)
|
||||
const aiCards = new DingTalkAiCardService(getAccessToken, config.dingtalk.clientId)
|
||||
const sessionWebhooks = new Map<string, string>()
|
||||
const pendingProjectSelection = new Map<string, boolean>()
|
||||
const projectSelectionController = new ProjectSelectionController({
|
||||
httpClient,
|
||||
defaultWorkDir,
|
||||
prepareNewSession,
|
||||
createSession: createSessionForChat,
|
||||
})
|
||||
const runtimeStates = new Map<string, ChatRuntimeState>()
|
||||
const aiCardBuffers = new Map<string, MessageBuffer>()
|
||||
const aiCardTargets = new Map<string, DingTalkAiCardTarget>()
|
||||
@@ -323,47 +332,22 @@ function formatProjectList(projects: RecentProject[]): string {
|
||||
|
||||
async function showProjectPicker(chatId: string): Promise<void> {
|
||||
try {
|
||||
const projects = await httpClient.listRecentProjects()
|
||||
const projects = await projectSelectionController.listProjects(chatId)
|
||||
if (projects.length === 0) {
|
||||
await sendText(chatId, `没有找到最近的项目。发送 /new 会使用默认工作目录:${defaultWorkDir}\n也可以发送 /new /path/to/project 指定项目。`)
|
||||
return
|
||||
}
|
||||
pendingProjectSelection.set(chatId, true)
|
||||
await sendText(chatId, formatProjectList(projects))
|
||||
} catch (err) {
|
||||
await sendText(chatId, `❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function startNewSession(chatId: string, query?: string): Promise<void> {
|
||||
function prepareNewSession(chatId: string): void {
|
||||
bridge.resetSession(chatId)
|
||||
sessionStore.delete(chatId)
|
||||
clearTransientChatState(chatId)
|
||||
pendingProjectSelection.delete(chatId)
|
||||
runtimeStates.delete(chatId)
|
||||
|
||||
if (query) {
|
||||
try {
|
||||
const { project, ambiguous } = await httpClient.matchProject(query)
|
||||
if (project) {
|
||||
const ok = await createSessionForChat(chatId, project.realPath)
|
||||
if (ok) await sendText(chatId, `✅ 已新建会话:**${project.projectName}**${project.branch ? ` (${project.branch})` : ''}`)
|
||||
return
|
||||
}
|
||||
if (ambiguous) {
|
||||
const list = ambiguous.map((project, index) => `${index + 1}. **${project.projectName}** — ${project.realPath}`).join('\n')
|
||||
await sendText(chatId, `匹配到多个项目,请更精确:\n\n${list}`)
|
||||
return
|
||||
}
|
||||
await sendText(chatId, `未找到匹配 "${query}" 的项目。发送 /projects 查看完整列表。`)
|
||||
} catch (err) {
|
||||
await sendText(chatId, `❌ ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const ok = await createSessionForChat(chatId, defaultWorkDir)
|
||||
if (ok) await sendText(chatId, '✅ 已新建会话,可以开始对话了。')
|
||||
}
|
||||
|
||||
async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<void> {
|
||||
@@ -485,14 +469,12 @@ async function routeUserMessage(chatId: string, text: string, attachments: Attac
|
||||
|
||||
if (!hasAttachments && handlePermissionCommand(chatId, trimmed)) return
|
||||
|
||||
if (!hasAttachments && pendingProjectSelection.has(chatId)) {
|
||||
if (trimmed) await startNewSession(chatId, trimmed)
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasAttachments && (trimmed === '/new' || trimmed === '新会话' || trimmed.startsWith('/new '))) {
|
||||
const arg = trimmed.startsWith('/new ') ? trimmed.slice(5).trim() : ''
|
||||
await startNewSession(chatId, arg || undefined)
|
||||
const projectOutcome = !hasAttachments
|
||||
? await projectSelectionController.handleInput(chatId, trimmed)
|
||||
: null
|
||||
if (projectOutcome) {
|
||||
const response = formatProjectSelectionOutcome(projectOutcome)
|
||||
if (response) await sendText(chatId, response)
|
||||
return
|
||||
}
|
||||
if (!hasAttachments && (trimmed === '/help' || trimmed === '帮助')) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-b
|
||||
import { MessageDedup } from '../common/message-dedup.js'
|
||||
import { StreamingCard } from './streaming-card.js'
|
||||
import { enqueue } from '../common/chat-queue.js'
|
||||
import { getConfiguredWorkDir, loadConfig } from '../common/config.js'
|
||||
import { loadConfig } from '../common/config.js'
|
||||
import {
|
||||
formatImHelp,
|
||||
formatImStatus,
|
||||
@@ -26,7 +26,12 @@ import {
|
||||
type PermissionDecision,
|
||||
} from '../common/permission.js'
|
||||
import { SessionStore } from '../common/session-store.js'
|
||||
import { AdapterHttpClient, type RecentProject } from '../common/http-client.js'
|
||||
import { type RecentProject } from '../common/http-client.js'
|
||||
import { createAdapterClient } from '../common/adapter-client.js'
|
||||
import {
|
||||
formatProjectSelectionOutcome,
|
||||
ProjectSelectionController,
|
||||
} from '../common/project-selection-router.js'
|
||||
import { restoreStoredSessionBinding } from '../common/session-recovery.js'
|
||||
import { isAllowedUser, tryPair } from '../common/pairing.js'
|
||||
import { extractInboundPayload } from './extract-payload.js'
|
||||
@@ -56,8 +61,7 @@ const larkClient = new Lark.Client({
|
||||
const bridge = new WsBridge(config.serverUrl, 'feishu')
|
||||
const dedup = new MessageDedup()
|
||||
const sessionStore = new SessionStore()
|
||||
const defaultWorkDir = getConfiguredWorkDir(config, config.feishu)
|
||||
const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] })
|
||||
const { httpClient, defaultWorkDir } = createAdapterClient(config, config.feishu)
|
||||
|
||||
// Attachment plumbing — shared by inbound (download) and outbound (upload) paths.
|
||||
const attachmentStore = new AttachmentStore()
|
||||
@@ -68,7 +72,12 @@ attachmentStore.gc().catch((err) => {
|
||||
|
||||
// One streaming card lifecycle per chatId (CardKit main + patch fallback).
|
||||
const streamingCards = new Map<string, StreamingCard>()
|
||||
const pendingProjectSelection = new Map<string, boolean>()
|
||||
const projectSelectionController = new ProjectSelectionController({
|
||||
httpClient,
|
||||
defaultWorkDir,
|
||||
prepareNewSession,
|
||||
createSession: createSessionForChat,
|
||||
})
|
||||
const runtimeStates = new Map<string, ChatRuntimeState>()
|
||||
const pendingPermissions = new Map<string, Set<string>>()
|
||||
|
||||
@@ -661,13 +670,12 @@ async function createSessionForChat(chatId: string, workDir: string): Promise<bo
|
||||
|
||||
async function showProjectPicker(chatId: string): Promise<void> {
|
||||
try {
|
||||
const projects = await httpClient.listRecentProjects()
|
||||
const projects = await projectSelectionController.listProjects(chatId)
|
||||
if (projects.length === 0) {
|
||||
await sendText(chatId,
|
||||
`没有找到最近的项目。发送 /new 会使用默认工作目录:${defaultWorkDir}\n也可以发送 /new /path/to/project 指定项目。`)
|
||||
return
|
||||
}
|
||||
pendingProjectSelection.set(chatId, true)
|
||||
const cardId = await sendCard(chatId, buildProjectPickerCard(projects))
|
||||
if (!cardId) {
|
||||
// Fallback to text picker if card delivery failed (permissions, etc.)
|
||||
@@ -681,7 +689,7 @@ async function showProjectPicker(chatId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function startNewSession(chatId: string, query?: string): Promise<void> {
|
||||
function prepareNewSession(chatId: string): void {
|
||||
bridge.resetSession(chatId)
|
||||
sessionStore.delete(chatId)
|
||||
// Abort any in-flight streaming card for the previous session
|
||||
@@ -692,41 +700,8 @@ async function startNewSession(chatId: string, query?: string): Promise<void> {
|
||||
}
|
||||
imageWatchers.delete(chatId)
|
||||
uploadedImageKeys.delete(chatId)
|
||||
pendingProjectSelection.delete(chatId)
|
||||
pendingPermissions.delete(chatId)
|
||||
runtimeStates.delete(chatId)
|
||||
|
||||
if (query) {
|
||||
try {
|
||||
const { project, ambiguous } = await httpClient.matchProject(query)
|
||||
if (project) {
|
||||
const ok = await createSessionForChat(chatId, project.realPath)
|
||||
if (ok) {
|
||||
await sendText(chatId,
|
||||
`✅ 已新建会话:**${project.projectName}**${project.branch ? ` (${project.branch})` : ''}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ambiguous) {
|
||||
const list = ambiguous.map((p, i) => `${i + 1}. **${p.projectName}** — ${p.realPath}`).join('\n')
|
||||
await sendText(chatId, `匹配到多个项目,请更精确:\n\n${list}`)
|
||||
return
|
||||
}
|
||||
await sendText(chatId, `未找到匹配 "${query}" 的项目。发送 /projects 查看完整列表。`)
|
||||
} catch (err) {
|
||||
await sendText(chatId, `❌ ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
} else {
|
||||
const workDir = defaultWorkDir
|
||||
if (workDir) {
|
||||
const ok = await createSessionForChat(chatId, workDir)
|
||||
if (ok) {
|
||||
await sendText(chatId, '✅ 已新建会话,可以开始对话了。')
|
||||
}
|
||||
} else {
|
||||
await showProjectPicker(chatId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- server message handler ----------
|
||||
@@ -980,9 +955,12 @@ async function handleMessage(data: any): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasAttachments && (msgText === '/new' || msgText === '新会话' || msgText.startsWith('/new '))) {
|
||||
const arg = msgText.startsWith('/new ') ? msgText.slice(5).trim() : ''
|
||||
await startNewSession(chatId, arg || undefined)
|
||||
const projectOutcome = !hasAttachments
|
||||
? await projectSelectionController.handleInput(chatId, msgText)
|
||||
: null
|
||||
if (projectOutcome) {
|
||||
const response = formatProjectSelectionOutcome(projectOutcome)
|
||||
if (response) await sendText(chatId, response)
|
||||
return
|
||||
}
|
||||
if (!hasAttachments && (msgText === '/help' || msgText === '帮助')) {
|
||||
@@ -1023,12 +1001,6 @@ async function handleMessage(data: any): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
// User is replying to a project picker prompt
|
||||
if (!hasAttachments && pendingProjectSelection.has(chatId)) {
|
||||
await startNewSession(chatId, msgText.trim())
|
||||
return
|
||||
}
|
||||
|
||||
// ----- Normal message flow (with optional inbound attachments) -----
|
||||
|
||||
const ready = await ensureSession(chatId)
|
||||
@@ -1195,7 +1167,7 @@ async function handleCardAction(data: any): Promise<any> {
|
||||
const projectName = event.action?.value?.projectName ?? realPath ?? '(unknown)'
|
||||
if (!realPath) return
|
||||
|
||||
pendingProjectSelection.delete(chatId)
|
||||
projectSelectionController.clear(chatId)
|
||||
// createSessionForChat handles its own error messaging on failure
|
||||
const ok = await createSessionForChat(chatId, realPath)
|
||||
if (ok) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { WsBridge, type ServerMessage } from '../common/ws-bridge.js'
|
||||
import { MessageBuffer } from '../common/message-buffer.js'
|
||||
import { MessageDedup } from '../common/message-dedup.js'
|
||||
import { enqueue } from '../common/chat-queue.js'
|
||||
import { getConfiguredWorkDir, loadConfig } from '../common/config.js'
|
||||
import { loadConfig } from '../common/config.js'
|
||||
import {
|
||||
formatImStatus,
|
||||
formatPermissionRequest,
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
type PermissionDecision,
|
||||
} from '../common/permission.js'
|
||||
import { SessionStore } from '../common/session-store.js'
|
||||
import { AdapterHttpClient } from '../common/http-client.js'
|
||||
import { createAdapterClient } from '../common/adapter-client.js'
|
||||
import { restoreStoredSessionBinding } from '../common/session-recovery.js'
|
||||
import { isAllowedUser, tryPair } from '../common/pairing.js'
|
||||
import { TelegramMediaService } from './media.js'
|
||||
@@ -59,8 +59,7 @@ const bot = new Bot(config.telegram.botToken)
|
||||
const bridge = new WsBridge(config.serverUrl, 'tg')
|
||||
const dedup = new MessageDedup()
|
||||
const sessionStore = new SessionStore()
|
||||
const defaultWorkDir = getConfiguredWorkDir(config, config.telegram)
|
||||
const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] })
|
||||
const { httpClient, defaultWorkDir } = createAdapterClient(config, config.telegram)
|
||||
const attachmentStore = new AttachmentStore()
|
||||
const media = new TelegramMediaService(bot, attachmentStore)
|
||||
attachmentStore.gc().catch((err) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-b
|
||||
import { MessageDedup } from '../common/message-dedup.js'
|
||||
import { MessageBuffer } from '../common/message-buffer.js'
|
||||
import { enqueue } from '../common/chat-queue.js'
|
||||
import { getConfiguredWorkDir, loadConfig } from '../common/config.js'
|
||||
import { loadConfig } from '../common/config.js'
|
||||
import {
|
||||
formatImHelp,
|
||||
formatImStatus,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
parsePermissionCommand,
|
||||
} from '../common/permission.js'
|
||||
import { SessionStore } from '../common/session-store.js'
|
||||
import { AdapterHttpClient } from '../common/http-client.js'
|
||||
import { createAdapterClient } from '../common/adapter-client.js'
|
||||
import { restoreStoredSessionBinding } from '../common/session-recovery.js'
|
||||
import { isAllowedUser, tryPair } from '../common/pairing.js'
|
||||
import { AttachmentStore } from '../common/attachment/attachment-store.js'
|
||||
@@ -48,8 +48,7 @@ const botToken = config.wechat.botToken
|
||||
const bridge = new WsBridge(config.serverUrl, 'wechat')
|
||||
const dedup = new MessageDedup()
|
||||
const sessionStore = new SessionStore()
|
||||
const defaultWorkDir = getConfiguredWorkDir(config, config.wechat)
|
||||
const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] })
|
||||
const { httpClient, defaultWorkDir } = createAdapterClient(config, config.wechat)
|
||||
const attachmentStore = new AttachmentStore()
|
||||
const media = new WechatMediaService(attachmentStore)
|
||||
const pendingProjectSelection = new Map<string, boolean>()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -658,7 +658,10 @@ describe('Electron pet window service', () => {
|
||||
},
|
||||
}
|
||||
|
||||
function panelController(petWindow: ReturnType<typeof createFakeWindow>) {
|
||||
function panelController(
|
||||
petWindow: ReturnType<typeof createFakeWindow>,
|
||||
platform: NodeJS.Platform = 'darwin',
|
||||
) {
|
||||
const onPanelPlacementChanged = vi.fn()
|
||||
// No cursor sampler, so the drag follows the payload coordinates the way
|
||||
// the other edge tests drive it.
|
||||
@@ -668,7 +671,7 @@ describe('Electron pet window service', () => {
|
||||
getWorkAreaForPoint: () => panelDrag.workArea,
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
onPanelPlacementChanged,
|
||||
platform: 'darwin',
|
||||
platform,
|
||||
preloadPath: '/app/electron-dist/preload.cjs',
|
||||
})
|
||||
return { controller, onPanelPlacementChanged }
|
||||
@@ -689,7 +692,7 @@ describe('Electron pet window service', () => {
|
||||
above.mascot,
|
||||
above.card,
|
||||
above.toggle,
|
||||
])).toEqual({ vertical: 'above' })
|
||||
])).toEqual({ vertical: 'above', horizontal: 'center' })
|
||||
|
||||
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
|
||||
const dragged = controller.dragWindow(petWindow as never, { phase: 'end', x: 150, y: -400 })
|
||||
@@ -699,15 +702,18 @@ describe('Electron pet window service', () => {
|
||||
expect(petWindow.getBounds().y).toBe(workArea.y - above.mascot.y)
|
||||
// The panel would be behind the menu bar there, so it has to change sides.
|
||||
expect(petWindow.getBounds().y + above.card.y).toBeLessThan(workArea.y)
|
||||
expect(dragged).toEqual({ vertical: 'below' })
|
||||
expect(onPanelPlacementChanged).toHaveBeenCalledWith(petWindow, { vertical: 'below' })
|
||||
expect(dragged).toEqual({ vertical: 'below', horizontal: 'center' })
|
||||
expect(onPanelPlacementChanged).toHaveBeenCalledWith(petWindow, {
|
||||
vertical: 'below',
|
||||
horizontal: 'center',
|
||||
})
|
||||
|
||||
// The renderer re-lays out and reports the flipped boxes.
|
||||
expect(controller.setInteractiveRegions(petWindow as never, [
|
||||
below.mascot,
|
||||
below.card,
|
||||
below.toggle,
|
||||
])).toEqual({ vertical: 'below' })
|
||||
])).toEqual({ vertical: 'below', horizontal: 'center' })
|
||||
|
||||
// Flipping moved the mascot up inside the window, so the window has to drop
|
||||
// by the same amount: the mascot stays on the menu bar rather than jumping
|
||||
@@ -717,6 +723,78 @@ describe('Electron pet window service', () => {
|
||||
expect(petWindow.getBounds().y + below.toggle.y).toBeGreaterThanOrEqual(workArea.y)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
edge: 'right',
|
||||
pointerEndX: 2_000,
|
||||
horizontal: 'left' as const,
|
||||
shiftedMascotX: 260,
|
||||
},
|
||||
{
|
||||
edge: 'left',
|
||||
pointerEndX: -2_000,
|
||||
horizontal: 'right' as const,
|
||||
shiftedMascotX: 12,
|
||||
},
|
||||
])('keeps the Windows task panel visible at the $edge edge without moving the mascot', async ({
|
||||
pointerEndX,
|
||||
horizontal,
|
||||
shiftedMascotX,
|
||||
}) => {
|
||||
const petWindow = createFakeWindow({
|
||||
x: 100,
|
||||
y: 120,
|
||||
width: PET_WINDOW_WIDTH,
|
||||
height: PET_WINDOW_HEIGHT,
|
||||
})
|
||||
const { controller, onPanelPlacementChanged } = panelController(petWindow, 'win32')
|
||||
await controller.show()
|
||||
|
||||
const { above, workArea } = panelDrag
|
||||
controller.setInteractiveRegions(petWindow as never, [
|
||||
above.mascot,
|
||||
above.card,
|
||||
above.toggle,
|
||||
])
|
||||
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
|
||||
const dragged = controller.dragWindow(petWindow as never, {
|
||||
phase: 'end',
|
||||
x: pointerEndX,
|
||||
y: 180,
|
||||
})
|
||||
const mascotScreenX = petWindow.getBounds().x + above.mascot.x
|
||||
|
||||
expect(dragged).toEqual({ vertical: 'above', horizontal })
|
||||
expect(onPanelPlacementChanged).toHaveBeenCalledWith(petWindow, {
|
||||
vertical: 'above',
|
||||
horizontal,
|
||||
})
|
||||
|
||||
// The renderer keeps the card centred in the fixed window and moves the
|
||||
// mascot to the outside. The host absorbs that internal shift by moving
|
||||
// the whole window in the opposite direction.
|
||||
const shiftedMascot = { ...above.mascot, x: shiftedMascotX }
|
||||
expect(controller.setInteractiveRegions(petWindow as never, [
|
||||
shiftedMascot,
|
||||
above.card,
|
||||
above.toggle,
|
||||
])).toEqual({ vertical: 'above', horizontal })
|
||||
|
||||
const shiftedBounds = petWindow.getBounds()
|
||||
expect(shiftedBounds.x + shiftedMascot.x).toBe(mascotScreenX)
|
||||
expect(shiftedBounds.x + above.card.x).toBeGreaterThanOrEqual(workArea.x)
|
||||
expect(shiftedBounds.x + above.card.x + above.card.width)
|
||||
.toBeLessThanOrEqual(workArea.x + workArea.width)
|
||||
|
||||
onPanelPlacementChanged.mockClear()
|
||||
controller.setInteractiveRegions(petWindow as never, [
|
||||
shiftedMascot,
|
||||
above.card,
|
||||
above.toggle,
|
||||
])
|
||||
expect(onPanelPlacementChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('holds the mascot still through a flip that happens short of the menu bar', async () => {
|
||||
// The panel runs out of room before the mascot reaches the edge, so most
|
||||
// flips happen mid-screen where the clamp has nothing to say. Without the
|
||||
@@ -739,7 +817,7 @@ describe('Electron pet window service', () => {
|
||||
const dragged = controller.dragWindow(petWindow as never, { phase: 'end', x: 150, y: -55 })
|
||||
const mascotScreenY = petWindow.getBounds().y + above.mascot.y
|
||||
expect(mascotScreenY).toBe(workArea.y + 100)
|
||||
expect(dragged).toEqual({ vertical: 'below' })
|
||||
expect(dragged).toEqual({ vertical: 'below', horizontal: 'center' })
|
||||
|
||||
controller.setInteractiveRegions(petWindow as never, [below.mascot, below.card, below.toggle])
|
||||
|
||||
@@ -778,7 +856,10 @@ describe('Electron pet window service', () => {
|
||||
// an event instead.
|
||||
cursor = { x: 150, y: -55 }
|
||||
vi.advanceTimersByTime(16)
|
||||
expect(onPanelPlacementChanged).toHaveBeenCalledWith(petWindow, { vertical: 'below' })
|
||||
expect(onPanelPlacementChanged).toHaveBeenCalledWith(petWindow, {
|
||||
vertical: 'below',
|
||||
horizontal: 'center',
|
||||
})
|
||||
|
||||
controller.setInteractiveRegions(petWindow as never, [below.mascot, below.card, below.toggle])
|
||||
expect(petWindow.getBounds().y + below.mascot.y).toBe(workArea.y + 100)
|
||||
@@ -816,7 +897,7 @@ describe('Electron pet window service', () => {
|
||||
below.mascot,
|
||||
below.card,
|
||||
below.toggle,
|
||||
])).toEqual({ vertical: 'below' })
|
||||
])).toEqual({ vertical: 'below', horizontal: 'center' })
|
||||
expect(petWindow.getBounds().y + below.mascot.y).toBe(panelDrag.workArea.y)
|
||||
})
|
||||
|
||||
@@ -856,7 +937,44 @@ describe('Electron pet window service', () => {
|
||||
expect(petWindow?.getBounds().y).toBe(workArea.y - above.mascot.y)
|
||||
expect((petWindow?.getBounds().y ?? 0) + above.mascot.y).toBe(workArea.y)
|
||||
// And it is still out of room up there, so it flips straight back.
|
||||
expect(placement).toEqual({ vertical: 'below' })
|
||||
expect(placement).toEqual({ vertical: 'below', horizontal: 'center' })
|
||||
})
|
||||
|
||||
it('reopens a side-shifted pet where the mascot was, not where the window was', async () => {
|
||||
const { above, workArea } = panelDrag
|
||||
const shiftedMascot = { ...above.mascot, x: 260 }
|
||||
const saved = {
|
||||
x: workArea.x + workArea.width - shiftedMascot.x - shiftedMascot.width,
|
||||
y: 120,
|
||||
region: shiftedMascot,
|
||||
}
|
||||
let petWindow: ReturnType<typeof createFakeWindow> | undefined
|
||||
const controller = new PetWindowController({
|
||||
createWindow: vi.fn((bounds) => {
|
||||
petWindow = createFakeWindow(
|
||||
bounds as { x: number, y: number, width: number, height: number },
|
||||
)
|
||||
return petWindow
|
||||
}) as never,
|
||||
getCurrentWorkArea: () => workArea,
|
||||
getWorkAreaForPoint: () => workArea,
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
platform: 'win32',
|
||||
preloadPath: '/app/electron-dist/preload.cjs',
|
||||
readPosition: () => saved,
|
||||
})
|
||||
|
||||
await controller.show()
|
||||
const placement = controller.setInteractiveRegions(petWindow as never, [
|
||||
above.mascot,
|
||||
above.card,
|
||||
above.toggle,
|
||||
])
|
||||
|
||||
const bounds = petWindow?.getBounds()
|
||||
expect((bounds?.x ?? 0) + above.mascot.x)
|
||||
.toBe(saved.x + shiftedMascot.x)
|
||||
expect(placement).toEqual({ vertical: 'above', horizontal: 'left' })
|
||||
})
|
||||
|
||||
it('leaves the panel above the mascot when only the mascot is reported', async () => {
|
||||
@@ -874,7 +992,7 @@ describe('Electron pet window service', () => {
|
||||
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
|
||||
const dragged = controller.dragWindow(petWindow as never, { phase: 'end', x: 150, y: -400 })
|
||||
|
||||
expect(dragged).toEqual({ vertical: 'above' })
|
||||
expect(dragged).toEqual({ vertical: 'above', horizontal: 'center' })
|
||||
expect(onPanelPlacementChanged).not.toHaveBeenCalled()
|
||||
expect(petWindow.getBounds().y).toBe(panelDrag.workArea.y - panelDrag.above.mascot.y)
|
||||
})
|
||||
@@ -883,7 +1001,7 @@ describe('Electron pet window service', () => {
|
||||
const mascot = { x: 136, y: 240, width: 112, height: 128 }
|
||||
const panel = { x: 16, y: 88, width: 352, height: 171 }
|
||||
const workArea = { x: 0, y: 25, width: 800, height: 575 }
|
||||
const above = { vertical: 'above' } as const
|
||||
const above = { vertical: 'above', horizontal: 'center' } as const
|
||||
|
||||
// 335px of room, 183px needed.
|
||||
expect(resolvePetPanelPlacement({
|
||||
@@ -892,7 +1010,7 @@ describe('Electron pet window service', () => {
|
||||
mascot,
|
||||
panel,
|
||||
previous: above,
|
||||
})).toEqual({ vertical: 'above' })
|
||||
})).toEqual({ vertical: 'above', horizontal: 'center' })
|
||||
|
||||
// Mascot on the menu bar: no room at all.
|
||||
expect(resolvePetPanelPlacement({
|
||||
@@ -901,7 +1019,7 @@ describe('Electron pet window service', () => {
|
||||
mascot,
|
||||
panel,
|
||||
previous: above,
|
||||
})).toEqual({ vertical: 'below' })
|
||||
})).toEqual({ vertical: 'below', horizontal: 'center' })
|
||||
|
||||
// One pixel short still flips; exactly enough does not.
|
||||
expect(resolvePetPanelPlacement({
|
||||
@@ -910,14 +1028,14 @@ describe('Electron pet window service', () => {
|
||||
mascot,
|
||||
panel,
|
||||
previous: above,
|
||||
})).toEqual({ vertical: 'below' })
|
||||
})).toEqual({ vertical: 'below', horizontal: 'center' })
|
||||
expect(resolvePetPanelPlacement({
|
||||
windowPosition: { x: 100, y: workArea.y - mascot.y + panel.height + 12 },
|
||||
workArea,
|
||||
mascot,
|
||||
panel,
|
||||
previous: above,
|
||||
})).toEqual({ vertical: 'above' })
|
||||
})).toEqual({ vertical: 'above', horizontal: 'center' })
|
||||
|
||||
// Coming back the other way costs extra, so a mascot parked on the boundary
|
||||
// does not flutter.
|
||||
@@ -926,15 +1044,15 @@ describe('Electron pet window service', () => {
|
||||
workArea,
|
||||
mascot,
|
||||
panel,
|
||||
previous: { vertical: 'below' },
|
||||
})).toEqual({ vertical: 'below' })
|
||||
previous: { vertical: 'below', horizontal: 'center' },
|
||||
})).toEqual({ vertical: 'below', horizontal: 'center' })
|
||||
expect(resolvePetPanelPlacement({
|
||||
windowPosition: { x: 100, y: workArea.y - mascot.y + panel.height + 36 },
|
||||
workArea,
|
||||
mascot,
|
||||
panel,
|
||||
previous: { vertical: 'below' },
|
||||
})).toEqual({ vertical: 'above' })
|
||||
previous: { vertical: 'below', horizontal: 'center' },
|
||||
})).toEqual({ vertical: 'above', horizontal: 'center' })
|
||||
})
|
||||
|
||||
it('bounds the panel by everything hanging off the mascot, not just the card', () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,9 @@ function createRuntime(options: {
|
||||
appRoot?: string
|
||||
diagnosticsFile?: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
now?: () => number
|
||||
resolveSystemProxy?: (url: string) => Promise<string>
|
||||
sleep?: (delayMs: number) => Promise<void>
|
||||
proxyBridge?: SystemProxyBridgeLike
|
||||
} = {}) {
|
||||
return new ElectronServerRuntime({
|
||||
@@ -55,8 +57,10 @@ function createRuntime(options: {
|
||||
resolveSystemProxy: options.resolveSystemProxy,
|
||||
deps: {
|
||||
appendHostDiagnostic: sidecarMocks.appendHostDiagnostic,
|
||||
...(options.now ? { now: options.now } : {}),
|
||||
preferredServerPorts: () => [],
|
||||
reserveServerPort: async () => sidecarMocks.nextPort++,
|
||||
...(options.sleep ? { sleep: options.sleep } : {}),
|
||||
spawnSidecar: sidecarMocks.spawnSidecar,
|
||||
waitForServer: async () => await sidecarMocks.waitForServerImpl(),
|
||||
writeLastServerPort: () => undefined,
|
||||
@@ -74,6 +78,13 @@ async function waitForServerChildren(count: number): Promise<void> {
|
||||
expect(sidecarMocks.serverChildren).toHaveLength(count)
|
||||
}
|
||||
|
||||
async function waitForMockCalls(mock: ReturnType<typeof vi.fn>, count: number): Promise<void> {
|
||||
for (let attempt = 0; attempt < 20 && mock.mock.calls.length !== count; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
expect(mock).toHaveBeenCalledTimes(count)
|
||||
}
|
||||
|
||||
describe('ElectronServerRuntime', () => {
|
||||
beforeEach(() => {
|
||||
isolatedConfigDir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-runtime-'))
|
||||
@@ -309,17 +320,127 @@ describe('ElectronServerRuntime', () => {
|
||||
expect(sidecarMocks.serverChildren[0]!.kill).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops active adapters immediately when the server exits without restart demand', async () => {
|
||||
it('stops active adapters and waits for the replacement server to become healthy', async () => {
|
||||
const runtime = createRuntime()
|
||||
await runtime.startServer()
|
||||
const activeAdapters = [...sidecarMocks.adapterChildren]
|
||||
let releaseReplacementHealth!: () => void
|
||||
sidecarMocks.waitForServerImpl = () => new Promise<void>(resolve => {
|
||||
releaseReplacementHealth = resolve
|
||||
})
|
||||
|
||||
sidecarMocks.serverChildren[0]!.emit('exit', 19, null)
|
||||
await waitForServerChildren(2)
|
||||
|
||||
for (const adapter of activeAdapters) {
|
||||
expect(adapter.kill).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
expect(sidecarMocks.serverChildren).toHaveLength(1)
|
||||
let recoveredUrl: string | null = null
|
||||
const recovery = runtime.getServerUrl().then((url) => {
|
||||
recoveredUrl = url
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(recoveredUrl).toBeNull()
|
||||
|
||||
releaseReplacementHealth()
|
||||
await recovery
|
||||
expect(recoveredUrl).toBe('http://127.0.0.1:49322')
|
||||
expect(sidecarMocks.adapterChildren).toHaveLength(10)
|
||||
})
|
||||
|
||||
it('keeps demand recovery available after an immediate restart fails transiently', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const runtime = createRuntime()
|
||||
await runtime.startServer()
|
||||
let replacementAttempts = 0
|
||||
sidecarMocks.waitForServerImpl = () => {
|
||||
replacementAttempts += 1
|
||||
return replacementAttempts === 1
|
||||
? Promise.reject(new Error('port release race'))
|
||||
: Promise.resolve()
|
||||
}
|
||||
|
||||
sidecarMocks.serverChildren[0]!.emit('exit', 24, null)
|
||||
await waitForServerChildren(2)
|
||||
await waitForMockCalls(sidecarMocks.serverChildren[1]!.kill, 1)
|
||||
|
||||
await expect(runtime.getServerUrl()).resolves.toBe('http://127.0.0.1:49323')
|
||||
expect(sidecarMocks.serverChildren).toHaveLength(3)
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining('failed to restart server sidecar after exit'),
|
||||
)
|
||||
})
|
||||
|
||||
it('opens a circuit after three consecutive automatic restarts', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
let now = 0
|
||||
const restartDelays: number[] = []
|
||||
const runtime = createRuntime({
|
||||
now: () => now,
|
||||
sleep: async (delayMs) => {
|
||||
restartDelays.push(delayMs)
|
||||
now += delayMs
|
||||
},
|
||||
})
|
||||
await runtime.startServer()
|
||||
|
||||
for (let crash = 0; crash < 3; crash++) {
|
||||
sidecarMocks.serverChildren[crash]!.emit('exit', 30 + crash, null)
|
||||
await waitForServerChildren(crash + 2)
|
||||
}
|
||||
sidecarMocks.serverChildren[3]!.emit('exit', 33, null)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
expect(sidecarMocks.serverChildren).toHaveLength(4)
|
||||
await expect(runtime.getServerUrl()).rejects.toThrow('automatic restart paused')
|
||||
expect(restartDelays).toEqual([250, 1_000])
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining('automatic restart paused after 3 consecutive crashes'),
|
||||
)
|
||||
|
||||
now += 60_000
|
||||
await expect(runtime.getServerUrl()).resolves.toBe('http://127.0.0.1:49325')
|
||||
expect(sidecarMocks.serverChildren).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('resets the automatic restart budget after a stable server window', async () => {
|
||||
let now = 0
|
||||
const restartDelays: number[] = []
|
||||
const runtime = createRuntime({
|
||||
now: () => now,
|
||||
sleep: async (delayMs) => {
|
||||
restartDelays.push(delayMs)
|
||||
},
|
||||
})
|
||||
await runtime.startServer()
|
||||
|
||||
sidecarMocks.serverChildren[0]!.emit('exit', 40, null)
|
||||
await waitForServerChildren(2)
|
||||
now = 60_000
|
||||
sidecarMocks.serverChildren[1]!.emit('exit', 41, null)
|
||||
await waitForServerChildren(3)
|
||||
|
||||
expect(restartDelays).toEqual([])
|
||||
})
|
||||
|
||||
it('cancels a delayed automatic restart when the runtime stops', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
let releaseBackoff!: () => void
|
||||
const sleep = vi.fn(() => new Promise<void>(resolve => {
|
||||
releaseBackoff = resolve
|
||||
}))
|
||||
const runtime = createRuntime({ now: () => 0, sleep })
|
||||
await runtime.startServer()
|
||||
|
||||
sidecarMocks.serverChildren[0]!.emit('exit', 42, null)
|
||||
await waitForServerChildren(2)
|
||||
sidecarMocks.serverChildren[1]!.emit('exit', 43, null)
|
||||
await waitForMockCalls(sleep, 1)
|
||||
runtime.stopAll()
|
||||
releaseBackoff()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
expect(sidecarMocks.serverChildren).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('stops active adapters immediately when the server emits a process error', async () => {
|
||||
@@ -328,6 +449,7 @@ describe('ElectronServerRuntime', () => {
|
||||
const activeAdapters = [...sidecarMocks.adapterChildren]
|
||||
|
||||
sidecarMocks.serverChildren[0]!.emit('error', new Error('active server failed'))
|
||||
await waitForServerChildren(2)
|
||||
|
||||
for (const adapter of activeAdapters) {
|
||||
expect(adapter.kill).toHaveBeenCalledTimes(1)
|
||||
@@ -358,6 +480,7 @@ describe('ElectronServerRuntime', () => {
|
||||
await runtime.restartAdaptersSidecars()
|
||||
const restartedAdapters = sidecarMocks.adapterChildren.slice(5)
|
||||
sidecarMocks.serverChildren[0]!.emit('exit', 22, null)
|
||||
await waitForServerChildren(2)
|
||||
|
||||
for (const adapter of firstAdapters) {
|
||||
expect(adapter.kill).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -43,8 +43,10 @@ type ServerRuntimeOptions = {
|
||||
|
||||
type ServerRuntimeDeps = {
|
||||
appendHostDiagnostic: typeof appendHostDiagnostic
|
||||
now: () => number
|
||||
preferredServerPorts: typeof preferredServerPorts
|
||||
reserveServerPort: typeof reserveServerPort
|
||||
sleep: (delayMs: number) => Promise<void>
|
||||
spawnSidecar: typeof spawnSidecar
|
||||
waitForServer: typeof waitForServer
|
||||
writeLastServerPort: typeof writeLastServerPort
|
||||
@@ -53,14 +55,21 @@ type ServerRuntimeDeps = {
|
||||
|
||||
const DEFAULT_SERVER_RUNTIME_DEPS: ServerRuntimeDeps = {
|
||||
appendHostDiagnostic,
|
||||
now: Date.now,
|
||||
preferredServerPorts,
|
||||
reserveServerPort,
|
||||
sleep: delayMs => new Promise(resolve => setTimeout(resolve, delayMs)),
|
||||
spawnSidecar,
|
||||
waitForServer,
|
||||
writeLastServerPort,
|
||||
createSystemProxyBridge: resolveSystemProxy => new SystemProxyBridge(resolveSystemProxy),
|
||||
}
|
||||
|
||||
const AUTOMATIC_RESTART_LIMIT = 3
|
||||
const AUTOMATIC_RESTART_STABLE_MS = 60_000
|
||||
const AUTOMATIC_RESTART_COOLDOWN_MS = 60_000
|
||||
const AUTOMATIC_RESTART_BACKOFF_MS = [0, 250, 1_000] as const
|
||||
|
||||
type ServerStartState = {
|
||||
child: SidecarChild
|
||||
adapterChildren: SidecarChild[]
|
||||
@@ -74,6 +83,7 @@ type ActiveServer = {
|
||||
url: string
|
||||
child: SidecarChild
|
||||
adapterChildren: SidecarChild[]
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
function createServerStartState(child: SidecarChild): ServerStartState {
|
||||
@@ -114,6 +124,9 @@ export class ElectronServerRuntime {
|
||||
private adapters: SidecarChild[] = []
|
||||
private startupError: string | null = null
|
||||
private restartAfterExit = false
|
||||
private automaticRestartAttempts = 0
|
||||
private restartBlockedUntil = 0
|
||||
private restartNotBefore = 0
|
||||
private startPromise: Promise<string> | null = null
|
||||
private lifecycleGeneration = 0
|
||||
private startingServer: ServerStartState | null = null
|
||||
@@ -132,10 +145,12 @@ export class ElectronServerRuntime {
|
||||
async startServer(): Promise<string> {
|
||||
if (this.server) return this.server.url
|
||||
if (this.startPromise) return this.startPromise
|
||||
this.assertRestartCircuitAllowsStart()
|
||||
|
||||
this.restartAfterExit = false
|
||||
const generation = this.lifecycleGeneration
|
||||
this.startPromise = this.startServerOnce(generation)
|
||||
const restartDelayMs = Math.max(0, this.restartNotBefore - this.deps.now())
|
||||
this.startPromise = this.startServerAfterDelay(generation, restartDelayMs)
|
||||
try {
|
||||
return await this.startPromise
|
||||
} finally {
|
||||
@@ -146,6 +161,7 @@ export class ElectronServerRuntime {
|
||||
async getServerUrl(): Promise<string> {
|
||||
if (this.server) return this.server.url
|
||||
if (this.startPromise) return await this.startServer()
|
||||
this.assertRestartCircuitAllowsStart()
|
||||
if (this.startupError && !this.restartAfterExit) throw new Error(this.startupError)
|
||||
return await this.startServer()
|
||||
}
|
||||
@@ -182,6 +198,7 @@ export class ElectronServerRuntime {
|
||||
|
||||
stopAll(sync = false) {
|
||||
++this.lifecycleGeneration
|
||||
this.restartNotBefore = 0
|
||||
const starting = this.startingServer
|
||||
if (starting) {
|
||||
this.startingServer = null
|
||||
@@ -201,6 +218,12 @@ export class ElectronServerRuntime {
|
||||
this.stopSystemProxyBridge()
|
||||
}
|
||||
|
||||
private async startServerAfterDelay(generation: number, delayMs: number): Promise<string> {
|
||||
if (delayMs > 0) await this.deps.sleep(delayMs)
|
||||
this.assertCurrentGeneration(generation)
|
||||
return await this.startServerOnce(generation)
|
||||
}
|
||||
|
||||
private async startServerOnce(generation: number): Promise<string> {
|
||||
// Prefer the configured fixed port, then the previous run's port, so
|
||||
// phone bookmarks / QR codes / reverse proxies survive restarts (#767).
|
||||
@@ -238,7 +261,12 @@ export class ElectronServerRuntime {
|
||||
])
|
||||
if (startState.failure) throw startState.failure
|
||||
this.deps.writeLastServerPort(port, this.baseEnv)
|
||||
this.server = { url, child, adapterChildren: startState.adapterChildren }
|
||||
this.server = {
|
||||
url,
|
||||
child,
|
||||
adapterChildren: startState.adapterChildren,
|
||||
startedAt: this.deps.now(),
|
||||
}
|
||||
const activeServer = this.server
|
||||
this.startupError = null
|
||||
this.stopAdaptersSidecars()
|
||||
@@ -418,6 +446,7 @@ export class ElectronServerRuntime {
|
||||
const active = this.server?.child === child
|
||||
const starting = this.startingServer?.child === child
|
||||
if (!active && !starting) return
|
||||
const failedServer = active ? this.server : null
|
||||
if (active) {
|
||||
const adapterChildren = this.server!.adapterChildren
|
||||
this.server = null
|
||||
@@ -426,6 +455,52 @@ export class ElectronServerRuntime {
|
||||
this.restartAfterExit = true
|
||||
this.startupError = formatStartupError(message, logs)
|
||||
if (starting) this.startingServer?.fail(new Error(message))
|
||||
if (failedServer && !starting) {
|
||||
const now = this.deps.now()
|
||||
if (now - failedServer.startedAt >= AUTOMATIC_RESTART_STABLE_MS) {
|
||||
this.automaticRestartAttempts = 0
|
||||
}
|
||||
if (this.automaticRestartAttempts >= AUTOMATIC_RESTART_LIMIT) {
|
||||
this.openAutomaticRestartCircuit(message, logs, now)
|
||||
return
|
||||
}
|
||||
const attempt = ++this.automaticRestartAttempts
|
||||
const backoffMs = AUTOMATIC_RESTART_BACKOFF_MS[attempt - 1] ?? 0
|
||||
this.restartNotBefore = now + backoffMs
|
||||
const restartGeneration = this.lifecycleGeneration
|
||||
void this.startServer().catch((error) => {
|
||||
if (this.lifecycleGeneration === restartGeneration) {
|
||||
// Keep a later renderer recovery request eligible to retry if this
|
||||
// immediate restart lost a port-release race or failed transiently.
|
||||
this.restartAfterExit = true
|
||||
}
|
||||
const detail = sanitizeHostDiagnostic(error instanceof Error ? error.message : String(error))
|
||||
console.error(`[desktop] failed to restart server sidecar after exit: ${detail}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private openAutomaticRestartCircuit(message: string, logs: string[], now: number) {
|
||||
this.restartAfterExit = false
|
||||
this.restartNotBefore = 0
|
||||
this.restartBlockedUntil = now + AUTOMATIC_RESTART_COOLDOWN_MS
|
||||
const circuitMessage = `automatic restart paused after ${AUTOMATIC_RESTART_LIMIT} consecutive crashes; retry in ${AUTOMATIC_RESTART_COOLDOWN_MS / 1_000} seconds`
|
||||
this.startupError = formatStartupError(`${message}; ${circuitMessage}`, logs)
|
||||
this.deps.appendHostDiagnostic(
|
||||
this.diagnosticsFile,
|
||||
`[claude-server] [restart-circuit-open] ${circuitMessage}`,
|
||||
)
|
||||
console.error(`[desktop] ${circuitMessage}`)
|
||||
}
|
||||
|
||||
private assertRestartCircuitAllowsStart() {
|
||||
if (this.restartBlockedUntil === 0) return
|
||||
if (this.deps.now() < this.restartBlockedUntil) {
|
||||
throw new Error(this.startupError ?? 'automatic restart paused')
|
||||
}
|
||||
this.restartBlockedUntil = 0
|
||||
this.automaticRestartAttempts = 0
|
||||
this.restartAfterExit = true
|
||||
}
|
||||
|
||||
private stopAdapterChildren(children: SidecarChild[], sync = false) {
|
||||
|
||||
@@ -1,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": {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { ProviderModelsResult, SavedProvider } from '../types/provider'
|
||||
import type { ProviderPreset } from '../types/providerPreset'
|
||||
import type { AppMode, ChatSendBehavior, PermissionMode, ThemeMode, UpdateProxySettings } from '../types/settings'
|
||||
import { browserHost } from '../lib/desktopHost/browserHost'
|
||||
import { settingsApi } from '../api/settings'
|
||||
|
||||
const MOCK_DELETE_PROVIDER = vi.fn()
|
||||
const MOCK_GET_SETTINGS = vi.fn()
|
||||
@@ -236,14 +237,25 @@ describe('Settings > General tab', () => {
|
||||
useSettingsStore.setState({
|
||||
locale: 'en',
|
||||
permissionMode: 'default',
|
||||
currentModel: {
|
||||
id: 'claude-opus-4-8',
|
||||
name: 'Opus 4.8',
|
||||
description: 'Highest capability for long-running tasks',
|
||||
context: '1m',
|
||||
defaultReasoningEffort: 'high',
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
},
|
||||
effortLevel: 'max',
|
||||
autoModeOptInAccepted: false,
|
||||
thinkingEnabled: true,
|
||||
workflowKeywordTriggerEnabled: true,
|
||||
autoDreamEnabled: false,
|
||||
skipWebFetchPreflight: true,
|
||||
desktopNotificationsEnabled: true,
|
||||
traceCapture: { enabled: true, storageDir: '/Users/test/.claude/cc-haha/traces' },
|
||||
chatSendBehavior: 'enter',
|
||||
responseLanguage: '',
|
||||
proxyManagedSettingsWarning: false,
|
||||
uiZoom: 1,
|
||||
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
|
||||
network: {
|
||||
@@ -281,6 +293,9 @@ describe('Settings > General tab', () => {
|
||||
setThinkingEnabled: vi.fn().mockImplementation(async (enabled: boolean) => {
|
||||
useSettingsStore.setState({ thinkingEnabled: enabled })
|
||||
}),
|
||||
setEffort: vi.fn().mockImplementation(async (effortLevel) => {
|
||||
useSettingsStore.setState({ effortLevel })
|
||||
}),
|
||||
setAutoDreamEnabled: vi.fn().mockImplementation(async (enabled: boolean) => {
|
||||
useSettingsStore.setState({ autoDreamEnabled: enabled })
|
||||
}),
|
||||
@@ -897,6 +912,68 @@ describe('Settings > General tab', () => {
|
||||
expect(useSettingsStore.getState().setThinkingEnabled).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('sets the new-session effort through the current model capability profile', async () => {
|
||||
useSettingsStore.setState({
|
||||
currentModel: {
|
||||
id: 'claude-sonnet-4-6',
|
||||
name: 'Sonnet 4.6',
|
||||
description: 'Balanced Claude model',
|
||||
context: '1m',
|
||||
defaultReasoningEffort: 'medium',
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high'],
|
||||
},
|
||||
// A default from another provider must not create a stop that this
|
||||
// model cannot actually use. The visible value falls back to the
|
||||
// model default until the user selects a supported level.
|
||||
effortLevel: 'max',
|
||||
})
|
||||
|
||||
render(<Settings />)
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
|
||||
const trigger = screen.getByRole('button', { name: 'Default reasoning effort: Medium' })
|
||||
fireEvent.click(trigger)
|
||||
|
||||
const slider = screen.getByRole('slider', { name: 'Default reasoning effort' })
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '2')
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '1')
|
||||
expect(screen.getAllByTestId('reasoning-effort-stop')).toHaveLength(3)
|
||||
|
||||
fireEvent.keyDown(slider, { key: 'ArrowRight' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useSettingsStore.getState().setEffort).toHaveBeenCalledWith('high')
|
||||
})
|
||||
expect(useSettingsStore.getState().effortLevel).toBe('high')
|
||||
})
|
||||
|
||||
it('lets the user disable and restore the Ultracode keyword trigger', async () => {
|
||||
const updateUser = vi.spyOn(settingsApi, 'updateUser').mockResolvedValue({ ok: true })
|
||||
|
||||
try {
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
|
||||
const toggle = screen.getByRole('switch', { name: 'Enable Ultracode keyword trigger' })
|
||||
expect(toggle).toBeChecked()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(toggle)
|
||||
})
|
||||
expect(toggle).not.toBeChecked()
|
||||
expect(updateUser).toHaveBeenLastCalledWith({ workflowKeywordTriggerEnabled: false })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(toggle)
|
||||
})
|
||||
expect(toggle).toBeChecked()
|
||||
expect(updateUser).toHaveBeenLastCalledWith({ workflowKeywordTriggerEnabled: true })
|
||||
} finally {
|
||||
updateUser.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('lets the user choose a default permission mode for new sessions', async () => {
|
||||
render(<Settings />)
|
||||
|
||||
@@ -1681,6 +1758,17 @@ describe('Settings > General tab', () => {
|
||||
expect(screen.getByText('MCP')).toBeInTheDocument()
|
||||
expect(screen.getByText('Plugins')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('warns when the user settings contain only a proxy-managed placeholder', async () => {
|
||||
useSettingsStore.setState({ proxyManagedSettingsWarning: true })
|
||||
|
||||
render(<Settings />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'General' }))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Your user settings contain only a PROXY_MANAGED proxy placeholder',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings > Providers tab', () => {
|
||||
@@ -2226,7 +2314,7 @@ describe('Settings > Providers tab', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults Tool Search on and persists an explicit disable from the provider form', async () => {
|
||||
it('defaults Tool Search off and requires confirmation before persisting an explicit enable', async () => {
|
||||
MOCK_GET_SETTINGS.mockResolvedValue({ env: { EXISTING_ENV: '1' } })
|
||||
providerStoreState.createProvider = vi.fn().mockResolvedValue({
|
||||
id: 'provider-new',
|
||||
@@ -2235,7 +2323,7 @@ describe('Settings > Providers tab', () => {
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.example.com/anthropic',
|
||||
apiFormat: 'anthropic',
|
||||
toolSearchEnabled: false,
|
||||
toolSearchEnabled: true,
|
||||
models: {
|
||||
main: 'custom-main',
|
||||
haiku: 'custom-main',
|
||||
@@ -2266,18 +2354,25 @@ describe('Settings > Providers tab', () => {
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const toolSearchCheckbox = within(dialog).getByRole('checkbox', { name: 'Enable Tool Search' })
|
||||
|
||||
expect(toolSearchCheckbox).toBeChecked()
|
||||
expect(toolSearchCheckbox).not.toBeChecked()
|
||||
await waitFor(() => {
|
||||
expect(within(dialog).getByDisplayValue((value) => (
|
||||
typeof value === 'string' && value.includes('"ENABLE_TOOL_SEARCH": "true"')
|
||||
typeof value === 'string' && value.includes('"ENABLE_TOOL_SEARCH": "false"')
|
||||
))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(toolSearchCheckbox)
|
||||
expect(toolSearchCheckbox).not.toBeChecked()
|
||||
|
||||
const confirmDialog = screen.getByRole('dialog', { name: 'Enable Tool Search?' })
|
||||
expect(within(confirmDialog).getByText(/final LLM upstream or gateway explicitly supports/)).toBeInTheDocument()
|
||||
expect(within(confirmDialog).getByText(/may return HTTP 400/)).toBeInTheDocument()
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: 'Enable anyway' }))
|
||||
|
||||
expect(toolSearchCheckbox).toBeChecked()
|
||||
await waitFor(() => {
|
||||
expect(within(dialog).getByDisplayValue((value) => (
|
||||
typeof value === 'string' && value.includes('"ENABLE_TOOL_SEARCH": "false"')
|
||||
typeof value === 'string' && value.includes('"ENABLE_TOOL_SEARCH": "true"')
|
||||
))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -2286,13 +2381,13 @@ describe('Settings > Providers tab', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(providerStoreState.createProvider).toHaveBeenCalledWith(expect.objectContaining({
|
||||
toolSearchEnabled: false,
|
||||
toolSearchEnabled: true,
|
||||
}))
|
||||
})
|
||||
expect(MOCK_UPDATE_SETTINGS).toHaveBeenCalledWith(expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
EXISTING_ENV: '1',
|
||||
ENABLE_TOOL_SEARCH: 'false',
|
||||
ENABLE_TOOL_SEARCH: 'true',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
@@ -2336,6 +2431,9 @@ describe('Settings > Providers tab', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Provider/i }))
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const disableBetasCheckbox = within(dialog).getByRole('checkbox', { name: 'Disable experimental beta headers' })
|
||||
expect(within(dialog).getByText(
|
||||
/GPT and o-series models still receive the reasoning effort selected for the Session/i,
|
||||
)).toBeInTheDocument()
|
||||
const settingsTextarea = await waitFor(() => {
|
||||
const textarea = dialog.querySelector('textarea')
|
||||
expect(textarea?.value).toContain('"ANTHROPIC_MODEL"')
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
const server = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
list: vi.fn(),
|
||||
getGitInfo: vi.fn(),
|
||||
getMessages: vi.fn(),
|
||||
getSlashCommands: vi.fn(),
|
||||
getInspection: vi.fn(),
|
||||
getRepositoryContext: vi.fn(),
|
||||
getRecentProjects: vi.fn(),
|
||||
rows: [] as Array<Record<string, unknown>>,
|
||||
materialized: false,
|
||||
}))
|
||||
|
||||
vi.mock('../api/sessions', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/sessions')>()
|
||||
return {
|
||||
...actual,
|
||||
sessionsApi: {
|
||||
...actual.sessionsApi,
|
||||
create: server.create,
|
||||
list: server.list,
|
||||
getGitInfo: server.getGitInfo,
|
||||
getMessages: server.getMessages,
|
||||
getSlashCommands: server.getSlashCommands,
|
||||
getInspection: server.getInspection,
|
||||
getRepositoryContext: server.getRepositoryContext,
|
||||
getRecentProjects: server.getRecentProjects,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../api/agents', () => ({
|
||||
agentsApi: { list: vi.fn(async () => ({ activeAgents: [], allAgents: [] })) },
|
||||
}))
|
||||
vi.mock('../api/skills', () => ({
|
||||
skillsApi: { list: vi.fn(async () => ({ skills: [] })) },
|
||||
}))
|
||||
vi.mock('../api/providers', () => ({
|
||||
providersApi: { list: vi.fn(async () => ({ providers: [], activeId: null })) },
|
||||
}))
|
||||
vi.mock('../api/mcp', () => ({
|
||||
mcpApi: { list: vi.fn(async () => ({ servers: [] })), status: vi.fn() },
|
||||
}))
|
||||
vi.mock('../api/teams', () => ({
|
||||
teamsApi: {
|
||||
list: vi.fn(async () => ({ teams: [] })),
|
||||
getWorkbenchForSession: vi.fn(async () => null),
|
||||
},
|
||||
}))
|
||||
vi.mock('../api/cliTasks', () => ({
|
||||
cliTasksApi: {
|
||||
getTasksForList: vi.fn(async () => ({ tasks: [] })),
|
||||
resetTaskList: vi.fn(async () => ({ ok: true })),
|
||||
},
|
||||
}))
|
||||
vi.mock('../api/websocket', () => ({
|
||||
wsManager: {
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
clearHandlers: vi.fn(),
|
||||
onConnectionState: vi.fn((_sessionId: string, handler: (state: string) => void) => {
|
||||
handler('connected')
|
||||
return () => {}
|
||||
}),
|
||||
onMessage: vi.fn(() => () => {}),
|
||||
send: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('../hooks/useMobileViewport', () => ({
|
||||
useMobileViewport: () => false,
|
||||
}))
|
||||
vi.mock('../components/chat/MessageList', () => ({
|
||||
MessageList: () => <div data-testid="message-list" />,
|
||||
}))
|
||||
vi.mock('../components/controls/PermissionModeSelector', () => ({
|
||||
PermissionModeSelector: () => <button type="button">Permissions</button>,
|
||||
}))
|
||||
vi.mock('../components/controls/ModelSelector', async () => {
|
||||
const React = await vi.importActual<typeof import('react')>('react')
|
||||
return {
|
||||
ModelSelector: React.forwardRef(() => <button type="button">Model</button>),
|
||||
}
|
||||
})
|
||||
|
||||
import { ActiveSession } from '../pages/ActiveSession'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useWorkspaceChatContextStore } from '../stores/workspaceChatContextStore'
|
||||
|
||||
const WORKTREE_SESSION_ID = 'worktree-lifecycle-session'
|
||||
const REGULAR_SESSION_ID = 'regular-lifecycle-session'
|
||||
const SOURCE_WORK_DIR = '/tmp/express'
|
||||
const PLANNED_WORKTREE_PATH = '/tmp/express/.claude/worktrees/desktop-main-planned'
|
||||
const ACTUAL_WORKTREE_CWD = '/private/tmp/express/.claude/worktrees/desktop-main-planned'
|
||||
const REGULAR_WORK_DIR = '/tmp/plain-project'
|
||||
|
||||
function sessionRow(id: string, workDir: string, messageCount = 0) {
|
||||
return {
|
||||
id,
|
||||
title: id === WORKTREE_SESSION_ID ? 'Worktree Session' : 'Regular Session',
|
||||
createdAt: '2026-08-10T00:00:00.000Z',
|
||||
modifiedAt: '2026-08-10T00:00:00.000Z',
|
||||
messageCount,
|
||||
projectPath: workDir,
|
||||
projectRoot: workDir,
|
||||
workDir,
|
||||
workDirExists: true,
|
||||
workspaceState: 'available',
|
||||
}
|
||||
}
|
||||
|
||||
async function finishTurn(sessionId: string, text: string) {
|
||||
await act(async () => {
|
||||
const chat = useChatStore.getState()
|
||||
chat.handleServerMessage(sessionId, { type: 'status', state: 'thinking' })
|
||||
chat.handleServerMessage(sessionId, { type: 'content_start', blockType: 'text' })
|
||||
chat.handleServerMessage(sessionId, { type: 'content_delta', text })
|
||||
chat.handleServerMessage(sessionId, {
|
||||
type: 'message_complete',
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
})
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
async function expectTooltipPath(trigger: HTMLElement, path: string) {
|
||||
fireEvent.focus(trigger)
|
||||
const tooltip = await screen.findByRole('tooltip')
|
||||
expect(tooltip).toHaveTextContent(path)
|
||||
fireEvent.blur(trigger)
|
||||
await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument())
|
||||
}
|
||||
|
||||
describe('worktree metadata across the live session lifecycle', () => {
|
||||
const initialChatState = useChatStore.getInitialState()
|
||||
const initialSessionState = useSessionStore.getInitialState()
|
||||
const initialTabState = useTabStore.getInitialState()
|
||||
const initialWorkspaceContextState = useWorkspaceChatContextStore.getInitialState()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
server.rows = []
|
||||
server.materialized = false
|
||||
useSettingsStore.setState({ locale: 'en', permissionMode: 'default' })
|
||||
useChatStore.setState(initialChatState, true)
|
||||
useSessionStore.setState(initialSessionState, true)
|
||||
useTabStore.setState(initialTabState, true)
|
||||
useWorkspaceChatContextStore.setState(initialWorkspaceContextState, true)
|
||||
|
||||
server.create.mockImplementation(async (input: {
|
||||
workDir?: string
|
||||
repository?: { worktree?: boolean }
|
||||
}) => {
|
||||
const isolated = input.repository?.worktree === true
|
||||
const id = isolated ? WORKTREE_SESSION_ID : REGULAR_SESSION_ID
|
||||
const workDir = input.workDir || REGULAR_WORK_DIR
|
||||
server.rows = [
|
||||
sessionRow(id, workDir),
|
||||
...server.rows.filter((row) => row.id !== id),
|
||||
]
|
||||
return { sessionId: id, workDir }
|
||||
})
|
||||
server.list.mockImplementation(async () => ({
|
||||
sessions: server.rows,
|
||||
total: server.rows.length,
|
||||
}))
|
||||
server.getGitInfo.mockImplementation(async (sessionId: string) => {
|
||||
if (sessionId === REGULAR_SESSION_ID) {
|
||||
return {
|
||||
branch: 'main',
|
||||
repoName: 'plain-project',
|
||||
workDir: REGULAR_WORK_DIR,
|
||||
changedFiles: 0,
|
||||
worktree: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
branch: 'main',
|
||||
repoName: 'express',
|
||||
workDir: server.materialized ? ACTUAL_WORKTREE_CWD : SOURCE_WORK_DIR,
|
||||
changedFiles: 0,
|
||||
worktree: {
|
||||
enabled: true,
|
||||
path: server.materialized ? ACTUAL_WORKTREE_CWD : null,
|
||||
plannedPath: PLANNED_WORKTREE_PATH,
|
||||
sourceWorkDir: SOURCE_WORK_DIR,
|
||||
slug: 'desktop-main-planned',
|
||||
branch: 'worktree-desktop-main-planned',
|
||||
},
|
||||
}
|
||||
})
|
||||
server.getMessages.mockResolvedValue({ messages: [] })
|
||||
server.getSlashCommands.mockResolvedValue({ commands: [] })
|
||||
server.getInspection.mockImplementation(async (sessionId: string) => ({
|
||||
active: false,
|
||||
status: {
|
||||
sessionId,
|
||||
workDir: sessionId === WORKTREE_SESSION_ID ? ACTUAL_WORKTREE_CWD : REGULAR_WORK_DIR,
|
||||
permissionMode: 'default',
|
||||
},
|
||||
}))
|
||||
server.getRepositoryContext.mockImplementation(async (workDir: string) => ({
|
||||
state: 'ok',
|
||||
workDir,
|
||||
repoRoot: workDir,
|
||||
repoName: workDir.split('/').filter(Boolean).at(-1) || null,
|
||||
currentBranch: 'main',
|
||||
defaultBranch: 'main',
|
||||
dirty: false,
|
||||
branches: [{
|
||||
name: 'main',
|
||||
current: true,
|
||||
local: true,
|
||||
remote: false,
|
||||
checkedOut: true,
|
||||
worktreePath: workDir,
|
||||
}],
|
||||
worktrees: [{ path: workDir, branch: 'main', current: true }],
|
||||
}))
|
||||
server.getRecentProjects.mockResolvedValue({ projects: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('keeps header and composer on the actual cwd after create, materialize, and a regular-session switch', async () => {
|
||||
let sessionId = ''
|
||||
await act(async () => {
|
||||
sessionId = await useSessionStore.getState().createSession(SOURCE_WORK_DIR, {
|
||||
repository: { branch: 'main', worktree: true },
|
||||
})
|
||||
})
|
||||
act(() => {
|
||||
useTabStore.getState().openTab(sessionId, 'Worktree Session', 'session')
|
||||
})
|
||||
|
||||
render(<ActiveSession />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(server.getGitInfo).toHaveBeenCalledWith(sessionId)
|
||||
})
|
||||
expect(screen.queryByTestId('session-worktree-indicator')).not.toBeInTheDocument()
|
||||
|
||||
const callsBeforeMaterialize = server.getGitInfo.mock.calls.length
|
||||
server.materialized = true
|
||||
await finishTurn(sessionId, 'worktree ready')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(server.getGitInfo.mock.calls.length).toBeGreaterThan(callsBeforeMaterialize)
|
||||
expect(screen.getByTestId('worktree-details-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await expectTooltipPath(screen.getByTestId('session-worktree-indicator'), ACTUAL_WORKTREE_CWD)
|
||||
await expectTooltipPath(screen.getByTestId('worktree-details-trigger'), ACTUAL_WORKTREE_CWD)
|
||||
|
||||
let regularSessionId = ''
|
||||
await act(async () => {
|
||||
regularSessionId = await useSessionStore.getState().createSession(REGULAR_WORK_DIR)
|
||||
})
|
||||
act(() => {
|
||||
useTabStore.getState().openTab(regularSessionId, 'Regular Session', 'session')
|
||||
})
|
||||
await finishTurn(regularSessionId, 'plain checkout ready')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('session-worktree-indicator')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('worktree-details-trigger')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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?')
|
||||
|
||||
|
||||
@@ -23,7 +23,24 @@ export type AgentDefinition = {
|
||||
target?: string
|
||||
overriddenBy?: AgentSource
|
||||
isActive: boolean
|
||||
/** The backing file can be rewritten. Never true for built-in agents. */
|
||||
editable?: boolean
|
||||
/** Built-in agents only: model and effort can be changed via setOverride. */
|
||||
overridable?: boolean
|
||||
/**
|
||||
* Built-in agents only: what this build ships with, so the UI can name and
|
||||
* restore the default. Never hardcode it — it varies per agent and per build.
|
||||
*/
|
||||
defaults?: { model?: string; effort?: string | number }
|
||||
/** Built-in agents only: the override currently in effect, if any. */
|
||||
override?: { model?: string; effort?: string | number; source: AgentSource }
|
||||
}
|
||||
|
||||
/** `null` clears that field; an omitted field is left unchanged. */
|
||||
export type AgentOverrideInput = {
|
||||
cwd?: string
|
||||
model?: string | null
|
||||
effort?: string | number | null
|
||||
}
|
||||
|
||||
export type AgentScope = 'user' | 'project'
|
||||
@@ -82,6 +99,19 @@ export const agentsApi = {
|
||||
if (target) query.set('target', target)
|
||||
return api.delete<void>(`/api/agents/${encodeURIComponent(name)}?${query.toString()}`)
|
||||
},
|
||||
setOverride: (name: string, input: AgentOverrideInput) =>
|
||||
api.put<AgentMutationResponse>(
|
||||
`/api/agents/${encodeURIComponent(name)}/override`,
|
||||
input,
|
||||
),
|
||||
clearOverride: (name: string, cwd?: string) => {
|
||||
const query = new URLSearchParams()
|
||||
if (cwd) query.set('cwd', cwd)
|
||||
const suffix = query.toString() ? `?${query.toString()}` : ''
|
||||
return api.delete<AgentMutationResponse>(
|
||||
`/api/agents/${encodeURIComponent(name)}/override${suffix}`,
|
||||
)
|
||||
},
|
||||
reload: (sessionId: string) =>
|
||||
api.post<AgentReloadResponse>(
|
||||
`/api/agents/reload?sessionId=${encodeURIComponent(sessionId)}`,
|
||||
|
||||
@@ -7,15 +7,112 @@ import {
|
||||
setAuthToken,
|
||||
setBaseUrl,
|
||||
} from './client'
|
||||
import { browserHost } from '../lib/desktopHost/browserHost'
|
||||
|
||||
describe('api diagnostics reporting', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
setAuthToken(null)
|
||||
setBaseUrl(getDefaultBaseUrl())
|
||||
Reflect.deleteProperty(window, 'desktopHost')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('recovers a desktop GET after the sidecar restarts on a new port', async () => {
|
||||
const firstUrl = 'http://127.0.0.1:49231'
|
||||
const recoveredUrl = 'http://127.0.0.1:49232'
|
||||
const getServerUrl = vi.fn().mockResolvedValue(recoveredUrl)
|
||||
window.desktopHost = {
|
||||
...browserHost,
|
||||
kind: 'electron',
|
||||
isDesktop: true,
|
||||
runtime: {
|
||||
...browserHost.runtime,
|
||||
getServerUrl,
|
||||
},
|
||||
}
|
||||
setBaseUrl(firstUrl)
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
|
||||
const url = String(input)
|
||||
if (url === `${firstUrl}/api/sessions/session-1/messages`) {
|
||||
return Promise.reject(new TypeError('Failed to fetch'))
|
||||
}
|
||||
if (url === `${recoveredUrl}/api/sessions/session-1/messages`) {
|
||||
return Promise.resolve(Response.json({ messages: [] }))
|
||||
}
|
||||
return Promise.resolve(new Response(null, { status: 204 }))
|
||||
})
|
||||
|
||||
await expect(api.get('/api/sessions/session-1/messages')).resolves.toEqual({ messages: [] })
|
||||
|
||||
expect(getServerUrl).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([
|
||||
`${firstUrl}/api/sessions/session-1/messages`,
|
||||
`${recoveredUrl}/api/sessions/session-1/messages`,
|
||||
])
|
||||
})
|
||||
|
||||
it('does not replay a desktop mutation after a transport failure', async () => {
|
||||
const getServerUrl = vi.fn().mockResolvedValue('http://127.0.0.1:49232')
|
||||
window.desktopHost = {
|
||||
...browserHost,
|
||||
kind: 'electron',
|
||||
isDesktop: true,
|
||||
runtime: {
|
||||
...browserHost.runtime,
|
||||
getServerUrl,
|
||||
},
|
||||
}
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
|
||||
if (String(input).endsWith('/api/providers/test')) {
|
||||
return Promise.reject(new TypeError('Failed to fetch'))
|
||||
}
|
||||
return Promise.resolve(new Response(null, { status: 204 }))
|
||||
})
|
||||
|
||||
await expect(api.post('/api/providers/test', { value: 'once' })).rejects.toThrow('Failed to fetch')
|
||||
|
||||
expect(getServerUrl).not.toHaveBeenCalled()
|
||||
expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/api/providers/test')))
|
||||
.toHaveLength(1)
|
||||
})
|
||||
|
||||
it('coalesces recovery when concurrent desktop GETs observe the same sidecar exit', async () => {
|
||||
const firstUrl = 'http://127.0.0.1:49241'
|
||||
const recoveredUrl = 'http://127.0.0.1:49242'
|
||||
let releaseRecovery!: (url: string) => void
|
||||
const getServerUrl = vi.fn(() => new Promise<string>(resolve => {
|
||||
releaseRecovery = resolve
|
||||
}))
|
||||
window.desktopHost = {
|
||||
...browserHost,
|
||||
kind: 'electron',
|
||||
isDesktop: true,
|
||||
runtime: {
|
||||
...browserHost.runtime,
|
||||
getServerUrl,
|
||||
},
|
||||
}
|
||||
setBaseUrl(firstUrl)
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
|
||||
const url = String(input)
|
||||
if (url.startsWith(firstUrl)) return Promise.reject(new TypeError('Failed to fetch'))
|
||||
return Promise.resolve(Response.json({ url }))
|
||||
})
|
||||
|
||||
const firstRequest = api.get<{ url: string }>('/api/sessions/session-1/messages')
|
||||
const secondRequest = api.get<{ url: string }>('/api/sessions/session-2/messages')
|
||||
await vi.waitFor(() => expect(getServerUrl).toHaveBeenCalledTimes(1))
|
||||
releaseRecovery(recoveredUrl)
|
||||
|
||||
await expect(Promise.all([firstRequest, secondRequest])).resolves.toEqual([
|
||||
{ url: `${recoveredUrl}/api/sessions/session-1/messages` },
|
||||
{ url: `${recoveredUrl}/api/sessions/session-2/messages` },
|
||||
])
|
||||
expect(getServerUrl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not send Authorization for default local requests', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), {
|
||||
@@ -117,6 +214,16 @@ describe('api diagnostics reporting', () => {
|
||||
|
||||
it('propagates caller cancellation without reporting an API failure', async () => {
|
||||
const controller = new AbortController()
|
||||
const getServerUrl = vi.fn().mockResolvedValue('http://127.0.0.1:49252')
|
||||
window.desktopHost = {
|
||||
...browserHost,
|
||||
kind: 'electron',
|
||||
isDesktop: true,
|
||||
runtime: {
|
||||
...browserHost.runtime,
|
||||
getServerUrl,
|
||||
},
|
||||
}
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock.mockImplementation((_url, init) => new Promise<Response>((_, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
@@ -131,6 +238,7 @@ describe('api diagnostics reporting', () => {
|
||||
|
||||
await expect(request).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(getServerUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults local API requests to a 120 second timeout', async () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getDesktopHost } from '../lib/desktopHost'
|
||||
|
||||
const ENV_BASE_URL =
|
||||
typeof import.meta !== 'undefined' &&
|
||||
typeof import.meta.env?.VITE_DESKTOP_SERVER_URL === 'string' &&
|
||||
@@ -9,6 +11,7 @@ const DEFAULT_BASE_URL = ENV_BASE_URL || 'http://127.0.0.1:3456'
|
||||
|
||||
let baseUrl = DEFAULT_BASE_URL
|
||||
let authToken: string | null = null
|
||||
let desktopServerRecovery: Promise<string> | null = null
|
||||
const DIAGNOSTICS_PATH = '/api/diagnostics/events'
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000
|
||||
const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 5_000
|
||||
@@ -75,7 +78,6 @@ export type ApiRequestOptions = {
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown, options?: ApiRequestOptions): Promise<T> {
|
||||
const url = `${baseUrl}${path}`
|
||||
const headers = buildHeaders()
|
||||
|
||||
const controller = new AbortController()
|
||||
@@ -89,12 +91,27 @@ async function request<T>(method: string, path: string, body?: unknown, options?
|
||||
if (options?.signal?.aborted) abortFromCaller()
|
||||
else options?.signal?.addEventListener('abort', abortFromCaller, { once: true })
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
const fetchOnce = () => fetch(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
})
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetchOnce()
|
||||
} catch (error) {
|
||||
if (
|
||||
method !== 'GET' ||
|
||||
timedOut ||
|
||||
options?.signal?.aborted ||
|
||||
!(error instanceof TypeError) ||
|
||||
!await recoverDesktopServerUrl()
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
res = await fetchOnce()
|
||||
}
|
||||
if (!res.ok) {
|
||||
const errorBody = await res.json().catch(() => res.text())
|
||||
throw new ApiError(res.status, errorBody)
|
||||
@@ -121,6 +138,25 @@ async function request<T>(method: string, path: string, body?: unknown, options?
|
||||
}
|
||||
}
|
||||
|
||||
async function recoverDesktopServerUrl(): Promise<boolean> {
|
||||
const host = getDesktopHost()
|
||||
if (!host.isDesktop) return false
|
||||
|
||||
if (!desktopServerRecovery) {
|
||||
const recovery = host.runtime.getServerUrl().then((serverUrl) => {
|
||||
setBaseUrl(serverUrl)
|
||||
return serverUrl
|
||||
})
|
||||
const trackedRecovery = recovery.finally(() => {
|
||||
if (desktopServerRecovery === trackedRecovery) desktopServerRecovery = null
|
||||
})
|
||||
desktopServerRecovery = trackedRecovery
|
||||
}
|
||||
|
||||
await desktopServerRecovery
|
||||
return true
|
||||
}
|
||||
|
||||
function reportApiFailure(method: string, path: string, error: unknown) {
|
||||
if (path.startsWith('/api/diagnostics')) return
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { desktopUiPreferencesApi, getProfileAvatarUrl } from './desktopUiPrefere
|
||||
|
||||
const preferences = {
|
||||
schemaVersion: 3,
|
||||
projectDisplayNames: {},
|
||||
profile: {
|
||||
displayName: 'cc-haha',
|
||||
subtitle: 'github.com/NanmiCoder/cc-haha',
|
||||
@@ -58,6 +59,62 @@ describe('desktopUiPreferencesApi', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('updates project display names and sends null to reset one', async () => {
|
||||
setBaseUrl('http://127.0.0.1:49237')
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
ok: true,
|
||||
projectKey: '/workspace/apps/../project',
|
||||
displayName: 'Project alias',
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
ok: true,
|
||||
projectKey: '/workspace/apps/../project',
|
||||
displayName: null,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
|
||||
await expect(desktopUiPreferencesApi.updateProjectDisplayName(
|
||||
'/workspace/apps/../project',
|
||||
'Project alias',
|
||||
)).resolves.toEqual({
|
||||
ok: true,
|
||||
projectKey: '/workspace/apps/../project',
|
||||
displayName: 'Project alias',
|
||||
})
|
||||
await expect(desktopUiPreferencesApi.updateProjectDisplayName(
|
||||
'/workspace/apps/../project',
|
||||
null,
|
||||
)).resolves.toEqual({
|
||||
ok: true,
|
||||
projectKey: '/workspace/apps/../project',
|
||||
displayName: null,
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'http://127.0.0.1:49237/api/desktop-ui/preferences/project-display-name',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ projectKey: '/workspace/apps/../project', displayName: 'Project alias' }),
|
||||
}),
|
||||
)
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'http://127.0.0.1:49237/api/desktop-ui/preferences/project-display-name',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ projectKey: '/workspace/apps/../project', displayName: null }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('reads only the pet projection through the scoped preference endpoint', async () => {
|
||||
setBaseUrl('http://127.0.0.1:49237')
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
|
||||
@@ -30,6 +30,7 @@ export type DesktopUiPreferences = {
|
||||
sidebar: SidebarProjectPreferences
|
||||
profile: DesktopProfilePreferences
|
||||
pet: DesktopPetPreferences
|
||||
projectDisplayNames: Record<string, string>
|
||||
}
|
||||
|
||||
export type DesktopUiPreferencesResponse = {
|
||||
@@ -46,6 +47,12 @@ export type DesktopPetPreferencesUpdateResponse =
|
||||
| { ok: true; preferences: DesktopUiPreferences }
|
||||
| { ok: true; pet: DesktopPetPreferences }
|
||||
|
||||
export type ProjectDisplayNameUpdateResponse = {
|
||||
ok: true
|
||||
projectKey: string
|
||||
displayName: string | null
|
||||
}
|
||||
|
||||
export const desktopUiPreferencesApi = {
|
||||
getPreferences() {
|
||||
return api.get<DesktopUiPreferencesResponse>('/api/desktop-ui/preferences')
|
||||
@@ -62,6 +69,13 @@ export const desktopUiPreferencesApi = {
|
||||
)
|
||||
},
|
||||
|
||||
updateProjectDisplayName(projectKey: string, displayName: string | null) {
|
||||
return api.put<ProjectDisplayNameUpdateResponse>(
|
||||
'/api/desktop-ui/preferences/project-display-name',
|
||||
{ projectKey, displayName },
|
||||
)
|
||||
},
|
||||
|
||||
updateProfilePreferences(profile: Pick<DesktopProfilePreferences, 'displayName' | 'subtitle'>) {
|
||||
return api.put<{ ok: true; preferences: DesktopUiPreferences }>(
|
||||
'/api/desktop-ui/preferences/profile',
|
||||
|
||||
@@ -8,7 +8,10 @@ describe('openTargetsApi', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('normalizes relative icon URLs to the configured desktop server URL', async () => {
|
||||
it('keeps the icon path server-relative so it is fetched through the credential path', async () => {
|
||||
// Absolute URLs only existed to feed an `<img src>`, and that shape is exactly
|
||||
// what the server's fetch-metadata policy rejects: a cross-origin no-cors
|
||||
// subresource carries no Authorization header and comes back 401.
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
platform: 'darwin',
|
||||
@@ -36,9 +39,27 @@ describe('openTargetsApi', () => {
|
||||
targets: [
|
||||
{
|
||||
id: 'vscode',
|
||||
iconUrl: 'http://127.0.0.1:49237/api/open-targets/icons/vscode',
|
||||
iconUrl: '/api/open-targets/icons/vscode',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('requests open targets for the concrete file path', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
platform: 'darwin',
|
||||
targets: [],
|
||||
primaryTargetId: null,
|
||||
cachedAt: 1,
|
||||
ttlMs: 30_000,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
|
||||
|
||||
setBaseUrl('http://127.0.0.1:49237')
|
||||
await openTargetsApi.listForPath('/tmp/My Report.docx')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:49237/api/open-targets?path=%2Ftmp%2FMy%20Report.docx',
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { api, getApiUrl } from './client'
|
||||
import { api } from './client'
|
||||
|
||||
export type OpenTargetKind = 'ide' | 'file_manager'
|
||||
export type OpenTargetKind = 'application' | 'system_default' | 'ide' | 'file_manager'
|
||||
|
||||
export type OpenTarget = {
|
||||
id: string
|
||||
kind: OpenTargetKind
|
||||
label: string
|
||||
icon: string
|
||||
/** Server-relative path. Load it through {@link loadTargetIconUrl}, not an `<img src>`. */
|
||||
iconUrl?: string
|
||||
platform: string
|
||||
appPath?: string
|
||||
bundleId?: string | null
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
export type OpenTargetList = {
|
||||
@@ -25,19 +29,12 @@ export type OpenTargetOpenResponse = {
|
||||
path: string
|
||||
}
|
||||
|
||||
function normalizeOpenTargetList(result: OpenTargetList): OpenTargetList {
|
||||
return {
|
||||
...result,
|
||||
targets: result.targets.map((target) => ({
|
||||
...target,
|
||||
iconUrl: target.iconUrl ? getApiUrl(target.iconUrl) : undefined,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export const openTargetsApi = {
|
||||
async list() {
|
||||
return normalizeOpenTargetList(await api.get<OpenTargetList>('/api/open-targets'))
|
||||
return api.get<OpenTargetList>('/api/open-targets')
|
||||
},
|
||||
async listForPath(path: string) {
|
||||
return api.get<OpenTargetList>(`/api/open-targets?path=${encodeURIComponent(path)}`)
|
||||
},
|
||||
open(targetId: string, path: string) {
|
||||
return api.post<OpenTargetOpenResponse>('/api/open-targets/open', { targetId, path })
|
||||
|
||||
@@ -40,6 +40,45 @@ describe('sessionsApi', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('deduplicates concurrent Git info requests for the same session', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
let resolveFetch!: (response: Response) => void
|
||||
fetchMock.mockReturnValueOnce(new Promise<Response>((resolve) => {
|
||||
resolveFetch = resolve
|
||||
}))
|
||||
|
||||
const first = sessionsApi.getGitInfo('session-1')
|
||||
const second = sessionsApi.getGitInfo('session-1')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
resolveFetch(new Response(JSON.stringify({
|
||||
branch: 'main',
|
||||
repoName: 'repo',
|
||||
workDir: '/repo',
|
||||
changedFiles: 0,
|
||||
worktree: null,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([first, second])
|
||||
expect(firstResult).toEqual(secondResult)
|
||||
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
branch: 'main',
|
||||
repoName: 'repo',
|
||||
workDir: '/repo',
|
||||
changedFiles: 1,
|
||||
worktree: null,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
await sessionsApi.getGitInfo('session-1')
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('fetches a single trace call from the call detail endpoint', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api } from './client'
|
||||
import { api, type ApiRequestOptions } from './client'
|
||||
import type { SlashCommandOption } from '../types/slashCommand'
|
||||
import type { AgentTaskNotification } from '../types/chat'
|
||||
import type { LocalIndexStatus, SessionListItem, MessageEntry } from '../types/session'
|
||||
@@ -72,6 +72,8 @@ export type RepositoryBranchInfo = {
|
||||
remoteRef?: string
|
||||
checkedOut: boolean
|
||||
worktreePath?: string
|
||||
/** Commit the branch points at; absent on servers predating the field. */
|
||||
commit?: string
|
||||
}
|
||||
export type RepositoryWorktreeInfo = {
|
||||
path: string
|
||||
@@ -85,11 +87,24 @@ export type RepositoryContextResult = {
|
||||
repoName: string | null
|
||||
currentBranch: string | null
|
||||
defaultBranch: string | null
|
||||
/** Commit `HEAD` resolves to; absent on servers predating the field. */
|
||||
headCommit?: string | null
|
||||
dirty: boolean
|
||||
branches: RepositoryBranchInfo[]
|
||||
worktrees: RepositoryWorktreeInfo[]
|
||||
error?: string
|
||||
}
|
||||
export type CreateRepositoryBranchRequest = {
|
||||
workDir: string
|
||||
name: string
|
||||
/** Branch the new one starts from, as named in `branches`. Defaults to HEAD. */
|
||||
from?: string | null
|
||||
}
|
||||
export type CreateRepositoryBranchResult = {
|
||||
branch: string
|
||||
baseRef: string
|
||||
context: RepositoryContextResult
|
||||
}
|
||||
export type SessionRewindResponse = {
|
||||
target: {
|
||||
targetUserMessageId: string
|
||||
@@ -108,8 +123,22 @@ export type SessionRewindResponse = {
|
||||
deletions: number
|
||||
}
|
||||
restoreAvailable?: boolean
|
||||
/**
|
||||
* Tool names whose file effects the checkpoint could not capture (a writing
|
||||
* shell command, a tool with no change extractor). Undo still works and still
|
||||
* restores every file it lists — these are the changes it will leave behind.
|
||||
*/
|
||||
unverifiedChangeSources?: string[]
|
||||
/** What the executed rewind touched. Absent on dry-run previews. */
|
||||
mode?: SessionRewindMode
|
||||
}
|
||||
|
||||
/**
|
||||
* `both` restores files and trims the transcript; `conversation` only trims,
|
||||
* which stays possible even when the files cannot be restored.
|
||||
*/
|
||||
export type SessionRewindMode = 'both' | 'conversation'
|
||||
|
||||
export type RecentProject = {
|
||||
projectPath: string
|
||||
realPath: string
|
||||
@@ -301,6 +330,7 @@ export type SessionTurnCheckpoint = {
|
||||
code: SessionRewindResponse['code']
|
||||
workDir?: string
|
||||
restoreAvailable?: boolean
|
||||
unverifiedChangeSources?: string[]
|
||||
}
|
||||
|
||||
export type SessionTurnCheckpointsResponse = {
|
||||
@@ -312,6 +342,22 @@ export type TurnCheckpointDiffResult = WorkspaceDiffResult & {
|
||||
workDir?: string
|
||||
}
|
||||
|
||||
const gitInfoRequests = new Map<string, Promise<SessionGitInfo>>()
|
||||
|
||||
function getSessionGitInfo(sessionId: string) {
|
||||
const pending = gitInfoRequests.get(sessionId)
|
||||
if (pending) return pending
|
||||
|
||||
const request = api.get<SessionGitInfo>(`/api/sessions/${sessionId}/git-info`)
|
||||
const trackedRequest = request.finally(() => {
|
||||
if (gitInfoRequests.get(sessionId) === trackedRequest) {
|
||||
gitInfoRequests.delete(sessionId)
|
||||
}
|
||||
})
|
||||
gitInfoRequests.set(sessionId, trackedRequest)
|
||||
return trackedRequest
|
||||
}
|
||||
|
||||
function buildWorkspacePath(
|
||||
sessionId: string,
|
||||
resource: 'status' | 'tree' | 'file' | 'diff',
|
||||
@@ -385,10 +431,12 @@ export const sessionsApi = {
|
||||
return api.get<RepositoryContextResult>(`/api/sessions/repository-context?${query.toString()}`)
|
||||
},
|
||||
|
||||
getGitInfo(sessionId: string) {
|
||||
return api.get<SessionGitInfo>(`/api/sessions/${sessionId}/git-info`)
|
||||
createRepositoryBranch(body: CreateRepositoryBranchRequest) {
|
||||
return api.post<CreateRepositoryBranchResult>('/api/sessions/repository-branch', body)
|
||||
},
|
||||
|
||||
getGitInfo: getSessionGitInfo,
|
||||
|
||||
getSlashCommands(sessionId: string) {
|
||||
return api.get<{ commands: SlashCommandOption[] }>(`/api/sessions/${sessionId}/slash-commands`)
|
||||
},
|
||||
@@ -428,8 +476,11 @@ export const sessionsApi = {
|
||||
return api.get<WorkspaceDiffResult>(buildWorkspacePath(sessionId, 'diff', workspacePath))
|
||||
},
|
||||
|
||||
getTurnCheckpoints(sessionId: string) {
|
||||
return api.get<SessionTurnCheckpointsResponse>(`/api/sessions/${sessionId}/turn-checkpoints`)
|
||||
getTurnCheckpoints(sessionId: string, options?: ApiRequestOptions) {
|
||||
return api.get<SessionTurnCheckpointsResponse>(
|
||||
`/api/sessions/${sessionId}/turn-checkpoints`,
|
||||
options,
|
||||
)
|
||||
},
|
||||
|
||||
getTurnCheckpointDiff(
|
||||
@@ -454,6 +505,7 @@ export const sessionsApi = {
|
||||
userMessageIndex?: number
|
||||
expectedContent?: string
|
||||
dryRun?: boolean
|
||||
mode?: SessionRewindMode
|
||||
}) {
|
||||
return api.post<SessionRewindResponse>(`/api/sessions/${sessionId}/rewind`, body, {
|
||||
timeout: 60_000,
|
||||
|
||||
@@ -8,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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from './client'
|
||||
import type { MessageEntry } from '../types/session'
|
||||
import type { AgentTaskNotification } from '../types/chat'
|
||||
|
||||
export type SubagentRunStatus = 'running' | 'completed' | 'failed' | 'stopped' | 'unknown'
|
||||
export type SubagentRunSource = 'subagent-jsonl' | 'session-history' | 'live-task' | 'none'
|
||||
@@ -23,16 +24,68 @@ export type SubagentRunResponse = {
|
||||
outputFile?: string
|
||||
usage?: SubagentRunUsage
|
||||
messages: MessageEntry[]
|
||||
/** Full Activity projection; conversation messages may be truncated. */
|
||||
activityMessages?: MessageEntry[]
|
||||
/** Structured terminal events hidden from the visible transcript. */
|
||||
taskNotifications?: AgentTaskNotification[]
|
||||
/** Terminal events with the same fragment-scoped ids as activityMessages. */
|
||||
activityTaskNotifications?: AgentTaskNotification[]
|
||||
truncated: boolean
|
||||
updatedAt?: string
|
||||
source: SubagentRunSource
|
||||
/**
|
||||
* Whether a follow-up can still reach this agent. Only named teammates and
|
||||
* in-flight background agents have an inbox — a one-shot subagent answers
|
||||
* once and is done, so the page shows its record without a composer.
|
||||
* Optional so a response from an older server still parses.
|
||||
*/
|
||||
canSendMessage?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a subagent addressed by agent id rather than by the `Agent` tool call
|
||||
* that spawned it.
|
||||
*
|
||||
* Workflow agents are spawned by the workflow runtime, so no such tool call
|
||||
* exists. Carrying the distinction in the identifier means the tab id, the
|
||||
* page, and the return path all stay exactly as they are for every other
|
||||
* subagent — only the fetch differs.
|
||||
*/
|
||||
export const AGENT_ID_REF_PREFIX = 'agent:'
|
||||
|
||||
export function isAgentIdRef(ref: string): boolean {
|
||||
if (!ref.startsWith(AGENT_ID_REF_PREFIX)) return false
|
||||
const agentId = readAgentIdRef(ref)
|
||||
return agentId.length > 0 && !agentId.includes('/')
|
||||
}
|
||||
|
||||
export function toAgentIdRef(agentId: string): string {
|
||||
return `${AGENT_ID_REF_PREFIX}${agentId}`
|
||||
}
|
||||
|
||||
export function readAgentIdRef(ref: string): string {
|
||||
return ref.slice(AGENT_ID_REF_PREFIX.length)
|
||||
}
|
||||
|
||||
export const subagentsApi = {
|
||||
getRunByAgent(sessionId: string, agentId: string) {
|
||||
return api.get<SubagentRunResponse>(
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/subagents/by-agent/${encodeURIComponent(agentId)}`,
|
||||
)
|
||||
},
|
||||
|
||||
getRunByTool(sessionId: string, toolUseId: string, taskId?: string) {
|
||||
const query = taskId ? `?taskId=${encodeURIComponent(taskId)}` : ''
|
||||
return api.get<SubagentRunResponse>(
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/subagents/by-tool/${encodeURIComponent(toolUseId)}${query}`,
|
||||
)
|
||||
},
|
||||
|
||||
sendMessage(sessionId: string, toolUseId: string, content: string, taskId?: string) {
|
||||
const query = taskId ? `?taskId=${encodeURIComponent(taskId)}` : ''
|
||||
return api.post<{ ok: true; delivery?: 'queued' | 'resumed'; agent_id?: string }>(
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/subagents/by-tool/${encodeURIComponent(toolUseId)}/messages${query}`,
|
||||
{ content },
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { api } from './client'
|
||||
import type { TeamSummary, TeamDetail } from '../types/team'
|
||||
import type {
|
||||
TeamSummary,
|
||||
TeamDetail,
|
||||
TeamWorkbenchSnapshot,
|
||||
TeamWorkbenchSessionTimeline,
|
||||
} from '../types/team'
|
||||
import type { AgentTaskNotification } from '../types/chat'
|
||||
|
||||
type TeamsResponse = { teams: TeamSummary[] }
|
||||
|
||||
@@ -10,10 +16,17 @@ type TranscriptMessage = {
|
||||
timestamp: string
|
||||
model?: string
|
||||
parentToolUseId?: string
|
||||
toolUseResult?: unknown
|
||||
}
|
||||
|
||||
type TranscriptResponse = {
|
||||
messages: TranscriptMessage[]
|
||||
/** Terminal notifications from this cursor page; merge by toolUseId. */
|
||||
taskNotifications?: AgentTaskNotification[]
|
||||
/** Physical transcript fragments that own this member's nested activity. */
|
||||
ownerAgentIds?: string[]
|
||||
/** Where this member's work on each team task starts and ends. */
|
||||
taskAnchors?: TeamTaskAnchor[]
|
||||
signature?: string
|
||||
cursor?: string
|
||||
afterOrdinal?: number
|
||||
@@ -24,6 +37,15 @@ type TranscriptOptions = {
|
||||
signature?: string
|
||||
cursor?: string
|
||||
afterOrdinal?: number
|
||||
leadSessionId?: string
|
||||
incarnationId?: string
|
||||
}
|
||||
|
||||
export type TeamTaskAnchor = {
|
||||
taskId: string
|
||||
status: 'pending' | 'in_progress' | 'completed'
|
||||
messageId: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export type { TranscriptMessage }
|
||||
@@ -37,6 +59,26 @@ export const teamsApi = {
|
||||
return api.get<TeamDetail>(`/api/teams/${encodeURIComponent(name)}`)
|
||||
},
|
||||
|
||||
getWorkbench(name: string) {
|
||||
return api.get<TeamWorkbenchSnapshot>(
|
||||
`/api/teams/${encodeURIComponent(name)}/workbench`,
|
||||
)
|
||||
},
|
||||
|
||||
getWorkbenchForSession(
|
||||
sessionId: string,
|
||||
options?: { teamName?: string; at?: number; incarnationId?: string },
|
||||
) {
|
||||
const params = new URLSearchParams()
|
||||
if (options?.teamName) params.set('teamName', options.teamName)
|
||||
if (options?.at !== undefined) params.set('at', String(options.at))
|
||||
if (options?.incarnationId) params.set('incarnationId', options.incarnationId)
|
||||
const query = params.toString()
|
||||
return api.get<TeamWorkbenchSessionTimeline>(
|
||||
`/api/teams/session/${encodeURIComponent(sessionId)}/workbench${query ? `?${query}` : ''}`,
|
||||
)
|
||||
},
|
||||
|
||||
getMemberTranscript(
|
||||
teamName: string,
|
||||
agentId: string,
|
||||
@@ -45,6 +87,8 @@ export const teamsApi = {
|
||||
const params = new URLSearchParams()
|
||||
if (options) {
|
||||
params.set('incremental', 'true')
|
||||
if (options.leadSessionId) params.set('leadSessionId', options.leadSessionId)
|
||||
if (options.incarnationId) params.set('incarnationId', options.incarnationId)
|
||||
if (options.signature) params.set('signature', options.signature)
|
||||
if (options.cursor) params.set('cursor', options.cursor)
|
||||
if (options.afterOrdinal !== undefined) {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { api } from './client'
|
||||
import type {
|
||||
ReconstructedWorkflowRun,
|
||||
WorkflowDefinition,
|
||||
WorkflowRunDetail,
|
||||
WorkflowRunSummary,
|
||||
} from '../types/workflow'
|
||||
|
||||
export type WorkflowValidateResult = {
|
||||
ok: boolean
|
||||
error?: string
|
||||
name?: string
|
||||
description?: string
|
||||
phases?: { title: string; detail?: string; model?: string }[]
|
||||
}
|
||||
|
||||
export const workflowsApi = {
|
||||
list(cwd?: string) {
|
||||
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
|
||||
return api.get<{ workflows: WorkflowDefinition[] }>(`/api/workflows${query}`)
|
||||
},
|
||||
|
||||
get(name: string, cwd?: string) {
|
||||
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
|
||||
return api.get<WorkflowDefinition>(
|
||||
`/api/workflows/${encodeURIComponent(name)}${query}`,
|
||||
)
|
||||
},
|
||||
|
||||
/** Finished runs for one session, rebuilt from what the CLI left on disk. */
|
||||
sessionRuns(sessionId: string) {
|
||||
return api.get<{ runs: ReconstructedWorkflowRun[] }>(
|
||||
`/api/workflows/session-runs/${encodeURIComponent(sessionId)}`,
|
||||
)
|
||||
},
|
||||
|
||||
listRuns(options?: { sessionId?: string; limit?: number }) {
|
||||
const params = new URLSearchParams()
|
||||
if (options?.sessionId) params.set('sessionId', options.sessionId)
|
||||
if (options?.limit) params.set('limit', String(options.limit))
|
||||
const query = params.toString()
|
||||
return api.get<{ runs: WorkflowRunSummary[] }>(
|
||||
`/api/workflows/runs${query ? `?${query}` : ''}`,
|
||||
)
|
||||
},
|
||||
|
||||
getRun(sessionId: string, runId: string) {
|
||||
return api.get<WorkflowRunDetail>(
|
||||
`/api/workflows/runs/${encodeURIComponent(sessionId)}/${encodeURIComponent(runId)}`,
|
||||
)
|
||||
},
|
||||
|
||||
validate(script: string) {
|
||||
return api.post<WorkflowValidateResult>('/api/workflows/validate', { script })
|
||||
},
|
||||
|
||||
save(
|
||||
script: string,
|
||||
scope: 'user' | 'project',
|
||||
cwd?: string,
|
||||
name?: string,
|
||||
) {
|
||||
return api.post<{ ok: true; name: string; filePath: string }>(
|
||||
'/api/workflows/save',
|
||||
{ script, scope, cwd, name },
|
||||
)
|
||||
},
|
||||
|
||||
remove(name: string, scope: 'user' | 'project', cwd?: string) {
|
||||
const params = new URLSearchParams({ scope })
|
||||
if (cwd) params.set('cwd', cwd)
|
||||
return api.delete<{ ok: true }>(
|
||||
`/api/workflows/${encodeURIComponent(name)}?${params.toString()}`,
|
||||
)
|
||||
},
|
||||
}
|
||||
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 106 KiB |
@@ -34,6 +34,7 @@ vi.mock('../../i18n', () => ({
|
||||
'session.activity.details.usage': 'Usage',
|
||||
'session.activity.section.tasks': 'Tasks',
|
||||
'session.activity.section.team': 'Team',
|
||||
'session.activity.section.workflow': 'Workflow',
|
||||
'session.activity.section.backgroundTasks': 'Background Tasks',
|
||||
'session.activity.section.subagents': 'SubAgents',
|
||||
'session.activity.section.sources': 'Sources',
|
||||
@@ -57,6 +58,7 @@ vi.mock('../../i18n', () => ({
|
||||
'session.activity.status.stopped': 'Stopped',
|
||||
'session.activity.status.idle': 'Idle',
|
||||
'session.activity.status.error': 'Error',
|
||||
'workflows.agent.cached': 'Cached',
|
||||
}
|
||||
|
||||
let text = translations[key] ?? key
|
||||
@@ -75,6 +77,7 @@ function model(overrides: Partial<SessionActivityModel> = {}): SessionActivityMo
|
||||
badgeCount: 1,
|
||||
sections: {
|
||||
output: { id: 'output', title: 'Output', emptyLabel: 'No output', rows: [] },
|
||||
workflow: { id: 'workflow', title: 'Workflow', emptyLabel: 'No workflow running', rows: [] },
|
||||
tasks: {
|
||||
id: 'tasks',
|
||||
title: 'Tasks',
|
||||
@@ -105,6 +108,42 @@ function model(overrides: Partial<SessionActivityModel> = {}): SessionActivityMo
|
||||
describe('SessionActivityPanel', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
it('tints the row icon tile by status so a one-line row still states what happened', () => {
|
||||
render(
|
||||
<SessionActivityPanel
|
||||
model={model({
|
||||
sections: {
|
||||
...model().sections,
|
||||
backgroundTasks: {
|
||||
id: 'backgroundTasks',
|
||||
title: 'Background Tasks',
|
||||
emptyLabel: 'No background tasks',
|
||||
rows: [
|
||||
{ id: 'bg-run', section: 'backgroundTasks', label: 'Vite dev server', status: 'running', taskId: 'bg-run', openable: false },
|
||||
{ id: 'bg-fail', section: 'backgroundTasks', label: 'pytest -q', status: 'failed', taskId: 'bg-fail', openable: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
})}
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onOpenSubagent={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const tiles = screen.getAllByTestId('activity-row-icon')
|
||||
const running = tiles.find((tile) => tile.getAttribute('data-tone-status') === 'running')
|
||||
const failed = tiles.find((tile) => tile.getAttribute('data-tone-status') === 'failed')
|
||||
|
||||
// 30px matches AgentMascot, so SubAgent and background rows share a text column.
|
||||
expect(running?.className).toContain('h-[30px]')
|
||||
expect(running?.className).toContain('bg-[var(--color-brand-soft)]')
|
||||
// Paired tokens, never a raw accent on its own container (AGENTS.md 3.2).
|
||||
expect(running?.className).toContain('text-[var(--color-on-brand-soft)]')
|
||||
expect(failed?.className).toContain('bg-[var(--color-error-container)]')
|
||||
expect(failed?.className).toContain('text-[var(--color-on-error-container)]')
|
||||
})
|
||||
|
||||
it('renders populated tasks section without empty visible section labels', () => {
|
||||
render(
|
||||
<SessionActivityPanel
|
||||
@@ -172,7 +211,9 @@ describe('SessionActivityPanel', () => {
|
||||
expect(screen.getByLabelText('Task in progress')).toHaveClass('motion-reduce:animate-none')
|
||||
expect(screen.getByLabelText('Task in progress')).toHaveClass('rounded-full')
|
||||
expect(screen.getByLabelText('Task in progress').querySelector('svg')).toBeNull()
|
||||
expect(screen.getByText('Active task').closest('button,div')).toHaveClass('py-2.5')
|
||||
// Dense row rhythm: the panel is an index of what is happening, so a row is
|
||||
// a line, not a card. Pinned because it is easy to lose to a stray `py-2.5`.
|
||||
expect(screen.getByText('Active task').closest('button,div')).toHaveClass('py-1.5')
|
||||
expect(screen.getByText('Finished task')).toHaveClass('line-through')
|
||||
expect(screen.queryByText('Completed')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Pending')).not.toBeInTheDocument()
|
||||
@@ -212,6 +253,49 @@ describe('SessionActivityPanel', () => {
|
||||
expect(screen.getByText('2/4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps run-local rows visible without adding them to canonical team progress', () => {
|
||||
render(
|
||||
<SessionActivityPanel
|
||||
model={model({
|
||||
sections: {
|
||||
...model().sections,
|
||||
tasks: {
|
||||
id: 'tasks',
|
||||
title: 'Tasks',
|
||||
emptyLabel: 'No tasks',
|
||||
rows: [
|
||||
{ id: 'local-a', section: 'tasks', label: 'Lead follow-up', status: 'stopped', openable: false },
|
||||
{
|
||||
id: 'team:one',
|
||||
section: 'tasks',
|
||||
label: 'Team task one',
|
||||
status: 'completed',
|
||||
teamTaskListId: 'team-list',
|
||||
openable: false,
|
||||
},
|
||||
{
|
||||
id: 'team:two',
|
||||
section: 'tasks',
|
||||
label: 'Team task two',
|
||||
status: 'completed',
|
||||
teamTaskListId: 'team-list',
|
||||
openable: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})}
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onOpenSubagent={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Lead follow-up')).toBeInTheDocument()
|
||||
expect(screen.getByRole('progressbar', { name: 'Task progress 2/2' })).toHaveAttribute('aria-valuenow', '100')
|
||||
expect(screen.queryByText('2/3')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves sections other than tasks without a progress rail', () => {
|
||||
render(
|
||||
<SessionActivityPanel
|
||||
@@ -806,6 +890,107 @@ describe('SessionActivityPanel', () => {
|
||||
expect(onOpenSubagent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders a workflow as phase headers with their agents, each opening the subagent page', () => {
|
||||
const onOpenSubagent = vi.fn()
|
||||
render(
|
||||
<SessionActivityPanel
|
||||
model={model({
|
||||
sections: {
|
||||
...model().sections,
|
||||
tasks: { id: 'tasks', title: 'Tasks', emptyLabel: 'No tasks', rows: [] },
|
||||
workflow: {
|
||||
id: 'workflow',
|
||||
title: 'Workflow',
|
||||
emptyLabel: 'No workflow running',
|
||||
rows: [
|
||||
{
|
||||
id: 'w1-phase-1',
|
||||
section: 'workflow',
|
||||
label: 'Survey',
|
||||
status: 'completed',
|
||||
groupProgress: { done: 2, total: 2 },
|
||||
openable: false,
|
||||
},
|
||||
{
|
||||
id: 'w1-agent-1',
|
||||
section: 'workflow',
|
||||
label: 'survey response.js',
|
||||
status: 'completed',
|
||||
group: 'Survey',
|
||||
toolUseId: 'agent:a11',
|
||||
openable: true,
|
||||
},
|
||||
{
|
||||
id: 'w1-agent-4',
|
||||
section: 'workflow',
|
||||
label: 'check response #2',
|
||||
status: 'pending',
|
||||
group: 'Cross-check',
|
||||
openable: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})}
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
/>,
|
||||
)
|
||||
|
||||
// The phase is a heading, not something you can open.
|
||||
const header = screen.getByTestId('workflow-phase-header')
|
||||
expect(header).toHaveTextContent('Survey')
|
||||
expect(header).toHaveTextContent('2/2')
|
||||
|
||||
// Its agent opens the ordinary subagent page, addressed by agent id
|
||||
// because a workflow agent has no parent Agent tool call.
|
||||
fireEvent.click(screen.getByRole('button', { name: /open run survey response\.js/i }))
|
||||
expect(onOpenSubagent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ toolUseId: 'agent:a11', title: 'survey response.js' }),
|
||||
)
|
||||
|
||||
// A queued agent has no transcript yet, so it must not offer to open one.
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /open run check response #2/i }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByText('check response #2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('labels a cached workflow agent explicitly instead of calling it merely completed', () => {
|
||||
render(
|
||||
<SessionActivityPanel
|
||||
model={model({
|
||||
sections: {
|
||||
...model().sections,
|
||||
tasks: { id: 'tasks', title: 'Tasks', emptyLabel: 'No tasks', rows: [] },
|
||||
workflow: {
|
||||
id: 'workflow',
|
||||
title: 'Workflow',
|
||||
emptyLabel: 'No workflow running',
|
||||
rows: [{
|
||||
id: 'cached-a',
|
||||
section: 'workflow',
|
||||
label: 'A',
|
||||
status: 'completed',
|
||||
group: 'Run',
|
||||
cached: true,
|
||||
toolUseId: 'agent:a1',
|
||||
openable: true,
|
||||
}],
|
||||
},
|
||||
},
|
||||
})}
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onOpenSubagent={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Cached')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /open run a · cached/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render when closed', () => {
|
||||
render(<SessionActivityPanel model={model()} open={false} onClose={vi.fn()} onOpenSubagent={vi.fn()} />)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, ChevronRight, Circle, FileText, LoaderCircle, Square, Terminal, Users, X } from 'lucide-react'
|
||||
import { Check, ChevronRight, Circle, FileText, LoaderCircle, Square, Terminal, Users, X, Zap } from 'lucide-react'
|
||||
import { Badge, StatusDot, type Tone } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { IconButton } from '@/components/ui/IconButton'
|
||||
@@ -17,6 +17,9 @@ export type OpenSubagentPayload = {
|
||||
taskId?: string
|
||||
toolUseId: string
|
||||
title: string
|
||||
teamName?: string
|
||||
teamMemberName?: string
|
||||
teamStartedAt?: number
|
||||
}
|
||||
|
||||
type SessionActivityPanelPlacement = 'overlay' | 'rail'
|
||||
@@ -72,6 +75,8 @@ function getSectionTitle(sectionId: ActivitySectionId, t: TranslationFn): string
|
||||
return t('session.activity.section.tasks')
|
||||
case 'team':
|
||||
return t('session.activity.section.team')
|
||||
case 'workflow':
|
||||
return t('session.activity.section.workflow')
|
||||
case 'backgroundTasks':
|
||||
return t('session.activity.section.backgroundTasks')
|
||||
case 'subagents':
|
||||
@@ -84,7 +89,7 @@ function getSectionTitle(sectionId: ActivitySectionId, t: TranslationFn): string
|
||||
}
|
||||
|
||||
function getSectionRowsClassName(sectionId: ActivitySectionId, rowCount: number): string {
|
||||
const base = 'space-y-1.5'
|
||||
const base = 'space-y-0.5'
|
||||
if (rowCount === 0) return base
|
||||
|
||||
switch (sectionId) {
|
||||
@@ -92,6 +97,8 @@ function getSectionRowsClassName(sectionId: ActivitySectionId, rowCount: number)
|
||||
return base
|
||||
case 'team':
|
||||
return base
|
||||
case 'workflow':
|
||||
return base
|
||||
case 'backgroundTasks':
|
||||
return base
|
||||
case 'subagents':
|
||||
@@ -201,6 +208,8 @@ function getRowIcon(row: ActivityRow) {
|
||||
switch (row.section) {
|
||||
case 'team':
|
||||
return Users
|
||||
case 'workflow':
|
||||
return Zap
|
||||
case 'backgroundTasks':
|
||||
return Terminal
|
||||
case 'subagents':
|
||||
@@ -220,11 +229,37 @@ function getStatusTone(status: ActivityRow['status']): Tone {
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
/** Visible rows only, so the ratio always matches what the section shows. */
|
||||
/** A canonical Team DAG owns the section ratio when present. Run-local rows
|
||||
* remain visible beside it without changing the shared workbench progress. */
|
||||
function getTaskProgress(rows: ActivityRow[]): { completed: number; total: number; percent: number } | null {
|
||||
if (rows.length === 0) return null
|
||||
const completed = rows.filter((row) => row.status === 'completed').length
|
||||
return { completed, total: rows.length, percent: Math.round((completed / rows.length) * 100) }
|
||||
const teamRows = rows.filter((row) => row.teamTaskListId !== undefined)
|
||||
const progressRows = teamRows.length > 0 ? teamRows : rows
|
||||
const completed = progressRows.filter((row) => row.status === 'completed').length
|
||||
return {
|
||||
completed,
|
||||
total: progressRows.length,
|
||||
percent: Math.round((completed / progressRows.length) * 100),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tile's fill states what happened without spending a word on it, which is
|
||||
* what lets the row itself stay one line. Tone pairs come from the token scale
|
||||
* (see components/AGENTS.md §3.2) — never a raw accent as foreground on its own
|
||||
* container.
|
||||
*/
|
||||
function getRowIconToneClass(status: ActivityRow['status']): string {
|
||||
if (status === 'running' || status === 'in_progress') {
|
||||
return 'bg-[var(--color-brand-soft)] text-[var(--color-on-brand-soft)]'
|
||||
}
|
||||
if (status === 'completed' || status === 'idle') {
|
||||
return 'bg-[var(--color-success-container)] text-[var(--color-on-success-container)]'
|
||||
}
|
||||
if (status === 'failed' || status === 'error') {
|
||||
return 'bg-[var(--color-error-container)] text-[var(--color-on-error-container)]'
|
||||
}
|
||||
return 'bg-[var(--color-surface-container)] text-[var(--color-text-secondary)]'
|
||||
}
|
||||
|
||||
function ActivityRowIcon({
|
||||
@@ -236,14 +271,22 @@ function ActivityRowIcon({
|
||||
sessionId: string
|
||||
status?: ActivityRow['status']
|
||||
}) {
|
||||
if (row.section === 'subagents') {
|
||||
// Workflow agents get the same mascot as any other subagent — they are the
|
||||
// same thing, and giving them a different glyph would imply otherwise.
|
||||
if (row.section === 'subagents' || (row.section === 'workflow' && row.group)) {
|
||||
return <AgentMascot seed={`${sessionId}:${row.toolUseId ?? row.taskId ?? row.id}`} status={status} />
|
||||
}
|
||||
|
||||
const Icon = getRowIcon(row)
|
||||
|
||||
// 30px to match `AgentMascot`, so a SubAgent row and a background-task row
|
||||
// start their text on the same column.
|
||||
return (
|
||||
<span className="inline-flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-[var(--radius-md)] text-[var(--color-text-tertiary)]">
|
||||
<span
|
||||
data-testid="activity-row-icon"
|
||||
data-tone-status={status}
|
||||
className={`inline-flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-[var(--radius-md)] ${getRowIconToneClass(status)}`}
|
||||
>
|
||||
<Icon size={15} strokeWidth={2} aria-hidden="true" />
|
||||
</span>
|
||||
)
|
||||
@@ -303,6 +346,41 @@ function BackgroundTaskStopButton({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A phase heading inside the workflow section.
|
||||
*
|
||||
* Deliberately not a card: the section is already a bordered list, and boxing
|
||||
* each phase inside it turned three stages into three nested frames. A rule
|
||||
* plus the settled count carries the grouping on its own.
|
||||
*/
|
||||
function WorkflowPhaseHeader({
|
||||
label,
|
||||
status,
|
||||
done,
|
||||
total,
|
||||
}: {
|
||||
label: string
|
||||
status: ActivityRow['status']
|
||||
done: number
|
||||
total: number
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid="workflow-phase-header"
|
||||
data-status={status}
|
||||
className="flex items-center gap-2 px-2 pb-1 pt-2.5 first:pt-1"
|
||||
>
|
||||
<span className="truncate text-[11px] font-semibold uppercase tracking-wide text-[var(--color-text-secondary)]">
|
||||
{label}
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-[var(--color-border)]" aria-hidden="true" />
|
||||
<span className="shrink-0 font-mono text-[11px] tabular-nums text-[var(--color-text-tertiary)]">
|
||||
{done}/{total}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityRowView({
|
||||
row,
|
||||
sessionId,
|
||||
@@ -323,12 +401,27 @@ function ActivityRowView({
|
||||
selected?: boolean
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
// A workflow phase is a heading over its agents, not a row you can open.
|
||||
// Rendering it as one made a fan-out read as a flat list where the stage
|
||||
// boundaries were invisible.
|
||||
if (row.groupProgress) {
|
||||
return (
|
||||
<WorkflowPhaseHeader
|
||||
label={row.label}
|
||||
status={row.status}
|
||||
done={row.groupProgress.done}
|
||||
total={row.groupProgress.total}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const isTask = row.section === 'tasks'
|
||||
const isStoppingSubagent = row.section === 'subagents' && row.status === 'running' && stoppingBackgroundTask
|
||||
const displayStatus: ActivityRow['status'] = isStoppingSubagent ? 'pending' : row.status
|
||||
const statusLabel = isStoppingSubagent
|
||||
? t('session.activity.status.stopping')
|
||||
: getActivityStatusLabel(row.status, t)
|
||||
: row.cached
|
||||
? t('workflows.agent.cached')
|
||||
: getActivityStatusLabel(row.status, t)
|
||||
const label = row.taskHistory
|
||||
? t('session.activity.tasks.earlier')
|
||||
: row.label
|
||||
@@ -338,6 +431,10 @@ function ActivityRowView({
|
||||
total: row.taskHistory.total,
|
||||
turns: row.taskHistory.turnCount,
|
||||
})
|
||||
// Tasks only. SubAgent / team / background rows deliberately stay one line:
|
||||
// their `description` is free-form agent prose, not the compact identity
|
||||
// string the prototype's second line shows, and three tests pin that a row
|
||||
// never previews it. Details live in the expandable panel instead.
|
||||
: isTask && row.description && row.description !== row.label
|
||||
? row.description
|
||||
: isTask && row.summary && row.summary !== row.label
|
||||
@@ -352,14 +449,14 @@ function ActivityRowView({
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-left">
|
||||
<span
|
||||
className={`block truncate font-semibold leading-5 ${isTask ? 'text-[14px]' : 'text-[13px]'} ${isTask && row.status === 'completed' ? 'text-[var(--color-text-secondary)] line-through decoration-[var(--color-text-tertiary)]' : 'text-[var(--color-text-primary)]'}`}
|
||||
className={`block truncate text-[13px] font-medium leading-5 ${isTask && row.status === 'completed' ? 'text-[var(--color-text-tertiary)] line-through decoration-[var(--color-text-tertiary)]' : 'text-[var(--color-text-primary)]'}`}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{detail ? (
|
||||
<span
|
||||
className={`mt-0.5 block truncate leading-4 text-[var(--color-text-tertiary)] ${isTask ? 'text-[12.5px]' : 'text-[12px]'}`}
|
||||
className="mt-px block truncate text-[11.5px] leading-4 text-[var(--color-text-tertiary)]"
|
||||
title={detail}
|
||||
>
|
||||
{detail}
|
||||
@@ -379,7 +476,7 @@ function ActivityRowView({
|
||||
</>
|
||||
)
|
||||
const interactiveRowClassName =
|
||||
'flex min-w-0 items-center gap-3 rounded-[var(--radius-md)] px-2.5 py-2.5 text-left transition-[background-color,transform] duration-150 ease-out hover:bg-[var(--color-surface-hover)] active:translate-y-px motion-reduce:active:translate-y-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]'
|
||||
'flex min-w-0 items-center gap-2.5 rounded-[var(--radius-md)] px-2 py-1.5 text-left transition-[background-color,transform] duration-150 ease-out hover:bg-[var(--color-surface-hover)] active:translate-y-px motion-reduce:active:translate-y-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]'
|
||||
const stopButton = row.section === 'backgroundTasks' && onStopBackgroundTask ? (
|
||||
<BackgroundTaskStopButton
|
||||
row={row}
|
||||
@@ -401,7 +498,13 @@ function ActivityRowView({
|
||||
)
|
||||
}
|
||||
|
||||
if (row.section === 'subagents' && row.openable && row.toolUseId) {
|
||||
// A workflow agent is an ordinary subagent, so it opens the same page by the
|
||||
// same handler — there is nothing workflow-specific to render for one.
|
||||
if (
|
||||
(row.section === 'subagents' || row.section === 'workflow' || row.section === 'team') &&
|
||||
row.openable &&
|
||||
row.toolUseId
|
||||
) {
|
||||
const openButton = (
|
||||
<button
|
||||
type="button"
|
||||
@@ -411,8 +514,11 @@ function ActivityRowView({
|
||||
...(row.taskId ? { taskId: row.taskId } : {}),
|
||||
toolUseId: row.toolUseId!,
|
||||
title: row.label,
|
||||
...(row.teamName ? { teamName: row.teamName } : {}),
|
||||
...(row.teamMemberName ? { teamMemberName: row.teamMemberName } : {}),
|
||||
...(row.teamStartedAt !== undefined ? { teamStartedAt: row.teamStartedAt } : {}),
|
||||
})}
|
||||
className={`${interactiveRowClassName} ${stopButton ? 'flex-1' : 'w-full'}`}
|
||||
className={`${interactiveRowClassName} ${stopButton ? 'flex-1' : 'w-full'} ${row.group ? 'pl-3' : ''}`}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
@@ -450,7 +556,7 @@ function ActivityRowView({
|
||||
if (stopButton) {
|
||||
return (
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3 rounded-[var(--radius-md)] px-2.5 py-2.5">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 rounded-[var(--radius-md)] px-2 py-1.5">
|
||||
{content}
|
||||
</div>
|
||||
{stopButton}
|
||||
@@ -459,7 +565,7 @@ function ActivityRowView({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-[var(--radius-md)] px-2.5 py-2.5">
|
||||
<div className="flex items-center gap-2.5 rounded-[var(--radius-md)] px-2 py-1.5">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
@@ -495,7 +601,7 @@ function BackgroundTaskDetail({ row }: { row: ActivityRow }) {
|
||||
if (details.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mx-2.5 mb-1.5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3">
|
||||
<div className="mx-2 mb-1.5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3">
|
||||
<div className="mb-2 text-[11px] font-semibold text-[var(--color-text-tertiary)]">
|
||||
{t('session.activity.details.title')}
|
||||
</div>
|
||||
@@ -634,9 +740,9 @@ export function SessionActivityPanel({
|
||||
aria-label={sectionTitle}
|
||||
className={index > 0 ? 'border-t border-[var(--color-border)] pt-3' : undefined}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2 px-2.5">
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2 px-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h3 className="text-[13.5px] font-semibold text-[var(--color-text-secondary)]">
|
||||
<h3 className="text-[12px] font-semibold text-[var(--color-text-secondary)]">
|
||||
{sectionTitle}
|
||||
</h3>
|
||||
{section.rows.length > 0 ? (
|
||||
|
||||
@@ -2,23 +2,49 @@ import type { BackgroundAgentTask, AgentTaskNotification, BackgroundAgentTaskUsa
|
||||
import type { TaskSummaryItem, UIMessage } from '../../types/chat'
|
||||
import type { CLITask, TaskStatus } from '../../types/cliTask'
|
||||
import type { TeamMember } from '../../types/team'
|
||||
import { createBackgroundTaskDismissKey } from '../../lib/backgroundTasks'
|
||||
import {
|
||||
EMPTY_TEAM_LIFECYCLE_CURSOR,
|
||||
isTeamLifecycleScopedAt,
|
||||
updateTeamLifecycleCursor,
|
||||
} from '../../lib/teamLifecycleScope'
|
||||
import {
|
||||
createBackgroundTaskDismissKey,
|
||||
isVisibleSessionBackgroundTask,
|
||||
} from '../../lib/backgroundTasks'
|
||||
import { toAgentIdRef } from '../../api/subagents'
|
||||
import type { WorkflowAgentEvent, WorkflowRun } from '../../types/workflow'
|
||||
|
||||
export type ActivityStatus = TaskStatus | BackgroundAgentTask['status'] | TeamMember['status']
|
||||
|
||||
export type ActivitySectionId = 'output' | 'tasks' | 'team' | 'backgroundTasks' | 'subagents' | 'sources'
|
||||
export type ActivitySectionId = 'output' | 'tasks' | 'team' | 'workflow' | 'backgroundTasks' | 'subagents' | 'sources'
|
||||
|
||||
export type ActivityRow = {
|
||||
id: string
|
||||
section: ActivitySectionId
|
||||
label: string
|
||||
status: ActivityStatus
|
||||
cached?: boolean
|
||||
description?: string
|
||||
summary?: string
|
||||
toolUseId?: string
|
||||
/**
|
||||
* Phase this row belongs to, for sections that group. A workflow is phases
|
||||
* of N agents, and the grouping is the only thing that makes a fan-out
|
||||
* readable — twelve flat rows say nothing about which stage they belong to.
|
||||
*/
|
||||
group?: string
|
||||
/** Set on a group's header row; agents under it carry `group` instead. */
|
||||
groupProgress?: { done: number; total: number }
|
||||
taskId?: string
|
||||
/** Structured owner for a canonical Agent Teams DAG row. */
|
||||
teamTaskListId?: string
|
||||
taskType?: BackgroundAgentTask['taskType']
|
||||
workflowName?: string
|
||||
/** Persisted Agent Teams launch identity for routing this row to the
|
||||
* incarnation-scoped member run instead of the ordinary subagent endpoint. */
|
||||
teamName?: string
|
||||
teamMemberName?: string
|
||||
teamStartedAt?: number
|
||||
dismissKey?: string
|
||||
outputFile?: string
|
||||
usage?: BackgroundAgentTaskUsage
|
||||
@@ -48,20 +74,58 @@ export type SessionActivityModel = {
|
||||
export type BuildSessionActivityModelInput = {
|
||||
sessionId: string
|
||||
messages?: UIMessage[]
|
||||
/**
|
||||
* Session transcripts can contain forwarded child-tool activity. A session
|
||||
* owns only root messages, while an agent detail transcript owns the
|
||||
* parent-linked messages forwarded for that agent run.
|
||||
*/
|
||||
runScope?: 'session' | 'agent'
|
||||
/**
|
||||
* Agent Teams coordinates through one shared task list. In that scope the
|
||||
* caller supplies the member-owned projection via `tasks`; Task* transcript
|
||||
* events must not recreate the whole shared list in this run.
|
||||
*/
|
||||
taskScope?: 'run' | 'team' | 'team-session'
|
||||
/** Explicit lifecycle windows cover the live gap before TeamCreate appears
|
||||
* in history and truncated transcripts that no longer contain that call. */
|
||||
teamTaskWindows?: Array<{ startedAt: number; endedAt?: number }>
|
||||
tasks: CLITask[]
|
||||
/** Canonical shared DAG for the team owned by this session. These rows sit
|
||||
* beside run-local tasks while bypassing transcript reconstruction, so
|
||||
* member updates cannot leak into the lead run and stale lead events cannot
|
||||
* override runtime task status. */
|
||||
teamTasks?: CLITask[]
|
||||
completedAndDismissed: boolean
|
||||
isForegroundTurnActive?: boolean
|
||||
backgroundTasks: BackgroundAgentTask[]
|
||||
dismissedBackgroundTaskKeys?: Set<string>
|
||||
agentNotifications: AgentTaskNotification[]
|
||||
teamMembers?: TeamMember[]
|
||||
/** AgentTeam has its own strip/workbench in a main session. Set false at
|
||||
* that ownership boundary so transcript spawn rows cannot affect Activity. */
|
||||
includeTeamActivity?: boolean
|
||||
/** Live workflow runs for this session, newest first. */
|
||||
workflowRuns?: WorkflowRun[]
|
||||
}
|
||||
|
||||
export type BuildMainSessionActivityModelInput = Omit<
|
||||
BuildSessionActivityModelInput,
|
||||
'runScope' | 'taskScope' | 'teamTasks' | 'teamMembers' | 'includeTeamActivity'
|
||||
>
|
||||
|
||||
/**
|
||||
* Ordered by how directly each section answers "what is this turn doing":
|
||||
* the plan first, then the agents working it, then the processes it left
|
||||
* running. Background tasks outlive the turn, so they sit last.
|
||||
*/
|
||||
export const VISIBLE_ACTIVITY_SECTION_ORDER = [
|
||||
'tasks',
|
||||
// A running workflow is the turn's whole shape, so it sits above the
|
||||
// individual agents it spawned rather than among them.
|
||||
'workflow',
|
||||
'subagents',
|
||||
'team',
|
||||
'backgroundTasks',
|
||||
'subagents',
|
||||
'sources',
|
||||
] as const satisfies readonly ActivitySectionId[]
|
||||
|
||||
@@ -71,6 +135,7 @@ const SECTION_META: Record<ActivitySectionId, Pick<ActivitySection, 'title' | 'e
|
||||
output: { title: 'Output', emptyLabel: 'No output' },
|
||||
tasks: { title: 'Tasks', emptyLabel: 'No tasks' },
|
||||
team: { title: 'Team', emptyLabel: 'No team members' },
|
||||
workflow: { title: 'Workflow', emptyLabel: 'No workflow running' },
|
||||
backgroundTasks: { title: 'Background Tasks', emptyLabel: 'No background tasks' },
|
||||
subagents: { title: 'SubAgents', emptyLabel: 'No SubAgents' },
|
||||
sources: { title: 'Sources', emptyLabel: 'No sources' },
|
||||
@@ -81,6 +146,7 @@ function createEmptySections(): Record<ActivitySectionId, ActivitySection> {
|
||||
output: createSection('output'),
|
||||
tasks: createSection('tasks'),
|
||||
team: createSection('team'),
|
||||
workflow: createSection('workflow'),
|
||||
backgroundTasks: createSection('backgroundTasks'),
|
||||
subagents: createSection('subagents'),
|
||||
sources: createSection('sources'),
|
||||
@@ -142,6 +208,14 @@ function buildTaskRow(task: CLITask): ActivityRow {
|
||||
}
|
||||
}
|
||||
|
||||
function buildTeamTaskRow(task: CLITask): ActivityRow {
|
||||
return {
|
||||
...buildTaskRow(task),
|
||||
id: `team-task:${task.taskListId}:${task.id}`,
|
||||
teamTaskListId: task.taskListId,
|
||||
}
|
||||
}
|
||||
|
||||
function buildTaskSummaryRow(task: TaskSummaryItem, index: number): ActivityRow {
|
||||
return {
|
||||
id: task.id || `summary-task-${index + 1}`,
|
||||
@@ -279,35 +353,105 @@ function collectToolResults(
|
||||
return resultsByToolUseId
|
||||
}
|
||||
|
||||
function collectSubagentCreatedTaskIds(messages: UIMessage[]): Set<string> {
|
||||
const taskIds = new Set<string>()
|
||||
const resultsByToolUseId = collectToolResults(messages)
|
||||
|
||||
for (const message of messages) {
|
||||
if (
|
||||
message.type !== 'tool_use' ||
|
||||
message.toolName !== 'TaskCreate' ||
|
||||
!message.parentToolUseId
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const result = resultsByToolUseId.get(message.toolUseId)
|
||||
if (!result || result.isError) continue
|
||||
const createdTask = parseCreatedTaskResult(result.content)
|
||||
if (createdTask) taskIds.add(createdTask.id)
|
||||
}
|
||||
|
||||
return taskIds
|
||||
}
|
||||
|
||||
function keepSessionLevelTaskMessage(message: UIMessage): boolean {
|
||||
function keepSessionRunMessage(message: UIMessage): boolean {
|
||||
return !(
|
||||
(message.type === 'tool_use' || message.type === 'tool_result') &&
|
||||
message.parentToolUseId
|
||||
)
|
||||
}
|
||||
|
||||
function projectMessagesToRun(messages: UIMessage[], runScope: 'session' | 'agent'): UIMessage[] {
|
||||
return runScope === 'agent' ? messages : messages.filter(keepSessionRunMessage)
|
||||
}
|
||||
|
||||
function explicitSuccessFlag(value: unknown): boolean | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const nested = explicitSuccessFlag(item)
|
||||
if (nested !== undefined) return nested
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
if (isRecordValue(value)) {
|
||||
if (typeof value.success === 'boolean') return value.success
|
||||
if ('content' in value) return explicitSuccessFlag(value.content)
|
||||
if ('text' in value) return explicitSuccessFlag(value.text)
|
||||
return undefined
|
||||
}
|
||||
if (typeof value !== 'string') return undefined
|
||||
const text = value.trim()
|
||||
if (!text.startsWith('{') && !text.startsWith('[')) return undefined
|
||||
try {
|
||||
return explicitSuccessFlag(JSON.parse(text))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function teamLifecycleSucceeded(
|
||||
result: Extract<UIMessage, { type: 'tool_result' }> | undefined,
|
||||
): boolean {
|
||||
if (!result || result.isError) return false
|
||||
return explicitSuccessFlag(result.content) !== false
|
||||
}
|
||||
|
||||
function projectMessagesToTaskScope(
|
||||
messages: UIMessage[],
|
||||
taskScope: 'run' | 'team' | 'team-session',
|
||||
teamTaskWindows: Array<{ startedAt: number; endedAt?: number }>,
|
||||
): UIMessage[] {
|
||||
if (taskScope === 'run') return messages
|
||||
|
||||
const sharedTaskToolUseIds = new Set<string>()
|
||||
const resultsByToolUseId = collectToolResults(messages)
|
||||
let transcriptTeamCursor = EMPTY_TEAM_LIFECYCLE_CURSOR
|
||||
for (const message of messages) {
|
||||
if (taskScope === 'team-session' && message.type === 'tool_use') {
|
||||
const lifecycleSucceeded = teamLifecycleSucceeded(
|
||||
resultsByToolUseId.get(message.toolUseId),
|
||||
)
|
||||
if (message.toolName === 'TeamCreate' && lifecycleSucceeded) {
|
||||
transcriptTeamCursor = updateTeamLifecycleCursor(
|
||||
true,
|
||||
message.timestamp,
|
||||
)
|
||||
}
|
||||
if (message.toolName === 'TeamDelete' && lifecycleSucceeded) {
|
||||
transcriptTeamCursor = updateTeamLifecycleCursor(
|
||||
false,
|
||||
message.timestamp,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (
|
||||
message.type === 'tool_use' &&
|
||||
(message.toolName === 'TaskCreate' || message.toolName === 'TaskUpdate') &&
|
||||
(
|
||||
taskScope === 'team' ||
|
||||
(
|
||||
isTeamLifecycleScopedAt(
|
||||
message.timestamp,
|
||||
transcriptTeamCursor,
|
||||
teamTaskWindows,
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
sharedTaskToolUseIds.add(message.toolUseId)
|
||||
}
|
||||
}
|
||||
|
||||
return messages.filter((message) => {
|
||||
if (
|
||||
message.type === 'tool_use' &&
|
||||
(message.toolName === 'TaskCreate' || message.toolName === 'TaskUpdate')
|
||||
) {
|
||||
return !sharedTaskToolUseIds.has(message.toolUseId)
|
||||
}
|
||||
return message.type !== 'tool_result' || !sharedTaskToolUseIds.has(message.toolUseId)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskUpdate 的 deleted 是删除动作而非状态,删除可能发生在创建它的那一轮之后,
|
||||
* 所以要跨轮次收集,避免已删任务留在历史统计里。
|
||||
@@ -474,20 +618,164 @@ function agentToolLabel(toolCall: Extract<UIMessage, { type: 'tool_use' }>): str
|
||||
)
|
||||
}
|
||||
|
||||
function buildAgentRowsFromMessages(messages: UIMessage[]): ActivityRow[] {
|
||||
function parseToolResultRecord(
|
||||
result: Extract<UIMessage, { type: 'tool_result' }> | undefined,
|
||||
): Record<string, unknown> | null {
|
||||
if (!result || result.isError) return null
|
||||
if (isRecordValue(result.content)) return result.content
|
||||
|
||||
const text = extractTextContent(result.content).trim()
|
||||
if (!text.startsWith('{')) return null
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown
|
||||
return isRecordValue(parsed) ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function updatedActiveTeamName(
|
||||
toolCall: Extract<UIMessage, { type: 'tool_use' }>,
|
||||
result: Extract<UIMessage, { type: 'tool_result' }> | undefined,
|
||||
): string | null | undefined {
|
||||
const output = parseToolResultRecord(result)
|
||||
if (!output) return undefined
|
||||
|
||||
if (toolCall.toolName === 'TeamCreate') {
|
||||
if (output.success === false) return undefined
|
||||
return stringField(output, 'team_name') || undefined
|
||||
}
|
||||
if (toolCall.toolName === 'TeamDelete' && output.success === true) {
|
||||
return null
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
type TeamSpawnIdentity = {
|
||||
memberName: string
|
||||
teamName?: string
|
||||
}
|
||||
|
||||
function teamSpawnIdentity(
|
||||
toolCall: Extract<UIMessage, { type: 'tool_use' }>,
|
||||
result: Extract<UIMessage, { type: 'tool_result' }> | undefined,
|
||||
activeTeamName: string | undefined,
|
||||
teamScoped: boolean,
|
||||
): TeamSpawnIdentity | null {
|
||||
const input = isRecordValue(toolCall.input) ? toolCall.input : {}
|
||||
const inputMemberName = stringField(input, 'name')
|
||||
const inputTeamName = stringField(input, 'team_name')
|
||||
if (inputMemberName && (inputTeamName || teamScoped)) {
|
||||
return {
|
||||
memberName: inputMemberName,
|
||||
...((inputTeamName || (teamScoped && activeTeamName))
|
||||
? { teamName: inputTeamName || activeTeamName }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
const output = parseToolResultRecord(result)
|
||||
if (stringField(output ?? {}, 'status') === 'teammate_spawned') {
|
||||
const memberName = stringField(output ?? {}, 'name') || inputMemberName
|
||||
if (!memberName) return null
|
||||
const outputTeamName = stringField(output ?? {}, 'team_name')
|
||||
return {
|
||||
memberName,
|
||||
...((outputTeamName || activeTeamName)
|
||||
? { teamName: outputTeamName || activeTeamName }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
const fields = new Map<string, string>()
|
||||
for (const line of extractTextContent(result?.content).split(/\r?\n/)) {
|
||||
const match = /^\s*([a-z_]+):\s*(\S.*?)\s*$/.exec(line)
|
||||
const key = match?.[1]
|
||||
const value = match?.[2]
|
||||
if (key && value) fields.set(key, value)
|
||||
}
|
||||
const resultTeamName = fields.get('team_name')
|
||||
if (!fields.get('agent_id') || !fields.get('name') || !resultTeamName) return null
|
||||
return {
|
||||
memberName: fields.get('name')!,
|
||||
teamName: resultTeamName,
|
||||
}
|
||||
}
|
||||
|
||||
function buildAgentRowsFromMessages(
|
||||
messages: UIMessage[],
|
||||
teamTaskWindows: Array<{ startedAt: number; endedAt?: number }>,
|
||||
): ActivityRow[] {
|
||||
const resultsByToolUseId = new Map<string, Extract<UIMessage, { type: 'tool_result' }>>()
|
||||
const toolCallsByToolUseId = new Map<string, Extract<UIMessage, { type: 'tool_use' }>>()
|
||||
for (const message of messages) {
|
||||
if (message.type === 'tool_result') {
|
||||
resultsByToolUseId.set(message.toolUseId, message)
|
||||
} else if (message.type === 'tool_use') {
|
||||
toolCallsByToolUseId.set(message.toolUseId, message)
|
||||
}
|
||||
}
|
||||
|
||||
const rows: ActivityRow[] = []
|
||||
let activeTeamName: string | undefined
|
||||
let transcriptTeamCursor = EMPTY_TEAM_LIFECYCLE_CURSOR
|
||||
for (const message of messages) {
|
||||
if (message.type !== 'tool_use' || message.toolName !== 'Agent') continue
|
||||
if (message.type === 'tool_result') {
|
||||
const toolCall = toolCallsByToolUseId.get(message.toolUseId)
|
||||
if (toolCall) {
|
||||
const updatedTeamName = updatedActiveTeamName(toolCall, message)
|
||||
if (updatedTeamName !== undefined) activeTeamName = updatedTeamName ?? undefined
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (message.type !== 'tool_use') continue
|
||||
|
||||
const result = resultsByToolUseId.get(message.toolUseId)
|
||||
const lifecycleSucceeded = teamLifecycleSucceeded(result)
|
||||
if (message.toolName === 'TeamCreate' && lifecycleSucceeded) {
|
||||
transcriptTeamCursor = updateTeamLifecycleCursor(true, message.timestamp)
|
||||
} else if (message.toolName === 'TeamDelete' && lifecycleSucceeded) {
|
||||
transcriptTeamCursor = updateTeamLifecycleCursor(false, message.timestamp)
|
||||
}
|
||||
if (message.toolName !== 'Agent') continue
|
||||
|
||||
const resultText = result ? stripAgentMetadata(extractTextContent(result.content)) : ''
|
||||
const teamIdentity = teamSpawnIdentity(
|
||||
message,
|
||||
result,
|
||||
activeTeamName,
|
||||
isTeamLifecycleScopedAt(
|
||||
message.timestamp,
|
||||
transcriptTeamCursor,
|
||||
teamTaskWindows,
|
||||
),
|
||||
)
|
||||
// Agent inputs stream before their ownership fields. Until the input is
|
||||
// complete, treating an unknown owner as direct makes Team members flash
|
||||
// through the main session's SubAgents section.
|
||||
if (message.isPending && !teamIdentity) continue
|
||||
if (teamIdentity) {
|
||||
rows.push({
|
||||
id: message.toolUseId,
|
||||
section: 'team',
|
||||
label: teamIdentity.memberName,
|
||||
status: message.status === 'stopped'
|
||||
? 'stopped'
|
||||
: result?.isError
|
||||
? 'failed'
|
||||
: 'running',
|
||||
description: agentToolLabel(message),
|
||||
summary: resultText ? compactText(resultText) : undefined,
|
||||
toolUseId: message.toolUseId,
|
||||
taskType: 'in_process_teammate',
|
||||
...(teamIdentity.teamName ? { teamName: teamIdentity.teamName } : {}),
|
||||
teamMemberName: teamIdentity.memberName,
|
||||
...(teamIdentity.teamName ? { teamStartedAt: message.timestamp } : {}),
|
||||
updatedAt: result?.timestamp ?? message.timestamp,
|
||||
openable: Boolean(teamIdentity.teamName),
|
||||
})
|
||||
continue
|
||||
}
|
||||
rows.push({
|
||||
id: message.toolUseId,
|
||||
section: 'subagents',
|
||||
@@ -669,17 +957,21 @@ function buildHistoricalTasksRow(groups: TaskTurnRows[]): ActivityRow | null {
|
||||
}
|
||||
}
|
||||
|
||||
function buildTaskRowsFromMessages(messages: UIMessage[], liveTasks: CLITask[]): ActivityRow[] {
|
||||
const subagentCreatedTaskIds = collectSubagentCreatedTaskIds(messages)
|
||||
const sessionMessages = messages.filter(keepSessionLevelTaskMessage)
|
||||
const deletedTaskIds = collectDeletedTaskIds(messages)
|
||||
const resultsByToolUseId = collectToolResults(sessionMessages)
|
||||
function buildTaskRowsFromMessages(
|
||||
runMessages: UIMessage[],
|
||||
liveTasks: CLITask[],
|
||||
taskScope: 'run' | 'team' | 'team-session',
|
||||
teamTaskWindows: Array<{ startedAt: number; endedAt?: number }>,
|
||||
): ActivityRow[] {
|
||||
const taskMessages = projectMessagesToTaskScope(runMessages, taskScope, teamTaskWindows)
|
||||
const deletedTaskIds = collectDeletedTaskIds(taskMessages)
|
||||
const resultsByToolUseId = collectToolResults(taskMessages)
|
||||
const isSessionTaskRow = (row: ActivityRow) => row.taskId
|
||||
? !deletedTaskIds.has(row.taskId) && !subagentCreatedTaskIds.has(row.taskId)
|
||||
? !deletedTaskIds.has(row.taskId)
|
||||
: true
|
||||
// 任务列表要等 tool_result 到达后才异步刷新,这中间 liveTasks 里还留着已删的任务
|
||||
const liveRows = liveTasks.map(buildTaskRow).filter(isSessionTaskRow)
|
||||
const taskTurnRows = splitMessagesIntoTurns(sessionMessages)
|
||||
const taskTurnRows = splitMessagesIntoTurns(taskMessages)
|
||||
.map((turn) => {
|
||||
const builtRows = buildTaskRowsFromTurnMessages(turn.messages, resultsByToolUseId)
|
||||
return {
|
||||
@@ -759,6 +1051,97 @@ function mergeNotificationRow(existing: ActivityRow | undefined, notification: A
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a workflow run into phase headers each followed by its agents.
|
||||
*
|
||||
* The agents are ordinary subagents, so every row carries the reference the
|
||||
* existing subagent page opens with — there is nothing workflow-specific to
|
||||
* render for one of them. An agent that has not been given a concurrency slot
|
||||
* yet has no transcript to open, so it is listed but not openable.
|
||||
*/
|
||||
function buildWorkflowRows(run: WorkflowRun): ActivityRow[] {
|
||||
const phaseTitles = new Map<number, string>()
|
||||
const agentsByPhase = new Map<number, WorkflowAgentEvent[]>()
|
||||
|
||||
for (const event of run.progress) {
|
||||
if (event.type === 'workflow_phase') {
|
||||
if (!phaseTitles.has(event.index)) phaseTitles.set(event.index, event.title)
|
||||
if (!agentsByPhase.has(event.index)) agentsByPhase.set(event.index, [])
|
||||
continue
|
||||
}
|
||||
const phaseIndex = event.phaseIndex ?? 0
|
||||
if (!phaseTitles.has(phaseIndex)) {
|
||||
phaseTitles.set(phaseIndex, event.phaseTitle ?? '')
|
||||
}
|
||||
const bucket = agentsByPhase.get(phaseIndex) ?? []
|
||||
bucket.push(event)
|
||||
agentsByPhase.set(phaseIndex, bucket)
|
||||
}
|
||||
|
||||
const rows: ActivityRow[] = []
|
||||
for (const [phaseIndex, title] of [...phaseTitles.entries()].sort(([a], [b]) => a - b)) {
|
||||
const agents = (agentsByPhase.get(phaseIndex) ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.index - b.index)
|
||||
if (agents.length === 0 && !title) continue
|
||||
// Agents emitted before any `phase()` call — and every agent of a run
|
||||
// recorded before phases were persisted — have no title. The run's name
|
||||
// says more about them than "Phase 0" does, and it also tells two runs in
|
||||
// the same session apart.
|
||||
const groupLabel = title || run.workflowName
|
||||
const done = agents.filter(
|
||||
agent => agent.state === 'done' || agent.state === 'error',
|
||||
).length
|
||||
|
||||
rows.push({
|
||||
id: `${run.taskId}-phase-${phaseIndex}`,
|
||||
section: 'workflow',
|
||||
label: groupLabel,
|
||||
status: workflowPhaseStatus(agents),
|
||||
groupProgress: { done, total: agents.length },
|
||||
workflowName: run.workflowName,
|
||||
openable: false,
|
||||
})
|
||||
|
||||
for (const agent of agents) {
|
||||
rows.push({
|
||||
id: `${run.taskId}-agent-${agent.index}`,
|
||||
section: 'workflow',
|
||||
label: agent.label,
|
||||
status: workflowAgentStatus(agent),
|
||||
cached: agent.cached,
|
||||
group: groupLabel,
|
||||
summary: agent.resultPreview,
|
||||
toolUseId: agent.agentId ? toAgentIdRef(agent.agentId) : undefined,
|
||||
taskType: 'local_agent',
|
||||
workflowName: run.workflowName,
|
||||
usage: agent.tokens ? { totalTokens: agent.tokens } : undefined,
|
||||
openable: Boolean(agent.agentId),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
function workflowAgentStatus(agent: WorkflowAgentEvent): ActivityStatus {
|
||||
if (agent.state === 'done') return 'completed'
|
||||
if (agent.state === 'error') return 'failed'
|
||||
if (agent.state === 'progress') return 'running'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function workflowPhaseStatus(agents: WorkflowAgentEvent[]): ActivityStatus {
|
||||
if (agents.length === 0) return 'pending'
|
||||
if (agents.some(agent => agent.state === 'progress')) return 'running'
|
||||
if (agents.every(agent => agent.state === 'done' || agent.state === 'error')) {
|
||||
return agents.some(agent => agent.state === 'error') ? 'failed' : 'completed'
|
||||
}
|
||||
return agents.some(agent => agent.state === 'done' || agent.state === 'error')
|
||||
? 'running'
|
||||
: 'pending'
|
||||
}
|
||||
|
||||
function buildOutputRow(key: string, outputFile: string): ActivityRow {
|
||||
return {
|
||||
id: `output-${key}`,
|
||||
@@ -773,25 +1156,42 @@ function buildOutputRow(key: string, outputFile: string): ActivityRow {
|
||||
export function buildSessionActivityModel(input: BuildSessionActivityModelInput): SessionActivityModel {
|
||||
const sections = createEmptySections()
|
||||
let badgeCount = 0
|
||||
const taskRows = buildTaskRowsFromMessages(input.messages ?? [], input.tasks)
|
||||
sections.tasks.rows = input.isForegroundTurnActive === false
|
||||
? sealUnfinishedTaskRows(taskRows)
|
||||
: taskRows
|
||||
const includeTeamActivity = input.includeTeamActivity !== false
|
||||
const runMessages = projectMessagesToRun(input.messages ?? [], input.runScope ?? 'session')
|
||||
const runTaskRows = buildTaskRowsFromMessages(
|
||||
runMessages,
|
||||
input.tasks,
|
||||
input.taskScope ?? 'run',
|
||||
input.teamTaskWindows ?? [],
|
||||
)
|
||||
const settledRunTaskRows = input.isForegroundTurnActive === false
|
||||
? sealUnfinishedTaskRows(runTaskRows)
|
||||
: runTaskRows
|
||||
const teamTaskRows = includeTeamActivity
|
||||
? input.teamTasks?.map(buildTeamTaskRow) ?? []
|
||||
: []
|
||||
sections.tasks.rows = [...settledRunTaskRows, ...teamTaskRows]
|
||||
for (const row of sections.tasks.rows) {
|
||||
if (isBadgeStatus(row.status)) {
|
||||
badgeCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
for (const member of input.teamMembers ?? []) {
|
||||
sections.team.rows.push(buildTeamRow(member))
|
||||
for (const run of input.workflowRuns ?? []) {
|
||||
sections.workflow.rows.push(...buildWorkflowRows(run))
|
||||
}
|
||||
for (const row of sections.team.rows) {
|
||||
if (isBadgeStatus(row.status)) {
|
||||
for (const row of sections.workflow.rows) {
|
||||
if (isBadgeStatus(row.status) && !row.groupProgress) {
|
||||
badgeCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (includeTeamActivity) {
|
||||
for (const member of input.teamMembers ?? []) {
|
||||
sections.team.rows.push(buildTeamRow(member))
|
||||
}
|
||||
}
|
||||
|
||||
const subagentRowsByKey = new Map<string, ActivityRow>()
|
||||
const subagentKeyByTaskId = new Map<string, string>()
|
||||
const outputRowsByKey = new Map<string, ActivityRow>()
|
||||
@@ -799,12 +1199,39 @@ export function buildSessionActivityModel(input: BuildSessionActivityModelInput)
|
||||
const dismissedNotificationKeys = new Set<string>()
|
||||
const dismissedNotificationTaskIds = new Set<string>()
|
||||
const visibleBackgroundTaskIds = new Set<string>()
|
||||
|
||||
for (const row of buildAgentRowsFromMessages(input.messages ?? [])) {
|
||||
const hiddenChildTaskIds = new Set<string>()
|
||||
const knownTeamMemberNames = new Set(
|
||||
(input.teamMembers ?? []).flatMap((member) => [
|
||||
member.name,
|
||||
member.agentId.split('@')[0],
|
||||
]).filter((name): name is string => Boolean(name)),
|
||||
)
|
||||
const teamLaunchRowsByMember = new Map<string, ActivityRow>()
|
||||
for (const row of buildAgentRowsFromMessages(runMessages, input.teamTaskWindows ?? [])) {
|
||||
if (row.section === 'team') {
|
||||
if (!includeTeamActivity) continue
|
||||
if (row.teamMemberName && knownTeamMemberNames.has(row.teamMemberName)) continue
|
||||
const key = row.teamName && row.teamMemberName
|
||||
? `${row.teamName}:${row.teamMemberName}`
|
||||
: row.id
|
||||
teamLaunchRowsByMember.set(key, row)
|
||||
continue
|
||||
}
|
||||
subagentRowsByKey.set(row.id, mergeSubagentRow(subagentRowsByKey.get(row.id), row))
|
||||
}
|
||||
sections.team.rows.push(...teamLaunchRowsByMember.values())
|
||||
for (const row of sections.team.rows) {
|
||||
if (isBadgeStatus(row.status)) {
|
||||
badgeCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
for (const task of input.backgroundTasks) {
|
||||
if (!isVisibleSessionBackgroundTask(task)) {
|
||||
hiddenChildTaskIds.add(task.taskId)
|
||||
continue
|
||||
}
|
||||
|
||||
const dismissKey = createBackgroundTaskDismissKey(task)
|
||||
if (task.status !== 'running' && dismissedBackgroundTaskKeys.has(dismissKey)) {
|
||||
const key = activityKey(task)
|
||||
@@ -833,6 +1260,8 @@ export function buildSessionActivityModel(input: BuildSessionActivityModelInput)
|
||||
}
|
||||
|
||||
for (const notification of input.agentNotifications) {
|
||||
if (hiddenChildTaskIds.has(notification.taskId)) continue
|
||||
|
||||
const key = notificationKey(notification)
|
||||
if (
|
||||
dismissedNotificationKeys.has(key) ||
|
||||
@@ -887,3 +1316,19 @@ export function buildSessionActivityModel(input: BuildSessionActivityModelInput)
|
||||
sections,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main-session Activity is the lead agent's run projection. AgentTeam owns a
|
||||
* separate strip/workbench, so its shared DAG, roster and launch rows cannot
|
||||
* enter this model or affect the toolbar badge/auto-open state.
|
||||
*/
|
||||
export function buildMainSessionActivityModel(
|
||||
input: BuildMainSessionActivityModelInput,
|
||||
): SessionActivityModel {
|
||||
return buildSessionActivityModel({
|
||||
...input,
|
||||
runScope: 'session',
|
||||
taskScope: 'team-session',
|
||||
includeTeamActivity: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import type { TeamWorkbenchSnapshot, TeamWorkbenchTask } from '../../types/team'
|
||||
import { AgentTeamsCanvas, type AgentTeamsCanvasProps } from './AgentTeamsCanvas'
|
||||
|
||||
function task(
|
||||
id: string,
|
||||
status: TeamWorkbenchTask['status'],
|
||||
blockedBy: string[] = [],
|
||||
owner?: string,
|
||||
): TeamWorkbenchTask {
|
||||
return {
|
||||
id,
|
||||
subject: `Task ${id}`,
|
||||
description: `Task ${id} detail`,
|
||||
activeForm: status === 'in_progress' ? `Working on task ${id}` : undefined,
|
||||
owner,
|
||||
status,
|
||||
blocks: [],
|
||||
blockedBy,
|
||||
taskListId: 'canvas-team',
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(version: 'previous' | 'current'): TeamWorkbenchSnapshot {
|
||||
const current = version === 'current'
|
||||
return {
|
||||
version,
|
||||
generatedAt: current ? '2026-08-12T02:30:00.000Z' : '2026-08-12T02:29:00.000Z',
|
||||
team: {
|
||||
name: 'canvas-team',
|
||||
leadAgentId: 'team-lead@canvas-team',
|
||||
members: [
|
||||
{ agentId: 'team-lead@canvas-team', name: 'team-lead', role: 'Lead', status: 'running' },
|
||||
{ agentId: 'builder@canvas-team', name: 'builder', role: 'Frontend', status: 'running', activity: 'active' },
|
||||
{ agentId: 'reviewer@canvas-team', name: 'reviewer', role: 'Review', status: 'idle', activity: 'idle' },
|
||||
{ agentId: 'qa@canvas-team', name: 'qa', role: 'QA', status: current ? 'completed' : 'running', activity: current ? 'exited' : 'idle' },
|
||||
],
|
||||
},
|
||||
tasks: current
|
||||
? [
|
||||
task('1', 'completed', [], 'builder'),
|
||||
task('2', 'in_progress', ['1'], 'builder'),
|
||||
task('3', 'pending', ['2'], 'reviewer'),
|
||||
task('4', 'completed', ['1'], 'qa'),
|
||||
]
|
||||
: [
|
||||
task('1', 'in_progress', [], 'builder'),
|
||||
task('2', 'pending', ['1'], 'builder'),
|
||||
task('3', 'pending', ['2'], 'reviewer'),
|
||||
task('4', 'pending', ['1'], 'qa'),
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
id: 'peer-message',
|
||||
from: 'builder',
|
||||
to: 'reviewer',
|
||||
recipients: ['reviewer'],
|
||||
kind: 'direct',
|
||||
text: 'The first task is ready for review.',
|
||||
timestamp: '2026-08-12T02:29:30.000Z',
|
||||
},
|
||||
{
|
||||
id: 'lead-message',
|
||||
from: 'team-lead',
|
||||
to: 'builder',
|
||||
recipients: ['builder'],
|
||||
kind: 'system',
|
||||
text: '{"type":"task_assignment","taskId":"2","subject":"Task 2"}',
|
||||
protocolType: 'task_assignment',
|
||||
taskId: '2',
|
||||
timestamp: '2026-08-12T02:29:40.000Z',
|
||||
},
|
||||
{
|
||||
id: 'claim-message',
|
||||
from: 'builder',
|
||||
to: 'builder',
|
||||
recipients: ['builder'],
|
||||
kind: 'system',
|
||||
text: '{"type":"task_assignment","taskId":"2","subject":"Task 2"}',
|
||||
protocolType: 'task_assignment',
|
||||
taskId: '2',
|
||||
timestamp: '2026-08-12T02:29:50.000Z',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function props(overrides: Partial<AgentTeamsCanvasProps> = {}): AgentTeamsCanvasProps {
|
||||
const previousSnapshot = snapshot('previous')
|
||||
const currentSnapshot = snapshot('current')
|
||||
return {
|
||||
snapshots: [previousSnapshot, currentSnapshot],
|
||||
selectedIndex: 1,
|
||||
snapshot: currentSnapshot,
|
||||
previousSnapshot,
|
||||
leadIsStreaming: true,
|
||||
activeMessageId: 'peer-message',
|
||||
selectedMemberId: null,
|
||||
onSelectMember: vi.fn(),
|
||||
onSelectTask: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('AgentTeamsCanvas', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
})
|
||||
|
||||
it('renders the fixed formation over horizontal dependency lanes with one node per member', () => {
|
||||
const { container } = render(<AgentTeamsCanvas {...props()} />)
|
||||
|
||||
expect(screen.getByTestId('agent-teams-formation-layer')).toBeTruthy()
|
||||
expect(screen.getByTestId('agent-teams-task-layer')).toBeTruthy()
|
||||
expect(screen.getByText('1 · Formation — lead centered, teammates below')).toBeTruthy()
|
||||
expect(screen.getByText('2 · Shared task list — dependency layers, left → right')).toBeTruthy()
|
||||
|
||||
const lead = screen.getByTestId('agent-teams-canvas-member-team-lead@canvas-team')
|
||||
const builder = screen.getByTestId('agent-teams-canvas-member-builder@canvas-team')
|
||||
expect(lead.style.top).toBe('20px')
|
||||
expect(builder.style.top).toBe('222px')
|
||||
expect(lead.textContent).toContain('Coordinating · awaiting reports')
|
||||
expect(builder.textContent).toContain('Executing #2')
|
||||
expect(container.querySelectorAll('[data-testid="agent-teams-canvas-member-builder@canvas-team"]')).toHaveLength(1)
|
||||
expect(container.querySelectorAll('[data-testid^="agent-teams-canvas-member-"]')).toHaveLength(4)
|
||||
|
||||
expect(container.querySelectorAll('[data-testid^="agent-teams-canvas-lane-"]')).toHaveLength(3)
|
||||
expect(screen.getByTestId('agent-teams-canvas-task-1').style.top).toBe('442px')
|
||||
expect(screen.getByTestId('agent-teams-canvas-task-1').className).toContain('h-[92px]')
|
||||
expect(screen.getByTestId('agent-teams-canvas-task-1').className).toContain('w-[200px]')
|
||||
expect(screen.getByTestId('agent-teams-legend').style.top).toBe('684px')
|
||||
})
|
||||
|
||||
it('draws dependency state, a maximum-one-per-active-member tether, and indeterminate real work', () => {
|
||||
const { container } = render(<AgentTeamsCanvas {...props()} />)
|
||||
|
||||
expect(container.querySelectorAll('[data-testid^="agent-teams-canvas-edge-"]')).toHaveLength(3)
|
||||
const unlocked = screen.getByTestId('agent-teams-canvas-edge-1-2')
|
||||
expect(unlocked.getAttribute('data-edge-satisfied')).toBe('true')
|
||||
expect(unlocked.getAttribute('data-edge-fresh')).toBe('true')
|
||||
expect(unlocked.getAttribute('stroke')).toBe('var(--color-success)')
|
||||
expect(screen.getByTestId('agent-teams-canvas-edge-2-3').getAttribute('data-edge-satisfied')).toBe('false')
|
||||
|
||||
expect(screen.getByTestId('agent-teams-canvas-tether-builder@canvas-team').getAttribute('data-task-id')).toBe('2')
|
||||
expect(screen.queryByTestId('agent-teams-canvas-tether-reviewer@canvas-team')).toBeNull()
|
||||
expect(
|
||||
screen.getByTestId('agent-teams-canvas-task-2').querySelector('[data-progress="indeterminate"]')?.className,
|
||||
).toContain('agent-teams-task-running-fill')
|
||||
|
||||
fireEvent.mouseEnter(screen.getByTestId('agent-teams-canvas-task-3'))
|
||||
expect(container.querySelectorAll('[data-edge-active="true"]').length).toBeGreaterThan(0)
|
||||
fireEvent.mouseLeave(screen.getByTestId('agent-teams-canvas-task-3'))
|
||||
expect(container.querySelectorAll('[data-edge-active="true"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('uses the transient unlocked label only while the task is actually open', () => {
|
||||
const previousSnapshot = snapshot('previous')
|
||||
const openSnapshot = snapshot('current')
|
||||
openSnapshot.tasks = openSnapshot.tasks.map(entry => (
|
||||
entry.id === '2' ? { ...entry, status: 'pending' as const } : entry
|
||||
))
|
||||
const { rerender } = render(<AgentTeamsCanvas {...props({
|
||||
snapshots: [previousSnapshot, openSnapshot],
|
||||
snapshot: openSnapshot,
|
||||
previousSnapshot,
|
||||
activeMessageId: null,
|
||||
})} />)
|
||||
|
||||
const unlockedTask = screen.getByTestId('agent-teams-canvas-task-2')
|
||||
expect(unlockedTask.textContent).toContain('Just unlocked')
|
||||
const unlockedPill = Array.from(unlockedTask.querySelectorAll('span'))
|
||||
.find(element => element.textContent === 'Just unlocked') as HTMLElement
|
||||
expect(unlockedPill.style.color).toBe('var(--color-on-success-container)')
|
||||
|
||||
const completedTask = screen.getByTestId('agent-teams-canvas-task-1')
|
||||
const completedPill = Array.from(completedTask.querySelectorAll('span'))
|
||||
.find(element => element.textContent === 'Completed') as HTMLElement
|
||||
expect(completedPill.style.color).toBe('var(--color-on-success-container)')
|
||||
|
||||
const runningSnapshot = snapshot('current')
|
||||
rerender(<AgentTeamsCanvas {...props({
|
||||
snapshots: [previousSnapshot, runningSnapshot],
|
||||
snapshot: runningSnapshot,
|
||||
previousSnapshot,
|
||||
activeMessageId: null,
|
||||
})} />)
|
||||
|
||||
expect(screen.getByTestId('agent-teams-canvas-task-2').textContent).toContain('In progress')
|
||||
expect(screen.getByTestId('agent-teams-canvas-task-2').textContent).not.toContain('Just unlocked')
|
||||
})
|
||||
|
||||
it('uses accessible controls and calls the task and member selection contracts', () => {
|
||||
const onSelectMember = vi.fn()
|
||||
const onSelectTask = vi.fn()
|
||||
render(<AgentTeamsCanvas {...props({ onSelectMember, onSelectTask, selectedMemberId: 'builder' })} />)
|
||||
|
||||
const builder = screen.getByRole('button', { name: 'builder' })
|
||||
expect(builder.getAttribute('aria-pressed')).toBe('true')
|
||||
fireEvent.click(builder)
|
||||
expect(onSelectMember).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'builder@canvas-team' }),
|
||||
false,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'team-lead' }))
|
||||
expect(onSelectMember).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'team-lead@canvas-team' }),
|
||||
true,
|
||||
)
|
||||
|
||||
const runningTask = screen.getByRole('button', { name: 'Task 2, In progress' })
|
||||
fireEvent.focus(runningTask)
|
||||
fireEvent.click(runningTask)
|
||||
expect(onSelectTask).toHaveBeenCalledWith(expect.objectContaining({ id: '2' }))
|
||||
})
|
||||
|
||||
it('routes the active capsule through peer, lead, and self-claim channels', () => {
|
||||
const base = props()
|
||||
const { rerender } = render(<AgentTeamsCanvas {...base} />)
|
||||
|
||||
expect(screen.getByTestId('agent-teams-active-flight').getAttribute('data-flight-channel')).toBe('peer')
|
||||
expect(screen.getByTestId('agent-teams-active-flight').textContent).toContain('DIRECT')
|
||||
expect(screen.getByTestId('agent-teams-active-flight').className).toContain('agent-teams-flight')
|
||||
expect(screen.getByTestId('agent-teams-canvas-member-builder@canvas-team').querySelector('.agent-teams-member-ring')).toBeTruthy()
|
||||
expect(screen.getByTestId('agent-teams-canvas-member-reviewer@canvas-team').querySelector('.agent-teams-member-ring')).toBeTruthy()
|
||||
|
||||
rerender(<AgentTeamsCanvas {...base} activeMessageId="lead-message" />)
|
||||
expect(screen.getByTestId('agent-teams-active-flight').getAttribute('data-flight-channel')).toBe('lead')
|
||||
expect(screen.getByTestId('agent-teams-active-flight').textContent).toContain('Assignments')
|
||||
|
||||
rerender(<AgentTeamsCanvas {...base} activeMessageId="claim-message" />)
|
||||
expect(screen.getByTestId('agent-teams-active-flight').getAttribute('data-flight-channel')).toBe('claim')
|
||||
expect(screen.getByTestId('agent-teams-active-flight-path').getAttribute('data-flight-channel')).toBe('claim')
|
||||
})
|
||||
|
||||
it('routes a broadcast to every recipient on the matching lead or peer channel', () => {
|
||||
const current = snapshot('current')
|
||||
current.messages = [
|
||||
...current.messages,
|
||||
{
|
||||
id: 'lead-broadcast',
|
||||
from: 'team-lead',
|
||||
to: '*',
|
||||
recipients: ['builder', 'reviewer', 'qa'],
|
||||
kind: 'broadcast',
|
||||
text: 'Check in now.',
|
||||
timestamp: '2026-08-12T02:29:55.000Z',
|
||||
},
|
||||
{
|
||||
id: 'worker-broadcast',
|
||||
from: 'builder',
|
||||
to: '*',
|
||||
recipients: ['team-lead', 'reviewer', 'qa'],
|
||||
kind: 'broadcast',
|
||||
text: 'The implementation is ready.',
|
||||
timestamp: '2026-08-12T02:29:56.000Z',
|
||||
},
|
||||
]
|
||||
const base = props({
|
||||
snapshots: [current],
|
||||
selectedIndex: 0,
|
||||
snapshot: current,
|
||||
previousSnapshot: undefined,
|
||||
})
|
||||
const { rerender } = render(<AgentTeamsCanvas {...base} activeMessageId="lead-broadcast" />)
|
||||
|
||||
expect(screen.getAllByTestId('agent-teams-active-flight')).toHaveLength(3)
|
||||
expect(screen.getAllByTestId('agent-teams-active-flight').map(element => (
|
||||
element.getAttribute('data-flight-channel')
|
||||
))).toEqual(['lead', 'lead', 'lead'])
|
||||
expect(screen.getAllByTestId('agent-teams-active-flight').every(element => (
|
||||
element.textContent?.includes('Assignments')
|
||||
))).toBe(true)
|
||||
|
||||
rerender(<AgentTeamsCanvas {...base} activeMessageId="worker-broadcast" />)
|
||||
expect(screen.getAllByTestId('agent-teams-active-flight')).toHaveLength(3)
|
||||
expect(screen.getAllByTestId('agent-teams-active-flight').map(element => (
|
||||
element.getAttribute('data-flight-channel')
|
||||
)).sort()).toEqual(['lead', 'peer', 'peer'])
|
||||
expect(screen.getAllByTestId('agent-teams-active-flight').every(element => (
|
||||
element.textContent?.includes('Reports')
|
||||
))).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps external task focus until an internal card hover temporarily takes over', () => {
|
||||
const current = snapshot('current')
|
||||
current.tasks = [...current.tasks, task('5', 'pending', [], 'qa')]
|
||||
const base = props({
|
||||
snapshots: [current],
|
||||
selectedIndex: 0,
|
||||
snapshot: current,
|
||||
previousSnapshot: undefined,
|
||||
activeMessageId: null,
|
||||
focusedTaskId: '3',
|
||||
})
|
||||
render(<AgentTeamsCanvas {...base} />)
|
||||
|
||||
const externalFocus = screen.getByTestId('agent-teams-canvas-task-3')
|
||||
const independentTask = screen.getByTestId('agent-teams-canvas-task-5')
|
||||
expect(externalFocus.getAttribute('data-chain-active')).toBe('true')
|
||||
expect(independentTask.style.opacity).toBe('0.34')
|
||||
|
||||
fireEvent.mouseEnter(independentTask)
|
||||
expect(independentTask.getAttribute('data-chain-active')).toBe('true')
|
||||
expect(externalFocus.style.opacity).toBe('0.34')
|
||||
|
||||
fireEvent.focus(independentTask)
|
||||
fireEvent.mouseLeave(independentTask)
|
||||
expect(independentTask.getAttribute('data-chain-active')).toBe('true')
|
||||
expect(externalFocus.style.opacity).toBe('0.34')
|
||||
|
||||
fireEvent.blur(independentTask)
|
||||
expect(externalFocus.getAttribute('data-chain-active')).toBe('true')
|
||||
expect(independentTask.style.opacity).toBe('0.34')
|
||||
})
|
||||
|
||||
it('draws every active member tether even when more than five workers are running', () => {
|
||||
const current = snapshot('current')
|
||||
const workers = Array.from({ length: 6 }, (_, index) => ({
|
||||
agentId: `worker-${index + 1}@canvas-team`,
|
||||
name: `worker-${index + 1}`,
|
||||
role: 'Reviewer',
|
||||
status: 'running' as const,
|
||||
activity: 'active' as const,
|
||||
}))
|
||||
current.team.members = [current.team.members[0]!, ...workers]
|
||||
current.tasks = workers.map((member, index) => task(
|
||||
String(index + 1),
|
||||
'in_progress',
|
||||
[],
|
||||
member.name,
|
||||
))
|
||||
current.messages = []
|
||||
|
||||
const { container } = render(<AgentTeamsCanvas {...props({
|
||||
snapshots: [current],
|
||||
selectedIndex: 0,
|
||||
snapshot: current,
|
||||
previousSnapshot: undefined,
|
||||
activeMessageId: null,
|
||||
})} />)
|
||||
|
||||
expect(container.querySelectorAll('[data-testid^="agent-teams-canvas-member-worker-"]')).toHaveLength(6)
|
||||
expect(container.querySelectorAll('[data-testid^="agent-teams-canvas-tether-worker-"]')).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('hides indeterminate fill under reduced motion instead of implying a fixed percentage', () => {
|
||||
const css = readFileSync(join(__dirname, '../../theme/globals.css'), 'utf8')
|
||||
expect(css).toMatch(
|
||||
/@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.agent-teams-task-running-fill\s*\{[^}]*opacity:\s*0;/,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,269 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import type { TeamMember, TeamWorkbenchMessage, TeamWorkbenchSnapshot } from '../../types/team'
|
||||
import { AgentTeamsCommunicationFeed } from './AgentTeamsCommunicationFeed'
|
||||
|
||||
function message(overrides: Partial<TeamWorkbenchMessage> & { id: string }): TeamWorkbenchMessage {
|
||||
return {
|
||||
from: 'builder',
|
||||
to: 'reviewer',
|
||||
recipients: ['reviewer'],
|
||||
kind: 'direct',
|
||||
text: 'body',
|
||||
timestamp: '2026-08-08T07:42:16.666Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const MEMBERS: TeamMember[] = [
|
||||
{ agentId: 'lead@team-a', name: 'lead', role: 'orchestrator', status: 'running' },
|
||||
{ agentId: 'builder@team-a', name: 'builder', role: 'server engineer', status: 'running' },
|
||||
{ agentId: 'reviewer@team-a', name: 'reviewer', role: 'qa engineer', status: 'idle' },
|
||||
]
|
||||
|
||||
function snapshot(
|
||||
messages: TeamWorkbenchMessage[],
|
||||
members: TeamMember[] = MEMBERS,
|
||||
): TeamWorkbenchSnapshot {
|
||||
return {
|
||||
version: 'v1',
|
||||
generatedAt: '2026-08-08T08:00:00.000Z',
|
||||
team: {
|
||||
name: 'team-a',
|
||||
leadAgentId: 'lead@team-a',
|
||||
leadSessionId: 'lead-session',
|
||||
members,
|
||||
},
|
||||
tasks: [],
|
||||
messages,
|
||||
}
|
||||
}
|
||||
|
||||
function categoryOf(id: string): string | null {
|
||||
return screen.getByTestId(`agent-teams-message-${id}`).getAttribute('data-message-category')
|
||||
}
|
||||
|
||||
describe('AgentTeamsCommunicationFeed', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
})
|
||||
|
||||
it('maps real CLI routes into assignment, peer, report, and system categories', () => {
|
||||
render(<AgentTeamsCommunicationFeed snapshot={snapshot([
|
||||
message({
|
||||
id: 'protocol-assignment',
|
||||
from: 'lead',
|
||||
to: 'builder@team-a',
|
||||
recipients: ['builder@team-a'],
|
||||
kind: 'system',
|
||||
protocolType: 'task_assignment',
|
||||
taskId: '2',
|
||||
text: '{"type":"task_assignment","taskId":"2","subject":"Build it"}',
|
||||
}),
|
||||
message({
|
||||
id: 'self-claim',
|
||||
from: 'builder',
|
||||
to: 'builder@team-a',
|
||||
recipients: ['builder@team-a'],
|
||||
kind: 'system',
|
||||
protocolType: 'task_assignment',
|
||||
taskId: '3',
|
||||
text: '{"type":"task_assignment","taskId":"3","subject":"Claim it"}',
|
||||
}),
|
||||
message({ id: 'lead-direct', from: 'lead', to: 'builder@team-a', recipients: ['builder@team-a'], text: 'Please own the API.' }),
|
||||
message({ id: 'peer-direct', from: 'builder@team-a', to: 'reviewer', recipients: ['reviewer'], text: 'Can you verify the route?' }),
|
||||
message({ id: 'worker-report', from: 'builder', to: 'lead@team-a', recipients: ['lead@team-a'], text: 'API implementation is ready.' }),
|
||||
message({
|
||||
id: 'lifecycle',
|
||||
from: 'builder@team-a',
|
||||
to: 'team-lead@team-a',
|
||||
recipients: ['team-lead@team-a'],
|
||||
kind: 'system',
|
||||
text: '{"type":"idle_notification","idleReason":"available"}',
|
||||
}),
|
||||
message({ id: 'lead-broadcast', from: 'lead@team-a', kind: 'broadcast', to: '*', recipients: ['*'], text: 'Everyone take the next ready task.' }),
|
||||
message({ id: 'worker-broadcast', from: 'reviewer@team-a', kind: 'broadcast', to: '*', recipients: ['*'], text: 'Review summary is published.' }),
|
||||
])} />)
|
||||
|
||||
expect(categoryOf('protocol-assignment')).toBe('assignment')
|
||||
expect(categoryOf('self-claim')).toBe('system')
|
||||
expect(categoryOf('lead-direct')).toBe('assignment')
|
||||
expect(categoryOf('lead-broadcast')).toBe('assignment')
|
||||
expect(categoryOf('peer-direct')).toBe('peer')
|
||||
expect(categoryOf('worker-report')).toBe('report')
|
||||
expect(categoryOf('worker-broadcast')).toBe('report')
|
||||
expect(categoryOf('lifecycle')).toBe('system')
|
||||
expect(within(screen.getByTestId('agent-teams-message-worker-report')).getByText('Reports')).toBeTruthy()
|
||||
expect(within(screen.getByTestId('agent-teams-message-peer-direct')).getByText('Direct').className).toContain('color-tertiary')
|
||||
expect(within(screen.getByTestId('agent-teams-message-worker-report')).getByText('Reports').className).toContain('color-text-secondary')
|
||||
expect(screen.getByText('Review summary is published.')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('always shows the five semantic filters and filters without using transport kind', () => {
|
||||
render(<AgentTeamsCommunicationFeed snapshot={snapshot([
|
||||
message({ id: 'assignment', from: 'lead', to: 'builder', recipients: ['builder'], text: 'Take task one.' }),
|
||||
message({ id: 'peer', from: 'builder', to: 'reviewer', recipients: ['reviewer'], text: 'Private review.' }),
|
||||
message({ id: 'report', from: 'builder', to: 'lead', recipients: ['lead'], text: 'Task one is done.' }),
|
||||
message({ id: 'system', kind: 'system', protocolType: 'custom_event', text: '{"type":"custom_event","message":"Context loaded"}' }),
|
||||
])} />)
|
||||
|
||||
expect(screen.getByTestId('agent-teams-message-count').textContent).toBe('4 messages')
|
||||
expect(screen.getByTestId('agent-teams-filter-all')).toBeTruthy()
|
||||
expect(screen.getByTestId('agent-teams-filter-assignment').getAttribute('data-count')).toBe('1')
|
||||
expect(screen.getByTestId('agent-teams-filter-peer').getAttribute('data-count')).toBe('1')
|
||||
expect(screen.getByTestId('agent-teams-filter-report').getAttribute('data-count')).toBe('1')
|
||||
expect(screen.getByTestId('agent-teams-filter-system').getAttribute('data-count')).toBe('1')
|
||||
|
||||
for (const category of ['assignment', 'peer', 'report', 'system'] as const) {
|
||||
fireEvent.click(screen.getByTestId(`agent-teams-filter-${category}`))
|
||||
expect(screen.getByTestId(`agent-teams-message-${category}`)).toBeTruthy()
|
||||
for (const other of ['assignment', 'peer', 'report', 'system']) {
|
||||
if (other !== category) expect(screen.queryByTestId(`agent-teams-message-${other}`)).toBeNull()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('narrates assignment and lifecycle protocols in one line without leaking JSON', () => {
|
||||
render(<AgentTeamsCommunicationFeed snapshot={snapshot([
|
||||
message({
|
||||
id: 'assignment',
|
||||
from: 'lead',
|
||||
to: 'builder',
|
||||
recipients: ['builder'],
|
||||
kind: 'system',
|
||||
protocolType: 'task_assignment',
|
||||
taskId: '7',
|
||||
text: '{"type":"task_assignment","taskId":"7","subject":"Repair queue"}',
|
||||
}),
|
||||
message({
|
||||
id: 'idle',
|
||||
from: 'builder',
|
||||
to: 'lead',
|
||||
recipients: ['lead'],
|
||||
kind: 'system',
|
||||
text: '{"type":"idle_notification","idleReason":"available"}',
|
||||
}),
|
||||
])} />)
|
||||
|
||||
expect(screen.getByText(/Assigned #7 Repair queue to builder/)).toBeTruthy()
|
||||
expect(screen.getByText(/Went idle, waiting for work · available/)).toBeTruthy()
|
||||
expect(screen.getByTestId('agent-teams-message-assignment-body').className).toContain('truncate')
|
||||
expect(screen.getByTestId('agent-teams-message-idle-body').className).toContain('truncate')
|
||||
expect(document.body.textContent).not.toContain('"taskId"')
|
||||
expect(document.body.textContent).not.toContain('idle_notification')
|
||||
})
|
||||
|
||||
it('localizes an unknown structured protocol instead of inventing an English label', () => {
|
||||
useSettingsStore.setState({ locale: 'zh' })
|
||||
render(<AgentTeamsCommunicationFeed snapshot={snapshot([
|
||||
message({
|
||||
id: 'custom',
|
||||
kind: 'system',
|
||||
protocolType: 'custom_event',
|
||||
text: '{"type":"custom_event","payload":42}',
|
||||
}),
|
||||
])} />)
|
||||
|
||||
expect(screen.getByTestId('agent-teams-message-custom-body').textContent).toBe('系统')
|
||||
expect(document.body.textContent).not.toContain('Custom event')
|
||||
})
|
||||
|
||||
it('keeps authored Markdown readable and uses compact 22px routing figures', () => {
|
||||
render(<AgentTeamsCommunicationFeed snapshot={snapshot([
|
||||
message({
|
||||
id: 'markdown',
|
||||
from: 'builder@team-a',
|
||||
to: 'reviewer',
|
||||
text: '## Finding A\n\n**Key evidence**\n\n```ts\nconst fixed = true\n```',
|
||||
}),
|
||||
])} />)
|
||||
|
||||
const row = screen.getByTestId('agent-teams-message-markdown')
|
||||
expect(row.querySelector('h2')?.textContent).toBe('Finding A')
|
||||
expect(row.querySelector('strong')?.textContent).toBe('Key evidence')
|
||||
expect(row.textContent).not.toContain('## Finding A')
|
||||
expect(screen.getByTestId('agent-teams-message-markdown-from').textContent).toBe('builder@team-a')
|
||||
expect(screen.getByTestId('agent-teams-message-markdown-to').textContent).toBe('reviewer')
|
||||
expect(screen.getByTestId('agent-teams-message-markdown-from-avatar').className).toContain('h-[22px]')
|
||||
expect(screen.getByTestId('agent-teams-message-markdown-from-avatar').getAttribute('data-avatar-key')).toBe('server-engineer')
|
||||
expect(screen.getByTestId('agent-teams-message-markdown-to-avatar').getAttribute('data-avatar-key')).toBe('security-reviewer')
|
||||
})
|
||||
|
||||
it('keeps a long human message behind an explicit expander', () => {
|
||||
render(<AgentTeamsCommunicationFeed snapshot={snapshot([
|
||||
message({ id: 'long', kind: 'broadcast', to: '*', recipients: ['*'], text: 'x'.repeat(400) }),
|
||||
])} />)
|
||||
|
||||
expect(screen.getByTestId('agent-teams-message-long-body').getAttribute('data-collapsed')).toBe('true')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show more' }))
|
||||
expect(screen.getByTestId('agent-teams-message-long-body').getAttribute('data-collapsed')).toBe('false')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show less' }))
|
||||
expect(screen.getByTestId('agent-teams-message-long-body').getAttribute('data-collapsed')).toBe('true')
|
||||
})
|
||||
|
||||
it('folds adjacent duplicate lifecycle signals into the newest row', () => {
|
||||
const idle = (id: string) => message({
|
||||
id,
|
||||
from: 'builder',
|
||||
to: 'lead',
|
||||
recipients: ['lead'],
|
||||
kind: 'system',
|
||||
text: '{"type":"idle_notification","idleReason":"available"}',
|
||||
})
|
||||
render(<AgentTeamsCommunicationFeed snapshot={snapshot([
|
||||
message({ id: 'report', from: 'builder', to: 'lead', recipients: ['lead'], text: 'Ready for more work.' }),
|
||||
idle('i1'),
|
||||
idle('i2'),
|
||||
idle('i3'),
|
||||
])} />)
|
||||
|
||||
expect(screen.getByTestId('agent-teams-message-count').textContent).toBe('4 messages')
|
||||
expect(screen.getByTestId('agent-teams-message-i3-repeats').textContent).toBe('×3')
|
||||
expect(screen.queryByTestId('agent-teams-message-i2')).toBeNull()
|
||||
expect(screen.queryByTestId('agent-teams-message-i1')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps task hover focus and its reset callback', () => {
|
||||
const onFocusTask = vi.fn()
|
||||
render(<AgentTeamsCommunicationFeed
|
||||
snapshot={snapshot([message({ id: 'task', taskId: '42', text: 'Please review task 42.' })])}
|
||||
onFocusTask={onFocusTask}
|
||||
/>)
|
||||
|
||||
const row = screen.getByTestId('agent-teams-message-task')
|
||||
fireEvent.mouseEnter(row)
|
||||
fireEvent.mouseLeave(row)
|
||||
|
||||
expect(onFocusTask.mock.calls).toEqual([['42'], [null]])
|
||||
})
|
||||
|
||||
it('keeps per-message wall time but removes the local early/middle/late controls', () => {
|
||||
render(<AgentTeamsCommunicationFeed snapshot={snapshot([
|
||||
message({ id: 'first', text: 'first', timestamp: '2026-08-08T07:42:00.000Z' }),
|
||||
message({ id: 'second', text: 'second', timestamp: '2026-08-08T09:15:00.000Z' }),
|
||||
])} />)
|
||||
|
||||
const times = ['first', 'second'].map((id) => (
|
||||
screen.getByTestId(`agent-teams-message-${id}`).querySelector('time')?.textContent
|
||||
))
|
||||
expect(times[0]).toBeTruthy()
|
||||
expect(times[0]).not.toBe(times[1])
|
||||
expect(screen.queryByTestId('agent-teams-time-range')).toBeNull()
|
||||
expect(screen.queryByTestId('agent-teams-time-early')).toBeNull()
|
||||
expect(screen.queryByTestId('agent-teams-time-middle')).toBeNull()
|
||||
expect(screen.queryByTestId('agent-teams-time-late')).toBeNull()
|
||||
expect(screen.queryByTestId('agent-teams-lifecycle-toggle')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps route figures available when archived member metadata is missing', () => {
|
||||
render(<AgentTeamsCommunicationFeed snapshot={snapshot([
|
||||
message({ id: 'unknown', from: 'unknown-builder', to: 'unknown-reviewer' }),
|
||||
], [])} />)
|
||||
|
||||
expect(screen.getByTestId('agent-teams-message-unknown-from-avatar').getAttribute('data-avatar-key')).toBeTruthy()
|
||||
expect(screen.getByTestId('agent-teams-message-unknown-to-avatar').getAttribute('data-avatar-key')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,527 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ArrowRight, Megaphone, MessageSquare, Settings2 } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||
import { useTranslation, type TranslationKey } from '../../i18n'
|
||||
import type { TeamMember, TeamWorkbenchMessage, TeamWorkbenchSnapshot } from '../../types/team'
|
||||
import { MEMBER_AVATARS, memberAccentColor } from './agentTeamsAvatars'
|
||||
import {
|
||||
formatWorkbenchMessageTime,
|
||||
getMemberAvatarKey,
|
||||
parseWorkbenchMessageBody,
|
||||
resolveTeamMemberIdentity,
|
||||
type MemberAvatarKey,
|
||||
type WorkbenchMessageBody,
|
||||
} from './agentTeamsModel'
|
||||
|
||||
type TranslationFn = ReturnType<typeof useTranslation>
|
||||
type FeedCategory = 'assignment' | 'peer' | 'report' | 'system'
|
||||
type FeedFilter = 'all' | FeedCategory
|
||||
|
||||
/** Beyond this the body collapses behind a "show more" toggle. */
|
||||
const COLLAPSED_BODY_CHARS = 260
|
||||
|
||||
type ParsedMessageRow = {
|
||||
message: TeamWorkbenchMessage
|
||||
body: WorkbenchMessageBody
|
||||
category: FeedCategory
|
||||
}
|
||||
|
||||
type MessageParticipant = {
|
||||
member: TeamMember
|
||||
avatarKey: MemberAvatarKey
|
||||
accent: string
|
||||
}
|
||||
|
||||
function resolveParticipant(
|
||||
value: string,
|
||||
snapshot: TeamWorkbenchSnapshot,
|
||||
fallbackIndex: number,
|
||||
): MessageParticipant {
|
||||
const { member, isLead } = resolveTeamMemberIdentity(snapshot.team, value)
|
||||
|
||||
return {
|
||||
member,
|
||||
avatarKey: getMemberAvatarKey(member, isLead),
|
||||
accent: memberAccentColor(member.color, fallbackIndex),
|
||||
}
|
||||
}
|
||||
|
||||
function isLeadIdentity(value: string, snapshot: TeamWorkbenchSnapshot): boolean {
|
||||
if (!value || value === '*') return false
|
||||
return resolveTeamMemberIdentity(snapshot.team, value).isLead
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI transport kinds describe how an envelope moved, not what the team was
|
||||
* doing. Derive the v2 communication groups from parsed protocol bodies and
|
||||
* the actual route while accepting both bare names and `name@team` ids.
|
||||
*/
|
||||
function classifyMessage(
|
||||
message: TeamWorkbenchMessage,
|
||||
body: WorkbenchMessageBody,
|
||||
snapshot: TeamWorkbenchSnapshot,
|
||||
): FeedCategory {
|
||||
if (body.kind === 'assignment') return body.selfClaim ? 'system' : 'assignment'
|
||||
if (body.kind === 'lifecycle') return 'system'
|
||||
if (message.protocolType || message.kind === 'system') return 'system'
|
||||
|
||||
const senderIsLead = isLeadIdentity(message.from, snapshot)
|
||||
if (message.kind === 'broadcast') return senderIsLead ? 'assignment' : 'report'
|
||||
|
||||
const recipients = Array.from(new Set([message.to, ...message.recipients])).filter(Boolean)
|
||||
const targetsLead = recipients.some((recipient) => isLeadIdentity(recipient, snapshot))
|
||||
const targetsMember = recipients.some((recipient) => !isLeadIdentity(recipient, snapshot))
|
||||
|
||||
if (senderIsLead && targetsMember) return 'assignment'
|
||||
if (!senderIsLead && targetsLead) return 'report'
|
||||
if (message.kind === 'direct') return 'peer'
|
||||
return 'system'
|
||||
}
|
||||
|
||||
function transportIcon(kind: TeamWorkbenchMessage['kind']): LucideIcon {
|
||||
if (kind === 'broadcast') return Megaphone
|
||||
if (kind === 'direct') return MessageSquare
|
||||
return Settings2
|
||||
}
|
||||
|
||||
function categoryLabel(category: FeedCategory, t: TranslationFn): string {
|
||||
if (category === 'assignment') return t('agentTeams.communication.assignment')
|
||||
if (category === 'peer') return t('agentTeams.communication.peer')
|
||||
if (category === 'report') return t('agentTeams.communication.report')
|
||||
return t('agentTeams.communication.system')
|
||||
}
|
||||
|
||||
function categoryTagClass(category: FeedCategory): string {
|
||||
if (category === 'assignment') {
|
||||
return 'border-[var(--color-primary-fixed-dim)] bg-[var(--color-brand-soft)] text-[var(--color-on-brand-soft)]'
|
||||
}
|
||||
if (category === 'peer') {
|
||||
return 'border-[var(--color-memory-border)] bg-[var(--color-tertiary-container)] text-[var(--color-tertiary)]'
|
||||
}
|
||||
if (category === 'system') {
|
||||
return 'border-[var(--color-success)] bg-[var(--color-success-container)] text-[var(--color-on-success-container)]'
|
||||
}
|
||||
return 'border-[var(--color-outline)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-secondary)]'
|
||||
}
|
||||
|
||||
function categoryAccent(category: FeedCategory): string {
|
||||
if (category === 'assignment') return 'var(--color-brand)'
|
||||
if (category === 'peer') return 'var(--color-tertiary)'
|
||||
if (category === 'system') return 'var(--color-success)'
|
||||
return 'var(--color-text-secondary)'
|
||||
}
|
||||
|
||||
function lifecycleNarration(
|
||||
body: Extract<WorkbenchMessageBody, { kind: 'lifecycle' }>,
|
||||
t: TranslationFn,
|
||||
): string {
|
||||
const narration = t(`agentTeams.communication.lifecycle.${body.type}` as TranslationKey)
|
||||
return body.detail ? `${narration} · ${body.detail}` : narration
|
||||
}
|
||||
|
||||
/** Picking work up and being handed it are different events, so they read differently. */
|
||||
function assignmentNarration(
|
||||
body: Extract<WorkbenchMessageBody, { kind: 'assignment' }>,
|
||||
message: TeamWorkbenchMessage,
|
||||
t: TranslationFn,
|
||||
): string {
|
||||
const taskId = body.taskId ?? message.taskId
|
||||
return t(
|
||||
body.selfClaim
|
||||
? 'agentTeams.communication.taskClaimed'
|
||||
: 'agentTeams.communication.taskAssignment',
|
||||
{
|
||||
task: taskId ? `#${taskId}` : '',
|
||||
subject: body.subject ?? '',
|
||||
name: body.selfClaim ? message.from : (message.recipients[0] ?? message.to),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function readableSystemText(
|
||||
body: Extract<WorkbenchMessageBody, { kind: 'text' }>,
|
||||
t: TranslationFn,
|
||||
): string {
|
||||
const text = body.text.trim()
|
||||
if (!text.startsWith('{') || !text.endsWith('}')) return text
|
||||
return t('agentTeams.communication.system')
|
||||
}
|
||||
|
||||
export function AgentTeamsCommunicationFeed({
|
||||
snapshot,
|
||||
fill = false,
|
||||
onFocusTask,
|
||||
}: {
|
||||
snapshot: TeamWorkbenchSnapshot
|
||||
/** Full-height layouts let the feed own its column instead of a fixed strip. */
|
||||
fill?: boolean
|
||||
/** Lights up the task a row is about on the map, reusing its focus channel. */
|
||||
onFocusTask?: (taskId: string | null) => void
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const [filter, setFilter] = useState<FeedFilter>('all')
|
||||
|
||||
const rows = useMemo(() => snapshot.messages
|
||||
.map((message): ParsedMessageRow => {
|
||||
const body = parseWorkbenchMessageBody(message)
|
||||
return {
|
||||
message,
|
||||
body,
|
||||
category: classifyMessage(message, body, snapshot),
|
||||
}
|
||||
})
|
||||
.reverse(), [snapshot])
|
||||
const categoryCounts = useMemo(() => rows.reduce<Record<FeedCategory, number>>(
|
||||
(counts, row) => {
|
||||
counts[row.category] += 1
|
||||
return counts
|
||||
},
|
||||
{ assignment: 0, peer: 0, report: 0, system: 0 },
|
||||
), [rows])
|
||||
const filters: Array<{ key: FeedFilter; label: string; count: number }> = [
|
||||
{ key: 'all', label: t('agentTeams.communication.all'), count: rows.length },
|
||||
{ key: 'assignment', label: categoryLabel('assignment', t), count: categoryCounts.assignment },
|
||||
{ key: 'peer', label: categoryLabel('peer', t), count: categoryCounts.peer },
|
||||
{ key: 'report', label: categoryLabel('report', t), count: categoryCounts.report },
|
||||
{ key: 'system', label: categoryLabel('system', t), count: categoryCounts.system },
|
||||
]
|
||||
const filteredRows = filter === 'all'
|
||||
? rows
|
||||
: rows.filter(({ category }) => category === filter)
|
||||
|
||||
// A teammate waiting for work repeats the same idle notice every few seconds.
|
||||
// Keep the newest signal and fold adjacent duplicates into its repeat count.
|
||||
const visibleRows = useMemo(() => {
|
||||
const collapsed: Array<ParsedMessageRow & { repeats: number }> = []
|
||||
for (const row of filteredRows) {
|
||||
const previous = collapsed[collapsed.length - 1]
|
||||
if (
|
||||
previous &&
|
||||
previous.body.kind === 'lifecycle' &&
|
||||
row.body.kind === 'lifecycle' &&
|
||||
previous.body.type === row.body.type &&
|
||||
previous.message.from === row.message.from
|
||||
) {
|
||||
previous.repeats += 1
|
||||
continue
|
||||
}
|
||||
collapsed.push({ ...row, repeats: 1 })
|
||||
}
|
||||
return collapsed
|
||||
}, [filteredRows])
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="agent-teams-communication"
|
||||
className={[
|
||||
'flex min-h-0 flex-col border-[var(--color-border)] bg-[var(--color-surface)]',
|
||||
fill
|
||||
? 'h-full w-full min-w-0'
|
||||
: 'h-[240px] shrink-0 border-t',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="shrink-0 border-b border-[var(--color-border)] px-3.5 py-3">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<h3 className="text-[15px] font-extrabold tracking-[-0.2px]">
|
||||
{t('agentTeams.communication.title')}
|
||||
</h3>
|
||||
<span
|
||||
data-testid="agent-teams-message-count"
|
||||
className="font-mono text-[12px] tabular-nums text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{t('agentTeams.communication.count', { count: rows.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('agentTeams.communication.title')}
|
||||
className="mt-2.5 flex min-w-0 flex-wrap items-center gap-1.5"
|
||||
>
|
||||
{filters.map((option) => {
|
||||
const selected = filter === option.key
|
||||
return (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
data-testid={`agent-teams-filter-${option.key}`}
|
||||
data-count={option.count}
|
||||
aria-pressed={selected}
|
||||
onClick={() => setFilter(option.key)}
|
||||
className={[
|
||||
'inline-flex shrink-0 items-center rounded-full border px-2.5 py-1 text-[11px] font-bold outline-none transition-[transform,background-color,border-color,color] duration-200 focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] active:scale-[0.98]',
|
||||
selected
|
||||
? 'border-[var(--color-text-primary)] bg-[var(--color-text-primary)] text-[var(--color-surface)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]',
|
||||
].join(' ')}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{visibleRows.length === 0 ? (
|
||||
<div className="flex min-h-28 items-center justify-center px-6 text-center text-[11px] leading-5 text-[var(--color-text-tertiary)]">
|
||||
{t('agentTeams.communication.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--color-border)]">
|
||||
{visibleRows.map(({ message, body, category, repeats }, index) => (
|
||||
<FeedRow
|
||||
key={message.id}
|
||||
message={message}
|
||||
body={body}
|
||||
category={category}
|
||||
repeats={repeats}
|
||||
isLatest={index === 0}
|
||||
snapshot={snapshot}
|
||||
onFocusTask={onFocusTask}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedRow({
|
||||
message,
|
||||
body,
|
||||
category,
|
||||
repeats,
|
||||
isLatest,
|
||||
snapshot,
|
||||
onFocusTask,
|
||||
t,
|
||||
}: {
|
||||
message: TeamWorkbenchMessage
|
||||
body: WorkbenchMessageBody
|
||||
category: FeedCategory
|
||||
/** How many identical signals in a row this one stands for. */
|
||||
repeats: number
|
||||
isLatest: boolean
|
||||
snapshot: TeamWorkbenchSnapshot
|
||||
onFocusTask?: (taskId: string | null) => void
|
||||
t: TranslationFn
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const focusedTaskId = (body.kind === 'assignment' ? body.taskId : undefined) ?? message.taskId
|
||||
const time = formatWorkbenchMessageTime(message.timestamp)
|
||||
const text = body.kind === 'lifecycle'
|
||||
? lifecycleNarration(body, t)
|
||||
: body.kind === 'assignment'
|
||||
? assignmentNarration(body, message, t)
|
||||
: category === 'system'
|
||||
? readableSystemText(body, t)
|
||||
: body.text
|
||||
const compact = body.kind !== 'text' || category === 'system'
|
||||
const rowProps = {
|
||||
'data-testid': `agent-teams-message-${message.id}`,
|
||||
'data-message-body': body.kind,
|
||||
'data-message-kind': message.kind,
|
||||
'data-message-category': category,
|
||||
onMouseEnter: focusedTaskId ? () => onFocusTask?.(focusedTaskId) : undefined,
|
||||
onMouseLeave: focusedTaskId ? () => onFocusTask?.(null) : undefined,
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<article
|
||||
{...rowProps}
|
||||
className="bg-[var(--color-surface-container-low)] px-3.5 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="h-1.5 w-1.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: categoryAccent(category) }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className="shrink-0 text-[9.5px] font-extrabold uppercase tracking-[0.08em]"
|
||||
style={{ color: categoryAccent(category) }}
|
||||
>
|
||||
{categoryLabel(category, t)}
|
||||
</span>
|
||||
<p
|
||||
data-testid={`agent-teams-message-${message.id}-body`}
|
||||
className="min-w-0 flex-1 truncate text-[11.5px] leading-[1.4] text-[var(--color-text-secondary)]"
|
||||
title={text}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
{message.taskId ? (
|
||||
<span className="shrink-0 rounded-full bg-[var(--color-surface-container-high)] px-1.5 py-0.5 font-mono text-[9px] text-[var(--color-text-tertiary)]">
|
||||
#{message.taskId}
|
||||
</span>
|
||||
) : null}
|
||||
{repeats > 1 ? (
|
||||
<span
|
||||
data-testid={`agent-teams-message-${message.id}-repeats`}
|
||||
className="shrink-0 rounded-full bg-[var(--color-surface-container-high)] px-1.5 py-0.5 font-mono text-[9px] tabular-nums text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
×{repeats}
|
||||
</span>
|
||||
) : null}
|
||||
<time
|
||||
dateTime={message.timestamp}
|
||||
className="shrink-0 font-mono text-[9px] tabular-nums text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{time}
|
||||
</time>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
const isTruncatable = text.length > COLLAPSED_BODY_CHARS
|
||||
const collapsed = isTruncatable && !expanded
|
||||
const recipient = message.kind === 'broadcast'
|
||||
? t('agentTeams.communication.everyone')
|
||||
: message.to
|
||||
const senderParticipant = resolveParticipant(message.from, snapshot, 0)
|
||||
const recipientParticipant = message.kind === 'direct'
|
||||
? resolveParticipant(message.to, snapshot, 1)
|
||||
: null
|
||||
const Icon = transportIcon(message.kind)
|
||||
|
||||
return (
|
||||
<article {...rowProps} className="relative px-3.5 py-3">
|
||||
{isLatest ? (
|
||||
<span className="absolute bottom-3 left-0 top-3 w-0.5 rounded-r-full bg-[var(--color-brand)]" aria-hidden="true" />
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={[
|
||||
'inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-[9.5px] font-extrabold uppercase tracking-[0.08em]',
|
||||
categoryTagClass(category),
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon size={12} strokeWidth={2} aria-hidden="true" />
|
||||
{categoryLabel(category, t)}
|
||||
</span>
|
||||
{message.taskId ? (
|
||||
<span className="shrink-0 rounded-full bg-[var(--color-surface-container-high)] px-1.5 py-0.5 font-mono text-[9px] text-[var(--color-text-tertiary)]">
|
||||
#{message.taskId}
|
||||
</span>
|
||||
) : null}
|
||||
<time
|
||||
dateTime={message.timestamp}
|
||||
className="ml-auto shrink-0 font-mono text-[9px] tabular-nums text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{time}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-testid={`agent-teams-message-${message.id}-route`}
|
||||
className="mt-1.5 flex min-w-0 items-center gap-1.5"
|
||||
>
|
||||
<ParticipantFigure
|
||||
participant={senderParticipant}
|
||||
name={message.from}
|
||||
nameTestId={`agent-teams-message-${message.id}-from`}
|
||||
avatarTestId={`agent-teams-message-${message.id}-from-avatar`}
|
||||
/>
|
||||
<ArrowRight size={12} strokeWidth={2} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
|
||||
{recipientParticipant ? (
|
||||
<ParticipantFigure
|
||||
participant={recipientParticipant}
|
||||
name={recipient}
|
||||
nameTestId={`agent-teams-message-${message.id}-to`}
|
||||
avatarTestId={`agent-teams-message-${message.id}-to-avatar`}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
data-testid={`agent-teams-message-${message.id}-to`}
|
||||
className="inline-flex min-w-0 flex-1 items-center gap-1.5"
|
||||
title={recipient}
|
||||
>
|
||||
<span className="inline-flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-brand)]">
|
||||
<Megaphone size={11} strokeWidth={2} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-mono text-[10px] font-semibold text-[var(--color-text-secondary)]">
|
||||
{recipient}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-testid={`agent-teams-message-${message.id}-body`}
|
||||
data-collapsed={collapsed ? 'true' : 'false'}
|
||||
className="relative mt-[7px] text-[12.5px] leading-[1.55]"
|
||||
>
|
||||
<div className={collapsed ? 'max-h-[148px] overflow-hidden' : ''}>
|
||||
<MarkdownRenderer
|
||||
content={text}
|
||||
variant="compact"
|
||||
className="prose-p:first:mt-0 prose-p:last:mb-0 prose-p:text-[12.5px] prose-p:leading-[1.55] prose-li:text-[12.5px] prose-li:leading-[1.55] prose-headings:first:mt-0 prose-headings:text-[var(--color-text-primary)] prose-code:text-[11px]"
|
||||
/>
|
||||
</div>
|
||||
{collapsed ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-12 bg-gradient-to-b from-transparent to-[var(--color-surface)]"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isTruncatable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
className="relative mt-2 rounded-[var(--radius-sm)] text-[10px] font-semibold text-[var(--color-brand)] outline-none transition-[transform,color] duration-200 hover:underline focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] active:scale-[0.98]"
|
||||
>
|
||||
{t(expanded ? 'agentTeams.communication.collapse' : 'agentTeams.communication.expand')}
|
||||
</button>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function ParticipantFigure({
|
||||
participant,
|
||||
name,
|
||||
nameTestId,
|
||||
avatarTestId,
|
||||
}: {
|
||||
participant: MessageParticipant
|
||||
name: string
|
||||
nameTestId: string
|
||||
avatarTestId: string
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex min-w-0 flex-1 items-center gap-1.5" title={name}>
|
||||
<span
|
||||
data-testid={avatarTestId}
|
||||
data-avatar-key={participant.avatarKey}
|
||||
aria-hidden="true"
|
||||
className="relative h-[22px] w-[22px] shrink-0"
|
||||
>
|
||||
<img
|
||||
src={MEMBER_AVATARS[participant.avatarKey]}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="h-full w-full select-none object-contain drop-shadow-[0_1px_1px_rgba(0,0,0,0.14)]"
|
||||
/>
|
||||
<span
|
||||
className="absolute bottom-0 left-1/2 h-0.5 w-3 -translate-x-1/2 rounded-full border border-[var(--color-surface)]"
|
||||
style={{ background: participant.accent }}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
data-testid={nameTestId}
|
||||
className="min-w-0 truncate font-mono text-[10px] font-bold text-[var(--color-text-secondary)]"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AgentTeamsMemberInspector } from '@/components/agentTeams/AgentTeamsMemberInspector'
|
||||
import { formatWorkbenchMessageTime } from '@/components/agentTeams/agentTeamsModel'
|
||||
import { useSettingsStore } from '@/stores/settingsStore'
|
||||
import type {
|
||||
TeamMember,
|
||||
TeamWorkbenchMessage,
|
||||
TeamWorkbenchSnapshot,
|
||||
TeamWorkbenchTask,
|
||||
} from '@/types/team'
|
||||
|
||||
const builder: TeamMember = {
|
||||
agentId: 'builder@team-a',
|
||||
name: 'builder',
|
||||
role: 'server engineer',
|
||||
status: 'running',
|
||||
activity: 'idle',
|
||||
}
|
||||
|
||||
const reviewer: TeamMember = {
|
||||
agentId: 'reviewer@team-a',
|
||||
name: 'reviewer',
|
||||
role: 'qa engineer',
|
||||
status: 'running',
|
||||
activity: 'active',
|
||||
}
|
||||
|
||||
function task(status: TeamWorkbenchTask['status']): TeamWorkbenchTask {
|
||||
return {
|
||||
id: '7',
|
||||
subject: 'Build API routes',
|
||||
description: '',
|
||||
owner: 'builder',
|
||||
status,
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
taskListId: 'team-a',
|
||||
}
|
||||
}
|
||||
|
||||
function message(overrides: Partial<TeamWorkbenchMessage> & { id: string }): TeamWorkbenchMessage {
|
||||
return {
|
||||
from: 'builder',
|
||||
to: 'reviewer',
|
||||
recipients: ['reviewer'],
|
||||
kind: 'direct',
|
||||
text: 'body',
|
||||
timestamp: '2026-08-08T07:10:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(
|
||||
generatedAt: string,
|
||||
status: TeamWorkbenchTask['status'],
|
||||
messages: TeamWorkbenchMessage[] = [],
|
||||
): TeamWorkbenchSnapshot {
|
||||
return {
|
||||
version: 'v1',
|
||||
generatedAt,
|
||||
team: {
|
||||
name: 'team-a',
|
||||
leadAgentId: 'lead@team-a',
|
||||
leadSessionId: 'lead-session',
|
||||
members: [builder, reviewer],
|
||||
},
|
||||
tasks: [task(status)],
|
||||
messages,
|
||||
}
|
||||
}
|
||||
|
||||
function renderInspector(options: {
|
||||
snapshots?: TeamWorkbenchSnapshot[]
|
||||
selectedIndex?: number
|
||||
snapshot?: TeamWorkbenchSnapshot
|
||||
onBack?: () => void
|
||||
onClose?: () => void
|
||||
onOpenExecution?: () => void
|
||||
} = {}) {
|
||||
const snapshots = options.snapshots ?? [snapshot('2026-08-08T07:00:00.000Z', 'pending')]
|
||||
const selectedIndex = options.selectedIndex ?? snapshots.length - 1
|
||||
const selected = options.snapshot ?? snapshots[selectedIndex]!
|
||||
return render(
|
||||
<AgentTeamsMemberInspector
|
||||
snapshots={snapshots}
|
||||
selectedIndex={selectedIndex}
|
||||
snapshot={selected}
|
||||
member={builder}
|
||||
isLead={false}
|
||||
leadIsStreaming={false}
|
||||
onBack={options.onBack ?? vi.fn()}
|
||||
onClose={options.onClose ?? vi.fn()}
|
||||
onOpenExecution={options.onOpenExecution ?? vi.fn()}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('AgentTeamsMemberInspector', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
})
|
||||
|
||||
it('derives the first start and elapsed duration from task state transitions', () => {
|
||||
const snapshots = [
|
||||
snapshot('2026-08-08T07:00:00.000Z', 'pending'),
|
||||
snapshot('2026-08-08T07:05:00.000Z', 'in_progress'),
|
||||
snapshot('2026-08-08T07:12:00.000Z', 'completed'),
|
||||
]
|
||||
renderInspector({ snapshots })
|
||||
|
||||
const row = screen.getByTestId('agent-teams-member-task-7')
|
||||
const expectedStart = formatWorkbenchMessageTime('2026-08-08T07:05:00.000Z')
|
||||
expect(row.textContent).toContain('#7')
|
||||
expect(row.textContent).toContain('Build API routes')
|
||||
expect(row.textContent).toContain('Completed')
|
||||
expect(row.textContent).toContain(`${expectedStart} +7:00`)
|
||||
expect(row.getAttribute('data-task-state')).toBe('completed')
|
||||
expect(screen.getByText('1', { selector: 'dd' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows message direction, renders human Markdown, and narrates protocol payloads', () => {
|
||||
const messages = [
|
||||
message({
|
||||
id: 'human',
|
||||
text: '## Finding A\n\n**Key evidence**',
|
||||
}),
|
||||
message({
|
||||
id: 'protocol',
|
||||
from: 'reviewer',
|
||||
to: 'builder',
|
||||
recipients: ['builder'],
|
||||
kind: 'system',
|
||||
protocolType: 'idle_notification',
|
||||
text: '{"type":"idle_notification","idleReason":"available"}',
|
||||
timestamp: '2026-08-08T07:11:00.000Z',
|
||||
}),
|
||||
]
|
||||
const selected = snapshot('2026-08-08T07:12:00.000Z', 'in_progress', messages)
|
||||
renderInspector({ snapshots: [selected] })
|
||||
|
||||
const human = screen.getByTestId('agent-teams-member-message-human')
|
||||
expect(human.getAttribute('data-message-direction')).toBe('sent')
|
||||
expect(human.textContent).toContain('Sent')
|
||||
expect(human.textContent).toContain('reviewer')
|
||||
expect(human.querySelector('h2')?.textContent).toBe('Finding A')
|
||||
expect(human.querySelector('strong')?.textContent).toBe('Key evidence')
|
||||
|
||||
const protocol = screen.getByTestId('agent-teams-member-message-protocol')
|
||||
expect(protocol.getAttribute('data-message-direction')).toBe('received')
|
||||
expect(protocol.getAttribute('data-message-body')).toBe('lifecycle')
|
||||
expect(protocol.className).toContain('color-tertiary')
|
||||
expect(protocol.textContent).toContain('Received')
|
||||
expect(protocol.textContent).toContain('Went idle, waiting for work · available')
|
||||
expect(protocol.textContent).not.toContain('idle_notification')
|
||||
})
|
||||
|
||||
it('calls back for the feed, close control, and explicit execution handoff', () => {
|
||||
const onBack = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const onOpenExecution = vi.fn()
|
||||
renderInspector({ onBack, onClose, onOpenExecution })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back to communication' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close communication panel' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View builder execution' }))
|
||||
|
||||
expect(onBack).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onOpenExecution).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,524 @@
|
||||
import { useId, useMemo } from 'react'
|
||||
import { ArrowLeft, ArrowRight, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer'
|
||||
import { MEMBER_AVATARS, memberAccentColor } from '@/components/agentTeams/agentTeamsAvatars'
|
||||
import {
|
||||
formatWorkbenchMessageTime,
|
||||
getMemberAvatarKey,
|
||||
getMemberWorkState,
|
||||
getWorkbenchTaskState,
|
||||
inferTaskOwner,
|
||||
parseWorkbenchMessageBody,
|
||||
resolveTeamMemberIdentity,
|
||||
taskOwnedByMember,
|
||||
type MemberWorkState,
|
||||
type WorkbenchMessageBody,
|
||||
type WorkbenchTaskState,
|
||||
} from '@/components/agentTeams/agentTeamsModel'
|
||||
import { Badge, StatusDot, type Tone } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { IconButton } from '@/components/ui/IconButton'
|
||||
import { useTranslation, type TranslationKey } from '@/i18n'
|
||||
import type {
|
||||
TeamMember,
|
||||
TeamWorkbenchMessage,
|
||||
TeamWorkbenchSnapshot,
|
||||
TeamWorkbenchTask,
|
||||
} from '@/types/team'
|
||||
|
||||
export type AgentTeamsMemberInspectorProps = {
|
||||
snapshots: TeamWorkbenchSnapshot[]
|
||||
selectedIndex: number
|
||||
snapshot: TeamWorkbenchSnapshot
|
||||
member: TeamMember
|
||||
isLead: boolean
|
||||
leadIsStreaming: boolean
|
||||
onBack: () => void
|
||||
onClose: () => void
|
||||
onOpenExecution: () => void
|
||||
}
|
||||
|
||||
type TranslationFn = ReturnType<typeof useTranslation>
|
||||
|
||||
type TaskHistoryEntry = {
|
||||
task: TeamWorkbenchTask
|
||||
state: WorkbenchTaskState
|
||||
startedAt: number | null
|
||||
durationMs: number | null
|
||||
}
|
||||
|
||||
type MemberMessage = {
|
||||
message: TeamWorkbenchMessage
|
||||
body: WorkbenchMessageBody
|
||||
direction: 'sent' | 'received'
|
||||
peerName: string
|
||||
}
|
||||
|
||||
function timestampMs(value: string | undefined): number | null {
|
||||
if (!value) return null
|
||||
const numeric = /^\d+$/.test(value) ? Number(value) : Number.NaN
|
||||
const parsed = Number.isFinite(numeric) ? numeric : Date.parse(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function identityAliases(value: string | undefined): string[] {
|
||||
const normalized = value?.trim().toLowerCase()
|
||||
if (!normalized) return []
|
||||
const short = normalized.split('@')[0] ?? normalized
|
||||
return normalized === short ? [normalized] : [normalized, short]
|
||||
}
|
||||
|
||||
function memberAliases(member: TeamMember): string[] {
|
||||
return [
|
||||
...identityAliases(member.agentId),
|
||||
...identityAliases(member.name),
|
||||
]
|
||||
}
|
||||
|
||||
function memberMatchesIdentity(member: TeamMember, identity: string | undefined): boolean {
|
||||
const aliases = identityAliases(identity)
|
||||
return aliases.some(alias => memberAliases(member).includes(alias))
|
||||
}
|
||||
|
||||
function memberName(member: TeamMember): string {
|
||||
return member.name || member.role || member.agentId.split('@')[0] || member.agentId
|
||||
}
|
||||
|
||||
function taskBelongsToMember(
|
||||
task: TeamWorkbenchTask,
|
||||
member: TeamMember,
|
||||
snapshot: TeamWorkbenchSnapshot,
|
||||
): boolean {
|
||||
if (taskOwnedByMember(task, member)) return true
|
||||
const attribution = inferTaskOwner(task, snapshot)
|
||||
return attribution ? memberMatchesIdentity(member, attribution.identity) : false
|
||||
}
|
||||
|
||||
function visibleSnapshots(
|
||||
snapshots: TeamWorkbenchSnapshot[],
|
||||
selectedIndex: number,
|
||||
snapshot: TeamWorkbenchSnapshot,
|
||||
): TeamWorkbenchSnapshot[] {
|
||||
if (snapshots.length === 0) return [snapshot]
|
||||
const clampedIndex = Math.max(0, Math.min(selectedIndex, snapshots.length - 1))
|
||||
const visible = snapshots.slice(0, clampedIndex + 1)
|
||||
// `snapshotWithHistoricalMembers` returns a reconstructed object. Keep that
|
||||
// roster while retaining the original earlier frames used for timing.
|
||||
visible[visible.length - 1] = snapshot
|
||||
return visible
|
||||
}
|
||||
|
||||
function deriveTaskHistory(
|
||||
snapshots: TeamWorkbenchSnapshot[],
|
||||
selectedIndex: number,
|
||||
snapshot: TeamWorkbenchSnapshot,
|
||||
member: TeamMember,
|
||||
): TaskHistoryEntry[] {
|
||||
type MutableTaskHistory = {
|
||||
task: TeamWorkbenchTask
|
||||
startedAt: number | null
|
||||
completedAt: number | null
|
||||
}
|
||||
|
||||
const history = new Map<string, MutableTaskHistory>()
|
||||
const frames = visibleSnapshots(snapshots, selectedIndex, snapshot)
|
||||
|
||||
frames.forEach((frame) => {
|
||||
const frameTime = timestampMs(frame.generatedAt)
|
||||
frame.tasks.forEach((task) => {
|
||||
if (!taskBelongsToMember(task, member, frame)) return
|
||||
const entry = history.get(task.id) ?? {
|
||||
task,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
}
|
||||
entry.task = task
|
||||
if (entry.startedAt === null && task.status !== 'pending') entry.startedAt = frameTime
|
||||
if (entry.completedAt === null && task.status === 'completed') entry.completedAt = frameTime
|
||||
history.set(task.id, entry)
|
||||
})
|
||||
})
|
||||
|
||||
const tasksById = new Map(snapshot.tasks.map(task => [task.id, task]))
|
||||
const selectedTime = timestampMs(snapshot.generatedAt)
|
||||
|
||||
return Array.from(history.values())
|
||||
.map((entry): TaskHistoryEntry => {
|
||||
const currentTask = tasksById.get(entry.task.id)
|
||||
const task = currentTask && taskBelongsToMember(currentTask, member, snapshot)
|
||||
? currentTask
|
||||
: entry.task
|
||||
const durationEnd = entry.completedAt ?? selectedTime
|
||||
const durationMs = entry.startedAt !== null && durationEnd !== null
|
||||
? Math.max(0, durationEnd - entry.startedAt)
|
||||
: null
|
||||
return {
|
||||
task,
|
||||
state: getWorkbenchTaskState(task, tasksById),
|
||||
startedAt: entry.startedAt,
|
||||
durationMs,
|
||||
}
|
||||
})
|
||||
.sort((left, right) => {
|
||||
// A task authored up-front is merely future work until it actually
|
||||
// leaves `pending`; do not place it before work the member already ran.
|
||||
const leftTime = left.startedAt ?? Number.MAX_SAFE_INTEGER
|
||||
const rightTime = right.startedAt ?? Number.MAX_SAFE_INTEGER
|
||||
return leftTime - rightTime || left.task.id.localeCompare(right.task.id, undefined, { numeric: true })
|
||||
})
|
||||
}
|
||||
|
||||
function formatDuration(durationMs: number): string {
|
||||
const seconds = Math.max(0, Math.round(durationMs / 1000))
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatTaskSpan(entry: TaskHistoryEntry): string {
|
||||
if (entry.startedAt === null || entry.durationMs === null) return '—'
|
||||
return `${formatWorkbenchMessageTime(new Date(entry.startedAt).toISOString())} +${formatDuration(entry.durationMs)}`
|
||||
}
|
||||
|
||||
function taskTone(state: WorkbenchTaskState): Tone {
|
||||
if (state === 'running') return 'brand'
|
||||
if (state === 'completed') return 'success'
|
||||
if (state === 'open') return 'warning'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
function memberTone(state: MemberWorkState): Tone {
|
||||
if (state === 'working') return 'brand'
|
||||
if (state === 'error') return 'danger'
|
||||
if (state === 'exited' || state === 'stopped') return 'neutral'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function leadStatusLabel(snapshot: TeamWorkbenchSnapshot, t: TranslationFn): string {
|
||||
if (snapshot.deletedAt) return t('agentTeams.lead.archived')
|
||||
if (snapshot.tasks.length === 0) return t('agentTeams.lead.forming')
|
||||
if (snapshot.tasks.every(task => task.status === 'completed')) {
|
||||
return t('agentTeams.lead.finishing')
|
||||
}
|
||||
return t('agentTeams.lead.coordinating')
|
||||
}
|
||||
|
||||
function relatedToMember(message: TeamWorkbenchMessage, member: TeamMember): boolean {
|
||||
return memberMatchesIdentity(member, message.from)
|
||||
|| memberMatchesIdentity(member, message.to)
|
||||
|| message.recipients.some(recipient => memberMatchesIdentity(member, recipient))
|
||||
|| message.to === '*'
|
||||
}
|
||||
|
||||
function displayIdentity(
|
||||
identity: string,
|
||||
snapshot: TeamWorkbenchSnapshot,
|
||||
t: TranslationFn,
|
||||
): string {
|
||||
if (identity === '*') return t('agentTeams.communication.everyone')
|
||||
return memberName(resolveTeamMemberIdentity(snapshot.team, identity).member)
|
||||
}
|
||||
|
||||
function deriveMemberMessages(
|
||||
snapshot: TeamWorkbenchSnapshot,
|
||||
member: TeamMember,
|
||||
t: TranslationFn,
|
||||
): MemberMessage[] {
|
||||
return snapshot.messages
|
||||
.filter(message => relatedToMember(message, member))
|
||||
.map((message): MemberMessage => {
|
||||
const direction = memberMatchesIdentity(member, message.from) ? 'sent' : 'received'
|
||||
const recipient = message.to === '*'
|
||||
? '*'
|
||||
: message.recipients[0] ?? message.to
|
||||
return {
|
||||
message,
|
||||
body: parseWorkbenchMessageBody(message),
|
||||
direction,
|
||||
peerName: direction === 'sent'
|
||||
? displayIdentity(recipient, snapshot, t)
|
||||
: displayIdentity(message.from, snapshot, t),
|
||||
}
|
||||
})
|
||||
.reverse()
|
||||
}
|
||||
|
||||
function protocolNarration(
|
||||
row: MemberMessage,
|
||||
snapshot: TeamWorkbenchSnapshot,
|
||||
t: TranslationFn,
|
||||
): string {
|
||||
if (row.body.kind === 'lifecycle') {
|
||||
const label = t(`agentTeams.communication.lifecycle.${row.body.type}` as TranslationKey)
|
||||
return row.body.detail ? `${label} · ${row.body.detail}` : label
|
||||
}
|
||||
if (row.body.kind === 'assignment') {
|
||||
const taskId = row.body.taskId ?? row.message.taskId
|
||||
const name = row.body.selfClaim
|
||||
? displayIdentity(row.message.from, snapshot, t)
|
||||
: displayIdentity(row.message.recipients[0] ?? row.message.to, snapshot, t)
|
||||
return t(
|
||||
row.body.selfClaim
|
||||
? 'agentTeams.communication.taskClaimed'
|
||||
: 'agentTeams.communication.taskAssignment',
|
||||
{
|
||||
task: taskId ? `#${taskId}` : '',
|
||||
subject: row.body.subject ?? '',
|
||||
name,
|
||||
},
|
||||
)
|
||||
}
|
||||
return row.body.text
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-hand transcript drawer for one Agent Teams participant. The board
|
||||
* remains visible; this panel explains the teammate's serial task history and
|
||||
* all communication at the currently selected replay frame.
|
||||
*/
|
||||
export function AgentTeamsMemberInspector({
|
||||
snapshots,
|
||||
selectedIndex,
|
||||
snapshot,
|
||||
member,
|
||||
isLead,
|
||||
leadIsStreaming,
|
||||
onBack,
|
||||
onClose,
|
||||
onOpenExecution,
|
||||
}: AgentTeamsMemberInspectorProps) {
|
||||
const t = useTranslation()
|
||||
const headingId = useId()
|
||||
const name = memberName(member)
|
||||
const memberIndex = Math.max(0, snapshot.team.members.findIndex(candidate => (
|
||||
memberMatchesIdentity(candidate, member.agentId)
|
||||
)))
|
||||
const avatarKey = getMemberAvatarKey(member, isLead)
|
||||
const accent = memberAccentColor(member.color, memberIndex)
|
||||
const workState = snapshot.deletedAt
|
||||
? 'exited'
|
||||
: getMemberWorkState(member, { isLead, leadIsStreaming })
|
||||
const taskHistory = useMemo(
|
||||
() => deriveTaskHistory(snapshots, selectedIndex, snapshot, member),
|
||||
[member, selectedIndex, snapshot, snapshots],
|
||||
)
|
||||
const messages = useMemo(
|
||||
() => deriveMemberMessages(snapshot, member, t),
|
||||
[member, snapshot, t],
|
||||
)
|
||||
const completedTasks = taskHistory.filter(entry => entry.state === 'completed').length
|
||||
const runningTask = taskHistory.find(entry => entry.state === 'running')?.task
|
||||
const blockedTask = taskHistory.find(entry => entry.state === 'blocked')?.task
|
||||
const waitingDependency = blockedTask?.blockedBy.find(dependencyId => (
|
||||
snapshot.tasks.find(task => task.id === dependencyId)?.status !== 'completed'
|
||||
))
|
||||
const currentStatusLabel = isLead
|
||||
? leadStatusLabel(snapshot, t)
|
||||
: workState === 'working' && runningTask
|
||||
? t('agentTeams.member.executingTask', { task: runningTask.id })
|
||||
: workState === 'idle' && taskHistory.length > 0 && completedTasks === taskHistory.length
|
||||
? t('agentTeams.member.waitingForClose')
|
||||
: workState === 'idle' && waitingDependency
|
||||
? t('agentTeams.member.waitingForDependency', { task: waitingDependency })
|
||||
: workState === 'idle'
|
||||
? t('agentTeams.member.waitingForTask')
|
||||
: t(`agentTeams.member.${workState}` as TranslationKey)
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="agent-teams-member-inspector"
|
||||
data-member-id={member.agentId}
|
||||
aria-labelledby={headingId}
|
||||
className="flex h-full min-h-0 w-full flex-col bg-[var(--color-surface)] text-[var(--color-text-primary)]"
|
||||
>
|
||||
<header className="shrink-0 border-b border-[var(--color-border)] px-3.5 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<ChevronLeft aria-hidden="true" />}
|
||||
onClick={onBack}
|
||||
>
|
||||
{t('agentTeams.communication.backToFeed')}
|
||||
</Button>
|
||||
<IconButton
|
||||
icon={<ChevronRight aria-hidden="true" />}
|
||||
label={t('agentTeams.communication.close')}
|
||||
size="sm"
|
||||
tone="muted"
|
||||
bordered
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 flex min-w-0 items-center gap-2.5">
|
||||
<span className="relative h-11 w-11 shrink-0" data-avatar-key={avatarKey}>
|
||||
<img
|
||||
src={MEMBER_AVATARS[avatarKey]}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="h-11 w-11 select-none object-contain drop-shadow-[0_3px_3px_var(--color-border-strong)]"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute bottom-0 left-1/2 h-1.5 w-5 -translate-x-1/2 rounded-full border border-[var(--color-surface)]"
|
||||
style={{ background: accent }}
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 id={headingId} className="truncate font-mono text-[14px] font-extrabold">
|
||||
{name}
|
||||
</h2>
|
||||
<p className="truncate text-[10.5px] text-[var(--color-text-secondary)]">
|
||||
{t('agentTeams.inspector.independentContext', { role: member.role })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="mt-3 grid grid-cols-3 gap-4 text-[11.5px]">
|
||||
<div className="min-w-0">
|
||||
<dt className="text-[10px] font-semibold text-[var(--color-text-tertiary)]">
|
||||
{t('agentTeams.inspector.current')}
|
||||
</dt>
|
||||
<dd className="mt-0.5 flex min-w-0 items-center gap-1.5 font-extrabold">
|
||||
<StatusDot tone={memberTone(workState)} pulse={workState === 'working'} />
|
||||
<span className="truncate">{currentStatusLabel}</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold text-[var(--color-text-tertiary)]">
|
||||
{t('agentTeams.inspector.completedTasks')}
|
||||
</dt>
|
||||
<dd className="mt-0.5 font-extrabold tabular-nums">{completedTasks}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold text-[var(--color-text-tertiary)]">
|
||||
{t('agentTeams.inspector.messages')}
|
||||
</dt>
|
||||
<dd className="mt-0.5 font-extrabold tabular-nums">{messages.length}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<section
|
||||
aria-labelledby={`${headingId}-tasks`}
|
||||
className="border-b border-[var(--color-border)] px-3.5 py-3"
|
||||
>
|
||||
<h3
|
||||
id={`${headingId}-tasks`}
|
||||
className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{t('agentTeams.inspector.taskHistory')}
|
||||
<span className="ml-1 normal-case tracking-normal">
|
||||
· {t('agentTeams.inspector.taskHistoryHint')}
|
||||
</span>
|
||||
</h3>
|
||||
{taskHistory.length > 0 ? (
|
||||
<ol className="mt-2" data-testid="agent-teams-member-task-history">
|
||||
{taskHistory.map(entry => {
|
||||
const span = formatTaskSpan(entry)
|
||||
return (
|
||||
<li
|
||||
key={entry.task.id}
|
||||
data-testid={`agent-teams-member-task-${entry.task.id}`}
|
||||
data-task-state={entry.state}
|
||||
className="flex min-w-0 items-center gap-2 border-b border-[var(--color-border)] py-1.5 last:border-b-0"
|
||||
>
|
||||
<span className="w-[26px] shrink-0 font-mono text-[10px] font-extrabold text-[var(--color-text-tertiary)]">
|
||||
#{entry.task.id}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[11.5px] leading-[1.3]" title={entry.task.subject}>
|
||||
{entry.task.subject}
|
||||
</span>
|
||||
<Badge
|
||||
data-testid={`agent-teams-member-task-${entry.task.id}-state`}
|
||||
tone={taskTone(entry.state)}
|
||||
size="xs"
|
||||
bordered
|
||||
>
|
||||
{t(`agentTeams.task.${entry.state}` as TranslationKey)}
|
||||
</Badge>
|
||||
<time
|
||||
dateTime={entry.startedAt === null ? undefined : new Date(entry.startedAt).toISOString()}
|
||||
className="w-[82px] shrink-0 text-right font-mono text-[9.5px] tabular-nums text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{span}
|
||||
</time>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="mt-2 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('agentTeams.noMemberTasks')}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby={`${headingId}-messages`} className="px-3.5 py-3">
|
||||
<h3
|
||||
id={`${headingId}-messages`}
|
||||
className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{t('agentTeams.inspector.messages')}
|
||||
</h3>
|
||||
{messages.length > 0 ? (
|
||||
<ol className="mt-2" data-testid="agent-teams-member-messages">
|
||||
{messages.map(row => {
|
||||
const sent = row.direction === 'sent'
|
||||
return (
|
||||
<li
|
||||
key={row.message.id}
|
||||
data-testid={`agent-teams-member-message-${row.message.id}`}
|
||||
data-message-direction={row.direction}
|
||||
data-message-body={row.body.kind}
|
||||
className={`mb-2 border-l-2 pb-2 pl-2.5 last:mb-0 ${sent ? 'border-l-[var(--color-brand)]' : 'border-l-[var(--color-tertiary)]'}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className={`shrink-0 text-[9.5px] font-extrabold ${sent ? 'text-[var(--color-brand)]' : 'text-[var(--color-tertiary)]'}`}>
|
||||
{t(sent ? 'agentTeams.inspector.sent' : 'agentTeams.inspector.received')}
|
||||
</span>
|
||||
{sent
|
||||
? <ArrowRight size={11} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
|
||||
: <ArrowLeft size={11} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />}
|
||||
<span className="min-w-0 truncate font-mono text-[10px] text-[var(--color-text-tertiary)]">
|
||||
{row.peerName}
|
||||
</span>
|
||||
<time
|
||||
dateTime={row.message.timestamp}
|
||||
className="ml-auto shrink-0 font-mono text-[10px] tabular-nums text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{formatWorkbenchMessageTime(row.message.timestamp)}
|
||||
</time>
|
||||
</div>
|
||||
{row.body.kind === 'text' ? (
|
||||
<MarkdownRenderer
|
||||
content={row.body.text}
|
||||
variant="compact"
|
||||
className="mt-1 text-[12.5px] leading-[1.55] text-[var(--color-text-primary)]"
|
||||
/>
|
||||
) : (
|
||||
<p className="mt-1 text-[11.5px] leading-[1.5] text-[var(--color-text-secondary)]">
|
||||
{protocolNarration(row, snapshot, t)}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="mt-2 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('agentTeams.noMemberMessages')}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer className="shrink-0 border-t border-[var(--color-border)] bg-[var(--color-surface)] px-3.5 py-3">
|
||||
<Button variant="primary" size="base" block onClick={onOpenExecution}>
|
||||
{t('agentTeams.openExecution', { name })}
|
||||
</Button>
|
||||
</footer>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { ChevronRight, UsersRound } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Badge, StatusDot, type Tone } from '@/components/ui/Badge'
|
||||
import { useTranslation, type TranslationKey } from '../../i18n'
|
||||
import type { TeamMember, TeamWorkbenchSnapshot } from '../../types/team'
|
||||
import { MEMBER_AVATARS } from './agentTeamsAvatars'
|
||||
import {
|
||||
getMemberAvatarKey,
|
||||
getWorkbenchPhase,
|
||||
getWorkbenchProgress,
|
||||
type WorkbenchPhase,
|
||||
} from './agentTeamsModel'
|
||||
|
||||
/** Faces beyond this collapse into a `+N`, so the strip never wraps. */
|
||||
const MAX_STACKED_AVATARS = 5
|
||||
|
||||
function phaseTone(phase: WorkbenchPhase): Tone {
|
||||
if (phase === 'forming') return 'warning'
|
||||
if (phase === 'running') return 'brand'
|
||||
if (phase === 'finishing') return 'info'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
function AvatarStack({
|
||||
members,
|
||||
leadAgentId,
|
||||
size,
|
||||
}: {
|
||||
members: TeamMember[]
|
||||
leadAgentId: string | undefined
|
||||
size: number
|
||||
}) {
|
||||
const shown = members.slice(0, MAX_STACKED_AVATARS)
|
||||
const overflow = members.length - shown.length
|
||||
return (
|
||||
<span className="flex shrink-0 items-center" aria-hidden="true">
|
||||
{shown.map((member, index) => (
|
||||
<span
|
||||
key={member.agentId}
|
||||
className="inline-flex items-center justify-center rounded-full bg-[var(--color-surface-container)] ring-1 ring-[var(--color-surface)]"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
marginLeft: index === 0 ? 0 : -Math.round(size * 0.32),
|
||||
zIndex: shown.length - index,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={MEMBER_AVATARS[getMemberAvatarKey(member, member.agentId === leadAgentId)]}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="select-none object-contain"
|
||||
style={{ width: size - 2, height: size - 2 }}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
{overflow > 0 ? (
|
||||
<span
|
||||
className="inline-flex items-center justify-center rounded-full bg-[var(--color-surface-container)] font-mono text-[9px] font-extrabold text-[var(--color-text-secondary)] ring-1 ring-[var(--color-surface)]"
|
||||
style={{ width: size, height: size, marginLeft: -Math.round(size * 0.32) }}
|
||||
>
|
||||
+{overflow}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The always-visible trace of a running team in the main session header. It
|
||||
* opens the full workbench directly; a team never takes over the chat's
|
||||
* right-hand panel.
|
||||
*/
|
||||
export function AgentTeamsStrip({
|
||||
snapshot,
|
||||
onOpen,
|
||||
compact,
|
||||
}: {
|
||||
snapshot: TeamWorkbenchSnapshot
|
||||
onOpen: () => void
|
||||
compact: boolean
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const phase = getWorkbenchPhase(snapshot)
|
||||
const progress = getWorkbenchProgress(snapshot)
|
||||
const members = snapshot.team.members
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="agent-teams-strip"
|
||||
onClick={onOpen}
|
||||
title={snapshot.team.name}
|
||||
className={[
|
||||
'mt-2 flex w-full max-w-full items-center gap-2 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5 py-1.5 text-left transition-colors',
|
||||
'hover:border-[var(--color-border-strong)] hover:bg-[var(--color-surface-hover)]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]',
|
||||
compact ? 'text-[11px]' : 'text-[12px]',
|
||||
].join(' ')}
|
||||
>
|
||||
<UsersRound size={compact ? 13 : 14} strokeWidth={2.25} aria-hidden="true" className="shrink-0 text-[var(--color-brand)]" />
|
||||
<span className="shrink-0 font-semibold text-[var(--color-text-primary)]">
|
||||
{t('agentTeams.strip.label')}
|
||||
</span>
|
||||
<AvatarStack members={members} leadAgentId={snapshot.team.leadAgentId} size={compact ? 18 : 20} />
|
||||
<span className="flex shrink-0 items-center gap-1 text-[var(--color-text-tertiary)]">
|
||||
<StatusDot tone={phaseTone(phase)} pulse={phase === 'running'} />
|
||||
{t(`agentTeams.phase.${phase}` as TranslationKey)}
|
||||
</span>
|
||||
{progress.total > 0 ? (
|
||||
<span className="shrink-0 tabular-nums text-[var(--color-text-secondary)]">
|
||||
{t('agentTeams.inline.tasks', { completed: progress.completed, total: progress.total })}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="ml-auto flex shrink-0 items-center gap-0.5 font-medium text-[var(--color-brand)]">
|
||||
{t('agentTeams.inline.open')}
|
||||
<ChevronRight size={12} strokeWidth={2.4} aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The in-transcript record of the team being formed. It sits where the
|
||||
* TeamCreate call happened so scrolling back through the conversation still
|
||||
* shows that this turn handed work to a team.
|
||||
*/
|
||||
export function AgentTeamsInlineCard({
|
||||
snapshot,
|
||||
teamName,
|
||||
fallbackPhase = 'forming',
|
||||
phaseOverride,
|
||||
onOpen,
|
||||
children,
|
||||
}: {
|
||||
snapshot?: TeamWorkbenchSnapshot
|
||||
teamName: string
|
||||
fallbackPhase?: WorkbenchPhase
|
||||
phaseOverride?: WorkbenchPhase
|
||||
onOpen?: () => void
|
||||
children?: ReactNode
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const phase = phaseOverride ?? (snapshot ? getWorkbenchPhase(snapshot) : fallbackPhase)
|
||||
const progress = snapshot ? getWorkbenchProgress(snapshot) : { completed: 0, total: 0 }
|
||||
const members = snapshot?.team.members ?? []
|
||||
const canOpen = Boolean(snapshot && onOpen)
|
||||
|
||||
return (
|
||||
<div className="mb-5 px-1">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="agent-teams-inline-card"
|
||||
onClick={onOpen}
|
||||
disabled={!canOpen}
|
||||
className="flex w-full items-center gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3.5 py-2.5 text-left shadow-[var(--shadow-card)] transition-colors hover:border-[var(--color-border-strong)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
||||
>
|
||||
{members.length > 0 ? (
|
||||
<AvatarStack members={members} leadAgentId={snapshot?.team.leadAgentId} size={30} />
|
||||
) : (
|
||||
<span className="inline-flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-full bg-[var(--color-surface-container)] text-[var(--color-brand)] ring-1 ring-[var(--color-border)]">
|
||||
<UsersRound size={16} strokeWidth={2.2} aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 truncate font-mono text-[12px] font-extrabold text-[var(--color-text-primary)]">
|
||||
{teamName}
|
||||
</span>
|
||||
<Badge tone={phaseTone(phase)} size="xs" bordered>
|
||||
{t(`agentTeams.phase.${phase}` as TranslationKey)}
|
||||
</Badge>
|
||||
</span>
|
||||
{snapshot ? (
|
||||
<span className="mt-0.5 flex items-center gap-1.5 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
<span>{t('agentTeams.inline.created', { count: members.length })}</span>
|
||||
{progress.total > 0 ? (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="tabular-nums">
|
||||
{t('agentTeams.inline.tasks', { completed: progress.completed, total: progress.total })}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{canOpen ? (
|
||||
<span className="flex shrink-0 items-center gap-0.5 text-[11.5px] font-medium text-[var(--color-brand)]">
|
||||
{t('agentTeams.inline.open')}
|
||||
<ChevronRight size={13} strokeWidth={2.4} aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useChatStore } from '@/stores/chatStore'
|
||||
import { useSettingsStore } from '@/stores/settingsStore'
|
||||
import { useTeamStore } from '@/stores/teamStore'
|
||||
import { useTabStore } from '@/stores/tabStore'
|
||||
import type { TeamWorkbenchSnapshot, TeamWorkbenchTask } from '@/types/team'
|
||||
|
||||
import { AgentTeamsWorkbench } from './AgentTeamsWorkbench'
|
||||
|
||||
const { getWorkbenchForSessionMock, getWorkbenchMock } = vi.hoisted(() => ({
|
||||
getWorkbenchForSessionMock: vi.fn(),
|
||||
getWorkbenchMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/teams', () => ({
|
||||
teamsApi: {
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getWorkbenchForSession: getWorkbenchForSessionMock,
|
||||
getWorkbench: getWorkbenchMock,
|
||||
getMemberTranscript: vi.fn(),
|
||||
sendMemberMessage: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function task(
|
||||
id: string,
|
||||
status: TeamWorkbenchTask['status'],
|
||||
blockedBy: string[] = [],
|
||||
owner?: string,
|
||||
): TeamWorkbenchTask {
|
||||
return {
|
||||
id,
|
||||
subject: `Task ${id}`,
|
||||
description: `Task ${id} detail`,
|
||||
activeForm: status === 'in_progress' ? `Working on task ${id}` : undefined,
|
||||
owner,
|
||||
status,
|
||||
blocks: [],
|
||||
blockedBy,
|
||||
taskListId: 'visual-team',
|
||||
}
|
||||
}
|
||||
|
||||
function workbench(
|
||||
version: string,
|
||||
statuses: [TeamWorkbenchTask['status'], TeamWorkbenchTask['status'], TeamWorkbenchTask['status']],
|
||||
): TeamWorkbenchSnapshot {
|
||||
return {
|
||||
version,
|
||||
generatedAt: `2026-08-08T00:00:0${version.slice(-1)}.000Z`,
|
||||
team: {
|
||||
name: 'visual-team',
|
||||
leadAgentId: 'team-lead@visual-team',
|
||||
leadSessionId: 'lead-session',
|
||||
members: [
|
||||
{ agentId: 'team-lead@visual-team', name: 'team-lead', role: 'lead', status: 'running' },
|
||||
{ agentId: 'builder@visual-team', name: 'builder', role: 'frontend', status: 'running' },
|
||||
{ agentId: 'reviewer@visual-team', name: 'reviewer', role: 'reviewer', status: 'idle' },
|
||||
],
|
||||
},
|
||||
tasks: [
|
||||
task('1', statuses[0], [], 'builder'),
|
||||
task('2', statuses[1], ['1'], 'reviewer'),
|
||||
task('3', statuses[2], ['1', '2']),
|
||||
],
|
||||
messages: [{
|
||||
id: `message-${version}`,
|
||||
from: 'builder',
|
||||
to: 'reviewer',
|
||||
recipients: ['reviewer'],
|
||||
kind: 'direct',
|
||||
text: `Snapshot ${version} ready`,
|
||||
timestamp: `2026-08-08T00:00:0${version.slice(-1)}.000Z`,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
function unownedWorkbench(
|
||||
version: string,
|
||||
builderStatus: 'running' | 'idle',
|
||||
): TeamWorkbenchSnapshot {
|
||||
const snapshot = workbench(version, ['pending', 'pending', 'pending'])
|
||||
snapshot.tasks = snapshot.tasks.map((entry) => ({ ...entry, owner: undefined }))
|
||||
snapshot.team.members = snapshot.team.members.map((member) => (
|
||||
member.name === 'builder'
|
||||
? { ...member, status: builderStatus }
|
||||
: member
|
||||
))
|
||||
return snapshot
|
||||
}
|
||||
|
||||
describe('AgentTeamsWorkbench', () => {
|
||||
beforeEach(() => {
|
||||
getWorkbenchMock.mockReset()
|
||||
getWorkbenchForSessionMock.mockReset()
|
||||
useTeamStore.getState().clearTeam()
|
||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
})
|
||||
|
||||
it('keeps live controls simple, then supports seeking, speed, playback, and returning live', async () => {
|
||||
getWorkbenchMock
|
||||
.mockResolvedValueOnce(workbench('v1', ['completed', 'in_progress', 'pending']))
|
||||
.mockResolvedValueOnce(workbench('v2', ['completed', 'completed', 'in_progress']))
|
||||
.mockResolvedValueOnce(workbench('v3', ['completed', 'completed', 'completed']))
|
||||
|
||||
await act(async () => {
|
||||
await useTeamStore.getState().fetchWorkbench('visual-team')
|
||||
await useTeamStore.getState().fetchWorkbench('visual-team')
|
||||
})
|
||||
render(<AgentTeamsWorkbench sessionId="lead-session" />)
|
||||
|
||||
expect(screen.getByTestId('agent-teams-live-controls')).toBeTruthy()
|
||||
expect(screen.queryByTestId('agent-teams-replay-controls')).toBeNull()
|
||||
expect(screen.queryByRole('slider', { name: 'Replay timeline · click to seek' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /^(Play|Pause|Replay)$/ })).toBeNull()
|
||||
expect(screen.getByTestId('agent-teams-communication-rail').className).toContain('w-14')
|
||||
expect(screen.getByTestId('agent-teams-canvas-task-2').getAttribute('data-state')).toBe('completed')
|
||||
expect(screen.getByTestId('agent-teams-canvas-task-3').getAttribute('data-state')).toBe('running')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Review history' }))
|
||||
expect(screen.queryByTestId('agent-teams-live-controls')).toBeNull()
|
||||
expect(screen.getByTestId('agent-teams-replay-controls')).toBeTruthy()
|
||||
|
||||
const timeline = screen.getByRole('slider', { name: 'Replay timeline · click to seek' }) as HTMLInputElement
|
||||
expect(timeline.value).toBe('1000')
|
||||
fireEvent.change(timeline, { target: { value: '0' } })
|
||||
expect(screen.getByTestId('agent-teams-canvas-task-2').getAttribute('data-state')).toBe('running')
|
||||
expect(screen.getByTestId('agent-teams-canvas-task-3').getAttribute('data-state')).toBe('blocked')
|
||||
|
||||
const quadrupleSpeed = screen.getByRole('button', { name: '4×' })
|
||||
fireEvent.click(quadrupleSpeed)
|
||||
expect(quadrupleSpeed.getAttribute('aria-pressed')).toBe('true')
|
||||
|
||||
await act(async () => {
|
||||
await useTeamStore.getState().fetchWorkbench('visual-team')
|
||||
})
|
||||
expect(timeline.max).toBe('2000')
|
||||
expect(timeline.value).toBe('0')
|
||||
expect(screen.getByTestId('agent-teams-canvas-task-2').getAttribute('data-state')).toBe('running')
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Play' }))
|
||||
expect(screen.getByRole('button', { name: 'Pause' })).toBeTruthy()
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(251)
|
||||
})
|
||||
expect(screen.getByTestId('agent-teams-canvas-task-2').getAttribute('data-state')).toBe('completed')
|
||||
expect(screen.getByTestId('agent-teams-canvas-task-3').getAttribute('data-state')).toBe('running')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pause' }))
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back to live' }))
|
||||
expect(screen.getByTestId('agent-teams-live-controls')).toBeTruthy()
|
||||
expect(screen.getByTestId('agent-teams-canvas-task-3').getAttribute('data-state')).toBe('completed')
|
||||
})
|
||||
|
||||
it('positions and plays replay snapshots by their real timestamps', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const first = workbench('v1', ['in_progress', 'pending', 'pending'])
|
||||
const middle = workbench('v2', ['completed', 'in_progress', 'pending'])
|
||||
const last = workbench('v3', ['completed', 'completed', 'completed'])
|
||||
first.generatedAt = '2026-08-08T00:00:00.000Z'
|
||||
middle.generatedAt = '2026-08-08T00:00:01.000Z'
|
||||
last.generatedAt = '2026-08-08T00:01:40.000Z'
|
||||
middle.messages = [...first.messages, ...middle.messages]
|
||||
last.messages = [...middle.messages, ...last.messages]
|
||||
getWorkbenchMock
|
||||
.mockResolvedValueOnce(first)
|
||||
.mockResolvedValueOnce(middle)
|
||||
.mockResolvedValueOnce(last)
|
||||
|
||||
await act(async () => {
|
||||
await useTeamStore.getState().fetchWorkbench('visual-team')
|
||||
await useTeamStore.getState().fetchWorkbench('visual-team')
|
||||
await useTeamStore.getState().fetchWorkbench('visual-team')
|
||||
})
|
||||
render(<AgentTeamsWorkbench sessionId="lead-session" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Review history' }))
|
||||
|
||||
const timeline = screen.getByRole('slider', { name: 'Replay timeline · click to seek' }) as HTMLInputElement
|
||||
expect(timeline.max).toBe('100000')
|
||||
fireEvent.change(timeline, { target: { value: '1000' } })
|
||||
expect(screen.getByTestId('agent-teams-replay-progress').getAttribute('style')).toContain('width: 1%')
|
||||
expect(screen.getByTestId('agent-teams-replay-thumb').getAttribute('style')).toContain('left: 1%')
|
||||
|
||||
fireEvent.change(timeline, { target: { value: '0' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Play' }))
|
||||
act(() => vi.advanceTimersByTime(1001))
|
||||
expect(screen.getByTestId('agent-teams-canvas-task-2').getAttribute('data-state')).toBe('running')
|
||||
act(() => vi.advanceTimersByTime(98_000))
|
||||
expect(screen.getByTestId('agent-teams-canvas-task-3').getAttribute('data-state')).toBe('blocked')
|
||||
act(() => vi.advanceTimersByTime(1001))
|
||||
expect(screen.getByTestId('agent-teams-canvas-task-3').getAttribute('data-state')).toBe('completed')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('follows server-driven idle to running to idle member lifecycle without an owned task', async () => {
|
||||
getWorkbenchMock
|
||||
.mockResolvedValueOnce(unownedWorkbench('lifecycle-1', 'idle'))
|
||||
.mockResolvedValueOnce(unownedWorkbench('lifecycle-2', 'running'))
|
||||
.mockResolvedValueOnce(unownedWorkbench('lifecycle-3', 'idle'))
|
||||
|
||||
await act(async () => {
|
||||
await useTeamStore.getState().fetchWorkbench('visual-team')
|
||||
})
|
||||
render(<AgentTeamsWorkbench sessionId="lead-session" />)
|
||||
|
||||
const builder = () => screen.getByTestId('agent-teams-canvas-member-builder@visual-team')
|
||||
expect(builder().getAttribute('data-member-state')).toBe('idle')
|
||||
|
||||
act(() => {
|
||||
useChatStore.getState().handleServerMessage('lead-session', {
|
||||
type: 'team_workbench_updated',
|
||||
teamName: 'visual-team',
|
||||
})
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(builder().getAttribute('data-member-state')).toBe('working')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
useChatStore.getState().handleServerMessage('lead-session', {
|
||||
type: 'team_workbench_updated',
|
||||
teamName: 'visual-team',
|
||||
})
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(builder().getAttribute('data-member-state')).toBe('idle')
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a member idle while it still owns an in-progress task', async () => {
|
||||
const snapshot = workbench('v1', ['in_progress', 'pending', 'pending'])
|
||||
snapshot.team.members = snapshot.team.members.map((member) => (
|
||||
member.name === 'builder'
|
||||
? { ...member, status: 'running' as const, activity: 'idle' as const }
|
||||
: member
|
||||
))
|
||||
getWorkbenchMock.mockResolvedValueOnce(snapshot)
|
||||
|
||||
await act(async () => {
|
||||
await useTeamStore.getState().fetchWorkbench('visual-team')
|
||||
})
|
||||
render(<AgentTeamsWorkbench sessionId="lead-session" />)
|
||||
|
||||
expect(
|
||||
screen.getByTestId('agent-teams-canvas-member-builder@visual-team').getAttribute('data-member-state'),
|
||||
).toBe('idle')
|
||||
expect(screen.getByTestId('agent-teams-canvas-task-1').getAttribute('data-state')).toBe('running')
|
||||
})
|
||||
|
||||
it('shows a member working while it owns no task at all', async () => {
|
||||
const snapshot = unownedWorkbench('v1', 'idle')
|
||||
snapshot.team.members = snapshot.team.members.map((member) => (
|
||||
member.name === 'builder'
|
||||
? { ...member, activity: 'active' as const }
|
||||
: member
|
||||
))
|
||||
getWorkbenchMock.mockResolvedValueOnce(snapshot)
|
||||
|
||||
await act(async () => {
|
||||
await useTeamStore.getState().fetchWorkbench('visual-team')
|
||||
})
|
||||
render(<AgentTeamsWorkbench sessionId="lead-session" />)
|
||||
|
||||
expect(
|
||||
screen.getByTestId('agent-teams-canvas-member-builder@visual-team').getAttribute('data-member-state'),
|
||||
).toBe('working')
|
||||
})
|
||||
|
||||
it('opens a 400px member inspector before the explicit execution handoff', async () => {
|
||||
getWorkbenchMock.mockResolvedValueOnce(workbench('v1', ['completed', 'in_progress', 'pending']))
|
||||
await act(async () => {
|
||||
await useTeamStore.getState().fetchWorkbench('visual-team')
|
||||
})
|
||||
render(<AgentTeamsWorkbench sessionId="lead-session" />)
|
||||
|
||||
const reviewer = screen.getByTestId('agent-teams-canvas-member-reviewer@visual-team')
|
||||
fireEvent.click(reviewer)
|
||||
|
||||
expect(useTabStore.getState().activeTabId).toBeNull()
|
||||
expect(reviewer.getAttribute('aria-pressed')).toBe('true')
|
||||
expect(screen.getByTestId('agent-teams-member-inspector')).toBeTruthy()
|
||||
expect(screen.getByTestId('agent-teams-communication-pane').className).toContain('w-[400px]')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View reviewer execution' }))
|
||||
expect(useTabStore.getState().activeTabId).toBe('team-member:reviewer@visual-team')
|
||||
expect(useTabStore.getState().tabs.find((tab) => tab.sessionId === 'team-member:reviewer@visual-team')).toMatchObject({
|
||||
type: 'team-member',
|
||||
teamLeadSessionId: 'lead-session',
|
||||
})
|
||||
})
|
||||
|
||||
it('closes an open member inspector when the selected member is clicked again', async () => {
|
||||
getWorkbenchMock.mockResolvedValueOnce(workbench('v1', ['completed', 'in_progress', 'pending']))
|
||||
await act(async () => {
|
||||
await useTeamStore.getState().fetchWorkbench('visual-team')
|
||||
})
|
||||
render(<AgentTeamsWorkbench sessionId="lead-session" />)
|
||||
|
||||
const reviewer = screen.getByTestId('agent-teams-canvas-member-reviewer@visual-team')
|
||||
fireEvent.click(reviewer)
|
||||
expect(screen.getByTestId('agent-teams-member-inspector')).toBeTruthy()
|
||||
fireEvent.click(reviewer)
|
||||
expect(screen.queryByTestId('agent-teams-member-inspector')).toBeNull()
|
||||
expect(screen.getByTestId('agent-teams-communication-rail')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('returns to the real lead session instead of creating a synthetic lead member run', async () => {
|
||||
getWorkbenchMock.mockResolvedValueOnce(workbench('v1', ['completed', 'in_progress', 'pending']))
|
||||
await act(async () => {
|
||||
await useTeamStore.getState().fetchWorkbench('visual-team')
|
||||
})
|
||||
render(<AgentTeamsWorkbench sessionId="lead-session" />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('agent-teams-canvas-member-team-lead@visual-team'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View team-lead execution' }))
|
||||
|
||||
expect(useTabStore.getState().activeTabId).toBe('lead-session')
|
||||
expect(useTabStore.getState().tabs).toContainEqual(expect.objectContaining({
|
||||
sessionId: 'lead-session',
|
||||
type: 'session',
|
||||
}))
|
||||
expect(useTabStore.getState().tabs.some(tab => tab.type === 'team-member')).toBe(false)
|
||||
})
|
||||
|
||||
it('opens task details without navigating to a member execution', async () => {
|
||||
getWorkbenchMock.mockResolvedValueOnce(workbench('v1', ['completed', 'in_progress', 'pending']))
|
||||
await act(async () => {
|
||||
await useTeamStore.getState().fetchWorkbench('visual-team')
|
||||
})
|
||||
render(<AgentTeamsWorkbench sessionId="lead-session" />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('agent-teams-canvas-task-1'))
|
||||
expect(useTabStore.getState().activeTabId).toBeNull()
|
||||
expect(screen.getByTestId('agent-teams-task-detail').getAttribute('data-task-id')).toBe('1')
|
||||
expect(screen.getByTestId('agent-teams-task-detail').textContent).toContain('Task 1 detail')
|
||||
|
||||
fireEvent.click(screen.getByTestId('agent-teams-canvas-task-2'))
|
||||
expect(useTabStore.getState().activeTabId).toBeNull()
|
||||
expect(screen.getByTestId('agent-teams-task-detail').getAttribute('data-task-id')).toBe('2')
|
||||
})
|
||||
|
||||
it('does not leak a selected task into replay frames before that task existed', async () => {
|
||||
const beforeCreation = workbench('v1', ['completed', 'pending', 'pending'])
|
||||
beforeCreation.tasks = beforeCreation.tasks.filter(entry => entry.id !== '3')
|
||||
const afterCreation = workbench('v2', ['completed', 'completed', 'in_progress'])
|
||||
getWorkbenchMock.mockResolvedValueOnce(beforeCreation).mockResolvedValueOnce(afterCreation)
|
||||
await act(async () => {
|
||||
await useTeamStore.getState().fetchWorkbench('visual-team')
|
||||
await useTeamStore.getState().fetchWorkbench('visual-team')
|
||||
})
|
||||
render(<AgentTeamsWorkbench sessionId="lead-session" />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('agent-teams-canvas-task-3'))
|
||||
expect(screen.getByTestId('agent-teams-task-detail')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Review history' }))
|
||||
fireEvent.change(screen.getByRole('slider', { name: 'Replay timeline · click to seek' }), {
|
||||
target: { value: '0' },
|
||||
})
|
||||
expect(screen.queryByTestId('agent-teams-task-detail')).toBeNull()
|
||||
})
|
||||
|
||||
it('draws a teammate once while retaining its owner labels across several tasks', async () => {
|
||||
const snapshot = workbench('v1', ['in_progress', 'in_progress', 'pending'])
|
||||
snapshot.tasks = snapshot.tasks.map((entry) => ({ ...entry, owner: 'builder' }))
|
||||
getWorkbenchMock.mockResolvedValueOnce(snapshot)
|
||||
await act(async () => {
|
||||
await useTeamStore.getState().fetchWorkbench('visual-team')
|
||||
})
|
||||
const { container } = render(<AgentTeamsWorkbench sessionId="lead-session" />)
|
||||
|
||||
expect(
|
||||
container.querySelectorAll('[data-testid="agent-teams-canvas-member-builder@visual-team"]'),
|
||||
).toHaveLength(1)
|
||||
expect(within(screen.getByTestId('agent-teams-canvas-task-2')).getByText('builder')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('restores archived roster members and task owners across replay snapshots', async () => {
|
||||
const beforeRemoval = workbench('roster-1', ['in_progress', 'in_progress', 'pending'])
|
||||
beforeRemoval.team.members.push({
|
||||
agentId: 'observer@visual-team',
|
||||
name: 'observer',
|
||||
role: 'researcher',
|
||||
status: 'idle',
|
||||
})
|
||||
const afterRemoval: TeamWorkbenchSnapshot = {
|
||||
...workbench('roster-2', ['completed', 'completed', 'completed']),
|
||||
deletedAt: '2026-08-08T00:10:00.000Z',
|
||||
team: {
|
||||
...beforeRemoval.team,
|
||||
members: beforeRemoval.team.members
|
||||
.filter((member) => member.name !== 'builder')
|
||||
.map((member) => ({ ...member, status: 'completed' as const })),
|
||||
},
|
||||
tasks: workbench('roster-2', ['completed', 'completed', 'completed']).tasks.map((entry) => (
|
||||
entry.id === '3' ? { ...entry, owner: 'legacy-owner' } : entry
|
||||
)),
|
||||
}
|
||||
getWorkbenchForSessionMock.mockResolvedValueOnce({
|
||||
sessionId: 'lead-session',
|
||||
teamName: 'visual-team',
|
||||
source: 'archive',
|
||||
snapshots: [beforeRemoval, afterRemoval],
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await useTeamStore.getState().fetchTeamForSession('lead-session', { force: true })
|
||||
})
|
||||
render(<AgentTeamsWorkbench sessionId="lead-session" />)
|
||||
|
||||
expect(within(screen.getByTestId('agent-teams-canvas-task-1')).getByText('builder')).toBeTruthy()
|
||||
expect(within(screen.getByTestId('agent-teams-canvas-task-3')).getByText('legacy-owner')).toBeTruthy()
|
||||
|
||||
const restoredBuilder = screen.getByTestId('agent-teams-canvas-member-builder@visual-team')
|
||||
const observer = screen.getByTestId('agent-teams-canvas-member-observer@visual-team')
|
||||
expect(restoredBuilder.getAttribute('data-member-state')).toBe('exited')
|
||||
expect(observer.getAttribute('data-member-state')).toBe('exited')
|
||||
expect(restoredBuilder.querySelector('img')).toBeTruthy()
|
||||
|
||||
fireEvent.click(restoredBuilder)
|
||||
expect(useTabStore.getState().activeTabId).toBeNull()
|
||||
expect(screen.getByTestId('agent-teams-member-inspector').getAttribute('data-member-id')).toBe('builder@visual-team')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View builder execution' }))
|
||||
expect(useTabStore.getState().activeTabId).toBe('team-member:builder@visual-team')
|
||||
})
|
||||
|
||||
it('expands the 56px communication rail to a fixed panel and closes it again', async () => {
|
||||
getWorkbenchMock.mockResolvedValueOnce(workbench('v1', ['completed', 'in_progress', 'pending']))
|
||||
await act(async () => {
|
||||
await useTeamStore.getState().fetchWorkbench('visual-team')
|
||||
})
|
||||
render(<AgentTeamsWorkbench sessionId="lead-session" />)
|
||||
|
||||
const rail = screen.getByTestId('agent-teams-communication-rail')
|
||||
expect(rail.className).toContain('w-14')
|
||||
expect(screen.queryByTestId('agent-teams-communication-pane')).toBeNull()
|
||||
|
||||
fireEvent.click(rail)
|
||||
const pane = screen.getByTestId('agent-teams-communication-pane')
|
||||
expect(pane.className).toContain('w-[400px]')
|
||||
expect(screen.getByTestId('agent-teams-communication').className).toContain('h-full')
|
||||
expect(screen.queryByTestId('agent-teams-communication-rail')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close communication panel' }))
|
||||
expect(screen.queryByTestId('agent-teams-communication-pane')).toBeNull()
|
||||
expect(screen.getByTestId('agent-teams-communication-rail')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('lights the dependency chain while a task-linked communication row is hovered', async () => {
|
||||
const snapshot = workbench('v1', ['completed', 'in_progress', 'pending'])
|
||||
snapshot.messages[0] = { ...snapshot.messages[0]!, taskId: '2' }
|
||||
getWorkbenchMock.mockResolvedValueOnce(snapshot)
|
||||
await act(async () => {
|
||||
await useTeamStore.getState().fetchWorkbench('visual-team')
|
||||
})
|
||||
render(<AgentTeamsWorkbench sessionId="lead-session" />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('agent-teams-communication-rail'))
|
||||
const row = screen.getByTestId('agent-teams-message-message-v1')
|
||||
fireEvent.mouseEnter(row)
|
||||
expect(screen.getByTestId('agent-teams-canvas-task-1').getAttribute('data-chain-active')).toBe('true')
|
||||
expect(screen.getByTestId('agent-teams-canvas-task-2').getAttribute('data-chain-active')).toBe('true')
|
||||
fireEvent.mouseLeave(row)
|
||||
expect(screen.getByTestId('agent-teams-canvas-task-2').getAttribute('data-chain-active')).toBe('false')
|
||||
fireEvent.mouseEnter(row)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close communication panel' }))
|
||||
expect(screen.getByTestId('agent-teams-canvas-task-2').getAttribute('data-chain-active')).toBe('false')
|
||||
})
|
||||
|
||||
it('animates every message in a CLI polling batch in arrival order', async () => {
|
||||
const first = workbench('v1', ['completed', 'in_progress', 'pending'])
|
||||
const second = workbench('v2', ['completed', 'in_progress', 'pending'])
|
||||
const peerMessage = {
|
||||
...first.messages[0]!,
|
||||
id: 'batch-peer',
|
||||
from: 'reviewer',
|
||||
to: 'builder',
|
||||
recipients: ['builder'],
|
||||
taskId: '2',
|
||||
}
|
||||
const reportMessage = {
|
||||
...first.messages[0]!,
|
||||
id: 'batch-report',
|
||||
from: 'builder',
|
||||
to: 'team-lead',
|
||||
recipients: ['team-lead'],
|
||||
taskId: '1',
|
||||
}
|
||||
second.messages = [...first.messages, peerMessage, reportMessage]
|
||||
getWorkbenchMock.mockResolvedValueOnce(first).mockResolvedValueOnce(second)
|
||||
await act(async () => {
|
||||
await useTeamStore.getState().fetchWorkbench('visual-team')
|
||||
})
|
||||
render(<AgentTeamsWorkbench sessionId="lead-session" />)
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await act(async () => {
|
||||
await useTeamStore.getState().fetchWorkbench('visual-team')
|
||||
})
|
||||
expect(screen.getByTestId('agent-teams-active-flight-path').getAttribute('data-flight-channel')).toBe('peer')
|
||||
act(() => vi.advanceTimersByTime(1501))
|
||||
expect(screen.getByTestId('agent-teams-active-flight-path').getAttribute('data-flight-channel')).toBe('lead')
|
||||
act(() => vi.advanceTimersByTime(1501))
|
||||
expect(screen.queryByTestId('agent-teams-active-flight-path')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('never describes an ownerless completed task as waiting to be claimed', async () => {
|
||||
const snapshot = workbench('v1', ['completed', 'pending', 'completed'])
|
||||
snapshot.tasks = snapshot.tasks.map((entry) => (
|
||||
entry.id === '1' || entry.id === '2'
|
||||
? { ...entry, owner: undefined }
|
||||
: entry
|
||||
))
|
||||
getWorkbenchMock.mockResolvedValueOnce(snapshot)
|
||||
await act(async () => {
|
||||
await useTeamStore.getState().fetchWorkbench('visual-team')
|
||||
})
|
||||
|
||||
render(<AgentTeamsWorkbench sessionId="lead-session" />)
|
||||
|
||||
const completed = screen.getByTestId('agent-teams-canvas-task-1')
|
||||
expect(within(completed).getByText('Completed · owner not recorded')).toBeTruthy()
|
||||
expect(within(completed).queryByText('Waiting for a teammate')).toBeNull()
|
||||
|
||||
const available = screen.getByTestId('agent-teams-canvas-task-2')
|
||||
expect(within(available).getByText('Waiting for a teammate')).toBeTruthy()
|
||||
expect(within(available).queryByText('Completed · owner not recorded')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,639 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Pause, Play, Radio, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { IconButton } from '@/components/ui/IconButton'
|
||||
import { useTranslation, type TranslationKey } from '../../i18n'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useTeamStore } from '../../stores/teamStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import type {
|
||||
TeamMember,
|
||||
TeamWorkbenchSnapshot,
|
||||
TeamWorkbenchTask,
|
||||
} from '../../types/team'
|
||||
import { AgentTeamsCanvas } from './AgentTeamsCanvas'
|
||||
import { AgentTeamsCommunicationFeed } from './AgentTeamsCommunicationFeed'
|
||||
import { AgentTeamsMemberInspector } from './AgentTeamsMemberInspector'
|
||||
import {
|
||||
getMemberWorkState,
|
||||
getWorkbenchPhase,
|
||||
getWorkbenchTaskState,
|
||||
inferTaskOwner,
|
||||
resolveTeamMemberIdentity,
|
||||
snapshotWithHistoricalMembers,
|
||||
type WorkbenchPhase,
|
||||
type WorkbenchTaskState,
|
||||
} from './agentTeamsModel'
|
||||
|
||||
type TranslationFn = ReturnType<typeof useTranslation>
|
||||
|
||||
const REPLAY_SPEEDS = [0.5, 1, 2, 4] as const
|
||||
const REPLAY_FALLBACK_FRAME_MS = 720
|
||||
const MESSAGE_FLIGHT_MS = 1500
|
||||
|
||||
function timestamp(value: string | undefined): number | null {
|
||||
if (!value) return null
|
||||
const numeric = /^\d+$/.test(value) ? Number(value) : Number.NaN
|
||||
const parsed = Number.isFinite(numeric) ? numeric : Date.parse(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function formatDuration(valueMs: number): string {
|
||||
const seconds = Math.max(0, Math.round(valueMs / 1000))
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const rest = seconds % 60
|
||||
if (hours > 0) {
|
||||
return [hours, minutes, rest].map((part) => String(part).padStart(2, '0')).join(':')
|
||||
}
|
||||
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatClock(value: string | undefined): string {
|
||||
const time = timestamp(value)
|
||||
if (time === null) return '--:--:--'
|
||||
return new Date(time).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function replayFrameDelay(
|
||||
snapshots: TeamWorkbenchSnapshot[],
|
||||
selectedIndex: number,
|
||||
speed: number,
|
||||
): number {
|
||||
const currentTime = timestamp(snapshots[selectedIndex]?.generatedAt)
|
||||
const nextTime = timestamp(snapshots[selectedIndex + 1]?.generatedAt)
|
||||
const sourceDelay = currentTime !== null && nextTime !== null && nextTime > currentTime
|
||||
? nextTime - currentTime
|
||||
: REPLAY_FALLBACK_FRAME_MS
|
||||
return Math.max(16, sourceDelay / speed)
|
||||
}
|
||||
|
||||
function phaseLabel(phase: WorkbenchPhase, t: TranslationFn): string {
|
||||
return t(`agentTeams.phase.${phase}` as TranslationKey)
|
||||
}
|
||||
|
||||
function stateCounts(snapshot: TeamWorkbenchSnapshot) {
|
||||
const tasksById = new Map(snapshot.tasks.map((task) => [task.id, task]))
|
||||
const counts: Record<WorkbenchTaskState, number> = {
|
||||
blocked: 0,
|
||||
open: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
}
|
||||
for (const task of snapshot.tasks) counts[getWorkbenchTaskState(task, tasksById)] += 1
|
||||
return counts
|
||||
}
|
||||
|
||||
function TimelineStat({ label, value, tone }: {
|
||||
label: string
|
||||
value: string | number
|
||||
tone?: 'brand' | 'muted'
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-[52px]">
|
||||
<div className="text-[10px] font-semibold text-[var(--color-text-tertiary)]">{label}</div>
|
||||
<div
|
||||
className={[
|
||||
'whitespace-nowrap font-mono text-[14px] font-extrabold tabular-nums',
|
||||
tone === 'brand'
|
||||
? 'text-[var(--color-brand)]'
|
||||
: tone === 'muted'
|
||||
? 'text-[var(--color-text-tertiary)]'
|
||||
: 'text-[var(--color-text-primary)]',
|
||||
].join(' ')}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The full Agent Teams surface. The snapshot stream is the only source of
|
||||
* truth for both live mode and replay; replay merely moves the cursor through
|
||||
* that same stream, so the canvas, counters, member history and communication
|
||||
* feed cannot drift into different stories.
|
||||
*/
|
||||
export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
|
||||
const t = useTranslation()
|
||||
const timeline = useTeamStore((state) => state.workbenchesBySession[sessionId])
|
||||
const historyIndex = useTeamStore(
|
||||
(state) => state.workbenchHistoryIndexBySession[sessionId] ?? null,
|
||||
)
|
||||
const setHistoryIndex = useTeamStore((state) => state.setWorkbenchHistoryIndex)
|
||||
const openMemberSession = useTeamStore((state) => state.openMemberSession)
|
||||
const leadIsStreaming = useChatStore(
|
||||
(state) => (state.sessions[sessionId]?.chatState ?? 'idle') !== 'idle',
|
||||
)
|
||||
const [communicationOpen, setCommunicationOpen] = useState(false)
|
||||
const [selectedMemberId, setSelectedMemberId] = useState<string | null>(null)
|
||||
const [selectedTask, setSelectedTask] = useState<TeamWorkbenchTask | null>(null)
|
||||
const [replaySpeed, setReplaySpeed] = useState<(typeof REPLAY_SPEEDS)[number]>(1)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [messageFlightQueue, setMessageFlightQueue] = useState<string[]>([])
|
||||
const [feedFocusedTaskId, setFeedFocusedTaskId] = useState<string | null>(null)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const seenMessageIdsRef = useRef<Set<string> | null>(null)
|
||||
|
||||
const snapshots = timeline?.snapshots ?? []
|
||||
const latestIndex = snapshots.length - 1
|
||||
const latestSnapshot = snapshots[latestIndex]
|
||||
const followingLive = historyIndex === null && !latestSnapshot?.deletedAt
|
||||
const selectedIndex = historyIndex === null
|
||||
? latestIndex
|
||||
: Math.max(0, Math.min(latestIndex, historyIndex))
|
||||
const snapshot = useMemo(
|
||||
() => snapshotWithHistoricalMembers(snapshots, selectedIndex),
|
||||
[selectedIndex, snapshots],
|
||||
)
|
||||
const previousSnapshot = useMemo(
|
||||
() => snapshotWithHistoricalMembers(snapshots, selectedIndex - 1),
|
||||
[selectedIndex, snapshots],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!followingLive) return
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [followingLive])
|
||||
|
||||
const visibleMessageIds = useMemo(
|
||||
() => snapshot?.messages.map(message => message.id) ?? [],
|
||||
[snapshot?.messages],
|
||||
)
|
||||
useEffect(() => {
|
||||
const current = new Set(visibleMessageIds)
|
||||
const previous = seenMessageIdsRef.current
|
||||
seenMessageIdsRef.current = current
|
||||
if (previous === null) return
|
||||
|
||||
const movedBackward = Array.from(previous).some(id => !current.has(id))
|
||||
const added = visibleMessageIds.filter(id => !previous.has(id))
|
||||
setMessageFlightQueue(queue => {
|
||||
const retained = movedBackward ? [] : queue.filter(id => current.has(id))
|
||||
const known = new Set(retained)
|
||||
return [...retained, ...added.filter(id => !known.has(id))]
|
||||
})
|
||||
}, [visibleMessageIds])
|
||||
|
||||
const activeMessageId = messageFlightQueue[0] ?? null
|
||||
useEffect(() => {
|
||||
if (!activeMessageId) return
|
||||
const timer = window.setTimeout(() => {
|
||||
setMessageFlightQueue(queue => (
|
||||
queue[0] === activeMessageId ? queue.slice(1) : queue.filter(id => id !== activeMessageId)
|
||||
))
|
||||
}, MESSAGE_FLIGHT_MS)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [activeMessageId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) return
|
||||
if (latestIndex <= 0 || selectedIndex >= latestIndex) {
|
||||
setPlaying(false)
|
||||
return
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
setHistoryIndex(sessionId, selectedIndex + 1)
|
||||
}, replayFrameDelay(snapshots, selectedIndex, replaySpeed))
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [latestIndex, playing, replaySpeed, selectedIndex, sessionId, setHistoryIndex, snapshots])
|
||||
|
||||
useEffect(() => {
|
||||
if (followingLive) setPlaying(false)
|
||||
}, [followingLive])
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<section
|
||||
aria-label={t('agentTeams.title')}
|
||||
className="flex h-full min-h-0 items-center justify-center bg-[var(--color-surface)] p-8 text-sm text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{timeline?.error || t('agentTeams.loading')}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const phase = getWorkbenchPhase(snapshot)
|
||||
const counts = stateCounts(snapshot)
|
||||
const startTime = timestamp(snapshot.team.createdAt) ?? timestamp(snapshots[0]?.generatedAt) ?? now
|
||||
const cursorTime = timestamp(snapshot.generatedAt) ?? startTime
|
||||
const endTime = timestamp(latestSnapshot?.deletedAt) ?? timestamp(latestSnapshot?.generatedAt) ?? now
|
||||
const elapsed = followingLive ? now - startTime : cursorTime - startTime
|
||||
const totalElapsed = Math.max(0, endTime - startTime)
|
||||
const workerMembers = snapshot.team.members.filter(
|
||||
(member) => member.agentId !== snapshot.team.leadAgentId,
|
||||
)
|
||||
const workingCount = workerMembers.filter((member) => (
|
||||
getMemberWorkState(member) === 'working'
|
||||
)).length
|
||||
const liveHint = phase === 'forming'
|
||||
? t('agentTeams.live.formingHint')
|
||||
: workingCount > 0
|
||||
? t('agentTeams.live.runningHint', { count: workingCount })
|
||||
: t('agentTeams.live.waitingHint')
|
||||
const selectedMember = selectedMemberId
|
||||
? snapshot.team.members.find((member) => member.agentId === selectedMemberId)
|
||||
: undefined
|
||||
const selectedMemberIsLead = Boolean(
|
||||
selectedMember && selectedMember.agentId === snapshot.team.leadAgentId,
|
||||
)
|
||||
const timelineTickPositions = snapshots.map((entry, index) => {
|
||||
const at = timestamp(entry.generatedAt)
|
||||
const position = totalElapsed > 0 && at !== null
|
||||
? ((at - startTime) / totalElapsed) * 100
|
||||
: latestIndex > 0
|
||||
? (index / latestIndex) * 100
|
||||
: 0
|
||||
return Math.max(0, Math.min(100, position))
|
||||
})
|
||||
const timelineExtent = totalElapsed > 0 ? totalElapsed : Math.max(1, latestIndex)
|
||||
const timelineValue = totalElapsed > 0
|
||||
? Math.max(0, Math.min(totalElapsed, cursorTime - startTime))
|
||||
: Math.max(0, selectedIndex)
|
||||
const timelinePosition = latestIndex <= 0
|
||||
? 100
|
||||
: Math.max(0, Math.min(100, (timelineValue / timelineExtent) * 100))
|
||||
|
||||
const enterReplay = () => {
|
||||
setPlaying(false)
|
||||
setHistoryIndex(sessionId, Math.max(0, selectedIndex))
|
||||
}
|
||||
const returnToLive = () => {
|
||||
setPlaying(false)
|
||||
setHistoryIndex(sessionId, null)
|
||||
}
|
||||
const togglePlayback = () => {
|
||||
if (selectedIndex >= latestIndex) {
|
||||
setHistoryIndex(sessionId, 0)
|
||||
setPlaying(latestIndex > 0)
|
||||
return
|
||||
}
|
||||
setPlaying((current) => !current)
|
||||
}
|
||||
const closeCommunication = () => {
|
||||
setCommunicationOpen(false)
|
||||
setSelectedMemberId(null)
|
||||
setFeedFocusedTaskId(null)
|
||||
}
|
||||
const selectMember = (member: TeamMember) => {
|
||||
if (communicationOpen && selectedMemberId === member.agentId) {
|
||||
closeCommunication()
|
||||
return
|
||||
}
|
||||
setFeedFocusedTaskId(null)
|
||||
setSelectedMemberId(member.agentId)
|
||||
setCommunicationOpen(true)
|
||||
}
|
||||
const seekTimeline = (value: number) => {
|
||||
setPlaying(false)
|
||||
if (totalElapsed <= 0) {
|
||||
setHistoryIndex(sessionId, Math.max(0, Math.min(latestIndex, Math.round(value))))
|
||||
return
|
||||
}
|
||||
const targetTime = startTime + value
|
||||
let targetIndex = 0
|
||||
snapshots.forEach((entry, index) => {
|
||||
const at = timestamp(entry.generatedAt)
|
||||
if (at !== null && at <= targetTime) targetIndex = index
|
||||
})
|
||||
setHistoryIndex(sessionId, targetIndex)
|
||||
}
|
||||
const openSelectedExecution = () => {
|
||||
if (!selectedMember) return
|
||||
if (selectedMemberIsLead) {
|
||||
useTabStore.getState().openTab(
|
||||
snapshot.team.leadSessionId ?? sessionId,
|
||||
selectedMember.name || selectedMember.role,
|
||||
'session',
|
||||
)
|
||||
return
|
||||
}
|
||||
openMemberSession(selectedMember, snapshot.team, snapshot)
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={t('agentTeams.title')}
|
||||
className="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[var(--color-surface)] text-[var(--color-text-primary)]"
|
||||
>
|
||||
<header className="shrink-0 overflow-x-auto border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
|
||||
<div className="flex min-w-[1180px] items-center gap-5 px-[18px] py-2.5">
|
||||
<div className="min-w-[260px]">
|
||||
<div className="text-[10px] font-bold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
|
||||
Agent Teams · {t('agentTeams.sharedTaskList')}
|
||||
</div>
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="max-w-[300px] truncate font-mono text-[16px] font-extrabold"
|
||||
title={snapshot.team.name}
|
||||
>
|
||||
{snapshot.team.name}
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full border border-[var(--color-primary-fixed-dim)] bg-[var(--color-brand-soft)] px-2 py-0.5 text-[10px] font-bold text-[var(--color-on-brand-soft)]">
|
||||
{t('agentTeams.experimental')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-[18px] border-x border-[var(--color-border)] px-[18px]">
|
||||
<div className="min-w-[74px]">
|
||||
<div className="text-[10px] font-semibold text-[var(--color-text-tertiary)]">
|
||||
{t('agentTeams.stats.phase')}
|
||||
</div>
|
||||
<div className="whitespace-nowrap text-[14px] font-extrabold">
|
||||
{phaseLabel(phase, t)}
|
||||
</div>
|
||||
</div>
|
||||
<TimelineStat
|
||||
label={t('agentTeams.stats.completed')}
|
||||
value={`${counts.completed}/${snapshot.tasks.length}`}
|
||||
/>
|
||||
<TimelineStat label={t('agentTeams.stats.running')} value={counts.running} tone="brand" />
|
||||
<TimelineStat label={t('agentTeams.stats.available')} value={counts.open} />
|
||||
<TimelineStat label={t('agentTeams.stats.blocked')} value={counts.blocked} tone="muted" />
|
||||
</div>
|
||||
|
||||
{followingLive ? (
|
||||
<div data-testid="agent-teams-live-controls" className="flex min-w-0 flex-1 items-center gap-3.5">
|
||||
<div className="flex shrink-0 items-center gap-2 rounded-full border border-[var(--color-primary-fixed-dim)] bg-[var(--color-brand-soft)] px-3 py-[7px]">
|
||||
<span className="agent-teams-live-dot h-2 w-2 rounded-full bg-[var(--color-brand)]" aria-hidden="true" />
|
||||
<span className="whitespace-nowrap text-[12.5px] font-extrabold text-[var(--color-on-brand-soft)]">
|
||||
{t('agentTeams.live.following')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="min-w-0 flex-1 truncate text-[11.5px] text-[var(--color-text-secondary)]">
|
||||
{liveHint}
|
||||
</span>
|
||||
<Button variant="secondary" size="sm" onClick={enterReplay}>
|
||||
{t('agentTeams.reviewHistory')}
|
||||
</Button>
|
||||
<div className="shrink-0 border-l border-[var(--color-border)] pl-3.5">
|
||||
<TimelineStat label={t('agentTeams.live.elapsed')} value={formatDuration(elapsed)} />
|
||||
</div>
|
||||
<div className="shrink-0 border-l border-[var(--color-border)] pl-3.5">
|
||||
<TimelineStat label={t('agentTeams.live.sessionClock')} value={formatClock(snapshot.generatedAt)} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div data-testid="agent-teams-replay-controls" className="flex min-w-0 flex-1 items-center gap-3.5">
|
||||
<Button
|
||||
variant="accent"
|
||||
size="base"
|
||||
className="w-[86px]"
|
||||
icon={playing ? <Pause size={13} aria-hidden="true" /> : <Play size={13} aria-hidden="true" />}
|
||||
onClick={togglePlayback}
|
||||
>
|
||||
{playing
|
||||
? t('agentTeams.replay.pause')
|
||||
: selectedIndex >= latestIndex
|
||||
? t('agentTeams.replay.replay')
|
||||
: t('agentTeams.replay.play')}
|
||||
</Button>
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('agentTeams.replay.speed')}
|
||||
className="flex shrink-0 overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-outline)]"
|
||||
>
|
||||
{REPLAY_SPEEDS.map((speed) => {
|
||||
const selected = replaySpeed === speed
|
||||
return (
|
||||
<button
|
||||
key={speed}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => setReplaySpeed(speed)}
|
||||
className={[
|
||||
'h-7 border-r border-[var(--color-border)] px-2.5 font-mono text-[11px] font-bold outline-none transition-colors last:border-r-0 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]',
|
||||
selected
|
||||
? 'bg-[var(--color-brand)] text-[var(--color-on-primary)]'
|
||||
: 'bg-[var(--color-surface-container-lowest)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]',
|
||||
].join(' ')}
|
||||
>
|
||||
{speed}×
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="min-w-[200px] flex-1">
|
||||
<div className="mb-1 flex justify-between text-[10px] font-semibold text-[var(--color-text-tertiary)]">
|
||||
<span>{t('agentTeams.replay.timeline')}</span>
|
||||
<span className="font-mono tabular-nums">
|
||||
{formatDuration(cursorTime - startTime)} / {formatDuration(totalElapsed)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-[18px]">
|
||||
<div className="absolute inset-x-0 top-2 h-1 rounded-full bg-[var(--color-surface-container-high)]" />
|
||||
<div
|
||||
data-testid="agent-teams-replay-progress"
|
||||
className="absolute left-0 top-2 h-1 rounded-full bg-[var(--color-brand)]"
|
||||
style={{ width: `${timelinePosition}%` }}
|
||||
/>
|
||||
{timelineTickPositions.map((position, index) => (
|
||||
<span
|
||||
key={`${snapshots[index]?.version ?? index}-${index}`}
|
||||
aria-hidden="true"
|
||||
className="absolute top-[5px] h-[10px] w-px bg-[var(--color-text-tertiary)] opacity-30"
|
||||
style={{ left: `${position}%` }}
|
||||
/>
|
||||
))}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-testid="agent-teams-replay-thumb"
|
||||
className="absolute top-1 h-3 w-3 -translate-x-1/2 rounded-full border-[3px] border-[var(--color-brand)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-card)]"
|
||||
style={{ left: `${timelinePosition}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={timelineExtent}
|
||||
step={1}
|
||||
value={timelineValue}
|
||||
aria-label={t('agentTeams.replay.timeline')}
|
||||
onChange={event => seekTimeline(Number(event.target.value))}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
||||
event.preventDefault()
|
||||
setPlaying(false)
|
||||
setHistoryIndex(
|
||||
sessionId,
|
||||
Math.max(0, Math.min(latestIndex, selectedIndex + (event.key === 'ArrowRight' ? 1 : -1))),
|
||||
)
|
||||
}}
|
||||
className="absolute inset-0 h-full w-full cursor-pointer opacity-0 focus-visible:opacity-100 focus-visible:accent-[var(--color-brand)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!latestSnapshot?.deletedAt ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Radio size={12} aria-hidden="true" />}
|
||||
onClick={returnToLive}
|
||||
>
|
||||
{t('agentTeams.backToLive')}
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="shrink-0 border-l border-[var(--color-border)] pl-3.5">
|
||||
<TimelineStat label={t('agentTeams.live.sessionClock')} value={formatClock(snapshot.generatedAt)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<div
|
||||
data-testid="agent-teams-office-viewport"
|
||||
className="min-h-0 min-w-0 flex-1 overflow-auto bg-[var(--color-surface)]"
|
||||
>
|
||||
<AgentTeamsCanvas
|
||||
snapshots={snapshots}
|
||||
selectedIndex={selectedIndex}
|
||||
snapshot={snapshot}
|
||||
previousSnapshot={previousSnapshot}
|
||||
leadIsStreaming={leadIsStreaming}
|
||||
activeMessageId={activeMessageId}
|
||||
focusedTaskId={feedFocusedTaskId}
|
||||
selectedMemberId={selectedMemberId}
|
||||
onSelectMember={(member) => selectMember(member)}
|
||||
onSelectTask={setSelectedTask}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{communicationOpen ? (
|
||||
<aside
|
||||
data-testid="agent-teams-communication-pane"
|
||||
className="h-full w-[400px] shrink-0 border-l border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]"
|
||||
>
|
||||
{selectedMember ? (
|
||||
<AgentTeamsMemberInspector
|
||||
snapshots={snapshots}
|
||||
selectedIndex={selectedIndex}
|
||||
snapshot={snapshot}
|
||||
member={selectedMember}
|
||||
isLead={selectedMemberIsLead}
|
||||
leadIsStreaming={leadIsStreaming}
|
||||
onBack={() => setSelectedMemberId(null)}
|
||||
onClose={closeCommunication}
|
||||
onOpenExecution={openSelectedExecution}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative h-full min-h-0">
|
||||
<AgentTeamsCommunicationFeed
|
||||
snapshot={snapshot}
|
||||
fill
|
||||
onFocusTask={setFeedFocusedTaskId}
|
||||
/>
|
||||
<div className="absolute right-3 top-2.5 z-[var(--z-raised)]">
|
||||
<IconButton
|
||||
icon={<ChevronRight aria-hidden="true" />}
|
||||
label={t('agentTeams.communication.close')}
|
||||
size="sm"
|
||||
tone="muted"
|
||||
bordered
|
||||
onClick={closeCommunication}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="agent-teams-communication-rail"
|
||||
aria-label={t('agentTeams.communication.open')}
|
||||
onClick={() => setCommunicationOpen(true)}
|
||||
className="flex h-full w-14 shrink-0 flex-col items-center gap-3 border-l border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-3 outline-none transition-colors hover:bg-[var(--color-surface-container-low)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]"
|
||||
>
|
||||
<span className="flex h-[30px] w-[30px] items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-outline)] text-[var(--color-text-secondary)]">
|
||||
<ChevronLeft size={14} strokeWidth={2.4} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-[11px] font-extrabold tracking-[0.28em] text-[var(--color-text-secondary)] [writing-mode:vertical-rl]">
|
||||
{t('agentTeams.communication.title')}
|
||||
</span>
|
||||
<span className="rounded-full bg-[var(--color-brand)] px-1.5 py-0.5 font-mono text-[10px] font-extrabold text-[var(--color-on-primary)]">
|
||||
{snapshot.messages.length}
|
||||
</span>
|
||||
<span
|
||||
className={[
|
||||
'h-2 w-2 rounded-full',
|
||||
activeMessageId
|
||||
? 'agent-teams-live-dot bg-[var(--color-brand)]'
|
||||
: 'bg-[var(--color-outline)]',
|
||||
].join(' ')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selectedTask && snapshot.tasks.some(task => task.id === selectedTask.id) ? (
|
||||
<TaskDetailPanel
|
||||
task={snapshot.tasks.find(task => task.id === selectedTask.id)!}
|
||||
snapshot={snapshot}
|
||||
communicationOpen={communicationOpen}
|
||||
onClose={() => setSelectedTask(null)}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskDetailPanel({
|
||||
task,
|
||||
snapshot,
|
||||
communicationOpen,
|
||||
onClose,
|
||||
t,
|
||||
}: {
|
||||
task: TeamWorkbenchTask
|
||||
snapshot: TeamWorkbenchSnapshot
|
||||
communicationOpen: boolean
|
||||
onClose: () => void
|
||||
t: TranslationFn
|
||||
}) {
|
||||
const owner = inferTaskOwner(task, snapshot)
|
||||
const ownerMember = owner
|
||||
? resolveTeamMemberIdentity(snapshot.team, owner.identity).member
|
||||
: undefined
|
||||
return (
|
||||
<aside
|
||||
data-testid="agent-teams-task-detail"
|
||||
data-task-id={task.id}
|
||||
className="agent-teams-drawer absolute bottom-4 z-[var(--z-drawer)] w-[360px] rounded-[var(--radius-lg)] border border-[var(--color-outline)] bg-[var(--color-surface-container-lowest)] p-4 shadow-[var(--shadow-overlay)]"
|
||||
style={{ right: communicationOpen ? 416 : 72 }}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-mono text-[10px] font-extrabold text-[var(--color-text-tertiary)]">
|
||||
#{task.id}
|
||||
</div>
|
||||
<h3 className="mt-1 text-[13px] font-extrabold leading-snug">{task.subject}</h3>
|
||||
</div>
|
||||
<IconButton
|
||||
icon={<X aria-hidden="true" />}
|
||||
label={t('agentTeams.task.closeDetail')}
|
||||
size="sm"
|
||||
tone="muted"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
{task.description ? (
|
||||
<p className="mt-3 whitespace-pre-wrap text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
|
||||
{task.description}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 border-t border-[var(--color-border)] pt-3 text-[10.5px] text-[var(--color-text-secondary)]">
|
||||
<span>{t('agentTeams.task.ownerLabel')}: {ownerMember?.name ?? owner?.identity ?? '—'}</span>
|
||||
<span>{t('agentTeams.task.dependsOn')}: {task.blockedBy.map((id) => `#${id}`).join(', ') || '—'}</span>
|
||||
<span>{t('agentTeams.task.unblocks')}: {task.blocks.map((id) => `#${id}`).join(', ') || '—'}</span>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useTeamStore } from '../../stores/teamStore'
|
||||
import type { TeamMember, TeamWorkbenchSnapshot } from '../../types/team'
|
||||
import { AgentTeamsWorkbenchTab } from './AgentTeamsWorkbenchTab'
|
||||
|
||||
vi.mock('../../api/teams', () => ({
|
||||
teamsApi: {
|
||||
list: vi.fn(), get: vi.fn(),
|
||||
getWorkbenchForSession: vi.fn().mockRejectedValue(new Error('no')),
|
||||
getWorkbench: vi.fn(), getMemberTranscript: vi.fn(), sendMemberMessage: vi.fn(), delete: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const lead: TeamMember = {
|
||||
agentId: 'lead@t',
|
||||
name: 'lead',
|
||||
role: 'orchestrator',
|
||||
status: 'running',
|
||||
}
|
||||
|
||||
const reviewer: TeamMember = {
|
||||
agentId: 'reviewer@t',
|
||||
name: 'reviewer',
|
||||
role: 'reviewer',
|
||||
status: 'running',
|
||||
}
|
||||
|
||||
function workbenchSnapshot(overrides: Partial<TeamWorkbenchSnapshot> = {}): TeamWorkbenchSnapshot {
|
||||
return {
|
||||
version: 'v1',
|
||||
generatedAt: '2026-08-08T00:00:00.000Z',
|
||||
team: {
|
||||
name: 't',
|
||||
leadAgentId: lead.agentId,
|
||||
leadSessionId: 'lead-session',
|
||||
members: [lead, reviewer],
|
||||
},
|
||||
tasks: [{
|
||||
id: '1',
|
||||
subject: 'Review the implementation',
|
||||
description: '',
|
||||
status: 'in_progress',
|
||||
owner: reviewer.name,
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
taskListId: 't',
|
||||
}],
|
||||
messages: [{
|
||||
id: 'm1',
|
||||
from: reviewer.name!,
|
||||
to: lead.name!,
|
||||
recipients: [lead.name!],
|
||||
kind: 'direct',
|
||||
text: 'Review is in progress.',
|
||||
timestamp: '2026-08-08T00:00:00.000Z',
|
||||
}],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function installTimeline(snapshot = workbenchSnapshot()) {
|
||||
useTeamStore.setState({
|
||||
workbenchesBySession: {
|
||||
'lead-session': {
|
||||
teamName: 't',
|
||||
loading: false,
|
||||
error: null,
|
||||
snapshots: [snapshot],
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('AgentTeamsWorkbenchTab', () => {
|
||||
beforeEach(() => {
|
||||
useTeamStore.getState().clearTeam()
|
||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('opens the 400px communication feed from a collapsed 56px rail', () => {
|
||||
installTimeline()
|
||||
|
||||
render(<AgentTeamsWorkbenchTab tabId="__team__lead-session" leadSessionId="lead-session" />)
|
||||
|
||||
expect(screen.getByTestId('agent-teams-canvas')).toBeTruthy()
|
||||
expect(screen.queryByTestId('agent-teams-communication')).toBeNull()
|
||||
expect(screen.queryByTestId('agent-teams-communication-pane')).toBeNull()
|
||||
|
||||
const rail = screen.getByTestId('agent-teams-communication-rail')
|
||||
expect(rail.className).toContain('w-14')
|
||||
fireEvent.click(rail)
|
||||
|
||||
const pane = screen.getByTestId('agent-teams-communication-pane')
|
||||
expect(pane.className).toContain('w-[400px]')
|
||||
expect(screen.queryByTestId('agent-teams-communication-rail')).toBeNull()
|
||||
expect(screen.getByTestId('agent-teams-communication').className).toContain('h-full')
|
||||
expect(screen.getByText('Review is in progress.')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: /Open the workbench full screen/i })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Back to session' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens a member inspector before routing to execution and restores the Canvas on return', async () => {
|
||||
installTimeline()
|
||||
useTabStore.getState().openTab('lead-session', 'Lead session')
|
||||
const teamTabId = useTabStore.getState().openTeamWorkbenchTab('lead-session', 't')
|
||||
|
||||
function RoutedWorkbench() {
|
||||
const activeTab = useTabStore((state) => (
|
||||
state.tabs.find((tab) => tab.sessionId === state.activeTabId)
|
||||
))
|
||||
if (activeTab?.type === 'team-member') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => useTabStore.getState().returnFromTeamMember(activeTab.sessionId)}
|
||||
>
|
||||
Return to workbench
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return <AgentTeamsWorkbenchTab tabId={teamTabId} leadSessionId="lead-session" />
|
||||
}
|
||||
|
||||
render(<RoutedWorkbench />)
|
||||
const member = await screen.findByTestId('agent-teams-canvas-member-reviewer@t')
|
||||
|
||||
fireEvent.click(member)
|
||||
|
||||
const inspector = screen.getByTestId('agent-teams-member-inspector')
|
||||
expect(inspector.getAttribute('data-member-id')).toBe('reviewer@t')
|
||||
expect(screen.getByTestId('agent-teams-communication-pane').className).toContain('w-[400px]')
|
||||
expect(useTabStore.getState().activeTabId).toBe(teamTabId)
|
||||
expect(screen.queryByRole('button', { name: 'Return to workbench' })).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View reviewer execution' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useTabStore.getState().activeTabId).toBe('team-member:reviewer@t')
|
||||
})
|
||||
expect(screen.getByRole('button', { name: 'Return to workbench' })).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Return to workbench' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useTabStore.getState().activeTabId).toBe(teamTabId)
|
||||
expect(screen.getByTestId('agent-teams-canvas')).toBeTruthy()
|
||||
})
|
||||
expect(screen.getByTestId('agent-teams-canvas-member-reviewer@t')).toBeTruthy()
|
||||
expect(screen.getByTestId('agent-teams-communication-rail').className).toContain('w-14')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect } from 'react'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useTeamStore } from '../../stores/teamStore'
|
||||
import { AgentTeamsWorkbench } from './AgentTeamsWorkbench'
|
||||
|
||||
/**
|
||||
* The detached, full-window form of the workbench. It reads the same
|
||||
* per-session timeline the docked panel does, so switching between the two
|
||||
* keeps history position and the selected member.
|
||||
*/
|
||||
export function AgentTeamsWorkbenchTab({
|
||||
tabId,
|
||||
leadSessionId,
|
||||
}: {
|
||||
tabId: string
|
||||
leadSessionId: string
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const hasTimeline = useTeamStore((state) =>
|
||||
Boolean(state.workbenchesBySession[leadSessionId]?.snapshots.length),
|
||||
)
|
||||
const fetchTeamForSession = useTeamStore((state) => state.fetchTeamForSession)
|
||||
|
||||
// Restoring straight into this tab (or opening it for a session whose team
|
||||
// was never fetched) leaves the store empty, so pull the timeline here too.
|
||||
useEffect(() => {
|
||||
if (hasTimeline) return
|
||||
void fetchTeamForSession(leadSessionId)
|
||||
}, [fetchTeamForSession, hasTimeline, leadSessionId])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)]">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-[var(--color-border)] px-3 py-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<ArrowLeft size={14} strokeWidth={2} aria-hidden="true" />}
|
||||
onClick={() => useTabStore.getState().returnFromTeamWorkbench(tabId)}
|
||||
>
|
||||
{t('agentTeams.backToSession')}
|
||||
</Button>
|
||||
</div>
|
||||
<AgentTeamsWorkbench sessionId={leadSessionId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import dataAnalystAvatar from '../../assets/agent-teams/data-analyst.png'
|
||||
import docsCoordinatorAvatar from '../../assets/agent-teams/docs-coordinator.png'
|
||||
import qaEngineerAvatar from '../../assets/agent-teams/qa-engineer.png'
|
||||
import releaseEngineerAvatar from '../../assets/agent-teams/release-engineer.png'
|
||||
import securityReviewerAvatar from '../../assets/agent-teams/security-reviewer.png'
|
||||
import serverEngineerAvatar from '../../assets/agent-teams/server-engineer.png'
|
||||
import teamLeadAvatar from '../../assets/agent-teams/team-lead.png'
|
||||
import uiDesignerAvatar from '../../assets/agent-teams/ui-designer.png'
|
||||
import type { MemberAvatarKey } from './agentTeamsModel'
|
||||
|
||||
export const MEMBER_AVATARS: Record<MemberAvatarKey, string> = {
|
||||
'team-lead': teamLeadAvatar,
|
||||
'server-engineer': serverEngineerAvatar,
|
||||
'ui-designer': uiDesignerAvatar,
|
||||
'qa-engineer': qaEngineerAvatar,
|
||||
'security-reviewer': securityReviewerAvatar,
|
||||
'data-analyst': dataAnalystAvatar,
|
||||
'release-engineer': releaseEngineerAvatar,
|
||||
'docs-coordinator': docsCoordinatorAvatar,
|
||||
}
|
||||
|
||||
export const MEMBER_ACCENTS = [
|
||||
'var(--color-brand)',
|
||||
'var(--color-warning)',
|
||||
'var(--color-success)',
|
||||
'var(--color-info)',
|
||||
'var(--color-text-secondary)',
|
||||
'var(--color-error)',
|
||||
] as const
|
||||
|
||||
const ACCENT_COLOR_ORDER = ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'cyan']
|
||||
|
||||
export function memberAccentColor(color: string | undefined, index: number): string {
|
||||
const colorIndex = color ? ACCENT_COLOR_ORDER.indexOf(color) : -1
|
||||
return MEMBER_ACCENTS[(colorIndex >= 0 ? colorIndex : index) % MEMBER_ACCENTS.length]!
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TeamMember, TeamWorkbenchSnapshot, TeamWorkbenchTask } from '../../types/team'
|
||||
import {
|
||||
formatWorkbenchMessageTime,
|
||||
getWorkbenchPhase,
|
||||
getWorkbenchProgress,
|
||||
getWorkbenchTaskState,
|
||||
getMemberAvatarKey,
|
||||
inferTaskOwner,
|
||||
layoutWorkbenchTasks,
|
||||
parseWorkbenchMessageBody,
|
||||
resolveTeamMemberIdentity,
|
||||
runningTaskForMember,
|
||||
snapshotWithHistoricalMembers,
|
||||
taskOwnedByMember,
|
||||
WORKBENCH_TASK_WIDTH,
|
||||
} from './agentTeamsModel'
|
||||
|
||||
function task(
|
||||
id: string,
|
||||
status: TeamWorkbenchTask['status'],
|
||||
blockedBy: string[] = [],
|
||||
owner?: string,
|
||||
): TeamWorkbenchTask {
|
||||
return {
|
||||
id,
|
||||
subject: `Task ${id}`,
|
||||
description: `Description ${id}`,
|
||||
status,
|
||||
owner,
|
||||
blocks: [],
|
||||
blockedBy,
|
||||
taskListId: 'team-a',
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(tasks: TeamWorkbenchTask[]): TeamWorkbenchSnapshot {
|
||||
return {
|
||||
version: 'v1',
|
||||
generatedAt: '2026-08-08T00:00:00.000Z',
|
||||
team: {
|
||||
name: 'team-a',
|
||||
leadAgentId: 'lead@team-a',
|
||||
leadSessionId: 'session-a',
|
||||
members: [],
|
||||
},
|
||||
tasks,
|
||||
messages: [],
|
||||
}
|
||||
}
|
||||
|
||||
describe('Agent Teams workbench model', () => {
|
||||
it('lays out dependency depth as horizontal lanes and keeps edge states honest', () => {
|
||||
const tasks = [
|
||||
task('1', 'completed'),
|
||||
task('2', 'in_progress'),
|
||||
task('3', 'pending', ['1', '2']),
|
||||
task('4', 'pending', ['missing']),
|
||||
]
|
||||
const layout = layoutWorkbenchTasks(tasks, 604)
|
||||
const byId = new Map(tasks.map((entry) => [entry.id, entry]))
|
||||
|
||||
expect(layout.columns).toBe(2)
|
||||
expect(layout.tasks.every((entry) => entry.x >= 0 && entry.x + WORKBENCH_TASK_WIDTH <= layout.width)).toBe(true)
|
||||
expect(layout.byId.get('3')!.x).toBeGreaterThan(layout.byId.get('1')!.x)
|
||||
expect(layout.byId.get('3')!.x).toBeGreaterThan(layout.byId.get('2')!.x)
|
||||
expect(layout.byId.get('1')!.row).toBe(0)
|
||||
expect(layout.byId.get('2')!.row).toBe(1)
|
||||
expect(layout.byId.get('4')!.row).toBe(2)
|
||||
expect(layout.byId.get('3')!.row).toBe(0)
|
||||
expect(layout.height).toBe(824)
|
||||
expect(layout.lanes).toEqual([
|
||||
{ depth: 0, x: 76, y: 410, width: 216, height: 368, count: 3 },
|
||||
{ depth: 1, x: 312, y: 410, width: 216, height: 368, count: 1 },
|
||||
])
|
||||
expect(layout.legendY).toBe(790)
|
||||
expect(getWorkbenchTaskState(tasks[2]!, byId)).toBe('blocked')
|
||||
// A blocker that is no longer in the task list was deleted. `claimTask`
|
||||
// only counts blockers it can still find and that are not completed
|
||||
// (src/utils/tasks.ts), so this task is claimable rather than stranded --
|
||||
// and `layoutWorkbenchTasks` already ignores dependencies outside the list.
|
||||
expect(getWorkbenchTaskState(tasks[3]!, byId)).toBe('open')
|
||||
|
||||
tasks[1] = task('2', 'completed')
|
||||
const completedParents = new Map(tasks.map((entry) => [entry.id, entry]))
|
||||
expect(getWorkbenchTaskState(tasks[2]!, completedParents)).toBe('open')
|
||||
})
|
||||
|
||||
it('keeps a task blocked while a dependency it can still see is unfinished', () => {
|
||||
const tasks = [task('1', 'in_progress'), task('2', 'pending', ['1'])]
|
||||
const byId = new Map(tasks.map((entry) => [entry.id, entry]))
|
||||
|
||||
expect(getWorkbenchTaskState(tasks[1]!, byId)).toBe('blocked')
|
||||
})
|
||||
|
||||
it('recovers an ownerless task attribution from its assignment envelope', () => {
|
||||
const orphan = task('7', 'completed')
|
||||
const base = snapshot([task('2', 'completed', [], 'backend-dev'), orphan])
|
||||
const withAssignment: TeamWorkbenchSnapshot = {
|
||||
...base,
|
||||
messages: [{
|
||||
id: 'mailbox-1',
|
||||
from: 'backend-dev',
|
||||
to: 'backend-dev',
|
||||
recipients: ['backend-dev'],
|
||||
kind: 'system',
|
||||
protocolType: 'task_assignment',
|
||||
taskId: '7',
|
||||
text: '{"type":"task_assignment","taskId":"7"}',
|
||||
timestamp: '2026-08-08T00:00:01.000Z',
|
||||
}],
|
||||
}
|
||||
|
||||
expect(inferTaskOwner(orphan, withAssignment)).toEqual({
|
||||
identity: 'backend-dev',
|
||||
inferred: true,
|
||||
})
|
||||
// A recorded owner is fact and must never be relabelled as a guess.
|
||||
expect(inferTaskOwner(withAssignment.tasks[0]!, withAssignment)).toEqual({
|
||||
identity: 'backend-dev',
|
||||
inferred: false,
|
||||
})
|
||||
// Nothing to recover from leaves the task unattributed rather than guessing.
|
||||
expect(inferTaskOwner(orphan, base)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the latest assignment when an ownerless task was reassigned', () => {
|
||||
const orphan = task('7', 'completed')
|
||||
const withReassignment: TeamWorkbenchSnapshot = {
|
||||
...snapshot([orphan]),
|
||||
messages: [
|
||||
{
|
||||
id: 'mailbox-first-owner',
|
||||
from: 'team-lead',
|
||||
to: 'backend-dev',
|
||||
recipients: ['backend-dev'],
|
||||
kind: 'system',
|
||||
protocolType: 'task_assignment',
|
||||
taskId: '7',
|
||||
text: 'Build the API',
|
||||
timestamp: '2026-08-08T00:00:01.000Z',
|
||||
},
|
||||
{
|
||||
id: 'mailbox-final-owner',
|
||||
from: 'team-lead',
|
||||
to: 'reviewer',
|
||||
recipients: ['reviewer'],
|
||||
kind: 'system',
|
||||
protocolType: 'task_assignment',
|
||||
taskId: '7',
|
||||
text: 'Build the API',
|
||||
timestamp: '2026-08-08T00:00:02.000Z',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
expect(inferTaskOwner(orphan, withReassignment)).toEqual({
|
||||
identity: 'reviewer',
|
||||
inferred: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back deterministically for cyclic dependencies instead of recursing forever', () => {
|
||||
const tasks = [
|
||||
task('a', 'pending', ['b']),
|
||||
task('b', 'pending', ['a']),
|
||||
]
|
||||
|
||||
const layout = layoutWorkbenchTasks(tasks, 440)
|
||||
const reversed = layoutWorkbenchTasks([...tasks].reverse(), 440)
|
||||
|
||||
expect(layout.tasks.map((entry) => entry.task.id).sort()).toEqual(['a', 'b'])
|
||||
expect(layout.columns).toBeGreaterThan(0)
|
||||
expect(layout.tasks.map(({ task: entry, depth, row, x }) => ({
|
||||
id: entry.id,
|
||||
depth,
|
||||
row,
|
||||
x,
|
||||
}))).toEqual(reversed.tasks.map(({ task: entry, depth, row, x }) => ({
|
||||
id: entry.id,
|
||||
depth,
|
||||
row,
|
||||
x,
|
||||
})))
|
||||
expect(layout.tasks.every((entry) => entry.x >= 0 && entry.x + WORKBENCH_TASK_WIDTH <= layout.width)).toBe(true)
|
||||
expect(layout.height).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('uses the six prototype lanes at their natural 1460px width', () => {
|
||||
const tasks = [
|
||||
task('1', 'completed'),
|
||||
task('2', 'completed', ['1']),
|
||||
task('3', 'completed', ['2']),
|
||||
task('4', 'completed', ['3']),
|
||||
task('5', 'completed', ['4']),
|
||||
task('6', 'pending', ['5']),
|
||||
]
|
||||
|
||||
const layout = layoutWorkbenchTasks(tasks, 604)
|
||||
|
||||
expect(layout.columns).toBe(6)
|
||||
expect(layout.width).toBe(1460)
|
||||
expect(layout.lanes.map(({ depth, x, count }) => ({ depth, x, count }))).toEqual([
|
||||
{ depth: 0, x: 32, count: 1 },
|
||||
{ depth: 1, x: 268, count: 1 },
|
||||
{ depth: 2, x: 504, count: 1 },
|
||||
{ depth: 3, x: 740, count: 1 },
|
||||
{ depth: 4, x: 976, count: 1 },
|
||||
{ depth: 5, x: 1212, count: 1 },
|
||||
])
|
||||
expect(layout.tasks.map(({ depth, row, x, y }) => ({ depth, row, x, y }))).toEqual([
|
||||
{ depth: 0, row: 0, x: 40, y: 442 },
|
||||
{ depth: 1, row: 0, x: 276, y: 442 },
|
||||
{ depth: 2, row: 0, x: 512, y: 442 },
|
||||
{ depth: 3, row: 0, x: 748, y: 442 },
|
||||
{ depth: 4, row: 0, x: 984, y: 442 },
|
||||
{ depth: 5, row: 0, x: 1220, y: 442 },
|
||||
])
|
||||
})
|
||||
|
||||
it('stacks same-depth tasks naturally and centers lanes inside a requested minimum width', () => {
|
||||
const tasks = [
|
||||
task('task-10', 'pending'),
|
||||
task('task-2', 'pending'),
|
||||
task('task-1', 'pending'),
|
||||
task('next', 'pending', ['task-1']),
|
||||
]
|
||||
|
||||
const layout = layoutWorkbenchTasks(tasks, 800)
|
||||
|
||||
expect(layout.width).toBe(800)
|
||||
expect(layout.lanes.map(({ x }) => x)).toEqual([174, 410])
|
||||
expect(layout.tasks.map(({ task: entry, depth, row, x, y }) => ({
|
||||
id: entry.id,
|
||||
depth,
|
||||
row,
|
||||
x,
|
||||
y,
|
||||
}))).toEqual([
|
||||
{ id: 'task-1', depth: 0, row: 0, x: 182, y: 442 },
|
||||
{ id: 'task-2', depth: 0, row: 1, x: 182, y: 548 },
|
||||
{ id: 'task-10', depth: 0, row: 2, x: 182, y: 654 },
|
||||
{ id: 'next', depth: 1, row: 0, x: 418, y: 442 },
|
||||
])
|
||||
expect(layout.height).toBe(824)
|
||||
})
|
||||
|
||||
it('normalizes bare owner names and full agent ids for active work', () => {
|
||||
const member: TeamMember = {
|
||||
agentId: 'reviewer@team-a',
|
||||
name: 'reviewer',
|
||||
role: 'security-reviewer',
|
||||
status: 'running',
|
||||
}
|
||||
const bareOwner = task('1', 'in_progress', [], 'reviewer')
|
||||
const fullOwner = task('2', 'pending', [], 'reviewer@team-a')
|
||||
|
||||
expect(taskOwnedByMember(bareOwner, member)).toBe(true)
|
||||
expect(taskOwnedByMember(fullOwner, member)).toBe(true)
|
||||
expect(runningTaskForMember([fullOwner, bareOwner], member)?.id).toBe('1')
|
||||
})
|
||||
|
||||
it('does not treat a shared role label as a member ownership identity', () => {
|
||||
const first: TeamMember = {
|
||||
agentId: 'reviewer-a@team-a',
|
||||
name: 'reviewer-a',
|
||||
role: 'security-reviewer',
|
||||
status: 'running',
|
||||
}
|
||||
const second: TeamMember = {
|
||||
agentId: 'reviewer-b@team-a',
|
||||
name: 'reviewer-b',
|
||||
role: 'security-reviewer',
|
||||
status: 'running',
|
||||
}
|
||||
const ambiguous = task('role-owned', 'in_progress', [], 'security-reviewer')
|
||||
|
||||
expect(taskOwnedByMember(ambiguous, first)).toBe(false)
|
||||
expect(taskOwnedByMember(ambiguous, second)).toBe(false)
|
||||
})
|
||||
|
||||
it('assigns generated occupational characters and preserves unknown-member identity', () => {
|
||||
const member = (agentId: string, role: string): TeamMember => ({
|
||||
agentId,
|
||||
name: agentId.split('@')[0],
|
||||
role,
|
||||
status: 'running',
|
||||
})
|
||||
|
||||
expect(getMemberAvatarKey(member('lead@team-a', 'orchestrator'), true)).toBe('team-lead')
|
||||
expect(getMemberAvatarKey(member('watcher-runtime@team-a', 'backend'))).toBe('server-engineer')
|
||||
expect(getMemberAvatarKey(member('desktop-workbench@team-a', 'frontend'))).toBe('ui-designer')
|
||||
expect(getMemberAvatarKey(member('test-engineer@team-a', 'quality'))).toBe('qa-engineer')
|
||||
expect(getMemberAvatarKey(member('security-reviewer@team-a', 'reviewer'))).toBe('security-reviewer')
|
||||
expect(getMemberAvatarKey(member('release-auditor@team-a', 'release'))).toBe('release-engineer')
|
||||
expect(getMemberAvatarKey(member('unknown-specialist@team-a', 'general-purpose')))
|
||||
.toBe(getMemberAvatarKey(member('unknown-specialist@team-a', 'general-purpose')))
|
||||
})
|
||||
|
||||
it('resolves bare transcript senders to the same full teammate identity as the workbench', () => {
|
||||
const team = {
|
||||
name: 'team-a',
|
||||
leadAgentId: 'team-lead@team-a',
|
||||
members: [
|
||||
{ agentId: 'team-lead@team-a', name: 'team-lead', role: 'orchestrator', status: 'running' as const },
|
||||
{ agentId: 'server-reviewer@team-a', name: 'server-reviewer', role: 'server engineer', status: 'idle' as const },
|
||||
],
|
||||
}
|
||||
|
||||
const lead = resolveTeamMemberIdentity(team, 'team-lead')
|
||||
const reviewer = resolveTeamMemberIdentity(team, 'server-reviewer@team-a')
|
||||
|
||||
expect(lead.member.agentId).toBe('team-lead@team-a')
|
||||
expect(lead.isLead).toBe(true)
|
||||
expect(getMemberAvatarKey(lead.member, lead.isLead)).toBe('team-lead')
|
||||
expect(reviewer.member.agentId).toBe('server-reviewer@team-a')
|
||||
expect(getMemberAvatarKey(reviewer.member, reviewer.isLead)).toBe('security-reviewer')
|
||||
})
|
||||
|
||||
it('replays roster transitions so shutdown cannot orphan a completed task owner', () => {
|
||||
const lead: TeamMember = {
|
||||
agentId: 'team-lead@team-a',
|
||||
name: 'team-lead',
|
||||
role: 'orchestrator',
|
||||
status: 'running',
|
||||
}
|
||||
const owner: TeamMember = {
|
||||
agentId: 'builder@team-a',
|
||||
name: 'builder',
|
||||
role: 'frontend',
|
||||
status: 'running',
|
||||
}
|
||||
const observer: TeamMember = {
|
||||
agentId: 'observer@team-a',
|
||||
name: 'observer',
|
||||
role: 'reviewer',
|
||||
status: 'idle',
|
||||
}
|
||||
const beforeRemoval = {
|
||||
...snapshot([task('1', 'in_progress', [], 'builder')]),
|
||||
version: 'roster-1',
|
||||
team: { ...snapshot([]).team, members: [lead, owner, observer] },
|
||||
}
|
||||
const afterRemoval = {
|
||||
...snapshot([task('1', 'completed', [], 'builder')]),
|
||||
version: 'roster-2',
|
||||
deletedAt: '2026-08-08T00:01:00.000Z',
|
||||
team: {
|
||||
...snapshot([]).team,
|
||||
members: [
|
||||
{ ...lead, status: 'completed' as const },
|
||||
{ ...observer, status: 'completed' as const },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const repaired = snapshotWithHistoricalMembers([beforeRemoval, afterRemoval], 1)!
|
||||
const repairedOwner = repaired.team.members.find(member => taskOwnedByMember(repaired.tasks[0]!, member))
|
||||
|
||||
expect(repaired.team.members.map(member => ({
|
||||
name: member.name,
|
||||
status: member.status,
|
||||
}))).toEqual([
|
||||
{ name: 'team-lead', status: 'completed' },
|
||||
{ name: 'builder', status: 'completed' },
|
||||
{ name: 'observer', status: 'completed' },
|
||||
])
|
||||
expect(repairedOwner?.agentId).toBe('builder@team-a')
|
||||
})
|
||||
|
||||
it('derives forming, running, finishing, and completed phases from real transitions', () => {
|
||||
expect(getWorkbenchPhase(snapshot([]))).toBe('forming')
|
||||
expect(getWorkbenchPhase(snapshot([task('1', 'in_progress')]))).toBe('running')
|
||||
|
||||
const finished = snapshot([task('1', 'completed'), task('2', 'completed')])
|
||||
expect(getWorkbenchPhase(finished)).toBe('finishing')
|
||||
expect(getWorkbenchProgress(finished)).toEqual({ completed: 2, total: 2, percent: 100 })
|
||||
|
||||
expect(getWorkbenchPhase({
|
||||
...finished,
|
||||
deletedAt: '2026-08-08T00:01:00.000Z',
|
||||
})).toBe('completed')
|
||||
})
|
||||
|
||||
it('narrates protocol payloads instead of leaking their raw JSON into the feed', () => {
|
||||
// This exact shape was rendering verbatim in the communication feed.
|
||||
const idle = parseWorkbenchMessageBody({
|
||||
text: '{"type":"idle_notification","from":"release-engineer","timestamp":"2026-08-08T07:42:16.666Z","idleReason":"available"}',
|
||||
})
|
||||
expect(idle).toEqual({ kind: 'lifecycle', type: 'idle_notification', detail: 'available' })
|
||||
|
||||
// protocolType wins over the body, and is enough on its own.
|
||||
expect(parseWorkbenchMessageBody({ text: 'shutting down', protocolType: 'shutdown_request' }))
|
||||
.toEqual({ kind: 'lifecycle', type: 'shutdown_request', detail: undefined })
|
||||
|
||||
// `TeamService.toWorkbenchMessage` replaces the assignment JSON body with
|
||||
// its subject while retaining the protocol metadata and routing fields.
|
||||
expect(parseWorkbenchMessageBody({
|
||||
text: 'Repair queue',
|
||||
protocolType: 'task_assignment',
|
||||
from: 'team-lead',
|
||||
recipients: ['builder'],
|
||||
})).toEqual({
|
||||
kind: 'assignment',
|
||||
selfClaim: false,
|
||||
subject: 'Repair queue',
|
||||
})
|
||||
expect(parseWorkbenchMessageBody({
|
||||
text: '{"type":"task_assignment","taskId":"7"}',
|
||||
protocolType: 'task_assignment',
|
||||
from: 'team-lead',
|
||||
recipients: ['builder'],
|
||||
})).toEqual({
|
||||
kind: 'assignment',
|
||||
selfClaim: false,
|
||||
taskId: '7',
|
||||
})
|
||||
|
||||
// Authored prose is never reclassified.
|
||||
expect(parseWorkbenchMessageBody({ text: 'Race condition confirmed in queue.ts' }))
|
||||
.toEqual({ kind: 'text', text: 'Race condition confirmed in queue.ts' })
|
||||
})
|
||||
|
||||
it('surfaces readable fields from unrecognised JSON rather than printing braces', () => {
|
||||
expect(parseWorkbenchMessageBody({ text: '{"type":"custom_event","message":"handoff ready"}' }))
|
||||
.toEqual({ kind: 'text', text: 'handoff ready' })
|
||||
|
||||
// Nothing prose-like inside: keep the payload rather than silently dropping it.
|
||||
const opaque = '{"type":"custom_event","count":3}'
|
||||
expect(parseWorkbenchMessageBody({ text: opaque })).toEqual({ kind: 'text', text: opaque })
|
||||
|
||||
// Malformed JSON must not throw or be mistaken for a protocol signal.
|
||||
expect(parseWorkbenchMessageBody({ text: '{not json' })).toEqual({ kind: 'text', text: '{not json' })
|
||||
})
|
||||
|
||||
it('stamps messages with their own send time, not a shared snapshot index', () => {
|
||||
const first = formatWorkbenchMessageTime('2026-08-08T07:42:16.666Z')
|
||||
const second = formatWorkbenchMessageTime('2026-08-08T09:15:00.000Z')
|
||||
expect(first).toBeTruthy()
|
||||
// Two messages inside one snapshot used to render an identical `T+0`.
|
||||
expect(first).not.toBe(second)
|
||||
expect(formatWorkbenchMessageTime('not-a-date')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,593 @@
|
||||
import { AGENT_LIFECYCLE_TYPES } from '../../types/team'
|
||||
import type {
|
||||
TeamMember,
|
||||
TeamWorkbenchMessage,
|
||||
TeamWorkbenchSnapshot,
|
||||
TeamWorkbenchTask,
|
||||
} from '../../types/team'
|
||||
|
||||
export type WorkbenchTaskState = 'blocked' | 'open' | 'running' | 'completed'
|
||||
export type WorkbenchPhase = 'forming' | 'running' | 'finishing' | 'completed'
|
||||
export type MemberAvatarKey =
|
||||
| 'team-lead'
|
||||
| 'server-engineer'
|
||||
| 'ui-designer'
|
||||
| 'qa-engineer'
|
||||
| 'security-reviewer'
|
||||
| 'data-analyst'
|
||||
| 'release-engineer'
|
||||
| 'docs-coordinator'
|
||||
|
||||
export type PositionedWorkbenchTask = {
|
||||
task: TeamWorkbenchTask
|
||||
state: WorkbenchTaskState
|
||||
/**
|
||||
* How many dependencies deep the task sits, counted along its longest path.
|
||||
* This is the number that describes where a task falls in the plan; the task
|
||||
* id only records the order the lead happened to write the tasks down. Each
|
||||
* depth owns one left-to-right lane regardless of that authored order.
|
||||
*/
|
||||
depth: number
|
||||
/** Stable vertical slot inside the dependency lane. */
|
||||
row: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type WorkbenchLane = {
|
||||
depth: number
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
count: number
|
||||
}
|
||||
|
||||
export type WorkbenchLayout = {
|
||||
tasks: PositionedWorkbenchTask[]
|
||||
byId: Map<string, PositionedWorkbenchTask>
|
||||
lanes: WorkbenchLane[]
|
||||
width: number
|
||||
height: number
|
||||
legendY: number
|
||||
columns: number
|
||||
}
|
||||
|
||||
const LANE_WIDTH = 216
|
||||
const LANE_GAP = 20
|
||||
const TASK_WIDTH = 200
|
||||
const TASK_HEIGHT = 92
|
||||
const HORIZONTAL_PADDING = 32
|
||||
const LANE_TOP = 410
|
||||
const TASK_TOP = 442
|
||||
const ROW_GAP = 14
|
||||
const LEGEND_GAP = 12
|
||||
const CANVAS_BOTTOM_PADDING = 46
|
||||
|
||||
const TASK_ID_COLLATOR = new Intl.Collator('en', {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
})
|
||||
|
||||
function compareTaskIds(left: string, right: string): number {
|
||||
const naturalOrder = TASK_ID_COLLATOR.compare(left, right)
|
||||
if (naturalOrder !== 0) return naturalOrder
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}
|
||||
|
||||
const WORKER_AVATARS: Array<{
|
||||
key: Exclude<MemberAvatarKey, 'team-lead'>
|
||||
matches: RegExp
|
||||
}> = [
|
||||
{ key: 'release-engineer', matches: /release|\bbuild\b|package|deploy|\bops\b|\bci\b|ship/ },
|
||||
{ key: 'security-reviewer', matches: /security|secure|audit|risk|threat|review/ },
|
||||
{ key: 'qa-engineer', matches: /\bqa\b|test|quality|verify|verification/ },
|
||||
{ key: 'ui-designer', matches: /\bui\b|\bux\b|frontend|desktop|design|theme|accessib/ },
|
||||
{ key: 'server-engineer', matches: /server|backend|\bapi\b|runtime|watcher|service|contract/ },
|
||||
{ key: 'data-analyst', matches: /data|research|analyst|replay|state|investigat|explor/ },
|
||||
{ key: 'docs-coordinator', matches: /docs?|product|spec|writer|coordinat/ },
|
||||
]
|
||||
|
||||
function stableHash(value: string): number {
|
||||
let hash = 0
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0
|
||||
}
|
||||
return Math.abs(hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a teammate's visual identity stable across snapshots while still
|
||||
* assigning the purpose-built occupational art when its role is known.
|
||||
*/
|
||||
export function getMemberAvatarKey(member: TeamMember, isLead = false): MemberAvatarKey {
|
||||
if (isLead) return 'team-lead'
|
||||
const identity = [member.name, member.role, member.agentId, member.currentTask]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
const matched = WORKER_AVATARS.find(({ matches }) => matches.test(identity))
|
||||
if (matched) return matched.key
|
||||
return WORKER_AVATARS[stableHash(member.agentId) % WORKER_AVATARS.length]!.key
|
||||
}
|
||||
|
||||
function identityAliases(value: string): string[] {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
const short = normalized.split('@')[0] ?? normalized
|
||||
return normalized === short ? [normalized] : [normalized, short]
|
||||
}
|
||||
|
||||
function sameTeamMember(left: TeamMember, right: TeamMember): boolean {
|
||||
const leftAliases = [
|
||||
...identityAliases(left.agentId),
|
||||
...(left.name ? identityAliases(left.name) : []),
|
||||
]
|
||||
const rightAliases = [
|
||||
...identityAliases(right.agentId),
|
||||
...(right.name ? identityAliases(right.name) : []),
|
||||
]
|
||||
return leftAliases.some(alias => rightAliases.includes(alias))
|
||||
}
|
||||
|
||||
/**
|
||||
* Team config is a mutable live roster, while the workbench is a run history.
|
||||
* Rebuild the roster by replaying snapshots so a teammate removed during
|
||||
* shutdown still owns its completed task and can reopen its transcript.
|
||||
*/
|
||||
export function snapshotWithHistoricalMembers(
|
||||
snapshots: TeamWorkbenchSnapshot[],
|
||||
selectedIndex: number,
|
||||
): TeamWorkbenchSnapshot | undefined {
|
||||
const selected = snapshots[selectedIndex]
|
||||
if (!selected) return undefined
|
||||
|
||||
let members: TeamMember[] = []
|
||||
for (const snapshot of snapshots.slice(0, selectedIndex + 1)) {
|
||||
const remaining = [...snapshot.team.members]
|
||||
members = members.map((historicalMember) => {
|
||||
const currentIndex = remaining.findIndex(member => sameTeamMember(historicalMember, member))
|
||||
if (currentIndex < 0) {
|
||||
return {
|
||||
...historicalMember,
|
||||
status: historicalMember.status === 'error' ? 'error' : 'completed',
|
||||
}
|
||||
}
|
||||
const [currentMember] = remaining.splice(currentIndex, 1)
|
||||
return { ...historicalMember, ...currentMember! }
|
||||
})
|
||||
members.push(...remaining)
|
||||
}
|
||||
|
||||
return {
|
||||
...selected,
|
||||
team: {
|
||||
...selected.team,
|
||||
members,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the same persisted teammate identity for the DAG, communication
|
||||
* feed, and member transcript. A sender can be serialized as either its bare
|
||||
* name or full `name@team` id, but it must keep one visual character.
|
||||
*/
|
||||
export function resolveTeamMemberIdentity(
|
||||
team: TeamWorkbenchSnapshot['team'],
|
||||
value: string,
|
||||
): { member: TeamMember; isLead: boolean } {
|
||||
const aliases = identityAliases(value)
|
||||
const member = team.members.find((candidate) => {
|
||||
const candidateAliases = [
|
||||
...identityAliases(candidate.agentId),
|
||||
...(candidate.name ? identityAliases(candidate.name) : []),
|
||||
]
|
||||
return aliases.some((alias) => candidateAliases.includes(alias))
|
||||
}) ?? {
|
||||
agentId: value,
|
||||
name: value,
|
||||
role: value,
|
||||
status: 'idle' as const,
|
||||
}
|
||||
const leadAliases = team.leadAgentId ? identityAliases(team.leadAgentId) : []
|
||||
const memberAliases = [
|
||||
...identityAliases(member.agentId),
|
||||
...(member.name ? identityAliases(member.name) : []),
|
||||
]
|
||||
const isLead = aliases.includes('team-lead')
|
||||
|| leadAliases.some((alias) => aliases.includes(alias) || memberAliases.includes(alias))
|
||||
|
||||
return { member, isLead }
|
||||
}
|
||||
|
||||
export type MemberWorkState = 'working' | 'idle' | 'stopped' | 'exited' | 'error'
|
||||
|
||||
/**
|
||||
* What the member itself is doing, which is never what its tasks say. A task
|
||||
* stays `in_progress` from the moment a teammate claims it until the teammate
|
||||
* remembers to close it -- across turn boundaries, and for umbrella tasks
|
||||
* across the whole run -- so reading activity off the task list reported every
|
||||
* member as permanently working.
|
||||
*
|
||||
* The lead has no runner writing turn markers for it, so its caller supplies
|
||||
* whether its session is streaming.
|
||||
*/
|
||||
export function getMemberWorkState(
|
||||
member: TeamMember,
|
||||
options: { isLead?: boolean; leadIsStreaming?: boolean } = {},
|
||||
): MemberWorkState {
|
||||
if (member.status === 'completed' || member.activity === 'exited') return 'exited'
|
||||
if (member.status === 'error') return 'error'
|
||||
if (options.isLead) return options.leadIsStreaming ? 'working' : 'idle'
|
||||
if (member.activity === 'active') return 'working'
|
||||
if (member.activity === 'idle') return 'idle'
|
||||
// `unknown` means the backend records no turn markers and left no transcript
|
||||
// to date, so fall back to the coarser roster status.
|
||||
return member.status === 'running' ? 'working' : 'idle'
|
||||
}
|
||||
|
||||
export type TaskOwnerAttribution = {
|
||||
identity: string
|
||||
/** True when the name was recovered from the mailbox rather than recorded. */
|
||||
inferred: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Who did a task. `task.owner` is authoritative, but runs archived before
|
||||
* batch-closed tasks recorded an owner left some finished work attributed to
|
||||
* nobody. The assignment envelope that reached a teammate's inbox names the
|
||||
* same person, so it recovers the answer for that history. The result is marked
|
||||
* inferred so the UI can present it as a reconstruction rather than as fact.
|
||||
*/
|
||||
export function inferTaskOwner(
|
||||
task: TeamWorkbenchTask,
|
||||
snapshot: TeamWorkbenchSnapshot,
|
||||
): TaskOwnerAttribution | undefined {
|
||||
const owner = task.owner?.trim()
|
||||
if (owner) return { identity: owner, inferred: false }
|
||||
|
||||
// Messages are stored in send order. A task can be reassigned before it is
|
||||
// completed, so the last matching envelope is the only one that describes
|
||||
// its final owner in this frame.
|
||||
for (let index = snapshot.messages.length - 1; index >= 0; index -= 1) {
|
||||
const assignment = snapshot.messages[index]!
|
||||
if (
|
||||
assignment.protocolType !== 'task_assignment' ||
|
||||
assignment.taskId !== task.id
|
||||
) continue
|
||||
const recipient = assignment.recipients[0]?.trim()
|
||||
if (recipient) return { identity: recipient, inferred: true }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function getWorkbenchTaskState(
|
||||
task: TeamWorkbenchTask,
|
||||
tasksById: Map<string, TeamWorkbenchTask>,
|
||||
): WorkbenchTaskState {
|
||||
if (task.status === 'completed') return 'completed'
|
||||
if (task.status === 'in_progress') return 'running'
|
||||
// A dependency the task list no longer contains was deleted, and the runtime
|
||||
// treats a blocker it cannot find as resolved -- it only refuses to claim a
|
||||
// task while a blocker is still open. Reading a missing blocker as unfinished
|
||||
// stranded such a task in `blocked` forever, and disagreed with `taskDepths`,
|
||||
// which has always ignored dependencies outside the list.
|
||||
const hasOpenDependency = task.blockedBy.some((dependencyId) => {
|
||||
const dependency = tasksById.get(dependencyId)
|
||||
return dependency !== undefined && dependency.status !== 'completed'
|
||||
})
|
||||
return hasOpenDependency ? 'blocked' : 'open'
|
||||
}
|
||||
|
||||
/** Longest dependency path with deterministic fallbacks for missing nodes/cycles. */
|
||||
function taskDepths(tasks: TeamWorkbenchTask[]): Map<string, number> {
|
||||
const byId = new Map(tasks.map((task) => [task.id, task]))
|
||||
const depths = new Map<string, number>()
|
||||
const visiting = new Set<string>()
|
||||
|
||||
const depthOf = (taskId: string): number => {
|
||||
const cached = depths.get(taskId)
|
||||
if (cached !== undefined) return cached
|
||||
if (visiting.has(taskId)) return 0
|
||||
const task = byId.get(taskId)
|
||||
if (!task) return 0
|
||||
|
||||
visiting.add(taskId)
|
||||
const dependencies = task.blockedBy
|
||||
.filter((dependencyId) => byId.has(dependencyId))
|
||||
.sort(compareTaskIds)
|
||||
const depth = dependencies.length === 0
|
||||
? 0
|
||||
: 1 + Math.max(...dependencies.map(depthOf))
|
||||
visiting.delete(taskId)
|
||||
depths.set(taskId, depth)
|
||||
return depth
|
||||
}
|
||||
|
||||
for (const task of tasks) depthOf(task.id)
|
||||
return depths
|
||||
}
|
||||
|
||||
export function layoutWorkbenchTasks(
|
||||
tasks: TeamWorkbenchTask[],
|
||||
requestedWidth: number,
|
||||
): WorkbenchLayout {
|
||||
const sortedTasks = tasks
|
||||
.map((task, index) => ({ task, index }))
|
||||
.sort((left, right) => (
|
||||
compareTaskIds(left.task.id, right.task.id) || left.index - right.index
|
||||
))
|
||||
.map(({ task }) => task)
|
||||
const depths = taskDepths(sortedTasks)
|
||||
const byLayer = new Map<number, TeamWorkbenchTask[]>()
|
||||
|
||||
for (const task of sortedTasks) {
|
||||
const depth = depths.get(task.id) ?? 0
|
||||
const layer = byLayer.get(depth)
|
||||
if (layer) layer.push(task)
|
||||
else byLayer.set(depth, [task])
|
||||
}
|
||||
|
||||
const columns = sortedTasks.length === 0
|
||||
? 0
|
||||
: Math.max(...Array.from(depths.values())) + 1
|
||||
const naturalWidth = HORIZONTAL_PADDING * 2
|
||||
+ columns * LANE_WIDTH
|
||||
+ Math.max(0, columns - 1) * LANE_GAP
|
||||
const minimumWidth = Number.isFinite(requestedWidth)
|
||||
? Math.max(0, Math.round(requestedWidth))
|
||||
: 0
|
||||
const width = Math.max(naturalWidth, minimumWidth)
|
||||
const startX = (width - naturalWidth) / 2 + HORIZONTAL_PADDING
|
||||
const maxRows = Math.max(0, ...Array.from(byLayer.values(), layer => layer.length))
|
||||
const taskStackHeight = maxRows === 0
|
||||
? 0
|
||||
: maxRows * TASK_HEIGHT + (maxRows - 1) * ROW_GAP
|
||||
const laneHeight = (TASK_TOP - LANE_TOP) * 2 + taskStackHeight
|
||||
const lanes: WorkbenchLane[] = Array.from({ length: columns }, (_, depth) => ({
|
||||
depth,
|
||||
x: startX + depth * (LANE_WIDTH + LANE_GAP),
|
||||
y: LANE_TOP,
|
||||
width: LANE_WIDTH,
|
||||
height: laneHeight,
|
||||
count: byLayer.get(depth)?.length ?? 0,
|
||||
}))
|
||||
|
||||
const tasksById = new Map(sortedTasks.map((task) => [task.id, task]))
|
||||
const positioned: PositionedWorkbenchTask[] = []
|
||||
for (const lane of lanes) {
|
||||
const layer = byLayer.get(lane.depth) ?? []
|
||||
layer.forEach((task, row) => {
|
||||
positioned.push({
|
||||
task,
|
||||
state: getWorkbenchTaskState(task, tasksById),
|
||||
depth: lane.depth,
|
||||
row,
|
||||
x: lane.x + (LANE_WIDTH - TASK_WIDTH) / 2,
|
||||
y: TASK_TOP + row * (TASK_HEIGHT + ROW_GAP),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
tasks: positioned,
|
||||
byId: new Map(positioned.map((task) => [task.task.id, task])),
|
||||
lanes,
|
||||
width,
|
||||
height: LANE_TOP + laneHeight + CANVAS_BOTTOM_PADDING,
|
||||
legendY: LANE_TOP + laneHeight + LEGEND_GAP,
|
||||
columns,
|
||||
}
|
||||
}
|
||||
|
||||
function memberNames(member: TeamMember): string[] {
|
||||
return [
|
||||
member.agentId,
|
||||
member.agentId.split('@')[0] ?? '',
|
||||
member.name ?? '',
|
||||
].filter(Boolean)
|
||||
}
|
||||
|
||||
export function taskOwnedByMember(
|
||||
task: TeamWorkbenchTask,
|
||||
member: TeamMember,
|
||||
): boolean {
|
||||
if (!task.owner) return false
|
||||
return memberNames(member).includes(task.owner)
|
||||
}
|
||||
|
||||
export function runningTaskForMember(
|
||||
tasks: TeamWorkbenchTask[],
|
||||
member: TeamMember,
|
||||
): TeamWorkbenchTask | undefined {
|
||||
return tasks.find((task) => task.status === 'in_progress' && taskOwnedByMember(task, member))
|
||||
}
|
||||
|
||||
/**
|
||||
* The one task a member is on right now, which is what decides where its
|
||||
* character stands on the map.
|
||||
*
|
||||
* A member routinely owns several open tasks at once: the umbrella task its
|
||||
* lead assigned stays `in_progress` for the whole run while the member works
|
||||
* through the smaller tasks it created underneath. Picking the first owned task
|
||||
* therefore parked every member on its umbrella, because the task list is
|
||||
* ordered by id and the umbrella was created first. Reading the snapshot
|
||||
* history instead -- which task most recently *became* `in_progress` -- follows
|
||||
* the member through its actual work.
|
||||
*/
|
||||
export function currentTaskForMember(
|
||||
snapshots: TeamWorkbenchSnapshot[],
|
||||
selectedIndex: number,
|
||||
member: TeamMember,
|
||||
): TeamWorkbenchTask | undefined {
|
||||
const selected = snapshots[selectedIndex]
|
||||
if (!selected) return undefined
|
||||
const running = selected.tasks.filter((task) => (
|
||||
task.status === 'in_progress' && taskOwnedByMember(task, member)
|
||||
))
|
||||
if (running.length <= 1) return running[0]
|
||||
|
||||
const startedBefore = new Set<string>()
|
||||
let latest: TeamWorkbenchTask | undefined
|
||||
for (const snapshot of snapshots.slice(0, selectedIndex + 1)) {
|
||||
for (const task of snapshot.tasks) {
|
||||
if (task.status !== 'in_progress') continue
|
||||
if (!startedBefore.has(task.id)) {
|
||||
startedBefore.add(task.id)
|
||||
const match = running.find((candidate) => candidate.id === task.id)
|
||||
if (match) latest = match
|
||||
}
|
||||
}
|
||||
}
|
||||
if (latest) return latest
|
||||
|
||||
// A timeline that starts mid-run (an archive opened cold) never witnessed the
|
||||
// transitions. A task that blocks nothing is a leaf the member is doing now,
|
||||
// whereas an umbrella task exists to hold others up.
|
||||
return running.find((task) => task.blocks.length === 0) ??
|
||||
running[running.length - 1]
|
||||
}
|
||||
|
||||
export function getWorkbenchPhase(snapshot: TeamWorkbenchSnapshot): WorkbenchPhase {
|
||||
if (snapshot.deletedAt) return 'completed'
|
||||
if (snapshot.tasks.length === 0) return 'forming'
|
||||
if (snapshot.tasks.every((task) => task.status === 'completed')) return 'finishing'
|
||||
return 'running'
|
||||
}
|
||||
|
||||
export function getWorkbenchProgress(snapshot: TeamWorkbenchSnapshot) {
|
||||
const total = snapshot.tasks.length
|
||||
const completed = snapshot.tasks.filter((task) => task.status === 'completed').length
|
||||
return {
|
||||
completed,
|
||||
total,
|
||||
percent: total === 0 ? 0 : Math.round((completed / total) * 100),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A workbench message is either something a teammate wrote or a protocol
|
||||
* signal the runtime emitted. The feed used to print the latter as raw JSON
|
||||
* (`{"type":"idle_notification",...}`), which is both unreadable and the
|
||||
* opposite of what the transcript does — `extractVisibleTeammateMessageContents`
|
||||
* has always dropped these payloads from chat. Classifying here lets the feed
|
||||
* render a sentence and de-emphasise it instead.
|
||||
*/
|
||||
export type WorkbenchMessageBody =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'assignment'; taskId?: string; subject?: string; selfClaim: boolean }
|
||||
| { kind: 'lifecycle'; type: string; detail?: string }
|
||||
|
||||
/**
|
||||
* Protocol payloads the feed states in words rather than dumping verbatim.
|
||||
*
|
||||
* `task_assignment` is deliberately absent: it records a task being picked up,
|
||||
* which is the first half of everything a team does. Filing it with shutdown
|
||||
* and idle chatter hid it behind a collapsed toggle and left the feed reporting
|
||||
* "0 messages" for a team that had just handed out all of its work.
|
||||
*/
|
||||
const NARRATED_PROTOCOL_TYPES = new Set([
|
||||
...AGENT_LIFECYCLE_TYPES,
|
||||
'shutdown_response',
|
||||
])
|
||||
|
||||
const LIFECYCLE_DETAIL_FIELDS = ['idleReason', 'reason', 'detail', 'message'] as const
|
||||
const READABLE_BODY_FIELDS = ['message', 'content', 'text', 'summary', 'reason'] as const
|
||||
|
||||
function parseJsonObject(raw: string): Record<string, unknown> | null {
|
||||
if (!raw.startsWith('{') || !raw.endsWith('}')) return null
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? parsed as Record<string, unknown>
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function firstNonEmptyString(
|
||||
record: Record<string, unknown>,
|
||||
fields: readonly string[],
|
||||
): string | undefined {
|
||||
for (const field of fields) {
|
||||
const value = record[field]
|
||||
if (typeof value === 'string' && value.trim()) return value.trim()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function parseWorkbenchMessageBody(
|
||||
message: Pick<TeamWorkbenchMessage, 'text' | 'protocolType'> &
|
||||
Partial<Pick<TeamWorkbenchMessage, 'from' | 'recipients'>>,
|
||||
): WorkbenchMessageBody {
|
||||
const raw = message.text?.trim() ?? ''
|
||||
const senderAliases = identityAliases(message.from ?? '')
|
||||
const selfClaim = Boolean(message.recipients?.some((recipient) => (
|
||||
identityAliases(recipient).some((alias) => senderAliases.includes(alias))
|
||||
)))
|
||||
const payload = parseJsonObject(raw)
|
||||
const payloadType = typeof payload?.type === 'string' ? payload.type : undefined
|
||||
const type = message.protocolType ?? payloadType
|
||||
|
||||
if (type === 'task_assignment') {
|
||||
// A teammate that claims its own next task addresses the envelope to
|
||||
// itself, which is what separates picking work up from being handed it.
|
||||
// The server projects a structured assignment to `text = subject` while
|
||||
// retaining `protocolType`; archives can still contain the original JSON.
|
||||
const structuredSubject = payloadType === 'task_assignment' && typeof payload?.subject === 'string'
|
||||
? payload.subject.trim()
|
||||
: ''
|
||||
const subject = structuredSubject || (
|
||||
message.protocolType === 'task_assignment' && payloadType !== 'task_assignment'
|
||||
? raw
|
||||
: ''
|
||||
)
|
||||
return {
|
||||
kind: 'assignment',
|
||||
selfClaim,
|
||||
...(typeof payload?.taskId === 'string' ? { taskId: payload.taskId } : {}),
|
||||
...(subject ? { subject } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
if (type && NARRATED_PROTOCOL_TYPES.has(type)) {
|
||||
return {
|
||||
kind: 'lifecycle',
|
||||
type,
|
||||
...(payload ? { detail: firstNonEmptyString(payload, LIFECYCLE_DETAIL_FIELDS) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
// An unrecognised JSON payload still beats raw braces: surface whichever
|
||||
// field actually carries prose, and only fall back to the literal text when
|
||||
// nothing readable is in there.
|
||||
if (payload) {
|
||||
return { kind: 'text', text: firstNonEmptyString(payload, READABLE_BODY_FIELDS) ?? raw }
|
||||
}
|
||||
return { kind: 'text', text: raw }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall-clock time the message was sent. The feed previously stamped every row
|
||||
* with `T+{snapshotIndex}`, which is identical for every message in a snapshot
|
||||
* and therefore carries no information at all.
|
||||
*/
|
||||
export function formatWorkbenchMessageTime(timestamp: string): string {
|
||||
const time = new Date(timestamp)
|
||||
return Number.isNaN(time.getTime())
|
||||
? ''
|
||||
: time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export function memberInitials(member: TeamMember): string {
|
||||
const source = member.name || member.role || member.agentId.split('@')[0] || 'AG'
|
||||
const words = source.split(/[^\p{L}\p{N}]+/u).filter(Boolean)
|
||||
return (words.length > 1
|
||||
? words.slice(0, 2).map((word) => word[0]).join('')
|
||||
: source.slice(0, 2)
|
||||
).toUpperCase()
|
||||
}
|
||||
|
||||
export const WORKBENCH_TASK_WIDTH = TASK_WIDTH
|
||||
export const WORKBENCH_TASK_HEIGHT = TASK_HEIGHT
|
||||
@@ -0,0 +1,304 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { ActivityGroup } from './ActivityGroup'
|
||||
import { buildActivitySegments, type ActivityStep } from './activityGroupModel'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { translate } from '../../i18n'
|
||||
import type { UIMessage } from '../../types/chat'
|
||||
|
||||
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
|
||||
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
|
||||
|
||||
function toolCall(overrides: Partial<ToolCall> & Pick<ToolCall, 'id' | 'toolUseId' | 'toolName'>): ToolCall {
|
||||
return {
|
||||
type: 'tool_use',
|
||||
input: {},
|
||||
timestamp: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function toolResult(overrides: Partial<ToolResult> & Pick<ToolResult, 'id' | 'toolUseId'>): ToolResult {
|
||||
return {
|
||||
type: 'tool_result',
|
||||
content: 'ok',
|
||||
isError: false,
|
||||
timestamp: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function thinkingStep(id: string, content: string, timestamp = 0): ActivityStep {
|
||||
return { kind: 'thinking', message: { id, type: 'thinking', content, timestamp } }
|
||||
}
|
||||
|
||||
function resultsOf(results: ToolResult[]): Map<string, ToolResult> {
|
||||
return new Map(results.map((result) => [result.toolUseId, result]))
|
||||
}
|
||||
|
||||
const t = (key: Parameters<typeof translate>[1], params?: Record<string, string | number>) =>
|
||||
translate('en', key, params)
|
||||
|
||||
describe('ActivityGroup', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
})
|
||||
|
||||
const readCall = toolCall({
|
||||
id: 'use-read',
|
||||
toolUseId: 'read-1',
|
||||
toolName: 'Read',
|
||||
input: { file_path: '/repo/src/MessageList.tsx' },
|
||||
timestamp: 1_000,
|
||||
})
|
||||
const bashCall = toolCall({
|
||||
id: 'use-bash',
|
||||
toolUseId: 'bash-1',
|
||||
toolName: 'Bash',
|
||||
input: { command: 'bun run lint' },
|
||||
timestamp: 2_000,
|
||||
})
|
||||
|
||||
it('plays open while live, then folds itself into a counted summary', () => {
|
||||
const steps: ActivityStep[] = [
|
||||
thinkingStep('think-1', '- Locate the five call sites first.\nThen patch each file.', 500),
|
||||
thinkingStep('think-2', 'Then check the retry path.', 800),
|
||||
{ kind: 'tool', toolCall: readCall },
|
||||
{ kind: 'tool', toolCall: bashCall },
|
||||
]
|
||||
const settled = resultsOf([
|
||||
toolResult({ id: 'res-read', toolUseId: 'read-1', timestamp: 2_400 }),
|
||||
toolResult({ id: 'res-bash', toolUseId: 'bash-1', timestamp: 5_700 }),
|
||||
])
|
||||
|
||||
const { rerender } = render(
|
||||
<ActivityGroup steps={steps} resultMap={settled} childToolCallsByParent={new Map()} isLive />,
|
||||
)
|
||||
let group = screen.getByTestId('activity-group')
|
||||
expect(group.getAttribute('data-expanded')).toBe('true')
|
||||
expect(within(group).getByText('MessageList.tsx')).toBeTruthy()
|
||||
|
||||
// The turn moves on: rows fold away and the counted digest stands in for
|
||||
// them, so finished machinery stops competing with the prose around it.
|
||||
rerender(
|
||||
<ActivityGroup steps={steps} resultMap={settled} childToolCallsByParent={new Map()} isLive={false} />,
|
||||
)
|
||||
group = screen.getByTestId('activity-group')
|
||||
expect(group.getAttribute('data-expanded')).toBe('false')
|
||||
expect(within(group).queryByText('MessageList.tsx')).toBeNull()
|
||||
|
||||
// Counted per family, thinking included — scale is the point of the line.
|
||||
const summary = group.querySelector('[data-chat-disclosure="true"]')!
|
||||
expect(summary.textContent).toContain('Thought 2 times')
|
||||
expect(summary.textContent).toContain('Read 1 file')
|
||||
expect(summary.textContent).toContain('ran a command')
|
||||
})
|
||||
|
||||
it('stays open across the gaps between one tool resolving and the next starting', () => {
|
||||
// The bug this pins: a live run folds and reopens on every step boundary,
|
||||
// because "a tool is executing right now" goes false in each gap. Six tools
|
||||
// meant six open/shut cycles under a reader trying to watch the work.
|
||||
const steps: ActivityStep[] = [
|
||||
{ kind: 'tool', toolCall: readCall },
|
||||
{ kind: 'tool', toolCall: bashCall },
|
||||
]
|
||||
const renderWith = (resultMap: ReturnType<typeof resultsOf>) => (
|
||||
<ActivityGroup steps={steps} resultMap={resultMap} childToolCallsByParent={new Map()} isLive />
|
||||
)
|
||||
|
||||
const { rerender } = render(renderWith(resultsOf([])))
|
||||
expect(screen.getByTestId('activity-group').getAttribute('data-expanded')).toBe('true')
|
||||
|
||||
for (const resultMap of [
|
||||
// Everything resolved — the lull the group used to collapse into.
|
||||
resultsOf([
|
||||
toolResult({ id: 'res-read', toolUseId: 'read-1', timestamp: 2_400 }),
|
||||
toolResult({ id: 'res-bash', toolUseId: 'bash-1', timestamp: 5_700 }),
|
||||
]),
|
||||
// The next step starts, and the cycle would have repeated.
|
||||
resultsOf([toolResult({ id: 'res-read', toolUseId: 'read-1', timestamp: 2_400 })]),
|
||||
]) {
|
||||
rerender(renderWith(resultMap))
|
||||
expect(screen.getByTestId('activity-group').getAttribute('data-expanded')).toBe('true')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the reader\'s choice once they open a settled run', () => {
|
||||
const settled = {
|
||||
steps: [
|
||||
thinkingStep('think-1', 'Check the call sites.', 500),
|
||||
{ kind: 'tool' as const, toolCall: readCall },
|
||||
],
|
||||
resultMap: resultsOf([toolResult({ id: 'res-read', toolUseId: 'read-1', timestamp: 2_400 })]),
|
||||
childToolCallsByParent: new Map(),
|
||||
}
|
||||
|
||||
const { rerender } = render(<ActivityGroup {...settled} />)
|
||||
const group = screen.getByTestId('activity-group')
|
||||
expect(group.getAttribute('data-expanded')).toBe('false')
|
||||
|
||||
fireEvent.click(group.querySelector('[data-chat-disclosure="true"]')!)
|
||||
expect(screen.getByTestId('activity-group').getAttribute('data-expanded')).toBe('true')
|
||||
|
||||
// A later refresh must not snap it shut again under the reader.
|
||||
rerender(<ActivityGroup {...settled} />)
|
||||
expect(screen.getByTestId('activity-group').getAttribute('data-expanded')).toBe('true')
|
||||
|
||||
fireEvent.click(screen.getByTestId('activity-group').querySelector('[data-chat-disclosure="true"]')!)
|
||||
expect(screen.getByTestId('activity-group').getAttribute('data-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('keeps each row openable for its own detail', () => {
|
||||
render(
|
||||
<ActivityGroup
|
||||
steps={[{ kind: 'tool', toolCall: bashCall }, { kind: 'tool', toolCall: readCall }]}
|
||||
resultMap={resultsOf([
|
||||
toolResult({ id: 'res-bash', toolUseId: 'bash-1', content: 'ok', timestamp: 3_000 }),
|
||||
toolResult({ id: 'res-read', toolUseId: 'read-1', timestamp: 1_400 }),
|
||||
])}
|
||||
childToolCallsByParent={new Map()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const group = screen.getByTestId('activity-group')
|
||||
// Settled, so it starts folded; open the run before reaching for a row.
|
||||
fireEvent.click(group.querySelector('[data-chat-disclosure="true"]')!)
|
||||
expect(group.querySelectorAll('[data-tool-call-details]')).toHaveLength(0)
|
||||
|
||||
fireEvent.click(within(group).getByText('bun run lint'))
|
||||
|
||||
// Opening one row reveals that row's detail and leaves the others closed.
|
||||
expect(group.querySelectorAll('[data-tool-call-details]')).toHaveLength(1)
|
||||
expect(within(group).getByText('MessageList.tsx')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('makes a lone tool call its own header row rather than hiding it behind a summary', () => {
|
||||
render(
|
||||
<ActivityGroup
|
||||
steps={[{ kind: 'tool', toolCall: readCall }]}
|
||||
resultMap={resultsOf([toolResult({ id: 'res-read', toolUseId: 'read-1', timestamp: 2_400 })])}
|
||||
childToolCallsByParent={new Map()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const group = screen.getByTestId('activity-group')
|
||||
expect(group.getAttribute('data-single-step')).toBe('true')
|
||||
// The file is readable without a click; "Read 1 file" would say less.
|
||||
expect(within(group).getByText('MessageList.tsx')).toBeTruthy()
|
||||
expect(within(group).queryByText('Read 1 file')).toBeNull()
|
||||
})
|
||||
|
||||
it('counts failed steps in the header while the run still reports its duration', () => {
|
||||
render(
|
||||
<ActivityGroup
|
||||
steps={[
|
||||
{ kind: 'tool', toolCall: readCall },
|
||||
{ kind: 'tool', toolCall: bashCall },
|
||||
]}
|
||||
resultMap={resultsOf([
|
||||
toolResult({ id: 'res-read', toolUseId: 'read-1', timestamp: 1_400 }),
|
||||
toolResult({
|
||||
id: 'res-bash',
|
||||
toolUseId: 'bash-1',
|
||||
content: 'IndentationError',
|
||||
isError: true,
|
||||
timestamp: 3_000,
|
||||
}),
|
||||
])}
|
||||
childToolCallsByParent={new Map()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(t('toolGroup.failedCount', { count: 1 }))).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reports the run as running while any step is still unresolved', () => {
|
||||
render(
|
||||
<ActivityGroup
|
||||
steps={[
|
||||
{ kind: 'tool', toolCall: readCall },
|
||||
{ kind: 'tool', toolCall: bashCall },
|
||||
]}
|
||||
resultMap={resultsOf([toolResult({ id: 'res-read', toolUseId: 'read-1', timestamp: 1_400 })])}
|
||||
childToolCallsByParent={new Map()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// There is no whole-run duration to get wrong any more: each row times
|
||||
// itself, so an unfinished step simply has no time yet rather than
|
||||
// suppressing a total that covered finished steps too.
|
||||
expect(screen.getByTestId('activity-group').getAttribute('data-running')).toBe('true')
|
||||
})
|
||||
|
||||
it('reports a run as finished once every step has a result', () => {
|
||||
render(
|
||||
<ActivityGroup
|
||||
steps={[
|
||||
{ kind: 'tool', toolCall: readCall },
|
||||
{ kind: 'tool', toolCall: bashCall },
|
||||
]}
|
||||
resultMap={resultsOf([
|
||||
toolResult({ id: 'res-read', toolUseId: 'read-1', timestamp: 1_400 }),
|
||||
toolResult({ id: 'res-bash', toolUseId: 'bash-1', timestamp: 3_000 }),
|
||||
])}
|
||||
childToolCallsByParent={new Map()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('activity-group').getAttribute('data-running')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildActivitySegments', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
})
|
||||
|
||||
it('keeps families in first-appearance order and counts repeats', () => {
|
||||
const segments = buildActivitySegments(
|
||||
[
|
||||
{ kind: 'tool', toolCall: toolCall({ id: 'a', toolUseId: 'a', toolName: 'Bash' }) },
|
||||
{ kind: 'tool', toolCall: toolCall({ id: 'b', toolUseId: 'b', toolName: 'Read' }) },
|
||||
{ kind: 'tool', toolCall: toolCall({ id: 'c', toolUseId: 'c', toolName: 'Read' }) },
|
||||
],
|
||||
t,
|
||||
)
|
||||
|
||||
expect(segments.map((segment) => segment.label)).toEqual(['ran a command', 'Read 2 files'])
|
||||
})
|
||||
|
||||
it('places thinking where it happened and counts it like any other family', () => {
|
||||
const segments = buildActivitySegments(
|
||||
[
|
||||
{ kind: 'tool', toolCall: toolCall({ id: 'a', toolUseId: 'a', toolName: 'Bash' }) },
|
||||
thinkingStep('t1', 'first'),
|
||||
thinkingStep('t2', 'second'),
|
||||
],
|
||||
t,
|
||||
)
|
||||
|
||||
// Counted now that this line is all a settled run shows: going back and
|
||||
// forth twice is a different run from going straight through, and that is
|
||||
// exactly the kind of shape the summary exists to convey.
|
||||
expect(segments.map((segment) => segment.label)).toEqual(['ran a command', 'Thought 2 times'])
|
||||
})
|
||||
|
||||
it('leaves a single thought uncounted', () => {
|
||||
const segments = buildActivitySegments([thinkingStep('t1', 'only once')], t)
|
||||
expect(segments.map((segment) => segment.label)).toEqual(['Thinking'])
|
||||
})
|
||||
|
||||
it('names a verbless tool rather than hiding it behind a generic count', () => {
|
||||
const segments = buildActivitySegments(
|
||||
[
|
||||
{ kind: 'tool', toolCall: toolCall({ id: 'a', toolUseId: 'a', toolName: 'TaskUpdate' }) },
|
||||
{ kind: 'tool', toolCall: toolCall({ id: 'b', toolUseId: 'b', toolName: 'SendMessage' }) },
|
||||
{ kind: 'tool', toolCall: toolCall({ id: 'c', toolUseId: 'c', toolName: 'SendMessage' }) },
|
||||
],
|
||||
t,
|
||||
)
|
||||
|
||||
// Which tool ran is the point; a single call drops the redundant "(1)".
|
||||
expect(segments.map((segment) => segment.label)).toEqual(['TaskUpdate', 'SendMessage (2)'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import { CircleX } from 'lucide-react'
|
||||
import { ToolCallBlock, formatDuration } from './ToolCallBlock'
|
||||
import { ThinkingBlock } from './ThinkingBlock'
|
||||
import {
|
||||
activityDurationMs,
|
||||
activityStepToolCalls,
|
||||
buildActivitySegments,
|
||||
countFailedToolCalls,
|
||||
hasUnresolvedToolCalls,
|
||||
toolCallDurationMs,
|
||||
type ActivityStep,
|
||||
} from './activityGroupModel'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { UIMessage } from '../../types/chat'
|
||||
|
||||
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
|
||||
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
|
||||
|
||||
type Props = {
|
||||
steps: ActivityStep[]
|
||||
resultMap: Map<string, ToolResult>
|
||||
childToolCallsByParent: Map<string, ToolCall[]>
|
||||
activeThinkingId?: string | null
|
||||
/** When true, the last step is still executing. */
|
||||
isStreaming?: boolean
|
||||
/**
|
||||
* This run is the tail of a turn that is still going, so more steps may land
|
||||
* in it. Distinct from `isStreaming`: that one dips false in the gap between
|
||||
* one tool resolving and the next starting, many times inside a single run.
|
||||
*/
|
||||
isLive?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* One contiguous run of thinking + tool calls.
|
||||
*
|
||||
* It plays open while it runs, so the reader can watch the work, then folds
|
||||
* itself into a single counted line the moment it finishes: `thought 5 times,
|
||||
* read 2 files, ran 1 command`. Standing rows open forever flattened the
|
||||
* transcript — machinery took as much room as the sentences it produced, and
|
||||
* nothing looked more important than anything else. A counted digest is small
|
||||
* enough to skip and specific enough to be worth reading, which a vaguer
|
||||
* "ran some commands" never was.
|
||||
*
|
||||
* Clicking pins the reader's choice: from then on that run stays as they left
|
||||
* it, instead of snapping shut under them when the next step resolves.
|
||||
*
|
||||
* A run holding exactly one tool call never summarises: "Read 1 file" over a
|
||||
* hidden `Read MessageList.tsx` row is strictly less than the row itself.
|
||||
*/
|
||||
export const ActivityGroup = memo(function ActivityGroup({
|
||||
steps,
|
||||
resultMap,
|
||||
childToolCallsByParent,
|
||||
activeThinkingId,
|
||||
isStreaming,
|
||||
isLive = false,
|
||||
}: Props) {
|
||||
const t = useTranslation()
|
||||
/** null = follow the run's own state; set = the reader decided. */
|
||||
const [pinnedCollapsed, setPinnedCollapsed] = useState<boolean | null>(null)
|
||||
|
||||
const toolCalls = useMemo(() => activityStepToolCalls(steps), [steps])
|
||||
const failedCount = countFailedToolCalls(toolCalls, resultMap, childToolCallsByParent)
|
||||
const hasActiveThinking = Boolean(activeThinkingId) && steps.some(
|
||||
(step) => step.kind === 'thinking' && step.message.id === activeThinkingId,
|
||||
)
|
||||
const isRunning =
|
||||
Boolean(isStreaming) ||
|
||||
hasActiveThinking ||
|
||||
hasUnresolvedToolCalls(toolCalls, resultMap, childToolCallsByParent)
|
||||
// Keyed off "still being written to", never off "a tool is executing right
|
||||
// now". The latter flickers: a run of six tools resolves and restarts six
|
||||
// times, and folding on each gap made the whole block open and shut under the
|
||||
// reader while they were trying to watch it.
|
||||
const collapsed = pinnedCollapsed ?? !isLive
|
||||
|
||||
const soleToolCall = steps.length === 1 && steps[0]?.kind === 'tool' ? steps[0].toolCall : null
|
||||
if (soleToolCall) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
data-testid="activity-group"
|
||||
data-single-step="true"
|
||||
>
|
||||
<ActivityToolRow
|
||||
toolCall={soleToolCall}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const segments = buildActivitySegments(steps, t)
|
||||
const elapsed = activityDurationMs(steps, resultMap)
|
||||
const durationLabel = !isRunning && typeof elapsed === 'number' ? formatDuration(elapsed) : ''
|
||||
const summaryText = segments.map((segment) => segment.label).join(', ')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
data-testid="activity-group"
|
||||
data-expanded={collapsed ? 'false' : 'true'}
|
||||
data-running={isRunning ? 'true' : 'false'}
|
||||
className="rounded-[var(--radius-md)]"
|
||||
>
|
||||
{/*
|
||||
Deliberately a whisper: 12px, tertiary, no icons, commas rather than
|
||||
middots. Its whole job is to be skippable — the prose above and below
|
||||
it is the conversation, and anything with weight here competes with
|
||||
that. The counts are what make it worth having at all.
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
data-chat-disclosure="true"
|
||||
onClick={() => setPinnedCollapsed(!collapsed)}
|
||||
aria-expanded={!collapsed}
|
||||
title={summaryText}
|
||||
className="-mx-2 flex w-[calc(100%+1rem)] items-center gap-2 rounded-[var(--radius-md)] px-2 py-1 text-left text-[12px] leading-[1.6] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-secondary)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{summaryText}</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
{failedCount > 0 && (
|
||||
<span className="flex items-center gap-[5px] whitespace-nowrap font-medium text-[var(--color-error)]">
|
||||
<CircleX size={12} strokeWidth={2} aria-hidden="true" />
|
||||
{t('toolGroup.failedCount', { count: failedCount })}
|
||||
</span>
|
||||
)}
|
||||
{durationLabel && (
|
||||
<span className="whitespace-nowrap font-mono tabular-nums">{durationLabel}</span>
|
||||
)}
|
||||
<span aria-hidden="true" className={`w-3 text-center text-[8px] ${collapsed ? '' : 'rotate-90'}`}>▸</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Rows hang off the summary that names them, so they take the guide
|
||||
line and its indent. That is also why they can afford it here and not
|
||||
when there is no summary — nothing to hang from, nothing to indent. */}
|
||||
{!collapsed && (
|
||||
<div className="ml-[3px] flex flex-col border-l border-[var(--color-border)] pl-3">
|
||||
{steps.map((step) => step.kind === 'thinking' ? (
|
||||
<ThinkingBlock
|
||||
key={step.message.id}
|
||||
content={step.message.content}
|
||||
isActive={step.message.id === activeThinkingId}
|
||||
/>
|
||||
) : (
|
||||
<ActivityToolRow
|
||||
key={step.toolCall.id}
|
||||
toolCall={step.toolCall}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/** A tool row plus, indented under it, the rows of anything it dispatched. */
|
||||
function ActivityToolRow({
|
||||
toolCall,
|
||||
resultMap,
|
||||
childToolCallsByParent,
|
||||
}: {
|
||||
toolCall: ToolCall
|
||||
resultMap: Map<string, ToolResult>
|
||||
childToolCallsByParent: Map<string, ToolCall[]>
|
||||
}) {
|
||||
const result = resultMap.get(toolCall.toolUseId)
|
||||
const childToolCalls = childToolCallsByParent.get(toolCall.toolUseId) ?? []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ToolCallBlock
|
||||
chrome="row"
|
||||
toolName={toolCall.toolName}
|
||||
input={toolCall.input}
|
||||
result={result ? { content: result.content, isError: result.isError } : null}
|
||||
isPending={toolCall.isPending}
|
||||
status={toolCall.status}
|
||||
partialInput={toolCall.partialInput}
|
||||
durationMs={toolCallDurationMs(toolCall, result)}
|
||||
/>
|
||||
{childToolCalls.length > 0 && (
|
||||
<div className="ml-2 border-l border-[var(--color-border)] pl-3">
|
||||
{childToolCalls.map((childToolCall) => (
|
||||
<ActivityToolRow
|
||||
key={childToolCall.id}
|
||||
toolCall={childToolCall}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -245,7 +245,7 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props)
|
||||
if (!activeQuestion) return null
|
||||
|
||||
return (
|
||||
<div className={`mb-4 rounded-[var(--radius-lg)] border overflow-hidden ${
|
||||
<div className={`rounded-[var(--radius-lg)] border overflow-hidden ${
|
||||
submitted
|
||||
? 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] opacity-70'
|
||||
: 'border-[var(--color-secondary)] bg-[var(--color-surface-container-lowest)]'
|
||||
|
||||
@@ -12,14 +12,16 @@ vi.mock('../../lib/desktopRuntime', async (orig) => ({
|
||||
}))
|
||||
|
||||
const ensureTargets = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
|
||||
const getTargetsForPath = vi.hoisted(() => vi.fn())
|
||||
const openTargetFn = vi.hoisted(() => vi.fn())
|
||||
const openTargets = vi.hoisted(() => [
|
||||
{ id: 'code', kind: 'ide', label: 'VS Code', icon: '', platform: 'darwin' },
|
||||
{ id: 'finder', kind: 'file_manager', label: 'Finder', icon: '', platform: 'darwin' },
|
||||
])
|
||||
getTargetsForPath.mockResolvedValue(openTargets)
|
||||
vi.mock('../../stores/openTargetStore', () => ({
|
||||
useOpenTargetStore: {
|
||||
getState: () => ({ ensureTargets, targets: openTargets, openTarget: openTargetFn }),
|
||||
getState: () => ({ ensureTargets, getTargetsForPath, targets: openTargets, openTarget: openTargetFn }),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -63,6 +65,7 @@ import { AssistantMessage } from './AssistantMessage'
|
||||
afterEach(() => {
|
||||
openBrowser.mockReset()
|
||||
ensureTargets.mockReset().mockResolvedValue(undefined)
|
||||
getTargetsForPath.mockReset().mockResolvedValue(openTargets)
|
||||
openTargetFn.mockReset()
|
||||
openPreviewFn.mockReset().mockResolvedValue(undefined)
|
||||
copyTextToClipboard.mockReset().mockResolvedValue(true)
|
||||
@@ -95,7 +98,7 @@ describe('AssistantMessage file references', () => {
|
||||
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument())
|
||||
const labels = screen.getAllByRole('menuitem').map((el) => el.textContent)
|
||||
expect(labels).toContain('openWith.openInTarget:VS Code')
|
||||
expect(labels).toContain('openWith.revealInTarget:Finder')
|
||||
expect(labels).toContain('openWith.revealIn.darwin')
|
||||
expect(labels).toContain('openWith.copyPath')
|
||||
expect(labels).toContain('openWith.copyFileContent')
|
||||
})
|
||||
|
||||
@@ -169,6 +169,46 @@ describe('AssistantMessage output-target cards', () => {
|
||||
expect(screen.queryByText('assistantOutputs.kind.image')).toBeNull()
|
||||
})
|
||||
|
||||
it('hands a Markdown image to the inline gallery without leaving a source-less duplicate', () => {
|
||||
const { container, rerender } = render(
|
||||
<AssistantMessage
|
||||
sessionId="s1"
|
||||
content={''}
|
||||
isStreaming
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('img')).toHaveLength(0)
|
||||
|
||||
rerender(
|
||||
<AssistantMessage
|
||||
sessionId="s1"
|
||||
content={''}
|
||||
isStreaming={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
const images = Array.from(container.querySelectorAll('img'))
|
||||
expect(images).toHaveLength(1)
|
||||
expect(images[0]).toHaveAttribute(
|
||||
'src',
|
||||
'http://127.0.0.1:4321/preview-fs/s1/outputs/foo/preview_frame.png',
|
||||
)
|
||||
expect(container.querySelector('img:not([src])')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not leave image nodes for remote or loopback sources rejected by assistant Markdown', () => {
|
||||
const { container } = render(
|
||||
<AssistantMessage
|
||||
sessionId="s1"
|
||||
content={' '}
|
||||
isStreaming={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('img')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders a relative video inline (a <video>) but NOT as a card', () => {
|
||||
const { container } = render(
|
||||
<AssistantMessage
|
||||
|
||||
@@ -21,17 +21,26 @@ type Props = {
|
||||
isStreaming?: boolean
|
||||
branchAction?: MessageBranchAction
|
||||
sessionId?: string
|
||||
timestamp?: number
|
||||
/** This turn's real changed files (absolute), used to anchor output chips onto
|
||||
* files that were actually written instead of guessing from the prose. */
|
||||
turnChangedFiles?: string[]
|
||||
/** Only one assistant message per turn owns fallback cards for unmentioned changed files. */
|
||||
isTurnOutputOwner?: boolean
|
||||
/** Set only on the last reply of a finished turn: when it ended and how long it took. */
|
||||
turnCompletion?: TurnCompletion
|
||||
}
|
||||
|
||||
const MAX_CARDS = 3
|
||||
|
||||
export const AssistantMessage = memo(function AssistantMessage({ content, isStreaming, branchAction, sessionId, timestamp, turnChangedFiles, turnCompletion }: Props) {
|
||||
export const AssistantMessage = memo(function AssistantMessage({
|
||||
content,
|
||||
isStreaming,
|
||||
branchAction,
|
||||
sessionId,
|
||||
turnChangedFiles,
|
||||
isTurnOutputOwner = true,
|
||||
turnCompletion,
|
||||
}: Props) {
|
||||
const t = useTranslation()
|
||||
const workDir = useWorkspacePanelStore((s) => (sessionId ? s.statusBySession[sessionId]?.workDir : undefined))
|
||||
|
||||
@@ -79,10 +88,14 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
|
||||
isStreaming || !sessionId
|
||||
? []
|
||||
: // Image/video targets render inline (InlineImageGallery/InlineVideoGallery); never also as a card.
|
||||
extractAssistantOutputTargets(content, { workDir, changedFiles: turnChangedFiles }).filter(
|
||||
extractAssistantOutputTargets(content, {
|
||||
workDir,
|
||||
changedFiles: turnChangedFiles,
|
||||
includeChangedFileFallback: isTurnOutputOwner,
|
||||
}).filter(
|
||||
(target) => target.kind !== 'image' && target.kind !== 'video',
|
||||
),
|
||||
[content, isStreaming, sessionId, workDir, turnChangedFiles],
|
||||
[content, isStreaming, isTurnOutputOwner, sessionId, workDir, turnChangedFiles],
|
||||
)
|
||||
|
||||
if (!content.trim()) return null
|
||||
@@ -91,21 +104,25 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
|
||||
const showTurnCompletion = !isStreaming && Boolean(turnCompletion)
|
||||
|
||||
return (
|
||||
<div className="mb-5 flex justify-start">
|
||||
<div className="flex justify-start">
|
||||
<div
|
||||
data-message-shell="assistant"
|
||||
data-layout={documentLayout ? 'document' : 'bubble'}
|
||||
className={`group flex min-w-0 flex-col items-start ${
|
||||
documentLayout
|
||||
? 'w-full max-w-full'
|
||||
: 'max-w-[88%] sm:max-w-[80%] lg:max-w-[720px]'
|
||||
}`}
|
||||
// Always the full column. A reply that hugs its text turns every short
|
||||
// answer into a differently-shaped block, so a scrolled transcript reads
|
||||
// as a ragged pile; one width makes the replies a single column the eye
|
||||
// can run down. The user bubble stays hugged — that asymmetry is what
|
||||
// says which side is speaking, so it does not need width to say it too.
|
||||
className="group flex w-full min-w-0 max-w-full flex-col items-start"
|
||||
>
|
||||
<div
|
||||
onContextMenu={sessionId ? handleContextMenu : undefined}
|
||||
className={`rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-4 text-[14.5px] text-[var(--color-text-primary)] shadow-[var(--shadow-card)] ${
|
||||
documentLayout ? 'w-full' : 'max-w-full'
|
||||
}`}
|
||||
// No card. Left-aligned, full-column prose against the page is already
|
||||
// unmistakably the reply — the hugged, tinted bubble on the right is
|
||||
// what says who is speaking (see the note above), so a border here
|
||||
// repeats that at the cost of ~50px per reply and makes prose look
|
||||
// like the tool rows it sits between. The turn rail groups it now.
|
||||
className="w-full text-[14.5px] text-[var(--color-text-primary)]"
|
||||
>
|
||||
<MarkdownRenderer
|
||||
content={content}
|
||||
@@ -156,17 +173,26 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
|
||||
/>
|
||||
)}
|
||||
|
||||
{showTurnCompletion ? <TurnCompletionStamp completion={turnCompletion!} /> : null}
|
||||
|
||||
<MessageActionBar
|
||||
copyText={isStreaming ? undefined : content}
|
||||
copyLabel={t('chat.copyReply')}
|
||||
branchAction={branchAction}
|
||||
align="start"
|
||||
// The stamp above already carries this turn's end time; a hover chip
|
||||
// repeating it a line below reads as two different times.
|
||||
timestamp={showTurnCompletion ? undefined : timestamp}
|
||||
/>
|
||||
{/*
|
||||
Only the reply that closes a turn gets this footer. A turn emits many
|
||||
intermediate replies ("checking the lint spread now") and nobody copies
|
||||
or forks from those — yet the bar reserved 36px on each one whether or
|
||||
not it was hovered, which on a one-line reply outweighed the text.
|
||||
The action buttons and completion metadata share one compact row so a
|
||||
finished answer has one visual close instead of two stacked footers.
|
||||
Being rare now, it stays visible instead of waiting for a hover.
|
||||
Mid-turn text is still copyable by selecting it (SelectableChatMessage).
|
||||
*/}
|
||||
{showTurnCompletion && (
|
||||
<MessageActionBar
|
||||
copyText={content}
|
||||
copyLabel={t('chat.copyReply')}
|
||||
branchAction={branchAction}
|
||||
align="start"
|
||||
alwaysVisible
|
||||
metadata={<TurnCompletionStamp completion={turnCompletion!} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -58,6 +58,17 @@ const localhostTarget: AssistantOutputTarget = {
|
||||
source: 'plain-url',
|
||||
}
|
||||
|
||||
const documentTarget: AssistantOutputTarget = {
|
||||
id: 'file:outputs/brief.docx',
|
||||
kind: 'file',
|
||||
title: 'brief.docx',
|
||||
subtitle: 'outputs/brief.docx',
|
||||
href: 'outputs/brief.docx',
|
||||
normalizedPath: 'outputs/brief.docx',
|
||||
confidence: 'high',
|
||||
source: 'changed-file',
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
openBrowser.mockReset()
|
||||
ensureTargets.mockReset().mockResolvedValue(undefined)
|
||||
@@ -81,6 +92,13 @@ describe('AssistantOutputTargetCard', () => {
|
||||
expect(screen.getByText('assistantOutputs.kind.localhost')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders an Office artifact with its concrete document type', () => {
|
||||
render(<AssistantOutputTargetCard target={documentTarget} sessionId="s1" />)
|
||||
expect(screen.getByText('brief.docx')).toBeInTheDocument()
|
||||
expect(screen.getByText('openWith.fileType.document')).toBeInTheDocument()
|
||||
expect(screen.getByText('outputs/brief.docx')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('routes Open to workspace preview for a markdown target', () => {
|
||||
render(<AssistantOutputTargetCard target={markdownTarget} sessionId="s1" />)
|
||||
fireEvent.click(screen.getByLabelText('assistantOutputs.open'))
|
||||
|
||||
@@ -30,7 +30,9 @@ export function AssistantOutputTargetCard({ target, sessionId, workDir }: Props)
|
||||
? t('assistantOutputs.kind.html')
|
||||
: target.kind === 'markdown'
|
||||
? t('assistantOutputs.kind.markdown')
|
||||
: t('assistantOutputs.kind.image')
|
||||
: target.kind === 'file'
|
||||
? t(typeInfo.categoryKey as TranslationKey)
|
||||
: t('assistantOutputs.kind.image')
|
||||
const subtitle = target.subtitle ?? target.normalizedPath ?? target.href
|
||||
const showSubtitle = subtitle !== target.title
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ describe('AttachmentGallery', () => {
|
||||
fetchedAt: Date.now(),
|
||||
loading: false,
|
||||
error: null,
|
||||
getTargetsForPath: vi.fn().mockResolvedValue([
|
||||
{ id: 'code', kind: 'ide', label: 'VS Code', icon: 'vscode', platform: 'darwin' },
|
||||
{ id: 'system-default', kind: 'system_default', label: 'System default', icon: 'system', platform: 'darwin' },
|
||||
{ id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', platform: 'darwin' },
|
||||
]),
|
||||
})
|
||||
window.desktopHost = {
|
||||
...browserHost,
|
||||
@@ -196,6 +201,25 @@ describe('AttachmentGallery', () => {
|
||||
})
|
||||
|
||||
it('reuses the Open With menu for IDE and file-manager destinations', async () => {
|
||||
const view = render(
|
||||
<AttachmentGallery
|
||||
attachments={[{
|
||||
type: 'file',
|
||||
name: 'notes.md',
|
||||
path: '/Users/example/Desktop/notes.md',
|
||||
}]}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(view.getByRole('button', { name: 'Open with' }))
|
||||
|
||||
expect(await view.findByText('Open in VS Code')).toBeInTheDocument()
|
||||
expect(view.getByText('Default application')).toBeInTheDocument()
|
||||
expect(view.getByText('Reveal in Finder')).toBeInTheDocument()
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves the editor out for an attachment no editor can open', async () => {
|
||||
const view = render(
|
||||
<AttachmentGallery
|
||||
attachments={[{
|
||||
@@ -208,9 +232,8 @@ describe('AttachmentGallery', () => {
|
||||
|
||||
fireEvent.click(view.getByRole('button', { name: 'Open with' }))
|
||||
|
||||
expect(await view.findByText('Open in VS Code')).toBeInTheDocument()
|
||||
expect(view.getByText('Reveal in Finder')).toBeInTheDocument()
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
expect(await view.findByText('Default application')).toBeInTheDocument()
|
||||
expect(view.queryByText('Open in VS Code')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('removes a quoted workspace attachment by id', () => {
|
||||
@@ -306,6 +329,72 @@ describe('AttachmentGallery', () => {
|
||||
expect(view.queryByRole('button', { name: 'Open <h1>' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps one ordinary message image readable at its natural aspect ratio and opens it', async () => {
|
||||
const view = render(
|
||||
<AttachmentGallery
|
||||
variant="message"
|
||||
attachments={[{
|
||||
id: 'image-1',
|
||||
type: 'image',
|
||||
name: 'single.png',
|
||||
data: 'data:image/png;base64,AAAA',
|
||||
}]}
|
||||
/>,
|
||||
)
|
||||
|
||||
const button = view.getByRole('button', { name: 'Open single.png' })
|
||||
const image = button.querySelector('img')
|
||||
expect(image).toHaveClass('max-h-[340px]', 'w-full', 'max-w-[360px]')
|
||||
expect(image).not.toHaveClass('h-28', 'w-28')
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(await view.findByText('1 / 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([2, 5])('keeps %i ordinary message images in a bounded thumbnail gallery and opens the selected image', async (imageCount) => {
|
||||
const attachments = Array.from({ length: imageCount }, (_, index) => ({
|
||||
id: `image-${index + 1}`,
|
||||
type: 'image' as const,
|
||||
name: `image-${index + 1}.png`,
|
||||
data: `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><rect width="16" height="16" fill="hsl(${index * 60} 60% 50%)"/></svg>`)}`,
|
||||
}))
|
||||
const view = render(
|
||||
<AttachmentGallery
|
||||
variant="message"
|
||||
attachments={attachments}
|
||||
/>,
|
||||
)
|
||||
|
||||
const imageButtons = attachments.map(({ name }) =>
|
||||
view.getByRole('button', { name: `Open ${name}` }))
|
||||
const gallery = imageButtons[0]?.parentElement?.parentElement
|
||||
|
||||
expect(gallery).toHaveClass('max-w-full', 'flex-wrap')
|
||||
for (const button of imageButtons) {
|
||||
expect(button.parentElement?.parentElement).toBe(gallery)
|
||||
expect(button.querySelector('img')).toHaveClass('h-28', 'w-28')
|
||||
}
|
||||
|
||||
fireEvent.click(imageButtons[imageCount - 1]!)
|
||||
expect(await view.findByText(`${imageCount} / ${imageCount}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the surviving image compact when another image in the original multi-image set fails', () => {
|
||||
const view = render(
|
||||
<AttachmentGallery
|
||||
variant="message"
|
||||
attachments={[
|
||||
{ id: 'image-1', type: 'image', name: 'missing.png', data: 'data:image/png;base64,AAAA' },
|
||||
{ id: 'image-2', type: 'image', name: 'kept.png', data: 'data:image/png;base64,BBBB' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.error(view.getByRole('img', { name: 'missing.png' }))
|
||||
|
||||
expect(view.getByRole('img', { name: 'kept.png' })).toHaveClass('h-28', 'w-28')
|
||||
})
|
||||
|
||||
it('previews a path-only pasted image instead of a file chip', () => {
|
||||
const path = '/Users/nanmi/Desktop/6代码仓库.png'
|
||||
const view = render(
|
||||
@@ -331,7 +420,9 @@ describe('AttachmentGallery', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(view.getByRole('button', { name: 'Open shot.png' }))
|
||||
const button = view.getByRole('button', { name: 'Open shot.png' })
|
||||
expect(button.querySelector('img')).toHaveClass('h-16', 'w-16')
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(await view.findByText('1 / 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -78,6 +78,15 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
|
||||
}),
|
||||
[attachments, unloadableImageSources],
|
||||
)
|
||||
// Keep the layout decision tied to the authored attachment set, not to
|
||||
// transient load failures. Otherwise a two-image thumbnail row jumps to a
|
||||
// large single-image layout when one source errors and falls back to a card.
|
||||
const previewableImageCount = useMemo(
|
||||
() => attachments.filter((attachment) => (
|
||||
attachment.type === 'image' && Boolean(attachmentImageSource(attachment))
|
||||
)).length,
|
||||
[attachments],
|
||||
)
|
||||
|
||||
const markImageUnloadable = (src: string) => {
|
||||
setUnloadableImageSources((previous) => {
|
||||
@@ -120,10 +129,10 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
|
||||
const triggerEl = event.currentTarget
|
||||
const anchor = triggerEl.getBoundingClientRect()
|
||||
void (async () => {
|
||||
await useOpenTargetStore.getState().ensureTargets()
|
||||
const targets = await useOpenTargetStore.getState().getTargetsForPath(attachment.path!)
|
||||
const items = buildOpenWithItems(
|
||||
{ kind: 'file', absolutePath: attachment.path! },
|
||||
useOpenTargetStore.getState().targets,
|
||||
targets,
|
||||
{
|
||||
openInAppBrowser: () => {},
|
||||
openSystem: (path) => {
|
||||
@@ -204,7 +213,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className={isComposer ? 'flex flex-wrap items-center gap-2' : 'flex flex-wrap justify-end gap-2'}>
|
||||
<div className={isComposer ? 'flex flex-wrap items-center gap-2' : 'flex max-w-full flex-wrap justify-end gap-2'}>
|
||||
{attachments.map((attachment, index) => {
|
||||
const imageSrc = attachment.type === 'image' ? attachmentImageSource(attachment) : undefined
|
||||
|
||||
@@ -234,7 +243,9 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
|
||||
className={
|
||||
isComposer
|
||||
? 'h-16 w-16 object-cover'
|
||||
: 'max-h-[340px] w-full max-w-[360px] object-cover'
|
||||
: previewableImageCount === 1
|
||||
? 'max-h-[340px] w-full max-w-[360px] object-cover'
|
||||
: 'h-28 w-28 object-cover'
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import '@testing-library/jest-dom'
|
||||
import { act } from 'react'
|
||||
import { act, StrictMode } from 'react'
|
||||
|
||||
const viewportMocks = vi.hoisted(() => ({
|
||||
isMobile: false,
|
||||
@@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({
|
||||
getSlashCommands: vi.fn(),
|
||||
listAgents: vi.fn(),
|
||||
getRepositoryContext: vi.fn(),
|
||||
createRepositoryBranch: vi.fn(),
|
||||
getRecentProjects: vi.fn(),
|
||||
search: vi.fn(),
|
||||
browse: vi.fn(),
|
||||
@@ -38,6 +39,7 @@ vi.mock('../../api/sessions', () => ({
|
||||
getGitInfo: mocks.getGitInfo,
|
||||
getSlashCommands: mocks.getSlashCommands,
|
||||
getRepositoryContext: mocks.getRepositoryContext,
|
||||
createRepositoryBranch: mocks.createRepositoryBranch,
|
||||
getRecentProjects: mocks.getRecentProjects,
|
||||
},
|
||||
}))
|
||||
@@ -107,7 +109,14 @@ import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||||
import { useWorkflowStore } from '../../stores/workflowStore'
|
||||
import { workflowsApi } from '../../api/workflows'
|
||||
import { browserHost } from '../../lib/desktopHost/browserHost'
|
||||
import { settingsApi } from '../../api/settings'
|
||||
import {
|
||||
captureProjectDisplayNameHydrationRevision,
|
||||
hydrateProjectDisplayNames,
|
||||
} from '../../stores/projectDisplayNameStore'
|
||||
|
||||
/**
|
||||
* Opens the run-location pill's menu. Directory, branch and worktree all live
|
||||
@@ -191,6 +200,10 @@ describe('ChatInput file mentions', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.createRepositoryBranch.mockReset()
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
mocks.webviewDragHandlers.length = 0
|
||||
Reflect.deleteProperty(window, 'desktopHost')
|
||||
delete (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__
|
||||
@@ -200,6 +213,7 @@ describe('ChatInput file mentions', () => {
|
||||
useSessionStore.setState(initialSessionState, true)
|
||||
useTabStore.setState(initialTabState, true)
|
||||
useWorkspaceChatContextStore.setState(initialWorkspaceContextState, true)
|
||||
useWorkflowStore.setState({ runs: {}, openRunId: null })
|
||||
|
||||
useTabStore.setState({
|
||||
activeTabId: sessionId,
|
||||
@@ -277,6 +291,10 @@ describe('ChatInput file mentions', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
vi.unstubAllGlobals()
|
||||
if (originalOffsetWidth) {
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', originalOffsetWidth)
|
||||
@@ -883,6 +901,64 @@ describe('ChatInput file mentions', () => {
|
||||
expect(screen.queryByRole('button', { name: /^Location/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reactively uses the active session project root display name while retaining the active worktree path in context', async () => {
|
||||
const worktreePath = '/repo/.claude/worktrees/desktop-main-12345678'
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
id: sessionId,
|
||||
title: 'Project',
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
modifiedAt: '2026-05-01T00:00:00.000Z',
|
||||
messageCount: 1,
|
||||
projectPath: '/repo',
|
||||
projectRoot: '/repo',
|
||||
workDir: worktreePath,
|
||||
workDirExists: true,
|
||||
}],
|
||||
activeSessionId: sessionId,
|
||||
})
|
||||
mocks.getGitInfo.mockResolvedValue({
|
||||
branch: 'main',
|
||||
repoName: 'repo',
|
||||
workDir: worktreePath,
|
||||
changedFiles: 0,
|
||||
worktree: {
|
||||
enabled: true,
|
||||
path: worktreePath,
|
||||
plannedPath: null,
|
||||
sourceWorkDir: '/repo',
|
||||
slug: 'desktop-main-12345678',
|
||||
branch: 'main',
|
||||
},
|
||||
})
|
||||
|
||||
render(<ChatInput />)
|
||||
|
||||
const chip = await screen.findByTestId('run-location-readonly')
|
||||
expect(chip).toHaveTextContent('repo')
|
||||
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames(
|
||||
{ '/repo': 'Personal repo' },
|
||||
captureProjectDisplayNameHydrationRevision(),
|
||||
)
|
||||
})
|
||||
|
||||
expect(chip).toHaveTextContent('Personal repo')
|
||||
|
||||
// Once the alias takes over the label, the worktree details tooltip is the
|
||||
// only place still naming the real project root and the active worktree
|
||||
// path — the toolbar variant carries no `title` for a worktree.
|
||||
expect(chip).not.toHaveAttribute('title')
|
||||
fireEvent.mouseEnter(screen.getByTestId('worktree-details-trigger'))
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip')
|
||||
// Exact-text lookups: '/repo' is a prefix of the worktree path, so a
|
||||
// substring assertion would pass even if the project root row went missing.
|
||||
expect(within(tooltip).getByText('/repo')).toBeInTheDocument()
|
||||
expect(within(tooltip).getByText(worktreePath)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The narrow layouts never adopted the in-toolbar pill: there is no room for
|
||||
// it beside the model selector, so they keep the location on its own line
|
||||
// below the panel.
|
||||
@@ -1115,7 +1191,10 @@ describe('ChatInput file mentions', () => {
|
||||
})
|
||||
|
||||
it('starts an empty active session on the selected branch without an isolated worktree', async () => {
|
||||
mocks.create.mockResolvedValueOnce({ sessionId: 'created-direct', workDir: '/repo' })
|
||||
let resolveCreate!: (value: { sessionId: string; workDir: string }) => void
|
||||
mocks.create.mockReturnValueOnce(new Promise((resolve) => {
|
||||
resolveCreate = resolve
|
||||
}))
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
id: sessionId,
|
||||
@@ -1166,10 +1245,124 @@ describe('ChatInput file mentions', () => {
|
||||
repository: { branch: 'feature/a', worktree: false },
|
||||
})
|
||||
})
|
||||
|
||||
// Session creation can take seconds. The click must cross the store
|
||||
// boundary immediately so the transcript can replace its empty hero with
|
||||
// a live placeholder before the new session id exists.
|
||||
expect(useChatStore.getState().sessions[sessionId]?.isPreparingTurn).toBe(true)
|
||||
|
||||
resolveCreate({ sessionId: 'created-direct', workDir: '/repo' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.wsSend).toHaveBeenCalledWith('created-direct', {
|
||||
type: 'user_message',
|
||||
content: 'run on feature branch',
|
||||
attachments: [],
|
||||
})
|
||||
})
|
||||
expect(useChatStore.getState().sessions['created-direct']?.isPreparingTurn).toBe(false)
|
||||
expect(mocks.delete).toHaveBeenCalledWith(sessionId)
|
||||
expect(mocks.wsSend).toHaveBeenCalledWith('created-direct', {
|
||||
})
|
||||
|
||||
it('launches on a branch created from the picker instead of falling back to the current branch', async () => {
|
||||
const headCommit = 'a'.repeat(40)
|
||||
const initialContext = {
|
||||
...okRepositoryContext(),
|
||||
headCommit,
|
||||
dirty: true,
|
||||
branches: okRepositoryContext().branches.map((branch) => ({ ...branch, commit: headCommit })),
|
||||
}
|
||||
const createdContext = {
|
||||
...initialContext,
|
||||
branches: [
|
||||
...initialContext.branches,
|
||||
{
|
||||
name: 'qa/launch-picker',
|
||||
current: false,
|
||||
local: true,
|
||||
remote: false,
|
||||
checkedOut: false,
|
||||
commit: headCommit,
|
||||
},
|
||||
],
|
||||
}
|
||||
mocks.getRepositoryContext.mockResolvedValue(initialContext)
|
||||
mocks.createRepositoryBranch.mockImplementation(() => new Promise((resolve) => {
|
||||
window.setTimeout(() => resolve({
|
||||
branch: 'qa/launch-picker',
|
||||
baseRef: 'main',
|
||||
context: createdContext,
|
||||
}), 0)
|
||||
}))
|
||||
mocks.create.mockResolvedValueOnce({ sessionId: 'created-picker-branch', workDir: '/repo' })
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
id: sessionId,
|
||||
title: 'Project',
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
modifiedAt: '2026-05-01T00:00:00.000Z',
|
||||
messageCount: 0,
|
||||
projectPath: '/repo',
|
||||
workDir: '/repo',
|
||||
workDirExists: true,
|
||||
}],
|
||||
activeSessionId: sessionId,
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[sessionId]: {
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
streamingResponseChars: 0,
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
render(
|
||||
<StrictMode>
|
||||
<ChatInput variant="hero" />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
await openBranchList()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create branch…' }))
|
||||
fireEvent.change(await screen.findByLabelText('Branch name'), {
|
||||
target: { value: 'qa/launch-picker' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Location: repo / qa/launch-picker' }))
|
||||
.toBeInTheDocument()
|
||||
expect(useChatStore.getState().sessions[sessionId]?.repositoryLaunchDraft?.branch)
|
||||
.toBe('qa/launch-picker')
|
||||
expect(screen.queryByRole('status', { name: /Uncommitted changes/ })).not.toBeInTheDocument()
|
||||
|
||||
setComposerText('show the current branch', 23)
|
||||
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.create).toHaveBeenCalledWith({
|
||||
workDir: '/repo',
|
||||
repository: { branch: 'qa/launch-picker', worktree: false },
|
||||
})
|
||||
})
|
||||
expect(mocks.wsSend).toHaveBeenCalledWith('created-picker-branch', {
|
||||
type: 'user_message',
|
||||
content: 'run on feature branch',
|
||||
content: 'show the current branch',
|
||||
attachments: [],
|
||||
})
|
||||
})
|
||||
@@ -1806,6 +1999,30 @@ describe('ChatInput file mentions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves Feishu-style paragraph breaks when pasting text', async () => {
|
||||
render(<ChatInput compact />)
|
||||
|
||||
const input = getComposerElement()
|
||||
fireEvent.paste(input, {
|
||||
clipboardData: {
|
||||
files: [],
|
||||
items: [],
|
||||
types: ['text/plain', 'text/html'],
|
||||
getData: (type: string) => {
|
||||
if (type === 'text/plain') return '第一行\r\n第二行\r\n\r\n第四行'
|
||||
if (type === 'text/html') {
|
||||
return '<div><span>第一行</span></div><div><span>第二行</span></div><div><br></div><div><span>第四行</span></div>'
|
||||
}
|
||||
return ''
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getComposerText()).toBe('第一行\n第二行\n\n第四行')
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores pasted files that finish loading after the prompt was sent', async () => {
|
||||
class DeferredFileReader {
|
||||
result: string | ArrayBuffer | null = null
|
||||
@@ -2050,6 +2267,113 @@ describe('ChatInput file mentions', () => {
|
||||
expect(getComposerText()).toBe('')
|
||||
})
|
||||
|
||||
it('opens actionable /save-workflow help locally without creating a silent user turn', async () => {
|
||||
useSettingsStore.setState({ chatSendBehavior: 'enter' })
|
||||
render(<ChatInput />)
|
||||
|
||||
const beforeMessages = useChatStore.getState().sessions[sessionId]!.messages
|
||||
setComposerText('/save-workflow', '/save-workflow'.length)
|
||||
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
|
||||
|
||||
expect(mocks.wsSend).not.toHaveBeenCalled()
|
||||
expect(await screen.findByText('Save workflow')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Complete a workflow in this session/)).toBeInTheDocument()
|
||||
expect(useChatStore.getState().sessions[sessionId]!.messages).toBe(beforeMessages)
|
||||
expect(getComposerText()).toBe('')
|
||||
})
|
||||
|
||||
it('saves a completed runtime workflow through the composer command', async () => {
|
||||
useSettingsStore.setState({ chatSendBehavior: 'enter' })
|
||||
const runId = 'wf_save-flow-abc'
|
||||
act(() => {
|
||||
const handle = useChatStore.getState().handleServerMessage
|
||||
handle(sessionId, {
|
||||
type: 'system_notification',
|
||||
subtype: 'task_started',
|
||||
data: {
|
||||
task_id: 'workflow-save-flow',
|
||||
task_type: 'local_workflow',
|
||||
workflow_name: 'generated-audit',
|
||||
workflow_run_id: runId,
|
||||
},
|
||||
})
|
||||
handle(sessionId, {
|
||||
type: 'system_notification',
|
||||
subtype: 'task_progress',
|
||||
data: {
|
||||
task_id: 'workflow-save-flow',
|
||||
workflow_run_id: runId,
|
||||
workflow_progress: [
|
||||
{ type: 'workflow_phase', index: 1, title: 'Scan' },
|
||||
{
|
||||
type: 'workflow_agent',
|
||||
index: 1,
|
||||
label: 'scan routes',
|
||||
state: 'done',
|
||||
phaseIndex: 1,
|
||||
agentId: 'agent-save-flow',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
handle(sessionId, {
|
||||
type: 'system_notification',
|
||||
subtype: 'task_notification',
|
||||
data: {
|
||||
task_id: 'workflow-save-flow',
|
||||
workflow_run_id: runId,
|
||||
status: 'completed',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const script = [
|
||||
"export const meta = { name: 'generated-audit', description: 'Audit routes' }",
|
||||
"return await agent('scan routes')",
|
||||
].join('\n')
|
||||
const getRun = vi.spyOn(workflowsApi, 'getRun').mockResolvedValue({
|
||||
runId,
|
||||
sessionId,
|
||||
workflowName: 'generated-audit',
|
||||
scriptPath: '/tmp/generated-audit.js',
|
||||
startedAt: 1,
|
||||
completedAgents: 1,
|
||||
status: 'completed',
|
||||
script,
|
||||
agents: [],
|
||||
})
|
||||
const save = vi.spyOn(workflowsApi, 'save').mockResolvedValue({
|
||||
ok: true,
|
||||
name: 'release-audit',
|
||||
filePath: '/repo/.claude/workflows/release-audit.js',
|
||||
})
|
||||
|
||||
render(<ChatInput />)
|
||||
setComposerText('/save-workflow', '/save-workflow'.length)
|
||||
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
|
||||
|
||||
await waitFor(() => expect(getRun).toHaveBeenCalledWith(sessionId, runId))
|
||||
fireEvent.change(
|
||||
await screen.findByRole('textbox', { name: 'Command name' }),
|
||||
{ target: { value: 'release-audit' } },
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save workflow' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
script,
|
||||
'project',
|
||||
'/repo',
|
||||
'release-audit',
|
||||
)
|
||||
})
|
||||
expect(await screen.findByText(/Start a new session and run \/release-audit/)).toBeInTheDocument()
|
||||
expect(mocks.wsSend).not.toHaveBeenCalledWith(
|
||||
sessionId,
|
||||
expect.objectContaining({ type: 'user_message' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('prioritizes active-session slash commands by command name when filtering', async () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
@@ -2227,4 +2551,65 @@ describe('ChatInput file mentions', () => {
|
||||
attachments: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('highlights only actionable ultracode tokens and follows the persisted setting in both directions', async () => {
|
||||
const updateUser = vi.spyOn(settingsApi, 'updateUser').mockResolvedValue({ ok: true })
|
||||
|
||||
try {
|
||||
render(<ChatInput />)
|
||||
|
||||
const highlightedKeywords = () =>
|
||||
Array.from(document.querySelectorAll('[data-workflow-keyword="true"]'))
|
||||
.map((element) => element.textContent)
|
||||
|
||||
const complexPrompt = 'ultracode:audit routes, error propagation, and missing tests with several agents'
|
||||
setComposerText(complexPrompt, complexPrompt.length)
|
||||
await waitFor(() => {
|
||||
expect(highlightedKeywords()).toEqual(['ultracode'])
|
||||
})
|
||||
|
||||
for (const literalPrompt of [
|
||||
'```text\nultracode\n```',
|
||||
'explain "ultracode" without running it',
|
||||
'open docs/ultracode/readme.md',
|
||||
'pass --ultracode to the CLI',
|
||||
'inspect the ultracode-runner package',
|
||||
]) {
|
||||
setComposerText(literalPrompt, literalPrompt.length)
|
||||
await waitFor(() => {
|
||||
expect(highlightedKeywords()).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
setComposerText(complexPrompt, complexPrompt.length)
|
||||
await waitFor(() => {
|
||||
expect(highlightedKeywords()).toEqual(['ultracode'])
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await useSettingsStore.getState().setWorkflowKeywordTriggerEnabled(false)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(highlightedKeywords()).toEqual([])
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await useSettingsStore.getState().setWorkflowKeywordTriggerEnabled(true)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(highlightedKeywords()).toEqual(['ultracode'])
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Run' }))
|
||||
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
|
||||
type: 'user_message',
|
||||
content: complexPrompt,
|
||||
attachments: [],
|
||||
})
|
||||
expect(updateUser).toHaveBeenNthCalledWith(1, { workflowKeywordTriggerEnabled: false })
|
||||
expect(updateUser).toHaveBeenNthCalledWith(2, { workflowKeywordTriggerEnabled: true })
|
||||
} finally {
|
||||
updateUser.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -185,14 +185,19 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
updateQueuedUserMessage,
|
||||
removeQueuedUserMessage,
|
||||
sendQueuedUserMessage,
|
||||
setPreparingTurn,
|
||||
} = useChatStore()
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const activeTabType = useTabStore((s) =>
|
||||
s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type,
|
||||
)
|
||||
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
|
||||
const repositoryLaunchDraft = sessionState?.repositoryLaunchDraft
|
||||
const launchWorkDir = repositoryLaunchDraft?.workDir ?? ''
|
||||
const launchBranch = repositoryLaunchDraft?.branch ?? null
|
||||
const launchUseWorktree = repositoryLaunchDraft?.useWorktree ?? false
|
||||
const chatState = sessionState?.chatState ?? 'idle'
|
||||
const isPreparingTurn = Boolean(sessionState?.isPreparingTurn)
|
||||
const slashCommands = sessionState?.slashCommands ?? []
|
||||
const composerPrefill = sessionState?.composerPrefill ?? null
|
||||
const composerInsertion = sessionState?.composerInsertion ?? null
|
||||
@@ -252,7 +257,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
pasteGenerationRef.current += 1
|
||||
}, [])
|
||||
|
||||
const isMemberSession = !!memberInfo
|
||||
const isMemberSession = !!memberInfo || activeTabType === 'subagent'
|
||||
const isActive = chatState !== 'idle'
|
||||
const hasRunningSubagents = hasRunningSubagentTasks(sessionState?.backgroundAgentTasks)
|
||||
const workspaceState = getSessionWorkspaceState(activeSession)
|
||||
@@ -295,6 +300,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
: null
|
||||
const canSubmit = !isWorkspaceMissing &&
|
||||
!launchTransitioning &&
|
||||
!isPreparingTurn &&
|
||||
(!showLaunchControls || launchReady || !!pendingSlashUiAction) &&
|
||||
(input.trim().length > 0 || (!isMemberSession && (attachments.length > 0 || hasWorkspaceReferences)))
|
||||
const composerAttachments = useMemo(
|
||||
@@ -790,14 +796,23 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
(gitInfo?.branch ? launchBranch !== gitInfo.branch : true)
|
||||
if (shouldReplaceForRepositoryLaunch) {
|
||||
setLaunchTransitioning(true)
|
||||
const placeholderSessionId = targetSessionId
|
||||
setPreparingTurn(placeholderSessionId, true)
|
||||
try {
|
||||
const newSessionId = await replaceEmptySession(activeLaunchWorkDir, {
|
||||
branch: launchBranch,
|
||||
worktree: launchUseWorktree,
|
||||
})
|
||||
if (!newSessionId) return
|
||||
if (!newSessionId) {
|
||||
setPreparingTurn(placeholderSessionId, false)
|
||||
return
|
||||
}
|
||||
targetSessionId = newSessionId
|
||||
// Keep the placeholder continuous across the old-id -> new-id swap.
|
||||
// sendMessage clears it in the same turn that appends the user row.
|
||||
setPreparingTurn(targetSessionId, true)
|
||||
} catch (error) {
|
||||
setPreparingTurn(placeholderSessionId, false)
|
||||
useUIStore.getState().addToast({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : t('empty.failedToCreate'),
|
||||
@@ -1291,7 +1306,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
onCompositionStart={() => { composingRef.current = true }}
|
||||
onCompositionEnd={() => { composingRef.current = false }}
|
||||
placeholder={composerPlaceholder}
|
||||
disabled={isWorkspaceMissing}
|
||||
disabled={isWorkspaceMissing || launchTransitioning || isPreparingTurn}
|
||||
className="flex-1"
|
||||
editorClassName="max-h-[200px] overflow-y-auto py-2 leading-relaxed text-[var(--color-text-primary)]"
|
||||
aria={{
|
||||
@@ -1317,7 +1332,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
onCompositionStart={() => { composingRef.current = true }}
|
||||
onCompositionEnd={() => { composingRef.current = false }}
|
||||
placeholder={composerPlaceholder}
|
||||
disabled={isWorkspaceMissing}
|
||||
disabled={isWorkspaceMissing || launchTransitioning || isPreparingTurn}
|
||||
editorClassName={`max-h-[200px] overflow-y-auto text-sm leading-relaxed text-[var(--color-text-primary)] ${
|
||||
useCompactChrome ? 'py-1.5' : 'py-2'
|
||||
}`}
|
||||
@@ -1348,7 +1363,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
`-mx-3` has to cancel the panel's `p-3` exactly, and the panel is
|
||||
padded by the same chrome rule.
|
||||
*/}
|
||||
<div data-testid="chat-input-toolbar" className={`flex items-center justify-between border-t border-[var(--color-border-separator)] ${
|
||||
<div data-testid="chat-input-toolbar" className={`flex items-center justify-between ${
|
||||
isHeroComposer
|
||||
? 'pt-3'
|
||||
: useCompactChrome
|
||||
@@ -1373,7 +1388,10 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
onClick={() => setPlusMenuOpen((value) => !value)}
|
||||
aria-label={t('chat.composerTools')}
|
||||
aria-expanded={plusMenuOpen}
|
||||
className={`inline-flex items-center justify-center rounded-[var(--radius-md)] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${isMobileComposer ? 'h-11 w-11' : 'h-8 w-8'}`}
|
||||
// Bordered on desktop so the tools affordance reads as a
|
||||
// control at rest, not only on hover — it sits next to
|
||||
// the permission chip, which is bordered too.
|
||||
className={`inline-flex items-center justify-center rounded-[var(--radius-md)] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${isMobileComposer ? 'h-11 w-11' : 'h-8 w-8 border border-[var(--color-border)]'}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">add</span>
|
||||
</button>
|
||||
@@ -1416,6 +1434,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
) : (
|
||||
<ProjectContextChip
|
||||
workDir={resolvedWorkDir}
|
||||
projectRoot={activeSession?.projectRoot}
|
||||
repoName={gitInfo?.repoName || null}
|
||||
branch={gitInfo?.branch || null}
|
||||
sourceWorkDir={gitInfo?.worktree?.sourceWorkDir || null}
|
||||
@@ -1487,7 +1506,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
send without a word next to it. Dropping the label is why the
|
||||
name now lives only in `aria-label`, on both breakpoints. */}
|
||||
<Button
|
||||
variant={!isMemberSession && isActive ? 'danger' : 'primary'}
|
||||
variant={!isMemberSession && isActive ? 'danger' : 'accent'}
|
||||
size="base"
|
||||
shape="circle"
|
||||
onClick={!isMemberSession && isActive ? () => stopGeneration(activeTabId!) : handleSubmit}
|
||||
@@ -1521,6 +1540,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
{messageCount > 0 ? (
|
||||
<ProjectContextChip
|
||||
workDir={resolvedWorkDir}
|
||||
projectRoot={activeSession?.projectRoot}
|
||||
repoName={gitInfo?.repoName || null}
|
||||
branch={gitInfo?.branch || null}
|
||||
sourceWorkDir={gitInfo?.worktree?.sourceWorkDir || null}
|
||||
|
||||
@@ -3,12 +3,16 @@ import { Highlight, type PrismTheme } from 'prism-react-renderer'
|
||||
import { CopyButton } from '@/components/ui/CopyButton'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export type CodeViewerChrome = 'card' | 'embedded'
|
||||
|
||||
type Props = {
|
||||
code: string
|
||||
language?: string
|
||||
maxLines?: number
|
||||
showLineNumbers?: boolean
|
||||
wrapLongLines?: boolean
|
||||
chrome?: CodeViewerChrome
|
||||
label?: string
|
||||
}
|
||||
|
||||
const warmPrismTheme: PrismTheme = {
|
||||
@@ -282,9 +286,18 @@ function CodeArea({
|
||||
)
|
||||
}
|
||||
|
||||
export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = false, wrapLongLines = false }: Props) {
|
||||
export function CodeViewer({
|
||||
code,
|
||||
language,
|
||||
maxLines = 20,
|
||||
showLineNumbers = false,
|
||||
wrapLongLines = false,
|
||||
chrome = 'card',
|
||||
label,
|
||||
}: Props) {
|
||||
const t = useTranslation()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const isEmbedded = chrome === 'embedded'
|
||||
|
||||
const allLines = code.split('\n')
|
||||
const isTruncated = !expanded && allLines.length > maxLines
|
||||
@@ -296,16 +309,35 @@ export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = fa
|
||||
const showExpandToggle = allLines.length > maxLines
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-code-bg)]">
|
||||
<div
|
||||
data-code-viewer-chrome={chrome}
|
||||
className={
|
||||
isEmbedded
|
||||
? 'overflow-hidden bg-[var(--color-code-bg)]'
|
||||
: 'overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-code-bg)]'
|
||||
}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-surface-container)] px-3 py-1.5 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-semibold uppercase tracking-[0.14em]">{languageLabel}</span>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{label ? (
|
||||
<>
|
||||
<span className="font-semibold uppercase tracking-[0.14em]">{label}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className={`${label ? 'font-medium' : 'font-semibold'} uppercase tracking-[0.14em]`}>
|
||||
{languageLabel}
|
||||
</span>
|
||||
<span>{lineCountLabel}</span>
|
||||
</div>
|
||||
<CopyButton
|
||||
text={code}
|
||||
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]"
|
||||
className={
|
||||
isEmbedded
|
||||
? 'rounded-[var(--radius-sm)] px-2 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]'
|
||||
: 'rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,23 +6,29 @@ import { act } from 'react'
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Hoisted mocks (vi.hoisted runs before module evaluation)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
const { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock, panelState } = vi.hoisted(() => {
|
||||
const { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock, getTargetsForPathMock, openSystemFileSpy, panelState } = vi.hoisted(() => {
|
||||
const openPreviewSpy = vi.fn().mockResolvedValue(undefined)
|
||||
const browserOpenSpy = vi.fn()
|
||||
const openTargetSpy = vi.fn().mockResolvedValue(undefined)
|
||||
const ensureTargetsMock = vi.fn().mockResolvedValue(undefined)
|
||||
const getTargetsForPathMock = vi.fn().mockResolvedValue([
|
||||
{ id: 'code', kind: 'ide', label: 'VS Code', icon: '', platform: 'darwin' },
|
||||
{ id: 'system-default', kind: 'system_default', label: 'System default', icon: '', platform: 'darwin' },
|
||||
])
|
||||
const openSystemFileSpy = vi.fn().mockResolvedValue(undefined)
|
||||
const panelState = { isOpen: false }
|
||||
return { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock, panelState }
|
||||
return { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock, getTargetsForPathMock, openSystemFileSpy, panelState }
|
||||
})
|
||||
|
||||
// Mock openTargetStore
|
||||
vi.mock('../../stores/openTargetStore', () => ({
|
||||
useOpenTargetStore: Object.assign(
|
||||
// Selector hook form: useOpenTargetStore((s) => s.xxx)
|
||||
(selector: (s: { targets: unknown[]; ensureTargets: () => Promise<void>; openTarget: () => Promise<void> }) => unknown) =>
|
||||
(selector: (s: { targets: unknown[]; ensureTargets: () => Promise<void>; getTargetsForPath: () => Promise<unknown[]>; openTarget: () => Promise<void> }) => unknown) =>
|
||||
selector({
|
||||
targets: [{ id: 'code', kind: 'ide', label: 'VS Code', icon: '', platform: 'darwin' }],
|
||||
ensureTargets: ensureTargetsMock,
|
||||
getTargetsForPath: getTargetsForPathMock,
|
||||
openTarget: openTargetSpy,
|
||||
}),
|
||||
{
|
||||
@@ -30,6 +36,7 @@ vi.mock('../../stores/openTargetStore', () => ({
|
||||
getState: vi.fn(() => ({
|
||||
targets: [{ id: 'code', kind: 'ide', label: 'VS Code', icon: '', platform: 'darwin' }],
|
||||
ensureTargets: ensureTargetsMock,
|
||||
getTargetsForPath: getTargetsForPathMock,
|
||||
openTarget: openTargetSpy,
|
||||
})),
|
||||
},
|
||||
@@ -68,6 +75,13 @@ vi.mock('../../lib/desktopRuntime', () => ({
|
||||
getServerBaseUrl: vi.fn(() => 'http://127.0.0.1:4321'),
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/systemFileOpen', () => ({
|
||||
openLocalFileWithSystem: openSystemFileSpy,
|
||||
resolveAbsoluteOpenPath: (path: string, workDir?: string) => (
|
||||
path.startsWith('/') || !workDir ? path : `${workDir}/${path}`
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock useTranslation: returns identity-ish t function
|
||||
vi.mock('../../i18n', () => ({
|
||||
useTranslation: () => (key: string, params?: Record<string, string | number>) => {
|
||||
@@ -87,6 +101,11 @@ vi.mock('../../i18n', () => ({
|
||||
import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
|
||||
import { localFileUrl } from '../../lib/handlePreviewLink'
|
||||
import type { SessionTurnCheckpoint } from '../../api/sessions'
|
||||
import { en } from '../../i18n/locales/en'
|
||||
import { zh } from '../../i18n/locales/zh'
|
||||
import { zh as zhTW } from '../../i18n/locales/zh-TW'
|
||||
import { jp } from '../../i18n/locales/jp'
|
||||
import { kr } from '../../i18n/locales/kr'
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
@@ -94,6 +113,7 @@ import type { SessionTurnCheckpoint } from '../../api/sessions'
|
||||
function makeCheckpoint(
|
||||
filesChanged: string[],
|
||||
restoreAvailable?: boolean,
|
||||
unverifiedChangeSources?: string[],
|
||||
): SessionTurnCheckpoint {
|
||||
return {
|
||||
code: {
|
||||
@@ -111,6 +131,7 @@ function makeCheckpoint(
|
||||
messagesRemoved: 0,
|
||||
},
|
||||
...(restoreAvailable === undefined ? {} : { restoreAvailable }),
|
||||
...(unverifiedChangeSources === undefined ? {} : { unverifiedChangeSources }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,8 +139,10 @@ function renderCard(
|
||||
filesChanged: string[],
|
||||
isLatest = true,
|
||||
restoreAvailable?: boolean,
|
||||
unverifiedChangeSources?: string[],
|
||||
onUndo: () => void = vi.fn(),
|
||||
) {
|
||||
const checkpoint = makeCheckpoint(filesChanged, restoreAvailable)
|
||||
const checkpoint = makeCheckpoint(filesChanged, restoreAvailable, unverifiedChangeSources)
|
||||
return render(
|
||||
<CurrentTurnChangeCard
|
||||
sessionId="s1"
|
||||
@@ -128,7 +151,7 @@ function renderCard(
|
||||
error={null}
|
||||
isUndoing={false}
|
||||
isLatest={isLatest}
|
||||
onUndo={vi.fn()}
|
||||
onUndo={onUndo}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
@@ -189,14 +212,78 @@ describe('CurrentTurnChangeCard – rich file row (icon / name / type)', () => {
|
||||
expect(screen.getByText(/HTML/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps incomplete checkpoint files visible but disables unsafe rewind', () => {
|
||||
renderCard(['/w/proj/src/main.ts', '/outside/generated.ts'], true, false)
|
||||
it('keeps incomplete checkpoint files visible and still offers undo for the conversation', () => {
|
||||
const onUndo = vi.fn()
|
||||
renderCard(['/w/proj/src/main.ts', '/outside/generated.ts'], true, false, undefined, onUndo)
|
||||
|
||||
expect(screen.getByText('main.ts')).toBeInTheDocument()
|
||||
expect(screen.getByText('generated.ts')).toBeInTheDocument()
|
||||
expect(screen.getByText('chat.turnChangesPreviewOnlySubtitle')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'chat.turnChangesRestoreUnavailable' }))
|
||||
.toBeDisabled()
|
||||
expect(screen.getByText('chat.turnChangesConversationOnlySubtitle')).toBeInTheDocument()
|
||||
// An unrestorable checkpoint must not cost the user the conversation
|
||||
// rollback too — the dialog is where the remaining action is chosen.
|
||||
const undoButton = screen.getByRole('button', { name: 'chat.turnChangesLatestUndoAria' })
|
||||
expect(undoButton).toBeEnabled()
|
||||
fireEvent.click(undoButton)
|
||||
expect(onUndo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps undo usable and warns instead of blocking when coverage is partial', () => {
|
||||
const onUndo = vi.fn()
|
||||
renderCard(['/w/proj/src/main.ts'], true, true, ['Bash', 'TaskCreate'], onUndo)
|
||||
|
||||
// Warn about what undo will NOT reverse...
|
||||
expect(screen.getByText('chat.turnChangesPartialCoverageSubtitle')).toBeInTheDocument()
|
||||
expect(screen.queryByText('chat.turnChangesConversationOnlySubtitle')).toBeNull()
|
||||
|
||||
// ...while still letting the user reverse the files it did capture.
|
||||
const undoButton = screen.getByRole('button', { name: 'chat.turnChangesLatestUndoAria' })
|
||||
expect(undoButton).toBeEnabled()
|
||||
fireEvent.click(undoButton)
|
||||
expect(onUndo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('names the unverified sources in every locale message', () => {
|
||||
// Every surface that warns about partial coverage feeds the joined tool list
|
||||
// in as {sources}; a locale that drops the placeholder would warn without
|
||||
// saying what undo is leaving behind.
|
||||
const keys = [
|
||||
'chat.turnChangesPartialCoverageSubtitle',
|
||||
'chat.turnChangesPartialCoverageConfirmBody',
|
||||
'chat.rewindSuccessPartialCoverage',
|
||||
] as const
|
||||
for (const [name, messages] of Object.entries({ en, zh, zhTW, jp, kr })) {
|
||||
for (const key of keys) {
|
||||
expect(messages[key], `${name}/${key} must interpolate {sources}`)
|
||||
.toContain('{sources}')
|
||||
}
|
||||
}
|
||||
// The post-undo message also has to say how much of the conversation went.
|
||||
for (const [name, messages] of Object.entries({ en, zh, zhTW, jp, kr })) {
|
||||
expect(
|
||||
messages['chat.rewindSuccessPartialCoverage'],
|
||||
`${name} must interpolate {count}`,
|
||||
).toContain('{count}')
|
||||
}
|
||||
})
|
||||
|
||||
it('shows no coverage warning when every change source is accounted for', () => {
|
||||
renderCard(['/w/proj/src/main.ts'], true, true, [])
|
||||
|
||||
expect(screen.queryByText('chat.turnChangesPartialCoverageSubtitle', { exact: false }))
|
||||
.toBeNull()
|
||||
expect(screen.getByText('chat.turnChangesLatestSubtitle')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'chat.turnChangesLatestUndoAria' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('prefers the conversation-only message over the coverage warning when restore is unavailable', () => {
|
||||
renderCard(['/w/proj/src/main.ts'], true, false, ['Bash'])
|
||||
|
||||
// Both conditions hold, but "files cannot be restored at all" is the one
|
||||
// that changes what the user can do, so it wins the subtitle.
|
||||
expect(screen.getByText('chat.turnChangesConversationOnlySubtitle')).toBeInTheDocument()
|
||||
expect(screen.queryByText('chat.turnChangesPartialCoverageSubtitle', { exact: false }))
|
||||
.toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'chat.turnChangesLatestUndoAria' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -272,22 +359,21 @@ describe('CurrentTurnChangeCard – open-with buttons', () => {
|
||||
expect(buttons).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does NOT render an "open-with" button for a source file (row still opens workspace)', () => {
|
||||
it('renders an "open-with" button for a source file while its row still opens workspace', () => {
|
||||
renderCard(['/w/proj/src/main.ts'])
|
||||
expect(screen.queryAllByRole('button', { name: 'openWith.title' })).toHaveLength(0)
|
||||
// source files keep their workspace-open row — only the open-with pill is dropped
|
||||
expect(screen.getAllByRole('button', { name: 'openWith.title' })).toHaveLength(1)
|
||||
expect(screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('mixed turn: only previewable rows (md/html) get the open-with button, not .ts', () => {
|
||||
it('mixed turn: every real changed file gets the open-with button', () => {
|
||||
renderCard(['/w/proj/README.md', '/w/proj/src/main.ts', '/w/proj/index.html'])
|
||||
expect(screen.getAllByRole('button', { name: 'openWith.title' })).toHaveLength(2)
|
||||
expect(screen.getAllByRole('button', { name: 'openWith.title' })).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('keeps open-with secondary while every row retains its workspace chevron', () => {
|
||||
renderCard(['/w/proj/README.md', '/w/proj/index.html', '/w/proj/src/main.ts'])
|
||||
|
||||
expect(screen.getAllByRole('button', { name: 'openWith.title' })).toHaveLength(2)
|
||||
expect(screen.getAllByRole('button', { name: 'openWith.title' })).toHaveLength(3)
|
||||
const rows = screen.getAllByRole('button', { name: /turnChangesOpenInWorkspaceAria/ })
|
||||
expect(rows.every((row) => row.querySelector('.lucide-chevron-right'))).toBe(true)
|
||||
})
|
||||
@@ -310,6 +396,21 @@ describe('CurrentTurnChangeCard – open-with buttons', () => {
|
||||
expect(await screen.findByText('openWith.workspacePreview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers the copy entries every other open-with surface has', async () => {
|
||||
// This card built its dependencies by hand instead of using the shared
|
||||
// factory, so it silently lacked the two clipboard rows the prose links and
|
||||
// the file tree both offer.
|
||||
renderCard(['/w/proj/README.md'])
|
||||
const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(openWithBtn!)
|
||||
})
|
||||
|
||||
expect(await screen.findByText('openWith.copyPath')).toBeInTheDocument()
|
||||
expect(screen.getByText('openWith.copyFileContent')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clicking workspace preview item in README.md menu calls openPreview', async () => {
|
||||
renderCard(['/w/proj/README.md'])
|
||||
const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' })
|
||||
@@ -366,7 +467,7 @@ describe('CurrentTurnChangeCard – open-with buttons', () => {
|
||||
expect(await screen.findByText('openWith.inAppBrowser')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ensureTargets is called when open-with button is clicked', async () => {
|
||||
it('loads targets for the concrete file when open-with is clicked', async () => {
|
||||
renderCard(['/w/proj/README.md'])
|
||||
const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' })
|
||||
|
||||
@@ -374,7 +475,16 @@ describe('CurrentTurnChangeCard – open-with buttons', () => {
|
||||
fireEvent.click(openWithBtn!)
|
||||
})
|
||||
|
||||
expect(ensureTargetsMock).toHaveBeenCalledTimes(1)
|
||||
expect(getTargetsForPathMock).toHaveBeenCalledWith('/w/proj/README.md')
|
||||
})
|
||||
|
||||
it('opens an office changed file with the system application instead of the binary workspace preview', () => {
|
||||
renderCard(['/w/proj/reports/brief.docx'])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /turnChangesOpenFileAria/ }))
|
||||
|
||||
expect(openSystemFileSpy).toHaveBeenCalledWith('/w/proj/reports/brief.docx')
|
||||
expect(openPreviewSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('open-with button does not also trigger the row workspace-open (stopPropagation)', async () => {
|
||||
|
||||
@@ -5,15 +5,17 @@ import type { SessionTurnCheckpoint } from '../../api/sessions'
|
||||
import { useTranslation, type TranslationKey } from '../../i18n'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { OpenWithMenu } from '@/components/composite/OpenWithMenu'
|
||||
import { buildOpenWithItems, describeFileType, isPreviewableChangedFile, type OpenWithItem } from '../../lib/openWithItems'
|
||||
import { describeFileType, isPreviewableChangedFile, type OpenWithItem } from '../../lib/openWithItems'
|
||||
import { buildOpenWithMenuItems } from '../../lib/openWithMenuItems'
|
||||
import { openWithContextForWorkspaceFile } from '../../lib/openWithContextForHref'
|
||||
import { isAbsoluteLocalPath, localFileUrl } from '../../lib/handlePreviewLink'
|
||||
import { shouldOfferStaticHtmlPreview } from '../../lib/htmlPreviewPolicy'
|
||||
import { getServerBaseUrl } from '../../lib/desktopRuntime'
|
||||
import { getDesktopHost } from '../../lib/desktopHost'
|
||||
import { useOpenTargetStore } from '../../stores/openTargetStore'
|
||||
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
|
||||
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
|
||||
import { isWorkspacePreviewableFile } from '../../lib/fileCapabilities'
|
||||
import { openLocalFileWithSystem, reportOpenFailure } from '../../lib/systemFileOpen'
|
||||
|
||||
type CurrentTurnChangeCardProps = {
|
||||
sessionId: string
|
||||
@@ -60,6 +62,11 @@ export function CurrentTurnChangeCard({
|
||||
? files.slice(0, COLLAPSED_COUNT)
|
||||
: files
|
||||
const restoreAvailable = checkpoint.restoreAvailable !== false
|
||||
// Undo restores every file listed above, but a turn that also ran a writing
|
||||
// shell command may have touched files no checkpoint captured. Say so instead
|
||||
// of withholding the undo — the listed files are still exactly reversible.
|
||||
const unverifiedChangeSources = checkpoint.unverifiedChangeSources ?? []
|
||||
const hasUnverifiedChanges = restoreAvailable && unverifiedChangeSources.length > 0
|
||||
|
||||
const openChangedFile = useCallback((event: ReactMouseEvent<HTMLButtonElement>, fileEntry: ChangedFileEntry) => {
|
||||
const renderItem = event.currentTarget.closest<HTMLElement>('[data-chat-render-item-key]')
|
||||
@@ -67,6 +74,10 @@ export function CurrentTurnChangeCard({
|
||||
sourceTurnKey: renderItem?.dataset.chatRenderItemKey ?? checkpoint.target.targetUserMessageId,
|
||||
sourceElementId: event.currentTarget.id,
|
||||
}
|
||||
if (!isWorkspacePreviewableFile(fileEntry.displayPath)) {
|
||||
void openLocalFileWithSystem(fileEntry.apiPath).catch(() => reportOpenFailure(fileEntry.apiPath))
|
||||
return
|
||||
}
|
||||
// A changed file outside the workdir (absolute displayPath — e.g. another
|
||||
// drive) has no checkpoint baseline, so a diff is meaningless. Render html in
|
||||
// the in-app browser and everything else as a file preview (served by its
|
||||
@@ -97,18 +108,17 @@ export function CurrentTurnChangeCard({
|
||||
const triggerEl = event.currentTarget
|
||||
const rect = triggerEl.getBoundingClientRect()
|
||||
void (async () => {
|
||||
await useOpenTargetStore.getState().ensureTargets()
|
||||
const targets = useOpenTargetStore.getState().targets
|
||||
const targets = await useOpenTargetStore.getState().getTargetsForPath(fileEntry.apiPath)
|
||||
const ctx = openWithContextForWorkspaceFile(fileEntry.displayPath, fileEntry.apiPath, {
|
||||
sessionId,
|
||||
serverBaseUrl: getServerBaseUrl(),
|
||||
siblingFiles: files.map((entry) => entry.displayPath),
|
||||
})
|
||||
const items = buildOpenWithItems(ctx, targets, {
|
||||
openInAppBrowser: (url) => useBrowserPanelStore.getState().open(sessionId, url),
|
||||
openSystem: (p) => { void getDesktopHost().shell.openPath(p).catch(() => {}) },
|
||||
openWorkspacePreview: (rel) => { void useWorkspacePanelStore.getState().openPreview(sessionId, rel, 'file') },
|
||||
openTarget: (id, abs) => { void useOpenTargetStore.getState().openTarget(id, abs) },
|
||||
// The shared dependency factory, not a fourth hand-copied set: this call
|
||||
// site was the one that never adopted it, which is why the changed-file
|
||||
// menu was missing the copy entries every other surface has.
|
||||
const items = buildOpenWithMenuItems(ctx, targets, {
|
||||
sessionId,
|
||||
t: (k, v) => t(k as TranslationKey, v),
|
||||
})
|
||||
setOpenWith({ items, anchor: rect, triggerEl })
|
||||
@@ -119,10 +129,14 @@ export function CurrentTurnChangeCard({
|
||||
? t('chat.turnChangesLatestCardLabel')
|
||||
: t('chat.turnChangesHistoricalCardLabel')
|
||||
const subtitle = !restoreAvailable
|
||||
? t('chat.turnChangesPreviewOnlySubtitle')
|
||||
: isLatest
|
||||
? t('chat.turnChangesLatestSubtitle')
|
||||
: t('chat.turnChangesCurrentWorkspaceDiff')
|
||||
? t('chat.turnChangesConversationOnlySubtitle')
|
||||
: hasUnverifiedChanges
|
||||
? t('chat.turnChangesPartialCoverageSubtitle', {
|
||||
sources: unverifiedChangeSources.join(', '),
|
||||
})
|
||||
: isLatest
|
||||
? t('chat.turnChangesLatestSubtitle')
|
||||
: t('chat.turnChangesCurrentWorkspaceDiff')
|
||||
const undoLabel = isLatest
|
||||
? t('chat.turnChangesLatestUndo')
|
||||
: t('chat.turnChangesHistoricalUndo')
|
||||
@@ -132,7 +146,10 @@ export function CurrentTurnChangeCard({
|
||||
|
||||
return (
|
||||
<section
|
||||
className="mx-auto mb-5 w-full max-w-[900px] overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-card)]"
|
||||
// Follows the message it belongs to inside the same rail box, so it takes a
|
||||
// top margin and no width of its own — `max-w-[900px]` here would have
|
||||
// overflowed the column once the rail indented it.
|
||||
className="mt-2 w-full overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-card)]"
|
||||
aria-label={cardLabel}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
|
||||
@@ -148,27 +165,29 @@ export function CurrentTurnChangeCard({
|
||||
-{checkpoint.code.deletions}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-[var(--color-text-tertiary)]">
|
||||
<div
|
||||
className={`mt-0.5 text-xs ${
|
||||
hasUnverifiedChanges
|
||||
? 'text-[var(--color-warning)]'
|
||||
: 'text-[var(--color-text-tertiary)]'
|
||||
}`}
|
||||
>
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Never disabled: rolling the conversation back is always possible, even
|
||||
when the files are not restorable. The dialog picks what to touch. */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="base"
|
||||
loading={isUndoing}
|
||||
disabled={!restoreAvailable}
|
||||
onClick={onUndo}
|
||||
aria-label={restoreAvailable ? undoAria : t('chat.turnChangesRestoreUnavailable')}
|
||||
title={restoreAvailable ? undefined : t('chat.turnChangesRestoreUnavailable')}
|
||||
aria-label={undoAria}
|
||||
className="shrink-0"
|
||||
icon={<span className="material-symbols-outlined text-[15px]" aria-hidden="true">undo</span>}
|
||||
>
|
||||
{isUndoing
|
||||
? t('chat.turnChangesUndoing')
|
||||
: restoreAvailable
|
||||
? undoLabel
|
||||
: t('chat.turnChangesRestoreUnavailable')}
|
||||
{isUndoing ? t('chat.turnChangesUndoing') : undoLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -176,7 +195,7 @@ export function CurrentTurnChangeCard({
|
||||
{visibleFiles.map((fileEntry) => {
|
||||
const fileName = fileEntry.displayPath.split('/').pop() || fileEntry.displayPath
|
||||
const typeInfo = describeFileType(fileEntry.displayPath)
|
||||
const previewable = isPreviewableChangedFile(fileEntry.displayPath)
|
||||
const workspacePreviewable = isWorkspacePreviewableFile(fileEntry.displayPath)
|
||||
return (
|
||||
<div key={fileEntry.apiPath} className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -184,7 +203,12 @@ export function CurrentTurnChangeCard({
|
||||
id={`turn-change-opener-${checkpoint.target.targetUserMessageId}-${encodeURIComponent(fileEntry.apiPath)}`}
|
||||
data-source-turn-key={checkpoint.target.targetUserMessageId}
|
||||
onClick={(event) => openChangedFile(event, fileEntry)}
|
||||
aria-label={t('chat.turnChangesOpenInWorkspaceAria', { path: fileEntry.displayPath })}
|
||||
aria-label={t(
|
||||
workspacePreviewable
|
||||
? 'chat.turnChangesOpenInWorkspaceAria'
|
||||
: 'chat.turnChangesOpenFileAria',
|
||||
{ path: fileEntry.displayPath },
|
||||
)}
|
||||
title={fileEntry.displayPath}
|
||||
className="flex min-h-[52px] min-w-0 flex-1 items-center gap-3 rounded-[var(--radius-md)] px-4 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]"
|
||||
>
|
||||
@@ -195,19 +219,17 @@ export function CurrentTurnChangeCard({
|
||||
</span>
|
||||
<ChevronRight size={17} strokeWidth={1.9} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
|
||||
</button>
|
||||
{previewable && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="base"
|
||||
aria-label={t('openWith.title')}
|
||||
onClick={(event) => handleOpenWith(event, fileEntry)}
|
||||
className="mr-2 shrink-0"
|
||||
icon={<ChevronDown size={14} strokeWidth={1.9} aria-hidden="true" />}
|
||||
iconPosition="end"
|
||||
>
|
||||
{t('openWith.title')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="base"
|
||||
aria-label={t('openWith.title')}
|
||||
onClick={(event) => handleOpenWith(event, fileEntry)}
|
||||
className="mr-2 shrink-0"
|
||||
icon={<ChevronDown size={14} strokeWidth={1.9} aria-hidden="true" />}
|
||||
iconPosition="end"
|
||||
>
|
||||
{t('openWith.title')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function InlineTaskSummary({ tasks }: { tasks: TaskSummaryItem[] }) {
|
||||
const total = tasks.length
|
||||
|
||||
return (
|
||||
<div className="mb-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] overflow-hidden">
|
||||
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-4 py-2 bg-[var(--color-surface-container)]">
|
||||
<div className="flex items-center justify-center w-5 h-5 rounded-[var(--radius-md)] bg-[var(--color-success-container)]">
|
||||
<span className="material-symbols-outlined text-[13px] text-[var(--color-success)]" style={{ fontVariationSettings: "'FILL' 1" }}>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { EmptyState } from '@/components/ui/EmptyState'
|
||||
import { ErrorState } from '@/components/ui/ErrorState'
|
||||
import { IconButton } from '@/components/ui/IconButton'
|
||||
import { LoadingState } from '@/components/ui/LoadingState'
|
||||
import { mcpStatusTone } from '@/lib/mcpStatus'
|
||||
import { useTranslation, type TranslationKey } from '../../i18n'
|
||||
@@ -21,8 +20,17 @@ import { useSkillStore } from '../../stores/skillStore'
|
||||
import type { McpServerRecord } from '../../types/mcp'
|
||||
import type { SkillMeta } from '../../types/skill'
|
||||
import type { SlashCommandOption } from './composerUtils'
|
||||
import { SlashCommandPanelShell as PanelShell } from './SlashCommandPanelShell'
|
||||
import { WorkflowSavePanel } from './WorkflowSavePanel'
|
||||
|
||||
export type LocalSlashCommandName = 'mcp' | 'skills' | 'help' | 'status' | 'cost' | 'context'
|
||||
export type LocalSlashCommandName =
|
||||
| 'mcp'
|
||||
| 'skills'
|
||||
| 'help'
|
||||
| 'status'
|
||||
| 'cost'
|
||||
| 'context'
|
||||
| 'save-workflow'
|
||||
|
||||
type Props = {
|
||||
command: LocalSlashCommandName
|
||||
@@ -54,39 +62,6 @@ function projectBadge(path?: string, t?: ReturnType<typeof useTranslation>) {
|
||||
return t('slash.mcp.projectBadge', { name: label })
|
||||
}
|
||||
|
||||
function PanelShell({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
title: string
|
||||
subtitle: string
|
||||
children: React.ReactNode
|
||||
onClose: () => void
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
return (
|
||||
<div className="absolute bottom-full left-0 right-0 z-[var(--z-dropdown)] mb-3 overflow-hidden rounded-[var(--radius-2xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-overlay)]">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-[var(--color-border)] px-5 py-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">{title}</h3>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-tertiary)]">{subtitle}</p>
|
||||
</div>
|
||||
<IconButton
|
||||
icon="close"
|
||||
label={t('tabs.close')}
|
||||
size="lg"
|
||||
tone="secondary"
|
||||
shape="circle"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[min(620px,72vh)] overflow-y-auto px-5 py-4">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatNumber(value: number | undefined) {
|
||||
return new Intl.NumberFormat().format(value ?? 0)
|
||||
}
|
||||
@@ -991,7 +966,7 @@ const COMMAND_GROUPS = [
|
||||
},
|
||||
{
|
||||
titleKey: 'slash.help.group.desktop',
|
||||
names: ['mcp', 'skills', 'plugin', 'help'],
|
||||
names: ['mcp', 'skills', 'save-workflow', 'plugin', 'help'],
|
||||
},
|
||||
] satisfies Array<{ titleKey: TranslationKey; names: string[] }>
|
||||
|
||||
@@ -1075,6 +1050,16 @@ function HelpPanel({
|
||||
export function LocalSlashCommandPanel({ command, sessionId, cwd, commands, onClose }: Props) {
|
||||
if (command === 'mcp') return <McpPanel cwd={cwd} onClose={onClose} />
|
||||
if (command === 'skills') return <SkillsPanel cwd={cwd} onClose={onClose} />
|
||||
if (command === 'save-workflow') {
|
||||
return (
|
||||
<WorkflowSavePanel
|
||||
sessionId={sessionId}
|
||||
cwd={cwd}
|
||||
commands={commands}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (command === 'status' || command === 'cost' || command === 'context') {
|
||||
return <SessionInspectorPanel command={command} sessionId={sessionId} commands={commands} onClose={onClose} />
|
||||
}
|
||||
|
||||
@@ -16,11 +16,13 @@
|
||||
* textarea handlers calling preventDefault).
|
||||
*/
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
|
||||
import { Slice, type Node as PMNode } from 'prosemirror-model'
|
||||
import { EditorState, Plugin, PluginKey, TextSelection } from 'prosemirror-state'
|
||||
import { EditorView } from 'prosemirror-view'
|
||||
import { Decoration, DecorationSet, EditorView } from 'prosemirror-view'
|
||||
import { keymap } from 'prosemirror-keymap'
|
||||
import { history, redo, undo } from 'prosemirror-history'
|
||||
import { baseKeymap, splitBlock } from 'prosemirror-commands'
|
||||
import { findKeywordRanges } from '../../../../src/utils/workflows/keyword'
|
||||
import {
|
||||
buildComposerDoc,
|
||||
composerSchema,
|
||||
@@ -33,6 +35,7 @@ import {
|
||||
} from './composerEditor'
|
||||
import type { ComposerMention } from '../../lib/composerMentions'
|
||||
import { mentionsEqual } from '../../lib/composerMentions'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
|
||||
export type MentionComposerHandle = {
|
||||
focus: () => void
|
||||
@@ -72,6 +75,7 @@ export type MentionComposerProps = {
|
||||
* editable element (`[data-composer-editor]`).
|
||||
*/
|
||||
const composerViewRegistry = new WeakMap<HTMLElement, EditorView>()
|
||||
const workflowKeywordPluginKey = new PluginKey('workflow-keyword-highlight')
|
||||
|
||||
export function getComposerViewForTesting(element: HTMLElement | null): EditorView | undefined {
|
||||
return element ? composerViewRegistry.get(element) : undefined
|
||||
@@ -91,6 +95,23 @@ function syncEmptyState(view: EditorView, placeholder?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function workflowKeywordDecorations(doc: PMNode, enabled: boolean): DecorationSet {
|
||||
if (!enabled) return DecorationSet.empty
|
||||
|
||||
const text = projectComposerDoc(doc).text
|
||||
const decorations = findKeywordRanges(text).map((range) =>
|
||||
Decoration.inline(
|
||||
textOffsetToPmPos(doc, range.start),
|
||||
textOffsetToPmPos(doc, range.end),
|
||||
{
|
||||
class: 'composer-workflow-keyword',
|
||||
'data-workflow-keyword': 'true',
|
||||
},
|
||||
),
|
||||
)
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
|
||||
export const MentionComposer = forwardRef<MentionComposerHandle, MentionComposerProps>(
|
||||
function MentionComposer(props, ref) {
|
||||
const {
|
||||
@@ -107,6 +128,11 @@ export const MentionComposer = forwardRef<MentionComposerHandle, MentionComposer
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
const workflowKeywordTriggerEnabled = useSettingsStore(
|
||||
(state) => state.workflowKeywordTriggerEnabled,
|
||||
)
|
||||
const workflowKeywordTriggerEnabledRef = useRef(workflowKeywordTriggerEnabled)
|
||||
workflowKeywordTriggerEnabledRef.current = workflowKeywordTriggerEnabled
|
||||
const propsRef = useRef(props)
|
||||
propsRef.current = props
|
||||
const lastProjectedRef = useRef({ text: value, mentions })
|
||||
@@ -124,7 +150,30 @@ export const MentionComposer = forwardRef<MentionComposerHandle, MentionComposer
|
||||
key: new PluginKey('mention-composer-props'),
|
||||
props: {
|
||||
handleKeyDown: (_view, event) => propsRef.current.onKeyDown?.(event) ?? false,
|
||||
handlePaste: (_view, event, _slice) => propsRef.current.onPaste?.(event) ?? false,
|
||||
handlePaste: (editorView, event, _slice) => {
|
||||
if (propsRef.current.onPaste?.(event)) return true
|
||||
|
||||
const clipboard = event.clipboardData
|
||||
if (!clipboard) return false
|
||||
const html = clipboard.getData('text/html')
|
||||
// Preserve ProseMirror's own slice metadata so copied mention
|
||||
// atoms remain atoms. External rich text only projects to
|
||||
// plain composer text, so its text/plain representation is
|
||||
// the authoritative source for exact line and blank-line
|
||||
// boundaries (Feishu uses empty HTML blocks for blank lines).
|
||||
if (html.includes('data-pm-slice')) return false
|
||||
const text = clipboard.getData('text/plain')
|
||||
if (!text) return false
|
||||
|
||||
const normalizedText = text.replace(/\r\n?/g, '\n')
|
||||
const pastedDoc = buildComposerDoc(normalizedText, [])
|
||||
editorView.dispatch(
|
||||
editorView.state.tr
|
||||
.replaceSelection(Slice.maxOpen(pastedDoc.content))
|
||||
.scrollIntoView(),
|
||||
)
|
||||
return true
|
||||
},
|
||||
handleDOMEvents: {
|
||||
// File drops belong to the composer's outer drop zone, which
|
||||
// turns them into attachments. Left to ProseMirror, a drop
|
||||
@@ -134,6 +183,15 @@ export const MentionComposer = forwardRef<MentionComposerHandle, MentionComposer
|
||||
},
|
||||
},
|
||||
}),
|
||||
new Plugin({
|
||||
key: workflowKeywordPluginKey,
|
||||
props: {
|
||||
decorations: (state) => workflowKeywordDecorations(
|
||||
state.doc,
|
||||
workflowKeywordTriggerEnabledRef.current,
|
||||
),
|
||||
},
|
||||
}),
|
||||
history(),
|
||||
// Whole-pill deletion, ahead of baseKeymap's structural commands.
|
||||
keymap({
|
||||
@@ -203,6 +261,18 @@ export const MentionComposer = forwardRef<MentionComposerHandle, MentionComposer
|
||||
syncEmptyState(view, propsRef.current.placeholder)
|
||||
}, [value, mentions])
|
||||
|
||||
// The setting can change while the current draft stays untouched. An
|
||||
// empty transaction makes ProseMirror recompute decorations immediately,
|
||||
// so the visible opt-in warning and the persisted runtime gate stay joined.
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
view.dispatch(view.state.tr.setMeta(
|
||||
workflowKeywordPluginKey,
|
||||
workflowKeywordTriggerEnabled,
|
||||
))
|
||||
}, [workflowKeywordTriggerEnabled])
|
||||
|
||||
// Dynamic editor chrome: classes and aria attributes are not part of the
|
||||
// document, so they are applied straight to the editable element.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Check, Copy, GitFork } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { formatExactMessageTimestamp, formatMessageHoverTime } from '../../lib/formatMessageTimestamp'
|
||||
import { CopyButton } from '@/components/ui/CopyButton'
|
||||
@@ -28,6 +29,14 @@ type Props = {
|
||||
branchAction?: MessageBranchAction
|
||||
align?: 'start' | 'end'
|
||||
timestamp?: number
|
||||
/** Inline metadata that shares the same compact row as the actions. */
|
||||
metadata?: ReactNode
|
||||
/**
|
||||
* Skip the hover gate. For bars that are already rare and deliberate — the
|
||||
* reply that closes a turn — where hiding them until hover only makes a
|
||||
* present affordance hard to find.
|
||||
*/
|
||||
alwaysVisible?: boolean
|
||||
}
|
||||
|
||||
export function MessageActionBar({
|
||||
@@ -36,6 +45,8 @@ export function MessageActionBar({
|
||||
branchAction,
|
||||
align = 'start',
|
||||
timestamp,
|
||||
metadata,
|
||||
alwaysVisible = false,
|
||||
}: Props) {
|
||||
const locale = useSettingsStore((state) => state.locale)
|
||||
const hasCopy = Boolean(copyText?.trim())
|
||||
@@ -46,17 +57,19 @@ export function MessageActionBar({
|
||||
? formatExactMessageTimestamp(timestamp, locale)
|
||||
: ''
|
||||
|
||||
if (!hasCopy && !branchAction) return null
|
||||
if (!hasCopy && !branchAction && !metadata) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-message-actions
|
||||
data-align={align}
|
||||
className={`pointer-events-none mt-2 flex h-7 w-full opacity-0 transition-opacity duration-150 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100 ${
|
||||
align === 'end' ? 'justify-end' : 'justify-start'
|
||||
}`}
|
||||
className={`mt-2 flex h-7 w-full transition-opacity duration-150 ${
|
||||
alwaysVisible
|
||||
? ''
|
||||
: 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100'
|
||||
} ${align === 'end' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className="flex min-h-7 items-center gap-1.5">
|
||||
<div className="flex min-h-7 min-w-0 items-center gap-1.5">
|
||||
{hasCopy ? (
|
||||
<CopyButton
|
||||
text={copyText!}
|
||||
@@ -79,6 +92,11 @@ export function MessageActionBar({
|
||||
onPointerUp={(event) => event.currentTarget.blur()}
|
||||
/>
|
||||
) : null}
|
||||
{metadata ? (
|
||||
<span className={hasCopy || branchAction ? 'ml-3 min-w-0' : 'min-w-0'}>
|
||||
{metadata}
|
||||
</span>
|
||||
) : null}
|
||||
{hoverTimeLabel ? (
|
||||
<span
|
||||
className="ml-1 inline-flex items-center text-[11px] font-medium tabular-nums text-[var(--color-text-tertiary)]"
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import '@testing-library/jest-dom'
|
||||
import {
|
||||
captureProjectDisplayNameHydrationRevision,
|
||||
hydrateProjectDisplayNames,
|
||||
} from '../../stores/projectDisplayNameStore'
|
||||
import { ProjectContextChip } from './ProjectContextChip'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('ProjectContextChip', () => {
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows only the source project label and worktree marker for isolated worktrees', () => {
|
||||
vi.useFakeTimers()
|
||||
render(
|
||||
<ProjectContextChip
|
||||
workDir="/workspace/OpenCutSkill/.claude/worktrees/desktop-main-54a09f85"
|
||||
@@ -13,6 +34,7 @@ describe('ProjectContextChip', () => {
|
||||
branch="main"
|
||||
isWorktree
|
||||
worktreeSlug="desktop-main-54a09f85"
|
||||
worktreePath="/workspace/OpenCutSkill/.claude/worktrees/desktop-main-54a09f85"
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -20,6 +42,87 @@ describe('ProjectContextChip', () => {
|
||||
expect(screen.getByText('worktree')).toBeInTheDocument()
|
||||
expect(screen.queryByText('main')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('desktop-main-54a09f85')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseEnter(screen.getByTestId('worktree-details-trigger'))
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('desktop-main-54a09f85')
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('/workspace/OpenCutSkill/.claude/worktrees/desktop-main-54a09f85')
|
||||
})
|
||||
|
||||
it('reveals the worktree name and directory from the compact marker', () => {
|
||||
vi.useFakeTimers()
|
||||
render(
|
||||
<ProjectContextChip
|
||||
variant="toolbar"
|
||||
workDir="/workspace/OpenCutSkill/.claude/worktrees/desktop-main-54a09f85"
|
||||
sourceWorkDir="/workspace/OpenCutSkill"
|
||||
repoName="OpenCutSkill"
|
||||
branch="main"
|
||||
isWorktree
|
||||
worktreeSlug={null}
|
||||
worktreePath={'C:\\workspace\\OpenCutSkill\\.claude\\worktrees\\desktop-main-54a09f85'}
|
||||
/>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByTestId('worktree-details-trigger')
|
||||
expect(screen.getByTestId('run-location-readonly')).not.toHaveAttribute('title')
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.focus(trigger)
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
|
||||
const tooltip = screen.getByRole('tooltip')
|
||||
expect(tooltip).toHaveTextContent('desktop-main-54a09f85')
|
||||
expect(tooltip).toHaveTextContent('C:\\workspace\\OpenCutSkill\\.claude\\worktrees\\desktop-main-54a09f85')
|
||||
expect(trigger).toHaveAttribute('aria-describedby', tooltip.id)
|
||||
})
|
||||
|
||||
it('prioritizes the project-root display name and retains raw worktree context in its tooltip', () => {
|
||||
vi.useFakeTimers()
|
||||
const projectRoot = '/workspace/canonical-project'
|
||||
const sourceWorkDir = '/workspace/OpenCutSkill'
|
||||
const worktreePath = '/workspace/OpenCutSkill/.claude/worktrees/desktop-main-54a09f85'
|
||||
|
||||
render(
|
||||
<ProjectContextChip
|
||||
workDir={worktreePath}
|
||||
projectRoot={projectRoot}
|
||||
repoName="OpenCutSkill"
|
||||
branch="main"
|
||||
sourceWorkDir={sourceWorkDir}
|
||||
isWorktree
|
||||
worktreeSlug="desktop-main-54a09f85"
|
||||
worktreePath={worktreePath}
|
||||
/>,
|
||||
)
|
||||
|
||||
const chip = screen.getByTestId('run-location-outside')
|
||||
expect(chip).toHaveTextContent('OpenCutSkill')
|
||||
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames(
|
||||
{
|
||||
[projectRoot]: 'OpenCut Studio',
|
||||
[sourceWorkDir]: 'Source project name',
|
||||
},
|
||||
captureProjectDisplayNameHydrationRevision(),
|
||||
)
|
||||
})
|
||||
|
||||
expect(chip).toHaveTextContent('OpenCut Studio')
|
||||
expect(chip).not.toHaveTextContent('Source project name')
|
||||
|
||||
// The alias replaces the folder name on the chip, so the real directory has
|
||||
// to stay reachable somewhere. For a worktree that is the details tooltip —
|
||||
// the chip itself carries no `title` in this mode.
|
||||
expect(chip).not.toHaveAttribute('title')
|
||||
fireEvent.mouseEnter(screen.getByTestId('worktree-details-trigger'))
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
|
||||
const tooltip = screen.getByRole('tooltip')
|
||||
expect(tooltip).toHaveTextContent(projectRoot)
|
||||
expect(tooltip).toHaveTextContent(worktreePath)
|
||||
})
|
||||
|
||||
it('does not show worktree details for a normal checkout', () => {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { GitFork } from 'lucide-react'
|
||||
import { Tooltip } from '@/components/ui/Tooltip'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getFileNameFromPath } from '../../lib/composerAttachments'
|
||||
import { useProjectDisplayName } from '../../stores/projectDisplayNameStore'
|
||||
import { getWorktreeDisplayName, WorktreeDetails } from './WorktreeDetails'
|
||||
|
||||
type Props = {
|
||||
workDir?: string | null
|
||||
projectRoot?: string | null
|
||||
repoName?: string | null
|
||||
branch?: string | null
|
||||
sourceWorkDir?: string | null
|
||||
@@ -22,11 +27,12 @@ type Props = {
|
||||
}
|
||||
|
||||
function basename(path: string | null | undefined): string {
|
||||
return path?.split('/').filter(Boolean).pop() || ''
|
||||
return path ? getFileNameFromPath(path) : ''
|
||||
}
|
||||
|
||||
export function ProjectContextChip({
|
||||
workDir,
|
||||
projectRoot,
|
||||
repoName,
|
||||
branch,
|
||||
sourceWorkDir,
|
||||
@@ -38,8 +44,10 @@ export function ProjectContextChip({
|
||||
}: Props) {
|
||||
const t = useTranslation()
|
||||
const labelRoot = isWorktree ? (sourceWorkDir || workDir) : workDir
|
||||
const label = branch ? (repoName || basename(labelRoot)) : (basename(labelRoot) || repoName || '')
|
||||
const worktreeName = worktreeSlug || basename(worktreePath) || 'isolated'
|
||||
const displayNameKey = projectRoot ?? sourceWorkDir ?? workDir ?? ''
|
||||
const displayName = useProjectDisplayName(displayNameKey)
|
||||
const label = displayName || (branch ? (repoName || basename(labelRoot)) : (basename(labelRoot) || repoName || ''))
|
||||
const worktreeName = getWorktreeDisplayName(worktreeSlug, worktreePath) || 'isolated'
|
||||
const isToolbar = variant === 'toolbar'
|
||||
// The chip variant hides the branch for worktrees — the worktree name already
|
||||
// encodes it. The toolbar variant keeps it, because it has to stay
|
||||
@@ -47,11 +55,17 @@ export function ProjectContextChip({
|
||||
const showBranch = !!branch && (isToolbar || !isWorktree)
|
||||
const title = [
|
||||
label,
|
||||
displayName ? `project root: ${displayNameKey}` : null,
|
||||
branch ? `branch: ${branch}` : null,
|
||||
isWorktree ? `worktree: ${worktreeName}` : null,
|
||||
worktreePath ? `worktree cwd: ${worktreePath}` : null,
|
||||
workDir ? `cwd: ${workDir}` : null,
|
||||
].filter(Boolean).join('\n')
|
||||
const worktreeDetails = (
|
||||
<WorktreeDetails
|
||||
name={worktreeName}
|
||||
path={worktreePath}
|
||||
projectRoot={displayName ? displayNameKey : null}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!label) return null
|
||||
|
||||
@@ -71,7 +85,7 @@ export function ProjectContextChip({
|
||||
if (isToolbar) {
|
||||
return (
|
||||
<div
|
||||
title={title}
|
||||
title={isWorktree ? undefined : title}
|
||||
data-testid="run-location-readonly"
|
||||
className="inline-flex h-9 min-w-0 max-w-full shrink items-center gap-1.5 text-[13px] font-medium leading-none"
|
||||
>
|
||||
@@ -93,10 +107,16 @@ export function ProjectContextChip({
|
||||
</>
|
||||
)}
|
||||
{isWorktree && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-[5px] bg-[var(--color-brand-soft)] px-1.5 py-1 text-[10px] font-bold leading-none text-[var(--color-brand)]">
|
||||
<GitFork size={11} aria-hidden="true" />
|
||||
{t('repoLaunch.worktreeBadge')}
|
||||
</span>
|
||||
<Tooltip content={worktreeDetails} placement="top-start">
|
||||
<span
|
||||
data-testid="worktree-details-trigger"
|
||||
tabIndex={0}
|
||||
className="inline-flex shrink-0 cursor-help items-center gap-1 rounded-[5px] bg-[var(--color-brand-soft)] px-1.5 py-1 text-[10px] font-bold leading-none text-[var(--color-brand)] outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)]"
|
||||
>
|
||||
<GitFork size={11} aria-hidden="true" />
|
||||
{t('repoLaunch.worktreeBadge')}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -104,7 +124,7 @@ export function ProjectContextChip({
|
||||
|
||||
return (
|
||||
<div
|
||||
title={title}
|
||||
title={isWorktree ? undefined : title}
|
||||
data-testid="run-location-outside"
|
||||
className={`inline-flex max-w-full items-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-secondary)] ${
|
||||
compact ? 'gap-1.5 px-3 py-1.5 text-xs' : 'gap-2 px-4 py-2 text-sm'
|
||||
@@ -123,9 +143,15 @@ export function ProjectContextChip({
|
||||
{isWorktree ? (
|
||||
<>
|
||||
<span className="text-[var(--color-text-tertiary)]">|</span>
|
||||
<span className="shrink-0 rounded-full border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] font-medium uppercase leading-none text-[var(--color-text-tertiary)]">
|
||||
worktree
|
||||
</span>
|
||||
<Tooltip content={worktreeDetails} placement="top-start">
|
||||
<span
|
||||
data-testid="worktree-details-trigger"
|
||||
tabIndex={0}
|
||||
className="shrink-0 cursor-help rounded-full border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] font-medium uppercase leading-none text-[var(--color-text-tertiary)] outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)]"
|
||||
>
|
||||
worktree
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { useState, type ComponentProps } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, useState, type ComponentProps } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { create } from 'zustand'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
const viewportMocks = vi.hoisted(() => ({
|
||||
@@ -10,6 +13,11 @@ const viewportMocks = vi.hoisted(() => ({
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
getRepositoryContext: vi.fn(),
|
||||
createRepositoryBranch: vi.fn(),
|
||||
}))
|
||||
|
||||
const uiMocks = vi.hoisted(() => ({
|
||||
addToast: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useMobileViewport', () => ({
|
||||
@@ -24,9 +32,16 @@ vi.mock('../../lib/desktopRuntime', () => ({
|
||||
vi.mock('../../api/sessions', () => ({
|
||||
sessionsApi: {
|
||||
getRepositoryContext: apiMocks.getRepositoryContext,
|
||||
createRepositoryBranch: apiMocks.createRepositoryBranch,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../stores/uiStore', () => ({
|
||||
useUIStore: (selector: (state: { addToast: typeof uiMocks.addToast }) => unknown) => (
|
||||
selector({ addToast: uiMocks.addToast })
|
||||
),
|
||||
}))
|
||||
|
||||
// Must match the component's own import specifier, or the mock silently does
|
||||
// nothing and the real panel renders. It moved to composite/ in the directory
|
||||
// reshuffle and this path was left behind.
|
||||
@@ -41,7 +56,9 @@ vi.mock('@/components/composite/DirectoryPicker', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../i18n', () => ({
|
||||
useTranslation: () => (key: string) => ({
|
||||
useTranslation: () => (key: string, params?: Record<string, string | number>) => {
|
||||
const text = {
|
||||
'common.cancel': 'Cancel',
|
||||
'common.loading': 'Loading',
|
||||
'dirPicker.directory': 'Directory',
|
||||
'dirPicker.selectProject': 'Select a project',
|
||||
@@ -55,6 +72,18 @@ vi.mock('../../i18n', () => ({
|
||||
'repoLaunch.launchLocation': 'Location',
|
||||
'repoLaunch.localBranch': 'Local branch',
|
||||
'repoLaunch.missingWorkdir': 'Missing working directory',
|
||||
'repoLaunch.newBranch': 'Create branch…',
|
||||
'repoLaunch.newBranchErrorNoCommits': 'This repository has no commits yet.',
|
||||
'repoLaunch.newBranchErrorExists': 'A branch with this name already exists.',
|
||||
'repoLaunch.newBranchErrorFailed': 'Could not create the branch.',
|
||||
'repoLaunch.newBranchErrorInvalid': 'Git will not accept this branch name.',
|
||||
'repoLaunch.newBranchFrom': 'Starts from {branch}',
|
||||
'repoLaunch.newBranchNameLabel': 'Branch name',
|
||||
'repoLaunch.newBranchPlaceholder': 'feature/my-change',
|
||||
'repoLaunch.newBranchSubmit': 'Create',
|
||||
'repoLaunch.newBranchSuccessCurrent': 'Created and selected “{branch}”. It will be checked out when the session starts.',
|
||||
'repoLaunch.newBranchSuccessIsolated': 'Created and selected “{branch}”. An isolated worktree will be created from it when the session starts.',
|
||||
'repoLaunch.newBranchTitle': 'New branch',
|
||||
'repoLaunch.noBranch': 'No branch',
|
||||
'repoLaunch.noBranchMatch': 'No matching branches',
|
||||
'repoLaunch.remoteBranch': 'Remote branch',
|
||||
@@ -67,10 +96,21 @@ vi.mock('../../i18n', () => ({
|
||||
'repoLaunch.worktreeIsolated': 'Isolated worktree',
|
||||
'repoLaunch.worktreeIsolatedHint': 'New isolated copy',
|
||||
'tabs.close': 'Close',
|
||||
}[key] ?? key),
|
||||
}[key] ?? key
|
||||
return params
|
||||
? Object.entries(params).reduce((acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)), text)
|
||||
: text
|
||||
},
|
||||
}))
|
||||
|
||||
import { RepositoryLaunchControls } from './RepositoryLaunchControls'
|
||||
import {
|
||||
captureProjectDisplayNameHydrationRevision,
|
||||
hydrateProjectDisplayNames,
|
||||
} from '../../stores/projectDisplayNameStore'
|
||||
|
||||
const HEAD_COMMIT = 'a'.repeat(40)
|
||||
const OTHER_COMMIT = 'b'.repeat(40)
|
||||
|
||||
const okRepositoryContext = {
|
||||
state: 'ok' as const,
|
||||
@@ -79,6 +119,7 @@ const okRepositoryContext = {
|
||||
repoName: 'cc-haha',
|
||||
currentBranch: 'main',
|
||||
defaultBranch: 'main',
|
||||
headCommit: HEAD_COMMIT,
|
||||
dirty: false,
|
||||
worktrees: [],
|
||||
branches: [
|
||||
@@ -90,6 +131,7 @@ const okRepositoryContext = {
|
||||
checkedOut: false,
|
||||
remoteRef: null,
|
||||
worktreePath: null,
|
||||
commit: HEAD_COMMIT,
|
||||
},
|
||||
{
|
||||
name: 'feature/h5',
|
||||
@@ -99,10 +141,34 @@ const okRepositoryContext = {
|
||||
checkedOut: false,
|
||||
remoteRef: null,
|
||||
worktreePath: null,
|
||||
commit: OTHER_COMMIT,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/** `okRepositoryContext` plus a branch created off `main`, so still at HEAD. */
|
||||
const contextWithCreatedBranch = {
|
||||
...okRepositoryContext,
|
||||
branches: [
|
||||
...okRepositoryContext.branches,
|
||||
{
|
||||
name: 'feature/new',
|
||||
current: false,
|
||||
local: true,
|
||||
remote: false,
|
||||
checkedOut: false,
|
||||
remoteRef: null,
|
||||
worktreePath: null,
|
||||
commit: HEAD_COMMIT,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/** Rejection shaped like the API client's `ApiError`, which carries the body. */
|
||||
function apiError(code: string, message = 'boom') {
|
||||
return Object.assign(new Error(message), { body: { error: code, message } })
|
||||
}
|
||||
|
||||
function notGitContext(workDir: string) {
|
||||
return {
|
||||
state: 'not_git_repo' as const,
|
||||
@@ -135,9 +201,16 @@ function renderControls(props: Partial<ComponentProps<typeof RepositoryLaunchCon
|
||||
* happens *after* a directory is picked needs the prop to actually come back
|
||||
* down changed.
|
||||
*/
|
||||
function ControlledHarness({ initialWorkDir = '' }: { initialWorkDir?: string }) {
|
||||
function ControlledHarness({
|
||||
initialWorkDir = '',
|
||||
initialUseWorktree = false,
|
||||
}: {
|
||||
initialWorkDir?: string
|
||||
initialUseWorktree?: boolean
|
||||
}) {
|
||||
const [workDir, setWorkDir] = useState(initialWorkDir)
|
||||
const [branch, setBranch] = useState<string | null>(null)
|
||||
const [useWorktree, setUseWorktree] = useState(initialUseWorktree)
|
||||
|
||||
return (
|
||||
<RepositoryLaunchControls
|
||||
@@ -145,8 +218,8 @@ function ControlledHarness({ initialWorkDir = '' }: { initialWorkDir?: string })
|
||||
onWorkDirChange={setWorkDir}
|
||||
branch={branch}
|
||||
onBranchChange={setBranch}
|
||||
useWorktree={false}
|
||||
onUseWorktreeChange={vi.fn()}
|
||||
useWorktree={useWorktree}
|
||||
onUseWorktreeChange={setUseWorktree}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -174,13 +247,29 @@ async function openBranchView() {
|
||||
|
||||
describe('RepositoryLaunchControls', () => {
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
viewportMocks.isMobile = false
|
||||
viewportMocks.isTauri = false
|
||||
apiMocks.getRepositoryContext.mockReset()
|
||||
apiMocks.getRepositoryContext.mockResolvedValue(okRepositoryContext)
|
||||
apiMocks.createRepositoryBranch.mockReset()
|
||||
uiMocks.addToast.mockReset()
|
||||
apiMocks.createRepositoryBranch.mockResolvedValue({
|
||||
branch: 'feature/new',
|
||||
baseRef: 'main',
|
||||
context: contextWithCreatedBranch,
|
||||
})
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
})
|
||||
|
||||
it('collapses directory, branch and worktree into a single pill', async () => {
|
||||
renderControls()
|
||||
|
||||
@@ -194,6 +283,28 @@ describe('RepositoryLaunchControls', () => {
|
||||
expect(screen.queryByRole('button', { name: /Select worktree mode:/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses the repository-root display name when repository context canonicalizes the requested path', async () => {
|
||||
const workDir = '/repo/subdirectory'
|
||||
apiMocks.getRepositoryContext.mockResolvedValue({
|
||||
...okRepositoryContext,
|
||||
workDir: '/repo',
|
||||
repoRoot: '/repo',
|
||||
})
|
||||
renderControls({ workDir })
|
||||
await screen.findByRole('button', { name: 'Location: cc-haha / main' })
|
||||
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames(
|
||||
{ '/repo': 'Custom repository' },
|
||||
captureProjectDisplayNameHydrationRevision(),
|
||||
)
|
||||
})
|
||||
|
||||
const pill = await screen.findByRole('button', { name: 'Location: Custom repository / main' })
|
||||
expect(pill).toHaveAttribute('title', `${workDir}\nBranch: main`)
|
||||
expect(within(pill).getByText('main')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('truncates the branch from the start so its tail survives', async () => {
|
||||
renderControls()
|
||||
|
||||
@@ -430,6 +541,496 @@ describe('RepositoryLaunchControls', () => {
|
||||
expect(screen.queryByRole('menu', { name: 'Location' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
/**
|
||||
* Switching branches has always been possible here; creating one meant
|
||||
* leaving for a terminal (#1190). The entry lives at the foot of the branch
|
||||
* list, and the branch it creates is selected but — like every other pick in
|
||||
* this menu — only checked out when the session launches.
|
||||
*/
|
||||
describe('new branch', () => {
|
||||
async function openNewBranchForm() {
|
||||
await openBranchView()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create branch…' }))
|
||||
return screen.findByLabelText('Branch name')
|
||||
}
|
||||
|
||||
it('offers the entry under the branch list without putting it in the listbox', async () => {
|
||||
renderControls()
|
||||
const listbox = await openBranchView()
|
||||
|
||||
const entry = screen.getByRole('button', { name: 'Create branch…' })
|
||||
expect(entry).toBeInTheDocument()
|
||||
// An action among the `option`s would be announced as a branch you could
|
||||
// pick, and Enter on the search field could land on it.
|
||||
expect(listbox).not.toContainElement(entry)
|
||||
expect(within(listbox).queryByRole('option', { name: /New branch/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('seeds the name with whatever was typed into the branch filter', async () => {
|
||||
renderControls()
|
||||
await openBranchView()
|
||||
fireEvent.change(await screen.findByPlaceholderText('Search branches'), {
|
||||
target: { value: ' feature/new ' },
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create branch…' }))
|
||||
|
||||
expect(await screen.findByLabelText('Branch name')).toHaveValue('feature/new')
|
||||
})
|
||||
|
||||
it('creates the branch from the selected one and adopts the returned context', async () => {
|
||||
render(<ControlledHarness initialWorkDir="/repo" />)
|
||||
const input = await openNewBranchForm()
|
||||
expect(screen.getByText('Starts from main')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'feature/new' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.createRepositoryBranch).toHaveBeenCalledWith({
|
||||
workDir: '/repo',
|
||||
name: 'feature/new',
|
||||
from: 'main',
|
||||
})
|
||||
})
|
||||
|
||||
// Success closes the menu so the selected branch is immediately visible
|
||||
// on the pill instead of leaving the user inside an apparently unchanged
|
||||
// menu. The returned list is adopted without a second request.
|
||||
const pill = await screen.findByRole('button', { name: 'Location: cc-haha / feature/new' })
|
||||
expect(screen.queryByRole('menu', { name: 'Location' })).not.toBeInTheDocument()
|
||||
expect(apiMocks.getRepositoryContext).toHaveBeenCalledTimes(1)
|
||||
expect(uiMocks.addToast).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'Created and selected “feature/new”. It will be checked out when the session starts.',
|
||||
})
|
||||
|
||||
fireEvent.click(pill)
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: /Branch/ }))
|
||||
const listbox = await screen.findByRole('listbox', { name: 'Select branch' })
|
||||
expect(within(listbox).getByRole('option', { name: /feature\/new/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps a created branch selected across the external launch-store boundary', async () => {
|
||||
const launch = vi.fn()
|
||||
const useLaunchTarget = create<{
|
||||
branch: string | null
|
||||
setBranch: (branch: string | null) => void
|
||||
}>((set) => ({
|
||||
branch: 'main',
|
||||
setBranch: (branch) => set({ branch }),
|
||||
}))
|
||||
const setLaunchBranch = (branch: string | null) => {
|
||||
if (branch === 'qa/launch-picker') {
|
||||
// Production React publishes the external-store selection before
|
||||
// this component's local context update. Vitest's development build
|
||||
// batches them, so force that real ordering at the store boundary.
|
||||
flushSync(() => useLaunchTarget.getState().setBranch(branch))
|
||||
return
|
||||
}
|
||||
useLaunchTarget.getState().setBranch(branch)
|
||||
}
|
||||
const createdContext = {
|
||||
...contextWithCreatedBranch,
|
||||
branches: contextWithCreatedBranch.branches.map((candidate) => (
|
||||
candidate.name === 'feature/new'
|
||||
? { ...candidate, name: 'qa/launch-picker' }
|
||||
: candidate
|
||||
)),
|
||||
}
|
||||
apiMocks.createRepositoryBranch.mockImplementation(() => new Promise((resolve) => {
|
||||
window.setTimeout(() => resolve({
|
||||
branch: 'qa/launch-picker',
|
||||
baseRef: 'main',
|
||||
context: createdContext,
|
||||
}), 0)
|
||||
}))
|
||||
|
||||
function ExternalLaunchHarness() {
|
||||
const branch = useLaunchTarget((state) => state.branch)
|
||||
return (
|
||||
<>
|
||||
<RepositoryLaunchControls
|
||||
workDir="/repo"
|
||||
onWorkDirChange={vi.fn()}
|
||||
branch={branch}
|
||||
onBranchChange={setLaunchBranch}
|
||||
useWorktree={false}
|
||||
onUseWorktreeChange={vi.fn()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => launch({
|
||||
workDir: '/repo',
|
||||
repository: {
|
||||
branch: useLaunchTarget.getState().branch,
|
||||
worktree: false,
|
||||
},
|
||||
})}
|
||||
>
|
||||
Launch
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
const testGlobals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
const actEnvironment = testGlobals.IS_REACT_ACT_ENVIRONMENT
|
||||
testGlobals.IS_REACT_ACT_ENVIRONMENT = false
|
||||
async function eventually<T>(read: () => T): Promise<T> {
|
||||
let lastError: unknown
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
try {
|
||||
return read()
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
try {
|
||||
root.render(<ExternalLaunchHarness />)
|
||||
const pill = await eventually(() => (
|
||||
screen.getByRole('button', { name: 'Location: cc-haha / main' })
|
||||
))
|
||||
pill.click()
|
||||
const branchEntry = await eventually(() => screen.getByRole('menuitem', { name: /Branch/ }))
|
||||
branchEntry.click()
|
||||
const createEntry = await eventually(() => screen.getByRole('button', { name: 'Create branch…' }))
|
||||
createEntry.click()
|
||||
|
||||
const input = await eventually(() => screen.getByLabelText('Branch name'))
|
||||
const setInputValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
|
||||
setInputValue?.call(input, 'qa/launch-picker')
|
||||
input.dispatchEvent(new window.Event('input', { bubbles: true }))
|
||||
const createButton = await eventually(() => {
|
||||
const button = screen.getByRole('button', { name: 'Create' }) as HTMLButtonElement
|
||||
if (button.disabled) throw new Error('Create branch button is still disabled')
|
||||
return button
|
||||
})
|
||||
createButton.click()
|
||||
|
||||
await eventually(() => (
|
||||
screen.getByRole('button', { name: 'Location: cc-haha / qa/launch-picker' })
|
||||
))
|
||||
await eventually(() => {
|
||||
if (screen.queryByRole('menu', { name: 'Location' })) {
|
||||
throw new Error('Location menu is still open')
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
screen.getByRole('button', { name: 'Launch' }).click()
|
||||
|
||||
expect(launch).toHaveBeenCalledWith({
|
||||
workDir: '/repo',
|
||||
repository: { branch: 'qa/launch-picker', worktree: false },
|
||||
})
|
||||
expect(useLaunchTarget.getState().branch).toBe('qa/launch-picker')
|
||||
expect(screen.getByRole('button', { name: 'Location: cc-haha / qa/launch-picker' }))
|
||||
.toBeInTheDocument()
|
||||
expect(screen.queryByRole('menu', { name: 'Location' })).not.toBeInTheDocument()
|
||||
} finally {
|
||||
root.unmount()
|
||||
container.remove()
|
||||
testGlobals.IS_REACT_ACT_ENVIRONMENT = actEnvironment
|
||||
}
|
||||
})
|
||||
|
||||
it('explains that an isolated worktree starts from the new branch', async () => {
|
||||
render(<ControlledHarness initialWorkDir="/repo" initialUseWorktree />)
|
||||
const input = await openNewBranchForm()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'feature/new' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uiMocks.addToast).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'Created and selected “feature/new”. An isolated worktree will be created from it when the session starts.',
|
||||
})
|
||||
})
|
||||
expect(await screen.findByRole('button', { name: 'Location: cc-haha / feature/new' }))
|
||||
.toBeInTheDocument()
|
||||
expect(screen.getByText('Isolated')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Creating off `main` is also what `from: context.currentBranch` would do,
|
||||
// so the default-selection test cannot tell the two apart — it stayed green
|
||||
// under exactly that mutation. This drives a non-default selection, which is
|
||||
// the contract the "Starts from {branch}" hint advertises.
|
||||
it('creates from the branch that is actually selected, not the current one', async () => {
|
||||
render(<ControlledHarness initialWorkDir="/repo" />)
|
||||
await openBranchView()
|
||||
fireEvent.click(screen.getByRole('option', { name: /feature\/h5/ }))
|
||||
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: /Branch/ }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Create branch…' }))
|
||||
expect(screen.getByText('Starts from feature/h5')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('Branch name'), {
|
||||
target: { value: 'feature/new' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.createRepositoryBranch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ from: 'feature/h5' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('trims the typed name before sending it', async () => {
|
||||
renderControls()
|
||||
const input = await openNewBranchForm()
|
||||
|
||||
fireEvent.change(input, { target: { value: ' feature/new ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.createRepositoryBranch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'feature/new' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('translates a blank name without requesting branch creation', async () => {
|
||||
renderControls()
|
||||
const input = await openNewBranchForm()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
expect(await screen.findByText('Git will not accept this branch name.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
expect(await screen.findByText('Git will not accept this branch name.')).toBeInTheDocument()
|
||||
expect(apiMocks.createRepositoryBranch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('translates a rejected name and keeps the form open', async () => {
|
||||
apiMocks.createRepositoryBranch.mockRejectedValue(apiError('REPOSITORY_BRANCH_EXISTS'))
|
||||
const onBranchChange = vi.fn()
|
||||
renderControls({ onBranchChange })
|
||||
const input = await openNewBranchForm()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'feature/h5' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
expect(await screen.findByText('A branch with this name already exists.')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Branch name')).toHaveValue('feature/h5')
|
||||
expect(onBranchChange).not.toHaveBeenCalledWith('feature/h5')
|
||||
})
|
||||
|
||||
it('translates an invalid git ref and keeps the form open', async () => {
|
||||
apiMocks.createRepositoryBranch.mockRejectedValue(apiError('REPOSITORY_BRANCH_NAME_INVALID'))
|
||||
renderControls()
|
||||
const input = await openNewBranchForm()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'qa invalid' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
expect(await screen.findByText('Git will not accept this branch name.')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Branch name')).toHaveValue('qa invalid')
|
||||
})
|
||||
|
||||
it('explains an empty repository in the user language', async () => {
|
||||
apiMocks.createRepositoryBranch.mockRejectedValue(apiError('REPOSITORY_NO_COMMITS'))
|
||||
renderControls()
|
||||
const input = await openNewBranchForm()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'first' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
expect(await screen.findByText('This repository has no commits yet.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the raw git message alone for an unrecognized failure', async () => {
|
||||
apiMocks.createRepositoryBranch.mockRejectedValue(
|
||||
apiError('REPOSITORY_BRANCH_CREATE_FAILED', 'Failed to create branch: fatal: cannot lock ref'),
|
||||
)
|
||||
renderControls()
|
||||
const input = await openNewBranchForm()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'feature' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
// A translated shrug would hide the one line that says what went wrong —
|
||||
// but the server message already opens with "Failed to create branch:",
|
||||
// so prefixing the generic line onto it said the same thing twice.
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent('Failed to create branch: fatal: cannot lock ref')
|
||||
expect(alert).not.toHaveTextContent('Could not create the branch.')
|
||||
})
|
||||
|
||||
it('falls back to the generic line when the failure carries no message', async () => {
|
||||
apiMocks.createRepositoryBranch.mockRejectedValue(new Error(''))
|
||||
renderControls()
|
||||
const input = await openNewBranchForm()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'feature' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
expect(await screen.findByText('Could not create the branch.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
/**
|
||||
* The pill is a per-tab control, so switching tabs swaps `workDir` under an
|
||||
* in-flight create. Adopting that response would file one repo's branch list
|
||||
* and selection under another repo's path.
|
||||
*/
|
||||
it('discards a response that arrives after the directory changed', async () => {
|
||||
let settle: (value: unknown) => void = () => {}
|
||||
apiMocks.createRepositoryBranch.mockImplementation(() => new Promise((resolve) => {
|
||||
settle = resolve
|
||||
}))
|
||||
apiMocks.getRepositoryContext.mockImplementation(async (dir: string) => (
|
||||
dir === '/repo' ? okRepositoryContext : { ...okRepositoryContext, workDir: dir, repoName: 'other' }
|
||||
))
|
||||
|
||||
const { rerender } = render(
|
||||
<RepositoryLaunchControls
|
||||
workDir="/repo"
|
||||
onWorkDirChange={vi.fn()}
|
||||
branch="main"
|
||||
onBranchChange={vi.fn()}
|
||||
useWorktree={false}
|
||||
onUseWorktreeChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const input = await openNewBranchForm()
|
||||
fireEvent.change(input, { target: { value: 'feature/new' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
const onBranchChange = vi.fn()
|
||||
rerender(
|
||||
<RepositoryLaunchControls
|
||||
workDir="/other"
|
||||
onWorkDirChange={vi.fn()}
|
||||
branch="main"
|
||||
onBranchChange={onBranchChange}
|
||||
useWorktree={false}
|
||||
onUseWorktreeChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
settle({ branch: 'feature/new', baseRef: 'main', context: contextWithCreatedBranch })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Location: other/ })).toBeInTheDocument()
|
||||
})
|
||||
expect(onBranchChange).not.toHaveBeenCalledWith('feature/new')
|
||||
})
|
||||
|
||||
it('returns to the branch list on cancel without creating anything', async () => {
|
||||
renderControls()
|
||||
await openNewBranchForm()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
expect(await screen.findByRole('listbox', { name: 'Select branch' })).toBeInTheDocument()
|
||||
expect(apiMocks.createRepositoryBranch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* The sheet has no inner scroller of its own — `branchList` drops its
|
||||
* `max-h`/`overflow-y-auto` on mobile — so rendering the entry as a sibling
|
||||
* of the list in `children` lets it scroll off the bottom with the branches.
|
||||
* `MobileBottomSheet`'s `footer` slot is `shrink-0` outside the scrolling
|
||||
* body, which is the only place it stays reachable.
|
||||
*/
|
||||
it('pins the entry to the sheet footer, outside the scrolling body, on mobile', async () => {
|
||||
viewportMocks.isMobile = true
|
||||
renderControls()
|
||||
await openPill()
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: /Branch/ }))
|
||||
|
||||
const entry = await screen.findByRole('button', { name: 'Create branch…' })
|
||||
const scroller = screen.getByRole('listbox', { name: 'Select branch' }).parentElement
|
||||
expect(scroller).toHaveClass('overflow-y-auto')
|
||||
expect(scroller).not.toContainElement(entry)
|
||||
})
|
||||
|
||||
it('reaches the create form from the mobile sheet and back again', async () => {
|
||||
viewportMocks.isMobile = true
|
||||
renderControls()
|
||||
await openPill()
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: /Branch/ }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Create branch…' }))
|
||||
|
||||
expect(await screen.findByLabelText('Branch name')).toBeInTheDocument()
|
||||
expect(await screen.findByRole('dialog', { name: 'New branch' })).toBeInTheDocument()
|
||||
// The footer belongs to the branch list, not the form under it.
|
||||
expect(screen.queryByRole('button', { name: 'Create branch…' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Select branch/ }))
|
||||
expect(await screen.findByRole('listbox', { name: 'Select branch' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
/**
|
||||
* Creating a branch off the one you are standing on is the whole point of
|
||||
* doing it with work in progress, and that switch only moves the ref — git
|
||||
* never blocks it. Warning there sent people to an isolated worktree they
|
||||
* did not need. Driven through the real create rather than hand-built state,
|
||||
* because that transition is the thing being claimed (AGENTS.md).
|
||||
*/
|
||||
it('drops the dirty warning once a branch is created off the current one', async () => {
|
||||
apiMocks.getRepositoryContext.mockResolvedValue({ ...okRepositoryContext, dirty: true })
|
||||
apiMocks.createRepositoryBranch.mockResolvedValue({
|
||||
branch: 'feature/new',
|
||||
baseRef: 'main',
|
||||
context: { ...contextWithCreatedBranch, dirty: true },
|
||||
})
|
||||
|
||||
render(<ControlledHarness initialWorkDir="/repo" />)
|
||||
const input = await openNewBranchForm()
|
||||
fireEvent.change(input, { target: { value: 'feature/new' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
await screen.findByRole('button', { name: 'Location: cc-haha / feature/new' })
|
||||
expect(screen.queryByRole('status', { name: 'Dirty worktree' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not warn about uncommitted changes for a branch already at HEAD', async () => {
|
||||
apiMocks.getRepositoryContext.mockResolvedValue({
|
||||
...contextWithCreatedBranch,
|
||||
dirty: true,
|
||||
})
|
||||
|
||||
renderControls({ branch: 'feature/new' })
|
||||
|
||||
await screen.findByRole('button', { name: 'Location: cc-haha / feature/new' })
|
||||
expect(screen.queryByRole('status', { name: 'Dirty worktree' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still warns when the selected branch would rewrite files', async () => {
|
||||
apiMocks.getRepositoryContext.mockResolvedValue({
|
||||
...contextWithCreatedBranch,
|
||||
dirty: true,
|
||||
})
|
||||
|
||||
renderControls({ branch: 'feature/h5' })
|
||||
|
||||
expect(await screen.findByRole('status', { name: 'Dirty worktree' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps warning when the server reports no commits at all', async () => {
|
||||
apiMocks.getRepositoryContext.mockResolvedValue({
|
||||
...okRepositoryContext,
|
||||
dirty: true,
|
||||
headCommit: undefined,
|
||||
branches: okRepositoryContext.branches.map(({ commit: _commit, ...rest }) => rest),
|
||||
})
|
||||
|
||||
renderControls({ branch: 'feature/h5' })
|
||||
|
||||
expect(await screen.findByRole('status', { name: 'Dirty worktree' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The pill's menu was built around a repo it already had: directory, branch
|
||||
* and worktree. In a fresh session `isGitReady` is false, both of the latter
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
GitBranch,
|
||||
GitFork,
|
||||
Loader2,
|
||||
Plus,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
@@ -17,10 +18,14 @@ import {
|
||||
type RepositoryContextResult,
|
||||
} from '../../api/sessions'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { useProjectDisplayName } from '../../stores/projectDisplayNameStore'
|
||||
import { RecentProjectsPanel } from '@/components/composite/DirectoryPicker'
|
||||
import { useDismissable } from '@/hooks/useDismissable'
|
||||
import { useMobileViewport } from '../../hooks/useMobileViewport'
|
||||
import { isDesktopRuntime } from '../../lib/desktopRuntime'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { MobileBottomSheet } from '@/components/ui/MobileBottomSheet'
|
||||
|
||||
type Props = {
|
||||
@@ -46,17 +51,31 @@ const VIEWPORT_GUTTER = 12
|
||||
/**
|
||||
* `root` lists directory, branch and the worktree modes; `directory` and
|
||||
* `branch` are its two drill-downs. The menu opens on `root` only when there
|
||||
* is a repo to describe — see `viewOnOpen`.
|
||||
* is a repo to describe — see `viewOnOpen`. `newBranch` is reached from the
|
||||
* branch list; cancelling returns there, while success closes the menu so the
|
||||
* newly selected branch is immediately visible in the pill.
|
||||
*/
|
||||
type MenuView = 'directory' | 'root' | 'branch'
|
||||
type MenuView = 'directory' | 'root' | 'branch' | 'newBranch'
|
||||
|
||||
/** Approximate heights, used only to decide whether the menu flips upward. */
|
||||
const VIEW_HEIGHTS: Record<MenuView, number> = {
|
||||
directory: 380,
|
||||
root: 340,
|
||||
branch: 400,
|
||||
newBranch: 240,
|
||||
}
|
||||
|
||||
/**
|
||||
* Server codes the create-branch form can explain in the user's own language.
|
||||
* Anything else falls through to the generic failure plus the raw git message,
|
||||
* which is more use than a translated shrug.
|
||||
*/
|
||||
const BRANCH_CREATE_ERROR_KEYS = {
|
||||
REPOSITORY_BRANCH_NAME_INVALID: 'repoLaunch.newBranchErrorInvalid',
|
||||
REPOSITORY_BRANCH_EXISTS: 'repoLaunch.newBranchErrorExists',
|
||||
REPOSITORY_NO_COMMITS: 'repoLaunch.newBranchErrorNoCommits',
|
||||
} as const
|
||||
|
||||
const GIT_MARK_PATH = 'M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z'
|
||||
|
||||
/**
|
||||
@@ -123,9 +142,11 @@ export function RepositoryLaunchControls({
|
||||
placement = 'outside',
|
||||
}: Props) {
|
||||
const t = useTranslation()
|
||||
const addToast = useUIStore((state) => state.addToast)
|
||||
const isMobileBrowser = useMobileViewport() && !isDesktopRuntime()
|
||||
const isToolbar = placement === 'toolbar' && !isMobileBrowser
|
||||
const [context, setContext] = useState<RepositoryContextResult | null>(null)
|
||||
const [contextSourceWorkDir, setContextSourceWorkDir] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
@@ -138,12 +159,29 @@ export function RepositoryLaunchControls({
|
||||
const [revealAfterPick, setRevealAfterPick] = useState<string | null>(null)
|
||||
const [branchFilter, setBranchFilter] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [newBranchName, setNewBranchName] = useState('')
|
||||
const [creatingBranch, setCreatingBranch] = useState(false)
|
||||
const [createBranchError, setCreateBranchError] = useState<string | null>(null)
|
||||
const [menuPos, setMenuPos] = useState<{ top: number; left: number; direction: 'up' | 'down' } | null>(null)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const pillRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
/** Read after an await to tell whether the directory moved on under us. */
|
||||
const latestWorkDirRef = useRef(workDir)
|
||||
latestWorkDirRef.current = workDir
|
||||
/**
|
||||
* Creating a branch updates this component's context and the parent-owned
|
||||
* launch target together. An external store can publish the target first,
|
||||
* so remember the exact context that makes that new target valid until the
|
||||
* local state commit catches up.
|
||||
*/
|
||||
const createdBranchTransitionRef = useRef<{
|
||||
workDir: string
|
||||
branch: string
|
||||
context: RepositoryContextResult
|
||||
} | null>(null)
|
||||
const searchInputId = useId()
|
||||
const listboxId = useId()
|
||||
const menuId = useId()
|
||||
@@ -171,23 +209,28 @@ export function RepositoryLaunchControls({
|
||||
useEffect(() => {
|
||||
if (!workDir) {
|
||||
setContext(null)
|
||||
setContextSourceWorkDir(null)
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
onBranchChange(null)
|
||||
return
|
||||
}
|
||||
|
||||
const requestedWorkDir = workDir
|
||||
let cancelled = false
|
||||
setContextSourceWorkDir(null)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
sessionsApi.getRepositoryContext(workDir)
|
||||
sessionsApi.getRepositoryContext(requestedWorkDir)
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
setContext(result)
|
||||
setContextSourceWorkDir(requestedWorkDir)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
setContext(null)
|
||||
setContextSourceWorkDir(null)
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -199,12 +242,28 @@ export function RepositoryLaunchControls({
|
||||
}
|
||||
}, [workDir, onBranchChange])
|
||||
|
||||
useEffect(() => {
|
||||
createdBranchTransitionRef.current = null
|
||||
}, [workDir])
|
||||
|
||||
useEffect(() => {
|
||||
if (context?.state !== 'ok') {
|
||||
if (context && branch !== null) onBranchChange(null)
|
||||
return
|
||||
}
|
||||
|
||||
const createdBranchTransition = createdBranchTransitionRef.current
|
||||
if (createdBranchTransition?.workDir === workDir) {
|
||||
if (context === createdBranchTransition.context) {
|
||||
createdBranchTransitionRef.current = null
|
||||
} else if (branch === createdBranchTransition.branch) {
|
||||
// The parent selection reached us before the refreshed branch list.
|
||||
// It is valid against the in-flight create response, so do not replace
|
||||
// it with the old context's current branch during this single render.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const branchExists = branch && context.branches.some((candidate) => candidate.name === branch)
|
||||
if (branchExists) return
|
||||
|
||||
@@ -215,13 +274,15 @@ export function RepositoryLaunchControls({
|
||||
].find((name) => name && context.branches.some((candidate) => candidate.name === name))
|
||||
|
||||
onBranchChange(fallbackBranch || null)
|
||||
}, [branch, context, onBranchChange])
|
||||
}, [branch, context, onBranchChange, workDir])
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
setMenuOpen(false)
|
||||
setView('root')
|
||||
setBranchFilter('')
|
||||
setRevealAfterPick(null)
|
||||
setNewBranchName('')
|
||||
setCreateBranchError(null)
|
||||
}, [])
|
||||
|
||||
// The directory list is one of this menu's own views now, so there is no
|
||||
@@ -276,7 +337,13 @@ export function RepositoryLaunchControls({
|
||||
|
||||
const warning = useMemo(() => {
|
||||
if (context?.state !== 'ok' || !selectedBranch || useWorktree) return null
|
||||
if (selectedBranch.name !== context.currentBranch && context.dirty) {
|
||||
// A branch that points where HEAD already points cannot be blocked by
|
||||
// uncommitted changes — git only moves the ref, it rewrites nothing. That
|
||||
// is exactly the branch you just created off the one you are standing on,
|
||||
// so warning about it there is a false alarm. Missing commit info (an older
|
||||
// server) keeps the warning rather than dropping it.
|
||||
const movesFiles = !(selectedBranch.commit && selectedBranch.commit === context.headCommit)
|
||||
if (selectedBranch.name !== context.currentBranch && context.dirty && movesFiles) {
|
||||
return {
|
||||
message: t('repoLaunch.dirtyWarning'),
|
||||
compactLabel: t('repoLaunch.dirtyWarningCompact'),
|
||||
@@ -297,6 +364,83 @@ export function RepositoryLaunchControls({
|
||||
setBranchFilter('')
|
||||
}
|
||||
|
||||
const openNewBranchView = () => {
|
||||
// Whatever was typed into the filter is almost always the name being looked
|
||||
// for, so it seeds the field instead of being thrown away.
|
||||
setNewBranchName(branchFilter.trim())
|
||||
setCreateBranchError(null)
|
||||
setView('newBranch')
|
||||
}
|
||||
|
||||
const leaveNewBranchView = () => {
|
||||
setNewBranchName('')
|
||||
setCreateBranchError(null)
|
||||
setView('branch')
|
||||
}
|
||||
|
||||
const createBranch = async () => {
|
||||
const name = newBranchName.trim()
|
||||
if (creatingBranch || context?.state !== 'ok') return
|
||||
if (!name) {
|
||||
setCreateBranchError(t('repoLaunch.newBranchErrorInvalid'))
|
||||
return
|
||||
}
|
||||
|
||||
const requestWorkDir = workDir
|
||||
setCreatingBranch(true)
|
||||
setCreateBranchError(null)
|
||||
try {
|
||||
const result = await sessionsApi.createRepositoryBranch({
|
||||
workDir: requestWorkDir,
|
||||
name,
|
||||
from: selectedBranch?.name ?? null,
|
||||
})
|
||||
// Adopting a result for a directory the user has since left would put one
|
||||
// repo's branch list and selection under another repo's path — the same
|
||||
// reason the `workDir` effect carries a `cancelled` flag.
|
||||
if (latestWorkDirRef.current !== requestWorkDir) return
|
||||
// The response carries the context the branch was created against, so the
|
||||
// list is correct without a second round trip.
|
||||
createdBranchTransitionRef.current = {
|
||||
workDir: requestWorkDir,
|
||||
branch: result.branch,
|
||||
context: result.context,
|
||||
}
|
||||
setContext(result.context)
|
||||
setError(null)
|
||||
onBranchChange(result.branch)
|
||||
addToast({
|
||||
type: 'success',
|
||||
message: t(
|
||||
useWorktree
|
||||
? 'repoLaunch.newBranchSuccessIsolated'
|
||||
: 'repoLaunch.newBranchSuccessCurrent',
|
||||
{ branch: result.branch },
|
||||
),
|
||||
})
|
||||
// Closing exposes the pill, which now names the branch selected for launch.
|
||||
// Keeping the menu open made a successful create look like a no-op.
|
||||
closeMenu()
|
||||
} catch (err) {
|
||||
if (latestWorkDirRef.current !== requestWorkDir) return
|
||||
const code = err instanceof Error && 'body' in err
|
||||
? (err as { body?: { error?: string } }).body?.error
|
||||
: undefined
|
||||
const known = code && code in BRANCH_CREATE_ERROR_KEYS
|
||||
? BRANCH_CREATE_ERROR_KEYS[code as keyof typeof BRANCH_CREATE_ERROR_KEYS]
|
||||
: null
|
||||
// The server's own message already reads "Failed to create branch: …", so
|
||||
// prefixing the generic line onto it says the same thing twice. The
|
||||
// generic line is for failures that arrive without one, e.g. a timeout.
|
||||
const raw = err instanceof Error ? err.message.trim() : String(err).trim()
|
||||
setCreateBranchError(known ? t(known) : raw || t('repoLaunch.newBranchErrorFailed'))
|
||||
} finally {
|
||||
// Unconditional: a stale response must still clear the spinner, or the
|
||||
// form stays disabled for the directory the user moved to.
|
||||
setCreatingBranch(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectWorktreeMode = (enabled: boolean) => {
|
||||
onUseWorktreeChange(enabled)
|
||||
closeMenu()
|
||||
@@ -363,14 +507,18 @@ export function RepositoryLaunchControls({
|
||||
// reading either one unguarded decides on the wrong repo.
|
||||
useEffect(() => {
|
||||
if (!revealAfterPick || loading) return
|
||||
const settled = context?.workDir === revealAfterPick
|
||||
const settled = contextSourceWorkDir === revealAfterPick
|
||||
|| (!!error && workDir === revealAfterPick)
|
||||
if (!settled) return
|
||||
setRevealAfterPick(null)
|
||||
if (context?.state !== 'ok') closeMenu()
|
||||
}, [revealAfterPick, loading, context, error, workDir, closeMenu])
|
||||
}, [revealAfterPick, loading, context, contextSourceWorkDir, error, workDir, closeMenu])
|
||||
|
||||
const repoLabel = context?.repoName || basename(context?.repoRoot) || basename(workDir)
|
||||
const projectKey = contextSourceWorkDir === workDir && context
|
||||
? (context.repoRoot || context.workDir)
|
||||
: workDir
|
||||
const displayName = useProjectDisplayName(projectKey)
|
||||
const repoLabel = displayName || context?.repoName || basename(context?.repoRoot) || basename(workDir)
|
||||
const selectedBranchName = isGitReady ? (selectedBranch?.name ?? null) : null
|
||||
// The worktree cards name the branch their changes would land on.
|
||||
const branchLabel = selectedBranch?.name ?? context?.currentBranch ?? t('repoLaunch.noBranch')
|
||||
@@ -483,6 +631,70 @@ export function RepositoryLaunchControls({
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* Sits under the list, never inside it: the list is a `role="listbox"`, and an
|
||||
* action button among its `option`s would be read as a branch you could pick
|
||||
* (components/AGENTS.md §6).
|
||||
*
|
||||
* The separator is the host's job, because the two hosts pin this differently
|
||||
* — the dropdown puts it after `branchList`'s own `overflow-y-auto` box, the
|
||||
* sheet hands it to `MobileBottomSheet`'s `footer` slot, which is `shrink-0`
|
||||
* outside the scrolling body and brings its own top border. Rendering it as a
|
||||
* sibling in the sheet's `children` instead would let it scroll away with the
|
||||
* list, which is the one thing it must not do.
|
||||
*/
|
||||
const branchCreateRow = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openNewBranchView}
|
||||
className={rowClassName}
|
||||
>
|
||||
<Plus size={17} className="shrink-0 text-[var(--color-text-tertiary)]" />
|
||||
<span className="min-w-0 flex-1 truncate text-[13.5px] font-semibold text-[var(--color-text-primary)]">
|
||||
{t('repoLaunch.newBranch')}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
const newBranchForm = (
|
||||
<form
|
||||
className="flex flex-col gap-3 p-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void createBranch()
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label={t('repoLaunch.newBranchNameLabel')}
|
||||
placeholder={t('repoLaunch.newBranchPlaceholder')}
|
||||
value={newBranchName}
|
||||
onChange={(event) => {
|
||||
setNewBranchName(event.target.value)
|
||||
setCreateBranchError(null)
|
||||
}}
|
||||
error={createBranchError ?? undefined}
|
||||
hint={t('repoLaunch.newBranchFrom', { branch: branchLabel })}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={creatingBranch}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="base" onClick={leaveNewBranchView}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="base"
|
||||
loading={creatingBranch}
|
||||
>
|
||||
{t('repoLaunch.newBranchSubmit')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
|
||||
const branchSearch = (
|
||||
<div className="flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2">
|
||||
<Search size={15} className="shrink-0 text-[var(--color-text-tertiary)]" />
|
||||
@@ -733,13 +945,16 @@ export function RepositoryLaunchControls({
|
||||
title={
|
||||
view === 'branch'
|
||||
? t('repoLaunch.selectBranch')
|
||||
: view === 'directory'
|
||||
? t('dirPicker.directory')
|
||||
: t('repoLaunch.launchLocation')
|
||||
: view === 'newBranch'
|
||||
? t('repoLaunch.newBranchTitle')
|
||||
: view === 'directory'
|
||||
? t('dirPicker.directory')
|
||||
: t('repoLaunch.launchLocation')
|
||||
}
|
||||
closeLabel={t('tabs.close')}
|
||||
panelRef={menuRef}
|
||||
id={menuId}
|
||||
footer={view === 'branch' ? <div className="p-1.5">{branchCreateRow}</div> : undefined}
|
||||
headerExtra={view === 'branch' ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
@@ -752,6 +967,15 @@ export function RepositoryLaunchControls({
|
||||
</button>
|
||||
{branchSearch}
|
||||
</div>
|
||||
) : view === 'newBranch' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={leaveNewBranchView}
|
||||
className={sheetBackClassName}
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
{t('repoLaunch.selectBranch')}
|
||||
</button>
|
||||
) : view === 'directory' && directoryBackLabel ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -763,7 +987,11 @@ export function RepositoryLaunchControls({
|
||||
</button>
|
||||
) : undefined}
|
||||
>
|
||||
{view === 'branch' ? branchList : view === 'directory' ? directoryList : rootView}
|
||||
{view === 'branch'
|
||||
? branchList
|
||||
: view === 'newBranch'
|
||||
? newBranchForm
|
||||
: view === 'directory' ? directoryList : rootView}
|
||||
</MobileBottomSheet>
|
||||
) : menuPos && createPortal(
|
||||
<div ref={menuRef} id={menuId} className={menuClassName} style={menuStyle}>
|
||||
@@ -781,6 +1009,23 @@ export function RepositoryLaunchControls({
|
||||
{branchSearch}
|
||||
</div>
|
||||
{branchList}
|
||||
<div className="border-t border-[var(--color-border-separator)] p-1.5">
|
||||
{branchCreateRow}
|
||||
</div>
|
||||
</>
|
||||
) : view === 'newBranch' ? (
|
||||
<>
|
||||
<div className="border-b border-[var(--color-border)] px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={leaveNewBranchView}
|
||||
className={dropdownBackClassName}
|
||||
>
|
||||
<ChevronLeft size={13} />
|
||||
{t('repoLaunch.newBranchTitle')}
|
||||
</button>
|
||||
</div>
|
||||
{newBranchForm}
|
||||
</>
|
||||
) : view === 'directory' ? (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SessionChatHeader, SessionChatSurface } from './SessionChatSurface'
|
||||
|
||||
describe('SessionChatSurface', () => {
|
||||
it('keeps every conversation on the main session column measure', () => {
|
||||
render(
|
||||
<SessionChatSurface
|
||||
surfaceKind="agent"
|
||||
agentRunKind="subagent"
|
||||
isMobileLayout={false}
|
||||
activityRailOpen
|
||||
activityRail={<aside>Activity</aside>}
|
||||
>
|
||||
<SessionChatHeader
|
||||
title="Child run"
|
||||
metadata={[
|
||||
{ key: 'status', content: <span>Running</span> },
|
||||
{ key: 'messages', content: <span>3 messages</span> },
|
||||
]}
|
||||
/>
|
||||
<main>Transcript</main>
|
||||
</SessionChatSurface>,
|
||||
)
|
||||
|
||||
const surface = screen.getByTestId('session-chat-surface')
|
||||
expect(surface).toHaveAttribute('data-session-chat-kind', 'agent')
|
||||
expect(surface).toHaveAttribute('data-agent-run-kind', 'subagent')
|
||||
expect(screen.getByTestId('session-chat-column')).toHaveClass('min-w-[360px]', 'pr-[352px]')
|
||||
expect(screen.getByText('Activity')).toBeInTheDocument()
|
||||
|
||||
const header = screen.getByTestId('session-header')
|
||||
expect(header.firstElementChild).toHaveClass('max-w-[900px]')
|
||||
expect(within(header).getByRole('heading', { name: 'Child run' })).toHaveClass('truncate')
|
||||
expect(header.querySelectorAll('[aria-hidden="true"]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('uses the same compact chat column contract when a side panel is present', () => {
|
||||
render(
|
||||
<SessionChatSurface
|
||||
surfaceKind="main"
|
||||
isMobileLayout={false}
|
||||
compact
|
||||
sidePanel={<aside>Workbench</aside>}
|
||||
>
|
||||
<div>Conversation</div>
|
||||
</SessionChatSurface>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('session-chat-column')).toHaveClass(
|
||||
'min-w-[400px]',
|
||||
'bg-[var(--color-surface)]',
|
||||
)
|
||||
expect(screen.getByText('Workbench')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Fragment, type ReactNode } from 'react'
|
||||
|
||||
export type SessionHeaderMetaItem = {
|
||||
key: string
|
||||
content: ReactNode
|
||||
}
|
||||
|
||||
export function SessionChatHeader({
|
||||
title,
|
||||
compact = false,
|
||||
leading,
|
||||
titleAddon,
|
||||
actions,
|
||||
metadata = [],
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
compact?: boolean
|
||||
leading?: ReactNode
|
||||
titleAddon?: ReactNode
|
||||
actions?: ReactNode
|
||||
metadata?: SessionHeaderMetaItem[]
|
||||
children?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid="session-header"
|
||||
className={[
|
||||
'w-full border-b border-[var(--color-border)]',
|
||||
compact ? 'px-4 py-2.5' : 'px-9 py-3',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="mx-auto w-full min-w-0 max-w-[900px]">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
{leading}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h1
|
||||
className={`min-w-0 truncate font-bold leading-tight tracking-[-0.2px] text-[var(--color-text-primary)] ${
|
||||
compact ? 'text-[15px]' : 'text-[17px]'
|
||||
}`}
|
||||
style={{ fontFamily: 'var(--font-headline)' }}
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{titleAddon}
|
||||
</div>
|
||||
{metadata.length > 0 ? (
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{metadata.map((item, index) => (
|
||||
<Fragment key={item.key}>
|
||||
{index > 0 ? <span aria-hidden="true" className="shrink-0">·</span> : null}
|
||||
{item.content}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 items-center gap-1">{actions}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionChatSurface({
|
||||
surfaceKind,
|
||||
agentRunKind,
|
||||
isMobileLayout,
|
||||
compact = false,
|
||||
activityRailOpen = false,
|
||||
contentRowTestId = 'session-chat-content-row',
|
||||
chatColumnTestId = 'session-chat-column',
|
||||
compactColumnClassName,
|
||||
children,
|
||||
activityRail,
|
||||
sidePanel,
|
||||
overlay,
|
||||
}: {
|
||||
surfaceKind: 'main' | 'agent'
|
||||
agentRunKind?: 'subagent' | 'team-member'
|
||||
isMobileLayout: boolean
|
||||
compact?: boolean
|
||||
activityRailOpen?: boolean
|
||||
contentRowTestId?: string
|
||||
chatColumnTestId?: string
|
||||
compactColumnClassName?: string
|
||||
children: ReactNode
|
||||
activityRail?: ReactNode
|
||||
sidePanel?: ReactNode
|
||||
overlay?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid="session-chat-surface"
|
||||
data-session-chat-kind={surfaceKind}
|
||||
data-agent-run-kind={agentRunKind}
|
||||
className="relative flex min-h-0 flex-1 overflow-hidden bg-background text-on-surface"
|
||||
>
|
||||
<div data-testid={contentRowTestId} className="flex min-h-0 min-w-0 flex-1">
|
||||
<div
|
||||
data-testid={chatColumnTestId}
|
||||
className={[
|
||||
'relative flex min-h-0 min-w-0 flex-col overflow-hidden',
|
||||
'transition-[padding] duration-200 ease-out motion-reduce:transition-none',
|
||||
activityRailOpen ? 'pr-[352px]' : '',
|
||||
compact
|
||||
? compactColumnClassName ?? 'min-w-[400px] flex-1 bg-[var(--color-surface)]'
|
||||
: isMobileLayout
|
||||
? 'flex-1'
|
||||
: 'min-w-[360px] flex-1',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{activityRail}
|
||||
{sidePanel}
|
||||
</div>
|
||||
{overlay}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { IconButton } from '@/components/ui/IconButton'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export function SlashCommandPanelShell({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
title: string
|
||||
subtitle: string
|
||||
children: ReactNode
|
||||
onClose: () => void
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
return (
|
||||
<div className="absolute bottom-full left-0 right-0 z-[var(--z-dropdown)] mb-3 overflow-hidden rounded-[var(--radius-2xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-overlay)]">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-[var(--color-border)] px-5 py-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">{title}</h3>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-tertiary)]">{subtitle}</p>
|
||||
</div>
|
||||
<IconButton
|
||||
icon="close"
|
||||
label={t('tabs.close')}
|
||||
size="lg"
|
||||
tone="secondary"
|
||||
shape="circle"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[min(620px,72vh)] overflow-y-auto px-5 py-4">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,28 @@ function translateServerVerb(
|
||||
return translated === key ? verb : translated
|
||||
}
|
||||
|
||||
/**
|
||||
* What the turn is currently doing, in the user's language.
|
||||
*
|
||||
* This is the only place in the UI that reports the verb, the elapsed time and
|
||||
* the tokens so far — the composer keeps just a Stop button. Do not delete this
|
||||
* component on the assumption the composer covers it, and do not fold it into
|
||||
* the turn rail either: during `isPreparingTurn` the session is still being
|
||||
* created, so the transcript holds no render items and there is no rail to hang
|
||||
* it on. That gap is exactly what regressed once before.
|
||||
*/
|
||||
export function resolveTurnStatusVerb(
|
||||
t: (key: TranslationKey) => string,
|
||||
chatState: string,
|
||||
statusVerb: string,
|
||||
): string {
|
||||
if (statusVerb) return translateServerVerb(t, statusVerb)
|
||||
if (chatState === 'thinking') return t('serverVerb.Thinking')
|
||||
if (chatState === 'compacting') return t('serverVerb.Compacting conversation')
|
||||
if (chatState === 'tool_executing') return t('serverVerb.Running')
|
||||
return t('serverVerb.Working')
|
||||
}
|
||||
|
||||
function formatRetrySeconds(ms: number): number {
|
||||
return Math.max(0, Math.ceil(ms / 1000))
|
||||
}
|
||||
@@ -33,7 +55,9 @@ export function StreamingIndicator() {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
|
||||
const chatState = sessionState?.chatState ?? 'idle'
|
||||
const chatState = sessionState?.isPreparingTurn
|
||||
? 'thinking'
|
||||
: sessionState?.chatState ?? 'idle'
|
||||
const statusVerb = sessionState?.statusVerb ?? ''
|
||||
const apiRetry = sessionState?.apiRetry ?? null
|
||||
const streamingFallback = sessionState?.streamingFallback ?? null
|
||||
@@ -98,7 +122,7 @@ export function StreamingIndicator() {
|
||||
data-testid="streaming-fallback-indicator"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="mb-2 flex w-fit items-center gap-[9px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-4 py-2 text-[13.5px] text-[var(--color-text-secondary)]"
|
||||
className="flex w-fit items-center gap-[9px] py-1 text-[13.5px] text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<RefreshCw size={13} strokeWidth={2.2} className="shrink-0 animate-spin text-[var(--color-text-secondary)]" aria-hidden="true" />
|
||||
<span className="font-medium text-[var(--color-text-primary)]">
|
||||
@@ -116,21 +140,18 @@ export function StreamingIndicator() {
|
||||
)
|
||||
}
|
||||
|
||||
let verb: string
|
||||
if (statusVerb) {
|
||||
verb = translateServerVerb(t, statusVerb)
|
||||
} else {
|
||||
verb = chatState === 'thinking'
|
||||
? t('serverVerb.Thinking')
|
||||
: chatState === 'compacting'
|
||||
? t('serverVerb.Compacting conversation')
|
||||
: chatState === 'tool_executing'
|
||||
? t('serverVerb.Running')
|
||||
: t('serverVerb.Working')
|
||||
}
|
||||
const verb = resolveTurnStatusVerb(t, chatState, statusVerb)
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex w-fit items-center gap-[9px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-4 py-2 text-[13.5px] text-[var(--color-text-secondary)]">
|
||||
<div
|
||||
data-testid="turn-status-indicator"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
// Bare line, not a pill: it now sits at the end of the live turn rail, and
|
||||
// the rail already says "still going". A bordered chip here would read as
|
||||
// a second, competing status object next to the one that is lit.
|
||||
className="flex w-fit items-center gap-[9px] py-1 text-[13.5px] text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<span className="animate-pulse-dot text-[var(--color-brand)]" aria-hidden="true">✦</span>
|
||||
<span className="font-medium text-[var(--color-text-primary)]">{verb}...</span>
|
||||
{elapsedSeconds > 0 && (
|
||||
|
||||
@@ -2,9 +2,37 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import { ThinkingBlock } from './ThinkingBlock'
|
||||
import { ThinkingBlock, thinkingPreview } from './ThinkingBlock'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
|
||||
describe('thinkingPreview', () => {
|
||||
it('follows the tail while streaming so the row says what it is thinking now', () => {
|
||||
const content = 'Diagnosis complete:\nlint is clean\nnow checking the retry path'
|
||||
expect(thinkingPreview(content, { streaming: true })).toBe('now checking the retry path')
|
||||
})
|
||||
|
||||
it('settles back onto the opening line once the block is done', () => {
|
||||
const content = 'The user ran the lifecycle suite and hit real failures.\nSo I will reset #7.'
|
||||
expect(thinkingPreview(content)).toBe('The user ran the lifecycle suite and hit real failures.')
|
||||
})
|
||||
|
||||
it('skips an opening that is only a heading for what follows', () => {
|
||||
// `Diagnosis complete:` is the one line in the block that carries nothing.
|
||||
expect(thinkingPreview('Diagnosis complete:\nlint is clean, 2 files left')).toBe('lint is clean, 2 files left')
|
||||
})
|
||||
|
||||
it('keeps a long opening that merely happens to end in a colon', () => {
|
||||
const content = 'The user wants me to handle GitHub issue #498 about the new KS signature, specifically:\n1. read the issue'
|
||||
expect(thinkingPreview(content)).toBe(
|
||||
'The user wants me to handle GitHub issue #498 about the new KS signature, specifically:',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a bare heading when it is all there is', () => {
|
||||
expect(thinkingPreview('Diagnosis complete:')).toBe('Diagnosis complete:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ThinkingBlock', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'zh' })
|
||||
|
||||
@@ -1,13 +1,36 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { Brain } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||
|
||||
export function ThinkingBlock({ content, isActive = false }: { content: string; isActive?: boolean }) {
|
||||
/**
|
||||
* One line: the label, a preview of the reasoning, and the full text on demand.
|
||||
*
|
||||
* There used to be a second, larger `standalone` form for a thinking block that
|
||||
* was the only thing in its run — it made sense when rows lived behind a
|
||||
* collapsed summary and a lone block needed to be findable. Now that every step
|
||||
* is a permanent row, the split only inverted the information: a turn's opening
|
||||
* reasoning is usually its most substantial, and it landed in the form that
|
||||
* showed no content at all, purely because no tool call happened to follow it in
|
||||
* the same run. Whether a thought is followed by a tool is not a fact about the
|
||||
* thought, so it no longer changes how one is drawn.
|
||||
*/
|
||||
export function ThinkingBlock({
|
||||
content,
|
||||
isActive = false,
|
||||
}: {
|
||||
content: string
|
||||
isActive?: boolean
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const displayContent = useMemo(() => content.replace(/\r\n?/g, '\n').trimEnd(), [content])
|
||||
const hasDisplayContent = displayContent.trim().length > 0
|
||||
const preview = useMemo(
|
||||
() => thinkingPreview(displayContent, { streaming: isActive }),
|
||||
[displayContent, isActive],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded && isActive && contentRef.current) {
|
||||
@@ -15,28 +38,49 @@ export function ThinkingBlock({ content, isActive = false }: { content: string;
|
||||
}
|
||||
}, [displayContent, expanded, isActive])
|
||||
|
||||
const label = (
|
||||
<>
|
||||
{isActive ? t('thinking.label') : t('thinking.labelDone')}
|
||||
{isActive && <span className="thinking-dots" />}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mb-1">
|
||||
<div>
|
||||
<style>{thinkingStyles}</style>
|
||||
<button
|
||||
type="button"
|
||||
data-chat-disclosure="true"
|
||||
data-thinking-row="true"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
className="flex w-full items-center gap-[7px] rounded-[var(--radius-sm)] px-1 py-0.5 text-left text-[13.5px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
|
||||
className="-mx-2 flex w-[calc(100%+1rem)] items-baseline gap-2 rounded-[var(--radius-md)] px-2 py-1 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
|
||||
>
|
||||
<span className="text-[10px]">
|
||||
{expanded ? '\u25BE' : '\u25B8'}
|
||||
<Brain
|
||||
size={13}
|
||||
strokeWidth={1.8}
|
||||
aria-hidden="true"
|
||||
className="mt-[3px] shrink-0 self-start text-[var(--color-text-tertiary)]"
|
||||
/>
|
||||
<span className="shrink-0 text-[12.5px] italic text-[var(--color-text-tertiary)]">
|
||||
{label}
|
||||
</span>
|
||||
<span className="shrink-0 font-medium italic">
|
||||
{isActive ? t('thinking.label') : t('thinking.labelDone')}
|
||||
{isActive && <span className="thinking-dots" />}
|
||||
{preview ? (
|
||||
<span className="min-w-0 flex-1 truncate text-[12.5px] italic leading-[1.7] text-[var(--color-text-tertiary)]">
|
||||
{preview}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex-1" />
|
||||
)}
|
||||
<span aria-hidden="true" className="shrink-0 text-[8px] text-[var(--color-text-tertiary)]">
|
||||
{expanded ? '▾' : '▸'}
|
||||
</span>
|
||||
</button>
|
||||
{expanded && hasDisplayContent && (
|
||||
<div
|
||||
ref={contentRef}
|
||||
data-thinking-content="expanded"
|
||||
className="relative mt-1 max-h-[300px] overflow-y-auto rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-2.5 text-[11px] text-[var(--color-text-secondary)]"
|
||||
className="relative mb-2 mt-1 max-h-[300px] overflow-y-auto rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 py-2.5 text-[11px] text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<MarkdownRenderer
|
||||
content={displayContent}
|
||||
@@ -52,6 +96,57 @@ export function ThinkingBlock({ content, isActive = false }: { content: string;
|
||||
)
|
||||
}
|
||||
|
||||
const THINKING_PREVIEW_MAX_CHARS = 160
|
||||
/** A short line ending in a colon is a heading for what comes after it. */
|
||||
const THINKING_OPENER_MAX_CHARS = 24
|
||||
|
||||
function cleanThinkingLines(content: string): string[] {
|
||||
const lines: string[] = []
|
||||
for (const rawLine of content.split('\n')) {
|
||||
const line = rawLine
|
||||
.trim()
|
||||
.replace(/^#{1,6}\s+/, '')
|
||||
.replace(/^[-*+]\s+/, '')
|
||||
.replace(/^>\s*/, '')
|
||||
.replace(/^\d+\.\s+/, '')
|
||||
.trim()
|
||||
if (!line || line === '---') continue
|
||||
lines.push(line)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* One line of reasoning for the collapsed row, stripped of the markdown that
|
||||
* would otherwise show as literal `##` / `-` noise. Always a hint — the full
|
||||
* block is one click away, so truncating here loses nothing.
|
||||
*
|
||||
* While the block is still streaming this follows the tail, because the useful
|
||||
* question then is "what is it thinking about *now*"; a first line pinned for
|
||||
* the thirty seconds a long deliberation takes answers nothing. Once it settles
|
||||
* the opening line becomes the summary again — except when that opening is a
|
||||
* bare heading like `Diagnosis complete:`, which is the one line in the block
|
||||
* that says least, so the substance under it is shown instead.
|
||||
*/
|
||||
export function thinkingPreview(content: string, options: { streaming?: boolean } = {}): string {
|
||||
const lines = cleanThinkingLines(content)
|
||||
if (lines.length === 0) return ''
|
||||
|
||||
const picked = options.streaming
|
||||
? lines[lines.length - 1]!
|
||||
: pickSettledPreviewLine(lines)
|
||||
|
||||
return picked.length > THINKING_PREVIEW_MAX_CHARS
|
||||
? `${picked.slice(0, THINKING_PREVIEW_MAX_CHARS)}…`
|
||||
: picked
|
||||
}
|
||||
|
||||
function pickSettledPreviewLine(lines: string[]): string {
|
||||
const first = lines[0]!
|
||||
const isBareHeading = first.length <= THINKING_OPENER_MAX_CHARS && /[::]$/.test(first)
|
||||
return isBareHeading ? lines[1] ?? first : first
|
||||
}
|
||||
|
||||
const thinkingStyles = `
|
||||
@keyframes thinking-cursor-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import { CircleStop, LoaderCircle } from 'lucide-react'
|
||||
import { CircleStop, CircleX, LoaderCircle } from 'lucide-react'
|
||||
import { activitySegmentIcon } from './activityGroupModel'
|
||||
import { CodeViewer } from './CodeViewer'
|
||||
import { DiffViewer } from './DiffViewer'
|
||||
import { TerminalChrome } from './TerminalChrome'
|
||||
@@ -17,12 +18,21 @@ import {
|
||||
isExitPlanModeTool,
|
||||
} from './PlanModePreview'
|
||||
|
||||
/**
|
||||
* `card` is the standalone bordered block. `row` strips the border, the ink icon
|
||||
* square and the bold name so the call reads as one line inside an expanded
|
||||
* activity group. Expanded row details stay inline on a nested guide instead of
|
||||
* rebuilding the card stack that the activity-group treatment removed (#1177).
|
||||
*/
|
||||
export type ToolCallChrome = 'card' | 'row'
|
||||
|
||||
type Props = {
|
||||
toolName: string
|
||||
input: unknown
|
||||
result?: { content: unknown; isError: boolean } | null
|
||||
agentTaskNotification?: AgentTaskNotification
|
||||
compact?: boolean
|
||||
chrome?: ToolCallChrome
|
||||
isPending?: boolean
|
||||
status?: 'stopped'
|
||||
partialInput?: string
|
||||
@@ -119,7 +129,8 @@ type ContentStats = {
|
||||
windowed?: boolean
|
||||
}
|
||||
|
||||
export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, isPending = false, status, partialInput, defaultExpanded = false, durationMs }: Props) {
|
||||
export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, chrome = 'card', isPending = false, status, partialInput, defaultExpanded = false, durationMs }: Props) {
|
||||
const isRow = chrome === 'row'
|
||||
const isExitPlanTool = isExitPlanModeTool(toolName)
|
||||
const isEnterPlanTool = isEnterPlanModeTool(toolName)
|
||||
const [expanded, setExpanded] = useState(defaultExpanded || isExitPlanTool)
|
||||
@@ -128,6 +139,9 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
|
||||
const icon = TOOL_ICONS[toolName] || 'build'
|
||||
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
|
||||
const summary = getToolSummary(toolName, obj, t)
|
||||
// Prose reads as prose. Monospace is for the things that are literally code —
|
||||
// a command, a glob, a path — not for a sentence describing one.
|
||||
const summaryIsProse = isProseToolSummary(toolName, obj)
|
||||
const outputSummary = getToolResultSummary(
|
||||
toolName,
|
||||
result?.content,
|
||||
@@ -146,8 +160,14 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
|
||||
)
|
||||
const liveStatsSummary = liveStats ? formatContentStats(liveStats, t) : ''
|
||||
|
||||
const preview = useMemo(() => renderPreview(toolName, obj, result, t), [obj, result, toolName, t])
|
||||
const details = useMemo(() => renderDetails(toolName, obj, t, isPending ? partialInput : undefined), [isPending, obj, partialInput, toolName, t])
|
||||
const preview = useMemo(
|
||||
() => renderPreview(toolName, obj, result, t, isRow),
|
||||
[isRow, obj, result, toolName, t],
|
||||
)
|
||||
const details = useMemo(
|
||||
() => renderDetails(toolName, obj, t, isPending ? partialInput : undefined, isRow),
|
||||
[isPending, isRow, obj, partialInput, toolName, t],
|
||||
)
|
||||
const hasResultDetails = Boolean(result && extractTextContent(result.content))
|
||||
const hasEditPreview = toolName === 'Edit' && typeof obj.old_string === 'string' && typeof obj.new_string === 'string'
|
||||
const hasWritePreview = toolName === 'Write' && typeof obj.content === 'string'
|
||||
@@ -201,21 +221,40 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] ${
|
||||
compact ? 'mb-0' : 'mb-2'
|
||||
}`}>
|
||||
<div
|
||||
data-tool-call-chrome={chrome}
|
||||
className={
|
||||
isRow
|
||||
? ''
|
||||
: `overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] ${
|
||||
compact ? 'mb-0' : 'mb-2'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-chat-disclosure="true"
|
||||
aria-expanded={expandable ? expanded : undefined}
|
||||
onClick={() => {
|
||||
if (expandable) {
|
||||
setExpanded((value) => !value)
|
||||
}
|
||||
}}
|
||||
className={`flex w-full items-center text-left transition-colors hover:bg-[var(--color-surface-hover)] ${
|
||||
compact ? 'gap-[11px] px-3.5 py-2.5' : 'gap-3 px-4 py-3'
|
||||
className={`flex items-center text-left transition-colors hover:bg-[var(--color-surface-hover)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
|
||||
isRow
|
||||
// The negative margin lets the hover highlight breathe past the
|
||||
// timeline rule without the row itself being inset from it.
|
||||
? '-mx-2 w-[calc(100%+1rem)] gap-2 rounded-[var(--radius-md)] px-2 py-1'
|
||||
: compact ? 'w-full gap-[11px] px-3.5 py-2.5' : 'w-full gap-3 px-4 py-3'
|
||||
}`}
|
||||
>
|
||||
{compact ? (
|
||||
{isRow ? (
|
||||
/* Rows had no icon at all, so every step began with a bare word and
|
||||
the eye had nothing to run down. A leading glyph gives the run a
|
||||
left edge, separates one tool family from the next at a glance, and
|
||||
marks the whole line as machinery rather than speech. */
|
||||
<RowToolIcon toolName={toolName} active={Boolean(pendingSummary)} />
|
||||
) : compact ? (
|
||||
<span className="material-symbols-outlined shrink-0 text-[16px] text-[var(--color-text-secondary)]">{icon}</span>
|
||||
) : (
|
||||
/* The ink square is the design's tool badge: solid `--t1` with the page
|
||||
@@ -224,15 +263,28 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
|
||||
<span className="material-symbols-outlined text-[16px]">{icon}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className={`shrink-0 font-bold text-[var(--color-text-primary)] ${compact ? 'text-[13px]' : 'text-[14px]'}`}>
|
||||
<span className={
|
||||
isRow
|
||||
// The running step is the one the reader is waiting on, so it is the
|
||||
// one that gets colour. Finished steps stay grey and the eye lands on
|
||||
// where the run actually is without a separate progress strip.
|
||||
//
|
||||
// Grey even when finished, and a step below the prose it sits among:
|
||||
// the layers have to be separable at a glance, or a run of machinery
|
||||
// reads as loudly as the sentence that concludes it.
|
||||
? `shrink-0 text-[12.5px] font-medium ${
|
||||
pendingSummary ? 'text-[var(--color-brand)]' : 'text-[var(--color-text-tertiary)]'
|
||||
}`
|
||||
: `shrink-0 font-bold text-[var(--color-text-primary)] ${compact ? 'text-[13px]' : 'text-[14px]'}`
|
||||
}>
|
||||
{toolName}
|
||||
</span>
|
||||
{filePath ? (
|
||||
<span className={`min-w-0 flex-1 truncate font-mono text-[var(--color-text-secondary)] ${compact ? 'text-[12.5px]' : 'text-[13px]'}`}>
|
||||
<span className={`min-w-0 flex-1 truncate font-mono ${isRow ? 'text-[12px] text-[var(--color-text-tertiary)]' : compact ? 'text-[12.5px] text-[var(--color-text-secondary)]' : 'text-[13px] text-[var(--color-text-secondary)]'}`}>
|
||||
{filePath.split('/').pop()}
|
||||
</span>
|
||||
) : summary ? (
|
||||
<span className={`min-w-0 flex-1 truncate font-mono text-[var(--color-text-secondary)] ${compact ? 'text-[12.5px]' : 'text-[13px]'}`}>
|
||||
<span className={`min-w-0 flex-1 truncate ${summaryIsProse ? '' : 'font-mono'} ${isRow ? 'text-[12px] text-[var(--color-text-tertiary)]' : compact ? 'text-[12.5px] text-[var(--color-text-secondary)]' : 'text-[13px] text-[var(--color-text-secondary)]'}`}>
|
||||
{summary}
|
||||
</span>
|
||||
) : (
|
||||
@@ -261,13 +313,14 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
|
||||
</span>
|
||||
) : result && outputSummary ? (
|
||||
<span
|
||||
className={`min-w-0 shrink truncate text-[12.5px] ${
|
||||
className={`inline-flex min-w-0 shrink items-center gap-1.5 text-[12.5px] ${
|
||||
result.isError
|
||||
? 'text-[var(--color-error)]'
|
||||
? 'font-medium text-[var(--color-error)]'
|
||||
: 'text-[var(--color-text-tertiary)]'
|
||||
}`}
|
||||
>
|
||||
{outputSummary}
|
||||
{result.isError && <CircleX size={13} strokeWidth={2} className="shrink-0" aria-hidden="true" />}
|
||||
<span className="min-w-0 truncate">{outputSummary}</span>
|
||||
</span>
|
||||
) : liveStatsSummary ? (
|
||||
<span className="shrink-0 font-mono text-[12.5px] tabular-nums text-[var(--color-text-tertiary)]">
|
||||
@@ -275,22 +328,37 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
|
||||
</span>
|
||||
) : null}
|
||||
{durationSummary && (
|
||||
<span className="shrink-0 font-mono text-[12px] tabular-nums text-[var(--color-text-tertiary)]">
|
||||
<span className={`shrink-0 font-mono text-[12px] tabular-nums ${
|
||||
result?.isError ? 'font-medium text-[var(--color-error)]' : 'text-[var(--color-text-tertiary)]'
|
||||
}`}>
|
||||
{durationSummary}
|
||||
</span>
|
||||
)}
|
||||
{result?.isError && (
|
||||
<span className="material-symbols-outlined shrink-0 text-[15px] text-[var(--color-error)]">error</span>
|
||||
)}
|
||||
{expandable && (
|
||||
<span className="shrink-0 text-[11px] leading-none text-[var(--color-text-tertiary)]" aria-hidden="true">
|
||||
{expanded ? '▴' : '▾'}
|
||||
<span className={`shrink-0 leading-none text-[var(--color-text-tertiary)] ${isRow ? 'text-[8px]' : 'text-[11px]'}`} aria-hidden="true">
|
||||
{isRow ? (expanded ? '▾' : '▸') : (expanded ? '▴' : '▾')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expandable && expanded && (
|
||||
<div className="space-y-2.5 border-t border-[var(--color-border)] px-4 py-3.5">
|
||||
<div
|
||||
data-tool-call-details={isRow ? 'inline' : 'panel'}
|
||||
data-tool-output-error={result?.isError ? 'true' : undefined}
|
||||
className={
|
||||
isRow
|
||||
? `mb-2 ml-2 mt-1 space-y-2.5 border-l py-1 pl-3 ${
|
||||
result?.isError
|
||||
? 'border-[var(--color-error-soft-hover)]'
|
||||
: 'border-[var(--color-border)]'
|
||||
}`
|
||||
: `space-y-2.5 border-t px-4 py-3.5 ${
|
||||
result?.isError
|
||||
? 'border-[var(--color-error-soft-hover)] bg-[var(--color-error-soft)]'
|
||||
: 'border-[var(--color-border)]'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{preview}
|
||||
{details}
|
||||
</div>
|
||||
@@ -425,6 +493,7 @@ function renderPreview(
|
||||
obj: Record<string, unknown>,
|
||||
result?: { content: unknown; isError: boolean } | null,
|
||||
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
||||
embedded = false,
|
||||
) {
|
||||
const filePath = typeof obj.file_path === 'string' ? obj.file_path : 'file'
|
||||
// Must match the terminal-card condition below exactly. When they diverged, a
|
||||
@@ -434,7 +503,7 @@ function renderPreview(
|
||||
const shellCommand = isShellTool(toolName) && typeof obj.command === 'string' ? obj.command : null
|
||||
const echoesInTerminal = shellCommand !== null
|
||||
const resultText = getVisibleResultText(toolName, result, echoesInTerminal)
|
||||
const resultOutput = result && resultText ? renderResultOutput(result, resultText, t) : null
|
||||
const resultOutput = result && resultText ? renderResultOutput(result, resultText, t, embedded) : null
|
||||
|
||||
if (toolName === 'Edit' && typeof obj.old_string === 'string' && typeof obj.new_string === 'string') {
|
||||
return (
|
||||
@@ -706,17 +775,49 @@ function renderResultOutput(
|
||||
result: { content: unknown; isError: boolean },
|
||||
text: string,
|
||||
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
||||
embedded = false,
|
||||
) {
|
||||
const label = result.isError
|
||||
? t?.('tool.errorOutput') ?? 'Error Output'
|
||||
: t?.('tool.toolOutput') ?? 'Tool Output'
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<>
|
||||
<InlineImageGallery text={text} />
|
||||
{result.isError ? (
|
||||
<div data-tool-detail-surface="embedded" className="overflow-hidden bg-[var(--color-error-soft)]">
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-error-soft-hover)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-error)]">
|
||||
<span>{label}</span>
|
||||
<CopyButton
|
||||
text={text}
|
||||
className="rounded-[var(--radius-sm)] px-2 py-1 text-[11px] normal-case tracking-normal text-[var(--color-error)] transition-colors hover:bg-[var(--color-error-soft-hover)]"
|
||||
/>
|
||||
</div>
|
||||
<pre className="max-h-[420px] overflow-auto whitespace-pre-wrap break-words px-3 py-2 font-mono text-[12px] leading-[1.45] text-[var(--color-error)]">
|
||||
{text}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<CodeViewer code={text} language="plaintext" maxLines={18} chrome="embedded" label={label} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<InlineImageGallery text={text} />
|
||||
<div className={`overflow-hidden rounded-[var(--radius-lg)] border ${
|
||||
result.isError
|
||||
? 'border-[var(--color-error)] bg-[var(--color-error-container)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
|
||||
}`}>
|
||||
<div
|
||||
data-tool-detail-surface="card"
|
||||
className={`overflow-hidden rounded-[var(--radius-lg)] border ${
|
||||
result.isError
|
||||
? 'border-[var(--color-error)] bg-[var(--color-error-container)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
|
||||
<span>{result.isError ? t?.('tool.errorOutput') ?? 'Error Output' : t?.('tool.toolOutput') ?? 'Tool Output'}</span>
|
||||
<span>{label}</span>
|
||||
<CopyButton
|
||||
text={text}
|
||||
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] px-2 py-1 text-[11px] normal-case tracking-normal text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
|
||||
@@ -739,15 +840,16 @@ function renderDetails(
|
||||
obj: Record<string, unknown>,
|
||||
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
||||
partialInput?: string,
|
||||
embedded = false,
|
||||
) {
|
||||
if (partialInput) {
|
||||
if (toolName === 'Write') {
|
||||
const writerContent = extractPartialJsonStringField(partialInput, 'content')
|
||||
if (writerContent !== null) {
|
||||
return renderWriterPreview(writerContent, t)
|
||||
return renderWriterPreview(writerContent, t, embedded)
|
||||
}
|
||||
}
|
||||
return renderPartialInput(partialInput, t)
|
||||
return renderPartialInput(partialInput, t, embedded)
|
||||
}
|
||||
|
||||
if (toolName === 'Edit' || toolName === 'Write') {
|
||||
@@ -765,10 +867,15 @@ function renderDetails(
|
||||
}
|
||||
|
||||
const text = JSON.stringify(displayed, null, 2)
|
||||
const label = t?.('tool.toolInput') ?? 'Tool Input'
|
||||
if (embedded) {
|
||||
return <CodeViewer code={text} language="json" maxLines={18} chrome="embedded" label={label} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<div data-tool-detail-surface="card" className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
|
||||
<span>{t?.('tool.toolInput') ?? 'Tool Input'}</span>
|
||||
<span>{label}</span>
|
||||
<CopyButton
|
||||
text={text}
|
||||
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] px-2 py-1 text-[11px] normal-case tracking-normal text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
|
||||
@@ -938,6 +1045,7 @@ function formatCount(count: number): string {
|
||||
function renderWriterPreview(
|
||||
content: string,
|
||||
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
||||
embedded = false,
|
||||
) {
|
||||
const contentStats = countContentStats(content)
|
||||
const lines = content.length === 0 ? [] : content.split('\n')
|
||||
@@ -961,7 +1069,14 @@ function renderWriterPreview(
|
||||
}, t)
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<div
|
||||
data-tool-detail-surface={embedded ? 'embedded' : 'card'}
|
||||
className={
|
||||
embedded
|
||||
? 'overflow-hidden bg-[var(--color-surface)]'
|
||||
: 'overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]'
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
|
||||
<span>{t?.('tool.writerPreview') ?? 'Writer'}</span>
|
||||
<span className="font-mono normal-case tracking-normal tabular-nums">
|
||||
@@ -978,13 +1093,28 @@ function renderWriterPreview(
|
||||
function renderPartialInput(
|
||||
partialInput: string,
|
||||
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
||||
embedded = false,
|
||||
) {
|
||||
const formattedInput = formatPartialJsonInput(partialInput)
|
||||
const label = t?.('tool.partialInput') ?? 'Partial input'
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<CodeViewer
|
||||
code={formattedInput}
|
||||
language="json"
|
||||
maxLines={8}
|
||||
wrapLongLines
|
||||
chrome="embedded"
|
||||
label={label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<div data-tool-detail-surface="card" className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<div className="border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
|
||||
{t?.('tool.partialInput') ?? 'Partial input'}
|
||||
{label}
|
||||
</div>
|
||||
<CodeViewer code={formattedInput} language="json" maxLines={8} wrapLongLines />
|
||||
</div>
|
||||
@@ -1132,10 +1262,46 @@ function stripAnsi(value: string): string {
|
||||
.replace(/\x1B\[[0-9;?]*[ -/]*[@-~]/g, '')
|
||||
}
|
||||
|
||||
function RowToolIcon({ toolName, active }: { toolName: string; active: boolean }) {
|
||||
const Icon = activitySegmentIcon(toolName)
|
||||
return (
|
||||
<Icon
|
||||
size={13}
|
||||
strokeWidth={1.8}
|
||||
aria-hidden="true"
|
||||
className={`mt-[3px] shrink-0 self-start ${
|
||||
active ? 'text-[var(--color-brand)]' : 'text-[var(--color-text-tertiary)]'
|
||||
}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether the row's summary is a sentence rather than something code-shaped. */
|
||||
function isProseToolSummary(toolName: string, obj: Record<string, unknown>): boolean {
|
||||
switch (toolName) {
|
||||
case 'Bash':
|
||||
case 'PowerShell':
|
||||
case 'Agent':
|
||||
return typeof obj.description === 'string' && obj.description.trim().length > 0
|
||||
case 'Read':
|
||||
case 'Write':
|
||||
case 'Edit':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getToolSummary(toolName: string, obj: Record<string, unknown>, t?: (key: TranslationKey, params?: Record<string, string | number>) => string): string {
|
||||
switch (toolName) {
|
||||
case 'Bash':
|
||||
case 'PowerShell':
|
||||
// The model sends a short description of what the command is for, in the
|
||||
// conversation's language ("查看最近 commit 的改动文件"). Prefer it: a row
|
||||
// saying that is readable at a glance, where the raw command it stands for
|
||||
// is a pipeline that truncates into noise. The command itself is still one
|
||||
// click away in the expanded row.
|
||||
if (typeof obj.description === 'string' && obj.description.trim()) return obj.description
|
||||
return typeof obj.command === 'string' ? obj.command : ''
|
||||
case 'Read':
|
||||
return t?.('tool.readFileContents') ?? 'Read file contents'
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { BookMarked, ChevronDown, ChevronRight, CircleCheck, Settings } from 'lucide-react'
|
||||
import { ToolCallBlock } from './ToolCallBlock'
|
||||
import { ToolCallBlock, type ToolCallChrome } from './ToolCallBlock'
|
||||
import { ActivityGroup } from './ActivityGroup'
|
||||
import { ThinkingBlock } from './ThinkingBlock'
|
||||
import {
|
||||
activityStepToolCalls,
|
||||
toActivitySteps,
|
||||
toolCallDurationMs,
|
||||
type ActivityStep,
|
||||
} from './activityGroupModel'
|
||||
import { ImageGenerationGroup, type ImageGenerationItem } from './ImageGenerationBlock'
|
||||
import { isImageGenerationToolName } from './imageGenerationTools'
|
||||
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||
import { Badge, StatusDot, type Tone } from '@/components/ui/Badge'
|
||||
import { Badge, type Tone } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { IconButton } from '@/components/ui/IconButton'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
@@ -32,20 +40,7 @@ type MemoryToolActivity = {
|
||||
files: MemoryToolFile[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall-clock gap between the tool_use and its tool_result, used for the "524ms"
|
||||
* badge (#1149). The CLI does not report a real execution duration over the wire
|
||||
* — BashProgress never leaves the ink renderer — so this is the transcript
|
||||
* timestamp delta and therefore includes any permission-approval wait.
|
||||
*/
|
||||
export function toolCallDurationMs(
|
||||
toolCall: Pick<ToolCall, 'timestamp'>,
|
||||
result?: Pick<ToolResult, 'timestamp'>,
|
||||
): number | undefined {
|
||||
if (!result) return undefined
|
||||
const elapsed = result.timestamp - toolCall.timestamp
|
||||
return Number.isFinite(elapsed) && elapsed >= 0 ? elapsed : undefined
|
||||
}
|
||||
export { toolCallDurationMs } from './activityGroupModel'
|
||||
|
||||
function imageGenerationItems(
|
||||
toolCalls: ToolCall[],
|
||||
@@ -74,105 +69,58 @@ function useExpandableCardState() {
|
||||
|
||||
type Props = {
|
||||
sessionId?: string | null
|
||||
onOpenAgentRun?: (payload: OpenAgentRunPayload) => void
|
||||
toolCalls: ToolCall[]
|
||||
/**
|
||||
* The run in transcript order, including any thinking blocks that happened
|
||||
* between the tool calls. Optional: callers that only have tool calls (and
|
||||
* every test predating the activity-group rollup) get an equivalent
|
||||
* tools-only run derived from `toolCalls`.
|
||||
*/
|
||||
steps?: ActivityStep[]
|
||||
resultMap: Map<string, ToolResult>
|
||||
childToolCallsByParent: Map<string, ToolCall[]>
|
||||
agentTaskNotifications: Record<string, AgentTaskNotification>
|
||||
agentTaskStatuses?: Record<string, BackgroundAgentTask['status']>
|
||||
activeThinkingId?: string | null
|
||||
showOpenRun?: boolean
|
||||
/** When true, the last tool is still executing. */
|
||||
isStreaming?: boolean
|
||||
/** This run is the tail of a turn that is still producing into it. */
|
||||
isLive?: boolean
|
||||
}
|
||||
|
||||
const TOOL_VERBS: Record<string, (count: number, t: (key: TranslationKey, params?: Record<string, string | number>) => string) => string> = {
|
||||
Read: (n, t) => n === 1 ? t('toolGroup.readOne') : t('toolGroup.readMany', { count: n }),
|
||||
Write: (n, t) => n === 1 ? t('toolGroup.createdOne') : t('toolGroup.createdMany', { count: n }),
|
||||
Edit: (n, t) => n === 1 ? t('toolGroup.editedOne') : t('toolGroup.editedMany', { count: n }),
|
||||
Bash: (n, t) => n === 1 ? t('toolGroup.ranOne') : t('toolGroup.ranMany', { count: n }),
|
||||
Glob: (_n, t) => t('toolGroup.foundFiles'),
|
||||
Grep: (n, t) => n === 1 ? t('toolGroup.searchedOne') : t('toolGroup.searchedMany', { count: n }),
|
||||
Agent: (n, t) => n === 1 ? t('toolGroup.agentOne') : t('toolGroup.agentMany', { count: n }),
|
||||
WebSearch: (_n, t) => t('toolGroup.searchedWeb'),
|
||||
WebFetch: (n, t) => n === 1 ? t('toolGroup.fetchedOne') : t('toolGroup.fetchedMany', { count: n }),
|
||||
}
|
||||
|
||||
function generateSummary(toolCalls: ToolCall[], t: (key: TranslationKey, params?: Record<string, string | number>) => string): string {
|
||||
const counts = new Map<string, number>()
|
||||
for (const tc of toolCalls) {
|
||||
counts.set(tc.toolName, (counts.get(tc.toolName) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
for (const [name, count] of counts) {
|
||||
const verbFn = TOOL_VERBS[name]
|
||||
parts.push(verbFn ? verbFn(count, t) : `${name} (${count})`)
|
||||
}
|
||||
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
function toolCallHasError(
|
||||
toolCall: ToolCall,
|
||||
resultMap: Map<string, ToolResult>,
|
||||
childToolCallsByParent: Map<string, ToolCall[]>,
|
||||
): boolean {
|
||||
const result = resultMap.get(toolCall.toolUseId)
|
||||
if (result?.isError) return true
|
||||
|
||||
return (childToolCallsByParent.get(toolCall.toolUseId) ?? []).some((childToolCall) =>
|
||||
toolCallHasError(childToolCall, resultMap, childToolCallsByParent),
|
||||
)
|
||||
}
|
||||
|
||||
function groupHasErrors(
|
||||
toolCalls: ToolCall[],
|
||||
resultMap: Map<string, ToolResult>,
|
||||
childToolCallsByParent: Map<string, ToolCall[]>,
|
||||
): boolean {
|
||||
return toolCalls.some((tc) => {
|
||||
return toolCallHasError(tc, resultMap, childToolCallsByParent)
|
||||
})
|
||||
}
|
||||
|
||||
function isToolCallResolved(
|
||||
toolCall: ToolCall,
|
||||
resultMap: Map<string, ToolResult>,
|
||||
childToolCallsByParent: Map<string, ToolCall[]>,
|
||||
): boolean {
|
||||
if (toolCall.status === 'stopped') return true
|
||||
if (!resultMap.has(toolCall.toolUseId)) return false
|
||||
|
||||
return (childToolCallsByParent.get(toolCall.toolUseId) ?? []).every((childToolCall) =>
|
||||
isToolCallResolved(childToolCall, resultMap, childToolCallsByParent),
|
||||
)
|
||||
}
|
||||
|
||||
function hasUnresolvedToolCalls(
|
||||
toolCalls: ToolCall[],
|
||||
resultMap: Map<string, ToolResult>,
|
||||
childToolCallsByParent: Map<string, ToolCall[]>,
|
||||
): boolean {
|
||||
return toolCalls.some((toolCall) =>
|
||||
!isToolCallResolved(toolCall, resultMap, childToolCallsByParent),
|
||||
)
|
||||
export type OpenAgentRunPayload = {
|
||||
sessionId: string
|
||||
toolUseId: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export const ToolCallGroup = memo(function ToolCallGroup({
|
||||
sessionId,
|
||||
onOpenAgentRun,
|
||||
toolCalls,
|
||||
steps,
|
||||
resultMap,
|
||||
childToolCallsByParent,
|
||||
agentTaskNotifications,
|
||||
agentTaskStatuses,
|
||||
activeThinkingId,
|
||||
showOpenRun = true,
|
||||
isStreaming,
|
||||
isLive = false,
|
||||
}: Props) {
|
||||
const resolvedSteps = useMemo(() => steps ?? toActivitySteps(toolCalls), [steps, toolCalls])
|
||||
const memoryActivity = getMemoryToolActivity(toolCalls, resultMap)
|
||||
if (memoryActivity) {
|
||||
const memoryToolCalls = toolCalls.filter(isMemoryToolCall)
|
||||
const regularToolCalls = toolCalls.filter((toolCall) => !isMemoryToolCall(toolCall))
|
||||
// Thinking stays with the remainder: the memory card is a summary of files
|
||||
// touched, not a place reasoning belongs.
|
||||
const regularSteps = resolvedSteps.filter(
|
||||
(step) => step.kind === 'thinking' || !isMemoryToolCall(step.toolCall),
|
||||
)
|
||||
return (
|
||||
<div className={regularToolCalls.length > 0 ? 'mb-2 space-y-2' : ''}>
|
||||
<div className={regularSteps.length > 0 ? 'space-y-2' : ''}>
|
||||
<MemoryToolActivityGroup
|
||||
activity={memoryActivity}
|
||||
toolCalls={memoryToolCalls}
|
||||
@@ -180,14 +128,16 @@ export const ToolCallGroup = memo(function ToolCallGroup({
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
{regularToolCalls.length > 0 ? (
|
||||
{regularSteps.length > 0 ? (
|
||||
<ToolCallGroupContent
|
||||
sessionId={sessionId}
|
||||
toolCalls={regularToolCalls}
|
||||
onOpenAgentRun={onOpenAgentRun}
|
||||
steps={regularSteps}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
agentTaskNotifications={agentTaskNotifications}
|
||||
agentTaskStatuses={agentTaskStatuses}
|
||||
activeThinkingId={activeThinkingId}
|
||||
showOpenRun={showOpenRun}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
@@ -199,39 +149,51 @@ export const ToolCallGroup = memo(function ToolCallGroup({
|
||||
return (
|
||||
<ToolCallGroupContent
|
||||
sessionId={sessionId}
|
||||
toolCalls={toolCalls}
|
||||
onOpenAgentRun={onOpenAgentRun}
|
||||
steps={resolvedSteps}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
agentTaskNotifications={agentTaskNotifications}
|
||||
agentTaskStatuses={agentTaskStatuses}
|
||||
activeThinkingId={activeThinkingId}
|
||||
showOpenRun={showOpenRun}
|
||||
isStreaming={isStreaming}
|
||||
isLive={isLive}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
type ContentProps = Omit<Props, 'toolCalls' | 'steps'> & { steps: ActivityStep[] }
|
||||
|
||||
function ToolCallGroupContent({
|
||||
sessionId,
|
||||
toolCalls,
|
||||
onOpenAgentRun,
|
||||
steps,
|
||||
resultMap,
|
||||
childToolCallsByParent,
|
||||
agentTaskNotifications,
|
||||
agentTaskStatuses,
|
||||
activeThinkingId,
|
||||
showOpenRun = true,
|
||||
isStreaming,
|
||||
}: Props) {
|
||||
isLive = false,
|
||||
}: ContentProps) {
|
||||
const toolCalls = activityStepToolCalls(steps)
|
||||
const hasImageGeneration = toolCalls.some((toolCall) => isImageGenerationToolName(toolCall.toolName))
|
||||
if (hasImageGeneration && !toolCalls.every((toolCall) => isImageGenerationToolName(toolCall.toolName))) {
|
||||
const hasNonImageSteps = steps.some(
|
||||
(step) => step.kind === 'thinking' || !isImageGenerationToolName(step.toolCall.toolName),
|
||||
)
|
||||
if (hasImageGeneration && hasNonImageSteps) {
|
||||
const segments: Array<
|
||||
| { kind: 'images'; toolCalls: ToolCall[] }
|
||||
| { kind: 'regular'; toolCalls: ToolCall[] }
|
||||
| { kind: 'regular'; steps: ActivityStep[] }
|
||||
> = []
|
||||
let regularToolCalls: ToolCall[] = []
|
||||
let regularSteps: ActivityStep[] = []
|
||||
let imageToolCalls: ToolCall[] = []
|
||||
const flushRegularCalls = () => {
|
||||
if (regularToolCalls.length === 0) return
|
||||
segments.push({ kind: 'regular', toolCalls: regularToolCalls })
|
||||
regularToolCalls = []
|
||||
const flushRegularSteps = () => {
|
||||
if (regularSteps.length === 0) return
|
||||
segments.push({ kind: 'regular', steps: regularSteps })
|
||||
regularSteps = []
|
||||
}
|
||||
const flushImageCalls = () => {
|
||||
if (imageToolCalls.length === 0) return
|
||||
@@ -239,16 +201,16 @@ function ToolCallGroupContent({
|
||||
imageToolCalls = []
|
||||
}
|
||||
|
||||
for (const toolCall of toolCalls) {
|
||||
if (isImageGenerationToolName(toolCall.toolName)) {
|
||||
flushRegularCalls()
|
||||
imageToolCalls.push(toolCall)
|
||||
for (const step of steps) {
|
||||
if (step.kind === 'tool' && isImageGenerationToolName(step.toolCall.toolName)) {
|
||||
flushRegularSteps()
|
||||
imageToolCalls.push(step.toolCall)
|
||||
} else {
|
||||
flushImageCalls()
|
||||
regularToolCalls.push(toolCall)
|
||||
regularSteps.push(step)
|
||||
}
|
||||
}
|
||||
flushRegularCalls()
|
||||
flushRegularSteps()
|
||||
flushImageCalls()
|
||||
|
||||
return (
|
||||
@@ -262,11 +224,13 @@ function ToolCallGroupContent({
|
||||
<ToolCallGroupContent
|
||||
key={`regular-${index}`}
|
||||
sessionId={sessionId}
|
||||
toolCalls={segment.toolCalls}
|
||||
onOpenAgentRun={onOpenAgentRun}
|
||||
steps={segment.steps}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
agentTaskNotifications={agentTaskNotifications}
|
||||
agentTaskStatuses={agentTaskStatuses}
|
||||
activeThinkingId={activeThinkingId}
|
||||
showOpenRun={showOpenRun}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
@@ -275,12 +239,27 @@ function ToolCallGroupContent({
|
||||
)
|
||||
}
|
||||
|
||||
const allAgents = toolCalls.every((toolCall) => toolCall.toolName === 'Agent')
|
||||
if (toolCalls.length === 0) {
|
||||
return (
|
||||
<>
|
||||
{steps.map((step) => step.kind === 'thinking' ? (
|
||||
<ThinkingBlock
|
||||
key={step.message.id}
|
||||
content={step.message.content}
|
||||
isActive={step.message.id === activeThinkingId}
|
||||
/>
|
||||
) : null)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const allAgents = toolCalls.length > 0 && toolCalls.every((toolCall) => toolCall.toolName === 'Agent')
|
||||
|
||||
if (allAgents) {
|
||||
return (
|
||||
<AgentToolGroup
|
||||
sessionId={sessionId}
|
||||
onOpenAgentRun={onOpenAgentRun}
|
||||
toolCalls={toolCalls}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
@@ -298,25 +277,14 @@ function ToolCallGroupContent({
|
||||
)
|
||||
}
|
||||
|
||||
// Single tool call — render directly without group wrapper
|
||||
if (toolCalls.length === 1) {
|
||||
const tc = toolCalls[0]!
|
||||
return (
|
||||
<ToolCallTree
|
||||
toolCall={tc}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolCallGroupMulti
|
||||
toolCalls={toolCalls}
|
||||
<ActivityGroup
|
||||
steps={steps}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
agentTaskNotifications={agentTaskNotifications}
|
||||
activeThinkingId={activeThinkingId}
|
||||
isStreaming={isStreaming}
|
||||
isLive={isLive}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -344,13 +312,15 @@ function MemoryToolActivityGroup({
|
||||
const hiddenCount = Math.max(0, activity.files.length - visibleFiles.length)
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div>
|
||||
<div
|
||||
data-testid="memory-tool-activity-card"
|
||||
className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-memory-border)] bg-[var(--color-memory-surface)]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-chat-disclosure="true"
|
||||
aria-expanded={expanded}
|
||||
onClick={toggleExpanded}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
@@ -442,6 +412,7 @@ function MemoryToolActivityGroup({
|
||||
|
||||
function AgentToolGroup({
|
||||
sessionId,
|
||||
onOpenAgentRun,
|
||||
toolCalls,
|
||||
resultMap,
|
||||
childToolCallsByParent,
|
||||
@@ -466,9 +437,11 @@ function AgentToolGroup({
|
||||
const anyStopped = statuses.some((status) => status === 'stopped')
|
||||
|
||||
return (
|
||||
<div className="mb-2 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
|
||||
<button
|
||||
type="button"
|
||||
data-chat-disclosure="true"
|
||||
aria-expanded={expanded}
|
||||
onClick={toggleExpanded}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
@@ -498,25 +471,20 @@ function AgentToolGroup({
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="relative border-t border-[var(--color-border)] py-3 pl-5 pr-3.5">
|
||||
<div className="absolute bottom-6 left-[11px] top-4 w-px rounded-full bg-[var(--color-border)]" />
|
||||
<div className="space-y-2">
|
||||
<div className="px-3.5 pb-2.5 pt-0.5">
|
||||
<div className="ml-1.5 flex flex-col border-l border-[var(--color-border)] pl-4">
|
||||
{toolCalls.map((toolCall) => (
|
||||
<div key={toolCall.id} className="relative pl-7">
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2">
|
||||
<div className="absolute left-[11px] top-1/2 h-px w-4 -translate-y-1/2 bg-[var(--color-border)]" />
|
||||
<div className="absolute left-[8px] top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[0_0_0_2px_var(--color-surface)]" />
|
||||
</div>
|
||||
<AgentCallCard
|
||||
sessionId={sessionId}
|
||||
toolCall={toolCall}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
agentTaskNotification={agentTaskNotifications[toolCall.toolUseId]}
|
||||
agentTaskStatus={agentTaskStatuses?.[toolCall.toolUseId]}
|
||||
showOpenRun={showOpenRun}
|
||||
/>
|
||||
</div>
|
||||
<AgentCallCard
|
||||
key={toolCall.id}
|
||||
sessionId={sessionId}
|
||||
onOpenAgentRun={onOpenAgentRun}
|
||||
toolCall={toolCall}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
agentTaskNotification={agentTaskNotifications[toolCall.toolUseId]}
|
||||
agentTaskStatus={agentTaskStatuses?.[toolCall.toolUseId]}
|
||||
showOpenRun={showOpenRun}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -525,58 +493,9 @@ function AgentToolGroup({
|
||||
)
|
||||
}
|
||||
|
||||
/** Separated so the useState hook is never called conditionally. */
|
||||
function ToolCallGroupMulti({ toolCalls, resultMap, childToolCallsByParent, isStreaming }: Props) {
|
||||
const { expanded, toggleExpanded } = useExpandableCardState()
|
||||
const t = useTranslation()
|
||||
const summary = generateSummary(toolCalls, t)
|
||||
const errorPresent = groupHasErrors(toolCalls, resultMap, childToolCallsByParent)
|
||||
const hasUnresolvedTools = hasUnresolvedToolCalls(toolCalls, resultMap, childToolCallsByParent)
|
||||
const isRunning = !!isStreaming || hasUnresolvedTools
|
||||
|
||||
return (
|
||||
<div className="mb-2 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpanded}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
<span className="shrink-0 text-[11px] leading-none text-[var(--color-text-tertiary)]" aria-hidden="true">
|
||||
{expanded ? '▾' : '▸'}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-[14px] font-semibold text-[var(--color-text-primary)]">
|
||||
{summary}
|
||||
</span>
|
||||
{!isRunning && !errorPresent && (
|
||||
<CircleCheck size={19} strokeWidth={1.6} className="shrink-0 text-[var(--color-success)]" aria-hidden="true" />
|
||||
)}
|
||||
{!isRunning && errorPresent && (
|
||||
<span className="material-symbols-outlined shrink-0 text-[17px] text-[var(--color-error)]">error</span>
|
||||
)}
|
||||
{isRunning && <StatusDot tone="brand" pulse />}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="flex flex-col gap-2.5 border-t border-[var(--color-border)] px-3.5 py-2.5">
|
||||
{toolCalls.map((tc) => {
|
||||
return (
|
||||
<ToolCallTree
|
||||
key={tc.id}
|
||||
toolCall={tc}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
compact
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentCallCard({
|
||||
sessionId,
|
||||
onOpenAgentRun,
|
||||
toolCall,
|
||||
resultMap,
|
||||
childToolCallsByParent,
|
||||
@@ -585,6 +504,7 @@ function AgentCallCard({
|
||||
showOpenRun = true,
|
||||
}: {
|
||||
sessionId?: string | null
|
||||
onOpenAgentRun?: (payload: OpenAgentRunPayload) => void
|
||||
toolCall: ToolCall
|
||||
resultMap: Map<string, ToolResult>
|
||||
childToolCallsByParent: Map<string, ToolCall[]>
|
||||
@@ -632,8 +552,8 @@ function AgentCallCard({
|
||||
const canOpenRun = showOpenRun && !!sessionId && !!toolCall.toolUseId
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
|
||||
<div className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)]">
|
||||
<div data-agent-call-layout="row">
|
||||
<div className="-mx-2 flex w-[calc(100%+1rem)] items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]">
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-outline)]">smart_toy</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -687,6 +607,14 @@ function AgentCallCard({
|
||||
aria-label={t('toolGroup.openRunNamed', { title: openRunTitle })}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (onOpenAgentRun) {
|
||||
onOpenAgentRun({
|
||||
sessionId,
|
||||
toolUseId: toolCall.toolUseId,
|
||||
title: openRunTitle,
|
||||
})
|
||||
return
|
||||
}
|
||||
useTabStore.getState().openSubagentTab(sessionId, toolCall.toolUseId, openRunTitle)
|
||||
}}
|
||||
className="shrink-0 border border-[var(--color-border)]"
|
||||
@@ -713,14 +641,14 @@ function AgentCallCard({
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-[var(--color-border)] px-3 py-3">
|
||||
<div className="mb-2 ml-2 mt-1 border-l border-[var(--color-border)] py-1 pl-3">
|
||||
{errorText && (
|
||||
<div className="mb-3 rounded-[var(--radius-lg)] border border-[var(--color-error)] bg-[var(--color-error-container)] px-3 py-2 text-[11px] text-[var(--color-on-error-container)]">
|
||||
<div className="mb-2 bg-[var(--color-error-soft)] px-3 py-2 text-[11px] text-[var(--color-error)]">
|
||||
{errorText}
|
||||
</div>
|
||||
)}
|
||||
{childToolCalls.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-0.5">
|
||||
{childToolCalls.map((childToolCall) => (
|
||||
<ToolCallTree
|
||||
key={childToolCall.id}
|
||||
@@ -728,15 +656,16 @@ function AgentCallCard({
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
compact
|
||||
chrome="row"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : outputSummary ? (
|
||||
<div className="text-[11px] text-[var(--color-text-tertiary)]">
|
||||
<div className="px-2 py-1 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('agentStatus.noActivity')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[11px] text-[var(--color-text-tertiary)]">
|
||||
<div className="px-2 py-1 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{status === 'starting' ? t('agentStatus.starting') : t('agentStatus.noActivity')}
|
||||
</div>
|
||||
)}
|
||||
@@ -761,30 +690,40 @@ function ToolCallTree({
|
||||
resultMap,
|
||||
childToolCallsByParent,
|
||||
compact = false,
|
||||
chrome = 'card',
|
||||
}: {
|
||||
toolCall: ToolCall
|
||||
resultMap: Map<string, ToolResult>
|
||||
childToolCallsByParent: Map<string, ToolCall[]>
|
||||
compact?: boolean
|
||||
chrome?: ToolCallChrome
|
||||
}) {
|
||||
const result = resultMap.get(toolCall.toolUseId)
|
||||
const childToolCalls = childToolCallsByParent.get(toolCall.toolUseId) ?? []
|
||||
const isRow = chrome === 'row'
|
||||
|
||||
return (
|
||||
<div className={compact ? 'space-y-1' : ''}>
|
||||
<div className={!isRow && compact ? 'space-y-1' : ''}>
|
||||
<ToolCallBlock
|
||||
toolName={toolCall.toolName}
|
||||
input={toolCall.input}
|
||||
result={result ? { content: result.content, isError: result.isError } : null}
|
||||
compact={compact}
|
||||
chrome={chrome}
|
||||
isPending={toolCall.isPending}
|
||||
status={toolCall.status}
|
||||
partialInput={toolCall.partialInput}
|
||||
durationMs={toolCallDurationMs(toolCall, result)}
|
||||
/>
|
||||
{childToolCalls.length > 0 && (
|
||||
<div className={compact ? 'ml-4 border-l border-[var(--color-border)] pl-3' : 'mb-2 ml-16 border-l border-[var(--color-border)] pl-3'}>
|
||||
<div className="space-y-1">
|
||||
<div className={
|
||||
isRow
|
||||
? 'ml-2 border-l border-[var(--color-border)] pl-3'
|
||||
: compact
|
||||
? 'ml-4 border-l border-[var(--color-border)] pl-3'
|
||||
: 'mb-2 ml-16 border-l border-[var(--color-border)] pl-3'
|
||||
}>
|
||||
<div className={isRow ? 'space-y-0.5' : 'space-y-1'}>
|
||||
{childToolCalls.map((childToolCall) => (
|
||||
<ToolCallTree
|
||||
key={childToolCall.id}
|
||||
@@ -792,6 +731,7 @@ function ToolCallTree({
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
compact
|
||||
chrome={chrome}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@ export const ToolResultBlock = memo(function ToolResultBlock({ content, isError,
|
||||
const hasMore = text.length > 200
|
||||
|
||||
return (
|
||||
<div className={`mb-2 overflow-hidden rounded-[var(--radius-lg)] border ${
|
||||
<div className={`overflow-hidden rounded-[var(--radius-lg)] border ${
|
||||
isError
|
||||
? 'border-[var(--color-error)]'
|
||||
: 'border-[var(--color-border)]'
|
||||
|
||||
@@ -11,10 +11,10 @@ describe('TurnCompletionStamp', () => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
})
|
||||
|
||||
it('reads as an end time plus how long the turn took', () => {
|
||||
it('reads as a compact clock time plus how long the turn took', () => {
|
||||
render(<TurnCompletionStamp completion={{ completedAt: COMPLETED_AT, durationMs: 739_000 }} />)
|
||||
|
||||
const stamp = screen.getByText(`Done ${formatMessageHoverTime(COMPLETED_AT, 'en')}`)
|
||||
const stamp = screen.getByText(formatMessageHoverTime(COMPLETED_AT, 'en'))
|
||||
expect(stamp).toBeTruthy()
|
||||
expect(screen.getByText('took 12m 19s')).toBeTruthy()
|
||||
})
|
||||
@@ -22,14 +22,14 @@ describe('TurnCompletionStamp', () => {
|
||||
it('shows the end time alone when the duration is not trustworthy', () => {
|
||||
const { container } = render(<TurnCompletionStamp completion={{ completedAt: COMPLETED_AT }} />)
|
||||
|
||||
expect(screen.getByText(`Done ${formatMessageHoverTime(COMPLETED_AT, 'en')}`)).toBeTruthy()
|
||||
expect(screen.getByText(formatMessageHoverTime(COMPLETED_AT, 'en'))).toBeTruthy()
|
||||
expect(container.querySelector('[data-turn-completion-duration]')).toBeNull()
|
||||
})
|
||||
|
||||
it('carries the exact timestamp as a title for the rounded clock label', () => {
|
||||
render(<TurnCompletionStamp completion={{ completedAt: COMPLETED_AT, durationMs: 1_000 }} />)
|
||||
|
||||
expect(screen.getByText(`Done ${formatMessageHoverTime(COMPLETED_AT, 'en')}`).getAttribute('title')).toBe(
|
||||
expect(screen.getByText(formatMessageHoverTime(COMPLETED_AT, 'en')).getAttribute('title')).toBe(
|
||||
formatExactMessageTimestamp(COMPLETED_AT, 'en'),
|
||||
)
|
||||
})
|
||||
@@ -38,7 +38,6 @@ describe('TurnCompletionStamp', () => {
|
||||
useSettingsStore.setState({ locale: 'zh' })
|
||||
const { container } = render(<TurnCompletionStamp completion={{ completedAt: COMPLETED_AT, durationMs: 739_000 }} />)
|
||||
|
||||
expect(container.textContent).toContain('完成于')
|
||||
expect(container.textContent).toContain('耗时 12 分 19 秒')
|
||||
})
|
||||
|
||||
@@ -48,10 +47,11 @@ describe('TurnCompletionStamp', () => {
|
||||
expect(container.firstElementChild).toBeNull()
|
||||
})
|
||||
|
||||
it('stays outside the hover-gated action bar styling', () => {
|
||||
// The whole point of the stamp is that it survives without a pointer.
|
||||
it('does not add a second row or its own hover gate', () => {
|
||||
const { container } = render(<TurnCompletionStamp completion={{ completedAt: COMPLETED_AT }} />)
|
||||
|
||||
expect(container.firstElementChild?.tagName).toBe('SPAN')
|
||||
expect(container.firstElementChild?.className).not.toContain('mt-')
|
||||
expect(container.firstElementChild?.className).not.toContain('opacity-0')
|
||||
expect(container.firstElementChild?.className).not.toContain('group-hover')
|
||||
})
|
||||
|
||||