diff --git a/AGENTS.md b/AGENTS.md index 6554d0f..1163a21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字 - 模型由 `pkg/agent` 在 `Stream` / `CompactIfNeeded` 内按 `ModelConfig` 创建:`provider=fake` 走脚本化假模型(测试用),`provider=openai` 走 OpenAI 兼容 HTTP。不由 Runtime 注入模型实例。 - Handler 直接使用 `*sqlite.Queries` 做 CRUD、SSE 回放、Run 的 Start / Continue / Cancel 和审批裁决;领取后的 Loop 才进入 `internal/agent`。 - `internal/agent` 负责把 pkg 的计算结果持久化为 Run、Turn、消息、用量和事件。`AgentEvent` 必须先同事务写入并递增 `sessions.last_event_seq`,提交后再 `events.Bus.Publish`。 -- 工具定义都在 `internal/agent/tools`(`ping`、记忆、编码八工具、`plan_*`)。外部模块只实现 `Ports` 上的接口,由 `cmd/server` 在初始化时注入。Agent 绑定 `Profile.Tools.Names`;运行模式提供 `read` / `write` / `memory`,须覆盖工具全部能力才可调用。记忆工具声明 `memory`。写类编码工具声明 `write` 并默认要审批。目录外路径在 Inspect / jail 失败,不能当成功执行。`plan_*` 只碰 `.cursor/*.md`。审批由工具声明,模式决定是否暂停。一次模型回复的待批 Tool 合成一条审批,一次提交审完再流转;拒绝或单个工具失败不打死 Run。Git 用户操作仍走 HTTP + `pkg/git`。 +- 工具定义都在 `internal/agent/tools`(`ping`、记忆、编码八工具、`plan_*`)。外部模块只实现 `Ports` 上的接口,由 `cmd/server` 在初始化时注入。三个内置 Agent(ask / plan / agent)各自一份 `Profile`:底座 system 与发给模型的工具表共用全量注册表,`mode` 冻进 Run 的是可执行 `Names` 与一条 developer 模式规则(发给网关时紧跟底座 system)。工作区在创建 Session 时冻结到 `workspace_id`(用户指定的已存在目录;未指定则 `GIT_REPO` / cwd。指定了但不存在则创建失败);本会话权限只覆盖该目录,目录外的文件操作必须审批(Agent 表和 yolo 都不能直接放行)。审批是流水线:工具默认+参数校验 → 本 Agent `Names`(未绑定 deny,yolo 不能抬)→ Agent `Effects` 表 → `approval`(manual / auto / yolo);每层只审上一层的 `ask`,任一层 `deny` 则不可调用。`auto` 先由独立复审模型裁定,通过则直接执行并再发工具事件;失败或说不清才开单给人。一次模型回复的待批 Tool 合成一条审批;拒绝或单个工具失败不打死 Run。Git 用户操作仍走 HTTP + `pkg/git`,不经过 Agent Tool。 - `internal/agent/memory` 负责 TextMemory 的 Get / Upsert / Delete / List、`SearchMessages` 和 `IndexMessage`;用户侧只看/删目录与专题。不负责 Prompt / Context Packet / 对话压缩,不自动建专题,不定义 Tool。Loop 在新 Session / 对话压缩后装冻结目录;写 message 时 `IndexMessage`。超限目录由 Runtime 后台 `CompactIndex` 改短盖写,不改当前 Session 冻结前缀。 - 不要使用 Store 接口包装 sqlc。 - Agent 契约不得依赖 React、UI 包或路由框架。 diff --git a/docs/architecture.md b/docs/architecture.md index 7b32017..0f2e95b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ 本文档定义 CodeDock 当前的技术骨架。目录按能力拆分;Issue、Task、Review、Workspace 等业务目录不属于本项目的基础结构。 -Agent Loop 已闭环:Handler 写用户消息与 Run,Worker 领取后由 Runtime 装上下文、调模型、执行 Tool,事件先落库再经 Bus 由 SSE 消费。默认注册 `ping`、记忆工具、编码八工具与 `plan_*`。 +Agent Loop 已闭环:Handler 写用户消息与 Run,Worker 领取后由 Runtime 装上下文、调模型、执行 Tool,事件先落库再经 Bus 由 SSE 消费。三个内置 Agent(ask / plan / agent)共用同一 Loop;审批走工具 → Agent 表 → 审批模式流水线。 ## 总体架构 @@ -61,7 +61,7 @@ CodeDock/ │ │ ├── handler/ # 大部分 HTTP:CRUD、SSE、Start / Continue / Cancel、记忆查看/删除、Git │ │ ├── agent/ # 运行时编排 + sqlc 持久化 │ │ │ ├── memory/ # 热层目录+专题,冷层工作区 FTS 索引 -│ │ │ └── tools/ # 具体工具定义:ping、memory_* +│ │ │ └── tools/ # 具体工具定义:ping、memory_*、编码八工具、plan_* │ │ ├── events/ # 进程内事件总线 │ │ ├── config/ │ │ ├── logger/ @@ -137,7 +137,7 @@ packages/views -> packages/ui 不 import next/* 按业务域拆目录,与 core 对齐(现有 chat) - AgentProvider 在包根注入 client + userId,以及可选的本机目录列举;导航用回调 + AgentProvider 在包根注入 client + userId;导航用回调 apps/web -> packages/views @@ -189,11 +189,11 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi 工具定义全部在本包。Runtime `New` 接收 `Ports`(Execute 要调用的外部实现),再 `Register`: -- 本包写工具名、入参/出参、schema、权限和编排。`ping`,`memory_read` / `memory_write` / `memory_search`,编码八工具(`read` / `write` / `edit` / `ls` / `grep` / `find` / `bash` / `powershell`),以及 `plan_list` / `plan_read` / `plan_write` -- Execute 若依赖外部能力,只通过 `Ports` 上的接口调用;由 `cmd/server` 在初始化时注入具体实现。`Ports.WorkspaceRoot` 只是进程回落,会话级根走 `tool.Input.WorkspaceRoot` -- 编码工具经 jail 限制在会话工作目录;目录外路径 Inspect 失败。`plan_*` 只读写 `.cursor/*.md` +- 本包写工具名、入参/出参、schema、默认 Effect 和编排。`ping`、记忆三件、编码八工具(`read`/`write`/`edit`/`ls`/`grep`/`find`/`bash`/`powershell`)、`plan_list`/`plan_read`/`plan_write` +- Execute 与第 1 层路径/计划名校验只通过 `Ports`(可替换 FS/RunCommand)和会话已冻结的工作目录。本包不负责解析或冻结 `workspace_id`,只消费 Handler 写入的路径(或 `Ports.WorkspaceRoot` 进程回落)。目录内走工具默认 Effect;目录外必须审批,Agent 表和 yolo 都不能抬成 allow。批过后才能在目录外执行 +- Git 用户操作仍走 HTTP + `pkg/git`,不在本包实现 Git Tool -每个工具只定义入参/出参结构体;执行用 `encoding/json`,给模型的 schema 由 `jsonschema.For` 从类型推断。Agent 通过 `Profile.Tools.Names` 绑定工具。运行模式提供 `read` / `write` / `memory` 能力,只有模式覆盖了工具声明的全部能力时该工具才对模型可见且可 Dispatch。记忆工具声明 `memory`。写类编码工具声明 `write`。审批仍由工具声明 `RequiresApproval` 或 `Effect=ask`,`ask_for_approval` 暂停、`auto_approve` / `yolo` 自动过。一批待批工具对应一条审批,一次提交审完再流转。不 import 父包 `internal/agent`。测试用 Tool 可留在测试文件。 +每个工具只定义入参/出参结构体;执行用 `encoding/json`,给模型的 schema 由 `jsonschema.For` 从类型推断。发给模型的是注册表全量工具;`Profile.Tools.Names` 是可执行绑定。模式规则由 `Build` 注入一条 developer 消息;发给网关时紧跟底座 system,不改底座正文。审批流水线:工具默认+参数校验 → 本 Agent `Names`(未绑定 deny,yolo / 已批准都不能抬)→ Agent `Effects` → `approval`(manual/auto/yolo);每层只审上一层的 `ask`。一批待批工具对应一条审批,一次提交审完再流转。不 import 父包 `internal/agent`。测试用 Tool 可留在测试文件。 ### `pkg/git` @@ -236,11 +236,11 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi ### `packages/views` -组合 core + ui。按业务域拆,与 core 对齐,不要 `src/`。现有 `chat/`:`ChatPage`、侧栏、瀑布、审批、prompt、新对话目录选择。包根 `provider.tsx` 注入 `AgentClient` + `userId`,以及可选的本机目录列举。`ChatPage` 接 `sessionId` 与 `onOpenSession`。Git 在 `git/`:`GitProvider` 只注入 `GitClient`,请求带当前会话 `session_id`,不进 `AgentContext`。不 import `next/*`。新业务新建目录,不预建 Issue / Task / Review / Workspace。 +组合 core + ui。按业务域拆,与 core 对齐,不要 `src/`。现有 `chat/`:`ChatPage`、侧栏、瀑布、审批、prompt。包根 `provider.tsx` 注入 `AgentClient` + `userId`。`ChatPage` 接 `sessionId` 与 `onOpenSession`。Git 在 `git/`:`GitProvider` 只注入 `GitClient`,不进 `AgentContext`。不 import `next/*`。新业务新建目录,不预建 Issue / Task / Review / Workspace。 ### `apps/web` -路由、`NEXT_PUBLIC_API_BASE` / `NEXT_PUBLIC_USER_ID`、创建 `AgentClient`、包 `AgentProvider`、`router.push`。本机 Web 直连 `:8080`(仅回环 Origin 的 CORS)。新对话选目录走 web 的本机目录接口。Git 页在 `(chat)` 组外的 `/git`,只装配 `GitClient`,并带当前会话 `session_id`。开发态顶栏(对话 / 仓库)只放 web,views 不知道路径。 +路由、`NEXT_PUBLIC_API_BASE` / `NEXT_PUBLIC_USER_ID`、创建 `AgentClient`、包 `AgentProvider`、`router.push`。本机 Web 直连 `:8080`(仅回环 Origin 的 CORS)。Git 页在 `(chat)` 组外的 `/git`,只装配 `GitClient`。开发态顶栏(对话 / 仓库)只放 web,views 不知道路径。 ## 组装关系 @@ -257,7 +257,7 @@ Worker -> pkg.Build -> pkg.Stream # 按 ModelConfig 在 pkg 内创建 fake 或 openai -> Transition:先落库再 Bus - -> pkg.Dispatch # ping、memory_*、编码八工具、plan_* 及测试用 Tool + -> pkg.Dispatch # 经流水线后执行已绑定工具 ``` ## 配置 diff --git a/packages/core/chat/client.ts b/packages/core/chat/client.ts index ef74f1e..e467754 100644 --- a/packages/core/chat/client.ts +++ b/packages/core/chat/client.ts @@ -1,6 +1,5 @@ import type { AgentEvent, - AgentMode, Approval, CreateSessionRequest, DecideApprovalRequest, @@ -120,7 +119,8 @@ export class AgentClient { method: "POST", json: { content: req.content, - mode: req.mode ?? ("ask_for_approval" satisfies AgentMode), + mode: req.mode ?? "agent", + approval: req.approval ?? "manual", }, }); } diff --git a/packages/core/chat/index.ts b/packages/core/chat/index.ts index 5a4ca69..2b8f256 100644 --- a/packages/core/chat/index.ts +++ b/packages/core/chat/index.ts @@ -15,6 +15,7 @@ export { applyApprovals, applyEvent, decisionsForApproval, + applyLocalCancel, applyOptimisticUser, applyUserText, dropOptimisticUser, @@ -23,9 +24,11 @@ export { indexMessages, } from "./reducer.ts"; export { parseSSEBlock, parseSSEChunk, watchEvents, type WatchEventsOptions } from "./sse.ts"; +export { waitUntilRunReleased } from "./wait-run.ts"; export type { AgentEvent, - AgentMode, + ApprovalMode, + WorkMode, Approval, ApprovalDecision, ApprovalStatus, diff --git a/packages/core/chat/reducer.test.ts b/packages/core/chat/reducer.test.ts index c976721..0ac92d3 100644 --- a/packages/core/chat/reducer.test.ts +++ b/packages/core/chat/reducer.test.ts @@ -8,6 +8,7 @@ import { applyApprovalRecord, applyApprovals, applyEvent, + applyLocalCancel, applyOptimisticUser, decisionsForApproval, emptyState, @@ -46,7 +47,7 @@ test("hydrate fills user text from messages and folds events in order", () => { ev({ seq: 2, type: "run.created", - payload: { trigger_message_id: "m-user", mode: "ask_for_approval", status: "queued" }, + payload: { trigger_message_id: "m-user", mode: "agent", status: "queued" }, }), ev({ seq: 3, @@ -213,7 +214,7 @@ test("run.created before optimistic keeps a single user bubble with text", () => ev({ seq: 1, type: "run.created", - payload: { trigger_message_id: "m-real", mode: "auto_approve", status: "queued" }, + payload: { trigger_message_id: "m-real", mode: "agent", status: "queued" }, }), ); state = applyOptimisticUser(state, { runId: "r1", text: "hi" }); @@ -229,7 +230,7 @@ test("run.created after local optimistic uses pending text", () => { ev({ seq: 1, type: "run.created", - payload: { trigger_message_id: "m-real", mode: "auto_approve", status: "queued" }, + payload: { trigger_message_id: "m-real", mode: "agent", status: "queued" }, }), ); const users = state.items.filter((item) => item.kind === "user"); @@ -452,6 +453,61 @@ test("denied approval marks the tool denied", () => { assert.equal(tool.state, "denied"); }); +test("optimistic user is not marked queued while another run is still local", () => { + let state = applyEvent( + emptyState(), + ev({ + seq: 1, + run_id: "r1", + type: "run.created", + payload: { trigger_message_id: "m1", mode: "agent", status: "queued", text: "first" }, + }), + ); + state = applyEvent( + state, + ev({ + seq: 2, + run_id: "r1", + type: "run.state_changed", + payload: { from: "queued", to: "running_llm", reason: "" }, + }), + ); + state = applyOptimisticUser(state, { runId: "local-2", text: "immediate" }); + const optimistic = state.items.find( + (item) => item.kind === "user" && item.messageId === "pending:local-2", + ); + assert.ok(optimistic && optimistic.kind === "user"); + assert.equal(optimistic.queued, false); +}); + +test("applyLocalCancel clears the executing run", () => { + let state = applyEvent( + emptyState(), + ev({ + seq: 1, + run_id: "r1", + type: "run.created", + payload: { trigger_message_id: "m1", mode: "agent", status: "queued", text: "first" }, + }), + ); + state = applyEvent( + state, + ev({ + seq: 2, + run_id: "r1", + type: "run.state_changed", + payload: { from: "queued", to: "running_llm", reason: "" }, + }), + ); + state = applyLocalCancel(state, "r1"); + assert.equal(state.activeRunId, null); + assert.equal(state.runStatus, "cancelled"); + state = applyOptimisticUser(state, { runId: "local-2", text: "next" }); + const optimistic = state.items.find((item) => item.kind === "user" && item.text === "next"); + assert.ok(optimistic && optimistic.kind === "user"); + assert.equal(optimistic.queued, false); +}); + test("optimistic user is replaced when run.created arrives", () => { let state = applyOptimisticUser(emptyState(), { runId: "r1", text: "hi" }); assert.equal(state.items[0]?.kind === "user" && state.items[0].messageId, "pending:r1"); @@ -460,7 +516,7 @@ test("optimistic user is replaced when run.created arrives", () => { ev({ seq: 4, type: "run.created", - payload: { trigger_message_id: "m-real", mode: "auto_approve", status: "queued" }, + payload: { trigger_message_id: "m-real", mode: "agent", status: "queued" }, }), ); const users = state.items.filter((item) => item.kind === "user"); @@ -476,7 +532,7 @@ test("a later run.created does not steal the executing run", () => { seq: 1, run_id: "r1", type: "run.created", - payload: { trigger_message_id: "m1", mode: "auto_approve", status: "queued", text: "first" }, + payload: { trigger_message_id: "m1", mode: "agent", status: "queued", text: "first" }, }), ); state = applyEvent( @@ -494,7 +550,7 @@ test("a later run.created does not steal the executing run", () => { seq: 3, run_id: "r2", type: "run.created", - payload: { trigger_message_id: "m2", mode: "auto_approve", status: "queued", text: "second" }, + payload: { trigger_message_id: "m2", mode: "agent", status: "queued", text: "second" }, }), ); assert.equal(state.activeRunId, "r1"); diff --git a/packages/core/chat/reducer.ts b/packages/core/chat/reducer.ts index 8d166a2..fed0105 100644 --- a/packages/core/chat/reducer.ts +++ b/packages/core/chat/reducer.ts @@ -55,16 +55,27 @@ export function applyOptimisticUser( state: SessionState, input: { runId: string; text: string }, ): SessionState { - const queued = hasExecutingRun(state, input.runId); return upsertUser(state, { messageId: `pending:${input.runId}`, runId: input.runId, text: input.text, - queued, + queued: false, seq: state.lastSeq, }); } +/** 本地已发出取消后,先清掉执行中标记,避免新气泡被当成排队。 */ +export function applyLocalCancel(state: SessionState, runId: string): SessionState { + if (!runId || state.activeRunId !== runId) { + return state; + } + return { + ...state, + activeRunId: null, + runStatus: "cancelled", + }; +} + export function dropOptimisticUser(state: SessionState, runId: string): SessionState { return removeItem(state, userId(`pending:${runId}`)); } diff --git a/packages/core/chat/types.ts b/packages/core/chat/types.ts index 8f8e34d..e051c55 100644 --- a/packages/core/chat/types.ts +++ b/packages/core/chat/types.ts @@ -1,11 +1,8 @@ export type SessionStatus = "active" | "archived"; -export type AgentMode = - | "ask_for_approval" - | "auto_approve" - | "yolo" - | "ask" - | "plan"; +export type WorkMode = "ask" | "plan" | "agent"; + +export type ApprovalMode = "manual" | "auto" | "yolo"; export type RunStatus = | "queued" @@ -58,6 +55,7 @@ export interface Session { tenant_id: string; user_id: string; agent_id: string; + /** 创建会话时冻结的工作目录(绝对路径);本会话权限只覆盖该目录。 */ workspace_id: string; status: SessionStatus; active_run_id?: string; @@ -132,7 +130,8 @@ export interface AgentEvent { export interface RunCreatedPayload { trigger_message_id: string; - mode: AgentMode; + mode: WorkMode; + approval?: ApprovalMode; status: RunStatus; text?: string; } @@ -212,6 +211,8 @@ export interface Run { id: string; session_id: string; status: RunStatus; + mode?: WorkMode; + approval?: ApprovalMode; cancel_requested?: boolean; needs_recover?: boolean; } @@ -230,7 +231,8 @@ export function isRecoverableRun(status: string): boolean { export interface StartRunRequest { content: string; - mode?: AgentMode; + mode?: WorkMode; + approval?: ApprovalMode; } export interface StartRunResponse { diff --git a/packages/core/chat/wait-run.test.ts b/packages/core/chat/wait-run.test.ts new file mode 100644 index 0000000..45bd42c --- /dev/null +++ b/packages/core/chat/wait-run.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { waitUntilRunReleased } from "./wait-run.ts"; + +test("waitUntilRunReleased returns when the session drops the run", async () => { + let active: string | undefined = "r1"; + let now = 0; + await waitUntilRunReleased({ + runId: "r1", + getActiveRunId: async () => { + const current = active; + active = undefined; + return current; + }, + now: () => now, + sleep: async () => { + now += 50; + }, + }); +}); + +test("waitUntilRunReleased returns immediately if another run is active", async () => { + await waitUntilRunReleased({ + runId: "r1", + getActiveRunId: async () => "r2", + now: () => 0, + sleep: async () => { + throw new Error("should not sleep"); + }, + }); +}); + +test("waitUntilRunReleased times out while the same run stays active", async () => { + await assert.rejects( + () => + waitUntilRunReleased({ + runId: "r1", + getActiveRunId: async () => "r1", + timeoutMs: 20, + intervalMs: 10, + now: (() => { + let t = 0; + return () => { + const current = t; + t += 10; + return current; + }; + })(), + sleep: async () => {}, + }), + /尚未结束/, + ); +}); diff --git a/packages/core/chat/wait-run.ts b/packages/core/chat/wait-run.ts new file mode 100644 index 0000000..be7f3c6 --- /dev/null +++ b/packages/core/chat/wait-run.ts @@ -0,0 +1,25 @@ +/** 取消后等到会话不再占用该 Run,再允许开下一轮。 */ +export async function waitUntilRunReleased(input: { + runId: string; + getActiveRunId: () => Promise; + timeoutMs?: number; + intervalMs?: number; + now?: () => number; + sleep?: (ms: number) => Promise; +}): Promise { + const timeoutMs = input.timeoutMs ?? 8000; + const intervalMs = input.intervalMs ?? 50; + const now = input.now ?? Date.now; + const sleep = input.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + const deadline = now() + timeoutMs; + for (;;) { + const active = await input.getActiveRunId(); + if (!active || active !== input.runId) { + return; + } + if (now() >= deadline) { + throw new Error("上一轮任务尚未结束"); + } + await sleep(intervalMs); + } +} diff --git a/packages/core/index.ts b/packages/core/index.ts index 1a5cb81..b19268e 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -5,6 +5,7 @@ export { applyApprovals, applyEvent, decisionsForApproval, + applyLocalCancel, applyOptimisticUser, applyUserText, decodeText, @@ -27,6 +28,7 @@ export { RECOVERABLE_RUN_STATUSES, TERMINAL_RUN_STATUSES, THINKING_PHASES, + waitUntilRunReleased, watchEvents, type AgentClientOptions, type WatchEventsOptions, @@ -53,7 +55,8 @@ export type { } from "./git/index.ts"; export type { AgentEvent, - AgentMode, + ApprovalMode, + WorkMode, Approval, ApprovalDecision, ApprovalStatus, diff --git a/packages/core/package.json b/packages/core/package.json index c01f05d..8ba12e4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -9,7 +9,7 @@ "./git": "./git/index.ts" }, "scripts": { - "test": "node --test --experimental-strip-types chat/reducer.test.ts chat/queue.test.ts chat/sse.test.ts chat/plan.test.ts git/client.test.ts" + "test": "node --test --experimental-strip-types chat/reducer.test.ts chat/queue.test.ts chat/sse.test.ts chat/plan.test.ts chat/wait-run.test.ts git/client.test.ts" }, "devDependencies": { "@types/node": "^20", diff --git a/packages/ui/lib/ime.ts b/packages/ui/lib/ime.ts index 7801484..4e37b92 100644 --- a/packages/ui/lib/ime.ts +++ b/packages/ui/lib/ime.ts @@ -4,7 +4,6 @@ import { useRef, type CompositionEvent, type KeyboardEvent as ReactKeyboardEvent type KeyLike = KeyboardEvent | ReactKeyboardEvent; -// flag 读取事件上的布尔字段,避免 KeyLike 联合类型没有 isComposing。 function flag(event: object, name: "isComposing"): boolean { return name in event && Boolean((event as { isComposing?: boolean }).isComposing); } diff --git a/packages/views/chat/approval-dock.tsx b/packages/views/chat/approval-dock.tsx index 9e021fc..787ddfc 100644 --- a/packages/views/chat/approval-dock.tsx +++ b/packages/views/chat/approval-dock.tsx @@ -1,7 +1,13 @@ "use client"; -import type { ApprovalDecision, ApprovalStatus, ApprovalToolCall, TimelineItem } from "@codedock/core/chat"; -import { Button, cn, formatJSON } from "@codedock/ui"; +import { + planPreviewFromTool, + type ApprovalDecision, + type ApprovalStatus, + type ApprovalToolCall, + type TimelineItem, +} from "@codedock/core/chat"; +import { Button, cn, formatJSON, MessageResponse } from "@codedock/ui"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; @@ -63,6 +69,7 @@ export function ApprovalDock({ } const choiceKey = pageKey(page); const currentChoice = choiceOf(page, choices); + const planPreview = planPreviewFromTool(page.call); const go = (next: number) => { if (next < 0 || next >= pages.length) { @@ -160,7 +167,11 @@ export function ApprovalDock({
{page.call.name || "工具调用"}
{statusLabel(currentChoice)}
- {page.call.arguments != null ? ( + {planPreview?.content ? ( +
+ {planPreview.content} +
+ ) : page.call.arguments != null ? (
               {formatJSON(page.call.arguments)}
             
diff --git a/packages/views/chat/chat-page.tsx b/packages/views/chat/chat-page.tsx index 487c121..d96f89c 100644 --- a/packages/views/chat/chat-page.tsx +++ b/packages/views/chat/chat-page.tsx @@ -1,6 +1,6 @@ "use client"; -import type { AgentMode, TimelineItem } from "@codedock/core/chat"; +import type { ApprovalMode, TimelineItem, WorkMode } from "@codedock/core/chat"; import { Button } from "@codedock/ui"; import { useState, type ReactNode } from "react"; @@ -45,7 +45,7 @@ export function ChatPage({ const [pickerOpen, setPickerOpen] = useState(false); const frozenWorkspace = - list.sessions.find((session) => session.id === sessionId)?.workspace_id ?? ""; + timeline.workspaceId ?? list.sessions.find((session) => session.id === sessionId)?.workspace_id ?? ""; const workspaceTitle = sessionId ? frozenWorkspace : workspaceDraft.trim() || "默认仓库目录"; const workspaceLabel = sessionId ? frozenWorkspace @@ -58,10 +58,10 @@ export function ChatPage({ item.kind === "approval" && item.status === "pending", ); - const onSend = async (text: string, mode: AgentMode) => { + const onSend = async (text: string, mode: WorkMode, approval: ApprovalMode) => { setComposerError(null); if (sessionId) { - await timeline.send(text, mode); + await timeline.send(text, mode, approval); await list.refresh(); return; } @@ -70,7 +70,7 @@ export function ChatPage({ const session = await list.createSession(workspaceDraft); writeLastWorkspace(session.workspace_id); setWorkspaceDraft(session.workspace_id); - await client.startRun(session.id, { content: text, mode }); + await client.startRun(session.id, { content: text, mode, approval }); await list.refresh(); onOpenSession(session.id); } catch (err) { @@ -106,11 +106,18 @@ export function ChatPage({ />
- {sessionId ? "对话" : "新对话"} + {sessionId ? "对话" : "新对话"} {workspaceLabel ? ( - - {workspaceLabel} - + <> + · + + {workspaceLabel} + + ) : null} {timeline.canRecover ? (