diff --git a/CHANGELOG.md b/CHANGELOG.md index adede049..5c529e59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,24 @@ ## [Unreleased] +## [0.4.20] - 2026-09-21 + +### 新增 +- 对话:长对话左侧出现聊天大纲,点刻度可跳到自己发出的消息;悬停预览,当前轮高亮 +- 对话:顶栏可切换会话,Alt+↑ / Alt+↓ 切上一条 / 下一条 +- 对话:本轮改过的文件会列出来,点「查看修改」可预览 + +### 改进 +- 对话:历史按工作目录分组,侧栏入口改成「工作区」;组标题显示文件夹名,悬停看路径,加号开新会话 +- 对话:思考过程和工具分开显示;思考是可折叠段,完成后写时长 +- 对话:计划条标出待做 / 进行中 / 已完成,可收起;Claude 新对话的任务清单接到计划条 +- 对话:顶栏不再写这次怎么接;从历史继续时,对上对方会话就复用 +- 用量:刷新嵌入的模型单价 + +### 修复 +- 对话:宽面板下聊天大纲会挂上可点刻度,不再只留下看不见的测量层 +- 对话:点「新建对话」不再因循环引用卡住 + ## [0.4.19] - 2026-09-17 ### 新增 diff --git a/Cargo.lock b/Cargo.lock index 56fa8dbd..a64f20ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "agenthub-cli" -version = "0.4.19" +version = "0.4.20" dependencies = [ "agenthub-core", "clap", @@ -24,7 +24,7 @@ dependencies = [ [[package]] name = "agenthub-core" -version = "0.4.19" +version = "0.4.20" dependencies = [ "async-stream", "axum", @@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "agenthub-gui" -version = "0.4.19" +version = "0.4.20" dependencies = [ "agenthub-core", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index 87ff675b..87540c18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.4.19" +version = "0.4.20" edition = "2021" license = "MIT" authors = ["AgentHub"] diff --git a/crates/agenthub-core/src/services/chat_runtime/actor_tests.rs b/crates/agenthub-core/src/services/chat_runtime/actor_tests.rs index 7f3ff93e..62681eae 100644 --- a/crates/agenthub-core/src/services/chat_runtime/actor_tests.rs +++ b/crates/agenthub-core/src/services/chat_runtime/actor_tests.rs @@ -2651,6 +2651,175 @@ fn claude_stream_result_completes_turn_and_keeps_session() { ); } +#[test] +fn claude_todo_write_fills_snapshot_not_timeline() { + let db = Database::open_in_memory().unwrap(); + conversation_with(&db, "claude-plan", AgentId::Claude, &std::env::temp_dir()); + let mut worker = worker(&db, "claude-plan"); + worker.agent = AgentId::Claude; + worker.store.enable_if_new("claude-plan").unwrap(); + start_placeholder(&mut worker); + + worker + .notification( + "claude/stream", + &json!({ + "type": "assistant", + "session_id": "claude-sess-plan", + "message": { + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "toolu_todo", + "name": "TodoWrite", + "input": { + "todos": [ + { "content": "read", "status": "completed" }, + { "content": "edit", "status": "in_progress" } + ] + } + }] + } + }), + ) + .unwrap(); + + let stored = worker.store.snapshot("claude-plan", None).unwrap(); + assert!(!stored.events.iter().any(|event| matches!( + &event.event, + ChatEvent::AgentProcess { .. } + ))); + let snapshot = worker.with_catalog_epoch(stored); + assert_eq!(snapshot.plan.len(), 2); + assert_eq!(snapshot.plan[0].content, "read"); + assert_eq!(snapshot.plan[0].status.as_deref(), Some("completed")); + assert_eq!(snapshot.plan[1].content, "edit"); + assert_eq!(snapshot.plan[1].status.as_deref(), Some("in_progress")); + + worker + .notification( + "claude/stream", + &json!({ + "type": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "id": "toolu_create", + "name": "TaskCreate", + "input": { "subject": "write tests" } + }] + } + }), + ) + .unwrap(); + worker + .notification( + "claude/stream", + &json!({ + "type": "user", + "tool_use_result": { "task": { "id": "task-2", "subject": "write tests" } }, + "message": { + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_create", + "content": "created" + }] + } + }), + ) + .unwrap(); + worker + .notification( + "claude/stream", + &json!({ + "type": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "name": "TaskUpdate", + "input": { "taskId": "task-2", "status": "in_progress" } + }] + } + }), + ) + .unwrap(); + + worker + .notification( + "claude/stream", + &json!({ + "type": "user", + "tool_use_result": { + "oldTodos": [{ "content": "read", "status": "pending" }], + "newTodos": [{ "content": "read", "status": "completed" }] + }, + "message": { + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_todo", + "content": "Todos have been modified successfully." + }] + } + }), + ) + .unwrap(); + worker + .notification( + "claude/stream", + &json!({ + "type": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "id": "toolu_bash", + "name": "Bash", + "input": { "command": "ls" } + }] + } + }), + ) + .unwrap(); + worker + .notification( + "claude/stream", + &json!({ + "type": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "name": "TodoWrite", + "input": { "todos": [] } + }] + } + }), + ) + .unwrap(); + + let patched = worker.with_catalog_epoch(worker.store.snapshot("claude-plan", None).unwrap()); + assert_eq!(patched.plan.len(), 3); + assert_eq!(patched.plan[2].content, "write tests"); + assert_eq!(patched.plan[2].id.as_deref(), Some("task-2")); + assert_eq!(patched.plan[2].status.as_deref(), Some("in_progress")); + assert!(patched.events.iter().any(|event| matches!( + &event.event, + ChatEvent::AgentProcess { + step: crate::models::ProcessStep::Tool { name, .. }, + .. + } if name == "Bash" + ))); + assert!(!patched.events.iter().any(|event| matches!( + &event.event, + ChatEvent::AgentProcess { + step: crate::models::ProcessStep::Tool { name, .. }, + .. + } if name == "TodoWrite" || name == "TaskCreate" || name == "TaskUpdate" || name == "tool" + ))); + + worker.clear_turn_plan(); + let cleared = worker.with_catalog_epoch(worker.store.snapshot("claude-plan", None).unwrap()); + assert!(cleared.plan.is_empty()); +} + #[cfg(unix)] #[test] fn grok_fs_write_outside_cwd_emits_card_then_writes_on_allow() { diff --git a/crates/agenthub-core/src/services/chat_runtime/mod.rs b/crates/agenthub-core/src/services/chat_runtime/mod.rs index 8effa3f4..7ad8cf74 100644 --- a/crates/agenthub-core/src/services/chat_runtime/mod.rs +++ b/crates/agenthub-core/src/services/chat_runtime/mod.rs @@ -102,6 +102,10 @@ struct CatalogCache { image_input: Option, catalog_epoch: i64, plan: Vec, + /// TaskCreate tool_use id → subject, until the result assigns a task id. + pending_plan_creates: Vec<(String, String)>, + /// Plan-tool `tool_use` ids this turn, so matching results stay off the timeline. + plan_tool_use_ids: Vec, /// Conversation-local Always allow. Survives catalog refetch; not SQLite. session_allow_always: bool, } @@ -117,6 +121,8 @@ fn merge_catalog_cache(previous: Option<&CatalogCache>, mut fetched: CatalogCach fetched.catalog_epoch = previous.catalog_epoch.max(fetched.catalog_epoch); if fetched.plan.is_empty() { fetched.plan = previous.plan.clone(); + fetched.pending_plan_creates = previous.pending_plan_creates.clone(); + fetched.plan_tool_use_ids = previous.plan_tool_use_ids.clone(); } fetched.session_allow_always = fetched.session_allow_always || previous.session_allow_always; @@ -124,6 +130,128 @@ fn merge_catalog_cache(previous: Option<&CatalogCache>, mut fetched: CatalogCach fetched } +fn apply_claude_plan_op( + cache: &mut CatalogCache, + op: crate::utils::stream_parse::claude::ClaudePlanOp, +) { + use crate::utils::stream_parse::claude::ClaudePlanOp; + match op { + ClaudePlanOp::Replace(entries) => { + let next: Vec = entries + .into_iter() + .filter(|entry| !entry.content.trim().is_empty()) + .map(|entry| RuntimePlanEntry { + content: entry.content, + status: entry.status, + priority: entry.priority, + id: entry.id, + }) + .collect(); + if next.is_empty() { + return; + } + cache.plan = next; + cache.pending_plan_creates.clear(); + } + ClaudePlanOp::Create { + tool_use_id, + content, + status, + id, + priority, + } => { + let content = content.trim(); + if content.is_empty() { + return; + } + if let Some(existing_id) = id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if let Some(existing) = cache + .plan + .iter_mut() + .find(|entry| entry.id.as_deref() == Some(existing_id)) + { + existing.content = content.to_string(); + if status.is_some() { + existing.status = status; + } + if priority.is_some() { + existing.priority = priority; + } + return; + } + } + cache.plan.push(RuntimePlanEntry { + content: content.to_string(), + status: status.or_else(|| Some("pending".into())), + priority, + id: id.filter(|value| !value.trim().is_empty()), + }); + if let Some(tool_use_id) = tool_use_id.filter(|value| !value.is_empty()) { + cache + .pending_plan_creates + .push((tool_use_id, content.to_string())); + } + } + ClaudePlanOp::Update { + id, + status, + content, + priority, + } => { + let id = id.trim(); + if id.is_empty() { + return; + } + let deleted = status.as_deref().is_some_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "deleted" | "removed" + ) + }); + if deleted { + cache.plan.retain(|entry| entry.id.as_deref() != Some(id)); + return; + } + if let Some(existing) = cache + .plan + .iter_mut() + .find(|entry| entry.id.as_deref() == Some(id)) + { + if let Some(status) = status { + existing.status = Some(status); + } + if let Some(content) = content.filter(|value| !value.trim().is_empty()) { + existing.content = content; + } + if let Some(priority) = priority { + existing.priority = Some(priority); + } + } + } + ClaudePlanOp::BindId { tool_use_id, id } => { + let Some(pos) = cache + .pending_plan_creates + .iter() + .position(|(pending, _)| pending == &tool_use_id) + else { + return; + }; + let (_, subject) = cache.pending_plan_creates.remove(pos); + if let Some(entry) = cache + .plan + .iter_mut() + .find(|entry| entry.id.is_none() && entry.content == subject) + { + entry.id = Some(id); + } + } + } +} + /// Map product identity → transport channel. Same channel (e.g. ACP) may be /// shared by multiple AgentIds; identities remain distinct (identity families). fn runtime_channel(agent: Option) -> RuntimeChannel { @@ -1439,7 +1567,7 @@ impl ActorWorker { let now = Utc::now().to_rfc3339(); let message_id = format!("msg-{}", Uuid::new_v4()); let mut user = ChatMessage { - id: format!("msg-{}", Uuid::new_v4()), + id: client_request_id.to_string(), conversation_id: self.conversation_id.clone(), turn: 0, role: ChatRole::User, @@ -1906,6 +2034,12 @@ impl ActorWorker { } } + self.remember_claude_plan_tools(params); + let plan_ops = crate::utils::stream_parse::claude::extract_todo_plan(params); + if !plan_ops.is_empty() { + self.apply_claude_plan(plan_ops); + } + let ty = params.get("type").and_then(Value::as_str).unwrap_or(""); if ty == "result" { let is_err = params @@ -1919,6 +2053,9 @@ impl ActorWorker { if let Some(steps) = crate::utils::stream_parse::claude::parse_line(¶ms.to_string()) { for step in steps { + if self.drops_claude_plan_process(&step) { + continue; + } match step { ProcessStep::Text { text } => { if !text.is_empty() { @@ -1993,6 +2130,9 @@ impl ActorWorker { return Ok(()); }; for step in steps { + if self.drops_claude_plan_process(&step) { + continue; + } match step { ProcessStep::Text { text } => { if !text.is_empty() { @@ -3247,15 +3387,62 @@ impl ActorWorker { content: entry.content, status: entry.status, priority: entry.priority, + id: None, }) .collect(); } } + fn apply_claude_plan(&self, ops: Vec) { + if ops.is_empty() { + return; + } + if let Ok(mut guard) = self.catalogs.lock() { + let cache = guard.entry(self.conversation_id.clone()).or_default(); + for op in ops { + apply_claude_plan_op(cache, op); + } + } + } + + fn remember_claude_plan_tools(&self, params: &Value) { + let ids = crate::utils::stream_parse::claude::plan_tool_use_ids(params); + if ids.is_empty() { + return; + } + if let Ok(mut guard) = self.catalogs.lock() { + let cache = guard.entry(self.conversation_id.clone()).or_default(); + for id in ids { + if !cache.plan_tool_use_ids.iter().any(|existing| existing == &id) { + cache.plan_tool_use_ids.push(id); + } + } + } + } + + fn drops_claude_plan_process(&self, step: &ProcessStep) -> bool { + let ProcessStep::Tool { id, name, .. } = step else { + return false; + }; + if crate::utils::stream_parse::claude::is_claude_plan_tool_name(name) { + return true; + } + let Some(id) = id.as_deref() else { + return false; + }; + self.catalogs.lock().ok().is_some_and(|guard| { + guard + .get(&self.conversation_id) + .is_some_and(|cache| cache.plan_tool_use_ids.iter().any(|known| known == id)) + }) + } + fn clear_turn_plan(&self) { if let Ok(mut guard) = self.catalogs.lock() { if let Some(entry) = guard.get_mut(&self.conversation_id) { entry.plan.clear(); + entry.pending_plan_creates.clear(); + entry.plan_tool_use_ids.clear(); } } } diff --git a/crates/agenthub-core/src/services/chat_runtime/tests.rs b/crates/agenthub-core/src/services/chat_runtime/tests.rs index 49fd690f..bf1f5d66 100644 --- a/crates/agenthub-core/src/services/chat_runtime/tests.rs +++ b/crates/agenthub-core/src/services/chat_runtime/tests.rs @@ -131,6 +131,7 @@ fn begin_turn_bumps_conversation_sort_time_without_renaming() { }] }) .unwrap(); + assert_eq!(user.id, "user-1"); let listed = repo.list_conversations().unwrap(); assert_eq!(listed[0].id, "older"); diff --git a/crates/agenthub-core/src/services/chat_runtime/types.rs b/crates/agenthub-core/src/services/chat_runtime/types.rs index 2077b94a..ec4fc992 100644 --- a/crates/agenthub-core/src/services/chat_runtime/types.rs +++ b/crates/agenthub-core/src/services/chat_runtime/types.rs @@ -141,7 +141,7 @@ pub struct RuntimeSnapshot { /// Bumps when Options catalog changes (slash commands, handshake image). Not the command list. #[serde(default)] pub catalog_epoch: i64, - /// Current-turn ACP plan. Live chrome only — not a process row, dropped on the next turn. + /// Current-turn plan. Live chrome only — not a process row, dropped on the next turn. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub plan: Vec, /// Live ACP host commands. One card per terminal id; not a conversation TTY. @@ -193,6 +193,9 @@ pub struct RuntimePlanEntry { pub status: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub priority: Option, + /// Vendor task id when the Agent sent one. Matching only; not shown. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/crates/agenthub-core/src/services/chat_service.rs b/crates/agenthub-core/src/services/chat_service.rs index 6fdd1fe8..b1a59f4b 100644 --- a/crates/agenthub-core/src/services/chat_service.rs +++ b/crates/agenthub-core/src/services/chat_service.rs @@ -128,7 +128,8 @@ impl ChatService { /// Open or create an AgentHub conversation keyed by official session id. /// History comes from the store (or is imported once). Missing cwd is kept - /// for display and never fails this call. + /// for display and never fails this call. Same Agent and working directory + /// without that session id still create a new row. pub fn open_from_session( &self, agent_id: AgentId, diff --git a/crates/agenthub-core/src/services/chat_service/tests.rs b/crates/agenthub-core/src/services/chat_service/tests.rs index 197064b3..a705ef35 100644 --- a/crates/agenthub-core/src/services/chat_service/tests.rs +++ b/crates/agenthub-core/src/services/chat_service/tests.rs @@ -1715,6 +1715,48 @@ fn rebind_missing_cwd_allowed_after_session_starts() { assert_eq!(chat.list_messages(&again.id).unwrap().len(), 2); } +#[test] +fn open_from_session_creates_when_native_id_is_new() { + let dir = tempdir().unwrap(); + let db = Database::open(&dir.path().join("t.db")).unwrap(); + let run = Arc::new(RunService::with_runner( + deterministic_registry(), + Arc::new(RecordingProcessRunner::new()), + )); + let chat = ChatService::new(db, run); + let existing = chat + .create_conversation(vec![AgentId::Claude], Some("/work/app".into())) + .unwrap(); + + let opened = chat + .open_from_session( + AgentId::Claude, + Some("sess-history".into()), + Some("/work/app".into()), + Some("历史里的那场".into()), + vec![ChatHistoryTurn { + role: ChatRole::User, + content: "接着改登录页".into(), + }], + ) + .unwrap(); + assert_ne!(opened.id, existing.id); + assert_eq!(opened.native_session_id.as_deref(), Some("sess-history")); + assert_eq!(chat.list_conversations().unwrap().len(), 2); + + let again = chat + .open_from_session( + AgentId::Claude, + Some("sess-history".into()), + Some("/work/app".into()), + Some("忽略".into()), + vec![], + ) + .unwrap(); + assert_eq!(again.id, opened.id); + assert_eq!(chat.list_conversations().unwrap().len(), 2); +} + #[test] fn rebind_missing_cwd_rejects_clear() { let dir = tempdir().unwrap(); diff --git a/crates/agenthub-core/src/usage/embedded-pricing.json b/crates/agenthub-core/src/usage/embedded-pricing.json index 851f7c1b..7a572a81 100644 --- a/crates/agenthub-core/src/usage/embedded-pricing.json +++ b/crates/agenthub-core/src/usage/embedded-pricing.json @@ -413,12 +413,24 @@ "cacheCreate": 2.5, "cacheRead": 0.5 }, + "dashscope/qwen3.8-flash": { + "input": 0.15, + "output": 0.47, + "cacheCreate": 0.2, + "cacheRead": 0.016 + }, "dashscope/qwen3.8-max": { "input": 2, "output": 6, "cacheCreate": 2, "cacheRead": 0.25 }, + "dashscope/qwen3.8-omni-flash": { + "input": 0.15, + "output": 0.47, + "cacheCreate": 0.15, + "cacheRead": 0.016 + }, "dashscope/qwq-plus": { "input": 0.8, "output": 2.4, @@ -821,17 +833,17 @@ "cacheRead": 0.03 }, "gemini-flash-latest": { + "input": 0.75, + "output": 3.75, + "cacheCreate": 0.75, + "cacheRead": 0.075 + }, + "gemini-flash-lite-latest": { "input": 0.3, "output": 2.5, "cacheCreate": 0.3, "cacheRead": 0.03 }, - "gemini-flash-lite-latest": { - "input": 0.1, - "output": 0.4, - "cacheCreate": 0.1, - "cacheRead": 0.01 - }, "gemini-gemma-2-27b-it": { "input": 0.35, "output": 1.05, @@ -853,13 +865,13 @@ "cacheCreate": 1.5 }, "gemini-pro-latest": { - "input": 1.25, - "output": 10, - "cacheCreate": 1.25, - "cacheRead": 0.125, - "inputAbove200k": 2.5, - "outputAbove200k": 15, - "cacheReadAbove200k": 0.25 + "input": 2, + "output": 12, + "cacheCreate": 2, + "cacheRead": 0.2, + "inputAbove200k": 4, + "outputAbove200k": 18, + "cacheReadAbove200k": 0.4 }, "gemini-robotics-er-1.5-preview": { "input": 0.3, @@ -873,10 +885,10 @@ "cacheCreate": 1 }, "gemini-robotics-er-2-preview": { - "input": 2, - "output": 10, - "cacheCreate": 2, - "cacheRead": 0.2 + "input": 1, + "output": 5, + "cacheCreate": 1, + "cacheRead": 0.1 }, "gemini-robotics-er-2-streaming-preview": { "input": 2, @@ -1029,17 +1041,17 @@ "cacheRead": 0.075 }, "gemini/gemini-flash-latest": { + "input": 0.75, + "output": 3.75, + "cacheCreate": 0.75, + "cacheRead": 0.075 + }, + "gemini/gemini-flash-lite-latest": { "input": 0.3, "output": 2.5, "cacheCreate": 0.3, "cacheRead": 0.03 }, - "gemini/gemini-flash-lite-latest": { - "input": 0.1, - "output": 0.4, - "cacheCreate": 0.1, - "cacheRead": 0.01 - }, "gemini/gemini-gemma-2-27b-it": { "input": 0.35, "output": 1.05, @@ -1061,13 +1073,13 @@ "cacheCreate": 1.5 }, "gemini/gemini-pro-latest": { - "input": 1.25, - "output": 10, - "cacheCreate": 1.25, - "cacheRead": 0.125, - "inputAbove200k": 2.5, - "outputAbove200k": 15, - "cacheReadAbove200k": 0.25 + "input": 2, + "output": 12, + "cacheCreate": 2, + "cacheRead": 0.2, + "inputAbove200k": 4, + "outputAbove200k": 18, + "cacheReadAbove200k": 0.4 }, "gemini/gemini-robotics-er-1.5-preview": { "input": 0.3, @@ -1081,10 +1093,10 @@ "cacheCreate": 1 }, "gemini/gemini-robotics-er-2-preview": { - "input": 2, - "output": 10, - "cacheCreate": 2, - "cacheRead": 0.2 + "input": 1, + "output": 5, + "cacheCreate": 1, + "cacheRead": 0.1 }, "gemini/gemini-robotics-er-2-streaming-preview": { "input": 2, @@ -2830,12 +2842,24 @@ "cacheCreate": 2.5, "cacheRead": 0.5 }, + "qwen3.8-flash": { + "input": 0.15, + "output": 0.47, + "cacheCreate": 0.2, + "cacheRead": 0.016 + }, "qwen3.8-max": { "input": 2, "output": 6, "cacheCreate": 2, "cacheRead": 0.25 }, + "qwen3.8-omni-flash": { + "input": 0.15, + "output": 0.47, + "cacheCreate": 0.15, + "cacheRead": 0.016 + }, "qwq-plus": { "input": 0.8, "output": 2.4, diff --git a/crates/agenthub-core/src/usage/embedded-pricing.meta.json b/crates/agenthub-core/src/usage/embedded-pricing.meta.json index c0cd1b5d..91c1bc61 100644 --- a/crates/agenthub-core/src/usage/embedded-pricing.meta.json +++ b/crates/agenthub-core/src/usage/embedded-pricing.meta.json @@ -1,9 +1,9 @@ { "source": "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json", - "fetchedAt": "2026-09-16T11:17:33.814Z", - "modelCount": 515, - "fromLitellmRows": 299, - "aliasKeysAdded": 211, + "fetchedAt": "2026-09-20T11:05:19.474Z", + "modelCount": 519, + "fromLitellmRows": 301, + "aliasKeysAdded": 213, "overrideKeys": 16, "unit": "USD per 1M tokens", "notes": "Offline embedded snapshot. Runtime does not fetch pricing. Re-run scripts/update-embedded-pricing.mjs or wait for daily CI." diff --git a/crates/agenthub-core/src/utils/stream_parse/claude.rs b/crates/agenthub-core/src/utils/stream_parse/claude.rs index 71728293..e52b4bd8 100644 --- a/crates/agenthub-core/src/utils/stream_parse/claude.rs +++ b/crates/agenthub-core/src/utils/stream_parse/claude.rs @@ -56,8 +56,16 @@ impl ClaudeStreamParser { "user" => Some(parse_user_tool_results(&v)), "result" => Some(self.parse_result(&v)), "content_block_delta" | "stream_event" => self.parse_deltaish(&v), - "tool_use" => Some(vec![tool_from_obj(&v, "start")]), - "tool_result" => Some(vec![tool_result_from_obj(&v)]), + "tool_use" => Some(if is_claude_plan_tool_name(tool_name(&v)) { + vec![] + } else { + vec![tool_from_obj(&v, "start")] + }), + "tool_result" => Some(if is_claude_plan_result(&v) { + vec![] + } else { + vec![tool_result_from_obj(&v)] + }), "error" => { let message = v .get("error") @@ -145,7 +153,11 @@ impl ClaudeStreamParser { }); } } - "tool_use" => steps.push(tool_from_obj(block, "start")), + "tool_use" => { + if !is_claude_plan_tool_name(tool_name(block)) { + steps.push(tool_from_obj(block, "start")); + } + } _ => {} } } @@ -225,6 +237,7 @@ pub fn parse_line(line: &str) -> Option> { fn parse_user_tool_results(v: &Value) -> Vec { let mut steps = Vec::new(); + let parent_plan_result = looks_like_plan_output(v); let content = v .pointer("/message/content") .or_else(|| v.get("content")) @@ -232,14 +245,22 @@ fn parse_user_tool_results(v: &Value) -> Vec { .unwrap_or(Value::Null); if let Some(arr) = content.as_array() { for block in arr { - if block.get("type").and_then(|t| t.as_str()) == Some("tool_result") { - steps.push(tool_result_from_obj(block)); + if block.get("type").and_then(|t| t.as_str()) != Some("tool_result") { + continue; + } + if is_claude_plan_result(block) || (parent_plan_result && tool_name(block).is_empty()) { + continue; } + steps.push(tool_result_from_obj(block)); } } steps } +fn tool_name(v: &Value) -> &str { + v.get("name").and_then(|name| name.as_str()).unwrap_or("") +} + fn tool_from_obj(v: &Value, status: &str) -> ProcessStep { let id = v .get("id") @@ -300,5 +321,301 @@ fn tool_result_from_obj(v: &Value) -> ProcessStep { } } +/// One Claude todo / Task tool row. Not a `ProcessStep`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ClaudePlanEntry { + pub content: String, + pub status: Option, + pub priority: Option, + pub id: Option, +} + +/// Mutations from TodoWrite / Task* tool_use and matching results. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ClaudePlanOp { + Replace(Vec), + Create { + tool_use_id: Option, + content: String, + status: Option, + id: Option, + priority: Option, + }, + Update { + id: String, + status: Option, + content: Option, + priority: Option, + }, + BindId { + tool_use_id: String, + id: String, + }, +} + +/// TodoWrite replaces the list; TaskCreate/TaskUpdate patch it. Not a process row. +pub(crate) fn extract_todo_plan(v: &Value) -> Vec { + if let Some(event) = v.get("event") { + let nested = extract_todo_plan(event); + if !nested.is_empty() { + return nested; + } + } + let mut ops = Vec::new(); + for_each_tool_use(v, |block| { + if let Some(op) = plan_op_from_tool_use(block) { + ops.push(op); + } + }); + ops.extend(plan_ops_from_results(v)); + ops +} + +pub(crate) fn is_claude_plan_tool_name(name: &str) -> bool { + plan_tool_kind(name).is_some() +} + +pub(crate) fn plan_tool_use_ids(v: &Value) -> Vec { + let mut ids = Vec::new(); + for_each_tool_use(v, |block| { + if !is_claude_plan_tool_name(tool_name(block)) { + return; + } + if let Some(id) = first_str(block, &["id", "tool_use_id"]) { + ids.push(id); + } + }); + ids +} + +fn is_claude_plan_result(v: &Value) -> bool { + is_claude_plan_tool_name(tool_name(v)) || looks_like_plan_output(v) +} + +fn plan_tool_kind(name: &str) -> Option<&'static str> { + let compact = name + .trim() + .to_ascii_lowercase() + .replace(['_', '-', ' '], ""); + match compact.as_str() { + "todowrite" => Some("write"), + "todoread" => Some("read"), + "taskcreate" => Some("create"), + "taskupdate" => Some("update"), + "taskget" => Some("get"), + "tasklist" => Some("list"), + _ => None, + } +} + +fn for_each_tool_use(v: &Value, mut visit: impl FnMut(&Value)) { + let ty = v.get("type").and_then(Value::as_str).unwrap_or(""); + if ty == "tool_use" { + visit(v); + return; + } + if let Some(block) = v.get("content_block") { + if block.get("type").and_then(Value::as_str) == Some("tool_use") { + visit(block); + } + } + let content = v.pointer("/message/content").or_else(|| v.get("content")); + if let Some(items) = content.and_then(Value::as_array) { + for block in items { + if block.get("type").and_then(Value::as_str) == Some("tool_use") { + visit(block); + } + } + } +} + +fn plan_op_from_tool_use(block: &Value) -> Option { + let kind = plan_tool_kind(tool_name(block))?; + let input = block.get("input").unwrap_or(&Value::Null); + match kind { + "write" => { + let entries = input + .get("todos") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(parse_plan_entry) + .collect::>(); + Some(ClaudePlanOp::Replace(entries)) + } + "create" => { + let content = first_str(input, &["subject", "content", "text", "title"])?; + Some(ClaudePlanOp::Create { + tool_use_id: first_str(block, &["id", "tool_use_id"]), + content, + status: first_str(input, &["status"]).or_else(|| Some("pending".into())), + id: first_str(input, &["taskId", "id", "task_id"]), + priority: first_str(input, &["priority"]), + }) + } + "update" => { + let id = first_str(input, &["taskId", "id", "task_id"])?; + Some(ClaudePlanOp::Update { + id, + status: first_str(input, &["status"]), + content: first_str(input, &["subject", "content", "text", "title"]), + priority: first_str(input, &["priority"]), + }) + } + _ => None, + } +} + +fn plan_ops_from_results(v: &Value) -> Vec { + let mut ops = Vec::new(); + let parent_result = v + .get("tool_use_result") + .or_else(|| v.pointer("/message/tool_use_result")); + let content = v.pointer("/message/content").or_else(|| v.get("content")); + if let Some(items) = content.and_then(Value::as_array) { + for block in items { + if block.get("type").and_then(Value::as_str) != Some("tool_result") { + continue; + } + collect_result_ops(block, parent_result, &mut ops); + } + } else if v.get("type").and_then(Value::as_str) == Some("tool_result") { + collect_result_ops(v, parent_result, &mut ops); + } + ops +} + +fn collect_result_ops(block: &Value, parent_result: Option<&Value>, ops: &mut Vec) { + if let Some(tool_use_id) = first_str(block, &["tool_use_id"]) { + if let Some(id) = task_id_from(parent_result).or_else(|| task_id_from(Some(block))) { + ops.push(ClaudePlanOp::BindId { tool_use_id, id }); + } + } + if let Some(entries) = tasks_from_payload(parent_result).or_else(|| tasks_from_value(block)) { + if !entries.is_empty() { + ops.push(ClaudePlanOp::Replace(entries)); + } + } +} + +fn task_id_from(payload: Option<&Value>) -> Option { + payload + .and_then(structured_task_output)? + .pointer("/task/id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn tasks_from_payload(payload: Option<&Value>) -> Option> { + let parsed = payload.and_then(structured_task_output)?; + let items = parsed.get("tasks").and_then(Value::as_array)?; + let entries = items + .iter() + .filter_map(parse_plan_entry) + .collect::>(); + (!entries.is_empty()).then_some(entries) +} + +fn looks_like_plan_output(v: &Value) -> bool { + payload_has_plan_shape(v) + || v.get("tool_use_result") + .is_some_and(payload_has_plan_shape) + || v.pointer("/message/tool_use_result") + .is_some_and(payload_has_plan_shape) + || v.get("content") + .and_then(parse_jsonish) + .is_some_and(|value| payload_has_plan_shape(&value)) + || v.get("output") + .and_then(parse_jsonish) + .is_some_and(|value| payload_has_plan_shape(&value)) +} + +fn payload_has_plan_shape(v: &Value) -> bool { + v.get("task").is_some() + || v.get("tasks").is_some() + || v.get("oldTodos").is_some() + || v.get("newTodos").is_some() + || v.get("old_todos").is_some() + || v.get("new_todos").is_some() + || v.get("updatedFields").is_some() + || v.get("updated_fields").is_some() + || ((v.get("taskId").is_some() || v.get("task_id").is_some()) + && (v.get("success").is_some() || v.get("status").is_some())) +} + +fn structured_task_output(v: &Value) -> Option { + if payload_has_plan_shape(v) { + return Some(v.clone()); + } + if let Some(direct) = v.get("tool_use_result") { + if payload_has_plan_shape(direct) { + return Some(direct.clone()); + } + } + let content = v.get("content").or_else(|| v.get("output"))?; + parse_jsonish(content).filter(payload_has_plan_shape) +} + +fn tasks_from_value(v: &Value) -> Option> { + let parsed = structured_task_output(v)?; + let items = parsed.get("tasks").and_then(Value::as_array)?; + let entries = items + .iter() + .filter_map(parse_plan_entry) + .collect::>(); + (!entries.is_empty()).then_some(entries) +} + +fn parse_plan_entry(value: &Value) -> Option { + let content = first_str(value, &["content", "subject", "text", "title"])?; + Some(ClaudePlanEntry { + content, + status: first_str(value, &["status"]), + priority: first_str(value, &["priority"]), + id: first_str(value, &["id", "taskId", "task_id"]), + }) +} + +fn parse_jsonish(content: &Value) -> Option { + match content { + Value::Object(_) => Some(content.clone()), + Value::String(text) => serde_json::from_str(text).ok(), + Value::Array(items) => { + let text = items + .iter() + .filter_map(|item| { + if item.get("type").and_then(Value::as_str) == Some("text") { + item.get("text").and_then(Value::as_str) + } else { + None + } + }) + .collect::(); + if text.trim().is_empty() { + None + } else { + serde_json::from_str(&text).ok() + } + } + _ => None, + } +} + +fn first_str(value: &Value, keys: &[&str]) -> Option { + for key in keys { + if let Some(text) = value + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + { + return Some(text.to_string()); + } + } + None +} + #[cfg(test)] mod tests; diff --git a/crates/agenthub-core/src/utils/stream_parse/claude/tests.rs b/crates/agenthub-core/src/utils/stream_parse/claude/tests.rs index 09a19376..a2170499 100644 --- a/crates/agenthub-core/src/utils/stream_parse/claude/tests.rs +++ b/crates/agenthub-core/src/utils/stream_parse/claude/tests.rs @@ -76,3 +76,284 @@ fn deltas_then_result_does_not_double_assistant_text() { let _ = s.feed(OutputStream::Stdout, ndjson); assert_eq!(s.assistant_text(), "PONGC"); } + +#[test] +fn todo_write_is_plan_not_process_step() { + let payload = serde_json::json!({ + "type": "assistant", + "message": { + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "toolu_1", + "name": "TodoWrite", + "input": { + "todos": [ + { "content": "read", "status": "completed", "priority": "high" }, + { "content": "edit", "status": "in_progress" }, + { "content": " " }, + { "content": "test", "status": "pending" } + ] + } + }] + } + }); + let steps = parse_line(&payload.to_string()).unwrap(); + assert!(steps.is_empty(), "{steps:?}"); + let ops = super::extract_todo_plan(&payload); + assert_eq!(ops.len(), 1); + match &ops[0] { + super::ClaudePlanOp::Replace(entries) => { + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].content, "read"); + assert_eq!(entries[0].status.as_deref(), Some("completed")); + assert_eq!(entries[1].status.as_deref(), Some("in_progress")); + assert_eq!(entries[2].content, "test"); + } + other => panic!("expected replace, got {other:?}"), + } +} + +#[test] +fn task_create_and_update_are_plan_patches() { + let create = serde_json::json!({ + "type": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "id": "toolu_create", + "name": "TaskCreate", + "input": { "subject": "build auth", "activeForm": "Building auth" } + }] + } + }); + let ops = super::extract_todo_plan(&create); + assert_eq!( + ops, + vec![super::ClaudePlanOp::Create { + tool_use_id: Some("toolu_create".into()), + content: "build auth".into(), + status: Some("pending".into()), + id: None, + priority: None, + }] + ); + let update = serde_json::json!({ + "type": "tool_use", + "id": "toolu_upd", + "name": "TaskUpdate", + "input": { "taskId": "task-1", "status": "in_progress" } + }); + assert_eq!( + super::extract_todo_plan(&update), + vec![super::ClaudePlanOp::Update { + id: "task-1".into(), + status: Some("in_progress".into()), + content: None, + priority: None, + }] + ); +} + +#[test] +fn task_create_result_binds_id() { + let payload = serde_json::json!({ + "type": "user", + "tool_use_result": { "task": { "id": "task-9", "subject": "build auth" } }, + "message": { + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_create", + "content": "created" + }] + } + }); + assert!(parse_line(&payload.to_string()).unwrap().is_empty()); + assert_eq!( + super::extract_todo_plan(&payload), + vec![super::ClaudePlanOp::BindId { + tool_use_id: "toolu_create".into(), + id: "task-9".into(), + }] + ); +} + +#[test] +fn todo_write_result_is_not_process_step() { + let payload = serde_json::json!({ + "type": "user", + "tool_use_result": { + "oldTodos": [{ "content": "read", "status": "pending" }], + "newTodos": [{ "content": "read", "status": "completed" }] + }, + "message": { + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_todo", + "content": "Todos have been modified successfully." + }] + } + }); + assert!(parse_line(&payload.to_string()).unwrap().is_empty()); +} + +#[test] +fn task_list_result_replaces_plan() { + let payload = serde_json::json!({ + "type": "user", + "tool_use_result": { + "tasks": [ + { "id": "t1", "subject": "read", "status": "completed" }, + { "id": "t2", "subject": "edit", "status": "in_progress" } + ] + }, + "message": { + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_list", + "content": "listed" + }] + } + }); + assert!(parse_line(&payload.to_string()).unwrap().is_empty()); + match &super::extract_todo_plan(&payload)[0] { + super::ClaudePlanOp::Replace(entries) => { + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].id.as_deref(), Some("t1")); + assert_eq!(entries[1].content, "edit"); + } + other => panic!("expected replace, got {other:?}"), + } +} + +#[test] +fn empty_todo_write_is_replace_with_no_rows() { + let payload = serde_json::json!({ + "type": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "name": "TodoWrite", + "input": { "todos": [] } + }] + } + }); + assert_eq!( + super::extract_todo_plan(&payload), + vec![super::ClaudePlanOp::Replace(vec![])] + ); +} + +#[test] +fn bash_tool_use_still_emits_process_step() { + let steps = parse_line( + r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]}}"#, + ) + .unwrap(); + assert!(matches!( + &steps[0], + ProcessStep::Tool { name, .. } if name == "Bash" + )); + assert!(super::extract_todo_plan(&serde_json::json!({ + "type": "assistant", + "message": { "content": [{ "type": "tool_use", "name": "Bash", "input": { "command": "ls" } }] } + })) + .is_empty()); +} + +#[test] +fn hyphenated_plan_tool_names_and_nested_event_unwrap() { + assert!(super::is_claude_plan_tool_name("Todo-Write")); + assert!(super::is_claude_plan_tool_name("Task Create")); + assert!(super::is_claude_plan_tool_name("todo_read")); + assert!(!super::is_claude_plan_tool_name("Bash")); + let payload = serde_json::json!({ + "event": { + "type": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "id": "toolu_todo", + "name": "Todo-Write", + "input": { + "todos": [ + { "title": "read docs", "status": "pending" }, + { "content": " " } + ] + } + }] + } + } + }); + match &super::extract_todo_plan(&payload)[0] { + super::ClaudePlanOp::Replace(entries) => { + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].content, "read docs"); + assert_eq!(entries[0].status.as_deref(), Some("pending")); + } + other => panic!("expected replace, got {other:?}"), + } + assert_eq!( + super::plan_tool_use_ids(payload.get("event").expect("event")), + vec!["toolu_todo".to_string()] + ); +} + +#[test] +fn content_block_task_create_and_skipped_read_tools() { + let create = serde_json::json!({ + "type": "content_block_start", + "content_block": { + "type": "tool_use", + "id": "toolu_create", + "name": "TaskCreate", + "input": { "title": "build auth", "priority": "high" } + } + }); + assert_eq!( + super::extract_todo_plan(&create), + vec![super::ClaudePlanOp::Create { + tool_use_id: Some("toolu_create".into()), + content: "build auth".into(), + status: Some("pending".into()), + id: None, + priority: Some("high".into()), + }] + ); + assert!(super::extract_todo_plan(&serde_json::json!({ + "type": "tool_use", + "name": "TodoRead", + "input": { "todos": [{ "content": "ignore" }] } + })) + .is_empty()); + assert!(super::extract_todo_plan(&serde_json::json!({ + "type": "tool_use", + "name": "TaskUpdate", + "input": { "status": "in_progress" } + })) + .is_empty()); + assert!(super::extract_todo_plan(&serde_json::json!({ + "type": "tool_use", + "name": "TaskCreate", + "input": { "status": "pending" } + })) + .is_empty()); +} + +#[test] +fn task_update_accepts_task_id_alias() { + let update = serde_json::json!({ + "type": "tool_use", + "name": "TaskUpdate", + "input": { "task_id": "task-3", "subject": "rewrite", "status": "completed" } + }); + assert_eq!( + super::extract_todo_plan(&update), + vec![super::ClaudePlanOp::Update { + id: "task-3".into(), + status: Some("completed".into()), + content: Some("rewrite".into()), + priority: None, + }] + ); +} diff --git a/docs/STATUS.md b/docs/STATUS.md index 1c7f63a6..2bd5efa0 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -3,7 +3,7 @@ title: AgentHub 当前实现状态 type: status status: current owner: maintainers -updated: 2026-09-17 +updated: 2026-09-20 --- # 当前实现状态 @@ -24,10 +24,10 @@ updated: 2026-09-17 - **新空 Grok 会话**:持续聊天(模型/思考、图片、后续轮排队),**不支持**为本轮指定「用于本次」技能,界面也不画可点的假按钮。真实窗口验收已通过。Chat 只带官方 `grok agent --no-leader stdio` 旗标(`--permission-mode` 写在 `agent` 前或后都会让进程在出卡前退出)。`session/new` 带 `_meta.yoloMode=false`,覆盖本机 always-approve;握手按官方 ACP 声明本机可读写文件、不发 `initialized`。工作目录外写出走本机 `fs/write_text_file`:先出「修改文件」卡片再写文件,可点一直允许;目录内直接写。对方若另发 `session/request_permission`,卡片仍只带对方给的「一直允许」。会话自动批准才加 `--always-approve` 和 `_meta.yoloMode=true`。进程退出时界面写「Grok 已退出」,不写 Codex 的 app-server 字样。 - **新空 Kiro 会话**:`kiro-cli acp` 持续通道(允许/拒绝、停止;生成时不能中途补充,可排队到下一轮)。真实窗口验收已通过(ACP 新对话;打印路径 HTTP 多轮为 Builder ID / 本机登录,不是企业 IdC)。旧对话保留原发送方式。 - **其余 Agent 与旧会话**:仍走原发送方式。 - - **过程面板**:主列一行过程摘要(正在读取 / 正在修改 / 正在执行,完成则已读取 / 已修改 / 已执行)。点开后在右侧栏看你说了什么、思考、工具行、等待允许或拒绝、本轮用量;工具名、状态和 JSON 进折叠的「细节」;命令、过程日志、退出码和状态事件仍在「运行详情」。允许 / 拒绝按钮仍在卡片上,不在过程行上造假按钮。右侧栏不随发送自动打开;点 Markdown 仍预览文件。种类约定见 [过程事件](concepts/chat-process-events.md)。斜杠目录和模型目录更新不进过程时间线。Grok / Kiro 若推了当前轮 `plan`,输入区上方出现计划条,换轮丢掉。ACP 若声明 `terminal`,对方跑的那条命令一张卡片,可停这一条(不是对话页终端)。仍约 80ms 读快照,过程行按序号增量挂现有面板,不另开总线。 + - **过程面板**:主列一行过程摘要(正在读取 / 正在修改 / 正在执行,完成则已读取 / 已修改 / 已执行)。点开后在右侧栏看你说了什么、思考、工具行、等待允许或拒绝、本轮用量;工具名、状态和 JSON 进折叠的「细节」;命令、过程日志、退出码和状态事件仍在「运行详情」。允许 / 拒绝按钮仍在卡片上,不在过程行上造假按钮。右侧栏不随发送自动打开;点 Markdown 仍预览文件。种类约定见 [过程事件](concepts/chat-process-events.md)。斜杠目录和模型目录更新不进过程时间线。Grok / Kiro 若推了当前轮 `plan`,或 Claude 新对话用 TodoWrite / Task 工具更新了任务清单,输入区上方出现计划条,换轮丢掉。ACP 若声明 `terminal`,对方跑的那条命令一张卡片,可停这一条(不是对话页终端)。仍约 80ms 读快照,过程行按序号增量挂现有面板,不另开总线。 - **过程内用量**:新空 Codex 会话仍解析 `thread/tokenUsage/updated` 的当前轮 `last`(累计 `total` / 窗口只留在总览等用量页)。新空 Grok 会话解析 **当前轮**(`turn_completed.usage`);ACP 没有会话累计字段,不把各轮相加冒充累计。Grok / Kiro 的 `context_usage` 有数字才进用量小字(窗口用量),全 0 不画。解析路径已接;真窗 2026-09-09 见过部分轮次 **没有** `turn_completed.usage`,此时界面不画假数字。对话里只在本轮结束后用小字写输入 / 输出(有缓存才写缓存);生成中不画用量。只显示协议里的数字,不估算费用。Kiro 没有 token 累计数据源。 - **`/` 菜单**:立刻执行的动作(新建对话、复制最近回复;换模型/思考/技能要搜到才列出,避免把整份目录摊在 `/` 上)。Grok / Kiro 会话就绪且对方声明了命令时,另列对方斜杠项:选中后当作一轮正常发出(无必填参数则直接发送 `/名字`;必填参数则插入 `/名字 ` 供补全后再发)。Grok 走标准 ACP `available_commands_update`;Kiro 走 `_kiro.dev/commands/available` 的 `commands[]`(不把技能/工具目录摊进 `/`)。目录变了会重拉;未就绪或未声明则不画,不猜菜单。Kiro 就绪会话「对方命令」Linux 真窗已 PASS(修复 tip `daed5ccf`,现行 tip 仍含该修复):列出对方声明的斜杠项,裸 `/` 不摊技能目录;记录见 `/workspace/qa-issues/CHAT-SLASH-PR365-RETEST-daed5ccf.md`。本条只记这次验过的展示,不把选中发送或其它 Agent 写成已验收。有可启动的命令行时,`/` 可列出「启动命令行」(DeepSeek 为「打开网页会话」),在外部打开,不标成对话页能力。 - - **本机对接与本会话**:本机持续通道按 Agent 写死:新空 Codex 走 app-server,新空 Grok / Kiro 走 ACP,新空 Claude 走 stream-json;其余与旧会话仍走原发送方式。Cursor 默认软隐藏,不在允许/拒绝之列,也不进持续聊天白名单。**Agents 详情**写这份 Agent 的新对话怎么接(ACP / 持续对话 / 原来的发送方式),不是一份可改的「ACP 总表」。**Chat 顶栏和会话设置**写这次对话实际在走哪条;点了卡片上的一直允许之后,会话设置里可以关掉「本会话已一直允许」(不能在这里假装打开)。一次对话是否在用持续通道,看这次会话是不是上述新空路径。Kiro 旧对话没有切到 ACP 的入口。会话字段见 [会话身份](concepts/chat-session-identity.md)。 + - **本机对接与本会话**:本机持续通道按 Agent 写死:新空 Codex 走 app-server,新空 Grok / Kiro 走 ACP,新空 Claude 走 stream-json;其余与旧会话仍走原发送方式。Cursor 默认软隐藏,不在允许/拒绝之列,也不进持续聊天白名单。**Agents 详情**写这份 Agent 的新对话怎么接(ACP / 持续对话 / 原来的发送方式),不是一份可改的「ACP 总表」。**Chat 会话设置**写这次对话实际在走哪条;点了卡片上的一直允许之后,会话设置里可以关掉「本会话已一直允许」(不能在这里假装打开)。顶栏不写接法。一次对话是否在用持续通道,看这次会话是不是上述新空路径。Kiro 旧对话没有切到 ACP 的入口。会话字段见 [会话身份](concepts/chat-session-identity.md)。 - **新空 Claude 会话(B3 首片)**:走 Claude Code `-p --input-format stream-json --output-format stream-json` 持续通道(同进程多轮、本地图片 base64、模型/思考强度参数);**不支持**生成中补充;本片**不**接可点允许/拒绝(默认 `dontAsk`,危险模式 `bypassPermissions`)。有历史的旧 Claude 会话仍走 print+resume。print 路径在已经出过 assistant 正文后不再把最终 `result` 再拼进气泡(短回复不会同一句写两遍);只有没见过 assistant 文本时才用 `result` 当正文。Linux 真窗短回复已验不双写。见 [Claude B3](archive/chat-claude-b3.md)。 - **对话标题**:新建对话先用首条消息提炼的短句。一轮结束后读对方写在自己会话记录里的标题并改用它:Codex(app-server 汇总的 `sqlite/*.db` 里 `local_thread_catalog.display_title`,退回 `session_index.jsonl` 的 `thread_name`)、Grok(`summary.json` 的 `generated_title`)、Kiro(`sessions/cli/.json` 的 `title`)、DSH(会话日志的 `session/title` 行)。Claude 没有标题来源,保持首条消息推导。手动改过名字的对话不再被覆盖(不新增「谁起的名字」列,也不做迁移);连续通道取运行时线程 id,旧会话取 `native_session_id`。AgentHub 不进协议里要标题。实机核对:Grok 与 Codex 的真实会话都能取到;Codex 的 `session_index.jsonl` 只收 IDE / 桌面端自己建的会话,所以优先读 `local_thread_catalog`;Kiro 常见只有 1–2 字的占位;DSH 普通发送不带会话 id,实际触发不到;Claude 无来源。 - **停止**:点停止后按钮保持「正在停止」并禁用,直到这一轮真正结束。运行时已经是 `cancelling` 时同样显示「正在停止」。取消请求落空时恢复可点。停止横幅标题「已停止」;`error=cancelled` 不把英文 `cancelled` 写在旁边,改用「已按你的要求停止。可恢复草稿后重发。」 diff --git a/docs/concepts/chat-and-agents.md b/docs/concepts/chat-and-agents.md index 8db7da40..d982934f 100644 --- a/docs/concepts/chat-and-agents.md +++ b/docs/concepts/chat-and-agents.md @@ -5,7 +5,7 @@ status: current owner: maintainers audience: chat, adapter, and frontend contributors source-of-truth: ChatService/RunService, ChatEvent, stream parsers, Tauri Channel adapter, and chat process reducer -updated: 2026-09-16 +updated: 2026-09-20 --- # Chat 与 Agent 运行 @@ -17,7 +17,7 @@ Chat 是 AgentHub 里的运行工作台。当前一个会话对应一个 Agent - **新空 Codex 会话**:app-server 持续聊天;会话级模型/思考强度、最小操作菜单、本地图片附件、「用于本次」技能已落地(见 [B2](../archive/chat-codex-b2.md))。计划模式未做;文本问答等上游默认稳定后再跟,不打开 under-development 开关、也不造假卡片;Linux AgentHub Chat 不可用 Codex Computer Use。Claude B3 首片见下。 - **新空 Grok 会话**:持续聊天,可选模型和思考等级,支持图片与后续轮排队。不能为本轮指定「用于本次」技能(与 Codex 不同;界面也不画出可点的假按钮)。生成时不能中途补充,只能排队到下一轮。 - **新空 Kiro 会话**:`kiro-cli acp` 持续通道(允许/拒绝、停止;生成时不能中途补充,可排队到下一轮)。旧对话保留原发送方式。本机登录或 `KIRO_API_KEY` 可用时,打印路径可走 HTTP 多轮(`kiro-http:` 前缀);已有 HTTP 会话失败时直接报错并保留会话,不回退成新的命令行会话。企业 IdC / `profileArn` 仍是提案剩余边界(带上参数 ≠ 已验收)。跨页事实见 [STATUS](../STATUS.md)。 -- **新空 Claude 会话**:`claude -p --input-format stream-json` 持续通道(多轮、本地图片;不能在生成中补充;本片无允许/拒绝卡片,默认 `dontAsk` / 危险模式 `bypassPermissions`)。有历史的 Claude 会话仍走 print+resume。见 [Claude B3](../archive/chat-claude-b3.md)。 +- **新空 Claude 会话**:`claude -p --input-format stream-json` 持续通道(多轮、本地图片;不能在生成中补充;本片无允许/拒绝卡片,默认 `dontAsk` / 危险模式 `bypassPermissions`)。对方用 TodoWrite 或 Task 工具更新任务清单时,输入区上方出现与 Grok / Kiro 相同的计划条(可折叠、带进展),不进过程时间线;换轮丢掉。有历史的 Claude 会话仍走 print+resume。见 [Claude B3](../archive/chat-claude-b3.md)。 - **其余 Agent 与旧会话**:保留原有发送方式。 ## 允许 / 拒绝 / 一直允许 @@ -36,7 +36,7 @@ Kiro 会话设置里的「帮我批准 / 完全访问权限」是启动时的 `- 本机有哪些持续通道,是按 Agent 写死的名单,不是用户可改的「ACP 总表」。界面上分两层: - **Agents 详情「新对话」**:这份 Agent 开新对话时走 ACP / 持续对话 / 原来的发送方式。隐藏 Agent 后 Chat 不会用它开新对话。 -- **Chat 顶栏和会话设置「这次对话怎么接」**:这场对话实际在走哪条。旧 Kiro 等仍走原来的发送方式时,这里会和「新对话」不一致。 +- **Chat 会话设置「这次对话」**:这场对话实际在走哪条。旧 Kiro 等仍走原来的发送方式时,这里会和「新对话」不一致。顶栏不写接法。 | 本机对接 | 用在哪次会话 | | --- | --- | diff --git a/docs/concepts/chat-process-events.md b/docs/concepts/chat-process-events.md index 389cf6ec..036b7f4b 100644 --- a/docs/concepts/chat-process-events.md +++ b/docs/concepts/chat-process-events.md @@ -5,7 +5,7 @@ status: current owner: maintainers audience: chat and core contributors source-of-truth: ProcessStep, ChatEvent, RuntimeSnapshot -updated: 2026-09-17 +updated: 2026-09-20 --- # Chat 过程事件 @@ -37,7 +37,7 @@ updated: 2026-09-17 - ACP `config_option_update`(模型 / 思考目录) - 世代号 `catalogEpoch`(只用来重拉 Options) -`plan` 走当前轮计划条,换轮丢掉。宿主 `terminal/*` 走命令卡片,不是对话 TTY。 +`plan` 走当前轮计划条,换轮丢掉。Grok / Kiro 的 ACP `sessionUpdate: plan`,以及 Claude 新对话的 TodoWrite / TaskCreate / TaskUpdate / TaskList,都进这条,不进过程时间线。宿主 `terminal/*` 走命令卡片,不是对话 TTY。 ## 本波边界 diff --git a/docs/concepts/chat-session-identity.md b/docs/concepts/chat-session-identity.md index 542fde5b..efa9775e 100644 --- a/docs/concepts/chat-session-identity.md +++ b/docs/concepts/chat-session-identity.md @@ -5,7 +5,7 @@ status: current owner: maintainers audience: chat and core contributors source-of-truth: Conversation / ChatService / chat_runtime thread_id -updated: 2026-09-15 +updated: 2026-09-20 --- # Chat 会话身份 @@ -31,7 +31,7 @@ updated: 2026-09-15 | --- | --- | | 对话页「新建对话」 | 插入新行:`native_session_id` 为空,直到对方给出会话 id。 | | 点开历史列表里已有行 | 续同一 `conversations.id`。持续通道用运行时线程;旧会话用已存的 `native_session_id` 解释标题和续聊。 | -| 按对方会话 id 打开(`open_from_session`) | 先用 `native_session_id` 查找:命中则续该行(空记录可补导入历史);未命中再新建并写入该 id。 | +| 按对方会话 id 打开(`open_from_session`) | 先用 `native_session_id` 查找:命中则续该行(空记录可补导入历史);未命中再新建并写入该 id。同一 Agent、同一工作目录但对方会话不同,仍是新开一条。 | | 默认空会话 `ensure_default_conversation` | 避免初始化重复插行;显式新建仍始终插入。 | | CLI `agenthub run` | 不复用对话页会话表。 | diff --git a/docs/reference/chat-session-options.md b/docs/reference/chat-session-options.md index 5adde4fc..bffe9444 100644 --- a/docs/reference/chat-session-options.md +++ b/docs/reference/chat-session-options.md @@ -4,7 +4,7 @@ description: Runtime Options 的 seed / 探测来源与 fail-closed 边界。 type: reference status: current owner: maintainers -updated: 2026-09-17 +updated: 2026-09-20 --- # Chat 会话选项目录 @@ -29,7 +29,7 @@ Chat「模型 / 思考 / 扩展 / 斜杠原生命令」等来自会话 **Options | Claude | `claude_fallback_catalog`(seed) | 无活探测权威时仅用 seed | | Codex(及其他走 app-server 探测的路径) | `model/list`、`skills/list`、`plugin/installed` | 探测失败 → 空/缓存策略;不伪装厂商未声明的项 | -原生命令目录(斜杠)由持续通道在会话就绪后注入 `native_commands`;未就绪或目录空 → UI fail-closed(不假装有对方命令)。Grok 走标准 ACP `available_commands_update`;Kiro 在 `session/new` 之后用厂商通知 `_kiro.dev/commands/available` 的 `commands[]`(不要把 `prompts` / 技能、`tools`、`mcpServers` 摊进 `/`)。立刻执行的动作(如新建对话)与对方命令分来源,见 STATUS / Chat 概念页。`transport` 会画在 Chat 顶栏和会话设置「这次对话怎么接」,不进过程时间线。 +原生命令目录(斜杠)由持续通道在会话就绪后注入 `native_commands`;未就绪或目录空 → UI fail-closed(不假装有对方命令)。Grok 走标准 ACP `available_commands_update`;Kiro 在 `session/new` 之后用厂商通知 `_kiro.dev/commands/available` 的 `commands[]`(不要把 `prompts` / 技能、`tools`、`mcpServers` 摊进 `/`)。立刻执行的动作(如新建对话)与对方命令分来源,见 STATUS / Chat 概念页。`transport` 会画在会话设置「这次对话」,不进过程时间线。 ## 纪律 diff --git a/docs/reference/chat-support-depth.md b/docs/reference/chat-support-depth.md index 12b9755d..6d07feeb 100644 --- a/docs/reference/chat-support-depth.md +++ b/docs/reference/chat-support-depth.md @@ -4,7 +4,7 @@ description: 每家 Agent 的探测/启动与 Chat 宿主深度对照;与 Capa type: reference status: current owner: maintainers -updated: 2026-09-15 +updated: 2026-09-20 --- # Chat 支持深度矩阵 @@ -35,7 +35,7 @@ updated: 2026-09-15 | Agent | D0 | Chat 主档 | `RuntimeChannel`(新空) | D3 摘要(新空持续会话) | 备注 | |---|---|---|---|---|---| -| Claude | 有 | **D2** 新空;有历史仍 **D1** print+resume | `stream-json` | 模型/思考、图片;**无**允许/拒绝卡(`dontAsk` / 危险 `bypassPermissions`);无「用于本次」 | 见 STATUS / Claude B3 | +| Claude | 有 | **D2** 新空;有历史仍 **D1** print+resume | `stream-json` | 模型/思考、图片;TodoWrite / Task 任务清单进计划条;**无**允许/拒绝卡(`dontAsk` / 危险 `bypassPermissions`);无「用于本次」 | 见 STATUS / Claude B3 | | Codex | 有 | **D2** 新空 | `app-server` | 允许/拒绝/一直允许、模型/思考、图片、「用于本次」、斜杠动态菜单;计划模式未做;CU 无产品路径 | 最深 | | Grok | 有 | **D2** 新空 | `acp` | 允许/拒绝(对方选项)、模型/思考、图片;**无**「用于本次」;生成中不可补充(可排队) | ACP 族 | | Kiro | 有 | **D2** 新空 ACP;旧会话 **D1** | `acp` | 允许/拒绝、停止;模型/思考/权限启动后固定;生成中不可补充 | ACP 族;旧 headless 不切 ACP | diff --git a/docs/reference/terminology.md b/docs/reference/terminology.md index be7517c1..6a09931a 100644 --- a/docs/reference/terminology.md +++ b/docs/reference/terminology.md @@ -4,7 +4,7 @@ description: AgentHub 用户界面、领域模型和内部实现术语的对应 type: reference audience: all status: current -updated: 2026-09-17 +updated: 2026-09-20 --- # 术语表 @@ -47,6 +47,7 @@ updated: 2026-09-17 | 项目技能 | `<工作区>/.agents/skills` | 只作用于该项目的技能;Skills 页按项目列表里已识别的工作区选择 | | 插件 | 各家 `plugin` / `extension` 包(Claude `/plugin`、Codex `/plugins`、Grok `plugin`、Pi `pi install`) | 可安装的发行单元,常含 skills/commands/hooks,有时附带 MCP。**不是** MCP server 条目,也不是 Skills 页 | | 历史 | `ProjectHistory` / `/projects` | 各 Agent 的本机会话记录与工作区。侧栏入口和页面标题说「历史」;代码、路径和内部文档仍用 Projects / 项目 | +| 聊天大纲 | Chat outline / `ChatOutlineRail` | 对话页 transcript 左侧的提示词跳转轨道 | | MCP | MCP server 条目;`/mcp` 可盘点,部分 Agent 可写入/启用 | Agent 作为客户端连接的外部工具;Claude / Codex / Grok / Cursor / WorkBuddy 为 Partial(无 OAuth) | | mock backend | `src/dev/mocks` | 仅供 `pnpm dev:mock` 和 Vitest 使用的浏览器实现 | | Tauri adapter | `src/lib/backend/tauri` | 生产桌面 backend;唯一允许直接 `invoke` 的前端边界 | diff --git a/docs/ui/chat-experience-bar.md b/docs/ui/chat-experience-bar.md index 508e531c..594f0f21 100644 --- a/docs/ui/chat-experience-bar.md +++ b/docs/ui/chat-experience-bar.md @@ -3,7 +3,7 @@ title: Chat 体验标杆 type: ui status: current owner: maintainers -updated: 2026-09-16 +updated: 2026-09-20 --- # Chat 体验标杆 @@ -17,7 +17,7 @@ updated: 2026-09-16 | 流式 | 正文随生成出现。过程是「正在读取 / 正在修改 / 正在执行」,细节可展开。 | 新空 Codex / Grok / Kiro / Claude 持续聊天:活动会话约 80ms 读快照,正文用同一次读取的完整 currentMessage,不用字符串猜增量,也不把整段回复拆开假装逐字打出。首字前显示「正在想」,正文出现后「正在写」并带光标。主列只留一行过程摘要(正在读取 / 正在修改 / 正在执行,完成则已读取 / 已修改 / 已执行);点开后在右侧栏看你说了什么、思考、工具行、等待允许或拒绝、本轮用量和运行详情。工具名、状态和 JSON 进折叠的「细节」。思考带计时。允许 / 拒绝按钮仍在卡片上。右侧栏不随发送自动打开,点 Markdown 时仍预览文件。有历史的 Claude 与其他旧会话仍是一次性发送。 | | 用量 | 本轮结束后用小字写下输入 / 输出;生成中不画。没有数据不画假进度条。 | Codex 持续聊天仍解析 `thread/tokenUsage/updated` 的当前轮 last;Grok 解析 `turn_completed.usage`。对话里只在回复结束后用 muted 小字写本轮输入 / 输出(有缓存才写缓存)。不写累计、不对窗口做 n / 窗口。Kiro 无 token 累计数据源。没有数字就不画。 | | 输入区 Enter / 排队 / 停止 | `Enter` 发送,`Shift+Enter` 换行。生成中能补充(不打断当前动作)或排队到下一轮,两种动作分得清。停止先「正在停止」,确认后再「已停止」。中文输入法组字时 `Enter` 不发送。 | 空会话不把 Enter / 换行写在输入区下方,改到输入框和发送按钮的悬停提示。有消息后仍在输入区下写当前快捷键。`Enter` 发送,组字时不发。生成中 Codex 为补充,Grok / Kiro / Claude 为排队(Claude 无中途补充)。右下角只留一个圆形按钮:空闲是发送(无字则禁用);生成中有字且能补充/排队则仍是发送,清空后同一位置改成停止(方块图标,不并排「停止」二字)。点了停止之后(或运行时已是正在取消)保持「正在停止」并禁用,直到这一轮结束;取消没发出去则恢复可点。停止横幅标题「已停止」,不把英文 `cancelled` 写在旁边,用「已按你的要求停止。可恢复草稿后重发。」发送后焦点留在输入框。`Esc` 仍走同一条停止(对话框/菜单/预览先吃掉 Esc);快捷键一览与悬停浮层写着 Esc。 | - | 审批 | 卡片是「允许」「拒绝」;「一直允许」写清管到这场对话还是本轮。改文件能看见路径和将要改的内容。 | 卡片先写种类再写标题;允许 / 拒绝;带了该选项才出一直允许,范围写在按钮旁边:「仅当前这次对话,不保存」,并写明不是会话设置里的自动批准 / 完全访问权限。点了之后三家都在这场对话里记住后续同类确认(Codex 跨轮,含另一条工作目录外路径;Grok / Kiro 同一条 ACP 进程可跨轮),对话里出现「本会话已一直允许」,会话设置可以关掉记住、不能假装打开。没有该选项不画按钮。文件卡标题「修改文件」,等宽字显示路径;协议里已有 `diff` / `content` / 前后片段时直接展示,没有这些字段就写「暂无改动预览」,不编一段假 diff。Kiro「完全访问权限」是启动时的会话设置。顶栏和会话设置写这次对话怎么接(ACP / 持续对话 / 原来的发送方式);Agents 详情写这份 Agent 的新对话怎么接。 | + | 审批 | 卡片是「允许」「拒绝」;「一直允许」写清管到这场对话还是本轮。改文件能看见路径和将要改的内容。 | 卡片先写种类再写标题;允许 / 拒绝;带了该选项才出一直允许,范围写在按钮旁边:「仅当前这次对话,不保存」,并写明不是会话设置里的自动批准 / 完全访问权限。点了之后三家都在这场对话里记住后续同类确认(Codex 跨轮,含另一条工作目录外路径;Grok / Kiro 同一条 ACP 进程可跨轮),对话里出现「本会话已一直允许」,会话设置可以关掉记住、不能假装打开。没有该选项不画按钮。文件卡标题「修改文件」,等宽字显示路径;协议里已有 `diff` / `content` / 前后片段时直接展示,没有这些字段就写「暂无改动预览」,不编一段假 diff。Kiro「完全访问权限」是启动时的会话设置。会话设置写这次对话(ACP / 持续对话 / 原来的发送方式),顶栏不写;Agents 详情写这份 Agent 的新对话怎么接。 | | 模型与思考 | 输入区旁显示当前模型和思考强度;选项来自这份登录真实可用的列表;换模型后强度跟着变;生成中不改正在跑的一轮。 | Codex / Grok 可持续聊天里可换模型和思考强度,拒绝后保留原值。菜单用可读名(如 GPT 5.3 Codex Spark),当前项打勾。思考强度旁有短说明(可能更慢)。Kiro 开始后要换需新建对话。`Ctrl+Shift+I`(macOS 也认 `Cmd+Shift+I`)打开模型菜单。 | | 附件 | 发送前能看见已加的图片或文件,可移除。支持选图、粘贴、拖入;工作目录里的文件能点名带上。 | 新空 Codex / Grok / Kiro / Claude 持续聊天已接本地图片(选图 / 粘贴 / 拖入,最多 8 张、单张 10MB);已选图片在输入区旁显示可移除芯片。有历史的旧 Claude 仍是 print+resume,**没有**图片按钮。普通文件和 `@` 提文件尚未接入(协议未验证,不把路径拼进提示词冒充附件)。 | | 用于本次技能 | 当前 Agent 可调用的技能能选进这一条,选中后看得到。对方做不到时不画可点的假入口。 | Codex **不画**工具条技能按钮:技能由 Codex 自动选用,或输入 `/` 在动态菜单里选「用于本次」。Grok 无此能力,也不画假按钮。 | @@ -25,6 +25,7 @@ updated: 2026-09-16 | 续聊 | 点开历史立刻看到上次正文。生成中切走再回来,任务还在。关窗口再开,同一场对话能接着说。 | 切会话不取消生成。Codex 关窗续聊 Linux 真窗已验、macOS 重开有效,Windows 未宣称。Grok 旧会话可「用新方式继续」。Kiro 同一进程内续聊;进程退出后留历史并提示新建。 | | 空状态 | 空会话焦点在输入区。先写清可以打字;示例只填草稿、不代发。缺目录或登录时一条提示加恢复,不占满中间。底栏保留所需控件,但不跟输入区抢第一眼。 | 空会话焦点在输入区。中间只写「开始对话」和示例芯片,不再单独写出 Agent 名或日期。占位是「发消息…」。示例只填草稿,说明写在芯片悬停提示里。缺条件时一条提示加恢复,不占满中间。没有 Agent 时去 Agent 页。Kiro / Grok / Claude 的「不能中途补充」写在输入框和发送的悬停提示,不占常驻行。空会话底栏图片和技能收成图标(仍有无障碍名称);连接名截断,悬停看全称。选中 Agent 后不再放重复的 ⋮ 菜单。侧栏「新建对话」用主题色。 | | 键盘 | `Enter` 发送,`Shift+Enter` 换行,`Esc` 停止,`/` 搜对话内动作。能新建对话、打开模型菜单,并能看到当前快捷键。输入框内全选、复制、剪切、粘贴必须可用。 | 输入区旁是紧凑的快捷键按钮:悬停或点击都打开同一份快捷键浮层(与一览相同:新建对话、发送/换行、停止、换模型等)。发送和换行用回车键图标,不用「Enter」字样。不在输入框里时按 `?` 仍打开快捷键对话框(美式键盘上 `?` 是 Shift+/,两种按键报告都认)。桌面窗口的快捷键在捕获阶段监听,冒泡阶段到不了。`Enter` / `Shift+Enter` / 组字时不发送 / `Esc` 停止 / `/` 本地菜单已有。`/` 先列立刻执行的动作(新建对话、复制最近回复;换模型/思考/用于本次技能要搜到才列出);Grok / Kiro 会话就绪且对方声明了命令时,另列对方斜杠项,选中后当作一轮正常发出。打开历史、搜索历史、打开设置、打开 Agent、打开连接走侧栏或顶栏,不再进 `/`。选中 Agent 旁不再放重复的 ⋮。侧栏「新建对话」用主题色。删除确认(会话、备份)按 Enter 仍确认,按钮上只画回车键图标。`Ctrl+K`(macOS `Cmd+K`)聚焦历史搜索。`Ctrl+Shift+I`(macOS 也认 `Cmd+Shift+I`)打开模型菜单。`Ctrl+N`(macOS `Cmd+N`)新建对话;桌面窗口另用系统菜单加速键,避免 WebKit 把 Ctrl+N 当成新窗口。输入框内 `Ctrl+A` / `C` / `X` / `V`(macOS `Cmd`)走系统编辑菜单,页面快捷键不拦截。 | +| 长对话定位 | 长对话能从大纲跳到自己发过的提示。 | 设置默认打开。用户消息不少于 2 条、对话面板够宽时,transcript 左侧出现聊天大纲;悬停看预览,点击跳到那条消息,跳转后不跟着新回复滚回底部。 | 前沿对照日期 2026-09-09,依据各家公开文档: [Claude Code 桌面](https://code.claude.com/docs/en/desktop)、[Cursor Agent](https://cursor.com/docs/agent/overview)、[Codex app](https://developers.openai.com/codex/app)。各家能力不同;本页不承诺每家生成相同答案,也不把 Claude 批准卡片或 SDK 宿主写成已完成(B3 首片仅 stream-json 多轮+图片)。 diff --git a/docs/ui/page-patterns.md b/docs/ui/page-patterns.md index c4c5b7a1..62c83607 100644 --- a/docs/ui/page-patterns.md +++ b/docs/ui/page-patterns.md @@ -3,7 +3,7 @@ title: UI 页面模式 type: reference status: current owner: maintainers -updated: 2026-09-17 +updated: 2026-09-20 --- # UI Page Patterns @@ -340,7 +340,8 @@ Chat is a one-conversation, one-Agent workbench with a session rail, transcript, - An empty transcript invites typing first (`开始对话` only). Example chips stay above the composer and only fill the draft; the draft-only note is on the chip hover, not a permanent line. The composer placeholder is generic (`发消息…`). Grok / Kiro / Claude queue-only limits and Enter / Shift+Enter sit on hover titles near the composer or send, not as permanent secondary lines on an empty session. On that first-use empty transcript the toolbar keeps Agent, connection, model, thinking, images, and skills, but secondary image/skill labels collapse to icon-only (accessible names stay); the connection label truncates and shows the full name on hover. Cursor is not on this surface. - The send button is the composer's accent action. The session-rail **新建对话** button uses the same theme fill (not gray). Enter sends; Shift+Enter inserts a new line; the footer names the current shortcut on a non-empty transcript, and on an empty session via hover. The bottom-right slot is a single circular control: never Stop and Send side by side. Idle shows Send (disabled when empty). While generating, Send stays when there is draft text and a real action (mid-turn inject, otherwise queue); an empty or blocked generating composer shows Stop in that same slot (square icon, danger styling, same footprint). After click or when the runtime is already cancelling, Stop stays 正在停止 and disabled until the turn ends. A missed cancel request re-enables it. Esc uses the same cancel path unless a dialog, menu, or preview already owns Escape; the shortcut overview and hover layer list Esc. Queued follow-ups show a count and preview with a clear action. The 已停止 banner does not show a raw `cancelled` status word; it uses 已按你的要求停止。可恢复草稿后重发。 After send, focus stays in the composer. Retry creates a new turn using the same validation path. Cursor is not on this surface. - Approval cards offer Allow / Deny; Always allow when this request includes that option. After Always allow, Codex / Grok / Kiro auto-accept later command/file prompts in the current process (Codex typically this turn; Grok / Kiro the live ACP process). Not saved. Chat then shows **本会话已一直允许**, distinct from session-settings auto-approve / Kiro full access; session settings can turn that remember off, and will not fake turning it on. File-change cards title **修改文件** and show the path. -- Streaming process details use a compact one-line summary of 正在读取 / 正在修改 / 正在执行 on the reply. Clicking it opens the right-hand pane with 你说了、thinking, tools, waiting allow/deny, turn usage, and run details (the same pane as Markdown preview; opening a file replaces process). Allow / Deny stay on the card — the pane does not invent buttons. Tool names, statuses, and JSON payloads stay in a per-step **细节** disclosure. Commands, stderr, status events, and exit codes stay in **运行详情**. After the turn ends, Codex / Grok may show a muted turn-only 输入 / 输出 footnote under the reply; nothing while generating, and no session total or window ratio. No fake usage bar. Continuous Codex / Grok / Kiro turns poll the focused snapshot about every 80ms and show **正在想** before the first character, then **正在写** with a caret. The body is the durable `currentMessage` from that read, not a client-side drip of a buffered reply. +- A current-turn plan list sits above the composer when the Agent published one. The header shows completed/total counts and status labels (待做 / 进行中 / 已完成 / 失败); the list collapses. Collapsed view keeps the counts and the in-progress row. Missing status counts as 待做. The bar is not a process timeline. +- Streaming process details use a compact one-line summary of 正在读取 / 正在修改 / 正在执行 on the reply. Clicking it opens the right-hand pane with 你说了、thinking, tools, waiting allow/deny, turn usage, and run details (the same pane as Markdown preview; opening a file replaces process). Allow / Deny stay on the card — the pane does not invent buttons. Tool names, statuses, and JSON payloads stay in a per-step **细节** disclosure. Commands, stderr, status events, and exit codes stay in **运行详情**. After the turn ends, Codex / Grok may show a muted turn-only 输入 / 输出 footnote under the reply; nothing while generating, and no session total or window ratio. No fake usage bar. Continuous Codex / Grok / Kiro turns poll the focused snapshot about every 80ms and show **正在想** before the first character, then **正在写** with a caret. The body is the durable `currentMessage` from that read, not a client-side drip of a buffered reply. A failed poll keeps the last view and shows **没法更新这场对话** with Retry after one second; it does not toast every poll, and it does not fall back to the one-shot send path. - Switching conversations does not cancel the active operation. Codex runtime keeps per-conversation process state and a replay cursor; its snapshot supplies the authoritative current reply. Legacy sends retain their existing in-memory process behavior. - Copy is available for completed user/Agent messages. Running messages do not show copy or retry. @@ -350,8 +351,10 @@ Chat is a one-conversation, one-Agent workbench with a session rail, transcript, - Header: Agent identity, working directory, how this chat connects, automatic-approval state, connection context. - Empty transcript: invite headline only; example chips fill the draft only (draft-only note on chip hover). Composer placeholder is generic (`发消息…`); Grok / Kiro / Claude queue-only limits and Enter / Shift+Enter use hover titles on an empty session. First-use toolbar keeps needed controls and quiets image/skill labels to icons; connection label truncates with a full-name hover. Composer blocker order: hidden Agent → environment not ready → missing authorization → unknown status → missing working directory; send is the composer accent action; rail **新建对话** uses the same theme fill. Enter sends, Shift+Enter makes a new line. While generating, one bottom-right control: Send injects or queues when the draft has text and that channel exists; empty draft shows icon Stop in the same slot; Esc still stops (dialogs/menus first). Queued lines show a count; Stop stays 正在停止 and disabled until the turn ends (re-enables if the cancel request misses); the 已停止 banner hides a raw `cancelled` status word; focus stays in the composer after send. Retry creates a new turn. Several conversations may generate at once. A compact shortcuts control opens the same overview on hover or click; `?` still opens the shortcuts dialog. Delete-confirm dialogs (session rail, backups) keep Enter-to-confirm and show a return-key icon, not the word Enter. - Approval cards: Allow / Deny; Always allow when the request includes that option. Codex / Grok / Kiro in-process remember (not saved). File-change cards show the path. +- Plan bar above the composer: counts and 待做 / 进行中 / 已完成 / 失败; collapsible. Snapshot poll failure: **没法更新这场对话** after one second, Retry, no toast spam. - Streaming process: one-line human 正在读取 / 正在修改 / 正在执行 on the reply; click to open thinking/tools in the right-hand pane; protocol details folded. After the turn ends, muted turn-only 输入 / 输出 under the reply when the protocol sent counts. Copy for completed messages only. -- New Codex conversations use durable app-server snapshots and show actual approval/question requests as controls. Replies and stop target the exact run; snapshot failure does not fall back to legacy send. Codex B2 is in: session model/effort, `/` command search, local image attachments, and skills/plugins discovery for this turn (no plan mode; no toolbar skill button — use `/` or Codex auto-use). The composer does not show a duplicate Agent overflow (⋮) next to the selected Agent. `/` lists run-now actions (new chat, copy latest reply). Model/effort/skill appear when the query matches, not as a full dump on a bare `/`. When a Grok/Kiro session is ready and the peer declared commands, `/` also lists those names: choosing one sends `/name` as a normal turn, or inserts `/name ` when the declaration requires args. History, search, settings, Agents, and Connections stay on the rail or header. New Grok conversations are continuous (model/thinking, images, queued follow-ups); **Unsupported**: choosing a skill “for this turn” (no clickable fake control). New Kiro conversations use the ACP continuous channel; old Kiro chats keep the original send path. New Claude conversations use stream-json continuous chat (images; no mid-turn steer; no approval cards in this slice); old Claude chats keep print+resume. See [B2](../archive/chat-codex-b2.md) and [STATUS](../STATUS.md). +- Chat outline (聊天大纲): with the setting on (default), two or more user messages, and a transcript panel at least 720px wide, a tick rail on the left of the transcript jumps between prompts. Hover magnifies nearby ticks and shows a preview; the current reading turn is highlighted. A jump turns off stick-to-bottom so streaming does not pull the view back. Toggle is Settings → 语言与外观 (`agenthub:chat-outline-enabled`). +- New Codex conversations use durable app-server snapshots and show actual approval/question requests as controls. Replies and stop target the exact run; snapshot failure does not fall back to legacy send. Codex B2 is in: session model/effort, `/` command search, local image attachments, and skills/plugins discovery for this turn (no plan mode; no toolbar skill button — use `/` or Codex auto-use). The composer does not show a duplicate Agent overflow (⋮) next to the selected Agent. `/` lists run-now actions (new chat, copy latest reply). Model/effort/skill appear when the query matches, not as a full dump on a bare `/`. When a Grok/Kiro session is ready and the peer declared commands, `/` also lists those names: choosing one sends `/name` as a normal turn, or inserts `/name ` when the declaration requires args. History, search, settings, Agents, and Connections stay on the rail or header. New Grok conversations are continuous (model/thinking, images, queued follow-ups); **Unsupported**: choosing a skill “for this turn” (no clickable fake control). New Kiro conversations use the ACP continuous channel; old Kiro chats keep the original send path. New Claude conversations use stream-json continuous chat (images; no mid-turn steer; no approval cards in this slice); TodoWrite / Task tool lists fill the same plan bar as Grok / Kiro. Old Claude chats keep print+resume. See [B2](../archive/chat-codex-b2.md) and [STATUS](../STATUS.md). - Runtime confirmation cards use **允许** / **拒绝**. **一直允许** appears only when this request includes that option (Codex command/file prompts always include it; Grok / Kiro only if the ACP request does, including Kiro `allow_always_tool`). Pending option lists are stored with the request so a snapshot or restart can still show the same buttons. After **一直允许**, Codex / Grok / Kiro auto-accept later command/file prompts in the current process (Codex typically this turn; Grok / Kiro the live ACP process; not saved). Grok / Kiro also forward the server option. Kiro session **完全访问权限** is a separate start-time setting, not this card. Cursor is out of this surface. See [Chat 与 Agent](../concepts/chat-and-agents.md#允许-拒绝-一直允许). ### Agent touchpoints (Chat) diff --git a/e2e/browser/boot-and-navigation.spec.ts b/e2e/browser/boot-and-navigation.spec.ts index f0dcc3ef..9e594be4 100644 --- a/e2e/browser/boot-and-navigation.spec.ts +++ b/e2e/browser/boot-and-navigation.spec.ts @@ -20,7 +20,7 @@ test('app boots on mock and primary navigation works', async ({ page }) => { await expect(page).toHaveURL(/#\/projects/); await expect(page.getByRole('heading', { name: '历史' })).toBeVisible(); - await goNav(page, '对话'); + await goNav(page, '工作区'); await expect(page).toHaveURL(/#\/chat/); await expect( page.getByRole('textbox', { name: '消息输入' }).or(page.getByText('还没有可对话的 Agent')), @@ -172,7 +172,7 @@ test('page title sits in the top bar; Chat has neither title nor in-app notifica expect(Math.abs(createBox!.y + createBox!.height / 2 - (leadBox!.y + leadBox!.height / 2))).toBeLessThanOrEqual(8); expect(Math.abs(leadBox!.y - connectionsTop)).toBeLessThanOrEqual(12); - await goNav(page, '对话'); + await goNav(page, '工作区'); await expect(page).toHaveURL(/#\/chat/); await expect(page.getByRole('button', { name: '通知' })).toHaveCount(0); await expect(page.getByRole('heading', { level: 1 })).toHaveCount(0); diff --git a/e2e/browser/chat-file-approval.spec.ts b/e2e/browser/chat-file-approval.spec.ts index 15e58173..cb32f351 100644 --- a/e2e/browser/chat-file-approval.spec.ts +++ b/e2e/browser/chat-file-approval.spec.ts @@ -20,7 +20,7 @@ test('path-only file approval stays readable without inventing a diff', async ({ const card = page.locator('[data-help="chat-file-change-preview-path-only"]'); await expect(card).toBeVisible({ timeout: 20_000 }); await expect(page.getByText('修改文件', { exact: true }).first()).toBeVisible(); - await expect(page.getByText('/workspace/notes.md', { exact: true })).toBeVisible(); + await expect(card.getByText('/workspace/notes.md', { exact: true })).toBeVisible(); await expect(page.getByText('仅有路径,无内容预览')).toBeVisible(); await expect(page.getByRole('button', { name: '允许', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: '一直允许' })).toBeVisible(); diff --git a/e2e/browser/chat-overflow.spec.ts b/e2e/browser/chat-overflow.spec.ts index f0b12a2d..209f80b9 100644 --- a/e2e/browser/chat-overflow.spec.ts +++ b/e2e/browser/chat-overflow.spec.ts @@ -10,7 +10,8 @@ test('selected Agent has no duplicate overflow menu; other entries stay', async await expect(composer.getByRole('button', { name: '更多操作' })).toHaveCount(0); await expect(page.getByRole('button', { name: '更多操作' })).toHaveCount(0); - await expect(page.getByRole('button', { name: '新建对话' })).toBeVisible(); + await expect(page.getByRole('button', { name: '新建对话', exact: true })).toBeVisible(); + await expect(page.locator('[data-help="chat-new"]')).toHaveCount(1); await expect(page.getByLabel('搜索标题或工作目录')).toBeVisible(); await expect(page.getByRole('button', { name: '会话设置' })).toBeVisible(); diff --git a/e2e/browser/helpers.ts b/e2e/browser/helpers.ts index b320a648..3e842485 100644 --- a/e2e/browser/helpers.ts +++ b/e2e/browser/helpers.ts @@ -82,7 +82,7 @@ export async function addClaudeApiKeyAndSwitch(page: Page): Promise { } export async function openChatComposer(page: Page): Promise { - await goNav(page, '对话'); + await goNav(page, '工作区'); await expect(page.getByRole('textbox', { name: '消息输入' })).toBeVisible({ timeout: 20_000, }); diff --git a/package.json b/package.json index 7fb592d3..e44b6946 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "agenthub", "private": true, - "version": "0.4.19", + "version": "0.4.20", "description": "Multi-agent desktop hub: install runtimes, manage providers/accounts, skills, usage, and chat", "license": "MIT", "repository": { diff --git a/src/lib/api/chat.test.ts b/src/lib/api/chat.test.ts index 3aacc2e4..9af8e890 100644 --- a/src/lib/api/chat.test.ts +++ b/src/lib/api/chat.test.ts @@ -63,6 +63,17 @@ describe('chat API (browser mock)', () => { await rejected; }); + it('createConversation drops a cyclic click-event cwd instead of throwing', async () => { + const cyclic: { target?: unknown } = {}; + cyclic.target = cyclic; + expect(() => JSON.stringify(cyclic)).toThrow(/circular|cyclic/i); + const createP = createConversation(['claude'], cyclic as unknown as string); + await vi.runAllTimersAsync(); + const created = await createP; + expect(created.agentIds).toEqual(['claude']); + expect(created.cwd).toBeNull(); + }); + it('ensureDefaultConversation reuses the initial blank conversation', async () => { const firstP = ensureDefaultConversation(['claude']); await vi.runAllTimersAsync(); @@ -156,6 +167,38 @@ describe('chat API (browser mock)', () => { expect(again.cwd).toBe('C:\\Users\\demo\\app'); }); + it('opens a new conversation when the official session is not already in Chat', async () => { + const createdP = createConversation(['claude'], 'D:\\demo\\chen\\2026\\AgentHub'); + await vi.runAllTimersAsync(); + const created = await createdP; + + const openP = openConversationFromSession({ + agentId: 'claude', + sessionId: 'sess-history', + cwd: 'D:\\demo\\chen\\2026\\AgentHub', + title: '历史里的那场', + history: [{ role: 'user', content: '接着改登录页' }], + }); + await vi.runAllTimersAsync(); + const opened = await openP; + expect(opened.id).not.toBe(created.id); + expect(opened.nativeSessionId).toBe('sess-history'); + + const againP = openConversationFromSession({ + agentId: 'claude', + sessionId: 'sess-history', + cwd: 'D:\\demo\\chen\\2026\\AgentHub', + title: '忽略', + history: [], + }); + await vi.runAllTimersAsync(); + expect((await againP).id).toBe(opened.id); + + const listP = listConversations(); + await vi.runAllTimersAsync(); + expect((await listP).map((row) => row.id).sort()).toEqual([created.id, opened.id].sort()); + }); + it('create / list / update / delete conversation', async () => { const createP = createConversation(['claude'], 'D:\\demo'); await vi.runAllTimersAsync(); diff --git a/src/lib/api/chat.ts b/src/lib/api/chat.ts index 79aa09a9..42ce49f6 100644 --- a/src/lib/api/chat.ts +++ b/src/lib/api/chat.ts @@ -2,6 +2,7 @@ * Chat API façade — delegates to app runtime backend. */ import { getBackend } from '@/app/runtime'; +import { createConversationCwd } from '@/lib/open-chat-cwd'; import type { AgentKey, ChatEvent, ChatHistoryTurn, ChatMessage, Conversation } from '@/lib/types'; import type { MarkdownFilePreviewDto } from '@/lib/backend/contracts/chat-port'; import type { RuntimeOptions, RuntimeReply, RuntimeSnapshot, RuntimeStartExtras, RuntimeTurnSettings } from '@/lib/backend/contracts/chat-runtime'; @@ -23,7 +24,7 @@ export async function createConversation( agentIds: AgentKey[], cwd?: string | null, ): Promise { - return getBackend().chat.createConversation(agentIds, cwd); + return getBackend().chat.createConversation(agentIds, createConversationCwd(cwd)); } export async function ensureDefaultConversation( diff --git a/src/lib/backend/contracts/chat-runtime.ts b/src/lib/backend/contracts/chat-runtime.ts index bbe34a6a..ef437a4b 100644 --- a/src/lib/backend/contracts/chat-runtime.ts +++ b/src/lib/backend/contracts/chat-runtime.ts @@ -53,7 +53,7 @@ export interface RuntimeSnapshot { currentMessage?: ChatMessage | null; /** Bumps when the Options catalog changes. Not the command list. */ catalogEpoch?: number; - /** Current-turn ACP plan. Live chrome only — dropped on the next turn. */ + /** Current-turn plan. Chrome only — dropped on the next turn. */ plan?: RuntimePlanEntry[]; /** Live ACP host commands. One card per id; not a conversation TTY. */ hostTerminals?: RuntimeHostTerminal[]; @@ -77,6 +77,8 @@ export interface RuntimePlanEntry { content: string; status?: string | null; priority?: string | null; + /** Vendor task id when the Agent sent one. Matching only; not shown. */ + id?: string | null; } export interface RuntimeReply { diff --git a/src/lib/backend/tauri/chat.ts b/src/lib/backend/tauri/chat.ts index 6fcf0926..5ce86adb 100644 --- a/src/lib/backend/tauri/chat.ts +++ b/src/lib/backend/tauri/chat.ts @@ -1,4 +1,5 @@ import type { ChatPort, MarkdownFilePreviewDto } from '@/lib/backend/contracts'; +import { createConversationCwd } from '@/lib/open-chat-cwd'; import { mapChatMessage, mapConversation, @@ -20,7 +21,7 @@ export function createTauriChatPort(): ChatPort { async createConversation(agentIds, cwd) { const row = await invoke('create_conversation', { agentIds, - cwd: cwd ?? null, + cwd: createConversationCwd(cwd), }); return mapConversation(row); }, diff --git a/src/lib/chat-process.test.ts b/src/lib/chat-process.test.ts index 65dea0c6..ff78e087 100644 --- a/src/lib/chat-process.test.ts +++ b/src/lib/chat-process.test.ts @@ -10,13 +10,17 @@ import { hasInspectableProcess, hasProcessDetails, isProtocolProcessStep, + latestThinkingStep, mergeThinkingText, mergeToolResult, phaseFromMessageStatus, processKey, processPhaseLabel, reduceProcessEvent, + showBubbleThinkingBar, stepSummary, + thinkingElapsedMs, + timelineHasToolRow, timelineProcessSteps, toolActionTarget, toolActionTone, @@ -622,6 +626,97 @@ describe('chat-process reduceProcessEvent', () => { expect(map['1:grok']?.steps[1]).toMatchObject({ type: 'tool', status: 'end', result: 'ok' }); }); + it('stamps thinking start and freezes duration when the episode ends', () => { + let map: ProcessMap = reduceProcessEvent( + {}, + { type: 'agentStarted', turn: 1, agent: 'grok', command: 'x' }, + 1000, + ); + map = reduceProcessEvent( + map, + { + type: 'agentProcess', + turn: 1, + agent: 'grok', + step: { type: 'thinking', text: 'Hel', done: false }, + }, + 2000, + ); + expect(map['1:grok']?.thinkingStartedAt).toBe(2000); + expect(map['1:grok']?.thinkingDurationMs).toBeUndefined(); + expect(thinkingElapsedMs(map['1:grok'], 3500)).toBe(1500); + expect(latestThinkingStep(map['1:grok']?.steps)).toMatchObject({ done: false }); + expect(showBubbleThinkingBar(map['1:grok']?.steps, false)).toBe(true); + expect(showBubbleThinkingBar(map['1:grok']?.steps, true)).toBe(false); + + map = reduceProcessEvent( + map, + { + type: 'agentProcess', + turn: 1, + agent: 'grok', + step: { type: 'thinking', text: 'lo', done: false }, + }, + 2800, + ); + expect(map['1:grok']?.thinkingStartedAt).toBe(2000); + + map = reduceProcessEvent( + map, + { + type: 'agentProcess', + turn: 1, + agent: 'grok', + step: { type: 'tool', id: 't1', name: 'Read', status: 'start' }, + }, + 5200, + ); + expect(map['1:grok']?.thinkingDurationMs).toBe(3200); + expect(thinkingElapsedMs(map['1:grok'], 9000)).toBe(3200); + expect(timelineHasToolRow(map['1:grok']?.steps)).toBe(true); + }); + + it('starts a new thinking timer after a tool', () => { + let map: ProcessMap = reduceProcessEvent( + {}, + { type: 'agentStarted', turn: 1, agent: 'grok', command: 'x' }, + 1, + ); + map = reduceProcessEvent( + map, + { + type: 'agentProcess', + turn: 1, + agent: 'grok', + step: { type: 'thinking', text: 'first', done: false }, + }, + 100, + ); + map = reduceProcessEvent( + map, + { + type: 'agentProcess', + turn: 1, + agent: 'grok', + step: { type: 'tool', id: 't1', name: 'Read', status: 'start' }, + }, + 400, + ); + map = reduceProcessEvent( + map, + { + type: 'agentProcess', + turn: 1, + agent: 'grok', + step: { type: 'thinking', text: 'second', done: false }, + }, + 900, + ); + expect(map['1:grok']?.thinkingStartedAt).toBe(900); + expect(map['1:grok']?.thinkingDurationMs).toBeUndefined(); + expect(thinkingElapsedMs(map['1:grok'], 1400)).toBe(500); + }); + it('agentFinished marks leftover thinking done', () => { let map: ProcessMap = reduceProcessEvent( {}, @@ -646,9 +741,11 @@ describe('chat-process reduceProcessEvent', () => { agent: 'grok', message: finishedMsg({ status: 'ok', content: 'done', agentId: 'grok' }), }, - 3, + 5002, ); expect(map['1:grok']?.steps[0]).toMatchObject({ type: 'thinking', done: true }); + expect(map['1:grok']?.thinkingStartedAt).toBe(2); + expect(map['1:grok']?.thinkingDurationMs).toBe(5000); }); it('finished finalizes still-active process views for the turn', () => { @@ -986,3 +1083,64 @@ describe('chat-process human tool labels', () => { expect(isProtocolProcessStep({ type: 'tool', name: 'Read', status: 'start' })).toBe(false); }); }); + +describe('thinking / tools pane helpers', () => { + it('keeps thinking-only timelines off the tool chip', () => { + const thinking = [{ type: 'thinking' as const, text: 'plan', done: false }]; + expect(latestThinkingStep(thinking)?.text).toBe('plan'); + expect(latestThinkingStep([])).toBeUndefined(); + expect(latestThinkingStep(undefined)).toBeUndefined(); + expect(timelineHasToolRow(thinking)).toBe(false); + expect(showBubbleThinkingBar(thinking, false)).toBe(true); + expect(formatProcessHeadline(thinking, 'running', t)).toBe('思考中'); + expect(thinkingElapsedMs(undefined, 1000)).toBe(0); + expect(thinkingElapsedMs({ + turn: 1, + agent: 'codex', + phase: 'running', + stdout: '', + stderr: '', + steps: thinking, + updatedAt: 1, + }, 1000)).toBe(0); + expect(thinkingElapsedMs({ + turn: 1, + agent: 'codex', + phase: 'ok', + stdout: '', + stderr: '', + steps: thinking, + updatedAt: 1, + thinkingStartedAt: 500, + thinkingDurationMs: -12, + }, 900)).toBe(0); + }); + + it('folds same-id execute updates and skips blank command output', () => { + expect(timelineProcessSteps([ + { type: 'raw', text: ' ', note: 'command output' }, + { type: 'tool', id: 'run-1', name: 'Bash', status: 'start', input: { command: 'ls' } }, + { type: 'tool', id: 'run-1', name: 'Bash', status: 'end', result: 'docs' }, + { type: 'status', phase: 'starting', detail: 'thread.started' }, + { type: 'usage', scope: 'turn', input: 1, output: 1 }, + ])).toEqual([ + { + type: 'tool', + id: 'run-1', + name: 'Bash', + status: 'end', + input: { command: 'ls' }, + result: 'docs', + }, + ]); + expect(timelineHasToolRow([ + { type: 'tool', name: 'Bash', status: 'end' }, + { type: 'error', message: 'boom' }, + ])).toBe(true); + expect(formatProcessHeadline( + [{ type: 'tool', name: 'Bash', status: 'error', input: { command: 'ls' } }], + 'failed', + t, + )).toBe('没法执行 ls'); + }); +}); diff --git a/src/lib/chat-process.ts b/src/lib/chat-process.ts index 41af2d83..cea37e2a 100644 --- a/src/lib/chat-process.ts +++ b/src/lib/chat-process.ts @@ -16,6 +16,8 @@ export type ProcessPhase = | 'cancelled' | 'timeout'; +export type ThinkingStep = Extract; + export type AgentProcessView = { turn: number; agent: AgentKey; @@ -26,6 +28,10 @@ export type AgentProcessView = { /** Structured steps (tool / thinking / status / raw / usage). Cap in reducer. */ steps: ProcessStep[]; updatedAt: number; + /** Wall-clock when the current/last thinking episode started. */ + thinkingStartedAt?: number; + /** Frozen duration for the last thinking episode once it finishes. */ + thinkingDurationMs?: number; }; export type ProcessMap = Record; @@ -344,6 +350,39 @@ export function timelineProcessSteps(steps: ProcessStep[]): ProcessStep[] { return out; } +export function isThinkingStep(step: ProcessStep): step is ThinkingStep { + return step.type === 'thinking'; +} + +export function latestThinkingStep(steps: ProcessStep[] | undefined): ThinkingStep | undefined { + const found = lastMatching(steps ?? [], isThinkingStep); + return found && isThinkingStep(found) ? found : undefined; +} + +/** Live timer, or the frozen duration after thinking ends. */ +export function thinkingElapsedMs(view: AgentProcessView | undefined, now: number): number { + if (!view?.thinkingStartedAt) return 0; + if (view.thinkingDurationMs != null) return Math.max(0, view.thinkingDurationMs); + return Math.max(0, now - view.thinkingStartedAt); +} + +/** Main-column chrome: thinking exists and the assistant body has not arrived. */ +export function showBubbleThinkingBar( + steps: ProcessStep[] | undefined, + hasContent: boolean, +): boolean { + if (hasContent) return false; + return latestThinkingStep(steps) != null; +} + +export function timelineHasToolRow(steps: ProcessStep[] | undefined): boolean { + return timelineProcessSteps(steps ?? []).some( + (step) => step.type === 'tool' || step.type === 'error' || step.type === 'raw', + ); +} + +function lastMatching(items: T[], pred: (item: T) => item is S): S | undefined; +function lastMatching(items: T[], pred: (item: T) => boolean): T | undefined; function lastMatching(items: T[], pred: (item: T) => boolean): T | undefined { for (let i = items.length - 1; i >= 0; i -= 1) { if (pred(items[i])) return items[i]; @@ -549,6 +588,45 @@ function markLastThinkingDone(steps: ProcessStep[]): ProcessStep[] { return steps; } +function freezeThinkingDuration( + view: Pick, + now: number, +): Pick { + if (view.thinkingStartedAt == null || view.thinkingDurationMs != null) { + return { + thinkingStartedAt: view.thinkingStartedAt, + thinkingDurationMs: view.thinkingDurationMs, + }; + } + return { + thinkingStartedAt: view.thinkingStartedAt, + thinkingDurationMs: Math.max(0, now - view.thinkingStartedAt), + }; +} + +function stampThinkingTiming( + prev: AgentProcessView, + step: ProcessStep, + now: number, +): Pick { + if (step.type === 'thinking') { + const last = prev.steps[prev.steps.length - 1]; + const mergeIntoOpen = last?.type === 'thinking' && !last.done; + const startedAt = mergeIntoOpen ? (prev.thinkingStartedAt ?? now) : now; + if (step.done) { + return { thinkingStartedAt: startedAt, thinkingDurationMs: Math.max(0, now - startedAt) }; + } + return { thinkingStartedAt: startedAt, thinkingDurationMs: undefined }; + } + if (prev.thinkingStartedAt != null && prev.thinkingDurationMs == null) { + return freezeThinkingDuration(prev, now); + } + return { + thinkingStartedAt: prev.thinkingStartedAt, + thinkingDurationMs: prev.thinkingDurationMs, + }; +} + /** * Codex `item.updated` reasoning is a full snapshot; Grok/Pi/Claude thinking * chunks are deltas. If the new text already contains the previous text as a @@ -686,6 +764,8 @@ export function reduceProcessEvent(map: ProcessMap, ev: ChatEvent, now = Date.no stdout: prev?.stdout ?? '', stderr: prev?.stderr ?? '', steps: prev?.steps ?? [], + thinkingStartedAt: prev?.thinkingStartedAt, + thinkingDurationMs: prev?.thinkingDurationMs, updatedAt: now, }, }; @@ -728,6 +808,7 @@ export function reduceProcessEvent(map: ProcessMap, ev: ChatEvent, now = Date.no ...prev, phase: prev.phase === 'queued' || prev.phase === 'starting' ? 'running' : prev.phase, steps: pushStep(prev.steps, ev.step), + ...stampThinkingTiming(prev, ev.step, now), updatedAt: now, }, }; @@ -744,6 +825,7 @@ export function reduceProcessEvent(map: ProcessMap, ev: ChatEvent, now = Date.no phase: phaseFromMessageStatus(ev.message.status), stdout: content || prev.stdout, steps: markLastThinkingDone(prev.steps), + ...freezeThinkingDuration(prev, now), updatedAt: now, }, }; @@ -765,6 +847,7 @@ export function reduceProcessEvent(map: ProcessMap, ev: ChatEvent, now = Date.no // 生产取消时 ok=true;缺省 cancelled 当 false,兼容旧事件 phase: ev.cancelled ? 'cancelled' : ev.ok ? 'ok' : 'failed', steps: markLastThinkingDone(view.steps), + ...freezeThinkingDuration(view, now), updatedAt: now, }; changed = true; diff --git a/src/lib/i18n/index.test.ts b/src/lib/i18n/index.test.ts index d1e668a9..4584ed1b 100644 --- a/src/lib/i18n/index.test.ts +++ b/src/lib/i18n/index.test.ts @@ -66,6 +66,7 @@ describe('translate / interpolate', () => { expect(translate('en', 'common.save')).toBe('Save'); expect(translate('zh', 'common.save')).toBe('保存'); const t = createTranslator('en'); + expect(t('nav.chat')).toBe('Workspace'); expect(t('nav.routes')).toBe('Routes'); expect(t('routes.page.title')).toBe('Routes'); expect(t('nav.dashboard')).toBe('Dashboard'); @@ -80,7 +81,7 @@ describe('translate / interpolate', () => { expect(zhNav('nav.routes')).toBe('路由'); expect(zhNav('routes.page.title')).toBe('路由'); expect(zhNav('nav.settings')).toBe('设置'); - expect(zhNav('nav.chat')).toBe('对话'); + expect(zhNav('nav.chat')).toBe('工作区'); expect(zhNav('nav.agents')).toBe('Agent'); expect(zhNav('chrome.onboarding.enterDashboard')).toBe('进入总览'); expect(zhNav('chrome.onboarding.skipGuide')).toBe('跳过引导'); diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 6f612558..6d331116 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -40,7 +40,7 @@ export const en = { nav: { workspace: 'Workspace', manage: 'Manage', - chat: 'Chat', + chat: 'Workspace', agents: 'Agents', skills: 'Skills', mcp: 'MCP', @@ -111,6 +111,8 @@ export const en = { canvasMint: 'Mint', canvasSand: 'Sand', canvasLilac: 'Lilac', + chatOutlineLabel: 'Chat outline', + chatOutlineDescription: 'Show an outline for jumping between prompts', autoStartLabel: 'Launch at login', autoStartDescription: 'Start after you sign in', autoStartTip: @@ -2738,6 +2740,7 @@ export const en = { expandHistory: "Expand history", resize: "Resize history", newChat: "New chat", + newChatInWorkspace: "New chat in this folder", newChatDisabled: "Install or unhide an agent first", searchPlaceholder: "Search title or folder", searchAria: "Search title or folder", @@ -2753,6 +2756,7 @@ export const en = { header: { conversation: "Chat", titleAria: "Conversation title", + switchSession: "Switch session", pickCwd: "Click to choose a working directory", cwdUnset: "Working directory not set", cwdMissing: "Folder is gone", @@ -2764,6 +2768,10 @@ export const en = { resumeCommandCopiedHint: "Paste in the terminal to continue this chat", noResumeCommand: "No session to copy", }, + outline: { + aria: "Chat outline", + tick: "{n} of {total}: {preview}", + }, preview: { titleFallback: "Preview", back: "Back", @@ -2776,6 +2784,7 @@ export const en = { failed: "Couldn't open", emptyBody: "Nothing to show", truncatedSuffix: " · Truncated", + viewEdit: "View edits", }, composer: { placeholder: "Message an agent…", @@ -2823,6 +2832,8 @@ export const en = { stop: "Stop", actions: "More actions", history: "Search history", + prevSession: "Previous session", + nextSession: "Next session", model: "Change model", newChat: "New chat", overview: "Shortcut overview", @@ -2851,9 +2862,9 @@ export const en = { sessionRemembered: 'Always allow is on for this chat', sessionRememberedHint: 'A new chat will ask again. This is not auto-approve in session settings.', sessionRememberedHintKiro: 'A new chat will ask again. This is not full access in session settings.', - sessionRememberedOff: 'Off. Turn it on from a card — this switch will not fake an allow.', - sessionRememberedOnHint: 'Turn off to ask again in this chat. This is not auto-approve in session settings.', - sessionRememberedOnHintKiro: 'Turn off to ask again in this chat. This is not full access in session settings.', + sessionRememberedOff: 'Turn on from a card', + sessionRememberedOnHint: 'Turn off to ask again', + sessionRememberedOnHintKiro: 'Turn off to ask again', sessionRememberedClear: 'Stop remembering', sessionRememberedClearFailed: "Couldn't turn off always allow for this chat", replyFailed: "Couldn't send allow or deny", @@ -2871,6 +2882,16 @@ export const en = { needConfirm: 'Needs confirmation', needAnswer: 'Needs your answer', plan: 'Plan', + planProgress: '{done}/{total} done', + planStatusPending: 'To do', + planStatusLive: 'In progress', + planStatusDone: 'Done', + planStatusFailed: 'Failed', + planExpand: 'Expand plan', + planCollapse: 'Collapse plan', + snapshotStale: "Couldn't update this chat", + snapshotStaleHint: 'Showing the last content we have. Retry to read again.', + snapshotRetry: 'Retry', hostCommand: 'Run command', stopCommand: 'Stop this one', stopCommandFailed: "Couldn't stop this command", @@ -3033,7 +3054,7 @@ export const en = { acp: "ACP", continuous: "Continuous chat", legacy: "Original send path", - sessionTitle: "How this chat connects", + sessionTitle: "This chat", sessionHintAcp: "This chat uses ACP. Always allow on a card only remembers this conversation — not start-of-chat auto-approve.", sessionHintContinuous: "This chat uses continuous chat. Always allow on a card only remembers this conversation — not start-of-chat auto-approve.", sessionHintLegacy: "This chat uses the original send path. There is no allow/deny card.", @@ -3044,7 +3065,7 @@ export const en = { }, settings: { title: "Session settings", - description: "Working directory, how this chat connects, and auto-approve", + description: "Working folder and auto-approve", cwd: "Working directory", cwdPlaceholder: "Choose a local folder, or paste a full path", pickDir: "Choose folder", diff --git a/src/lib/i18n/locales/zh.ts b/src/lib/i18n/locales/zh.ts index 7314d5de..b48fa143 100644 --- a/src/lib/i18n/locales/zh.ts +++ b/src/lib/i18n/locales/zh.ts @@ -39,7 +39,7 @@ export const zh = { nav: { workspace: '工作区', manage: '管理', - chat: '对话', + chat: '工作区', agents: 'Agent', skills: '技能', mcp: 'MCP', @@ -110,6 +110,8 @@ export const zh = { canvasMint: '薄荷', canvasSand: '沙色', canvasLilac: '淡紫', + chatOutlineLabel: '聊天大纲', + chatOutlineDescription: '显示用于在提示词之间跳转的大纲', autoStartLabel: '开机自启', autoStartDescription: '登录后启动', autoStartTip: @@ -2718,6 +2720,7 @@ export const zh = { expandHistory: "展开历史", resize: "拖动调整历史宽度", newChat: "新建对话", + newChatInWorkspace: "在此工作目录新建对话", newChatDisabled: "请先安装或取消隐藏 Agent", searchPlaceholder: "搜索标题或工作目录", searchAria: "搜索标题或工作目录", @@ -2733,6 +2736,7 @@ export const zh = { header: { conversation: "对话", titleAria: "会话标题", + switchSession: "切换会话", pickCwd: "点击选择工作目录", cwdUnset: "未设置工作目录", cwdMissing: "目录已不存在", @@ -2744,6 +2748,10 @@ export const zh = { resumeCommandCopiedHint: "粘贴到终端即可继续这场对话", noResumeCommand: "没有可复制的会话", }, + outline: { + aria: "聊天大纲", + tick: "{n} / {total}:{preview}", + }, preview: { titleFallback: "预览", back: "返回", @@ -2756,6 +2764,7 @@ export const zh = { failed: "无法打开", emptyBody: "没有内容", truncatedSuffix: " · 已截断", + viewEdit: "查看修改", }, composer: { placeholder: "发给 Agent…", @@ -2803,6 +2812,8 @@ export const zh = { stop: "停止", actions: "更多操作", history: "搜索历史会话", + prevSession: "上一条会话", + nextSession: "下一条会话", model: "换模型", newChat: "新建对话", overview: "快捷键一览", @@ -2831,9 +2842,9 @@ export const zh = { sessionRemembered: '本会话已一直允许', sessionRememberedHint: '新对话再问。不是会话设置里的自动批准。', sessionRememberedHintKiro: '新对话再问。不是会话设置里的完全访问权限。', - sessionRememberedOff: '关。卡片上点了才会开,不会在这里假装打开。', - sessionRememberedOnHint: '关掉后这次对话再问。不是会话设置里的自动批准。', - sessionRememberedOnHintKiro: '关掉后这次对话再问。不是会话设置里的完全访问权限。', + sessionRememberedOff: '卡片上点了才会开', + sessionRememberedOnHint: '关了这次对话再问', + sessionRememberedOnHintKiro: '关了这次对话再问', sessionRememberedClear: '停止记住', sessionRememberedClearFailed: '没法关掉本会话一直允许', replyFailed: '没法回传允许或拒绝', @@ -2851,6 +2862,16 @@ export const zh = { needConfirm: '需要确认', needAnswer: '需要你的回答', plan: '计划', + planProgress: '{done}/{total} 已完成', + planStatusPending: '待做', + planStatusLive: '进行中', + planStatusDone: '已完成', + planStatusFailed: '失败', + planExpand: '展开计划', + planCollapse: '收起计划', + snapshotStale: '没法更新这场对话', + snapshotStaleHint: '停在最后看到的内容。点重试再读一次。', + snapshotRetry: '重试', hostCommand: '执行命令', stopCommand: '停止这条', stopCommandFailed: '没法停止这条命令', @@ -3013,7 +3034,7 @@ export const zh = { acp: "ACP", continuous: "持续对话", legacy: "原来的发送方式", - sessionTitle: "这次对话怎么接", + sessionTitle: "这次对话", sessionHintAcp: "这次对话走 ACP。卡片上的一直允许只记这一次,不是启动时的自动批准。", sessionHintContinuous: "这次对话走持续对话。卡片上的一直允许只记这一次,不是启动时的自动批准。", sessionHintLegacy: "这次对话走原来的发送方式,没有允许/拒绝卡片。", @@ -3024,7 +3045,7 @@ export const zh = { }, settings: { title: "会话设置", - description: "工作目录、这次对话怎么接、自动批准", + description: "工作目录和自动批准", cwd: "工作目录", cwdPlaceholder: "选择本机目录,或粘贴完整路径", pickDir: "选择目录", diff --git a/src/lib/open-chat-cwd.test.ts b/src/lib/open-chat-cwd.test.ts index f3a8f12b..482d7f56 100644 --- a/src/lib/open-chat-cwd.test.ts +++ b/src/lib/open-chat-cwd.test.ts @@ -4,7 +4,9 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it, vi } from 'vitest'; import { consumePendingOpenChatCwd, + createConversationCwd, folderNameFromCwd, + newChatCwdArg, shellOpenChatBootstrap, shellOpenChatHref, } from './open-chat-cwd'; @@ -26,6 +28,25 @@ describe('folderNameFromCwd', () => { }); }); +describe('newChatCwdArg', () => { + it('keeps a folder path or explicit null and drops click events', () => { + expect(newChatCwdArg('/workspace')).toBe('/workspace'); + expect(newChatCwdArg(null)).toBeNull(); + expect(newChatCwdArg(undefined)).toBeUndefined(); + const cyclic: { target?: unknown } = {}; + cyclic.target = cyclic; + expect(() => JSON.stringify(cyclic)).toThrow(/circular|cyclic/i); + expect(newChatCwdArg(cyclic)).toBeUndefined(); + expect(() => JSON.stringify({ + agentIds: ['grok'], + cwd: createConversationCwd(cyclic), + })).not.toThrow(); + expect(createConversationCwd(cyclic)).toBeNull(); + expect(createConversationCwd('/workspace')).toBe('/workspace'); + expect(createConversationCwd(null)).toBeNull(); + }); +}); + describe('consumePendingOpenChatCwd', () => { it('is a no-op when takePending returns nothing', async () => { const applyBootstrap = vi.fn(); @@ -85,4 +106,13 @@ describe('App open-chat wiring', () => { /HashRouter `useNavigate` changes identity with pathname; do not resubscribe\.\s*\n\s*\}, \[\]\);/, ); }); + + it('omits non-string cwd before create-conversation persist and IPC', () => { + const dir = path.dirname(fileURLToPath(import.meta.url)); + const api = readFileSync(path.resolve(dir, 'api/chat.ts'), 'utf8'); + const tauri = readFileSync(path.resolve(dir, 'backend/tauri/chat.ts'), 'utf8'); + expect(api).toContain('createConversationCwd'); + expect(tauri).toContain('createConversationCwd'); + expect(tauri).toContain('cwd: createConversationCwd(cwd)'); + }); }); diff --git a/src/lib/open-chat-cwd.ts b/src/lib/open-chat-cwd.ts index 84b38230..9da1ef96 100644 --- a/src/lib/open-chat-cwd.ts +++ b/src/lib/open-chat-cwd.ts @@ -1,5 +1,21 @@ import type { ChatBootstrap } from '@/lib/types'; +/** + * New-chat cwd for persist / IPC. Only a folder path or explicit null. + * Click events and other objects are dropped so JSON.stringify never sees a cycle. + */ +export function newChatCwdArg(value: unknown): string | null | undefined { + if (value === undefined) return undefined; + if (value === null) return null; + if (typeof value === 'string') return value; + return undefined; +} + +/** Wire / invoke shape: never pass a non-string through JSON.stringify. */ +export function createConversationCwd(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + /** Last path segment for a folder chosen in the OS file manager. */ export function folderNameFromCwd(cwd: string): string { const trimmed = cwd.trim().replace(/[\\/]+$/, ''); diff --git a/src/lib/storage-key.test.ts b/src/lib/storage-key.test.ts index 3c160485..9dfb1ed4 100644 --- a/src/lib/storage-key.test.ts +++ b/src/lib/storage-key.test.ts @@ -90,6 +90,10 @@ describe('StorageKey', () => { chatBootstrap: 'agenthub:chat-bootstrap', }); }); + + it('stores the chat outline toggle on the kebab catalog', () => { + expect(StorageKey.chatOutlineEnabled).toBe('agenthub:chat-outline-enabled'); + }); }); describe('readStorageItem', () => { diff --git a/src/lib/storage-key.ts b/src/lib/storage-key.ts index 30a513da..15da0927 100644 --- a/src/lib/storage-key.ts +++ b/src/lib/storage-key.ts @@ -100,6 +100,8 @@ export const StorageKey = { projectsListSort: `${PREFIX}projects-list-sort`, /** Last workspace chosen on the project-skills tab. */ skillsProjectWorkspace: `${PREFIX}skills-project-workspace`, + /** Chat transcript outline (tick rail); default on. */ + chatOutlineEnabled: `${PREFIX}chat-outline-enabled`, ...LAYOUT_STORAGE_KEY, } as const; diff --git a/src/pages/chat/ChatEditPreviewPanel.tsx b/src/pages/chat/ChatEditPreviewPanel.tsx new file mode 100644 index 00000000..c3ac4792 --- /dev/null +++ b/src/pages/chat/ChatEditPreviewPanel.tsx @@ -0,0 +1,166 @@ +import { useEffect, useId } from 'react'; +import { PanelRightClose } from 'lucide-react'; +import { SourcePreview } from '@/components/shared/SourcePreview'; +import { CopyableFileName } from '@/components/shared/CopyableFileName'; +import { pathTailLabel } from '@/components/shared/file-name-label'; +import { useI18n } from '@/components/shared/LanguageProvider'; +import { Button } from '@/components/ui/button'; +import { Tip } from '@/components/ui/tooltip'; +import { CHAT_FILE_PREVIEW_MAX_CHARS } from '@/lib/source-preview'; +import { hasEscPriorityOverlay } from '@/lib/skills/preview-keys'; +import { cn } from '@/lib/utils'; +import { + sameEditPath, + turnEditDiffText, + type TurnEditFile, +} from './chat-edit-preview'; + +function fileName(path: string): string { + const parts = path.trim().split(/[/\\]/).filter(Boolean); + return parts[parts.length - 1] ?? path.trim(); +} + +export function ChatTurnEditList({ + files, + selectedPath, + onSelect, +}: { + files: TurnEditFile[]; + selectedPath?: string; + onSelect: (file: TurnEditFile) => void; +}) { + const { t } = useI18n(); + if (files.length === 0) return null; + return ( +
+

{t('chat.preview.viewEdit')}

+
    + {files.map((file) => { + const selected = Boolean(selectedPath && sameEditPath(selectedPath, file.path)); + const statusLabel = file.status === 'live' + ? t('chat.process.toolEdit') + : t('chat.process.toolEditDone'); + return ( +
  • + +
  • + ); + })} +
+
+ ); +} + +export function ChatEditPreviewPanel({ + file, + open, + width, + onClose, + className, +}: { + file: TurnEditFile; + open: boolean; + width?: number; + onClose: () => void; + className?: string; +}) { + const { t } = useI18n(); + const titleId = useId(); + const name = fileName(file.path); + const diff = turnEditDiffText(file) ?? ''; + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + if (hasEscPriorityOverlay()) return; + e.preventDefault(); + onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, onClose]); + + if (!open) return null; + + return ( + + ); +} diff --git a/src/pages/chat/ChatMessageBubble.test.ts b/src/pages/chat/ChatMessageBubble.test.ts index c5442f9a..13bbd4b6 100644 --- a/src/pages/chat/ChatMessageBubble.test.ts +++ b/src/pages/chat/ChatMessageBubble.test.ts @@ -91,6 +91,128 @@ describe('ChatMessageBubble streaming feel', () => { expect(html).not.toContain('已停止'); }); + it('shows a clickable thinking bar instead of three dots when thinking has no body yet', () => { + const process: AgentProcessView = { + turn: 1, + agent: 'codex', + phase: 'running', + stdout: '', + stderr: '', + steps: [{ type: 'thinking', text: 'secret plan that must not enter the bubble', done: false }], + updatedAt: 1, + thinkingStartedAt: Date.now() - 3200, + }; + const html = renderToStaticMarkup( + createElement(TooltipProvider, null, createElement(ChatMessageBubble, { + message: agentMessage(''), + process, + isLastTurn: true, + multiAgent: false, + retryDisabled: false, + onRetry: () => undefined, + onOpenProcess: () => undefined, + })), + ); + expect(html).toContain('data-help="chat-thinking-bar"'); + expect(html).toContain('思考中'); + expect(html).toContain('▸'); + expect(html).not.toContain('正在想'); + expect(html).not.toContain('secret plan that must not enter the bubble'); + expect(html).not.toContain('data-help="chat-process-chip"'); + }); + + it('shows 思考了 after thinking ends and before the reply body', () => { + const process: AgentProcessView = { + turn: 1, + agent: 'codex', + phase: 'running', + stdout: '', + stderr: '', + steps: [{ type: 'thinking', text: 'done thinking body', done: true }], + updatedAt: 1, + thinkingStartedAt: 1, + thinkingDurationMs: 3200, + }; + const html = renderToStaticMarkup( + createElement(TooltipProvider, null, createElement(ChatMessageBubble, { + message: agentMessage(''), + process, + isLastTurn: true, + multiAgent: false, + retryDisabled: false, + onRetry: () => undefined, + onOpenProcess: () => undefined, + })), + ); + expect(html).toContain('data-help="chat-thinking-bar"'); + expect(html).toContain('思考了 3.2s'); + expect(html).not.toContain('正在写'); + expect(html).not.toContain('done thinking body'); + }); + + it('hides the thinking bar once the reply body arrives', () => { + const process: AgentProcessView = { + turn: 1, + agent: 'codex', + phase: 'running', + stdout: '', + stderr: '', + steps: [{ type: 'thinking', text: 'secret plan', done: true }], + updatedAt: 1, + thinkingStartedAt: 1, + thinkingDurationMs: 1200, + }; + const html = renderToStaticMarkup( + createElement(TooltipProvider, null, createElement(ChatMessageBubble, { + message: agentMessage('第一段正文'), + process, + isLastTurn: true, + multiAgent: false, + retryDisabled: false, + onRetry: () => undefined, + onOpenProcess: () => undefined, + })), + ); + expect(html).not.toContain('data-help="chat-thinking-bar"'); + expect(html).not.toContain('secret plan'); + expect(html).toContain('第一段正文'); + }); + + it('shows the thinking bar and a tool chip together before any body', () => { + const process: AgentProcessView = { + turn: 1, + agent: 'codex', + phase: 'running', + stdout: '', + stderr: '', + steps: [ + { type: 'thinking', text: 'secret plan', done: false }, + { type: 'tool', name: 'Read', status: 'start', input: { path: 'README.md' } }, + ], + updatedAt: 1, + thinkingStartedAt: Date.now() - 1500, + }; + const html = renderToStaticMarkup( + createElement(TooltipProvider, null, createElement(ChatMessageBubble, { + message: agentMessage(''), + process, + isLastTurn: true, + multiAgent: false, + retryDisabled: false, + onRetry: () => undefined, + onOpenProcess: () => undefined, + })), + ); + expect(html).toContain('data-help="chat-thinking-bar"'); + expect(html).toContain('data-help="chat-process-chip"'); + expect(html).toContain('正在读取 README.md'); + expect(html).not.toContain('secret plan'); + const barAt = html.indexOf('data-help="chat-thinking-bar"'); + const chipAt = html.indexOf('data-help="chat-process-chip"'); + expect(barAt).toBeGreaterThan(-1); + expect(chipAt).toBeGreaterThan(barAt); + }); + it('opens process details from a one-line chip', () => { const process: AgentProcessView = { turn: 1, diff --git a/src/pages/chat/ChatMessageBubble.tsx b/src/pages/chat/ChatMessageBubble.tsx index e98767aa..d933f835 100644 --- a/src/pages/chat/ChatMessageBubble.tsx +++ b/src/pages/chat/ChatMessageBubble.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import { AgentLogo } from '@/components/shared/AgentLogo'; import { AgentThinking } from '@/components/shared/AgentThinking'; import { CopyTextButton } from '@/components/shared/CopyTextButton'; @@ -10,7 +11,11 @@ import { formatProcessHeadline, formatTurnUsageFooter, hasInspectableProcess, + latestThinkingStep, phaseFromMessageStatus, + showBubbleThinkingBar, + thinkingElapsedMs, + timelineHasToolRow, } from '@/lib/chat-process'; import type { AgentProcessView } from '@/lib/chat-process'; import type { AgentKey, ChatMessage } from '@/lib/types'; @@ -20,6 +25,7 @@ import { localizeChatFailure, looksLikeChatProtocolDump, sanitizeCliChatText, + thinkingChromeLabel, } from './chat-format'; import { messageStatusLabel } from './chat-model'; import { streamingActivity, streamingPlaceholderKey } from './chat-streaming'; @@ -159,20 +165,31 @@ function AgentBubble({ : running ? 'running' : null; + const thinking = latestThinkingStep(process?.steps); + const showThinkingBar = showBubbleThinkingBar(process?.steps, hasContent); const showProcessChip = Boolean(onOpenProcess) && ( running || Boolean(process && hasInspectableProcess(process)) - ); + ) && (!showThinkingBar || timelineHasToolRow(process?.steps)); const processHeadline = showProcessChip ? process && effectivePhase ? formatProcessHeadline(process.steps, effectivePhase, t) : messageStatusLabel(t, resolvedStatus, process, hasContent) ?? t('chat.process.summaryGenerating') : ''; - const statusText = (hideRetry && looksFailed) || showProcessChip + const statusText = (hideRetry && looksFailed) || showProcessChip || showThinkingBar ? null : messageStatusLabel(t, resolvedStatus, process, hasContent); const activity = running ? streamingActivity(process, hasContent) : null; const showRetry = isLastTurn && looksFailed && !hideRetry; const usageText = formatTurnUsageFooter(process?.steps, running, t); + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!showThinkingBar || thinking?.done) return; + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, [showThinkingBar, thinking?.done]); + const thinkingLabel = thinking + ? thinkingChromeLabel(Boolean(thinking.done), thinkingElapsedMs(process, now), t) + : ''; return (
@@ -200,6 +217,29 @@ function AgentBubble({ )}
+ {showThinkingBar && thinkingLabel ? ( + + ) : null} {showProcessChip && processHeadline && onOpenProcess ? ( + {hasAttention ? ( +
+

{prompt.preview}

+
+ ) : null} + + ); +}); diff --git a/src/pages/chat/ChatPlanBar.test.ts b/src/pages/chat/ChatPlanBar.test.ts new file mode 100644 index 00000000..b53e7ef8 --- /dev/null +++ b/src/pages/chat/ChatPlanBar.test.ts @@ -0,0 +1,57 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import type { RuntimePlanEntry } from '@/lib/api/chat'; +import { ChatPlanBar } from './ChatPlanBar'; + +function renderPlan(plan?: RuntimePlanEntry[] | null): string { + return renderToStaticMarkup(createElement(ChatPlanBar, { plan })); +} + +describe('ChatPlanBar', () => { + it('draws nothing when the plan is missing or only blank rows', () => { + expect(renderPlan(undefined)).toBe(''); + expect(renderPlan(null)).toBe(''); + expect(renderPlan([])).toBe(''); + expect(renderPlan([{ content: ' ' }])).toBe(''); + }); + + it('shows progress, live/failed counts, and every kept row when expanded', () => { + const html = renderPlan([ + { content: 'read', status: 'completed' }, + { content: 'edit', status: 'in_progress' }, + { content: ' ' }, + { content: 'test', status: 'pending' }, + { content: 'broken', status: 'failed' }, + ]); + expect(html).toContain('data-help="chat-plan-bar"'); + expect(html).toContain('aria-expanded="true"'); + expect(html).toContain('1/4 已完成'); + expect(html).toContain('进行中 1'); + expect(html).toContain('失败 1'); + expect(html).toContain('已完成'); + expect(html).toContain('read'); + expect(html).toContain('edit'); + expect(html).toContain('test'); + expect(html).toContain('broken'); + expect(html).toContain('收起计划'); + expect(html).not.toContain(' '); + }); + + it('maps vendor status aliases onto the same live / done / pending / failed labels', () => { + const html = renderPlan([ + { content: 'done-row', status: 'complete' }, + { content: 'live-row', status: 'running' }, + { content: 'fail-row', status: 'canceled' }, + { content: 'wait-row' }, + ]); + expect(html).toContain('1/4 已完成'); + expect(html).toContain('进行中 1'); + expect(html).toContain('失败 1'); + expect(html).toContain('待做'); + expect(html).toContain('done-row'); + expect(html).toContain('live-row'); + expect(html).toContain('fail-row'); + expect(html).toContain('wait-row'); + }); +}); diff --git a/src/pages/chat/ChatPlanBar.tsx b/src/pages/chat/ChatPlanBar.tsx index 3c5cbf2b..809b8602 100644 --- a/src/pages/chat/ChatPlanBar.tsx +++ b/src/pages/chat/ChatPlanBar.tsx @@ -1,36 +1,85 @@ +import { useState } from 'react'; +import { ChevronDown } from 'lucide-react'; import { useI18n } from '@/components/shared/LanguageProvider'; import { cn } from '@/lib/utils'; import type { RuntimePlanEntry } from '@/lib/api/chat'; -import { runtimePlanEntryTone, visibleRuntimePlan } from './chat-runtime-model'; +import { + runtimePlanEntryTone, + runtimePlanProgress, + runtimePlanStatusKey, + visibleRuntimePlan, +} from './chat-runtime-model'; export function ChatPlanBar({ plan }: { plan?: RuntimePlanEntry[] | null }) { const { t } = useI18n(); const entries = visibleRuntimePlan(plan); + const [open, setOpen] = useState(true); if (entries.length === 0) return null; + const progress = runtimePlanProgress(entries); + const live = entries.find((entry) => runtimePlanEntryTone(entry.status) === 'live'); + const listId = 'chat-plan-bar-list'; return (
-

{t('chat.runtime.plan')}

-
    - {entries.map((entry, index) => { - const tone = runtimePlanEntryTone(entry.status); - return ( -
  1. - {entry.content} -
  2. - ); - })} -
+ + {open ? ( +
    + {entries.map((entry, index) => { + const tone = runtimePlanEntryTone(entry.status); + return ( +
  1. + + {t(runtimePlanStatusKey(entry.status))} + + {entry.content} +
  2. + ); + })} +
+ ) : live ? ( +

+ {t(runtimePlanStatusKey(live.status))} · + {live.content} +

+ ) : null}
); } diff --git a/src/pages/chat/ChatProcessPanel.test.ts b/src/pages/chat/ChatProcessPanel.test.ts index d1c20973..8a3f564b 100644 --- a/src/pages/chat/ChatProcessPanel.test.ts +++ b/src/pages/chat/ChatProcessPanel.test.ts @@ -102,13 +102,75 @@ describe('ChatProcessPanel human copy', () => { view({ phase: 'ok', steps: [{ type: 'thinking', text: '先看工作目录', done: true }], + thinkingStartedAt: 1, + thinkingDurationMs: 3200, }), 'ok', ); expect(html).toContain('先看工作目录'); + expect(html).toContain('思考了 3.2s'); + expect(html).toContain('data-help="chat-process-thinking"'); expect(html).toMatch(/]*open/); }); + it('shows a thinking fold without a tool row when no tools ran', () => { + const html = renderPanel( + view({ + phase: 'running', + steps: [{ type: 'thinking', text: 'only thinking', done: false }], + thinkingStartedAt: Date.now() - 800, + }), + ); + expect(html).toContain('data-help="chat-process-thinking"'); + expect(html).toContain('only thinking'); + expect(html).not.toContain('data-help="chat-process-tool"'); + }); + + it('keeps an error on the tools timeline, not inside the thinking fold', () => { + const html = renderPanel( + view({ + phase: 'failed', + steps: [ + { type: 'thinking', text: 'tried', done: true }, + { type: 'error', message: 'disk full' }, + ], + thinkingStartedAt: 1, + thinkingDurationMs: 400, + }), + 'failed', + ); + expect(html).toContain('data-help="chat-process-thinking"'); + expect(html).toContain('tried'); + expect(html).toContain('disk full'); + expect(html).toContain('text-danger'); + const thinkingAt = html.indexOf('data-help="chat-process-thinking"'); + const errorAt = html.indexOf('disk full'); + expect(errorAt).toBeGreaterThan(thinkingAt); + }); + + it('keeps thinking as a fold separate from tool rows and pins live text', () => { + const html = renderPanel( + view({ + phase: 'running', + steps: [ + { type: 'thinking', text: '先看目录再改', done: false }, + { type: 'tool', name: 'Read', status: 'start', input: { path: 'README.md' } }, + ], + thinkingStartedAt: Date.now() - 3200, + }), + ); + expect(html).toContain('data-help="chat-process-thinking"'); + expect(html).toContain('data-help="chat-process-tool"'); + expect(html).toContain('思考中'); + expect(html).toContain('正在读取 README.md'); + expect(html).toContain('[overflow-anchor:none]'); + expect(html).toContain('max-h-40'); + const thinkingAt = html.indexOf('data-help="chat-process-thinking"'); + const toolAt = html.indexOf('data-help="chat-process-tool"'); + expect(thinkingAt).toBeGreaterThan(-1); + expect(toolAt).toBeGreaterThan(thinkingAt); + }); + it('offers one-click copy on JSON in tool details', () => { const html = renderPanel( view({ diff --git a/src/pages/chat/ChatProcessPanel.tsx b/src/pages/chat/ChatProcessPanel.tsx index 95c02565..e6950209 100644 --- a/src/pages/chat/ChatProcessPanel.tsx +++ b/src/pages/chat/ChatProcessPanel.tsx @@ -15,6 +15,7 @@ import { formatToolStep, formatUsageStep, isProtocolProcessStep, + latestThinkingStep, phaseFromMessageStatus, stepSummary, timelineProcessSteps, @@ -194,13 +195,21 @@ function toolHasProtocolDetails(step: Extract): b ); } -function ProcessStepRow({ step }: { step: ProcessStep }) { +function ProcessStepRow({ + step, + thinkingStartedAt, + thinkingDurationMs, +}: { + step: ProcessStep; + thinkingStartedAt?: number; + thinkingDurationMs?: number; +}) { const { t } = useI18n(); if (step.type === 'tool') { const input = formatStepInput(step.input); const live = toolActionTone(step.status) === 'live'; return ( -
+
; + return ( + + ); } if (step.type === 'error') { return
{step.message}
; @@ -259,15 +276,24 @@ function ThinkingStepRow({ text, done, defaultOpen, + startedAt, + durationMs, }: { text: string; done: boolean; defaultOpen: boolean; + startedAt?: number; + durationMs?: number; }) { const { t } = useI18n(); - const [elapsedMs, setElapsedMs] = useState(0); - const startRef = useRef(Date.now()); + const startRef = useRef(startedAt ?? Date.now()); + const [now, setNow] = useState(() => Date.now()); const [open, setOpen] = useState(defaultOpen); + const bodyRef = useRef(null); + + useEffect(() => { + if (startedAt != null) startRef.current = startedAt; + }, [startedAt]); useEffect(() => { if (done) { @@ -275,20 +301,26 @@ function ThinkingStepRow({ return; } setOpen(true); - startRef.current = Date.now(); - const tick = () => setElapsedMs(Math.max(0, Date.now() - startRef.current)); + const tick = () => setNow(Date.now()); tick(); const id = window.setInterval(tick, 1000); return () => window.clearInterval(id); }, [done, defaultOpen]); + const elapsedMs = done + ? (durationMs ?? 0) + : Math.max(0, now - startRef.current); const label = thinkingChromeLabel(done, elapsedMs, t); - const body = clipProcessTail(text); + useLayoutEffect(() => { + if (!done) pinElementScrollToBottom(bodyRef.current); + }, [body, done]); + return (
{ e.stopPropagation(); @@ -304,7 +336,12 @@ function ThinkingStepRow({ )} {body ? ( -
{body}
+
+          {body}
+        
) : null}
); @@ -343,6 +380,7 @@ export function ChatProcessPanel({ ); const timelineRef = useRef(null); const stderrRef = useRef(null); + const latestThinking = latestThinkingStep(timeline); useLayoutEffect(() => { pinElementScrollToBottom(timelineRef.current); @@ -367,7 +405,20 @@ export function ChatProcessPanel({
) : null} {timeline.map((step, i) => ( - + ))} {pendingConfirm ? (
diff --git a/src/pages/chat/ChatSessionHeader.tsx b/src/pages/chat/ChatSessionHeader.tsx index c4576f17..1348764c 100644 --- a/src/pages/chat/ChatSessionHeader.tsx +++ b/src/pages/chat/ChatSessionHeader.tsx @@ -1,22 +1,35 @@ import { useEffect, useRef, useState } from 'react'; -import { Copy, FolderOpen, PanelLeftOpen, Settings2, ShieldAlert, Terminal } from 'lucide-react'; +import { + ChevronDown, + ChevronUp, + ChevronsUpDown, + Copy, + FolderOpen, + PanelLeftOpen, + Settings2, + ShieldAlert, + Terminal, +} from 'lucide-react'; import { ChromeActions } from '@/components/layout/ChromeActions'; import { pageRhythm } from '@/components/layout/page-rhythm'; import { copyTextToClipboard } from '@/components/shared/CopyTextButton'; import { useI18n } from '@/components/shared/LanguageProvider'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Hint } from '@/components/ui/tooltip'; import { Input } from '@/components/ui/input'; import { useToast } from '@/components/ui/toast'; import type { Conversation } from '@/lib/types'; import { cn } from '@/lib/utils'; +import { sessionSwitchNeighbors } from './chat-session-switch'; import { isKiroChatAgent } from './chat-kiro-model'; -import { - chatConnectLabelKey, - sessionChatConnectKind, -} from './chat-connect-model'; import { sessionAllowAlwaysActive } from './chat-runtime-model'; -import type { RuntimeChannel, RuntimeSnapshot } from '@/lib/api/chat'; +import type { RuntimeSnapshot } from '@/lib/api/chat'; import { autoApproveActive, autoApproveEffect, @@ -32,25 +45,27 @@ export function ChatSessionHeader({ active, railOpen, recordText, + sessions, + sendingConversationIds = [], onExpandRail, onRename, + onFocus, onOpenSettings, onPickWorkingDirectory, runtimeLocked = false, - transport = null, - runtimeEnabled = false, runtime = null, }: { active: Conversation | null; railOpen: boolean; recordText?: string; + sessions: readonly Conversation[]; + sendingConversationIds?: readonly string[]; onExpandRail: () => void; onRename: (next: string) => Promise; + onFocus: (id: string) => void; onOpenSettings: () => void; onPickWorkingDirectory: () => void; runtimeLocked?: boolean; - transport?: RuntimeChannel | null; - runtimeEnabled?: boolean; runtime?: Pick | null; }) { const { t } = useI18n(); @@ -73,11 +88,6 @@ export function ChatSessionHeader({ const selectedAgent = active?.agentIds[0] ?? null; const approveOn = autoApproveActive(Boolean(active?.allowDangerous), selectedAgent); const kiroPermissions = isKiroChatAgent(selectedAgent); - const connectKind = sessionChatConnectKind({ - agentId: selectedAgent, - transport, - runtimeEnabled, - }); const sessionAlways = sessionAllowAlwaysActive(runtime); async function commit() { @@ -114,7 +124,14 @@ export function ChatSessionHeader({ )}
- {active && editing ? ( + {!railOpen && sessions.length > 0 ? ( + + ) : active && editing ? ( - - - {active.nativeSessionId && ( + + + + + + {sessions.map((session) => { + const selected = active?.id === session.id; + const sending = sendingConversationIds.includes(session.id); + return ( + onFocus(session.id)} + > + + + {conversationTitle(t, session.title)} + + {cwdShortName(session.cwd, t)} + + + + ); + })} + + + +
+ ); +} diff --git a/src/pages/chat/ChatSessionRail.test.ts b/src/pages/chat/ChatSessionRail.test.ts index 1387f861..2f242f20 100644 --- a/src/pages/chat/ChatSessionRail.test.ts +++ b/src/pages/chat/ChatSessionRail.test.ts @@ -5,7 +5,11 @@ import { describe, expect, it, vi } from 'vitest'; import { TooltipProvider } from '@/components/ui/tooltip'; import type { Conversation } from '@/lib/types'; import { createTranslator } from '@/lib/i18n'; -import { conversationRailHintView, conversationSemanticTitle } from './chat-model'; +import { + conversationRailHintView, + conversationSemanticTitle, + type ConversationWorkspaceGroup, +} from './chat-model'; import { ChatSessionRail } from './ChatSessionRail'; vi.mock('@/components/shared/LanguageProvider', async () => { @@ -34,12 +38,26 @@ function renderMarkup(node: ReactElement) { return renderToStaticMarkup(createElement(TooltipProvider, null, node)); } +function workspaceGroup( + items: Conversation[], + partial?: Partial, +): ConversationWorkspaceGroup { + const cwd = items[0]?.cwd ?? null; + return { + key: cwd ? `path:${cwd}` : 'unset', + label: cwd ? 'demo-project' : '未设置工作目录', + cwd, + items, + ...partial, + }; +} + function rail(partial?: Partial[0]>) { const item = conversation(); return createElement(ChatSessionRail, { open: true, listLoading: false, - groups: [{ key: 'today', label: '今天', items: [item] }], + groups: [workspaceGroup([item])], conversations: [item], filteredCount: 1, query: '', @@ -65,7 +83,7 @@ describe('ChatSessionRail titles', () => { 'Use your terminal to write exactly what I asked without clipping the title'; const html = renderMarkup( rail({ - groups: [{ key: 'today', label: '今天', items: [conversation({ title: full })] }], + groups: [workspaceGroup([conversation({ title: full })])], conversations: [conversation({ title: full })], firstUserContentById: { c1: full }, }), @@ -94,7 +112,7 @@ describe('ChatSessionRail titles', () => { renderMarkup( rail({ activeId: active.id, - groups: [{ key: 'today', label: '今天', items: [active, clipped] }], + groups: [workspaceGroup([active, clipped])], conversations: [active, clipped], filteredCount: 2, firstUserContentById, @@ -133,7 +151,42 @@ describe('ChatSessionRail titles', () => { expect(hint.meta).toContain('/workspace/demo-project'); const src = readFileSync(new URL('./ChatSessionRail.tsx', import.meta.url), 'utf8'); expect(src).toContain('conversationRailHintView('); - expect(src).not.toContain('cwdShortName'); + expect(src).toContain('data-help="chat-workspace-group-label"'); + }); + + it('groups sessions under a collapsible working-directory header', () => { + const html = renderMarkup(rail()); + expect(html).toContain('data-help="chat-workspace-group"'); + expect(html).toContain('data-help="chat-workspace-group-label"'); + expect(html).toContain('demo-project'); + expect(html).toContain('aria-expanded="true"'); + expect(html).not.toContain('今天'); + const src = readFileSync(new URL('./ChatSessionRail.tsx', import.meta.url), 'utf8'); + expect(src).toContain('ChevronDown'); + expect(src).toContain('ChevronRight'); + }); + + it('hides a plus on the folder until hover, then starts a chat in that folder', () => { + const html = renderMarkup(rail()); + const src = readFileSync(new URL('./ChatSessionRail.tsx', import.meta.url), 'utf8'); + expect(html).toContain('data-help="chat-workspace-new"'); + expect(src).toContain('group-hover:opacity-100'); + expect(src).toContain('onNewChat(group.cwd)'); + expect(html).toContain('opacity-0'); + }); + + it('puts the folder path on hover, not in the group header', () => { + const item = conversation(); + const html = renderMarkup(rail({ groups: [workspaceGroup([item])] })); + const src = readFileSync(new URL('./ChatSessionRail.tsx', import.meta.url), 'utf8'); + expect(src).toContain('Hint label={group.cwd ?? group.label}'); + expect(src).not.toContain('chat-workspace-group-path'); + expect(html).toContain('data-help="chat-workspace-group-label"'); + expect(html).toContain('demo-project'); + const labelStart = html.indexOf('data-help="chat-workspace-group-label"'); + const labelHtml = html.slice(labelStart, labelStart + 180); + expect(labelHtml).toContain('demo-project'); + expect(labelHtml).not.toContain('/workspace/demo-project'); }); it('paints 新建对话 with the theme fill', () => { @@ -143,5 +196,39 @@ describe('ChatSessionRail titles', () => { expect(html).toContain('bg-accent'); expect(html).not.toContain('删除确认 Enter'); }); + + it('keeps two working-directory groups and an unset group on separate headers', () => { + const app = conversation({ id: 'app', cwd: '/workspace/demo-project', title: '修登录' }); + const other = conversation({ id: 'other', cwd: '/tmp/other', title: '另一场' }); + const unset = conversation({ id: 'unset', cwd: null, title: '未设' }); + const html = renderMarkup( + rail({ + groups: [ + workspaceGroup([app]), + workspaceGroup([other], { key: 'path:/tmp/other', label: 'other', cwd: '/tmp/other' }), + workspaceGroup([unset], { key: 'unset', label: '未设置工作目录', cwd: null }), + ], + conversations: [app, other, unset], + filteredCount: 3, + }), + ); + expect(html.split('data-help="chat-workspace-group"')).toHaveLength(4); + expect(html).toContain('demo-project'); + expect(html).toContain('other'); + expect(html).toContain('未设置工作目录'); + expect(html).toContain('data-session-id="app"'); + expect(html).toContain('data-session-id="other"'); + expect(html).toContain('data-session-id="unset"'); + expect(html.split('data-help="chat-workspace-new"')).toHaveLength(3); + const unsetAt = html.indexOf('data-session-id="unset"'); + const unsetGroup = html.slice(html.lastIndexOf('data-help="chat-workspace-group"', unsetAt), unsetAt); + expect(unsetGroup).not.toContain('data-help="chat-workspace-new"'); + }); + + it('starts the main new chat without passing the click event as a folder', () => { + const src = readFileSync(new URL('./ChatSessionRail.tsx', import.meta.url), 'utf8'); + expect(src).toContain('onClick={() => onNewChat()}'); + expect(src).not.toContain('onClick={onNewChat}'); + }); }); diff --git a/src/pages/chat/ChatSessionRail.tsx b/src/pages/chat/ChatSessionRail.tsx index 9f4b1423..785672e2 100644 --- a/src/pages/chat/ChatSessionRail.tsx +++ b/src/pages/chat/ChatSessionRail.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef } from 'react'; -import { Loader2, PanelLeftClose, Plus, Trash2 } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { ChevronDown, ChevronRight, Loader2, PanelLeftClose, Plus, Trash2 } from 'lucide-react'; import { AgentLogo } from '@/components/shared/AgentLogo'; import { NavResizeHandle } from '@/components/layout/NavResizeHandle'; import { pageRhythm } from '@/components/layout/page-rhythm'; @@ -28,7 +28,7 @@ import { conversationRailMarkColor, conversationRailSelectedFill, conversationTitle, - type ConversationDayGroup, + type ConversationWorkspaceGroup, } from './chat-model'; export function ChatSessionRail({ @@ -56,7 +56,7 @@ export function ChatSessionRail({ }: { open: boolean; listLoading: boolean; - groups: ConversationDayGroup[]; + groups: ConversationWorkspaceGroup[]; conversations: Conversation[]; filteredCount: number; query: string; @@ -67,7 +67,7 @@ export function ChatSessionRail({ hasUsableAgent: boolean; deleteConfirmId: string | null; onToggleRail: () => void; - onNewChat: () => void; + onNewChat: (cwd?: string | null) => void; onFocus: (id: string) => void; onRequestDelete: (id: string) => void; onCancelDelete: () => void; @@ -85,6 +85,8 @@ export function ChatSessionRail({ const pending = conversations.find((c) => c.id === deleteConfirmId) ?? null; const railRef = useRef(null); const searchInputRef = useRef(null); + const [collapsedKeys, setCollapsedKeys] = useState>(() => new Set()); + const searching = Boolean(query.trim()); useEffect(() => { if (!open || !searchFocusNonce) return; const timer = window.setTimeout(() => { @@ -93,6 +95,17 @@ export function ChatSessionRail({ }, 0); return () => window.clearTimeout(timer); }, [open, searchFocusNonce]); + useEffect(() => { + if (!activeId) return; + const key = groups.find((group) => group.items.some((item) => item.id === activeId))?.key; + if (!key) return; + setCollapsedKeys((prev) => { + if (!prev.has(key)) return prev; + const next = new Set(prev); + next.delete(key); + return next; + }); + }, [activeId, groups, historyRevealNonce]); useEffect(() => { if (!open || !historyRevealNonce) return; const timer = window.setTimeout(() => { @@ -147,7 +160,7 @@ export function ChatSessionRail({ disabled={agentsReady && !hasUsableAgent} data-help="chat-new" aria-keyshortcuts="Control+N" - onClick={onNewChat} + onClick={() => onNewChat()} > {t('chat.rail.newChat')} @@ -180,12 +193,61 @@ export function ChatSessionRail({

{t('chat.rail.noMatch')}

) : ( - groups.map((group) => ( -
-
- {group.label} + groups.map((group) => { + const expanded = searching || !collapsedKeys.has(group.key); + return ( +
+
+ + + + {group.cwd ? ( + + + + ) : null}
- {group.items.map((c) => { + {expanded ? group.items.map((c) => { const selected = activeId === c.id; const sending = sendingConversationIds.includes(c.id); return ( @@ -255,9 +317,10 @@ export function ChatSessionRail({
); - })} + }) : null}
- )) + ); + }) )}
!next && onCancelDelete()}> diff --git a/src/pages/chat/ChatSettingsDialog.tsx b/src/pages/chat/ChatSettingsDialog.tsx index 5cab4912..2612938e 100644 --- a/src/pages/chat/ChatSettingsDialog.tsx +++ b/src/pages/chat/ChatSettingsDialog.tsx @@ -27,7 +27,6 @@ import { import { agentNewChatConnectKind, chatConnectLabelKey, - sessionChatConnectHintKey, sessionChatConnectKind, } from './chat-connect-model'; import { sessionAllowAlwaysActive } from './chat-runtime-model'; @@ -114,14 +113,14 @@ export function ChatSettingsDialog({ <> - + {t('chat.settings.title')} - {t('chat.settings.description')} + {t('chat.settings.description')} {active && ( -
+
-
-
-

{t('chat.connect.sessionTitle')}

-

{t(chatConnectLabelKey(connectKind))}

-

{t(sessionChatConnectHintKey(connectKind))}

- {agentConnectKind !== connectKind ? ( -

- {t('chat.connect.agentTitle')} - {' · '} - {t(chatConnectLabelKey(agentConnectKind))} -

- ) : null} +
+ {t('chat.connect.sessionTitle')} +

+ {t(chatConnectLabelKey(connectKind))} + {agentConnectKind !== connectKind ? ( + + {t('chat.connect.agentTitle')} + {' · '} + {t(chatConnectLabelKey(agentConnectKind))} + + ) : null} +

{connectKind !== 'legacy' ? ( ) : null} {kiroPermissions ? ( -
- {t('chat.kiro.permissionTitle')} +
+ {t('chat.kiro.permissionTitle')} {permissionLocked ? (

{t('chat.kiro.settingsLocked')}

) : null} -
) : (