chore: include full workspace in v1.5.1

This commit is contained in:
coso
2026-04-08 01:37:09 +08:00
parent c6d1fc4b4a
commit 7431c19f31
599 changed files with 10224 additions and 6980 deletions
+19 -17
View File
@@ -2,32 +2,34 @@
### ✨ 主要更新
- `aster-rust` 依赖已从 `v0.27.1` 升级到 `v0.27.2`
- `src-tauri/Cargo.toml` 的远程 git tag 引用已同步到 `v0.27.2`
- 应用与 CLI 发布版本同步提升到 `1.5.1`,用于标记这次依赖补丁发布
### ⚠️ 发布与兼容性说明
- 本次发布 tag 为 `v1.5.1`,应用内版本号保持为 `1.5.1`
- `@limecloud/lime-cli@1.5.1` 要求 `Node >= 18`,支持 `darwin / linux / win32` 与 `x64 / arm64`
- 本次为补丁发布,主要目的是对齐 `aster-rust v0.27.2`,不额外引入新的 Lime 功能面
- 本地 `.cargo/config.toml` patch override 仍仅用于开发联调,不属于发布事实源
- 本次 `v1.5.1` 已收口当前工作区全部改动,核心集中在 Agent 聊天工作台、General Workbench、Service Skill、Team Workspace、Artifact / Timeline 展示与输入发送主链
- `src/components/agent/**`、`src/components/workspace/**`、`src/lib/api/**`、`src/features/browser-runtime/**`、`src/components/settings-v2/**` 一批界面、运行时与回归测试已一并进入本次发布
- 浏览器运行时、现有会话桥接、工具展示、团队协作、项目选择、技能目录、工作台工具命令与内容同步相关边界已同步更新
- 工程文档 `docs/aiprompts/commands.md`、`playwright-e2e.md`、`quality-workflow.md` 已随当前实现一起更新
### 🔗 依赖与版本同步
- 应用版本已同步提升到 `1.5.1`,覆盖 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json`
- `packages/lime-cli-npm/package.json` 与 README 发布示例已同步更新到 `1.5.1`
- 本地 `package-lock.json` 也已同步到 `1.5.1`,用于版本一致性校验
- `src-tauri/Cargo.lock` 已刷新,`aster-core` / `aster-models` 已对齐到 `0.27.2`
- `aster-rust` 依赖已从 `v0.27.1` 升级到 `v0.27.2`
- `src-tauri/Cargo.toml` 的远程 git tag 引用已同步到 `v0.27.2`
- 应用与 CLI 发布版本保持为 `1.5.1`
- 应用版本入口已对齐到 `1.5.1`,覆盖 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json`
- `packages/lime-cli-npm/package.json`、README 发布示例与本地 `package-lock.json` 已同步到 `1.5.1`
- `src-tauri/Cargo.lock` 中 `aster-core` / `aster-models` 已对齐到 `0.27.2`
### 🧪 发布前校验
### ⚠️ 发布说明
- 本次发布 tag 仍为 `v1.5.1`
- 因为要把当前工作区全部改动统一纳入同一补丁版本,本次会更新已经存在的 `v1.5.1` Git tag 与 GitHub Release 指向
- 当前公开 release note 已按这次完整发布内容刷新
### 🧪 当前校验
- `cargo update --manifest-path src-tauri/Cargo.toml`
- `npm run verify:app-version`
- Git 提交前的仓库 AI 验证会随提交钩子执行
### 📝 文档同步
- 发布说明已切换到当前这次 `v1.5.1` 补丁版内容,可直接作为 GitHub Release note 使用
- 发布说明已更新为当前这次完整的 `v1.5.1` 内容,可直接作为 GitHub Release note 使用
---
+5 -1
View File
@@ -88,7 +88,7 @@
- `CharacterMention`、`builtinCommands`、场景 slash 补全不得再各自维护一套业务命令静态常量
- 服务端尚未返回 `entries` 时,允许网关层从 legacy `items` 兼容投影出 `entries`
- 客户端必须保留 seeded fallback,不能因为服务端暂时不可用就让 `@配图`、`@PPT`、`@表单`、`@网页`、`@代码`、`@发布`、`@搜索`、`@深搜`、`@研报`、`@站点搜索`、`@读PDF`、`@总结`、`@翻译`、`@分析`、`@转写` 这类主链入口失能
- 客户端必须保留 seeded fallback,不能因为服务端暂时不可用就让 `@配图`、`@配音`、`@PPT`、`@表单`、`@网页`、`@代码`、`@发布`、`@搜索`、`@深搜`、`@研报`、`@站点搜索`、`@读PDF`、`@总结`、`@翻译`、`@分析`、`@转写` 这类主链入口失能
- `src/components/agent/chat/commands/catalog.ts` 只继续承接 Lime 本地 / Codex 原生命令;产品型 `/` 场景不应再长期硬编码在这里
- 若服务端下发的 `renderContract` 超出 Lime 当前支持范围,优先由服务端回退到已支持类型,客户端也必须退化到通用 timeline / artifact 展示
@@ -225,6 +225,10 @@ Skill 执行链路同样遵循单一命令边界。当前前端入口为 `src/li
- 工作流入口型命令:`@发布` / `@publish` / `@发文` / `@投稿` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本展示,但把实际 dispatch 改写到现有 `/content_post_with_cover ...` 主链,并把结构化 `publish_command` 写入 `request_metadata.harness.publish_command`。当前实现优先复用已有 `content_post_with_cover` 发布工作流、`content-posts/*.md` / `*.publish-pack.json` 产物链,以及 `detectBrowserTaskRequirement(...)` 推导出的浏览器门禁,而不是再发明新的 `publish_task` 协议。若输入里已明确平台后台,如微信公众号后台,必须继续写入 `browser_requirement=required_with_user_step` 与平台 launch URL;若只是整理发布稿而未指定平台,则允许先在同一工作流里生成发布稿与发布前检查,不强行要求浏览器。后续若统一 agent/workflow runtime 成熟,可以把 `@发布` 从当前 slash workflow 迁走,但在那之前不得同时维护第二套发布入口真相。
`Claw` 的纯文本配音命令也应沿同一条服务型技能主链收敛:
- Agent 驱动的配音命令:`@配音` / `@voice` / `@dubbing` / `@dub` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会优先从当前 `serviceSkills` / seeded fallback 中解析配音能力(当前兜底为 `cloud-video-dubbing`),并把结构化 `service_scene_launch` 写入 `request_metadata.harness.service_scene_launch`,其中固定 `scene_key=voice_runtime`、`entry_source=at_voice_command`,同时注入 OEM `scene_base_url / tenant_id / session_token` 运行时上下文。Rust 侧 `runtime_turn.rs`、`prompt_context.rs` 与 `tool_runtime/service_skill_tools.rs` 会把当前 turn 切到 `workbench`,并强约束首刀优先调用 `lime_run_service_skill`,由 OEM scene runtime 负责 run / poll。当前上下文缺少明确配音要求时,允许 Agent 最多追问 1 个关键问题;但不能退回普通聊天解释、不能伪造“配音已完成”,也不能重新回流到旧的本地 TTS 测试命令。
这些命令除了 Tauri `generate_handler!` 之外,也必须继续保持 DevBridge dispatcher 已桥接,避免浏览器模式、headless smoke 或 Playwright 续测时回退成 unknown command。
自动化设置链路同样遵循这条路径。当前主入口为 `src/lib/api/automation.ts`,统一承接:
+9
View File
@@ -259,6 +259,15 @@ npm run test:contracts
5. 等待工作流继续推进后,确认产物仍落在现有 `content-posts/*.md` / `*.publish-pack.json` 主链,而不是另一套发布任务协议
6. 刷新页面或切换会话后再返回原话题,确认发布稿与发布包仍可恢复
### Claw `@配音` 云端技能主链验证
1. 在 `Claw` 对话框输入 `@配音 目标语言: 英文 风格: 科技感 给这个新品视频做一版发布配音稿`
2. 确认聊天区保留原始 `@配音 ...` 文本,而不是被改写成 slash scene 或其它内部协议
3. 如页面可查看发送详情或运行时摘要,确认 `request_metadata.harness.service_scene_launch.service_scene_run.scene_key=voice_runtime`,并且 `skill_id` 已绑定到当前可用的配音 service skill
4. 确认首刀进入 `lime_run_service_skill` 主链,而不是普通聊天解释、站点型 `lime_site_run`,或旧的本地 TTS 测试命令
5. 如果当前 OEM 云端会话缺失,确认界面明确提示需要登录 / 注入会话,而不是伪造“配音已完成”
6. 刷新页面或切换会话后再返回原话题,确认该配音任务的时间线与最近使用状态仍可恢复
### Claw `@读PDF` Prompt Skill 验证
1. 在 `Claw` 对话框输入 `@读PDF /tmp/agent-report.pdf 提炼三点结论并标注关键证据`
+9
View File
@@ -204,6 +204,7 @@ npm run bridge:health -- --timeout-ms 120000
- 检查纯文本 `Claw @表单` 是否已经走 `原始用户消息 -> harness.form_skill_launch -> Agent 首刀 Skill(form_generate) -> ```a2ui simple form JSON` 主链,而不是退回普通聊天字段建议、卡在 `ToolSearch / WebSearch / Read / Glob / Grep` 这类通用工具偏航,或回流成单文件 HTML 表单原型;同时确认 render contract 已收敛为 `form + json`
- 检查纯文本 `Claw @代码` 是否已经走 `原始用户消息 -> harness.code_command + preferred_team_preset_id -> code_orchestrated -> code_execution / tools / team runtime` 主链,而不是继续停留在普通聊天、没有打开 `task/subagent` 偏好,或把代码任务改写成另一套 prompt / workflow 旁路
- 检查纯文本 `Claw @发布` 是否已经走 `原始用户消息 -> displayContent 保留 -> dispatch /content_post_with_cover -> content_post workflow` 主链,而不是直接把 `@发布` 文本原样当普通聊天发送,或重新造一套 `publish_task` 协议;同时确认平台后台类输入会继续触发 `browser_requirement`
- 检查纯文本 `Claw @配音` 是否已经走 `原始用户消息 -> harness.service_scene_launch(scene_key=voice_runtime) -> Agent 首刀 lime_run_service_skill -> OEM scene run/timeline` 主链,而不是退回普通聊天解释、误走站点型 `service_skill_launch`,或重新回流旧的本地 TTS 测试命令;同时确认 `skill_id`、OEM runtime 上下文与最近使用记录都能写回
- 检查产品型 `/scene-key` 是否已经走 `原始用户消息 -> harness.service_scene_launch -> Agent 首刀 lime_run_service_skill -> OEM run/timeline` 主链,而不是前端直接调用云端 run API
- 如果某个 `/scene-key` 绑定的是 `site_adapter` 型技能,还要额外检查 `scene -> linkedSkillId -> 完整 ServiceSkill 目录 -> harness.service_skill_launch` 这条绑定链是否仍然成立,避免首页隐藏 site skill 后 slash scene 变成“目录可见但执行找不到 skill”
- 如果某个 `site_adapter` 结果开始返回 `markdown_bundle`,还要确认保存链会把 Markdown、图片和 `meta.json` 一起落到项目导出目录,并把重写后的相对图片路径写回内容 metadata;同时确认聊天轻卡或 tool timeline 能显示项目目录、Markdown 路径和图片数量,不能只把远程图片 URL 或临时 DOM 文本留在聊天结果里
@@ -245,6 +246,7 @@ npm run bridge:health -- --timeout-ms 120000
- 修改 `@PPT` parser、`useWorkspaceSendActions`、`runtime_turn`、`presentation_skill_launch`、`presentation_generate` skill 或演示稿 artifact 预览链路,尤其是调整 `Claw @PPT -> harness.presentation_skill_launch -> Agent 首刀 Skill(presentation_generate) -> write_file Markdown artifact` 主链
- 修改 `@代码` parser、`useWorkspaceSendActions`、mention builtin command 或 `code_orchestrated` 发送边界,尤其是调整 `Claw @代码 -> harness.code_command -> code_orchestrated -> tools / team runtime` 主链
- 修改 `@发布` parser、`useWorkspaceSendActions`、content post workflow 入口或浏览器门禁推导,尤其是调整 `Claw @发布 -> displayContent/raw -> /content_post_with_cover -> publish workflow` 主链
- 修改 `@配音` parser、`useWorkspaceSendActions`、`service_scene_launch` 组装、OEM runtime 上下文注入或 `lime_run_service_skill` 配音场景接线,尤其是调整 `Claw @配音 -> harness.service_scene_launch(scene_key=voice_runtime) -> lime_run_service_skill -> OEM run/timeline` 主链
- 修改 `/scene-key` 解析、`serviceSkillSceneLaunch`、`useWorkspaceSendActions`、`runtime_turn`、`prompt_context`、`lime_run_service_skill` 或 `client/skills` scene 目录协议,尤其是调整 `Claw /scene-key -> harness.service_scene_launch -> Agent 首刀 lime_run_service_skill -> OEM run/timeline` 主链
- 修改 `src/lib/dev-bridge/`
- 修改 `src/lib/tauri-mock/`
@@ -403,6 +405,13 @@ npm run bridge:health -- --timeout-ms 120000
- 若浏览器门禁或 harness 协议继续扩散,再补 `npm run test:contracts`
- 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke`
如果本轮修改了 `Claw @配音` 或配音服务型技能接线,最低校验至少包含:
- `npx vitest run "src/components/agent/chat/utils/voiceWorkbenchCommand.test.ts" "src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx" "src/components/agent/chat/skill-selection/CharacterMention.test.tsx" "src/lib/api/skillCatalog.test.ts" "src/lib/api/serviceSkills.test.ts"`
- 如有改动扩散到 `service_scene_launch` runtime、`lime_run_service_skill`、OEM scene run/poll 或云端结果回流,再补对应 `runtime_turn` / `prompt_context` / `tool_runtime/service_skill_tools` 定向测试
- 若 `service_scene_launch` / harness 协议继续扩散,再补 `npm run test:contracts`
- 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke`
如果本轮修改了 Provider 模型真相源或设置页中的“支持的模型”展示逻辑,还应额外确认:
- 资源索引损坏时,GUI 会明确提示“模型真相源异常”
+4 -10
View File
@@ -8,12 +8,7 @@
* _需求: 2.2, 3.2, 5.2_
*/
import React, {
Suspense,
lazy,
useState,
useCallback,
} from "react";
import React, { Suspense, lazy, useState, useCallback } from "react";
import styled from "styled-components";
import { withI18nPatch } from "./i18n/withI18nPatch";
import { AppPageContent } from "./components/AppPageContent";
@@ -211,10 +206,9 @@ function AppContent() {
onOpenBrowserConnectorSettings: handleOpenBrowserConnectorSettings,
});
const { error: registryError, refresh: _refreshRegistry } =
useRelayRegistry({
autoLoad: hasTauriDesktopRuntime,
});
const { error: registryError, refresh: _refreshRegistry } = useRelayRegistry({
autoLoad: hasTauriDesktopRuntime,
});
useAppStartupEffects({
currentPage,
registryError,
+9 -3
View File
@@ -140,20 +140,26 @@ describe("AppPageContent", () => {
const container = renderContent("channels");
await flushEffects();
expect(container.querySelector('[data-testid="im-config-page"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="im-config-page"]'),
).not.toBeNull();
});
it("video 页面应渲染现役视频工作台入口", async () => {
const container = renderContent("video");
await flushEffects();
expect(container.querySelector('[data-testid="video-page"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="video-page"]'),
).not.toBeNull();
});
it("settings 页面应渲染设置页入口", async () => {
const container = renderContent("settings");
await flushEffects();
expect(container.querySelector('[data-testid="settings-page"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="settings-page"]'),
).not.toBeNull();
});
});
+13 -9
View File
@@ -25,12 +25,10 @@ interface MountedSidebar {
const mountedSidebars: MountedSidebar[] = [];
const APP_SIDEBAR_COLLAPSED_STORAGE_KEY = "lime.app-sidebar.collapsed";
function mountSidebar(
options?: {
currentPage?: Page;
currentPageParams?: PageParams;
},
): MountedSidebar["container"] {
function mountSidebar(options?: {
currentPage?: Page;
currentPageParams?: PageParams;
}): MountedSidebar["container"] {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
@@ -91,7 +89,9 @@ describe("AppSidebar", () => {
expect(
container.querySelector('button[aria-label="展开导航栏"]'),
).not.toBeNull();
expect(localStorage.getItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY)).toBe("true");
expect(localStorage.getItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY)).toBe(
"true",
);
});
it("新建任务页应自动展开导航栏,不沿用上一个页面的折叠状态", async () => {
@@ -107,7 +107,9 @@ describe("AppSidebar", () => {
expect(
container.querySelector('button[aria-label="折叠导航栏"]'),
).not.toBeNull();
expect(localStorage.getItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY)).toBe("false");
expect(localStorage.getItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY)).toBe(
"false",
);
});
it("旧导航配置未包含能力入口时也应显示固定能力分组和核心能力项", async () => {
@@ -142,7 +144,9 @@ describe("AppSidebar", () => {
expect(container.textContent).toContain("能力");
expect(
container.querySelector('button[aria-label="消息渠道"][aria-current="page"]'),
container.querySelector(
'button[aria-label="消息渠道"][aria-current="page"]',
),
).not.toBeNull();
});
+40 -25
View File
@@ -19,11 +19,7 @@ import {
} from "lucide-react";
import * as LucideIcons from "lucide-react";
import { getPluginsForSurface, PluginUIInfo } from "@/lib/api/pluginUI";
import {
AgentPageParams,
Page,
PageParams,
} from "@/types/page";
import { AgentPageParams, Page, PageParams } from "@/types/page";
import { getConfig } from "@/lib/api/appConfig";
import {
buildClawAgentParams,
@@ -88,7 +84,9 @@ const Container = styled.aside<{
--sidebar-border: ${({ $themeMode }) =>
$themeMode === "dark" ? "#2d3a46" : "#e2f0e2"};
--sidebar-divider: ${({ $themeMode }) =>
$themeMode === "dark" ? "rgba(148, 163, 184, 0.14)" : "rgba(132, 204, 22, 0.15)"};
$themeMode === "dark"
? "rgba(148, 163, 184, 0.14)"
: "rgba(132, 204, 22, 0.15)"};
--sidebar-hover: ${({ $themeMode }) =>
$themeMode === "dark" ? "#22303c" : "#eef7ee"};
--sidebar-active: ${({ $themeMode }) =>
@@ -109,13 +107,12 @@ const Container = styled.aside<{
padding: ${({ $collapsed }) => ($collapsed ? "12px 6px" : "12px 10px")};
position: relative;
isolation: isolate;
background:
linear-gradient(
180deg,
var(--sidebar-surface-top) 0%,
var(--sidebar-surface-middle) 34%,
var(--sidebar-surface-bottom) 100%
);
background: linear-gradient(
180deg,
var(--sidebar-surface-top) 0%,
var(--sidebar-surface-middle) 34%,
var(--sidebar-surface-bottom) 100%
);
border-right: 1px solid var(--sidebar-border);
box-shadow: 16px 0 36px -34px rgba(15, 23, 42, 0.42);
transition:
@@ -178,7 +175,8 @@ const UserButton = styled.button<{ $collapsed?: boolean }>`
padding: ${({ $collapsed }) => ($collapsed ? "8px" : "8px 10px")};
cursor: pointer;
color: var(--sidebar-foreground);
justify-content: ${({ $collapsed }) => ($collapsed ? "center" : "flex-start")};
justify-content: ${({ $collapsed }) =>
$collapsed ? "center" : "flex-start"};
transition:
background-color 0.18s ease,
color 0.18s ease;
@@ -228,7 +226,8 @@ const SearchButton = styled.button<{ $collapsed?: boolean }>`
color: var(--sidebar-muted);
padding: ${({ $collapsed }) => ($collapsed ? "0" : "0 10px")};
cursor: pointer;
justify-content: ${({ $collapsed }) => ($collapsed ? "center" : "flex-start")};
justify-content: ${({ $collapsed }) =>
$collapsed ? "center" : "flex-start"};
transition:
border-color 0.18s ease,
background-color 0.18s ease,
@@ -304,13 +303,16 @@ const NavButton = styled.button<{ $active?: boolean; $collapsed?: boolean }>`
background-color 0.18s ease,
color 0.18s ease,
transform 0.18s ease;
justify-content: ${({ $collapsed }) => ($collapsed ? "center" : "flex-start")};
justify-content: ${({ $collapsed }) =>
$collapsed ? "center" : "flex-start"};
&:hover {
background: ${({ $active }) =>
$active ? "var(--sidebar-active)" : "var(--sidebar-hover)"};
color: ${({ $active }) =>
$active ? "var(--sidebar-active-foreground)" : "var(--sidebar-foreground)"};
$active
? "var(--sidebar-active-foreground)"
: "var(--sidebar-foreground)"};
}
svg {
@@ -332,7 +334,10 @@ const NavLabel = styled.span<{ $collapsed?: boolean }>`
display: ${({ $collapsed }) => ($collapsed ? "none" : "inline")};
`;
const NavTrailingIcon = styled.span<{ $collapsed?: boolean; $active?: boolean }>`
const NavTrailingIcon = styled.span<{
$collapsed?: boolean;
$active?: boolean;
}>`
display: ${({ $collapsed }) => ($collapsed ? "none" : "inline-flex")};
align-items: center;
justify-content: center;
@@ -387,7 +392,8 @@ const FooterArea = styled.div<{ $collapsed?: boolean }>`
const ActionRow = styled.div<{ $collapsed?: boolean }>`
display: flex;
align-items: center;
justify-content: ${({ $collapsed }) => ($collapsed ? "center" : "space-between")};
justify-content: ${({ $collapsed }) =>
$collapsed ? "center" : "space-between"};
padding: 0 2px;
`;
@@ -412,7 +418,9 @@ const IconActionButton = styled.button<{ $active?: boolean }>`
background: ${({ $active }) =>
$active ? "var(--sidebar-active)" : "var(--sidebar-hover)"};
color: ${({ $active }) =>
$active ? "var(--sidebar-active-foreground)" : "var(--sidebar-foreground)"};
$active
? "var(--sidebar-active-foreground)"
: "var(--sidebar-foreground)"};
}
svg {
@@ -433,7 +441,8 @@ export function AppSidebar({
currentPageParams,
onNavigate,
}: AppSidebarProps) {
const agentEntry = (currentPageParams as AgentPageParams | undefined)?.agentEntry;
const agentEntry = (currentPageParams as AgentPageParams | undefined)
?.agentEntry;
const isClawTaskCenter = currentPage === "agent" && agentEntry === "claw";
const isNewTaskHome = currentPage === "agent" && agentEntry === "new-task";
const [collapsed, setCollapsed] = useState<boolean>(() => {
@@ -461,7 +470,8 @@ export function AppSidebar({
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [expandedGroupIds, setExpandedGroupIds] = useState<string[]>(() =>
MAIN_SIDEBAR_NAV_ITEMS.filter(
(item) => item.defaultExpanded && item.children && item.children.length > 0,
(item) =>
item.defaultExpanded && item.children && item.children.length > 0,
).map((item) => item.id),
);
@@ -641,7 +651,8 @@ export function AppSidebar({
};
const activeGroupIds = MAIN_SIDEBAR_NAV_ITEMS.filter(
(item) => item.children && item.children.length > 0 && isGroupActive(item),
(item) =>
item.children && item.children.length > 0 && isGroupActive(item),
).map((item) => item.id);
if (activeGroupIds.length === 0) {
@@ -807,7 +818,9 @@ export function AppSidebar({
<MenuScroll>
{filteredMainSections.map((section) => (
<Section key={section.id} $collapsed={collapsed}>
<SectionTitle $collapsed={collapsed}>{section.title}</SectionTitle>
<SectionTitle $collapsed={collapsed}>
{section.title}
</SectionTitle>
{section.items.map((item) => renderNavItem(item))}
</Section>
))}
@@ -823,7 +836,9 @@ export function AppSidebar({
<FooterArea $collapsed={collapsed}>
{filteredFooterSections.map((section) => (
<Section key={section.id} $collapsed={collapsed}>
<SectionTitle $collapsed={collapsed}>{section.title}</SectionTitle>
<SectionTitle $collapsed={collapsed}>
{section.title}
</SectionTitle>
{section.items.map((item) => renderNavItem(item))}
</Section>
))}
+3 -1
View File
@@ -223,7 +223,9 @@ export function ModalFooter({
className?: string;
}) {
return (
<div className={`shrink-0 flex flex-wrap justify-end gap-2 border-t px-6 py-4 ${className}`}>
<div
className={`shrink-0 flex flex-wrap justify-end gap-2 border-t px-6 py-4 ${className}`}
>
{children}
</div>
);
@@ -271,7 +271,9 @@ function resolveTaskPreviewArtifact(
if (normalizedArtifactPath) {
const matchedArtifact = messageArtifacts.find(
(artifact) =>
!isHiddenInternalArtifactPath(resolveArtifactProtocolFilePath(artifact)) &&
!isHiddenInternalArtifactPath(
resolveArtifactProtocolFilePath(artifact),
) &&
(areArtifactProtocolPathsEquivalent(
resolveArtifactProtocolFilePath(artifact),
normalizedArtifactPath,
@@ -310,9 +312,7 @@ function normalizeVideoAspectRatio(
}
}
function normalizeVideoResolution(
value?: string,
): "480p" | "720p" | "1080p" {
function normalizeVideoResolution(value?: string): "480p" | "720p" | "1080p" {
switch (value) {
case "480p":
case "1080p":
@@ -527,7 +527,9 @@ export function AgentChatWorkspace({
let resolvedRootPath = defaultProject.rootPath;
try {
const ensuredWorkspace = await ensureWorkspaceReady(defaultProject.id);
const ensuredWorkspace = await ensureWorkspaceReady(
defaultProject.id,
);
resolvedRootPath = ensuredWorkspace.rootPath || resolvedRootPath;
} catch (error) {
logAgentDebug(
@@ -1024,7 +1026,9 @@ export function AgentChatWorkspace({
: theme
) as ThemeType;
const rawBody = content.body || "";
const sanitizedBody = isCorruptedGeneralWorkbenchDocumentContent(rawBody)
const sanitizedBody = isCorruptedGeneralWorkbenchDocumentContent(
rawBody,
)
? ""
: rawBody;
@@ -2173,8 +2177,8 @@ export function AgentChatWorkspace({
pendingActionRequest,
});
const generalWorkbenchSidebarRuntime = useWorkspaceGeneralWorkbenchSidebarRuntime(
{
const generalWorkbenchSidebarRuntime =
useWorkspaceGeneralWorkbenchSidebarRuntime({
isThemeWorkbench,
sessionId,
messages,
@@ -2182,8 +2186,7 @@ export function AgentChatWorkspace({
themeWorkbenchBackendRunState,
contextActivityLogs: contextWorkspace.activityLogs,
historyPageSize: GENERAL_WORKBENCH_HISTORY_PAGE_SIZE,
},
);
});
const { handleViewContextDetail } = useWorkspaceContextDetailActions({
contextWorkspace,
@@ -2460,12 +2463,10 @@ export function AgentChatWorkspace({
setCanvasState,
});
const submitImageWorkbenchAgentCommandRef =
useRef<
((
params: SubmitImageWorkbenchAgentCommandParams,
) => Promise<boolean>) | null
>(null);
const submitImageWorkbenchAgentCommandRef = useRef<
| ((params: SubmitImageWorkbenchAgentCommandParams) => Promise<boolean>)
| null
>(null);
const imageWorkbenchActionRuntime = useWorkspaceImageWorkbenchActionRuntime({
cancelImageTask: cancelMediaTaskArtifact,
contentId,
@@ -2572,7 +2573,11 @@ export function AgentChatWorkspace({
),
},
),
[effectiveChatToolPreferences.thinking, handleSendRef, webSearchPreferenceRef],
[
effectiveChatToolPreferences.thinking,
handleSendRef,
webSearchPreferenceRef,
],
);
submitImageWorkbenchAgentCommandRef.current =
submitImageWorkbenchAgentCommand;
@@ -2961,11 +2966,17 @@ export function AgentChatWorkspace({
if (normalizedArtifactPath) {
const matchedTaskFile = taskFiles.find(
(file) =>
areArtifactProtocolPathsEquivalent(file.name, normalizedArtifactPath) ||
areArtifactProtocolPathsEquivalent(
file.name,
normalizedArtifactPath,
) ||
normalizeArtifactProtocolPath(file.name) === normalizedArtifactPath,
);
if (matchedTaskFile?.content?.trim()) {
handleWorkspaceFileClick(matchedTaskFile.name, matchedTaskFile.content);
handleWorkspaceFileClick(
matchedTaskFile.name,
matchedTaskFile.content,
);
return;
}
}
@@ -3093,32 +3104,34 @@ export function AgentChatWorkspace({
hasRealTeamGraph: teamSessionRuntime.hasRealTeamGraph,
teamDispatchPreviewState,
});
const generalWorkbenchShellRuntime = useWorkspaceGeneralWorkbenchShellRuntime({
showChatPanel: effectiveShowChatPanel,
showSidebar,
hasPendingA2UIForm,
contextHarnessRuntime,
generalWorkbenchScaffoldRuntime,
generalWorkbenchSidebarRuntime,
harnessInventoryRuntime,
handleCreateVersionSnapshot,
handleSwitchBranchVersion,
handleSetBranchStatus,
handleAddImage,
handleImportDocument,
handleViewContextDetail,
messages,
harnessState,
compatSubagentRuntime,
childSubagentSessions,
selectedTeamLabel,
selectedTeamSummary,
selectedTeamRoles: selectedTeam?.roles,
teamMemorySnapshot: resolvedTeamMemoryShadowSnapshot,
handleOpenSubagentSession,
handleHarnessLoadFilePreview,
handleFileClick: handleWorkspaceFileClick,
});
const generalWorkbenchShellRuntime = useWorkspaceGeneralWorkbenchShellRuntime(
{
showChatPanel: effectiveShowChatPanel,
showSidebar,
hasPendingA2UIForm,
contextHarnessRuntime,
generalWorkbenchScaffoldRuntime,
generalWorkbenchSidebarRuntime,
harnessInventoryRuntime,
handleCreateVersionSnapshot,
handleSwitchBranchVersion,
handleSetBranchStatus,
handleAddImage,
handleImportDocument,
handleViewContextDetail,
messages,
harnessState,
compatSubagentRuntime,
childSubagentSessions,
selectedTeamLabel,
selectedTeamSummary,
selectedTeamRoles: selectedTeam?.roles,
teamMemorySnapshot: resolvedTeamMemoryShadowSnapshot,
handleOpenSubagentSession,
handleHarnessLoadFilePreview,
handleFileClick: handleWorkspaceFileClick,
},
);
useWorkspaceWorkflowProgressSync({
enabled: isSpecializedThemeMode && hasMessages && steps.length > 0,
@@ -3283,10 +3296,8 @@ export function AgentChatWorkspace({
const workspaceShellSceneRuntime = useWorkspaceConversationShellSceneRuntime({
messageListEmptyStateVariant:
agentEntry === "claw" ? "task-center" : "default",
navbarContextVariant:
agentEntry === "claw" ? "task-center" : "default",
sidebarContextVariant:
agentEntry === "claw" ? "task-center" : "default",
navbarContextVariant: agentEntry === "claw" ? "task-center" : "default",
sidebarContextVariant: agentEntry === "claw" ? "task-center" : "default",
navigationActions,
inputbarScene,
canvasScene,
@@ -16,7 +16,9 @@ function ensureSessionMutationAllowed(
return true;
}
notifyInfo(`当前仍有任务执行中,请先等待结束或停止生成后再执行 ${commandPrefix}`);
notifyInfo(
`当前仍有任务执行中,请先等待结束或停止生成后再执行 ${commandPrefix}`,
);
return false;
}
@@ -78,7 +80,9 @@ export async function executeCodexSlashCommand(
return true;
case "model":
if (command.userInput.trim()) {
notifyInfo("当前暂不支持通过 /model 切换模型,请使用输入框右侧的模型选择器");
notifyInfo(
"当前暂不支持通过 /model 切换模型,请使用输入框右侧的模型选择器",
);
return true;
}
appendAssistantMessage(buildCodexSlashModelMessage(statusSnapshot));
@@ -2,7 +2,10 @@ import {
getSupportedCodexSlashCommands,
getUnsupportedCodexSlashCommands,
} from "./parser";
import type { CodexSlashStatusSnapshot, ParsedCodexSlashCommand } from "./types";
import type {
CodexSlashStatusSnapshot,
ParsedCodexSlashCommand,
} from "./types";
function formatExecutionStrategyLabel(
strategy: CodexSlashStatusSnapshot["executionStrategy"],
@@ -1,9 +1,6 @@
import { A2UIRenderer } from "@/lib/workspace/a2ui";
import { cn } from "@/lib/utils";
import type {
A2UIFormData,
A2UIResponse,
} from "@/lib/workspace/a2ui";
import type { A2UIFormData, A2UIResponse } from "@/lib/workspace/a2ui";
import {
DEFAULT_A2UI_TASK_CARD_PRESET,
type A2UITaskCardPreset,
@@ -24,7 +24,10 @@ export const AgentPlanBlock: React.FC<AgentPlanBlockProps> = ({
<ListChecks className="h-4 w-4" />
</div>
<div className="text-sm font-medium text-foreground">执行计划</div>
<Badge variant={isComplete ? "outline" : "secondary"} className="ml-auto">
<Badge
variant={isComplete ? "outline" : "secondary"}
className="ml-auto"
>
{isComplete ? (
"已生成"
) : (
@@ -9,9 +9,7 @@ import type {
import type { ChatToolPreferences } from "../utils/chatToolPreferences";
import type { CompatSubagentRuntimeStatus } from "../utils/compatSubagentRuntime";
import type { HarnessSessionState } from "../utils/harnessState";
import {
getOutputSchemaRuntimeLabel,
} from "../utils/sessionExecutionRuntime";
import { getOutputSchemaRuntimeLabel } from "../utils/sessionExecutionRuntime";
interface AgentRuntimeStripProps {
activeTheme?: string;
@@ -84,7 +84,9 @@ export const AgentThreadOutcomeSummary: React.FC<
<div className="mt-2 inline-flex items-center gap-1 text-xs text-muted-foreground">
<RotateCcw className="h-3.5 w-3.5" />
<span>{outcome.retryable ? "建议可重试或恢复" : "当前无需人工恢复"}</span>
<span>
{outcome.retryable ? "建议可重试或恢复" : "当前无需人工恢复"}
</span>
</div>
</div>
);
@@ -180,7 +180,9 @@ describe("AgentThreadReliabilityPanel", () => {
expect(container.textContent).toContain("待处理请求");
expect(container.textContent).toContain("Provider 失败");
expect(container.textContent).toContain("审批等待超过阈值");
expect(container.textContent).toContain("审批等待过久,建议尽快处理或停止当前执行");
expect(container.textContent).toContain(
"审批等待过久,建议尽快处理或停止当前执行",
);
});
it("缺少 thread_read 时,应从当前 turn 与 pendingActions 推导并支持中断", async () => {
@@ -226,8 +228,8 @@ describe("AgentThreadReliabilityPanel", () => {
expect(container.textContent).toContain("等待人工处理");
expect(container.textContent).toContain("请确认是否继续发布");
const button = Array.from(container.querySelectorAll("button")).find((node) =>
node.textContent?.includes("停止当前执行"),
const button = Array.from(container.querySelectorAll("button")).find(
(node) => node.textContent?.includes("停止当前执行"),
);
expect(button).toBeDefined();
@@ -271,8 +273,8 @@ describe("AgentThreadReliabilityPanel", () => {
onInterruptCurrentTurn,
});
const button = Array.from(container.querySelectorAll("button")).find((node) =>
node.textContent?.includes("停止当前执行"),
const button = Array.from(container.querySelectorAll("button")).find(
(node) => node.textContent?.includes("停止当前执行"),
);
expect(button).toBeDefined();
@@ -460,7 +462,9 @@ describe("AgentThreadReliabilityPanel", () => {
});
expect(container.textContent).toContain("当前回合长时间无进展");
expect(container.textContent).toContain("当前回合长时间无进展,建议停止后恢复执行");
expect(container.textContent).toContain(
"当前回合长时间无进展,建议停止后恢复执行",
);
});
it("存在待处理请求时应支持重新拉起请求", async () => {
@@ -712,7 +716,9 @@ describe("AgentThreadReliabilityPanel", () => {
expect(container.textContent).toContain("快速复制给 AI");
expect(container.textContent).toContain("复制原始 JSON(debug)");
expect(container.textContent).toContain("外部分析交接");
expect(container.textContent).toContain("analysis-brief.md / analysis-context.json");
expect(container.textContent).toContain(
"analysis-brief.md / analysis-context.json",
);
});
it("应支持复制原始 JSON 诊断数据", async () => {
@@ -55,9 +55,7 @@ interface AgentThreadReliabilityPanelProps {
onResumeThread?: () => boolean | Promise<boolean>;
onReplayPendingRequest?: (requestId: string) => boolean | Promise<boolean>;
onLocatePendingRequest?: (requestId: string) => void;
onPromoteQueuedTurn?: (
queuedTurnId: string,
) => boolean | Promise<boolean>;
onPromoteQueuedTurn?: (queuedTurnId: string) => boolean | Promise<boolean>;
className?: string;
harnessState?: HarnessSessionState | null;
messages?: Message[];
@@ -76,7 +74,10 @@ function normalizeDiagnosticText(value?: string | null): string {
return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
}
function truncateDiagnosticText(value?: string | null, maxLength = 240): string {
function truncateDiagnosticText(
value?: string | null,
maxLength = 240,
): string {
const normalized = normalizeDiagnosticText(value);
if (!normalized) {
return "";
@@ -145,9 +146,14 @@ function summarizeRecentMessages(messages: Message[]) {
: null,
action_request_count: message.actionRequests?.length || 0,
action_request_titles:
message.actionRequests?.slice(0, 3).map((request) =>
truncateDiagnosticText(request.prompt || request.toolName || request.requestId, 120),
) || [],
message.actionRequests
?.slice(0, 3)
.map((request) =>
truncateDiagnosticText(
request.prompt || request.toolName || request.requestId,
120,
),
) || [],
tool_calls:
message.toolCalls?.slice(0, 4).map((toolCall) => ({
id: toolCall.id,
@@ -175,8 +181,12 @@ function summarizeHarnessState(harnessState?: HarnessSessionState | null) {
? {
phase: harnessState.runtimeStatus.phase,
title: harnessState.runtimeStatus.title,
detail: truncateDiagnosticText(harnessState.runtimeStatus.detail, 220),
checkpoints: harnessState.runtimeStatus.checkpoints?.slice(0, 6) || [],
detail: truncateDiagnosticText(
harnessState.runtimeStatus.detail,
220,
),
checkpoints:
harnessState.runtimeStatus.checkpoints?.slice(0, 6) || [],
metadata: harnessState.runtimeStatus.metadata || null,
}
: null,
@@ -214,23 +224,30 @@ function summarizeHarnessState(harnessState?: HarnessSessionState | null) {
truncated: signal.truncated || false,
offloaded: signal.offloaded || false,
})),
active_file_writes: harnessState.activeFileWrites.slice(0, 6).map((write) => ({
id: write.id,
path: write.path,
display_name: write.displayName,
phase: write.phase,
status: write.status,
preview: truncateDiagnosticText(write.preview || write.latestChunk, 160),
})),
recent_file_events: harnessState.recentFileEvents.slice(0, 8).map((event) => ({
id: event.id,
path: event.path,
display_name: event.displayName,
kind: event.kind,
action: event.action,
source_tool_name: event.sourceToolName,
preview: truncateDiagnosticText(event.preview, 140),
})),
active_file_writes: harnessState.activeFileWrites
.slice(0, 6)
.map((write) => ({
id: write.id,
path: write.path,
display_name: write.displayName,
phase: write.phase,
status: write.status,
preview: truncateDiagnosticText(
write.preview || write.latestChunk,
160,
),
})),
recent_file_events: harnessState.recentFileEvents
.slice(0, 8)
.map((event) => ({
id: event.id,
path: event.path,
display_name: event.displayName,
kind: event.kind,
action: event.action,
source_tool_name: event.sourceToolName,
preview: truncateDiagnosticText(event.preview, 140),
})),
};
}
@@ -441,7 +458,9 @@ function buildReliabilityDiagnosticText(params: {
sections.push("", "### 后端诊断聚合");
if (threadRead?.diagnostics) {
const diagnostics = threadRead.diagnostics;
sections.push(`- 最新回合状态:${diagnostics.latest_turn_status || "未知"}`);
sections.push(
`- 最新回合状态:${diagnostics.latest_turn_status || "未知"}`,
);
sections.push(
`- 最新回合开始时间:${diagnostics.latest_turn_started_at || "未知"}`,
);
@@ -461,7 +480,9 @@ function buildReliabilityDiagnosticText(params: {
`- 最新回合错误:${diagnostics.latest_turn_error_message || "无"}`,
);
sections.push(`- 中断原因:${diagnostics.interrupt_reason || "未知"}`);
sections.push(`- 中断来源:${diagnostics.runtime_interrupt_source || "未知"}`);
sections.push(
`- 中断来源:${diagnostics.runtime_interrupt_source || "未知"}`,
);
sections.push(
`- 中断请求时间:${diagnostics.runtime_interrupt_requested_at || "未知"}`,
);
@@ -480,8 +501,12 @@ function buildReliabilityDiagnosticText(params: {
sections.push(
`- 最老待处理请求等待时长(秒):${diagnostics.oldest_pending_request_wait_seconds ?? "无"}`,
);
sections.push(`- 主阻塞类型:${diagnostics.primary_blocking_kind || "未知"}`);
sections.push(`- 主阻塞摘要:${diagnostics.primary_blocking_summary || "未知"}`);
sections.push(
`- 主阻塞类型:${diagnostics.primary_blocking_kind || "未知"}`,
);
sections.push(
`- 主阻塞摘要:${diagnostics.primary_blocking_summary || "未知"}`,
);
sections.push(
`- 最近 warning:${
diagnostics.latest_warning
@@ -737,7 +762,9 @@ export const AgentThreadReliabilityPanel: React.FC<
);
toast.success("原始 JSON 已复制");
} catch (error) {
toast.error(error instanceof Error ? error.message : "复制原始 JSON 失败");
toast.error(
error instanceof Error ? error.message : "复制原始 JSON 失败",
);
}
};
@@ -773,11 +800,15 @@ export const AgentThreadReliabilityPanel: React.FC<
</span>
) : null}
</div>
<div className="mt-2 text-sm leading-6 text-muted-foreground">{summary}</div>
<div className="mt-2 text-sm leading-6 text-muted-foreground">
{summary}
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 text-xs text-muted-foreground">
{view.updatedAtLabel ? <span>最近刷新 {view.updatedAtLabel}</span> : null}
{view.updatedAtLabel ? (
<span>最近刷新 {view.updatedAtLabel}</span>
) : null}
{view.interruptStateLabel ? (
<Badge
variant="outline"
@@ -812,9 +843,10 @@ export const AgentThreadReliabilityPanel: React.FC<
</div>
<div className="mt-2 rounded-xl border border-amber-200 bg-amber-50/80 px-3 py-2 text-[11px] leading-5 text-amber-900">
当前入口属于 `compat` 线程级快速诊断,只覆盖当前 thread 的运行信号。
正式交给外部 Claude / Codex 分析时,请优先使用工作台“交接制品
→ 外部分析交接”的 `analysis-brief.md / analysis-context.json`
主链;这里的“快速复制给 AI”只适合临时排障,“复制原始 JSON(debug)”适合程序化分析、存档或二次处理。
正式交给外部 Claude / Codex 分析时,请优先使用工作台“交接制品 →
外部分析交接”的 `analysis-brief.md / analysis-context.json`
主链;这里的“快速复制给 AI”只适合临时排障,“复制原始
JSON(debug)”适合程序化分析、存档或二次处理。
</div>
<div className="mt-4 grid gap-2 md:grid-cols-3">
@@ -22,7 +22,10 @@ function resolveMockToolText(toolCall: {
let parsedArguments: Record<string, unknown> | null = null;
if (toolCall.arguments) {
try {
parsedArguments = JSON.parse(toolCall.arguments) as Record<string, unknown>;
parsedArguments = JSON.parse(toolCall.arguments) as Record<
string,
unknown
>;
} catch {
parsedArguments = null;
}
@@ -167,9 +170,7 @@ function at(second: number): string {
return `2026-03-15T09:10:${String(second).padStart(2, "0")}Z`;
}
function createTurn(
overrides?: Partial<AgentThreadTurn>,
): AgentThreadTurn {
function createTurn(overrides?: Partial<AgentThreadTurn>): AgentThreadTurn {
return {
id: "turn-1",
thread_id: "thread-1",
@@ -384,7 +385,7 @@ describe("AgentThreadTimeline", () => {
createFileArtifactItem({
id: "artifact-hidden-task-json",
path: ".lime/tasks/image_generate/task-image-1.json",
content: "{\"status\":\"running\"}",
content: '{"status":"running"}',
metadata: {},
}),
]);
@@ -515,9 +516,7 @@ describe("AgentThreadTimeline", () => {
.map((node) => node.dataset.testid)
.filter((value): value is string => Boolean(value))
.filter(
(value) =>
!value.endsWith(":shell") &&
!value.endsWith(":details"),
(value) => !value.endsWith(":shell") && !value.endsWith(":details"),
);
expect(blockIds).toEqual(["agent-thread-block:1:process"]);
@@ -552,7 +551,9 @@ describe("AgentThreadTimeline", () => {
expect(summary?.textContent).toContain("2 步");
expect(summary?.textContent).toContain("2 个工具步骤");
expect(container.querySelectorAll('[data-testid="tool-call-item"]')).toHaveLength(0);
expect(
container.querySelectorAll('[data-testid="tool-call-item"]'),
).toHaveLength(0);
act(() => {
summary?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -791,14 +792,19 @@ describe("AgentThreadTimeline", () => {
});
expect(
container.querySelector('[data-testid="agent-thread-block:1:process:details"]'),
container.querySelector(
'[data-testid="agent-thread-block:1:process:details"]',
),
).toBeNull();
expect(container.textContent).not.toContain("思考摘要");
expect((container.textContent?.split(reasoningText).length ?? 1) - 1).toBe(1);
expect((container.textContent?.split(reasoningText).length ?? 1) - 1).toBe(
1,
);
});
it("已完成的思考应默认折叠,只保留摘要行", () => {
const reasoningText = "先核对执行链路,再立即恢复当前运行。\n随后补齐自动续提。";
const reasoningText =
"先核对执行链路,再立即恢复当前运行。\n随后补齐自动续提。";
const items: AgentThreadItem[] = [
{
...createBaseItem("reasoning-1", 1),
@@ -820,7 +826,9 @@ describe("AgentThreadTimeline", () => {
expect(block?.open).toBe(false);
expect(summary?.textContent).toContain("已完成思考");
expect(summary?.textContent).toContain("先核对执行链路,再立即恢复当前运行。");
expect(summary?.textContent).toContain(
"先核对执行链路,再立即恢复当前运行。",
);
expect(container.textContent).not.toContain("随后补齐自动续提。");
act(() => {
@@ -914,8 +922,12 @@ describe("AgentThreadTimeline", () => {
summary?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect((container.textContent?.split("先判断任务类型").length ?? 1) - 1).toBe(1);
expect((container.textContent?.split("再决定是否联网").length ?? 1) - 1).toBe(1);
expect(
(container.textContent?.split("先判断任务类型").length ?? 1) - 1,
).toBe(1);
expect(
(container.textContent?.split("再决定是否联网").length ?? 1) - 1,
).toBe(1);
});
it("已完成的 request_user_input 应以只读 A2UI 卡片回显", () => {
@@ -945,7 +957,9 @@ describe("AgentThreadTimeline", () => {
expect(
container.querySelector('[data-testid="timeline-a2ui-card"]'),
).not.toBeNull();
expect(container.querySelector('[data-testid="decision-panel"]')).toBeNull();
expect(
container.querySelector('[data-testid="decision-panel"]'),
).toBeNull();
});
it("真实协作成员 item 应支持查看协作详情", () => {
@@ -273,7 +273,8 @@ function toToolCallState(item: AgentThreadItem): ToolCallState | null {
item.error !== undefined ||
item.exit_code !== undefined
? {
success: item.status === "completed" && item.error === undefined,
success:
item.status === "completed" && item.error === undefined,
output: item.aggregated_output || "",
error: item.error,
metadata:
@@ -405,61 +406,61 @@ function ThinkingItemCard({
() => parseAIResponse(displayText, false),
[displayText],
);
const hasStructuredPreview = parsedContent.hasA2UI || parsedContent.hasPending;
const hasStructuredPreview =
parsedContent.hasA2UI || parsedContent.hasPending;
const shouldHideTurnSummaryContent =
item.type === "turn_summary" &&
!hasStructuredPreview &&
isInternalRoutingTurnSummaryText(displayText);
const content =
hasStructuredPreview ? (
<div className="space-y-3">
{parsedContent.parts.map((part, index) => {
if (part.type === "a2ui" && typeof part.content !== "string") {
const readonlyResponse: A2UIResponse = {
...part.content,
submitAction: undefined,
};
return (
<A2UITaskCard
key={`timeline-a2ui-${index}`}
response={readonlyResponse}
compact={true}
preview={true}
preset={TIMELINE_A2UI_TASK_CARD_PRESET}
/>
);
}
if (part.type === "pending_a2ui") {
return (
<A2UITaskLoadingCard
key={`timeline-pending-a2ui-${index}`}
compact={true}
preset={TIMELINE_A2UI_TASK_CARD_PRESET}
subtitle="这一步还在整理,稍等一下。"
/>
);
}
const textContent =
typeof part.content === "string" ? part.content.trim() : "";
if (!textContent) {
return null;
}
const content = hasStructuredPreview ? (
<div className="space-y-3">
{parsedContent.parts.map((part, index) => {
if (part.type === "a2ui" && typeof part.content !== "string") {
const readonlyResponse: A2UIResponse = {
...part.content,
submitAction: undefined,
};
return (
<MarkdownRenderer
key={`timeline-text-${index}`}
content={textContent}
<A2UITaskCard
key={`timeline-a2ui-${index}`}
response={readonlyResponse}
compact={true}
preview={true}
preset={TIMELINE_A2UI_TASK_CARD_PRESET}
/>
);
})}
</div>
) : shouldHideTurnSummaryContent ? null : (
<MarkdownRenderer content={displayText} />
);
}
if (part.type === "pending_a2ui") {
return (
<A2UITaskLoadingCard
key={`timeline-pending-a2ui-${index}`}
compact={true}
preset={TIMELINE_A2UI_TASK_CARD_PRESET}
subtitle="这一步还在整理,稍等一下。"
/>
);
}
const textContent =
typeof part.content === "string" ? part.content.trim() : "";
if (!textContent) {
return null;
}
return (
<MarkdownRenderer
key={`timeline-text-${index}`}
content={textContent}
/>
);
})}
</div>
) : shouldHideTurnSummaryContent ? null : (
<MarkdownRenderer content={displayText} />
);
const statusLabel =
item.type === "turn_summary"
? item.status === "in_progress"
@@ -577,7 +578,12 @@ function InlinePlanBlock({
function renderThinkingItemDetails(item: AgentThreadItem) {
if (item.type === "plan") {
return <InlinePlanBlock content={item.text} isComplete={item.status !== "in_progress"} />;
return (
<InlinePlanBlock
content={item.text}
isComplete={item.status !== "in_progress"}
/>
);
}
if (item.type === "reasoning" || item.type === "turn_summary") {
@@ -613,10 +619,12 @@ function isToolExecutionTimelineItem(item: AgentThreadItem): boolean {
);
}
function extractCompactThinkingParts(item: Extract<
AgentThreadItem,
{ type: "plan" | "reasoning" | "turn_summary" | "context_compaction" }
>) {
function extractCompactThinkingParts(
item: Extract<
AgentThreadItem,
{ type: "plan" | "reasoning" | "turn_summary" | "context_compaction" }
>,
) {
if (item.type === "context_compaction") {
const title =
item.stage === "completed" || item.status === "completed"
@@ -638,15 +646,20 @@ function extractCompactThinkingParts(item: Extract<
}
if (item.type === "reasoning") {
const { summaryText, bodyText, combinedText } = resolveReasoningDisplayText(item);
const { summaryText, bodyText, combinedText } =
resolveReasoningDisplayText(item);
const previewSource = summaryText || combinedText;
const lines = previewSource
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const [title = item.status === "in_progress" ? "思考中" : "已完成思考", ...rest] =
lines;
const detail = [rest.join("\n").trim(), bodyText].filter(Boolean).join("\n\n");
const [
title = item.status === "in_progress" ? "思考中" : "已完成思考",
...rest
] = lines;
const detail = [rest.join("\n").trim(), bodyText]
.filter(Boolean)
.join("\n\n");
const parsed = parseAIResponse(combinedText, false);
if (parsed.hasA2UI || parsed.hasPending) {
@@ -886,7 +899,10 @@ function renderGroupItemDetails(
<div className="py-1.5">
<div className="mb-2 flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{item.type}</span>
<Badge variant={resolveStatusBadgeVariant(item.status)} className="ml-auto">
<Badge
variant={resolveStatusBadgeVariant(item.status)}
className="ml-auto"
>
{resolveItemStatusLabel(item.status)}
</Badge>
</div>
@@ -911,7 +927,9 @@ function renderTimelineItemDetails(
) {
if (isThinkingTimelineItem(item)) {
if (options?.groupedToolCall) {
return <GroupedThinkingRow item={item} groupMarker={options.groupMarker} />;
return (
<GroupedThinkingRow item={item} groupMarker={options.groupMarker} />
);
}
return renderThinkingItemDetails(item);
}
@@ -927,7 +945,9 @@ function renderTimelineItemDetails(
);
}
function resolveCompactTechnicalSummary(block: AgentThreadOrderedBlock): string {
function resolveCompactTechnicalSummary(
block: AgentThreadOrderedBlock,
): string {
return `处理了 ${block.items.length} 个步骤`;
}
@@ -1060,23 +1080,41 @@ function normalizeBlockPreviewLine(
if (
kind === "artifact" &&
!hasAnyPrefix(trimmed, ["看了 ", "读了 ", "写了 ", "改了 ", "动了 ", "产出了 "])
!hasAnyPrefix(trimmed, [
"看了 ",
"读了 ",
"写了 ",
"改了 ",
"动了 ",
"产出了 ",
])
) {
return `产出了 ${trimmed}`;
}
if (
kind === "approval" &&
!hasAnyPrefix(trimmed, ["等你补充:", "等你确认:", "等你补充信息", "等你确认这一步"])
!hasAnyPrefix(trimmed, [
"等你补充:",
"等你确认:",
"等你补充信息",
"等你确认这一步",
])
) {
return `等你确认:${trimmed}`;
}
if (kind === "alert" && !hasAnyPrefix(trimmed, ["收到提醒:", "碰到错误:"])) {
if (
kind === "alert" &&
!hasAnyPrefix(trimmed, ["收到提醒:", "碰到错误:"])
) {
return `收到提醒:${trimmed}`;
}
if (kind === "subagent" && !hasAnyPrefix(trimmed, ["分给协作成员", "协作成员"])) {
if (
kind === "subagent" &&
!hasAnyPrefix(trimmed, ["分给协作成员", "协作成员"])
) {
return `分给协作成员处理 ${trimmed}`;
}
@@ -1136,7 +1174,9 @@ function resolveBlockSummaryLines(block: AgentThreadOrderedBlock): string[] {
}
if (block.kind === "subagent") {
return [block.status === "completed" ? "协作成员处理完了" : "协作成员在处理"];
return [
block.status === "completed" ? "协作成员处理完了" : "协作成员在处理",
];
}
if (block.kind === "other") {
@@ -1385,8 +1425,7 @@ function TimelineBlockCard({
const visibleHeadline = headline;
const visibleSupportingLines =
isThinkingOnlyBlock && open ? [] : supportingLines;
const summaryCountLabel =
block.items.length > 1 ? block.countLabel : null;
const summaryCountLabel = block.items.length > 1 ? block.countLabel : null;
const processMixLabel = resolveProcessMixLabel(block);
const summaryDetailHint =
hasDetailEntries && block.items.length > 1 && !open
@@ -1395,13 +1434,23 @@ function TimelineBlockCard({
const summaryToneClassName = cn(
"text-slate-900",
block.status === "in_progress" && "text-sky-700",
block.kind === "approval" && block.status !== "completed" && "text-amber-800",
block.kind === "approval" &&
block.status !== "completed" &&
"text-amber-800",
(block.status === "failed" || block.kind === "alert") && "text-rose-700",
);
return (
<div className="py-0.5" data-testid={`${dataTestId}:shell`} data-emphasis={emphasis}>
<details data-testid={dataTestId} data-emphasis={emphasis} open={hasDetailEntries ? open : true}>
<div
className="py-0.5"
data-testid={`${dataTestId}:shell`}
data-emphasis={emphasis}
>
<details
data-testid={dataTestId}
data-emphasis={emphasis}
open={hasDetailEntries ? open : true}
>
<summary
className={cn(
"list-none rounded-md px-2 py-1.5",
@@ -1516,7 +1565,10 @@ export const AgentThreadTimeline: React.FC<AgentThreadTimelineProps> = ({
(item) =>
item.type !== "user_message" &&
item.type !== "agent_message" &&
!(item.type === "file_artifact" && isHiddenInternalArtifactPath(item.path)),
!(
item.type === "file_artifact" &&
isHiddenInternalArtifactPath(item.path)
),
),
[items],
);
@@ -1553,11 +1605,13 @@ export const AgentThreadTimeline: React.FC<AgentThreadTimelineProps> = ({
data-testid="agent-thread-flow"
data-placement={placement}
>
{inlineStatusHint ? <ThreadInlineStatusHint hint={inlineStatusHint} /> : null}
{inlineStatusHint ? (
<ThreadInlineStatusHint hint={inlineStatusHint} />
) : null}
{displayModel.orderedBlocks.map((block, index) => {
const blockHasFocusedItem = Boolean(
focusedItemId &&
block.items.some((item) => item.id === focusedItemId),
block.items.some((item) => item.id === focusedItemId),
);
return (
@@ -135,7 +135,9 @@ describe("AgentThreadTimelineArtifactCard", () => {
expect(container.textContent).toContain("2 个区块");
expect(container.textContent).toContain("1 条来源");
expect(container.textContent).toContain("V2");
expect(container.textContent).toContain("定位到 本轮重点是补齐来源线索与交付节奏。");
expect(container.textContent).toContain(
"定位到 本轮重点是补齐来源线索与交付节奏。",
);
expect(container.textContent).not.toContain("artifact_document_service");
expect(container.textContent).not.toContain("schemaVersion");
expect(container.textContent).not.toContain('"artifactId"');
@@ -155,7 +157,9 @@ describe("AgentThreadTimelineArtifactCard", () => {
);
expect(container.textContent).toContain("已同步");
expect(container.textContent).toContain("包含结构化结果,点击在画布中查看完整内容。");
expect(container.textContent).toContain(
"包含结构化结果,点击在画布中查看完整内容。",
);
expect(container.textContent).not.toContain('"queue"');
expect(container.textContent).not.toContain('"retryable"');
});
@@ -145,7 +145,9 @@ function resolveBlockLabel(
normalizeText(record.text) ||
normalizeText(record.markdown);
return label ? truncateInlineText(label, 20) : fallbackByType[block.type] || blockId;
return label
? truncateInlineText(label, 20)
: fallbackByType[block.type] || blockId;
}
function resolveFallbackPreview(content: string | undefined): string | null {
@@ -24,21 +24,23 @@ type MockResizeObserverCallback = (
observer: unknown,
) => void;
const { mockListDirectory, mockToast, resizeObserverState } = vi.hoisted(() => ({
mockListDirectory: vi.fn(),
mockToast: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
},
resizeObserverState: {
width: 1280,
observers: [] as Array<{
callback: MockResizeObserverCallback;
target: Element | null;
}>,
},
}));
const { mockListDirectory, mockToast, resizeObserverState } = vi.hoisted(
() => ({
mockListDirectory: vi.fn(),
mockToast: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
},
resizeObserverState: {
width: 1280,
observers: [] as Array<{
callback: MockResizeObserverCallback;
target: Element | null;
}>,
},
}),
);
vi.mock("sonner", () => ({
toast: mockToast,
@@ -387,7 +389,8 @@ beforeEach(() => {
disconnect = () => {
resizeObserverState.observers = resizeObserverState.observers.filter(
(observer) =>
observer.callback !== this.callback || observer.target !== this.target,
observer.callback !== this.callback ||
observer.target !== this.target,
);
};
},
@@ -537,9 +540,9 @@ describe("CanvasWorkbenchLayout", () => {
await flushEffects();
expect(mockListDirectory).toHaveBeenCalledWith("/workspace");
expect(container.querySelector('[data-testid="preview-panel"]')?.textContent).toContain(
"default-canvas:draft.md",
);
expect(
container.querySelector('[data-testid="preview-panel"]')?.textContent,
).toContain("default-canvas:draft.md");
clickButtonByLabel(container, "切换画布标签-变更");
await flushEffects();
@@ -558,9 +561,9 @@ describe("CanvasWorkbenchLayout", () => {
clickButtonByLabel(container, "切换画布标签-产物");
clickButtonByLabel(container, "选择画布产物-draft.md");
await flushEffects();
expect(container.querySelector('[data-testid="preview-panel"]')?.textContent).toContain(
"artifact:draft.md",
);
expect(
container.querySelector('[data-testid="preview-panel"]')?.textContent,
).toContain("artifact:draft.md");
clickButtonByLabel(container, "切换画布标签-全部文件");
await flushEffects();
@@ -639,15 +642,15 @@ describe("CanvasWorkbenchLayout", () => {
container.querySelector('button[aria-label="展开当前文稿检查器"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="canvas-workbench-document-inspector"]'),
container.querySelector(
'[data-testid="canvas-workbench-document-inspector"]',
),
).toBeNull();
expect(container.textContent).toContain("当前文稿");
expect(container.textContent).toContain("统一在右侧切换产物与版本");
expect(container.textContent).toContain("董事会季度复盘");
expect(container.textContent).toContain("需要优先补齐来源与版本线索。");
expect(container.textContent).toContain(
"默认先收起概览、来源、版本与编辑",
);
expect(container.textContent).toContain("默认先收起概览、来源、版本与编辑");
clickButtonByLabel(container, "展开当前文稿检查器");
await flushEffects();
@@ -656,7 +659,9 @@ describe("CanvasWorkbenchLayout", () => {
container.querySelector('button[aria-label="折叠当前文稿检查器"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="canvas-workbench-document-inspector"]'),
container.querySelector(
'[data-testid="canvas-workbench-document-inspector"]',
),
).not.toBeNull();
});
@@ -746,7 +751,9 @@ describe("CanvasWorkbenchLayout", () => {
await flushEffects();
expect(container.querySelector('[data-testid="team-preview"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="team-preview"]'),
).not.toBeNull();
expect(container.querySelector('[data-testid="team-panel"]')).toBeNull();
expect(
container.querySelector('button[aria-label="展开画布工作台"]'),
@@ -759,9 +766,7 @@ describe("CanvasWorkbenchLayout", () => {
it("teamView 的 autoFocusToken 变化时应切到 Team Workbench", async () => {
const renderPreview = vi.fn((target: CanvasWorkbenchPreviewTarget) => (
<div data-testid="fallback-preview">
fallback:{target.kind}
</div>
<div data-testid="fallback-preview">fallback:{target.kind}</div>
));
const renderTeamPreview = vi.fn(
(_options?: { stackedWorkbenchTrigger?: React.ReactNode }) => (
@@ -811,9 +816,15 @@ describe("CanvasWorkbenchLayout", () => {
const harness = mountHarness(baseProps);
await flushEffects();
expect(harness.container.querySelector('[data-testid="team-preview"]')).toBeNull();
expect(harness.container.querySelector('[data-testid="team-panel"]')).toBeNull();
expect(harness.container.querySelector('[data-testid="fallback-preview"]')).not.toBeNull();
expect(
harness.container.querySelector('[data-testid="team-preview"]'),
).toBeNull();
expect(
harness.container.querySelector('[data-testid="team-panel"]'),
).toBeNull();
expect(
harness.container.querySelector('[data-testid="fallback-preview"]'),
).not.toBeNull();
harness.rerender({
...baseProps,
@@ -824,8 +835,12 @@ describe("CanvasWorkbenchLayout", () => {
});
await flushEffects();
expect(harness.container.querySelector('[data-testid="team-preview"]')).not.toBeNull();
expect(harness.container.querySelector('[data-testid="team-panel"]')).not.toBeNull();
expect(
harness.container.querySelector('[data-testid="team-preview"]'),
).not.toBeNull();
expect(
harness.container.querySelector('[data-testid="team-panel"]'),
).not.toBeNull();
expect(harness.container.textContent).toContain("多成员实时协作");
});
@@ -1006,7 +1021,9 @@ describe("CanvasWorkbenchLayout", () => {
await flushEffects();
expect(container.querySelector('[data-testid="team-preview"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="team-preview"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="canvas-workbench-layout"]'),
).toBeNull();
@@ -1055,7 +1072,9 @@ describe("CanvasWorkbenchLayout", () => {
.querySelector('[data-testid="canvas-workbench-layout"]')
?.getAttribute("data-panel-placement"),
).toBe("side");
expect(container.querySelector('[data-testid="canvas-workbench-trigger"]')).toBeNull();
expect(
container.querySelector('[data-testid="canvas-workbench-trigger"]'),
).toBeNull();
expect(container.querySelector('[data-testid="team-panel"]')).toBeNull();
expect(
container.querySelector('button[aria-label="展开画布工作台"]'),
@@ -167,7 +167,9 @@ export interface CanvasWorkbenchTeamView {
tone: "idle" | "active" | "error";
label?: string | null;
} | null;
renderPreview: (options?: { stackedWorkbenchTrigger?: ReactNode }) => ReactNode;
renderPreview: (options?: {
stackedWorkbenchTrigger?: ReactNode;
}) => ReactNode;
renderPanel?: () => ReactNode;
renderFooter?: () => ReactNode;
}
@@ -231,12 +233,13 @@ function resolveStackedWorkbenchMetrics(shellWidth: number): {
maxWidth: number;
defaultWidth: number;
} {
const safeShellWidth = shellWidth > 0 ? shellWidth : STACKED_LAYOUT_BREAKPOINT;
const minWidth = Math.max(280, Math.min(340, Math.round(safeShellWidth * 0.36)));
const maxWidth = Math.max(
minWidth + 40,
Math.min(480, safeShellWidth - 28),
const safeShellWidth =
shellWidth > 0 ? shellWidth : STACKED_LAYOUT_BREAKPOINT;
const minWidth = Math.max(
280,
Math.min(340, Math.round(safeShellWidth * 0.36)),
);
const maxWidth = Math.max(minWidth + 40, Math.min(480, safeShellWidth - 28));
const defaultWidth = clamp(
Math.round(safeShellWidth * 0.42),
minWidth,
@@ -314,7 +317,9 @@ function resolvePreviousArtifactContent(
if (candidate.id === artifact.id) {
continue;
}
const candidatePath = normalizePath(resolveArtifactProtocolFilePath(candidate));
const candidatePath = normalizePath(
resolveArtifactProtocolFilePath(candidate),
);
if (candidatePath === currentPath && candidate.content.trim()) {
return candidate.content;
}
@@ -342,10 +347,15 @@ function resolveMappedPreviousContentForPath(
workspaceRoot,
version.metadata?.sourceFileName,
);
return versionPath ? normalizePath(versionPath) === normalizedTarget : false;
return versionPath
? normalizePath(versionPath) === normalizedTarget
: false;
});
if (matchedVersion) {
return resolvePreviousVersionContent(matchedVersion, canvasState.versions);
return resolvePreviousVersionContent(
matchedVersion,
canvasState.versions,
);
}
}
@@ -354,7 +364,9 @@ function resolveMappedPreviousContentForPath(
workspaceRoot,
resolveArtifactProtocolFilePath(artifact),
);
return artifactPath ? normalizePath(artifactPath) === normalizedTarget : false;
return artifactPath
? normalizePath(artifactPath) === normalizedTarget
: false;
});
return matchedArtifact
@@ -384,7 +396,9 @@ function buildEntries(
absolutePath: resolveAbsoluteWorkspacePath(workspaceRoot, filePath),
previewText: resolveArtifactPreviewText(artifact),
createdAt: artifact.updatedAt || artifact.createdAt,
badgeLabel: writePhase ? formatArtifactWritePhaseLabel(writePhase) : undefined,
badgeLabel: writePhase
? formatArtifactWritePhaseLabel(writePhase)
: undefined,
kindLabel: "产物",
};
});
@@ -410,7 +424,8 @@ function buildEntries(
previewText: version.content.trim().slice(0, 180),
createdAt: version.createdAt,
isCurrent: version.id === canvasState.currentVersionId,
badgeLabel: version.id === canvasState.currentVersionId ? "当前" : undefined,
badgeLabel:
version.id === canvasState.currentVersionId ? "当前" : undefined,
kindLabel: "版本",
})),
);
@@ -427,7 +442,10 @@ function buildEntries(
title: extractFileNameFromPath(taskFile.name),
subtitle: taskFile.name,
filePath: taskFile.name,
absolutePath: resolveAbsoluteWorkspacePath(workspaceRoot, taskFile.name),
absolutePath: resolveAbsoluteWorkspacePath(
workspaceRoot,
taskFile.name,
),
previewText: taskFile.content?.trim().slice(0, 180),
createdAt: taskFile.updatedAt,
badgeLabel: taskFile.type === "document" ? "文档" : undefined,
@@ -445,9 +463,7 @@ function buildEntries(
});
}
function renderDiffState(
diffLines: CanvasWorkbenchDiffLine[],
): ReactNode {
function renderDiffState(diffLines: CanvasWorkbenchDiffLine[]): ReactNode {
return (
<div className={cn("overflow-hidden", WORKBENCH_PANEL_CLASSNAME)}>
<div className="max-h-[28rem] overflow-auto">
@@ -464,7 +480,9 @@ function renderDiffState(
<span className="select-none text-center">
{line.type === "add" ? "+" : line.type === "remove" ? "-" : " "}
</span>
<span className="whitespace-pre-wrap break-all">{line.value || " "}</span>
<span className="whitespace-pre-wrap break-all">
{line.value || " "}
</span>
</div>
))}
</div>
@@ -487,36 +505,42 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
onLayoutModeChange,
teamView = null,
}: CanvasWorkbenchLayoutProps) {
const shouldPreferTeamTabByDefault = teamView?.enabled === true && !defaultPreview;
const shouldPreferTeamTabByDefault =
teamView?.enabled === true && !defaultPreview;
const shellRef = useRef<HTMLDivElement | null>(null);
const stackedResizeCleanupRef = useRef<(() => void) | null>(null);
const [activeTab, setActiveTab] = useState<CanvasWorkbenchTab>(() =>
shouldPreferTeamTabByDefault ? "team" : "artifacts",
);
const [collapsed, setCollapsed] = useState(() => shouldPreferTeamTabByDefault);
const [collapsed, setCollapsed] = useState(
() => shouldPreferTeamTabByDefault,
);
const [isStackedLayout, setIsStackedLayout] = useState(false);
const [stackedWorkbenchOpen, setStackedWorkbenchOpen] = useState(false);
const [shellWidth, setShellWidth] = useState(STACKED_LAYOUT_BREAKPOINT);
const [stackedWorkbenchWidth, setStackedWorkbenchWidth] = useState<number | null>(
null,
);
const [documentInspectorCollapsed, setDocumentInspectorCollapsed] = useState(true);
const [stackedWorkbenchWidth, setStackedWorkbenchWidth] = useState<
number | null
>(null);
const [documentInspectorCollapsed, setDocumentInspectorCollapsed] =
useState(true);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [artifactDocumentController, setArtifactDocumentController] =
useState<ArtifactWorkbenchDocumentController | null>(null);
const [directoryCache, setDirectoryCache] = useState<Record<string, DirectoryListing>>(
{},
);
const [loadingDirectories, setLoadingDirectories] = useState<Record<string, boolean>>(
{},
);
const [expandedDirectories, setExpandedDirectories] = useState<Record<string, boolean>>(
{},
);
const [directoryCache, setDirectoryCache] = useState<
Record<string, DirectoryListing>
>({});
const [loadingDirectories, setLoadingDirectories] = useState<
Record<string, boolean>
>({});
const [expandedDirectories, setExpandedDirectories] = useState<
Record<string, boolean>
>({});
const [workspaceFileSelections, setWorkspaceFileSelections] = useState<
Record<string, WorkspaceFileSelection>
>({});
const availableTabs = useMemo<Array<{ key: CanvasWorkbenchTab; label: string }>>(
const availableTabs = useMemo<
Array<{ key: CanvasWorkbenchTab; label: string }>
>(
() => [
...(teamView?.enabled ? [{ key: "team" as const, label: "团队" }] : []),
{ key: "artifacts" as const, label: "产物" },
@@ -559,7 +583,10 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
}, [defaultPreview, entries, entryMap, selectedFileId]);
useEffect(() => {
if (selectedKey && (entryMap.has(selectedKey) || selectedKey.startsWith("workspace-file:"))) {
if (
selectedKey &&
(entryMap.has(selectedKey) || selectedKey.startsWith("workspace-file:"))
) {
return;
}
setSelectedKey(fallbackEntryKey);
@@ -593,12 +620,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
return;
}
void loadDirectory(workspaceRoot);
}, [
directoryCache,
loadDirectory,
workspaceRoot,
workspaceUnavailable,
]);
}, [directoryCache, loadDirectory, workspaceRoot, workspaceUnavailable]);
useEffect(() => {
const node = shellRef.current;
@@ -615,7 +637,9 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
};
const fallbackWidth =
node.getBoundingClientRect().width || node.clientWidth || window.innerWidth;
node.getBoundingClientRect().width ||
node.clientWidth ||
window.innerWidth;
updateLayout(fallbackWidth);
if (typeof ResizeObserver === "undefined") {
@@ -717,11 +741,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
setStackedWorkbenchOpen(true);
emitCompactRightPanelOpen({ source: "workbench" });
}
}, [
teamView?.autoFocusToken,
teamView?.enabled,
usesStackedWorkbenchLayout,
]);
}, [teamView?.autoFocusToken, teamView?.enabled, usesStackedWorkbenchLayout]);
const handleToggleDirectory = useCallback(
(path: string) => {
@@ -797,7 +817,9 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
);
const effectiveKey = selectedKey || fallbackEntryKey;
const selectedEntry = effectiveKey ? entryMap.get(effectiveKey) || null : null;
const selectedEntry = effectiveKey
? entryMap.get(effectiveKey) || null
: null;
const selectedWorkspaceFile = effectiveKey?.startsWith("workspace-file:")
? workspaceFileSelections[effectiveKey] || null
: null;
@@ -938,7 +960,13 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
kind: "empty",
title: "暂无可预览内容",
};
}, [activeTab, defaultPreview, selectedEntry, selectedWorkspaceFile, teamView]);
}, [
activeTab,
defaultPreview,
selectedEntry,
selectedWorkspaceFile,
teamView,
]);
const currentContent = useMemo(() => {
if (currentTarget.kind === "default-canvas") {
@@ -979,8 +1007,14 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
return resolvePreviousArtifactContent(selectedEntry.artifact, artifacts);
}
if (selectedEntry.source === "document-version" && isDocumentCanvasState(canvasState)) {
return resolvePreviousVersionContent(selectedEntry.version, canvasState.versions);
if (
selectedEntry.source === "document-version" &&
isDocumentCanvasState(canvasState)
) {
return resolvePreviousVersionContent(
selectedEntry.version,
canvasState.versions,
);
}
if (selectedEntry.absolutePath) {
@@ -1030,9 +1064,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
await navigator.clipboard.writeText(selectionPath);
toast.success("已复制路径");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "复制路径失败",
);
toast.error(error instanceof Error ? error.message : "复制路径失败");
}
}, [selectionPath]);
@@ -1056,7 +1088,8 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
}
const showDocumentInspector = Boolean(
selectedEntry?.source === "artifact" && artifactDocumentController?.document,
selectedEntry?.source === "artifact" &&
artifactDocumentController?.document,
);
const documentTitle =
artifactDocumentController?.document?.title?.trim() ||
@@ -1142,7 +1175,12 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
</section>
{showDocumentInspector && artifactDocumentController ? (
<section className={cn(WORKBENCH_PANEL_CLASSNAME, "overflow-hidden bg-slate-50/80")}>
<section
className={cn(
WORKBENCH_PANEL_CLASSNAME,
"overflow-hidden bg-slate-50/80",
)}
>
<button
type="button"
aria-label={documentInspectorButtonLabel}
@@ -1258,7 +1296,9 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
) : (
<Folder className="h-4 w-4 shrink-0 text-amber-600" />
)
) : entry.name.match(/\.(ts|tsx|js|jsx|rs|json|yml|yaml|toml)$/i) ? (
) : entry.name.match(
/\.(ts|tsx|js|jsx|rs|json|yml|yaml|toml)$/i,
) ? (
<FileCode2 className="h-4 w-4 shrink-0 text-sky-600" />
) : (
<FileText className="h-4 w-4 shrink-0 text-slate-500" />
@@ -1268,7 +1308,9 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
) : null}
</button>
{isDirectory && isExpanded ? renderDirectoryNode(entry.path, depth + 1) : null}
{isDirectory && isExpanded
? renderDirectoryNode(entry.path, depth + 1)
: null}
</div>
);
});
@@ -1299,7 +1341,9 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
<div className="text-xs font-medium uppercase tracking-[0.08em] text-slate-500">
Workspace Root
</div>
<div className="mt-1 truncate text-sm text-foreground">{workspaceRoot}</div>
<div className="mt-1 truncate text-sm text-foreground">
{workspaceRoot}
</div>
</div>
<button
type="button"
@@ -1400,13 +1444,11 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
return teamView.renderPanel ? (
teamView.renderPanel()
) : (
<div className={WORKBENCH_MUTED_PANEL_CLASSNAME}>
团队工作台已启用。
</div>
<div className={WORKBENCH_MUTED_PANEL_CLASSNAME}>团队工作台已启用。</div>
);
};
const renderTabButtons = (stacked: boolean) => (
const renderTabButtons = (stacked: boolean) =>
stacked ? (
<div className="mt-3 flex gap-2 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{availableTabs.map((tab) => (
@@ -1453,8 +1495,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
</button>
))}
</div>
)
);
);
const renderWorkbenchHeader = (
stacked: boolean,
@@ -1463,7 +1504,9 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
},
) => {
const headerTitle =
activeTab === "team" ? teamView?.title || currentTarget.title : currentTarget.title;
activeTab === "team"
? teamView?.title || currentTarget.title
: currentTarget.title;
if (stacked) {
return (
@@ -1558,7 +1601,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
);
};
const renderWorkbenchFooter = (stacked: boolean) => (
const renderWorkbenchFooter = (stacked: boolean) =>
activeTab === "team" && teamView?.renderFooter ? (
<div
className={cn(
@@ -1569,83 +1612,82 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
{teamView.renderFooter()}
</div>
) : (
<div
className={cn(
"border-t border-slate-200/80",
stacked ? "px-3 py-3" : "px-4 py-3",
)}
>
<div className="flex flex-wrap gap-2">
<button
type="button"
aria-label="复制当前路径"
disabled={!selectionPath}
onClick={() => {
void handleCopyPath();
}}
className={cn(
"inline-flex items-center gap-1 rounded-xl border px-3 py-2 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50",
WORKBENCH_GHOST_BUTTON_CLASSNAME,
)}
>
<Copy className="h-3.5 w-3.5" />
复制路径
</button>
<button
type="button"
aria-label="定位当前文件"
disabled={!selectionPath}
onClick={() => {
if (selectionPath) {
void onRevealPath(selectionPath);
}
}}
className={cn(
"inline-flex items-center gap-1 rounded-xl border px-3 py-2 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50",
WORKBENCH_GHOST_BUTTON_CLASSNAME,
)}
>
<FolderOpen className="h-3.5 w-3.5" />
定位
</button>
<button
type="button"
aria-label="系统打开当前文件"
disabled={!selectionPath}
onClick={() => {
if (selectionPath) {
void onOpenPath(selectionPath);
}
}}
className={cn(
"inline-flex items-center gap-1 rounded-xl border px-3 py-2 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50",
WORKBENCH_GHOST_BUTTON_CLASSNAME,
)}
>
<ExternalLink className="h-3.5 w-3.5" />
打开
</button>
</div>
{previousContent !== null ? (
<div className="mt-3 flex items-center gap-2 text-[11px] text-slate-500">
<GitCompare className="h-3.5 w-3.5" />
已关联到上一版本,可在“变更”中查看差异。
<div
className={cn(
"border-t border-slate-200/80",
stacked ? "px-3 py-3" : "px-4 py-3",
)}
>
<div className="flex flex-wrap gap-2">
<button
type="button"
aria-label="复制当前路径"
disabled={!selectionPath}
onClick={() => {
void handleCopyPath();
}}
className={cn(
"inline-flex items-center gap-1 rounded-xl border px-3 py-2 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50",
WORKBENCH_GHOST_BUTTON_CLASSNAME,
)}
>
<Copy className="h-3.5 w-3.5" />
复制路径
</button>
<button
type="button"
aria-label="定位当前文件"
disabled={!selectionPath}
onClick={() => {
if (selectionPath) {
void onRevealPath(selectionPath);
}
}}
className={cn(
"inline-flex items-center gap-1 rounded-xl border px-3 py-2 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50",
WORKBENCH_GHOST_BUTTON_CLASSNAME,
)}
>
<FolderOpen className="h-3.5 w-3.5" />
定位
</button>
<button
type="button"
aria-label="系统打开当前文件"
disabled={!selectionPath}
onClick={() => {
if (selectionPath) {
void onOpenPath(selectionPath);
}
}}
className={cn(
"inline-flex items-center gap-1 rounded-xl border px-3 py-2 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50",
WORKBENCH_GHOST_BUTTON_CLASSNAME,
)}
>
<ExternalLink className="h-3.5 w-3.5" />
打开
</button>
</div>
) : null}
</div>
)
);
{previousContent !== null ? (
<div className="mt-3 flex items-center gap-2 text-[11px] text-slate-500">
<GitCompare className="h-3.5 w-3.5" />
已关联到上一版本,可在“变更”中查看差异。
</div>
) : null}
</div>
);
const renderActiveTab = () =>
activeTab === "team"
? renderTeamTab()
: activeTab === "artifacts"
? renderEntriesTab()
: activeTab === "files"
? renderFilesTab()
: activeTab === "changes"
? renderChangesTab()
: renderPreviewTab();
? renderEntriesTab()
: activeTab === "files"
? renderFilesTab()
: activeTab === "changes"
? renderChangesTab()
: renderPreviewTab();
const handleStartStackedResize = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
@@ -1723,38 +1765,38 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
!usesStackedWorkbenchLayout;
return (
<div
ref={shellRef}
data-testid="canvas-workbench-shell"
data-layout-mode={usesStackedWorkbenchLayout ? "stacked" : "split"}
className={cn(
"relative h-full min-h-0 overflow-hidden rounded-[28px]",
useFramelessTeamShell
? "border-0 bg-transparent shadow-none"
: "border border-slate-200 bg-white shadow-sm shadow-slate-950/5",
usesStackedWorkbenchLayout ? "block" : "flex flex-row",
)}
>
<div
ref={shellRef}
data-testid="canvas-workbench-shell"
data-layout-mode={usesStackedWorkbenchLayout ? "stacked" : "split"}
data-testid="canvas-workbench-preview-region"
className={cn(
"relative h-full min-h-0 overflow-hidden rounded-[28px]",
useFramelessTeamShell
? "border-0 bg-transparent shadow-none"
: "border border-slate-200 bg-white shadow-sm shadow-slate-950/5",
usesStackedWorkbenchLayout ? "block" : "flex flex-row",
"min-w-0 overflow-hidden",
usesStackedWorkbenchLayout ? "h-full" : "flex-1 h-full",
)}
>
<div
data-testid="canvas-workbench-preview-region"
className={cn(
"min-w-0 overflow-hidden",
usesStackedWorkbenchLayout ? "h-full" : "flex-1 h-full",
)}
>
{activeTab === "team" && teamView?.enabled
? teamView.renderPreview(
preferFullscreenTeamPreview
? undefined
: {
stackedWorkbenchTrigger,
},
)
: renderPreview(currentTarget, {
stackedWorkbenchTrigger,
onArtifactDocumentControllerChange:
handleArtifactDocumentControllerChange,
})}
{activeTab === "team" && teamView?.enabled
? teamView.renderPreview(
preferFullscreenTeamPreview
? undefined
: {
stackedWorkbenchTrigger,
},
)
: renderPreview(currentTarget, {
stackedWorkbenchTrigger,
onArtifactDocumentControllerChange:
handleArtifactDocumentControllerChange,
})}
</div>
{preferFullscreenTeamPreview ? null : usesStackedWorkbenchLayout ? (
@@ -1787,11 +1829,11 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
<span className="h-10 w-1.5 rounded-full bg-slate-200" />
</div>
<div className="flex min-h-0 flex-1 flex-col pl-5">
{renderWorkbenchHeader(true)}
<div className="flex-1 overflow-auto px-3 py-3">
{renderActiveTab()}
</div>
{renderWorkbenchFooter(true)}
{renderWorkbenchHeader(true)}
<div className="flex-1 overflow-auto px-3 py-3">
{renderActiveTab()}
</div>
{renderWorkbenchFooter(true)}
</div>
</section>
</>
@@ -1840,7 +1882,9 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
{renderWorkbenchHeader(false, {
showCollapseButton: true,
})}
<div className="flex-1 overflow-auto px-4 py-4">{renderActiveTab()}</div>
<div className="flex-1 overflow-auto px-4 py-4">
{renderActiveTab()}
</div>
{renderWorkbenchFooter(false)}
</>
)}
@@ -56,10 +56,9 @@ vi.mock("@/lib/api/agentRuntime", async () => {
});
vi.mock("@/lib/api/agentProtocol", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api/agentProtocol")>(
"@/lib/api/agentProtocol",
);
const actual = await vi.importActual<
typeof import("@/lib/api/agentProtocol")
>("@/lib/api/agentProtocol");
return {
...actual,
parseAgentEvent: mockParseAgentEvent,
@@ -67,9 +66,10 @@ vi.mock("@/lib/api/agentProtocol", async () => {
});
vi.mock("@/lib/dev-bridge", async () => {
const actual = await vi.importActual<typeof import("@/lib/dev-bridge")>(
"@/lib/dev-bridge",
);
const actual =
await vi.importActual<typeof import("@/lib/dev-bridge")>(
"@/lib/dev-bridge",
);
return {
...actual,
@@ -121,7 +121,9 @@ const mountedRoots: MountedHarness[] = [];
interface MountOptions {
onManageProviders?: () => void;
chatModelSelectorProps?: Partial<React.ComponentProps<typeof ChatModelSelector>>;
chatModelSelectorProps?: Partial<
React.ComponentProps<typeof ChatModelSelector>
>;
}
function createModel(id: string, providerId: string) {
@@ -1,8 +1,5 @@
import React from "react";
import {
ModelSelector,
type ModelSelectorProps,
} from "@/components/input-kit";
import { ModelSelector, type ModelSelectorProps } from "@/components/input-kit";
export type ChatModelSelectorProps = ModelSelectorProps;
@@ -137,8 +137,12 @@ describe("ChatNavbar", () => {
});
expect(container.querySelector('[aria-label="切换历史"]')).not.toBeNull();
expect(container.querySelector('[aria-label="展开Harness"]')).not.toBeNull();
expect(container.querySelector('[data-testid="project-selector"]')).toBeNull();
expect(
container.querySelector('[aria-label="展开Harness"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="project-selector"]'),
).toBeNull();
});
it("任务中心顶栏应展示轻量上下文提示", () => {
@@ -46,8 +46,7 @@ interface ChatNavbarProps {
const toolbarGroupClassName =
"flex items-center rounded-[20px] border border-slate-200/80 bg-white/90 p-1.5 shadow-sm shadow-slate-950/5 backdrop-blur-sm";
const toolbarDividerClassName =
"mx-1.5 h-6 w-px shrink-0 bg-slate-200/80";
const toolbarDividerClassName = "mx-1.5 h-6 w-px shrink-0 bg-slate-200/80";
const toolbarEmbeddedButtonClassName =
"h-9 rounded-2xl border border-transparent px-3.5 text-xs shadow-none";
@@ -111,7 +110,8 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
Boolean(onBackToProjectManagement));
const showWorkspaceTools = showHistoryToggle || showCanvasToggle;
const showProjectSelector = !isWorkspaceCompact;
const showCompactSettingsButton = isWorkspaceCompact && Boolean(onToggleSettings);
const showCompactSettingsButton =
isWorkspaceCompact && Boolean(onToggleSettings);
const compactProjectSelectorClassName = isWorkspaceCompact
? "min-w-[184px] max-w-[248px]"
: "min-w-[196px] max-w-[280px]";
@@ -141,7 +141,10 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
<Button
variant="ghost"
size="sm"
className={cn(embeddedButtonClassName, toolbarTextButtonClassName)}
className={cn(
embeddedButtonClassName,
toolbarTextButtonClassName,
)}
onClick={onBackToResources}
>
<FolderOpen size={16} className="mr-0.5" />
@@ -155,7 +158,10 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
<Button
variant="ghost"
size="sm"
className={cn(embeddedButtonClassName, toolbarTextButtonClassName)}
className={cn(
embeddedButtonClassName,
toolbarTextButtonClassName,
)}
onClick={onBackToProjectManagement}
>
项目管理
@@ -274,14 +280,19 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
type="button"
variant="ghost"
size="sm"
className={cn(embeddedButtonClassName, toolbarTextButtonClassName)}
className={cn(
embeddedButtonClassName,
toolbarTextButtonClassName,
)}
onClick={onCompactContext}
disabled={contextCompactionRunning}
aria-label="压缩上下文"
title="压缩上下文"
>
<Box size={14} />
<span>{contextCompactionRunning ? "压缩中..." : "压缩上下文"}</span>
<span>
{contextCompactionRunning ? "压缩中..." : "压缩上下文"}
</span>
</Button>
) : null}
@@ -332,7 +343,6 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
) : null}
</div>
) : null}
</div>
</Navbar>
);
@@ -17,9 +17,15 @@ vi.mock("@/components/ui/badge", () => ({
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuItem: ({
children,
onClick,
@@ -238,7 +244,9 @@ describe("ChatSidebar", () => {
const taskHeading = container.querySelector(
'[data-testid="task-section-heading"]',
) as (HTMLDivElement & { scrollIntoView?: ReturnType<typeof vi.fn> }) | null;
) as
| (HTMLDivElement & { scrollIntoView?: ReturnType<typeof vi.fn> })
| null;
expect(taskHeading).toBeTruthy();
const scrollIntoView = vi.fn();
@@ -268,9 +276,7 @@ describe("ChatSidebar", () => {
const container = renderSidebar({ onSwitchTopic });
const taskItem = Array.from(
container.querySelectorAll('[role="button"]'),
).find(
(element) => element.textContent?.includes("任务一"),
);
).find((element) => element.textContent?.includes("任务一"));
expect(taskItem).toBeTruthy();
if (taskItem) {
act(() => {
@@ -475,9 +481,7 @@ describe("ChatSidebar", () => {
expect(resumeButton).toBeTruthy();
act(() => {
resumeButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
resumeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onResumeTask).toHaveBeenCalledWith("topic-1", "user_action");
@@ -596,7 +600,9 @@ describe("ChatSidebar", () => {
collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("已收起 · 2 个子代理 · 1 个处理中 · 1 个已完成");
expect(container.textContent).toContain(
"已收起 · 2 个子代理 · 1 个处理中 · 1 个已完成",
);
expect(container.textContent).not.toContain("代码审查代理");
expect(container.textContent).not.toContain("文档校对代理");
@@ -684,9 +690,9 @@ describe("ChatSidebar", () => {
expect(container.textContent).not.toContain("回归验证代理");
expect(container.textContent).toContain("展开剩余 1 个子代理");
const expandMoreButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("展开剩余 1 个子代理"),
);
const expandMoreButton = Array.from(
container.querySelectorAll("button"),
).find((element) => element.textContent?.includes("展开剩余 1 个子代理"));
expect(expandMoreButton).toBeTruthy();
act(() => {
@@ -698,9 +704,9 @@ describe("ChatSidebar", () => {
expect(container.textContent).toContain("回归验证代理");
expect(container.textContent).toContain("收起子代理列表");
const collapseListButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("收起子代理列表"),
);
const collapseListButton = Array.from(
container.querySelectorAll("button"),
).find((element) => element.textContent?.includes("收起子代理列表"));
expect(collapseListButton).toBeTruthy();
act(() => {
@@ -55,7 +55,7 @@ const STATUS_META: Record<
draft: {
label: "待补充",
badgeClassName:
"border border-slate-200 bg-white text-slate-600 dark:border-white/10 dark:bg-white/5 dark:text-slate-300",
"border border-slate-200/80 bg-white/80 text-slate-600 dark:border-white/10 dark:bg-white/5 dark:text-slate-300",
dotClassName: "bg-slate-400",
},
running: {
@@ -84,11 +84,7 @@ const STATUS_META: Record<
},
};
type TaskSectionKey =
| "running"
| "waiting"
| "recent"
| "older";
type TaskSectionKey = "running" | "waiting" | "recent" | "older";
type ChatSidebarContextVariant = "default" | "task-center";
@@ -209,7 +205,10 @@ function resolveCurrentStatusPreview(
pendingActionCount: number,
workspaceError: boolean,
) {
if ((workspaceError || statusReason === "workspace_error") && status === "failed") {
if (
(workspaceError || statusReason === "workspace_error") &&
status === "failed"
) {
return "工作区异常,等待你重新选择本地目录后继续。";
}
if (status === "running") {
@@ -462,9 +461,8 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
recent: false,
older: false,
});
const [teamSectionCollapsedOverride, setTeamSectionCollapsedOverride] = useState<
boolean | null
>(null);
const [teamSectionCollapsedOverride, setTeamSectionCollapsedOverride] =
useState<boolean | null>(null);
const [showAllChildSubagents, setShowAllChildSubagents] = useState(false);
const [showAllSiblingSubagents, setShowAllSiblingSubagents] = useState(false);
const editInputRef = useRef<HTMLInputElement>(null);
@@ -551,7 +549,10 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
() =>
showAllChildSubagents
? sortedChildSubagentSessions
: sortedChildSubagentSessions.slice(0, TEAM_SECTION_INITIAL_CHILD_COUNT),
: sortedChildSubagentSessions.slice(
0,
TEAM_SECTION_INITIAL_CHILD_COUNT,
),
[showAllChildSubagents, sortedChildSubagentSessions],
);
const visibleSiblingSubagentSessions = useMemo(
@@ -662,7 +663,9 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
}, [teamSectionIdentity]);
useEffect(() => {
if (sortedChildSubagentSessions.length <= TEAM_SECTION_INITIAL_CHILD_COUNT) {
if (
sortedChildSubagentSessions.length <= TEAM_SECTION_INITIAL_CHILD_COUNT
) {
setShowAllChildSubagents(false);
}
}, [sortedChildSubagentSessions.length]);
@@ -785,9 +788,16 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
</Badge>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-slate-500 dark:text-slate-400">
<span>{options?.subtitle ?? resolveSubagentSessionTypeLabel(session.session_type)}</span>
{session.role_hint ? <span>角色 · {session.role_hint}</span> : null}
{updatedAt ? <span>更新于 {formatRelativeTime(updatedAt)}</span> : null}
<span>
{options?.subtitle ??
resolveSubagentSessionTypeLabel(session.session_type)}
</span>
{session.role_hint ? (
<span>角色 · {session.role_hint}</span>
) : null}
{updatedAt ? (
<span>更新于 {formatRelativeTime(updatedAt)}</span>
) : null}
</div>
{session.task_summary ? (
<p className="mt-2 line-clamp-2 text-xs leading-5 text-slate-600 dark:text-slate-300">
@@ -802,7 +812,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
return (
<aside
className="w-[308px] shrink-0 overflow-hidden rounded-[30px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(248,250,252,0.98)_0%,rgba(241,245,249,0.92)_100%)] shadow-sm shadow-slate-950/5 backdrop-blur dark:border-white/10 dark:bg-[#111318]"
className="w-[308px] shrink-0 overflow-hidden rounded-[30px] border border-emerald-200/40 bg-[linear-gradient(180deg,rgba(252,254,252,0.98)_0%,rgba(247,251,248,0.92)_100%)] shadow-sm shadow-slate-950/5 backdrop-blur dark:border-white/10 dark:bg-[#111318]"
data-testid="chat-sidebar"
>
<div className="flex h-full min-h-0 flex-col gap-4 p-4">
@@ -814,14 +824,14 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
value={searchKeyword}
onChange={(event) => setSearchKeyword(event.target.value)}
placeholder="搜索任务标题或摘要"
className="h-11 w-full rounded-[18px] border border-slate-200/80 bg-white/92 pl-9 pr-3 text-sm text-slate-700 shadow-sm shadow-slate-950/5 outline-none transition focus:border-slate-300 focus:ring-2 focus:ring-slate-200 dark:border-white/10 dark:bg-white/5 dark:text-slate-100 dark:focus:border-white/20 dark:focus:ring-white/10"
className="h-11 w-full rounded-[18px] border border-emerald-200/40 bg-white/90 pl-9 pr-3 text-sm text-slate-700 shadow-sm shadow-slate-950/5 outline-none transition focus:border-emerald-300/60 focus:ring-2 focus:ring-emerald-100/50 dark:border-white/10 dark:bg-white/5 dark:text-slate-100 dark:focus:border-white/20 dark:focus:ring-white/10"
/>
</div>
<button
type="button"
onClick={onNewChat}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-[18px] bg-slate-900 px-4 text-sm font-semibold text-white shadow-sm shadow-slate-950/10 transition hover:bg-slate-800 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-100"
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-[18px] bg-[#1a3b2b] px-4 text-sm font-semibold text-white shadow-sm shadow-[#1a3b2b]/10 transition hover:bg-[#132c20] dark:bg-white dark:text-slate-900 dark:hover:bg-slate-100"
>
<Plus className="h-4 w-4" />
新建任务
@@ -834,8 +844,8 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
className={cn(
"inline-flex h-9 flex-1 items-center justify-center rounded-2xl border px-2 text-xs font-medium transition",
statusFilter === "all"
? "border-slate-900 bg-slate-900 text-white dark:border-white dark:bg-white dark:text-slate-900"
: "border-slate-200/80 bg-white/90 text-slate-500 hover:border-slate-300 hover:bg-white hover:text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-300",
? "border-[#1a3b2b] bg-[#1a3b2b] text-white dark:border-white dark:bg-white dark:text-slate-900"
: "border-slate-200/80 bg-white/90 text-slate-500 hover:border-slate-300 hover:bg-white hover:text-slate-800 dark:border-white/10 dark:bg-white/5 dark:text-slate-300",
)}
>
全部任务
@@ -846,8 +856,8 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
className={cn(
"inline-flex h-9 flex-1 items-center justify-center rounded-2xl border px-2 text-xs font-medium transition",
statusFilter === "active"
? "border-slate-900 bg-slate-900 text-white dark:border-white dark:bg-white dark:text-slate-900"
: "border-slate-200/80 bg-white/90 text-slate-500 hover:border-slate-300 hover:bg-white hover:text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-300",
? "border-[#1a3b2b] bg-[#1a3b2b] text-white dark:border-white dark:bg-white dark:text-slate-900"
: "border-slate-200/80 bg-white/90 text-slate-500 hover:border-slate-300 hover:bg-white hover:text-slate-800 dark:border-white/10 dark:bg-white/5 dark:text-slate-300",
)}
>
仅看进行中
@@ -979,7 +989,8 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
) : null}
{currentTaskItem?.updatedAt ? (
<span>
更新于 {formatRelativeTime(currentTaskItem.updatedAt)}
更新于{" "}
{formatRelativeTime(currentTaskItem.updatedAt)}
</span>
) : null}
</div>
@@ -1069,7 +1080,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
{taskHeadingLabel}
</div>
{taskHeadingHint ? (
<p className="mt-1 text-[11px] leading-5 text-slate-500 dark:text-slate-400">
<p className="mt-1 text-[11px] leading-5 text-slate-400 dark:text-slate-400">
{taskHeadingHint}
</p>
) : null}
@@ -1106,261 +1117,268 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
) : (
<div className="space-y-3">
{sections.map((section) => {
const isOlderSection = section.key === "older";
const isSectionCollapsed = collapsedSections[section.key];
const visibleItems =
isOlderSection && !showAllOlder
? section.items.slice(0, OLDER_TASKS_INITIAL_COUNT)
: section.items;
const isOlderSection = section.key === "older";
const isSectionCollapsed = collapsedSections[section.key];
const visibleItems =
isOlderSection && !showAllOlder
? section.items.slice(0, OLDER_TASKS_INITIAL_COUNT)
: section.items;
if (section.items.length === 0) {
return null;
}
if (section.items.length === 0) {
return null;
}
return (
<section key={section.key} className="space-y-2">
<button
type="button"
onClick={() =>
setCollapsedSections((prev) => ({
...prev,
[section.key]: !prev[section.key],
}))
}
className="flex w-full items-center justify-between rounded-2xl px-2.5 py-2 text-left transition hover:bg-white/78 dark:hover:bg-white/5"
>
<div className="flex items-center gap-2">
<ChevronDown
className={cn(
"h-4 w-4 text-slate-400 transition-transform",
isSectionCollapsed ? "-rotate-90" : "",
)}
/>
<span className="text-xs font-semibold text-slate-700 dark:text-slate-200">
{section.title}
return (
<section key={section.key} className="space-y-2">
<button
type="button"
onClick={() =>
setCollapsedSections((prev) => ({
...prev,
[section.key]: !prev[section.key],
}))
}
className="flex w-full items-center justify-between rounded-2xl px-2.5 py-2 text-left transition hover:bg-white/78 dark:hover:bg-white/5"
>
<div className="flex items-center gap-2">
<ChevronDown
className={cn(
"h-4 w-4 text-emerald-400 transition-transform",
isSectionCollapsed ? "-rotate-90" : "",
)}
/>
<span className="text-xs font-semibold text-slate-700 dark:text-slate-200">
{section.title}
</span>
</div>
<span className="text-[11px] text-slate-400">
{section.items.length}
</span>
</div>
<span className="text-[11px] text-slate-400">
{section.items.length}
</span>
</button>
</button>
{isSectionCollapsed ? null : (
<div className="space-y-2">
{visibleItems.map((item) => {
const statusMeta = STATUS_META[item.status];
const isResumableItem =
item.status === "waiting" ||
(item.status === "failed" &&
item.statusReason === "workspace_error");
{isSectionCollapsed ? null : (
<div className="space-y-2">
{visibleItems.map((item) => {
const statusMeta = STATUS_META[item.status];
const isResumableItem =
item.status === "waiting" ||
(item.status === "failed" &&
item.statusReason === "workspace_error");
return (
<div
key={item.id}
role="button"
tabIndex={0}
onClick={() => {
if (editingTopicId !== item.id) {
onSwitchTopic(item.id);
}
}}
onDoubleClick={() =>
handleStartEdit(item.id, item.title)
}
onKeyDown={(event) => {
if (
event.key === "Enter" ||
event.key === " "
) {
event.preventDefault();
return (
<div
key={item.id}
role="button"
tabIndex={0}
onClick={() => {
if (editingTopicId !== item.id) {
onSwitchTopic(item.id);
}
}}
onDoubleClick={() =>
handleStartEdit(item.id, item.title)
}
}}
className={cn(
"group rounded-[22px] border p-3.5 text-left shadow-sm shadow-slate-950/5 transition",
isResumableItem
? "border-amber-200/80 bg-[linear-gradient(180deg,rgba(255,251,235,0.9)_0%,rgba(255,255,255,0.96)_100%)] shadow-sm shadow-amber-950/5 dark:border-amber-500/20 dark:bg-white/10"
: "",
item.isCurrent
? "border-slate-300 bg-white/98 ring-1 ring-slate-100 dark:border-white/15 dark:bg-white/10"
: "border-slate-200/70 bg-white/72 hover:border-slate-300 hover:bg-white/92 dark:border-white/10 dark:bg-white/5 dark:hover:border-white/10 dark:hover:bg-white/5",
)}
>
<div className="flex items-start gap-3">
<span
className={cn(
"mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full",
statusMeta.dotClassName,
)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
{editingTopicId === item.id ? (
<input
ref={editInputRef}
type="text"
value={editTitle}
onChange={(event) =>
setEditTitle(event.target.value)
}
onKeyDown={handleEditKeyDown}
onBlur={handleSaveEdit}
onClick={(event) =>
event.stopPropagation()
}
className="h-8 flex-1 rounded-xl border border-slate-300 bg-white px-2.5 text-sm font-medium text-slate-900 outline-none focus:border-slate-400 dark:border-white/10 dark:bg-[#17191f] dark:text-slate-100"
/>
) : (
<>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<div className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
{item.title || "未命名任务"}
</div>
{item.isPinned ? (
<Pin className="h-3.5 w-3.5 text-slate-400" />
) : null}
{item.hasUnread ? (
<span className="h-2 w-2 rounded-full bg-sky-500" />
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-1 pt-0.5">
<div className="text-[11px] text-slate-400">
{formatRelativeTime(item.updatedAt)}
</div>
<button
type="button"
aria-label="删除任务"
title="删除任务"
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-lg text-slate-400 transition hover:bg-rose-50 hover:text-rose-600 dark:hover:bg-rose-500/10 dark:hover:text-rose-300",
item.isCurrent
? "opacity-100"
: "opacity-0 group-hover:opacity-100",
)}
onClick={(event) => {
event.stopPropagation();
handleDeleteClick(item.id);
}}
>
<Trash2 className="h-4 w-4" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="任务操作"
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-lg text-slate-400 transition hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-white/10 dark:hover:text-slate-100",
item.isCurrent
? "opacity-100"
: "opacity-0 group-hover:opacity-100",
)}
onClick={(event) =>
event.stopPropagation()
}
>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
handleStartEdit(item.id, item.title)
}
>
<PencilLine className="h-4 w-4" />
重命名任务
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
handleTogglePinned(item.id)
}
>
{item.isPinned ? (
<PinOff className="h-4 w-4" />
) : (
<Pin className="h-4 w-4" />
)}
{item.isPinned ? "取消固定" : "固定任务"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-rose-600"
onClick={() =>
handleDeleteClick(item.id)
}
>
<Trash2 className="h-4 w-4" />
删除任务
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
onKeyDown={(event) => {
if (
event.key === "Enter" ||
event.key === " "
) {
event.preventDefault();
if (editingTopicId !== item.id) {
onSwitchTopic(item.id);
}
}
}}
className={cn(
"group rounded-[22px] border p-3.5 text-left shadow-sm shadow-slate-950/5 transition",
isResumableItem
? "border-amber-200/80 bg-[linear-gradient(180deg,rgba(255,251,235,0.9)_0%,rgba(255,255,255,0.96)_100%)] shadow-sm shadow-amber-950/5 dark:border-amber-500/20 dark:bg-white/10"
: "",
item.isCurrent
? "border-emerald-200/60 bg-white/98 ring-1 ring-emerald-50 dark:border-white/15 dark:bg-white/10"
: "border-slate-200/60 bg-white/72 hover:border-emerald-200/60 hover:bg-white/92 dark:border-white/10 dark:bg-white/5 dark:hover:border-white/10 dark:hover:bg-white/5",
)}
>
<div className="flex items-start gap-3">
<span
className={cn(
"mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full",
statusMeta.dotClassName,
)}
</div>
/>
<div className="mt-2 line-clamp-2 text-xs leading-5 text-slate-500 dark:text-slate-400">
{item.lastPreview}
</div>
<div className="mt-3 flex items-center gap-2">
<Badge
variant="outline"
className={cn(
"px-2.5 py-1 text-[11px] font-medium",
statusMeta.badgeClassName,
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
{editingTopicId === item.id ? (
<input
ref={editInputRef}
type="text"
value={editTitle}
onChange={(event) =>
setEditTitle(event.target.value)
}
onKeyDown={handleEditKeyDown}
onBlur={handleSaveEdit}
onClick={(event) =>
event.stopPropagation()
}
className="h-8 flex-1 rounded-xl border border-slate-300 bg-white px-2.5 text-sm font-medium text-slate-900 outline-none focus:border-slate-400 dark:border-white/10 dark:bg-[#17191f] dark:text-slate-100"
/>
) : (
<>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<div className="truncate text-sm font-semibold text-slate-800 dark:text-slate-100">
{item.title || "未命名任务"}
</div>
{item.isPinned ? (
<Pin className="h-3.5 w-3.5 text-emerald-400" />
) : null}
{item.hasUnread ? (
<span className="h-2 w-2 rounded-full bg-emerald-500" />
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-1 pt-0.5">
<div className="text-[11px] text-slate-400">
{formatRelativeTime(
item.updatedAt,
)}
</div>
<button
type="button"
aria-label="删除任务"
title="删除任务"
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-lg text-slate-400 transition hover:bg-rose-50 hover:text-rose-600 dark:hover:bg-rose-500/10 dark:hover:text-rose-300",
item.isCurrent
? "opacity-100"
: "opacity-0 group-hover:opacity-100",
)}
onClick={(event) => {
event.stopPropagation();
handleDeleteClick(item.id);
}}
>
<Trash2 className="h-4 w-4" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="任务操作"
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-lg text-slate-400 transition hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-white/10 dark:hover:text-slate-100",
item.isCurrent
? "opacity-100"
: "opacity-0 group-hover:opacity-100",
)}
onClick={(event) =>
event.stopPropagation()
}
>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
handleStartEdit(
item.id,
item.title,
)
}
>
<PencilLine className="h-4 w-4" />
重命名任务
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
handleTogglePinned(item.id)
}
>
{item.isPinned ? (
<PinOff className="h-4 w-4" />
) : (
<Pin className="h-4 w-4" />
)}
{item.isPinned
? "取消固定"
: "固定任务"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-rose-600"
onClick={() =>
handleDeleteClick(item.id)
}
>
<Trash2 className="h-4 w-4" />
删除任务
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)}
>
{item.status === "running" ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : null}
{item.statusLabel}
</Badge>
<span className="text-[11px] text-slate-400">
{item.messagesCount > 0
? `${item.messagesCount} 条消息`
: "尚未开始执行"}
</span>
{isResumableItem && onResumeTask ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleResumeTask(item);
}}
className="inline-flex items-center rounded-full border border-amber-200/80 bg-amber-50 px-2.5 py-1 text-[11px] font-medium text-amber-700 transition hover:border-amber-300 hover:bg-amber-100 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200 dark:hover:bg-amber-500/15"
</div>
<div className="mt-2 line-clamp-2 text-xs leading-5 text-slate-500 dark:text-slate-400">
{item.lastPreview}
</div>
<div className="mt-3 flex items-center gap-2">
<Badge
variant="outline"
className={cn(
"px-2.5 py-1 text-[11px] font-medium",
statusMeta.badgeClassName,
)}
>
继续任务
</button>
) : null}
{item.status === "running" ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : null}
{item.statusLabel}
</Badge>
<span className="text-[11px] text-slate-400">
{item.messagesCount > 0
? `${item.messagesCount} 条消息`
: "尚未开始执行"}
</span>
{isResumableItem && onResumeTask ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleResumeTask(item);
}}
className="inline-flex items-center rounded-full border border-amber-200/80 bg-amber-50 px-2.5 py-1 text-[11px] font-medium text-amber-700 transition hover:border-amber-300 hover:bg-amber-100 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200 dark:hover:bg-amber-500/15"
>
继续任务
</button>
) : null}
</div>
</div>
</div>
</div>
</div>
);
})}
);
})}
{isOlderSection &&
section.items.length > OLDER_TASKS_INITIAL_COUNT &&
!showAllOlder ? (
<button
type="button"
onClick={() => setShowAllOlder(true)}
className="w-full rounded-2xl border border-dashed border-slate-200 bg-white/75 px-3 py-2 text-sm font-medium text-slate-500 transition hover:border-slate-300 hover:text-slate-800 dark:border-white/10 dark:bg-white/5 dark:text-slate-300 dark:hover:border-white/20 dark:hover:text-white"
>
{olderSectionMoreLabel}
</button>
) : null}
</div>
)}
</section>
);
{isOlderSection &&
section.items.length > OLDER_TASKS_INITIAL_COUNT &&
!showAllOlder ? (
<button
type="button"
onClick={() => setShowAllOlder(true)}
className="w-full rounded-2xl border border-dashed border-slate-200 bg-white/75 px-3 py-2 text-sm font-medium text-slate-500 transition hover:border-slate-300 hover:text-slate-800 dark:border-white/10 dark:bg-white/5 dark:text-slate-300 dark:hover:border-white/20 dark:hover:text-white"
>
{olderSectionMoreLabel}
</button>
) : null}
</div>
)}
</section>
);
})}
</div>
)}
@@ -284,12 +284,7 @@ describe("EmptyState", () => {
await Promise.resolve();
});
const expectedLabels = [
"技能",
"自动化",
"多代理",
"浏览器接入",
];
const expectedLabels = ["技能", "自动化", "多代理", "浏览器接入"];
const expectedAlts = [
"技能能力卡占位图",
"自动化能力卡占位图",
@@ -745,7 +740,7 @@ describe("EmptyState", () => {
executionStrategy?: "react" | "code_orchestrated" | "auto",
images?: unknown[],
) => void
>();
>();
const container = renderEmptyState({
input: "请输出一篇用户访谈纪要",
activeTheme: "general",
@@ -826,9 +821,8 @@ describe("EmptyState", () => {
it("通用主题工具栏应包含附件、思考、Plan 与多代理开关", async () => {
const onThinkingEnabledChange = vi.fn<(enabled: boolean) => void>();
const onSubagentEnabledChange = vi.fn<(enabled: boolean) => void>();
const setExecutionStrategy = vi.fn<
(strategy: "react" | "code_orchestrated" | "auto") => void
>();
const setExecutionStrategy =
vi.fn<(strategy: "react" | "code_orchestrated" | "auto") => void>();
const container = renderEmptyState({
activeTheme: "general",
thinkingEnabled: false,
@@ -896,5 +890,4 @@ describe("EmptyState", () => {
expect(onLaunchBrowserAssist).toHaveBeenCalledTimes(1);
});
});
@@ -338,8 +338,10 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
appendSelectedTextToRecommendation,
setAppendSelectedTextToRecommendation,
] = useState(true);
const [entryRecommendedSolutionsVersion, setEntryRecommendedSolutionsVersion] =
useState(0);
const [
entryRecommendedSolutionsVersion,
setEntryRecommendedSolutionsVersion,
] = useState(0);
useEffect(() => {
const loadConfigPreferences = async () => {
@@ -417,13 +419,10 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
subagentEnabled,
]);
const entryRecommendedSolutions = useMemo(
() => {
void entryRecommendedSolutionsVersion;
return listEntryRecommendedSolutions();
},
[entryRecommendedSolutionsVersion],
);
const entryRecommendedSolutions = useMemo(() => {
void entryRecommendedSolutionsVersion;
return listEntryRecommendedSolutions();
}, [entryRecommendedSolutionsVersion]);
const selectedTextPreview = useMemo(() => {
const normalized = (recommendationSelectedText || "")
@@ -488,11 +487,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
}
const imagesToSend = pendingImages.length > 0 ? pendingImages : undefined;
onSend(
wrapTextWithDefaultSkill(input),
executionStrategy,
imagesToSend,
);
onSend(wrapTextWithDefaultSkill(input), executionStrategy, imagesToSend);
setPendingImages([]);
clearSelectedSkill?.();
};
@@ -658,7 +653,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
key: "agent-teams",
eyebrow: "支撑能力",
title: "多代理",
value: subagentEnabled ? "当前任务支持并行协作" : "复杂任务可拆成并行分工",
value: subagentEnabled
? "当前任务支持并行协作"
: "复杂任务可拆成并行分工",
description:
"当研究、方案和执行需要同时推进时,可把任务拆给多个代理并行处理,再统一回收结论。",
icon: <Workflow className="h-5 w-5" />,
@@ -672,9 +669,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
key: "browser",
eyebrow: "支撑能力",
title: "浏览器接入",
value: browserAssistLoading
? "正在检查连接状态"
: "CDP / 浏览器插件复用",
value: browserAssistLoading ? "正在检查连接状态" : "CDP / 浏览器插件复用",
description:
"登录、验证和网页动作可直接复用 CDP 或浏览器插件连接,不必再切到单独工作台。",
icon: <Globe className="h-5 w-5" />,
@@ -686,7 +681,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
void onLaunchBrowserAssist();
}
: undefined,
mediaActionLabel: browserAssistLoading ? "浏览器连接准备中" : "连接浏览器",
mediaActionLabel: browserAssistLoading
? "浏览器连接准备中"
: "连接浏览器",
mediaActionDisabled: browserAssistLoading,
});
@@ -847,9 +844,13 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
handleApplyEntryRecommendedSolution(solution);
}}
>
<RecommendationShelfTitle>{solution.title}</RecommendationShelfTitle>
<RecommendationShelfTitle>
{solution.title}
</RecommendationShelfTitle>
{solution.isRecent ? (
<RecommendationShelfMeta>{solution.badge}</RecommendationShelfMeta>
<RecommendationShelfMeta>
{solution.badge}
</RecommendationShelfMeta>
) : null}
</RecommendationShelfButton>
</RecommendationShelfRow>
@@ -312,9 +312,9 @@ describe("EmptyStateComposerPanel", () => {
onSend,
});
const textarea = container.querySelector("textarea") as
| HTMLTextAreaElement
| null;
const textarea = container.querySelector(
"textarea",
) as HTMLTextAreaElement | null;
const pendingButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("稍后处理"),
) as HTMLButtonElement | undefined;
@@ -377,7 +377,9 @@ describe("EmptyStateComposerPanel", () => {
expect(enableTeamButton).toBeTruthy();
act(() => {
enableTeamButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
enableTeamButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
expect(onSubagentEnabledChange).toHaveBeenCalledWith(true);
@@ -391,16 +393,14 @@ describe("EmptyStateComposerPanel", () => {
onSubagentEnabledChange: vi.fn(),
});
const continueButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("继续单代理"),
);
const continueButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.includes("继续单代理"));
expect(continueButton).toBeTruthy();
act(() => {
continueButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
continueButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).not.toContain("当前任务更适合 Team 协作");
@@ -427,7 +427,9 @@ describe("EmptyStateComposerPanel", () => {
container.querySelector('[data-testid="empty-state-team-selector"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="empty-state-team-mode-enable-button"]'),
container.querySelector(
'[data-testid="empty-state-team-mode-enable-button"]',
),
).toBeNull();
const toggleButton = container.querySelector(
@@ -561,14 +563,14 @@ describe("EmptyStateComposerPanel", () => {
input: "请拆成多个子任务分别分析、实现、验证,并最终统一回归验收",
});
const enableButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.includes("启用 Team")) as
| HTMLButtonElement
| undefined;
const enableButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("启用 Team"),
) as HTMLButtonElement | undefined;
expect(
container.querySelector('[data-testid="empty-state-team-mode-enable-button"]'),
container.querySelector(
'[data-testid="empty-state-team-mode-enable-button"]',
),
).toBeNull();
expect(
container.querySelector('button[title="多代理偏好已关闭"]'),
@@ -134,7 +134,7 @@ const SloganLine = styled.p`
width: fit-content;
align-items: center;
gap: 1.3rem;
/* lime gradient with scan effect */
background: linear-gradient(
90deg,
@@ -162,7 +162,11 @@ const SloganLine = styled.p`
height: 0.92rem;
flex-shrink: 0;
border-radius: 9999px;
background: linear-gradient(135deg, rgba(132, 204, 22, 0.9), rgba(16, 185, 129, 0.75));
background: linear-gradient(
135deg,
rgba(132, 204, 22, 0.9),
rgba(16, 185, 129, 0.75)
);
box-shadow: 0 0 0 12px rgba(132, 204, 22, 0.12);
}
`;
@@ -272,7 +276,6 @@ const HeroCard = styled.article.attrs({
font-size: 0.92rem;
line-height: 1.3;
}
}
.card-media {
@@ -376,7 +379,9 @@ export function EmptyStateHero({
) : null}
<LeadDescriptionText>{description}</LeadDescriptionText>
{supportingDescription ? (
<LeadSupportingText>{supportingDescription}</LeadSupportingText>
<LeadSupportingText>
{supportingDescription}
</LeadSupportingText>
) : null}
</LeadTextGroup>
@@ -386,7 +391,9 @@ export function EmptyStateHero({
<span
key={badge.key}
className={`${EMPTY_STATE_BADGE_BASE_CLASSNAME} ${
EMPTY_STATE_BADGE_TONE_CLASSNAMES[badge.tone || "slate"]
EMPTY_STATE_BADGE_TONE_CLASSNAMES[
badge.tone || "slate"
]
}`}
>
{badge.label}
@@ -394,11 +401,9 @@ export function EmptyStateHero({
))}
</div>
) : null}
</LeadBody>
</LeadBlock>
</LeadColumn>
</StageGrid>
{prioritySlot ? (
@@ -483,7 +488,6 @@ export function EmptyStateHero({
</HeroCard>
))}
</CardsShell>
</HeroContent>
</HeroSection>
);
@@ -263,10 +263,14 @@ export function EmptyStateQuickActions({
<button
type="button"
data-testid={
item.testId ? `${item.testId}-secondary-status` : undefined
item.testId
? `${item.testId}-secondary-status`
: undefined
}
className={`inline-flex items-center rounded-full border px-2 py-1 text-[11px] font-medium transition-colors hover:brightness-95 ${STATUS_TONE_CLASSNAMES[item.secondaryStatusTone ?? "slate"]}`}
title={item.secondaryStatusDescription ?? "打开对应任务"}
title={
item.secondaryStatusDescription ?? "打开对应任务"
}
onClick={(event) => {
event.stopPropagation();
onSecondaryStatusAction(item);
@@ -78,7 +78,8 @@ const CONTEXT_SECTION_TITLE_CLASSNAME =
const CONTEXT_SECTION_BADGE_CLASSNAME =
"inline-flex min-h-4 min-w-4 items-center justify-center rounded-full border border-slate-200 bg-white px-1.5 text-[10px] font-semibold text-slate-500";
const CONTEXT_META_TEXT_CLASSNAME = "mt-1.5 text-[11px] leading-5 text-slate-500";
const CONTEXT_META_TEXT_CLASSNAME =
"mt-1.5 text-[11px] leading-5 text-slate-500";
const CONTEXT_ADD_BUTTON_CLASSNAME =
"flex h-9 w-full items-center justify-center gap-2 rounded-[12px] border border-dashed border-slate-200/90 bg-white/90 px-3 text-sm font-medium text-slate-700 shadow-sm shadow-slate-950/5 transition-colors hover:border-slate-300 hover:bg-white hover:text-slate-900";
@@ -91,7 +92,8 @@ const CONTEXT_SEARCH_INPUT_WRAP_CLASSNAME = "relative";
const CONTEXT_SEARCH_INPUT_CLASSNAME =
"h-7 w-full border-0 bg-transparent pl-7 text-[13px] leading-5 text-slate-900 placeholder:text-slate-400 focus:outline-none";
const CONTEXT_SEARCH_ICON_CLASSNAME = "absolute left-0 top-[5px] text-slate-400";
const CONTEXT_SEARCH_ICON_CLASSNAME =
"absolute left-0 top-[5px] text-slate-400";
const CONTEXT_SEARCH_ACTION_ROW_CLASSNAME =
"mt-2.5 flex items-center justify-between gap-2.5";
@@ -139,7 +141,8 @@ const CONTEXT_DETAIL_CARD_CLASSNAME =
const CONTEXT_DETAIL_TITLE_CLASSNAME =
"text-lg font-bold leading-[1.35] text-slate-900";
const CONTEXT_DETAIL_META_CLASSNAME = "mt-2 text-[11px] leading-5 text-slate-500";
const CONTEXT_DETAIL_META_CLASSNAME =
"mt-2 text-[11px] leading-5 text-slate-500";
const CONTEXT_DETAIL_SECTION_CLASSNAME =
"mt-3 rounded-[12px] border border-slate-200/80 bg-slate-50/80 p-2.5";
@@ -300,7 +303,11 @@ function renderContextList(
>
<button
type="button"
aria-label={interactive ? `查看搜索结果 ${item.name}` : `查看上下文 ${item.name}`}
aria-label={
interactive
? `查看搜索结果 ${item.name}`
: `查看上下文 ${item.name}`
}
className={CONTEXT_OPEN_BUTTON_CLASSNAME}
onClick={() => {
if (interactive) {
@@ -312,7 +319,11 @@ function renderContextList(
>
<div className={CONTEXT_ICON_WRAP_CLASSNAME}>
{item.source === "search" ? (
item.searchMode === "social" ? <Share2 size={12} /> : <Globe size={12} />
item.searchMode === "social" ? (
<Share2 size={12} />
) : (
<Globe size={12} />
)
) : (
<CheckCircle2 size={12} />
)}
@@ -385,7 +396,9 @@ function GeneralWorkbenchContextPanelComponent({
<section className={CONTEXT_SECTION_CLASSNAME}>
<div className={CONTEXT_SECTION_TITLE_CLASSNAME}>
<span>搜索上下文</span>
<span className={CONTEXT_SECTION_BADGE_CLASSNAME}>{latestSearchLabel}</span>
<span className={CONTEXT_SECTION_BADGE_CLASSNAME}>
{latestSearchLabel}
</span>
</div>
<button
type="button"
@@ -403,7 +416,9 @@ function GeneralWorkbenchContextPanelComponent({
className={CONTEXT_SEARCH_INPUT_CLASSNAME}
value={contextSearchQuery}
placeholder="搜索网络添加新上下文"
onChange={(event) => onContextSearchQueryChange(event.target.value)}
onChange={(event) =>
onContextSearchQueryChange(event.target.value)
}
onKeyDown={(event) => {
if (event.key === "Enter" && !isSearchActionDisabled) {
event.preventDefault();
@@ -420,13 +435,21 @@ function GeneralWorkbenchContextPanelComponent({
aria-label="选择上下文搜索来源"
className={CONTEXT_SEARCH_MODE_TRIGGER_CLASSNAME}
>
{contextSearchMode === "social" ? <Share2 size={13} /> : <Globe size={13} />}
<span>{contextSearchMode === "social" ? "社交媒体" : "网络搜索"}</span>
{contextSearchMode === "social" ? (
<Share2 size={13} />
) : (
<Globe size={13} />
)}
<span>
{contextSearchMode === "social" ? "社交媒体" : "网络搜索"}
</span>
<ChevronDown size={13} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-36 p-1">
<DropdownMenuItem onClick={() => onContextSearchModeChange("web")}>
<DropdownMenuItem
onClick={() => onContextSearchModeChange("web")}
>
<div className={CONTEXT_SEARCH_MODE_ROW_CLASSNAME}>
<Globe size={14} />
<span>网络搜索</span>
@@ -437,7 +460,9 @@ function GeneralWorkbenchContextPanelComponent({
) : null}
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onContextSearchModeChange("social")}>
<DropdownMenuItem
onClick={() => onContextSearchModeChange("social")}
>
<div className={CONTEXT_SEARCH_MODE_ROW_CLASSNAME}>
<Share2 size={14} />
<span>社交媒体</span>
@@ -453,7 +478,9 @@ function GeneralWorkbenchContextPanelComponent({
<button
type="button"
aria-label="提交上下文搜索"
className={getContextSearchSubmitButtonClassName(isSearchActionDisabled)}
className={getContextSearchSubmitButtonClassName(
isSearchActionDisabled,
)}
disabled={isSearchActionDisabled}
onClick={() => {
if (!isSearchActionDisabled) {
@@ -470,11 +497,17 @@ function GeneralWorkbenchContextPanelComponent({
</div>
</div>
{contextSearchError ? (
<div className={getContextHintTextClassName(true)}>{contextSearchError}</div>
<div className={getContextHintTextClassName(true)}>
{contextSearchError}
</div>
) : contextSearchLoading ? (
<div className={getContextHintTextClassName()}>正在联网检索并整理上下文...</div>
<div className={getContextHintTextClassName()}>
正在联网检索并整理上下文...
</div>
) : contextSearchBlockedReason ? (
<div className={getContextHintTextClassName()}>{contextSearchBlockedReason}</div>
<div className={getContextHintTextClassName()}>
{contextSearchBlockedReason}
</div>
) : (
<div className={getContextHintTextClassName()}>
输入关键词后按 Enter,可直接把检索结果加入当前上下文。
@@ -498,7 +531,9 @@ function GeneralWorkbenchContextPanelComponent({
</button>
</div>
<div className={CONTEXT_DETAIL_CARD_CLASSNAME}>
<div className={CONTEXT_DETAIL_TITLE_CLASSNAME}>{selectedSearchResult.name}</div>
<div className={CONTEXT_DETAIL_TITLE_CLASSNAME}>
{selectedSearchResult.name}
</div>
<div className={CONTEXT_DETAIL_META_CLASSNAME}>
{resolveContextSourceSubLabel(
selectedSearchResult.source,
@@ -510,13 +545,16 @@ function GeneralWorkbenchContextPanelComponent({
{selectedSearchResult.active ? " · 已启用" : " · 未启用"}
</div>
<div className={CONTEXT_DETAIL_SECTION_CLASSNAME}>
<div className={CONTEXT_DETAIL_SECTION_LABEL_CLASSNAME}>Source guide</div>
<div className={CONTEXT_DETAIL_SECTION_LABEL_CLASSNAME}>
Source guide
</div>
{selectedSearchResult.query ? (
<div className={CONTEXT_QUERY_CLASSNAME}>
检索词:{selectedSearchResult.query}
</div>
) : null}
{selectedSearchResult.citations && selectedSearchResult.citations.length > 0 ? (
{selectedSearchResult.citations &&
selectedSearchResult.citations.length > 0 ? (
<div className={CONTEXT_DETAIL_SOURCE_LIST_CLASSNAME}>
{selectedSearchResult.citations.map((citation) => (
<a
@@ -553,12 +591,14 @@ function GeneralWorkbenchContextPanelComponent({
<section className={cn(CONTEXT_SECTION_CLASSNAME, "border-b-0")}>
<div className={CONTEXT_SECTION_TITLE_CLASSNAME}>
<span>上下文列表</span>
<span className={CONTEXT_SECTION_BADGE_CLASSNAME}>{contextItems.length} 条</span>
<span className={CONTEXT_SECTION_BADGE_CLASSNAME}>
{contextItems.length} 条
</span>
</div>
<div className={CONTEXT_META_TEXT_CLASSNAME}>
已生效 {contextBudget.activeCount}/{contextBudget.activeCountLimit} 条 · 检索结果{" "}
{searchContextItems.length} 条 · 估算 {contextBudget.estimatedTokens}/
{contextBudget.tokenLimit} tokens
已生效 {contextBudget.activeCount}/{contextBudget.activeCountLimit}{" "}
条 · 检索结果 {searchContextItems.length} 条 · 估算{" "}
{contextBudget.estimatedTokens}/{contextBudget.tokenLimit} tokens
</div>
<div className={CONTEXT_META_TEXT_CLASSNAME}>
搜索结果可点击查看详情,其他上下文可直接勾选启用。
@@ -615,7 +655,9 @@ function GeneralWorkbenchContextPanelComponent({
void onDropContextFile(event);
}}
>
<div className={CONTEXT_DROP_HINT_CLASSNAME}>or drop your files here</div>
<div className={CONTEXT_DROP_HINT_CLASSNAME}>
or drop your files here
</div>
<div className={CONTEXT_MODAL_ACTION_GRID_CLASSNAME}>
<button
type="button"
@@ -662,7 +704,9 @@ function GeneralWorkbenchContextPanelComponent({
</div>
</div>
{contextCreateError ? (
<div className={CONTEXT_MODAL_ERROR_TEXT_CLASSNAME}>{contextCreateError}</div>
<div className={CONTEXT_MODAL_ERROR_TEXT_CLASSNAME}>
{contextCreateError}
</div>
) : null}
</div>
</div>
@@ -697,7 +741,9 @@ function GeneralWorkbenchContextPanelComponent({
<ArrowLeft size={20} />
</button>
</div>
<h3 className={CONTEXT_MODAL_TITLE_CENTERED_CLASSNAME}>添加文本内容</h3>
<h3 className={CONTEXT_MODAL_TITLE_CENTERED_CLASSNAME}>
添加文本内容
</h3>
<div className={CONTEXT_MODAL_HEADER_ACTIONS_CLASSNAME}>
<button
type="button"
@@ -718,19 +764,26 @@ function GeneralWorkbenchContextPanelComponent({
className={CONTEXT_TEXTAREA_CLASSNAME}
value={contextDraftText}
placeholder="在此粘贴或输入文本..."
onChange={(event) => onContextDraftTextChange(event.target.value)}
onChange={(event) =>
onContextDraftTextChange(event.target.value)
}
/>
{contextCreateError ? (
<div className={CONTEXT_MODAL_ERROR_TEXT_CLASSNAME}>{contextCreateError}</div>
<div className={CONTEXT_MODAL_ERROR_TEXT_CLASSNAME}>
{contextCreateError}
</div>
) : null}
<div className={CONTEXT_MODAL_FOOTER_CLASSNAME}>
<button
type="button"
aria-label="确认添加文本上下文"
className={getContextConfirmButtonClassName(
contextCreateLoading || contextDraftText.trim().length === 0,
contextCreateLoading ||
contextDraftText.trim().length === 0,
)}
disabled={contextCreateLoading || contextDraftText.trim().length === 0}
disabled={
contextCreateLoading || contextDraftText.trim().length === 0
}
onClick={() => {
void onSubmitTextContext();
}}
@@ -775,7 +828,9 @@ function GeneralWorkbenchContextPanelComponent({
<ArrowLeft size={20} />
</button>
</div>
<h3 className={CONTEXT_MODAL_TITLE_CENTERED_CLASSNAME}>添加网站链接</h3>
<h3 className={CONTEXT_MODAL_TITLE_CENTERED_CLASSNAME}>
添加网站链接
</h3>
<div className={CONTEXT_MODAL_HEADER_ACTIONS_CLASSNAME}>
<button
type="button"
@@ -796,19 +851,26 @@ function GeneralWorkbenchContextPanelComponent({
className={CONTEXT_LINK_INPUT_CLASSNAME}
value={contextDraftLink}
placeholder="请输入网站链接"
onChange={(event) => onContextDraftLinkChange(event.target.value)}
onChange={(event) =>
onContextDraftLinkChange(event.target.value)
}
/>
{contextCreateError ? (
<div className={CONTEXT_MODAL_ERROR_TEXT_CLASSNAME}>{contextCreateError}</div>
<div className={CONTEXT_MODAL_ERROR_TEXT_CLASSNAME}>
{contextCreateError}
</div>
) : null}
<div className={CONTEXT_MODAL_FOOTER_CLASSNAME}>
<button
type="button"
aria-label="确认添加链接上下文"
className={getContextConfirmButtonClassName(
contextCreateLoading || contextDraftLink.trim().length === 0,
contextCreateLoading ||
contextDraftLink.trim().length === 0,
)}
disabled={contextCreateLoading || contextDraftLink.trim().length === 0}
disabled={
contextCreateLoading || contextDraftLink.trim().length === 0
}
onClick={() => {
void onSubmitLinkContext();
}}
@@ -828,4 +890,6 @@ function GeneralWorkbenchContextPanelComponent({
);
}
export const GeneralWorkbenchContextPanel = memo(GeneralWorkbenchContextPanelComponent);
export const GeneralWorkbenchContextPanel = memo(
GeneralWorkbenchContextPanelComponent,
);
@@ -94,7 +94,10 @@ const EXEC_LOG_DETAIL_TAG_LIST_CLASSNAME = "flex flex-wrap gap-1.5";
const EXEC_LOG_DETAIL_TAG_CLASSNAME =
"inline-flex min-h-6 items-center rounded-full border border-slate-200 bg-white px-2.5 text-[10.5px] font-medium text-slate-600";
function matchesExecLogFilter(entry: ExecLogEntry, filter: ExecLogFilter): boolean {
function matchesExecLogFilter(
entry: ExecLogEntry,
filter: ExecLogFilter,
): boolean {
if (filter === "skill") {
return entry.type === "run";
}
@@ -130,18 +133,14 @@ function getExecLogBadgeClassName(type: string, status?: string) {
status === "completed" &&
"border-emerald-200 bg-emerald-50 text-emerald-700",
!status && type === "user" && "border-sky-200 bg-sky-50 text-sky-700",
!status &&
type === "thinking" &&
"border-sky-100 bg-sky-50 text-sky-600",
!status && type === "thinking" && "border-sky-100 bg-sky-50 text-sky-600",
!status &&
type === "response" &&
"border-slate-200 bg-slate-100 text-slate-600",
!status &&
type === "run" &&
"border-emerald-200 bg-emerald-50 text-emerald-700",
!status &&
type === "task" &&
"border-amber-200 bg-amber-50 text-amber-700",
!status && type === "task" && "border-amber-200 bg-amber-50 text-amber-700",
!status && type === "tool" && "border-sky-200 bg-sky-50 text-sky-700",
);
}
@@ -206,7 +205,9 @@ export function GeneralWorkbenchExecLog({
useEffect(() => {
setExpandedEntryIds((previous) =>
previous.filter((entryId) => entries.some((entry) => entry.id === entryId)),
previous.filter((entryId) =>
entries.some((entry) => entry.id === entryId),
),
);
}, [entries]);
@@ -216,7 +217,8 @@ export function GeneralWorkbenchExecLog({
return null;
}
const isExpanded = expandedEntryIds.includes(entry.id);
const detailLabel = entry.detail.kind === "tool" ? "工具详情" : "技能详情";
const detailLabel =
entry.detail.kind === "tool" ? "工具详情" : "技能详情";
const hasDetailContent =
Boolean(entry.detail.sourceRef) ||
Boolean(entry.detail.description) ||
@@ -241,13 +243,19 @@ export function GeneralWorkbenchExecLog({
onClick={() => toggleDetail(entry.id)}
>
{isExpanded ? `收起${detailLabel}` : `查看${detailLabel}`}
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
{isExpanded ? (
<ChevronDown size={12} />
) : (
<ChevronRight size={12} />
)}
</button>
{isExpanded ? (
<div className={EXEC_LOG_DETAIL_PANEL_CLASSNAME}>
{entry.detail.argumentsText ? (
<div className={EXEC_LOG_DETAIL_SECTION_CLASSNAME}>
<div className={EXEC_LOG_DETAIL_LABEL_CLASSNAME}>请求参数</div>
<div className={EXEC_LOG_DETAIL_LABEL_CLASSNAME}>
请求参数
</div>
<div className={EXEC_LOG_DETAIL_TEXT_CLASSNAME}>
{entry.detail.argumentsText}
</div>
@@ -255,7 +263,9 @@ export function GeneralWorkbenchExecLog({
) : null}
{entry.detail.resultText ? (
<div className={EXEC_LOG_DETAIL_SECTION_CLASSNAME}>
<div className={EXEC_LOG_DETAIL_LABEL_CLASSNAME}>执行结果</div>
<div className={EXEC_LOG_DETAIL_LABEL_CLASSNAME}>
执行结果
</div>
<div className={EXEC_LOG_DETAIL_TEXT_CLASSNAME}>
{entry.detail.resultText}
</div>
@@ -263,7 +273,9 @@ export function GeneralWorkbenchExecLog({
) : null}
{entry.detail.errorText ? (
<div className={EXEC_LOG_DETAIL_SECTION_CLASSNAME}>
<div className={EXEC_LOG_DETAIL_LABEL_CLASSNAME}>错误信息</div>
<div className={EXEC_LOG_DETAIL_LABEL_CLASSNAME}>
错误信息
</div>
<div className={EXEC_LOG_DETAIL_TEXT_CLASSNAME}>
{entry.detail.errorText}
</div>
@@ -271,7 +283,9 @@ export function GeneralWorkbenchExecLog({
) : null}
{entry.detail.sourceRef ? (
<div className={EXEC_LOG_DETAIL_SECTION_CLASSNAME}>
<div className={EXEC_LOG_DETAIL_LABEL_CLASSNAME}>技能标识</div>
<div className={EXEC_LOG_DETAIL_LABEL_CLASSNAME}>
技能标识
</div>
<div className={EXEC_LOG_DETAIL_TEXT_CLASSNAME}>
{entry.detail.sourceRef}
</div>
@@ -279,7 +293,9 @@ export function GeneralWorkbenchExecLog({
) : null}
{entry.detail.description ? (
<div className={EXEC_LOG_DETAIL_SECTION_CLASSNAME}>
<div className={EXEC_LOG_DETAIL_LABEL_CLASSNAME}>技能说明</div>
<div className={EXEC_LOG_DETAIL_LABEL_CLASSNAME}>
技能说明
</div>
<div className={EXEC_LOG_DETAIL_TEXT_CLASSNAME}>
{entry.detail.description}
</div>
@@ -287,15 +303,20 @@ export function GeneralWorkbenchExecLog({
) : null}
{entry.detail.whenToUse ? (
<div className={EXEC_LOG_DETAIL_SECTION_CLASSNAME}>
<div className={EXEC_LOG_DETAIL_LABEL_CLASSNAME}>适用场景</div>
<div className={EXEC_LOG_DETAIL_LABEL_CLASSNAME}>
适用场景
</div>
<div className={EXEC_LOG_DETAIL_TEXT_CLASSNAME}>
{entry.detail.whenToUse}
</div>
</div>
) : null}
{entry.detail.workflowSteps && entry.detail.workflowSteps.length > 0 ? (
{entry.detail.workflowSteps &&
entry.detail.workflowSteps.length > 0 ? (
<div className={EXEC_LOG_DETAIL_SECTION_CLASSNAME}>
<div className={EXEC_LOG_DETAIL_LABEL_CLASSNAME}>工作流步骤</div>
<div className={EXEC_LOG_DETAIL_LABEL_CLASSNAME}>
工作流步骤
</div>
<div className={EXEC_LOG_DETAIL_LIST_CLASSNAME}>
{entry.detail.workflowSteps.map((step, index) => (
<div
@@ -308,9 +329,12 @@ export function GeneralWorkbenchExecLog({
</div>
</div>
) : null}
{entry.detail.allowedTools && entry.detail.allowedTools.length > 0 ? (
{entry.detail.allowedTools &&
entry.detail.allowedTools.length > 0 ? (
<div className={EXEC_LOG_DETAIL_SECTION_CLASSNAME}>
<div className={EXEC_LOG_DETAIL_LABEL_CLASSNAME}>允许工具</div>
<div className={EXEC_LOG_DETAIL_LABEL_CLASSNAME}>
允许工具
</div>
<div className={EXEC_LOG_DETAIL_TAG_LIST_CLASSNAME}>
{entry.detail.allowedTools.map((toolName) => (
<span
@@ -323,9 +347,12 @@ export function GeneralWorkbenchExecLog({
</div>
</div>
) : null}
{entry.detail.artifactPaths && entry.detail.artifactPaths.length > 0 ? (
{entry.detail.artifactPaths &&
entry.detail.artifactPaths.length > 0 ? (
<div className={EXEC_LOG_DETAIL_SECTION_CLASSNAME}>
<div className={EXEC_LOG_DETAIL_LABEL_CLASSNAME}>关联产物</div>
<div className={EXEC_LOG_DETAIL_LABEL_CLASSNAME}>
关联产物
</div>
<div className={EXEC_LOG_DETAIL_LIST_CLASSNAME}>
{entry.detail.artifactPaths.map((artifactPath) => (
<div
@@ -410,10 +437,17 @@ export function GeneralWorkbenchExecLog({
<div className={EXEC_LOG_TIMELINE_CLASSNAME}>
{filteredEntries.map((entry) => (
<div key={entry.id} className={EXEC_LOG_ITEM_CLASSNAME}>
<span className={getExecLogDotClassName(entry.type, entry.status)} />
<span
className={getExecLogDotClassName(entry.type, entry.status)}
/>
<div className={EXEC_LOG_ITEM_CARD_CLASSNAME}>
<div className={EXEC_LOG_HEADER_CLASSNAME}>
<span className={getExecLogBadgeClassName(entry.type, entry.status)}>
<span
className={getExecLogBadgeClassName(
entry.type,
entry.status,
)}
>
{entry.typeLabel}
</span>
<span className={EXEC_LOG_TIME_CLASSNAME}>
@@ -424,15 +458,20 @@ export function GeneralWorkbenchExecLog({
minute: "2-digit",
second: "2-digit",
})
: new Date(entry.timestamp).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
: new Date(entry.timestamp).toLocaleTimeString(
"zh-CN",
{
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
},
)
: ""}
</span>
</div>
<div className={EXEC_LOG_CONTENT_CLASSNAME}>{entry.content}</div>
<div className={EXEC_LOG_CONTENT_CLASSNAME}>
{entry.content}
</div>
{entry.meta ? (
<div className={EXEC_LOG_META_CLASSNAME}>{entry.meta}</div>
) : null}
@@ -77,7 +77,8 @@ export function GeneralWorkbenchHarnessCard({
}: GeneralWorkbenchHarnessCardProps) {
const runStateMeta = resolveRunStateMeta(runState);
const resolvedRunTitle = runTitle?.trim() || "暂无运行记录";
const artifactSummary = artifactCount > 0 ? `${artifactCount} 个产物` : "暂无产物";
const artifactSummary =
artifactCount > 0 ? `${artifactCount} 个产物` : "暂无产物";
const updatedAtLabel = formatUpdatedAt(updatedAt);
const iconLabel = [
"工作台 Harness",
@@ -312,7 +313,9 @@ export function GeneralWorkbenchHarnessCard({
<span className="text-sm font-semibold text-foreground">
工作台 Harness
</span>
<Badge variant={runStateMeta.variant}>{runStateMeta.label}</Badge>
<Badge variant={runStateMeta.variant}>
{runStateMeta.label}
</Badge>
{pendingCount > 0 ? (
<Badge variant="destructive">{`待处理 ${pendingCount}`}</Badge>
) : null}
@@ -114,9 +114,7 @@ function renderSidebar(
searchMode: "web",
query: "品牌 2026",
previewText: "品牌讨论聚焦产品定位、渠道节奏与转化质量。",
citations: [
{ title: "官方博客", url: "https://example.com/blog" },
],
citations: [{ title: "官方博客", url: "https://example.com/blog" }],
active: true,
},
],
@@ -198,9 +196,9 @@ describe("GeneralWorkbenchSidebar", () => {
it("点击添加上下文应打开添加弹窗", () => {
const { container } = renderSidebar();
const addContextButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("添加上下文"),
);
const addContextButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.includes("添加上下文"));
expect(addContextButton).toBeTruthy();
if (addContextButton) {
act(() => {
@@ -218,9 +216,9 @@ describe("GeneralWorkbenchSidebar", () => {
const onAddTextContext = vi.fn().mockResolvedValue(undefined);
const { container } = renderSidebar({ onAddTextContext });
const addContextButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("添加上下文"),
);
const addContextButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.includes("添加上下文"));
expect(addContextButton).toBeTruthy();
if (addContextButton) {
act(() => {
@@ -346,7 +344,9 @@ describe("GeneralWorkbenchSidebar", () => {
expect(container.textContent).toContain("技能:社媒主稿与封面");
expect(container.textContent).toContain("生成内容主稿,并补齐封面素材。");
expect(container.textContent).toContain("技能标识:content_post_with_cover");
expect(container.textContent).toContain(
"技能标识:content_post_with_cover",
);
});
it("执行日志应支持展开技能详情", () => {
@@ -379,7 +379,9 @@ describe("GeneralWorkbenchSidebar", () => {
expect(container.textContent).toContain("文件读取");
expect(container.textContent).toContain("图片生成");
expect(container.textContent).toContain("适用场景");
expect(container.textContent).toContain("适合需要主稿与封面同时产出的社媒场景。");
expect(container.textContent).toContain(
"适合需要主稿与封面同时产出的社媒场景。",
);
});
it("执行日志应支持展开工具详情", () => {
@@ -625,7 +627,9 @@ describe("GeneralWorkbenchSidebar", () => {
expect(container.textContent).toContain("品牌话题观察");
expect(container.textContent).not.toContain("检索词:品牌 2026");
expect(container.textContent).not.toContain("品牌讨论聚焦产品定位");
expect((container.textContent?.match(/品牌话题观察/g) || []).length).toBe(1);
expect((container.textContent?.match(/品牌话题观察/g) || []).length).toBe(
1,
);
const openButton = container.querySelector(
'button[aria-label="查看搜索结果 品牌话题观察"]',
@@ -771,7 +775,7 @@ describe("GeneralWorkbenchSidebar", () => {
gateKey: "topic_select",
source: "skill",
artifactPaths: ["content-posts/research.md"],
inputSummary: "{\"topic\":\"AI\"}",
inputSummary: '{"topic":"AI"}',
outputSummary: "已完成选题调研",
},
{
@@ -810,7 +814,7 @@ describe("GeneralWorkbenchSidebar", () => {
expect(container.textContent).toContain("write_file");
expect(container.textContent).toContain("技能:research_topic");
expect(container.textContent).toContain("修改:content-posts/research.md");
expect(container.textContent).toContain("输入:{\"topic\":\"AI\"}");
expect(container.textContent).toContain('输入:{"topic":"AI"}');
expect(container.textContent).toContain("输出:已完成选题调研");
const runButtons = Array.from(container.querySelectorAll("button")).filter(
(button) => button.textContent === "运行:rungrp01",
@@ -1006,18 +1010,25 @@ describe("GeneralWorkbenchSidebar", () => {
});
}
expect(container.textContent).toContain("工作流:social_content_pipeline_v1");
expect(container.textContent).toContain(
"工作流:social_content_pipeline_v1",
);
expect(container.textContent).toContain("执行ID:exec-artifact-1");
expect(container.textContent).toContain("版本ID:ver-artifact-1");
expect(container.textContent).toContain("阶段:选题闸门 → 写作闸门 → 发布闸门");
expect(container.textContent).toContain(
"阶段:选题闸门 → 写作闸门 → 发布闸门",
);
expect(container.textContent).toContain("content-posts/demo.md");
expect(container.textContent).toContain("content-posts/demo.publish-pack.json");
expect(container.textContent).toContain(
"content-posts/demo.publish-pack.json",
);
const copyArtifactButton = Array.from(container.querySelectorAll("button")).find(
(button) =>
button
.getAttribute("aria-label")
?.startsWith("复制产物路径-content-posts/demo.md"),
const copyArtifactButton = Array.from(
container.querySelectorAll("button"),
).find((button) =>
button
.getAttribute("aria-label")
?.startsWith("复制产物路径-content-posts/demo.md"),
);
expect(copyArtifactButton).toBeTruthy();
if (copyArtifactButton) {
@@ -1026,13 +1037,16 @@ describe("GeneralWorkbenchSidebar", () => {
});
}
expect(mockWriteClipboardText).toHaveBeenCalledWith("content-posts/demo.md");
expect(mockWriteClipboardText).toHaveBeenCalledWith(
"content-posts/demo.md",
);
const revealArtifactButton = Array.from(container.querySelectorAll("button")).find(
(button) =>
button
.getAttribute("aria-label")
?.startsWith("定位产物路径-content-posts/demo.md"),
const revealArtifactButton = Array.from(
container.querySelectorAll("button"),
).find((button) =>
button
.getAttribute("aria-label")
?.startsWith("定位产物路径-content-posts/demo.md"),
);
expect(revealArtifactButton).toBeTruthy();
if (revealArtifactButton) {
@@ -1045,11 +1059,12 @@ describe("GeneralWorkbenchSidebar", () => {
"content-posts/demo.md",
);
const openArtifactButton = Array.from(container.querySelectorAll("button")).find(
(button) =>
button
.getAttribute("aria-label")
?.startsWith("打开产物路径-content-posts/demo.md"),
const openArtifactButton = Array.from(
container.querySelectorAll("button"),
).find((button) =>
button
.getAttribute("aria-label")
?.startsWith("打开产物路径-content-posts/demo.md"),
);
expect(openArtifactButton).toBeTruthy();
if (openArtifactButton) {
@@ -1101,11 +1116,12 @@ describe("GeneralWorkbenchSidebar", () => {
});
}
const revealArtifactButton = Array.from(container.querySelectorAll("button")).find(
(button) =>
button
.getAttribute("aria-label")
?.startsWith("定位活动产物路径-content-posts/group.md"),
const revealArtifactButton = Array.from(
container.querySelectorAll("button"),
).find((button) =>
button
.getAttribute("aria-label")
?.startsWith("定位活动产物路径-content-posts/group.md"),
);
expect(revealArtifactButton).toBeTruthy();
if (revealArtifactButton) {
@@ -1118,11 +1134,12 @@ describe("GeneralWorkbenchSidebar", () => {
"content-posts/group.md",
);
const openArtifactButton = Array.from(container.querySelectorAll("button")).find(
(button) =>
button
.getAttribute("aria-label")
?.startsWith("打开活动产物路径-content-posts/group.md"),
const openArtifactButton = Array.from(
container.querySelectorAll("button"),
).find((button) =>
button
.getAttribute("aria-label")
?.startsWith("打开活动产物路径-content-posts/group.md"),
);
expect(openArtifactButton).toBeTruthy();
if (openArtifactButton) {
@@ -1197,7 +1214,9 @@ describe("GeneralWorkbenchSidebar", () => {
const consoleWarnSpy = vi
.spyOn(console, "warn")
.mockImplementation(() => {});
mockRevealSessionFileInFinder.mockRejectedValueOnce(new Error("文件不存在"));
mockRevealSessionFileInFinder.mockRejectedValueOnce(
new Error("文件不存在"),
);
try {
const { container } = renderSidebar({
activeRunDetail: {
@@ -4,8 +4,7 @@ import { GeneralWorkbenchWorkflowPanel } from "./GeneralWorkbenchWorkflowPanel";
import type { GeneralWorkbenchSidebarTab } from "./GeneralWorkbenchSidebarShell";
import type { GeneralWorkbenchSidebarContentProps } from "./generalWorkbenchSidebarContentContract";
export interface GeneralWorkbenchSidebarPanelsProps
extends GeneralWorkbenchSidebarContentProps {
export interface GeneralWorkbenchSidebarPanelsProps extends GeneralWorkbenchSidebarContentProps {
activeTab: GeneralWorkbenchSidebarTab;
}
@@ -130,7 +130,9 @@ export function GeneralWorkbenchSidebarShell({
onClick={() => onTabChange("context")}
>
<span className={SIDEBAR_TAB_LABEL_CLASSNAME}>上下文</span>
<span className={getSidebarTabCountClassName(activeTab === "context")}>
<span
className={getSidebarTabCountClassName(activeTab === "context")}
>
{activeContextCount}
</span>
</button>
@@ -142,7 +144,9 @@ export function GeneralWorkbenchSidebarShell({
onClick={() => onTabChange("workflow")}
>
<span className={SIDEBAR_TAB_LABEL_CLASSNAME}>编排</span>
<span className={getSidebarTabCountClassName(activeTab === "workflow")}>
<span
className={getSidebarTabCountClassName(activeTab === "workflow")}
>
{branchCount}
</span>
</button>
@@ -20,7 +20,10 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import type { TopicBranchItem, TopicBranchStatus } from "../hooks/useTopicBranchBoard";
import type {
TopicBranchItem,
TopicBranchStatus,
} from "../hooks/useTopicBranchBoard";
import type { SidebarActivityLog } from "../hooks/useThemeContextWorkspace";
import type {
GeneralWorkbenchActivityLogGroup,
@@ -249,8 +252,7 @@ function getWorkflowStepRowClassName(status: StepStatus) {
"flex items-center gap-2 rounded-[12px] border px-2.5 py-2 text-sm leading-5",
status === "completed" &&
"border-emerald-200 bg-emerald-50/80 text-slate-900",
status === "active" &&
"border-sky-200 bg-sky-50/80 text-slate-900",
status === "active" && "border-sky-200 bg-sky-50/80 text-slate-900",
status !== "completed" &&
status !== "active" &&
"border-slate-200/80 bg-white/82 text-slate-500",
@@ -274,7 +276,9 @@ function getBranchStatusText(status: TopicBranchStatus): string {
return "备选";
}
function formatGateLabel(gateKey?: SidebarActivityLog["gateKey"]): string | null {
function formatGateLabel(
gateKey?: SidebarActivityLog["gateKey"],
): string | null {
if (!gateKey || gateKey === "idle") {
return null;
}
@@ -320,7 +324,8 @@ function renderActivityLogItem(
const gateLabel = formatGateLabel(group.gateKey);
const runLabel = formatRunIdShort(group.runId);
const sourceLabel = group.source?.trim() || "-";
const primaryLog = group.logs.find((log) => log.source === "skill") || group.logs[0];
const primaryLog =
group.logs.find((log) => log.source === "skill") || group.logs[0];
return (
<ActivityItem key={`activity-${group.key}`}>
@@ -351,14 +356,21 @@ function renderActivityLogItem(
<span>{log.name}</span>
<span className="ml-auto">{log.timeLabel}</span>
</ActivityTitle>
{log.inputSummary ? <ActivityMeta>输入:{log.inputSummary}</ActivityMeta> : null}
{log.outputSummary ? <ActivityMeta>输出:{log.outputSummary}</ActivityMeta> : null}
{log.inputSummary ? (
<ActivityMeta>输入:{log.inputSummary}</ActivityMeta>
) : null}
{log.outputSummary ? (
<ActivityMeta>输出:{log.outputSummary}</ActivityMeta>
) : null}
</ActivityStepItem>
))}
</ActivityStepList>
<ActionRow>
{group.runId && onViewRunDetail ? (
<RunLinkButton type="button" onClick={() => onViewRunDetail(group.runId!)}>
<RunLinkButton
type="button"
onClick={() => onViewRunDetail(group.runId!)}
>
运行:{runLabel || group.runId}
</RunLinkButton>
) : null}
@@ -445,7 +457,10 @@ function GeneralWorkbenchWorkflowPanelComponent({
<section className={cn(WORKFLOW_SECTION_CLASSNAME, "relative z-10")}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={WORKFLOW_NEW_TOPIC_BUTTON_CLASSNAME}>
<button
type="button"
className={WORKFLOW_NEW_TOPIC_BUTTON_CLASSNAME}
>
<Plus size={14} />
{isVersionMode ? "创建版本快照" : "新建分支任务"}
<ChevronDown size={12} className="ml-auto" />
@@ -492,7 +507,10 @@ function GeneralWorkbenchWorkflowPanelComponent({
</div>
<div className={WORKFLOW_STEP_LIST_CLASSNAME}>
{workflowSteps.map((step) => (
<div key={step.id} className={getWorkflowStepRowClassName(step.status)}>
<div
key={step.id}
className={getWorkflowStepRowClassName(step.status)}
>
{getStepIcon(step.status)}
<span>{step.title}</span>
</div>
@@ -503,12 +521,16 @@ function GeneralWorkbenchWorkflowPanelComponent({
<section className={WORKFLOW_SECTION_CLASSNAME}>
<div className={WORKFLOW_SECTION_TITLE_CLASSNAME}>
<span>{isVersionMode ? "产物版本" : "篇内分支"}</span>
<span className={WORKFLOW_SECTION_BADGE_CLASSNAME}>{branchItems.length}</span>
<span className={WORKFLOW_SECTION_BADGE_CLASSNAME}>
{branchItems.length}
</span>
</div>
<BranchList className="custom-scrollbar">
{branchItems.length === 0 ? (
<ActivityMeta>
{isVersionMode ? "暂无文稿版本,先生成或创建快照" : "暂无分支任务"}
{isVersionMode
? "暂无文稿版本,先生成或创建快照"
: "暂无分支任务"}
</ActivityMeta>
) : (
branchItems.map((item) => (
@@ -522,16 +544,23 @@ function GeneralWorkbenchWorkflowPanelComponent({
{getBranchStatusText(item.status)}
</StatusBadge>
{!isVersionMode ? (
<DeleteButton onClick={() => onDeleteTopic(item.id)} aria-label="删除分支">
<DeleteButton
onClick={() => onDeleteTopic(item.id)}
aria-label="删除分支"
>
<Trash2 size={12} />
</DeleteButton>
) : null}
</BranchHead>
<ActionRow>
<TinyButton onClick={() => onSetBranchStatus(item.id, "merged")}>
<TinyButton
onClick={() => onSetBranchStatus(item.id, "merged")}
>
{isVersionMode ? "设为主稿" : "采纳到主稿"}
</TinyButton>
<TinyButton onClick={() => onSetBranchStatus(item.id, "pending")}>
<TinyButton
onClick={() => onSetBranchStatus(item.id, "pending")}
>
{isVersionMode ? "标记待评审" : "标记待决策"}
</TinyButton>
</ActionRow>
@@ -554,7 +583,11 @@ function GeneralWorkbenchWorkflowPanelComponent({
className={TOGGLE_BUTTON_CLASSNAME}
onClick={onToggleCreationTasks}
>
{showCreationTasks ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{showCreationTasks ? (
<ChevronDown size={13} />
) : (
<ChevronRight size={13} />
)}
</button>
</span>
</div>
@@ -585,7 +618,9 @@ function GeneralWorkbenchWorkflowPanelComponent({
{task.absolutePath ? (
<RunDetailArtifacts>
<RunDetailArtifactRow>
<RunDetailArtifactPath>{task.absolutePath}</RunDetailArtifactPath>
<RunDetailArtifactPath>
{task.absolutePath}
</RunDetailArtifactPath>
<RunDetailActionButton
type="button"
aria-label={`复制任务文件绝对路径-${task.taskId}`}
@@ -633,7 +668,11 @@ function GeneralWorkbenchWorkflowPanelComponent({
className={TOGGLE_BUTTON_CLASSNAME}
onClick={onToggleActivityLogs}
>
{showActivityLogs ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{showActivityLogs ? (
<ChevronDown size={13} />
) : (
<ChevronRight size={13} />
)}
</button>
</span>
</div>
@@ -659,15 +698,23 @@ function GeneralWorkbenchWorkflowPanelComponent({
<RunDetailPanel>
<RunDetailTitle>运行详情</RunDetailTitle>
<RunDetailRow>ID:{activeRunDetail.id}</RunDetailRow>
<RunDetailRow>状态:{formatRunStatusLabel(activeRunDetail.status)}</RunDetailRow>
<RunDetailRow>
状态:{formatRunStatusLabel(activeRunDetail.status)}
</RunDetailRow>
{runMetadataSummary.workflow ? (
<RunDetailRow>工作流:{runMetadataSummary.workflow}</RunDetailRow>
<RunDetailRow>
工作流:{runMetadataSummary.workflow}
</RunDetailRow>
) : null}
{runMetadataSummary.executionId ? (
<RunDetailRow>执行ID:{runMetadataSummary.executionId}</RunDetailRow>
<RunDetailRow>
执行ID:{runMetadataSummary.executionId}
</RunDetailRow>
) : null}
{runMetadataSummary.versionId ? (
<RunDetailRow>版本ID:{runMetadataSummary.versionId}</RunDetailRow>
<RunDetailRow>
版本ID:{runMetadataSummary.versionId}
</RunDetailRow>
) : null}
{activeRunStagesLabel ? (
<RunDetailRow>阶段:{activeRunStagesLabel}</RunDetailRow>
@@ -696,7 +743,9 @@ function GeneralWorkbenchWorkflowPanelComponent({
<RunDetailArtifacts>
{runMetadataSummary.artifactPaths.map((artifactPath) => (
<RunDetailArtifactRow key={`run-detail-${artifactPath}`}>
<RunDetailArtifactPath>{artifactPath}</RunDetailArtifactPath>
<RunDetailArtifactPath>
{artifactPath}
</RunDetailArtifactPath>
<RunDetailActionButton
type="button"
aria-label={`复制产物路径-${artifactPath}`}
@@ -738,4 +787,6 @@ function GeneralWorkbenchWorkflowPanelComponent({
);
}
export const GeneralWorkbenchWorkflowPanel = memo(GeneralWorkbenchWorkflowPanelComponent);
export const GeneralWorkbenchWorkflowPanel = memo(
GeneralWorkbenchWorkflowPanelComponent,
);
@@ -1169,7 +1169,10 @@ describe("HarnessStatusPanel", () => {
setInputValue(strategyTextarea, "先补 runtime save,再补 UI 回归。");
}
if (regressionsTextarea) {
setInputValue(regressionsTextarea, "npm run test:contracts\nRust 定向测试");
setInputValue(
regressionsTextarea,
"npm run test:contracts\nRust 定向测试",
);
}
if (followupsTextarea) {
setInputValue(followupsTextarea, "补充 HarnessStatusPanel 测试");
@@ -346,7 +346,9 @@ export function ImageTaskViewer({
</div>
<div className="text-sm leading-6 text-slate-500">
{reason === "error"
? resolveImageUnavailableDescription(selectedTask?.mode)
? resolveImageUnavailableDescription(
selectedTask?.mode,
)
: resolveEmptyStateDescription(
selectedTask?.status,
selectedTask?.failureMessage,
@@ -108,10 +108,7 @@ function resolveDescription(preview: MessageImageWorkbenchPreview): string {
const statusMessage = preview.statusMessage?.trim();
if (
statusMessage &&
!(
preview.status !== "running" &&
isTransitionStatusMessage(statusMessage)
)
!(preview.status !== "running" && isTransitionStatusMessage(statusMessage))
) {
return statusMessage;
}
@@ -147,7 +144,9 @@ function resolveDescription(preview: MessageImageWorkbenchPreview): string {
}
}
function resolvePlaceholderLabel(preview: MessageImageWorkbenchPreview): string {
function resolvePlaceholderLabel(
preview: MessageImageWorkbenchPreview,
): string {
if (preview.status === "failed") {
return "暂未生成成功";
}
@@ -174,11 +173,11 @@ function shouldShowSourceFootnote(
): boolean {
return Boolean(
preview.mode === "edit" ||
preview.mode === "variation" ||
preview.sourceImageUrl?.trim() ||
preview.sourceImagePrompt?.trim() ||
preview.sourceImageRef?.trim() ||
preview.sourceImageCount,
preview.mode === "variation" ||
preview.sourceImageUrl?.trim() ||
preview.sourceImagePrompt?.trim() ||
preview.sourceImageRef?.trim() ||
preview.sourceImageCount,
);
}
@@ -214,7 +213,10 @@ function resolveSourceFootnote(
return `${resolveSourceLabel(preview.mode)}:${resolveSourceSummary(preview)}`;
}
function renderPlaceholder(preview: MessageImageWorkbenchPreview, reason: string) {
function renderPlaceholder(
preview: MessageImageWorkbenchPreview,
reason: string,
) {
return (
<div className="flex aspect-[16/10] items-center justify-center bg-[linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.98))] px-6 text-center">
<div className="space-y-2">
@@ -1,10 +1,6 @@
import React from "react";
import { ShieldCheck } from "lucide-react";
import {
MetaSelect,
MetaSelectIcon,
MetaSelectWrap,
} from "../styles";
import { MetaSelect, MetaSelectIcon, MetaSelectWrap } from "../styles";
import type { AgentAccessMode } from "../../../hooks/agentChatStorage";
interface InputbarAccessModeSelectProps {
@@ -326,7 +326,9 @@ describe("InputbarCore", () => {
expect(removeButton).toBeTruthy();
act(() => {
removeButton?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
removeButton?.dispatchEvent(
new MouseEvent("mousedown", { bubbles: true }),
);
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
@@ -170,7 +170,8 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
: "";
const shouldRenderMetaBar =
!shouldUseCompactFloatingComposer &&
(Boolean(leftExtra) || (toolMode === "default" && !shouldCollapseFloatingTools));
(Boolean(leftExtra) ||
(toolMode === "default" && !shouldCollapseFloatingTools));
const dictationButtonTitle = isDictationProcessing
? dictationState === "polishing"
? "语音润色中"
@@ -189,11 +190,18 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
}, [isFloatingVariant, toolMode]);
const handleCollapseComposer = useCallback(() => {
if (!isFloatingVariant || toolMode !== "attach-only" || pendingImages.length > 0) {
if (
!isFloatingVariant ||
toolMode !== "attach-only" ||
pendingImages.length > 0
) {
return;
}
const activeElement = document.activeElement;
if (activeElement && inputBarContainerRef.current?.contains(activeElement)) {
if (
activeElement &&
inputBarContainerRef.current?.contains(activeElement)
) {
return;
}
setIsComposerExpanded(false);
@@ -301,7 +309,9 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
type="button"
aria-label={`移除图片 ${index + 1}`}
onMouseDown={handleRemoveImageMouseDown}
onClick={(event) => handleRemoveImageClick(event, index)}
onClick={(event) =>
handleRemoveImageClick(event, index)
}
>
<X size={12} />
</ImageRemoveButton>
@@ -339,7 +349,9 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
onClick={handleToggleTextareaExpanded}
disabled={disabled}
className={isTextareaExpanded ? "is-active" : ""}
aria-label={isTextareaExpanded ? "收起输入框" : "展开输入框"}
aria-label={
isTextareaExpanded ? "收起输入框" : "展开输入框"
}
title={isTextareaExpanded ? "收起输入框" : "展开输入框"}
>
{isTextareaExpanded ? (
@@ -18,8 +18,11 @@ interface InputbarExecutionStrategySelectProps {
export const InputbarExecutionStrategySelect: React.FC<
InputbarExecutionStrategySelectProps
> = (props) => {
const { isFullscreen = false, executionStrategy, setExecutionStrategy } =
props;
const {
isFullscreen = false,
executionStrategy,
setExecutionStrategy,
} = props;
if (isFullscreen || !setExecutionStrategy) {
return null;
@@ -1,9 +1,5 @@
import React from "react";
import {
Lightbulb,
Globe,
Workflow,
} from "lucide-react";
import { Lightbulb, Globe, Workflow } from "lucide-react";
import { ToolButton } from "../styles";
import { isGeneralResearchTheme } from "../../../utils/generalAgentPrompt";
@@ -9,7 +9,8 @@ const mockUseProviderModels = vi.fn();
const mockResolveVisionModel = vi.fn();
vi.mock("@/hooks/useConfiguredProviders", () => ({
useConfiguredProviders: (options: unknown) => mockUseConfiguredProviders(options),
useConfiguredProviders: (options: unknown) =>
mockUseConfiguredProviders(options),
}));
vi.mock("@/hooks/useProviderModels", () => ({
@@ -131,6 +132,8 @@ describe("InputbarVisionCapabilityNotice", () => {
const container = renderNotice();
expect(container.textContent).toContain("当前 Provider 暂无可用的多模态模型");
expect(container.textContent).toContain(
"当前 Provider 暂无可用的多模态模型",
);
});
});
@@ -31,7 +31,11 @@ export const InputbarVisionCapabilityNotice: React.FC<
providerType: selectedProvider?.type,
apiHost: selectedProvider?.apiHost,
}),
[selectedProvider?.apiHost, selectedProvider?.providerId, selectedProvider?.type],
[
selectedProvider?.apiHost,
selectedProvider?.providerId,
selectedProvider?.type,
],
);
const { models, loading: modelsLoading } = useProviderModels(
@@ -158,7 +158,9 @@ describe("TeamSelectorPanel", () => {
await flushEffects();
const savedTeam = onSelectTeam.mock.calls[0]?.[0] as TeamDefinition | undefined;
const savedTeam = onSelectTeam.mock.calls[0]?.[0] as
| TeamDefinition
| undefined;
expect(savedTeam).toBeTruthy();
expect(savedTeam?.source).toBe("custom");
@@ -210,8 +210,8 @@ function TeamCard({
{role.profileId ? (
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[11px] text-slate-500">
画像 ·{" "}
{getBuiltinTeamProfileOption(role.profileId)?.label ||
role.profileId}
{getBuiltinTeamProfileOption(role.profileId)
?.label || role.profileId}
</span>
) : null}
{role.roleKey ? (
@@ -225,7 +225,8 @@ function TeamCard({
className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[11px] text-slate-500"
>
技能 ·{" "}
{getBuiltinTeamSkillOption(skillId)?.label || skillId}
{getBuiltinTeamSkillOption(skillId)?.label ||
skillId}
</span>
))}
</div>
@@ -332,7 +333,9 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
const builtinTeams = useMemo(
() =>
listBuiltinTeamDefinitions().filter((team) => matchTeamQuery(team, query)),
listBuiltinTeamDefinitions().filter((team) =>
matchTeamQuery(team, query),
),
[query],
);
@@ -374,7 +377,11 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
};
const handleStartCreate = (base?: TeamDefinition | null) => {
setDraft(base ? buildDraftFromTeam(cloneTeamDefinitionAsCustom(base)) : createBlankDraft(activeTheme));
setDraft(
base
? buildDraftFromTeam(cloneTeamDefinitionAsCustom(base))
: createBlankDraft(activeTheme),
);
};
const handleStartEdit = (team: TeamDefinition) => {
@@ -399,7 +406,8 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
theme: draft?.theme,
presetId:
draft?.presetId ||
(draft?.id && customTeams.find((team) => team.id === draft.id)?.presetId),
(draft?.id &&
customTeams.find((team) => team.id === draft.id)?.presetId),
roles: draft?.roles,
});
@@ -417,9 +425,10 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
Date.now(),
};
const nextCustomTeams = [...customTeams.filter((team) => team.id !== nextTeam.id), nextTeam].sort(
(left, right) => (right.updatedAt || 0) - (left.updatedAt || 0),
);
const nextCustomTeams = [
...customTeams.filter((team) => team.id !== nextTeam.id),
nextTeam,
].sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0));
try {
await persistCustomTeams(nextCustomTeams);
setCustomTeams(nextCustomTeams);
@@ -514,7 +523,8 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
if (!inspectorTeam) {
return (
<div className="rounded-3xl border border-dashed border-slate-200 bg-white px-5 py-6 text-sm text-slate-500">
还没有可预览的 Team。你可以从左侧模板中查看详情,或新建一个自定义 Team。
还没有可预览的 Team。你可以从左侧模板中查看详情,或新建一个自定义
Team。
</div>
);
}
@@ -550,7 +560,8 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
) : null}
</div>
<div className="mt-2 text-sm leading-6 text-slate-600">
{inspectorTeam.description || "该 Team 将作为主代理拆分子任务时的协作参考。"}
{inspectorTeam.description ||
"该 Team 将作为主代理拆分子任务时的协作参考。"}
</div>
</div>
<div className="flex flex-wrap gap-2">
@@ -939,7 +950,9 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
onChange={(event) =>
updateDraftRole(index, (item) => ({
...item,
skillIds: parseSkillIdsInput(event.target.value),
skillIds: parseSkillIdsInput(
event.target.value,
),
}))
}
data-testid={`team-role-skill-ids-input-${index}`}
@@ -1054,10 +1067,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
className="bg-slate-900 text-white hover:bg-slate-800"
onClick={() =>
handleStartCreate(
selectedTeam ||
recommendedTeam ||
builtinTeams[0] ||
null,
selectedTeam || recommendedTeam || builtinTeams[0] || null,
)
}
>
@@ -18,10 +18,7 @@ interface UseHintRoutesParams {
textareaRef: RefObject<HTMLTextAreaElement>;
}
export function useHintRoutes({
setInput,
textareaRef,
}: UseHintRoutesParams) {
export function useHintRoutes({ setInput, textareaRef }: UseHintRoutesParams) {
const [showHintPopup, setShowHintPopup] = useState(false);
const [hintRoutes, setHintRoutes] = useState<HintRouteItem[]>([]);
const [hintIndex, setHintIndex] = useState(0);
@@ -40,7 +40,8 @@ class MockFileReader {
}
function Harness() {
const { pendingImages, handlePaste, handleRemoveImage } = useImageAttachments();
const { pendingImages, handlePaste, handleRemoveImage } =
useImageAttachments();
return (
<div>
@@ -18,11 +18,17 @@ export function useImageAttachments() {
const fileInputRef = useRef<HTMLInputElement>(null);
const appendImageFile = useCallback(
async (file: File, successMessage?: string, preferredMediaType?: string) => {
async (
file: File,
successMessage?: string,
preferredMediaType?: string,
) => {
try {
const image = await readImageAttachment(file, preferredMediaType);
setPendingImages((prev) => [...prev, image]);
toast.success(successMessage ?? `已添加图片: ${file.name || "未命名图片"}`);
toast.success(
successMessage ?? `已添加图片: ${file.name || "未命名图片"}`,
);
} catch {
toast.error(`图片读取失败: ${file.name || "未命名图片"}`);
}
@@ -1,4 +1,10 @@
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import {
useCallback,
useEffect,
useRef,
useState,
type RefObject,
} from "react";
import { toast } from "sonner";
import {
cancelRecording,
@@ -32,7 +38,9 @@ function insertTranscriptAtCursor(
if (!textarea) {
return {
nextText: currentText ? `${currentText}\n${transcript}` : transcript,
cursor: currentText ? currentText.length + transcript.length + 1 : transcript.length,
cursor: currentText
? currentText.length + transcript.length + 1
: transcript.length,
};
}
@@ -40,10 +48,8 @@ function insertTranscriptAtCursor(
const selectionEnd = textarea.selectionEnd ?? currentText.length;
const before = currentText.slice(0, selectionStart);
const after = currentText.slice(selectionEnd);
const prefix =
before.length > 0 && !/[\s\n]$/.test(before) ? "\n" : "";
const suffix =
after.length > 0 && !/^[\s\n]/.test(after) ? "\n" : "";
const prefix = before.length > 0 && !/[\s\n]$/.test(before) ? "\n" : "";
const suffix = after.length > 0 && !/^[\s\n]/.test(after) ? "\n" : "";
const inserted = `${prefix}${transcript}${suffix}`;
const nextText = `${before}${inserted}${after}`;
const cursor = before.length + inserted.length;
@@ -85,13 +91,16 @@ export function useInputbarDictation({
};
}, []);
const focusTextarea = useCallback((cursor: number) => {
window.requestAnimationFrame(() => {
const textarea = textareaRef.current;
textarea?.focus();
textarea?.setSelectionRange(cursor, cursor);
});
}, [textareaRef]);
const focusTextarea = useCallback(
(cursor: number) => {
window.requestAnimationFrame(() => {
const textarea = textareaRef.current;
textarea?.focus();
textarea?.setSelectionRange(cursor, cursor);
});
},
[textareaRef],
);
const insertTranscript = useCallback(
(transcript: string) => {
@@ -29,8 +29,7 @@ export function useInputbarToolState({
);
const [isFullscreen, setIsFullscreen] = useState(false);
const webSearchEnabled =
toolStates?.webSearch ?? localToolStates.webSearch;
const webSearchEnabled = toolStates?.webSearch ?? localToolStates.webSearch;
const thinkingEnabled = toolStates?.thinking ?? localToolStates.thinking;
const subagentEnabled = toolStates?.subagent ?? localToolStates.subagent;
@@ -127,12 +126,7 @@ export function useInputbarToolState({
});
toast.info(`多代理${enabled ? "偏好已开启" : "偏好已关闭"}`);
},
[
subagentEnabled,
thinkingEnabled,
updateToolStates,
webSearchEnabled,
],
[subagentEnabled, thinkingEnabled, updateToolStates, webSearchEnabled],
);
return {
@@ -39,10 +39,10 @@ export const Container = styled.div`
export const InputBarContainer = styled.div`
position: relative;
border: 1px solid #dcfce7;
border: 1px solid rgba(110, 231, 183, 0.55);
border-radius: 22px;
padding: 10px 12px 10px 10px;
background: linear-gradient(180deg, #fdfffb 0%, #f4fbf5 100%);
background: linear-gradient(180deg, #fdfffb 0%, #f6fbf7 100%);
box-shadow:
0 10px 28px rgba(22, 101, 52, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
@@ -52,10 +52,10 @@ export const InputBarContainer = styled.div`
background 0.2s ease;
&:focus-within {
border-color: #bbf7d0;
background: linear-gradient(180deg, #ffffff 0%, #f0fdf4 100%);
border-color: rgba(74, 222, 128, 0.65);
background: linear-gradient(180deg, #ffffff 0%, #f4fbf5 100%);
box-shadow:
0 0 0 3px rgba(134, 239, 172, 0.35),
0 0 0 3px rgba(74, 222, 128, 0.22),
0 14px 32px rgba(22, 101, 52, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.92);
}
@@ -68,8 +68,8 @@ export const InputBarContainer = styled.div`
&.floating-composer {
border-radius: 20px;
padding: 16px 18px 14px 16px;
background: linear-gradient(180deg, #fcfffc 0%, #f4fcf5 100%);
border-color: #d1fae5;
background: linear-gradient(180deg, #fcfffc 0%, #f6fbf7 100%);
border-color: rgba(110, 231, 183, 0.6);
box-shadow:
0 16px 32px rgba(22, 101, 52, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.85);
@@ -81,13 +81,12 @@ export const InputBarContainer = styled.div`
}
&.floating-composer:focus-within {
background: linear-gradient(180deg, #ffffff 0%, #ecfdf5 100%);
border-color: #a7f3d0;
background: linear-gradient(180deg, #ffffff 0%, #f0fdf4 100%);
border-color: rgba(74, 222, 128, 0.7);
box-shadow:
0 0 0 3px rgba(110, 231, 183, 0.38),
0 0 0 3px rgba(74, 222, 128, 0.25),
0 18px 34px rgba(22, 101, 52, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.88);
}
`;
@@ -240,10 +239,10 @@ export const MetaSelectWrap = styled.label`
}
&:focus-within {
border-color: rgba(125, 167, 255, 0.42);
border-color: rgba(167, 243, 208, 0.42);
background: rgba(255, 255, 255, 0.98);
box-shadow:
0 0 0 3px rgba(191, 219, 254, 0.28),
0 0 0 3px rgba(167, 243, 208, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.92);
color: hsl(var(--foreground));
}
@@ -299,11 +298,9 @@ export const MetaToggleButton = styled.button<{
border-radius: 999px;
border: 1px solid
${({ $checked }) =>
$checked
? "rgba(125, 167, 255, 0.4)"
: "rgba(148, 163, 184, 0.24)"};
$checked ? "rgba(167, 243, 208, 0.4)" : "rgba(148, 163, 184, 0.24)"};
background: ${({ $checked }) =>
$checked ? "rgba(240, 246, 255, 0.98)" : "rgba(255, 255, 255, 0.9)"};
$checked ? "rgba(240, 253, 244, 0.98)" : "rgba(255, 255, 255, 0.9)"};
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
color: ${({ $checked }) =>
$checked ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"};
@@ -317,20 +314,18 @@ export const MetaToggleButton = styled.button<{
&:hover {
border-color: ${({ $checked }) =>
$checked
? "rgba(125, 167, 255, 0.5)"
: "rgba(148, 163, 184, 0.34)"};
$checked ? "rgba(167, 243, 208, 0.5)" : "rgba(148, 163, 184, 0.34)"};
background: ${({ $checked }) =>
$checked ? "rgba(244, 248, 255, 1)" : "rgba(255, 255, 255, 0.98)"};
$checked ? "rgba(220, 252, 231, 1)" : "rgba(255, 255, 255, 0.98)"};
color: hsl(var(--foreground));
transform: translateY(-1px);
}
&:focus-visible {
outline: none;
border-color: rgba(125, 167, 255, 0.44);
border-color: rgba(167, 243, 208, 0.44);
box-shadow:
0 0 0 3px rgba(191, 219, 254, 0.28),
0 0 0 3px rgba(167, 243, 208, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.92);
}
@@ -352,11 +347,9 @@ export const MetaToggleCheck = styled.span<{
border-radius: 4px;
border: 1px solid
${({ $checked }) =>
$checked
? "rgba(59, 130, 246, 0.45)"
: "rgba(148, 163, 184, 0.42)"};
$checked ? "rgba(34, 197, 94, 0.45)" : "rgba(148, 163, 184, 0.42)"};
background: ${({ $checked }) =>
$checked ? "rgba(219, 234, 254, 0.9)" : "rgba(255, 255, 255, 0.96)"};
$checked ? "rgba(220, 252, 231, 0.9)" : "rgba(255, 255, 255, 0.96)"};
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.88);
flex-shrink: 0;
@@ -429,9 +422,9 @@ export const ToolButton = styled.button`
}
&.active {
color: #0f172a;
border-color: rgba(125, 167, 255, 0.44);
background: rgba(224, 236, 255, 0.82);
color: #14532d;
border-color: rgba(167, 243, 208, 0.44);
background: rgba(220, 252, 231, 0.82);
}
span {
@@ -464,13 +457,13 @@ export const InputIconButton = styled.button<{
: "rgba(148, 163, 184, 0.28)"};
background: ${({ $primary, $destructive }) =>
$primary
? "#0f172a"
? "#1aa34e"
: $destructive
? "rgba(255, 226, 234, 0.92)"
: "rgba(255, 255, 255, 0.9)"};
color: ${({ $primary, $destructive }) =>
$primary
? "#f8fafc"
? "#f0fdf4"
: $destructive
? "#be123c"
: "hsl(var(--muted-foreground))"};
@@ -485,7 +478,7 @@ export const InputIconButton = styled.button<{
transform: translateY(-1px);
background: ${({ $primary, $destructive }) =>
$primary
? "#111c31"
? "#168840"
: $destructive
? "rgba(255, 221, 229, 1)"
: "rgba(255, 255, 255, 1)"};
@@ -498,21 +491,21 @@ export const InputIconButton = styled.button<{
}
&.is-active {
border-color: rgba(125, 167, 255, 0.44);
background: rgba(224, 236, 255, 0.82);
color: #0f172a;
border-color: rgba(167, 243, 208, 0.44);
background: rgba(220, 252, 231, 0.82);
color: #14532d;
}
&.is-recording {
border-color: rgba(120, 235, 190, 0.6);
background: rgba(120, 235, 190, 0.12);
color: #0f172a;
color: #14532d;
}
&.is-processing {
border-color: rgba(160, 200, 255, 0.6);
background: rgba(160, 200, 255, 0.12);
color: #0f172a;
border-color: rgba(167, 243, 208, 0.6);
background: rgba(167, 243, 208, 0.12);
color: #14532d;
}
&:disabled {
@@ -145,8 +145,8 @@ function renderHarness(
{
isStreaming: nextIsStreaming = isStreaming,
collapseCodeBlocks: nextCollapseCodeBlocks = collapseCodeBlocks,
shouldCollapseCodeBlock: nextShouldCollapseCodeBlock =
shouldCollapseCodeBlock,
shouldCollapseCodeBlock:
nextShouldCollapseCodeBlock = shouldCollapseCodeBlock,
showBlockActions: nextShowBlockActions = showBlockActions,
onQuoteContent: nextOnQuoteContent = onQuoteContent,
}: RenderOptions = {},
@@ -409,11 +409,7 @@ describe("MarkdownRenderer", () => {
});
it("代码块高亮应关闭 textShadow 与字体连字,避免中英混排发虚", () => {
const content = [
"```typescript",
"const answer = 42;",
"```",
].join("\n");
const content = ["```typescript", "const answer = 42;", "```"].join("\n");
const container = render(content);
const syntaxHighlighter = container.querySelector(
@@ -179,8 +179,8 @@ const MarkdownContainer = styled.div`
th,
td {
border-right: 1px solid hsl(var(--border));
border-bottom: 1px solid hsl(var(--border));
border-right: 1px solid rgba(167, 243, 208, 0.42);
border-bottom: 1px solid rgba(167, 243, 208, 0.42);
padding: 0.45rem 0.65rem;
vertical-align: top;
text-align: left;
@@ -190,10 +190,10 @@ const MarkdownContainer = styled.div`
font-weight: 600;
background: linear-gradient(
180deg,
hsl(var(--muted) / 0.78) 0%,
hsl(var(--muted) / 0.96) 100%
rgba(220, 252, 231, 0.45) 0%,
rgba(220, 252, 231, 0.8) 100%
);
color: hsl(var(--foreground));
color: #14532d;
white-space: nowrap;
}
@@ -206,7 +206,7 @@ const MarkdownContainer = styled.div`
}
tbody tr:nth-child(even) td {
background: hsl(var(--muted) / 0.22);
background: rgba(220, 252, 231, 0.15);
}
a {
@@ -245,14 +245,14 @@ const MarkdownDivider = styled.hr`
const MarkdownQuoteCard = styled.blockquote`
margin: 0 0 0.95em;
padding: 0;
border: 1px solid hsl(var(--border));
border: 1px solid rgba(167, 243, 208, 0.5);
border-radius: 20px;
background: linear-gradient(
180deg,
hsl(var(--background)) 0%,
hsl(var(--muted) / 0.58) 100%
rgba(220, 252, 231, 0.3) 100%
);
box-shadow: 0 14px 34px -30px rgba(15, 23, 42, 0.25);
box-shadow: 0 14px 34px -30px rgba(15, 23, 42, 0.18);
overflow: hidden;
`;
@@ -432,7 +432,7 @@ const MarkdownBlockActionButton = styled.button`
const MarkdownTableScroll = styled.div`
margin: 0 0 0.82em;
overflow-x: auto;
border: 1px solid hsl(var(--border));
border: 1px solid rgba(167, 243, 208, 0.6);
border-radius: 14px;
background: hsl(var(--background));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
@@ -515,11 +515,16 @@ function resolveCodePresentationMode(
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const arrowRows = lines.filter((line) => FLOW_ARROW_ONLY_PATTERN.test(line)).length;
const arrowRows = lines.filter((line) =>
FLOW_ARROW_ONLY_PATTERN.test(line),
).length;
const bulletRows = lines.filter((line) => /^[-*]\s+/.test(line)).length;
const hasCodeSignals = CODE_SIGNAL_PATTERN.test(trimmed);
if ((PLAIN_TEXT_LANGUAGES.has(normalizedLanguage) || !hasCodeSignals) && arrowRows >= 2) {
if (
(PLAIN_TEXT_LANGUAGES.has(normalizedLanguage) || !hasCodeSignals) &&
arrowRows >= 2
) {
return "flow";
}
@@ -583,23 +588,34 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
};
}, []);
const handleCopy = React.useCallback(async (copyKey: string, value: string) => {
try {
await navigator.clipboard.writeText(value);
setCopied(copyKey);
if (copyTimeoutRef.current !== null) {
window.clearTimeout(copyTimeoutRef.current);
const handleCopy = React.useCallback(
async (copyKey: string, value: string) => {
try {
await navigator.clipboard.writeText(value);
setCopied(copyKey);
if (copyTimeoutRef.current !== null) {
window.clearTimeout(copyTimeoutRef.current);
}
copyTimeoutRef.current = window.setTimeout(
() => setCopied(null),
1200,
);
} catch {
// 剪贴板在受限上下文里可能不可用,这里保持静默降级。
}
copyTimeoutRef.current = window.setTimeout(() => setCopied(null), 1200);
} catch {
// 剪贴板在受限上下文里可能不可用,这里保持静默降级。
}
}, []);
},
[],
);
const getSelectedMarkdownText = React.useCallback(() => {
const block = blockRef.current;
const selection = window.getSelection();
if (!block || !selection || selection.rangeCount === 0 || selection.isCollapsed) {
if (
!block ||
!selection ||
selection.rangeCount === 0 ||
selection.isCollapsed
) {
return null;
}
@@ -647,14 +663,18 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
const selectedText =
getSelectedMarkdownText() ?? selectionSnapshotRef.current ?? undefined;
selectionSnapshotRef.current = null;
onQuoteContent(selectedText?.trim().length ? selectedText : normalizedContent);
onQuoteContent(
selectedText?.trim().length ? selectedText : normalizedContent,
);
}, [getSelectedMarkdownText, normalizedContent, onQuoteContent]);
const handleCopyContent = React.useCallback(async () => {
const selectedText =
getSelectedMarkdownText() ?? selectionSnapshotRef.current ?? undefined;
selectionSnapshotRef.current = null;
const copyValue = selectedText?.trim().length ? selectedText : normalizedContent;
const copyValue = selectedText?.trim().length
? selectedText
: normalizedContent;
if (!copyValue) {
return;
}
@@ -880,11 +900,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
aria-label="复制内容区块"
title={isContentCopied ? "已复制" : "复制内容区块"}
>
{isContentCopied ? (
<Check size={14} />
) : (
<Copy size={14} />
)}
{isContentCopied ? <Check size={14} /> : <Copy size={14} />}
</MarkdownBlockActionButton>
</MarkdownBlockActions>
) : null}
@@ -1042,7 +1058,9 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
},
code({ inline, className, children, ...props }: any) {
const content = String(
Array.isArray(children) ? children.join("") : children || "",
Array.isArray(children)
? children.join("")
: children || "",
);
const isInlineCode =
typeof inline === "boolean"
@@ -1630,7 +1630,7 @@ describe("MessageList", () => {
id: "artifact-hidden-task-json",
type: "code",
title: "task-image-1.json",
content: "{\"status\":\"running\"}",
content: '{"status":"running"}',
status: "complete",
meta: {
filePath: ".lime/tasks/image_generate/task-image-1.json",
@@ -100,7 +100,10 @@ interface MessageListProps {
/** Artifact 点击回调 */
onArtifactClick?: (artifact: Artifact) => void;
/** 打开消息结果预览 */
onOpenMessagePreview?: (target: MessagePreviewTarget, message: Message) => void;
onOpenMessagePreview?: (
target: MessagePreviewTarget,
message: Message,
) => void;
/** 打开子代理会话 */
onOpenSubagentSession?: (sessionId: string) => void;
/** 权限确认响应回调 */
@@ -697,7 +700,9 @@ const MessageListInner: React.FC<MessageListProps> = ({
const visibleArtifacts =
artifacts?.filter(
(artifact) =>
!isHiddenInternalArtifactPath(resolveArtifactProtocolFilePath(artifact)),
!isHiddenInternalArtifactPath(
resolveArtifactProtocolFilePath(artifact),
),
) || [];
if (visibleArtifacts.length === 0) {
return null;
@@ -787,55 +792,53 @@ const MessageListInner: React.FC<MessageListProps> = ({
</p>
</div>
</div>
) : isTaskCenterEmptyState ? (
<div className="flex min-h-[22rem] items-center justify-center py-6">
<section
data-testid="message-list-empty-task-center"
className="w-full max-w-[640px] rounded-[28px] border border-slate-200/80 bg-white px-6 py-6 text-left shadow-sm shadow-slate-950/5 md:px-7 md:py-7"
>
<div className="mb-4 flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-[18px] border border-slate-200/80 bg-slate-50/80">
<img
src={logoImg}
alt="Lime"
className="h-7 w-7 opacity-80"
/>
</div>
<span className="inline-flex items-center rounded-full border border-slate-200/80 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-600">
任务中心
</span>
</div>
<div className="space-y-2">
<p className="text-[26px] font-semibold tracking-tight text-slate-900">
回到进行中的任务和最近工作现场
</p>
<p className="max-w-[46rem] text-sm leading-7 text-slate-600">
这里会承接最近会话、进行中的任务和刚恢复的工作内容。要开始一个全新目标,随时回到“新建任务”。
</p>
</div>
<div className="mt-5 flex flex-wrap gap-2">
<span className="inline-flex items-center rounded-full border border-slate-200/80 bg-white px-3 py-1.5 text-xs text-slate-500">
左侧会继续显示最近任务
</span>
<span className="inline-flex items-center rounded-full border border-slate-200/80 bg-white px-3 py-1.5 text-xs text-slate-500">
恢复中的会话会自动回到这里
</span>
</div>
</section>
</div>
) : (
isTaskCenterEmptyState ? (
<div className="flex min-h-[22rem] items-center justify-center py-6">
<section
data-testid="message-list-empty-task-center"
className="w-full max-w-[640px] rounded-[28px] border border-slate-200/80 bg-white px-6 py-6 text-left shadow-sm shadow-slate-950/5 md:px-7 md:py-7"
>
<div className="mb-4 flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-[18px] border border-slate-200/80 bg-slate-50/80">
<img
src={logoImg}
alt="Lime"
className="h-7 w-7 opacity-80"
/>
</div>
<span className="inline-flex items-center rounded-full border border-slate-200/80 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-600">
任务中心
</span>
</div>
<div className="space-y-2">
<p className="text-[26px] font-semibold tracking-tight text-slate-900">
回到进行中的任务和最近工作现场
</p>
<p className="max-w-[46rem] text-sm leading-7 text-slate-600">
这里会承接最近会话、进行中的任务和刚恢复的工作内容。要开始一个全新目标,随时回到“新建任务”。
</p>
</div>
<div className="mt-5 flex flex-wrap gap-2">
<span className="inline-flex items-center rounded-full border border-slate-200/80 bg-white px-3 py-1.5 text-xs text-slate-500">
左侧会继续显示最近任务
</span>
<span className="inline-flex items-center rounded-full border border-slate-200/80 bg-white px-3 py-1.5 text-xs text-slate-500">
恢复中的会话会自动回到这里
</span>
</div>
</section>
</div>
) : (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground opacity-50">
<img
src={logoImg}
alt="Lime"
className="w-12 h-12 mb-4 opacity-20"
/>
<p className="text-lg font-medium">开始一段新的对话吧</p>
</div>
)
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground opacity-50">
<img
src={logoImg}
alt="Lime"
className="w-12 h-12 mb-4 opacity-20"
/>
<p className="text-lg font-medium">开始一段新的对话吧</p>
</div>
))}
{messageGroups.map((group, groupIndex) => {
@@ -144,14 +144,12 @@ export function RuntimeReviewDecisionDialog({
}
}, [open, template]);
const statusOptions =
template?.decision_status_options.length
? template.decision_status_options
: DEFAULT_STATUS_OPTIONS;
const riskLevelOptions =
template?.risk_level_options.length
? template.risk_level_options
: DEFAULT_RISK_LEVEL_OPTIONS;
const statusOptions = template?.decision_status_options.length
? template.decision_status_options
: DEFAULT_STATUS_OPTIONS;
const riskLevelOptions = template?.risk_level_options.length
? template.risk_level_options
: DEFAULT_RISK_LEVEL_OPTIONS;
const handleSave = async () => {
if (!template || !formState) {
@@ -6,7 +6,11 @@ import {
Globe,
Search,
} from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type { SearchResultPreviewItem } from "../utils/searchResultPreview";
@@ -226,7 +230,10 @@ export function SearchResultPreviewList({
aria-label={expanded ? "收起搜索结果" : "展开搜索结果"}
>
<ChevronDown
className={cn("h-3.5 w-3.5 transition-transform", expanded && "rotate-180")}
className={cn(
"h-3.5 w-3.5 transition-transform",
expanded && "rotate-180",
)}
/>
<span>
{expanded ? "收起结果" : `展开其余 ${hiddenCount} 条结果`}
@@ -31,7 +31,9 @@ afterEach(() => {
}
});
function renderCard(snapshot?: Parameters<typeof TeamMemoryShadowCard>[0]["snapshot"]) {
function renderCard(
snapshot?: Parameters<typeof TeamMemoryShadowCard>[0]["snapshot"],
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
@@ -79,12 +81,16 @@ describe("TeamMemoryShadowCard", () => {
container.querySelector('[data-testid="team-memory-shadow-card"]'),
).not.toBeNull();
expect(container.textContent).toContain("协作记忆影子");
expect(container.textContent).toContain("当前仓库已缓存 3 条 Team 续接上下文");
expect(container.textContent).toContain(
"当前仓库已缓存 3 条 Team 续接上下文",
);
expect(container.textContent).toContain("/workspace/lime");
expect(container.textContent).toContain("当前 Team");
expect(container.textContent).toContain("子代理概览");
expect(container.textContent).toContain("父会话上下文");
expect(container.textContent).toContain("研究代理 [running] explorer · 梳理主线风险");
expect(container.textContent).toContain(
"研究代理 [running] explorer · 梳理主线风险",
);
expect(container.textContent).toContain("当前任务:汇总结论");
});
});
@@ -89,7 +89,8 @@ export function TeamMemoryShadowCard({
当前仓库已缓存 {entries.length} 条 Team 续接上下文
</div>
<p className="mt-1 text-xs leading-5 text-slate-500">
仅保存在当前仓库本地作用域,用来续接 Team 选择、子代理概览和父会话上下文。
仅保存在当前仓库本地作用域,用来续接 Team
选择、子代理概览和父会话上下文。
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -115,27 +115,26 @@ export function TeamWorkbenchSummaryPanel({
.slice(0, 4);
const displayRoleCount = hasRealTeamGraph
? executionSummary.totalSessionCount
: dispatchPreviewState?.members.length ?? 0;
const roleCards =
dispatchPreviewState?.members.length
? dispatchPreviewState.members.map((member) => ({
id: member.id,
label: member.label,
roleKey: member.roleKey,
profileId: member.profileId,
summary: member.summary,
skillIds: member.skillIds,
statusMeta: resolveRuntimeMemberStatusMeta(member.status),
}))
: (selectedTeamRoles ?? []).map((role) => ({
id: role.id,
label: role.label,
roleKey: role.roleKey,
profileId: role.profileId,
summary: role.summary,
skillIds: role.skillIds ?? [],
statusMeta: null,
}));
: (dispatchPreviewState?.members.length ?? 0);
const roleCards = dispatchPreviewState?.members.length
? dispatchPreviewState.members.map((member) => ({
id: member.id,
label: member.label,
roleKey: member.roleKey,
profileId: member.profileId,
summary: member.summary,
skillIds: member.skillIds,
statusMeta: resolveRuntimeMemberStatusMeta(member.status),
}))
: (selectedTeamRoles ?? []).map((role) => ({
id: role.id,
label: role.label,
roleKey: role.roleKey,
profileId: role.profileId,
summary: role.summary,
skillIds: role.skillIds ?? [],
statusMeta: null,
}));
const summaryCards = [
{
label: "活跃 Agent",
@@ -159,7 +158,9 @@ export function TeamWorkbenchSummaryPanel({
? "当前成员"
: "总会话",
value: String(
hasRealTeamGraph ? executionSummary.totalSessionCount : displayRoleCount,
hasRealTeamGraph
? executionSummary.totalSessionCount
: displayRoleCount,
),
hint: hasRealTeamGraph
? "已进入团队图谱"
@@ -201,7 +202,7 @@ export function TeamWorkbenchSummaryPanel({
? dispatchPreviewState.errorMessage?.trim() ||
"这次 Team 准备失败,可继续在当前对话中推进。"
: runtimeSummaryText ||
"这里展示团队总览与运行密度;主对话只保留调度记录,角色执行正文在左侧 Team 画布查看。"}
"这里展示团队总览与运行密度;主对话只保留调度记录,角色执行正文在左侧 Team 画布查看。"}
</p>
{!hasRealTeamGraph && dispatchPreviewState ? (
<div
@@ -221,7 +222,8 @@ export function TeamWorkbenchSummaryPanel({
) : null}
{!hasRealTeamGraph && !dispatchPreviewState ? (
<div className="mt-3 rounded-2xl border border-sky-100 bg-sky-50 px-3 py-2 text-xs leading-5 text-sky-700">
尚未出现真实团队成员。开始分派成员后,左侧 Team 画布会自动切换为实时协作视图。
尚未出现真实团队成员。开始分派成员后,左侧 Team
画布会自动切换为实时协作视图。
</div>
) : null}
</section>
@@ -10,10 +10,9 @@ const { mockGetAgentRuntimeSession } = vi.hoisted(() => ({
}));
vi.mock("@/lib/api/agentRuntime", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api/agentRuntime")>(
"@/lib/api/agentRuntime",
);
const actual = await vi.importActual<typeof import("@/lib/api/agentRuntime")>(
"@/lib/api/agentRuntime",
);
return {
...actual,
getAgentRuntimeSession: mockGetAgentRuntimeSession,
@@ -34,12 +33,7 @@ vi.mock("@/components/ui/button", () => ({
type?: "button" | "submit" | "reset";
[key: string]: unknown;
}) => (
<button
type={type}
className={className}
onClick={onClick}
{...props}
>
<button type={type} className={className} onClick={onClick} {...props}>
{children}
</button>
),
@@ -413,7 +407,9 @@ describe("TeamWorkspaceBoard", () => {
expect(container.textContent).toContain("仓库探索");
expect(container.textContent).toContain("最近进展 处理中");
expect(
container.querySelector('[data-testid="team-workspace-member-detail-child-1"]'),
container.querySelector(
'[data-testid="team-workspace-member-detail-child-1"]',
),
).toBeFalsy();
expect(
container
@@ -453,7 +449,9 @@ describe("TeamWorkspaceBoard", () => {
await expandLane(container, "child-1");
expect(
container.querySelector('[data-testid="team-workspace-member-detail-child-1"]'),
container.querySelector(
'[data-testid="team-workspace-member-detail-child-1"]',
),
).toBeTruthy();
expect(
container
@@ -651,7 +649,9 @@ describe("TeamWorkspaceBoard", () => {
expect(container.textContent).toContain("检索代理");
expect(container.textContent).toContain("等待中 1");
expect(
container.querySelector('[data-testid="team-workspace-member-detail-child-current"]'),
container.querySelector(
'[data-testid="team-workspace-member-detail-child-current"]',
),
).toBeFalsy();
expect(
container.querySelector('[data-testid="team-workspace-canvas-toolbar"]')
@@ -765,10 +765,14 @@ describe("TeamWorkspaceBoard", () => {
expect(boardBody?.className).toContain("overflow-y-auto");
expect(railList?.getAttribute("data-layout-kind")).toBe("free-canvas");
expect(
container.querySelector('[data-testid="team-workspace-inspector-overlay"]'),
container.querySelector(
'[data-testid="team-workspace-inspector-overlay"]',
),
).toBeNull();
expect(
container.querySelector('[data-testid="team-workspace-member-detail-child-1"]'),
container.querySelector(
'[data-testid="team-workspace-member-detail-child-1"]',
),
).toBeNull();
expect(
container
@@ -812,45 +816,63 @@ describe("TeamWorkspaceBoard", () => {
});
expect(
container.querySelector('[data-testid="team-workspace-member-detail-child-collapse-1"]'),
container.querySelector(
'[data-testid="team-workspace-member-detail-child-collapse-1"]',
),
).toBeFalsy();
expect(
container.querySelector('[data-testid="team-workspace-member-detail-child-collapse-2"]'),
container.querySelector(
'[data-testid="team-workspace-member-detail-child-collapse-2"]',
),
).toBeFalsy();
expect(
container
.querySelector('[data-testid="team-workspace-member-lane-child-collapse-1"]')
.querySelector(
'[data-testid="team-workspace-member-lane-child-collapse-1"]',
)
?.getAttribute("data-expanded"),
).toBe("false");
expect(
container
.querySelector('[data-testid="team-workspace-member-lane-child-collapse-2"]')
.querySelector(
'[data-testid="team-workspace-member-lane-child-collapse-2"]',
)
?.getAttribute("data-expanded"),
).toBe("false");
await expandLane(container, "child-collapse-1");
expect(
container.querySelector('[data-testid="team-workspace-member-detail-child-collapse-1"]'),
container.querySelector(
'[data-testid="team-workspace-member-detail-child-collapse-1"]',
),
).toBeTruthy();
expect(
container
.querySelector('[data-testid="team-workspace-member-lane-child-collapse-1"]')
.querySelector(
'[data-testid="team-workspace-member-lane-child-collapse-1"]',
)
?.getAttribute("data-expanded"),
).toBe("true");
expect(
container
.querySelector('[data-testid="team-workspace-member-lane-child-collapse-2"]')
.querySelector(
'[data-testid="team-workspace-member-lane-child-collapse-2"]',
)
?.getAttribute("data-expanded"),
).toBe("false");
await expandLane(container, "child-collapse-2");
expect(
container.querySelector('[data-testid="team-workspace-member-detail-child-collapse-1"]'),
container.querySelector(
'[data-testid="team-workspace-member-detail-child-collapse-1"]',
),
).toBeFalsy();
expect(
container.querySelector('[data-testid="team-workspace-member-detail-child-collapse-2"]'),
container.querySelector(
'[data-testid="team-workspace-member-detail-child-collapse-2"]',
),
).toBeTruthy();
});
@@ -889,8 +911,7 @@ describe("TeamWorkspaceBoard", () => {
title: "状态切换",
detail: "收到 team 状态事件,已切换为运行中。",
statusLabel: "运行中",
badgeClassName:
"border border-sky-200 bg-sky-50 text-sky-700",
badgeClassName: "border border-sky-200 bg-sky-50 text-sky-700",
},
],
},
@@ -910,7 +931,9 @@ describe("TeamWorkspaceBoard", () => {
).toContain("处理中");
expect(container.textContent).toContain("最近进展 处理中");
expect(
container.querySelector('[data-testid="team-workspace-member-detail-child-live-1"]'),
container.querySelector(
'[data-testid="team-workspace-member-detail-child-live-1"]',
),
).toBeFalsy();
await expandLane(container, "child-live-1");
@@ -1428,22 +1451,23 @@ describe("TeamWorkspaceBoard", () => {
});
it("同一会话重新挂载后,应恢复上次保存的 lane 布局", async () => {
const boardProps: Partial<React.ComponentProps<typeof TeamWorkspaceBoard>> = {
currentSessionId: "parent-persist-1",
childSubagentSessions: [
{
id: "child-persist-1",
name: "持久化代理",
created_at: 1_710_000_000,
updated_at: 1_710_000_100,
session_type: "sub_agent",
runtime_status: "running",
latest_turn_status: "running",
task_summary: "验证会话级画布持久化",
role_hint: "reviewer",
},
],
};
const boardProps: Partial<React.ComponentProps<typeof TeamWorkspaceBoard>> =
{
currentSessionId: "parent-persist-1",
childSubagentSessions: [
{
id: "child-persist-1",
name: "持久化代理",
created_at: 1_710_000_000,
updated_at: 1_710_000_100,
session_type: "sub_agent",
runtime_status: "running",
latest_turn_status: "running",
task_summary: "验证会话级画布持久化",
role_hint: "reviewer",
},
],
};
const firstContainer = await renderBoard(boardProps);
const firstHeader = firstContainer.querySelector(
@@ -1490,7 +1514,9 @@ describe("TeamWorkspaceBoard", () => {
await expandLane(container, "child-close-1");
expect(
container.querySelector('[data-testid="team-workspace-member-detail-child-close-1"]'),
container.querySelector(
'[data-testid="team-workspace-member-detail-child-close-1"]',
),
).toBeTruthy();
const closeButton = Array.from(container.querySelectorAll("button")).find(
@@ -1532,7 +1558,9 @@ describe("TeamWorkspaceBoard", () => {
);
expect(resumeButton).toBeTruthy();
expect(
container.querySelector('[data-testid="team-workspace-member-detail-child-resume-1"]'),
container.querySelector(
'[data-testid="team-workspace-member-detail-child-resume-1"]',
),
).toBeTruthy();
await act(async () => {
@@ -1768,9 +1796,7 @@ describe("TeamWorkspaceBoard", () => {
expect(operations).toBeTruthy();
expect(operations?.textContent).toContain("暂停处理");
expect(operations?.textContent).toContain(
"刚才已暂停 2 位协作成员的处理",
);
expect(operations?.textContent).toContain("刚才已暂停 2 位协作成员的处理");
});
it("点击 Team 轨迹项时应切换焦点到对应 agent", async () => {
@@ -1973,7 +1999,9 @@ describe("TeamWorkspaceBoard", () => {
});
await act(async () => {
interruptButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
interruptButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
await Promise.resolve();
});
@@ -2081,7 +2109,9 @@ describe("TeamWorkspaceBoard", () => {
});
expect(
container.querySelector('[data-testid="team-workspace-runtime-formation"]'),
container.querySelector(
'[data-testid="team-workspace-runtime-formation"]',
),
).toBeTruthy();
expect(
container.querySelector('[data-testid="team-workspace-runtime-members"]'),
@@ -106,9 +106,7 @@ interface TeamWorkspaceBoardProps {
sessionIds: string[],
timeoutMs?: number,
) => void | Promise<void>;
onCloseCompletedTeamSessions?: (
sessionIds: string[],
) => void | Promise<void>;
onCloseCompletedTeamSessions?: (sessionIds: string[]) => void | Promise<void>;
onCloseSubagentSession?: (sessionId: string) => void | Promise<void>;
onResumeSubagentSession?: (sessionId: string) => void | Promise<void>;
onOpenSubagentSession?: (sessionId: string) => void | Promise<void>;
@@ -279,8 +277,8 @@ function canStartCanvasPanGesture(
}
return (
target.closest('[data-team-workspace-canvas-pan-surface="true"]') !== null ||
target === currentTarget
target.closest('[data-team-workspace-canvas-pan-surface="true"]') !==
null || target === currentTarget
);
}
@@ -349,7 +347,8 @@ function resolveActivityEntryStatusMeta(
case "completed":
return {
label: "完成",
badgeClassName: "border border-emerald-200 bg-emerald-50 text-emerald-700",
badgeClassName:
"border border-emerald-200 bg-emerald-50 text-emerald-700",
};
default:
return {
@@ -483,7 +482,9 @@ function isEditableKeyboardTarget(target: EventTarget | null): boolean {
);
}
function buildRuntimeDetailSummary(session?: TeamSessionCard | null): string | null {
function buildRuntimeDetailSummary(
session?: TeamSessionCard | null,
): string | null {
if (!session) {
return null;
}
@@ -500,9 +501,14 @@ function buildRuntimeDetailSummary(session?: TeamSessionCard | null): string | n
session.teamActiveCount !== undefined &&
session.teamParallelBudget !== undefined
) {
parts.push(`处理中 ${session.teamActiveCount}/${session.teamParallelBudget}`);
parts.push(
`处理中 ${session.teamActiveCount}/${session.teamParallelBudget}`,
);
}
if (session.providerParallelBudget === 1 && session.providerConcurrencyGroup) {
if (
session.providerParallelBudget === 1 &&
session.providerConcurrencyGroup
) {
parts.push(resolveTeamWorkspaceStableProcessingLabel());
}
@@ -512,8 +518,8 @@ function buildRuntimeDetailSummary(session?: TeamSessionCard | null): string | n
function isWaitableTeamSession(session?: TeamSessionCard | null) {
return Boolean(
session &&
session.sessionType !== "user" &&
!isTeamWorkspaceTerminalStatus(session.runtimeStatus),
session.sessionType !== "user" &&
!isTeamWorkspaceTerminalStatus(session.runtimeStatus),
);
}
@@ -540,7 +546,8 @@ function buildTeamWaitSummaryDisplay(
}
const resolvedName = summary.resolvedSessionId
? sessionNameById.get(summary.resolvedSessionId) ?? summary.resolvedSessionId
? (sessionNameById.get(summary.resolvedSessionId) ??
summary.resolvedSessionId)
: "成员";
const normalizedStatus = summary.resolvedStatus
? normalizeTeamWorkspaceRuntimeStatus(summary.resolvedStatus)
@@ -563,7 +570,7 @@ function buildTeamControlSummaryDisplay(
const affectedCount = summary.affectedSessionIds.length;
const firstAffectedId = summary.affectedSessionIds[0];
const firstAffectedName = firstAffectedId
? sessionNameById.get(firstAffectedId) ?? firstAffectedId
? (sessionNameById.get(firstAffectedId) ?? firstAffectedId)
: "成员";
switch (summary.action) {
@@ -765,7 +772,10 @@ function resolveSessionBlueprintRoleId(
) {
score += 8;
}
if (sessionRoleKey && normalizeComparableText(role.roleKey) === sessionRoleKey) {
if (
sessionRoleKey &&
normalizeComparableText(role.roleKey) === sessionRoleKey
) {
score += 4;
}
if (
@@ -788,10 +798,7 @@ function resolveSessionBlueprintRoleId(
if (candidates.length === 0) {
return null;
}
if (
candidates.length > 1 &&
candidates[0]?.score === candidates[1]?.score
) {
if (candidates.length > 1 && candidates[0]?.score === candidates[1]?.score) {
return null;
}
@@ -809,7 +816,7 @@ function orderSessionsByRuntimeRoles(
const runtimeRoles = (
teamDispatchPreviewState.members.length > 0
? teamDispatchPreviewState.members
: teamDispatchPreviewState.blueprint?.roles ?? []
: (teamDispatchPreviewState.blueprint?.roles ?? [])
).map((role) => ({
id: role.id,
label: role.label,
@@ -821,7 +828,9 @@ function orderSessionsByRuntimeRoles(
return sessions;
}
const roleOrder = new Map(runtimeRoles.map((role, index) => [role.id, index]));
const roleOrder = new Map(
runtimeRoles.map((role, index) => [role.id, index]),
);
const usedRoleIds = new Set<string>();
return [...sessions]
@@ -973,7 +982,10 @@ function buildSessionLaneEmptyState(params: {
return "这部分已经完成,结果会继续汇入当前内容。";
}
if (session?.runtimeStatus === "failed" || session?.runtimeStatus === "aborted") {
if (
session?.runtimeStatus === "failed" ||
session?.runtimeStatus === "aborted"
) {
return "这一步没有顺利完成,你可以在下方查看细节并决定是否继续。";
}
@@ -1052,7 +1064,10 @@ function resolveCanvasLanePreferredSize(params: {
params.viewportWidth,
);
const gapX = TEAM_WORKSPACE_CANVAS_AUTO_LAYOUT_GAP_X;
const safeViewportWidth = Math.max(params.viewportWidth, columns >= 3 ? 1180 : 980);
const safeViewportWidth = Math.max(
params.viewportWidth,
columns >= 3 ? 1180 : 980,
);
const usableWidth =
safeViewportWidth -
TEAM_WORKSPACE_CANVAS_VIEWPORT_PADDING * 2 -
@@ -1060,7 +1075,11 @@ function resolveCanvasLanePreferredSize(params: {
const rawWidth = Math.floor(usableWidth / columns);
const width =
params.laneKind === "session"
? clampCanvasNumber(rawWidth, 340, columns === 1 ? 560 : columns === 2 ? 460 : 390)
? clampCanvasNumber(
rawWidth,
340,
columns === 1 ? 560 : columns === 2 ? 460 : 390,
)
: clampCanvasNumber(rawWidth - 20, 320, columns === 1 ? 520 : 380);
const height =
params.laneKind === "session"
@@ -1083,8 +1102,7 @@ function buildCanvasStageHint(params: {
hasRuntimeFormation,
hasSelectedTeamPlan,
teamDispatchPreviewState,
} =
params;
} = params;
if (hasRealTeamGraph) {
return "拖动画布空白处可平移,滚轮配合 Ctrl/Cmd 可缩放,拖动成员卡片可调整布局。";
@@ -1166,7 +1184,10 @@ function resolveLaneMatchingRuntimeMemberId(
}
const explicitRoleId = session.blueprintRoleId?.trim();
if (explicitRoleId && runtimeMembers.some((member) => member.id === explicitRoleId)) {
if (
explicitRoleId &&
runtimeMembers.some((member) => member.id === explicitRoleId)
) {
return explicitRoleId;
}
@@ -1181,10 +1202,16 @@ function resolveLaneMatchingRuntimeMemberId(
const candidates = runtimeMembers
.map((member) => {
let score = 0;
if (normalizedRoleLabel && normalizeComparableText(member.label) === normalizedRoleLabel) {
if (
normalizedRoleLabel &&
normalizeComparableText(member.label) === normalizedRoleLabel
) {
score += 8;
}
if (normalizedRoleKey && normalizeComparableText(member.roleKey) === normalizedRoleKey) {
if (
normalizedRoleKey &&
normalizeComparableText(member.roleKey) === normalizedRoleKey
) {
score += 5;
}
if (
@@ -1204,10 +1231,7 @@ function resolveLaneMatchingRuntimeMemberId(
if (candidates.length === 0) {
return null;
}
if (
candidates.length > 1 &&
candidates[0]?.score === candidates[1]?.score
) {
if (candidates.length > 1 && candidates[0]?.score === candidates[1]?.score) {
return null;
}
return candidates[0]?.memberId ?? null;
@@ -1232,10 +1256,16 @@ function resolveLaneMatchingPlannedRoleId(
const candidates = plannedRoles
.map((role) => {
let score = 0;
if (normalizedRoleLabel && normalizeComparableText(role.label) === normalizedRoleLabel) {
if (
normalizedRoleLabel &&
normalizeComparableText(role.label) === normalizedRoleLabel
) {
score += 8;
}
if (normalizedRoleKey && normalizeComparableText(role.roleKey) === normalizedRoleKey) {
if (
normalizedRoleKey &&
normalizeComparableText(role.roleKey) === normalizedRoleKey
) {
score += 5;
}
if (
@@ -1255,10 +1285,7 @@ function resolveLaneMatchingPlannedRoleId(
if (candidates.length === 0) {
return null;
}
if (
candidates.length > 1 &&
candidates[0]?.score === candidates[1]?.score
) {
if (candidates.length > 1 && candidates[0]?.score === candidates[1]?.score) {
return null;
}
return candidates[0]?.roleId ?? null;
@@ -1279,10 +1306,16 @@ function resolveRuntimeMemberMatchingPlannedRoleId(
const candidates = plannedRoles
.map((role) => {
let score = 0;
if (normalizedRoleLabel && normalizeComparableText(role.label) === normalizedRoleLabel) {
if (
normalizedRoleLabel &&
normalizeComparableText(role.label) === normalizedRoleLabel
) {
score += 8;
}
if (normalizedRoleKey && normalizeComparableText(role.roleKey) === normalizedRoleKey) {
if (
normalizedRoleKey &&
normalizeComparableText(role.roleKey) === normalizedRoleKey
) {
score += 5;
}
if (
@@ -1302,10 +1335,7 @@ function resolveRuntimeMemberMatchingPlannedRoleId(
if (candidates.length === 0) {
return null;
}
if (
candidates.length > 1 &&
candidates[0]?.score === candidates[1]?.score
) {
if (candidates.length > 1 && candidates[0]?.score === candidates[1]?.score) {
return null;
}
return candidates[0]?.roleId ?? null;
@@ -1403,12 +1433,12 @@ export function TeamWorkspaceBoard({
"team-workspace";
const [shellExpanded, setShellExpanded] = useState(defaultShellExpanded);
const detailExpanded = !embedded;
const canvasViewportFallbackHeight =
embedded && !detailExpanded ? 720 : 560;
const canvasViewportFallbackHeight = embedded && !detailExpanded ? 720 : 560;
const [canvasLayoutState, setCanvasLayoutState] =
useState<TeamWorkspaceCanvasLayoutState>(() =>
loadTeamWorkspaceCanvasLayout(canvasStorageScopeId) ??
createDefaultTeamWorkspaceCanvasLayoutState(),
useState<TeamWorkspaceCanvasLayoutState>(
() =>
loadTeamWorkspaceCanvasLayout(canvasStorageScopeId) ??
createDefaultTeamWorkspaceCanvasLayoutState(),
);
const [pendingSessionAction, setPendingSessionAction] = useState<{
sessionId: string;
@@ -1438,12 +1468,11 @@ export function TeamWorkspaceBoard({
width: 960,
height: embedded ? 720 : 560,
});
const canvasLayoutStateRef = useRef<TeamWorkspaceCanvasLayoutState>(
canvasLayoutState,
);
const canvasLaneLayoutsRef = useRef<Record<string, TeamWorkspaceCanvasItemLayout>>(
{},
);
const canvasLayoutStateRef =
useRef<TeamWorkspaceCanvasLayoutState>(canvasLayoutState);
const canvasLaneLayoutsRef = useRef<
Record<string, TeamWorkspaceCanvasItemLayout>
>({});
const canvasInteractionCleanupRef = useRef<(() => void) | null>(null);
useEffect(() => {
@@ -1602,10 +1631,8 @@ export function TeamWorkspaceBoard({
() =>
baseVisibleSessions.map(
(session) =>
applyLiveRuntimeState(
session,
liveRuntimeBySessionId[session.id],
) ?? session,
applyLiveRuntimeState(session, liveRuntimeBySessionId[session.id]) ??
session,
),
[baseVisibleSessions, liveRuntimeBySessionId],
);
@@ -1871,12 +1898,14 @@ export function TeamWorkspaceBoard({
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
() => railSessions[0]?.id ?? null,
);
const [expandedSessionId, setExpandedSessionId] = useState<string | null>(null);
const [expandedSessionId, setExpandedSessionId] = useState<string | null>(
null,
);
useEffect(() => {
const defaultSelectedId = isChildSession
? currentSessionId ?? railSessions[0]?.id ?? null
: memberCanvasSessions[0]?.id ?? railSessions[0]?.id ?? null;
? (currentSessionId ?? railSessions[0]?.id ?? null)
: (memberCanvasSessions[0]?.id ?? railSessions[0]?.id ?? null);
if (!selectedSessionId) {
setSelectedSessionId(defaultSelectedId);
@@ -1909,7 +1938,9 @@ export function TeamWorkspaceBoard({
return;
}
if (!memberCanvasSessions.some((session) => session.id === expandedSessionId)) {
if (
!memberCanvasSessions.some((session) => session.id === expandedSessionId)
) {
setExpandedSessionId(null);
}
}, [expandedSessionId, memberCanvasSessions]);
@@ -1929,7 +1960,7 @@ export function TeamWorkspaceBoard({
[baseRailSessions, selectedSessionId],
);
const selectedSessionActivityPreview = selectedSession
? sessionActivityPreviewById[selectedSession.id] ?? null
? (sessionActivityPreviewById[selectedSession.id] ?? null)
: null;
const selectedSessionActivityEntries = selectedSession
? mergeSessionActivityEntries(
@@ -1946,15 +1977,15 @@ export function TeamWorkspaceBoard({
selectedSession && selectedSession.sessionType !== "user",
);
const selectedSessionActivityId = selectedSessionSupportsActivityPreview
? selectedSession?.id ?? null
? (selectedSession?.id ?? null)
: null;
const selectedSessionActivityFingerprint = selectedSessionSupportsActivityPreview
? buildSessionActivityFingerprint(selectedBaseSession)
: null;
const selectedSessionActivityRefreshVersion =
selectedSessionActivityId
? (activityRefreshVersionBySessionId[selectedSessionActivityId] ?? 0)
: 0;
const selectedSessionActivityFingerprint =
selectedSessionSupportsActivityPreview
? buildSessionActivityFingerprint(selectedBaseSession)
: null;
const selectedSessionActivityRefreshVersion = selectedSessionActivityId
? (activityRefreshVersionBySessionId[selectedSessionActivityId] ?? 0)
: 0;
const selectedSessionActivityShouldPoll =
selectedSessionSupportsActivityPreview &&
shouldPollSessionActivity(selectedSession);
@@ -2073,7 +2104,8 @@ export function TeamWorkspaceBoard({
!selectedSessionActivityShouldPoll &&
cachedPreview?.status === "ready" &&
cachedPreview.fingerprint === fingerprint &&
(cachedPreview.refreshVersion ?? 0) === selectedSessionActivityRefreshVersion
(cachedPreview.refreshVersion ?? 0) ===
selectedSessionActivityRefreshVersion
) {
return;
}
@@ -2086,7 +2118,8 @@ export function TeamWorkspaceBoard({
selectedSessionActivityRefreshVersion,
{
force:
(current?.refreshVersion ?? 0) < selectedSessionActivityRefreshVersion,
(current?.refreshVersion ?? 0) <
selectedSessionActivityRefreshVersion,
},
);
};
@@ -2139,7 +2172,8 @@ export function TeamWorkspaceBoard({
const refreshVersion =
activityRefreshVersionBySessionId[session.id] ?? 0;
const cachedPreview = sessionActivityPreviewByIdRef.current[session.id];
const cachedPreview =
sessionActivityPreviewByIdRef.current[session.id];
return syncSessionActivityPreview(
session.id,
buildSessionActivityFingerprint(session),
@@ -2187,7 +2221,8 @@ export function TeamWorkspaceBoard({
normalizedSelectedTeamRoles,
);
const presetLabel = session.teamPresetId
? getTeamPresetOption(session.teamPresetId)?.label ?? session.teamPresetId
? (getTeamPresetOption(session.teamPresetId)?.label ??
session.teamPresetId)
: undefined;
return {
@@ -2219,7 +2254,8 @@ export function TeamWorkspaceBoard({
.map((skill) => buildSkillDisplayName(skill)),
session,
previewText:
cardActivityPreview || buildSessionLaneEmptyState({ session, previewState }),
cardActivityPreview ||
buildSessionLaneEmptyState({ session, previewState }),
previewEntries: mergedEntries.slice(0, 3),
};
}),
@@ -2232,70 +2268,64 @@ export function TeamWorkspaceBoard({
],
);
const canvasBlueprintLanes = useMemo<TeamWorkspaceCanvasLane[]>(
() => {
if (hasRealTeamGraph) {
return [];
}
const canvasBlueprintLanes = useMemo<TeamWorkspaceCanvasLane[]>(() => {
if (hasRealTeamGraph) {
return [];
}
if (runtimeMembers.length > 0) {
return runtimeMembers.map((member) => {
const laneSummary = buildCanvasLaneTitleSummary(member);
const matchedPlannedRoleId = resolveRuntimeMemberMatchingPlannedRoleId(
member,
normalizedSelectedTeamRoles,
);
return {
id: member.id,
persistKey: `runtime:${member.id}`,
fallbackPersistKeys: matchedPlannedRoleId
? [`planned:${matchedPlannedRoleId}`]
: [],
kind: "runtime" as const,
title: member.label,
summary: laneSummary.summary,
badgeLabel: laneSummary.badgeLabel,
badgeClassName: laneSummary.badgeClassName,
dotClassName: laneSummary.dotClassName,
roleLabel: resolveTeamWorkspaceRoleHintLabel(member.roleKey) || undefined,
profileLabel: undefined,
statusHint: laneSummary.statusHint,
updatedAtLabel: "等待成员加入",
skillLabels: [],
previewText: member.summary,
previewEntries: [],
};
});
}
return normalizedSelectedTeamRoles.map((role) => {
const laneSummary = buildPlannedRoleLaneSummary(role);
if (runtimeMembers.length > 0) {
return runtimeMembers.map((member) => {
const laneSummary = buildCanvasLaneTitleSummary(member);
const matchedPlannedRoleId = resolveRuntimeMemberMatchingPlannedRoleId(
member,
normalizedSelectedTeamRoles,
);
return {
id: role.id,
persistKey: `planned:${role.id}`,
fallbackPersistKeys: [],
kind: "planned" as const,
title: role.label,
id: member.id,
persistKey: `runtime:${member.id}`,
fallbackPersistKeys: matchedPlannedRoleId
? [`planned:${matchedPlannedRoleId}`]
: [],
kind: "runtime" as const,
title: member.label,
summary: laneSummary.summary,
badgeLabel: laneSummary.badgeLabel,
badgeClassName: laneSummary.badgeClassName,
dotClassName: laneSummary.dotClassName,
roleLabel: resolveTeamWorkspaceRoleHintLabel(role.roleKey) || undefined,
roleLabel:
resolveTeamWorkspaceRoleHintLabel(member.roleKey) || undefined,
profileLabel: undefined,
statusHint: laneSummary.statusHint,
updatedAtLabel: "计划分工",
updatedAtLabel: "等待成员加入",
skillLabels: [],
previewText: role.summary,
previewText: member.summary,
previewEntries: [],
};
});
},
[
hasRealTeamGraph,
normalizedSelectedTeamRoles,
runtimeMembers,
],
);
}
return normalizedSelectedTeamRoles.map((role) => {
const laneSummary = buildPlannedRoleLaneSummary(role);
return {
id: role.id,
persistKey: `planned:${role.id}`,
fallbackPersistKeys: [],
kind: "planned" as const,
title: role.label,
summary: laneSummary.summary,
badgeLabel: laneSummary.badgeLabel,
badgeClassName: laneSummary.badgeClassName,
dotClassName: laneSummary.dotClassName,
roleLabel: resolveTeamWorkspaceRoleHintLabel(role.roleKey) || undefined,
profileLabel: undefined,
statusHint: laneSummary.statusHint,
updatedAtLabel: "计划分工",
skillLabels: [],
previewText: role.summary,
previewEntries: [],
};
});
}, [hasRealTeamGraph, normalizedSelectedTeamRoles, runtimeMembers]);
const canvasLanes = hasRealTeamGraph
? canvasSessionLanes
@@ -2406,10 +2436,7 @@ export function TeamWorkspaceBoard({
}, []);
const bindCanvasMouseInteraction = useCallback(
(
onMove: (event: MouseEvent) => void,
onEnd?: () => void,
) => {
(onMove: (event: MouseEvent) => void, onEnd?: () => void) => {
canvasInteractionCleanupRef.current?.();
const handleMouseMove = (event: MouseEvent) => {
@@ -2581,7 +2608,11 @@ export function TeamWorkspaceBoard({
}));
});
},
[bindCanvasMouseInteraction, isCanvasPanModifierActive, updateCanvasViewport],
[
bindCanvasMouseInteraction,
isCanvasPanModifierActive,
updateCanvasViewport,
],
);
const handleStartCanvasLaneDrag = useCallback(
@@ -2610,7 +2641,11 @@ export function TeamWorkspaceBoard({
}));
});
},
[bindCanvasMouseInteraction, bringCanvasLaneToFront, updateCanvasLaneLayout],
[
bindCanvasMouseInteraction,
bringCanvasLaneToFront,
updateCanvasLaneLayout,
],
);
const handleStartCanvasLaneResize = useCallback(
@@ -2680,7 +2715,11 @@ export function TeamWorkspaceBoard({
});
});
},
[bindCanvasMouseInteraction, bringCanvasLaneToFront, updateCanvasLaneLayout],
[
bindCanvasMouseInteraction,
bringCanvasLaneToFront,
updateCanvasLaneLayout,
],
);
const handleCanvasWheel = useCallback(
@@ -2720,7 +2759,9 @@ export function TeamWorkspaceBoard({
}, [updateCanvasViewport]);
const handleResetCanvasView = useCallback(() => {
updateCanvasViewport(() => createDefaultTeamWorkspaceCanvasLayoutState().viewport);
updateCanvasViewport(
() => createDefaultTeamWorkspaceCanvasLayoutState().viewport,
);
}, [updateCanvasViewport]);
const handleAutoArrangeCanvas = useCallback(() => {
if (canvasLanes.length === 0) {
@@ -2985,8 +3026,10 @@ export function TeamWorkspaceBoard({
return null;
}
return [...teamControlSummary.requestedSessionIds, ...teamControlSummary.affectedSessionIds]
.some((sessionId) => railSessionNameById.has(sessionId))
return [
...teamControlSummary.requestedSessionIds,
...teamControlSummary.affectedSessionIds,
].some((sessionId) => railSessionNameById.has(sessionId))
? teamControlSummary
: null;
}, [railSessionNameById, teamControlSummary]);
@@ -2998,7 +3041,8 @@ export function TeamWorkspaceBoard({
teamControlSummary: visibleTeamControlSummary,
}).filter(
(entry) =>
!entry.targetSessionId || railSessions.some((session) => session.id === entry.targetSessionId),
!entry.targetSessionId ||
railSessions.some((session) => session.id === entry.targetSessionId),
),
[
railSessionNameById,
@@ -3024,38 +3068,40 @@ export function TeamWorkspaceBoard({
);
const canOpenSelectedSession = Boolean(
selectedSession &&
onOpenSubagentSession &&
selectedSession.id !== currentSessionId,
onOpenSubagentSession &&
selectedSession.id !== currentSessionId,
);
const canWaitSelectedSession = Boolean(
selectedSession && isWaitableTeamSession(selectedSession) && onWaitSubagentSession,
selectedSession &&
isWaitableTeamSession(selectedSession) &&
onWaitSubagentSession,
);
const canSendSelectedSessionInput = Boolean(
selectedSession &&
selectedSession.sessionType !== "user" &&
selectedSession.runtimeStatus !== "closed" &&
onSendSubagentInput &&
selectedSession.id !== currentSessionId,
selectedSession.sessionType !== "user" &&
selectedSession.runtimeStatus !== "closed" &&
onSendSubagentInput &&
selectedSession.id !== currentSessionId,
);
const canStopSelectedSession = Boolean(
selectedSession &&
selectedSession.sessionType !== "user" &&
isTeamWorkspaceActiveStatus(
selectedSession.runtimeStatus ?? selectedSession.latestTurnStatus,
) &&
onCloseSubagentSession,
selectedSession.sessionType !== "user" &&
isTeamWorkspaceActiveStatus(
selectedSession.runtimeStatus ?? selectedSession.latestTurnStatus,
) &&
onCloseSubagentSession,
);
const canResumeSelectedSession = Boolean(
selectedSession &&
selectedSession.sessionType !== "user" &&
selectedSession.runtimeStatus === "closed" &&
onResumeSubagentSession,
selectedSession.sessionType !== "user" &&
selectedSession.runtimeStatus === "closed" &&
onResumeSubagentSession,
);
const selectedActionPending = Boolean(
selectedSession && pendingSessionAction?.sessionId === selectedSession.id,
);
const selectedSessionInputDraft = selectedSession
? sessionInputDraftById[selectedSession.id] ?? ""
? (sessionInputDraftById[selectedSession.id] ?? "")
: "";
const selectedSessionInputMessage = selectedSessionInputDraft.trim();
const handleWaitAnyActiveTeamSessions = useCallback(async () => {
@@ -3090,7 +3136,9 @@ export function TeamWorkspaceBoard({
if (!entry.targetSessionId) {
return;
}
if (!railSessions.some((session) => session.id === entry.targetSessionId)) {
if (
!railSessions.some((session) => session.id === entry.targetSessionId)
) {
return;
}
setSelectedSessionId(entry.targetSessionId);
@@ -3282,7 +3330,9 @@ export function TeamWorkspaceBoard({
const selectedStatusMeta = resolveStatusMeta(selectedSession?.runtimeStatus);
const detailVisible =
isEmptyShellState || !hasRealTeamGraph ? detailExpanded || shellExpanded : false;
isEmptyShellState || !hasRealTeamGraph
? detailExpanded || shellExpanded
: false;
const detailToggleLabel = detailVisible ? "收起细节" : "查看细节";
const boardHeadline =
!hasRealTeamGraph && runtimeFormationMeta
@@ -3310,7 +3360,9 @@ export function TeamWorkspaceBoard({
});
const useCompactCanvasChrome = hasRealTeamGraph;
const runtimeDetailSummary = buildRuntimeDetailSummary(selectedSession);
const selectedPresetOption = getTeamPresetOption(selectedSession?.teamPresetId);
const selectedPresetOption = getTeamPresetOption(
selectedSession?.teamPresetId,
);
const selectedSkills = selectedSession?.skills ?? [];
const selectedMetadata = [
selectedSession?.blueprintRoleLabel
@@ -3327,7 +3379,9 @@ export function TeamWorkspaceBoard({
selectedSession?.createdFromTurnId
? `来自之前的任务 ${selectedSession.createdFromTurnId}`
: null,
selectedSession && (selectedSession.teamQueuedCount ?? selectedSession.queuedTurnCount ?? 0) > 0
selectedSession &&
(selectedSession.teamQueuedCount ?? selectedSession.queuedTurnCount ?? 0) >
0
? `等待中 ${selectedSession.teamQueuedCount ?? selectedSession.queuedTurnCount}`
: null,
selectedSession?.teamActiveCount !== undefined &&
@@ -3368,7 +3422,9 @@ export function TeamWorkspaceBoard({
const boardBodyClassName = embedded
? cn(
"min-h-0 flex-1 overflow-y-auto overscroll-contain",
useCompactCanvasChrome ? "p-3 sm:p-3.5 space-y-2.5" : "p-3 sm:p-4 space-y-3",
useCompactCanvasChrome
? "p-3 sm:p-3.5 space-y-2.5"
: "p-3 sm:p-4 space-y-3",
)
: cn(useCompactCanvasChrome ? "p-3 sm:p-3.5" : "p-3 sm:p-4");
const canvasStageHeight =
@@ -3376,7 +3432,10 @@ export function TeamWorkspaceBoard({
? "clamp(560px, 76vh, 980px)"
: TEAM_WORKSPACE_CANVAS_STAGE_HEIGHT;
const railCardClassName = embedded
? cn("pointer-events-auto", useCompactCanvasChrome ? "space-y-3" : "space-y-4")
? cn(
"pointer-events-auto",
useCompactCanvasChrome ? "space-y-3" : "space-y-4",
)
: "rounded-[22px] border border-slate-200 bg-slate-50 p-3.5 shadow-sm shadow-slate-950/5";
const detailCardClassName = cn(
embedded
@@ -3523,7 +3582,8 @@ export function TeamWorkspaceBoard({
) : null}
{resolveTeamWorkspaceRoleHintLabel(selectedSession?.roleKey) ? (
<span className="rounded-full border border-slate-200 bg-white px-2.5 py-1">
分工 {resolveTeamWorkspaceRoleHintLabel(selectedSession?.roleKey)}
分工{" "}
{resolveTeamWorkspaceRoleHintLabel(selectedSession?.roleKey)}
</span>
) : null}
{selectedSession?.theme ? (
@@ -3602,7 +3662,9 @@ export function TeamWorkspaceBoard({
<Button
type="button"
size="sm"
disabled={selectedActionPending || !selectedSessionInputMessage}
disabled={
selectedActionPending || !selectedSessionInputMessage
}
onClick={() => void handleSelectedSessionSendInput(false)}
>
{selectedActionPending &&
@@ -3618,7 +3680,9 @@ export function TeamWorkspaceBoard({
type="button"
size="sm"
variant="outline"
disabled={selectedActionPending || !selectedSessionInputMessage}
disabled={
selectedActionPending || !selectedSessionInputMessage
}
onClick={() => void handleSelectedSessionSendInput(true)}
>
{selectedActionPending &&
@@ -3682,7 +3746,10 @@ export function TeamWorkspaceBoard({
</div>
<div className="mt-3 space-y-2.5">
{selectedSessionActivityEntries.map((entry) => (
<div key={entry.id} className={inlineTimelineEntryClassName}>
<div
key={entry.id}
className={inlineTimelineEntryClassName}
>
<div className="flex flex-wrap items-center gap-2 text-[11px]">
<span className="font-semibold text-slate-800">
{entry.title}
@@ -3758,16 +3825,16 @@ export function TeamWorkspaceBoard({
来自之前的任务 {subagentParentContext.created_from_turn_id}
</span>
) : null}
{!useCompactCanvasChrome && !isChildSession && totalTeamSessions > 0 ? (
{!useCompactCanvasChrome &&
!isChildSession &&
totalTeamSessions > 0 ? (
<span className="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-1 text-[11px] text-sky-700">
{totalTeamSessions} 位协作成员
</span>
) : null}
</div>
{!useCompactCanvasChrome ? (
<p className="mt-1 text-xs leading-5 text-slate-500">
{boardHint}
</p>
<p className="mt-1 text-xs leading-5 text-slate-500">{boardHint}</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -3789,30 +3856,28 @@ export function TeamWorkspaceBoard({
{detailToggleLabel}
</Button>
) : null}
{isEmptyShellState
? (
<span className="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[11px] text-slate-500">
{runtimeFormationMeta?.label || "还没有协作成员加入"}
</span>
)
: !useCompactCanvasChrome
? Object.entries(statusSummary)
.filter(([, count]) => count > 0)
.map(([status, count]) => {
const meta = resolveStatusMeta(status as RuntimeStatus);
return (
<span
key={status}
className={cn(
"inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-medium",
meta.badgeClassName,
)}
>
{meta.label} {count}
</span>
);
})
: null}
{isEmptyShellState ? (
<span className="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[11px] text-slate-500">
{runtimeFormationMeta?.label || "还没有协作成员加入"}
</span>
) : !useCompactCanvasChrome ? (
Object.entries(statusSummary)
.filter(([, count]) => count > 0)
.map(([status, count]) => {
const meta = resolveStatusMeta(status as RuntimeStatus);
return (
<span
key={status}
className={cn(
"inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-medium",
meta.badgeClassName,
)}
>
{meta.label} {count}
</span>
);
})
) : null}
{isChildSession && onReturnToParentSession ? (
<Button
type="button"
@@ -3974,8 +4039,8 @@ export function TeamWorkspaceBoard({
</div>
{!useCompactCanvasChrome &&
(canWaitAnyActiveTeamSession ||
canCloseCompletedTeamSessions ||
teamOperationEntries.length > 0) ? (
canCloseCompletedTeamSessions ||
teamOperationEntries.length > 0) ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
{canWaitAnyActiveTeamSession ? (
<Button
@@ -3989,9 +4054,9 @@ export function TeamWorkspaceBoard({
{pendingTeamAction === "wait_any" ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : null}
{pendingTeamAction === "wait_any"
? "等待中..."
: "等待任一成员结果"}
{pendingTeamAction === "wait_any"
? "等待中..."
: "等待任一成员结果"}
</Button>
) : null}
{canWaitAnyActiveTeamSession ? (
@@ -4011,9 +4076,9 @@ export function TeamWorkspaceBoard({
{pendingTeamAction === "close_completed" ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : null}
{pendingTeamAction === "close_completed"
? "收起中..."
: "收起已完成成员"}
{pendingTeamAction === "close_completed"
? "收起中..."
: "收起已完成成员"}
</Button>
) : null}
{canCloseCompletedTeamSessions ? (
@@ -4098,10 +4163,12 @@ export function TeamWorkspaceBoard({
const content = (
<>
<div className="flex flex-wrap items-center gap-2">
<span className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium",
entry.badgeClassName,
)}>
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium",
entry.badgeClassName,
)}
>
{entry.title}
</span>
<span className="text-[11px] text-slate-500">
@@ -4146,7 +4213,12 @@ export function TeamWorkspaceBoard({
)
) : null}
<div className={cn("mt-3", useCompactCanvasChrome ? "space-y-2.5" : "space-y-3")}>
<div
className={cn(
"mt-3",
useCompactCanvasChrome ? "space-y-2.5" : "space-y-3",
)}
>
{!useCompactCanvasChrome ? (
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2 text-[11px] text-slate-500">
@@ -4241,7 +4313,9 @@ export function TeamWorkspaceBoard({
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] font-semibold tracking-[0.12em] text-slate-500">
画布
</span>
<span className="truncate">空白处拖拽 · Space 手型 · A 整理 · F 适应</span>
<span className="truncate">
空白处拖拽 · Space 手型 · A 整理 · F 适应
</span>
</div>
{canvasLanes.length > 0 ? (
<div
@@ -4370,7 +4444,10 @@ export function TeamWorkspaceBoard({
data-testid={`team-workspace-member-lane-header-${lane.id}`}
className="flex cursor-grab items-start justify-between gap-3 border-b border-slate-200 bg-slate-50/88 px-4 py-3 active:cursor-grabbing"
onMouseDown={(event) =>
handleStartCanvasLaneDrag(lane.persistKey, event)
handleStartCanvasLaneDrag(
lane.persistKey,
event,
)
}
>
<div className="min-w-0 flex-1">
@@ -4426,21 +4503,24 @@ export function TeamWorkspaceBoard({
<p className="text-sm leading-6 text-slate-600">
{lane.summary}
</p>
{(lane.skillLabels.length > 0 || lane.presetLabel) ? (
{lane.skillLabels.length > 0 ||
lane.presetLabel ? (
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-[11px] text-slate-500">
{lane.presetLabel ? (
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5">
{lane.presetLabel}
</span>
) : null}
{lane.skillLabels.slice(0, 4).map((skillLabel) => (
<span
key={`${lane.persistKey}-${skillLabel}`}
className="rounded-full border border-slate-200 bg-white px-2 py-0.5"
>
{skillLabel}
</span>
))}
{lane.skillLabels
.slice(0, 4)
.map((skillLabel) => (
<span
key={`${lane.persistKey}-${skillLabel}`}
className="rounded-full border border-slate-200 bg-white px-2 py-0.5"
>
{skillLabel}
</span>
))}
</div>
) : null}
<div className="mt-3 flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-slate-200 bg-slate-50">
@@ -4448,14 +4528,17 @@ export function TeamWorkspaceBoard({
<PanelTop className="h-3 w-3" />
<span>成员进展</span>
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-medium tracking-normal text-slate-600 normal-case">
{lane.kind === "session" ? "最近进展" : "等待接入"}
{lane.kind === "session"
? "最近进展"
: "等待接入"}
</span>
</div>
<div className="min-h-0 flex-1 overflow-auto px-3 py-3">
<p className="whitespace-pre-wrap break-words text-[12px] leading-5 text-slate-700">
{lane.previewText}
</p>
{lane.previewEntries && lane.previewEntries.length > 0 ? (
{lane.previewEntries &&
lane.previewEntries.length > 0 ? (
<div className="mt-3 space-y-2">
{lane.previewEntries.map((entry) => (
<div
@@ -4505,7 +4588,9 @@ export function TeamWorkspaceBoard({
) : null}
</div>
</div>
{expanded ? renderSelectedSessionInlineDetail() : null}
{expanded
? renderSelectedSessionInlineDetail()
: null}
</div>
</div>
{resizeHandles.map((handle) => (
@@ -8,19 +8,15 @@ import { TeamWorkspaceDock } from "./TeamWorkspaceDock";
const { mockGetAgentRuntimeSession } = vi.hoisted(() => ({
mockGetAgentRuntimeSession: vi.fn(),
}));
const {
mockSafeListen,
mockParseAgentEvent,
} = vi.hoisted(() => ({
const { mockSafeListen, mockParseAgentEvent } = vi.hoisted(() => ({
mockSafeListen: vi.fn(),
mockParseAgentEvent: vi.fn((payload: unknown) => payload),
}));
vi.mock("@/lib/api/agentRuntime", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api/agentRuntime")>(
"@/lib/api/agentRuntime",
);
const actual = await vi.importActual<typeof import("@/lib/api/agentRuntime")>(
"@/lib/api/agentRuntime",
);
return {
...actual,
getAgentRuntimeSession: mockGetAgentRuntimeSession,
@@ -28,10 +24,9 @@ vi.mock("@/lib/api/agentRuntime", async () => {
});
vi.mock("@/lib/api/agentProtocol", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api/agentProtocol")>(
"@/lib/api/agentProtocol",
);
const actual = await vi.importActual<
typeof import("@/lib/api/agentProtocol")
>("@/lib/api/agentProtocol");
return {
...actual,
parseAgentEvent: mockParseAgentEvent,
@@ -138,7 +133,9 @@ async function renderDock(
nextProps?: Partial<React.ComponentProps<typeof TeamWorkspaceDock>>,
) => {
await act(async () => {
root.render(<TeamWorkspaceDock {...defaultProps} {...props} {...nextProps} />);
root.render(
<TeamWorkspaceDock {...defaultProps} {...props} {...nextProps} />,
);
await Promise.resolve();
});
await flushDockEffects();
@@ -180,9 +177,13 @@ describe("TeamWorkspaceDock", () => {
expect(document.body.textContent).toContain("当前协作方案:前端联调团队");
expect(document.body.textContent).toContain("分析、实现、验证三段式推进。");
expect(document.body.textContent).not.toContain("查看详情");
expect(document.body.querySelector('[data-testid="team-workspace-empty-card"]')).toBeTruthy();
expect(
document.body.querySelector('[data-testid="team-workspace-selected-team"]'),
document.body.querySelector('[data-testid="team-workspace-empty-card"]'),
).toBeTruthy();
expect(
document.body.querySelector(
'[data-testid="team-workspace-selected-team"]',
),
).toBeTruthy();
});
@@ -33,11 +33,7 @@ const DockContainer = styled.div<{
$placement === "inline" ? "relative" : "absolute"};
right: ${({ $placement }) => ($placement === "inline" ? "auto" : "14px")};
bottom: ${({ $placement, $withBottomOverlay }) =>
$placement === "inline"
? "auto"
: $withBottomOverlay
? "108px"
: "16px"};
$placement === "inline" ? "auto" : $withBottomOverlay ? "108px" : "16px"};
z-index: ${({ $placement }) => ($placement === "inline" ? 120 : 18)};
display: flex;
flex-direction: column;
@@ -98,8 +94,8 @@ const DockPanel = styled.div<{
: "min(560px, calc(100vw - 40px))"};
max-height: ${({ $compact }) =>
$compact
? "min(220px, calc(100vh - 200px))"
: "min(520px, calc(100vh - 148px))"};
? "min(220px, calc(100vh - 200px))"
: "min(520px, calc(100vh - 148px))"};
}
@media (max-width: 720px) {
@@ -333,7 +329,9 @@ const EmptyStateBadge = styled.span<{ $tone?: "neutral" | "success" }>`
border-radius: 999px;
border: 1px solid
${({ $tone }) =>
$tone === "success" ? "rgba(167, 243, 208, 0.96)" : "rgba(226, 232, 240, 0.96)"};
$tone === "success"
? "rgba(167, 243, 208, 0.96)"
: "rgba(226, 232, 240, 0.96)"};
background: ${({ $tone }) => ($tone === "success" ? "#ecfdf5" : "#f8fafc")};
color: ${({ $tone }) => ($tone === "success" ? "#047857" : "#64748b")};
padding: 4px 10px;
@@ -458,9 +456,7 @@ interface TeamWorkspaceDockProps {
sessionIds: string[],
timeoutMs?: number,
) => void | Promise<void>;
onCloseCompletedTeamSessions?: (
sessionIds: string[],
) => void | Promise<void>;
onCloseCompletedTeamSessions?: (sessionIds: string[]) => void | Promise<void>;
onCloseSubagentSession?: (sessionId: string) => void | Promise<void>;
onResumeSubagentSession?: (sessionId: string) => void | Promise<void>;
onOpenSubagentSession?: (sessionId: string) => void | Promise<void>;
@@ -557,7 +553,8 @@ export function TeamWorkspaceDock({
(selectedTeamRoles?.length ?? 0) > 0;
const activeRuntimeSessionCount = childSubagentSessions.filter(
(session) =>
session.runtime_status === "queued" || session.runtime_status === "running",
session.runtime_status === "queued" ||
session.runtime_status === "running",
).length;
const runningRuntimeSessionCount = childSubagentSessions.filter(
(session) => session.runtime_status === "running",
@@ -580,14 +577,15 @@ export function TeamWorkspaceDock({
? `${runningRuntimeSessionCount} 位处理中`
: null;
const teamQueueBadgeText =
(teamConcurrencySnapshot?.team_queued_count ?? queuedRuntimeSessionCount) > 0
(teamConcurrencySnapshot?.team_queued_count ?? queuedRuntimeSessionCount) >
0
? `${teamConcurrencySnapshot?.team_queued_count ?? queuedRuntimeSessionCount} 位等待中`
: null;
const teamQueueReason =
teamConcurrencySnapshot?.queue_reason?.trim() || null;
const launcherStatusMeta = useMemo<
{ label: string; tone: "idle" | "active" | "success" | "error" } | null
>(() => {
const teamQueueReason = teamConcurrencySnapshot?.queue_reason?.trim() || null;
const launcherStatusMeta = useMemo<{
label: string;
tone: "idle" | "active" | "success" | "error";
} | null>(() => {
if (!launcherOnly) {
return null;
}
@@ -604,8 +602,7 @@ export function TeamWorkspaceDock({
if (activeRuntimeSessionCount > 0) {
return {
label:
teamConcurrencyBadgeText ||
`${activeRuntimeSessionCount} 位处理中`,
teamConcurrencyBadgeText || `${activeRuntimeSessionCount} 位处理中`,
tone: "active",
};
}
@@ -645,11 +642,7 @@ export function TeamWorkspaceDock({
}
if (hasRealTeamGraph) {
if (teamConcurrencyBadgeText || teamQueueBadgeText) {
return [
"查看任务进展",
teamConcurrencyBadgeText,
teamQueueBadgeText,
]
return ["查看任务进展", teamConcurrencyBadgeText, teamQueueBadgeText]
.filter(Boolean)
.join(" · ");
}
@@ -693,10 +686,8 @@ export function TeamWorkspaceDock({
return;
}
const graphAppeared =
!previousHasRealGraphRef.current && hasRealTeamGraph;
const graphCleared =
previousHasRealGraphRef.current && !hasRealTeamGraph;
const graphAppeared = !previousHasRealGraphRef.current && hasRealTeamGraph;
const graphCleared = previousHasRealGraphRef.current && !hasRealTeamGraph;
if (graphCleared) {
setUserDismissedAutoExpand(false);
@@ -777,12 +768,12 @@ export function TeamWorkspaceDock({
liveRuntimeBySessionId={liveRuntimeBySessionId}
liveActivityBySessionId={liveActivityBySessionId}
activityRefreshVersionBySessionId={activityRefreshVersionBySessionId}
onSendSubagentInput={onSendSubagentInput}
onWaitSubagentSession={onWaitSubagentSession}
onWaitActiveTeamSessions={onWaitActiveTeamSessions}
onCloseCompletedTeamSessions={onCloseCompletedTeamSessions}
onCloseSubagentSession={onCloseSubagentSession}
onResumeSubagentSession={onResumeSubagentSession}
onSendSubagentInput={onSendSubagentInput}
onWaitSubagentSession={onWaitSubagentSession}
onWaitActiveTeamSessions={onWaitActiveTeamSessions}
onCloseCompletedTeamSessions={onCloseCompletedTeamSessions}
onCloseSubagentSession={onCloseSubagentSession}
onResumeSubagentSession={onResumeSubagentSession}
onOpenSubagentSession={onOpenSubagentSession}
onReturnToParentSession={onReturnToParentSession}
teamWaitSummary={teamWaitSummary}
@@ -811,18 +802,16 @@ export function TeamWorkspaceDock({
{runtimeFormationMeta?.title || "等待协作成员加入"}
</EmptyStateTitle>
<EmptyStateDescription>
{dispatchPreviewState?.status === "forming"
? "系统正在按当前任务准备协作分工,成员接入后会自动展开完整协作面板。"
: dispatchPreviewState?.status === "formed"
? `已准备 ${dispatchPreviewState.members.length} 位协作成员,后续会自动接入并继续处理。`
: dispatchPreviewState?.status === "failed"
? dispatchPreviewState.errorMessage?.trim() ||
"这次协作准备失败,你仍然可以继续在当前对话中处理。"
: (
<>
需要时系统会自动邀请协作成员加入,这里会切换成完整协作面板。
</>
)}
{dispatchPreviewState?.status === "forming" ? (
"系统正在按当前任务准备协作分工,成员接入后会自动展开完整协作面板。"
) : dispatchPreviewState?.status === "formed" ? (
`已准备 ${dispatchPreviewState.members.length} 位协作成员,后续会自动接入并继续处理。`
) : dispatchPreviewState?.status === "failed" ? (
dispatchPreviewState.errorMessage?.trim() ||
"这次协作准备失败,你仍然可以继续在当前对话中处理。"
) : (
<>需要时系统会自动邀请协作成员加入,这里会切换成完整协作面板。</>
)}
</EmptyStateDescription>
{teamQueueReason ? (
<EmptyStateBadge>{teamQueueReason}</EmptyStateBadge>
@@ -836,7 +825,9 @@ export function TeamWorkspaceDock({
data-testid="team-workspace-selected-team-toggle"
>
<div>
<EmptyStateDetailTitle>{runtimeTeamLabel}</EmptyStateDetailTitle>
<EmptyStateDetailTitle>
{runtimeTeamLabel}
</EmptyStateDetailTitle>
<EmptyStateDetailHint>
{dispatchPreviewState
? "查看当前协作分工与参考方案"
@@ -856,7 +847,9 @@ export function TeamWorkspaceDock({
<EmptyStateRoleName>
{dispatchPreviewState ? "当前摘要" : "方案摘要"}
</EmptyStateRoleName>
<EmptyStateRoleSummary>{runtimeTeamSummary}</EmptyStateRoleSummary>
<EmptyStateRoleSummary>
{runtimeTeamSummary}
</EmptyStateRoleSummary>
</EmptyStateRoleItem>
) : null}
{dispatchPreviewState?.members.map((member) => {
@@ -883,7 +876,9 @@ export function TeamWorkspaceDock({
</span>
) : null}
</div>
<EmptyStateRoleSummary>{member.summary}</EmptyStateRoleSummary>
<EmptyStateRoleSummary>
{member.summary}
</EmptyStateRoleSummary>
{member.skillIds.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{member.skillIds.map((skillId) => (
@@ -903,7 +898,9 @@ export function TeamWorkspaceDock({
? selectedTeamRoles?.map((role) => (
<EmptyStateRoleItem key={`dock-role-${role.id}`}>
<EmptyStateRoleName>{role.label}</EmptyStateRoleName>
<EmptyStateRoleSummary>{role.summary}</EmptyStateRoleSummary>
<EmptyStateRoleSummary>
{role.summary}
</EmptyStateRoleSummary>
</EmptyStateRoleItem>
))
: null}
@@ -932,7 +929,9 @@ export function TeamWorkspaceDock({
<EmptyStateBadge>{runtimeTeamSummary}</EmptyStateBadge>
) : null}
{dispatchPreviewState?.members.length ? (
<EmptyStateBadge>{dispatchPreviewState.members.length} 个成员</EmptyStateBadge>
<EmptyStateBadge>
{dispatchPreviewState.members.length} 个成员
</EmptyStateBadge>
) : null}
<EmptyStateBadge>不遮挡画布</EmptyStateBadge>
</EmptyStateFooter>
@@ -164,7 +164,9 @@ describe("ToolCallDisplay", () => {
expect(
container.querySelector('[data-testid="tool-call-rendered-result"]'),
).not.toBeNull();
expect(container.textContent).toContain("本次检索未返回可解析链接,请稍后重试。");
expect(container.textContent).toContain(
"本次检索未返回可解析链接,请稍后重试。",
);
expect(container.textContent).not.toContain("查看原始输出");
});
@@ -189,7 +191,9 @@ describe("ToolCallDisplay", () => {
{
id: "tool-search-2",
name: "WebSearch",
arguments: JSON.stringify({ query: "March 13 2026 world headlines" }),
arguments: JSON.stringify({
query: "March 13 2026 world headlines",
}),
status: "completed",
result: { success: true, output: "https://example.com/2" },
startTime: new Date("2026-03-13T12:00:02.000Z"),
@@ -207,7 +211,9 @@ describe("ToolCallDisplay", () => {
expect(container.textContent).toContain("3月13日国际新闻");
expect(container.textContent).toContain("March 13 2026 world headlines");
expect(container.textContent).toContain("搜索 3月13日国际新闻");
expect(container.textContent).toContain("搜索 March 13 2026 world headlines");
expect(container.textContent).toContain(
"搜索 March 13 2026 world headlines",
);
expect(container.textContent).toContain("中文日期检索");
expect(container.textContent).toContain("头条检索");
});
@@ -246,7 +252,9 @@ describe("ToolCallDisplay", () => {
mountedRoots.push({ container, root });
const groups = container.querySelectorAll('[data-testid="tool-call-work-group"]');
const groups = container.querySelectorAll(
'[data-testid="tool-call-work-group"]',
);
expect(groups).toHaveLength(1);
expect(container.textContent).toContain("已执行 2 条命令");
expect(container.textContent).toContain("2");
@@ -254,7 +262,9 @@ describe("ToolCallDisplay", () => {
expect(container.textContent).toContain("ls -la");
act(() => {
const groupToggle = groups[0]?.querySelector("button") as HTMLButtonElement | null;
const groupToggle = groups[0]?.querySelector(
"button",
) as HTMLButtonElement | null;
groupToggle?.click();
});
@@ -324,8 +334,7 @@ describe("ToolCallDisplay", () => {
project_root_path: "/Users/coso/.proxycast/projects/project-1",
markdown_relative_path:
"exports/x-article-export/github-mcp/index.md",
images_relative_dir:
"exports/x-article-export/github-mcp/images",
images_relative_dir: "exports/x-article-export/github-mcp/images",
image_count: 7,
},
saved_project_id: "project-1",
@@ -359,7 +368,9 @@ describe("ToolCallDisplay", () => {
expect(container.textContent).toContain(
"图片资源:7 张 · exports/x-article-export/github-mcp/images",
);
expect(container.textContent).toContain("脚本来源:服务端脚本 · 2026-03-25");
expect(container.textContent).toContain(
"脚本来源:服务端脚本 · 2026-03-25",
);
});
it("站点能力工具结果应支持直接打开已保存内容", () => {
@@ -559,7 +570,10 @@ describe("ToolCallDisplay", () => {
{
id: "tool-remote-trigger-1",
name: "RemoteTrigger",
arguments: JSON.stringify({ action: "run", trigger_id: "remote-1" }),
arguments: JSON.stringify({
action: "run",
trigger_id: "remote-1",
}),
status: "completed",
result: { success: true, output: "ok" },
startTime: new Date("2026-03-20T12:20:14.000Z"),
@@ -13,12 +13,7 @@ import React, {
useCallback,
} from "react";
import { open as openExternal } from "@tauri-apps/plugin-shell";
import {
ChevronDown,
ChevronRight,
Loader2,
ExternalLink,
} from "lucide-react";
import { ChevronDown, ChevronRight, Loader2, ExternalLink } from "lucide-react";
import { cn } from "@/lib/utils";
import type {
AgentToolCallState as ToolCallState,
@@ -389,43 +384,43 @@ function normalizeSiteToolResultSummary(
}
return {
savedContent: hasSavedContent
? {
contentId: readFirstNonEmptyString([savedContentRecord], [
"content_id",
"contentId",
]),
projectId: readFirstNonEmptyString([savedContentRecord], [
"project_id",
"projectId",
]),
title: readFirstNonEmptyString([savedContentRecord], ["title"]),
projectRootPath: readFirstNonEmptyString([savedContentRecord], [
"project_root_path",
"projectRootPath",
]),
bundleRelativeDir: readFirstNonEmptyString([savedContentRecord], [
"bundle_relative_dir",
"bundleRelativeDir",
]),
markdownRelativePath: readFirstNonEmptyString([savedContentRecord], [
"markdown_relative_path",
"markdownRelativePath",
]),
imagesRelativeDir: readFirstNonEmptyString([savedContentRecord], [
"images_relative_dir",
"imagesRelativeDir",
]),
metaRelativePath: readFirstNonEmptyString([savedContentRecord], [
"meta_relative_path",
"metaRelativePath",
]),
imageCount: readFirstFiniteNumber([savedContentRecord], [
"image_count",
"imageCount",
]),
}
: undefined,
savedContent: hasSavedContent
? {
contentId: readFirstNonEmptyString(
[savedContentRecord],
["content_id", "contentId"],
),
projectId: readFirstNonEmptyString(
[savedContentRecord],
["project_id", "projectId"],
),
title: readFirstNonEmptyString([savedContentRecord], ["title"]),
projectRootPath: readFirstNonEmptyString(
[savedContentRecord],
["project_root_path", "projectRootPath"],
),
bundleRelativeDir: readFirstNonEmptyString(
[savedContentRecord],
["bundle_relative_dir", "bundleRelativeDir"],
),
markdownRelativePath: readFirstNonEmptyString(
[savedContentRecord],
["markdown_relative_path", "markdownRelativePath"],
),
imagesRelativeDir: readFirstNonEmptyString(
[savedContentRecord],
["images_relative_dir", "imagesRelativeDir"],
),
metaRelativePath: readFirstNonEmptyString(
[savedContentRecord],
["meta_relative_path", "metaRelativePath"],
),
imageCount: readFirstFiniteNumber(
[savedContentRecord],
["image_count", "imageCount"],
),
}
: undefined,
savedProjectId,
savedBy: readFirstNonEmptyString(candidates, ["saved_by", "savedBy"]),
saveSkippedProjectId,
@@ -449,7 +444,9 @@ function resolveSiteProjectSourceLabel(source?: string): string | null {
return null;
}
function resolveSiteAdapterSourceLabel(summary: SiteToolResultSummary): string | null {
function resolveSiteAdapterSourceLabel(
summary: SiteToolResultSummary,
): string | null {
if (summary.adapterSourceKind === "server_synced") {
return summary.adapterSourceVersion
? `服务端脚本 · ${summary.adapterSourceVersion}`
@@ -741,7 +738,8 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
// 获取文件名
const fileName = useMemo(
() => resolveToolPrimarySubjectFromInfo(toolCall.name, parsedArgs, filePath),
() =>
resolveToolPrimarySubjectFromInfo(toolCall.name, parsedArgs, filePath),
[filePath, parsedArgs, toolCall.name],
);
@@ -825,8 +823,11 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
const notices: ToolResultNotice[] = [];
const savedProjectId =
siteResultSummary.savedProjectId || siteResultSummary.savedContent?.projectId;
const savedSourceLabel = resolveSiteProjectSourceLabel(siteResultSummary.savedBy);
siteResultSummary.savedProjectId ||
siteResultSummary.savedContent?.projectId;
const savedSourceLabel = resolveSiteProjectSourceLabel(
siteResultSummary.savedBy,
);
if (siteResultSummary.savedContent?.title) {
let text = `结果已自动保存${
@@ -984,9 +985,7 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
const shouldShowRawSearchResultToggle =
hasSearchResults && resultText !== "(无输出)";
const shouldRenderResultPanel =
isExpanded &&
hasResult &&
(!hasSearchResults || showRawSearchResultOutput);
isExpanded && hasResult && (!hasSearchResults || showRawSearchResultOutput);
const handleOpenExternalUrl = useCallback(async (url: string) => {
try {
@@ -1007,7 +1006,13 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
) {
setIsExpanded(true);
}
}, [isMessageStreaming, isRunning, hasResult, hasResultImages, hasSearchResults]);
}, [
isMessageStreaming,
isRunning,
hasResult,
hasResultImages,
hasSearchResults,
]);
useEffect(() => {
if (hasSearchResults && !hasUserToggledExpandedRef.current) {
@@ -1042,7 +1047,10 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
return (
<div className={cn("group", grouped && "pl-1")}>
{grouped ? (
<div className="flex items-start gap-2 py-1.5" data-testid="tool-call-row">
<div
className="flex items-start gap-2 py-1.5"
data-testid="tool-call-row"
>
<span className="pt-0.5 font-mono text-xs text-slate-400">
{groupMarker}
</span>
@@ -1165,9 +1173,7 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
{hasSearchResults && isExpanded && (
<div className="mb-2 ml-6 mt-1.5">
<div className="mb-2 flex flex-wrap gap-2 text-[11px] text-slate-500">
<span>
{searchSemantic.label}
</span>
<span>{searchSemantic.label}</span>
</div>
<SearchResultPreviewList
items={searchResultItems}
@@ -1189,9 +1195,7 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
setShowRawSearchResultOutput((current) => !current)
}
>
{showRawSearchResultOutput
? "收起原始输出"
: "查看原始输出"}
{showRawSearchResultOutput ? "收起原始输出" : "查看原始输出"}
</button>
</div>
) : null}
@@ -1312,11 +1316,7 @@ export const ToolCallList: React.FC<ToolCallListProps> = ({
for (const toolCall of toolCalls) {
const isSearch = isUnifiedWebSearchToolName(toolCall.name);
const lastGroup = groups[groups.length - 1];
if (
isSearch &&
lastGroup &&
lastGroup.type === "search"
) {
if (isSearch && lastGroup && lastGroup.type === "search") {
lastGroup.items.push(toolCall);
continue;
}
@@ -1,13 +1,13 @@
export type EmptyStateTone = "slate" | "sky" | "emerald" | "amber" | "lime";
export const EMPTY_STATE_PANEL_CLASSNAME =
"rounded-[26px] border border-slate-200/80 bg-white p-4 shadow-sm shadow-slate-950/5 md:p-5";
"rounded-[26px] border border-lime-200/80 bg-white p-4 shadow-sm shadow-slate-950/5 md:p-5";
export const EMPTY_STATE_PANEL_EMBEDDED_CLASSNAME =
"rounded-[22px] border border-slate-200/80 bg-white p-3.5 shadow-sm shadow-slate-950/5";
"rounded-[22px] border border-lime-200/80 bg-white p-3.5 shadow-sm shadow-slate-950/5";
export const EMPTY_STATE_CARD_SURFACE_CLASSNAME =
"overflow-hidden rounded-[22px] border border-slate-200/80 bg-white p-3.5 shadow-sm shadow-slate-950/5";
"overflow-hidden rounded-[22px] border border-lime-200/80 bg-white p-3.5 shadow-sm shadow-slate-950/5";
export const EMPTY_STATE_BADGE_BASE_CLASSNAME =
"inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium shadow-sm shadow-slate-950/5";
@@ -31,19 +31,19 @@ export const EMPTY_STATE_ICON_TONE_CLASSNAMES: Record<EmptyStateTone, string> =
};
export const EMPTY_STATE_META_PILL_CLASSNAME =
"rounded-full border border-slate-200/80 bg-white/90 px-2 py-0.5 text-[10px] font-medium text-slate-500";
"rounded-full border border-lime-200/80 bg-lime-50/50 px-2 py-0.5 text-[10px] font-medium text-slate-600";
export const EMPTY_STATE_PRESET_BUTTON_CLASSNAME =
"inline-flex items-center gap-2 rounded-full border border-slate-200/80 bg-white/92 px-3 py-1.5 text-[13px] text-slate-600 shadow-sm shadow-slate-950/5 transition-colors hover:border-slate-300 hover:bg-white hover:text-slate-900";
"inline-flex items-center gap-2 rounded-full border border-lime-200/80 bg-lime-50/50 px-3 py-1.5 text-[13px] text-slate-600 shadow-sm shadow-slate-950/5 transition-colors hover:border-lime-300 hover:bg-white hover:text-slate-900";
export const EMPTY_STATE_SECONDARY_ACTION_BUTTON_CLASSNAME =
"h-8 w-full rounded-full border-slate-200/80 bg-white/88 text-xs text-slate-700 shadow-none transition-colors hover:border-slate-300 hover:bg-white";
"h-8 w-full rounded-full border border-lime-200/80 bg-lime-50/50 text-xs text-slate-700 shadow-none transition-colors hover:border-lime-300 hover:bg-white";
export const EMPTY_STATE_PRIMARY_ACTION_BUTTON_CLASSNAME =
"h-9 w-full rounded-full bg-slate-900 px-5 text-white shadow-sm shadow-slate-900/10 transition-colors hover:bg-slate-800 sm:w-auto";
export const EMPTY_STATE_RECOMMENDATION_CARD_CLASSNAME =
"group flex min-w-0 flex-col items-start gap-2 rounded-[20px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.97)_0%,rgba(248,250,252,0.9)_100%)] px-3.5 py-3.5 text-left shadow-sm shadow-slate-950/5 transition-colors hover:border-slate-300 hover:bg-white";
"group flex min-w-0 flex-col items-start gap-2 rounded-[20px] border border-lime-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.97)_0%,rgba(247,254,231,0.9)_100%)] px-3.5 py-3.5 text-left shadow-sm shadow-slate-950/5 transition-colors hover:border-lime-300 hover:bg-white";
export const EMPTY_STATE_PAGE_CONTAINER_CLASSNAME =
"relative flex min-h-full flex-1 flex-col items-stretch justify-start overflow-y-auto bg-transparent px-2 pb-5 pt-[clamp(2px,0.6vw,6px)] md:px-3";
@@ -52,29 +52,28 @@ export const EMPTY_STATE_CONTENT_WRAPPER_CLASSNAME =
"relative z-[1] mx-auto flex min-h-full w-full max-w-[1360px] flex-1 flex-col items-stretch gap-3";
export const EMPTY_STATE_THEME_TABS_CONTAINER_CLASSNAME =
"flex w-full max-w-full flex-nowrap justify-start gap-1.5 overflow-x-auto overflow-y-hidden rounded-[20px] border border-slate-200/80 bg-slate-50 p-[5px] shadow-[0_10px_24px_-22px_rgba(15,23,42,0.18)] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden";
"flex w-full max-w-full flex-nowrap justify-start gap-1.5 overflow-x-auto overflow-y-hidden rounded-[20px] border border-lime-200/80 bg-lime-50/50 p-[5px] shadow-[0_10px_24px_-22px_rgba(15,23,42,0.18)] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden";
export const EMPTY_STATE_SELECT_TRIGGER_CLASSNAME =
"h-8 rounded-full border-slate-200/80 bg-white/92 px-3 text-xs text-slate-700 shadow-none transition-colors hover:border-slate-300 hover:bg-white focus:ring-1 focus:ring-slate-200";
"h-8 rounded-full border-lime-200/80 bg-white/92 px-3 text-xs text-slate-700 shadow-none transition-colors hover:border-lime-300 hover:bg-white focus:ring-1 focus:ring-lime-200";
export const EMPTY_STATE_PASSIVE_BADGE_CLASSNAME =
"h-8 rounded-full border border-slate-200/80 bg-white/92 px-3 text-xs font-normal text-slate-600 shadow-none hover:border-slate-300 hover:bg-white hover:text-slate-900";
"h-8 rounded-full border border-lime-200/80 bg-lime-50/50 px-3 text-xs font-normal text-slate-600 shadow-none hover:border-lime-300 hover:bg-white hover:text-slate-900";
export const EMPTY_STATE_ICON_TOOL_BUTTON_CLASSNAME =
"ml-1 h-8 w-8 rounded-full border-slate-200/80 bg-white text-slate-500 shadow-none transition-colors hover:border-slate-300 hover:bg-slate-50 hover:text-slate-700";
"ml-1 h-8 w-8 rounded-full border-lime-200/80 bg-white text-slate-500 shadow-none transition-colors hover:border-lime-300 hover:bg-lime-50 hover:text-slate-700";
const EMPTY_STATE_TOOL_TOGGLE_TONE_CLASSNAMES: Record<EmptyStateTone, string> = {
slate:
"border-slate-400 bg-slate-200 text-slate-900 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.28)] hover:border-slate-400 hover:bg-slate-200 hover:text-slate-900",
sky:
"border-sky-300 bg-sky-50 text-sky-700 hover:border-sky-300 hover:bg-sky-50 hover:text-sky-700",
emerald:
"border-emerald-300 bg-emerald-50 text-emerald-700 hover:border-emerald-300 hover:bg-emerald-50 hover:text-emerald-700",
amber:
"border-amber-300 bg-amber-50 text-amber-700 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-700",
lime:
"border-lime-300 bg-lime-50 text-lime-700 hover:border-lime-300 hover:bg-lime-50 hover:text-lime-700",
};
const EMPTY_STATE_TOOL_TOGGLE_TONE_CLASSNAMES: Record<EmptyStateTone, string> =
{
slate:
"border-slate-400 bg-slate-200 text-slate-900 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.28)] hover:border-slate-400 hover:bg-slate-200 hover:text-slate-900",
sky: "border-sky-300 bg-sky-50 text-sky-700 hover:border-sky-300 hover:bg-sky-50 hover:text-sky-700",
emerald:
"border-emerald-300 bg-emerald-50 text-emerald-700 hover:border-emerald-300 hover:bg-emerald-50 hover:text-emerald-700",
amber:
"border-amber-300 bg-amber-50 text-amber-700 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-700",
lime: "border-lime-300 bg-lime-50 text-lime-700 hover:border-lime-300 hover:bg-lime-50 hover:text-lime-700",
};
export function getEmptyStateIconToolButtonClassName(
enabled: boolean,
@@ -92,8 +91,8 @@ export function getEmptyStateThemeTabClassName(active: boolean) {
return [
"flex flex-none items-center gap-1.5 rounded-xl border px-3 py-[7px] text-xs font-medium leading-none transition-[background-color,border-color,color,box-shadow]",
active
? "border-slate-300 bg-white/95 text-slate-900 shadow-[0_10px_22px_-20px_rgba(15,23,42,0.24)]"
: "border-transparent text-slate-600 hover:border-slate-200/90 hover:bg-white/80 hover:text-slate-900",
? "border-lime-300 bg-white/95 text-slate-900 shadow-[0_10px_22px_-20px_rgba(15,23,42,0.24)]"
: "border-transparent text-slate-600 hover:border-lime-200/90 hover:bg-white/80 hover:text-slate-900",
].join(" ");
}
@@ -101,7 +100,7 @@ export function getEmptyStateThemeTabIconClassName(active: boolean) {
return [
"flex h-5 w-5 items-center justify-center rounded-full border text-[11px]",
active
? "border-slate-200 bg-white text-slate-700"
? "border-lime-200 bg-white text-slate-700"
: "border-transparent bg-transparent text-slate-400",
].join(" ");
}
@@ -80,6 +80,7 @@ export function resolveGeneralWorkbenchSelectedSearchResult(
return null;
}
return (
searchContextItems.find((item) => item.id === selectedSearchResultId) || null
searchContextItems.find((item) => item.id === selectedSearchResultId) ||
null
);
}
@@ -22,9 +22,7 @@ function truncate(text: string, max = 300): string {
return "";
}
const normalized = text.trim();
return normalized.length > max
? `${normalized.slice(0, max)}…`
: normalized;
return normalized.length > max ? `${normalized.slice(0, max)}…` : normalized;
}
function formatExecLogJson(value: unknown): string | undefined {
@@ -161,7 +159,9 @@ function buildRunEntry(
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
whenToUse: skillDetail?.when_to_use?.trim() || undefined,
artifactPaths:
group.artifactPaths.length > 0 ? [...group.artifactPaths] : undefined,
group.artifactPaths.length > 0
? [...group.artifactPaths]
: undefined,
}
: undefined;
@@ -169,8 +169,14 @@ function buildRunEntry(
group.artifactPaths.length > 0
? `产物:${group.artifactPaths.map((path) => path.split("/").pop()).join("、")}`
: undefined;
const durationLabel = group.logs.find((log) => log.durationLabel)?.durationLabel;
const meta = [sourceRef ? `技能标识:${sourceRef}` : null, detailSummary, artifactSummary]
const durationLabel = group.logs.find(
(log) => log.durationLabel,
)?.durationLabel;
const meta = [
sourceRef ? `技能标识:${sourceRef}` : null,
detailSummary,
artifactSummary,
]
.filter((item): item is string => Boolean(item))
.join(" · ");
@@ -221,7 +227,12 @@ export function buildGeneralWorkbenchExecLogEntries({
for (const toolCall of message.toolCalls || []) {
entries.push(
buildToolCallEntry(message.id, toolCall, message.timestamp, toolCallIndex),
buildToolCallEntry(
message.id,
toolCall,
message.timestamp,
toolCallIndex,
),
);
toolCallIndex += 1;
}
@@ -248,14 +259,18 @@ export function buildGeneralWorkbenchExecLogEntries({
type: "task",
typeLabel: "任务提交",
content: group.label || group.taskType,
timestamp: latestTask?.createdAt ? new Date(latestTask.createdAt) : new Date(0),
timestamp: latestTask?.createdAt
? new Date(latestTask.createdAt)
: new Date(0),
status: "completed",
});
}
entries.sort((left, right) => {
const leftTime = left.timestamp instanceof Date ? left.timestamp.getTime() : 0;
const rightTime = right.timestamp instanceof Date ? right.timestamp.getTime() : 0;
const leftTime =
left.timestamp instanceof Date ? left.timestamp.getTime() : 0;
const rightTime =
right.timestamp instanceof Date ? right.timestamp.getTime() : 0;
return leftTime - rightTime;
});
@@ -271,9 +286,10 @@ export function filterGeneralWorkbenchExecLogEntries(
}
return entries.filter((entry) => {
const timestamp = entry.timestamp instanceof Date
? entry.timestamp.getTime()
: new Date(entry.timestamp).getTime();
const timestamp =
entry.timestamp instanceof Date
? entry.timestamp.getTime()
: new Date(entry.timestamp).getTime();
return Number.isFinite(timestamp) && timestamp > clearedAt;
});
}
@@ -71,7 +71,8 @@ export interface GeneralWorkbenchSidebarExecLogContract {
}
export interface GeneralWorkbenchSidebarProps
extends GeneralWorkbenchSidebarShellContract,
extends
GeneralWorkbenchSidebarShellContract,
GeneralWorkbenchSidebarWorkflowContract,
GeneralWorkbenchSidebarContextContract,
GeneralWorkbenchSidebarExecLogContract {}
@@ -11,28 +11,31 @@ import type {
GeneralWorkbenchAddTextContextAction,
} from "./useGeneralWorkbenchContextPanelState";
export interface GeneralWorkbenchSidebarContextOrchestrationInput
extends Omit<BuildGeneralWorkbenchContextPanelPropsParams, "contextPanelState"> {
export interface GeneralWorkbenchSidebarContextOrchestrationInput extends Omit<
BuildGeneralWorkbenchContextPanelPropsParams,
"contextPanelState"
> {
onAddTextContext?: GeneralWorkbenchAddTextContextAction;
onAddLinkContext?: GeneralWorkbenchAddLinkContextAction;
onAddFileContext?: GeneralWorkbenchAddFileContextAction;
}
export interface GeneralWorkbenchSidebarWorkflowOrchestrationInput
extends Omit<
BuildGeneralWorkbenchWorkflowPanelPropsParams,
| "creationTaskEventsCount"
| "isVersionMode"
| "onOpenArtifactWithDefaultApp"
| "onRevealArtifactInFinder"
| "workflowPanelState"
> {
export interface GeneralWorkbenchSidebarWorkflowOrchestrationInput extends Omit<
BuildGeneralWorkbenchWorkflowPanelPropsParams,
| "creationTaskEventsCount"
| "isVersionMode"
| "onOpenArtifactWithDefaultApp"
| "onRevealArtifactInFinder"
| "workflowPanelState"
> {
activityLogs: SidebarActivityLog[];
creationTaskEvents: GeneralWorkbenchCreationTaskEvent[];
}
export interface GeneralWorkbenchSidebarExecLogOrchestrationInput
extends Omit<BuildGeneralWorkbenchExecLogPropsParams, "execLogState"> {
export interface GeneralWorkbenchSidebarExecLogOrchestrationInput extends Omit<
BuildGeneralWorkbenchExecLogPropsParams,
"execLogState"
> {
messages: Message[];
skillDetailMap: Record<string, SkillDetailInfo | null>;
}
@@ -47,19 +50,17 @@ export interface GeneralWorkbenchSidebarOrchestrationInput {
export type GeneralWorkbenchSidebarContextOrchestrationSource =
GeneralWorkbenchSidebarContextOrchestrationInput;
export interface GeneralWorkbenchSidebarWorkflowOrchestrationSource
extends Omit<
GeneralWorkbenchSidebarWorkflowOrchestrationInput,
"creationTaskEvents"
> {
export interface GeneralWorkbenchSidebarWorkflowOrchestrationSource extends Omit<
GeneralWorkbenchSidebarWorkflowOrchestrationInput,
"creationTaskEvents"
> {
creationTaskEvents?: GeneralWorkbenchCreationTaskEvent[];
}
export interface GeneralWorkbenchSidebarExecLogOrchestrationSource
extends Omit<
GeneralWorkbenchSidebarExecLogOrchestrationInput,
"messages" | "skillDetailMap"
> {
export interface GeneralWorkbenchSidebarExecLogOrchestrationSource extends Omit<
GeneralWorkbenchSidebarExecLogOrchestrationInput,
"messages" | "skillDetailMap"
> {
messages?: Message[];
skillDetailMap?: Record<string, SkillDetailInfo | null>;
}
@@ -28,7 +28,9 @@ export function formatGeneralWorkbenchActionErrorMessage(
return `${prefix}:${detail}`;
}
export async function writeGeneralWorkbenchClipboardText(text: string): Promise<void> {
export async function writeGeneralWorkbenchClipboardText(
text: string,
): Promise<void> {
const value = text.trim();
if (!value) {
return;
@@ -48,7 +48,10 @@ describe("generalWorkbenchWorkflowData", () => {
status: "completed",
timeLabel: "10:31",
runId: "run-1",
artifactPaths: ["content-posts/demo.md", "content-posts/demo-cover.png"],
artifactPaths: [
"content-posts/demo.md",
"content-posts/demo-cover.png",
],
},
])[0]?.artifactPaths,
).toEqual(["content-posts/demo.md", "content-posts/demo-cover.png"]);
@@ -85,14 +85,18 @@ export function useGeneralWorkbenchContextPanelState({
onAddLinkContext,
onAddFileContext,
}: UseGeneralWorkbenchContextPanelStateParams): GeneralWorkbenchContextPanelState {
const [selectedSearchResultId, setSelectedSearchResultId] = useState<string | null>(null);
const [selectedSearchResultId, setSelectedSearchResultId] = useState<
string | null
>(null);
const [addContextDialogOpen, setAddContextDialogOpen] = useState(false);
const [addTextDialogOpen, setAddTextDialogOpen] = useState(false);
const [addLinkDialogOpen, setAddLinkDialogOpen] = useState(false);
const [contextDraftText, setContextDraftText] = useState("");
const [contextDraftLink, setContextDraftLink] = useState("");
const [contextCreateLoading, setContextCreateLoading] = useState(false);
const [contextCreateError, setContextCreateError] = useState<string | null>(null);
const [contextCreateError, setContextCreateError] = useState<string | null>(
null,
);
const [contextDropActive, setContextDropActive] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -212,15 +216,12 @@ export function useGeneralWorkbenchContextPanelState({
return;
}
await runContextAction(
async () => {
await onAddFileContext({
path: selected,
name: resolveGeneralWorkbenchFileNameFromPath(selected),
});
},
"已添加文件上下文",
);
await runContextAction(async () => {
await onAddFileContext({
path: selected,
name: resolveGeneralWorkbenchFileNameFromPath(selected),
});
}, "已添加文件上下文");
} catch (error) {
const nextError = formatGeneralWorkbenchActionErrorMessage(
"读取文件失败",
@@ -242,36 +243,32 @@ export function useGeneralWorkbenchContextPanelState({
const fileWithPath = file as File & { path?: string };
if (fileWithPath.path && onAddFileContext) {
await runContextAction(
async () => {
await onAddFileContext({
path: fileWithPath.path || "",
name: file.name,
});
},
"已添加文件上下文",
);
await runContextAction(async () => {
await onAddFileContext({
path: fileWithPath.path || "",
name: file.name,
});
}, "已添加文件上下文");
return;
}
if (!onAddTextContext) {
setContextCreateError("当前环境无法读取拖拽文件路径,请使用“上传文件”按钮");
setContextCreateError(
"当前环境无法读取拖拽文件路径,请使用“上传文件”按钮",
);
return;
}
await runContextAction(
async () => {
const content = await file.text();
if (!content.trim()) {
throw new Error("文件内容为空");
}
await onAddTextContext({
content,
name: file.name,
});
},
"已添加文本上下文",
);
await runContextAction(async () => {
const content = await file.text();
if (!content.trim()) {
throw new Error("文件内容为空");
}
await onAddTextContext({
content,
name: file.name,
});
}, "已添加文本上下文");
},
[onAddFileContext, onAddTextContext, runContextAction],
);
@@ -286,14 +283,11 @@ export function useGeneralWorkbenchContextPanelState({
setContextCreateError("请输入文本内容");
return;
}
await runContextAction(
async () => {
await onAddTextContext({
content: normalizedText,
});
},
"已添加文本上下文",
);
await runContextAction(async () => {
await onAddTextContext({
content: normalizedText,
});
}, "已添加文本上下文");
}, [contextDraftText, onAddTextContext, runContextAction]);
const handleSubmitLinkContext = useCallback(async () => {
@@ -306,14 +300,11 @@ export function useGeneralWorkbenchContextPanelState({
setContextCreateError("请输入网站链接");
return;
}
await runContextAction(
async () => {
await onAddLinkContext({
url: normalizedLink,
});
},
"已添加网站链接上下文",
);
await runContextAction(async () => {
await onAddLinkContext({
url: normalizedLink,
});
}, "已添加网站链接上下文");
}, [contextDraftLink, onAddLinkContext, runContextAction]);
return {
@@ -44,7 +44,8 @@ export function useGeneralWorkbenchExecLogState({
);
const visibleExecLogEntries = useMemo(
() => filterGeneralWorkbenchExecLogEntries(execLogEntries, execLogClearedAt),
() =>
filterGeneralWorkbenchExecLogEntries(execLogEntries, execLogClearedAt),
[execLogClearedAt, execLogEntries],
);
@@ -31,7 +31,8 @@ export function useGeneralWorkbenchSidebarOrchestration({
}: UseGeneralWorkbenchSidebarOrchestrationParams): GeneralWorkbenchSidebarOrchestration {
const { isVersionMode, context, workflow, execLog } = input;
const branchCount = workflow.branchItems.length;
const runDetailSessionId = workflow.activeRunDetail?.session_id?.trim() || null;
const runDetailSessionId =
workflow.activeRunDetail?.session_id?.trim() || null;
const { handleRevealArtifactInFinder, handleOpenArtifactWithDefaultApp } =
useGeneralWorkbenchArtifactActions({
runDetailSessionId,
@@ -21,7 +21,9 @@ interface UseGeneralWorkbenchWorkflowPanelStateParams {
export interface GeneralWorkbenchWorkflowPanelState {
completedSteps: number;
progressPercent: number;
groupedActivityLogs: ReturnType<typeof buildGeneralWorkbenchActivityLogGroups>;
groupedActivityLogs: ReturnType<
typeof buildGeneralWorkbenchActivityLogGroups
>;
groupedCreationTaskEvents: ReturnType<
typeof buildGeneralWorkbenchCreationTaskGroups
>;
@@ -49,7 +51,9 @@ export function useGeneralWorkbenchWorkflowPanelState({
);
const progressPercent =
workflowSteps.length > 0 ? (completedSteps / workflowSteps.length) * 100 : 0;
workflowSteps.length > 0
? (completedSteps / workflowSteps.length) * 100
: 0;
const groupedActivityLogs = useMemo(
() => buildGeneralWorkbenchActivityLogGroups(activityLogs),
@@ -1,6 +1,9 @@
import type { Dispatch, SetStateAction } from "react";
import type { ActionRequired, Message } from "../types";
import { appendActionRequiredToParts, resolveActionPromptKey } from "./agentChatCoreUtils";
import {
appendActionRequiredToParts,
resolveActionPromptKey,
} from "./agentChatCoreUtils";
interface UpsertAssistantActionRequestOptions {
assistantMsgId: string;
@@ -111,7 +111,9 @@ describe("createAgentChatSendMessage", () => {
await sendMessage("/review src-tauri", [], false, false, false);
expect(rawSendMessage).toHaveBeenCalledTimes(1);
expect(rawSendMessage.mock.calls[0]?.[0]).toContain("请对以下对象进行代码审查");
expect(rawSendMessage.mock.calls[0]?.[0]).toContain(
"请对以下对象进行代码审查",
);
expect(rawSendMessage.mock.calls[0]?.[0]).toContain("src-tauri");
expect(listSlashEntryUsage()).toEqual([
expect.objectContaining({
@@ -1,13 +1,7 @@
import type { CodexSlashStatusSnapshot } from "../commands";
import {
executeCodexSlashCommand,
parseCodexSlashCommand,
} from "../commands";
import { executeCodexSlashCommand, parseCodexSlashCommand } from "../commands";
import { recordSlashEntryUsage } from "../skill-selection/slashEntryUsage";
import type {
ClearMessagesOptions,
SendMessageFn,
} from "./agentChatShared";
import type { ClearMessagesOptions, SendMessageFn } from "./agentChatShared";
interface CreateAgentChatSendMessageOptions {
baseStatusSnapshot: CodexSlashStatusSnapshot;
@@ -87,9 +87,8 @@ describe("agentChatShared", () => {
});
it("应优先展示待处理请求的摘要", () => {
const messages = createPendingActionMessages(
"请先确认发布标题后继续执行。",
);
const messages =
createPendingActionMessages("请先确认发布标题后继续执行。");
expect(extractTaskPreviewFromMessages(messages)).toBe(
"请先确认发布标题后继续执行。",
@@ -145,24 +145,32 @@ function normalizeTaskPreviewText(value: string) {
}
function extractMessageTextContent(message: Message): string {
const sanitizedContent = sanitizeMessageTextForPreview(message.content || "", {
role: message.role,
hasImages: Array.isArray(message.images) && message.images.length > 0,
});
const sanitizedContent = sanitizeMessageTextForPreview(
message.content || "",
{
role: message.role,
hasImages: Array.isArray(message.images) && message.images.length > 0,
},
);
if (sanitizedContent) {
return normalizeTaskPreviewText(sanitizedContent);
}
const partText = message.contentParts
?.filter(
(part): part is Extract<(typeof message.contentParts)[number], { type: "text" | "thinking" }> =>
part.type === "text" || part.type === "thinking",
(
part,
): part is Extract<
(typeof message.contentParts)[number],
{ type: "text" | "thinking" }
> => part.type === "text" || part.type === "thinking",
)
.map((part) =>
part.type === "text"
? sanitizeMessageTextForPreview(part.text, {
role: message.role,
hasImages: Array.isArray(message.images) && message.images.length > 0,
hasImages:
Array.isArray(message.images) && message.images.length > 0,
})
: part.text,
)
@@ -189,9 +197,9 @@ function extractToolCallPreview(message: Message): string {
}
function extractActionRequestPreview(message: Message): string {
const pendingAction = [...(message.actionRequests || [])].reverse().find(
(item) => item.status !== "submitted",
);
const pendingAction = [...(message.actionRequests || [])]
.reverse()
.find((item) => item.status !== "submitted");
if (!pendingAction) {
return "";
}
@@ -201,9 +209,7 @@ function extractActionRequestPreview(message: Message): string {
.join(" ");
return normalizeTaskPreviewText(
pendingAction.prompt ||
questionText ||
"等待你确认或补充信息后继续执行。",
pendingAction.prompt || questionText || "等待你确认或补充信息后继续执行。",
);
}
@@ -351,8 +357,13 @@ export function buildLiveTaskSnapshot(params: {
queuedTurnCount?: number;
workspaceError: boolean;
}): LiveTaskSnapshot {
const { messages, isSending, pendingActionCount, queuedTurnCount, workspaceError } =
params;
const {
messages,
isSending,
pendingActionCount,
queuedTurnCount,
workspaceError,
} = params;
const lastMessage = messages[messages.length - 1];
const preview = extractTaskPreviewFromMessages(messages);
const taskState = deriveTaskLiveState({
@@ -368,8 +379,7 @@ export function buildLiveTaskSnapshot(params: {
messagesCount: messages.length,
status: taskState.status,
statusReason: taskState.statusReason,
lastPreview:
preview || "等待你补充任务需求后开始执行。",
lastPreview: preview || "等待你补充任务需求后开始执行。",
hasUnread: false,
};
}
@@ -197,11 +197,7 @@ export const getAccessModeStorageKey = (
};
export const normalizeAccessMode = (value: unknown): AgentAccessMode => {
if (
value === "read-only" ||
value === "current" ||
value === "full-access"
) {
if (value === "read-only" || value === "current" || value === "full-access") {
return value;
}
return DEFAULT_AGENT_ACCESS_MODE;
@@ -104,7 +104,9 @@ describe("agentProjectStorage", () => {
});
expect(harness.getValue().projectId).toBe("project-updated");
expect(loadPersistedProjectId(LAST_PROJECT_ID_KEY)).toBe("project-updated");
expect(loadPersistedProjectId(LAST_PROJECT_ID_KEY)).toBe(
"project-updated",
);
} finally {
harness.unmount();
}
@@ -4,7 +4,9 @@ import { normalizeProjectId } from "../utils/topicProjectResolution";
export const LAST_PROJECT_ID_KEY = "agent_last_project_id";
export const SESSION_WORKSPACE_STORAGE_KEY_PREFIX = "agent_session_workspace_";
export function getSessionWorkspaceStorageKey(sessionId: string): string | null {
export function getSessionWorkspaceStorageKey(
sessionId: string,
): string | null {
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
return null;
@@ -50,7 +50,9 @@ export interface AgentRuntimeAdapter {
): Promise<string>;
listSessions(): Promise<AsterSessionInfo[]>;
getSession(sessionId: string): Promise<AsterSessionDetail>;
getSessionReadModel(sessionId: string): Promise<AsterSessionDetail["thread_read"]>;
getSessionReadModel(
sessionId: string,
): Promise<AsterSessionDetail["thread_read"]>;
replayRequest(
sessionId: string,
requestId: string,
@@ -76,10 +76,7 @@ describe("agentSessionRefresh", () => {
preserveExecutionStrategyOnMissingDetail: true,
},
);
expect(markSynced).toHaveBeenCalledWith(
"session-1",
"code_orchestrated",
);
expect(markSynced).toHaveBeenCalledWith("session-1", "code_orchestrated");
});
it("刷新 read model 时应在会话仍匹配时应用 snapshot", async () => {
@@ -154,10 +154,7 @@ export function buildHydratedAgentSessionSnapshot(
snapshot: {
sessionId: syncSessionId ? topicId : currentSessionId,
messages: shouldPreserveExistingTimeline
? mergeHydratedMessagesWithLocalState(
currentMessages,
hydratedMessages,
)
? mergeHydratedMessagesWithLocalState(currentMessages, hydratedMessages)
: hydratedMessages,
threadTurns: nextThreadTurns,
threadItems: nextThreadItems,
@@ -1,8 +1,15 @@
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { parseAgentEvent, type AgentThreadItem, type AgentThreadTurn } from "@/lib/api/agentProtocol";
import {
parseAgentEvent,
type AgentThreadItem,
type AgentThreadTurn,
} from "@/lib/api/agentProtocol";
import type { AgentRuntimeAdapter } from "./agentRuntimeAdapter";
import type { ActiveStreamState } from "./agentStreamSubmissionLifecycle";
import { upsertThreadItemState, upsertThreadTurnState } from "./agentThreadState";
import {
upsertThreadItemState,
upsertThreadTurnState,
} from "./agentThreadState";
import { resolveRuntimeWarningToastPresentation } from "./runtimeWarningPresentation";
interface AgentStreamCompactionNotifications {
@@ -148,7 +155,8 @@ export async function runAgentStreamCompaction(
break;
}
case "error":
notifiedErrorMessage = String(data.message ?? "").trim() || "未知错误";
notifiedErrorMessage =
String(data.message ?? "").trim() || "未知错误";
notify.error(`压缩上下文失败: ${notifiedErrorMessage}`);
clearActiveStreamIfMatch(eventName);
disposeListener();
@@ -122,21 +122,36 @@ describe("agentStreamFlowControl", () => {
} as never,
removeStreamListener,
refreshSessionReadModel,
setQueuedTurns: createStateSetter(() => queuedTurns, (value) => {
queuedTurns = value;
}),
setThreadItems: createStateSetter(() => threadItems, (value) => {
threadItems = value;
}),
setThreadTurns: createStateSetter(() => threadTurns, (value) => {
threadTurns = value;
}),
setCurrentTurnId: createStateSetter(() => currentTurnId, (value) => {
currentTurnId = value;
}),
setMessages: createStateSetter(() => messages, (value) => {
messages = value;
}),
setQueuedTurns: createStateSetter(
() => queuedTurns,
(value) => {
queuedTurns = value;
},
),
setThreadItems: createStateSetter(
() => threadItems,
(value) => {
threadItems = value;
},
),
setThreadTurns: createStateSetter(
() => threadTurns,
(value) => {
threadTurns = value;
},
),
setCurrentTurnId: createStateSetter(
() => currentTurnId,
(value) => {
currentTurnId = value;
},
),
setMessages: createStateSetter(
() => messages,
(value) => {
messages = value;
},
),
setActiveStream: (next) => {
activeStream = next as never;
},
@@ -182,9 +197,12 @@ describe("agentStreamFlowControl", () => {
queuedTurnId: "queued-1",
sessionIdRef,
refreshSessionReadModel,
setQueuedTurns: createStateSetter(() => queuedTurns, (value) => {
queuedTurns = value;
}),
setQueuedTurns: createStateSetter(
() => queuedTurns,
(value) => {
queuedTurns = value;
},
),
notify,
}),
).resolves.toBe(true);
@@ -208,9 +226,12 @@ describe("agentStreamFlowControl", () => {
queuedTurnId: "queued-1",
sessionIdRef,
refreshSessionReadModel,
setQueuedTurns: createStateSetter(() => queuedTurns, (value) => {
queuedTurns = value;
}),
setQueuedTurns: createStateSetter(
() => queuedTurns,
(value) => {
queuedTurns = value;
},
),
notify,
}),
).resolves.toBe(true);
@@ -5,7 +5,10 @@ import type { Message } from "../types";
import type { ActiveStreamState } from "./agentStreamSubmissionLifecycle";
import type { AgentRuntimeAdapter } from "./agentRuntimeAdapter";
import { updateMessageArtifactsStatus } from "../utils/messageArtifacts";
import { removeThreadItemState, removeThreadTurnState } from "./agentThreadState";
import {
removeThreadItemState,
removeThreadTurnState,
} from "./agentThreadState";
interface AgentStreamFlowNotify {
info: (message: string) => void;
@@ -47,7 +50,10 @@ interface PromoteQueuedTurnOptions extends QueueActionOptions {
onError?: (error: unknown) => void;
}
interface ResumeThreadOptions extends Omit<QueueActionOptions, "setQueuedTurns"> {
interface ResumeThreadOptions extends Omit<
QueueActionOptions,
"setQueuedTurns"
> {
runtime: Pick<AgentRuntimeAdapter, "resumeThread">;
onError?: (error: unknown) => void;
}
@@ -147,7 +153,10 @@ export async function removeQueuedAgentTurn(options: RemoveQueuedTurnOptions) {
}
try {
const removed = await runtime.removeQueuedTurn(activeSessionId, queuedTurnId);
const removed = await runtime.removeQueuedTurn(
activeSessionId,
queuedTurnId,
);
if (removed) {
setQueuedTurns((prev) => removeQueuedTurnFromState(prev, queuedTurnId));
}
@@ -161,7 +170,9 @@ export async function removeQueuedAgentTurn(options: RemoveQueuedTurnOptions) {
}
}
export async function promoteQueuedAgentTurn(options: PromoteQueuedTurnOptions) {
export async function promoteQueuedAgentTurn(
options: PromoteQueuedTurnOptions,
) {
const {
runtime,
queuedTurnId,
@@ -179,7 +190,10 @@ export async function promoteQueuedAgentTurn(options: PromoteQueuedTurnOptions)
setQueuedTurns((prev) => removeQueuedTurnFromState(prev, queuedTurnId));
try {
const promoted = await runtime.promoteQueuedTurn(activeSessionId, queuedTurnId);
const promoted = await runtime.promoteQueuedTurn(
activeSessionId,
queuedTurnId,
);
await refreshSessionReadModel(activeSessionId);
if (!promoted) {
return false;

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