mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: release v1.8.0
This commit is contained in:
+8
-8
@@ -1,8 +1,8 @@
|
||||
## Lime v1.7.0
|
||||
## Lime v1.8.0
|
||||
|
||||
### ✨ 主要更新
|
||||
|
||||
- 本次 `v1.7.0` 已收口当前工作区全部改动,核心集中在 Agent 聊天工作台、General Workbench、Service Skill、Team Workspace、Artifact / Timeline 展示与输入发送主链
|
||||
- 本次 `v1.8.0` 已收口当前工作区全部改动,核心集中在 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` 已随当前实现一起更新
|
||||
@@ -10,14 +10,14 @@
|
||||
### 🔗 依赖与版本同步
|
||||
|
||||
- `aster-core` / `aster-models` 已内置到 `src-tauri/crates/aster-rust/`,不再依赖外部仓库本地 override
|
||||
- 应用与 CLI 发布版本提升到 `1.7.0`
|
||||
- 应用版本入口已对齐到 `1.7.0`,覆盖 `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.7.0`
|
||||
- 应用与 CLI 发布版本提升到 `1.8.0`
|
||||
- 应用版本入口已对齐到 `1.8.0`,覆盖 `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.8.0`
|
||||
- `src-tauri/Cargo.lock` 已在本轮 Rust 校验后同步更新到当前 workspace 状态
|
||||
|
||||
### ⚠️ 发布说明
|
||||
|
||||
- 本次发布 tag 为 `v1.7.0`
|
||||
- 本次发布 tag 为 `v1.8.0`
|
||||
- 本次发布以当前工作区完整改动为准,不复用旧 tag
|
||||
- 当前 release note 已按这次完整发布内容刷新
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
|
||||
### 📝 文档同步
|
||||
|
||||
- 发布说明已更新为当前这次完整的 `v1.7.0` 内容,可直接作为 GitHub Release note 使用
|
||||
- 发布说明已更新为当前这次完整的 `v1.8.0` 内容,可直接作为 GitHub Release note 使用
|
||||
|
||||
---
|
||||
|
||||
**完整变更**: `v1.6.1` -> `v1.7.0`
|
||||
**完整变更**: `v1.7.0` -> `v1.8.0`
|
||||
|
||||
@@ -528,6 +528,9 @@ npm run verify:local
|
||||
- **旧 `chat_*` 命令**:已停止注册,不应重新回到 `commands::mod` 或 `generate_handler!`
|
||||
- **旧 `general_chat_*` 边界**:前端 compat 网关与 Rust 命令都已移除,不应重新接入
|
||||
- **记忆系统**:统一沉淀优先走 `unified_memory_*`,runtime / 上下文视图优先走 `memory_runtime_*`
|
||||
- **工作记忆主链**:会话级计划 / 发现 / 进度 / 错误文件继续收敛到 `memory_runtime_get_working_memory`;不要让页面、Hook 或运行时各自重新扫描 `.lime/memory`
|
||||
- **记忆抽取状态主链**:记忆抽取与上下文压缩状态继续收敛到 `memory_runtime_get_extraction_status`;Memory 页面、诊断视图和后续 GUI 提示都不应各自拼凑“最近是否压缩过”
|
||||
- **单回合记忆预取主链**:运行时 working / durable / compaction recall 继续收敛到 `memory_runtime_prefetch_for_turn`;不要把 working memory、统一记忆检索或压缩摘要再拆成第二套 prompt 拼装边界
|
||||
- **旧项目风格命令**:`style_guide_get` / `style_guide_update` 已下线,不应再从前端网关、Rust 注册或 mock 中接回
|
||||
- **旧项目模板命令**:`create_template` / `list_templates` / `get_template` / `update_template` / `delete_template` / `set_default_template` / `get_default_template` 已下线,不应再从前端网关、Rust 注册或 mock 中接回
|
||||
- **旧品牌人设扩展命令**:`get_brand_persona` / `get_brand_extension` / `save_brand_extension` / `update_brand_extension` / `delete_brand_extension` / `list_brand_persona_templates` 已下线,不应再从前端网关、Rust 注册或 mock 中接回
|
||||
|
||||
@@ -127,6 +127,15 @@ npm run test:contracts
|
||||
3. 验证主导航可见,例如“首页”“社媒内容”“设置”
|
||||
4. 检查控制台 error 是否为 0
|
||||
|
||||
### 记忆工作台验证
|
||||
|
||||
1. 从左侧导航进入 `灵感库`
|
||||
2. 确认页面左侧已经收敛为 `总览 / 规则 / 工作记忆 / 长期记忆 / Team 影子 / 压缩边界` 六区,而不是继续把 `identity/context/preference/experience/activity` 平铺成顶层导航
|
||||
3. 打开 `规则`,确认能看到有效来源与自动记忆入口,不会把 working memory 混在同一区块
|
||||
4. 打开 `工作记忆`,确认至少能看到 session 级文件摘要或明确空态,不会直接报错
|
||||
5. 打开 `长期记忆`,确认旧 category 仍作为内部筛选存在,并且 `带回创作输入` 能跳回 `Claw` / 新建任务主链
|
||||
6. 打开 `Team 影子` 与 `压缩边界`,确认页面能消费 localStorage shadow 与 compaction 快照,不会退回旧的单层“灵感库”文案
|
||||
|
||||
### AI 服务商页拆分后验证
|
||||
|
||||
1. 进入 `设置 -> AI 服务商`
|
||||
|
||||
@@ -89,6 +89,8 @@
|
||||
|
||||
如果本轮涉及 `create_skill_scaffold_for_app`、`SkillsPage / SkillScaffoldDialog`,或“聊天结果 -> Skill 脚手架”沉淀闭环,还要同步检查前端网关、Rust 模板、DevBridge 分发与默认 mock 是否仍保持同一条主链;若新增了结构化骨架字段,至少要确认 `何时使用 / 输入 / 执行步骤 / 输出 / 失败回退` 能真实落进生成后的 `SKILL.md`。
|
||||
|
||||
如果本轮涉及记忆主链,还要同步检查 `src/lib/api/memoryRuntime.ts`、`src-tauri/src/commands/memory_management_cmd.rs`、`runner.rs`、DevBridge dispatcher 与默认 mock 是否仍保持同一条 current surface;`rules / working / durable / team / compaction` 的产品分层可以在页面上拆开,但底层命令边界仍必须继续收敛到 `memory_runtime_*` 与 `unified_memory_*`。
|
||||
|
||||
### 3. 用户可见 UI 改动必须补稳定回归
|
||||
|
||||
- 优先补现有 `*.test.tsx` 的关键文案、状态与交互断言
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "lime",
|
||||
"version": "1.7.0",
|
||||
"version": "1.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lime",
|
||||
"version": "1.7.0",
|
||||
"version": "1.8.0",
|
||||
"dependencies": {
|
||||
"@babel/standalone": "^7.29.0",
|
||||
"@fabianlars/tauri-plugin-oauth": "^2",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "lime",
|
||||
"private": true,
|
||||
"version": "1.7.0",
|
||||
"version": "1.8.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
|
||||
@@ -112,7 +112,7 @@ npm run build:release -- \
|
||||
```bash
|
||||
npm run build:release -- \
|
||||
--target-triple "aarch64-apple-darwin" \
|
||||
--version "1.7.0" \
|
||||
--version "1.8.0" \
|
||||
--out-dir "./dist"
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@limecloud/lime-cli",
|
||||
"version": "1.7.0",
|
||||
"version": "1.8.0",
|
||||
"description": "Lime 官方任务 CLI",
|
||||
"bin": {
|
||||
"lime": "scripts/run.js"
|
||||
|
||||
@@ -19,6 +19,7 @@ const __dirname = path.dirname(__filename);
|
||||
const rootDir = path.resolve(__dirname, "..");
|
||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const LIME_SKIP_STARTUP_WINDOW_REVEAL = "LIME_SKIP_STARTUP_WINDOW_REVEAL";
|
||||
const LIME_DISABLE_SINGLE_INSTANCE = "LIME_DISABLE_SINGLE_INSTANCE";
|
||||
|
||||
const state = {
|
||||
child: null,
|
||||
@@ -167,6 +168,7 @@ function startHeadlessTauri() {
|
||||
env: {
|
||||
...process.env,
|
||||
[LIME_SKIP_STARTUP_WINDOW_REVEAL]: "1",
|
||||
[LIME_DISABLE_SINGLE_INSTANCE]: "1",
|
||||
},
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
|
||||
Generated
+19
-19
@@ -5101,7 +5101,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -5206,7 +5206,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-agent"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aster-core",
|
||||
@@ -5235,7 +5235,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-browser-runtime"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -5252,7 +5252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-cli"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"lime-core",
|
||||
@@ -5264,7 +5264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-config"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"lime-core",
|
||||
@@ -5280,7 +5280,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-core"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"aster-models",
|
||||
"async-trait",
|
||||
@@ -5320,7 +5320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-credential"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"base64 0.22.1",
|
||||
@@ -5355,7 +5355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-gateway"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"axum 0.7.9",
|
||||
@@ -5385,7 +5385,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-infra"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap 5.5.3",
|
||||
@@ -5405,7 +5405,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-mcp"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -5421,7 +5421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-media-runtime"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -5452,7 +5452,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-processor"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"lime-core",
|
||||
@@ -5471,7 +5471,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-providers"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -5526,7 +5526,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-server"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"aster-core",
|
||||
"async-stream",
|
||||
@@ -5571,7 +5571,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-server-utils"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"futures",
|
||||
@@ -5586,7 +5586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-services"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aster-core",
|
||||
@@ -5628,7 +5628,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-skills"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -5646,7 +5646,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-terminal"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -5673,7 +5673,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-websocket"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
|
||||
@@ -4,7 +4,7 @@ exclude = ["crates/aster", "crates/aster-models", "crates/aster-rust"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
edition = "2021"
|
||||
authors = ["coso"]
|
||||
repository = "https://github.com/aiclientproxy/lime"
|
||||
@@ -189,7 +189,7 @@ version = "2.4"
|
||||
|
||||
[package]
|
||||
name = "lime"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -199,6 +199,7 @@ homepage = "https://github.com/aiclientproxy/lime"
|
||||
[lib]
|
||||
name = "lime_lib"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
doctest = false
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build.workspace = true
|
||||
|
||||
@@ -246,6 +246,28 @@ fn normalize_interpreter_model_override(interpreter_model: Option<String>) -> Op
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn should_attempt_tool_interpretation(content: &str) -> bool {
|
||||
let trimmed = content.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let lowered = trimmed.to_ascii_lowercase();
|
||||
trimmed.starts_with('{')
|
||||
|| trimmed.starts_with('[')
|
||||
|| trimmed.starts_with("```")
|
||||
|| lowered.contains("\"tool_calls\"")
|
||||
|| lowered.contains("\"name\"")
|
||||
|| lowered.contains("\"arguments\"")
|
||||
|| lowered.contains("tool_calls")
|
||||
|| lowered.contains("tool call")
|
||||
|| lowered.contains("use tool")
|
||||
|| lowered.contains("using tool")
|
||||
|| lowered.contains("调用工具")
|
||||
|| lowered.contains("使用工具")
|
||||
|| lowered.contains("工具调用")
|
||||
}
|
||||
|
||||
fn resolve_interpreter_model(explicit_model: Option<&str>) -> String {
|
||||
explicit_model
|
||||
.map(str::trim)
|
||||
@@ -433,6 +455,11 @@ pub async fn augment_message_with_tool_calls<T: ToolInterpreter>(
|
||||
None => return Ok(message),
|
||||
};
|
||||
|
||||
// 普通自然语言增量不需要进入 toolshim 解释器,否则会被每个文本片段重复放大延迟。
|
||||
if !should_attempt_tool_interpretation(content) {
|
||||
return Ok(message);
|
||||
}
|
||||
|
||||
// Check if there's already a tool request
|
||||
if message
|
||||
.content
|
||||
@@ -501,4 +528,21 @@ mod tests {
|
||||
|
||||
assert_eq!(resolved, DEFAULT_INTERPRETER_MODEL_OLLAMA);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_reply_does_not_trigger_tool_interpretation() {
|
||||
assert!(!should_attempt_tool_interpretation("测试通过"));
|
||||
assert!(!should_attempt_tool_interpretation("好的,我来处理。"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_like_reply_still_triggers_tool_interpretation() {
|
||||
assert!(should_attempt_tool_interpretation(
|
||||
r#"{"name":"write_file","arguments":{"path":"demo.txt"}}"#
|
||||
));
|
||||
assert!(should_attempt_tool_interpretation(
|
||||
"```json\n{\"tool_calls\":[{\"name\":\"noop\",\"arguments\":{}}]}\n```"
|
||||
));
|
||||
assert!(should_attempt_tool_interpretation("我准备调用工具"));
|
||||
}
|
||||
}
|
||||
|
||||
+62
-31
@@ -17,6 +17,7 @@ use super::types::{AppState, TrayManagerState};
|
||||
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
const SKIP_STARTUP_WINDOW_REVEAL_ENV: &str = "LIME_SKIP_STARTUP_WINDOW_REVEAL";
|
||||
const DISABLE_SINGLE_INSTANCE_ENV: &str = "LIME_DISABLE_SINGLE_INSTANCE";
|
||||
|
||||
fn env_flag_enabled(key: &str) -> bool {
|
||||
matches!(
|
||||
@@ -34,6 +35,10 @@ fn should_reveal_main_window_on_startup() -> bool {
|
||||
!env_flag_enabled(SKIP_STARTUP_WINDOW_REVEAL_ENV)
|
||||
}
|
||||
|
||||
fn should_enable_single_instance() -> bool {
|
||||
!env_flag_enabled(DISABLE_SINGLE_INSTANCE_ENV)
|
||||
}
|
||||
|
||||
fn compiled_updater_public_key() -> Option<&'static str> {
|
||||
option_env!("LIME_UPDATER_PUBLIC_KEY")
|
||||
.map(str::trim)
|
||||
@@ -169,39 +174,43 @@ pub fn run() {
|
||||
builder = builder.plugin(tauri_plugin_deep_link::init());
|
||||
}
|
||||
|
||||
builder = builder
|
||||
// 单实例插件:当第二个实例启动时,将 URL 传递给第一个实例
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
tracing::info!("[单实例] 收到来自新实例的参数: {:?}", args);
|
||||
if should_enable_single_instance() {
|
||||
builder = builder
|
||||
// 单实例插件:当第二个实例启动时,将 URL 传递给第一个实例
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
tracing::info!("[单实例] 收到来自新实例的参数: {:?}", args);
|
||||
|
||||
// 将窗口带到前台
|
||||
if should_reveal_main_window_on_startup() {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
reveal_main_window(&window);
|
||||
}
|
||||
} else {
|
||||
tracing::info!("[启动] 已跳过主窗口展示流程(headless smoke 模式)");
|
||||
}
|
||||
|
||||
let deep_link_urls: Vec<String> = args
|
||||
.iter()
|
||||
.filter_map(|arg| {
|
||||
let value = arg.trim();
|
||||
if value.starts_with("lime://") {
|
||||
Some(value.to_string())
|
||||
} else {
|
||||
None
|
||||
// 将窗口带到前台
|
||||
if should_reveal_main_window_on_startup() {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
reveal_main_window(&window);
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !deep_link_urls.is_empty() {
|
||||
tracing::info!("[单实例] 转发 Deep Link URL: {:?}", deep_link_urls);
|
||||
if let Err(error) = app.emit("deep-link://new-url", &deep_link_urls) {
|
||||
tracing::error!("[单实例] 转发 Deep Link URL 失败: {}", error);
|
||||
} else {
|
||||
tracing::info!("[启动] 已跳过主窗口展示流程(headless smoke 模式)");
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
let deep_link_urls: Vec<String> = args
|
||||
.iter()
|
||||
.filter_map(|arg| {
|
||||
let value = arg.trim();
|
||||
if value.starts_with("lime://") {
|
||||
Some(value.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !deep_link_urls.is_empty() {
|
||||
tracing::info!("[单实例] 转发 Deep Link URL: {:?}", deep_link_urls);
|
||||
if let Err(error) = app.emit("deep-link://new-url", &deep_link_urls) {
|
||||
tracing::error!("[单实例] 转发 Deep Link URL 失败: {}", error);
|
||||
}
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
tracing::info!("[启动] 已禁用单实例插件(当前会话允许并行实例)");
|
||||
}
|
||||
|
||||
builder
|
||||
.manage(state)
|
||||
@@ -1755,6 +1764,9 @@ pub fn run() {
|
||||
commands::memory_management_cmd::memory_runtime_get_overview,
|
||||
commands::memory_management_cmd::memory_runtime_request_analysis,
|
||||
commands::memory_management_cmd::memory_runtime_cleanup,
|
||||
commands::memory_management_cmd::memory_runtime_get_working_memory,
|
||||
commands::memory_management_cmd::memory_runtime_get_extraction_status,
|
||||
commands::memory_management_cmd::memory_runtime_prefetch_for_turn,
|
||||
commands::memory_management_cmd::memory_get_effective_sources,
|
||||
commands::memory_management_cmd::memory_get_auto_index,
|
||||
commands::memory_management_cmd::memory_toggle_auto,
|
||||
@@ -1856,7 +1868,9 @@ pub fn run() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::should_minimize_to_tray;
|
||||
use super::{
|
||||
should_enable_single_instance, should_minimize_to_tray, DISABLE_SINGLE_INSTANCE_ENV,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn should_only_minimize_main_window_to_tray() {
|
||||
@@ -1864,4 +1878,21 @@ mod tests {
|
||||
assert!(!should_minimize_to_tray("openclaw-dashboard", true));
|
||||
assert!(!should_minimize_to_tray("main", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_allow_disabling_single_instance_via_env() {
|
||||
unsafe {
|
||||
std::env::remove_var(DISABLE_SINGLE_INSTANCE_ENV);
|
||||
}
|
||||
assert!(should_enable_single_instance());
|
||||
|
||||
unsafe {
|
||||
std::env::set_var(DISABLE_SINGLE_INSTANCE_ENV, "1");
|
||||
}
|
||||
assert!(!should_enable_single_instance());
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var(DISABLE_SINGLE_INSTANCE_ENV);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use super::*;
|
||||
use crate::commands::aster_agent_cmd::runtime_turn::build_runtime_queue_executor;
|
||||
use crate::commands::aster_agent_cmd::session_runtime::{
|
||||
create_runtime_session_internal, list_runtime_sessions_internal,
|
||||
rename_runtime_session_internal, update_runtime_session_execution_strategy_internal,
|
||||
@@ -20,6 +19,28 @@ pub(crate) mod session_api;
|
||||
#[path = "command_api/subagent_api.rs"]
|
||||
pub(crate) mod subagent_api;
|
||||
|
||||
fn build_runtime_command_context(
|
||||
app: AppHandle,
|
||||
state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
|
||||
logs: State<'_, LogState>,
|
||||
config_manager: State<'_, GlobalConfigManagerState>,
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
) -> RuntimeCommandContext {
|
||||
RuntimeCommandContext::new(
|
||||
app,
|
||||
state.inner(),
|
||||
db.inner(),
|
||||
api_key_provider_service.inner(),
|
||||
logs.inner(),
|
||||
config_manager.inner(),
|
||||
mcp_manager.inner(),
|
||||
automation_state.inner(),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_subagent_control_runtime(
|
||||
app: AppHandle,
|
||||
state: State<'_, AsterAgentState>,
|
||||
|
||||
@@ -42,6 +42,7 @@ pub async fn aster_agent_configure_provider(
|
||||
mut request: ConfigureProviderRequest,
|
||||
session_id: String,
|
||||
) -> Result<AsterAgentStatus, String> {
|
||||
ensure_provider_runtime_ready(&request).await?;
|
||||
let runtime_tool_call_decision =
|
||||
enrich_provider_config_with_runtime_tool_strategy(&mut request).await;
|
||||
tracing::info!(
|
||||
|
||||
@@ -18,6 +18,24 @@ use crate::services::runtime_review_decision_service::{
|
||||
use crate::services::thread_reliability_projection_service::sync_thread_reliability_projection;
|
||||
use std::path::PathBuf;
|
||||
|
||||
async fn resume_runtime_queue_with_warning(
|
||||
runtime: &RuntimeCommandContext,
|
||||
session_id: &str,
|
||||
action_label: &str,
|
||||
) {
|
||||
if let Err(error) = runtime
|
||||
.resume_runtime_queue_if_needed(session_id.to_string())
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"[AsterAgent][Queue] {}恢复排队执行失败: session_id={}, error={}",
|
||||
action_label,
|
||||
session_id,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_submit_turn(
|
||||
app: AppHandle,
|
||||
@@ -30,23 +48,22 @@ pub async fn agent_runtime_submit_turn(
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
request: AgentRuntimeSubmitTurnRequest,
|
||||
) -> Result<(), String> {
|
||||
let runtime = build_runtime_command_context(
|
||||
app,
|
||||
state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
);
|
||||
let runtime_request: AsterChatRequest = request.into();
|
||||
let queue_if_busy = runtime_request.queue_if_busy.unwrap_or(false);
|
||||
let queued_task = build_queued_turn_task(runtime_request)?;
|
||||
submit_runtime_turn_service(
|
||||
app,
|
||||
state.inner(),
|
||||
db.inner(),
|
||||
api_key_provider_service.inner(),
|
||||
logs.inner(),
|
||||
config_manager.inner(),
|
||||
mcp_manager.inner(),
|
||||
automation_state.inner(),
|
||||
queued_task,
|
||||
queue_if_busy,
|
||||
build_runtime_queue_executor(),
|
||||
)
|
||||
.await
|
||||
runtime
|
||||
.submit_runtime_turn(queued_task, queue_if_busy)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 统一运行时:中断当前 turn。
|
||||
@@ -97,24 +114,22 @@ pub async fn agent_runtime_resume_thread(
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
request: AgentRuntimeResumeThreadRequest,
|
||||
) -> Result<bool, String> {
|
||||
let runtime = build_runtime_command_context(
|
||||
app,
|
||||
state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
);
|
||||
let session_id = request.session_id.trim().to_string();
|
||||
if session_id.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
resume_runtime_queue_if_needed_service(
|
||||
app,
|
||||
state.inner(),
|
||||
db.inner(),
|
||||
api_key_provider_service.inner(),
|
||||
logs.inner(),
|
||||
config_manager.inner(),
|
||||
mcp_manager.inner(),
|
||||
automation_state.inner(),
|
||||
session_id,
|
||||
build_runtime_queue_executor(),
|
||||
)
|
||||
.await
|
||||
runtime.resume_runtime_queue_if_needed(session_id).await
|
||||
}
|
||||
|
||||
/// 统一运行时:获取会话详情。
|
||||
@@ -130,32 +145,23 @@ pub async fn agent_runtime_get_session(
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
session_id: String,
|
||||
) -> Result<AgentRuntimeSessionDetail, String> {
|
||||
tracing::info!("[AsterAgent] 获取运行时会话: {}", session_id);
|
||||
if let Err(error) = resume_runtime_queue_if_needed_service(
|
||||
let runtime = build_runtime_command_context(
|
||||
app,
|
||||
state.inner(),
|
||||
db.inner(),
|
||||
api_key_provider_service.inner(),
|
||||
logs.inner(),
|
||||
config_manager.inner(),
|
||||
mcp_manager.inner(),
|
||||
automation_state.inner(),
|
||||
session_id.clone(),
|
||||
build_runtime_queue_executor(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"[AsterAgent][Queue] 获取会话后恢复排队执行失败: session_id={}, error={}",
|
||||
session_id,
|
||||
error
|
||||
);
|
||||
}
|
||||
state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
);
|
||||
tracing::info!("[AsterAgent] 获取运行时会话: {}", session_id);
|
||||
resume_runtime_queue_with_warning(&runtime, &session_id, "获取会话后").await;
|
||||
|
||||
let detail = AsterAgentWrapper::get_runtime_session_detail(db.inner(), &session_id).await?;
|
||||
let detail = AsterAgentWrapper::get_runtime_session_detail(runtime.db(), &session_id).await?;
|
||||
let queued_turns = list_runtime_queue_snapshots_service(&session_id).await?;
|
||||
let projection = sync_thread_reliability_projection(db.inner(), &detail)?;
|
||||
let interrupt_marker = state.get_interrupt_marker(&session_id).await;
|
||||
let projection = sync_thread_reliability_projection(runtime.db(), &detail)?;
|
||||
let interrupt_marker = runtime.state().get_interrupt_marker(&session_id).await;
|
||||
let thread_read = AgentRuntimeThreadReadModel::from_parts(
|
||||
&detail,
|
||||
&queued_turns,
|
||||
@@ -186,32 +192,23 @@ pub async fn agent_runtime_get_thread_read(
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
session_id: String,
|
||||
) -> Result<AgentRuntimeThreadReadModel, String> {
|
||||
tracing::info!("[AsterAgent] 获取运行时线程读模型: {}", session_id);
|
||||
if let Err(error) = resume_runtime_queue_if_needed_service(
|
||||
let runtime = build_runtime_command_context(
|
||||
app,
|
||||
state.inner(),
|
||||
db.inner(),
|
||||
api_key_provider_service.inner(),
|
||||
logs.inner(),
|
||||
config_manager.inner(),
|
||||
mcp_manager.inner(),
|
||||
automation_state.inner(),
|
||||
session_id.clone(),
|
||||
build_runtime_queue_executor(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"[AsterAgent][Queue] 获取线程读模型后恢复排队执行失败: session_id={}, error={}",
|
||||
session_id,
|
||||
error
|
||||
);
|
||||
}
|
||||
state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
);
|
||||
tracing::info!("[AsterAgent] 获取运行时线程读模型: {}", session_id);
|
||||
resume_runtime_queue_with_warning(&runtime, &session_id, "获取线程读模型后").await;
|
||||
|
||||
let detail = AsterAgentWrapper::get_runtime_session_detail(db.inner(), &session_id).await?;
|
||||
let detail = AsterAgentWrapper::get_runtime_session_detail(runtime.db(), &session_id).await?;
|
||||
let queued_turns = list_runtime_queue_snapshots_service(&session_id).await?;
|
||||
let projection = sync_thread_reliability_projection(db.inner(), &detail)?;
|
||||
let interrupt_marker = state.get_interrupt_marker(&session_id).await;
|
||||
let projection = sync_thread_reliability_projection(runtime.db(), &detail)?;
|
||||
let interrupt_marker = runtime.state().get_interrupt_marker(&session_id).await;
|
||||
Ok(AgentRuntimeThreadReadModel::from_parts(
|
||||
&detail,
|
||||
&queued_turns,
|
||||
@@ -261,43 +258,16 @@ fn resolve_runtime_export_workspace_root(
|
||||
}
|
||||
|
||||
async fn load_runtime_export_context(
|
||||
app: &AppHandle,
|
||||
state: &AsterAgentState,
|
||||
db: &DbConnection,
|
||||
api_key_provider_service: &ApiKeyProviderServiceState,
|
||||
logs: &LogState,
|
||||
config_manager: &GlobalConfigManagerState,
|
||||
mcp_manager: &McpManagerState,
|
||||
automation_state: &AutomationServiceState,
|
||||
runtime: &RuntimeCommandContext,
|
||||
session_id: &str,
|
||||
action_label: &str,
|
||||
) -> Result<RuntimeExportContext, String> {
|
||||
if let Err(error) = resume_runtime_queue_if_needed_service(
|
||||
app.clone(),
|
||||
state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
session_id.to_string(),
|
||||
build_runtime_queue_executor(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"[AsterAgent][Queue] {} 前恢复排队执行失败: session_id={}, error={}",
|
||||
action_label,
|
||||
session_id,
|
||||
error
|
||||
);
|
||||
}
|
||||
resume_runtime_queue_with_warning(runtime, session_id, action_label).await;
|
||||
|
||||
let detail = AsterAgentWrapper::get_runtime_session_detail(db, session_id).await?;
|
||||
let detail = AsterAgentWrapper::get_runtime_session_detail(runtime.db(), session_id).await?;
|
||||
let queued_turns = list_runtime_queue_snapshots_service(session_id).await?;
|
||||
let projection = sync_thread_reliability_projection(db, &detail)?;
|
||||
let interrupt_marker = state.get_interrupt_marker(session_id).await;
|
||||
let projection = sync_thread_reliability_projection(runtime.db(), &detail)?;
|
||||
let interrupt_marker = runtime.state().get_interrupt_marker(session_id).await;
|
||||
let thread_read = AgentRuntimeThreadReadModel::from_parts(
|
||||
&detail,
|
||||
&queued_turns,
|
||||
@@ -306,7 +276,7 @@ async fn load_runtime_export_context(
|
||||
projection.incidents,
|
||||
interrupt_marker.as_ref(),
|
||||
);
|
||||
let workspace_root = resolve_runtime_export_workspace_root(db, &detail)?;
|
||||
let workspace_root = resolve_runtime_export_workspace_root(runtime.db(), &detail)?;
|
||||
|
||||
Ok(RuntimeExportContext {
|
||||
detail,
|
||||
@@ -328,20 +298,19 @@ pub async fn agent_runtime_export_handoff_bundle(
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
session_id: String,
|
||||
) -> Result<RuntimeHandoffBundleExportResult, String> {
|
||||
let runtime = build_runtime_command_context(
|
||||
app,
|
||||
state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
);
|
||||
tracing::info!("[AsterAgent] 导出 handoff bundle: {}", session_id);
|
||||
let context = load_runtime_export_context(
|
||||
&app,
|
||||
state.inner(),
|
||||
db.inner(),
|
||||
api_key_provider_service.inner(),
|
||||
logs.inner(),
|
||||
config_manager.inner(),
|
||||
mcp_manager.inner(),
|
||||
automation_state.inner(),
|
||||
&session_id,
|
||||
"导出 handoff bundle",
|
||||
)
|
||||
.await?;
|
||||
let context =
|
||||
load_runtime_export_context(&runtime, &session_id, "导出 handoff bundle 前").await?;
|
||||
|
||||
export_runtime_handoff_bundle(
|
||||
&context.detail,
|
||||
@@ -363,20 +332,19 @@ pub async fn agent_runtime_export_evidence_pack(
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
session_id: String,
|
||||
) -> Result<RuntimeEvidencePackExportResult, String> {
|
||||
let runtime = build_runtime_command_context(
|
||||
app,
|
||||
state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
);
|
||||
tracing::info!("[AsterAgent] 导出 evidence pack: {}", session_id);
|
||||
let context = load_runtime_export_context(
|
||||
&app,
|
||||
state.inner(),
|
||||
db.inner(),
|
||||
api_key_provider_service.inner(),
|
||||
logs.inner(),
|
||||
config_manager.inner(),
|
||||
mcp_manager.inner(),
|
||||
automation_state.inner(),
|
||||
&session_id,
|
||||
"导出 evidence pack",
|
||||
)
|
||||
.await?;
|
||||
let context =
|
||||
load_runtime_export_context(&runtime, &session_id, "导出 evidence pack 前").await?;
|
||||
|
||||
export_runtime_evidence_pack(
|
||||
&context.detail,
|
||||
@@ -398,20 +366,19 @@ pub async fn agent_runtime_export_analysis_handoff(
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
session_id: String,
|
||||
) -> Result<RuntimeAnalysisHandoffExportResult, String> {
|
||||
let runtime = build_runtime_command_context(
|
||||
app,
|
||||
state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
);
|
||||
tracing::info!("[AsterAgent] 导出 analysis handoff: {}", session_id);
|
||||
let context = load_runtime_export_context(
|
||||
&app,
|
||||
state.inner(),
|
||||
db.inner(),
|
||||
api_key_provider_service.inner(),
|
||||
logs.inner(),
|
||||
config_manager.inner(),
|
||||
mcp_manager.inner(),
|
||||
automation_state.inner(),
|
||||
&session_id,
|
||||
"导出 analysis handoff",
|
||||
)
|
||||
.await?;
|
||||
let context =
|
||||
load_runtime_export_context(&runtime, &session_id, "导出 analysis handoff 前").await?;
|
||||
|
||||
export_runtime_analysis_handoff(
|
||||
&context.detail,
|
||||
@@ -433,20 +400,19 @@ pub async fn agent_runtime_export_review_decision_template(
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
session_id: String,
|
||||
) -> Result<RuntimeReviewDecisionTemplateExportResult, String> {
|
||||
let runtime = build_runtime_command_context(
|
||||
app,
|
||||
state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
);
|
||||
tracing::info!("[AsterAgent] 导出 review decision 模板: {}", session_id);
|
||||
let context = load_runtime_export_context(
|
||||
&app,
|
||||
state.inner(),
|
||||
db.inner(),
|
||||
api_key_provider_service.inner(),
|
||||
logs.inner(),
|
||||
config_manager.inner(),
|
||||
mcp_manager.inner(),
|
||||
automation_state.inner(),
|
||||
&session_id,
|
||||
"导出 review decision 模板",
|
||||
)
|
||||
.await?;
|
||||
let context =
|
||||
load_runtime_export_context(&runtime, &session_id, "导出 review decision 模板前").await?;
|
||||
|
||||
export_runtime_review_decision_template(
|
||||
&context.detail,
|
||||
@@ -468,21 +434,20 @@ pub async fn agent_runtime_save_review_decision(
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
request: AgentRuntimeSaveReviewDecisionRequest,
|
||||
) -> Result<RuntimeReviewDecisionTemplateExportResult, String> {
|
||||
let runtime = build_runtime_command_context(
|
||||
app,
|
||||
state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
);
|
||||
let session_id = request.session_id.trim().to_string();
|
||||
tracing::info!("[AsterAgent] 保存 review decision: {}", session_id);
|
||||
let context = load_runtime_export_context(
|
||||
&app,
|
||||
state.inner(),
|
||||
db.inner(),
|
||||
api_key_provider_service.inner(),
|
||||
logs.inner(),
|
||||
config_manager.inner(),
|
||||
mcp_manager.inner(),
|
||||
automation_state.inner(),
|
||||
&session_id,
|
||||
"保存 review decision",
|
||||
)
|
||||
.await?;
|
||||
let context =
|
||||
load_runtime_export_context(&runtime, &session_id, "保存 review decision 前").await?;
|
||||
|
||||
save_runtime_review_decision(
|
||||
&context.detail,
|
||||
@@ -516,20 +481,18 @@ pub async fn agent_runtime_export_replay_case(
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
session_id: String,
|
||||
) -> Result<RuntimeReplayCaseExportResult, String> {
|
||||
let runtime = build_runtime_command_context(
|
||||
app,
|
||||
state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
);
|
||||
tracing::info!("[AsterAgent] 导出 replay case: {}", session_id);
|
||||
let context = load_runtime_export_context(
|
||||
&app,
|
||||
state.inner(),
|
||||
db.inner(),
|
||||
api_key_provider_service.inner(),
|
||||
logs.inner(),
|
||||
config_manager.inner(),
|
||||
mcp_manager.inner(),
|
||||
automation_state.inner(),
|
||||
&session_id,
|
||||
"导出 replay case",
|
||||
)
|
||||
.await?;
|
||||
let context = load_runtime_export_context(&runtime, &session_id, "导出 replay case 前").await?;
|
||||
|
||||
export_runtime_replay_case(
|
||||
&context.detail,
|
||||
@@ -697,6 +660,16 @@ pub async fn agent_runtime_promote_queued_turn(
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
request: AgentRuntimePromoteQueuedTurnRequest,
|
||||
) -> Result<bool, String> {
|
||||
let runtime = build_runtime_command_context(
|
||||
app,
|
||||
state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
);
|
||||
let session_id = request.session_id.trim().to_string();
|
||||
let queued_turn_id = request.queued_turn_id.trim().to_string();
|
||||
if session_id.is_empty() || queued_turn_id.is_empty() {
|
||||
@@ -708,20 +681,8 @@ pub async fn agent_runtime_promote_queued_turn(
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let _ = state.cancel_session(&session_id).await;
|
||||
let _ = resume_runtime_queue_if_needed_service(
|
||||
app,
|
||||
state.inner(),
|
||||
db.inner(),
|
||||
api_key_provider_service.inner(),
|
||||
logs.inner(),
|
||||
config_manager.inner(),
|
||||
mcp_manager.inner(),
|
||||
automation_state.inner(),
|
||||
session_id,
|
||||
build_runtime_queue_executor(),
|
||||
)
|
||||
.await?;
|
||||
let _ = runtime.state().cancel_session(&session_id).await;
|
||||
let _ = runtime.resume_runtime_queue_if_needed(session_id).await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -411,6 +411,19 @@ pub struct AgentRuntimeDiagnosticPendingRequestSample {
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentRuntimeCompactionBoundarySnapshot {
|
||||
pub session_id: String,
|
||||
pub summary_preview: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub turn_count: Option<u32>,
|
||||
pub created_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trigger: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentRuntimeThreadDiagnostics {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -477,6 +490,8 @@ pub struct AgentRuntimeThreadReadModel {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub latest_compaction_boundary: Option<AgentRuntimeCompactionBoundarySnapshot>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub diagnostics: Option<AgentRuntimeThreadDiagnostics>,
|
||||
}
|
||||
|
||||
@@ -628,6 +643,12 @@ impl AgentRuntimeThreadReadModel {
|
||||
&incidents,
|
||||
runtime_interrupt_marker,
|
||||
);
|
||||
let latest_compaction_boundary = build_latest_compaction_boundary_snapshot(
|
||||
&detail.id,
|
||||
diagnostics
|
||||
.as_ref()
|
||||
.and_then(|value| value.latest_context_compaction.as_ref()),
|
||||
);
|
||||
let active_turn = detail
|
||||
.turns
|
||||
.iter()
|
||||
@@ -691,6 +712,7 @@ impl AgentRuntimeThreadReadModel {
|
||||
updated_at: latest_turn
|
||||
.map(|turn| turn.updated_at.clone())
|
||||
.or_else(|| Some(detail.updated_at.to_string())),
|
||||
latest_compaction_boundary,
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
@@ -717,6 +739,37 @@ fn elapsed_seconds_between(start_raw: Option<&str>, end_raw: Option<&str>) -> Op
|
||||
Some(end.signed_duration_since(start).num_seconds().max(0))
|
||||
}
|
||||
|
||||
fn compact_summary_preview(input: &str, max_chars: usize) -> String {
|
||||
let normalized = input
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" | ");
|
||||
let mut chars = normalized.chars();
|
||||
let prefix: String = chars.by_ref().take(max_chars).collect();
|
||||
if chars.next().is_some() {
|
||||
format!("{prefix}...")
|
||||
} else {
|
||||
prefix
|
||||
}
|
||||
}
|
||||
|
||||
fn build_latest_compaction_boundary_snapshot(
|
||||
session_id: &str,
|
||||
latest_context_compaction: Option<&AgentRuntimeDiagnosticContextCompactionSample>,
|
||||
) -> Option<AgentRuntimeCompactionBoundarySnapshot> {
|
||||
let summary = aster::session::load_summary_data(session_id)?;
|
||||
Some(AgentRuntimeCompactionBoundarySnapshot {
|
||||
session_id: session_id.to_string(),
|
||||
summary_preview: compact_summary_preview(&summary.summary, 220),
|
||||
turn_count: summary.turn_count.map(|value| value as u32),
|
||||
created_at: summary.timestamp.to_rfc3339(),
|
||||
trigger: latest_context_compaction.and_then(|sample| sample.trigger.clone()),
|
||||
detail: latest_context_compaction.and_then(|sample| sample.detail.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
fn warning_sample_from_item(
|
||||
item: &lime_core::database::dao::agent_timeline::AgentThreadItem,
|
||||
) -> Option<AgentRuntimeDiagnosticWarningSample> {
|
||||
|
||||
@@ -270,6 +270,7 @@ mod mcp_bridge;
|
||||
mod pdf_read_skill_launch;
|
||||
mod presentation_skill_launch;
|
||||
mod prompt_context;
|
||||
mod provider_runtime_bootstrap;
|
||||
mod provider_runtime_strategy;
|
||||
mod reply_runtime;
|
||||
mod report_skill_launch;
|
||||
@@ -402,6 +403,7 @@ pub(crate) use prompt_context::{
|
||||
merge_system_prompt_with_service_skill_launch_preload,
|
||||
merge_system_prompt_with_team_preference,
|
||||
};
|
||||
pub(crate) use provider_runtime_bootstrap::ensure_provider_runtime_ready;
|
||||
pub(crate) use provider_runtime_strategy::{
|
||||
enrich_provider_config_with_runtime_tool_strategy, RuntimeToolCallStrategy,
|
||||
};
|
||||
@@ -524,6 +526,149 @@ pub(crate) use webpage_skill_launch::{
|
||||
prune_webpage_skill_launch_detour_tools_from_registry,
|
||||
};
|
||||
|
||||
pub(crate) struct RuntimeCommandContext {
|
||||
app_handle: AppHandle,
|
||||
state: AsterAgentState,
|
||||
db: DbConnection,
|
||||
api_key_provider_service: ApiKeyProviderServiceState,
|
||||
logs: LogState,
|
||||
config_manager: GlobalConfigManagerState,
|
||||
mcp_manager: McpManagerState,
|
||||
automation_state: AutomationServiceState,
|
||||
runtime_queue_executor: RuntimeQueueExecutor,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RuntimeCommandContext {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("RuntimeCommandContext")
|
||||
.field("app_handle", &"<tauri-app-handle>")
|
||||
.field("state", &"<aster-agent-state>")
|
||||
.field("db", &"<db-connection>")
|
||||
.field("api_key_provider_service", &"<api-key-provider-service>")
|
||||
.field("logs", &"<log-state>")
|
||||
.field("config_manager", &"<global-config-manager>")
|
||||
.field("mcp_manager", &"<mcp-manager>")
|
||||
.field("automation_state", &"<automation-state>")
|
||||
.field("runtime_queue_executor", &"<runtime-queue-executor>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for RuntimeCommandContext {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
app_handle: self.app_handle.clone(),
|
||||
state: self.state.clone(),
|
||||
db: self.db.clone(),
|
||||
api_key_provider_service: ApiKeyProviderServiceState(
|
||||
self.api_key_provider_service.0.clone(),
|
||||
),
|
||||
logs: self.logs.clone(),
|
||||
config_manager: GlobalConfigManagerState(self.config_manager.0.clone()),
|
||||
mcp_manager: self.mcp_manager.clone(),
|
||||
automation_state: self.automation_state.clone(),
|
||||
runtime_queue_executor: self.runtime_queue_executor.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeCommandContext {
|
||||
pub(crate) fn new(
|
||||
app_handle: AppHandle,
|
||||
state: &AsterAgentState,
|
||||
db: &DbConnection,
|
||||
api_key_provider_service: &ApiKeyProviderServiceState,
|
||||
logs: &LogState,
|
||||
config_manager: &GlobalConfigManagerState,
|
||||
mcp_manager: &McpManagerState,
|
||||
automation_state: &AutomationServiceState,
|
||||
) -> Self {
|
||||
Self {
|
||||
app_handle,
|
||||
state: state.clone(),
|
||||
db: db.clone(),
|
||||
api_key_provider_service: ApiKeyProviderServiceState(
|
||||
api_key_provider_service.0.clone(),
|
||||
),
|
||||
logs: logs.clone(),
|
||||
config_manager: GlobalConfigManagerState(config_manager.0.clone()),
|
||||
mcp_manager: mcp_manager.clone(),
|
||||
automation_state: automation_state.clone(),
|
||||
runtime_queue_executor: build_runtime_queue_executor(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn state(&self) -> &AsterAgentState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
pub(crate) fn db(&self) -> &DbConnection {
|
||||
&self.db
|
||||
}
|
||||
|
||||
pub(crate) async fn submit_runtime_turn(
|
||||
&self,
|
||||
queued_task: QueuedTurnTask<serde_json::Value>,
|
||||
queue_if_busy: bool,
|
||||
) -> Result<(), String> {
|
||||
submit_runtime_turn_service(
|
||||
self.app_handle.clone(),
|
||||
&self.state,
|
||||
&self.db,
|
||||
&self.api_key_provider_service,
|
||||
&self.logs,
|
||||
&self.config_manager,
|
||||
&self.mcp_manager,
|
||||
&self.automation_state,
|
||||
queued_task,
|
||||
queue_if_busy,
|
||||
self.runtime_queue_executor.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn resume_runtime_queue_if_needed(
|
||||
&self,
|
||||
session_id: String,
|
||||
) -> Result<bool, String> {
|
||||
resume_runtime_queue_if_needed_service(
|
||||
self.app_handle.clone(),
|
||||
&self.state,
|
||||
&self.db,
|
||||
&self.api_key_provider_service,
|
||||
&self.logs,
|
||||
&self.config_manager,
|
||||
&self.mcp_manager,
|
||||
&self.automation_state,
|
||||
session_id,
|
||||
self.runtime_queue_executor.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn resume_persisted_runtime_queues_on_startup(&self) -> Result<usize, String> {
|
||||
resume_persisted_runtime_queues_on_startup_service(
|
||||
self.app_handle.clone(),
|
||||
&self.state,
|
||||
&self.db,
|
||||
&self.api_key_provider_service,
|
||||
&self.logs,
|
||||
&self.config_manager,
|
||||
&self.mcp_manager,
|
||||
&self.automation_state,
|
||||
self.runtime_queue_executor.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn clear_runtime_queue(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Result<Vec<aster::session::QueuedTurnRuntime>, String> {
|
||||
clear_runtime_queue_service(&self.app_handle, session_id).await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resume_persisted_runtime_queues_on_startup(
|
||||
app: AppHandle,
|
||||
state: &AsterAgentState,
|
||||
@@ -534,7 +679,7 @@ pub async fn resume_persisted_runtime_queues_on_startup(
|
||||
mcp_manager: &McpManagerState,
|
||||
automation_state: &AutomationServiceState,
|
||||
) -> Result<usize, String> {
|
||||
resume_persisted_runtime_queues_on_startup_service(
|
||||
RuntimeCommandContext::new(
|
||||
app,
|
||||
state,
|
||||
db,
|
||||
@@ -543,8 +688,8 @@ pub async fn resume_persisted_runtime_queues_on_startup(
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
build_runtime_queue_executor(),
|
||||
)
|
||||
.resume_persisted_runtime_queues_on_startup()
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
use super::dto::ConfigureProviderRequest;
|
||||
use super::provider_runtime_strategy::{is_ollama_provider, normalize_ollama_base_url};
|
||||
use async_trait::async_trait;
|
||||
use std::process::Stdio;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
use tokio::time::sleep;
|
||||
use url::Url;
|
||||
|
||||
#[cfg(test)]
|
||||
use std::collections::VecDeque;
|
||||
#[cfg(test)]
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
#[cfg(test)]
|
||||
use std::sync::Mutex;
|
||||
|
||||
const OLLAMA_RUNTIME_BOOTSTRAP_TIMEOUT_SECS: u64 = 20;
|
||||
const OLLAMA_RUNTIME_BOOTSTRAP_POLL_INTERVAL_MS: u64 = 500;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct RuntimeBootstrapPolicy {
|
||||
startup_timeout: Duration,
|
||||
poll_interval: Duration,
|
||||
}
|
||||
|
||||
impl Default for RuntimeBootstrapPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
startup_timeout: Duration::from_secs(OLLAMA_RUNTIME_BOOTSTRAP_TIMEOUT_SECS),
|
||||
poll_interval: Duration::from_millis(OLLAMA_RUNTIME_BOOTSTRAP_POLL_INTERVAL_MS),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum LocalProviderRuntimeStrategy {
|
||||
Ollama,
|
||||
}
|
||||
|
||||
impl LocalProviderRuntimeStrategy {
|
||||
fn resolve(request: &ConfigureProviderRequest) -> Option<Self> {
|
||||
if is_ollama_provider(request.provider_id.as_deref(), &request.provider_name) {
|
||||
return Some(Self::Ollama);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn ensure_ready(self, request: &ConfigureProviderRequest) -> Result<(), String> {
|
||||
match self {
|
||||
Self::Ollama => {
|
||||
ensure_ollama_runtime_ready(&normalize_ollama_base_url(request.base_url.as_deref()))
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_provider_runtime_ready(
|
||||
request: &ConfigureProviderRequest,
|
||||
) -> Result<(), String> {
|
||||
let Some(strategy) = LocalProviderRuntimeStrategy::resolve(request) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
strategy.ensure_ready(request).await
|
||||
}
|
||||
|
||||
fn ollama_launch_guard() -> &'static AsyncMutex<()> {
|
||||
static GUARD: OnceLock<AsyncMutex<()>> = OnceLock::new();
|
||||
GUARD.get_or_init(|| AsyncMutex::new(()))
|
||||
}
|
||||
|
||||
fn should_manage_local_ollama_runtime(base_url: &str) -> bool {
|
||||
let Ok(parsed) = Url::parse(base_url) else {
|
||||
return false;
|
||||
};
|
||||
matches!(parsed.host_str(), Some("127.0.0.1" | "localhost" | "::1"))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
trait OllamaRuntimeHooks: Send + Sync {
|
||||
async fn is_ready(&self, base_url: &str) -> bool;
|
||||
async fn launch(&self) -> Result<(), String>;
|
||||
}
|
||||
|
||||
struct SystemOllamaRuntimeHooks;
|
||||
|
||||
#[async_trait]
|
||||
impl OllamaRuntimeHooks for SystemOllamaRuntimeHooks {
|
||||
async fn is_ready(&self, base_url: &str) -> bool {
|
||||
check_ollama_runtime_ready(base_url).await
|
||||
}
|
||||
|
||||
async fn launch(&self) -> Result<(), String> {
|
||||
launch_ollama_runtime().await
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_ollama_runtime_ready(base_url: &str) -> Result<(), String> {
|
||||
let hooks = SystemOllamaRuntimeHooks;
|
||||
ensure_ollama_runtime_ready_with_hooks(base_url, &hooks, RuntimeBootstrapPolicy::default())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn ensure_ollama_runtime_ready_with_hooks(
|
||||
base_url: &str,
|
||||
hooks: &dyn OllamaRuntimeHooks,
|
||||
policy: RuntimeBootstrapPolicy,
|
||||
) -> Result<(), String> {
|
||||
if !should_manage_local_ollama_runtime(base_url) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if hooks.is_ready(base_url).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _guard = ollama_launch_guard().lock().await;
|
||||
if hooks.is_ready(base_url).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 检测到本地 Ollama 未就绪,尝试自动拉起: {}",
|
||||
base_url
|
||||
);
|
||||
hooks
|
||||
.launch()
|
||||
.await
|
||||
.map_err(|error| format!("自动启动本地 Ollama 失败: {error}"))?;
|
||||
|
||||
let deadline = Instant::now() + policy.startup_timeout;
|
||||
loop {
|
||||
if hooks.is_ready(base_url).await {
|
||||
tracing::info!("[AsterAgent] 本地 Ollama 已就绪: {}", base_url);
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
sleep(policy.poll_interval).await;
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"本地 Ollama 未就绪,已尝试自动启动但仍无法连接:{}。请确认 Ollama.app 或 `ollama serve` 已运行。",
|
||||
base_url
|
||||
))
|
||||
}
|
||||
|
||||
async fn check_ollama_runtime_ready(base_url: &str) -> bool {
|
||||
let tags_url = format!("{}/api/tags", base_url.trim_end_matches('/'));
|
||||
let Ok(client) = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(3))
|
||||
.no_proxy()
|
||||
.build()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
client
|
||||
.get(tags_url)
|
||||
.send()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|response| response.error_for_status().ok())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CommandCandidate {
|
||||
program: &'static str,
|
||||
args: &'static [&'static str],
|
||||
}
|
||||
|
||||
fn ollama_launch_candidates() -> Vec<CommandCandidate> {
|
||||
let mut candidates = vec![CommandCandidate {
|
||||
program: "ollama",
|
||||
args: &["serve"],
|
||||
}];
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
candidates.push(CommandCandidate {
|
||||
program: "/Applications/Ollama.app/Contents/Resources/ollama",
|
||||
args: &["serve"],
|
||||
});
|
||||
candidates.push(CommandCandidate {
|
||||
program: "open",
|
||||
args: &["-a", "Ollama"],
|
||||
});
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
async fn launch_ollama_runtime() -> Result<(), String> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for candidate in ollama_launch_candidates() {
|
||||
let mut command = Command::new(candidate.program);
|
||||
command
|
||||
.args(candidate.args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
match command.spawn() {
|
||||
Ok(child) => {
|
||||
drop(child);
|
||||
tracing::info!(
|
||||
"[AsterAgent] 已发起本地 Ollama 启动命令: {} {:?}",
|
||||
candidate.program,
|
||||
candidate.args
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Err(error) => {
|
||||
errors.push(format!(
|
||||
"{} {:?}: {}",
|
||||
candidate.program, candidate.args, error
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(errors.join(" | "))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct FakeOllamaRuntimeHooks {
|
||||
readiness: Mutex<VecDeque<bool>>,
|
||||
launch_count: AtomicUsize,
|
||||
launch_result: Result<(), String>,
|
||||
}
|
||||
|
||||
impl FakeOllamaRuntimeHooks {
|
||||
fn new(readiness: Vec<bool>, launch_result: Result<(), String>) -> Self {
|
||||
Self {
|
||||
readiness: Mutex::new(readiness.into()),
|
||||
launch_count: AtomicUsize::new(0),
|
||||
launch_result,
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_count(&self) -> usize {
|
||||
self.launch_count.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OllamaRuntimeHooks for FakeOllamaRuntimeHooks {
|
||||
async fn is_ready(&self, _base_url: &str) -> bool {
|
||||
self.readiness
|
||||
.lock()
|
||||
.expect("lock readiness")
|
||||
.pop_front()
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn launch(&self) -> Result<(), String> {
|
||||
self.launch_count.fetch_add(1, Ordering::SeqCst);
|
||||
self.launch_result.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn fast_policy() -> RuntimeBootstrapPolicy {
|
||||
RuntimeBootstrapPolicy {
|
||||
startup_timeout: Duration::from_millis(20),
|
||||
poll_interval: Duration::from_millis(1),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_only_manage_loopback_ollama_runtime() {
|
||||
assert!(should_manage_local_ollama_runtime("http://127.0.0.1:11434"));
|
||||
assert!(should_manage_local_ollama_runtime("http://localhost:11434"));
|
||||
assert!(!should_manage_local_ollama_runtime(
|
||||
"https://ollama.example.com"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_should_skip_remote_ollama_host() {
|
||||
let hooks = FakeOllamaRuntimeHooks::new(vec![false], Ok(()));
|
||||
|
||||
ensure_ollama_runtime_ready_with_hooks("https://ollama.example.com", &hooks, fast_policy())
|
||||
.await
|
||||
.expect("remote host should skip bootstrap");
|
||||
|
||||
assert_eq!(hooks.launch_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_should_not_launch_when_ollama_is_already_ready() {
|
||||
let hooks = FakeOllamaRuntimeHooks::new(vec![true], Ok(()));
|
||||
|
||||
ensure_ollama_runtime_ready_with_hooks("http://127.0.0.1:11434", &hooks, fast_policy())
|
||||
.await
|
||||
.expect("ready ollama");
|
||||
|
||||
assert_eq!(hooks.launch_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_should_launch_once_and_wait_until_ready() {
|
||||
let hooks = FakeOllamaRuntimeHooks::new(vec![false, false, true], Ok(()));
|
||||
|
||||
ensure_ollama_runtime_ready_with_hooks("http://127.0.0.1:11434", &hooks, fast_policy())
|
||||
.await
|
||||
.expect("ollama should become ready after launch");
|
||||
|
||||
assert_eq!(hooks.launch_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_should_fail_when_launch_does_not_make_ollama_ready() {
|
||||
let hooks = FakeOllamaRuntimeHooks::new(vec![false, false, false, false], Ok(()));
|
||||
|
||||
let error =
|
||||
ensure_ollama_runtime_ready_with_hooks("http://127.0.0.1:11434", &hooks, fast_policy())
|
||||
.await
|
||||
.expect_err("ollama should time out");
|
||||
|
||||
assert_eq!(hooks.launch_count(), 1);
|
||||
assert!(error.contains("本地 Ollama 未就绪"));
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ fn normalize_provider_identity(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn is_ollama_provider(provider_selector: Option<&str>, provider_name: &str) -> bool {
|
||||
pub(crate) fn is_ollama_provider(provider_selector: Option<&str>, provider_name: &str) -> bool {
|
||||
provider_selector
|
||||
.map(normalize_provider_identity)
|
||||
.or_else(|| Some(normalize_provider_identity(provider_name)))
|
||||
@@ -59,7 +59,7 @@ fn normalize_optional_text(value: Option<&str>) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_ollama_base_url(base_url: Option<&str>) -> String {
|
||||
pub(crate) fn normalize_ollama_base_url(base_url: Option<&str>) -> String {
|
||||
let candidate =
|
||||
normalize_optional_text(base_url).unwrap_or_else(|| "http://127.0.0.1:11434".to_string());
|
||||
let raw = if candidate.starts_with("http://") || candidate.starts_with("https://") {
|
||||
|
||||
@@ -10,9 +10,43 @@ const ARTIFACT_DOCUMENT_PERSIST_FAILED_WARNING_CODE: &str = "artifact_document_p
|
||||
const AUTO_CONTEXT_COMPACTION_EVENT_PREFIX: &str = "agent_context_compaction_auto_internal";
|
||||
const AUTO_CONTEXT_COMPACTION_FAILED_WARNING_CODE: &str = "context_compaction_auto_failed";
|
||||
const CONTEXT_COMPACTION_NOT_NEEDED_WARNING_CODE: &str = "context_compaction_not_needed";
|
||||
const TURN_MEMORY_PREFETCH_PROMPT_MARKER: &str = "【运行时记忆召回】";
|
||||
const LIME_RUNTIME_METADATA_KEY: &str = "lime_runtime";
|
||||
const LIME_RUNTIME_AUTO_COMPACT_KEY: &str = "auto_compact";
|
||||
|
||||
fn emit_runtime_events(app: &AppHandle, event_name: &str, events: Vec<RuntimeAgentEvent>) {
|
||||
for event in events {
|
||||
if let Err(error) = app.emit(event_name, &event) {
|
||||
tracing::error!("[AsterAgent] 发送运行时事件失败: {}", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_runtime_memory_prefetch_prompt(
|
||||
base_prompt: Option<String>,
|
||||
prefetch_prompt: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let Some(prefetch_prompt) = prefetch_prompt
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return base_prompt;
|
||||
};
|
||||
|
||||
match base_prompt {
|
||||
Some(base) => {
|
||||
if base.contains(TURN_MEMORY_PREFETCH_PROMPT_MARKER) {
|
||||
Some(base)
|
||||
} else if base.trim().is_empty() {
|
||||
Some(prefetch_prompt.to_string())
|
||||
} else {
|
||||
Some(format!("{base}\n\n{prefetch_prompt}"))
|
||||
}
|
||||
}
|
||||
None => Some(prefetch_prompt.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ProviderConfigApplyMode {
|
||||
Direct,
|
||||
@@ -473,6 +507,7 @@ async fn execute_aster_chat_request(
|
||||
request.provider_config = Some(resolved_provider_config);
|
||||
}
|
||||
if let Some(provider_config) = request.provider_config.as_mut() {
|
||||
ensure_provider_runtime_ready(provider_config).await?;
|
||||
let runtime_tool_call_decision =
|
||||
enrich_provider_config_with_runtime_tool_strategy(provider_config).await;
|
||||
tracing::info!(
|
||||
@@ -776,6 +811,50 @@ async fn execute_aster_chat_request(
|
||||
&runtime_config,
|
||||
MemoryPromptContext::with_working_dir(Path::new(&workspace_root)),
|
||||
);
|
||||
let prompt_with_memory = if runtime_config.memory.enabled {
|
||||
let prefetch_request = crate::commands::memory_management_cmd::TurnMemoryPrefetchRequest {
|
||||
session_id: session_id.to_string(),
|
||||
working_dir: Some(workspace_root.clone()),
|
||||
user_message: request.message.clone(),
|
||||
request_metadata: request.metadata.clone(),
|
||||
max_durable_entries: None,
|
||||
max_working_chars: None,
|
||||
};
|
||||
|
||||
match db.lock() {
|
||||
Ok(conn) => {
|
||||
match crate::commands::memory_management_cmd::build_turn_memory_prefetch_result(
|
||||
&runtime_config,
|
||||
&conn,
|
||||
Path::new(&workspace_root),
|
||||
&prefetch_request,
|
||||
) {
|
||||
Ok(prefetch) => merge_runtime_memory_prefetch_prompt(
|
||||
prompt_with_memory,
|
||||
prefetch.prompt.as_deref(),
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 单回合记忆预取失败,已降级继续: session_id={}, error={}",
|
||||
session_id,
|
||||
error
|
||||
);
|
||||
prompt_with_memory
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 记忆预取无法获取数据库锁,已降级继续: session_id={}, error={}",
|
||||
session_id,
|
||||
error
|
||||
);
|
||||
prompt_with_memory
|
||||
}
|
||||
}
|
||||
} else {
|
||||
prompt_with_memory
|
||||
};
|
||||
turn_input_builder.apply_prompt_stage(
|
||||
TurnPromptAugmentationStageKind::Memory,
|
||||
prompt_with_memory.clone(),
|
||||
@@ -1669,15 +1748,21 @@ async fn execute_aster_chat_request(
|
||||
&runtime_status_session_config,
|
||||
)
|
||||
.await;
|
||||
{
|
||||
let terminal_events = {
|
||||
let mut recorder = match timeline_recorder.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
if let Err(error) = recorder.complete_turn_success(app, &request.event_name) {
|
||||
recorder.complete_turn_success()
|
||||
};
|
||||
{
|
||||
if let Err(error) = &terminal_events {
|
||||
tracing::warn!("[AsterAgent] 完成 turn 时间线失败(已降级继续): {}", error);
|
||||
}
|
||||
}
|
||||
if let Ok(events) = terminal_events {
|
||||
emit_runtime_events(app, &request.event_name, events);
|
||||
}
|
||||
let usage = resolve_runtime_message_usage(session_id).await;
|
||||
if let Some(ref usage) = usage {
|
||||
if let Err(error) = persist_latest_assistant_message_usage(db, session_id, usage) {
|
||||
@@ -1703,18 +1788,24 @@ async fn execute_aster_chat_request(
|
||||
&runtime_status_session_config,
|
||||
)
|
||||
.await;
|
||||
{
|
||||
let terminal_events = {
|
||||
let mut recorder = match timeline_recorder.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
if let Err(timeline_error) = recorder.fail_turn(app, &request.event_name, &e) {
|
||||
recorder.fail_turn(&e)
|
||||
};
|
||||
{
|
||||
if let Err(timeline_error) = &terminal_events {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 记录失败 turn 时间线失败(已降级继续): {}",
|
||||
timeline_error
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Ok(events) = terminal_events {
|
||||
emit_runtime_events(app, &request.event_name, events);
|
||||
}
|
||||
let error_event = RuntimeAgentEvent::Error { message: e.clone() };
|
||||
if let Err(emit_err) = app.emit(&request.event_name, &error_event) {
|
||||
tracing::error!("[AsterAgent] 发送错误事件失败: {}", emit_err);
|
||||
@@ -2121,39 +2212,51 @@ async fn compact_runtime_session_with_trigger(
|
||||
|
||||
match final_result {
|
||||
Ok(()) => {
|
||||
let mut recorder = match timeline_recorder.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
let terminal_events = {
|
||||
let mut recorder = match timeline_recorder.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
recorder.complete_turn_success()
|
||||
};
|
||||
if let Err(error) = recorder.complete_turn_success(app, &event_name) {
|
||||
if let Err(error) = &terminal_events {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 完成压缩 turn 时间线失败(已降级继续): {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
if let Ok(events) = terminal_events {
|
||||
emit_runtime_events(app, &event_name, events);
|
||||
}
|
||||
let done_event = RuntimeAgentEvent::FinalDone { usage: None };
|
||||
if let Err(error) = app.emit(&event_name, &done_event) {
|
||||
tracing::error!("[AsterAgent] 发送压缩完成事件失败: {}", error);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
{
|
||||
let terminal_events = {
|
||||
let mut recorder = match timeline_recorder.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
if let Err(timeline_error) = recorder.fail_turn(app, &event_name, &error) {
|
||||
recorder.fail_turn(&error)
|
||||
};
|
||||
{
|
||||
if let Err(timeline_error) = &terminal_events {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 记录压缩失败 turn 时间线失败(已降级继续): {}",
|
||||
timeline_error
|
||||
);
|
||||
}
|
||||
let error_event = RuntimeAgentEvent::Error {
|
||||
message: error.clone(),
|
||||
};
|
||||
if let Err(emit_error) = app.emit(&event_name, &error_event) {
|
||||
tracing::error!("[AsterAgent] 发送压缩错误事件失败: {}", emit_error);
|
||||
}
|
||||
}
|
||||
if let Ok(events) = terminal_events {
|
||||
emit_runtime_events(app, &event_name, events);
|
||||
}
|
||||
let error_event = RuntimeAgentEvent::Error {
|
||||
message: error.clone(),
|
||||
};
|
||||
if let Err(emit_error) = app.emit(&event_name, &error_event) {
|
||||
tracing::error!("[AsterAgent] 发送压缩错误事件失败: {}", emit_error);
|
||||
}
|
||||
state.remove_cancel_token(&session_id).await;
|
||||
return Err(error);
|
||||
|
||||
@@ -110,6 +110,19 @@ impl SubagentControlRuntime {
|
||||
async fn ensure_initialized(&self) -> Result<(), String> {
|
||||
self.state.init_agent_with_db(&self.db).await
|
||||
}
|
||||
|
||||
fn runtime_command_context(&self) -> RuntimeCommandContext {
|
||||
RuntimeCommandContext::new(
|
||||
self.app_handle.clone(),
|
||||
&self.state,
|
||||
&self.db,
|
||||
&self.api_key_provider_service,
|
||||
&self.logs,
|
||||
&self.config_manager,
|
||||
&self.mcp_manager,
|
||||
&self.automation_state,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_required_text(value: &str, field_name: &str) -> Result<String, String> {
|
||||
@@ -736,21 +749,11 @@ fn spawn_subagent_turn_in_background(
|
||||
) -> Result<String, String> {
|
||||
let queued_task = build_queued_turn_task(request)?;
|
||||
let submission_id = queued_task.queued_turn_id.clone();
|
||||
let runtime_command_context = runtime.runtime_command_context();
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = submit_runtime_turn_service(
|
||||
runtime.app_handle.clone(),
|
||||
&runtime.state,
|
||||
&runtime.db,
|
||||
&runtime.api_key_provider_service,
|
||||
&runtime.logs,
|
||||
&runtime.config_manager,
|
||||
&runtime.mcp_manager,
|
||||
&runtime.automation_state,
|
||||
queued_task,
|
||||
false,
|
||||
build_runtime_queue_executor(),
|
||||
)
|
||||
.await
|
||||
if let Err(error) = runtime_command_context
|
||||
.submit_runtime_turn(queued_task, false)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[AsterAgent][Subagent] 后台启动子代理失败: {}", error);
|
||||
}
|
||||
@@ -835,6 +838,7 @@ pub(crate) async fn agent_runtime_send_subagent_input_internal(
|
||||
request: AgentRuntimeSendSubagentInputRequest,
|
||||
) -> Result<AgentRuntimeSendSubagentInputResponse, String> {
|
||||
runtime.ensure_initialized().await?;
|
||||
let runtime_command_context = runtime.runtime_command_context();
|
||||
let session_id = normalize_required_text(&request.id, "id")?;
|
||||
let message = normalize_required_text(&request.message, "message")?;
|
||||
let status = load_subagent_runtime_status(&session_id).await?;
|
||||
@@ -853,7 +857,9 @@ pub(crate) async fn agent_runtime_send_subagent_input_internal(
|
||||
let system_prompt = build_subagent_customization_system_prompt(customization.as_ref())?;
|
||||
if request.interrupt {
|
||||
let _ = runtime.state.cancel_session(&session_id).await;
|
||||
let _ = clear_runtime_queue_service(&runtime.app_handle, &session_id).await?;
|
||||
let _ = runtime_command_context
|
||||
.clear_runtime_queue(&session_id)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let workspace_id =
|
||||
@@ -897,20 +903,9 @@ pub(crate) async fn agent_runtime_send_subagent_input_internal(
|
||||
queued_turn_id: None,
|
||||
})?;
|
||||
let submission_id = queued_task.queued_turn_id.clone();
|
||||
submit_runtime_turn_service(
|
||||
runtime.app_handle.clone(),
|
||||
&runtime.state,
|
||||
&runtime.db,
|
||||
&runtime.api_key_provider_service,
|
||||
&runtime.logs,
|
||||
&runtime.config_manager,
|
||||
&runtime.mcp_manager,
|
||||
&runtime.automation_state,
|
||||
queued_task,
|
||||
true,
|
||||
build_runtime_queue_executor(),
|
||||
)
|
||||
.await?;
|
||||
runtime_command_context
|
||||
.submit_runtime_turn(queued_task, true)
|
||||
.await?;
|
||||
emit_subagent_status_changed_events(&runtime.app_handle, &session_id).await;
|
||||
|
||||
Ok(AgentRuntimeSendSubagentInputResponse { submission_id })
|
||||
@@ -961,6 +956,7 @@ pub(crate) async fn agent_runtime_resume_subagent_internal(
|
||||
request: AgentRuntimeResumeSubagentRequest,
|
||||
) -> Result<AgentRuntimeResumeSubagentResponse, String> {
|
||||
runtime.ensure_initialized().await?;
|
||||
let runtime_command_context = runtime.runtime_command_context();
|
||||
let session_id = normalize_required_text(&request.id, "id")?;
|
||||
let current_status = load_subagent_runtime_status(&session_id).await?;
|
||||
if current_status.kind == SubagentRuntimeStatusKind::NotFound
|
||||
@@ -988,19 +984,9 @@ pub(crate) async fn agent_runtime_resume_subagent_internal(
|
||||
write_subagent_control_state(&session, &next_state).await?;
|
||||
restore_stashed_subagent_queue(stashed_queued_turns.clone()).await?;
|
||||
if !stashed_queued_turns.is_empty() {
|
||||
let _ = resume_runtime_queue_if_needed_service(
|
||||
runtime.app_handle.clone(),
|
||||
&runtime.state,
|
||||
&runtime.db,
|
||||
&runtime.api_key_provider_service,
|
||||
&runtime.logs,
|
||||
&runtime.config_manager,
|
||||
&runtime.mcp_manager,
|
||||
&runtime.automation_state,
|
||||
target_id.clone(),
|
||||
build_runtime_queue_executor(),
|
||||
)
|
||||
.await?;
|
||||
let _ = runtime_command_context
|
||||
.resume_runtime_queue_if_needed(target_id.clone())
|
||||
.await?;
|
||||
}
|
||||
changed_ids.push(target_id);
|
||||
}
|
||||
@@ -1021,6 +1007,7 @@ pub(crate) async fn agent_runtime_close_subagent_internal(
|
||||
request: AgentRuntimeCloseSubagentRequest,
|
||||
) -> Result<AgentRuntimeCloseSubagentResponse, String> {
|
||||
runtime.ensure_initialized().await?;
|
||||
let runtime_command_context = runtime.runtime_command_context();
|
||||
let session_id = normalize_required_text(&request.id, "id")?;
|
||||
let previous_status = load_subagent_runtime_status(&session_id).await?;
|
||||
if matches!(
|
||||
@@ -1044,7 +1031,8 @@ pub(crate) async fn agent_runtime_close_subagent_internal(
|
||||
}
|
||||
|
||||
let _ = runtime.state.cancel_session(&target_id).await;
|
||||
let cleared_queued_turns = clear_runtime_queue_service(&runtime.app_handle, &target_id)
|
||||
let cleared_queued_turns = runtime_command_context
|
||||
.clear_runtime_queue(&target_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let next_state = SubagentControlState::closed(
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! 属于当前仍在演进的记忆治理配置入口
|
||||
|
||||
use crate::commands::context_memory::ContextMemoryServiceState;
|
||||
use crate::commands::unified_memory_cmd::{list_unified_memories, ListFilters};
|
||||
use crate::config::GlobalConfigManagerState;
|
||||
use crate::database::DbConnection;
|
||||
use crate::services::auto_memory_service::{
|
||||
@@ -22,6 +23,7 @@ use crate::services::runtime_agents_template_service::{
|
||||
RuntimeAgentsTemplateScaffoldResult, RuntimeAgentsTemplateTarget,
|
||||
WorkspaceGitignoreEnsureResult,
|
||||
};
|
||||
use aster::session::list_summaries;
|
||||
use chrono::{Local, NaiveDateTime, TimeZone};
|
||||
use lime_core::app_paths;
|
||||
use lime_services::context_memory_service::{MemoryEntry, MemoryFileType};
|
||||
@@ -102,6 +104,124 @@ pub struct MemoryAutoToggleResponse {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// 工作记忆文件摘要
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkingMemoryFileSummary {
|
||||
pub file_type: String,
|
||||
pub path: String,
|
||||
pub exists: bool,
|
||||
pub entry_count: u32,
|
||||
pub updated_at: i64,
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// 单个会话的工作记忆视图
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkingMemorySessionSummary {
|
||||
pub session_id: String,
|
||||
pub total_entries: u32,
|
||||
pub updated_at: i64,
|
||||
pub files: Vec<WorkingMemoryFileSummary>,
|
||||
pub highlights: Vec<MemoryEntryPreview>,
|
||||
}
|
||||
|
||||
/// 工作记忆总览
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkingMemoryView {
|
||||
pub memory_dir: String,
|
||||
pub total_sessions: u32,
|
||||
pub total_entries: u32,
|
||||
pub sessions: Vec<WorkingMemorySessionSummary>,
|
||||
}
|
||||
|
||||
/// 压缩边界快照
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompactionBoundarySnapshot {
|
||||
pub session_id: String,
|
||||
pub source: String,
|
||||
pub summary_preview: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub turn_count: Option<u32>,
|
||||
pub created_at: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trigger: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// 工作记忆抽取状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemoryExtractionStatusResponse {
|
||||
pub enabled: bool,
|
||||
pub status: String,
|
||||
pub status_summary: String,
|
||||
pub working_session_count: u32,
|
||||
pub working_entry_count: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub latest_working_memory_at: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub latest_compaction: Option<CompactionBoundarySnapshot>,
|
||||
#[serde(default)]
|
||||
pub recent_compactions: Vec<CompactionBoundarySnapshot>,
|
||||
}
|
||||
|
||||
/// 持久记忆召回条目
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DurableMemoryRecallEntry {
|
||||
pub id: String,
|
||||
pub session_id: String,
|
||||
pub category: String,
|
||||
pub title: String,
|
||||
pub summary: String,
|
||||
pub updated_at: i64,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Team 影子记忆条目
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TeamMemoryShadowEntry {
|
||||
pub key: String,
|
||||
pub content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// 单回合记忆预取请求
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TurnMemoryPrefetchRequest {
|
||||
#[serde(default)]
|
||||
pub session_id: String,
|
||||
#[serde(default)]
|
||||
pub working_dir: Option<String>,
|
||||
#[serde(default)]
|
||||
pub user_message: String,
|
||||
#[serde(default)]
|
||||
pub request_metadata: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub max_durable_entries: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub max_working_chars: Option<usize>,
|
||||
}
|
||||
|
||||
/// 单回合记忆预取结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TurnMemoryPrefetchResult {
|
||||
pub session_id: String,
|
||||
#[serde(default)]
|
||||
pub rules_source_paths: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub working_memory_excerpt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub durable_memories: Vec<DurableMemoryRecallEntry>,
|
||||
#[serde(default)]
|
||||
pub team_memory_entries: Vec<TeamMemoryShadowEntry>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub latest_compaction: Option<CompactionBoundarySnapshot>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
struct ErrorEntryRecord {
|
||||
#[serde(default)]
|
||||
@@ -138,6 +258,11 @@ const MAX_GENERATED_PER_REQUEST: usize = 200;
|
||||
const MAX_GENERATED_PER_REQUEST_CAP: usize = 2000;
|
||||
const MAX_GENERATED_PER_SESSION: usize = 40;
|
||||
const MIN_MESSAGE_LENGTH: usize = 18;
|
||||
const DEFAULT_WORKING_SESSION_LIMIT: usize = 24;
|
||||
const MAX_WORKING_SESSION_LIMIT: usize = 120;
|
||||
const DEFAULT_RECENT_COMPACTION_LIMIT: usize = 12;
|
||||
const DEFAULT_PREFETCH_DURABLE_LIMIT: usize = 5;
|
||||
const DEFAULT_PREFETCH_WORKING_CHARS: usize = 2400;
|
||||
|
||||
async fn memory_runtime_get_stats_impl() -> Result<MemoryStatsResponse, String> {
|
||||
info!("[记忆管理] 获取记忆统计信息");
|
||||
@@ -447,6 +572,85 @@ pub async fn memory_ensure_workspace_local_agents_gitignore(
|
||||
ensure_workspace_local_agents_gitignore(&resolved_working_dir)
|
||||
}
|
||||
|
||||
/// 获取结构化工作记忆视图
|
||||
#[tauri::command]
|
||||
pub async fn memory_runtime_get_working_memory(
|
||||
session_id: Option<String>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<WorkingMemoryView, String> {
|
||||
let memory_dir = resolve_memory_dir();
|
||||
let normalized_limit = limit
|
||||
.map(|value| value as usize)
|
||||
.unwrap_or(DEFAULT_WORKING_SESSION_LIMIT)
|
||||
.clamp(1, MAX_WORKING_SESSION_LIMIT);
|
||||
collect_working_memory_view(&memory_dir, session_id.as_deref(), normalized_limit)
|
||||
}
|
||||
|
||||
/// 获取记忆抽取与压缩状态
|
||||
#[tauri::command]
|
||||
pub async fn memory_runtime_get_extraction_status(
|
||||
global_config: State<'_, GlobalConfigManagerState>,
|
||||
) -> Result<MemoryExtractionStatusResponse, String> {
|
||||
let config = global_config.config();
|
||||
let memory_dir = resolve_memory_dir();
|
||||
let working_view =
|
||||
collect_working_memory_view(&memory_dir, None, DEFAULT_WORKING_SESSION_LIMIT)?;
|
||||
let recent_compactions = list_recent_compactions(DEFAULT_RECENT_COMPACTION_LIMIT);
|
||||
let latest_working_memory_at = working_view
|
||||
.sessions
|
||||
.iter()
|
||||
.map(|session| session.updated_at)
|
||||
.filter(|timestamp| *timestamp > 0)
|
||||
.max();
|
||||
let latest_compaction = recent_compactions.first().cloned();
|
||||
|
||||
let (status, status_summary) = if !config.memory.enabled {
|
||||
(
|
||||
"disabled".to_string(),
|
||||
"全局记忆功能当前已关闭,规则、工作记忆与自动沉淀都不会参与运行时。".to_string(),
|
||||
)
|
||||
} else if working_view.total_entries == 0 && latest_compaction.is_none() {
|
||||
(
|
||||
"idle".to_string(),
|
||||
"还没有检测到工作记忆文件或上下文压缩结果,当前处于冷启动状态。".to_string(),
|
||||
)
|
||||
} else if working_view.total_entries > 0 && latest_compaction.is_none() {
|
||||
(
|
||||
"collecting".to_string(),
|
||||
"已检测到工作记忆文件,当前还没有可复用的上下文压缩快照。".to_string(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"ready".to_string(),
|
||||
"工作记忆和上下文压缩快照都已就绪,可用于运行时回忆与续接。".to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
Ok(MemoryExtractionStatusResponse {
|
||||
enabled: config.memory.enabled,
|
||||
status,
|
||||
status_summary,
|
||||
working_session_count: working_view.total_sessions,
|
||||
working_entry_count: working_view.total_entries,
|
||||
latest_working_memory_at,
|
||||
latest_compaction,
|
||||
recent_compactions,
|
||||
})
|
||||
}
|
||||
|
||||
/// 为单回合构建记忆预取结果
|
||||
#[tauri::command]
|
||||
pub async fn memory_runtime_prefetch_for_turn(
|
||||
global_config: State<'_, GlobalConfigManagerState>,
|
||||
db: State<'_, DbConnection>,
|
||||
request: TurnMemoryPrefetchRequest,
|
||||
) -> Result<TurnMemoryPrefetchResult, String> {
|
||||
let config = global_config.config();
|
||||
let resolved_working_dir = resolve_working_dir(request.working_dir.clone())?;
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
build_turn_memory_prefetch_result(&config, &conn, &resolved_working_dir, &request)
|
||||
}
|
||||
|
||||
fn resolve_memory_dir() -> PathBuf {
|
||||
app_paths::best_effort_runtime_subdir("memory")
|
||||
}
|
||||
@@ -580,6 +784,504 @@ fn collect_memory_overview(memory_dir: &Path) -> Result<MemoryOverviewResponse,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct WorkingMemorySessionAccumulator {
|
||||
total_entries: u32,
|
||||
updated_at: i64,
|
||||
highlights: Vec<MemoryEntryPreview>,
|
||||
files: HashMap<String, WorkingMemoryFileSummary>,
|
||||
}
|
||||
|
||||
fn collect_working_memory_view(
|
||||
memory_dir: &Path,
|
||||
session_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<WorkingMemoryView, String> {
|
||||
let overview = collect_memory_overview(memory_dir)?;
|
||||
let mut sessions: HashMap<String, WorkingMemorySessionAccumulator> = HashMap::new();
|
||||
|
||||
for entry in overview.entries.iter() {
|
||||
if session_id.is_some_and(|value| value.trim() != entry.session_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let accumulator = sessions
|
||||
.entry(entry.session_id.clone())
|
||||
.or_insert_with(WorkingMemorySessionAccumulator::default);
|
||||
accumulator.total_entries += 1;
|
||||
accumulator.updated_at = accumulator.updated_at.max(entry.updated_at);
|
||||
accumulator.highlights.push(entry.clone());
|
||||
|
||||
let file_path = memory_dir
|
||||
.join(&entry.session_id)
|
||||
.join(memory_file_name_by_file_type(&entry.file_type));
|
||||
let file_summary = accumulator
|
||||
.files
|
||||
.entry(entry.file_type.clone())
|
||||
.or_insert_with(|| WorkingMemoryFileSummary {
|
||||
file_type: entry.file_type.clone(),
|
||||
path: file_path.to_string_lossy().to_string(),
|
||||
exists: file_path.exists(),
|
||||
entry_count: 0,
|
||||
updated_at: 0,
|
||||
summary: String::new(),
|
||||
});
|
||||
file_summary.entry_count += 1;
|
||||
file_summary.updated_at = file_summary.updated_at.max(entry.updated_at);
|
||||
if file_summary.summary.is_empty() {
|
||||
file_summary.summary = entry.summary.clone();
|
||||
} else if !file_summary.summary.contains(&entry.title) {
|
||||
file_summary.summary =
|
||||
truncate_text(&format!("{} / {}", file_summary.summary, entry.title), 140);
|
||||
}
|
||||
}
|
||||
|
||||
let mut session_summaries = sessions
|
||||
.into_iter()
|
||||
.map(|(current_session_id, mut accumulator)| {
|
||||
accumulator.highlights.sort_by(|left, right| {
|
||||
right
|
||||
.updated_at
|
||||
.cmp(&left.updated_at)
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
accumulator.highlights.truncate(5);
|
||||
|
||||
let mut files = accumulator.files.into_values().collect::<Vec<_>>();
|
||||
files.sort_by(|left, right| {
|
||||
working_file_sort_key(&left.file_type)
|
||||
.cmp(&working_file_sort_key(&right.file_type))
|
||||
.then_with(|| left.file_type.cmp(&right.file_type))
|
||||
});
|
||||
|
||||
WorkingMemorySessionSummary {
|
||||
session_id: current_session_id,
|
||||
total_entries: accumulator.total_entries,
|
||||
updated_at: accumulator.updated_at,
|
||||
files,
|
||||
highlights: accumulator.highlights,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
session_summaries.sort_by(|left, right| {
|
||||
right
|
||||
.updated_at
|
||||
.cmp(&left.updated_at)
|
||||
.then_with(|| left.session_id.cmp(&right.session_id))
|
||||
});
|
||||
session_summaries.truncate(limit);
|
||||
|
||||
let total_entries = session_summaries
|
||||
.iter()
|
||||
.map(|session| session.total_entries)
|
||||
.sum();
|
||||
|
||||
Ok(WorkingMemoryView {
|
||||
memory_dir: memory_dir.to_string_lossy().to_string(),
|
||||
total_sessions: session_summaries.len() as u32,
|
||||
total_entries,
|
||||
sessions: session_summaries,
|
||||
})
|
||||
}
|
||||
|
||||
fn memory_file_name_by_file_type(file_type: &str) -> &'static str {
|
||||
match file_type {
|
||||
"task_plan" => "task_plan.md",
|
||||
"findings" => "findings.md",
|
||||
"progress" => "progress.md",
|
||||
"error_log" => "error_log.json",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn working_file_sort_key(file_type: &str) -> usize {
|
||||
match file_type {
|
||||
"task_plan" => 0,
|
||||
"findings" => 1,
|
||||
"progress" => 2,
|
||||
"error_log" => 3,
|
||||
_ => 99,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_compaction_boundary_snapshot(
|
||||
session_id: &str,
|
||||
summary_text: &str,
|
||||
created_at: i64,
|
||||
turn_count: Option<usize>,
|
||||
trigger: Option<String>,
|
||||
detail: Option<String>,
|
||||
) -> CompactionBoundarySnapshot {
|
||||
CompactionBoundarySnapshot {
|
||||
session_id: session_id.to_string(),
|
||||
source: "summary_cache".to_string(),
|
||||
summary_preview: compact_preview(summary_text, 220),
|
||||
turn_count: turn_count.map(|value| value as u32),
|
||||
created_at,
|
||||
trigger,
|
||||
detail,
|
||||
}
|
||||
}
|
||||
|
||||
fn list_recent_compactions(limit: usize) -> Vec<CompactionBoundarySnapshot> {
|
||||
list_summaries()
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|summary| {
|
||||
build_compaction_boundary_snapshot(
|
||||
&summary.uuid,
|
||||
&summary.summary,
|
||||
summary.timestamp.timestamp_millis(),
|
||||
summary.turn_count,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn build_turn_memory_prefetch_result(
|
||||
config: &lime_core::config::Config,
|
||||
conn: &Connection,
|
||||
working_dir: &Path,
|
||||
request: &TurnMemoryPrefetchRequest,
|
||||
) -> Result<TurnMemoryPrefetchResult, String> {
|
||||
let memory_dir = resolve_memory_dir();
|
||||
let session_id = request.session_id.trim().to_string();
|
||||
let max_durable_entries = request
|
||||
.max_durable_entries
|
||||
.unwrap_or(DEFAULT_PREFETCH_DURABLE_LIMIT)
|
||||
.clamp(1, 12);
|
||||
let max_working_chars = request
|
||||
.max_working_chars
|
||||
.unwrap_or(DEFAULT_PREFETCH_WORKING_CHARS)
|
||||
.clamp(400, 12_000);
|
||||
let resolution = resolve_effective_sources(config, working_dir, None);
|
||||
let rules_source_paths = resolution
|
||||
.response
|
||||
.sources
|
||||
.iter()
|
||||
.filter(|source| source.loaded)
|
||||
.map(|source| source.path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let working_memory_excerpt = if session_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
load_working_memory_excerpt(&memory_dir, &session_id, max_working_chars)
|
||||
};
|
||||
let durable_memories = resolve_durable_memory_recall(
|
||||
conn,
|
||||
&session_id,
|
||||
&request.user_message,
|
||||
max_durable_entries,
|
||||
)?;
|
||||
let team_memory_entries = extract_team_memory_shadow_entries(request.request_metadata.as_ref());
|
||||
let latest_compaction = list_recent_compactions(DEFAULT_RECENT_COMPACTION_LIMIT)
|
||||
.into_iter()
|
||||
.find(|snapshot| snapshot.session_id == session_id);
|
||||
let prompt = build_turn_memory_prefetch_prompt(
|
||||
working_memory_excerpt.as_deref(),
|
||||
&durable_memories,
|
||||
latest_compaction.as_ref(),
|
||||
);
|
||||
|
||||
Ok(TurnMemoryPrefetchResult {
|
||||
session_id,
|
||||
rules_source_paths,
|
||||
working_memory_excerpt,
|
||||
durable_memories,
|
||||
team_memory_entries,
|
||||
latest_compaction,
|
||||
prompt,
|
||||
})
|
||||
}
|
||||
|
||||
fn load_working_memory_excerpt(
|
||||
memory_dir: &Path,
|
||||
session_id: &str,
|
||||
max_chars: usize,
|
||||
) -> Option<String> {
|
||||
let session_dir = memory_dir.join(session_id);
|
||||
if !session_dir.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut sections = Vec::new();
|
||||
for file_name in SUPPORTED_MEMORY_FILES {
|
||||
let file_path = session_dir.join(file_name);
|
||||
if !file_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let raw = match fs::read_to_string(&file_path) {
|
||||
Ok(content) => content,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let rendered = if file_name == "error_log.json" {
|
||||
render_error_log_excerpt(trimmed)
|
||||
} else {
|
||||
compact_preview(trimmed, max_chars / 2)
|
||||
};
|
||||
if rendered.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
sections.push(format!("【{}】\n{}", file_name, rendered));
|
||||
}
|
||||
|
||||
if sections.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(truncate_text(§ions.join("\n\n"), max_chars))
|
||||
}
|
||||
}
|
||||
|
||||
fn render_error_log_excerpt(content: &str) -> String {
|
||||
parse_error_entries("session", content)
|
||||
.into_iter()
|
||||
.take(3)
|
||||
.map(|entry| format!("- {}:{}", entry.title, entry.summary))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn compact_preview(input: &str, max_chars: usize) -> String {
|
||||
let normalized = input
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" | ");
|
||||
truncate_text(&normalized, max_chars)
|
||||
}
|
||||
|
||||
fn resolve_durable_memory_recall(
|
||||
conn: &Connection,
|
||||
session_id: &str,
|
||||
user_message: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<DurableMemoryRecallEntry>, String> {
|
||||
let trimmed_query = user_message.trim();
|
||||
let mut memories = if !trimmed_query.is_empty() {
|
||||
search_durable_memories(conn, trimmed_query, limit)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
if memories.is_empty() && !session_id.trim().is_empty() {
|
||||
let session_memories = list_unified_memories(
|
||||
conn,
|
||||
ListFilters {
|
||||
session_id: Some(session_id.to_string()),
|
||||
limit: Some(limit),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
memories = session_memories
|
||||
.into_iter()
|
||||
.map(map_durable_memory_recall_entry)
|
||||
.collect();
|
||||
}
|
||||
|
||||
if memories.is_empty() {
|
||||
let recent_memories = list_unified_memories(
|
||||
conn,
|
||||
ListFilters {
|
||||
limit: Some(limit),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
memories = recent_memories
|
||||
.into_iter()
|
||||
.map(map_durable_memory_recall_entry)
|
||||
.collect();
|
||||
}
|
||||
|
||||
memories.truncate(limit);
|
||||
Ok(memories)
|
||||
}
|
||||
|
||||
fn search_durable_memories(
|
||||
conn: &Connection,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<DurableMemoryRecallEntry>, String> {
|
||||
let search_pattern = format!("%{}%", escape_like_for_sqlite(query));
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, session_id, category, title, summary, tags, updated_at
|
||||
FROM unified_memory
|
||||
WHERE archived = 0
|
||||
AND (title LIKE ? ESCAPE '\\\\' OR summary LIKE ? ESCAPE '\\\\' OR content LIKE ? ESCAPE '\\\\')
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?",
|
||||
)
|
||||
.map_err(|e| format!("构建持久记忆搜索失败: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(
|
||||
rusqlite::params![search_pattern, search_pattern, search_pattern, limit as i64],
|
||||
|row| {
|
||||
let tags_raw: String = row.get(5)?;
|
||||
let tags = serde_json::from_str::<Vec<String>>(&tags_raw).unwrap_or_default();
|
||||
Ok(DurableMemoryRecallEntry {
|
||||
id: row.get(0)?,
|
||||
session_id: row.get(1)?,
|
||||
category: normalize_category_value_for_storage(&row.get::<_, String>(2)?),
|
||||
title: row.get(3)?,
|
||||
summary: row.get(4)?,
|
||||
tags,
|
||||
updated_at: row.get(6)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("执行持久记忆搜索失败: {e}"))?;
|
||||
|
||||
rows.collect::<Result<Vec<_>, rusqlite::Error>>()
|
||||
.map_err(|e| format!("解析持久记忆搜索结果失败: {e}"))
|
||||
}
|
||||
|
||||
fn normalize_category_value_for_storage(value: &str) -> String {
|
||||
serde_json::from_str::<String>(value).unwrap_or_else(|_| value.to_string())
|
||||
}
|
||||
|
||||
fn map_durable_memory_recall_entry(memory: lime_memory::UnifiedMemory) -> DurableMemoryRecallEntry {
|
||||
DurableMemoryRecallEntry {
|
||||
id: memory.id,
|
||||
session_id: memory.session_id,
|
||||
category: serde_json::to_string(&memory.category)
|
||||
.ok()
|
||||
.and_then(|encoded| serde_json::from_str::<String>(&encoded).ok())
|
||||
.unwrap_or_else(|| "context".to_string()),
|
||||
title: memory.title,
|
||||
summary: memory.summary,
|
||||
updated_at: memory.updated_at,
|
||||
tags: memory.tags,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_team_memory_shadow_entries(
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> Vec<TeamMemoryShadowEntry> {
|
||||
let Some(metadata_object) = request_metadata.and_then(serde_json::Value::as_object) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let harness_object = metadata_object
|
||||
.get("harness")
|
||||
.and_then(serde_json::Value::as_object);
|
||||
let shadow = harness_object
|
||||
.and_then(|object| {
|
||||
object
|
||||
.get("team_memory_shadow")
|
||||
.or_else(|| object.get("teamMemoryShadow"))
|
||||
})
|
||||
.or_else(|| {
|
||||
metadata_object
|
||||
.get("team_memory_shadow")
|
||||
.or_else(|| metadata_object.get("teamMemoryShadow"))
|
||||
})
|
||||
.and_then(serde_json::Value::as_object);
|
||||
let Some(shadow_object) = shadow else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(entries) = shadow_object
|
||||
.get("entries")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
entries
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let object = entry.as_object()?;
|
||||
let key = object
|
||||
.get("key")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let content = object
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let updated_at = object
|
||||
.get("updated_at")
|
||||
.or_else(|| object.get("updatedAt"))
|
||||
.and_then(serde_json::Value::as_i64);
|
||||
Some(TeamMemoryShadowEntry {
|
||||
key: key.to_string(),
|
||||
content: compact_preview(content, 220),
|
||||
updated_at,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_turn_memory_prefetch_prompt(
|
||||
working_memory_excerpt: Option<&str>,
|
||||
durable_memories: &[DurableMemoryRecallEntry],
|
||||
latest_compaction: Option<&CompactionBoundarySnapshot>,
|
||||
) -> Option<String> {
|
||||
let mut lines = vec!["【运行时记忆召回】".to_string()];
|
||||
|
||||
if let Some(excerpt) = working_memory_excerpt.filter(|value| !value.trim().is_empty()) {
|
||||
lines.push(
|
||||
"- 以下是当前会话最近沉淀下来的工作记忆,只用于帮助你续接上下文,不要逐字复述给用户。"
|
||||
.to_string(),
|
||||
);
|
||||
lines.push(excerpt.to_string());
|
||||
}
|
||||
|
||||
if !durable_memories.is_empty() {
|
||||
lines.push(
|
||||
"- 以下是与当前输入最接近的长期结构化记忆,请优先当作可复用事实,而不是重新臆测。"
|
||||
.to_string(),
|
||||
);
|
||||
for memory in durable_memories {
|
||||
let tag_suffix = if memory.tags.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" / tags: {}", memory.tags.join(", "))
|
||||
};
|
||||
lines.push(format!(
|
||||
" - [{}] {}:{}{}",
|
||||
memory.category, memory.title, memory.summary, tag_suffix
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(compaction) = latest_compaction {
|
||||
lines.push(
|
||||
"- 以下是最近一次上下文压缩后的边界摘要;如果当前对话需要追溯更早历史,优先从这里续接。"
|
||||
.to_string(),
|
||||
);
|
||||
lines.push(format!(
|
||||
" - session={} / turns={:?} / summary={}",
|
||||
compaction.session_id, compaction.turn_count, compaction.summary_preview
|
||||
));
|
||||
}
|
||||
|
||||
if lines.len() == 1 {
|
||||
None
|
||||
} else {
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_like_for_sqlite(input: &str) -> String {
|
||||
input
|
||||
.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_")
|
||||
}
|
||||
|
||||
fn parse_memory_file(session_id: &str, file_name: &str, content: &str) -> Vec<MemoryEntryPreview> {
|
||||
match file_name {
|
||||
"task_plan.md" => parse_markdown_entries(session_id, content, "task_plan"),
|
||||
|
||||
@@ -63,6 +63,55 @@ pub(super) async fn try_handle(
|
||||
.map_err(|e| format!("清理记忆失败: {e}"))?,
|
||||
)?
|
||||
}
|
||||
"memory_runtime_get_working_memory" => {
|
||||
let args = args_or_default(args);
|
||||
let session_id = args
|
||||
.get("sessionId")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(ToString::to_string);
|
||||
let limit = args
|
||||
.get("limit")
|
||||
.and_then(|value| value.as_u64())
|
||||
.map(|value| value as u32);
|
||||
serde_json::to_value(
|
||||
crate::commands::memory_management_cmd::memory_runtime_get_working_memory(
|
||||
session_id, limit,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("获取工作记忆失败: {e}"))?,
|
||||
)?
|
||||
}
|
||||
"memory_runtime_get_extraction_status" => {
|
||||
let app_handle = require_app_handle(state)?;
|
||||
let global_config = app_handle.state::<crate::config::GlobalConfigManagerState>();
|
||||
serde_json::to_value(
|
||||
crate::commands::memory_management_cmd::memory_runtime_get_extraction_status(
|
||||
global_config,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("获取记忆抽取状态失败: {e}"))?,
|
||||
)?
|
||||
}
|
||||
"memory_runtime_prefetch_for_turn" => {
|
||||
let app_handle = require_app_handle(state)?;
|
||||
let args = args_or_default(args);
|
||||
let request = serde_json::from_value(
|
||||
args.get("request")
|
||||
.cloned()
|
||||
.ok_or_else(|| "缺少参数: request".to_string())?,
|
||||
)?;
|
||||
let global_config = app_handle.state::<crate::config::GlobalConfigManagerState>();
|
||||
let db = app_handle.state::<crate::database::DbConnection>();
|
||||
serde_json::to_value(
|
||||
crate::commands::memory_management_cmd::memory_runtime_prefetch_for_turn(
|
||||
global_config,
|
||||
db,
|
||||
request,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("预取单回合记忆失败: {e}"))?,
|
||||
)?
|
||||
}
|
||||
"memory_get_effective_sources" => {
|
||||
let app_handle = require_app_handle(state)?;
|
||||
let args = args_or_default(args);
|
||||
|
||||
@@ -235,12 +235,7 @@ impl AgentTimelineRecorder {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn complete_turn_success(
|
||||
&mut self,
|
||||
app: &AppHandle,
|
||||
event_name: &str,
|
||||
) -> Result<(), String> {
|
||||
self.complete_projection_items(app, event_name, AgentThreadItemStatus::Completed)?;
|
||||
pub fn complete_turn_success(&mut self) -> Result<Vec<RuntimeAgentEvent>, String> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
self.turn.status = AgentThreadTurnStatus::Completed;
|
||||
self.turn.completed_at = Some(now.clone());
|
||||
@@ -258,33 +253,14 @@ impl AgentTimelineRecorder {
|
||||
.map_err(|e| format!("更新 turn 完成状态失败: {e}"))?;
|
||||
drop(conn);
|
||||
|
||||
emit_event(
|
||||
app,
|
||||
event_name,
|
||||
&RuntimeAgentEvent::TurnCompleted {
|
||||
turn: self.turn.clone(),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
let mut events = self.complete_projection_items(AgentThreadItemStatus::Completed)?;
|
||||
events.push(RuntimeAgentEvent::TurnCompleted {
|
||||
turn: self.turn.clone(),
|
||||
});
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
pub fn fail_turn(
|
||||
&mut self,
|
||||
app: &AppHandle,
|
||||
event_name: &str,
|
||||
message: &str,
|
||||
) -> Result<(), String> {
|
||||
self.complete_projection_items(app, event_name, AgentThreadItemStatus::Completed)?;
|
||||
let error_item = self.build_item(
|
||||
format!("error:{}", self.turn_id),
|
||||
AgentThreadItemStatus::Failed,
|
||||
Some(Utc::now().to_rfc3339()),
|
||||
AgentThreadItemPayload::Error {
|
||||
message: message.to_string(),
|
||||
},
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, error_item)?;
|
||||
|
||||
pub fn fail_turn(&mut self, message: &str) -> Result<Vec<RuntimeAgentEvent>, String> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
self.turn.status = AgentThreadTurnStatus::Failed;
|
||||
self.turn.completed_at = Some(now.clone());
|
||||
@@ -303,22 +279,27 @@ impl AgentTimelineRecorder {
|
||||
.map_err(|e| format!("更新 turn 失败状态失败: {e}"))?;
|
||||
drop(conn);
|
||||
|
||||
emit_event(
|
||||
app,
|
||||
event_name,
|
||||
&RuntimeAgentEvent::TurnFailed {
|
||||
turn: self.turn.clone(),
|
||||
let mut events = self.complete_projection_items(AgentThreadItemStatus::Completed)?;
|
||||
let error_item = self.build_item(
|
||||
format!("error:{}", self.turn_id),
|
||||
AgentThreadItemStatus::Failed,
|
||||
Some(Utc::now().to_rfc3339()),
|
||||
AgentThreadItemPayload::Error {
|
||||
message: message.to_string(),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
events.push(self.persist_item_and_build_event(error_item)?);
|
||||
events.push(RuntimeAgentEvent::TurnFailed {
|
||||
turn: self.turn.clone(),
|
||||
});
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn complete_projection_items(
|
||||
&mut self,
|
||||
app: &AppHandle,
|
||||
event_name: &str,
|
||||
status: AgentThreadItemStatus,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<Vec<RuntimeAgentEvent>, String> {
|
||||
let mut events = Vec::new();
|
||||
if let Some(plan_text) = self.plan_text.clone() {
|
||||
let item = self.build_item(
|
||||
format!("plan:{}", self.turn_id),
|
||||
@@ -326,10 +307,10 @@ impl AgentTimelineRecorder {
|
||||
Some(Utc::now().to_rfc3339()),
|
||||
AgentThreadItemPayload::Plan { text: plan_text },
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, item)?;
|
||||
events.push(self.persist_item_and_build_event(item)?);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn build_item(
|
||||
@@ -381,6 +362,15 @@ impl AgentTimelineRecorder {
|
||||
event_name: &str,
|
||||
item: AgentThreadItem,
|
||||
) -> Result<(), String> {
|
||||
let event = self.persist_item_and_build_event(item)?;
|
||||
emit_event(app, event_name, &event);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_item_and_build_event(
|
||||
&mut self,
|
||||
item: AgentThreadItem,
|
||||
) -> Result<RuntimeAgentEvent, String> {
|
||||
{
|
||||
let conn = lock_db(&self.db)?;
|
||||
AgentTimelineDao::upsert_item(&conn, &item)
|
||||
@@ -400,8 +390,7 @@ impl AgentTimelineRecorder {
|
||||
}
|
||||
_ => RuntimeAgentEvent::ItemUpdated { item: item.clone() },
|
||||
};
|
||||
emit_event(app, event_name, &event);
|
||||
Ok(())
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
fn persist_runtime_item(
|
||||
@@ -434,3 +423,71 @@ impl AgentTimelineRecorder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lime_core::database::dao::agent_timeline::{AgentThreadTurnStatus, AgentTimelineDao};
|
||||
use lime_core::database::schema::create_tables;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn setup_db() -> DbConnection {
|
||||
let conn = Connection::open_in_memory().expect("创建内存数据库失败");
|
||||
create_tables(&conn).expect("创建 agent timeline 表失败");
|
||||
conn.execute(
|
||||
"INSERT INTO agent_sessions (id, model, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params![
|
||||
"thread-1",
|
||||
"general:test",
|
||||
"2026-03-13T00:00:00Z",
|
||||
"2026-03-13T00:00:00Z"
|
||||
],
|
||||
)
|
||||
.expect("创建测试 session");
|
||||
Arc::new(Mutex::new(conn))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fail_turn_should_persist_failed_turn_before_emitting_events() {
|
||||
let db = setup_db();
|
||||
let mut recorder = AgentTimelineRecorder::create(db.clone(), "thread-1", "turn-1", "hello")
|
||||
.expect("创建 recorder");
|
||||
|
||||
let events = recorder.fail_turn("boom").expect("写入失败终态");
|
||||
|
||||
let conn = lock_db(&db).expect("获取数据库锁");
|
||||
let turns = AgentTimelineDao::list_turns_by_thread(&conn, "thread-1").expect("读取 turn");
|
||||
assert_eq!(turns.len(), 1);
|
||||
assert_eq!(turns[0].status, AgentThreadTurnStatus::Failed);
|
||||
assert_eq!(turns[0].error_message.as_deref(), Some("boom"));
|
||||
drop(conn);
|
||||
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|event| matches!(event, RuntimeAgentEvent::TurnFailed { .. })));
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|event| matches!(event, RuntimeAgentEvent::ItemCompleted { item } if item.id == "error:turn-1")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_turn_success_should_persist_completed_turn_before_emitting_events() {
|
||||
let db = setup_db();
|
||||
let mut recorder = AgentTimelineRecorder::create(db.clone(), "thread-1", "turn-1", "hello")
|
||||
.expect("创建 recorder");
|
||||
|
||||
let events = recorder.complete_turn_success().expect("写入完成终态");
|
||||
|
||||
let conn = lock_db(&db).expect("获取数据库锁");
|
||||
let turns = AgentTimelineDao::list_turns_by_thread(&conn, "thread-1").expect("读取 turn");
|
||||
assert_eq!(turns.len(), 1);
|
||||
assert_eq!(turns[0].status, AgentThreadTurnStatus::Completed);
|
||||
assert!(turns[0].completed_at.is_some());
|
||||
drop(conn);
|
||||
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|event| matches!(event, RuntimeAgentEvent::TurnCompleted { .. })));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1119,6 +1119,7 @@ mod tests {
|
||||
}],
|
||||
interrupt_state: None,
|
||||
updated_at: Some("2026-03-27T10:01:20Z".to_string()),
|
||||
latest_compaction_boundary: None,
|
||||
diagnostics: Some(AgentRuntimeThreadDiagnostics {
|
||||
latest_turn_status: Some("action_required".to_string()),
|
||||
latest_turn_started_at: None,
|
||||
|
||||
@@ -885,6 +885,7 @@ mod tests {
|
||||
}],
|
||||
interrupt_state: None,
|
||||
updated_at: Some("2026-03-27T10:01:00Z".to_string()),
|
||||
latest_compaction_boundary: None,
|
||||
diagnostics: Some(
|
||||
crate::commands::aster_agent_cmd::AgentRuntimeThreadDiagnostics {
|
||||
latest_turn_status: Some("running".to_string()),
|
||||
|
||||
@@ -966,6 +966,7 @@ mod tests {
|
||||
}],
|
||||
interrupt_state: None,
|
||||
updated_at: Some("2026-03-27T10:01:00Z".to_string()),
|
||||
latest_compaction_boundary: None,
|
||||
diagnostics: Some(crate::commands::aster_agent_cmd::AgentRuntimeThreadDiagnostics {
|
||||
latest_turn_status: Some("completed".to_string()),
|
||||
latest_turn_started_at: None,
|
||||
|
||||
@@ -1164,6 +1164,7 @@ mod tests {
|
||||
}],
|
||||
interrupt_state: None,
|
||||
updated_at: Some("2026-03-27T10:02:00Z".to_string()),
|
||||
latest_compaction_boundary: None,
|
||||
diagnostics: Some(crate::commands::aster_agent_cmd::AgentRuntimeThreadDiagnostics {
|
||||
latest_turn_status: Some("completed".to_string()),
|
||||
latest_turn_started_at: None,
|
||||
|
||||
@@ -753,6 +753,7 @@ mod tests {
|
||||
}],
|
||||
interrupt_state: None,
|
||||
updated_at: Some("2026-03-27T10:01:20Z".to_string()),
|
||||
latest_compaction_boundary: None,
|
||||
diagnostics: Some(AgentRuntimeThreadDiagnostics {
|
||||
latest_turn_status: Some("action_required".to_string()),
|
||||
latest_turn_started_at: None,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Lime",
|
||||
"version": "1.7.0",
|
||||
"version": "1.8.0",
|
||||
"identifier": "com.lime.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev:web-bridge",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Lime",
|
||||
"version": "1.7.0",
|
||||
"version": "1.8.0",
|
||||
"identifier": "com.lime.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -112,7 +112,7 @@ describe("AppSidebar", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("旧导航配置未包含能力入口时也应显示固定能力分组和核心能力项", async () => {
|
||||
it("旧导航配置未包含能力入口时也应显示固定能力分组和核心入口", async () => {
|
||||
mockGetConfig.mockResolvedValue({
|
||||
navigation: {
|
||||
enabled_items: ["home-general", "claw"],
|
||||
@@ -127,13 +127,13 @@ describe("AppSidebar", () => {
|
||||
await flushEffects();
|
||||
|
||||
expect(container.textContent).toContain("能力");
|
||||
expect(container.textContent).toContain("技能");
|
||||
expect(container.textContent).toContain("我的方法");
|
||||
expect(container.textContent).toContain("消息渠道");
|
||||
expect(container.textContent).toContain("资料");
|
||||
expect(container.textContent).toContain("资料库");
|
||||
expect(container.textContent).toContain("灵感库");
|
||||
expect(container.textContent).not.toContain("视频");
|
||||
expect(container.textContent).not.toContain("自动化");
|
||||
expect(container.textContent).not.toContain("持续流程");
|
||||
});
|
||||
|
||||
it("进入消息渠道页时应高亮对应能力入口并保留能力分组标题", async () => {
|
||||
@@ -150,6 +150,20 @@ describe("AppSidebar", () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("默认应隐藏系统区的可选入口,只保留设置", async () => {
|
||||
const container = mountSidebar({
|
||||
currentPageParams: {
|
||||
agentEntry: "new-task",
|
||||
} as AgentPageParams,
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(container.textContent).toContain("系统");
|
||||
expect(container.textContent).toContain("设置");
|
||||
expect(container.textContent).not.toContain("终端");
|
||||
expect(container.textContent).not.toContain("桌宠");
|
||||
});
|
||||
|
||||
it("侧边栏不应再渲染旧主题分组标题", async () => {
|
||||
const container = mountSidebar({
|
||||
currentPageParams: {
|
||||
|
||||
@@ -195,8 +195,8 @@ import {
|
||||
isAbsoluteWorkspacePath,
|
||||
resolveAbsoluteWorkspacePath,
|
||||
} from "./workspace/workspacePath";
|
||||
import { doesWorkspaceFileCandidateMatch } from "./workspace/workspaceFilePathMatch";
|
||||
import {
|
||||
areArtifactProtocolPathsEquivalent,
|
||||
normalizeArtifactProtocolPath,
|
||||
resolveArtifactProtocolFilePath,
|
||||
} from "@/lib/artifact-protocol";
|
||||
@@ -293,13 +293,10 @@ function resolveTaskPreviewArtifact(
|
||||
!isHiddenInternalArtifactPath(
|
||||
resolveArtifactProtocolFilePath(artifact),
|
||||
) &&
|
||||
(areArtifactProtocolPathsEquivalent(
|
||||
doesWorkspaceFileCandidateMatch(
|
||||
resolveArtifactProtocolFilePath(artifact),
|
||||
normalizedArtifactPath,
|
||||
) ||
|
||||
normalizeArtifactProtocolPath(
|
||||
resolveArtifactProtocolFilePath(artifact),
|
||||
) === normalizedArtifactPath),
|
||||
),
|
||||
);
|
||||
if (matchedArtifact) {
|
||||
return matchedArtifact;
|
||||
@@ -3207,12 +3204,7 @@ export function AgentChatWorkspace({
|
||||
);
|
||||
if (normalizedArtifactPath) {
|
||||
const matchedTaskFile = taskFiles.find(
|
||||
(file) =>
|
||||
areArtifactProtocolPathsEquivalent(
|
||||
file.name,
|
||||
normalizedArtifactPath,
|
||||
) ||
|
||||
normalizeArtifactProtocolPath(file.name) === normalizedArtifactPath,
|
||||
(file) => doesWorkspaceFileCandidateMatch(file.name, normalizedArtifactPath),
|
||||
);
|
||||
if (matchedTaskFile?.content?.trim()) {
|
||||
handleWorkspaceFileClick(
|
||||
@@ -3292,10 +3284,7 @@ export function AgentChatWorkspace({
|
||||
}
|
||||
|
||||
const matchedTaskFile = taskFiles.find(
|
||||
(file) =>
|
||||
areArtifactProtocolPathsEquivalent(file.name, normalizedPath) ||
|
||||
normalizeArtifactProtocolPath(file.name) ===
|
||||
normalizeArtifactProtocolPath(normalizedPath),
|
||||
(file) => doesWorkspaceFileCandidateMatch(file.name, normalizedPath),
|
||||
);
|
||||
if (matchedTaskFile) {
|
||||
handleWorkspaceFileClick(
|
||||
@@ -3305,6 +3294,10 @@ export function AgentChatWorkspace({
|
||||
return;
|
||||
}
|
||||
|
||||
if (absolutePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("打开结果文件失败:当前工作区里还没有同步到这份文件");
|
||||
},
|
||||
[
|
||||
@@ -3615,6 +3608,7 @@ export function AgentChatWorkspace({
|
||||
setModel,
|
||||
sessionExecutionRuntime: executionRuntime,
|
||||
projectId: projectId ?? null,
|
||||
projectRootPath: project?.rootPath || null,
|
||||
executionStrategy,
|
||||
setExecutionStrategy,
|
||||
accessMode,
|
||||
|
||||
@@ -141,8 +141,8 @@ export const AgentRuntimeStrip: React.FC<AgentRuntimeStripProps> = ({
|
||||
key: "team_running",
|
||||
label:
|
||||
queuedTeamSessions > 0
|
||||
? `协作处理中 ${activeTeamSessions}/${childSubagentSessions.length} · 稍后开始 ${queuedTeamSessions}`
|
||||
: `协作处理中 ${activeTeamSessions}/${childSubagentSessions.length}`,
|
||||
? `任务进行中 ${activeTeamSessions}/${childSubagentSessions.length} · 稍后开始 ${queuedTeamSessions}`
|
||||
: `任务进行中 ${activeTeamSessions}/${childSubagentSessions.length}`,
|
||||
tone: "secondary",
|
||||
});
|
||||
} else if (childSubagentSessions.length > 0) {
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { TurnMemoryPrefetchResult } from "@/lib/api/memoryRuntime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type RuntimeMemoryPrefetchStatus = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
interface AgentThreadMemoryPrefetchPreviewProps {
|
||||
status: RuntimeMemoryPrefetchStatus;
|
||||
result: TurnMemoryPrefetchResult | null;
|
||||
error: string | null;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DURABLE_CATEGORY_LABELS: Record<string, string> = {
|
||||
identity: "风格",
|
||||
context: "参考",
|
||||
preference: "偏好",
|
||||
experience: "成果",
|
||||
activity: "收藏",
|
||||
};
|
||||
|
||||
function normalizeText(value?: string | null): string {
|
||||
return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
|
||||
}
|
||||
|
||||
function truncateText(value?: string | null, maxLength = 240): string {
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, maxLength - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function parseDate(value?: string | number | null): Date | null {
|
||||
if (typeof value === "number") {
|
||||
const normalizedValue = value > 10_000_000_000 ? value : value * 1000;
|
||||
const date = new Date(normalizedValue);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | number | null): string | null {
|
||||
const date = parseDate(value);
|
||||
if (!date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date.toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function formatMemoryLayerStatusLabel(
|
||||
label: string,
|
||||
count?: number | null,
|
||||
active?: boolean,
|
||||
): string {
|
||||
if (typeof count === "number") {
|
||||
return `${label} ${count}`;
|
||||
}
|
||||
return `${label} ${active ? "已命中" : "未命中"}`;
|
||||
}
|
||||
|
||||
function formatDurableCategoryLabel(category: string): string {
|
||||
return DURABLE_CATEGORY_LABELS[category] || category;
|
||||
}
|
||||
|
||||
function DetailPanel(props: {
|
||||
title: string;
|
||||
emptyText: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<article className="rounded-xl border border-sky-200/80 bg-white px-3 py-3">
|
||||
<div className="text-xs font-medium text-sky-700">{props.title}</div>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-700">
|
||||
{props.children || (
|
||||
<div className="text-sm leading-6 text-slate-500">{props.emptyText}</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentThreadMemoryPrefetchPreview({
|
||||
status,
|
||||
result,
|
||||
error,
|
||||
actions,
|
||||
className,
|
||||
}: AgentThreadMemoryPrefetchPreviewProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 rounded-2xl border px-4 py-3",
|
||||
status === "error" ? "border-amber-200 bg-amber-50" : "border-sky-200 bg-sky-50",
|
||||
className,
|
||||
)}
|
||||
data-testid="agent-thread-reliability-memory-prefetch"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
status === "error" ? "text-amber-900" : "text-sky-900",
|
||||
)}
|
||||
>
|
||||
本回合记忆预取
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
status === "error"
|
||||
? "border-amber-300 bg-white text-amber-700"
|
||||
: "border-sky-300 bg-white text-sky-700",
|
||||
)}
|
||||
>
|
||||
{status === "loading"
|
||||
? "加载中"
|
||||
: status === "ready"
|
||||
? "五层记忆预演"
|
||||
: status === "error"
|
||||
? "暂不可用"
|
||||
: "待预取"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-2 text-sm leading-6",
|
||||
status === "error" ? "text-amber-900" : "text-sky-950",
|
||||
)}
|
||||
>
|
||||
{status === "loading"
|
||||
? "正在按最新回合 prompt 预演 rules / working / durable / team / compaction 五层记忆命中。"
|
||||
: status === "error"
|
||||
? error
|
||||
: "下面展示的是当前这轮真实会用到的记忆命中预演,不会改写会话,只帮助判断续接质量。"}
|
||||
</div>
|
||||
|
||||
{result ? (
|
||||
<>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge variant="outline" className="border-sky-200 bg-white text-sky-700">
|
||||
{formatMemoryLayerStatusLabel("规则", result.rules_source_paths.length)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-sky-200 bg-white text-sky-700">
|
||||
{formatMemoryLayerStatusLabel(
|
||||
"工作",
|
||||
null,
|
||||
Boolean(result.working_memory_excerpt),
|
||||
)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-sky-200 bg-white text-sky-700">
|
||||
{formatMemoryLayerStatusLabel("持久", result.durable_memories.length)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-sky-200 bg-white text-sky-700">
|
||||
{formatMemoryLayerStatusLabel("Team", result.team_memory_entries.length)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-sky-200 bg-white text-sky-700">
|
||||
{formatMemoryLayerStatusLabel(
|
||||
"压缩",
|
||||
null,
|
||||
Boolean(result.latest_compaction),
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
||||
<DetailPanel title="规则来源" emptyText="当前没有命中的规则来源。">
|
||||
{result.rules_source_paths.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{result.rules_source_paths.slice(0, 3).map((path) => (
|
||||
<div
|
||||
key={path}
|
||||
className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs leading-5 text-slate-600"
|
||||
>
|
||||
{path}
|
||||
</div>
|
||||
))}
|
||||
{result.rules_source_paths.length > 3 ? (
|
||||
<div className="text-xs text-slate-500">
|
||||
另有 {result.rules_source_paths.length - 3} 个来源未展开。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</DetailPanel>
|
||||
|
||||
<DetailPanel title="工作记忆摘录" emptyText="当前回合没有命中工作记忆摘录。">
|
||||
{result.working_memory_excerpt ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm leading-6 text-slate-700">
|
||||
{truncateText(result.working_memory_excerpt, 320)}
|
||||
</div>
|
||||
) : null}
|
||||
</DetailPanel>
|
||||
|
||||
<DetailPanel title="持久记忆命中" emptyText="当前没有命中的长期记忆。">
|
||||
{result.durable_memories.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{result.durable_memories.slice(0, 3).map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-slate-200 bg-white text-slate-700"
|
||||
>
|
||||
{formatDurableCategoryLabel(entry.category)}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium text-slate-900">
|
||||
{entry.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-700">
|
||||
{truncateText(entry.summary, 220)}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-slate-500">
|
||||
{entry.tags.length > 0 ? (
|
||||
<span>标签:{entry.tags.join("、")}</span>
|
||||
) : null}
|
||||
{formatDateTime(entry.updated_at) ? (
|
||||
<span>更新于 {formatDateTime(entry.updated_at)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{result.durable_memories.length > 3 ? (
|
||||
<div className="text-xs text-slate-500">
|
||||
另有 {result.durable_memories.length - 3} 条长期记忆未展开。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</DetailPanel>
|
||||
|
||||
<DetailPanel title="Team Shadow 明细" emptyText="当前没有命中的 Team shadow。">
|
||||
{result.team_memory_entries.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{result.team_memory_entries.slice(0, 3).map((entry) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-slate-900">
|
||||
{entry.key}
|
||||
</span>
|
||||
{formatDateTime(entry.updated_at) ? (
|
||||
<span className="text-xs text-slate-500">
|
||||
{formatDateTime(entry.updated_at)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-700">
|
||||
{truncateText(entry.content, 220)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{result.team_memory_entries.length > 3 ? (
|
||||
<div className="text-xs text-slate-500">
|
||||
另有 {result.team_memory_entries.length - 3} 条 Team shadow 未展开。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</DetailPanel>
|
||||
|
||||
<DetailPanel title="压缩续接摘要" emptyText="当前没有命中的压缩边界。">
|
||||
{result.latest_compaction ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-slate-500">
|
||||
{result.latest_compaction.trigger ? (
|
||||
<span>触发原因:{result.latest_compaction.trigger}</span>
|
||||
) : null}
|
||||
{typeof result.latest_compaction.turn_count === "number" ? (
|
||||
<span>覆盖回合:{result.latest_compaction.turn_count}</span>
|
||||
) : null}
|
||||
{formatDateTime(result.latest_compaction.created_at) ? (
|
||||
<span>
|
||||
生成于 {formatDateTime(result.latest_compaction.created_at)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-700">
|
||||
{truncateText(result.latest_compaction.summary_preview, 260)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DetailPanel>
|
||||
|
||||
<DetailPanel
|
||||
title="运行时记忆片段"
|
||||
emptyText="当前没有返回组装后的运行时记忆片段。"
|
||||
>
|
||||
{result.prompt ? (
|
||||
<pre className="overflow-x-auto rounded-lg border border-slate-200 bg-slate-950 px-3 py-2 text-xs leading-6 text-slate-100">
|
||||
{truncateText(result.prompt, 500)}
|
||||
</pre>
|
||||
) : null}
|
||||
</DetailPanel>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,19 +11,26 @@ import type {
|
||||
Message,
|
||||
} from "../types";
|
||||
import type { AgentRuntimeThreadReadModel } from "@/lib/api/agentRuntime";
|
||||
import type { TeamMemorySnapshot } from "@/lib/teamMemorySync";
|
||||
import type { HarnessSessionState } from "../utils/harnessState";
|
||||
import { recordRuntimeMemoryPrefetchHistory } from "@/lib/runtimeMemoryPrefetchHistory";
|
||||
|
||||
const { mockToast } = vi.hoisted(() => ({
|
||||
const { mockToast, mockPrefetchContextMemoryForTurn } = vi.hoisted(() => ({
|
||||
mockToast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
mockPrefetchContextMemoryForTurn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: mockToast,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/memoryRuntime", () => ({
|
||||
prefetchContextMemoryForTurn: mockPrefetchContextMemoryForTurn,
|
||||
}));
|
||||
|
||||
interface MountedHarness {
|
||||
container: HTMLDivElement;
|
||||
root: Root;
|
||||
@@ -46,6 +53,16 @@ beforeEach(() => {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
window.localStorage.clear();
|
||||
mockPrefetchContextMemoryForTurn.mockResolvedValue({
|
||||
session_id: "session-default",
|
||||
rules_source_paths: [],
|
||||
working_memory_excerpt: null,
|
||||
durable_memories: [],
|
||||
team_memory_entries: [],
|
||||
latest_compaction: null,
|
||||
prompt: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -77,11 +94,14 @@ function renderPanel(props?: {
|
||||
onReplayPendingRequest?: (requestId: string) => boolean | Promise<boolean>;
|
||||
onLocatePendingRequest?: (requestId: string) => void;
|
||||
onPromoteQueuedTurn?: (queuedTurnId: string) => boolean | Promise<boolean>;
|
||||
onOpenMemoryWorkbench?: () => void;
|
||||
harnessState?: HarnessSessionState | null;
|
||||
messages?: Message[];
|
||||
teamMemorySnapshot?: TeamMemorySnapshot | null;
|
||||
diagnosticRuntimeContext?: {
|
||||
sessionId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
workingDir?: string | null;
|
||||
providerType?: string | null;
|
||||
model?: string | null;
|
||||
executionStrategy?: string | null;
|
||||
@@ -108,8 +128,10 @@ function renderPanel(props?: {
|
||||
onReplayPendingRequest={props?.onReplayPendingRequest}
|
||||
onLocatePendingRequest={props?.onLocatePendingRequest}
|
||||
onPromoteQueuedTurn={props?.onPromoteQueuedTurn}
|
||||
onOpenMemoryWorkbench={props?.onOpenMemoryWorkbench}
|
||||
harnessState={props?.harnessState}
|
||||
messages={props?.messages}
|
||||
teamMemorySnapshot={props?.teamMemorySnapshot}
|
||||
diagnosticRuntimeContext={props?.diagnosticRuntimeContext}
|
||||
/>,
|
||||
);
|
||||
@@ -502,12 +524,306 @@ describe("AgentThreadReliabilityPanel", () => {
|
||||
expect(onReplayPendingRequest).toHaveBeenCalledWith("req-replay-1");
|
||||
});
|
||||
|
||||
it("应展示本回合五层记忆预取,帮助判断五层续接是否命中", async () => {
|
||||
mockPrefetchContextMemoryForTurn.mockResolvedValue({
|
||||
session_id: "session-memory-1",
|
||||
rules_source_paths: ["/workspace/AGENTS.md", "/workspace/.memory/rules.md"],
|
||||
working_memory_excerpt: "【task_plan.md】继续整理风险点与来源摘要。",
|
||||
durable_memories: [
|
||||
{
|
||||
id: "durable-1",
|
||||
session_id: "session-memory-1",
|
||||
category: "experience",
|
||||
title: "研究简报输出偏好",
|
||||
summary: "优先给风险与证据链",
|
||||
updated_at: 1711184700,
|
||||
tags: ["research"],
|
||||
},
|
||||
],
|
||||
team_memory_entries: [
|
||||
{
|
||||
key: "team.selection",
|
||||
content: "研究协作队",
|
||||
updated_at: 1711184700,
|
||||
},
|
||||
],
|
||||
latest_compaction: {
|
||||
session_id: "session-memory-1",
|
||||
source: "summary_cache",
|
||||
summary_preview: "保留研究目标与来源摘要。",
|
||||
created_at: 1711184700,
|
||||
trigger: "token_budget",
|
||||
},
|
||||
prompt: "【运行时记忆召回】...",
|
||||
});
|
||||
|
||||
const container = renderPanel({
|
||||
turns: [
|
||||
{
|
||||
id: "turn-memory-1",
|
||||
thread_id: "thread-memory-1",
|
||||
prompt_text: "继续输出研究简报的风险点",
|
||||
status: "running",
|
||||
started_at: "2026-03-23T10:00:00Z",
|
||||
created_at: "2026-03-23T10:00:00Z",
|
||||
updated_at: "2026-03-23T10:01:00Z",
|
||||
},
|
||||
],
|
||||
diagnosticRuntimeContext: {
|
||||
sessionId: "session-memory-1",
|
||||
workspaceId: "workspace-memory-1",
|
||||
workingDir: "/workspace/project-a",
|
||||
},
|
||||
teamMemorySnapshot: {
|
||||
repoScope: "/workspace/project-a",
|
||||
entries: {
|
||||
"team.selection": {
|
||||
key: "team.selection",
|
||||
content: "研究协作队",
|
||||
updatedAt: 1711184700,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockPrefetchContextMemoryForTurn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
session_id: "session-memory-1",
|
||||
working_dir: "/workspace/project-a",
|
||||
user_message: "继续输出研究简报的风险点",
|
||||
request_metadata: expect.objectContaining({
|
||||
team_memory_shadow: expect.objectContaining({
|
||||
repo_scope: "/workspace/project-a",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[data-testid="agent-thread-reliability-memory-prefetch"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(container.textContent).toContain("本回合记忆预取");
|
||||
expect(container.textContent).toContain("五层记忆预演");
|
||||
expect(container.textContent).toContain("规则 2");
|
||||
expect(container.textContent).toContain("工作 已命中");
|
||||
expect(container.textContent).toContain("持久 1");
|
||||
expect(container.textContent).toContain("Team 1");
|
||||
expect(container.textContent).toContain("压缩 已命中");
|
||||
expect(container.textContent).toContain("工作记忆摘录");
|
||||
expect(container.textContent).toContain("继续整理风险点与来源摘要");
|
||||
expect(container.textContent).toContain("规则来源");
|
||||
expect(container.textContent).toContain("/workspace/AGENTS.md");
|
||||
expect(container.textContent).toContain("持久记忆命中");
|
||||
expect(container.textContent).toContain("研究简报输出偏好");
|
||||
expect(container.textContent).toContain("优先给风险与证据链");
|
||||
expect(container.textContent).toContain("Team Shadow 明细");
|
||||
expect(container.textContent).toContain("team.selection");
|
||||
expect(container.textContent).toContain("研究协作队");
|
||||
expect(container.textContent).toContain("压缩续接摘要");
|
||||
expect(container.textContent).toContain("保留研究目标与来源摘要");
|
||||
expect(container.textContent).toContain("运行时记忆片段");
|
||||
expect(container.textContent).toContain("【运行时记忆召回】");
|
||||
});
|
||||
|
||||
it("应展示相对最近基线的记忆变化摘要", async () => {
|
||||
recordRuntimeMemoryPrefetchHistory({
|
||||
sessionId: "session-memory-compare-1",
|
||||
workingDir: "/workspace/project-a",
|
||||
userMessage: "继续输出旧版简报",
|
||||
source: "memory_page",
|
||||
capturedAt: 1_711_184_600_000,
|
||||
result: {
|
||||
session_id: "session-memory-compare-1",
|
||||
rules_source_paths: ["/workspace/AGENTS.md"],
|
||||
working_memory_excerpt: null,
|
||||
durable_memories: [],
|
||||
team_memory_entries: [],
|
||||
latest_compaction: null,
|
||||
prompt: null,
|
||||
},
|
||||
});
|
||||
|
||||
mockPrefetchContextMemoryForTurn.mockResolvedValue({
|
||||
session_id: "session-memory-compare-1",
|
||||
rules_source_paths: ["/workspace/AGENTS.md", "/workspace/.memory/rules.md"],
|
||||
working_memory_excerpt: "【task_plan.md】补上新版风险结论。",
|
||||
durable_memories: [
|
||||
{
|
||||
id: "durable-compare-1",
|
||||
session_id: "session-memory-compare-1",
|
||||
category: "experience",
|
||||
title: "研究简报结构偏好",
|
||||
summary: "先结论,再列风险",
|
||||
updated_at: 1711184700,
|
||||
tags: ["research"],
|
||||
},
|
||||
],
|
||||
team_memory_entries: [],
|
||||
latest_compaction: null,
|
||||
prompt: null,
|
||||
});
|
||||
|
||||
const container = renderPanel({
|
||||
turns: [
|
||||
{
|
||||
id: "turn-memory-compare-1",
|
||||
thread_id: "thread-memory-compare-1",
|
||||
prompt_text: "继续输出新版研究简报",
|
||||
status: "running",
|
||||
started_at: "2026-03-23T10:00:00Z",
|
||||
created_at: "2026-03-23T10:00:00Z",
|
||||
updated_at: "2026-03-23T10:01:00Z",
|
||||
},
|
||||
],
|
||||
diagnosticRuntimeContext: {
|
||||
sessionId: "session-memory-compare-1",
|
||||
workspaceId: "workspace-memory-compare-1",
|
||||
workingDir: "/workspace/project-a",
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("相对最近基线");
|
||||
expect(container.textContent).toContain("基线输入:继续输出旧版简报");
|
||||
expect(container.textContent).toContain("补强");
|
||||
expect(container.textContent).toContain("补强层:规则层、工作层、持久层。 摘要内容也有更新。");
|
||||
expect(container.textContent).toContain("具体变化");
|
||||
expect(container.textContent).toContain("规则 +1");
|
||||
expect(container.textContent).toContain("工作 新命中");
|
||||
expect(container.textContent).toContain("持久 +1");
|
||||
expect(container.textContent).toContain(
|
||||
"工作摘录 无 -> 【task_plan.md】补上新版风险结论。",
|
||||
);
|
||||
});
|
||||
|
||||
it("应支持从线程可靠性面板跳到记忆工作台查看当前预演", async () => {
|
||||
const onOpenMemoryWorkbench = vi.fn();
|
||||
const container = renderPanel({
|
||||
turns: [
|
||||
{
|
||||
id: "turn-memory-open-1",
|
||||
thread_id: "thread-memory-open-1",
|
||||
prompt_text: "继续整理运行时记忆",
|
||||
status: "running",
|
||||
started_at: "2026-03-23T10:00:00Z",
|
||||
created_at: "2026-03-23T10:00:00Z",
|
||||
updated_at: "2026-03-23T10:01:00Z",
|
||||
},
|
||||
],
|
||||
diagnosticRuntimeContext: {
|
||||
sessionId: "session-memory-open-1",
|
||||
workspaceId: "workspace-memory-open-1",
|
||||
workingDir: "/workspace/project-b",
|
||||
},
|
||||
onOpenMemoryWorkbench,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const button = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent?.includes("在记忆工作台查看"),
|
||||
);
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onOpenMemoryWorkbench).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("应展示最近压缩边界,帮助判断压缩后的工作记忆续接", () => {
|
||||
const container = renderPanel({
|
||||
threadRead: {
|
||||
thread_id: "thread-compaction-1",
|
||||
status: "completed",
|
||||
pending_requests: [],
|
||||
incidents: [],
|
||||
latest_compaction_boundary: {
|
||||
session_id: "session-compaction-1",
|
||||
summary_preview:
|
||||
"保留研究目标、已确认来源和待输出风险点,后续回答应沿这条摘要继续。",
|
||||
turn_count: 8,
|
||||
created_at: "2026-03-23T10:05:00Z",
|
||||
trigger: "token_budget",
|
||||
detail: "压缩后保留研究目标与来源摘要",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[data-testid="agent-thread-reliability-compaction-boundary"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(container.textContent).toContain("最近压缩边界");
|
||||
expect(container.textContent).toContain("token_budget");
|
||||
expect(container.textContent).toContain("覆盖 8 回合");
|
||||
expect(container.textContent).toContain("保留研究目标、已确认来源和待输出风险点");
|
||||
expect(container.textContent).toContain("压缩备注 压缩后保留研究目标与来源摘要");
|
||||
});
|
||||
|
||||
it("应支持复制给 AI 的可靠性诊断包", async () => {
|
||||
mockPrefetchContextMemoryForTurn.mockResolvedValue({
|
||||
session_id: "session-diag-1",
|
||||
rules_source_paths: ["/workspace/AGENTS.md"],
|
||||
working_memory_excerpt: "【progress.md】继续生成研究简报。",
|
||||
durable_memories: [
|
||||
{
|
||||
id: "durable-diag-1",
|
||||
session_id: "session-diag-1",
|
||||
category: "experience",
|
||||
title: "研究输出格式偏好",
|
||||
summary: "先给结论,再列风险",
|
||||
updated_at: 1711184700,
|
||||
tags: ["report"],
|
||||
},
|
||||
],
|
||||
team_memory_entries: [
|
||||
{
|
||||
key: "team.selection",
|
||||
content: "研究协作队",
|
||||
updated_at: 1711184700,
|
||||
},
|
||||
],
|
||||
latest_compaction: {
|
||||
session_id: "session-diag-1",
|
||||
source: "summary_cache",
|
||||
summary_preview: "保留研究目标与最近来源摘要。",
|
||||
created_at: 1711184700,
|
||||
trigger: "token_budget",
|
||||
},
|
||||
prompt: "【运行时记忆召回】...",
|
||||
});
|
||||
|
||||
const container = renderPanel({
|
||||
threadRead: {
|
||||
thread_id: "thread-1",
|
||||
status: "aborted",
|
||||
active_turn_id: "turn-1",
|
||||
latest_compaction_boundary: {
|
||||
session_id: "session-diag-1",
|
||||
summary_preview:
|
||||
"保留研究目标、最近来源摘要和待输出风险点,后续回答应基于这段摘要继续。",
|
||||
turn_count: 6,
|
||||
created_at: "2026-03-23T10:01:05Z",
|
||||
trigger: "token_budget",
|
||||
detail: "压缩后保留研究目标与最近来源摘要",
|
||||
},
|
||||
diagnostics: {
|
||||
latest_turn_status: "aborted",
|
||||
latest_turn_started_at: "2026-03-23T10:00:00Z",
|
||||
@@ -657,12 +973,28 @@ describe("AgentThreadReliabilityPanel", () => {
|
||||
diagnosticRuntimeContext: {
|
||||
sessionId: "session-diag-1",
|
||||
workspaceId: "workspace-diag-1",
|
||||
workingDir: "/workspace/research",
|
||||
providerType: "openai",
|
||||
model: "gpt-5.4",
|
||||
executionStrategy: "react",
|
||||
activeTheme: "general",
|
||||
selectedTeamLabel: "研究协作队",
|
||||
},
|
||||
teamMemorySnapshot: {
|
||||
repoScope: "/workspace/research",
|
||||
entries: {
|
||||
"team.selection": {
|
||||
key: "team.selection",
|
||||
content: "研究协作队",
|
||||
updatedAt: 1711184700,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const copyButton = container.querySelector(
|
||||
@@ -690,6 +1022,39 @@ describe("AgentThreadReliabilityPanel", () => {
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining("### Harness 过程信号"),
|
||||
);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining("### 最近压缩边界"),
|
||||
);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining("### 当前回合记忆预取"),
|
||||
);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining("规则层:1 个来源"),
|
||||
);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Team 层:1 条"),
|
||||
);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining("规则来源:/workspace/AGENTS.md"),
|
||||
);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining("持久记忆详情:研究输出格式偏好|先给结论,再列风险"),
|
||||
);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Team 影子详情:team.selection|研究协作队"),
|
||||
);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining("压缩命中摘要:保留研究目标与最近来源摘要。"),
|
||||
);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining("运行时记忆片段:【运行时记忆召回】"),
|
||||
);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining("覆盖回合数:6"),
|
||||
);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining("边界摘要:保留研究目标、最近来源摘要和待输出风险点"),
|
||||
);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining("### 后端诊断聚合"),
|
||||
);
|
||||
@@ -722,11 +1087,29 @@ describe("AgentThreadReliabilityPanel", () => {
|
||||
});
|
||||
|
||||
it("应支持复制原始 JSON 诊断数据", async () => {
|
||||
mockPrefetchContextMemoryForTurn.mockResolvedValue({
|
||||
session_id: "session-json-1",
|
||||
rules_source_paths: ["/workspace/AGENTS.md"],
|
||||
working_memory_excerpt: null,
|
||||
durable_memories: [],
|
||||
team_memory_entries: [],
|
||||
latest_compaction: null,
|
||||
prompt: null,
|
||||
});
|
||||
|
||||
const container = renderPanel({
|
||||
threadRead: {
|
||||
thread_id: "thread-json-1",
|
||||
status: "waiting_request",
|
||||
active_turn_id: "turn-json-1",
|
||||
latest_compaction_boundary: {
|
||||
session_id: "session-json-1",
|
||||
summary_preview: "保留用户确认点与待继续执行动作。",
|
||||
turn_count: 3,
|
||||
created_at: "2026-03-23T10:00:20Z",
|
||||
trigger: "manual_compact",
|
||||
detail: "压缩后等待用户输入",
|
||||
},
|
||||
diagnostics: {
|
||||
latest_turn_status: "running",
|
||||
warning_count: 0,
|
||||
@@ -802,6 +1185,7 @@ describe("AgentThreadReliabilityPanel", () => {
|
||||
diagnosticRuntimeContext: {
|
||||
sessionId: "session-json-1",
|
||||
workspaceId: "workspace-json-1",
|
||||
workingDir: "/workspace/json",
|
||||
providerType: "openai",
|
||||
model: "gpt-5.4-mini",
|
||||
executionStrategy: "react",
|
||||
@@ -810,6 +1194,11 @@ describe("AgentThreadReliabilityPanel", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const jsonButton = container.querySelector(
|
||||
'[data-testid="agent-thread-reliability-copy-json"]',
|
||||
);
|
||||
@@ -826,6 +1215,12 @@ describe("AgentThreadReliabilityPanel", () => {
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"backend_diagnostics"'),
|
||||
);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"latest_compaction_boundary"'),
|
||||
);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"memory_prefetch_preview"'),
|
||||
);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"harness_state"'),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Clock3,
|
||||
@@ -13,6 +13,24 @@ import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
prefetchContextMemoryForTurn,
|
||||
type TurnMemoryPrefetchResult,
|
||||
} from "@/lib/api/memoryRuntime";
|
||||
import {
|
||||
buildTeamMemoryShadowRequestMetadata,
|
||||
type TeamMemorySnapshot,
|
||||
} from "@/lib/teamMemorySync";
|
||||
import {
|
||||
assessRuntimeMemoryPrefetchHistoryDiff,
|
||||
compareRuntimeMemoryPrefetchHistoryEntries,
|
||||
describeRuntimeMemoryPrefetchHistoryDiffAssessment,
|
||||
formatRuntimeMemoryPrefetchHistoryDiffStatusLabel,
|
||||
recordRuntimeMemoryPrefetchHistory,
|
||||
type RuntimeMemoryPrefetchHistoryDiff,
|
||||
type RuntimeMemoryPrefetchHistoryDiffAssessment,
|
||||
type RuntimeMemoryPrefetchHistoryEntry,
|
||||
} from "@/lib/runtimeMemoryPrefetchHistory";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
AgentRuntimeThreadReadModel,
|
||||
@@ -30,11 +48,13 @@ import {
|
||||
type ThreadReliabilityTone,
|
||||
} from "../utils/threadReliabilityView";
|
||||
import { AgentIncidentPanel } from "./AgentIncidentPanel";
|
||||
import { AgentThreadMemoryPrefetchPreview } from "./AgentThreadMemoryPrefetchPreview";
|
||||
import { AgentThreadOutcomeSummary } from "./AgentThreadOutcomeSummary";
|
||||
|
||||
interface AgentThreadReliabilityDiagnosticContext {
|
||||
sessionId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
workingDir?: string | null;
|
||||
providerType?: string | null;
|
||||
model?: string | null;
|
||||
executionStrategy?: string | null;
|
||||
@@ -56,12 +76,26 @@ interface AgentThreadReliabilityPanelProps {
|
||||
onReplayPendingRequest?: (requestId: string) => boolean | Promise<boolean>;
|
||||
onLocatePendingRequest?: (requestId: string) => void;
|
||||
onPromoteQueuedTurn?: (queuedTurnId: string) => boolean | Promise<boolean>;
|
||||
onOpenMemoryWorkbench?: () => void;
|
||||
className?: string;
|
||||
harnessState?: HarnessSessionState | null;
|
||||
messages?: Message[];
|
||||
teamMemorySnapshot?: TeamMemorySnapshot | null;
|
||||
diagnosticRuntimeContext?: AgentThreadReliabilityDiagnosticContext | null;
|
||||
}
|
||||
|
||||
interface RuntimeMemoryPrefetchState {
|
||||
status: "idle" | "loading" | "ready" | "error";
|
||||
result: TurnMemoryPrefetchResult | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface RuntimeMemoryPrefetchComparisonState {
|
||||
baselineEntry: RuntimeMemoryPrefetchHistoryEntry | null;
|
||||
diff: RuntimeMemoryPrefetchHistoryDiff | null;
|
||||
assessment: RuntimeMemoryPrefetchHistoryDiffAssessment | null;
|
||||
}
|
||||
|
||||
function serializeClipboardPayload(value: unknown): string {
|
||||
return JSON.stringify(
|
||||
value,
|
||||
@@ -88,6 +122,35 @@ function truncateDiagnosticText(
|
||||
return `${normalized.slice(0, maxLength - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function parseDiagnosticDate(value?: string | number | null): Date | null {
|
||||
if (typeof value === "number") {
|
||||
const normalizedValue = value > 10_000_000_000 ? value : value * 1000;
|
||||
const date = new Date(normalizedValue);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatDiagnosticDateTime(value?: string | number | null): string | null {
|
||||
const date = parseDiagnosticDate(value);
|
||||
if (!date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date.toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeThreadItemSignals(threadItems: AgentThreadItem[]) {
|
||||
const warningItems = threadItems.filter((item) => item.type === "warning");
|
||||
const contextCompactionItems = threadItems.filter(
|
||||
@@ -285,6 +348,173 @@ function resolveStatShellClassName(tone: ThreadReliabilityTone) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildMemoryPrefetchDiagnosticLines(
|
||||
memoryPrefetchState?: RuntimeMemoryPrefetchState,
|
||||
): string[] {
|
||||
if (memoryPrefetchState?.result) {
|
||||
const prefetch = memoryPrefetchState.result;
|
||||
const sections = [
|
||||
`- 规则层:${prefetch.rules_source_paths.length} 个来源`,
|
||||
`- 工作层:${prefetch.working_memory_excerpt ? "已命中" : "未命中"}`,
|
||||
`- 持久层:${prefetch.durable_memories.length} 条`,
|
||||
`- Team 层:${prefetch.team_memory_entries.length} 条`,
|
||||
`- 压缩层:${prefetch.latest_compaction ? "已命中" : "未命中"}`,
|
||||
];
|
||||
|
||||
if (prefetch.rules_source_paths.length > 0) {
|
||||
sections.push(
|
||||
`- 规则来源:${prefetch.rules_source_paths
|
||||
.slice(0, 3)
|
||||
.map((path) => truncateDiagnosticText(path, 120))
|
||||
.join("|")}`,
|
||||
);
|
||||
}
|
||||
if (prefetch.working_memory_excerpt) {
|
||||
sections.push(
|
||||
`- 工作记忆摘录:${truncateDiagnosticText(prefetch.working_memory_excerpt, 220)}`,
|
||||
);
|
||||
}
|
||||
if (prefetch.durable_memories.length > 0) {
|
||||
sections.push(
|
||||
`- 持久记忆命中:${prefetch.durable_memories
|
||||
.slice(0, 3)
|
||||
.map((entry) => entry.title)
|
||||
.join("|")}`,
|
||||
);
|
||||
sections.push(
|
||||
`- 持久记忆详情:${prefetch.durable_memories
|
||||
.slice(0, 3)
|
||||
.map(
|
||||
(entry) =>
|
||||
`${truncateDiagnosticText(entry.title, 80)}|${truncateDiagnosticText(entry.summary, 120)}`,
|
||||
)
|
||||
.join(" || ")}`,
|
||||
);
|
||||
}
|
||||
if (prefetch.team_memory_entries.length > 0) {
|
||||
sections.push(
|
||||
`- Team 影子键:${prefetch.team_memory_entries
|
||||
.slice(0, 3)
|
||||
.map((entry) => entry.key)
|
||||
.join("|")}`,
|
||||
);
|
||||
sections.push(
|
||||
`- Team 影子详情:${prefetch.team_memory_entries
|
||||
.slice(0, 3)
|
||||
.map(
|
||||
(entry) =>
|
||||
`${truncateDiagnosticText(entry.key, 80)}|${truncateDiagnosticText(entry.content, 120)}`,
|
||||
)
|
||||
.join(" || ")}`,
|
||||
);
|
||||
}
|
||||
if (prefetch.latest_compaction) {
|
||||
sections.push(
|
||||
`- 压缩命中摘要:${truncateDiagnosticText(prefetch.latest_compaction.summary_preview, 180)}`,
|
||||
);
|
||||
sections.push(
|
||||
`- 压缩命中元数据:触发=${prefetch.latest_compaction.trigger || "未知"}|覆盖回合=${prefetch.latest_compaction.turn_count ?? "未知"}`,
|
||||
);
|
||||
}
|
||||
if (prefetch.prompt) {
|
||||
sections.push(
|
||||
`- 运行时记忆片段:${truncateDiagnosticText(prefetch.prompt, 220)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
if (memoryPrefetchState?.status === "loading") {
|
||||
return ["- 正在按当前回合最新 prompt 预演五层记忆命中"];
|
||||
}
|
||||
|
||||
if (memoryPrefetchState?.error) {
|
||||
return [`- 失败:${memoryPrefetchState.error}`];
|
||||
}
|
||||
|
||||
return ["- 无"];
|
||||
}
|
||||
|
||||
function resolveMemoryPrefetchHistorySourceLabel(
|
||||
source: RuntimeMemoryPrefetchHistoryEntry["source"],
|
||||
): string {
|
||||
return source === "thread_reliability" ? "线程面板" : "记忆工作台";
|
||||
}
|
||||
|
||||
function resolveMemoryPrefetchPreviewChangeLabel(
|
||||
change: RuntimeMemoryPrefetchHistoryDiff["previewChanges"][number],
|
||||
): string {
|
||||
switch (change.key) {
|
||||
case "rule":
|
||||
return `规则来源 ${change.previous || "无"} -> ${change.current || "无"}`;
|
||||
case "working":
|
||||
return `工作摘录 ${change.previous || "无"} -> ${change.current || "无"}`;
|
||||
case "durable":
|
||||
return `长期记忆 ${change.previous || "无"} -> ${change.current || "无"}`;
|
||||
case "team":
|
||||
return `Team 影子 ${change.previous || "无"} -> ${change.current || "无"}`;
|
||||
case "compaction":
|
||||
return `压缩摘要 ${change.previous || "无"} -> ${change.current || "无"}`;
|
||||
case "user_message":
|
||||
return `输入 ${change.previous || "无"} -> ${change.current || "无"}`;
|
||||
default:
|
||||
return `${change.previous || "无"} -> ${change.current || "无"}`;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMemoryPrefetchAssessmentBadgeClassName(
|
||||
assessment: RuntimeMemoryPrefetchHistoryDiffAssessment,
|
||||
): string {
|
||||
switch (assessment.status) {
|
||||
case "stronger":
|
||||
return "border-emerald-200 bg-white text-emerald-700";
|
||||
case "weaker":
|
||||
return "border-amber-200 bg-white text-amber-700";
|
||||
case "mixed":
|
||||
return "border-sky-200 bg-white text-sky-700";
|
||||
case "same":
|
||||
default:
|
||||
return "border-slate-200 bg-white text-slate-700";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMemoryPrefetchComparison(
|
||||
entries: RuntimeMemoryPrefetchHistoryEntry[],
|
||||
currentWorkingDir: string,
|
||||
): RuntimeMemoryPrefetchComparisonState {
|
||||
const currentEntry = entries[0];
|
||||
if (!currentEntry) {
|
||||
return {
|
||||
baselineEntry: null,
|
||||
diff: null,
|
||||
assessment: null,
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedCurrentWorkingDir =
|
||||
currentWorkingDir.trim().replace(/\\/g, "/").replace(/\/+$/u, "");
|
||||
const candidates = entries.slice(1);
|
||||
const baselineEntry =
|
||||
candidates.find((entry) => entry.sessionId === currentEntry.sessionId) ||
|
||||
candidates.find(
|
||||
(entry) =>
|
||||
entry.workingDir.replace(/\\/g, "/").replace(/\/+$/u, "") ===
|
||||
normalizedCurrentWorkingDir,
|
||||
) ||
|
||||
candidates[0] ||
|
||||
null;
|
||||
const diff = baselineEntry
|
||||
? compareRuntimeMemoryPrefetchHistoryEntries(currentEntry, baselineEntry)
|
||||
: null;
|
||||
|
||||
return {
|
||||
baselineEntry,
|
||||
diff,
|
||||
assessment: diff ? assessRuntimeMemoryPrefetchHistoryDiff(diff) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildReliabilityDiagnosticText(params: {
|
||||
threadRead?: AgentRuntimeThreadReadModel | null;
|
||||
statusLabel: string;
|
||||
@@ -293,6 +523,8 @@ function buildReliabilityDiagnosticText(params: {
|
||||
threadItems: AgentThreadItem[];
|
||||
messages: Message[];
|
||||
harnessState?: HarnessSessionState | null;
|
||||
memoryPrefetchState?: RuntimeMemoryPrefetchState;
|
||||
memoryPrefetchComparison?: RuntimeMemoryPrefetchComparisonState;
|
||||
diagnosticRuntimeContext?: AgentThreadReliabilityDiagnosticContext | null;
|
||||
}): string {
|
||||
const {
|
||||
@@ -303,6 +535,8 @@ function buildReliabilityDiagnosticText(params: {
|
||||
threadItems,
|
||||
messages,
|
||||
harnessState,
|
||||
memoryPrefetchState,
|
||||
memoryPrefetchComparison,
|
||||
diagnosticRuntimeContext,
|
||||
} = params;
|
||||
const threadItemSignals = summarizeThreadItemSignals(threadItems);
|
||||
@@ -341,6 +575,7 @@ function buildReliabilityDiagnosticText(params: {
|
||||
`- 执行策略:${diagnosticRuntimeContext?.executionStrategy || "未知"}`,
|
||||
`- 主题:${diagnosticRuntimeContext?.activeTheme || "未知"}`,
|
||||
`- 协作方案:${diagnosticRuntimeContext?.selectedTeamLabel || "未设置"}`,
|
||||
`- 工作区根目录:${diagnosticRuntimeContext?.workingDir || "未知"}`,
|
||||
"",
|
||||
"### 当前状态",
|
||||
`- 状态:${statusLabel}`,
|
||||
@@ -455,6 +690,68 @@ function buildReliabilityDiagnosticText(params: {
|
||||
sections.push("- 无");
|
||||
}
|
||||
|
||||
sections.push("", "### 最近压缩边界");
|
||||
if (threadRead?.latest_compaction_boundary) {
|
||||
const boundary = threadRead.latest_compaction_boundary;
|
||||
sections.push(`- 生成时间:${boundary.created_at || "未知"}`);
|
||||
sections.push(`- 覆盖回合数:${boundary.turn_count ?? "未知"}`);
|
||||
sections.push(`- 触发原因:${boundary.trigger || "未知"}`);
|
||||
sections.push(
|
||||
`- 边界摘要:${boundary.summary_preview || "无摘要预览"}`,
|
||||
);
|
||||
sections.push(`- 压缩备注:${boundary.detail || "无"}`);
|
||||
} else {
|
||||
sections.push("- 无");
|
||||
}
|
||||
|
||||
sections.push("", "### 当前回合记忆预取");
|
||||
sections.push(...buildMemoryPrefetchDiagnosticLines(memoryPrefetchState));
|
||||
|
||||
sections.push("", "### 相对最近基线的记忆变化");
|
||||
if (memoryPrefetchComparison?.baselineEntry && memoryPrefetchComparison.diff) {
|
||||
sections.push(
|
||||
`- 基线来源:${resolveMemoryPrefetchHistorySourceLabel(memoryPrefetchComparison.baselineEntry.source)}`,
|
||||
);
|
||||
sections.push(
|
||||
`- 基线输入:${memoryPrefetchComparison.baselineEntry.userMessage || "无"}`,
|
||||
);
|
||||
sections.push(
|
||||
`- 基线摘要:${truncateDiagnosticText(
|
||||
memoryPrefetchComparison.baselineEntry.preview.durableTitle ||
|
||||
memoryPrefetchComparison.baselineEntry.preview.workingExcerpt ||
|
||||
memoryPrefetchComparison.baselineEntry.preview.compactionSummary ||
|
||||
memoryPrefetchComparison.baselineEntry.preview.firstRuleSourcePath ||
|
||||
memoryPrefetchComparison.baselineEntry.preview.teamKey,
|
||||
180,
|
||||
) || "无"}`,
|
||||
);
|
||||
if (memoryPrefetchComparison.assessment) {
|
||||
sections.push(
|
||||
`- 对照判断:${formatRuntimeMemoryPrefetchHistoryDiffStatusLabel(memoryPrefetchComparison.assessment.status)}`,
|
||||
);
|
||||
sections.push(
|
||||
`- 对照结论:${describeRuntimeMemoryPrefetchHistoryDiffAssessment(memoryPrefetchComparison.assessment)}`,
|
||||
);
|
||||
}
|
||||
if (memoryPrefetchComparison.diff.changed) {
|
||||
sections.push(
|
||||
`- 层变化:规则 ${memoryPrefetchComparison.diff.layerChanges.rulesDelta >= 0 ? "+" : ""}${memoryPrefetchComparison.diff.layerChanges.rulesDelta}|工作 ${memoryPrefetchComparison.diff.layerChanges.workingChanged}|持久 ${memoryPrefetchComparison.diff.layerChanges.durableDelta >= 0 ? "+" : ""}${memoryPrefetchComparison.diff.layerChanges.durableDelta}|Team ${memoryPrefetchComparison.diff.layerChanges.teamDelta >= 0 ? "+" : ""}${memoryPrefetchComparison.diff.layerChanges.teamDelta}|压缩 ${memoryPrefetchComparison.diff.layerChanges.compactionChanged}`,
|
||||
);
|
||||
if (memoryPrefetchComparison.diff.previewChanges.length > 0) {
|
||||
sections.push(
|
||||
`- 摘要变化:${memoryPrefetchComparison.diff.previewChanges
|
||||
.slice(0, 4)
|
||||
.map((change) => resolveMemoryPrefetchPreviewChangeLabel(change))
|
||||
.join(" || ")}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
sections.push("- 与最近基线相比没有明显变化");
|
||||
}
|
||||
} else {
|
||||
sections.push("- 暂无可对照的历史基线");
|
||||
}
|
||||
|
||||
sections.push("", "### 后端诊断聚合");
|
||||
if (threadRead?.diagnostics) {
|
||||
const diagnostics = threadRead.diagnostics;
|
||||
@@ -571,12 +868,25 @@ function buildReliabilityRawPayload(params: {
|
||||
view: ReturnType<typeof buildThreadReliabilityView>;
|
||||
harnessState?: HarnessSessionState | null;
|
||||
messages: Message[];
|
||||
memoryPrefetchState?: RuntimeMemoryPrefetchState;
|
||||
memoryPrefetchComparison?: RuntimeMemoryPrefetchComparisonState;
|
||||
diagnosticRuntimeContext?: AgentThreadReliabilityDiagnosticContext | null;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
exported_at: new Date().toISOString(),
|
||||
runtime_context: params.diagnosticRuntimeContext || null,
|
||||
backend_diagnostics: params.threadRead?.diagnostics || null,
|
||||
latest_compaction_boundary:
|
||||
params.threadRead?.latest_compaction_boundary || null,
|
||||
memory_prefetch_preview: params.memoryPrefetchState?.result || null,
|
||||
memory_prefetch_error: params.memoryPrefetchState?.error || null,
|
||||
memory_prefetch_comparison: params.memoryPrefetchComparison
|
||||
? {
|
||||
baseline_entry: params.memoryPrefetchComparison.baselineEntry,
|
||||
diff: params.memoryPrefetchComparison.diff,
|
||||
assessment: params.memoryPrefetchComparison.assessment,
|
||||
}
|
||||
: null,
|
||||
current_turn_id: params.currentTurnId || null,
|
||||
thread_read: params.threadRead || null,
|
||||
turns: params.turns,
|
||||
@@ -607,15 +917,29 @@ export const AgentThreadReliabilityPanel: React.FC<
|
||||
onReplayPendingRequest,
|
||||
onLocatePendingRequest,
|
||||
onPromoteQueuedTurn,
|
||||
onOpenMemoryWorkbench,
|
||||
className,
|
||||
harnessState = null,
|
||||
messages = [],
|
||||
teamMemorySnapshot = null,
|
||||
diagnosticRuntimeContext = null,
|
||||
}) => {
|
||||
const [isInterrupting, setIsInterrupting] = useState(false);
|
||||
const [isResumingThread, setIsResumingThread] = useState(false);
|
||||
const [isReplayingRequest, setIsReplayingRequest] = useState(false);
|
||||
const [isPromotingQueuedTurn, setIsPromotingQueuedTurn] = useState(false);
|
||||
const [memoryPrefetchState, setMemoryPrefetchState] =
|
||||
useState<RuntimeMemoryPrefetchState>({
|
||||
status: "idle",
|
||||
result: null,
|
||||
error: null,
|
||||
});
|
||||
const [memoryPrefetchComparison, setMemoryPrefetchComparison] =
|
||||
useState<RuntimeMemoryPrefetchComparisonState>({
|
||||
baselineEntry: null,
|
||||
diff: null,
|
||||
assessment: null,
|
||||
});
|
||||
const view = useMemo(
|
||||
() =>
|
||||
buildThreadReliabilityView({
|
||||
@@ -642,6 +966,130 @@ export const AgentThreadReliabilityPanel: React.FC<
|
||||
const summary = isInterrupting
|
||||
? "正在请求停止当前执行,等待运行时确认最新线程状态。"
|
||||
: view.summary;
|
||||
const latestCompactionBoundary = threadRead?.latest_compaction_boundary || null;
|
||||
const latestCompactionCreatedLabel = formatDiagnosticDateTime(
|
||||
latestCompactionBoundary?.created_at,
|
||||
);
|
||||
const latestCompactionDetail = truncateDiagnosticText(
|
||||
latestCompactionBoundary?.detail,
|
||||
160,
|
||||
);
|
||||
const latestCompactionSummary =
|
||||
latestCompactionBoundary?.summary_preview || "已生成压缩摘要预览";
|
||||
const latestTurnPrompt = useMemo(() => {
|
||||
const activeTurn =
|
||||
turns.find((turn) => turn.id === currentTurnId) || turns[turns.length - 1];
|
||||
return activeTurn?.prompt_text?.trim() || "";
|
||||
}, [currentTurnId, turns]);
|
||||
const teamMemoryShadowMetadata = useMemo(
|
||||
() => buildTeamMemoryShadowRequestMetadata(teamMemorySnapshot),
|
||||
[teamMemorySnapshot],
|
||||
);
|
||||
const teamMemoryShadowKey = useMemo(() => {
|
||||
if (!teamMemoryShadowMetadata) {
|
||||
return "";
|
||||
}
|
||||
return [
|
||||
teamMemoryShadowMetadata.repo_scope,
|
||||
...teamMemoryShadowMetadata.entries.map(
|
||||
(entry) => `${entry.key}:${entry.updated_at}`,
|
||||
),
|
||||
].join("|");
|
||||
}, [teamMemoryShadowMetadata]);
|
||||
const diagnosticSessionId = diagnosticRuntimeContext?.sessionId?.trim() || "";
|
||||
const diagnosticWorkingDir = diagnosticRuntimeContext?.workingDir?.trim() || "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!diagnosticSessionId) {
|
||||
setMemoryPrefetchState({ status: "idle", result: null, error: null });
|
||||
setMemoryPrefetchComparison({
|
||||
baselineEntry: null,
|
||||
diff: null,
|
||||
assessment: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!diagnosticWorkingDir) {
|
||||
setMemoryPrefetchState({
|
||||
status: "error",
|
||||
result: null,
|
||||
error:
|
||||
"当前未绑定工作区,暂时无法预演 rules / working / durable / compaction 记忆命中。",
|
||||
});
|
||||
setMemoryPrefetchComparison({
|
||||
baselineEntry: null,
|
||||
diff: null,
|
||||
assessment: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setMemoryPrefetchState({
|
||||
status: "loading",
|
||||
result: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
void prefetchContextMemoryForTurn({
|
||||
session_id: diagnosticSessionId,
|
||||
working_dir: diagnosticWorkingDir,
|
||||
user_message: latestTurnPrompt,
|
||||
request_metadata: teamMemoryShadowMetadata
|
||||
? {
|
||||
team_memory_shadow: teamMemoryShadowMetadata,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
.then((result) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const historyEntries = recordRuntimeMemoryPrefetchHistory({
|
||||
sessionId: diagnosticSessionId,
|
||||
workingDir: diagnosticWorkingDir,
|
||||
userMessage: latestTurnPrompt || null,
|
||||
source: "thread_reliability",
|
||||
result,
|
||||
});
|
||||
setMemoryPrefetchComparison(
|
||||
resolveMemoryPrefetchComparison(historyEntries, diagnosticWorkingDir),
|
||||
);
|
||||
setMemoryPrefetchState({
|
||||
status: "ready",
|
||||
result,
|
||||
error: null,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setMemoryPrefetchState({
|
||||
status: "error",
|
||||
result: null,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "记忆预取失败,请稍后重试",
|
||||
});
|
||||
setMemoryPrefetchComparison({
|
||||
baselineEntry: null,
|
||||
diff: null,
|
||||
assessment: null,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
diagnosticSessionId,
|
||||
diagnosticWorkingDir,
|
||||
latestTurnPrompt,
|
||||
teamMemoryShadowKey,
|
||||
teamMemoryShadowMetadata,
|
||||
]);
|
||||
|
||||
if (!view.shouldRender) {
|
||||
return null;
|
||||
@@ -725,6 +1173,8 @@ export const AgentThreadReliabilityPanel: React.FC<
|
||||
threadItems,
|
||||
messages,
|
||||
harnessState,
|
||||
memoryPrefetchState,
|
||||
memoryPrefetchComparison,
|
||||
diagnosticRuntimeContext,
|
||||
}),
|
||||
);
|
||||
@@ -756,6 +1206,8 @@ export const AgentThreadReliabilityPanel: React.FC<
|
||||
view,
|
||||
harnessState,
|
||||
messages,
|
||||
memoryPrefetchState,
|
||||
memoryPrefetchComparison,
|
||||
diagnosticRuntimeContext,
|
||||
}),
|
||||
),
|
||||
@@ -843,7 +1295,7 @@ 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)”适合程序化分析、存档或二次处理。
|
||||
@@ -1120,6 +1572,194 @@ export const AgentThreadReliabilityPanel: React.FC<
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{diagnosticSessionId ? (
|
||||
<AgentThreadMemoryPrefetchPreview
|
||||
status={memoryPrefetchState.status}
|
||||
result={memoryPrefetchState.result}
|
||||
error={memoryPrefetchState.error}
|
||||
actions={
|
||||
onOpenMemoryWorkbench ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onOpenMemoryWorkbench}
|
||||
className="h-8 rounded-full border-sky-300 bg-white text-sky-700 hover:bg-sky-50"
|
||||
>
|
||||
在记忆工作台查看
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{memoryPrefetchState.status === "ready" &&
|
||||
memoryPrefetchComparison.baselineEntry &&
|
||||
memoryPrefetchComparison.diff ? (
|
||||
<div className="mt-4 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-sm font-medium text-sky-900">
|
||||
相对最近基线
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-200 bg-white text-sky-700"
|
||||
>
|
||||
{resolveMemoryPrefetchHistorySourceLabel(
|
||||
memoryPrefetchComparison.baselineEntry.source,
|
||||
)}
|
||||
</Badge>
|
||||
<span className="text-xs text-sky-800">
|
||||
{formatDiagnosticDateTime(
|
||||
memoryPrefetchComparison.baselineEntry.capturedAt,
|
||||
) || "未知时间"}
|
||||
</span>
|
||||
</div>
|
||||
{memoryPrefetchComparison.baselineEntry.userMessage ? (
|
||||
<div className="mt-2 text-sm leading-6 text-sky-950">
|
||||
基线输入:{memoryPrefetchComparison.baselineEntry.userMessage}
|
||||
</div>
|
||||
) : null}
|
||||
{memoryPrefetchComparison.assessment ? (
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={resolveMemoryPrefetchAssessmentBadgeClassName(
|
||||
memoryPrefetchComparison.assessment,
|
||||
)}
|
||||
>
|
||||
{formatRuntimeMemoryPrefetchHistoryDiffStatusLabel(
|
||||
memoryPrefetchComparison.assessment.status,
|
||||
)}
|
||||
</Badge>
|
||||
<span className="text-sm leading-6 text-sky-950">
|
||||
{describeRuntimeMemoryPrefetchHistoryDiffAssessment(
|
||||
memoryPrefetchComparison.assessment,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{memoryPrefetchComparison.diff.changed ? (
|
||||
<>
|
||||
<div className="mt-3 text-xs font-medium text-sky-700">
|
||||
具体变化
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{memoryPrefetchComparison.diff.layerChanges.rulesDelta !== 0 ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-200 bg-white text-sky-700"
|
||||
>
|
||||
规则{" "}
|
||||
{memoryPrefetchComparison.diff.layerChanges.rulesDelta > 0 ? "+" : ""}
|
||||
{memoryPrefetchComparison.diff.layerChanges.rulesDelta}
|
||||
</Badge>
|
||||
) : null}
|
||||
{memoryPrefetchComparison.diff.layerChanges.workingChanged !== "same" ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-200 bg-white text-sky-700"
|
||||
>
|
||||
工作
|
||||
{memoryPrefetchComparison.diff.layerChanges.workingChanged === "added"
|
||||
? " 新命中"
|
||||
: " 取消命中"}
|
||||
</Badge>
|
||||
) : null}
|
||||
{memoryPrefetchComparison.diff.layerChanges.durableDelta !== 0 ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-200 bg-white text-sky-700"
|
||||
>
|
||||
持久{" "}
|
||||
{memoryPrefetchComparison.diff.layerChanges.durableDelta > 0 ? "+" : ""}
|
||||
{memoryPrefetchComparison.diff.layerChanges.durableDelta}
|
||||
</Badge>
|
||||
) : null}
|
||||
{memoryPrefetchComparison.diff.layerChanges.teamDelta !== 0 ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-200 bg-white text-sky-700"
|
||||
>
|
||||
Team{" "}
|
||||
{memoryPrefetchComparison.diff.layerChanges.teamDelta > 0 ? "+" : ""}
|
||||
{memoryPrefetchComparison.diff.layerChanges.teamDelta}
|
||||
</Badge>
|
||||
) : null}
|
||||
{memoryPrefetchComparison.diff.layerChanges.compactionChanged !== "same" ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-200 bg-white text-sky-700"
|
||||
>
|
||||
压缩
|
||||
{memoryPrefetchComparison.diff.layerChanges.compactionChanged === "added"
|
||||
? " 新命中"
|
||||
: " 取消命中"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{memoryPrefetchComparison.diff.previewChanges.length > 0 ? (
|
||||
<div className="mt-3 space-y-1.5 text-sm leading-6 text-sky-950">
|
||||
{memoryPrefetchComparison.diff.previewChanges
|
||||
.slice(0, 3)
|
||||
.map((change, index) => (
|
||||
<p key={`${change.key}:${index}`}>
|
||||
{resolveMemoryPrefetchPreviewChangeLabel(change)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-2 text-sm leading-6 text-sky-950">
|
||||
当前回合与最近基线相比没有明显变化。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{latestCompactionBoundary ? (
|
||||
<div
|
||||
className="mt-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3"
|
||||
data-testid="agent-thread-reliability-compaction-boundary"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-sm font-medium text-emerald-900">
|
||||
最近压缩边界
|
||||
</div>
|
||||
{latestCompactionBoundary.trigger ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-emerald-300 bg-white text-emerald-700"
|
||||
>
|
||||
{latestCompactionBoundary.trigger}
|
||||
</Badge>
|
||||
) : null}
|
||||
{typeof latestCompactionBoundary.turn_count === "number" ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-emerald-300 bg-white text-emerald-700"
|
||||
>
|
||||
覆盖 {latestCompactionBoundary.turn_count} 回合
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-emerald-950">
|
||||
{latestCompactionSummary}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-emerald-800">
|
||||
<span title={String(latestCompactionBoundary.created_at)}>
|
||||
生成时间 {latestCompactionCreatedLabel || "未知"}
|
||||
</span>
|
||||
{latestCompactionDetail ? (
|
||||
<span>压缩备注 {latestCompactionDetail}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
{view.outcome ? (
|
||||
<AgentThreadOutcomeSummary outcome={view.outcome} />
|
||||
|
||||
@@ -633,6 +633,9 @@ describe("CanvasWorkbenchLayout", () => {
|
||||
expect(
|
||||
container.querySelector('button[aria-label="切换画布标签-draft.md"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelectorAll('button[aria-label="切换画布标签-draft.md"]'),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
container.querySelector('[data-testid="preview-panel"]')?.textContent,
|
||||
).toContain("default-canvas:draft.md");
|
||||
|
||||
@@ -575,13 +575,53 @@ function buildEntries(
|
||||
taskFiles: TaskFile[],
|
||||
workspaceRoot?: string | null,
|
||||
): CanvasWorkbenchEntry[] {
|
||||
const taskFileEntries: CanvasWorkbenchTaskFileEntry[] = taskFiles
|
||||
.slice()
|
||||
.sort((left, right) => right.updatedAt - left.updatedAt)
|
||||
.map((taskFile) => ({
|
||||
key: `task:${taskFile.id}`,
|
||||
source: "task-file" as const,
|
||||
taskFile,
|
||||
title: resolveContentPostArtifactDisplayTitle({
|
||||
title: extractFileNameFromPath(taskFile.name),
|
||||
filePath: taskFile.name,
|
||||
metadata: taskFile.metadata,
|
||||
}),
|
||||
subtitle: taskFile.name,
|
||||
filePath: taskFile.name,
|
||||
absolutePath: resolveAbsoluteWorkspacePath(
|
||||
workspaceRoot,
|
||||
taskFile.name,
|
||||
),
|
||||
previewText: taskFile.content?.trim().slice(0, 180),
|
||||
createdAt: taskFile.updatedAt,
|
||||
badgeLabel: taskFile.type === "document" ? "文档" : undefined,
|
||||
kindLabel: "任务文件",
|
||||
}));
|
||||
|
||||
const taskFilePathSet = new Set(
|
||||
taskFileEntries
|
||||
.map((entry) => normalizePath(entry.absolutePath || entry.filePath || ""))
|
||||
.filter(Boolean),
|
||||
);
|
||||
const seenArtifactPaths = new Set<string>();
|
||||
|
||||
const entries: CanvasWorkbenchEntry[] = artifacts
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((artifact) => {
|
||||
.flatMap((artifact) => {
|
||||
const filePath = resolveArtifactProtocolFilePath(artifact);
|
||||
const writePhase = resolveArtifactWritePhase(artifact);
|
||||
return {
|
||||
const absolutePath = resolveAbsoluteWorkspacePath(workspaceRoot, filePath);
|
||||
const pathKey = normalizePath(absolutePath || filePath || "");
|
||||
if (pathKey) {
|
||||
if (taskFilePathSet.has(pathKey) || seenArtifactPaths.has(pathKey)) {
|
||||
return [];
|
||||
}
|
||||
seenArtifactPaths.add(pathKey);
|
||||
}
|
||||
|
||||
return [{
|
||||
key: `artifact:${artifact.id}`,
|
||||
source: "artifact",
|
||||
artifact,
|
||||
@@ -592,14 +632,14 @@ function buildEntries(
|
||||
}),
|
||||
subtitle: filePath,
|
||||
filePath,
|
||||
absolutePath: resolveAbsoluteWorkspacePath(workspaceRoot, filePath),
|
||||
absolutePath,
|
||||
previewText: resolveArtifactPreviewText(artifact),
|
||||
createdAt: artifact.updatedAt || artifact.createdAt,
|
||||
badgeLabel: writePhase
|
||||
? formatArtifactWritePhaseLabel(writePhase)
|
||||
: undefined,
|
||||
kindLabel: "产物",
|
||||
};
|
||||
}];
|
||||
});
|
||||
|
||||
if (isDocumentCanvasState(canvasState)) {
|
||||
@@ -630,31 +670,7 @@ function buildEntries(
|
||||
);
|
||||
}
|
||||
|
||||
entries.push(
|
||||
...taskFiles
|
||||
.slice()
|
||||
.sort((left, right) => right.updatedAt - left.updatedAt)
|
||||
.map((taskFile) => ({
|
||||
key: `task:${taskFile.id}`,
|
||||
source: "task-file" as const,
|
||||
taskFile,
|
||||
title: resolveContentPostArtifactDisplayTitle({
|
||||
title: extractFileNameFromPath(taskFile.name),
|
||||
filePath: taskFile.name,
|
||||
metadata: taskFile.metadata,
|
||||
}),
|
||||
subtitle: taskFile.name,
|
||||
filePath: taskFile.name,
|
||||
absolutePath: resolveAbsoluteWorkspacePath(
|
||||
workspaceRoot,
|
||||
taskFile.name,
|
||||
),
|
||||
previewText: taskFile.content?.trim().slice(0, 180),
|
||||
createdAt: taskFile.updatedAt,
|
||||
badgeLabel: taskFile.type === "document" ? "文档" : undefined,
|
||||
kindLabel: "任务文件",
|
||||
})),
|
||||
);
|
||||
entries.push(...taskFileEntries);
|
||||
|
||||
const seen = new Set<string>();
|
||||
return entries.filter((entry) => {
|
||||
|
||||
@@ -5,6 +5,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ChatSidebar } from "./ChatSidebar";
|
||||
import type { Topic } from "../hooks/agentChatShared";
|
||||
import type { Message } from "../types";
|
||||
import type { Project } from "@/lib/api/project";
|
||||
|
||||
const { mockGetProject } = vi.hoisted(() => ({
|
||||
mockGetProject: vi.fn<(projectId: string) => Promise<Project | null>>(
|
||||
async () => null,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/project", () => ({
|
||||
getProject: (projectId: string) => mockGetProject(projectId),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/badge", () => ({
|
||||
Badge: ({
|
||||
@@ -50,6 +61,8 @@ beforeEach(() => {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
mockGetProject.mockReset();
|
||||
mockGetProject.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -148,6 +161,96 @@ describe("ChatSidebar", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("任务中心侧栏应在顶部展示继续上次任务与最近结果入口", async () => {
|
||||
mockGetProject.mockImplementation(async (projectId: string) => {
|
||||
if (projectId === "project-waiting") {
|
||||
return {
|
||||
id: projectId,
|
||||
name: "品牌项目",
|
||||
} as Project;
|
||||
}
|
||||
if (projectId === "project-recent") {
|
||||
return {
|
||||
id: projectId,
|
||||
name: "活动项目",
|
||||
} as Project;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const onResumeTask = vi.fn();
|
||||
const onSwitchTopic = vi.fn();
|
||||
const now = Date.now();
|
||||
const container = renderSidebar({
|
||||
contextVariant: "task-center",
|
||||
onResumeTask,
|
||||
onSwitchTopic,
|
||||
currentTopicId: null,
|
||||
topics: [
|
||||
{
|
||||
...defaultTopics[0],
|
||||
id: "topic-waiting",
|
||||
title: "待继续任务",
|
||||
updatedAt: new Date(now),
|
||||
status: "waiting",
|
||||
statusReason: "user_action",
|
||||
lastPreview: "请先确认发布标题后继续。",
|
||||
workspaceId: "project-waiting",
|
||||
sourceSessionId: "topic-waiting",
|
||||
},
|
||||
{
|
||||
...defaultTopics[0],
|
||||
id: "topic-recent",
|
||||
title: "最近回访任务",
|
||||
updatedAt: new Date(now - 2_000),
|
||||
status: "done",
|
||||
lastPreview: "首版结果已经产出,可继续补充和复盘。",
|
||||
workspaceId: "project-recent",
|
||||
sourceSessionId: "topic-recent",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const panel = container.querySelector(
|
||||
'[data-testid="task-center-continuation-panel"]',
|
||||
) as HTMLElement | null;
|
||||
expect(panel).toBeTruthy();
|
||||
expect(panel?.textContent).toContain("继续上次任务");
|
||||
expect(panel?.textContent).toContain(
|
||||
"上次推进到哪、结果留在哪个项目里,这里会直接告诉你。",
|
||||
);
|
||||
expect(panel?.textContent).toContain("待继续任务");
|
||||
expect(panel?.textContent).toContain("品牌项目");
|
||||
expect(panel?.textContent).toContain("最近回访任务");
|
||||
expect(panel?.textContent).toContain("活动项目");
|
||||
|
||||
const primaryButton = container.querySelector(
|
||||
'[data-testid="task-center-primary-continuation"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(primaryButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
primaryButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onResumeTask).toHaveBeenCalledWith("topic-waiting", "user_action");
|
||||
|
||||
const relatedButton = container.querySelector(
|
||||
'[data-testid="task-center-related-topic-recent"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(relatedButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
relatedButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSwitchTopic).toHaveBeenCalledWith("topic-recent");
|
||||
});
|
||||
|
||||
it("任务中心侧栏应使用回访型任务分组标题", () => {
|
||||
const now = Date.now();
|
||||
const container = renderSidebar({
|
||||
@@ -196,7 +299,7 @@ describe("ChatSidebar", () => {
|
||||
expect(container.textContent).toContain("更早记录");
|
||||
});
|
||||
|
||||
it("Team Runtime 和任务列表应处于同一滚动区域", () => {
|
||||
it("任务协作和任务列表应处于同一滚动区域", () => {
|
||||
const container = renderSidebar({
|
||||
childSubagentSessions: [
|
||||
{
|
||||
@@ -222,11 +325,14 @@ describe("ChatSidebar", () => {
|
||||
expect(scrollArea).toBeTruthy();
|
||||
expect(teamSection).toBeTruthy();
|
||||
expect(scrollArea?.contains(teamSection)).toBe(true);
|
||||
expect(scrollArea?.textContent).toContain("Team Runtime");
|
||||
expect(scrollArea?.textContent).toContain("任务协作");
|
||||
expect(scrollArea?.textContent).toContain(
|
||||
"这里展示当前任务的协作成员、当前任务和任务节点。",
|
||||
);
|
||||
expect(scrollArea?.textContent).toContain("任务一");
|
||||
});
|
||||
|
||||
it("点击 Team Runtime 的任务入口应收起顶部区块并滚动到任务列表", () => {
|
||||
it("点击任务协作的任务入口应收起顶部区块并滚动到任务列表", () => {
|
||||
const container = renderSidebar({
|
||||
childSubagentSessions: [
|
||||
{
|
||||
@@ -544,7 +650,7 @@ describe("ChatSidebar", () => {
|
||||
onOpenSubagentSession,
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Team Runtime");
|
||||
expect(container.textContent).toContain("任务协作");
|
||||
expect(container.textContent).toContain("代码审查代理");
|
||||
expect(container.textContent).toContain("文档校对代理");
|
||||
expect(container.textContent).toContain("处理中");
|
||||
@@ -562,7 +668,7 @@ describe("ChatSidebar", () => {
|
||||
expect(onOpenSubagentSession).toHaveBeenCalledWith("child-1");
|
||||
});
|
||||
|
||||
it("父线程 Team Runtime 区域应支持折叠和展开", () => {
|
||||
it("父线程任务协作区域应支持折叠和展开", () => {
|
||||
const container = renderSidebar({
|
||||
childSubagentSessions: [
|
||||
{
|
||||
@@ -592,7 +698,7 @@ describe("ChatSidebar", () => {
|
||||
expect(container.textContent).toContain("文档校对代理");
|
||||
|
||||
const collapseButton = container.querySelector(
|
||||
'button[aria-label="收起 Team Runtime"]',
|
||||
'button[aria-label="收起任务协作"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(collapseButton).toBeTruthy();
|
||||
|
||||
@@ -607,7 +713,7 @@ describe("ChatSidebar", () => {
|
||||
expect(container.textContent).not.toContain("文档校对代理");
|
||||
|
||||
const expandButton = container.querySelector(
|
||||
'button[aria-label="展开 Team Runtime"]',
|
||||
'button[aria-label="展开任务协作"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(expandButton).toBeTruthy();
|
||||
|
||||
@@ -619,7 +725,7 @@ describe("ChatSidebar", () => {
|
||||
expect(container.textContent).toContain("文档校对代理");
|
||||
});
|
||||
|
||||
it("父线程 Team Runtime 在子代理较多时应默认收起,并支持展开更多子代理", () => {
|
||||
it("父线程任务协作在子代理较多时应默认收起,并支持展开更多子代理", () => {
|
||||
const container = renderSidebar({
|
||||
childSubagentSessions: [
|
||||
{
|
||||
@@ -674,7 +780,7 @@ describe("ChatSidebar", () => {
|
||||
expect(container.textContent).not.toContain("回归验证代理");
|
||||
|
||||
const expandTeamButton = container.querySelector(
|
||||
'button[aria-label="展开 Team Runtime"]',
|
||||
'button[aria-label="展开任务协作"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(expandTeamButton).toBeTruthy();
|
||||
|
||||
@@ -718,7 +824,7 @@ describe("ChatSidebar", () => {
|
||||
expect(container.textContent).not.toContain("回归验证代理");
|
||||
});
|
||||
|
||||
it("子线程同级子代理较多时应默认收起 Team Runtime", () => {
|
||||
it("子线程同级子代理较多时应默认收起任务协作", () => {
|
||||
const container = renderSidebar({
|
||||
topics: [
|
||||
{
|
||||
@@ -778,7 +884,7 @@ describe("ChatSidebar", () => {
|
||||
expect(container.textContent).not.toContain("文档代理");
|
||||
|
||||
const expandButton = container.querySelector(
|
||||
'button[aria-label="展开 Team Runtime"]',
|
||||
'button[aria-label="展开任务协作"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(expandButton).toBeTruthy();
|
||||
|
||||
@@ -828,7 +934,7 @@ describe("ChatSidebar", () => {
|
||||
onReturnToParentSession,
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Team Runtime");
|
||||
expect(container.textContent).toContain("任务协作");
|
||||
expect(container.textContent).toContain("主线程");
|
||||
expect(container.textContent).toContain("当前子代理");
|
||||
expect(container.textContent).toContain("实现 team sidebar");
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getProject } from "@/lib/api/project";
|
||||
import type {
|
||||
AsterSubagentParentContext,
|
||||
AsterSubagentSessionInfo,
|
||||
@@ -42,6 +43,7 @@ const RECENT_TASK_WINDOW_MS = 1000 * 60 * 60 * 24 * 3;
|
||||
const OLDER_TASKS_INITIAL_COUNT = 8;
|
||||
const TEAM_SECTION_INITIAL_CHILD_COUNT = 3;
|
||||
const TEAM_SECTION_INITIAL_SIBLING_COUNT = 2;
|
||||
const TEAM_SECTION_LABEL = "任务协作";
|
||||
const PINNED_TASK_IDS_STORAGE_KEY = "lime_task_sidebar_pinned_ids";
|
||||
|
||||
const STATUS_META: Record<
|
||||
@@ -92,6 +94,7 @@ interface TaskCardViewModel {
|
||||
id: string;
|
||||
title: string;
|
||||
updatedAt: Date;
|
||||
workspaceId?: string | null;
|
||||
messagesCount: number;
|
||||
status: TaskStatus;
|
||||
statusReason?: TaskStatusReason;
|
||||
@@ -108,6 +111,11 @@ interface TaskSection {
|
||||
items: TaskCardViewModel[];
|
||||
}
|
||||
|
||||
interface TaskCenterContinuationState {
|
||||
primary: TaskCardViewModel | null;
|
||||
related: TaskCardViewModel[];
|
||||
}
|
||||
|
||||
interface ChatSidebarProps {
|
||||
contextVariant?: ChatSidebarContextVariant;
|
||||
onNewChat: () => void;
|
||||
@@ -182,6 +190,87 @@ function sortTaskItems(items: TaskCardViewModel[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function isResumableTask(item: Pick<TaskCardViewModel, "status" | "statusReason">) {
|
||||
return (
|
||||
item.status === "waiting" ||
|
||||
(item.status === "failed" && item.statusReason === "workspace_error")
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTaskCenterContinuationState(
|
||||
items: TaskCardViewModel[],
|
||||
currentTopicId: string | null,
|
||||
): TaskCenterContinuationState {
|
||||
const sortedItems = sortTaskItems(items);
|
||||
const resumableItems = sortedItems.filter((item) => isResumableTask(item));
|
||||
const recentDoneItems = sortedItems.filter((item) => item.status === "done");
|
||||
const currentItem =
|
||||
sortedItems.find((item) => item.id === currentTopicId) ?? null;
|
||||
const primary =
|
||||
(currentItem && isResumableTask(currentItem) ? currentItem : null) ??
|
||||
resumableItems[0] ??
|
||||
recentDoneItems[0] ??
|
||||
currentItem ??
|
||||
sortedItems[0] ??
|
||||
null;
|
||||
|
||||
const related = [...resumableItems, ...recentDoneItems]
|
||||
.filter((item) => item.id !== primary?.id)
|
||||
.filter(
|
||||
(item, index, array) =>
|
||||
array.findIndex((candidate) => candidate.id === item.id) === index,
|
||||
)
|
||||
.slice(0, 3);
|
||||
|
||||
return {
|
||||
primary,
|
||||
related,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTaskCenterContinuationBadge(
|
||||
item: TaskCardViewModel,
|
||||
): string {
|
||||
if (isResumableTask(item)) {
|
||||
return "等你继续";
|
||||
}
|
||||
if (item.status === "done") {
|
||||
return "最近结果";
|
||||
}
|
||||
if (item.status === "running") {
|
||||
return "正在推进";
|
||||
}
|
||||
return "工作现场";
|
||||
}
|
||||
|
||||
function resolveTaskCenterContinuationActionLabel(
|
||||
item: TaskCardViewModel,
|
||||
): string {
|
||||
if (isResumableTask(item)) {
|
||||
return "继续任务";
|
||||
}
|
||||
if (item.status === "done") {
|
||||
return "回看结果";
|
||||
}
|
||||
if (item.status === "running") {
|
||||
return "查看进展";
|
||||
}
|
||||
return "打开任务";
|
||||
}
|
||||
|
||||
function areProjectNameMapsEqual(
|
||||
left: Record<string, string>,
|
||||
right: Record<string, string>,
|
||||
): boolean {
|
||||
const leftKeys = Object.keys(left);
|
||||
const rightKeys = Object.keys(right);
|
||||
if (leftKeys.length !== rightKeys.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return leftKeys.every((key) => left[key] === right[key]);
|
||||
}
|
||||
|
||||
function loadPinnedTaskIds() {
|
||||
if (typeof window === "undefined") {
|
||||
return [] as string[];
|
||||
@@ -506,6 +595,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
id: topic.id,
|
||||
title: resolveSidebarDisplayTitle(topic.title, "未命名任务"),
|
||||
updatedAt: topic.updatedAt || topic.createdAt,
|
||||
workspaceId: topic.workspaceId ?? null,
|
||||
messagesCount: topic.messagesCount,
|
||||
status,
|
||||
statusReason,
|
||||
@@ -531,6 +621,34 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
() => taskItems.find((item) => item.id === currentTopicId) ?? null,
|
||||
[currentTopicId, taskItems],
|
||||
);
|
||||
const taskCenterContinuationState = useMemo(
|
||||
() =>
|
||||
contextVariant === "task-center"
|
||||
? resolveTaskCenterContinuationState(taskItems, currentTopicId)
|
||||
: { primary: null, related: [] },
|
||||
[contextVariant, currentTopicId, taskItems],
|
||||
);
|
||||
const continuationProjectIds = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set(
|
||||
[
|
||||
taskCenterContinuationState.primary,
|
||||
...taskCenterContinuationState.related,
|
||||
]
|
||||
.map((item) => item?.workspaceId?.trim())
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
),
|
||||
),
|
||||
[taskCenterContinuationState],
|
||||
);
|
||||
const continuationProjectIdsKey = useMemo(
|
||||
() => continuationProjectIds.join("|"),
|
||||
[continuationProjectIds],
|
||||
);
|
||||
const [continuationProjectNames, setContinuationProjectNames] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const sortedChildSubagentSessions = useMemo(
|
||||
() =>
|
||||
[...childSubagentSessions].sort(
|
||||
@@ -687,6 +805,55 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
);
|
||||
}, [pinnedTaskIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
contextVariant !== "task-center" ||
|
||||
continuationProjectIds.length === 0
|
||||
) {
|
||||
setContinuationProjectNames((current) =>
|
||||
Object.keys(current).length > 0 ? {} : current,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadProjectNames = async () => {
|
||||
const entries = await Promise.all(
|
||||
continuationProjectIds.map(async (projectId) => {
|
||||
try {
|
||||
const project = await getProject(projectId);
|
||||
return [projectId, project?.name?.trim() || null] as const;
|
||||
} catch {
|
||||
return [projectId, null] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextProjectNames = Object.fromEntries(
|
||||
entries.filter(
|
||||
(entry): entry is readonly [string, string] => Boolean(entry[1]),
|
||||
),
|
||||
);
|
||||
|
||||
setContinuationProjectNames((current) =>
|
||||
areProjectNameMapsEqual(current, nextProjectNames)
|
||||
? current
|
||||
: nextProjectNames,
|
||||
);
|
||||
};
|
||||
|
||||
void loadProjectNames();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [contextVariant, continuationProjectIds, continuationProjectIdsKey]);
|
||||
|
||||
const handleDeleteClick = (topicId: string) => {
|
||||
onDeleteTopic(topicId);
|
||||
};
|
||||
@@ -713,6 +880,24 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
void onSwitchTopic(item.id);
|
||||
};
|
||||
|
||||
const handleOpenContinuationTask = (item: TaskCardViewModel) => {
|
||||
if (isResumableTask(item)) {
|
||||
handleResumeTask(item);
|
||||
return;
|
||||
}
|
||||
|
||||
void onSwitchTopic(item.id);
|
||||
};
|
||||
|
||||
const resolveTaskProjectLabel = (item: TaskCardViewModel) => {
|
||||
const projectId = item.workspaceId?.trim();
|
||||
if (!projectId) {
|
||||
return "通用任务";
|
||||
}
|
||||
|
||||
return continuationProjectNames[projectId] || "当前项目";
|
||||
};
|
||||
|
||||
const handleJumpToTaskSection = () => {
|
||||
setTeamSectionCollapsedOverride(true);
|
||||
setShowAllChildSubagents(false);
|
||||
@@ -870,6 +1055,92 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
data-testid="chat-sidebar-scroll-area"
|
||||
>
|
||||
<div className="space-y-4 pb-1">
|
||||
{contextVariant === "task-center" &&
|
||||
taskCenterContinuationState.primary ? (
|
||||
<section
|
||||
className="rounded-[24px] border border-emerald-200/70 bg-white px-3.5 py-3.5 shadow-sm shadow-slate-950/5"
|
||||
data-testid="task-center-continuation-panel"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold tracking-[0.12em] text-emerald-700">
|
||||
继续上次任务
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] leading-5 text-slate-500">
|
||||
上次推进到哪、结果留在哪个项目里,这里会直接告诉你。
|
||||
</p>
|
||||
</div>
|
||||
<Badge className="border border-emerald-200 bg-emerald-50 text-emerald-700">
|
||||
沉淀已保留
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="task-center-primary-continuation"
|
||||
onClick={() =>
|
||||
handleOpenContinuationTask(taskCenterContinuationState.primary!)
|
||||
}
|
||||
className="mt-3 w-full rounded-[20px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98)_0%,rgba(248,250,252,0.94)_100%)] px-3.5 py-3 text-left transition hover:border-emerald-200/80 hover:bg-white"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-2.5 py-1 text-[11px] font-medium text-emerald-700">
|
||||
{resolveTaskCenterContinuationBadge(
|
||||
taskCenterContinuationState.primary,
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
{resolveTaskProjectLabel(
|
||||
taskCenterContinuationState.primary,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold text-slate-900">
|
||||
{taskCenterContinuationState.primary.title}
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-5 text-slate-500">
|
||||
{taskCenterContinuationState.primary.lastPreview}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-[11px] text-slate-400">
|
||||
<span>
|
||||
更新于{" "}
|
||||
{formatRelativeTime(
|
||||
taskCenterContinuationState.primary.updatedAt,
|
||||
)}
|
||||
</span>
|
||||
<span className="font-medium text-slate-600">
|
||||
{resolveTaskCenterContinuationActionLabel(
|
||||
taskCenterContinuationState.primary,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{taskCenterContinuationState.related.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{taskCenterContinuationState.related.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-testid={`task-center-related-${item.id}`}
|
||||
onClick={() => handleOpenContinuationTask(item)}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1.5 text-left text-[11px] text-slate-600 transition hover:border-slate-300 hover:bg-white hover:text-slate-900"
|
||||
title={`${item.title} · ${item.lastPreview}`}
|
||||
>
|
||||
<span className="shrink-0 rounded-full bg-white px-1.5 py-0.5 text-[10px] text-slate-500">
|
||||
{resolveTaskCenterContinuationBadge(item)}
|
||||
</span>
|
||||
<span className="truncate">{item.title}</span>
|
||||
<span className="shrink-0 text-slate-400">
|
||||
{resolveTaskProjectLabel(item)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{shouldShowTeamSection ? (
|
||||
<section
|
||||
className="rounded-[24px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.94)_0%,rgba(248,250,252,0.9)_100%)] px-3.5 py-3.5 shadow-sm shadow-slate-950/5 dark:border-white/10 dark:bg-white/5"
|
||||
@@ -882,14 +1153,14 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
Team Runtime
|
||||
{TEAM_SECTION_LABEL}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-[11px] leading-5 text-slate-500 dark:text-slate-400">
|
||||
{teamSectionCollapsed
|
||||
? collapsedTeamSummary
|
||||
: subagentParentContext
|
||||
? "当前线程来自父会话,可直接返回主线程并切换同级子代理。"
|
||||
: "这里展示真实 child session,而不是 synthetic timeline。"}
|
||||
? "当前线程来自主任务,可直接返回主任务并切换其他分工。"
|
||||
: "这里展示当前任务的协作成员、当前任务和任务节点。"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -914,8 +1185,8 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
type="button"
|
||||
aria-label={
|
||||
teamSectionCollapsed
|
||||
? "展开 Team Runtime"
|
||||
: "收起 Team Runtime"
|
||||
? `展开${TEAM_SECTION_LABEL}`
|
||||
: `收起${TEAM_SECTION_LABEL}`
|
||||
}
|
||||
onClick={() =>
|
||||
setTeamSectionCollapsedOverride(
|
||||
|
||||
@@ -6,9 +6,12 @@ import { EmptyState } from "./EmptyState";
|
||||
import type { Character } from "@/lib/api/memory";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
import type { ServiceSkillHomeItem } from "../service-skills/types";
|
||||
import { recordSlashEntryUsage } from "../skill-selection/slashEntryUsage";
|
||||
import { recordEntryRecommendedSolutionUsage } from "../utils/entryRecommendedSolutions";
|
||||
|
||||
const { mockGetConfig } = vi.hoisted(() => ({
|
||||
const { mockGetConfig, mockProjectSelector } = vi.hoisted(() => ({
|
||||
mockGetConfig: vi.fn(async () => ({})),
|
||||
mockProjectSelector: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCharacterMention =
|
||||
@@ -28,6 +31,21 @@ vi.mock("@/lib/api/appConfig", () => ({
|
||||
getConfig: mockGetConfig,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/projects/ProjectSelector", () => ({
|
||||
ProjectSelector: (props: {
|
||||
value: string | null;
|
||||
placeholder?: string;
|
||||
onChange?: (projectId: string) => void;
|
||||
}) => {
|
||||
mockProjectSelector(props);
|
||||
return (
|
||||
<div data-testid="project-selector-stub">
|
||||
{props.value ?? props.placeholder ?? "选择项目"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./ChatModelSelector", () => ({
|
||||
ChatModelSelector: () => <div data-testid="chat-model-selector" />,
|
||||
}));
|
||||
@@ -236,6 +254,36 @@ function createGithubSearchServiceSkill(): ServiceSkillHomeItem {
|
||||
};
|
||||
}
|
||||
|
||||
function createSceneBoundServiceSkill(): ServiceSkillHomeItem {
|
||||
return {
|
||||
id: "project-insight-flow",
|
||||
title: "项目线索整理",
|
||||
summary: "围绕当前项目整理线索、结论和下一步动作。",
|
||||
category: "研究与方案",
|
||||
outputHint: "线索清单 + 下一步建议",
|
||||
source: "cloud_catalog",
|
||||
runnerType: "instant",
|
||||
defaultExecutorBinding: "cloud_scene",
|
||||
executionLocation: "cloud_required",
|
||||
version: "seed-v1",
|
||||
badge: "云目录",
|
||||
recentUsedAt: null,
|
||||
isRecent: false,
|
||||
runnerLabel: "云端场景执行",
|
||||
runnerTone: "emerald",
|
||||
runnerDescription: "围绕当前项目持续整理线索和结果。",
|
||||
actionLabel: "继续整理",
|
||||
automationStatus: null,
|
||||
slotSchema: [],
|
||||
sceneBinding: {
|
||||
sceneKey: "project-insight-flow",
|
||||
commandPrefix: "/project-insight-flow",
|
||||
title: "项目线索整理",
|
||||
summary: "围绕当前项目整理线索和下一步动作。",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("EmptyState", () => {
|
||||
it("首页应以 slogan 作为主视觉并保留创作语义", async () => {
|
||||
const container = renderEmptyState({
|
||||
@@ -253,12 +301,12 @@ describe("EmptyState", () => {
|
||||
"文案、图片、视频、搜索和网页任务,会围绕同一个目标持续推进。",
|
||||
);
|
||||
expect(container.textContent).toContain(
|
||||
"跑通过的方法会沉淀成技能、偏好和项目上下文,下次不用重新开始。",
|
||||
"跑通过的方法会沉淀成常用做法、偏好和项目上下文,下次不用重新开始。",
|
||||
);
|
||||
expect(container.textContent).not.toContain("新建任务");
|
||||
});
|
||||
|
||||
it("通用首页应展示精简后的推荐方案条", async () => {
|
||||
it("通用首页应展示收口后的结果模板条", async () => {
|
||||
const container = renderEmptyState({
|
||||
activeTheme: "general",
|
||||
onLaunchBrowserAssist: vi.fn(),
|
||||
@@ -269,7 +317,17 @@ describe("EmptyState", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("推荐方案");
|
||||
expect(container.textContent).toContain("结果模板");
|
||||
expect(container.textContent).toContain("继续上次做法");
|
||||
expect(container.textContent).toContain(
|
||||
"先选你想拿到什么结果,具体由 Lime 在当前任务里分发对应能力。",
|
||||
);
|
||||
expect(container.textContent).toContain(
|
||||
"最近跑通过的结果模板和常用做法会留在这里,下一次不用重新开始。",
|
||||
);
|
||||
expect(container.textContent).toContain(
|
||||
"你最近跑通过的结果模板和方法,会出现在这里。",
|
||||
);
|
||||
expect(container.textContent).toContain("网页研究简报");
|
||||
expect(container.textContent).toContain("内容主稿生成");
|
||||
expect(container.textContent).toContain("演示提纲草案");
|
||||
@@ -288,6 +346,7 @@ describe("EmptyState", () => {
|
||||
expect(container.textContent).not.toContain("生成配图");
|
||||
expect(container.textContent).not.toContain("浏览器任务起步");
|
||||
expect(container.textContent).not.toContain("Team 冒烟测试");
|
||||
expect(container.textContent).not.toContain("推荐方案");
|
||||
|
||||
const recommendationTitles = Array.from(
|
||||
container.querySelectorAll(
|
||||
@@ -303,6 +362,132 @@ describe("EmptyState", () => {
|
||||
"复制轮播帖",
|
||||
"复制视频脚本",
|
||||
]);
|
||||
expect(
|
||||
container.querySelector('[data-testid^="entry-continuation-"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("有最近记录时应展示继续上次做法入口,并允许直接继续模板与方法", async () => {
|
||||
recordEntryRecommendedSolutionUsage("social-post-starter");
|
||||
const onSelectServiceSkill = vi.fn<(skill: ServiceSkillHomeItem) => void>();
|
||||
const setInput = vi.fn<(value: string) => void>();
|
||||
const recentMethod: ServiceSkillHomeItem = {
|
||||
...createSceneBoundServiceSkill(),
|
||||
id: "content-iteration-flow",
|
||||
title: "内容迭代整理",
|
||||
recentUsedAt: 1_000,
|
||||
isRecent: true,
|
||||
};
|
||||
|
||||
const container = renderEmptyState({
|
||||
activeTheme: "general",
|
||||
setInput,
|
||||
onSelectServiceSkill,
|
||||
serviceSkills: [recentMethod],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("继续上次做法");
|
||||
expect(container.textContent).toContain(
|
||||
"最近跑通过的结果模板和常用做法会留在这里,这次可以直接续上。",
|
||||
);
|
||||
|
||||
const continuationItems = Array.from(
|
||||
container.querySelectorAll('[data-testid^="entry-continuation-"]'),
|
||||
).map((element) => element.getAttribute("data-testid"));
|
||||
|
||||
expect(continuationItems).toEqual([
|
||||
"entry-continuation-solution-social-post-starter",
|
||||
"entry-continuation-method-content-iteration-flow",
|
||||
]);
|
||||
|
||||
const recentTemplateButton = container.querySelector(
|
||||
'[data-testid="entry-continuation-solution-social-post-starter"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(recentTemplateButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
recentTemplateButton?.click();
|
||||
});
|
||||
|
||||
expect(setInput).toHaveBeenCalledWith(
|
||||
"请先帮我起草一版内容首稿:明确目标受众、标题方向、正文结构和可继续扩写的角度。",
|
||||
);
|
||||
|
||||
const recentMethodButton = container.querySelector(
|
||||
'[data-testid="entry-continuation-method-content-iteration-flow"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(recentMethodButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
recentMethodButton?.click();
|
||||
});
|
||||
|
||||
expect(onSelectServiceSkill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "content-iteration-flow",
|
||||
title: "内容迭代整理",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("scene 最近使用记录也应把对应做法带回首页继续层", async () => {
|
||||
recordSlashEntryUsage({
|
||||
kind: "scene",
|
||||
entryId: "project-insight-flow",
|
||||
usedAt: 1_800_000_000_000,
|
||||
replayText: "继续帮我整理这个项目的关键信息和后续动作",
|
||||
});
|
||||
const onSelectServiceSkill = vi.fn<(skill: ServiceSkillHomeItem) => void>();
|
||||
const container = renderEmptyState({
|
||||
activeTheme: "general",
|
||||
serviceSkills: [createSceneBoundServiceSkill()],
|
||||
onSelectServiceSkill,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("继续上次做法");
|
||||
const sceneContinuationButton = container.querySelector(
|
||||
'[data-testid="entry-continuation-method-project-insight-flow"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(sceneContinuationButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
sceneContinuationButton?.click();
|
||||
});
|
||||
|
||||
expect(onSelectServiceSkill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "project-insight-flow",
|
||||
title: "项目线索整理",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("传入项目切换能力时应继续保留项目选择器入口", async () => {
|
||||
const container = renderEmptyState({
|
||||
activeTheme: "general",
|
||||
projectId: "project-brand",
|
||||
onProjectChange: vi.fn(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="project-selector-stub"]'),
|
||||
).toBeTruthy();
|
||||
expect(container.textContent).not.toContain("当前项目");
|
||||
expect(
|
||||
container.querySelector('[data-testid^="entry-project-continuation-"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("通用首页应继续渲染 4 个带视觉预览的支撑能力入口", async () => {
|
||||
@@ -315,10 +500,10 @@ describe("EmptyState", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const expectedLabels = ["技能", "自动化", "多代理", "浏览器接入"];
|
||||
const expectedLabels = ["我的方法", "持续流程", "多代理", "浏览器接入"];
|
||||
const expectedAlts = [
|
||||
"技能能力卡占位图",
|
||||
"自动化能力卡占位图",
|
||||
"方法能力卡占位图",
|
||||
"持续流程能力卡占位图",
|
||||
"多代理协作能力卡占位图",
|
||||
"浏览器接入能力卡占位图",
|
||||
];
|
||||
@@ -327,7 +512,7 @@ describe("EmptyState", () => {
|
||||
expect(container.textContent).toContain(label);
|
||||
}
|
||||
|
||||
expect(container.textContent).toContain("重复任务可持续推进");
|
||||
expect(container.textContent).toContain("重复任务可持续复用");
|
||||
expect(container.textContent).toContain("复杂任务可拆分并行推进");
|
||||
expect(container.textContent).toContain("网页登录与网页执行");
|
||||
|
||||
@@ -650,7 +835,7 @@ describe("EmptyState", () => {
|
||||
expect(onSend).toHaveBeenCalledWith("帮我设计封面", "react", undefined);
|
||||
});
|
||||
|
||||
it("首页技能卡应复用统一的技能数量文案", async () => {
|
||||
it("首页方法卡应复用统一的做法数量文案", async () => {
|
||||
const container = renderEmptyState({
|
||||
skills: [
|
||||
{
|
||||
@@ -691,7 +876,7 @@ describe("EmptyState", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("2 项技能可挂载");
|
||||
expect(container.textContent).toContain("2 套做法可直接复用");
|
||||
});
|
||||
|
||||
it("通用对话且存在站点型 service skill 时,应展示自然句占位示例", async () => {
|
||||
@@ -780,12 +965,18 @@ describe("EmptyState", () => {
|
||||
) as HTMLButtonElement | null;
|
||||
expect(advancedToggle).toBeTruthy();
|
||||
|
||||
expect(container.querySelector('button[title="深度思考已关闭"]')).toBeNull();
|
||||
expect(container.querySelector('button[title="联网搜索已关闭"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('button[title="深度思考已关闭"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('button[title="联网搜索已关闭"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="inputbar-plan-toggle"]'),
|
||||
).toBeNull();
|
||||
expect(container.querySelector('button[title="多代理偏好已关闭"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('button[title="多代理偏好已关闭"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="inputbar-access-mode-select"]'),
|
||||
).toBeNull();
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
getActiveSkillDisplayLabel,
|
||||
getSkillSelectionSummaryLabel,
|
||||
} from "../skill-selection/skillSelectionDisplay";
|
||||
import { listSlashEntryUsage } from "../skill-selection/slashEntryUsage";
|
||||
import {
|
||||
getSiteSkillAutoLaunchExample,
|
||||
hasAutoLaunchableSiteSkill,
|
||||
@@ -112,10 +113,11 @@ const RecommendationShelfHeader = styled.div`
|
||||
|
||||
const RecommendationShelfHeaderBody = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
flex: 1 1 auto;
|
||||
`;
|
||||
|
||||
const RecommendationShelfHeaderTitle = styled.div`
|
||||
@@ -125,6 +127,12 @@ const RecommendationShelfHeaderTitle = styled.div`
|
||||
color: rgb(100 116 139);
|
||||
`;
|
||||
|
||||
const RecommendationShelfHeaderDescription = styled.div`
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: rgb(148 163 184);
|
||||
`;
|
||||
|
||||
const RecommendationShelfList = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -147,6 +155,7 @@ const RecommendationShelfButton = styled.button`
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
@@ -175,6 +184,26 @@ const RecommendationShelfHint = styled.span`
|
||||
color: rgb(148 163 184);
|
||||
`;
|
||||
|
||||
const RecommendationShelfBadge = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgb(209 250 229);
|
||||
background: rgb(236 253 245);
|
||||
padding: 0.18rem 0.42rem;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: rgb(5 150 105);
|
||||
`;
|
||||
|
||||
const RecommendationShelfEmptyState = styled.div`
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: rgb(148 163 184);
|
||||
padding: 0.1rem 0;
|
||||
`;
|
||||
|
||||
interface EmptyStateProps extends SkillSelectionSourceProps {
|
||||
input: string;
|
||||
setInput: (value: string) => void;
|
||||
@@ -256,6 +285,16 @@ type RecommendationShelfItem =
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
interface ContinuationShelfItem {
|
||||
key: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
badge: string;
|
||||
usedAt: number;
|
||||
testId: string;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
const GENERAL_CATEGORY_LABEL = "通用对话";
|
||||
const FEATURED_HOME_RECOMMENDED_SOLUTION_IDS = [
|
||||
"web-research-brief",
|
||||
@@ -290,7 +329,9 @@ function buildSeededFeaturedHomeServiceSkills(): ServiceSkillHomeItem[] {
|
||||
const seededCatalog = getSeededServiceSkillCatalog();
|
||||
|
||||
return FEATURED_HOME_SERVICE_SKILL_IDS.flatMap((skillId) => {
|
||||
const matchedSkill = seededCatalog.items.find((item) => item.id === skillId);
|
||||
const matchedSkill = seededCatalog.items.find(
|
||||
(item) => item.id === skillId,
|
||||
);
|
||||
if (!matchedSkill) {
|
||||
return [];
|
||||
}
|
||||
@@ -319,7 +360,8 @@ function buildSeededFeaturedHomeServiceSkills(): ServiceSkillHomeItem[] {
|
||||
});
|
||||
}
|
||||
|
||||
const SEEDED_FEATURED_HOME_SERVICE_SKILLS = buildSeededFeaturedHomeServiceSkills();
|
||||
const SEEDED_FEATURED_HOME_SERVICE_SKILLS =
|
||||
buildSeededFeaturedHomeServiceSkills();
|
||||
|
||||
// 需要显示创作模式选择器的主题
|
||||
const CREATION_THEMES: string[] = [];
|
||||
@@ -340,7 +382,7 @@ const THEME_WORKBENCH_COPY: Record<
|
||||
title: "",
|
||||
description: "说一句目标,剩下的交给 Lime。",
|
||||
supportingDescription:
|
||||
"文案、图片、视频、搜索和网页任务,会围绕同一个目标持续推进。跑通过的方法会沉淀成技能、偏好和项目上下文,下次不用重新开始。",
|
||||
"文案、图片、视频、搜索和网页任务,会围绕同一个目标持续推进。跑通过的方法会沉淀成常用做法、偏好和项目上下文,下次不用重新开始。",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -352,6 +394,32 @@ function truncatePrompt(value: string, maxLength = 92) {
|
||||
return `${normalized.slice(0, maxLength).trim()}…`;
|
||||
}
|
||||
|
||||
function compareRecentShelfItems<
|
||||
T extends {
|
||||
title: string;
|
||||
usedAt: number;
|
||||
},
|
||||
>(left: T, right: T): number {
|
||||
if (left.usedAt !== right.usedAt) {
|
||||
return right.usedAt - left.usedAt;
|
||||
}
|
||||
|
||||
return left.title.localeCompare(right.title, "zh-CN");
|
||||
}
|
||||
|
||||
function formatMethodSummaryLabel(summaryLabel: string): string {
|
||||
const countMatch = summaryLabel.match(/^(\d+)\s+项技能可挂载$/);
|
||||
if (countMatch) {
|
||||
return `${countMatch[1]} 套做法可直接复用`;
|
||||
}
|
||||
|
||||
if (summaryLabel === "按需挂载任务能力") {
|
||||
return "按需挂上常用做法";
|
||||
}
|
||||
|
||||
return summaryLabel;
|
||||
}
|
||||
|
||||
export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
input,
|
||||
setInput,
|
||||
@@ -421,6 +489,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
activeSkill: currentSkill,
|
||||
skillCount: skillOptionCount,
|
||||
});
|
||||
const methodSummaryLabel = formatMethodSummaryLabel(skillSummaryLabel);
|
||||
const hasAutoLaunchSiteSkill = hasAutoLaunchableSiteSkill(serviceSkills);
|
||||
const siteSkillAutoLaunchExample =
|
||||
getSiteSkillAutoLaunchExample(serviceSkills);
|
||||
@@ -465,11 +534,14 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
}, []);
|
||||
|
||||
// 使用外部传入的 activeTheme,如果有 onThemeChange 则使用受控模式
|
||||
const handleThemeChange = useCallback((theme: string) => {
|
||||
if (onThemeChange) {
|
||||
onThemeChange(theme === "general" ? theme : "general");
|
||||
}
|
||||
}, [onThemeChange]);
|
||||
const handleThemeChange = useCallback(
|
||||
(theme: string) => {
|
||||
if (onThemeChange) {
|
||||
onThemeChange(theme === "general" ? theme : "general");
|
||||
}
|
||||
},
|
||||
[onThemeChange],
|
||||
);
|
||||
|
||||
// 判断当前主题是否需要显示创作模式选择器
|
||||
const showCreationModeSelector = CREATION_THEMES.includes(activeTheme);
|
||||
@@ -515,6 +587,14 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
return listEntryRecommendedSolutions();
|
||||
}, [entryRecommendedSolutionsVersion]);
|
||||
|
||||
const recentSceneUsageBySceneKey = useMemo(() => {
|
||||
return new Map(
|
||||
listSlashEntryUsage()
|
||||
.filter((record) => record.kind === "scene")
|
||||
.map((record) => [record.entryId, record] as const),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const selectedTextPreview = useMemo(() => {
|
||||
const normalized = (recommendationSelectedText || "")
|
||||
.trim()
|
||||
@@ -596,75 +676,76 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
: "有什么我可以帮你的?";
|
||||
};
|
||||
|
||||
const handleApplyRecommendation = useCallback((
|
||||
shortLabel: string,
|
||||
fullPrompt: string,
|
||||
) => {
|
||||
const looksLikeTeamRuntimePrompt =
|
||||
activeTheme === "general" &&
|
||||
isTeamRuntimeRecommendation(shortLabel, fullPrompt);
|
||||
if (looksLikeTeamRuntimePrompt) {
|
||||
onSubagentEnabledChange?.(true);
|
||||
}
|
||||
const handleApplyRecommendation = useCallback(
|
||||
(shortLabel: string, fullPrompt: string) => {
|
||||
const looksLikeTeamRuntimePrompt =
|
||||
activeTheme === "general" &&
|
||||
isTeamRuntimeRecommendation(shortLabel, fullPrompt);
|
||||
if (looksLikeTeamRuntimePrompt) {
|
||||
onSubagentEnabledChange?.(true);
|
||||
}
|
||||
|
||||
const promptWithSelection = buildRecommendationPrompt(
|
||||
fullPrompt,
|
||||
selectedText,
|
||||
const promptWithSelection = buildRecommendationPrompt(
|
||||
fullPrompt,
|
||||
selectedText,
|
||||
appendSelectedTextToRecommendation,
|
||||
);
|
||||
if (onRecommendationClick) {
|
||||
onRecommendationClick(shortLabel, promptWithSelection);
|
||||
return;
|
||||
}
|
||||
setInput(promptWithSelection);
|
||||
},
|
||||
[
|
||||
activeTheme,
|
||||
appendSelectedTextToRecommendation,
|
||||
);
|
||||
if (onRecommendationClick) {
|
||||
onRecommendationClick(shortLabel, promptWithSelection);
|
||||
return;
|
||||
}
|
||||
setInput(promptWithSelection);
|
||||
}, [
|
||||
activeTheme,
|
||||
appendSelectedTextToRecommendation,
|
||||
onRecommendationClick,
|
||||
onSubagentEnabledChange,
|
||||
selectedText,
|
||||
setInput,
|
||||
]);
|
||||
|
||||
const handleApplyEntryRecommendedSolution = useCallback((
|
||||
solution: EntryRecommendedSolutionItem,
|
||||
) => {
|
||||
recordEntryRecommendedSolutionUsage(solution.id);
|
||||
setEntryRecommendedSolutionsVersion((previous) => previous + 1);
|
||||
|
||||
if (solution.shouldEnableWebSearch && !webSearchEnabled) {
|
||||
onWebSearchEnabledChange?.(true);
|
||||
}
|
||||
|
||||
if (solution.shouldEnableTeamMode && !subagentEnabled) {
|
||||
onSubagentEnabledChange?.(true);
|
||||
}
|
||||
|
||||
if (solution.themeTarget) {
|
||||
handleThemeChange(solution.themeTarget);
|
||||
}
|
||||
|
||||
if (solution.shouldLaunchBrowserAssist) {
|
||||
void onLaunchBrowserAssist?.();
|
||||
}
|
||||
|
||||
const promptWithSelection = buildRecommendationPrompt(
|
||||
solution.prompt,
|
||||
onRecommendationClick,
|
||||
onSubagentEnabledChange,
|
||||
selectedText,
|
||||
setInput,
|
||||
],
|
||||
);
|
||||
|
||||
const handleApplyEntryRecommendedSolution = useCallback(
|
||||
(solution: EntryRecommendedSolutionItem) => {
|
||||
recordEntryRecommendedSolutionUsage(solution.id);
|
||||
setEntryRecommendedSolutionsVersion((previous) => previous + 1);
|
||||
|
||||
if (solution.shouldEnableWebSearch && !webSearchEnabled) {
|
||||
onWebSearchEnabledChange?.(true);
|
||||
}
|
||||
|
||||
if (solution.shouldEnableTeamMode && !subagentEnabled) {
|
||||
onSubagentEnabledChange?.(true);
|
||||
}
|
||||
|
||||
if (solution.themeTarget) {
|
||||
handleThemeChange(solution.themeTarget);
|
||||
}
|
||||
|
||||
if (solution.shouldLaunchBrowserAssist) {
|
||||
void onLaunchBrowserAssist?.();
|
||||
}
|
||||
|
||||
const promptWithSelection = buildRecommendationPrompt(
|
||||
solution.prompt,
|
||||
selectedText,
|
||||
appendSelectedTextToRecommendation,
|
||||
);
|
||||
setInput(promptWithSelection);
|
||||
},
|
||||
[
|
||||
appendSelectedTextToRecommendation,
|
||||
);
|
||||
setInput(promptWithSelection);
|
||||
}, [
|
||||
appendSelectedTextToRecommendation,
|
||||
handleThemeChange,
|
||||
onLaunchBrowserAssist,
|
||||
onSubagentEnabledChange,
|
||||
onWebSearchEnabledChange,
|
||||
selectedText,
|
||||
setInput,
|
||||
subagentEnabled,
|
||||
webSearchEnabled,
|
||||
]);
|
||||
handleThemeChange,
|
||||
onLaunchBrowserAssist,
|
||||
onSubagentEnabledChange,
|
||||
onWebSearchEnabledChange,
|
||||
selectedText,
|
||||
setInput,
|
||||
subagentEnabled,
|
||||
webSearchEnabled,
|
||||
],
|
||||
);
|
||||
|
||||
const workspaceBadges = useMemo(() => {
|
||||
const badges: Array<{
|
||||
@@ -735,26 +816,26 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
}> = [
|
||||
{
|
||||
key: "skills",
|
||||
eyebrow: "支撑能力",
|
||||
title: "技能",
|
||||
value: skillSummaryLabel,
|
||||
eyebrow: "沉淀能力",
|
||||
title: "我的方法",
|
||||
value: methodSummaryLabel,
|
||||
description:
|
||||
"把常用做法沉淀下来,下次遇到同类任务可以直接复用。",
|
||||
"把跑通过的常用做法沉淀下来,下次遇到同类任务可以直接续上。",
|
||||
icon: <Lightbulb className="h-5 w-5" />,
|
||||
imageSrc: capabilitySkillsPlaceholder,
|
||||
imageAlt: "技能能力卡占位图",
|
||||
imageAlt: "方法能力卡占位图",
|
||||
tone: "lime",
|
||||
},
|
||||
{
|
||||
key: "automation",
|
||||
eyebrow: "支撑能力",
|
||||
title: "自动化",
|
||||
value: planEnabled ? "当前会按步骤推进" : "重复任务可持续推进",
|
||||
eyebrow: "沉淀能力",
|
||||
title: "持续流程",
|
||||
value: planEnabled ? "当前任务会按步骤推进" : "重复任务可持续复用",
|
||||
description:
|
||||
"适合批量任务和重复流程,让常做的事情持续推进,不再每次从头开始。",
|
||||
"适合批量任务和重复流程,让一套做法可以自己持续往下跑,不再每次从头开始。",
|
||||
icon: <ListChecks className="h-5 w-5" />,
|
||||
imageSrc: capabilityAutomationsPlaceholder,
|
||||
imageAlt: "自动化能力卡占位图",
|
||||
imageAlt: "持续流程能力卡占位图",
|
||||
tone: "lime",
|
||||
},
|
||||
{
|
||||
@@ -762,10 +843,10 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
eyebrow: "支撑能力",
|
||||
title: "多代理",
|
||||
value: subagentEnabled
|
||||
? "当前任务支持协作分工"
|
||||
? "当前任务支持分工推进"
|
||||
: "复杂任务可拆分并行推进",
|
||||
description:
|
||||
"当一个目标需要调研、方案和执行同时推进时,可以拆成协作分工后统一回收结论。",
|
||||
"当一个目标需要调研、方案和执行同时推进时,可以拆成多个分工后统一回收结论。",
|
||||
icon: <Workflow className="h-5 w-5" />,
|
||||
imageSrc: capabilityAgentTeamsPlaceholder,
|
||||
imageAlt: "多代理协作能力卡占位图",
|
||||
@@ -799,8 +880,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
}, [
|
||||
browserAssistLoading,
|
||||
planEnabled,
|
||||
methodSummaryLabel,
|
||||
onLaunchBrowserAssist,
|
||||
skillSummaryLabel,
|
||||
subagentEnabled,
|
||||
]);
|
||||
|
||||
@@ -840,26 +921,25 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
|
||||
const serviceSkillRecommendations = isGeneralTheme
|
||||
? featuredServiceSkills.map((skill) => {
|
||||
const requiresSlots = skill.slotSchema.some(
|
||||
(slot) => slot.required,
|
||||
);
|
||||
const requiresSlots = skill.slotSchema.some((slot) => slot.required);
|
||||
|
||||
return {
|
||||
kind: "service-skill" as const,
|
||||
key: `service-skill-${skill.id}`,
|
||||
title: skill.title,
|
||||
summary:
|
||||
skill.summary?.trim() || resolveServiceSkillEntryDescription(skill),
|
||||
badge: skill.isRecent ? "最近使用" : skill.badge,
|
||||
hint: requiresSlots
|
||||
? "对话内补参后开始"
|
||||
: `${skill.actionLabel} · 当前对话继续`,
|
||||
testId: `entry-service-skill-${skill.id}`,
|
||||
onSelect: () => {
|
||||
onSelectServiceSkill?.(skill);
|
||||
},
|
||||
};
|
||||
})
|
||||
return {
|
||||
kind: "service-skill" as const,
|
||||
key: `service-skill-${skill.id}`,
|
||||
title: skill.title,
|
||||
summary:
|
||||
skill.summary?.trim() ||
|
||||
resolveServiceSkillEntryDescription(skill),
|
||||
badge: skill.isRecent ? "最近使用" : skill.badge,
|
||||
hint: requiresSlots
|
||||
? "对话内补参后开始"
|
||||
: `${skill.actionLabel} · 当前对话继续`,
|
||||
testId: `entry-service-skill-${skill.id}`,
|
||||
onSelect: () => {
|
||||
onSelectServiceSkill?.(skill);
|
||||
},
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
return [...solutionRecommendations, ...serviceSkillRecommendations];
|
||||
@@ -871,6 +951,64 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
serviceSkills,
|
||||
]);
|
||||
|
||||
const continuationShelfItems = useMemo<ContinuationShelfItem[]>(() => {
|
||||
const recentSolutionItems = entryRecommendedSolutions
|
||||
.filter(
|
||||
(solution) =>
|
||||
solution.isRecent && typeof solution.recentUsedAt === "number",
|
||||
)
|
||||
.map((solution) => ({
|
||||
key: `solution-${solution.id}`,
|
||||
title: solution.title,
|
||||
summary: solution.summary,
|
||||
badge: "结果模板",
|
||||
usedAt: solution.recentUsedAt as number,
|
||||
testId: `entry-continuation-solution-${solution.id}`,
|
||||
onSelect: () => handleApplyEntryRecommendedSolution(solution),
|
||||
}));
|
||||
|
||||
const recentMethodItems =
|
||||
typeof onSelectServiceSkill === "function"
|
||||
? (serviceSkills ?? [])
|
||||
.map((skill) => {
|
||||
const serviceSkillUsedAt =
|
||||
typeof skill.recentUsedAt === "number" ? skill.recentUsedAt : 0;
|
||||
const sceneUsedAt = skill.sceneBinding?.sceneKey
|
||||
? (recentSceneUsageBySceneKey.get(skill.sceneBinding.sceneKey)
|
||||
?.usedAt ?? 0)
|
||||
: 0;
|
||||
const usedAt = Math.max(serviceSkillUsedAt, sceneUsedAt);
|
||||
|
||||
if (usedAt <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
key: `method-${skill.id}`,
|
||||
title: skill.title,
|
||||
summary: resolveServiceSkillEntryDescription(skill),
|
||||
badge: "我的方法",
|
||||
usedAt,
|
||||
testId: `entry-continuation-method-${skill.id}`,
|
||||
onSelect: () => {
|
||||
onSelectServiceSkill(skill);
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter((item): item is ContinuationShelfItem => item !== null)
|
||||
: [];
|
||||
|
||||
return [...recentSolutionItems, ...recentMethodItems]
|
||||
.sort(compareRecentShelfItems)
|
||||
.slice(0, 4);
|
||||
}, [
|
||||
entryRecommendedSolutions,
|
||||
handleApplyEntryRecommendedSolution,
|
||||
onSelectServiceSkill,
|
||||
recentSceneUsageBySceneKey,
|
||||
serviceSkills,
|
||||
]);
|
||||
|
||||
const quickActionItems = useMemo(
|
||||
() =>
|
||||
currentRecommendations.slice(0, 4).map(([shortLabel, fullPrompt]) => ({
|
||||
@@ -1001,8 +1139,11 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
<RecommendationShelfHeader>
|
||||
<RecommendationShelfHeaderBody>
|
||||
<RecommendationShelfHeaderTitle>
|
||||
推荐方案
|
||||
结果模板
|
||||
</RecommendationShelfHeaderTitle>
|
||||
<RecommendationShelfHeaderDescription>
|
||||
先选你想拿到什么结果,具体由 Lime 在当前任务里分发对应能力。
|
||||
</RecommendationShelfHeaderDescription>
|
||||
</RecommendationShelfHeaderBody>
|
||||
{selectedTextPreview ? (
|
||||
<span className="truncate text-[10px] text-slate-400">
|
||||
@@ -1015,7 +1156,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
{recommendationShelfItems.map((item, index) => (
|
||||
<RecommendationShelfRow key={item.key}>
|
||||
{index > 0 ? (
|
||||
<RecommendationShelfHint aria-hidden="true">|</RecommendationShelfHint>
|
||||
<RecommendationShelfHint aria-hidden="true">
|
||||
|
|
||||
</RecommendationShelfHint>
|
||||
) : null}
|
||||
<RecommendationShelfButton
|
||||
type="button"
|
||||
@@ -1033,6 +1176,56 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
</RecommendationShelf>
|
||||
);
|
||||
|
||||
const generalContinuationPanel = (
|
||||
<RecommendationShelf>
|
||||
<RecommendationShelfHeader>
|
||||
<RecommendationShelfHeaderBody>
|
||||
<RecommendationShelfHeaderTitle>
|
||||
继续上次做法
|
||||
</RecommendationShelfHeaderTitle>
|
||||
<RecommendationShelfHeaderDescription>
|
||||
{continuationShelfItems.length > 0
|
||||
? "最近跑通过的结果模板和常用做法会留在这里,这次可以直接续上。"
|
||||
: "最近跑通过的结果模板和常用做法会留在这里,下一次不用重新开始。"}
|
||||
</RecommendationShelfHeaderDescription>
|
||||
</RecommendationShelfHeaderBody>
|
||||
</RecommendationShelfHeader>
|
||||
|
||||
{continuationShelfItems.length > 0 ? (
|
||||
<RecommendationShelfList>
|
||||
{continuationShelfItems.map((item, index) => (
|
||||
<RecommendationShelfRow key={item.key}>
|
||||
{index > 0 ? (
|
||||
<RecommendationShelfHint aria-hidden="true">
|
||||
|
|
||||
</RecommendationShelfHint>
|
||||
) : null}
|
||||
<RecommendationShelfButton
|
||||
type="button"
|
||||
data-testid={item.testId}
|
||||
title={`${item.badge} · ${item.summary}`}
|
||||
onClick={() => {
|
||||
item.onSelect();
|
||||
}}
|
||||
>
|
||||
<RecommendationShelfBadge>
|
||||
{item.badge}
|
||||
</RecommendationShelfBadge>
|
||||
<RecommendationShelfTitle>
|
||||
{item.title}
|
||||
</RecommendationShelfTitle>
|
||||
</RecommendationShelfButton>
|
||||
</RecommendationShelfRow>
|
||||
))}
|
||||
</RecommendationShelfList>
|
||||
) : (
|
||||
<RecommendationShelfEmptyState>
|
||||
你最近跑通过的结果模板和方法,会出现在这里。
|
||||
</RecommendationShelfEmptyState>
|
||||
)}
|
||||
</RecommendationShelf>
|
||||
);
|
||||
|
||||
const headerControls = onProjectChange ? (
|
||||
<div className="flex w-full justify-start sm:w-auto sm:justify-end">
|
||||
<div className="inline-flex max-w-full items-center rounded-[24px] border border-slate-200/80 bg-white p-1 shadow-sm shadow-slate-950/5">
|
||||
@@ -1084,9 +1277,14 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
cards={workspaceCards}
|
||||
prioritySlot={composerPanel}
|
||||
supportingSlot={
|
||||
isGeneralTheme
|
||||
? generalRecommendedSolutionsPanel
|
||||
: defaultQuickActionsPanel
|
||||
isGeneralTheme ? (
|
||||
<>
|
||||
{generalContinuationPanel}
|
||||
{generalRecommendedSolutionsPanel}
|
||||
</>
|
||||
) : (
|
||||
defaultQuickActionsPanel
|
||||
)
|
||||
}
|
||||
headerControls={headerControls}
|
||||
/>
|
||||
|
||||
@@ -378,7 +378,7 @@ describe("EmptyStateComposerPanel", () => {
|
||||
expect(onRemoveImage).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("复杂任务应显示 Team 建议并支持开启多代理", () => {
|
||||
it("复杂任务应显示任务分工建议并支持开启多代理", () => {
|
||||
const onSubagentEnabledChange = vi.fn();
|
||||
const container = renderPanel({
|
||||
isGeneralTheme: true,
|
||||
@@ -387,12 +387,12 @@ describe("EmptyStateComposerPanel", () => {
|
||||
onSubagentEnabledChange,
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("当前任务更适合 Team 协作");
|
||||
expect(container.textContent).toContain("当前任务更适合分工推进");
|
||||
expect(container.textContent).toContain("建议角色:分析");
|
||||
|
||||
const enableTeamButton = Array.from(
|
||||
container.querySelectorAll("button"),
|
||||
).find((button) => button.textContent?.includes("启用 Team"));
|
||||
).find((button) => button.textContent?.includes("启用任务分工"));
|
||||
|
||||
expect(enableTeamButton).toBeTruthy();
|
||||
|
||||
@@ -405,7 +405,7 @@ describe("EmptyStateComposerPanel", () => {
|
||||
expect(onSubagentEnabledChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("继续单代理后应隐藏当前输入对应的 Team 建议", () => {
|
||||
it("继续单代理后应隐藏当前输入对应的任务分工建议", () => {
|
||||
const container = renderPanel({
|
||||
isGeneralTheme: true,
|
||||
input:
|
||||
@@ -423,7 +423,7 @@ describe("EmptyStateComposerPanel", () => {
|
||||
continueButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("当前任务更适合 Team 协作");
|
||||
expect(container.textContent).not.toContain("当前任务更适合分工推进");
|
||||
});
|
||||
|
||||
it("开启 Team mode 后应显示 TeamSelector", () => {
|
||||
@@ -603,7 +603,7 @@ describe("EmptyStateComposerPanel", () => {
|
||||
});
|
||||
|
||||
const enableButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("启用 Team"),
|
||||
(button) => button.textContent?.includes("启用任务分工"),
|
||||
) as HTMLButtonElement | undefined;
|
||||
|
||||
expect(
|
||||
@@ -615,8 +615,8 @@ describe("EmptyStateComposerPanel", () => {
|
||||
container.querySelector('button[title="多代理偏好已关闭"]'),
|
||||
).toBeNull();
|
||||
expect(enableButton).toBeTruthy();
|
||||
expect(enableButton?.textContent).toContain("启用 Team");
|
||||
expect(container.textContent).toContain("当前任务更适合 Team 协作");
|
||||
expect(enableButton?.textContent).toContain("启用任务分工");
|
||||
expect(container.textContent).toContain("当前任务更适合分工推进");
|
||||
});
|
||||
|
||||
it("折叠态应保留当前模型轻提示,展开高级设置后再允许修改", () => {
|
||||
|
||||
@@ -177,6 +177,46 @@ function renderSidebar(
|
||||
}
|
||||
|
||||
describe("GeneralWorkbenchSidebar", () => {
|
||||
it("任务进行时面板应突出当前任务并按状态优先级排序", () => {
|
||||
const { container } = renderSidebar({
|
||||
workflowSteps: [
|
||||
{ id: "done", title: "完成提纲", status: "completed" },
|
||||
{ id: "pending", title: "等待补充案例", status: "pending" },
|
||||
{ id: "active", title: "撰写主稿", status: "active" },
|
||||
{ id: "error", title: "封面生成失败", status: "error" },
|
||||
],
|
||||
});
|
||||
|
||||
const workflowTab = container.querySelector(
|
||||
'button[aria-label="打开任务进行时面板"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(workflowTab).toBeTruthy();
|
||||
expect(workflowTab?.textContent).toContain("任务");
|
||||
if (workflowTab) {
|
||||
act(() => {
|
||||
workflowTab.click();
|
||||
});
|
||||
}
|
||||
|
||||
expect(container.textContent).toContain("任务工作台");
|
||||
expect(container.textContent).toContain("跟踪当前任务、后续节点与产物版本。");
|
||||
expect(container.textContent).toContain("任务进行时");
|
||||
expect(container.textContent).toContain("当前任务");
|
||||
expect(container.textContent).toContain("撰写主稿");
|
||||
expect(container.textContent).toContain("已完成 1/4");
|
||||
|
||||
const stepNodes = Array.from(
|
||||
container.querySelectorAll('[data-testid="workflow-sidebar-step"]'),
|
||||
);
|
||||
expect(stepNodes).toHaveLength(4);
|
||||
expect(stepNodes.map((node) => node.getAttribute("data-status"))).toEqual([
|
||||
"active",
|
||||
"error",
|
||||
"pending",
|
||||
"completed",
|
||||
]);
|
||||
});
|
||||
|
||||
it("传入折叠回调时应显示折叠按钮并可触发", () => {
|
||||
const onRequestCollapse = vi.fn();
|
||||
const { container } = renderSidebar({ onRequestCollapse });
|
||||
@@ -277,7 +317,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
container.querySelector('button[aria-label="打开上下文管理"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector('button[aria-label="打开编排面板"]'),
|
||||
container.querySelector('button[aria-label="打开任务进行时面板"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector('button[aria-label="打开执行日志"]'),
|
||||
@@ -685,7 +725,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
});
|
||||
|
||||
const workflowTab = container.querySelector(
|
||||
'button[aria-label="打开编排面板"]',
|
||||
'button[aria-label="打开任务进行时面板"]',
|
||||
) as HTMLButtonElement | null;
|
||||
if (workflowTab) {
|
||||
act(() => {
|
||||
@@ -713,7 +753,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
});
|
||||
|
||||
const workflowTab = container.querySelector(
|
||||
'button[aria-label="打开编排面板"]',
|
||||
'button[aria-label="打开任务进行时面板"]',
|
||||
) as HTMLButtonElement | null;
|
||||
if (workflowTab) {
|
||||
act(() => {
|
||||
@@ -740,7 +780,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
it("活动日志应展示后端闸门与运行标识", () => {
|
||||
const { container } = renderSidebar();
|
||||
const workflowTab = container.querySelector(
|
||||
'button[aria-label="打开编排面板"]',
|
||||
'button[aria-label="打开任务进行时面板"]',
|
||||
) as HTMLButtonElement | null;
|
||||
if (workflowTab) {
|
||||
act(() => {
|
||||
@@ -793,7 +833,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
});
|
||||
|
||||
const workflowTab = container.querySelector(
|
||||
'button[aria-label="打开编排面板"]',
|
||||
'button[aria-label="打开任务进行时面板"]',
|
||||
) as HTMLButtonElement | null;
|
||||
if (workflowTab) {
|
||||
act(() => {
|
||||
@@ -826,7 +866,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
const onViewRunDetail = vi.fn();
|
||||
const { container } = renderSidebar({ onViewRunDetail });
|
||||
const workflowTab = container.querySelector(
|
||||
'button[aria-label="打开编排面板"]',
|
||||
'button[aria-label="打开任务进行时面板"]',
|
||||
) as HTMLButtonElement | null;
|
||||
if (workflowTab) {
|
||||
act(() => {
|
||||
@@ -876,7 +916,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
});
|
||||
|
||||
const workflowTab = container.querySelector(
|
||||
'button[aria-label="打开编排面板"]',
|
||||
'button[aria-label="打开任务进行时面板"]',
|
||||
) as HTMLButtonElement | null;
|
||||
if (workflowTab) {
|
||||
act(() => {
|
||||
@@ -918,7 +958,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
});
|
||||
|
||||
const workflowTab = container.querySelector(
|
||||
'button[aria-label="打开编排面板"]',
|
||||
'button[aria-label="打开任务进行时面板"]',
|
||||
) as HTMLButtonElement | null;
|
||||
if (workflowTab) {
|
||||
act(() => {
|
||||
@@ -993,7 +1033,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
});
|
||||
|
||||
const workflowTab = container.querySelector(
|
||||
'button[aria-label="打开编排面板"]',
|
||||
'button[aria-label="打开任务进行时面板"]',
|
||||
) as HTMLButtonElement | null;
|
||||
if (workflowTab) {
|
||||
act(() => {
|
||||
@@ -1099,7 +1139,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
});
|
||||
|
||||
const workflowTab = container.querySelector(
|
||||
'button[aria-label="打开编排面板"]',
|
||||
'button[aria-label="打开任务进行时面板"]',
|
||||
) as HTMLButtonElement | null;
|
||||
if (workflowTab) {
|
||||
act(() => {
|
||||
@@ -1182,7 +1222,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
});
|
||||
|
||||
const workflowTab = container.querySelector(
|
||||
'button[aria-label="打开编排面板"]',
|
||||
'button[aria-label="打开任务进行时面板"]',
|
||||
) as HTMLButtonElement | null;
|
||||
if (workflowTab) {
|
||||
act(() => {
|
||||
@@ -1239,7 +1279,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
});
|
||||
|
||||
const workflowTab = container.querySelector(
|
||||
'button[aria-label="打开编排面板"]',
|
||||
'button[aria-label="打开任务进行时面板"]',
|
||||
) as HTMLButtonElement | null;
|
||||
if (workflowTab) {
|
||||
act(() => {
|
||||
|
||||
@@ -74,16 +74,25 @@ function resolveSidebarTitle(
|
||||
if (activeTab === "context") {
|
||||
return "上下文管理";
|
||||
}
|
||||
return isVersionMode ? "编排与版本" : "编排与分支";
|
||||
if (activeTab === "workflow") {
|
||||
return "任务进行时";
|
||||
}
|
||||
return isVersionMode ? "版本记录" : "运行记录";
|
||||
}
|
||||
|
||||
function resolveSidebarDescription(
|
||||
activeTab: GeneralWorkbenchSidebarTab,
|
||||
isVersionMode: boolean,
|
||||
): string {
|
||||
if (activeTab === "context") {
|
||||
return "检索、筛选并启用当前创作真正会用到的上下文。";
|
||||
}
|
||||
return "跟踪编排进度、产物版本与运行记录。";
|
||||
if (activeTab === "workflow") {
|
||||
return isVersionMode
|
||||
? "跟踪当前任务、后续节点与产物版本。"
|
||||
: "跟踪当前任务、后续节点与分支进展。";
|
||||
}
|
||||
return "查看技能调用、工具输出与运行记录。";
|
||||
}
|
||||
|
||||
export function GeneralWorkbenchSidebarShell({
|
||||
@@ -105,7 +114,7 @@ export function GeneralWorkbenchSidebarShell({
|
||||
>
|
||||
<div className={SIDEBAR_HEADER_CLASSNAME}>
|
||||
<div className={SIDEBAR_HEADER_META_ROW_CLASSNAME}>
|
||||
<div className={SIDEBAR_EYEBROW_CLASSNAME}>工作区编排</div>
|
||||
<div className={SIDEBAR_EYEBROW_CLASSNAME}>任务工作台</div>
|
||||
{headerActionSlot ? (
|
||||
<div
|
||||
className={SIDEBAR_HEADER_ACTION_SLOT_CLASSNAME}
|
||||
@@ -119,7 +128,7 @@ export function GeneralWorkbenchSidebarShell({
|
||||
{resolveSidebarTitle(activeTab, isVersionMode)}
|
||||
</div>
|
||||
<div className={SIDEBAR_DESCRIPTION_CLASSNAME}>
|
||||
{resolveSidebarDescription(activeTab)}
|
||||
{resolveSidebarDescription(activeTab, isVersionMode)}
|
||||
</div>
|
||||
<div className={SIDEBAR_TABS_CLASSNAME}>
|
||||
<button
|
||||
@@ -138,12 +147,12 @@ export function GeneralWorkbenchSidebarShell({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="打开编排面板"
|
||||
title="编排面板"
|
||||
aria-label="打开任务进行时面板"
|
||||
title="任务进行时面板"
|
||||
className={getSidebarTabButtonClassName(activeTab === "workflow")}
|
||||
onClick={() => onTabChange("workflow")}
|
||||
>
|
||||
<span className={SIDEBAR_TAB_LABEL_CLASSNAME}>编排</span>
|
||||
<span className={SIDEBAR_TAB_LABEL_CLASSNAME}>任务</span>
|
||||
<span
|
||||
className={getSidebarTabCountClassName(activeTab === "workflow")}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { memo } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CheckCircle2,
|
||||
@@ -25,6 +26,12 @@ import type {
|
||||
TopicBranchStatus,
|
||||
} from "../hooks/useTopicBranchBoard";
|
||||
import type { SidebarActivityLog } from "../hooks/useThemeContextWorkspace";
|
||||
import {
|
||||
buildWorkflowStepSnapshot,
|
||||
buildWorkflowSummaryText,
|
||||
formatWorkflowProgressLabel,
|
||||
getWorkflowStatusLabel,
|
||||
} from "../utils/workflowStepPresentation";
|
||||
import type {
|
||||
GeneralWorkbenchActivityLogGroup,
|
||||
GeneralWorkbenchCreationTaskGroup,
|
||||
@@ -88,6 +95,9 @@ const WORKFLOW_PROGRESS_FILL_CLASSNAME =
|
||||
|
||||
const WORKFLOW_STEP_LIST_CLASSNAME = "mt-3 flex flex-col gap-2";
|
||||
|
||||
const WORKFLOW_STATUS_CARD_CLASSNAME =
|
||||
"mt-3 rounded-[16px] border border-slate-200/80 bg-white px-3 py-3 shadow-sm shadow-slate-950/5";
|
||||
|
||||
const TOGGLE_BUTTON_CLASSNAME =
|
||||
"inline-flex items-center text-slate-500 transition-colors hover:text-slate-900";
|
||||
|
||||
@@ -249,13 +259,15 @@ const RunDetailActionButton = createButton(
|
||||
|
||||
function getWorkflowStepRowClassName(status: StepStatus) {
|
||||
return cn(
|
||||
"flex items-center gap-2 rounded-[12px] border px-2.5 py-2 text-sm leading-5",
|
||||
"flex items-start 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 === "error" && "border-rose-200 bg-rose-50/80 text-slate-900",
|
||||
status === "active" && "border-sky-200 bg-sky-50/80 text-slate-900",
|
||||
status !== "completed" &&
|
||||
status !== "error" &&
|
||||
status !== "active" &&
|
||||
"border-slate-200/80 bg-white/82 text-slate-500",
|
||||
"border-slate-200/80 bg-slate-50/70 text-slate-500",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -263,12 +275,26 @@ function getStepIcon(status: StepStatus) {
|
||||
if (status === "completed") {
|
||||
return <CheckCircle2 size={13} />;
|
||||
}
|
||||
if (status === "error") {
|
||||
return <AlertCircle size={13} />;
|
||||
}
|
||||
if (status === "active") {
|
||||
return <Clock3 size={13} />;
|
||||
}
|
||||
return <Circle size={11} />;
|
||||
}
|
||||
|
||||
function getStatusBadgeClassName(status: StepStatus) {
|
||||
return cn(
|
||||
"inline-flex rounded-full px-2 py-0.5 text-[10px] font-semibold",
|
||||
status === "completed" && "bg-emerald-100 text-emerald-700",
|
||||
status === "error" && "bg-rose-100 text-rose-700",
|
||||
status === "active" && "bg-sky-100 text-sky-700",
|
||||
status === "pending" && "bg-amber-100 text-amber-700",
|
||||
status === "skipped" && "bg-slate-100 text-slate-500",
|
||||
);
|
||||
}
|
||||
|
||||
function getBranchStatusText(status: TopicBranchStatus): string {
|
||||
if (status === "in_progress") return "进行中";
|
||||
if (status === "pending") return "待评审";
|
||||
@@ -452,6 +478,20 @@ function GeneralWorkbenchWorkflowPanelComponent({
|
||||
onRevealArtifactInFinder,
|
||||
onOpenArtifactWithDefaultApp,
|
||||
}: GeneralWorkbenchWorkflowPanelProps) {
|
||||
const workflowSnapshot = buildWorkflowStepSnapshot(workflowSteps, 3);
|
||||
const currentWorkflowStep = workflowSnapshot.leadingStep;
|
||||
const remainingSteps = workflowSnapshot.remainingCount;
|
||||
const sortedWorkflowSteps = workflowSnapshot.sortedSteps;
|
||||
const workflowSummaryText = buildWorkflowSummaryText({
|
||||
leadingStep: currentWorkflowStep,
|
||||
remainingCount: remainingSteps,
|
||||
emptyLabel: workflowSteps.length > 0 ? "当前流程已完成" : "等待创建第一条任务",
|
||||
});
|
||||
const workflowProgressLabel = formatWorkflowProgressLabel({
|
||||
completedCount: completedSteps,
|
||||
totalCount: workflowSteps.length,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className={cn(WORKFLOW_SECTION_CLASSNAME, "relative z-10")}>
|
||||
@@ -489,13 +529,58 @@ function GeneralWorkbenchWorkflowPanelComponent({
|
||||
|
||||
<section className={WORKFLOW_SECTION_CLASSNAME}>
|
||||
<div className={WORKFLOW_SECTION_TITLE_CLASSNAME}>
|
||||
<span>编排进度</span>
|
||||
<span>任务进行时</span>
|
||||
<span className={WORKFLOW_SECTION_BADGE_CLASSNAME}>
|
||||
{workflowSteps.length - completedSteps}
|
||||
{remainingSteps}
|
||||
</span>
|
||||
</div>
|
||||
<div className={WORKFLOW_PROGRESS_TEXT_CLASSNAME}>
|
||||
{completedSteps}/{workflowSteps.length} 步已完成
|
||||
<div className={WORKFLOW_STATUS_CARD_CLASSNAME}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full",
|
||||
currentWorkflowStep?.status === "error" &&
|
||||
"bg-rose-100 text-rose-600",
|
||||
currentWorkflowStep?.status === "pending" &&
|
||||
"bg-amber-100 text-amber-700",
|
||||
(!currentWorkflowStep ||
|
||||
currentWorkflowStep.status === "active" ||
|
||||
currentWorkflowStep.status === "completed") &&
|
||||
"bg-sky-100 text-sky-700",
|
||||
)}
|
||||
>
|
||||
{getStepIcon(currentWorkflowStep?.status ?? "active")}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11px] font-semibold text-slate-500">
|
||||
当前任务
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 text-sm font-semibold leading-5 text-slate-900"
|
||||
data-testid="workflow-sidebar-current-step"
|
||||
>
|
||||
{currentWorkflowStep?.title || "当前流程已完成"}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] leading-5 text-slate-500">
|
||||
{workflowSummaryText}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] leading-5 text-slate-400">
|
||||
{workflowProgressLabel}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={getStatusBadgeClassName(
|
||||
currentWorkflowStep?.status ?? "completed",
|
||||
)}
|
||||
>
|
||||
{getWorkflowStatusLabel(currentWorkflowStep?.status ?? "completed")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn(WORKFLOW_PROGRESS_TEXT_CLASSNAME, "mt-3")}>
|
||||
{remainingSteps > 0
|
||||
? `剩余 ${remainingSteps} 项待处理`
|
||||
: "当前流程已全部完成"}
|
||||
</div>
|
||||
<div className={WORKFLOW_PROGRESS_BAR_CLASSNAME}>
|
||||
<div
|
||||
@@ -506,13 +591,20 @@ function GeneralWorkbenchWorkflowPanelComponent({
|
||||
/>
|
||||
</div>
|
||||
<div className={WORKFLOW_STEP_LIST_CLASSNAME}>
|
||||
{workflowSteps.map((step) => (
|
||||
{sortedWorkflowSteps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={getWorkflowStepRowClassName(step.status)}
|
||||
data-testid="workflow-sidebar-step"
|
||||
data-status={step.status}
|
||||
>
|
||||
{getStepIcon(step.status)}
|
||||
<span>{step.title}</span>
|
||||
<span className="mt-0.5">{getStepIcon(step.status)}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="break-words text-sm leading-5">{step.title}</div>
|
||||
</div>
|
||||
<span className={getStatusBadgeClassName(step.status)}>
|
||||
{getWorkflowStatusLabel(step.status)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
exportAgentRuntimeReplayCaseMock,
|
||||
exportAgentRuntimeReviewDecisionTemplateMock,
|
||||
saveAgentRuntimeReviewDecisionMock,
|
||||
prefetchContextMemoryForTurnMock,
|
||||
mockToast,
|
||||
} = vi.hoisted(() => ({
|
||||
exportAgentRuntimeAnalysisHandoffMock: vi.fn(),
|
||||
@@ -20,6 +21,7 @@ const {
|
||||
exportAgentRuntimeReplayCaseMock: vi.fn(),
|
||||
exportAgentRuntimeReviewDecisionTemplateMock: vi.fn(),
|
||||
saveAgentRuntimeReviewDecisionMock: vi.fn(),
|
||||
prefetchContextMemoryForTurnMock: vi.fn(),
|
||||
mockToast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
@@ -48,6 +50,10 @@ vi.mock("sonner", () => ({
|
||||
toast: mockToast,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/memoryRuntime", () => ({
|
||||
prefetchContextMemoryForTurn: prefetchContextMemoryForTurnMock,
|
||||
}));
|
||||
|
||||
interface RenderResult {
|
||||
container: HTMLDivElement;
|
||||
root: Root;
|
||||
@@ -336,6 +342,15 @@ beforeEach(() => {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
prefetchContextMemoryForTurnMock.mockResolvedValue({
|
||||
session_id: "session-default",
|
||||
rules_source_paths: [],
|
||||
working_memory_excerpt: null,
|
||||
durable_memories: [],
|
||||
team_memory_entries: [],
|
||||
latest_compaction: null,
|
||||
prompt: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -448,7 +463,9 @@ describe("HarnessStatusPanel", () => {
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("当前任务");
|
||||
expect(document.body.textContent).toContain("任务进展");
|
||||
expect(document.body.textContent).toContain("任务进行时");
|
||||
expect(document.body.textContent).toContain("任务节点");
|
||||
expect(document.body.textContent).toContain("已记录 2 个任务节点");
|
||||
expect(document.body.textContent).toContain("正在启动处理流程");
|
||||
expect(document.body.textContent).toContain("等待首个模型事件");
|
||||
});
|
||||
@@ -697,6 +714,8 @@ describe("HarnessStatusPanel", () => {
|
||||
".lime/harness/sessions/session-analysis-1/analysis/analysis-context.json",
|
||||
);
|
||||
expect(document.body.textContent).toContain("/workspace/lime");
|
||||
expect(document.body.textContent).not.toContain("Claude Code");
|
||||
expect(document.body.textContent).not.toContain("Claude / Codex");
|
||||
expect(mockToast.success).toHaveBeenCalledWith("已导出 2 个外部分析文件");
|
||||
});
|
||||
|
||||
@@ -1377,7 +1396,7 @@ describe("HarnessStatusPanel", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("协作处理中");
|
||||
expect(document.body.textContent).toContain("任务进行中");
|
||||
expect(document.body.textContent).toContain("协作会话");
|
||||
expect(document.body.textContent).toContain("当前协作会话");
|
||||
expect(document.body.textContent).toContain("实时协作会话");
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { StepStatus } from "@/lib/workspace/workbenchContract";
|
||||
import type {
|
||||
AgentRuntimeAnalysisHandoff,
|
||||
AgentRuntimeEvidencePack,
|
||||
@@ -102,10 +103,15 @@ import {
|
||||
normalizeToolNameKey,
|
||||
resolveToolDisplayLabel,
|
||||
} from "../utils/toolDisplayInfo";
|
||||
import {
|
||||
buildWorkflowSummaryText,
|
||||
getWorkflowStatusLabel,
|
||||
} from "../utils/workflowStepPresentation";
|
||||
import { buildThreadReliabilityView } from "../utils/threadReliabilityView";
|
||||
import { resolveTeamWorkspaceStableProcessingLabel } from "../utils/teamWorkspaceCopy";
|
||||
import type { CompatSubagentRuntimeDisplaySnapshot } from "../utils/compatSubagentRuntime";
|
||||
import type { TeamRoleDefinition } from "../utils/teamDefinitions";
|
||||
import type { TeamMemorySnapshot } from "@/lib/teamMemorySync";
|
||||
import { AgentThreadReliabilityPanel } from "./AgentThreadReliabilityPanel";
|
||||
import { RuntimeReviewDecisionDialog } from "./RuntimeReviewDecisionDialog";
|
||||
|
||||
@@ -161,10 +167,13 @@ interface HarnessStatusPanelProps {
|
||||
onResumeThread?: () => boolean | Promise<boolean>;
|
||||
onReplayPendingRequest?: (requestId: string) => boolean | Promise<boolean>;
|
||||
onPromoteQueuedTurn?: (queuedTurnId: string) => boolean | Promise<boolean>;
|
||||
onOpenMemoryWorkbench?: () => void;
|
||||
messages?: Message[];
|
||||
teamMemorySnapshot?: TeamMemorySnapshot | null;
|
||||
diagnosticRuntimeContext?: {
|
||||
sessionId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
workingDir?: string | null;
|
||||
providerType?: string | null;
|
||||
model?: string | null;
|
||||
executionStrategy?: string | null;
|
||||
@@ -220,6 +229,16 @@ interface HarnessSummaryCard {
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
interface RuntimeTaskPresentation {
|
||||
title: string;
|
||||
summaryText: string;
|
||||
phaseLabel: string;
|
||||
statusLabel: string;
|
||||
progressLabel: string;
|
||||
stepStatus: StepStatus;
|
||||
checkpoints: string[];
|
||||
}
|
||||
|
||||
interface TextSegment {
|
||||
type: "text" | "url";
|
||||
value: string;
|
||||
@@ -821,6 +840,84 @@ function formatRuntimePhaseLabel(
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRuntimeStepStatus(
|
||||
runtimeStatus: NonNullable<HarnessSessionState["runtimeStatus"]>,
|
||||
): StepStatus {
|
||||
if (runtimeStatus.phase === "failed") {
|
||||
return "error";
|
||||
}
|
||||
if (runtimeStatus.phase === "cancelled") {
|
||||
return "skipped";
|
||||
}
|
||||
return "active";
|
||||
}
|
||||
|
||||
function resolveRuntimeStatusLabel(
|
||||
runtimeStatus: NonNullable<HarnessSessionState["runtimeStatus"]>,
|
||||
): string {
|
||||
if (runtimeStatus.phase === "cancelled") {
|
||||
return "已取消";
|
||||
}
|
||||
return getWorkflowStatusLabel(resolveRuntimeStepStatus(runtimeStatus));
|
||||
}
|
||||
|
||||
function buildRuntimeSummaryText(
|
||||
runtimeStatus: NonNullable<HarnessSessionState["runtimeStatus"]>,
|
||||
): string {
|
||||
const detail = runtimeStatus.detail?.trim();
|
||||
if (detail) {
|
||||
return detail;
|
||||
}
|
||||
if (runtimeStatus.phase === "cancelled") {
|
||||
return "当前流程已取消,可重新发起新的任务继续。";
|
||||
}
|
||||
return buildWorkflowSummaryText({
|
||||
leadingStep: {
|
||||
status: resolveRuntimeStepStatus(runtimeStatus),
|
||||
},
|
||||
remainingCount: 1,
|
||||
emptyLabel: "当前流程已完成",
|
||||
});
|
||||
}
|
||||
|
||||
function formatRuntimeProgressLabel(
|
||||
runtimeStatus: NonNullable<HarnessSessionState["runtimeStatus"]>,
|
||||
checkpoints: string[],
|
||||
): string {
|
||||
if (checkpoints.length > 0) {
|
||||
return `已记录 ${checkpoints.length} 个任务节点`;
|
||||
}
|
||||
if (runtimeStatus.phase === "failed") {
|
||||
return "等待处理异常后重试";
|
||||
}
|
||||
if (runtimeStatus.phase === "cancelled") {
|
||||
return "当前流程已取消";
|
||||
}
|
||||
return "等待更多执行进展";
|
||||
}
|
||||
|
||||
function buildRuntimeTaskPresentation(
|
||||
runtimeStatus: HarnessSessionState["runtimeStatus"],
|
||||
): RuntimeTaskPresentation | null {
|
||||
if (!runtimeStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const checkpoints = (runtimeStatus.checkpoints ?? [])
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
|
||||
return {
|
||||
title: runtimeStatus.title?.trim() || "正在整理当前任务",
|
||||
summaryText: buildRuntimeSummaryText(runtimeStatus),
|
||||
phaseLabel: formatRuntimePhaseLabel(runtimeStatus),
|
||||
statusLabel: resolveRuntimeStatusLabel(runtimeStatus),
|
||||
progressLabel: formatRuntimeProgressLabel(runtimeStatus, checkpoints),
|
||||
stepStatus: resolveRuntimeStepStatus(runtimeStatus),
|
||||
checkpoints,
|
||||
};
|
||||
}
|
||||
|
||||
function formatWriteSourceLabel(source?: string): string {
|
||||
switch (source) {
|
||||
case "tool_start":
|
||||
@@ -1631,7 +1728,9 @@ export function HarnessStatusPanel({
|
||||
onResumeThread,
|
||||
onReplayPendingRequest,
|
||||
onPromoteQueuedTurn,
|
||||
onOpenMemoryWorkbench,
|
||||
messages = [],
|
||||
teamMemorySnapshot = null,
|
||||
diagnosticRuntimeContext = null,
|
||||
}: HarnessStatusPanelProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
@@ -1939,6 +2038,10 @@ export function HarnessStatusPanel({
|
||||
Boolean(toolInventoryError) ||
|
||||
Boolean(toolInventory);
|
||||
const hasHandoffSection = Boolean(currentSessionId);
|
||||
const runtimeTaskPresentation = useMemo(
|
||||
() => buildRuntimeTaskPresentation(harnessState.runtimeStatus),
|
||||
[harnessState.runtimeStatus],
|
||||
);
|
||||
const toolInventorySourceStats = useMemo(
|
||||
() => buildToolInventorySourceStats(toolInventory?.catalog_tools || []),
|
||||
[toolInventory],
|
||||
@@ -2129,8 +2232,8 @@ export function HarnessStatusPanel({
|
||||
sections.push({ key: "team_config", label: "协作设置" });
|
||||
}
|
||||
|
||||
if (harnessState.runtimeStatus) {
|
||||
sections.push({ key: "runtime", label: "任务进展" });
|
||||
if (runtimeTaskPresentation) {
|
||||
sections.push({ key: "runtime", label: "任务进行时" });
|
||||
}
|
||||
if (hasHandoffSection) {
|
||||
sections.push({ key: "handoff", label: "交接制品" });
|
||||
@@ -2186,24 +2289,23 @@ export function HarnessStatusPanel({
|
||||
harnessState.plan.items.length,
|
||||
harnessState.plan.phase,
|
||||
harnessState.recentFileEvents.length,
|
||||
harnessState.runtimeStatus,
|
||||
hasHandoffSection,
|
||||
hasSelectedTeamConfig,
|
||||
hasCompatSchedulerSignals,
|
||||
realTeamSummary.total,
|
||||
runtimeTaskPresentation,
|
||||
threadReliabilityView.shouldRender,
|
||||
]);
|
||||
|
||||
const summaryCards = useMemo(() => {
|
||||
const cards: HarnessSummaryCard[] = [];
|
||||
|
||||
if (harnessState.runtimeStatus) {
|
||||
if (runtimeTaskPresentation) {
|
||||
cards.push({
|
||||
sectionKey: "runtime",
|
||||
title: "当前任务",
|
||||
value: formatRuntimePhaseLabel(harnessState.runtimeStatus),
|
||||
hint:
|
||||
harnessState.runtimeStatus.detail || harnessState.runtimeStatus.title,
|
||||
value: runtimeTaskPresentation.title,
|
||||
hint: `${runtimeTaskPresentation.statusLabel} · ${runtimeTaskPresentation.progressLabel}`,
|
||||
icon: Loader2,
|
||||
});
|
||||
}
|
||||
@@ -2353,13 +2455,13 @@ export function HarnessStatusPanel({
|
||||
harnessState.plan.phase,
|
||||
harnessState.plan.summaryText,
|
||||
harnessState.recentFileEvents,
|
||||
harnessState.runtimeStatus,
|
||||
realTeamSummary.active,
|
||||
realTeamSummary.failed,
|
||||
realTeamSummary.queued,
|
||||
realTeamSummary.running,
|
||||
realTeamSummary.settled,
|
||||
realTeamSummary.total,
|
||||
runtimeTaskPresentation,
|
||||
selectedTeamLabel,
|
||||
selectedTeamRoles?.length,
|
||||
selectedTeamSummary,
|
||||
@@ -2592,7 +2694,7 @@ export function HarnessStatusPanel({
|
||||
{realTeamSummary.active > 0 ? (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
协作处理中
|
||||
任务进行中
|
||||
</Badge>
|
||||
) : compatSubagentRuntime.isRunning ? (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
@@ -2778,42 +2880,167 @@ export function HarnessStatusPanel({
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
{harnessState.runtimeStatus ? (
|
||||
{runtimeTaskPresentation ? (
|
||||
<Section
|
||||
sectionKey="runtime"
|
||||
title="当前任务进展"
|
||||
badge={formatRuntimePhaseLabel(harnessState.runtimeStatus)}
|
||||
title="任务进行时"
|
||||
badge={
|
||||
runtimeTaskPresentation.checkpoints.length > 0
|
||||
? `${runtimeTaskPresentation.checkpoints.length} 个节点`
|
||||
: runtimeTaskPresentation.phaseLabel
|
||||
}
|
||||
registerRef={registerSectionRef}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-primary/20 bg-primary/5 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<span>{harnessState.runtimeStatus.title}</span>
|
||||
<div className="rounded-xl border border-border bg-background p-4 shadow-sm shadow-slate-950/5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 inline-flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full",
|
||||
runtimeTaskPresentation.stepStatus === "error" &&
|
||||
"bg-destructive/10 text-destructive",
|
||||
runtimeTaskPresentation.stepStatus === "skipped" &&
|
||||
"bg-muted text-muted-foreground",
|
||||
runtimeTaskPresentation.stepStatus === "active" &&
|
||||
"bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{runtimeTaskPresentation.stepStatus === "error" ? (
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
) : runtimeTaskPresentation.stepStatus === "skipped" ? (
|
||||
<Clock3 className="h-4 w-4" />
|
||||
) : (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-semibold text-muted-foreground">
|
||||
当前任务
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold leading-6 text-foreground">
|
||||
{runtimeTaskPresentation.title}
|
||||
</div>
|
||||
<InteractiveText
|
||||
text={runtimeTaskPresentation.summaryText}
|
||||
className="mt-2 text-sm leading-6 text-muted-foreground"
|
||||
onOpenUrl={handleOpenExternalLink}
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
runtimeTaskPresentation.stepStatus === "error"
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{runtimeTaskPresentation.statusLabel}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{runtimeTaskPresentation.phaseLabel}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{runtimeTaskPresentation.progressLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InteractiveText
|
||||
text={harnessState.runtimeStatus.detail}
|
||||
className="mt-2 text-sm text-muted-foreground"
|
||||
onOpenUrl={handleOpenExternalLink}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{harnessState.runtimeStatus.checkpoints &&
|
||||
harnessState.runtimeStatus.checkpoints.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{harnessState.runtimeStatus.checkpoints.map(
|
||||
(checkpoint, index) => (
|
||||
<ActionableBadge
|
||||
key={`${checkpoint}-${index}`}
|
||||
variant="outline"
|
||||
value={checkpoint}
|
||||
onOpenUrl={handleOpenExternalLink}
|
||||
onOpenPath={handleOpenPathValue}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{runtimeTaskPresentation.checkpoints.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-semibold text-muted-foreground">
|
||||
任务节点
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{runtimeTaskPresentation.progressLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{runtimeTaskPresentation.checkpoints.map(
|
||||
(checkpoint, index) => {
|
||||
const isCurrentCheckpoint =
|
||||
index ===
|
||||
runtimeTaskPresentation.checkpoints.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={`${checkpoint}-${index}`}
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-xl border px-3 py-2.5",
|
||||
isCurrentCheckpoint &&
|
||||
runtimeTaskPresentation.stepStatus ===
|
||||
"error" &&
|
||||
"border-destructive/30 bg-destructive/5",
|
||||
isCurrentCheckpoint &&
|
||||
runtimeTaskPresentation.stepStatus ===
|
||||
"active" &&
|
||||
"border-primary/20 bg-primary/5",
|
||||
isCurrentCheckpoint &&
|
||||
runtimeTaskPresentation.stepStatus ===
|
||||
"skipped" &&
|
||||
"border-border bg-muted/30",
|
||||
!isCurrentCheckpoint &&
|
||||
"border-border bg-muted/20",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-[11px] font-semibold",
|
||||
isCurrentCheckpoint &&
|
||||
runtimeTaskPresentation.stepStatus ===
|
||||
"error" &&
|
||||
"bg-destructive/10 text-destructive",
|
||||
isCurrentCheckpoint &&
|
||||
runtimeTaskPresentation.stepStatus ===
|
||||
"active" &&
|
||||
"bg-primary/10 text-primary",
|
||||
isCurrentCheckpoint &&
|
||||
runtimeTaskPresentation.stepStatus ===
|
||||
"skipped" &&
|
||||
"bg-background text-muted-foreground",
|
||||
!isCurrentCheckpoint &&
|
||||
"bg-background text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isCurrentCheckpoint ? (
|
||||
runtimeTaskPresentation.stepStatus ===
|
||||
"error" ? (
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
) : runtimeTaskPresentation.stepStatus ===
|
||||
"skipped" ? (
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
)
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<InteractiveText
|
||||
text={checkpoint}
|
||||
className="text-sm leading-6 text-foreground"
|
||||
onOpenUrl={handleOpenExternalLink}
|
||||
/>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
isCurrentCheckpoint ? "secondary" : "outline"
|
||||
}
|
||||
>
|
||||
{isCurrentCheckpoint ? "当前" : "已记录"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border bg-muted/20 px-3 py-3 text-sm text-muted-foreground">
|
||||
{runtimeTaskPresentation.progressLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
@@ -3538,8 +3765,7 @@ export function HarnessStatusPanel({
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
把 handoff / evidence / replay 主链重新包装成外部
|
||||
Claude / Codex
|
||||
可直接消费的分析交接;复制后可直接粘贴给 AI,
|
||||
AI 可直接消费的分析交接;复制后可直接粘贴给 AI,
|
||||
不需要你再手写补充 prompt。
|
||||
</div>
|
||||
</div>
|
||||
@@ -3788,7 +4014,7 @@ export function HarnessStatusPanel({
|
||||
<div className="mt-3 rounded-lg border border-dashed border-border px-3 py-3 text-sm text-muted-foreground">
|
||||
尚未导出外部分析交接。点击“一键复制给
|
||||
AI”时会自动先导出再复制, 用于把当前 Lime
|
||||
证据链直接交给外部 Claude / Codex 做诊断与最小修复。
|
||||
证据链直接交给外部 AI 做诊断与最小修复。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3801,10 +4027,8 @@ export function HarnessStatusPanel({
|
||||
<span>人工审核记录</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
把外部 Claude / Codex 的分析结论回挂为
|
||||
`review-decision.md/json`
|
||||
模板,固定接受、延后、拒绝与回归要求;最终决策仍由开发者审核,不是
|
||||
Lime 自动闭环。
|
||||
把外部 AI 的分析结论回挂为
|
||||
`review-decision.md/json` 模板,固定接受、延后、拒绝与回归要求;最终决策仍由开发者审核,不是 Lime 自动闭环。
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -4304,8 +4528,10 @@ export function HarnessStatusPanel({
|
||||
onResumeThread={onResumeThread}
|
||||
onReplayPendingRequest={onReplayPendingRequest}
|
||||
onPromoteQueuedTurn={onPromoteQueuedTurn}
|
||||
onOpenMemoryWorkbench={onOpenMemoryWorkbench}
|
||||
harnessState={harnessState}
|
||||
messages={messages}
|
||||
teamMemorySnapshot={teamMemorySnapshot}
|
||||
diagnosticRuntimeContext={diagnosticRuntimeContext}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
@@ -44,6 +44,12 @@ interface InputbarComposerSectionProps {
|
||||
workflowGate?: WorkflowGateState | null;
|
||||
workflowQuickActions: WorkflowQuickAction[];
|
||||
workflowQueueItems: WorkflowStep[];
|
||||
workflowActiveItem: WorkflowStep | null;
|
||||
workflowQueueTotalCount: number;
|
||||
workflowCompletedCount: number;
|
||||
workflowTotalCount: number;
|
||||
workflowProgressLabel: string;
|
||||
workflowSummaryLabel: string;
|
||||
inputAdapter: ChatInputAdapter;
|
||||
characters: Character[];
|
||||
skillSelection: SkillSelectionProps;
|
||||
@@ -86,6 +92,12 @@ export const InputbarComposerSection: React.FC<
|
||||
workflowGate,
|
||||
workflowQuickActions,
|
||||
workflowQueueItems,
|
||||
workflowActiveItem,
|
||||
workflowQueueTotalCount,
|
||||
workflowCompletedCount,
|
||||
workflowTotalCount,
|
||||
workflowProgressLabel,
|
||||
workflowSummaryLabel,
|
||||
inputAdapter,
|
||||
characters,
|
||||
skillSelection,
|
||||
@@ -283,6 +295,12 @@ export const InputbarComposerSection: React.FC<
|
||||
gate={workflowGate}
|
||||
quickActions={workflowQuickActions}
|
||||
queueItems={workflowQueueItems}
|
||||
activeItem={workflowActiveItem}
|
||||
queueTotalCount={workflowQueueTotalCount}
|
||||
completedCount={workflowCompletedCount}
|
||||
totalCount={workflowTotalCount}
|
||||
progressLabel={workflowProgressLabel}
|
||||
summaryLabel={workflowSummaryLabel}
|
||||
renderGeneratingPanel
|
||||
onQuickAction={inputAdapter.actions.setText}
|
||||
onStop={inputAdapter.actions.stop}
|
||||
@@ -296,6 +314,12 @@ export const InputbarComposerSection: React.FC<
|
||||
gate={workflowGate}
|
||||
quickActions={workflowQuickActions}
|
||||
queueItems={workflowQueueItems}
|
||||
activeItem={workflowActiveItem}
|
||||
queueTotalCount={workflowQueueTotalCount}
|
||||
completedCount={workflowCompletedCount}
|
||||
totalCount={workflowTotalCount}
|
||||
progressLabel={workflowProgressLabel}
|
||||
summaryLabel={workflowSummaryLabel}
|
||||
renderGeneratingPanel={false}
|
||||
onQuickAction={inputAdapter.actions.setText}
|
||||
onStop={inputAdapter.actions.stop}
|
||||
|
||||
+350
-305
@@ -1,10 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
Clock3,
|
||||
AlertCircle,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
Clock3,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import styled from "styled-components";
|
||||
import type {
|
||||
@@ -17,141 +16,161 @@ interface InputbarWorkflowStatusPanelProps {
|
||||
gate?: WorkflowGateState | null;
|
||||
quickActions: WorkflowQuickAction[];
|
||||
queueItems: WorkflowStep[];
|
||||
activeItem: WorkflowStep | null;
|
||||
queueTotalCount: number;
|
||||
completedCount: number;
|
||||
totalCount: number;
|
||||
progressLabel: string;
|
||||
summaryLabel: string;
|
||||
renderGeneratingPanel: boolean;
|
||||
onQuickAction: (prompt: string) => void;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
const GateStrip = styled.div`
|
||||
margin: 0 12px 8px;
|
||||
type WorkflowTone = "active" | "pending" | "error";
|
||||
|
||||
const PanelWrap = styled.div`
|
||||
margin: 0 10px 10px;
|
||||
`;
|
||||
|
||||
const StatusCard = styled.div`
|
||||
border: 1px solid hsl(var(--border) / 0.84);
|
||||
border-radius: 18px;
|
||||
background: hsl(var(--background));
|
||||
box-shadow: 0 10px 24px hsl(var(--foreground) / 0.05);
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const SummaryRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid hsl(var(--border) / 0.92);
|
||||
background: hsl(var(--muted) / 0.78);
|
||||
box-shadow: none;
|
||||
opacity: 1;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: hsl(222 18% 14% / 0.96);
|
||||
border-color: hsl(217 18% 24% / 0.95);
|
||||
}
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
`;
|
||||
|
||||
const GateMeta = styled.div`
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const GateIcon = styled.span`
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
const SummaryIcon = styled.span<{ $tone: WorkflowTone }>`
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(var(--background));
|
||||
flex-shrink: 0;
|
||||
background: ${({ $tone }) =>
|
||||
$tone === "error"
|
||||
? "hsl(var(--destructive) / 0.1)"
|
||||
: $tone === "pending"
|
||||
? "hsl(38 100% 92%)"
|
||||
: "hsl(var(--primary) / 0.12)"};
|
||||
color: ${({ $tone }) =>
|
||||
$tone === "error"
|
||||
? "hsl(var(--destructive))"
|
||||
: $tone === "pending"
|
||||
? "hsl(30 90% 42%)"
|
||||
: "hsl(var(--primary))"};
|
||||
`;
|
||||
|
||||
const SummaryBody = styled.div`
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const SummaryEyebrow = styled.div`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border: 1px solid hsl(var(--border) / 0.9);
|
||||
`;
|
||||
|
||||
const SummaryTitle = styled.div`
|
||||
margin-top: 2px;
|
||||
font-size: 15px;
|
||||
line-height: 1.35;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
const SummaryDescription = styled.div`
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: hsl(var(--muted-foreground));
|
||||
`;
|
||||
|
||||
const SummaryAside = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const GateTitle = styled.span`
|
||||
font-size: 12px;
|
||||
color: hsl(var(--foreground) / 0.86);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
`;
|
||||
|
||||
const GateStatus = styled.span<{
|
||||
$status: "running" | "waiting" | "idle";
|
||||
}>`
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
color: ${({ $status }) =>
|
||||
$status === "waiting"
|
||||
? "hsl(var(--destructive))"
|
||||
: $status === "running"
|
||||
? "hsl(var(--primary))"
|
||||
: "hsl(var(--muted-foreground))"};
|
||||
background: ${({ $status }) =>
|
||||
$status === "waiting"
|
||||
? "hsl(var(--destructive) / 0.08)"
|
||||
: $status === "running"
|
||||
? "hsl(var(--primary) / 0.1)"
|
||||
: "hsl(var(--muted) / 0.7)"};
|
||||
`;
|
||||
|
||||
const QuickActions = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
`;
|
||||
|
||||
const QuickButton = styled.button`
|
||||
border: 1px solid hsl(var(--border) / 0.88);
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground) / 0.82);
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: hsl(var(--primary) / 0.22);
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
`;
|
||||
|
||||
const GeneratingWrap = styled.div`
|
||||
margin: 0 10px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const TaskCard = styled.div`
|
||||
border: 1px solid hsl(var(--border) / 0.78);
|
||||
border-radius: 15px;
|
||||
background: hsl(var(--background));
|
||||
box-shadow: 0 8px 20px hsl(var(--foreground) / 0.05);
|
||||
padding: 11px 12px 10px;
|
||||
`;
|
||||
|
||||
const TaskHead = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const TaskHeadButton = styled.button`
|
||||
const SummaryStatus = styled.span<{ $tone: WorkflowTone }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
color: ${({ $tone }) =>
|
||||
$tone === "error"
|
||||
? "hsl(var(--destructive))"
|
||||
: $tone === "pending"
|
||||
? "hsl(35 95% 35%)"
|
||||
: "hsl(var(--primary))"};
|
||||
background: ${({ $tone }) =>
|
||||
$tone === "error"
|
||||
? "hsl(var(--destructive) / 0.12)"
|
||||
: $tone === "pending"
|
||||
? "hsl(36 100% 90%)"
|
||||
: "hsl(var(--primary) / 0.14)"};
|
||||
`;
|
||||
|
||||
const TaskHeadChevron = styled.span<{ $collapsed: boolean }>`
|
||||
const QueueSection = styled.div`
|
||||
border-top: 1px solid hsl(var(--border) / 0.66);
|
||||
padding: 0 12px 12px;
|
||||
`;
|
||||
|
||||
const QueueToggleButton = styled.button`
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 10px 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--muted-foreground));
|
||||
`;
|
||||
|
||||
const QueueToggleLabel = styled.span`
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground) / 0.88);
|
||||
`;
|
||||
|
||||
const QueueCount = styled.span`
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 6px;
|
||||
background: hsl(var(--muted) / 0.75);
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const QueueHint = styled.span`
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
`;
|
||||
|
||||
const QueueChevron = styled.span<{ $collapsed: boolean }>`
|
||||
display: inline-flex;
|
||||
transition: transform 0.2s ease;
|
||||
transform: ${({ $collapsed }) =>
|
||||
@@ -161,106 +180,87 @@ const TaskHeadChevron = styled.span<{ $collapsed: boolean }>`
|
||||
const TaskList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 7px;
|
||||
margin-top: 10px;
|
||||
`;
|
||||
|
||||
const TaskRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-height: 34px;
|
||||
min-width: 0;
|
||||
border-radius: 13px;
|
||||
border: 1px solid hsl(var(--border) / 0.74);
|
||||
background: hsl(var(--muted) / 0.2);
|
||||
padding: 9px 10px;
|
||||
`;
|
||||
|
||||
const TaskIcon = styled.span<{ $kind: "active" | "pending" | "error" }>`
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
const TaskIcon = styled.span<{ $tone: WorkflowTone }>`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: ${({ $kind }) =>
|
||||
$kind === "active"
|
||||
? "hsl(var(--primary) / 0.12)"
|
||||
: $kind === "error"
|
||||
? "hsl(var(--destructive) / 0.1)"
|
||||
: "hsl(38 100% 92%)"};
|
||||
color: ${({ $kind }) =>
|
||||
$kind === "active"
|
||||
? "hsl(var(--primary))"
|
||||
: $kind === "error"
|
||||
? "hsl(var(--destructive))"
|
||||
: "hsl(30 90% 42%)"};
|
||||
flex-shrink: 0;
|
||||
background: ${({ $tone }) =>
|
||||
$tone === "error"
|
||||
? "hsl(var(--destructive) / 0.1)"
|
||||
: $tone === "pending"
|
||||
? "hsl(38 100% 92%)"
|
||||
: "hsl(var(--primary) / 0.12)"};
|
||||
color: ${({ $tone }) =>
|
||||
$tone === "error"
|
||||
? "hsl(var(--destructive))"
|
||||
: $tone === "pending"
|
||||
? "hsl(30 90% 42%)"
|
||||
: "hsl(var(--primary))"};
|
||||
`;
|
||||
|
||||
const TaskText = styled.span`
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: hsl(var(--foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const TaskStatus = styled.span<{ $kind: "active" | "pending" | "error" }>`
|
||||
font-size: 11px;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
color: ${(props) =>
|
||||
props.$kind === "active"
|
||||
? "hsl(var(--primary))"
|
||||
: props.$kind === "error"
|
||||
? "hsl(var(--destructive))"
|
||||
: "hsl(35 95% 35%)"};
|
||||
background: ${(props) =>
|
||||
props.$kind === "active"
|
||||
? "hsl(var(--primary) / 0.14)"
|
||||
: props.$kind === "error"
|
||||
? "hsl(var(--destructive) / 0.12)"
|
||||
: "hsl(36 100% 90%)"};
|
||||
`;
|
||||
|
||||
const RunningBar = styled.div`
|
||||
min-height: 44px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 11px;
|
||||
background: hsl(var(--background));
|
||||
box-shadow: 0 4px 14px hsl(var(--foreground) / 0.04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 10px;
|
||||
`;
|
||||
|
||||
const RunningIcon = styled.span`
|
||||
color: hsl(var(--primary));
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const RunningSub = styled.span`
|
||||
flex: 1;
|
||||
const TaskBody = styled.div`
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const RunningMain = styled.span`
|
||||
color: hsl(var(--primary));
|
||||
font-weight: 600;
|
||||
margin-right: 2px;
|
||||
font-size: 14px;
|
||||
const TaskTitle = styled.div`
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: hsl(var(--foreground));
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
const TaskMeta = styled.div`
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
`;
|
||||
|
||||
const QuickActions = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 0 12px 12px;
|
||||
`;
|
||||
|
||||
const QuickButton = styled.button`
|
||||
border: 1px solid hsl(var(--border) / 0.88);
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground) / 0.82);
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
padding: 6px 11px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: hsl(var(--primary) / 0.22);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
`;
|
||||
|
||||
const StopButton = styled.button`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.28);
|
||||
@@ -269,7 +269,6 @@ const StopButton = styled.button`
|
||||
justify-content: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
color: hsl(var(--destructive));
|
||||
@@ -296,127 +295,173 @@ const StopGlyph = styled.span`
|
||||
}
|
||||
`;
|
||||
|
||||
function resolveWorkflowTone(
|
||||
status: WorkflowStep["status"] | WorkflowGateState["status"] | undefined,
|
||||
): WorkflowTone {
|
||||
if (status === "error") {
|
||||
return "error";
|
||||
}
|
||||
if (status === "pending" || status === "waiting") {
|
||||
return "pending";
|
||||
}
|
||||
return "active";
|
||||
}
|
||||
|
||||
function getStatusLabel(tone: WorkflowTone): string {
|
||||
if (tone === "error") {
|
||||
return "异常";
|
||||
}
|
||||
if (tone === "pending") {
|
||||
return "待处理";
|
||||
}
|
||||
return "进行中";
|
||||
}
|
||||
|
||||
function renderToneIcon(tone: WorkflowTone) {
|
||||
if (tone === "error") {
|
||||
return <AlertCircle size={14} />;
|
||||
}
|
||||
if (tone === "pending") {
|
||||
return <Clock3 size={14} />;
|
||||
}
|
||||
return <Loader2 size={14} className="animate-spin" />;
|
||||
}
|
||||
|
||||
export function InputbarWorkflowStatusPanel({
|
||||
gate,
|
||||
quickActions,
|
||||
queueItems,
|
||||
activeItem,
|
||||
queueTotalCount,
|
||||
completedCount,
|
||||
totalCount,
|
||||
progressLabel,
|
||||
summaryLabel,
|
||||
renderGeneratingPanel,
|
||||
onQuickAction,
|
||||
onStop,
|
||||
}: InputbarWorkflowStatusPanelProps) {
|
||||
const [queueCollapsed, setQueueCollapsed] = useState(false);
|
||||
|
||||
if (renderGeneratingPanel) {
|
||||
return (
|
||||
<GeneratingWrap>
|
||||
<TaskCard>
|
||||
<TaskHead>
|
||||
<TaskHeadButton
|
||||
type="button"
|
||||
onClick={() => setQueueCollapsed((prev) => !prev)}
|
||||
aria-label={queueCollapsed ? "展开待办列表" : "折叠待办列表"}
|
||||
>
|
||||
<span>当前待办</span>
|
||||
<TaskHeadChevron $collapsed={queueCollapsed}>
|
||||
<ChevronDown size={14} />
|
||||
</TaskHeadChevron>
|
||||
</TaskHeadButton>
|
||||
</TaskHead>
|
||||
{!queueCollapsed ? (
|
||||
<TaskList>
|
||||
{queueItems.length === 0 ? (
|
||||
<TaskRow>
|
||||
<TaskIcon $kind="active">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
</TaskIcon>
|
||||
<TaskText>正在编排任务节点...</TaskText>
|
||||
<TaskStatus $kind="active">进行中</TaskStatus>
|
||||
</TaskRow>
|
||||
) : (
|
||||
queueItems.map((item) => {
|
||||
const statusKind =
|
||||
item.status === "active"
|
||||
? "active"
|
||||
: item.status === "error"
|
||||
? "error"
|
||||
: "pending";
|
||||
return (
|
||||
<TaskRow key={item.id}>
|
||||
<TaskIcon $kind={statusKind}>
|
||||
{statusKind === "active" ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : statusKind === "error" ? (
|
||||
<AlertCircle size={14} />
|
||||
) : (
|
||||
<Clock3 size={14} />
|
||||
)}
|
||||
</TaskIcon>
|
||||
<TaskText>{item.title}</TaskText>
|
||||
<TaskStatus $kind={statusKind}>
|
||||
{statusKind === "active"
|
||||
? "进行中"
|
||||
: statusKind === "error"
|
||||
? "异常"
|
||||
: "待处理"}
|
||||
</TaskStatus>
|
||||
</TaskRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TaskList>
|
||||
) : null}
|
||||
</TaskCard>
|
||||
<RunningBar>
|
||||
<RunningIcon>
|
||||
<Sparkles size={13} />
|
||||
</RunningIcon>
|
||||
<RunningMain>正在生成中 • • •</RunningMain>
|
||||
<RunningSub>切换项目或关闭网页将中断任务</RunningSub>
|
||||
<StopButton
|
||||
type="button"
|
||||
data-testid="workflow-stop"
|
||||
onClick={() => onStop?.()}
|
||||
aria-label="停止生成"
|
||||
>
|
||||
<StopGlyph />
|
||||
</StopButton>
|
||||
</RunningBar>
|
||||
</GeneratingWrap>
|
||||
);
|
||||
}
|
||||
|
||||
if (!gate || gate.status === "idle") {
|
||||
if (!renderGeneratingPanel && (!gate || gate.status === "idle")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summaryTone = renderGeneratingPanel
|
||||
? resolveWorkflowTone(activeItem?.status)
|
||||
: resolveWorkflowTone(gate?.status);
|
||||
const summaryTitle = renderGeneratingPanel
|
||||
? activeItem?.title || "正在编排任务节点"
|
||||
: gate?.title || "等待继续";
|
||||
const queueHiddenCount = Math.max(queueTotalCount - queueItems.length, 0);
|
||||
const queueHintLabel =
|
||||
queueHiddenCount > 0
|
||||
? `${progressLabel} · 另有 ${queueHiddenCount} 项收纳`
|
||||
: progressLabel;
|
||||
|
||||
return (
|
||||
<GateStrip>
|
||||
<GateMeta>
|
||||
<GateIcon>
|
||||
<Sparkles size={13} />
|
||||
</GateIcon>
|
||||
<GateTitle>{gate.title}</GateTitle>
|
||||
<GateStatus $status={gate.status}>
|
||||
{gate.status === "waiting"
|
||||
? "等待决策"
|
||||
: gate.status === "running"
|
||||
? "自动执行中"
|
||||
: "待启动"}
|
||||
</GateStatus>
|
||||
</GateMeta>
|
||||
{quickActions.length > 0 ? (
|
||||
<QuickActions>
|
||||
{quickActions.map((action) => (
|
||||
<QuickButton
|
||||
key={action.id}
|
||||
<PanelWrap>
|
||||
<StatusCard data-testid="workflow-status-card">
|
||||
<SummaryRow>
|
||||
<SummaryIcon $tone={summaryTone}>{renderToneIcon(summaryTone)}</SummaryIcon>
|
||||
<SummaryBody>
|
||||
<SummaryEyebrow>
|
||||
任务进行时
|
||||
</SummaryEyebrow>
|
||||
<SummaryTitle data-testid="workflow-current-title">
|
||||
{summaryTitle}
|
||||
</SummaryTitle>
|
||||
<SummaryDescription>{summaryLabel}</SummaryDescription>
|
||||
</SummaryBody>
|
||||
<SummaryAside>
|
||||
<SummaryStatus $tone={summaryTone}>
|
||||
{renderGeneratingPanel
|
||||
? getStatusLabel(summaryTone)
|
||||
: gate?.status === "waiting"
|
||||
? "等待决策"
|
||||
: gate?.status === "running"
|
||||
? "自动执行中"
|
||||
: "待启动"}
|
||||
</SummaryStatus>
|
||||
{renderGeneratingPanel ? (
|
||||
<StopButton
|
||||
type="button"
|
||||
data-testid="workflow-stop"
|
||||
onClick={() => onStop?.()}
|
||||
aria-label="停止生成"
|
||||
>
|
||||
<StopGlyph />
|
||||
</StopButton>
|
||||
) : null}
|
||||
</SummaryAside>
|
||||
</SummaryRow>
|
||||
|
||||
{renderGeneratingPanel && queueTotalCount > 0 ? (
|
||||
<QueueSection>
|
||||
<QueueToggleButton
|
||||
type="button"
|
||||
onClick={() => onQuickAction(action.prompt)}
|
||||
onClick={() => setQueueCollapsed((previous) => !previous)}
|
||||
aria-label={queueCollapsed ? "展开任务队列" : "折叠任务队列"}
|
||||
>
|
||||
{action.label}
|
||||
</QuickButton>
|
||||
))}
|
||||
</QuickActions>
|
||||
) : null}
|
||||
</GateStrip>
|
||||
<QueueToggleLabel>任务队列</QueueToggleLabel>
|
||||
<QueueCount>{queueTotalCount}</QueueCount>
|
||||
<QueueHint>{queueHintLabel}</QueueHint>
|
||||
<QueueChevron $collapsed={queueCollapsed}>
|
||||
<ChevronDown size={14} />
|
||||
</QueueChevron>
|
||||
</QueueToggleButton>
|
||||
{!queueCollapsed ? (
|
||||
<TaskList>
|
||||
{queueItems.length === 0 ? (
|
||||
<TaskRow>
|
||||
<TaskIcon $tone="active">
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
</TaskIcon>
|
||||
<TaskBody>
|
||||
<TaskTitle>正在编排任务节点...</TaskTitle>
|
||||
<TaskMeta>进行中</TaskMeta>
|
||||
</TaskBody>
|
||||
</TaskRow>
|
||||
) : (
|
||||
queueItems.map((item) => {
|
||||
const tone = resolveWorkflowTone(item.status);
|
||||
return (
|
||||
<TaskRow
|
||||
key={item.id}
|
||||
data-testid="workflow-queue-item"
|
||||
data-status={item.status}
|
||||
>
|
||||
<TaskIcon $tone={tone}>{renderToneIcon(tone)}</TaskIcon>
|
||||
<TaskBody>
|
||||
<TaskTitle>{item.title}</TaskTitle>
|
||||
<TaskMeta>
|
||||
{getStatusLabel(tone)}
|
||||
{totalCount > 0 ? ` · ${completedCount}/${totalCount}` : ""}
|
||||
</TaskMeta>
|
||||
</TaskBody>
|
||||
</TaskRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TaskList>
|
||||
) : null}
|
||||
</QueueSection>
|
||||
) : null}
|
||||
|
||||
{!renderGeneratingPanel && quickActions.length > 0 ? (
|
||||
<QuickActions>
|
||||
{quickActions.map((action) => (
|
||||
<QuickButton
|
||||
key={action.id}
|
||||
type="button"
|
||||
onClick={() => onQuickAction(action.prompt)}
|
||||
>
|
||||
{action.label}
|
||||
</QuickButton>
|
||||
))}
|
||||
</QuickActions>
|
||||
) : null}
|
||||
</StatusCard>
|
||||
</PanelWrap>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ export const TeamSelector: React.FC<TeamSelectorProps> = ({
|
||||
}, [autoOpenToken]);
|
||||
|
||||
const resolvedLabel = selectedTeam?.label?.trim()
|
||||
? `Team · ${selectedTeam.label.trim()}`
|
||||
: "配置 Team";
|
||||
? `分工 · ${selectedTeam.label.trim()}`
|
||||
: "配置分工";
|
||||
|
||||
const selectedRoleCount = selectedTeam?.roles.length || 0;
|
||||
|
||||
|
||||
@@ -95,15 +95,15 @@ describe("TeamSelectorPanel", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("不应再展示模型生成 Team 入口", async () => {
|
||||
it("不应再展示模型生成分工入口", async () => {
|
||||
const { container } = renderPanel();
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(container.textContent).not.toContain("模型生成 Team");
|
||||
expect(container.textContent).not.toContain("模型生成分工");
|
||||
});
|
||||
|
||||
it("应保存自定义 Team 的 profileId、roleKey 与 skillIds", async () => {
|
||||
it("应保存自定义分工的 profileId、roleKey 与 skillIds", async () => {
|
||||
const onSelectTeam = vi.fn();
|
||||
const selectedTeam = createTeamDefinitionFromPreset(
|
||||
"code-triage-team",
|
||||
@@ -116,7 +116,7 @@ describe("TeamSelectorPanel", () => {
|
||||
await flushEffects();
|
||||
|
||||
const createButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("新建自定义 Team"),
|
||||
(button) => button.textContent?.includes("新建自定义分工"),
|
||||
);
|
||||
|
||||
expect(createButton).toBeTruthy();
|
||||
@@ -147,7 +147,7 @@ describe("TeamSelectorPanel", () => {
|
||||
);
|
||||
|
||||
const saveButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("保存 Team"),
|
||||
(button) => button.textContent?.includes("保存分工"),
|
||||
);
|
||||
|
||||
expect(saveButton).toBeTruthy();
|
||||
@@ -173,7 +173,7 @@ describe("TeamSelectorPanel", () => {
|
||||
expect(mockToast.success).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("项目级自定义 Team 应通过回调持久化", async () => {
|
||||
it("项目级自定义分工应通过回调持久化", async () => {
|
||||
const onPersistCustomTeams = vi.fn().mockResolvedValue(undefined);
|
||||
const workspaceSettings: WorkspaceSettings = {
|
||||
agentTeam: {
|
||||
@@ -201,10 +201,10 @@ describe("TeamSelectorPanel", () => {
|
||||
await flushEffects();
|
||||
|
||||
expect(container.textContent).toContain("项目联调 Team");
|
||||
expect(container.textContent).toContain("当前项目 Team");
|
||||
expect(container.textContent).toContain("当前项目分工");
|
||||
|
||||
const deleteButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("删除这个 Team"),
|
||||
(button) => button.textContent?.includes("删除这套分工"),
|
||||
);
|
||||
|
||||
expect(deleteButton).toBeTruthy();
|
||||
@@ -219,7 +219,7 @@ describe("TeamSelectorPanel", () => {
|
||||
expect(mockToast.success).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("命中稳妥模式模型时不应再展示 Team 横幅提示", async () => {
|
||||
it("命中稳妥模式模型时不应再展示任务分工横幅提示", async () => {
|
||||
const { container } = renderPanel();
|
||||
|
||||
await flushEffects();
|
||||
|
||||
@@ -258,8 +258,8 @@ function TeamCard({
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
|
||||
onClick={onCopy}
|
||||
title="复制为自定义 Team"
|
||||
aria-label="复制为自定义 Team"
|
||||
title="复制为自定义分工"
|
||||
aria-label="复制为自定义分工"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -269,8 +269,8 @@ function TeamCard({
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
|
||||
onClick={onEdit}
|
||||
title="编辑 Team"
|
||||
aria-label="编辑 Team"
|
||||
title="编辑分工"
|
||||
aria-label="编辑分工"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -280,8 +280,8 @@ function TeamCard({
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition hover:bg-rose-50 hover:text-rose-600"
|
||||
onClick={onDelete}
|
||||
title="删除 Team"
|
||||
aria-label="删除 Team"
|
||||
title="删除分工"
|
||||
aria-label="删除分工"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -412,7 +412,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
});
|
||||
|
||||
if (!normalized) {
|
||||
toast.error("请至少填写 Team 名称和 1 个角色");
|
||||
toast.error("请至少填写分工名称和 1 个角色");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -437,12 +437,12 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
onClose?.();
|
||||
toast.success(
|
||||
isProjectScopedCustomTeam
|
||||
? `已将 Team「${nextTeam.label}」保存到当前项目`
|
||||
: `已保存 Team「${nextTeam.label}」`,
|
||||
? `已将分工「${nextTeam.label}」保存到当前项目`
|
||||
: `已保存分工「${nextTeam.label}」`,
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`保存 Team 失败:${error instanceof Error ? error.message : String(error)}`,
|
||||
`保存分工失败:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -461,12 +461,12 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
}
|
||||
toast.success(
|
||||
isProjectScopedCustomTeam
|
||||
? `已从当前项目删除 Team「${team.label}」`
|
||||
: `已删除 Team「${team.label}」`,
|
||||
? `已从当前项目删除分工「${team.label}」`
|
||||
: `已删除分工「${team.label}」`,
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`删除 Team 失败:${error instanceof Error ? error.message : String(error)}`,
|
||||
`删除分工失败:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -523,8 +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。
|
||||
还没有可预览的分工。你可以从左侧模板中查看详情,或新建一个自定义
|
||||
分工。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -561,7 +561,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-600">
|
||||
{inspectorTeam.description ||
|
||||
"该 Team 将作为主代理拆分子任务时的协作参考。"}
|
||||
"这套分工会作为主代理拆分子任务时的协作参考。"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -571,7 +571,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
className="bg-slate-900 text-white hover:bg-slate-800"
|
||||
onClick={() => handleSelect(inspectorTeam)}
|
||||
>
|
||||
采用这个 Team
|
||||
采用这套分工
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
@@ -599,7 +599,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<div className="text-xs font-semibold tracking-[0.08em] text-slate-500">
|
||||
TEAM 概览
|
||||
分工概览
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-700">
|
||||
{buildTeamDefinitionSummary(inspectorTeam)}
|
||||
@@ -665,7 +665,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-4 w-4" />
|
||||
删除这个 Team
|
||||
删除这套分工
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -686,10 +686,10 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-slate-900">
|
||||
{draft.id ? "编辑自定义 Team" : "新建自定义 Team"}
|
||||
{draft.id ? "编辑自定义分工" : "新建自定义分工"}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-slate-500">
|
||||
在右侧完整配置 Team 角色、画像和技能,左侧列表用于浏览与切换模板。
|
||||
在右侧完整配置分工角色、画像和技能,左侧列表用于浏览与切换模板。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -705,7 +705,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-xs font-medium text-slate-600">
|
||||
Team 名称
|
||||
分工名称
|
||||
</label>
|
||||
<Input
|
||||
value={draft.label}
|
||||
@@ -726,7 +726,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<label className="text-xs font-medium text-slate-600">
|
||||
Team 描述
|
||||
分工说明
|
||||
</label>
|
||||
<Textarea
|
||||
value={draft.description}
|
||||
@@ -740,7 +740,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
: current,
|
||||
)
|
||||
}
|
||||
placeholder="说明这个 Team 适合什么类型任务。"
|
||||
placeholder="说明这套分工适合什么类型任务。"
|
||||
className="min-h-[88px] border-slate-200 bg-white"
|
||||
/>
|
||||
</div>
|
||||
@@ -992,7 +992,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
void handleSaveDraft();
|
||||
}}
|
||||
>
|
||||
保存 Team
|
||||
保存分工
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1008,15 +1008,15 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold tracking-[0.08em] text-slate-500">
|
||||
TEAM 配置
|
||||
任务分工配置
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-slate-700">
|
||||
只在当前任务适合拆分协作时,为主代理提供团队结构参考。
|
||||
只在当前任务适合拆分推进时,为主代理提供分工结构参考。
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-500">
|
||||
{isProjectScopedCustomTeam
|
||||
? "自定义 Team 会保存到当前项目,便于项目级复用与评审。"
|
||||
: "自定义 Team 当前保存在本地设备,用于快速测试与个人偏好。"}
|
||||
? "自定义分工会保存到当前项目,便于项目级复用与评审。"
|
||||
: "自定义分工当前保存在本地设备,用于快速测试与个人偏好。"}
|
||||
</div>
|
||||
</div>
|
||||
{selectedTeam ? (
|
||||
@@ -1024,8 +1024,8 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
|
||||
onClick={handleClearSelection}
|
||||
title="清除 Team 选择"
|
||||
aria-label="清除 Team 选择"
|
||||
title="清除分工选择"
|
||||
aria-label="清除分工选择"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -1038,7 +1038,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-slate-500">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
当前已选 Team
|
||||
当前已选分工
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-slate-900">
|
||||
{selectedTeam.label}
|
||||
@@ -1058,7 +1058,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索 Team、角色或职责"
|
||||
placeholder="搜索分工、角色或职责"
|
||||
className="border-slate-200 bg-white"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -1072,7 +1072,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
}
|
||||
>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
新建自定义 Team
|
||||
新建自定义分工
|
||||
</Button>
|
||||
{selectedTeam ? (
|
||||
<Button
|
||||
@@ -1090,7 +1090,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-[11px] font-semibold tracking-[0.08em] text-slate-500">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
推荐 Team
|
||||
推荐分工
|
||||
</div>
|
||||
<TeamCard
|
||||
team={recommendedTeam}
|
||||
@@ -1114,7 +1114,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-[11px] font-semibold tracking-[0.08em] text-slate-500">
|
||||
{isProjectScopedCustomTeam ? "当前项目 Team" : "我的 Team"}
|
||||
{isProjectScopedCustomTeam ? "当前项目分工" : "我的分工"}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1161,8 +1161,8 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
|
||||
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
|
||||
<div>
|
||||
{isProjectScopedCustomTeam
|
||||
? "当前项目还没有自定义 Team。可以从推荐方案或系统模板复制一份后再改。"
|
||||
: "还没有自定义 Team。可以从推荐方案或系统模板复制一份后再改。"}
|
||||
? "当前项目还没有自定义分工。可以从推荐方案或系统模板复制一份后再改。"
|
||||
: "还没有自定义分工。可以从推荐方案或系统模板复制一份后再改。"}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -153,6 +153,12 @@ export function useInputbarController({
|
||||
const {
|
||||
workflowQuickActions,
|
||||
workflowQueueItems,
|
||||
workflowActiveItem,
|
||||
workflowQueueTotalCount,
|
||||
workflowCompletedCount,
|
||||
workflowTotalCount,
|
||||
workflowProgressLabel,
|
||||
workflowSummaryLabel,
|
||||
renderWorkflowGeneratingPanel,
|
||||
} = useWorkflowInputState({
|
||||
isWorkspaceVariant,
|
||||
@@ -252,6 +258,12 @@ export function useInputbarController({
|
||||
topExtra,
|
||||
workflowQuickActions,
|
||||
workflowQueueItems,
|
||||
workflowActiveItem,
|
||||
workflowQueueTotalCount,
|
||||
workflowCompletedCount,
|
||||
workflowTotalCount,
|
||||
workflowProgressLabel,
|
||||
workflowSummaryLabel,
|
||||
renderWorkflowGeneratingPanel,
|
||||
skillSelection,
|
||||
setActiveBuiltinCommand: (command: BuiltinInputCommand | null) => {
|
||||
|
||||
@@ -547,7 +547,7 @@ describe("Inputbar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("通用聊天态复杂任务应显示 Team 建议并支持开启多代理", async () => {
|
||||
it("通用聊天态复杂任务应显示任务分工建议并支持开启多代理", async () => {
|
||||
const onToolStatesChange = vi.fn();
|
||||
const { container } = renderInputbar({
|
||||
input:
|
||||
@@ -565,11 +565,11 @@ describe("Inputbar", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("当前任务更适合 Team 协作");
|
||||
expect(container.textContent).toContain("当前任务更适合分工推进");
|
||||
|
||||
const enableTeamButton = Array.from(
|
||||
container.querySelectorAll("button"),
|
||||
).find((button) => button.textContent?.includes("启用 Team"));
|
||||
).find((button) => button.textContent?.includes("启用任务分工"));
|
||||
|
||||
expect(enableTeamButton).toBeTruthy();
|
||||
|
||||
@@ -722,7 +722,7 @@ describe("Inputbar", () => {
|
||||
|
||||
const recommendationButton = Array.from(
|
||||
container.querySelectorAll("button"),
|
||||
).find((button) => button.textContent?.includes("启用 Team"));
|
||||
).find((button) => button.textContent?.includes("启用任务分工"));
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="team-mode-enable-button"]'),
|
||||
@@ -731,7 +731,7 @@ describe("Inputbar", () => {
|
||||
container.querySelector('[data-testid="toggle-subagent-mode"]'),
|
||||
).toBeNull();
|
||||
expect(recommendationButton).toBeTruthy();
|
||||
expect(container.textContent).toContain("当前任务更适合 Team 协作");
|
||||
expect(container.textContent).toContain("当前任务更适合分工推进");
|
||||
});
|
||||
|
||||
it("内容主题默认发送时不应再注入旧 skill 前缀", async () => {
|
||||
@@ -924,8 +924,9 @@ describe("Inputbar", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("当前待办");
|
||||
expect(container.textContent).toContain("正在生成中");
|
||||
expect(container.textContent).toContain("任务进行时");
|
||||
expect(container.textContent).toContain("检索项目素材");
|
||||
expect(container.textContent).toContain("任务队列");
|
||||
expect(container.querySelector('[data-testid="inputbar-core"]')).toBeNull();
|
||||
|
||||
const stopButton = container.querySelector(
|
||||
@@ -955,19 +956,21 @@ describe("Inputbar", () => {
|
||||
|
||||
expect(container.textContent).toContain("检索项目素材");
|
||||
|
||||
const collapseButton = Array.from(
|
||||
container.querySelectorAll("button"),
|
||||
).find((button) => button.getAttribute("aria-label") === "折叠待办列表");
|
||||
const collapseButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.getAttribute("aria-label") === "折叠任务队列",
|
||||
);
|
||||
expect(collapseButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("检索项目素材");
|
||||
expect(
|
||||
container.querySelectorAll('[data-testid="workflow-queue-item"]'),
|
||||
).toHaveLength(0);
|
||||
|
||||
const expandButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.getAttribute("aria-label") === "展开待办列表",
|
||||
(button) => button.getAttribute("aria-label") === "展开任务队列",
|
||||
);
|
||||
expect(expandButton).toBeTruthy();
|
||||
|
||||
@@ -975,7 +978,38 @@ describe("Inputbar", () => {
|
||||
expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("检索项目素材");
|
||||
expect(
|
||||
container.querySelectorAll('[data-testid="workflow-queue-item"]'),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("工作区任务队列应展示统一进度计数并按优先级排序", async () => {
|
||||
const { container } = renderInputbar({
|
||||
variant: "workspace",
|
||||
isLoading: true,
|
||||
workflowSteps: [
|
||||
{ id: "done", title: "完成选题", status: "completed" },
|
||||
{ id: "pending", title: "等待补充案例", status: "pending" },
|
||||
{ id: "active", title: "撰写主稿", status: "active" },
|
||||
{ id: "error", title: "封面生成失败", status: "error" },
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("已完成 1/4");
|
||||
|
||||
const queueItems = Array.from(
|
||||
container.querySelectorAll('[data-testid="workflow-queue-item"]'),
|
||||
);
|
||||
expect(queueItems.map((item) => item.getAttribute("data-status"))).toEqual([
|
||||
"active",
|
||||
"error",
|
||||
"pending",
|
||||
]);
|
||||
});
|
||||
|
||||
it("工作区工作流在 auto_running 状态下应展示生成面板(不依赖 isLoading)", async () => {
|
||||
@@ -994,8 +1028,8 @@ describe("Inputbar", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("当前待办");
|
||||
expect(container.textContent).toContain("正在生成中");
|
||||
expect(container.textContent).toContain("任务进行时");
|
||||
expect(container.textContent).toContain("检索项目素材");
|
||||
expect(container.querySelector('[data-testid="inputbar-core"]')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1004,6 +1038,12 @@ describe("Inputbar", () => {
|
||||
variant: "workspace",
|
||||
isLoading: true,
|
||||
workflowRunState: "await_user_decision",
|
||||
workflowGate: {
|
||||
key: "topic_select",
|
||||
title: "等待用户确认选题",
|
||||
status: "waiting",
|
||||
description: "等待你确认后继续推进下一步。",
|
||||
},
|
||||
workflowSteps: [
|
||||
{ id: "topic", title: "等待用户确认选题", status: "pending" },
|
||||
],
|
||||
@@ -1017,6 +1057,8 @@ describe("Inputbar", () => {
|
||||
expect(
|
||||
container.querySelector('[data-testid="inputbar-core"]'),
|
||||
).toBeTruthy();
|
||||
expect(container.textContent).toContain("任务进行时");
|
||||
expect(container.textContent).toContain("等待用户确认选题");
|
||||
expect(container.textContent).not.toContain("正在生成中");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,6 +174,12 @@ export const Inputbar: React.FC<InputbarProps> = ({
|
||||
topExtra,
|
||||
workflowQuickActions,
|
||||
workflowQueueItems,
|
||||
workflowActiveItem,
|
||||
workflowQueueTotalCount,
|
||||
workflowCompletedCount,
|
||||
workflowTotalCount,
|
||||
workflowProgressLabel,
|
||||
workflowSummaryLabel,
|
||||
renderWorkflowGeneratingPanel,
|
||||
skillSelection,
|
||||
setActiveBuiltinCommand,
|
||||
@@ -246,6 +252,12 @@ export const Inputbar: React.FC<InputbarProps> = ({
|
||||
workflowGate={workflowGate}
|
||||
workflowQuickActions={workflowQuickActions}
|
||||
workflowQueueItems={workflowQueueItems}
|
||||
workflowActiveItem={workflowActiveItem}
|
||||
workflowQueueTotalCount={workflowQueueTotalCount}
|
||||
workflowCompletedCount={workflowCompletedCount}
|
||||
workflowTotalCount={workflowTotalCount}
|
||||
workflowProgressLabel={workflowProgressLabel}
|
||||
workflowSummaryLabel={workflowSummaryLabel}
|
||||
inputAdapter={inputAdapter}
|
||||
characters={characters}
|
||||
skillSelection={skillSelection}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as fileBrowserModule from "@/lib/api/fileBrowser";
|
||||
import { MarkdownRenderer } from "./MarkdownRenderer";
|
||||
|
||||
const mockConvertLocalFileSrc = vi.fn((path: string) => `asset://${path}`);
|
||||
@@ -69,6 +70,10 @@ vi.mock("@/lib/api/fileSystem", () => ({
|
||||
convertLocalFileSrc: (path: string) => mockConvertLocalFileSrc(path),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/fileBrowser", () => ({
|
||||
readFilePreview: vi.fn(),
|
||||
}));
|
||||
|
||||
interface MountedHarness {
|
||||
container: HTMLDivElement;
|
||||
root: Root;
|
||||
@@ -286,6 +291,48 @@ describe("MarkdownRenderer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("应通过同目录 meta.json 将远程图片替换为本地下载资源", async () => {
|
||||
vi.mocked(fileBrowserModule.readFilePreview).mockResolvedValue({
|
||||
path:
|
||||
"/Users/coso/.lime/projects/default/exports/x-article/google/meta.json",
|
||||
content: JSON.stringify({
|
||||
markdown_relative_path: "exports/x-article/google/index.md",
|
||||
images: [
|
||||
{
|
||||
original_url: "https://cdn.example.com/hero.png",
|
||||
markdown_path: "images/hero.png",
|
||||
},
|
||||
],
|
||||
}),
|
||||
isBinary: false,
|
||||
size: 160,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const container = render("", {
|
||||
baseFilePath:
|
||||
"/Users/coso/.lime/projects/default/exports/x-article/google/index.md",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(fileBrowserModule.readFilePreview).toHaveBeenCalledWith(
|
||||
"/Users/coso/.lime/projects/default/exports/x-article/google/meta.json",
|
||||
64 * 1024,
|
||||
);
|
||||
|
||||
const image = container.querySelector("img");
|
||||
expect(image).not.toBeNull();
|
||||
expect(mockConvertLocalFileSrc).toHaveBeenCalledWith(
|
||||
"/Users/coso/.lime/projects/default/exports/x-article/google/images/hero.png",
|
||||
);
|
||||
expect(image?.getAttribute("src")).toBe(
|
||||
"asset:///Users/coso/.lime/projects/default/exports/x-article/google/images/hero.png",
|
||||
);
|
||||
});
|
||||
|
||||
it("应归一化 ./ 和 ../ 相对图片路径并保留查询串", () => {
|
||||
const container = render("", {
|
||||
baseFilePath:
|
||||
|
||||
@@ -12,13 +12,19 @@ import { parseA2UIJson } from "@/lib/workspace/a2ui";
|
||||
import type { A2UIFormData } from "@/lib/workspace/a2ui";
|
||||
import { CHAT_A2UI_TASK_CARD_PRESET } from "@/lib/workspace/a2ui";
|
||||
import { useDebouncedValue } from "@/lib/artifact/hooks/useDebouncedValue";
|
||||
import { readFilePreview } from "@/lib/api/fileBrowser";
|
||||
import { resolveMarkdownImageSrc } from "@/lib/markdown/resolveMarkdownImageSrc";
|
||||
import {
|
||||
parseMarkdownBundleImageOverrides,
|
||||
resolveMarkdownBundleMetaPath,
|
||||
} from "@/lib/markdown/markdownBundleMeta";
|
||||
import { ArtifactPlaceholder } from "./ArtifactPlaceholder";
|
||||
import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard";
|
||||
|
||||
const STREAMING_LIGHT_RENDER_THRESHOLD = 2_000;
|
||||
const STREAMING_LIGHT_RENDER_DEBOUNCE_MS = 48;
|
||||
const STREAMING_STANDARD_RENDER_DEBOUNCE_MS = 24;
|
||||
const MARKDOWN_BUNDLE_META_MAX_SIZE = 64 * 1024;
|
||||
const CODE_BLOCK_SURFACE = "#f8fafc";
|
||||
const CODE_BLOCK_SURFACE_ACCENT =
|
||||
"linear-gradient(180deg, rgba(255, 255, 255, 0.92) 0%, rgba(248, 250, 252, 0.98) 100%)";
|
||||
@@ -570,6 +576,9 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
|
||||
onQuoteContent,
|
||||
}) => {
|
||||
const [copied, setCopied] = React.useState<string | null>(null);
|
||||
const [bundleImageOverrides, setBundleImageOverrides] = React.useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const copyTimeoutRef = React.useRef<number | null>(null);
|
||||
const blockRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const selectionSnapshotRef = React.useRef<string | null>(null);
|
||||
@@ -598,14 +607,65 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
|
||||
() => (useLightweightStreamingRender ? [] : [rehypeRaw, rehypeKatex]),
|
||||
[useLightweightStreamingRender],
|
||||
);
|
||||
const hasRemoteImageReferences = React.useMemo(
|
||||
() => /https?:\/\//i.test(content),
|
||||
[content],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const metaPath = resolveMarkdownBundleMetaPath(baseFilePath);
|
||||
if (!metaPath || !hasRemoteImageReferences) {
|
||||
setBundleImageOverrides((previous) =>
|
||||
Object.keys(previous).length === 0 ? previous : {},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const preview = await readFilePreview(
|
||||
metaPath,
|
||||
MARKDOWN_BUNDLE_META_MAX_SIZE,
|
||||
);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
preview.error ||
|
||||
preview.isBinary ||
|
||||
typeof preview.content !== "string"
|
||||
) {
|
||||
setBundleImageOverrides({});
|
||||
return;
|
||||
}
|
||||
|
||||
setBundleImageOverrides(
|
||||
parseMarkdownBundleImageOverrides(preview.content),
|
||||
);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setBundleImageOverrides({});
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [baseFilePath, hasRemoteImageReferences]);
|
||||
|
||||
const resolveImageSrc = React.useCallback(
|
||||
(src?: string | null) => {
|
||||
if (typeof src !== "string") {
|
||||
return "";
|
||||
}
|
||||
return resolveMarkdownImageSrc(src, baseFilePath);
|
||||
const normalizedSrc = src.trim();
|
||||
const overriddenSrc = bundleImageOverrides[normalizedSrc] || normalizedSrc;
|
||||
return resolveMarkdownImageSrc(overriddenSrc, baseFilePath);
|
||||
},
|
||||
[baseFilePath],
|
||||
[baseFilePath, bundleImageOverrides],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -164,13 +164,16 @@ describe("MessageList", () => {
|
||||
expect(container.textContent).toContain("创作");
|
||||
expect(container.textContent).toContain("任务中心");
|
||||
expect(container.textContent).toContain(
|
||||
"回到进行中的任务、旧历史和最近工作现场。",
|
||||
"回到进行中的任务、最近结果和已经跑过的工作现场。",
|
||||
);
|
||||
expect(container.textContent).toContain(
|
||||
"还没有进行中的任务时,从新建任务开始也很自然。",
|
||||
"还没有进行中的任务时,从新建任务开始也很自然;跑过的结果和做法后面都会继续留在这里。",
|
||||
);
|
||||
expect(container.textContent).toContain("左侧会继续显示继续中的任务");
|
||||
expect(container.textContent).toContain("最近结果会继续在这里回访");
|
||||
expect(container.textContent).toContain(
|
||||
"常用做法和恢复中的会话会自动回到这里",
|
||||
);
|
||||
expect(container.textContent).toContain("左侧会继续显示最近任务");
|
||||
expect(container.textContent).toContain("旧历史会继续在这里回访");
|
||||
expect(container.textContent).not.toContain("开始一段新的对话吧");
|
||||
});
|
||||
|
||||
|
||||
@@ -1070,10 +1070,10 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
任务中心
|
||||
</h2>
|
||||
<p className="max-w-[48rem] text-[15px] leading-7 text-slate-600">
|
||||
回到进行中的任务、旧历史和最近工作现场。
|
||||
回到进行中的任务、最近结果和已经跑过的工作现场。
|
||||
</p>
|
||||
<p className="text-sm leading-7 text-slate-500">
|
||||
还没有进行中的任务时,从新建任务开始也很自然。
|
||||
还没有进行中的任务时,从新建任务开始也很自然;跑过的结果和做法后面都会继续留在这里。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1081,13 +1081,13 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
|
||||
<div className="mt-6 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>
|
||||
<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>
|
||||
|
||||
@@ -14,12 +14,39 @@ Agent 聊天界面的 UI 组件集合。
|
||||
| `MarkdownRenderer.tsx` | Markdown 渲染组件 |
|
||||
| `MessageList.tsx` | 消息列表组件 |
|
||||
| `team-workspace-board/TeamWorkspaceBoardHeader.tsx` | Team 头部壳,统一承载标题、状态 badge、空 shell 展开和返回主助手入口 |
|
||||
| `team-workspace-board/TeamWorkspaceBoardCanvasSection.tsx` | Team Board 主画布区壳,统一承载 overview chrome、toolbar、canvas stage 与无成员 fallback detail |
|
||||
| `team-workspace-board/TeamWorkspaceBoardShell.tsx` | Team Board 顶层壳,统一承载 header、body 与主画布区装配 |
|
||||
| `team-workspace-board/TeamWorkspaceCanvasLaneCard.tsx` | Team 自由画布里的 lane 卡片壳,统一承载成员进展、摘要、badge 与 resize handle |
|
||||
| `team-workspace-board/TeamWorkspaceCanvasStage.tsx` | Team 自由画布 stage 壳,统一承载视口、背景网格、shortcuts、lane 容器与空态 |
|
||||
| `team-workspace-board/TeamWorkspaceCanvasToolbar.tsx` | Team 自由画布顶部 toolbar 壳,统一承载缩放信息、成员面板计数与布局控制按钮 |
|
||||
| `team-workspace-board/TeamWorkspaceEmptyShellState.tsx` | Team 空 shell 折叠态横条,保持壳层紧凑与展开入口统一 |
|
||||
| `team-workspace-board/TeamWorkspaceFallbackDetailSection.tsx` | Team 在无真实成员画布时的分工展示与当前详情壳,统一承载 notice、计划分工与参考分工 |
|
||||
| `team-workspace-board/TeamWorkspaceFormationPanels.tsx` | Team 运行时分工 / 计划分工展示壳,避免 Board 内重复 JSX |
|
||||
| `team-workspace-board/SelectedSessionInlineActivitySection.tsx` | Team 当前成员的完整进展区壳,统一承载活动预览文案与进展记录列表 |
|
||||
| `team-workspace-board/SelectedSessionInlineCollaborationSection.tsx` | Team 当前成员的继续协作区壳,统一承载等待结果与补充说明输入 |
|
||||
| `team-workspace-board/TeamWorkspaceTeamOperationsPanel.tsx` | Team 协作动态列表壳,统一承载 compact / 非 compact 的操作项展示与点击跳转 |
|
||||
| `team-workspace-board/TeamWorkspaceTeamOverviewControls.tsx` | Team 顶部控制按钮与 compact toolbar chip 壳,统一承载等待/收尾/视图控制与 badge 映射 |
|
||||
| `team-workspace-board/SelectedSessionInlineDetail.tsx` | Team 当前查看成员的详情区与协作操作壳 |
|
||||
| `team-workspace-board/SelectedSessionInlineHeader.tsx` | Team 当前成员详情头壳,统一承载摘要、状态提示与会话操作按钮 |
|
||||
| `team-workspace-board/TeamWorkspaceCanvasSelectedInlineDetail.tsx` | Team 画布当前选中成员详情壳,统一承载 inline detail 组合与透传 |
|
||||
| `team-workspace-board/TeamWorkspaceTeamOverviewChrome.tsx` | Team 顶部 toolbar / 协作动态壳,收口 compact 与非 compact 的重复展示逻辑 |
|
||||
| `team-workspace-board/teamWorkspaceBoardPropBuilders.ts` | Team Board 壳层 props 组装工具,统一承载 empty shell 与 shell/header/canvas props 映射 |
|
||||
| `team-workspace-board/teamWorkspaceCanvasControllerState.ts` | Team 画布控制纯状态工具,统一承载视口更新、lane 布局写回与置顶规则 |
|
||||
| `team-workspace-board/useTeamWorkspaceActivityPreviewSync.ts` | Team 过程预览同步 hook,统一承载请求去重、选中成员轮询与 stale preview 预取 |
|
||||
| `team-workspace-board/useTeamWorkspaceActivityPreviews.ts` | Team 过程预览 hook,统一承载成员最近过程的预取、轮询与 stale preview 预热 |
|
||||
| `team-workspace-board/useTeamWorkspaceBoardActions.ts` | Team Board 操作控制 hook,统一承载成员发送/等待/关闭与 Team 级等待/收尾的 pending 状态 |
|
||||
| `team-workspace-board/useTeamWorkspaceBoardCanvasRuntime.ts` | Team Board 画布运行态组合 hook,统一承载 lane 拼装、stage hint 与自由画布控制接线 |
|
||||
| `team-workspace-board/useTeamWorkspaceBoardComposer.ts` | Team Board 高阶编排 hook,统一承载 runtime、activity、canvas、presentation 与 shell/empty-shell props 组合 |
|
||||
| `team-workspace-board/useTeamWorkspaceBoardFormationState.ts` | Team Board 分工展示前置 hook,统一承载 selected team 归一化与 runtime formation / plan display 组装 |
|
||||
| `team-workspace-board/useTeamWorkspaceBoardRuntimeState.ts` | Team Board 运行态组合 hook,统一承载 team 轨迹、焦点成员、会话控制与交互 action 组装 |
|
||||
| `team-workspace-board/useTeamWorkspaceBoardShellProps.ts` | Team Board 壳层 props 组合 hook,统一承载 empty shell / shell props 映射与当前选中成员 inline detail 装配 |
|
||||
| `team-workspace-board/useTeamWorkspaceBoardSessionGraph.ts` | Team Board 会话图组合 hook,统一承载 orchestrator/current child/visible/member canvas/rail 的派生组装 |
|
||||
| `team-workspace-board/useTeamWorkspaceBoardSelectedInlineDetail.tsx` | Team Board 当前选中成员详情组合 hook,统一承载 inline detail 条件渲染与 props 映射 |
|
||||
| `team-workspace-board/useTeamWorkspaceBoardPresentation.tsx` | Team Board 展示态 hook,统一承载 chrome 文案、详情展示与壳层样式拼装 |
|
||||
| `team-workspace-board/useTeamWorkspaceCanvasController.ts` | Team Board 画布控制 hook,统一承载自由画布的布局状态、持久化、lane 布局派生与交互接线 |
|
||||
| `team-workspace-board/useTeamWorkspaceCanvasInteractionHandlers.ts` | Team 画布交互 hook,统一承载拖拽、resize、平移、缩放、自动布局、fit 与选中行为 |
|
||||
| `team-workspace-board/useTeamWorkspaceCanvasKeyboardShortcuts.ts` | Team 画布键盘快捷键 hook,统一承载 Space 手型、缩放、适应与方向键平移 |
|
||||
| `team-workspace-board/useTeamWorkspaceSessionFocus.ts` | Team Board 焦点控制 hook,统一承载当前成员选择、展开状态同步与 team wait 自动聚焦 |
|
||||
| `StreamingRenderer.tsx` | 流式消息渲染(支持思考内容、工具调用) |
|
||||
| `TokenUsageDisplay.tsx` | Token 使用量显示 |
|
||||
| `ToolCallDisplay.tsx` | 工具调用显示(状态、参数、日志、结果) |
|
||||
|
||||
@@ -37,7 +37,7 @@ export function TeamSuggestionBar({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge className="border border-slate-200 bg-white text-slate-700">
|
||||
<Workflow className="mr-1 h-3.5 w-3.5" />
|
||||
Team 建议
|
||||
任务分工建议
|
||||
</Badge>
|
||||
{!compact ? (
|
||||
<span className="text-xs font-medium text-slate-500">
|
||||
@@ -46,7 +46,7 @@ export function TeamSuggestionBar({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-slate-900">
|
||||
当前任务更适合 Team 协作
|
||||
当前任务更适合分工推进
|
||||
</div>
|
||||
<div className="text-sm leading-6 text-slate-600">
|
||||
{reasons.slice(0, 2).join(" ")}
|
||||
@@ -84,7 +84,7 @@ export function TeamSuggestionBar({
|
||||
className="bg-slate-900 text-white hover:bg-slate-800"
|
||||
onClick={onEnableTeam}
|
||||
>
|
||||
启用 Team
|
||||
启用任务分工
|
||||
<ArrowRight className="ml-1.5 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -233,9 +233,9 @@ describe("TeamWorkspaceBoard", () => {
|
||||
childSubagentSessions: [],
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("创作协作");
|
||||
expect(container.textContent).toContain("还没有协作成员加入");
|
||||
expect(container.textContent).toContain("查看任务进展");
|
||||
expect(container.textContent).toContain("任务协作");
|
||||
expect(container.textContent).toContain("还没有成员接入");
|
||||
expect(container.textContent).toContain("查看任务进行时");
|
||||
expect(container.textContent).not.toContain("spawn_agent");
|
||||
expect(container.textContent).not.toContain("Explorer 槽位");
|
||||
expect(container.textContent).not.toContain("Executor 槽位");
|
||||
@@ -256,7 +256,7 @@ describe("TeamWorkspaceBoard", () => {
|
||||
expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("需要时会自动加入协作成员");
|
||||
expect(container.textContent).toContain("需要时会自动接入协作成员");
|
||||
expect(container.textContent).toContain("邀请协作成员");
|
||||
expect(container.textContent).toContain("收起细节");
|
||||
});
|
||||
@@ -299,7 +299,7 @@ describe("TeamWorkspaceBoard", () => {
|
||||
expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("计划中的协作分工");
|
||||
expect(container.textContent).toContain("计划中的任务分工");
|
||||
expect(container.textContent).toContain("分析");
|
||||
expect(container.textContent).toContain("执行");
|
||||
});
|
||||
@@ -398,7 +398,7 @@ describe("TeamWorkspaceBoard", () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("创作协作");
|
||||
expect(container.textContent).toContain("任务协作");
|
||||
expect(container.textContent).toContain("2 位成员协作中");
|
||||
expect(container.textContent).toContain("研究员");
|
||||
expect(container.textContent).toContain("执行器");
|
||||
@@ -2060,13 +2060,13 @@ describe("TeamWorkspaceBoard", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("正在准备协作分工");
|
||||
expect(container.textContent).toContain("正在准备任务分工");
|
||||
expect(container.textContent).toContain("准备中");
|
||||
expect(container.textContent).toContain("协作方案 · 排障 Team");
|
||||
expect(container.textContent).toContain("任务方案 · 排障 Team");
|
||||
expect(container.textContent).toContain("参考方案 · 代码排障团队");
|
||||
});
|
||||
|
||||
it("本轮协作方案已就绪时,应在无真实子会话下展示当前成员", async () => {
|
||||
it("本轮任务方案已就绪时,应在无真实子会话下展示当前成员", async () => {
|
||||
const container = await renderBoard({
|
||||
shellVisible: true,
|
||||
defaultShellExpanded: true,
|
||||
@@ -2118,7 +2118,7 @@ describe("TeamWorkspaceBoard", () => {
|
||||
expect(
|
||||
container.querySelector('[data-testid="team-workspace-runtime-members"]'),
|
||||
).toBeTruthy();
|
||||
expect(container.textContent).toContain("协作分工已准备好");
|
||||
expect(container.textContent).toContain("任务分工已准备好");
|
||||
expect(container.textContent).toContain("分析");
|
||||
expect(container.textContent).toContain("执行");
|
||||
expect(container.textContent).toContain("参考分工");
|
||||
@@ -2140,7 +2140,7 @@ describe("TeamWorkspaceBoard", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("协作准备失败");
|
||||
expect(container.textContent).toContain("任务分工准备失败");
|
||||
expect(container.textContent).toContain(
|
||||
"Provider 认证失败,无法生成 Team。",
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -163,7 +163,7 @@ describe("TeamWorkspaceDock", () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("创作协作");
|
||||
expect(container.textContent).toContain("任务协作");
|
||||
|
||||
const toggleButton = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="team-workspace-dock-toggle"]',
|
||||
@@ -174,10 +174,10 @@ describe("TeamWorkspaceDock", () => {
|
||||
toggleButton?.click();
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("等待协作成员加入");
|
||||
expect(document.body.textContent).toContain("协作方案");
|
||||
expect(document.body.textContent).toContain("等待成员接入");
|
||||
expect(document.body.textContent).toContain("任务方案");
|
||||
expect(document.body.textContent).toContain("不遮挡画布");
|
||||
expect(document.body.textContent).toContain("当前协作方案:前端联调团队");
|
||||
expect(document.body.textContent).toContain("当前任务方案:前端联调团队");
|
||||
expect(document.body.textContent).toContain("分析、实现、验证三段式推进。");
|
||||
expect(document.body.textContent).not.toContain("查看详情");
|
||||
expect(
|
||||
@@ -190,7 +190,7 @@ describe("TeamWorkspaceDock", () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("空态工作台应支持展开当前协作方案的详情", async () => {
|
||||
it("空态工作台应支持展开当前任务方案的详情", async () => {
|
||||
const { container } = await renderDock({
|
||||
selectedTeamLabel: "前端联调团队",
|
||||
selectedTeamSummary: "分析、实现、验证三段式推进。",
|
||||
@@ -264,7 +264,7 @@ describe("TeamWorkspaceDock", () => {
|
||||
expect(document.body.textContent).toContain("研究员");
|
||||
});
|
||||
|
||||
it("仅处于协作准备中时,不应自动展开面板,但应显示提醒", async () => {
|
||||
it("仅处于任务准备中时,不应自动展开面板,但应显示提醒", async () => {
|
||||
const { container } = await renderDock({
|
||||
teamDispatchPreviewState: {
|
||||
requestId: "runtime-forming-1",
|
||||
@@ -284,13 +284,13 @@ describe("TeamWorkspaceDock", () => {
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="team-workspace-dock-panel"]'),
|
||||
).toBeNull();
|
||||
expect(container.textContent).toContain("查看任务进展 · 准备中");
|
||||
expect(container.textContent).toContain("查看任务进行时 · 准备中");
|
||||
expect(
|
||||
container.querySelector('[data-testid="team-workspace-dock-signal"]'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("协作方案已就绪但真实成员未出现时,不应自动展开面板", async () => {
|
||||
it("任务方案已就绪但真实成员未出现时,不应自动展开面板", async () => {
|
||||
const { container } = await renderDock({
|
||||
teamDispatchPreviewState: {
|
||||
requestId: "runtime-formed-2",
|
||||
@@ -320,13 +320,13 @@ describe("TeamWorkspaceDock", () => {
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="team-workspace-dock-panel"]'),
|
||||
).toBeNull();
|
||||
expect(container.textContent).toContain("查看任务进展 · 1");
|
||||
expect(container.textContent).toContain("查看任务进行时 · 1");
|
||||
expect(
|
||||
container.querySelector('[data-testid="team-workspace-dock-signal"]'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("协作准备失败时,不应自动展开面板,但应保留提醒入口", async () => {
|
||||
it("任务分工准备失败时,不应自动展开面板,但应保留提醒入口", async () => {
|
||||
const { container } = await renderDock({
|
||||
teamDispatchPreviewState: {
|
||||
requestId: "runtime-failed-1",
|
||||
@@ -347,7 +347,7 @@ describe("TeamWorkspaceDock", () => {
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="team-workspace-dock-panel"]'),
|
||||
).toBeNull();
|
||||
expect(container.textContent).toContain("查看任务进展 · 失败");
|
||||
expect(container.textContent).toContain("查看任务进行时 · 失败");
|
||||
expect(
|
||||
container.querySelector('[data-testid="team-workspace-dock-signal"]'),
|
||||
).toBeTruthy();
|
||||
@@ -402,7 +402,7 @@ describe("TeamWorkspaceDock", () => {
|
||||
expect(toggleButton).toBeTruthy();
|
||||
expect(toggleButton?.textContent).toContain("修复 Team");
|
||||
expect(toggleButton?.textContent).toContain("1 位处理中");
|
||||
expect(toggleButton?.textContent).not.toContain("查看任务进展");
|
||||
expect(toggleButton?.textContent).not.toContain("查看任务进行时");
|
||||
|
||||
act(() => {
|
||||
toggleButton?.click();
|
||||
@@ -434,7 +434,7 @@ describe("TeamWorkspaceDock", () => {
|
||||
'[data-testid="team-workspace-dock-toggle"]',
|
||||
);
|
||||
|
||||
expect(toggleButton?.textContent).toContain("查看任务进展 · 1");
|
||||
expect(toggleButton?.textContent).toContain("查看任务进行时 · 1");
|
||||
expect(
|
||||
container.querySelector('[data-testid="team-workspace-dock-signal"]'),
|
||||
).toBeTruthy();
|
||||
@@ -550,7 +550,7 @@ describe("TeamWorkspaceDock", () => {
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="team-workspace-dock-panel"]'),
|
||||
).toBeNull();
|
||||
expect(toggleButton?.textContent).toContain("查看任务进展 · 2");
|
||||
expect(toggleButton?.textContent).toContain("查看任务进行时 · 2");
|
||||
});
|
||||
|
||||
it("切换会话后,新的真实成员出现应再次自动展开", async () => {
|
||||
@@ -636,7 +636,7 @@ describe("TeamWorkspaceDock", () => {
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="team-workspace-dock-panel"]'),
|
||||
).toBeNull();
|
||||
expect(container.textContent).toContain("查看任务进展 · 1");
|
||||
expect(container.textContent).toContain("查看任务进行时 · 1");
|
||||
});
|
||||
|
||||
it("已有真实成员的 Dock 切到 inline 时,不应因为布局变化自动展开", async () => {
|
||||
@@ -679,10 +679,10 @@ describe("TeamWorkspaceDock", () => {
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="team-workspace-dock-panel"]'),
|
||||
).toBeNull();
|
||||
expect(container.textContent).toContain("查看任务进展 · 1");
|
||||
expect(container.textContent).toContain("查看任务进行时 · 1");
|
||||
});
|
||||
|
||||
it("本轮协作方案已就绪时,应在空态 Dock 展示成员摘要", async () => {
|
||||
it("本轮任务方案已就绪时,应在空态 Dock 展示成员摘要", async () => {
|
||||
const { container } = await renderDock({
|
||||
teamDispatchPreviewState: {
|
||||
requestId: "runtime-formed-1",
|
||||
@@ -712,7 +712,7 @@ describe("TeamWorkspaceDock", () => {
|
||||
const toggleButton = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="team-workspace-dock-toggle"]',
|
||||
);
|
||||
expect(toggleButton?.textContent).toContain("查看任务进展 · 1");
|
||||
expect(toggleButton?.textContent).toContain("查看任务进行时 · 1");
|
||||
|
||||
act(() => {
|
||||
toggleButton?.click();
|
||||
@@ -727,7 +727,7 @@ describe("TeamWorkspaceDock", () => {
|
||||
detailToggle?.click();
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("协作分工已准备好");
|
||||
expect(document.body.textContent).toContain("任务分工已准备好");
|
||||
expect(document.body.textContent).toContain("修复 Team");
|
||||
expect(document.body.textContent).toContain("分析");
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
TEAM_WORKSPACE_PLAN_LABEL,
|
||||
TEAM_WORKSPACE_REALTIME_BADGE_LABEL,
|
||||
TEAM_WORKSPACE_SURFACE_TITLE,
|
||||
TEAM_WORKSPACE_WAITING_HEADLINE,
|
||||
} from "../utils/teamWorkspaceCopy";
|
||||
|
||||
const DockContainer = styled.div<{
|
||||
@@ -642,20 +643,20 @@ export function TeamWorkspaceDock({
|
||||
}
|
||||
if (hasRealTeamGraph) {
|
||||
if (teamConcurrencyBadgeText || teamQueueBadgeText) {
|
||||
return ["查看任务进展", teamConcurrencyBadgeText, teamQueueBadgeText]
|
||||
return ["查看任务进行时", teamConcurrencyBadgeText, teamQueueBadgeText]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
return `查看任务进展 · ${dockCount}`;
|
||||
return `查看任务进行时 · ${dockCount}`;
|
||||
}
|
||||
if (dispatchPreviewState?.status === "forming") {
|
||||
return "查看任务进展 · 准备中";
|
||||
return "查看任务进行时 · 准备中";
|
||||
}
|
||||
if (dispatchPreviewState?.status === "formed") {
|
||||
return `查看任务进展 · ${dispatchPreviewState.members.length}`;
|
||||
return `查看任务进行时 · ${dispatchPreviewState.members.length}`;
|
||||
}
|
||||
if (dispatchPreviewState?.status === "failed") {
|
||||
return "查看任务进展 · 失败";
|
||||
return "查看任务进行时 · 失败";
|
||||
}
|
||||
return TEAM_WORKSPACE_SURFACE_TITLE;
|
||||
}, [
|
||||
@@ -799,18 +800,18 @@ export function TeamWorkspaceDock({
|
||||
) : null}
|
||||
</EmptyStateEyebrow>
|
||||
<EmptyStateTitle>
|
||||
{runtimeFormationMeta?.title || "等待协作成员加入"}
|
||||
{runtimeFormationMeta?.title || TEAM_WORKSPACE_WAITING_HEADLINE}
|
||||
</EmptyStateTitle>
|
||||
<EmptyStateDescription>
|
||||
{dispatchPreviewState?.status === "forming" ? (
|
||||
"系统正在按当前任务准备协作分工,成员接入后会自动展开完整协作面板。"
|
||||
"系统正在按当前任务准备分工,成员接入后会自动展开完整协作面板。"
|
||||
) : dispatchPreviewState?.status === "formed" ? (
|
||||
`已准备 ${dispatchPreviewState.members.length} 位协作成员,后续会自动接入并继续处理。`
|
||||
) : dispatchPreviewState?.status === "failed" ? (
|
||||
dispatchPreviewState.errorMessage?.trim() ||
|
||||
"这次协作准备失败,你仍然可以继续在当前对话中处理。"
|
||||
"这次任务分工准备失败,你仍然可以继续在当前对话中处理。"
|
||||
) : (
|
||||
<>需要时系统会自动邀请协作成员加入,这里会切换成完整协作面板。</>
|
||||
<>需要时系统会自动安排任务分工,这里会切换成完整任务协作面板。</>
|
||||
)}
|
||||
</EmptyStateDescription>
|
||||
{teamQueueReason ? (
|
||||
@@ -830,8 +831,8 @@ export function TeamWorkspaceDock({
|
||||
</EmptyStateDetailTitle>
|
||||
<EmptyStateDetailHint>
|
||||
{dispatchPreviewState
|
||||
? "查看当前协作分工与参考方案"
|
||||
: "查看当前协作方案与分工"}
|
||||
? "查看当前任务分工与参考方案"
|
||||
: "查看当前任务方案与分工"}
|
||||
</EmptyStateDetailHint>
|
||||
</div>
|
||||
{teamDetailExpanded ? (
|
||||
@@ -911,7 +912,7 @@ export function TeamWorkspaceDock({
|
||||
</EmptyStateRoleName>
|
||||
<EmptyStateRoleSummary>
|
||||
{dispatchPreviewState.blueprint.summary ||
|
||||
"当前协作参考了当前方案里的分工偏好。"}
|
||||
"当前任务分工参考了当前方案里的角色偏好。"}
|
||||
</EmptyStateRoleSummary>
|
||||
</EmptyStateRoleItem>
|
||||
) : null}
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { Activity } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SessionActivityPreviewState } from "../../team-workspace-runtime/activityPreviewSelectors";
|
||||
import type { TeamWorkspaceActivityEntry } from "../../teamWorkspaceRuntime";
|
||||
|
||||
interface SelectedSessionInlineActivitySectionProps {
|
||||
inlineDetailSectionClassName: string;
|
||||
inlineTimelineEntryClassName: string;
|
||||
inlineTimelineFeedClassName: string;
|
||||
selectedSessionActivityEntries: TeamWorkspaceActivityEntry[];
|
||||
selectedSessionActivityPreview: SessionActivityPreviewState | null;
|
||||
selectedSessionActivityPreviewText: string | null;
|
||||
selectedSessionActivityShouldPoll: boolean;
|
||||
selectedSessionSupportsActivityPreview: boolean;
|
||||
}
|
||||
|
||||
export function SelectedSessionInlineActivitySection({
|
||||
inlineDetailSectionClassName,
|
||||
inlineTimelineEntryClassName,
|
||||
inlineTimelineFeedClassName,
|
||||
selectedSessionActivityEntries,
|
||||
selectedSessionActivityPreview,
|
||||
selectedSessionActivityPreviewText,
|
||||
selectedSessionActivityShouldPoll,
|
||||
selectedSessionSupportsActivityPreview,
|
||||
}: SelectedSessionInlineActivitySectionProps) {
|
||||
if (!selectedSessionSupportsActivityPreview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={inlineDetailSectionClassName}>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
<span>完整进展</span>
|
||||
{selectedSessionActivityShouldPoll ? (
|
||||
<span className="rounded-full border border-sky-200 bg-white px-2 py-0.5 text-[10px] font-medium tracking-normal text-sky-700 normal-case">
|
||||
处理中自动刷新
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedSessionActivityPreviewText ? (
|
||||
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-slate-700">
|
||||
{selectedSessionActivityPreviewText}
|
||||
</p>
|
||||
) : selectedSessionActivityPreview?.status === "error" ? (
|
||||
<p className="mt-2 text-sm leading-6 text-rose-600">
|
||||
最新进展暂不可用:
|
||||
{selectedSessionActivityPreview.errorMessage ?? "同步失败"}
|
||||
</p>
|
||||
) : selectedSessionActivityPreview?.status === "ready" ? (
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
这位成员暂时还没有可展示的新进展。
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
正在同步这位成员的最新进展...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedSessionActivityEntries.length > 0 ? (
|
||||
<div
|
||||
className={inlineTimelineFeedClassName}
|
||||
data-testid="team-workspace-activity-feed"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">
|
||||
<span>进展记录</span>
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] font-medium tracking-normal text-slate-600 normal-case">
|
||||
{selectedSessionActivityEntries.length} 条
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2.5">
|
||||
{selectedSessionActivityEntries.map((entry) => (
|
||||
<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}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 font-medium",
|
||||
entry.badgeClassName,
|
||||
)}
|
||||
>
|
||||
{entry.statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6 text-slate-600">
|
||||
{entry.detail}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import { Clock3, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type SelectedSessionInlineDetailAction =
|
||||
| "close"
|
||||
| "interrupt_send"
|
||||
| "resume"
|
||||
| "send"
|
||||
| "wait";
|
||||
|
||||
interface SelectedSessionInlineCollaborationSectionProps {
|
||||
canSendSelectedSessionInput: boolean;
|
||||
canWaitSelectedSession: boolean;
|
||||
inlineDetailSectionClassName: string;
|
||||
onSelectedSessionAction: (
|
||||
action: "close" | "resume" | "wait",
|
||||
) => void | Promise<void>;
|
||||
onSelectedSessionInputDraftChange: (value: string) => void;
|
||||
onSelectedSessionSendInput: (interrupt: boolean) => void | Promise<void>;
|
||||
pendingAction: SelectedSessionInlineDetailAction | null;
|
||||
selectedActionPending: boolean;
|
||||
selectedSessionInputDraft: string;
|
||||
selectedSessionInputMessage: string;
|
||||
}
|
||||
|
||||
export function SelectedSessionInlineCollaborationSection({
|
||||
canSendSelectedSessionInput,
|
||||
canWaitSelectedSession,
|
||||
inlineDetailSectionClassName,
|
||||
onSelectedSessionAction,
|
||||
onSelectedSessionInputDraftChange,
|
||||
onSelectedSessionSendInput,
|
||||
pendingAction,
|
||||
selectedActionPending,
|
||||
selectedSessionInputDraft,
|
||||
selectedSessionInputMessage,
|
||||
}: SelectedSessionInlineCollaborationSectionProps) {
|
||||
if (!canWaitSelectedSession && !canSendSelectedSessionInput) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={inlineDetailSectionClassName}>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
<span>继续协作</span>
|
||||
{canWaitSelectedSession ? (
|
||||
<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">
|
||||
可直接查看结果
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{canWaitSelectedSession ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={selectedActionPending}
|
||||
onClick={() => void onSelectedSessionAction("wait")}
|
||||
>
|
||||
{selectedActionPending && pendingAction === "wait" ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
{selectedActionPending && pendingAction === "wait"
|
||||
? "等待中..."
|
||||
: "等待结果 30 秒"}
|
||||
</Button>
|
||||
<span className="text-xs leading-5 text-slate-500">
|
||||
仅在当前内容确实依赖这位成员结果时使用。
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{canSendSelectedSessionInput ? (
|
||||
<div className="mt-3 space-y-3">
|
||||
<Textarea
|
||||
value={selectedSessionInputDraft}
|
||||
onChange={(event) =>
|
||||
onSelectedSessionInputDraftChange(event.target.value)
|
||||
}
|
||||
placeholder="给这位协作成员补充说明、补充约束,或请它继续推进下一步。"
|
||||
className="min-h-[96px] resize-y border-slate-200 bg-white text-sm text-slate-700 placeholder:text-slate-400"
|
||||
data-testid="team-workspace-send-input-textarea"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={selectedActionPending || !selectedSessionInputMessage}
|
||||
onClick={() => void onSelectedSessionSendInput(false)}
|
||||
>
|
||||
{selectedActionPending && pendingAction === "send" ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
{selectedActionPending && pendingAction === "send"
|
||||
? "发送中..."
|
||||
: "发送说明"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={selectedActionPending || !selectedSessionInputMessage}
|
||||
onClick={() => void onSelectedSessionSendInput(true)}
|
||||
>
|
||||
{selectedActionPending && pendingAction === "interrupt_send" ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
{selectedActionPending && pendingAction === "interrupt_send"
|
||||
? "中断中..."
|
||||
: "立即插入说明"}
|
||||
</Button>
|
||||
<span className="text-xs leading-5 text-slate-500">
|
||||
这条说明只会发送给当前成员,不影响其他协作成员。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+42
-232
@@ -1,10 +1,10 @@
|
||||
import { Activity, Bot, Clock3, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Bot, Clock3 } from "lucide-react";
|
||||
import type { SessionActivityPreviewState } from "../../team-workspace-runtime/activityPreviewSelectors";
|
||||
import type { SelectedSessionDetailDisplayState } from "../../team-workspace-runtime/selectedSessionDetailSelectors";
|
||||
import type { TeamWorkspaceActivityEntry } from "../../teamWorkspaceRuntime";
|
||||
import { SelectedSessionInlineActivitySection } from "./SelectedSessionInlineActivitySection";
|
||||
import { SelectedSessionInlineCollaborationSection } from "./SelectedSessionInlineCollaborationSection";
|
||||
import { SelectedSessionInlineHeader } from "./SelectedSessionInlineHeader";
|
||||
|
||||
type SelectedSessionInlineDetailAction =
|
||||
| "close"
|
||||
@@ -81,83 +81,20 @@ export function SelectedSessionInlineDetail({
|
||||
className="mt-3 border-t border-slate-200 pt-3"
|
||||
data-testid={`team-workspace-member-detail-${selectedSession.id}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-500">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
<span>当前查看</span>
|
||||
{selectedSession.isCurrent ? (
|
||||
<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">
|
||||
当前对话
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p
|
||||
className="mt-2 text-sm leading-6 text-slate-600"
|
||||
data-testid="team-workspace-session-summary"
|
||||
>
|
||||
{detailSummary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{canResumeSelectedSession ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={selectedActionPending}
|
||||
onClick={() => void onSelectedSessionAction("resume")}
|
||||
>
|
||||
{selectedActionPending && pendingAction === "resume" ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
{selectedActionPending && pendingAction === "resume"
|
||||
? "继续中..."
|
||||
: "继续处理"}
|
||||
</Button>
|
||||
) : null}
|
||||
{canStopSelectedSession ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={selectedActionPending}
|
||||
onClick={() => void onSelectedSessionAction("close")}
|
||||
>
|
||||
{selectedActionPending && pendingAction === "close" ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
{selectedActionPending && pendingAction === "close"
|
||||
? "暂停中..."
|
||||
: "暂停处理"}
|
||||
</Button>
|
||||
) : null}
|
||||
{canOpenSelectedSession ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void onOpenSelectedSession?.()}
|
||||
>
|
||||
{isChildSession ? "切换会话" : "打开对话"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailDisplay.runtimeDetailSummary ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2.5 py-1">
|
||||
{detailDisplay.runtimeDetailSummary}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{detailDisplay.queueReason ? (
|
||||
<div className="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-900">
|
||||
{detailDisplay.queueReason}
|
||||
</div>
|
||||
) : null}
|
||||
<SelectedSessionInlineHeader
|
||||
canOpenSelectedSession={canOpenSelectedSession}
|
||||
canResumeSelectedSession={canResumeSelectedSession}
|
||||
canStopSelectedSession={canStopSelectedSession}
|
||||
detailSummary={detailSummary}
|
||||
isChildSession={isChildSession}
|
||||
onOpenSelectedSession={onOpenSelectedSession}
|
||||
onSelectedSessionAction={onSelectedSessionAction}
|
||||
pendingAction={pendingAction}
|
||||
queueReason={detailDisplay.queueReason}
|
||||
runtimeDetailSummary={detailDisplay.runtimeDetailSummary}
|
||||
selectedActionPending={selectedActionPending}
|
||||
selectedSession={selectedSession}
|
||||
/>
|
||||
|
||||
{detailDisplay.hasSettings ? (
|
||||
<div className={inlineDetailSectionClassName}>
|
||||
@@ -196,158 +133,31 @@ export function SelectedSessionInlineDetail({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canWaitSelectedSession || canSendSelectedSessionInput ? (
|
||||
<div className={inlineDetailSectionClassName}>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
<span>继续协作</span>
|
||||
{canWaitSelectedSession ? (
|
||||
<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">
|
||||
可直接查看结果
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{canWaitSelectedSession ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={selectedActionPending}
|
||||
onClick={() => void onSelectedSessionAction("wait")}
|
||||
>
|
||||
{selectedActionPending && pendingAction === "wait" ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
{selectedActionPending && pendingAction === "wait"
|
||||
? "等待中..."
|
||||
: "等待结果 30 秒"}
|
||||
</Button>
|
||||
<span className="text-xs leading-5 text-slate-500">
|
||||
仅在当前内容确实依赖这位成员结果时使用。
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{canSendSelectedSessionInput ? (
|
||||
<div className="mt-3 space-y-3">
|
||||
<Textarea
|
||||
value={selectedSessionInputDraft}
|
||||
onChange={(event) =>
|
||||
onSelectedSessionInputDraftChange(event.target.value)
|
||||
}
|
||||
placeholder="给这位协作成员补充说明、补充约束,或请它继续推进下一步。"
|
||||
className="min-h-[96px] resize-y border-slate-200 bg-white text-sm text-slate-700 placeholder:text-slate-400"
|
||||
data-testid="team-workspace-send-input-textarea"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={
|
||||
selectedActionPending || !selectedSessionInputMessage
|
||||
}
|
||||
onClick={() => void onSelectedSessionSendInput(false)}
|
||||
>
|
||||
{selectedActionPending && pendingAction === "send" ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
{selectedActionPending && pendingAction === "send"
|
||||
? "发送中..."
|
||||
: "发送说明"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
selectedActionPending || !selectedSessionInputMessage
|
||||
}
|
||||
onClick={() => void onSelectedSessionSendInput(true)}
|
||||
>
|
||||
{selectedActionPending &&
|
||||
pendingAction === "interrupt_send" ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
{selectedActionPending && pendingAction === "interrupt_send"
|
||||
? "中断中..."
|
||||
: "立即插入说明"}
|
||||
</Button>
|
||||
<span className="text-xs leading-5 text-slate-500">
|
||||
这条说明只会发送给当前成员,不影响其他协作成员。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<SelectedSessionInlineCollaborationSection
|
||||
canSendSelectedSessionInput={canSendSelectedSessionInput}
|
||||
canWaitSelectedSession={canWaitSelectedSession}
|
||||
inlineDetailSectionClassName={inlineDetailSectionClassName}
|
||||
onSelectedSessionAction={onSelectedSessionAction}
|
||||
onSelectedSessionInputDraftChange={onSelectedSessionInputDraftChange}
|
||||
onSelectedSessionSendInput={onSelectedSessionSendInput}
|
||||
pendingAction={pendingAction}
|
||||
selectedActionPending={selectedActionPending}
|
||||
selectedSessionInputDraft={selectedSessionInputDraft}
|
||||
selectedSessionInputMessage={selectedSessionInputMessage}
|
||||
/>
|
||||
|
||||
{selectedSessionSupportsActivityPreview ? (
|
||||
<div className={inlineDetailSectionClassName}>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
<span>完整进展</span>
|
||||
{selectedSessionActivityShouldPoll ? (
|
||||
<span className="rounded-full border border-sky-200 bg-white px-2 py-0.5 text-[10px] font-medium tracking-normal text-sky-700 normal-case">
|
||||
处理中自动刷新
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedSessionActivityPreviewText ? (
|
||||
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-slate-700">
|
||||
{selectedSessionActivityPreviewText}
|
||||
</p>
|
||||
) : selectedSessionActivityPreview?.status === "error" ? (
|
||||
<p className="mt-2 text-sm leading-6 text-rose-600">
|
||||
最新进展暂不可用:
|
||||
{selectedSessionActivityPreview.errorMessage ?? "同步失败"}
|
||||
</p>
|
||||
) : selectedSessionActivityPreview?.status === "ready" ? (
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
这位成员暂时还没有可展示的新进展。
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
正在同步这位成员的最新进展...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedSessionActivityEntries.length > 0 ? (
|
||||
<div
|
||||
className={inlineTimelineFeedClassName}
|
||||
data-testid="team-workspace-activity-feed"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">
|
||||
<span>进展记录</span>
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] font-medium tracking-normal text-slate-600 normal-case">
|
||||
{selectedSessionActivityEntries.length} 条
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2.5">
|
||||
{selectedSessionActivityEntries.map((entry) => (
|
||||
<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}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 font-medium",
|
||||
entry.badgeClassName,
|
||||
)}
|
||||
>
|
||||
{entry.statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6 text-slate-600">
|
||||
{entry.detail}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<SelectedSessionInlineActivitySection
|
||||
inlineDetailSectionClassName={inlineDetailSectionClassName}
|
||||
inlineTimelineEntryClassName={inlineTimelineEntryClassName}
|
||||
inlineTimelineFeedClassName={inlineTimelineFeedClassName}
|
||||
selectedSessionActivityEntries={selectedSessionActivityEntries}
|
||||
selectedSessionActivityPreview={selectedSessionActivityPreview}
|
||||
selectedSessionActivityPreviewText={selectedSessionActivityPreviewText}
|
||||
selectedSessionActivityShouldPoll={selectedSessionActivityShouldPoll}
|
||||
selectedSessionSupportsActivityPreview={
|
||||
selectedSessionSupportsActivityPreview
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-2.5 py-1">
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import { Bot, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type SelectedSessionInlineDetailAction =
|
||||
| "close"
|
||||
| "interrupt_send"
|
||||
| "resume"
|
||||
| "send"
|
||||
| "wait";
|
||||
|
||||
interface SelectedSessionInlineHeaderProps {
|
||||
canOpenSelectedSession: boolean;
|
||||
canResumeSelectedSession: boolean;
|
||||
canStopSelectedSession: boolean;
|
||||
detailSummary: string;
|
||||
isChildSession: boolean;
|
||||
onOpenSelectedSession?: () => void;
|
||||
onSelectedSessionAction: (
|
||||
action: "close" | "resume" | "wait",
|
||||
) => void | Promise<void>;
|
||||
pendingAction: SelectedSessionInlineDetailAction | null;
|
||||
queueReason?: string | null;
|
||||
runtimeDetailSummary?: string | null;
|
||||
selectedActionPending: boolean;
|
||||
selectedSession: {
|
||||
id: string;
|
||||
isCurrent?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function SelectedSessionInlineHeader({
|
||||
canOpenSelectedSession,
|
||||
canResumeSelectedSession,
|
||||
canStopSelectedSession,
|
||||
detailSummary,
|
||||
isChildSession,
|
||||
onOpenSelectedSession,
|
||||
onSelectedSessionAction,
|
||||
pendingAction,
|
||||
queueReason,
|
||||
runtimeDetailSummary,
|
||||
selectedActionPending,
|
||||
selectedSession,
|
||||
}: SelectedSessionInlineHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-500">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
<span>当前查看</span>
|
||||
{selectedSession.isCurrent ? (
|
||||
<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">
|
||||
当前对话
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p
|
||||
className="mt-2 text-sm leading-6 text-slate-600"
|
||||
data-testid="team-workspace-session-summary"
|
||||
>
|
||||
{detailSummary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{canResumeSelectedSession ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={selectedActionPending}
|
||||
onClick={() => void onSelectedSessionAction("resume")}
|
||||
>
|
||||
{selectedActionPending && pendingAction === "resume" ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
{selectedActionPending && pendingAction === "resume"
|
||||
? "继续中..."
|
||||
: "继续处理"}
|
||||
</Button>
|
||||
) : null}
|
||||
{canStopSelectedSession ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={selectedActionPending}
|
||||
onClick={() => void onSelectedSessionAction("close")}
|
||||
>
|
||||
{selectedActionPending && pendingAction === "close" ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
{selectedActionPending && pendingAction === "close"
|
||||
? "暂停中..."
|
||||
: "暂停处理"}
|
||||
</Button>
|
||||
) : null}
|
||||
{canOpenSelectedSession ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void onOpenSelectedSession?.()}
|
||||
>
|
||||
{isChildSession ? "切换会话" : "打开对话"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{runtimeDetailSummary ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2.5 py-1">
|
||||
{runtimeDetailSummary}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{queueReason ? (
|
||||
<div className="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-900">
|
||||
{queueReason}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TeamWorkspaceCanvasStage } from "./TeamWorkspaceCanvasStage";
|
||||
import { TeamWorkspaceCanvasToolbar } from "./TeamWorkspaceCanvasToolbar";
|
||||
import { TeamWorkspaceFallbackDetailSection } from "./TeamWorkspaceFallbackDetailSection";
|
||||
import { TeamWorkspaceTeamOverviewChrome } from "./TeamWorkspaceTeamOverviewChrome";
|
||||
|
||||
interface TeamWorkspaceBoardCanvasSectionProps {
|
||||
canvasStageProps: ComponentProps<typeof TeamWorkspaceCanvasStage>;
|
||||
canvasToolbarProps: ComponentProps<typeof TeamWorkspaceCanvasToolbar>;
|
||||
embedded: boolean;
|
||||
fallbackDetailProps?: ComponentProps<
|
||||
typeof TeamWorkspaceFallbackDetailSection
|
||||
> | null;
|
||||
overviewChromeProps: Omit<
|
||||
ComponentProps<typeof TeamWorkspaceTeamOverviewChrome>,
|
||||
"embedded" | "useCompactCanvasChrome"
|
||||
>;
|
||||
railCardClassName: string;
|
||||
useCompactCanvasChrome: boolean;
|
||||
}
|
||||
|
||||
export function TeamWorkspaceBoardCanvasSection({
|
||||
canvasStageProps,
|
||||
canvasToolbarProps,
|
||||
embedded,
|
||||
fallbackDetailProps = null,
|
||||
overviewChromeProps,
|
||||
railCardClassName,
|
||||
useCompactCanvasChrome,
|
||||
}: TeamWorkspaceBoardCanvasSectionProps) {
|
||||
return (
|
||||
<div className={railCardClassName}>
|
||||
<TeamWorkspaceTeamOverviewChrome
|
||||
{...overviewChromeProps}
|
||||
embedded={embedded}
|
||||
useCompactCanvasChrome={useCompactCanvasChrome}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mt-3",
|
||||
useCompactCanvasChrome ? "space-y-2.5" : "space-y-3",
|
||||
)}
|
||||
>
|
||||
{!useCompactCanvasChrome ? (
|
||||
<TeamWorkspaceCanvasToolbar {...canvasToolbarProps} />
|
||||
) : null}
|
||||
<TeamWorkspaceCanvasStage {...canvasStageProps} />
|
||||
</div>
|
||||
|
||||
{fallbackDetailProps ? (
|
||||
<TeamWorkspaceFallbackDetailSection
|
||||
{...fallbackDetailProps}
|
||||
detailCardClassName={cn("mt-3", fallbackDetailProps.detailCardClassName)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+2
-1
@@ -10,6 +10,7 @@ import { cn } from "@/lib/utils";
|
||||
import type { TeamWorkspaceBoardChromeDisplayState } from "../../team-workspace-runtime/boardChromeSelectors";
|
||||
import type { TeamWorkspaceRuntimeStatus } from "../../teamWorkspaceRuntime";
|
||||
import {
|
||||
TEAM_WORKSPACE_IDLE_STATUS_LABEL,
|
||||
TEAM_WORKSPACE_REALTIME_BADGE_LABEL,
|
||||
TEAM_WORKSPACE_SURFACE_TITLE,
|
||||
} from "../../utils/teamWorkspaceCopy";
|
||||
@@ -109,7 +110,7 @@ export function TeamWorkspaceBoardHeader({
|
||||
) : null}
|
||||
{isEmptyShellState ? (
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[11px] text-slate-500">
|
||||
{runtimeFormationStatusLabel || "还没有协作成员加入"}
|
||||
{runtimeFormationStatusLabel || TEAM_WORKSPACE_IDLE_STATUS_LABEL}
|
||||
</span>
|
||||
) : !useCompactCanvasChrome ? (
|
||||
boardChromeDisplay.statusSummaryBadges.map((badge) => {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { TeamWorkspaceBoardCanvasSection } from "./TeamWorkspaceBoardCanvasSection";
|
||||
import { TeamWorkspaceBoardHeader } from "./TeamWorkspaceBoardHeader";
|
||||
|
||||
interface TeamWorkspaceBoardShellProps {
|
||||
boardBodyClassName: string;
|
||||
boardHeaderClassName: string;
|
||||
boardShellClassName: string;
|
||||
canvasSectionProps: Omit<
|
||||
ComponentProps<typeof TeamWorkspaceBoardCanvasSection>,
|
||||
"embedded"
|
||||
>;
|
||||
embedded: boolean;
|
||||
headerProps: Omit<
|
||||
ComponentProps<typeof TeamWorkspaceBoardHeader>,
|
||||
"className" | "dataTestId"
|
||||
>;
|
||||
style?: { maxHeight: string } | undefined;
|
||||
}
|
||||
|
||||
export function TeamWorkspaceBoardShell({
|
||||
boardBodyClassName,
|
||||
boardHeaderClassName,
|
||||
boardShellClassName,
|
||||
canvasSectionProps,
|
||||
embedded,
|
||||
headerProps,
|
||||
style,
|
||||
}: TeamWorkspaceBoardShellProps) {
|
||||
return (
|
||||
<section
|
||||
className={boardShellClassName}
|
||||
data-testid={embedded ? "team-workspace-board-embedded-shell" : undefined}
|
||||
style={style}
|
||||
>
|
||||
<TeamWorkspaceBoardHeader
|
||||
{...headerProps}
|
||||
className={boardHeaderClassName}
|
||||
dataTestId={embedded ? "team-workspace-board-header" : undefined}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={boardBodyClassName}
|
||||
data-testid={embedded ? "team-workspace-board-body" : undefined}
|
||||
>
|
||||
<TeamWorkspaceBoardCanvasSection
|
||||
{...canvasSectionProps}
|
||||
embedded={embedded}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { SelectedSessionInlineDetail } from "./SelectedSessionInlineDetail";
|
||||
|
||||
type TeamWorkspaceCanvasSelectedInlineDetailProps = ComponentProps<
|
||||
typeof SelectedSessionInlineDetail
|
||||
>;
|
||||
|
||||
export function TeamWorkspaceCanvasSelectedInlineDetail({
|
||||
...props
|
||||
}: TeamWorkspaceCanvasSelectedInlineDetailProps) {
|
||||
return <SelectedSessionInlineDetail {...props} />;
|
||||
}
|
||||
+3
-5
@@ -36,8 +36,8 @@ interface TeamWorkspaceCanvasStageProps {
|
||||
event: ReactMouseEvent<HTMLSpanElement>,
|
||||
) => void;
|
||||
onStartCanvasPan: (event: ReactMouseEvent<HTMLDivElement>) => void;
|
||||
renderSelectedInlineDetail?: (lane: TeamWorkspaceCanvasLane) => ReactNode;
|
||||
selectedSessionId?: string | null;
|
||||
selectedInlineDetail?: ReactNode;
|
||||
viewport: TeamWorkspaceCanvasLayoutState["viewport"];
|
||||
viewportRef: Ref<HTMLDivElement>;
|
||||
}
|
||||
@@ -56,8 +56,8 @@ export function TeamWorkspaceCanvasStage({
|
||||
onStartCanvasLaneDrag,
|
||||
onStartCanvasLaneResize,
|
||||
onStartCanvasPan,
|
||||
renderSelectedInlineDetail,
|
||||
selectedSessionId,
|
||||
selectedInlineDetail,
|
||||
viewport,
|
||||
viewportRef,
|
||||
}: TeamWorkspaceCanvasStageProps) {
|
||||
@@ -142,9 +142,7 @@ export function TeamWorkspaceCanvasStage({
|
||||
onStartCanvasLaneResize(lane, direction, event)
|
||||
}
|
||||
selected={selected}
|
||||
selectedInlineDetail={
|
||||
expanded ? renderSelectedInlineDetail?.(lane) ?? null : null
|
||||
}
|
||||
selectedInlineDetail={expanded ? selectedInlineDetail : null}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface TeamWorkspaceCanvasToolbarProps {
|
||||
laneCount: number;
|
||||
onAutoArrangeCanvas: () => void;
|
||||
onFitCanvasView: () => void;
|
||||
onResetCanvasView: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
export function TeamWorkspaceCanvasToolbar({
|
||||
laneCount,
|
||||
onAutoArrangeCanvas,
|
||||
onFitCanvasView,
|
||||
onResetCanvasView,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
zoom,
|
||||
}: TeamWorkspaceCanvasToolbarProps) {
|
||||
return (
|
||||
<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">
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2.5 py-1">
|
||||
自由画布
|
||||
</span>
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1">
|
||||
缩放 {Math.round(zoom * 100)}%
|
||||
</span>
|
||||
{laneCount > 0 ? (
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1">
|
||||
{laneCount} 个成员面板
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onAutoArrangeCanvas}
|
||||
data-testid="team-workspace-auto-arrange-button"
|
||||
>
|
||||
整理布局
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onZoomOut}>
|
||||
缩小
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onZoomIn}>
|
||||
放大
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onResetCanvasView}
|
||||
>
|
||||
100%
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onFitCanvasView}
|
||||
>
|
||||
适应视图
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+6
-4
@@ -6,6 +6,7 @@ import type {
|
||||
TeamWorkspaceSelectedTeamPlanDisplayState,
|
||||
} from "../../team-workspace-runtime/formationDisplaySelectors";
|
||||
import {
|
||||
TEAM_WORKSPACE_IDLE_STATUS_LABEL,
|
||||
TEAM_WORKSPACE_REALTIME_BADGE_LABEL,
|
||||
TEAM_WORKSPACE_SURFACE_TITLE,
|
||||
} from "../../utils/teamWorkspaceCopy";
|
||||
@@ -54,16 +55,17 @@ export function TeamWorkspaceEmptyShellState({
|
||||
<span className="text-sm font-semibold text-slate-900">
|
||||
{hasRuntimeFormation
|
||||
? runtimeFormationDisplay.panelHeadline
|
||||
: "协作面板已就绪"}
|
||||
: "任务协作已就绪"}
|
||||
</span>
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] text-slate-600">
|
||||
{runtimeFormationDisplay.panelStatusLabel || "还没有协作成员加入"}
|
||||
{runtimeFormationDisplay.panelStatusLabel ||
|
||||
TEAM_WORKSPACE_IDLE_STATUS_LABEL}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">
|
||||
{hasRuntimeFormation
|
||||
? runtimeFormationDisplay.hint
|
||||
: "这里先保持简洁,避免遮挡消息区;只有真正需要协作分工时才会展开完整面板。"}
|
||||
: "这里先保持简洁,避免遮挡消息区;只有真正需要任务分工时才会展开完整面板。"}
|
||||
</p>
|
||||
{summaryBadges.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-slate-500">
|
||||
@@ -83,7 +85,7 @@ export function TeamWorkspaceEmptyShellState({
|
||||
data-testid="team-workspace-detail-toggle"
|
||||
>
|
||||
<ChevronDown className="mr-1.5 h-3.5 w-3.5" />
|
||||
查看任务进展
|
||||
查看任务进行时
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { Bot } from "lucide-react";
|
||||
import type {
|
||||
TeamWorkspaceRuntimeFormationDisplayState,
|
||||
TeamWorkspaceSelectedTeamPlanDisplayState,
|
||||
} from "../../team-workspace-runtime/formationDisplaySelectors";
|
||||
import {
|
||||
TeamWorkspaceRuntimeFormationPanel,
|
||||
TeamWorkspaceSelectedPlanPanel,
|
||||
} from "./TeamWorkspaceFormationPanels";
|
||||
|
||||
interface TeamWorkspaceFallbackDetailSectionProps {
|
||||
detailCardClassName: string;
|
||||
detailVisible: boolean;
|
||||
runtimeFormationDisplay: TeamWorkspaceRuntimeFormationDisplayState;
|
||||
selectedTeamPlanDisplay: TeamWorkspaceSelectedTeamPlanDisplayState;
|
||||
}
|
||||
|
||||
export function TeamWorkspaceFallbackDetailSection({
|
||||
detailCardClassName,
|
||||
detailVisible,
|
||||
runtimeFormationDisplay,
|
||||
selectedTeamPlanDisplay,
|
||||
}: TeamWorkspaceFallbackDetailSectionProps) {
|
||||
const hasRuntimeFormation = runtimeFormationDisplay.hasRuntimeFormation;
|
||||
const hasSelectedTeamPlan = selectedTeamPlanDisplay.hasSelectedTeamPlan;
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasRuntimeFormation ? (
|
||||
<TeamWorkspaceRuntimeFormationPanel
|
||||
runtimeFormationDisplay={runtimeFormationDisplay}
|
||||
/>
|
||||
) : hasSelectedTeamPlan ? (
|
||||
<TeamWorkspaceSelectedPlanPanel
|
||||
selectedTeamPlanDisplay={selectedTeamPlanDisplay}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-4 rounded-[20px] border border-dashed border-slate-200 bg-slate-50 p-5 text-sm leading-6 text-slate-500">
|
||||
{runtimeFormationDisplay.noticeText}
|
||||
</div>
|
||||
{detailVisible ? (
|
||||
<div
|
||||
className={detailCardClassName}
|
||||
data-testid="team-workspace-detail-section"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
<span>当前详情</span>
|
||||
</div>
|
||||
<div className="mt-2 text-base font-semibold text-slate-900">
|
||||
{runtimeFormationDisplay.panelHeadline}
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-600">
|
||||
{runtimeFormationDisplay.emptyDetail}
|
||||
</p>
|
||||
{hasRuntimeFormation ? (
|
||||
<div className="mt-4 space-y-4">
|
||||
<TeamWorkspaceRuntimeFormationPanel
|
||||
runtimeFormationDisplay={runtimeFormationDisplay}
|
||||
showBlueprintRoleCards
|
||||
/>
|
||||
</div>
|
||||
) : hasSelectedTeamPlan ? (
|
||||
<div className="mt-4">
|
||||
<TeamWorkspaceSelectedPlanPanel
|
||||
selectedTeamPlanDisplay={selectedTeamPlanDisplay}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2.5 py-1">
|
||||
推荐流程:邀请协作成员 → 查看结果 → 补充说明
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -149,7 +149,7 @@ export function TeamWorkspaceSelectedPlanPanel({
|
||||
<div className="rounded-[18px] border border-slate-200 bg-white p-4 shadow-sm shadow-slate-950/5">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
<span>计划中的协作分工</span>
|
||||
<span>计划中的任务分工</span>
|
||||
{selectedTeamPlanDisplay.label ? (
|
||||
<span className="rounded-full border border-sky-200 bg-sky-50 px-2 py-0.5 text-[10px] font-medium tracking-normal text-sky-700 normal-case">
|
||||
{selectedTeamPlanDisplay.label}
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
import { Activity } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
formatOperationUpdatedAt,
|
||||
type TeamOperationDisplayEntry,
|
||||
} from "../../team-workspace-runtime/teamOperationSelectors";
|
||||
|
||||
interface TeamWorkspaceTeamOperationsPanelProps {
|
||||
embedded: boolean;
|
||||
onSelectTeamOperationEntry: (entry: TeamOperationDisplayEntry) => void;
|
||||
teamOperationEntries: TeamOperationDisplayEntry[];
|
||||
useCompactCanvasChrome: boolean;
|
||||
}
|
||||
|
||||
export function TeamWorkspaceTeamOperationsPanel({
|
||||
embedded,
|
||||
onSelectTeamOperationEntry,
|
||||
teamOperationEntries,
|
||||
useCompactCanvasChrome,
|
||||
}: TeamWorkspaceTeamOperationsPanelProps) {
|
||||
if (teamOperationEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (useCompactCanvasChrome) {
|
||||
return (
|
||||
<div
|
||||
className="mt-2 flex items-start gap-2 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
data-testid="team-workspace-team-operations"
|
||||
>
|
||||
<div className="sticky left-0 z-10 flex shrink-0 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-[11px] font-medium text-slate-500 shadow-sm shadow-slate-950/5">
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
<span>协作动态</span>
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] text-slate-600">
|
||||
{teamOperationEntries.length}
|
||||
</span>
|
||||
</div>
|
||||
{teamOperationEntries.map((entry) => {
|
||||
const content = (
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
entry.badgeClassName,
|
||||
)}
|
||||
>
|
||||
{entry.title}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs leading-5 text-slate-700">
|
||||
{entry.detail}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-slate-500">
|
||||
{formatOperationUpdatedAt(entry.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return entry.targetSessionId ? (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className="inline-flex min-w-[220px] max-w-[340px] shrink-0 rounded-[16px] border border-slate-200 bg-white px-3 py-2 text-left transition hover:border-slate-300 hover:bg-slate-50"
|
||||
onClick={() => onSelectTeamOperationEntry(entry)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="inline-flex min-w-[220px] max-w-[340px] shrink-0 rounded-[16px] border border-slate-200 bg-slate-50 px-3 py-2"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
embedded
|
||||
? "mt-3 border-t border-slate-200 pt-3"
|
||||
: "mt-3 rounded-[18px] border border-slate-200 bg-white p-3",
|
||||
)}
|
||||
data-testid="team-workspace-team-operations"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
<span>协作动态</span>
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] font-medium tracking-normal text-slate-600 normal-case">
|
||||
最近 {teamOperationEntries.length} 条
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{teamOperationEntries.map((entry) => {
|
||||
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,
|
||||
)}
|
||||
>
|
||||
{entry.title}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-500">
|
||||
{formatOperationUpdatedAt(entry.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-700">
|
||||
{entry.detail}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
return entry.targetSessionId ? (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full text-left transition",
|
||||
embedded
|
||||
? "border-l-2 border-slate-200 px-3 py-2 hover:border-slate-300 hover:bg-slate-50"
|
||||
: "rounded-[14px] border border-slate-200 bg-slate-50 px-3 py-2.5 hover:border-slate-300 hover:bg-slate-50",
|
||||
)}
|
||||
onClick={() => onSelectTeamOperationEntry(entry)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={cn(
|
||||
embedded
|
||||
? "border-l-2 border-slate-200 px-3 py-2"
|
||||
: "rounded-[14px] border border-slate-200 bg-slate-50 px-3 py-2.5",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+8
-271
@@ -1,12 +1,13 @@
|
||||
import { Activity, Clock3, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Clock3 } from "lucide-react";
|
||||
import type { TeamWorkspaceBoardChromeDisplayState } from "../../team-workspace-runtime/boardChromeSelectors";
|
||||
import {
|
||||
formatOperationUpdatedAt,
|
||||
type TeamOperationDisplayEntry,
|
||||
} from "../../team-workspace-runtime/teamOperationSelectors";
|
||||
import type { TeamOperationDisplayEntry } from "../../team-workspace-runtime/teamOperationSelectors";
|
||||
import type { TeamWorkspaceRuntimeStatus } from "../../teamWorkspaceRuntime";
|
||||
import {
|
||||
TeamWorkspaceCanvasViewButtons,
|
||||
TeamWorkspaceCompactToolbarChip,
|
||||
TeamWorkspaceTeamActionButtons,
|
||||
} from "./TeamWorkspaceTeamOverviewControls";
|
||||
import { TeamWorkspaceTeamOperationsPanel } from "./TeamWorkspaceTeamOperationsPanel";
|
||||
|
||||
interface TeamWorkspaceStatusMeta {
|
||||
badgeClassName: string;
|
||||
@@ -43,270 +44,6 @@ interface TeamWorkspaceTeamOverviewChromeProps {
|
||||
waitableCount: number;
|
||||
}
|
||||
|
||||
function TeamWorkspaceTeamActionButtons(props: {
|
||||
canCloseCompletedTeamSessions: boolean;
|
||||
canWaitAnyActiveTeamSession: boolean;
|
||||
onCloseCompletedTeamSessions: () => void | Promise<void>;
|
||||
onWaitAnyActiveTeamSessions: () => void | Promise<void>;
|
||||
pendingTeamAction: "wait_any" | "close_completed" | null;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{props.canWaitAnyActiveTeamSession ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={props.pendingTeamAction === "wait_any"}
|
||||
onClick={() => void props.onWaitAnyActiveTeamSessions()}
|
||||
data-testid="team-workspace-wait-active-button"
|
||||
>
|
||||
{props.pendingTeamAction === "wait_any" ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
{props.pendingTeamAction === "wait_any"
|
||||
? "等待中..."
|
||||
: "等待任一成员结果"}
|
||||
</Button>
|
||||
) : null}
|
||||
{props.canCloseCompletedTeamSessions ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={props.pendingTeamAction === "close_completed"}
|
||||
onClick={() => void props.onCloseCompletedTeamSessions()}
|
||||
data-testid="team-workspace-close-completed-button"
|
||||
>
|
||||
{props.pendingTeamAction === "close_completed" ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
{props.pendingTeamAction === "close_completed"
|
||||
? "收起中..."
|
||||
: "收起已完成成员"}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamWorkspaceCanvasViewButtons(props: {
|
||||
onAutoArrangeCanvas: () => void;
|
||||
onFitCanvasView: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={props.onAutoArrangeCanvas}
|
||||
data-testid="team-workspace-auto-arrange-button"
|
||||
>
|
||||
整理布局
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={props.onZoomOut}
|
||||
>
|
||||
缩小
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={props.onZoomIn}
|
||||
>
|
||||
放大
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={props.onFitCanvasView}
|
||||
>
|
||||
适应视图
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamWorkspaceCompactToolbarChip(props: {
|
||||
chip: TeamWorkspaceBoardChromeDisplayState["compactToolbarChips"][number];
|
||||
compactCanvasMutedChipClassName: string;
|
||||
compactCanvasSummaryChipClassName: string;
|
||||
resolveStatusMeta: (
|
||||
status?: TeamWorkspaceRuntimeStatus,
|
||||
) => TeamWorkspaceStatusMeta;
|
||||
}) {
|
||||
if (props.chip.tone === "status") {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-1 text-[11px] font-medium",
|
||||
props.resolveStatusMeta(props.chip.status).badgeClassName,
|
||||
)}
|
||||
>
|
||||
{props.chip.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
props.chip.tone === "summary"
|
||||
? props.compactCanvasSummaryChipClassName
|
||||
: props.compactCanvasMutedChipClassName
|
||||
}
|
||||
>
|
||||
{props.chip.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamWorkspaceTeamOperationsPanel(props: {
|
||||
embedded: boolean;
|
||||
onSelectTeamOperationEntry: (entry: TeamOperationDisplayEntry) => void;
|
||||
teamOperationEntries: TeamOperationDisplayEntry[];
|
||||
useCompactCanvasChrome: boolean;
|
||||
}) {
|
||||
if (props.teamOperationEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (props.useCompactCanvasChrome) {
|
||||
return (
|
||||
<div
|
||||
className="mt-2 flex items-start gap-2 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
data-testid="team-workspace-team-operations"
|
||||
>
|
||||
<div className="sticky left-0 z-10 flex shrink-0 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-[11px] font-medium text-slate-500 shadow-sm shadow-slate-950/5">
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
<span>协作动态</span>
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] text-slate-600">
|
||||
{props.teamOperationEntries.length}
|
||||
</span>
|
||||
</div>
|
||||
{props.teamOperationEntries.map((entry) => {
|
||||
const content = (
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
entry.badgeClassName,
|
||||
)}
|
||||
>
|
||||
{entry.title}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs leading-5 text-slate-700">
|
||||
{entry.detail}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-slate-500">
|
||||
{formatOperationUpdatedAt(entry.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return entry.targetSessionId ? (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className="inline-flex min-w-[220px] max-w-[340px] shrink-0 rounded-[16px] border border-slate-200 bg-white px-3 py-2 text-left transition hover:border-slate-300 hover:bg-slate-50"
|
||||
onClick={() => props.onSelectTeamOperationEntry(entry)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="inline-flex min-w-[220px] max-w-[340px] shrink-0 rounded-[16px] border border-slate-200 bg-slate-50 px-3 py-2"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
props.embedded
|
||||
? "mt-3 border-t border-slate-200 pt-3"
|
||||
: "mt-3 rounded-[18px] border border-slate-200 bg-white p-3",
|
||||
)}
|
||||
data-testid="team-workspace-team-operations"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
<span>协作动态</span>
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] font-medium tracking-normal text-slate-600 normal-case">
|
||||
最近 {props.teamOperationEntries.length} 条
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{props.teamOperationEntries.map((entry) => {
|
||||
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,
|
||||
)}
|
||||
>
|
||||
{entry.title}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-500">
|
||||
{formatOperationUpdatedAt(entry.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-700">
|
||||
{entry.detail}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
return entry.targetSessionId ? (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full text-left transition",
|
||||
props.embedded
|
||||
? "border-l-2 border-slate-200 px-3 py-2 hover:border-slate-300 hover:bg-slate-50"
|
||||
: "rounded-[14px] border border-slate-200 bg-slate-50 px-3 py-2.5 hover:border-slate-300 hover:bg-slate-50",
|
||||
)}
|
||||
onClick={() => props.onSelectTeamOperationEntry(entry)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={cn(
|
||||
props.embedded
|
||||
? "border-l-2 border-slate-200 px-3 py-2"
|
||||
: "rounded-[14px] border border-slate-200 bg-slate-50 px-3 py-2.5",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TeamWorkspaceTeamOverviewChrome({
|
||||
boardChromeDisplay,
|
||||
canCloseCompletedTeamSessions,
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TeamWorkspaceBoardChromeDisplayState } from "../../team-workspace-runtime/boardChromeSelectors";
|
||||
import type { TeamWorkspaceRuntimeStatus } from "../../teamWorkspaceRuntime";
|
||||
|
||||
interface TeamWorkspaceStatusMeta {
|
||||
badgeClassName: string;
|
||||
}
|
||||
|
||||
interface TeamWorkspaceTeamActionButtonsProps {
|
||||
canCloseCompletedTeamSessions: boolean;
|
||||
canWaitAnyActiveTeamSession: boolean;
|
||||
onCloseCompletedTeamSessions: () => void | Promise<void>;
|
||||
onWaitAnyActiveTeamSessions: () => void | Promise<void>;
|
||||
pendingTeamAction: "wait_any" | "close_completed" | null;
|
||||
}
|
||||
|
||||
export function TeamWorkspaceTeamActionButtons({
|
||||
canCloseCompletedTeamSessions,
|
||||
canWaitAnyActiveTeamSession,
|
||||
onCloseCompletedTeamSessions,
|
||||
onWaitAnyActiveTeamSessions,
|
||||
pendingTeamAction,
|
||||
}: TeamWorkspaceTeamActionButtonsProps) {
|
||||
return (
|
||||
<>
|
||||
{canWaitAnyActiveTeamSession ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={pendingTeamAction === "wait_any"}
|
||||
onClick={() => void onWaitAnyActiveTeamSessions()}
|
||||
data-testid="team-workspace-wait-active-button"
|
||||
>
|
||||
{pendingTeamAction === "wait_any" ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
{pendingTeamAction === "wait_any"
|
||||
? "等待中..."
|
||||
: "等待任一成员结果"}
|
||||
</Button>
|
||||
) : null}
|
||||
{canCloseCompletedTeamSessions ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={pendingTeamAction === "close_completed"}
|
||||
onClick={() => void onCloseCompletedTeamSessions()}
|
||||
data-testid="team-workspace-close-completed-button"
|
||||
>
|
||||
{pendingTeamAction === "close_completed" ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
{pendingTeamAction === "close_completed"
|
||||
? "收起中..."
|
||||
: "收起已完成成员"}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface TeamWorkspaceCanvasViewButtonsProps {
|
||||
onAutoArrangeCanvas: () => void;
|
||||
onFitCanvasView: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
}
|
||||
|
||||
export function TeamWorkspaceCanvasViewButtons({
|
||||
onAutoArrangeCanvas,
|
||||
onFitCanvasView,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
}: TeamWorkspaceCanvasViewButtonsProps) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onAutoArrangeCanvas}
|
||||
data-testid="team-workspace-auto-arrange-button"
|
||||
>
|
||||
整理布局
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onZoomOut}>
|
||||
缩小
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onZoomIn}>
|
||||
放大
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onFitCanvasView}>
|
||||
适应视图
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface TeamWorkspaceCompactToolbarChipProps {
|
||||
chip: TeamWorkspaceBoardChromeDisplayState["compactToolbarChips"][number];
|
||||
compactCanvasMutedChipClassName: string;
|
||||
compactCanvasSummaryChipClassName: string;
|
||||
resolveStatusMeta: (
|
||||
status?: TeamWorkspaceRuntimeStatus,
|
||||
) => TeamWorkspaceStatusMeta;
|
||||
}
|
||||
|
||||
export function TeamWorkspaceCompactToolbarChip({
|
||||
chip,
|
||||
compactCanvasMutedChipClassName,
|
||||
compactCanvasSummaryChipClassName,
|
||||
resolveStatusMeta,
|
||||
}: TeamWorkspaceCompactToolbarChipProps) {
|
||||
if (chip.tone === "status") {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-1 text-[11px] font-medium",
|
||||
resolveStatusMeta(chip.status).badgeClassName,
|
||||
)}
|
||||
>
|
||||
{chip.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
chip.tone === "summary"
|
||||
? compactCanvasSummaryChipClassName
|
||||
: compactCanvasMutedChipClassName
|
||||
}
|
||||
>
|
||||
{chip.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { TeamWorkspaceBoardShell } from "./TeamWorkspaceBoardShell";
|
||||
import { TeamWorkspaceEmptyShellState } from "./TeamWorkspaceEmptyShellState";
|
||||
|
||||
type TeamWorkspaceEmptyShellProps = ComponentProps<
|
||||
typeof TeamWorkspaceEmptyShellState
|
||||
>;
|
||||
type TeamWorkspaceBoardShellProps = ComponentProps<typeof TeamWorkspaceBoardShell>;
|
||||
type TeamWorkspaceBoardHeaderProps = TeamWorkspaceBoardShellProps["headerProps"];
|
||||
type TeamWorkspaceBoardCanvasSectionProps =
|
||||
TeamWorkspaceBoardShellProps["canvasSectionProps"];
|
||||
type TeamWorkspaceBoardCanvasStageProps =
|
||||
TeamWorkspaceBoardCanvasSectionProps["canvasStageProps"];
|
||||
type TeamWorkspaceBoardCanvasToolbarProps =
|
||||
TeamWorkspaceBoardCanvasSectionProps["canvasToolbarProps"];
|
||||
type TeamWorkspaceBoardOverviewChromeProps =
|
||||
TeamWorkspaceBoardCanvasSectionProps["overviewChromeProps"];
|
||||
type TeamWorkspaceBoardFallbackDetailProps = NonNullable<
|
||||
TeamWorkspaceBoardCanvasSectionProps["fallbackDetailProps"]
|
||||
>;
|
||||
|
||||
interface BuildTeamWorkspaceBoardEmptyShellPropsParams {
|
||||
className?: string;
|
||||
embedded: boolean;
|
||||
hasRuntimeFormation: boolean;
|
||||
onExpand: NonNullable<TeamWorkspaceEmptyShellProps["onExpand"]>;
|
||||
runtimeFormationDisplay: TeamWorkspaceEmptyShellProps["runtimeFormationDisplay"];
|
||||
selectedTeamPlanDisplay: TeamWorkspaceEmptyShellProps["selectedTeamPlanDisplay"];
|
||||
}
|
||||
|
||||
interface BuildTeamWorkspaceBoardShellPropsParams {
|
||||
boardBodyClassName: TeamWorkspaceBoardShellProps["boardBodyClassName"];
|
||||
boardChromeDisplay: TeamWorkspaceBoardHeaderProps["boardChromeDisplay"];
|
||||
boardHeaderClassName: TeamWorkspaceBoardShellProps["boardHeaderClassName"];
|
||||
boardShellClassName: TeamWorkspaceBoardShellProps["boardShellClassName"];
|
||||
canvasBoundsHeight: TeamWorkspaceBoardCanvasStageProps["canvasBoundsHeight"];
|
||||
canvasBoundsWidth: TeamWorkspaceBoardCanvasStageProps["canvasBoundsWidth"];
|
||||
canvasStageHeight: TeamWorkspaceBoardCanvasStageProps["canvasStageHeight"];
|
||||
canvasStageHint: TeamWorkspaceBoardCanvasStageProps["canvasStageHint"];
|
||||
canvasViewportRef: TeamWorkspaceBoardCanvasStageProps["viewportRef"];
|
||||
canvasZoom: TeamWorkspaceBoardCanvasToolbarProps["zoom"];
|
||||
canCloseCompletedTeamSessions:
|
||||
TeamWorkspaceBoardOverviewChromeProps["canCloseCompletedTeamSessions"];
|
||||
canWaitAnyActiveTeamSession:
|
||||
TeamWorkspaceBoardOverviewChromeProps["canWaitAnyActiveTeamSession"];
|
||||
completedCount: TeamWorkspaceBoardOverviewChromeProps["completedCount"];
|
||||
createdFromTurnId?: TeamWorkspaceBoardHeaderProps["createdFromTurnId"];
|
||||
detailCardClassName: TeamWorkspaceBoardFallbackDetailProps["detailCardClassName"];
|
||||
detailToggleLabel: TeamWorkspaceBoardHeaderProps["detailToggleLabel"];
|
||||
detailVisible: TeamWorkspaceBoardHeaderProps["detailVisible"];
|
||||
embedded: boolean;
|
||||
expandedSessionId?: TeamWorkspaceBoardCanvasStageProps["expandedSessionId"];
|
||||
formatUpdatedAt: TeamWorkspaceBoardOverviewChromeProps["formatUpdatedAt"];
|
||||
hasRealTeamGraph: boolean;
|
||||
isCanvasPanModifierActive:
|
||||
TeamWorkspaceBoardCanvasStageProps["isCanvasPanModifierActive"];
|
||||
isChildSession: TeamWorkspaceBoardHeaderProps["isChildSession"];
|
||||
isEmptyShellState: TeamWorkspaceBoardHeaderProps["isEmptyShellState"];
|
||||
laneLayouts: TeamWorkspaceBoardCanvasStageProps["laneLayouts"];
|
||||
lanes: TeamWorkspaceBoardCanvasStageProps["lanes"];
|
||||
memberCanvasSubtitle: TeamWorkspaceBoardOverviewChromeProps["memberCanvasSubtitle"];
|
||||
memberCanvasTitle: TeamWorkspaceBoardOverviewChromeProps["memberCanvasTitle"];
|
||||
onAutoArrangeCanvas:
|
||||
TeamWorkspaceBoardCanvasToolbarProps["onAutoArrangeCanvas"];
|
||||
onCanvasWheel: TeamWorkspaceBoardCanvasStageProps["onCanvasWheel"];
|
||||
onCloseCompletedTeamSessions:
|
||||
TeamWorkspaceBoardOverviewChromeProps["onCloseCompletedTeamSessions"];
|
||||
onFitCanvasView: TeamWorkspaceBoardCanvasToolbarProps["onFitCanvasView"];
|
||||
onReturnToParentSession?: TeamWorkspaceBoardHeaderProps["onReturnToParentSession"];
|
||||
onResetCanvasView: TeamWorkspaceBoardCanvasToolbarProps["onResetCanvasView"];
|
||||
onSelectCanvasLane: TeamWorkspaceBoardCanvasStageProps["onSelectLane"];
|
||||
onSelectTeamOperationEntry:
|
||||
TeamWorkspaceBoardOverviewChromeProps["onSelectTeamOperationEntry"];
|
||||
onStartCanvasLaneDrag: (
|
||||
persistKey: string,
|
||||
event: Parameters<TeamWorkspaceBoardCanvasStageProps["onStartCanvasLaneDrag"]>[1],
|
||||
) => void;
|
||||
onStartCanvasLaneResize: (
|
||||
persistKey: string,
|
||||
direction: Parameters<
|
||||
TeamWorkspaceBoardCanvasStageProps["onStartCanvasLaneResize"]
|
||||
>[1],
|
||||
event: Parameters<
|
||||
TeamWorkspaceBoardCanvasStageProps["onStartCanvasLaneResize"]
|
||||
>[2],
|
||||
) => void;
|
||||
onStartCanvasPan: TeamWorkspaceBoardCanvasStageProps["onStartCanvasPan"];
|
||||
onToggleDetail: TeamWorkspaceBoardHeaderProps["onToggleDetail"];
|
||||
onWaitAnyActiveTeamSessions:
|
||||
TeamWorkspaceBoardOverviewChromeProps["onWaitAnyActiveTeamSessions"];
|
||||
onZoomIn: TeamWorkspaceBoardCanvasToolbarProps["onZoomIn"];
|
||||
onZoomOut: TeamWorkspaceBoardCanvasToolbarProps["onZoomOut"];
|
||||
pendingTeamAction: TeamWorkspaceBoardOverviewChromeProps["pendingTeamAction"];
|
||||
railCardClassName: TeamWorkspaceBoardCanvasSectionProps["railCardClassName"];
|
||||
resolveStatusMeta: TeamWorkspaceBoardHeaderProps["resolveStatusMeta"];
|
||||
runtimeFormationStatusLabel?: TeamWorkspaceBoardHeaderProps["runtimeFormationStatusLabel"];
|
||||
runtimeFormationDisplay:
|
||||
TeamWorkspaceBoardFallbackDetailProps["runtimeFormationDisplay"];
|
||||
selectedInlineDetail?: TeamWorkspaceBoardCanvasStageProps["selectedInlineDetail"];
|
||||
selectedSession: TeamWorkspaceBoardOverviewChromeProps["selectedSession"];
|
||||
selectedSessionId?: TeamWorkspaceBoardCanvasStageProps["selectedSessionId"];
|
||||
selectedTeamPlanDisplay:
|
||||
TeamWorkspaceBoardFallbackDetailProps["selectedTeamPlanDisplay"];
|
||||
teamOperationEntries:
|
||||
TeamWorkspaceBoardOverviewChromeProps["teamOperationEntries"];
|
||||
totalTeamSessions: TeamWorkspaceBoardHeaderProps["totalTeamSessions"];
|
||||
useCompactCanvasChrome: TeamWorkspaceBoardHeaderProps["useCompactCanvasChrome"];
|
||||
viewport: TeamWorkspaceBoardCanvasStageProps["viewport"];
|
||||
waitableCount: TeamWorkspaceBoardOverviewChromeProps["waitableCount"];
|
||||
}
|
||||
|
||||
export function buildTeamWorkspaceBoardEmptyShellProps({
|
||||
className,
|
||||
embedded,
|
||||
hasRuntimeFormation,
|
||||
onExpand,
|
||||
runtimeFormationDisplay,
|
||||
selectedTeamPlanDisplay,
|
||||
}: BuildTeamWorkspaceBoardEmptyShellPropsParams): TeamWorkspaceEmptyShellProps {
|
||||
return {
|
||||
className,
|
||||
embedded,
|
||||
hasRuntimeFormation,
|
||||
onExpand,
|
||||
runtimeFormationDisplay,
|
||||
selectedTeamPlanDisplay,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTeamWorkspaceBoardShellProps({
|
||||
boardBodyClassName,
|
||||
boardChromeDisplay,
|
||||
boardHeaderClassName,
|
||||
boardShellClassName,
|
||||
canvasBoundsHeight,
|
||||
canvasBoundsWidth,
|
||||
canvasStageHeight,
|
||||
canvasStageHint,
|
||||
canvasViewportRef,
|
||||
canvasZoom,
|
||||
canCloseCompletedTeamSessions,
|
||||
canWaitAnyActiveTeamSession,
|
||||
completedCount,
|
||||
createdFromTurnId,
|
||||
detailCardClassName,
|
||||
detailToggleLabel,
|
||||
detailVisible,
|
||||
embedded,
|
||||
expandedSessionId,
|
||||
formatUpdatedAt,
|
||||
hasRealTeamGraph,
|
||||
isCanvasPanModifierActive,
|
||||
isChildSession,
|
||||
isEmptyShellState,
|
||||
laneLayouts,
|
||||
lanes,
|
||||
memberCanvasSubtitle,
|
||||
memberCanvasTitle,
|
||||
onAutoArrangeCanvas,
|
||||
onCanvasWheel,
|
||||
onCloseCompletedTeamSessions,
|
||||
onFitCanvasView,
|
||||
onReturnToParentSession,
|
||||
onResetCanvasView,
|
||||
onSelectCanvasLane,
|
||||
onSelectTeamOperationEntry,
|
||||
onStartCanvasLaneDrag,
|
||||
onStartCanvasLaneResize,
|
||||
onStartCanvasPan,
|
||||
onToggleDetail,
|
||||
onWaitAnyActiveTeamSessions,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
pendingTeamAction,
|
||||
railCardClassName,
|
||||
resolveStatusMeta,
|
||||
runtimeFormationStatusLabel,
|
||||
runtimeFormationDisplay,
|
||||
selectedInlineDetail,
|
||||
selectedSession,
|
||||
selectedSessionId,
|
||||
selectedTeamPlanDisplay,
|
||||
teamOperationEntries,
|
||||
totalTeamSessions,
|
||||
useCompactCanvasChrome,
|
||||
viewport,
|
||||
waitableCount,
|
||||
}: BuildTeamWorkspaceBoardShellPropsParams): TeamWorkspaceBoardShellProps {
|
||||
return {
|
||||
boardBodyClassName,
|
||||
boardHeaderClassName,
|
||||
boardShellClassName,
|
||||
canvasSectionProps: {
|
||||
canvasStageProps: {
|
||||
canvasBoundsHeight,
|
||||
canvasBoundsWidth,
|
||||
canvasStageHeight,
|
||||
canvasStageHint,
|
||||
expandedSessionId,
|
||||
isCanvasPanModifierActive,
|
||||
laneLayouts,
|
||||
lanes,
|
||||
onCanvasWheel,
|
||||
onSelectLane: onSelectCanvasLane,
|
||||
onStartCanvasLaneDrag: (lane, event) =>
|
||||
onStartCanvasLaneDrag(lane.persistKey, event),
|
||||
onStartCanvasLaneResize: (lane, direction, event) =>
|
||||
onStartCanvasLaneResize(lane.persistKey, direction, event),
|
||||
onStartCanvasPan,
|
||||
selectedInlineDetail,
|
||||
selectedSessionId,
|
||||
viewport,
|
||||
viewportRef: canvasViewportRef,
|
||||
},
|
||||
canvasToolbarProps: {
|
||||
laneCount: lanes.length,
|
||||
onAutoArrangeCanvas,
|
||||
onFitCanvasView,
|
||||
onResetCanvasView,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
zoom: canvasZoom,
|
||||
},
|
||||
fallbackDetailProps: hasRealTeamGraph
|
||||
? null
|
||||
: {
|
||||
detailCardClassName,
|
||||
detailVisible,
|
||||
runtimeFormationDisplay,
|
||||
selectedTeamPlanDisplay,
|
||||
},
|
||||
overviewChromeProps: {
|
||||
boardChromeDisplay,
|
||||
canCloseCompletedTeamSessions,
|
||||
canWaitAnyActiveTeamSession,
|
||||
completedCount,
|
||||
formatUpdatedAt,
|
||||
memberCanvasSubtitle,
|
||||
memberCanvasTitle,
|
||||
onAutoArrangeCanvas,
|
||||
onCloseCompletedTeamSessions,
|
||||
onFitCanvasView,
|
||||
onSelectTeamOperationEntry,
|
||||
onWaitAnyActiveTeamSessions,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
pendingTeamAction,
|
||||
resolveStatusMeta,
|
||||
selectedSession,
|
||||
teamOperationEntries,
|
||||
waitableCount,
|
||||
},
|
||||
railCardClassName,
|
||||
useCompactCanvasChrome,
|
||||
},
|
||||
embedded,
|
||||
headerProps: {
|
||||
boardChromeDisplay,
|
||||
createdFromTurnId,
|
||||
detailToggleLabel,
|
||||
detailVisible,
|
||||
isChildSession,
|
||||
isEmptyShellState,
|
||||
onReturnToParentSession,
|
||||
onToggleDetail,
|
||||
resolveStatusMeta,
|
||||
runtimeFormationStatusLabel,
|
||||
totalTeamSessions,
|
||||
useCompactCanvasChrome,
|
||||
},
|
||||
style: embedded ? { maxHeight: "inherit" } : undefined,
|
||||
};
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
buildDefaultTeamWorkspaceCanvasItemLayout,
|
||||
clampTeamWorkspaceCanvasZoom,
|
||||
TEAM_WORKSPACE_CANVAS_MIN_HEIGHT,
|
||||
TEAM_WORKSPACE_CANVAS_MIN_WIDTH,
|
||||
type TeamWorkspaceCanvasItemLayout,
|
||||
type TeamWorkspaceCanvasLayoutState,
|
||||
} from "../../utils/teamWorkspaceCanvas";
|
||||
|
||||
export function updateTeamWorkspaceCanvasViewportState(
|
||||
previous: TeamWorkspaceCanvasLayoutState,
|
||||
updater: (
|
||||
viewport: TeamWorkspaceCanvasLayoutState["viewport"],
|
||||
) => TeamWorkspaceCanvasLayoutState["viewport"],
|
||||
): TeamWorkspaceCanvasLayoutState {
|
||||
const nextViewport = updater(previous.viewport);
|
||||
if (
|
||||
nextViewport.x === previous.viewport.x &&
|
||||
nextViewport.y === previous.viewport.y &&
|
||||
nextViewport.zoom === previous.viewport.zoom
|
||||
) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return {
|
||||
...previous,
|
||||
updatedAt: Date.now(),
|
||||
viewport: {
|
||||
x: nextViewport.x,
|
||||
y: nextViewport.y,
|
||||
zoom: clampTeamWorkspaceCanvasZoom(nextViewport.zoom),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function updateTeamWorkspaceCanvasLaneLayoutState(
|
||||
previous: TeamWorkspaceCanvasLayoutState,
|
||||
persistKey: string,
|
||||
updater: (
|
||||
current: TeamWorkspaceCanvasItemLayout,
|
||||
) => TeamWorkspaceCanvasItemLayout,
|
||||
): TeamWorkspaceCanvasLayoutState {
|
||||
const current =
|
||||
previous.items[persistKey] ?? buildDefaultTeamWorkspaceCanvasItemLayout(0);
|
||||
const next = updater(current);
|
||||
|
||||
if (
|
||||
next.x === current.x &&
|
||||
next.y === current.y &&
|
||||
next.width === current.width &&
|
||||
next.height === current.height &&
|
||||
next.zIndex === current.zIndex
|
||||
) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return {
|
||||
...previous,
|
||||
updatedAt: Date.now(),
|
||||
items: {
|
||||
...previous.items,
|
||||
[persistKey]: {
|
||||
x: next.x,
|
||||
y: next.y,
|
||||
width: Math.max(TEAM_WORKSPACE_CANVAS_MIN_WIDTH, next.width),
|
||||
height: Math.max(TEAM_WORKSPACE_CANVAS_MIN_HEIGHT, next.height),
|
||||
zIndex: Math.max(1, next.zIndex),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function bringTeamWorkspaceCanvasLaneToFront(
|
||||
previous: TeamWorkspaceCanvasLayoutState,
|
||||
persistKey: string,
|
||||
): TeamWorkspaceCanvasLayoutState {
|
||||
const target = previous.items[persistKey];
|
||||
if (!target) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const maxZIndex = Math.max(
|
||||
1,
|
||||
...Object.values(previous.items).map((item) => item.zIndex),
|
||||
);
|
||||
if (target.zIndex >= maxZIndex) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return {
|
||||
...previous,
|
||||
updatedAt: Date.now(),
|
||||
items: {
|
||||
...previous.items,
|
||||
[persistKey]: {
|
||||
...target,
|
||||
zIndex: maxZIndex + 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getAgentRuntimeSession } from "@/lib/api/agentRuntime";
|
||||
import {
|
||||
buildPreviewableRailSessionsSyncKey,
|
||||
collectStaleSessionActivityTargets,
|
||||
extractSessionActivitySnapshot,
|
||||
type ActivityPreviewSession,
|
||||
type SelectedSessionActivityState,
|
||||
type SessionActivityPreviewState,
|
||||
} from "../../team-workspace-runtime/activityPreviewSelectors";
|
||||
|
||||
const DEFAULT_ACTIVITY_PREVIEW_POLL_INTERVAL_MS = 1500;
|
||||
|
||||
interface UseTeamWorkspaceActivityPreviewSyncParams {
|
||||
activityRefreshVersionBySessionId?: Record<string, number>;
|
||||
activityTimelineEntryLimit: number;
|
||||
basePreviewableRailSessions: ActivityPreviewSession[];
|
||||
pollIntervalMs?: number;
|
||||
selectedSessionActivityState: SelectedSessionActivityState;
|
||||
}
|
||||
|
||||
export function useTeamWorkspaceActivityPreviewSync({
|
||||
activityRefreshVersionBySessionId = {},
|
||||
activityTimelineEntryLimit,
|
||||
basePreviewableRailSessions,
|
||||
pollIntervalMs = DEFAULT_ACTIVITY_PREVIEW_POLL_INTERVAL_MS,
|
||||
selectedSessionActivityState,
|
||||
}: UseTeamWorkspaceActivityPreviewSyncParams) {
|
||||
const [sessionActivityPreviewById, setSessionActivityPreviewById] = useState<
|
||||
Record<string, SessionActivityPreviewState>
|
||||
>({});
|
||||
const sessionActivityPreviewByIdRef = useRef<
|
||||
Record<string, SessionActivityPreviewState>
|
||||
>({});
|
||||
const pendingSessionActivityRequestsRef = useRef(new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
sessionActivityPreviewByIdRef.current = sessionActivityPreviewById;
|
||||
}, [sessionActivityPreviewById]);
|
||||
|
||||
const previewableRailSessionsSyncKey = useMemo(
|
||||
() =>
|
||||
buildPreviewableRailSessionsSyncKey({
|
||||
sessions: basePreviewableRailSessions,
|
||||
activityRefreshVersionBySessionId,
|
||||
}),
|
||||
[activityRefreshVersionBySessionId, basePreviewableRailSessions],
|
||||
);
|
||||
|
||||
const syncSessionActivityPreview = useCallback(
|
||||
async (
|
||||
sessionId: string,
|
||||
fingerprint: string,
|
||||
refreshVersion = 0,
|
||||
options?: { force?: boolean },
|
||||
) => {
|
||||
const current = sessionActivityPreviewByIdRef.current[sessionId];
|
||||
const shouldForceRefresh =
|
||||
options?.force || (current?.refreshVersion ?? 0) < refreshVersion;
|
||||
if (
|
||||
!shouldForceRefresh &&
|
||||
current?.status === "ready" &&
|
||||
current.fingerprint === fingerprint &&
|
||||
(current.refreshVersion ?? 0) === refreshVersion
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingSessionActivityRequestsRef.current.has(sessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingSessionActivityRequestsRef.current.add(sessionId);
|
||||
setSessionActivityPreviewById((previous) => {
|
||||
const currentState = previous[sessionId];
|
||||
if (
|
||||
currentState?.status === "loading" &&
|
||||
currentState.fingerprint === fingerprint
|
||||
) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return {
|
||||
...previous,
|
||||
[sessionId]: {
|
||||
preview: currentState?.preview ?? null,
|
||||
entries: currentState?.entries ?? [],
|
||||
status: "loading",
|
||||
errorMessage: undefined,
|
||||
fingerprint,
|
||||
refreshVersion,
|
||||
syncedAt: currentState?.syncedAt,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const detail = await getAgentRuntimeSession(sessionId);
|
||||
const activitySnapshot = extractSessionActivitySnapshot(
|
||||
detail,
|
||||
activityTimelineEntryLimit,
|
||||
);
|
||||
const syncedAt = Date.now();
|
||||
setSessionActivityPreviewById((previous) => ({
|
||||
...previous,
|
||||
[sessionId]: {
|
||||
preview: activitySnapshot.preview,
|
||||
entries: activitySnapshot.entries,
|
||||
status: "ready",
|
||||
errorMessage: undefined,
|
||||
fingerprint,
|
||||
refreshVersion,
|
||||
syncedAt,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
setSessionActivityPreviewById((previous) => ({
|
||||
...previous,
|
||||
[sessionId]: {
|
||||
preview: previous[sessionId]?.preview ?? null,
|
||||
entries: previous[sessionId]?.entries ?? [],
|
||||
status: "error",
|
||||
errorMessage:
|
||||
error instanceof Error ? error.message : "同步最近过程失败",
|
||||
fingerprint,
|
||||
refreshVersion,
|
||||
syncedAt: previous[sessionId]?.syncedAt,
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
pendingSessionActivityRequestsRef.current.delete(sessionId);
|
||||
}
|
||||
},
|
||||
[activityTimelineEntryLimit],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!selectedSessionActivityState.activityId ||
|
||||
!selectedSessionActivityState.fingerprint
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = selectedSessionActivityState.activityId;
|
||||
const fingerprint = selectedSessionActivityState.fingerprint;
|
||||
let pollTimer: number | null = null;
|
||||
const cachedPreview = sessionActivityPreviewByIdRef.current[sessionId];
|
||||
|
||||
if (
|
||||
!selectedSessionActivityState.shouldPoll &&
|
||||
cachedPreview?.status === "ready" &&
|
||||
cachedPreview.fingerprint === fingerprint &&
|
||||
(cachedPreview.refreshVersion ?? 0) ===
|
||||
selectedSessionActivityState.refreshVersion
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncSelectedSessionActivity = async () => {
|
||||
const current = sessionActivityPreviewByIdRef.current[sessionId];
|
||||
await syncSessionActivityPreview(
|
||||
sessionId,
|
||||
fingerprint,
|
||||
selectedSessionActivityState.refreshVersion,
|
||||
{
|
||||
force:
|
||||
selectedSessionActivityState.shouldPoll ||
|
||||
(current?.refreshVersion ?? 0) <
|
||||
selectedSessionActivityState.refreshVersion,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
void syncSelectedSessionActivity();
|
||||
|
||||
if (selectedSessionActivityState.shouldPoll) {
|
||||
pollTimer = window.setInterval(() => {
|
||||
void syncSelectedSessionActivity();
|
||||
}, pollIntervalMs);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollTimer !== null) {
|
||||
window.clearInterval(pollTimer);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
pollIntervalMs,
|
||||
selectedSessionActivityState.activityId,
|
||||
selectedSessionActivityState.fingerprint,
|
||||
selectedSessionActivityState.refreshVersion,
|
||||
selectedSessionActivityState.shouldPoll,
|
||||
syncSessionActivityPreview,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const staleTargets = collectStaleSessionActivityTargets({
|
||||
sessions: basePreviewableRailSessions,
|
||||
previewBySessionId: sessionActivityPreviewByIdRef.current,
|
||||
activityRefreshVersionBySessionId,
|
||||
});
|
||||
|
||||
if (staleTargets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const prefetchPreviews = async () => {
|
||||
await Promise.allSettled(
|
||||
staleTargets.map((target) => {
|
||||
if (cancelled) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return syncSessionActivityPreview(
|
||||
target.sessionId,
|
||||
target.fingerprint,
|
||||
target.refreshVersion,
|
||||
{
|
||||
force: true,
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
void prefetchPreviews();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
activityRefreshVersionBySessionId,
|
||||
basePreviewableRailSessions,
|
||||
previewableRailSessionsSyncKey,
|
||||
syncSessionActivityPreview,
|
||||
]);
|
||||
|
||||
return {
|
||||
sessionActivityPreviewById,
|
||||
};
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AsterSessionDetail } from "@/lib/api/agentRuntime";
|
||||
import type { ActivityPreviewSession } from "../../team-workspace-runtime/activityPreviewSelectors";
|
||||
import { useTeamWorkspaceActivityPreviews } from "./useTeamWorkspaceActivityPreviews";
|
||||
|
||||
const { mockGetAgentRuntimeSession } = vi.hoisted(() => ({
|
||||
mockGetAgentRuntimeSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/agentRuntime", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/agentRuntime")>(
|
||||
"@/lib/api/agentRuntime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getAgentRuntimeSession: mockGetAgentRuntimeSession,
|
||||
};
|
||||
});
|
||||
|
||||
type HookProps = Parameters<typeof useTeamWorkspaceActivityPreviews>[0];
|
||||
|
||||
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
|
||||
|
||||
function createSessionDetail(
|
||||
sessionId: string,
|
||||
text: string,
|
||||
): AsterSessionDetail {
|
||||
return {
|
||||
id: sessionId,
|
||||
created_at: 1_710_000_000,
|
||||
updated_at: 1_710_000_100,
|
||||
messages: [],
|
||||
items: [
|
||||
{
|
||||
id: `${sessionId}-item-1`,
|
||||
thread_id: `${sessionId}-thread`,
|
||||
turn_id: `${sessionId}-turn`,
|
||||
sequence: 1,
|
||||
status: "completed",
|
||||
started_at: "2026-03-20T10:00:00Z",
|
||||
updated_at: "2026-03-20T10:00:03Z",
|
||||
type: "agent_message",
|
||||
text,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createPreviewSession(
|
||||
id: string,
|
||||
overrides?: Partial<ActivityPreviewSession>,
|
||||
): ActivityPreviewSession {
|
||||
return {
|
||||
id,
|
||||
sessionType: "sub_agent",
|
||||
runtimeStatus: "completed",
|
||||
latestTurnStatus: "completed",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushHookEffects() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function renderHook(props?: Partial<HookProps>) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
let latestValue: ReturnType<typeof useTeamWorkspaceActivityPreviews> | null =
|
||||
null;
|
||||
|
||||
const defaultProps: HookProps = {
|
||||
activityRefreshVersionBySessionId: {},
|
||||
activityTimelineEntryLimit: 4,
|
||||
basePreviewableRailSessions: [],
|
||||
liveActivityBySessionId: {},
|
||||
selectedBaseSession: null,
|
||||
selectedSession: null,
|
||||
};
|
||||
|
||||
function Probe(currentProps: HookProps) {
|
||||
latestValue = useTeamWorkspaceActivityPreviews(currentProps);
|
||||
return null;
|
||||
}
|
||||
|
||||
const render = async (nextProps?: Partial<HookProps>) => {
|
||||
await act(async () => {
|
||||
root.render(<Probe {...defaultProps} {...props} {...nextProps} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushHookEffects();
|
||||
};
|
||||
|
||||
mountedRoots.push({ root, container });
|
||||
|
||||
return {
|
||||
getValue: () => {
|
||||
if (!latestValue) {
|
||||
throw new Error("hook 尚未初始化");
|
||||
}
|
||||
return latestValue;
|
||||
},
|
||||
render,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
mockGetAgentRuntimeSession.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
mounted.root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("useTeamWorkspaceActivityPreviews", () => {
|
||||
it("refreshVersion 变化时应重新抓取 stale preview", async () => {
|
||||
const session = createPreviewSession("child-refresh-1");
|
||||
mockGetAgentRuntimeSession
|
||||
.mockResolvedValueOnce(
|
||||
createSessionDetail("child-refresh-1", "首轮同步完成。"),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createSessionDetail("child-refresh-1", "第二轮同步已更新。"),
|
||||
);
|
||||
|
||||
const harness = renderHook({
|
||||
basePreviewableRailSessions: [session],
|
||||
});
|
||||
|
||||
await harness.render();
|
||||
|
||||
expect(mockGetAgentRuntimeSession).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
harness.getValue().sessionActivityPreviewById["child-refresh-1"]?.preview,
|
||||
).toContain("首轮同步完成");
|
||||
|
||||
await harness.render({
|
||||
activityRefreshVersionBySessionId: {
|
||||
"child-refresh-1": 1,
|
||||
},
|
||||
basePreviewableRailSessions: [session],
|
||||
});
|
||||
|
||||
expect(mockGetAgentRuntimeSession).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
harness.getValue().sessionActivityPreviewById["child-refresh-1"]?.preview,
|
||||
).toContain("第二轮同步已更新");
|
||||
});
|
||||
|
||||
it("选中运行中的成员时应轮询,完成后应停止继续轮询", async () => {
|
||||
vi.useFakeTimers();
|
||||
const runningSession = createPreviewSession("child-poll-1", {
|
||||
runtimeStatus: "running",
|
||||
latestTurnStatus: "running",
|
||||
});
|
||||
const completedSession = createPreviewSession("child-poll-1", {
|
||||
runtimeStatus: "completed",
|
||||
latestTurnStatus: "completed",
|
||||
});
|
||||
mockGetAgentRuntimeSession.mockResolvedValue(
|
||||
createSessionDetail("child-poll-1", "轮询中的最新过程。"),
|
||||
);
|
||||
|
||||
const harness = renderHook({
|
||||
pollIntervalMs: 1200,
|
||||
selectedBaseSession: runningSession,
|
||||
selectedSession: runningSession,
|
||||
});
|
||||
|
||||
await harness.render();
|
||||
|
||||
expect(mockGetAgentRuntimeSession).toHaveBeenCalledTimes(1);
|
||||
expect(harness.getValue().selectedSessionActivityState.shouldPoll).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1200);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushHookEffects();
|
||||
|
||||
expect(mockGetAgentRuntimeSession).toHaveBeenCalledTimes(2);
|
||||
|
||||
await harness.render({
|
||||
pollIntervalMs: 1200,
|
||||
selectedBaseSession: completedSession,
|
||||
selectedSession: completedSession,
|
||||
});
|
||||
|
||||
const callsAfterCompleted = mockGetAgentRuntimeSession.mock.calls.length;
|
||||
expect(harness.getValue().selectedSessionActivityState.shouldPoll).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2400);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushHookEffects();
|
||||
|
||||
expect(mockGetAgentRuntimeSession).toHaveBeenCalledTimes(
|
||||
callsAfterCompleted,
|
||||
);
|
||||
});
|
||||
});
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { useMemo } from "react";
|
||||
import type { TeamWorkspaceActivityEntry } from "../../teamWorkspaceRuntime";
|
||||
import {
|
||||
buildSelectedSessionActivityState,
|
||||
type ActivityPreviewSession,
|
||||
type SelectedSessionActivityState,
|
||||
} from "../../team-workspace-runtime/activityPreviewSelectors";
|
||||
import { useTeamWorkspaceActivityPreviewSync } from "./useTeamWorkspaceActivityPreviewSync";
|
||||
|
||||
interface UseTeamWorkspaceActivityPreviewsParams {
|
||||
activityRefreshVersionBySessionId?: Record<string, number>;
|
||||
activityTimelineEntryLimit: number;
|
||||
basePreviewableRailSessions: ActivityPreviewSession[];
|
||||
liveActivityBySessionId?: Record<string, TeamWorkspaceActivityEntry[]>;
|
||||
pollIntervalMs?: number;
|
||||
selectedBaseSession?: ActivityPreviewSession | null;
|
||||
selectedSession?: ActivityPreviewSession | null;
|
||||
}
|
||||
|
||||
export function useTeamWorkspaceActivityPreviews({
|
||||
activityRefreshVersionBySessionId = {},
|
||||
activityTimelineEntryLimit,
|
||||
basePreviewableRailSessions,
|
||||
liveActivityBySessionId = {},
|
||||
pollIntervalMs,
|
||||
selectedBaseSession = null,
|
||||
selectedSession = null,
|
||||
}: UseTeamWorkspaceActivityPreviewsParams) {
|
||||
const selectedSessionActivitySyncTarget =
|
||||
useMemo<SelectedSessionActivityState>(
|
||||
() =>
|
||||
buildSelectedSessionActivityState({
|
||||
selectedSession,
|
||||
selectedBaseSession,
|
||||
activityRefreshVersionBySessionId,
|
||||
activityTimelineEntryLimit,
|
||||
}),
|
||||
[
|
||||
activityRefreshVersionBySessionId,
|
||||
activityTimelineEntryLimit,
|
||||
selectedBaseSession,
|
||||
selectedSession,
|
||||
],
|
||||
);
|
||||
const { sessionActivityPreviewById } = useTeamWorkspaceActivityPreviewSync({
|
||||
activityRefreshVersionBySessionId,
|
||||
activityTimelineEntryLimit,
|
||||
basePreviewableRailSessions,
|
||||
pollIntervalMs,
|
||||
selectedSessionActivityState: selectedSessionActivitySyncTarget,
|
||||
});
|
||||
const selectedSessionActivityState = useMemo<SelectedSessionActivityState>(
|
||||
() =>
|
||||
buildSelectedSessionActivityState({
|
||||
selectedSession,
|
||||
selectedBaseSession,
|
||||
liveActivityBySessionId,
|
||||
previewBySessionId: sessionActivityPreviewById,
|
||||
activityRefreshVersionBySessionId,
|
||||
activityTimelineEntryLimit,
|
||||
}),
|
||||
[
|
||||
activityRefreshVersionBySessionId,
|
||||
activityTimelineEntryLimit,
|
||||
liveActivityBySessionId,
|
||||
selectedBaseSession,
|
||||
selectedSession,
|
||||
sessionActivityPreviewById,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedSessionActivityState,
|
||||
sessionActivityPreviewById,
|
||||
};
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useTeamWorkspaceBoardActions } from "./useTeamWorkspaceBoardActions";
|
||||
|
||||
type HookProps = Parameters<typeof useTeamWorkspaceBoardActions>[0];
|
||||
|
||||
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
|
||||
|
||||
function createDeferred() {
|
||||
let resolvePromise: () => void = () => {};
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
return {
|
||||
promise,
|
||||
resolve: resolvePromise,
|
||||
};
|
||||
}
|
||||
|
||||
function renderHook(props?: Partial<HookProps>) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
let latestValue: ReturnType<typeof useTeamWorkspaceBoardActions> | null = null;
|
||||
|
||||
const defaultProps: HookProps = {
|
||||
completedTeamSessionIds: [],
|
||||
selectedSession: {
|
||||
id: "child-1",
|
||||
},
|
||||
waitableTeamSessionIds: [],
|
||||
};
|
||||
|
||||
function Probe(currentProps: HookProps) {
|
||||
latestValue = useTeamWorkspaceBoardActions(currentProps);
|
||||
return null;
|
||||
}
|
||||
|
||||
const render = async (nextProps?: Partial<HookProps>) => {
|
||||
await act(async () => {
|
||||
root.render(<Probe {...defaultProps} {...props} {...nextProps} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
mountedRoots.push({ root, container });
|
||||
|
||||
return {
|
||||
render,
|
||||
getValue: () => {
|
||||
if (!latestValue) {
|
||||
throw new Error("hook 尚未初始化");
|
||||
}
|
||||
return latestValue;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
mounted.root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe("useTeamWorkspaceBoardActions", () => {
|
||||
it("应维护当前选中成员的输入草稿,并在发送后清空", async () => {
|
||||
const onSendSubagentInput = vi.fn().mockResolvedValue(undefined);
|
||||
const harness = renderHook({
|
||||
onSendSubagentInput,
|
||||
});
|
||||
|
||||
await harness.render();
|
||||
|
||||
await act(async () => {
|
||||
harness.getValue().handleSelectedSessionInputDraftChange("继续推进下一步");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(harness.getValue().selectedSessionInputDraft).toBe("继续推进下一步");
|
||||
expect(harness.getValue().selectedSessionInputMessage).toBe("继续推进下一步");
|
||||
|
||||
await act(async () => {
|
||||
await harness.getValue().handleSelectedSessionSendInput(false);
|
||||
});
|
||||
|
||||
expect(onSendSubagentInput).toHaveBeenCalledWith("child-1", "继续推进下一步", {
|
||||
interrupt: false,
|
||||
});
|
||||
expect(harness.getValue().selectedSessionInputDraft).toBe("");
|
||||
expect(harness.getValue().selectedSessionInputMessage).toBe("");
|
||||
expect(harness.getValue().selectedActionPending).toBe(false);
|
||||
});
|
||||
|
||||
it("应在等待任一活跃成员时维护 Team 级 pending 状态", async () => {
|
||||
const deferred = createDeferred();
|
||||
const onWaitActiveTeamSessions = vi.fn().mockReturnValue(deferred.promise);
|
||||
const harness = renderHook({
|
||||
onWaitActiveTeamSessions,
|
||||
waitableTeamSessionIds: ["child-1", "child-2"],
|
||||
});
|
||||
|
||||
await harness.render();
|
||||
|
||||
let task: Promise<void> | undefined;
|
||||
act(() => {
|
||||
task = harness.getValue().handleWaitAnyActiveTeamSessions();
|
||||
});
|
||||
|
||||
expect(harness.getValue().pendingTeamAction).toBe("wait_any");
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve();
|
||||
await task;
|
||||
});
|
||||
|
||||
expect(onWaitActiveTeamSessions).toHaveBeenCalledWith(
|
||||
["child-1", "child-2"],
|
||||
30_000,
|
||||
);
|
||||
expect(harness.getValue().pendingTeamAction).toBeNull();
|
||||
});
|
||||
|
||||
it("应在中断发送期间暴露 session 级 pending 状态", async () => {
|
||||
const deferred = createDeferred();
|
||||
const onSendSubagentInput = vi.fn().mockReturnValue(deferred.promise);
|
||||
const harness = renderHook({
|
||||
onSendSubagentInput,
|
||||
});
|
||||
|
||||
await harness.render();
|
||||
|
||||
await act(async () => {
|
||||
harness.getValue().handleSelectedSessionInputDraftChange("立即补充约束");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
let task: Promise<void> | undefined;
|
||||
act(() => {
|
||||
task = harness.getValue().handleSelectedSessionSendInput(true);
|
||||
});
|
||||
|
||||
expect(harness.getValue().selectedActionPending).toBe(true);
|
||||
expect(harness.getValue().pendingSessionAction).toEqual({
|
||||
sessionId: "child-1",
|
||||
action: "interrupt_send",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve();
|
||||
await task;
|
||||
});
|
||||
|
||||
expect(harness.getValue().pendingSessionAction).toBeNull();
|
||||
expect(harness.getValue().selectedActionPending).toBe(false);
|
||||
});
|
||||
});
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { TeamSessionCard } from "../../utils/teamWorkspaceSessions";
|
||||
|
||||
export type TeamWorkspacePendingSessionAction =
|
||||
| "close"
|
||||
| "interrupt_send"
|
||||
| "resume"
|
||||
| "send"
|
||||
| "wait";
|
||||
|
||||
export type TeamWorkspacePendingTeamAction =
|
||||
| "close_completed"
|
||||
| "wait_any";
|
||||
|
||||
interface UseTeamWorkspaceBoardActionsParams {
|
||||
completedTeamSessionIds: string[];
|
||||
onCloseCompletedTeamSessions?: (sessionIds: string[]) => void | Promise<void>;
|
||||
onCloseSubagentSession?: (sessionId: string) => void | Promise<void>;
|
||||
onResumeSubagentSession?: (sessionId: string) => void | Promise<void>;
|
||||
onSendSubagentInput?: (
|
||||
sessionId: string,
|
||||
message: string,
|
||||
options?: { interrupt?: boolean },
|
||||
) => void | Promise<void>;
|
||||
onWaitActiveTeamSessions?: (
|
||||
sessionIds: string[],
|
||||
timeoutMs?: number,
|
||||
) => void | Promise<void>;
|
||||
onWaitSubagentSession?: (
|
||||
sessionId: string,
|
||||
timeoutMs?: number,
|
||||
) => void | Promise<void>;
|
||||
selectedSession?: Pick<TeamSessionCard, "id"> | null;
|
||||
waitTimeoutMs?: number;
|
||||
waitableTeamSessionIds: string[];
|
||||
}
|
||||
|
||||
export function useTeamWorkspaceBoardActions({
|
||||
completedTeamSessionIds,
|
||||
onCloseCompletedTeamSessions,
|
||||
onCloseSubagentSession,
|
||||
onResumeSubagentSession,
|
||||
onSendSubagentInput,
|
||||
onWaitActiveTeamSessions,
|
||||
onWaitSubagentSession,
|
||||
selectedSession,
|
||||
waitTimeoutMs = 30_000,
|
||||
waitableTeamSessionIds,
|
||||
}: UseTeamWorkspaceBoardActionsParams) {
|
||||
const [pendingSessionAction, setPendingSessionAction] = useState<{
|
||||
sessionId: string;
|
||||
action: TeamWorkspacePendingSessionAction;
|
||||
} | null>(null);
|
||||
const [pendingTeamAction, setPendingTeamAction] =
|
||||
useState<TeamWorkspacePendingTeamAction | null>(null);
|
||||
const [sessionInputDraftById, setSessionInputDraftById] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
const selectedActionPending = Boolean(
|
||||
selectedSession && pendingSessionAction?.sessionId === selectedSession.id,
|
||||
);
|
||||
const selectedSessionInputDraft = selectedSession
|
||||
? (sessionInputDraftById[selectedSession.id] ?? "")
|
||||
: "";
|
||||
const selectedSessionInputMessage = useMemo(
|
||||
() => selectedSessionInputDraft.trim(),
|
||||
[selectedSessionInputDraft],
|
||||
);
|
||||
|
||||
const handleWaitAnyActiveTeamSessions = useCallback(async () => {
|
||||
if (!onWaitActiveTeamSessions || waitableTeamSessionIds.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingTeamAction("wait_any");
|
||||
try {
|
||||
await onWaitActiveTeamSessions(waitableTeamSessionIds, waitTimeoutMs);
|
||||
} finally {
|
||||
setPendingTeamAction(null);
|
||||
}
|
||||
}, [onWaitActiveTeamSessions, waitTimeoutMs, waitableTeamSessionIds]);
|
||||
|
||||
const handleCloseCompletedTeamSessions = useCallback(async () => {
|
||||
if (!onCloseCompletedTeamSessions || completedTeamSessionIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingTeamAction("close_completed");
|
||||
try {
|
||||
await onCloseCompletedTeamSessions(completedTeamSessionIds);
|
||||
} finally {
|
||||
setPendingTeamAction(null);
|
||||
}
|
||||
}, [completedTeamSessionIds, onCloseCompletedTeamSessions]);
|
||||
|
||||
const handleSelectedSessionAction = useCallback(
|
||||
async (action: "close" | "resume" | "wait") => {
|
||||
if (!selectedSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingSessionAction({ sessionId: selectedSession.id, action });
|
||||
try {
|
||||
if (action === "close") {
|
||||
await onCloseSubagentSession?.(selectedSession.id);
|
||||
return;
|
||||
}
|
||||
if (action === "resume") {
|
||||
await onResumeSubagentSession?.(selectedSession.id);
|
||||
return;
|
||||
}
|
||||
await onWaitSubagentSession?.(selectedSession.id, waitTimeoutMs);
|
||||
} finally {
|
||||
setPendingSessionAction((current) =>
|
||||
current?.sessionId === selectedSession.id ? null : current,
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
onCloseSubagentSession,
|
||||
onResumeSubagentSession,
|
||||
onWaitSubagentSession,
|
||||
selectedSession,
|
||||
waitTimeoutMs,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSelectedSessionInputDraftChange = useCallback(
|
||||
(value: string) => {
|
||||
if (!selectedSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSessionInputDraftById((previous) => {
|
||||
if (previous[selectedSession.id] === value) {
|
||||
return previous;
|
||||
}
|
||||
return {
|
||||
...previous,
|
||||
[selectedSession.id]: value,
|
||||
};
|
||||
});
|
||||
},
|
||||
[selectedSession],
|
||||
);
|
||||
|
||||
const handleSelectedSessionSendInput = useCallback(
|
||||
async (interrupt: boolean) => {
|
||||
if (!selectedSession || !selectedSessionInputMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = interrupt ? "interrupt_send" : "send";
|
||||
const sessionId = selectedSession.id;
|
||||
setPendingSessionAction({ sessionId, action });
|
||||
try {
|
||||
await onSendSubagentInput?.(sessionId, selectedSessionInputMessage, {
|
||||
interrupt,
|
||||
});
|
||||
setSessionInputDraftById((previous) => {
|
||||
if (!previous[sessionId]) {
|
||||
return previous;
|
||||
}
|
||||
return {
|
||||
...previous,
|
||||
[sessionId]: "",
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
setPendingSessionAction((current) =>
|
||||
current?.sessionId === sessionId ? null : current,
|
||||
);
|
||||
}
|
||||
},
|
||||
[onSendSubagentInput, selectedSession, selectedSessionInputMessage],
|
||||
);
|
||||
|
||||
return {
|
||||
handleCloseCompletedTeamSessions,
|
||||
handleSelectedSessionAction,
|
||||
handleSelectedSessionInputDraftChange,
|
||||
handleSelectedSessionSendInput,
|
||||
handleWaitAnyActiveTeamSessions,
|
||||
pendingSessionAction,
|
||||
pendingTeamAction,
|
||||
selectedActionPending,
|
||||
selectedSessionInputDraft,
|
||||
selectedSessionInputMessage,
|
||||
};
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
TeamWorkspaceActivityEntry,
|
||||
TeamWorkspaceRuntimeFormationState,
|
||||
} from "../../teamWorkspaceRuntime";
|
||||
import type { SessionActivityPreviewState } from "../../team-workspace-runtime/activityPreviewSelectors";
|
||||
import {
|
||||
buildTeamWorkspaceCanvasLanes,
|
||||
type TeamWorkspaceCanvasLane,
|
||||
} from "../../team-workspace-runtime/canvasLaneSelectors";
|
||||
import type { TeamRoleDefinition } from "../../utils/teamDefinitions";
|
||||
import { buildCanvasStageHint } from "../../utils/teamWorkspaceCanvas";
|
||||
import type { TeamSessionCard } from "../../utils/teamWorkspaceSessions";
|
||||
import { useTeamWorkspaceCanvasController } from "./useTeamWorkspaceCanvasController";
|
||||
|
||||
interface UseTeamWorkspaceBoardCanvasRuntimeParams {
|
||||
activityTimelineEntryLimit: number;
|
||||
canvasStorageScopeId: string;
|
||||
canvasViewportFallbackHeight: number;
|
||||
embedded: boolean;
|
||||
expandedSessionId?: string | null;
|
||||
focusSession: (sessionId: string) => void;
|
||||
hasRealTeamGraph: boolean;
|
||||
hasRuntimeFormation: boolean;
|
||||
hasSelectedTeamPlan: boolean;
|
||||
liveActivityBySessionId?: Record<string, TeamWorkspaceActivityEntry[]>;
|
||||
memberCanvasSessions: TeamSessionCard[];
|
||||
plannedRoles: TeamRoleDefinition[];
|
||||
previewBySessionId: Record<string, SessionActivityPreviewState>;
|
||||
teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null;
|
||||
}
|
||||
|
||||
interface TeamWorkspaceBoardCanvasRuntimeState
|
||||
extends ReturnType<typeof useTeamWorkspaceCanvasController> {
|
||||
canvasLanes: TeamWorkspaceCanvasLane[];
|
||||
canvasStageHint: string;
|
||||
}
|
||||
|
||||
export function useTeamWorkspaceBoardCanvasRuntime({
|
||||
activityTimelineEntryLimit,
|
||||
canvasStorageScopeId,
|
||||
canvasViewportFallbackHeight,
|
||||
embedded,
|
||||
expandedSessionId = null,
|
||||
focusSession,
|
||||
hasRealTeamGraph,
|
||||
hasRuntimeFormation,
|
||||
hasSelectedTeamPlan,
|
||||
liveActivityBySessionId = {},
|
||||
memberCanvasSessions,
|
||||
plannedRoles,
|
||||
previewBySessionId,
|
||||
teamDispatchPreviewState = null,
|
||||
}: UseTeamWorkspaceBoardCanvasRuntimeParams): TeamWorkspaceBoardCanvasRuntimeState {
|
||||
const canvasLanes = useMemo<TeamWorkspaceCanvasLane[]>(
|
||||
() =>
|
||||
buildTeamWorkspaceCanvasLanes({
|
||||
hasRealTeamGraph,
|
||||
sessions: memberCanvasSessions,
|
||||
runtimeMembers: teamDispatchPreviewState?.members ?? [],
|
||||
plannedRoles,
|
||||
liveActivityBySessionId,
|
||||
previewBySessionId,
|
||||
activityTimelineEntryLimit,
|
||||
}),
|
||||
[
|
||||
activityTimelineEntryLimit,
|
||||
hasRealTeamGraph,
|
||||
liveActivityBySessionId,
|
||||
memberCanvasSessions,
|
||||
plannedRoles,
|
||||
previewBySessionId,
|
||||
teamDispatchPreviewState?.members,
|
||||
],
|
||||
);
|
||||
const canvasControllerState = useTeamWorkspaceCanvasController({
|
||||
canvasLanes,
|
||||
canvasStorageScopeId,
|
||||
canvasViewportFallbackHeight,
|
||||
embedded,
|
||||
expandedSessionId,
|
||||
onSelectSession: focusSession,
|
||||
});
|
||||
const canvasStageHint = buildCanvasStageHint({
|
||||
hasRealTeamGraph,
|
||||
hasRuntimeFormation,
|
||||
hasSelectedTeamPlan,
|
||||
teamDispatchPreviewState,
|
||||
});
|
||||
|
||||
return {
|
||||
...canvasControllerState,
|
||||
canvasLanes,
|
||||
canvasStageHint,
|
||||
};
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
import type { AsterSubagentParentContext } from "@/lib/api/agentRuntime";
|
||||
import type {
|
||||
TeamWorkspaceActivityEntry,
|
||||
TeamWorkspaceControlSummary,
|
||||
TeamWorkspaceRuntimeFormationState,
|
||||
TeamWorkspaceWaitSummary,
|
||||
} from "../../teamWorkspaceRuntime";
|
||||
import { useTeamWorkspaceActivityPreviews } from "./useTeamWorkspaceActivityPreviews";
|
||||
import { useTeamWorkspaceBoardCanvasRuntime } from "./useTeamWorkspaceBoardCanvasRuntime";
|
||||
import { useTeamWorkspaceBoardFormationState } from "./useTeamWorkspaceBoardFormationState";
|
||||
import { useTeamWorkspaceBoardPresentation } from "./useTeamWorkspaceBoardPresentation";
|
||||
import { useTeamWorkspaceBoardRuntimeState } from "./useTeamWorkspaceBoardRuntimeState";
|
||||
import { useTeamWorkspaceBoardShellProps } from "./useTeamWorkspaceBoardShellProps";
|
||||
import { useTeamWorkspaceBoardSessionGraph } from "./useTeamWorkspaceBoardSessionGraph";
|
||||
|
||||
const DEFAULT_ACTIVITY_TIMELINE_ENTRY_LIMIT = 4;
|
||||
const DEFAULT_WAIT_SELECTED_SUBAGENT_TIMEOUT_MS = 30_000;
|
||||
|
||||
interface UseTeamWorkspaceBoardComposerParams {
|
||||
activityRefreshVersionBySessionId?: Record<string, number>;
|
||||
canvasViewportFallbackHeight: number;
|
||||
className?: string;
|
||||
currentSessionId?: string | null;
|
||||
detailExpanded: boolean;
|
||||
embedded: boolean;
|
||||
formationState: ReturnType<typeof useTeamWorkspaceBoardFormationState>;
|
||||
liveActivityBySessionId?: Record<string, TeamWorkspaceActivityEntry[]>;
|
||||
onCloseCompletedTeamSessions?: (sessionIds: string[]) => void | Promise<void>;
|
||||
onCloseSubagentSession?: (sessionId: string) => void | Promise<void>;
|
||||
onExpandEmptyShell: () => void;
|
||||
onOpenSubagentSession?: (sessionId: string) => void | Promise<void>;
|
||||
onResumeSubagentSession?: (sessionId: string) => void | Promise<void>;
|
||||
onReturnToParentSession?: () => void | Promise<void>;
|
||||
onSendSubagentInput?: (
|
||||
sessionId: string,
|
||||
message: string,
|
||||
options?: { interrupt?: boolean },
|
||||
) => void | Promise<void>;
|
||||
onWaitActiveTeamSessions?: (
|
||||
sessionIds: string[],
|
||||
timeoutMs?: number,
|
||||
) => void | Promise<void>;
|
||||
onWaitSubagentSession?: (
|
||||
sessionId: string,
|
||||
timeoutMs?: number,
|
||||
) => void | Promise<void>;
|
||||
onToggleDetail: () => void;
|
||||
sessionGraphState: ReturnType<typeof useTeamWorkspaceBoardSessionGraph>;
|
||||
shellExpanded: boolean;
|
||||
subagentParentContext?: AsterSubagentParentContext | null;
|
||||
teamControlSummary?: TeamWorkspaceControlSummary | null;
|
||||
teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null;
|
||||
teamWaitSummary?: TeamWorkspaceWaitSummary | null;
|
||||
}
|
||||
|
||||
type TeamWorkspaceBoardComposerState = ReturnType<
|
||||
typeof useTeamWorkspaceBoardShellProps
|
||||
>;
|
||||
|
||||
export function useTeamWorkspaceBoardComposer({
|
||||
activityRefreshVersionBySessionId = {},
|
||||
canvasViewportFallbackHeight,
|
||||
className,
|
||||
currentSessionId,
|
||||
detailExpanded,
|
||||
embedded,
|
||||
formationState,
|
||||
liveActivityBySessionId = {},
|
||||
onCloseCompletedTeamSessions,
|
||||
onCloseSubagentSession,
|
||||
onExpandEmptyShell,
|
||||
onOpenSubagentSession,
|
||||
onResumeSubagentSession,
|
||||
onReturnToParentSession,
|
||||
onSendSubagentInput,
|
||||
onToggleDetail,
|
||||
onWaitActiveTeamSessions,
|
||||
onWaitSubagentSession,
|
||||
sessionGraphState,
|
||||
shellExpanded,
|
||||
subagentParentContext = null,
|
||||
teamControlSummary = null,
|
||||
teamDispatchPreviewState = null,
|
||||
teamWaitSummary = null,
|
||||
}: UseTeamWorkspaceBoardComposerParams): TeamWorkspaceBoardComposerState {
|
||||
const runtimeState = useTeamWorkspaceBoardRuntimeState({
|
||||
baseRailSessions: sessionGraphState.baseRailSessions,
|
||||
currentChildSession: sessionGraphState.currentChildSession,
|
||||
currentSessionId,
|
||||
isChildSession: sessionGraphState.isChildSession,
|
||||
memberCanvasSessions: sessionGraphState.memberCanvasSessions,
|
||||
onCloseCompletedTeamSessions,
|
||||
onCloseSubagentSession,
|
||||
onOpenSubagentSession,
|
||||
onResumeSubagentSession,
|
||||
onSendSubagentInput,
|
||||
onWaitActiveTeamSessions,
|
||||
onWaitSubagentSession,
|
||||
orchestratorSessionId: sessionGraphState.orchestratorSession?.id,
|
||||
railSessions: sessionGraphState.railSessions,
|
||||
teamControlSummary,
|
||||
teamWaitSummary,
|
||||
visibleSessions: sessionGraphState.visibleSessions,
|
||||
waitTimeoutMs: DEFAULT_WAIT_SELECTED_SUBAGENT_TIMEOUT_MS,
|
||||
});
|
||||
const { selectedSessionActivityState, sessionActivityPreviewById } =
|
||||
useTeamWorkspaceActivityPreviews({
|
||||
activityRefreshVersionBySessionId,
|
||||
activityTimelineEntryLimit: DEFAULT_ACTIVITY_TIMELINE_ENTRY_LIMIT,
|
||||
basePreviewableRailSessions: sessionGraphState.basePreviewableRailSessions,
|
||||
liveActivityBySessionId,
|
||||
selectedBaseSession: runtimeState.selectedBaseSession,
|
||||
selectedSession: runtimeState.selectedSession,
|
||||
});
|
||||
const canvasRuntimeState = useTeamWorkspaceBoardCanvasRuntime({
|
||||
activityTimelineEntryLimit: DEFAULT_ACTIVITY_TIMELINE_ENTRY_LIMIT,
|
||||
canvasStorageScopeId: sessionGraphState.canvasStorageScopeId,
|
||||
canvasViewportFallbackHeight,
|
||||
embedded,
|
||||
expandedSessionId: runtimeState.expandedSessionId,
|
||||
focusSession: runtimeState.focusSession,
|
||||
hasRealTeamGraph: sessionGraphState.hasRealTeamGraph,
|
||||
hasRuntimeFormation: formationState.hasRuntimeFormation,
|
||||
hasSelectedTeamPlan: formationState.hasSelectedTeamPlan,
|
||||
liveActivityBySessionId,
|
||||
memberCanvasSessions: sessionGraphState.memberCanvasSessions,
|
||||
plannedRoles: formationState.plannedRoles,
|
||||
previewBySessionId: sessionActivityPreviewById,
|
||||
teamDispatchPreviewState,
|
||||
});
|
||||
const presentationState = useTeamWorkspaceBoardPresentation({
|
||||
canCloseCompletedTeamSessions: runtimeState.canCloseCompletedTeamSessions,
|
||||
canWaitAnyActiveTeamSession: runtimeState.canWaitAnyActiveTeamSession,
|
||||
className,
|
||||
completedCount: runtimeState.completedTeamSessionIds.length,
|
||||
detailExpanded,
|
||||
dispatchPreviewStatus: teamDispatchPreviewState?.status,
|
||||
embedded,
|
||||
hasRealTeamGraph: sessionGraphState.hasRealTeamGraph,
|
||||
hasRuntimeFormation: formationState.hasRuntimeFormation,
|
||||
isChildSession: sessionGraphState.isChildSession,
|
||||
isEmptyShellState: sessionGraphState.isEmptyShellState,
|
||||
parentSessionName: subagentParentContext?.parent_session_name,
|
||||
runtimeFormationDisplay: formationState.runtimeFormationDisplay,
|
||||
selectedSession: runtimeState.selectedSession,
|
||||
shellExpanded,
|
||||
siblingCount: sessionGraphState.siblingCount,
|
||||
statusSummary: runtimeState.statusSummary,
|
||||
totalTeamSessions: sessionGraphState.totalTeamSessions,
|
||||
visibleSessionsCount: sessionGraphState.visibleSessions.length,
|
||||
waitableCount: runtimeState.waitableTeamSessionIds.length,
|
||||
zoom: canvasRuntimeState.zoom,
|
||||
});
|
||||
return useTeamWorkspaceBoardShellProps({
|
||||
className,
|
||||
embedded,
|
||||
canvasRuntimeState,
|
||||
formationState,
|
||||
onExpandEmptyShell,
|
||||
onOpenSubagentSession,
|
||||
onReturnToParentSession,
|
||||
onToggleDetail,
|
||||
presentationState,
|
||||
runtimeState,
|
||||
selectedSessionActivityState,
|
||||
sessionGraphState,
|
||||
subagentParentContext,
|
||||
});
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
TeamWorkspaceRuntimeFormationDisplayState,
|
||||
TeamWorkspaceSelectedTeamPlanDisplayState,
|
||||
} from "../../team-workspace-runtime/formationDisplaySelectors";
|
||||
import {
|
||||
buildRuntimeFormationDisplayState,
|
||||
buildSelectedTeamPlanDisplayState,
|
||||
} from "../../team-workspace-runtime/formationDisplaySelectors";
|
||||
import type { TeamWorkspaceRuntimeFormationState } from "../../teamWorkspaceRuntime";
|
||||
import type { TeamRoleDefinition } from "../../utils/teamDefinitions";
|
||||
|
||||
interface UseTeamWorkspaceBoardFormationStateParams {
|
||||
selectedTeamLabel?: string | null;
|
||||
selectedTeamRoles?: TeamRoleDefinition[] | null;
|
||||
selectedTeamSummary?: string | null;
|
||||
teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null;
|
||||
}
|
||||
|
||||
interface TeamWorkspaceBoardFormationState {
|
||||
hasRuntimeFormation: boolean;
|
||||
hasSelectedTeamPlan: boolean;
|
||||
plannedRoles: TeamRoleDefinition[];
|
||||
runtimeFormationDisplay: TeamWorkspaceRuntimeFormationDisplayState;
|
||||
selectedTeamPlanDisplay: TeamWorkspaceSelectedTeamPlanDisplayState;
|
||||
}
|
||||
|
||||
export function useTeamWorkspaceBoardFormationState({
|
||||
selectedTeamLabel = null,
|
||||
selectedTeamRoles = [],
|
||||
selectedTeamSummary = null,
|
||||
teamDispatchPreviewState = null,
|
||||
}: UseTeamWorkspaceBoardFormationStateParams): TeamWorkspaceBoardFormationState {
|
||||
const normalizedSelectedTeamLabel = selectedTeamLabel?.trim() || null;
|
||||
const normalizedSelectedTeamSummary = selectedTeamSummary?.trim() || null;
|
||||
const plannedRoles = useMemo(
|
||||
() => (selectedTeamRoles ?? []).filter((role) => role.label.trim()),
|
||||
[selectedTeamRoles],
|
||||
);
|
||||
const selectedTeamPlanDisplay = useMemo(
|
||||
() =>
|
||||
buildSelectedTeamPlanDisplayState({
|
||||
selectedTeamLabel: normalizedSelectedTeamLabel,
|
||||
selectedTeamSummary: normalizedSelectedTeamSummary,
|
||||
selectedTeamRoles: plannedRoles,
|
||||
}),
|
||||
[normalizedSelectedTeamLabel, normalizedSelectedTeamSummary, plannedRoles],
|
||||
);
|
||||
const runtimeFormationDisplay = useMemo(
|
||||
() =>
|
||||
buildRuntimeFormationDisplayState({
|
||||
teamDispatchPreviewState,
|
||||
fallbackLabel: normalizedSelectedTeamLabel,
|
||||
fallbackSummary: normalizedSelectedTeamSummary,
|
||||
}),
|
||||
[
|
||||
normalizedSelectedTeamLabel,
|
||||
normalizedSelectedTeamSummary,
|
||||
teamDispatchPreviewState,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
hasRuntimeFormation: runtimeFormationDisplay.hasRuntimeFormation,
|
||||
hasSelectedTeamPlan: selectedTeamPlanDisplay.hasSelectedTeamPlan,
|
||||
plannedRoles,
|
||||
runtimeFormationDisplay,
|
||||
selectedTeamPlanDisplay,
|
||||
};
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
import { useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildTeamWorkspaceBoardChromeDisplayState } from "../../team-workspace-runtime/boardChromeSelectors";
|
||||
import {
|
||||
buildSelectedSessionDetailDisplayState,
|
||||
type SelectedSessionDetailDisplayState,
|
||||
} from "../../team-workspace-runtime/selectedSessionDetailSelectors";
|
||||
import type { TeamWorkspaceRuntimeFormationDisplayState } from "../../team-workspace-runtime/formationDisplaySelectors";
|
||||
import type { TeamWorkspaceRuntimeFormationStatus } from "../../teamWorkspaceRuntime";
|
||||
import {
|
||||
buildFallbackSummary,
|
||||
resolveStatusMeta,
|
||||
type TeamSessionCard,
|
||||
} from "../../utils/teamWorkspaceSessions";
|
||||
import { TEAM_WORKSPACE_CANVAS_STAGE_HEIGHT } from "../../utils/teamWorkspaceCanvas";
|
||||
|
||||
interface UseTeamWorkspaceBoardPresentationParams {
|
||||
canCloseCompletedTeamSessions: boolean;
|
||||
canWaitAnyActiveTeamSession: boolean;
|
||||
className?: string;
|
||||
completedCount: number;
|
||||
detailExpanded: boolean;
|
||||
dispatchPreviewStatus?: TeamWorkspaceRuntimeFormationStatus | null;
|
||||
embedded: boolean;
|
||||
hasRealTeamGraph: boolean;
|
||||
hasRuntimeFormation: boolean;
|
||||
isChildSession: boolean;
|
||||
isEmptyShellState: boolean;
|
||||
parentSessionName?: string | null;
|
||||
selectedSession: TeamSessionCard | null;
|
||||
shellExpanded: boolean;
|
||||
siblingCount: number;
|
||||
statusSummary: Record<string, number>;
|
||||
totalTeamSessions: number;
|
||||
visibleSessionsCount: number;
|
||||
waitableCount: number;
|
||||
zoom: number;
|
||||
runtimeFormationDisplay: TeamWorkspaceRuntimeFormationDisplayState;
|
||||
}
|
||||
|
||||
interface TeamWorkspaceBoardPresentationState {
|
||||
boardBodyClassName: string;
|
||||
boardChromeDisplay: ReturnType<typeof buildTeamWorkspaceBoardChromeDisplayState>;
|
||||
boardHeaderClassName: string;
|
||||
boardShellClassName: string;
|
||||
canvasStageHeight: string;
|
||||
detailCardClassName: string;
|
||||
detailSummary: string;
|
||||
detailToggleLabel: string;
|
||||
detailVisible: boolean;
|
||||
inlineDetailSectionClassName: string;
|
||||
inlineTimelineEntryClassName: string;
|
||||
inlineTimelineFeedClassName: string;
|
||||
memberCanvasSubtitle: string;
|
||||
memberCanvasTitle: string;
|
||||
railCardClassName: string;
|
||||
selectedSessionDetailDisplay: SelectedSessionDetailDisplayState;
|
||||
useCompactCanvasChrome: boolean;
|
||||
}
|
||||
|
||||
export function useTeamWorkspaceBoardPresentation({
|
||||
canCloseCompletedTeamSessions,
|
||||
canWaitAnyActiveTeamSession,
|
||||
className,
|
||||
completedCount,
|
||||
detailExpanded,
|
||||
dispatchPreviewStatus = null,
|
||||
embedded,
|
||||
hasRealTeamGraph,
|
||||
hasRuntimeFormation,
|
||||
isChildSession,
|
||||
isEmptyShellState,
|
||||
parentSessionName,
|
||||
selectedSession,
|
||||
shellExpanded,
|
||||
siblingCount,
|
||||
statusSummary,
|
||||
totalTeamSessions,
|
||||
visibleSessionsCount,
|
||||
waitableCount,
|
||||
zoom,
|
||||
runtimeFormationDisplay,
|
||||
}: UseTeamWorkspaceBoardPresentationParams): TeamWorkspaceBoardPresentationState {
|
||||
const useCompactCanvasChrome = hasRealTeamGraph;
|
||||
const detailVisible =
|
||||
isEmptyShellState || !hasRealTeamGraph
|
||||
? detailExpanded || shellExpanded
|
||||
: false;
|
||||
const detailToggleLabel = detailVisible ? "收起细节" : "查看细节";
|
||||
const memberCanvasTitle = "协作进展画布";
|
||||
const memberCanvasSubtitle = hasRealTeamGraph
|
||||
? isChildSession
|
||||
? "当前协作成员会在各自面板里持续更新进展和结果,主对话只保留必要摘要。"
|
||||
: `${visibleSessionsCount} 位协作成员已加入,每位成员都会在自己的面板里持续更新进展和结果。`
|
||||
: dispatchPreviewStatus === "forming"
|
||||
? "正在准备当前任务分工,成员接入后会在这里独立更新进展。"
|
||||
: dispatchPreviewStatus === "formed"
|
||||
? "当前任务分工已经就绪,成员接入后会在各自面板里开始处理。"
|
||||
: dispatchPreviewStatus === "failed"
|
||||
? "这次任务分工准备失败,暂时无法生成成员面板。"
|
||||
: "成员加入后,这里会展开为独立的任务进行时面板。";
|
||||
|
||||
const boardChromeDisplay = useMemo(
|
||||
() =>
|
||||
buildTeamWorkspaceBoardChromeDisplayState({
|
||||
hasRealTeamGraph,
|
||||
hasRuntimeFormation,
|
||||
runtimeFormationTitle: hasRuntimeFormation
|
||||
? runtimeFormationDisplay.panelHeadline
|
||||
: null,
|
||||
runtimeFormationHint: runtimeFormationDisplay.hint,
|
||||
isChildSession,
|
||||
parentSessionName,
|
||||
totalTeamSessions,
|
||||
siblingCount,
|
||||
selectedSession,
|
||||
zoom,
|
||||
canWaitAnyActiveTeamSession,
|
||||
waitableCount,
|
||||
canCloseCompletedTeamSessions,
|
||||
completedCount,
|
||||
statusSummary,
|
||||
}),
|
||||
[
|
||||
canCloseCompletedTeamSessions,
|
||||
canWaitAnyActiveTeamSession,
|
||||
completedCount,
|
||||
hasRealTeamGraph,
|
||||
hasRuntimeFormation,
|
||||
isChildSession,
|
||||
parentSessionName,
|
||||
runtimeFormationDisplay.hint,
|
||||
runtimeFormationDisplay.panelHeadline,
|
||||
selectedSession,
|
||||
siblingCount,
|
||||
statusSummary,
|
||||
totalTeamSessions,
|
||||
waitableCount,
|
||||
zoom,
|
||||
],
|
||||
);
|
||||
|
||||
const detailSummary =
|
||||
selectedSession?.taskSummary ||
|
||||
buildFallbackSummary({
|
||||
hasRealTeamGraph,
|
||||
isChildSession,
|
||||
selectedSession,
|
||||
});
|
||||
const selectedSessionDetailDisplay = useMemo(
|
||||
() =>
|
||||
buildSelectedSessionDetailDisplayState({
|
||||
selectedSession,
|
||||
isChildSession,
|
||||
parentSessionName,
|
||||
}),
|
||||
[isChildSession, parentSessionName, selectedSession],
|
||||
);
|
||||
const selectedStatusMeta = resolveStatusMeta(selectedSession?.runtimeStatus);
|
||||
const boardShellClassName = cn(
|
||||
embedded
|
||||
? "pointer-events-auto flex h-full min-h-0 flex-1 flex-col overflow-hidden rounded-none border-0 bg-transparent shadow-none"
|
||||
: "overflow-hidden rounded-[26px] border border-slate-200 bg-white shadow-[0_18px_64px_-42px_rgba(15,23,42,0.24)]",
|
||||
embedded ? "mx-0 mt-0" : "mx-3 mt-2",
|
||||
className,
|
||||
);
|
||||
const boardHeaderClassName = cn(
|
||||
"flex flex-wrap items-start justify-between gap-3",
|
||||
useCompactCanvasChrome ? "px-4 py-2.5 sm:px-4" : "px-4 py-3.5 sm:px-5",
|
||||
embedded
|
||||
? cn(
|
||||
"sticky top-0 z-20 border-b border-slate-200",
|
||||
useCompactCanvasChrome ? "bg-white" : "bg-slate-50",
|
||||
)
|
||||
: cn(
|
||||
"border-b border-slate-200",
|
||||
useCompactCanvasChrome ? "bg-white" : "bg-slate-50",
|
||||
),
|
||||
);
|
||||
const boardBodyClassName = embedded
|
||||
? cn(
|
||||
"min-h-0 flex-1 overflow-y-auto overscroll-contain",
|
||||
useCompactCanvasChrome
|
||||
? "space-y-2.5 p-3 sm:p-3.5"
|
||||
: "space-y-3 p-3 sm:p-4",
|
||||
)
|
||||
: cn(useCompactCanvasChrome ? "p-3 sm:p-3.5" : "p-3 sm:p-4");
|
||||
const canvasStageHeight =
|
||||
embedded && !detailVisible
|
||||
? "clamp(560px, 76vh, 980px)"
|
||||
: TEAM_WORKSPACE_CANVAS_STAGE_HEIGHT;
|
||||
const railCardClassName = embedded
|
||||
? 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
|
||||
? "rounded-[20px] border border-slate-200 bg-white p-4"
|
||||
: "rounded-[22px] border p-4 shadow-sm shadow-slate-950/5",
|
||||
!embedded &&
|
||||
(selectedSession
|
||||
? selectedStatusMeta.cardClassName
|
||||
: "border-slate-200 bg-white"),
|
||||
);
|
||||
|
||||
return {
|
||||
boardBodyClassName,
|
||||
boardChromeDisplay,
|
||||
boardHeaderClassName,
|
||||
boardShellClassName,
|
||||
canvasStageHeight,
|
||||
detailCardClassName,
|
||||
detailSummary,
|
||||
detailToggleLabel,
|
||||
detailVisible,
|
||||
inlineDetailSectionClassName:
|
||||
"mt-3 rounded-[18px] border border-slate-200 bg-slate-50 p-3",
|
||||
inlineTimelineEntryClassName:
|
||||
"rounded-[14px] border border-slate-200 bg-white p-3",
|
||||
inlineTimelineFeedClassName:
|
||||
"mt-3 rounded-[16px] border border-slate-200 bg-white p-3",
|
||||
memberCanvasSubtitle,
|
||||
memberCanvasTitle,
|
||||
railCardClassName,
|
||||
selectedSessionDetailDisplay,
|
||||
useCompactCanvasChrome,
|
||||
};
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type {
|
||||
TeamWorkspaceControlSummary,
|
||||
TeamWorkspaceWaitSummary,
|
||||
} from "../../teamWorkspaceRuntime";
|
||||
import {
|
||||
buildTeamWorkspaceSelectedSessionActionState,
|
||||
buildTeamWorkspaceSessionControlState,
|
||||
} from "../../team-workspace-runtime/sessionStateSelectors";
|
||||
import {
|
||||
buildVisibleTeamOperationState,
|
||||
type TeamOperationDisplayEntry,
|
||||
} from "../../team-workspace-runtime/teamOperationSelectors";
|
||||
import type { TeamSessionCard } from "../../utils/teamWorkspaceSessions";
|
||||
import { useTeamWorkspaceBoardActions } from "./useTeamWorkspaceBoardActions";
|
||||
import { useTeamWorkspaceSessionFocus } from "./useTeamWorkspaceSessionFocus";
|
||||
|
||||
interface UseTeamWorkspaceBoardRuntimeStateParams {
|
||||
baseRailSessions: TeamSessionCard[];
|
||||
currentChildSession?: TeamSessionCard | null;
|
||||
currentSessionId?: string | null;
|
||||
isChildSession: boolean;
|
||||
memberCanvasSessions: TeamSessionCard[];
|
||||
onCloseCompletedTeamSessions?: (sessionIds: string[]) => void | Promise<void>;
|
||||
onCloseSubagentSession?: (sessionId: string) => void | Promise<void>;
|
||||
onOpenSubagentSession?: (sessionId: string) => void | Promise<void>;
|
||||
onResumeSubagentSession?: (sessionId: string) => void | Promise<void>;
|
||||
onSendSubagentInput?: (
|
||||
sessionId: string,
|
||||
message: string,
|
||||
options?: { interrupt?: boolean },
|
||||
) => void | Promise<void>;
|
||||
onWaitActiveTeamSessions?: (
|
||||
sessionIds: string[],
|
||||
timeoutMs?: number,
|
||||
) => void | Promise<void>;
|
||||
onWaitSubagentSession?: (
|
||||
sessionId: string,
|
||||
timeoutMs?: number,
|
||||
) => void | Promise<void>;
|
||||
orchestratorSessionId?: string | null;
|
||||
railSessions: TeamSessionCard[];
|
||||
teamControlSummary?: TeamWorkspaceControlSummary | null;
|
||||
teamWaitSummary?: TeamWorkspaceWaitSummary | null;
|
||||
visibleSessions: TeamSessionCard[];
|
||||
waitTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export function useTeamWorkspaceBoardRuntimeState({
|
||||
baseRailSessions,
|
||||
currentChildSession = null,
|
||||
currentSessionId,
|
||||
isChildSession,
|
||||
memberCanvasSessions,
|
||||
onCloseCompletedTeamSessions,
|
||||
onCloseSubagentSession,
|
||||
onOpenSubagentSession,
|
||||
onResumeSubagentSession,
|
||||
onSendSubagentInput,
|
||||
onWaitActiveTeamSessions,
|
||||
onWaitSubagentSession,
|
||||
orchestratorSessionId,
|
||||
railSessions,
|
||||
teamControlSummary = null,
|
||||
teamWaitSummary = null,
|
||||
visibleSessions,
|
||||
waitTimeoutMs = 30_000,
|
||||
}: UseTeamWorkspaceBoardRuntimeStateParams) {
|
||||
const teamOperationState = useMemo(
|
||||
() =>
|
||||
buildVisibleTeamOperationState({
|
||||
railSessions,
|
||||
teamWaitSummary,
|
||||
teamControlSummary,
|
||||
}),
|
||||
[railSessions, teamControlSummary, teamWaitSummary],
|
||||
);
|
||||
const visibleTeamWaitSummary = teamOperationState.visibleTeamWaitSummary;
|
||||
const teamOperationEntries = teamOperationState.entries;
|
||||
|
||||
const {
|
||||
expandedSessionId,
|
||||
focusSession,
|
||||
selectedBaseSession,
|
||||
selectedSession,
|
||||
} = useTeamWorkspaceSessionFocus({
|
||||
baseRailSessions,
|
||||
currentSessionId,
|
||||
isChildSession,
|
||||
memberCanvasSessions,
|
||||
orchestratorSessionId,
|
||||
railSessions,
|
||||
visibleTeamWaitSummary,
|
||||
});
|
||||
|
||||
const sessionControlState = useMemo(
|
||||
() =>
|
||||
buildTeamWorkspaceSessionControlState({
|
||||
visibleSessions,
|
||||
railSessions,
|
||||
currentChildSession,
|
||||
isChildSession,
|
||||
currentSessionId,
|
||||
}),
|
||||
[
|
||||
currentChildSession,
|
||||
currentSessionId,
|
||||
isChildSession,
|
||||
railSessions,
|
||||
visibleSessions,
|
||||
],
|
||||
);
|
||||
const statusSummary = sessionControlState.statusSummary;
|
||||
const waitableTeamSessionIds = sessionControlState.waitableSessionIds;
|
||||
const completedTeamSessionIds = sessionControlState.completedSessionIds;
|
||||
|
||||
const sessionActionState = useMemo(
|
||||
() =>
|
||||
buildTeamWorkspaceSelectedSessionActionState({
|
||||
completedTeamSessionIds,
|
||||
currentSessionId,
|
||||
hasCloseCompletedTeamSessionsHandler: Boolean(
|
||||
onCloseCompletedTeamSessions,
|
||||
),
|
||||
hasCloseSubagentSessionHandler: Boolean(onCloseSubagentSession),
|
||||
hasOpenSubagentSessionHandler: Boolean(onOpenSubagentSession),
|
||||
hasResumeSubagentSessionHandler: Boolean(onResumeSubagentSession),
|
||||
hasSendSubagentInputHandler: Boolean(onSendSubagentInput),
|
||||
hasWaitActiveTeamSessionsHandler: Boolean(onWaitActiveTeamSessions),
|
||||
hasWaitSubagentSessionHandler: Boolean(onWaitSubagentSession),
|
||||
selectedSession,
|
||||
waitableTeamSessionIds,
|
||||
}),
|
||||
[
|
||||
completedTeamSessionIds,
|
||||
currentSessionId,
|
||||
onCloseCompletedTeamSessions,
|
||||
onCloseSubagentSession,
|
||||
onOpenSubagentSession,
|
||||
onResumeSubagentSession,
|
||||
onSendSubagentInput,
|
||||
onWaitActiveTeamSessions,
|
||||
onWaitSubagentSession,
|
||||
selectedSession,
|
||||
waitableTeamSessionIds,
|
||||
],
|
||||
);
|
||||
const canCloseCompletedTeamSessions =
|
||||
sessionActionState.canCloseCompletedTeamSessions;
|
||||
const canOpenSelectedSession = sessionActionState.canOpenSelectedSession;
|
||||
const canResumeSelectedSession =
|
||||
sessionActionState.canResumeSelectedSession;
|
||||
const canSendSelectedSessionInput =
|
||||
sessionActionState.canSendSelectedSessionInput;
|
||||
const canStopSelectedSession = sessionActionState.canStopSelectedSession;
|
||||
const canWaitAnyActiveTeamSession =
|
||||
sessionActionState.canWaitAnyActiveTeamSession;
|
||||
const canWaitSelectedSession = sessionActionState.canWaitSelectedSession;
|
||||
|
||||
const {
|
||||
handleCloseCompletedTeamSessions,
|
||||
handleSelectedSessionAction,
|
||||
handleSelectedSessionInputDraftChange,
|
||||
handleSelectedSessionSendInput,
|
||||
handleWaitAnyActiveTeamSessions,
|
||||
pendingSessionAction,
|
||||
pendingTeamAction,
|
||||
selectedActionPending,
|
||||
selectedSessionInputDraft,
|
||||
selectedSessionInputMessage,
|
||||
} = useTeamWorkspaceBoardActions({
|
||||
completedTeamSessionIds,
|
||||
onCloseCompletedTeamSessions,
|
||||
onCloseSubagentSession,
|
||||
onResumeSubagentSession,
|
||||
onSendSubagentInput,
|
||||
onWaitActiveTeamSessions,
|
||||
onWaitSubagentSession,
|
||||
selectedSession,
|
||||
waitTimeoutMs,
|
||||
waitableTeamSessionIds,
|
||||
});
|
||||
|
||||
const handleSelectTeamOperationEntry = useCallback(
|
||||
(entry: TeamOperationDisplayEntry) => {
|
||||
if (!entry.targetSessionId) {
|
||||
return;
|
||||
}
|
||||
focusSession(entry.targetSessionId);
|
||||
},
|
||||
[focusSession],
|
||||
);
|
||||
|
||||
return {
|
||||
canCloseCompletedTeamSessions,
|
||||
canOpenSelectedSession,
|
||||
canResumeSelectedSession,
|
||||
canSendSelectedSessionInput,
|
||||
canStopSelectedSession,
|
||||
canWaitAnyActiveTeamSession,
|
||||
canWaitSelectedSession,
|
||||
completedTeamSessionIds,
|
||||
expandedSessionId,
|
||||
focusSession,
|
||||
handleCloseCompletedTeamSessions,
|
||||
handleSelectTeamOperationEntry,
|
||||
handleSelectedSessionAction,
|
||||
handleSelectedSessionInputDraftChange,
|
||||
handleSelectedSessionSendInput,
|
||||
handleWaitAnyActiveTeamSessions,
|
||||
pendingSessionAction,
|
||||
pendingTeamAction,
|
||||
selectedActionPending,
|
||||
selectedBaseSession,
|
||||
selectedSession,
|
||||
selectedSessionInputDraft,
|
||||
selectedSessionInputMessage,
|
||||
statusSummary,
|
||||
teamOperationEntries,
|
||||
visibleTeamWaitSummary,
|
||||
waitableTeamSessionIds,
|
||||
};
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import type { SelectedSessionActivityState } from "../../team-workspace-runtime/activityPreviewSelectors";
|
||||
import { TeamWorkspaceCanvasSelectedInlineDetail } from "./TeamWorkspaceCanvasSelectedInlineDetail";
|
||||
|
||||
type TeamWorkspaceCanvasSelectedInlineDetailProps = ComponentProps<
|
||||
typeof TeamWorkspaceCanvasSelectedInlineDetail
|
||||
>;
|
||||
|
||||
interface UseTeamWorkspaceBoardSelectedInlineDetailParams {
|
||||
canOpenSelectedSession: TeamWorkspaceCanvasSelectedInlineDetailProps["canOpenSelectedSession"];
|
||||
canResumeSelectedSession: TeamWorkspaceCanvasSelectedInlineDetailProps["canResumeSelectedSession"];
|
||||
canSendSelectedSessionInput: TeamWorkspaceCanvasSelectedInlineDetailProps["canSendSelectedSessionInput"];
|
||||
canStopSelectedSession: TeamWorkspaceCanvasSelectedInlineDetailProps["canStopSelectedSession"];
|
||||
canWaitSelectedSession: TeamWorkspaceCanvasSelectedInlineDetailProps["canWaitSelectedSession"];
|
||||
detailDisplay: TeamWorkspaceCanvasSelectedInlineDetailProps["detailDisplay"];
|
||||
detailSummary: TeamWorkspaceCanvasSelectedInlineDetailProps["detailSummary"];
|
||||
formatUpdatedAt: TeamWorkspaceCanvasSelectedInlineDetailProps["formatUpdatedAt"];
|
||||
inlineDetailSectionClassName: TeamWorkspaceCanvasSelectedInlineDetailProps["inlineDetailSectionClassName"];
|
||||
inlineTimelineEntryClassName: TeamWorkspaceCanvasSelectedInlineDetailProps["inlineTimelineEntryClassName"];
|
||||
inlineTimelineFeedClassName: TeamWorkspaceCanvasSelectedInlineDetailProps["inlineTimelineFeedClassName"];
|
||||
isChildSession: TeamWorkspaceCanvasSelectedInlineDetailProps["isChildSession"];
|
||||
onOpenSubagentSession?: (sessionId: string) => void | Promise<void>;
|
||||
onSelectedSessionAction: TeamWorkspaceCanvasSelectedInlineDetailProps["onSelectedSessionAction"];
|
||||
onSelectedSessionInputDraftChange: TeamWorkspaceCanvasSelectedInlineDetailProps["onSelectedSessionInputDraftChange"];
|
||||
onSelectedSessionSendInput: TeamWorkspaceCanvasSelectedInlineDetailProps["onSelectedSessionSendInput"];
|
||||
pendingSessionAction?: {
|
||||
action: NonNullable<TeamWorkspaceCanvasSelectedInlineDetailProps["pendingAction"]>;
|
||||
} | null;
|
||||
selectedActionPending: TeamWorkspaceCanvasSelectedInlineDetailProps["selectedActionPending"];
|
||||
selectedSession:
|
||||
| TeamWorkspaceCanvasSelectedInlineDetailProps["selectedSession"]
|
||||
| null;
|
||||
selectedSessionActivityState: SelectedSessionActivityState;
|
||||
selectedSessionInputDraft: TeamWorkspaceCanvasSelectedInlineDetailProps["selectedSessionInputDraft"];
|
||||
selectedSessionInputMessage: TeamWorkspaceCanvasSelectedInlineDetailProps["selectedSessionInputMessage"];
|
||||
}
|
||||
|
||||
export function useTeamWorkspaceBoardSelectedInlineDetail({
|
||||
canOpenSelectedSession,
|
||||
canResumeSelectedSession,
|
||||
canSendSelectedSessionInput,
|
||||
canStopSelectedSession,
|
||||
canWaitSelectedSession,
|
||||
detailDisplay,
|
||||
detailSummary,
|
||||
formatUpdatedAt,
|
||||
inlineDetailSectionClassName,
|
||||
inlineTimelineEntryClassName,
|
||||
inlineTimelineFeedClassName,
|
||||
isChildSession,
|
||||
onOpenSubagentSession,
|
||||
onSelectedSessionAction,
|
||||
onSelectedSessionInputDraftChange,
|
||||
onSelectedSessionSendInput,
|
||||
pendingSessionAction = null,
|
||||
selectedActionPending,
|
||||
selectedSession,
|
||||
selectedSessionActivityState,
|
||||
selectedSessionInputDraft,
|
||||
selectedSessionInputMessage,
|
||||
}: UseTeamWorkspaceBoardSelectedInlineDetailParams): ReactNode {
|
||||
if (!selectedSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TeamWorkspaceCanvasSelectedInlineDetail
|
||||
canOpenSelectedSession={canOpenSelectedSession}
|
||||
canResumeSelectedSession={canResumeSelectedSession}
|
||||
canSendSelectedSessionInput={canSendSelectedSessionInput}
|
||||
canStopSelectedSession={canStopSelectedSession}
|
||||
canWaitSelectedSession={canWaitSelectedSession}
|
||||
detailSummary={detailSummary}
|
||||
detailDisplay={detailDisplay}
|
||||
formatUpdatedAt={formatUpdatedAt}
|
||||
inlineDetailSectionClassName={inlineDetailSectionClassName}
|
||||
inlineTimelineEntryClassName={inlineTimelineEntryClassName}
|
||||
inlineTimelineFeedClassName={inlineTimelineFeedClassName}
|
||||
isChildSession={isChildSession}
|
||||
onOpenSelectedSession={() => void onOpenSubagentSession?.(selectedSession.id)}
|
||||
onSelectedSessionAction={onSelectedSessionAction}
|
||||
onSelectedSessionInputDraftChange={onSelectedSessionInputDraftChange}
|
||||
onSelectedSessionSendInput={onSelectedSessionSendInput}
|
||||
pendingAction={
|
||||
selectedActionPending ? pendingSessionAction?.action ?? null : null
|
||||
}
|
||||
selectedActionPending={selectedActionPending}
|
||||
selectedSession={selectedSession}
|
||||
selectedSessionActivityEntries={selectedSessionActivityState.entries}
|
||||
selectedSessionActivityPreview={selectedSessionActivityState.previewState}
|
||||
selectedSessionActivityPreviewText={selectedSessionActivityState.previewText}
|
||||
selectedSessionActivityShouldPoll={selectedSessionActivityState.shouldPoll}
|
||||
selectedSessionInputDraft={selectedSessionInputDraft}
|
||||
selectedSessionInputMessage={selectedSessionInputMessage}
|
||||
selectedSessionSupportsActivityPreview={
|
||||
selectedSessionActivityState.supportsPreview
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
AsterSubagentParentContext,
|
||||
AsterSubagentSessionInfo,
|
||||
} from "@/lib/api/agentRuntime";
|
||||
import {
|
||||
applyLiveRuntimeState,
|
||||
type TeamWorkspaceLiveRuntimeState,
|
||||
type TeamWorkspaceRuntimeFormationState,
|
||||
} from "../../teamWorkspaceRuntime";
|
||||
import type { ActivityPreviewSession } from "../../team-workspace-runtime/activityPreviewSelectors";
|
||||
import {
|
||||
buildCurrentChildSession,
|
||||
buildTeamWorkspaceMemberCanvasSessions,
|
||||
buildTeamWorkspaceRailSessions,
|
||||
buildVisibleTeamSessionCards,
|
||||
buildOrchestratorSession,
|
||||
type TeamSessionCard,
|
||||
} from "../../utils/teamWorkspaceSessions";
|
||||
|
||||
type RuntimeStatus = AsterSubagentSessionInfo["runtime_status"];
|
||||
|
||||
interface UseTeamWorkspaceBoardSessionGraphParams {
|
||||
childSubagentSessions: AsterSubagentSessionInfo[];
|
||||
currentSessionId?: string | null;
|
||||
currentSessionLatestTurnStatus?: RuntimeStatus;
|
||||
currentSessionName?: string | null;
|
||||
currentSessionQueuedTurnCount?: number;
|
||||
currentSessionRuntimeStatus?: RuntimeStatus;
|
||||
liveRuntimeBySessionId?: Record<string, TeamWorkspaceLiveRuntimeState>;
|
||||
shellVisible?: boolean;
|
||||
subagentParentContext?: AsterSubagentParentContext | null;
|
||||
teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null;
|
||||
}
|
||||
|
||||
interface TeamWorkspaceBoardSessionGraphState {
|
||||
basePreviewableRailSessions: ActivityPreviewSession[];
|
||||
baseRailSessions: TeamSessionCard[];
|
||||
canvasStorageScopeId: string;
|
||||
currentChildSession: TeamSessionCard | null;
|
||||
hasRealTeamGraph: boolean;
|
||||
isChildSession: boolean;
|
||||
isEmptyShellState: boolean;
|
||||
memberCanvasSessions: TeamSessionCard[];
|
||||
orchestratorSession: TeamSessionCard | null;
|
||||
railSessions: TeamSessionCard[];
|
||||
siblingCount: number;
|
||||
totalTeamSessions: number;
|
||||
visibleSessions: TeamSessionCard[];
|
||||
}
|
||||
|
||||
export function useTeamWorkspaceBoardSessionGraph({
|
||||
childSubagentSessions,
|
||||
currentSessionId,
|
||||
currentSessionLatestTurnStatus,
|
||||
currentSessionName,
|
||||
currentSessionQueuedTurnCount = 0,
|
||||
currentSessionRuntimeStatus,
|
||||
liveRuntimeBySessionId = {},
|
||||
shellVisible = false,
|
||||
subagentParentContext = null,
|
||||
teamDispatchPreviewState = null,
|
||||
}: UseTeamWorkspaceBoardSessionGraphParams): TeamWorkspaceBoardSessionGraphState {
|
||||
const isChildSession = Boolean(subagentParentContext);
|
||||
const canvasStorageScopeId =
|
||||
currentSessionId?.trim() ||
|
||||
subagentParentContext?.parent_session_id?.trim() ||
|
||||
teamDispatchPreviewState?.requestId?.trim() ||
|
||||
"team-workspace";
|
||||
const baseOrchestratorSession = useMemo(
|
||||
() =>
|
||||
buildOrchestratorSession(
|
||||
currentSessionId,
|
||||
currentSessionName,
|
||||
currentSessionRuntimeStatus,
|
||||
),
|
||||
[currentSessionId, currentSessionName, currentSessionRuntimeStatus],
|
||||
);
|
||||
const baseCurrentChildSession = useMemo(
|
||||
() =>
|
||||
buildCurrentChildSession(
|
||||
currentSessionId,
|
||||
currentSessionName,
|
||||
currentSessionRuntimeStatus,
|
||||
currentSessionLatestTurnStatus,
|
||||
currentSessionQueuedTurnCount,
|
||||
subagentParentContext,
|
||||
),
|
||||
[
|
||||
currentSessionId,
|
||||
currentSessionLatestTurnStatus,
|
||||
currentSessionName,
|
||||
currentSessionQueuedTurnCount,
|
||||
currentSessionRuntimeStatus,
|
||||
subagentParentContext,
|
||||
],
|
||||
);
|
||||
const baseVisibleSessions = useMemo<TeamSessionCard[]>(
|
||||
() =>
|
||||
buildVisibleTeamSessionCards(
|
||||
isChildSession
|
||||
? (subagentParentContext?.sibling_subagent_sessions ?? [])
|
||||
: childSubagentSessions,
|
||||
),
|
||||
[childSubagentSessions, isChildSession, subagentParentContext],
|
||||
);
|
||||
const baseHasRealTeamGraph = isChildSession || baseVisibleSessions.length > 0;
|
||||
const baseRailSessions = useMemo(
|
||||
() =>
|
||||
buildTeamWorkspaceRailSessions({
|
||||
isChildSession,
|
||||
hasRealTeamGraph: baseHasRealTeamGraph,
|
||||
currentChildSession: baseCurrentChildSession,
|
||||
orchestratorSession: baseOrchestratorSession,
|
||||
visibleSessions: baseVisibleSessions,
|
||||
}),
|
||||
[
|
||||
baseCurrentChildSession,
|
||||
baseHasRealTeamGraph,
|
||||
baseOrchestratorSession,
|
||||
baseVisibleSessions,
|
||||
isChildSession,
|
||||
],
|
||||
);
|
||||
const orchestratorSession = useMemo(
|
||||
() =>
|
||||
applyLiveRuntimeState(
|
||||
baseOrchestratorSession,
|
||||
baseOrchestratorSession
|
||||
? liveRuntimeBySessionId[baseOrchestratorSession.id]
|
||||
: undefined,
|
||||
),
|
||||
[baseOrchestratorSession, liveRuntimeBySessionId],
|
||||
);
|
||||
const currentChildSession = useMemo(
|
||||
() =>
|
||||
applyLiveRuntimeState(
|
||||
baseCurrentChildSession,
|
||||
baseCurrentChildSession
|
||||
? liveRuntimeBySessionId[baseCurrentChildSession.id]
|
||||
: undefined,
|
||||
),
|
||||
[baseCurrentChildSession, liveRuntimeBySessionId],
|
||||
);
|
||||
const visibleSessions = useMemo(
|
||||
() =>
|
||||
baseVisibleSessions.map(
|
||||
(session) =>
|
||||
applyLiveRuntimeState(session, liveRuntimeBySessionId[session.id]) ??
|
||||
session,
|
||||
),
|
||||
[baseVisibleSessions, liveRuntimeBySessionId],
|
||||
);
|
||||
const totalTeamSessions = isChildSession
|
||||
? visibleSessions.length + (currentChildSession ? 1 : 0)
|
||||
: visibleSessions.length;
|
||||
const siblingCount =
|
||||
subagentParentContext?.sibling_subagent_sessions?.length ?? 0;
|
||||
const hasRealTeamGraph = isChildSession || visibleSessions.length > 0;
|
||||
const isEmptyShellState =
|
||||
!isChildSession && shellVisible && visibleSessions.length === 0;
|
||||
const memberCanvasSessions = useMemo(
|
||||
() =>
|
||||
buildTeamWorkspaceMemberCanvasSessions({
|
||||
isChildSession,
|
||||
currentChildSession,
|
||||
visibleSessions,
|
||||
teamDispatchPreviewState,
|
||||
}),
|
||||
[
|
||||
currentChildSession,
|
||||
isChildSession,
|
||||
teamDispatchPreviewState,
|
||||
visibleSessions,
|
||||
],
|
||||
);
|
||||
const railSessions = useMemo(
|
||||
() =>
|
||||
buildTeamWorkspaceRailSessions({
|
||||
isChildSession,
|
||||
hasRealTeamGraph,
|
||||
currentChildSession,
|
||||
orchestratorSession,
|
||||
visibleSessions,
|
||||
}),
|
||||
[
|
||||
currentChildSession,
|
||||
hasRealTeamGraph,
|
||||
isChildSession,
|
||||
orchestratorSession,
|
||||
visibleSessions,
|
||||
],
|
||||
);
|
||||
const basePreviewableRailSessions = useMemo<ActivityPreviewSession[]>(
|
||||
() =>
|
||||
baseRailSessions.filter((session) => session.sessionType !== "user"),
|
||||
[baseRailSessions],
|
||||
);
|
||||
|
||||
return {
|
||||
basePreviewableRailSessions,
|
||||
baseRailSessions,
|
||||
canvasStorageScopeId,
|
||||
currentChildSession,
|
||||
hasRealTeamGraph,
|
||||
isChildSession,
|
||||
isEmptyShellState,
|
||||
memberCanvasSessions,
|
||||
orchestratorSession,
|
||||
railSessions,
|
||||
siblingCount,
|
||||
totalTeamSessions,
|
||||
visibleSessions,
|
||||
};
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
import type { AsterSubagentParentContext } from "@/lib/api/agentRuntime";
|
||||
import type { SelectedSessionActivityState } from "../../team-workspace-runtime/activityPreviewSelectors";
|
||||
import {
|
||||
formatUpdatedAt,
|
||||
resolveStatusMeta,
|
||||
} from "../../utils/teamWorkspaceSessions";
|
||||
import {
|
||||
buildTeamWorkspaceBoardEmptyShellProps,
|
||||
buildTeamWorkspaceBoardShellProps,
|
||||
} from "./teamWorkspaceBoardPropBuilders";
|
||||
import { useTeamWorkspaceBoardSelectedInlineDetail } from "./useTeamWorkspaceBoardSelectedInlineDetail";
|
||||
|
||||
type TeamWorkspaceBoardCanvasRuntimeState = ReturnType<
|
||||
(
|
||||
typeof import("./useTeamWorkspaceBoardCanvasRuntime")
|
||||
)["useTeamWorkspaceBoardCanvasRuntime"]
|
||||
>;
|
||||
type TeamWorkspaceBoardFormationState = ReturnType<
|
||||
(
|
||||
typeof import("./useTeamWorkspaceBoardFormationState")
|
||||
)["useTeamWorkspaceBoardFormationState"]
|
||||
>;
|
||||
type TeamWorkspaceBoardPresentationState = ReturnType<
|
||||
(
|
||||
typeof import("./useTeamWorkspaceBoardPresentation")
|
||||
)["useTeamWorkspaceBoardPresentation"]
|
||||
>;
|
||||
type TeamWorkspaceBoardRuntimeState = ReturnType<
|
||||
(
|
||||
typeof import("./useTeamWorkspaceBoardRuntimeState")
|
||||
)["useTeamWorkspaceBoardRuntimeState"]
|
||||
>;
|
||||
type TeamWorkspaceBoardSessionGraphState = ReturnType<
|
||||
(
|
||||
typeof import("./useTeamWorkspaceBoardSessionGraph")
|
||||
)["useTeamWorkspaceBoardSessionGraph"]
|
||||
>;
|
||||
type TeamWorkspaceBoardEmptyShellProps = ReturnType<
|
||||
typeof buildTeamWorkspaceBoardEmptyShellProps
|
||||
>;
|
||||
type TeamWorkspaceBoardShellProps = ReturnType<
|
||||
typeof buildTeamWorkspaceBoardShellProps
|
||||
>;
|
||||
|
||||
interface UseTeamWorkspaceBoardShellPropsParams {
|
||||
className?: string;
|
||||
embedded: boolean;
|
||||
canvasRuntimeState: TeamWorkspaceBoardCanvasRuntimeState;
|
||||
formationState: TeamWorkspaceBoardFormationState;
|
||||
onExpandEmptyShell: () => void;
|
||||
onOpenSubagentSession?: (sessionId: string) => void | Promise<void>;
|
||||
onReturnToParentSession?: () => void | Promise<void>;
|
||||
onToggleDetail: () => void;
|
||||
presentationState: TeamWorkspaceBoardPresentationState;
|
||||
runtimeState: TeamWorkspaceBoardRuntimeState;
|
||||
selectedSessionActivityState: SelectedSessionActivityState;
|
||||
sessionGraphState: TeamWorkspaceBoardSessionGraphState;
|
||||
subagentParentContext?: AsterSubagentParentContext | null;
|
||||
}
|
||||
|
||||
interface TeamWorkspaceBoardShellState {
|
||||
emptyShellProps: TeamWorkspaceBoardEmptyShellProps;
|
||||
shellProps: TeamWorkspaceBoardShellProps;
|
||||
}
|
||||
|
||||
export function useTeamWorkspaceBoardShellProps({
|
||||
className,
|
||||
embedded,
|
||||
canvasRuntimeState,
|
||||
formationState,
|
||||
onExpandEmptyShell,
|
||||
onOpenSubagentSession,
|
||||
onReturnToParentSession,
|
||||
onToggleDetail,
|
||||
presentationState,
|
||||
runtimeState,
|
||||
selectedSessionActivityState,
|
||||
sessionGraphState,
|
||||
subagentParentContext = null,
|
||||
}: UseTeamWorkspaceBoardShellPropsParams): TeamWorkspaceBoardShellState {
|
||||
const {
|
||||
hasRuntimeFormation,
|
||||
runtimeFormationDisplay,
|
||||
selectedTeamPlanDisplay,
|
||||
} = formationState;
|
||||
const {
|
||||
boardBodyClassName,
|
||||
boardChromeDisplay,
|
||||
boardHeaderClassName,
|
||||
boardShellClassName,
|
||||
canvasStageHeight,
|
||||
detailCardClassName,
|
||||
detailSummary,
|
||||
detailToggleLabel,
|
||||
detailVisible,
|
||||
inlineDetailSectionClassName,
|
||||
inlineTimelineEntryClassName,
|
||||
inlineTimelineFeedClassName,
|
||||
memberCanvasSubtitle,
|
||||
memberCanvasTitle,
|
||||
railCardClassName,
|
||||
selectedSessionDetailDisplay,
|
||||
useCompactCanvasChrome,
|
||||
} = presentationState;
|
||||
const {
|
||||
canCloseCompletedTeamSessions,
|
||||
canOpenSelectedSession,
|
||||
canResumeSelectedSession,
|
||||
canSendSelectedSessionInput,
|
||||
canStopSelectedSession,
|
||||
canWaitAnyActiveTeamSession,
|
||||
canWaitSelectedSession,
|
||||
completedTeamSessionIds,
|
||||
expandedSessionId,
|
||||
handleCloseCompletedTeamSessions,
|
||||
handleSelectTeamOperationEntry,
|
||||
handleSelectedSessionAction,
|
||||
handleSelectedSessionInputDraftChange,
|
||||
handleSelectedSessionSendInput,
|
||||
handleWaitAnyActiveTeamSessions,
|
||||
pendingSessionAction,
|
||||
pendingTeamAction,
|
||||
selectedActionPending,
|
||||
selectedSession,
|
||||
selectedSessionInputDraft,
|
||||
selectedSessionInputMessage,
|
||||
teamOperationEntries,
|
||||
waitableTeamSessionIds,
|
||||
} = runtimeState;
|
||||
const {
|
||||
canvasBounds,
|
||||
canvasLaneLayouts,
|
||||
canvasViewportRef,
|
||||
canvasLanes,
|
||||
canvasStageHint,
|
||||
handleAutoArrangeCanvas,
|
||||
handleCanvasWheel,
|
||||
handleFitCanvasView,
|
||||
handleResetCanvasView,
|
||||
handleSelectCanvasLane,
|
||||
handleStartCanvasLaneDrag,
|
||||
handleStartCanvasLaneResize,
|
||||
handleStartCanvasPan,
|
||||
handleZoomIn,
|
||||
handleZoomOut,
|
||||
isCanvasPanModifierActive,
|
||||
viewport: canvasViewport,
|
||||
zoom: canvasZoom,
|
||||
} = canvasRuntimeState;
|
||||
const {
|
||||
hasRealTeamGraph,
|
||||
isChildSession,
|
||||
isEmptyShellState,
|
||||
totalTeamSessions,
|
||||
} = sessionGraphState;
|
||||
|
||||
const selectedInlineDetail = useTeamWorkspaceBoardSelectedInlineDetail({
|
||||
canOpenSelectedSession,
|
||||
canResumeSelectedSession,
|
||||
canSendSelectedSessionInput,
|
||||
canStopSelectedSession,
|
||||
canWaitSelectedSession,
|
||||
detailDisplay: selectedSessionDetailDisplay,
|
||||
detailSummary,
|
||||
formatUpdatedAt,
|
||||
inlineDetailSectionClassName,
|
||||
inlineTimelineEntryClassName,
|
||||
inlineTimelineFeedClassName,
|
||||
isChildSession,
|
||||
onOpenSubagentSession,
|
||||
onSelectedSessionAction: handleSelectedSessionAction,
|
||||
onSelectedSessionInputDraftChange: handleSelectedSessionInputDraftChange,
|
||||
onSelectedSessionSendInput: handleSelectedSessionSendInput,
|
||||
pendingSessionAction,
|
||||
selectedActionPending,
|
||||
selectedSession,
|
||||
selectedSessionActivityState,
|
||||
selectedSessionInputDraft,
|
||||
selectedSessionInputMessage,
|
||||
});
|
||||
|
||||
const emptyShellProps = buildTeamWorkspaceBoardEmptyShellProps({
|
||||
className,
|
||||
embedded,
|
||||
hasRuntimeFormation,
|
||||
onExpand: onExpandEmptyShell,
|
||||
runtimeFormationDisplay,
|
||||
selectedTeamPlanDisplay,
|
||||
});
|
||||
const shellProps = buildTeamWorkspaceBoardShellProps({
|
||||
boardBodyClassName,
|
||||
boardChromeDisplay,
|
||||
boardHeaderClassName,
|
||||
boardShellClassName,
|
||||
canvasBoundsHeight: canvasBounds.height,
|
||||
canvasBoundsWidth: canvasBounds.width,
|
||||
canvasStageHeight,
|
||||
canvasStageHint,
|
||||
canvasViewportRef,
|
||||
canvasZoom,
|
||||
canCloseCompletedTeamSessions,
|
||||
canWaitAnyActiveTeamSession,
|
||||
completedCount: completedTeamSessionIds.length,
|
||||
createdFromTurnId: subagentParentContext?.created_from_turn_id,
|
||||
detailCardClassName,
|
||||
detailToggleLabel,
|
||||
detailVisible,
|
||||
embedded,
|
||||
expandedSessionId,
|
||||
formatUpdatedAt,
|
||||
hasRealTeamGraph,
|
||||
isCanvasPanModifierActive,
|
||||
isChildSession,
|
||||
isEmptyShellState,
|
||||
laneLayouts: canvasLaneLayouts,
|
||||
lanes: canvasLanes,
|
||||
memberCanvasSubtitle,
|
||||
memberCanvasTitle,
|
||||
onAutoArrangeCanvas: handleAutoArrangeCanvas,
|
||||
onCanvasWheel: handleCanvasWheel,
|
||||
onCloseCompletedTeamSessions: handleCloseCompletedTeamSessions,
|
||||
onFitCanvasView: handleFitCanvasView,
|
||||
onReturnToParentSession,
|
||||
onResetCanvasView: handleResetCanvasView,
|
||||
onSelectCanvasLane: handleSelectCanvasLane,
|
||||
onSelectTeamOperationEntry: handleSelectTeamOperationEntry,
|
||||
onStartCanvasLaneDrag: handleStartCanvasLaneDrag,
|
||||
onStartCanvasLaneResize: handleStartCanvasLaneResize,
|
||||
onStartCanvasPan: handleStartCanvasPan,
|
||||
onToggleDetail,
|
||||
onWaitAnyActiveTeamSessions: handleWaitAnyActiveTeamSessions,
|
||||
onZoomIn: handleZoomIn,
|
||||
onZoomOut: handleZoomOut,
|
||||
pendingTeamAction,
|
||||
railCardClassName,
|
||||
resolveStatusMeta,
|
||||
runtimeFormationStatusLabel: runtimeFormationDisplay.panelStatusLabel,
|
||||
runtimeFormationDisplay,
|
||||
selectedInlineDetail,
|
||||
selectedSession,
|
||||
selectedSessionId: selectedSession?.id ?? null,
|
||||
selectedTeamPlanDisplay,
|
||||
teamOperationEntries,
|
||||
totalTeamSessions,
|
||||
useCompactCanvasChrome,
|
||||
viewport: canvasViewport,
|
||||
waitableCount: waitableTeamSessionIds.length,
|
||||
});
|
||||
|
||||
return {
|
||||
emptyShellProps,
|
||||
shellProps,
|
||||
};
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TeamWorkspaceCanvasLane } from "../../team-workspace-runtime/canvasLaneSelectors";
|
||||
import {
|
||||
getTeamWorkspaceCanvasStorageKey,
|
||||
TEAM_WORKSPACE_CANVAS_STORAGE_VERSION,
|
||||
} from "../../utils/teamWorkspaceCanvas";
|
||||
import { useTeamWorkspaceCanvasController } from "./useTeamWorkspaceCanvasController";
|
||||
|
||||
type HookProps = Parameters<typeof useTeamWorkspaceCanvasController>[0];
|
||||
|
||||
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
|
||||
|
||||
function createSessionLane(id: string): TeamWorkspaceCanvasLane {
|
||||
return {
|
||||
id,
|
||||
persistKey: `session:${id}`,
|
||||
fallbackPersistKeys: [],
|
||||
kind: "session",
|
||||
title: `成员 ${id}`,
|
||||
summary: "负责推进当前协作任务。",
|
||||
badgeLabel: "运行中",
|
||||
badgeClassName: "border border-sky-200 bg-sky-50 text-sky-700",
|
||||
dotClassName: "bg-sky-500",
|
||||
skillLabels: [],
|
||||
session: {
|
||||
id,
|
||||
name: `成员 ${id}`,
|
||||
runtimeStatus: "running",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function seedCanvasLayout(
|
||||
scopeId: string,
|
||||
overrides?: Partial<{
|
||||
items: HookStoredItems;
|
||||
viewport: { x: number; y: number; zoom: number };
|
||||
}>,
|
||||
) {
|
||||
window.localStorage.setItem(
|
||||
getTeamWorkspaceCanvasStorageKey(scopeId),
|
||||
JSON.stringify({
|
||||
version: TEAM_WORKSPACE_CANVAS_STORAGE_VERSION,
|
||||
updatedAt: 1_710_000_000,
|
||||
viewport: overrides?.viewport ?? {
|
||||
x: 56,
|
||||
y: 56,
|
||||
zoom: 1,
|
||||
},
|
||||
items: overrides?.items ?? {},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
type HookStoredItems = Record<
|
||||
string,
|
||||
{ x: number; y: number; width: number; height: number; zIndex: number }
|
||||
>;
|
||||
|
||||
async function flushHookEffects() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function renderHook(props?: Partial<HookProps>) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
let latestValue: ReturnType<typeof useTeamWorkspaceCanvasController> | null =
|
||||
null;
|
||||
|
||||
const defaultProps: HookProps = {
|
||||
canvasLanes: [],
|
||||
canvasStorageScopeId: "scope-a",
|
||||
canvasViewportFallbackHeight: 560,
|
||||
embedded: false,
|
||||
expandedSessionId: null,
|
||||
onSelectSession: vi.fn(),
|
||||
};
|
||||
|
||||
function Probe(currentProps: HookProps) {
|
||||
latestValue = useTeamWorkspaceCanvasController(currentProps);
|
||||
return null;
|
||||
}
|
||||
|
||||
const render = async (nextProps?: Partial<HookProps>) => {
|
||||
await act(async () => {
|
||||
root.render(<Probe {...defaultProps} {...props} {...nextProps} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushHookEffects();
|
||||
};
|
||||
|
||||
mountedRoots.push({ root, container });
|
||||
|
||||
return {
|
||||
getValue: () => {
|
||||
if (!latestValue) {
|
||||
throw new Error("hook 尚未初始化");
|
||||
}
|
||||
return latestValue;
|
||||
},
|
||||
render,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
mounted.root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe("useTeamWorkspaceCanvasController", () => {
|
||||
it("切换 storage scope 时应重新加载对应的画布视口", async () => {
|
||||
seedCanvasLayout("scope-a", {
|
||||
viewport: {
|
||||
x: 120,
|
||||
y: 148,
|
||||
zoom: 1.22,
|
||||
},
|
||||
});
|
||||
seedCanvasLayout("scope-b", {
|
||||
viewport: {
|
||||
x: 24,
|
||||
y: 36,
|
||||
zoom: 0.84,
|
||||
},
|
||||
});
|
||||
|
||||
const harness = renderHook();
|
||||
|
||||
await harness.render({
|
||||
canvasStorageScopeId: "scope-a",
|
||||
});
|
||||
expect(harness.getValue().viewport).toMatchObject({
|
||||
x: 120,
|
||||
y: 148,
|
||||
zoom: 1.22,
|
||||
});
|
||||
|
||||
await harness.render({
|
||||
canvasStorageScopeId: "scope-b",
|
||||
});
|
||||
expect(harness.getValue().viewport).toMatchObject({
|
||||
x: 24,
|
||||
y: 36,
|
||||
zoom: 0.84,
|
||||
});
|
||||
});
|
||||
|
||||
it("选择成员 lane 时应回调选中会话并把当前 lane 置顶", async () => {
|
||||
const onSelectSession = vi.fn();
|
||||
const lanes = [createSessionLane("child-1"), createSessionLane("child-2")];
|
||||
seedCanvasLayout("scope-select", {
|
||||
items: {
|
||||
"session:child-1": {
|
||||
x: 320,
|
||||
y: 180,
|
||||
width: 360,
|
||||
height: 420,
|
||||
zIndex: 1,
|
||||
},
|
||||
"session:child-2": {
|
||||
x: 760,
|
||||
y: 220,
|
||||
width: 360,
|
||||
height: 420,
|
||||
zIndex: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const harness = renderHook({
|
||||
canvasLanes: lanes,
|
||||
canvasStorageScopeId: "scope-select",
|
||||
onSelectSession,
|
||||
});
|
||||
|
||||
await harness.render();
|
||||
|
||||
expect(
|
||||
harness.getValue().canvasLaneLayouts["session:child-1"]?.zIndex,
|
||||
).toBe(1);
|
||||
expect(
|
||||
harness.getValue().canvasLaneLayouts["session:child-2"]?.zIndex,
|
||||
).toBe(2);
|
||||
|
||||
act(() => {
|
||||
harness.getValue().handleSelectCanvasLane(lanes[0]);
|
||||
});
|
||||
|
||||
expect(onSelectSession).toHaveBeenCalledWith("child-1");
|
||||
expect(
|
||||
harness.getValue().canvasLaneLayouts["session:child-1"]?.zIndex,
|
||||
).toBeGreaterThan(
|
||||
harness.getValue().canvasLaneLayouts["session:child-2"]?.zIndex ?? 0,
|
||||
);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user