diff --git a/AGENTS.md b/AGENTS.md index a4b552b..74caa68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -353,6 +353,7 @@ ctx.effect(() => { | `editor` | 10 | 否(按 path 去重) | 否 | 唯一的「文件窗口」(文件编辑/预览 + 文件资源管理):文件 tab(有 path)在两种 `editorExplorer` 模式下 chrome 恒为合并形态——头部路径输入框 + 文本编辑器预览/编辑/保存控件 + 可开关的内嵌文件树面板(含全局文件名搜索,走 host `fs.search` 路由;左缘拖拽调宽),状态存 `tab.meta.treeOpen` / `tab.meta.treeWidth`;pref 控制**打开行为与无路径窗口形态**:关(默认,独立)= 走 `openSidebarFile` 按 path 新开,**无路径窗口即独立资源管理器——只渲染文件树面板**(搜索 + FileTree 撑满全窗,无编辑器 chrome);开(合并)= 树点击/输入框 Enter 原地切换当前 tab(`updateTab` 重写 path/title,id 与 meta 不变),无路径窗口 = 带 chrome 的空文件窗口(树默认展开)。树右键菜单提供「在新 Tab 中打开」「在侧边打开」(后者在当前 pane 右侧 split 出新 editor tab)。新会话在两种模式下都默认 seed 空文件窗口(`title: 'Files'`,无 path,树面板展开);持久化的旧 `explorer` tab 经 `sanitizeState` 迁移为该 home tab | | `git` | 20 | 是 | 否 | Git 面板 | | `subagent` | 30 | 是 | 否 | 子代理拓扑 | +| `sidechat` | 35 | 否(createTab 铸造 `sidechat:`/按 `meta.threadId` 去重) | 否 | 侧边对话(Codex 风格,**每个对话一个独立 Tab**):打开 Tab 即自动创建空线程(composer 拥有首条消息,host 侧包裹边界提示 + 创建时停泊的进行中快照,首条消息赢得真实标签并同步 Tab 标题);线程 = 插件自建子会话(自定义种子继承父会话完整上下文,进行中回合以 `interrupted` 冻结诚实闭合;种子尾部带合法 `subagent/descriptor`——否则 cold 线程在宿主 subagents.list 里是 corrupt 诊断行——SubagentView/subagent-detect 按 `Side: ` 前缀过滤保持拓扑零噪音),`origin:'subagent'` 隐藏于主列表;生命周期走自有 `/sidebar/api/sidechat.*` 路由(`ctx.agents.create/resume` + `agent.followup/cancel` + `sidechat.info` 读 Agent 身份/状态);头部菜单可切换/重开既有线程(`parkSidechatReopen` + `sidechat:` 确定性 id),关闭 Tab 释放 live agent(历史保留);重开 Tab 经 `collectOwnEvents` 大页回源到种子边界(cold 读会展开 chunk 压缩行,小窗口会丢早期 tool/call 行);「保存为新会话」= `session.fork` 提升顶层会话(必须方法调用形态,`this` 敏感)。UI 对齐主对话区(用户气泡 `--dsw-specific-bubble`、assistant 通栏 markdown、胶囊 composer + 圆形发送/停止钮、运行扫光状态行)。见 [设计文档](docs/plans/2026-08-20-sidechat-tab-design.md) | | `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增) | | `browser` | 50 | 否(createTab 铸造 browser:``,nextBrowser 自增) | 否 | 内嵌网页浏览器(沙箱 iframe;可设置关闭沙箱) | | `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) | diff --git a/README.md b/README.md index eb0cdab..f703daa 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ - **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具 - **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原 - **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止) +- **💬 侧边对话**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话 - **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉 - **🔁 会话隔离**:布局 / Tab / 面板按会话持久化,陈旧状态自动净化 - **⚙️ 声明式设置**:设置页「侧边卡片」逐项独立开关,二级设置经齿轮弹窗 @@ -42,6 +43,12 @@ 添加插件截图 +### v0.14.1(开发中) + +**✨ 新功能** + +- 💬 **侧边对话 Tab**:Codex 风格的侧边线程——子会话继承主会话**完整上下文**(已完成回合 + 未回答消息 + 进行中回合的 assistant 输出与工具调用,以「interrupted」冻结标记诚实继承);同组合创建(同 preset / provider / model)复用前缀输入缓存;线程对主会话列表不可见、零子代理目录噪音;线程内可持续追问(重启后自动恢复);一键「保存为新会话」提升为顶层会话([设计文档](docs/plans/2026-08-20-sidechat-tab-design.md)) + ### v0.14.1 **✨ 新功能** diff --git a/README_EN.md b/README_EN.md index 2b2e3ee..f95172b 100644 --- a/README_EN.md +++ b/README_EN.md @@ -26,6 +26,7 @@ - **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model - **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage / commit / revert - **🧩 Background Tasks**: agent topology + background tasks (exit codes / live output / force-kill) +- **💬 Side Chat**: Codex-style side threads — the child inherits the parent's FULL context (completed turns + the pending question + the in-progress turn's assistant output and tool activity, honestly frozen as "interrupted") and runs independently without entering the main conversation; threads support continuous follow-ups (auto-resumed after a DSH restart) and one-click "Save as new session" promotion to a top-level session - **🪟 Dual Workbench**: right sidebar + bottom panel; drag tabs to split / merge panes (cross-panel), mobile auto-merges into a full-width drawer - **🔁 Session Isolation**: layout / tabs / panels persisted per session, stale state auto-purged - **⚙️ Declarative Settings**: per-item toggles in the "Side Cards" settings section, secondary settings via the gear dialog @@ -41,6 +42,12 @@ Add Plugins screenshot +### v0.14.1 (in development) + +**✨ New features** + +- 💬 **Side Chat tab**: Codex-style side threads — the child inherits the parent's FULL context (completed turns + pending message + the in-progress turn's assistant output and tool activity, honestly frozen with an "interrupted" marker); created with an identical composition (same preset / provider / model) so the first request reuses the parent's input prefix cache; threads stay invisible in the main session list with zero subagent-catalog noise; follow-ups survive DSH restarts (auto cold-resume); one-click "Save as new session" promotes the thread to a top-level session ([design](docs/plans/2026-08-20-sidechat-tab-design.md)) + ### v0.14.1 **✨ New features** diff --git a/docs/plans/2026-08-20-sidechat-tab-design.md b/docs/plans/2026-08-20-sidechat-tab-design.md new file mode 100644 index 0000000..47b54b2 --- /dev/null +++ b/docs/plans/2026-08-20-sidechat-tab-design.md @@ -0,0 +1,97 @@ +# 侧边对话(Side Chat)Tab 设计 + +**日期**:2026-08-20 +**状态**:已批准,实施中 +**作者**:opencode + 用户 +**当前版本**:v0.14.1(分支 `feat/sidechat-tab`) +**目标版本**:v0.14.1(不 bump) + +## 1. 目标 + +1. 对标 Codex 的 side 对话,为 better-sidebar 新增内置 Tab **sidechat**:在当前主会话下开一个**继承主会话完整上下文**的独立子会话线程,线程在 Tab 内可续聊、可查看完整时间线(user / assistant / reasoning / tool)。 +2. **继承必须是结构化的**:线程种子 = 父会话点击时刻的全部事件——已完成回合 + 未回答的 user 消息 + **进行中回合的 assistant 流式输出与工具调用**(原样复制,合成 `step/end`+`turn/end{reason:'interrupted'}` 诚实闭合,子会话看到的是「被切断的进行中回合」而非完整定稿)。 +3. **前缀缓存复用**:子会话与父会话同组合(同 preset / 同 provider/model、无 persona/toolFilter、非 continuable 无 report 增量)→ 子会话首个请求的 token 前缀与父会话当前请求一致(到未回答 user 消息为止),命中 provider 前缀缓存。 +4. **即用即销语义 + 保存为新会话**:线程以 `origin:'subagent'` 创建(主会话列表不可见、子代理目录零噪音);用户可一键「保存为新会话」——`session.fork` 提升为顶层会话(自动命名、挂工作区、打开)。 +5. 零 dsh-subagent 依赖;dsh-subagent 缺失时功能不受影响。 + +## 2. 非目标(Out of Scope) + +- 不做 `/btw` 一次性侧问(只做持续可续聊线程)。 +- 不做合并回主会话 / 重命名线程。 +- **不改 DSH 源码**:不加 promote RPC、不改 fork 边界、不改 continuation 机制。 +- 工具执行中途(悬挂 tool/call)的事件级继承不可行(provider 拒绝悬挂 assistant 调用)→ 该窗口回退为「截断 + 结构化文本转储」(见 §4)。 + +## 3. 为什么自建机制(重实现) + +用户明确拍板:不走 `ctx.subagents.startContinuable`。原因(已核实源码): + +1. **种子粒度受限**:fork provider 的 `completedTurnPrefix` 固定切在最后一个 `turn/end`(`subagent-fork-in-process/src/index.ts`),`session.fork` RPC 显式拒绝 open-turn 锚点(`core/session` `OPEN_TURN`)——进行中回合永远进不了种子。 +2. **continuable 组合破坏前缀复用**:continuable 子会话的请求头部在继承历史之前插入 `report` 工具 + `tool:report` 提示段(`.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md`,issue #2124)——前缀从请求头即分叉,重发整个转录却零 cache 收益。DSH 官方因此把 fork 绑定为 one-shot。 +3. **无法注入自定义种子**:`ContinuableStartSpec` 无 seed 字段,创建路径无注入点。 + +自建机制只复用 DSH 公开 host 能力:`ctx.agents.create/resume`(AgentRegistry 公开方法,api-proxy fork 与 subagent driver 同用)、`agent.followup/cancel`(公开方法)、`ctx.get('agentPresets')`、`ctx.get('sessionTitle')`、`ctx.get('sessionPersistence')`。客户端复用 `ctx.sessions.list / fork / binding / open` 与通用 `session.history` RPC。 + +## 4. 种子算法(`src/sidechat-core.ts`) + +1. 复制父会话**全部事件**(点击时刻快照;live 日志 seq === 数组下标,保持连续)。 +2. 日志结束于 turn 外(最后 turn 边界是 turn/end 或无 turn):种子 = 全部复制事件(可能以未回答 user 消息结尾,合法)。 +3. 结束于 open turn 内: + - **当前 step 存在无配对 tool/call(悬挂)** → 回退:切到该 turn 的 `turn/start` 之前(保留已完成回合 + 未回答 user 消息);进行中内容(含执行中的工具调用,标注 executing)走 `buildOpenTurnSnapshot` 结构化文本转储并入 boundary 消息。原因:validator 不拒悬挂调用(step/end 清空 pendingCalls),但 **provider 拒绝悬挂 assistant 调用**(`core/session/src/repair.ts:108`)。 + - 否则:追加合成 `step/end{turn,step}`(若 step 未闭合)+ `turn/end{turn, reason:{kind:'interrupted'}}`(编号取自已复制事件的 turn/start、step/start)→ 种子平衡合法(种子逐事件过同一不变式检查器,`core/session/src/index.ts:517-541`;`turn/end` schema 要求 reason,`interrupted` 是合法持久化变体,语义即「回合未完成被切断」——诚实标记,客户端对冻结局部渲染「已停止」)。 + +子会话首个请求(常见路径)= [已完成回合][未回答 user][冻结的进行中回合:chunk 文本 + 已完成工具调用/结果][boundary+question]。前缀复用不受影响:共享前缀到未回答 user 消息为止,冻结回合位于共享前缀之后。 + +## 5. 线程生命周期(host 路由,`/sidebar/api/sidechat.*`,既有信任 fence) + +- **start** `{sessionId, question}`:父 Agent 必须 live;`buildSidechatInheritance` 出种子 + 转储;`ctx.agents.create({meta:{cwd,parentSession,seedLength,origin:'subagent',delegationDepth:父+1,agentPreset}, seed, agentOptions:父 provider/model, setup:preset 组合})`;`agent.followup(createUserMessage({content:[boundary+转储?+question], source:{kind:'user'}}))`;`sessionTitle.rename(session,'Side: '+截断(question,48))` 钉死线程标签;返回 `{childId}`。 +- **prompt** `{childId, text}`:live → `followup`;absent(重启/关闭后)→ `ctx.agents.resume`(`resolvePresetId` 从 persisted header/events 解析 preset,`agentPresets.resolve/mount` 组合)→ `followup`。 +- **cancel** `{childId}`:`agent.cancel({kind:'user'},{keepInbox:true})`。 +- **dispose** `{childId}`:释放创建/恢复时保存的 AgentHandle disposer(会话与历史保留)。 + +为什么全走自有路由:`origin:'subagent'` 的会话被通用 RPC 的 agent-lookup 所有权 fence 全面封锁(`api/remotes/agent-lookup.ts` `hasApiRemoteSubagentOwner`),`subagents.prompt` 对我们的线程也无法寻址——自有路由直接调 registry/agent 绕开 fence,且只依赖公开方法。~~线程没有 subagent/descriptor(无目录注册、零噪音:`list-children.ts` 投影为 undefined 即丢弃)~~ **修正(v3 起)**:该判断只对 live 线程成立——cold 线程无 descriptor 时 `resolveColdIdentity` 确定性产出 `corrupt` 诊断行(`list-children.ts:405`),用户实测在 Subagent 页看到「目录损坏」。修复 = 线程种子尾部追加合法的 `subagent/descriptor`(`snapshotSubagentDescriptor`,version 2 / continuable / provider `'sidechat'` / label 即 `Side: ` 标题),线程成为目录里的健康行;SubagentView 与 `subagent-detect.ts` 按 `Side: ` 标签/标题前缀过滤,拓扑 UI 与自动打开触发保持零噪音(模型侧 `list_agents` 也会列出带标签的健康行,而非 corrupt 垃圾)。 + +## 6. 客户端 Tab(`src/client/SideChatView.tsx`) + +- 内置 descriptor:`id:'sidechat'`、`order:35`、`single:true`、图标 `IconNewChatOutline16`。 +- 两栏布局:左线程列表(`sideThreadRows`:`origin==='subagent' && parentId===当前 && displayTitle 前缀 'Side: '`;订阅 `ctx.sessions.list`);右详情(时间线 + 输入框 + 保存/停止/关闭按钮)。 +- 时间线:轮询 `connection.api.sessions.history`(通用 RPC,未 fence;`session/end-seed` 截断 + boundary 行丢弃 + chunk 流式累加 + 工具配对——`src/client/sidechat-transcript.ts` 纯映射);可见且运行中 ~2s,seq 去重缓存;`visible=false` 停轮询。 +- 保存为新会话:`ctx.sessions.fork({sessionId: threadId, increaseTitle:true})` → `binding(newId).session.rename(标题去前缀)` → `ctx.sessions.open(newId)`。无已完成回合 → 禁用 + 提示;末条追问未完成 → 提示不包含(fork 边界=最后 turn/end)。 +- 选中线程持久化:`tab.meta.threadId`(既有 per-session localStorage 通道)。 + +## 7. 边界情况与失败模式 + +| 场景 | 行为 | +|---|---| +| 父会话无 live agent | 409 `sidechat-error` 内联提示 | +| 父会话进行中(流式/思考/工具已返回) | 完整事件入种子 + interrupted 合成闭合 | +| 父会话工具执行中途点击新建 | 悬挂 tool/call → 截断 + 结构化转储(工具标注 executing) | +| seam 缺失(agentPresets/sessionTitle/sessionPersistence) | 降级:默认组合 / 自动标题 / 无 preset 组合的冷恢复 | +| DSH 重启后续聊 / 线程关闭后追问 | `ctx.agents.resume`(persisted preset 组合) | +| 线程无已完成回合 | 保存禁用 + 提示 | +| 线程末条追问未完成 | 保存前提示不包含 | +| 与 dsh-sidechain 并存 | 各自独立;双方线程都有 descriptor(目录健康行),SubagentView 按 `Side: ` 前缀过滤均不可见;双方共享 `Side: ` 标签约定,线程互见 | + +## 8. 测试与自验 + +- 单测:`sidechat-core.spec.ts`(种子全分支:turn 外 / 空 / 仅 pending user / open turn 无工具 / 成对工具 / 悬挂回退 / 多 step 闭合 / seq 连续性;转储保真;线程过滤;保存资格;preset 解析;boundaryDelivered)、`sidechat-seed-validation.spec.ts`(真实 `Session.create` 校验器接受种子产物)、`sidechat-transcript.spec.ts`(种子截断、boundary 丢弃、chunk 流式、工具配对、孤儿失败工具)、`sidechat-routes.spec.ts`(start 带问/空问立即创建、首条 prompt 包裹边界+快照并改名、prompt live+cold、cancel、dispose、info live/cold/未知)、`builtins.spec.ts` 7 tab + sidechat 每线程一 Tab 铸造/去重。 +- e2e 挂载冒烟:+ 菜单深扫含 Side Chat;**host 路由级自验**(真实 keyless dsh web 中 sidechat.start/prompt/cancel/dispose/info 全链路 + 空问题立即创建流程,种子会话为父)。 +- 真实环境手测(实施后勾选):新建线程 → 追问往返 → 流式渲染 → 工具行 → 主会话流式中新建(冻结继承)→ 工具执行中途新建(回退转储)→ 重启后续聊 → 保存为新会话 → 刷新持久。 + +## 9. 复用门记录 + +搜索面:DSH checkout `docs/` + `packages/`(关键词 side conversation / sidetrack / thread / promote)+ dsh-external hub catalog(`search-org.mjs`,323 repos)。命中:`dsh-external/dsh-sidechain`(Codex 风格侧会话,/side & /btw,临时 fork + 侧栏面板),但**无 better-sidebar Tab、无保存为新会话**;用户拍板独立实现(「我们做独立实现」),sidechain 保持不动。本文档与 PR 记录该结论。 + +## 10. 实施偏差记录 + +- 路由键命名:`buildSidechatApi` 返回对象的键必须是**完整 wire 方法名**(`'sidechat.start'` 等,/sidebar/api 分发器按 `api[method]` 查找),而非短名 `start/prompt/…`。首次实现用了短名,单测直接调对象方法未暴露,真实挂载冒烟以 404 抓出(该冒烟因此新增了路由级自验,见 §8)。 +- 种子函数入参类型:sidechat-core 使用宽松结构类型 `SidechatLogEvent {type, seq, time, data: unknown}`(同时接受 host 真实 `SessionEvent` 与 client 的 `SidebarSessionEvent` 镜像),避免两半的类型图互相污染。 +- keyless 挂载冒烟无法验证 boundary 消息落盘:`agent.followup` 的消息先入 inbox,被 turn claim 后才写入 session 日志——keyless(无模型路由)下 turn 不会 claim。挂载冒烟的深度断言因此改为 **session.list 成员校验**(证明子会话真实创建,provider 无关);boundary 落盘与流式/回答在真实 provider 环境手测(§8 清单)。 +- **UI 两轮返工(用户实测驱动)**: + 1. 种子事件信封字段丢失(用户实测报 `invalid seed event … requires a surfaceOp marker`):`copyEvents` 重建事件时只保留 `{type,seq,time,data}`,剥掉了 `surfaceOp`/`sourceEventSeqs`/`ignorable`,而种子校验器要求 surface-eligible 事件必带 `surfaceOp`。修复 = 信封字段原样透传 + 新增 `sidechat-seed-validation.spec.ts`(用真实 `@deepseek-ai/dsh-session` 的 `Session.create` 校验器跑种子产物,回归守护)。 + 2. 交互模型从「单 Tab + 内嵌线程列表 + 首问表单」改为 **Codex app 对齐的「每线程一 Tab」**:`createTab` 铸造 `sidechat:new-`(meta `autoCreate`,视图挂载即调 `sidechat.start` 空问题立即建线程)或消费 `parkSidechatReopen` 停泊的 `sidechat:` 重连 Tab;`dedupeKey = meta.threadId` 保证同线程聚焦不重复;`onClose` 释放 live agent(历史保留,头部菜单可重开)。配套后端变化:`sidechat.start` 的 `question` 变为可选(空 = 仅建线程,快照停泊在 `pendingSnapshots`);`sidechat.prompt` 检测边界未送达时(`boundaryDelivered` 扫日志)自动包裹边界 + 停泊快照并用首条消息改名;新增 `sidechat.info` 路由(live 状态 + provider/model/preset 身份,供头部 Agent 徽标)。UI 对齐主对话区:用户右对齐气泡(`--dsw-specific-bubble`)、assistant 通栏 markdown、胶囊 composer(`--dsw-specific-input-major` + 圆形 accent 发送/停止钮 + 自动增高 textarea)、运行扫光状态行与工具行(`StateDot` + shimmer),动效全部 ≤200ms 交叉淡化并受 `prefers-reduced-motion` 收敛。 +- (预留)实施过程中如有与本文档的其他偏差,在此记录。 +- **第三轮实测修复(4 bug)**: + 1. 「保存为新会话」报 `Cannot read properties of undefined (reading 'list')`:`handleSave` 把 `ctx.sessions.fork` 解构成自由函数再调用,丢失 `this`——client-runtime 的 `service.fork` 内部读 `this.list.getSnapshot()`(标题递增)。修复 = 保持方法调用形态(`ctx.sessions.fork({...})`)。 + 2. 图标语义:线程切换菜单改自绘 `IconHistoryOutline16`(逆时针时钟)、保存按钮改自绘 `IconSaveOutline16`(软盘),沿用 1.5px 描边规范(primitives 无现成 history/save 图标)。 + 3. 重开线程 Tab 丢工具行:根因**不在宿主**(`session.history` cold 路径原样返回 `tool/call`,见 `api-proxy.ts:749` 与 `chunk-rows.ts` 的无损 chunk 压缩)而在首挂回源窗口——旧实现 8 事件/页 × 32 页 = 256 事件上限,而 cold 读会把 chunk-rows 展开回每个流式 delta 一条事件,一轮回答动辄数百事件,较早的 `tool/call` 掉出窗口(settled 文本靠最终 `assistant/message` 幸存)。修复 = 抽出 `collectOwnEvents`(200 事件/页 × 40 页帽、日志穷尽/无标记时落定 `seedBoundary:0` 停止每轮重走),单测覆盖四分支。 + 4. Subagent 页「目录损坏」行:见 §5 修正——种子追加 descriptor + SubagentView/subagent-detect 按前缀过滤(`isSideThreadSummary` 同时修好 autoOpenSubagent 误触发与计数膨胀)。旧版创建的线程仍是 corrupt 诊断行,由 SubagentView 的标题前缀过滤兜底隐藏。 diff --git a/package.json b/package.json index a461367..fa238f7 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8", "@deepseek-ai/dsh-llm": "^0.1.0-rc.8", "@deepseek-ai/dsh-session": "^0.1.0-rc.8", + "@deepseek-ai/dsh-subagent": "^0.1.0-rc.8", "@deepseek-ai/dsh-settings": "^0.1.0-rc.8", "@deepseek-ai/dsh-tools": "^0.1.0-rc.8", "cordis": "^4.0.0-rc.8", @@ -150,6 +151,7 @@ "@deepseek-ai/dsh-llm": "0.1.0-rc.8", "@deepseek-ai/dsh-session": "0.1.0-rc.8", "@deepseek-ai/dsh-settings": "0.1.0-rc.8", + "@deepseek-ai/dsh-subagent": "0.1.0-rc.8", "@deepseek-ai/dsh-tools": "0.1.0-rc.8", "@playwright/test": "^1.62.1", "@types/node": "^24.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ab952b..d3e08d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,9 @@ importers: '@deepseek-ai/dsh-settings': specifier: 0.1.0-rc.8 version: 0.1.0-rc.8(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-brand@0.1.0-rc.8(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.8(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.8(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/schemastery@3.18.1) + '@deepseek-ai/dsh-subagent': + specifier: 0.1.0-rc.8 + version: 0.1.0-rc.8(52d3162df9a65923cc941f3a328408da) '@deepseek-ai/dsh-tools': specifier: 0.1.0-rc.8 version: 0.1.0-rc.8(ac9081d1e007de8a90a707560a8ae1c3) diff --git a/src/client/SideChatView.module.css b/src/client/SideChatView.module.css new file mode 100644 index 0000000..d3178f8 --- /dev/null +++ b/src/client/SideChatView.module.css @@ -0,0 +1,396 @@ +/* Side Chat tab: ONE side conversation per tab (Codex-style). Visual + * language mirrors the main conversation pane: right-aligned user bubbles + * on --dsw-specific-bubble, full-width assistant markdown, a capsule + * composer card on --dsw-specific-input-major with a circular accent + * send/stop button, and shimmer reserved for the running status line and + * executing tools. Motion is crossfade-only (≤200ms entrances, no + * springs); reduced-motion disables every loop. All values consume DSW + * tokens (fallbacks point at older alias tokens for rc-era hosts). */ +.sidechat { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* ── thread header (status dot · agent badge · threads menu · save) ─── */ +.sidechatDetailHeader { + flex: none; + display: flex; + align-items: center; + gap: 4px; + min-height: 36px; + padding: 4px 8px 4px 12px; + border-bottom: 1px solid var(--dsw-alias-hairline); +} + +.sidechatHeaderDot { + flex: none; +} + +.sidechatHeaderSpacer { + flex: 1; + min-width: 0; +} + +.sidechatAgentBadge { + flex: none; + max-width: 55%; + padding: 1px 8px; + border: 1px solid var(--dsw-alias-hairline); + border-radius: 999px; + font: var(--dsw-font-xxs-12); + color: var(--dsw-alias-label-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidechatIconBtn { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-secondary); + cursor: pointer; + transition: background-color 100ms ease-out, color 100ms ease-out; +} + +.sidechatIconBtn:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +.sidechatIconBtn:disabled { + opacity: 0.4; + cursor: default; +} + +/* ── hero (unbound tab: the one brand moment) ───────────────────────── */ +.sidechatHero { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 24px 20px; + color: var(--dsw-alias-label-tertiary); + text-align: center; + animation: sidechatFadeIn 200ms ease-out; +} + +.sidechatHeroTitle { + font: var(--dsw-font-s-14); + font-weight: 500; + color: var(--dsw-alias-label-primary); +} + +.sidechatHeroDesc { + max-width: 300px; + font: var(--dsw-font-xxs-12); + color: var(--dsw-alias-label-tertiary); + line-height: 1.6; +} + +.sidechatPrimaryBtn { + flex: none; + margin-top: 4px; + padding: 6px 14px; + border: none; + border-radius: 999px; + background: var(--dsw-alias-button-info-fill, var(--dsw-alias-accent)); + color: var(--dsw-alias-button-info-label, var(--dsw-alias-accent-ink, #fff)); + font: var(--dsw-font-s-13); + cursor: pointer; + transition: opacity 100ms ease-out; +} + +.sidechatPrimaryBtn:hover:not(:disabled) { + opacity: 0.88; +} + +.sidechatPrimaryBtn:disabled { + opacity: 0.4; + cursor: default; +} + +.sidechatHint { + flex: none; + padding: 4px 12px; + font: var(--dsw-font-xxs-12); + color: var(--dsw-alias-label-tertiary); +} + +.sidechatError { + flex: none; + padding: 4px 12px; + font: var(--dsw-font-xxs-12); + color: var(--dsw-alias-danger); +} + +/* ── transcript ─────────────────────────────────────────────────────── */ +.sidechatScroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +/* Rows crossfade in on append (keys are stable, so only NEW rows play). */ +.sidechatScroll > * { + animation: sidechatRowIn 180ms ease-out; +} + +/* User message: right-aligned bubble, same surface as the main pane. */ +.sidechatUser { + align-self: flex-end; + max-width: 88%; + padding: 8px 14px; + border-radius: 18px; + background: var(--dsw-specific-bubble, var(--dsw-alias-bg-base)); + font: var(--dsw-font-s-14); + color: var(--dsw-alias-label-primary); + overflow-wrap: anywhere; +} + +/* Assistant message: full-width narration, no bubble — the main pane way. */ +.sidechatAssistant { + align-self: stretch; + font: var(--dsw-font-s-14); + color: var(--dsw-alias-label-primary); + overflow-wrap: anywhere; +} + +.sidechatReasoning { + align-self: stretch; +} + +.sidechatReasoningSummary { + display: inline-block; + font: var(--dsw-font-xxs-12); + color: var(--dsw-alias-label-tertiary); + cursor: pointer; + list-style: none; +} + +.sidechatReasoningSummary::-webkit-details-marker { + display: none; +} + +.sidechatReasoningBody { + margin-top: 2px; + padding-left: 2px; + font: var(--dsw-font-xxs-12); + color: var(--dsw-alias-label-tertiary); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.sidechatTool { + align-self: stretch; + border: 1px solid var(--dsw-alias-hairline); + border-radius: 10px; + background: var(--dsw-alias-bg-base); + overflow: hidden; +} + +.sidechatToolSummary { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + font: var(--dsw-font-xxs-12); + color: var(--dsw-alias-label-secondary); + cursor: pointer; + list-style: none; +} + +.sidechatToolSummary::-webkit-details-marker { + display: none; +} + +.sidechatToolName { + font-family: var(--dsw-font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Running tool: a light sweep across the summary row (shimmer = generating). */ +.sidechatToolExecuting { + background-image: linear-gradient( + 90deg, + transparent 0%, + var(--dsw-alias-interactive-bg-hover) 50%, + transparent 100% + ); + background-size: 200% 100%; + animation: sidechatSweep 2.6s linear infinite; +} + +.sidechatToolFailed { + color: var(--dsw-alias-danger); +} + +.sidechatToolArgs, +.sidechatToolResult { + margin: 0; + padding: 6px 10px; + border-top: 1px solid var(--dsw-alias-hairline); + font: var(--dsw-font-xxs-12); + font-family: var(--dsw-font-mono); + color: var(--dsw-alias-label-secondary); + white-space: pre-wrap; + overflow-wrap: anywhere; + max-height: 220px; + overflow-y: auto; +} + +/* ── running status line (above the composer, main-pane style) ──────── */ +.sidechatStatus { + flex: none; + display: flex; + align-items: center; + gap: 8px; + padding: 2px 14px 6px; + animation: sidechatFadeIn 160ms ease-out; +} + +.sidechatStatusText { + font: var(--dsw-font-xxs-12); + background-image: linear-gradient( + 90deg, + var(--dsw-alias-label-tertiary) 0%, + var(--dsw-alias-label-primary) 50%, + var(--dsw-alias-label-tertiary) 100% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: sidechatSweep 2.6s linear infinite; +} + +/* ── composer (capsule card, InputBar-aligned) ──────────────────────── */ +.sidechatComposer { + flex: none; + display: flex; + flex-direction: column; + gap: 4px; + margin: 0 8px 8px; + padding: 8px 8px 6px 14px; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin, var(--dsw-alias-hairline)); + border-radius: 16px; + background: var(--dsw-specific-input-major, var(--dsw-alias-bg-base)); + box-shadow: var(--dsw-shadow-lv2, none); +} + +.sidechatComposerInput { + width: 100%; + box-sizing: border-box; + padding: 2px 0; + border: none; + background: transparent; + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-s-14); + line-height: 22px; + outline: none; + resize: none; + max-height: 132px; +} + +.sidechatComposerInput::placeholder { + color: var(--dsw-alias-label-tertiary); +} + +.sidechatComposerBar { + flex: none; + display: flex; + align-items: center; + gap: 8px; + min-height: 28px; +} + +.sidechatComposerMeta { + flex: 1; + min-width: 0; + font: var(--dsw-font-xxs-12); + color: var(--dsw-alias-label-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidechatSendBtn { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 50%; + background: var(--dsw-alias-button-info-fill, var(--dsw-alias-accent)); + color: var(--dsw-alias-button-info-label, var(--dsw-alias-accent-ink, #fff)); + cursor: pointer; + /* send ⇄ stop swap: keyed remount plays this, hover only fades. */ + animation: sidechatBtnIn 120ms ease-out; + transition: opacity 100ms ease-out; +} + +.sidechatSendBtn:hover:not(:disabled) { + opacity: 0.88; +} + +.sidechatSendBtn:disabled { + opacity: 0.35; + cursor: default; +} + +/* ── motion vocabulary (crossfade only, ≤200ms; loops reduced-motion off) */ +@keyframes sidechatRowIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes sidechatFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes sidechatBtnIn { + from { opacity: 0; transform: scale(0.85); } + to { opacity: 1; transform: scale(1); } +} + +@keyframes sidechatSweep { + from { background-position: 200% 0; } + to { background-position: -200% 0; } +} + +@media (prefers-reduced-motion: reduce) { + .sidechatScroll > *, + .sidechatHero, + .sidechatStatus, + .sidechatSendBtn { + animation: none; + } + .sidechatStatusText, + .sidechatToolExecuting { + animation: none; + background-image: none; + color: var(--dsw-alias-label-tertiary); + } +} diff --git a/src/client/SideChatView.tsx b/src/client/SideChatView.tsx new file mode 100644 index 0000000..446b366 --- /dev/null +++ b/src/client/SideChatView.tsx @@ -0,0 +1,568 @@ +/** + * Side Chat page: Codex-style side conversations for the current session. + * + * EVERY side conversation is its own sidebar tab (侧边对话1/2/3 …): the + * descriptor's createTab mints a fresh tab flagged `autoCreate` and this + * view creates the EMPTY thread on mount (one click = one conversation, + * exactly like the Codex app); the composer owns the first message (the + * host wraps it with the side boundary + the in-progress snapshot parked + * at creation, and the thread earns its real label — and the tab its + * title — from that first message). Closing the tab releases the thread's + * live agent (its history stays persisted); the header menu reopens any + * existing thread into a tab (deduped by threadId). + * + * Each side thread is a child session the plugin created itself with a + * custom seed (the parent's full log up to the click moment — see + * sidechat-core.ts). Transport: thread creation/follow-up/cancel/dispose/ + * info go through the plugin's own /sidebar/api sidechat.* routes + * (subagent-origin identities are fenced from the generic session RPCs); + * the transcript is polled from the generic session.history RPC (seed-cut + * at session/end-seed, boundary row dropped, chunk streaming accumulated) + * — see sidechat-transcript.ts. + */ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useSyncExternalStore } from 'react' +import clsx from 'clsx' +import { + IconNewChatOutline16, + IconPlusOutline16, + IconSendOutline16, + IconStopFill16, + MarkdownText, + Menu, + StateDot, + type MenuEntry, +} from '@deepseek-ai/dsh-client-ui-primitives' +import { IconHistoryOutline16, IconSaveOutline16 } from './icons.tsx' +import type { Context, SidebarHistoryEntry } from '../context-types.ts' +import { + SIDE_LABEL_PREFIX, + SIDE_NEW_THREAD_TITLE, + sideThreadRows, + threadHasCompletedTurn, + threadTrailingPending, + type SidechatThreadInfo, +} from '../sidechat-core.ts' +import { collectOwnEvents, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts' +import { api } from './api.ts' +import { t } from './locales.ts' +import type { SessionScope } from './api.ts' +import type { SidebarTab } from './state.ts' +import css from './SideChatView.module.css' + +/** Tail-page size for one transcript poll (events per page). Small on + * purpose: streaming polls ride the tail and merge by seq. */ +const PAGE_MESSAGES = 8 +/** First-attach walk page size: cold reads re-expand chunk-rows into one + * event per streamed delta, so a single answer can be hundreds of events — + * the walk must page big or earlier tool/call rows fall out of the window. */ +const WALK_PAGE_EVENTS = 200 +/** Poll cadence while the selected thread is running and the tab visible. */ +const POLL_MS = 2000 +/** Textarea auto-grow ceiling (px) — the composer scrolls beyond it. */ +const COMPOSER_MAX_HEIGHT = 132 + +/** The thread a tab is bound to (durable in tab.meta across refreshes). */ +export function sidechatThreadIdOf(tab: SidebarTab): string | undefined { + const meta = tab.meta as { threadId?: unknown } | undefined + return typeof meta?.threadId === 'string' ? meta.threadId : undefined +} + +/** The parked reopen target consumed by the descriptor's createTab (the + * service's createTab receives no seed, so a thread-switch parks the id + * here and openTab picks it up synchronously — exactly one consume per + * park). */ +let parkedReopen: string | undefined + +/** Park a thread id for the NEXT sidechat openTab to reattach. */ +export function parkSidechatReopen(threadId: string): void { + parkedReopen = threadId +} + +/** Consume the parked reopen target (undefined = mint a fresh thread tab). */ +export function consumeSidechatSeed(): string | undefined { + const value = parkedReopen + parkedReopen = undefined + return value +} + +/** In-flight thread creations keyed by tab id (double-mount guard: React + * StrictMode / HMR must not mint two threads for one tab). */ +const inFlightStarts = new Set() + +/** Per-thread transcript cache: seed boundary + thread-own events merged by + * seq (streaming polls never re-download the inherited seed). */ +interface ThreadCache { + seedBoundary: number | null + entries: SidebarHistoryEntry[] +} + +/** Row-render labels (locale-dependent, memoized once per mount). */ +interface RowLabels { + copyLabel: string + copiedLabel: string + thinkLabel: string +} + +/** Merge history entries by event seq (newest wins), log order preserved. */ +function mergeBySeq( + previous: readonly SidebarHistoryEntry[], + incoming: readonly SidebarHistoryEntry[], +): SidebarHistoryEntry[] { + const bySeq = new Map() + for (const entry of previous) bySeq.set(entry.event.seq, entry) + for (const entry of incoming) bySeq.set(entry.event.seq, entry) + return [...bySeq.values()].sort((a, b) => a.event.seq - b.event.seq) +} + +/** The display title of a thread: the durable label minus the 'Side: ' + * prefix, with the fresh-thread placeholder localized. */ +function threadDisplayTitle(title: string): string { + if (title === SIDE_NEW_THREAD_TITLE) return t('sideChatUntitled') + return title.startsWith(SIDE_LABEL_PREFIX) ? title.slice(SIDE_LABEL_PREFIX.length) : title +} + +/** One row renderer (React keys ride the source event seq). */ +function renderRow(row: SidechatTranscriptRow, labels: RowLabels): React.ReactNode { + switch (row.kind) { + case 'user': + return ( +
+ +
+ ) + case 'assistant': + return ( +
+ +
+ ) + case 'reasoning': + return ( +
+ {labels.thinkLabel} +
{row.text}
+
+ ) + case 'tool': + return ( +
+ + + {row.name} + + {row.args !== undefined &&
{row.args}
} + {row.resultText !== undefined &&
{row.resultText}
} +
+ ) + } +} + +/** One side conversation tab (one thread per tab, Codex-style). */ +export function SideChatView(props: { + ctx: Context + scope: SessionScope + tab: SidebarTab + visible: boolean +}): React.ReactNode { + const { ctx, scope, tab, visible } = props + const rowLabels = useMemo( + () => ({ copyLabel: t('copy'), copiedLabel: t('copied'), thinkLabel: t('sideChatThink') }), + [], + ) + + // The session list feed: thread rows (the header menu) + running states. + const list = useSyncExternalStore( + useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]), + useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]), + ) + const threads = useMemo( + () => sideThreadRows(list.byId, scope.sessionId), + [list, scope.sessionId], + ) + + // The thread this tab is bound to rides tab.meta (refresh-restored). + const threadId = sidechatThreadIdOf(tab) + const autoCreate = (tab.meta as { autoCreate?: unknown } | undefined)?.autoCreate === true + + const [composer, setComposer] = useState('') + const [busy, setBusy] = useState<'starting' | 'sending' | 'saving' | null>(null) + const [error, setError] = useState(null) + const [saved, setSaved] = useState(false) + const [revision, setRevision] = useState(0) + const [info, setInfo] = useState(null) + const [menuOpen, setMenuOpen] = useState(false) + + const cacheRef = useRef({ seedBoundary: null, entries: [] }) + const controllerRef = useRef(null) + const scrollRef = useRef(null) + const composerRef = useRef(null) + + const summary = threadId === undefined ? undefined : list.byId[threadId] + const running = summary?.running === true + + /** The agent-identity badge of the thread header (preset · model). */ + const agentBadge = useMemo(() => { + if (info === null) return '' + return [info.preset, info.model ?? info.provider].filter(Boolean).join(' · ') + }, [info]) + + /** Create this tab's thread (immediate-create tabs and hero retries). */ + const startThread = useCallback(async (): Promise => { + if (inFlightStarts.has(tab.id)) return + inFlightStarts.add(tab.id) + setBusy('starting') + setError(null) + try { + const { childId } = await api.sidechatStart(scope.sessionId) + ctx.betterSidebar?.updateTab(tab.id, { meta: { threadId: childId } }) + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)) + } finally { + inFlightStarts.delete(tab.id) + setBusy(null) + } + }, [ctx, scope.sessionId, tab.id]) + + // Codex-style immediate create: an autoCreate tab spawns its thread as + // soon as it first renders. + useEffect(() => { + if (threadId !== undefined || !autoCreate || !visible) return + void startThread() + }, [threadId, autoCreate, visible, startThread]) + + // The tab title follows the thread's durable label (the first prompt + // renames the thread; the strip picks it up here). + useEffect(() => { + const display = summary?.displayTitle + if (display === undefined) return + const title = threadDisplayTitle(display) + if (title !== '' && title !== tab.title) { + try { + ctx.betterSidebar?.updateTab(tab.id, { title }) + } catch { + // A stale title is cosmetic; the thread keeps working. + } + } + }, [summary, tab.id, tab.title, ctx]) + + /** One transcript pull: the first read walks back to the seed boundary + * (big pages — chunk deltas re-expand on cold reads), later reads fetch + * one tail page and merge (seq-deduped). */ + const fetchThread = useCallback(async (childId: string): Promise => { + controllerRef.current?.abort() + const controller = new AbortController() + controllerRef.current = controller + const cache = cacheRef.current + try { + if (cache.seedBoundary === null) { + const walk = await collectOwnEvents(async (beforeSeq) => { + const response = await ctx.connection.api.sessions.history( + { + sessionId: childId, + maxMessages: WALK_PAGE_EVENTS, + ...(beforeSeq === undefined ? {} : { beforeSeq }), + }, + controller.signal, + ) + if (!response.result.ok) throw new Error('history walk failed') + return response.result.value.events + }) + cache.seedBoundary = walk.seedBoundary + cache.entries = mergeBySeq(cache.entries, walk.entries) + } else { + const response = await ctx.connection.api.sessions.history( + { sessionId: childId, maxMessages: PAGE_MESSAGES }, + controller.signal, + ) + if (!response.result.ok) return + cache.entries = mergeBySeq(cache.entries, response.result.value.events) + } + setRevision(value => value + 1) + } catch { + // Aborted by a newer pull or a wire failure: keep the last rows. + } + }, [ctx]) + + /** The thread header badge pull (live state + preset/model identity). */ + const fetchInfo = useCallback(async (childId: string): Promise => { + try { + setInfo(await api.sidechatInfo(childId)) + } catch { + // The badge is decorative; a wire failure keeps the last value. + } + }, []) + + // Reset the transcript cache whenever the binding changes, then focus + // the composer — it owns the first message of a fresh thread. + useEffect(() => { + cacheRef.current = { seedBoundary: null, entries: [] } + controllerRef.current?.abort() + setError(null) + setSaved(false) + setInfo(null) + if (threadId !== undefined) { + void fetchInfo(threadId) + window.setTimeout(() => composerRef.current?.focus(), 0) + } + }, [threadId, fetchInfo]) + + // Poll while the tab is visible and the thread runs. + useEffect(() => { + if (!visible || threadId === undefined) return + void fetchThread(threadId) + if (!running) return + const timer = window.setInterval(() => { + void fetchThread(threadId) + void fetchInfo(threadId) + }, POLL_MS) + return () => { window.clearInterval(timer) } + }, [visible, threadId, running, fetchThread, fetchInfo]) + + useEffect(() => () => { controllerRef.current?.abort() }, []) + + const rows = useMemo( + () => (threadId === undefined ? [] : transcriptRows(cacheRef.current.entries)), + // The cache is a ref; revision bumps on every successful pull. + // eslint-disable-next-line react-hooks/exhaustive-deps + [threadId, revision], + ) + const canSave = threadId !== undefined && threadHasCompletedTurn(cacheRef.current.entries) + const trailingPending = threadId !== undefined && threadTrailingPending(cacheRef.current.entries) + const freshThread = threadId !== undefined && rows.length === 0 + + // Follow the stream: stick to the bottom while the log grows. + useEffect(() => { + const scroller = scrollRef.current + if (scroller === null) return + scroller.scrollTop = scroller.scrollHeight + }, [rows.length, threadId]) + + /** Open a NEW thread tab (createTab mints the autoCreate tab; its view + * creates the thread on mount). */ + const openNewThread = (): void => { + setMenuOpen(false) + ctx.betterSidebar?.openTab({ type: 'sidechat' }, scope) + } + + /** Switch to an existing thread: parked for createTab, deduped to the + * already-open tab when there is one. */ + const openExistingThread = (id: string): void => { + setMenuOpen(false) + if (id === threadId) return + parkSidechatReopen(id) + ctx.betterSidebar?.openTab({ type: 'sidechat' }, scope) + } + + const menuItems = useMemo(() => { + const items: MenuEntry[] = [ + { id: '$new', label: t('sideChatNew'), icon: }, + ] + if (threads.length > 0) { + items.push({ type: 'separator', id: '$sep' }) + for (const row of threads) { + items.push({ + id: row.id, + label: threadDisplayTitle(row.title), + ...(row.running ? { icon: } : {}), + }) + } + } + return items + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [threads]) + + const growComposer = (): void => { + const field = composerRef.current + if (field === null) return + field.style.height = '0px' + field.style.height = `${Math.min(field.scrollHeight, COMPOSER_MAX_HEIGHT)}px` + } + + const handleSend = async (): Promise => { + const text = composer.trim() + if (text === '' || threadId === undefined || busy !== null) return + setBusy('sending') + setError(null) + try { + await api.sidechatPrompt(threadId, text) + setComposer('') + const field = composerRef.current + if (field !== null) field.style.height = '' + void fetchThread(threadId) + void fetchInfo(threadId) + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)) + } finally { + setBusy(null) + } + } + + const handleCancel = async (): Promise => { + if (threadId === undefined || busy !== null) return + try { + await api.sidechatCancel(threadId) + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)) + } + } + + const handleSave = async (): Promise => { + if (threadId === undefined || !canSave || busy !== null) return + setBusy('saving') + setError(null) + setSaved(false) + try { + // NOTE: fork must stay a METHOD call — `ctx.sessions.fork` is the + // client-runtime sessions service, and an unbound reference loses + // `this` (its fork reads this.list for the title bump). + if (ctx.sessions.fork === undefined) throw new Error('session fork is unavailable') + const newId = await ctx.sessions.fork({ sessionId: threadId, increaseTitle: true }) + const title = summary === undefined ? '' : threadDisplayTitle(summary.displayTitle).trim() + const binding = ctx.sessions.binding?.(newId) + if (binding !== undefined && title !== '') { + await binding.session.rename(title) + } + ctx.sessions.open?.(newId) + setSaved(true) + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)) + } finally { + setBusy(null) + } + } + + // ── unbound tab: the hero (fresh autoCreate tabs flash a creating state + // until the thread lands; legacy persisted tabs offer a manual start) ── + if (threadId === undefined) { + return ( +
+
+ +
+ {busy === 'starting' ? t('sideChatCreating') : t('sideChatEmpty')} +
+
{t('sideChatEmptyDesc')}
+ {error !== null &&
{t('sideChatError', { message: error })}
} + {busy !== 'starting' && ( + + )} +
+
+ ) + } + + return ( +
+
+ {running && } + {agentBadge !== '' && {agentBadge}} + + { setMenuOpen(value => !value) }} + title={t('sideChatThreads')} + > + + + )} + items={menuItems} + selectedId={threadId} + onSelect={(id) => { id === '$new' ? openNewThread() : openExistingThread(id) }} + onClose={() => { setMenuOpen(false) }} + align="end" + portal + dense + /> + +
+ {!canSave && !freshThread &&
{t('sideChatNoTurn')}
} + {canSave && trailingPending + &&
{t('sideChatPendingDrop')}
} + {saved &&
{t('sideChatSaved')}
} + {error !== null &&
{t('sideChatError', { message: error })}
} +
+ {rows.map(row => renderRow(row, rowLabels))} +
+ {running && ( +
+ + {t('sideChatThinking')} +
+ )} +
+