From 93f7b1cf046fdc07ab9db355be95ae4337c36a29 Mon Sep 17 00:00:00 2001 From: 2penheimer <2603237065@qq.com> Date: Sun, 20 Sep 2026 16:12:19 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=E5=AF=B9=E8=AF=9D=E9=A1=B5?= =?UTF-8?q?=E6=94=B9=E6=88=90=E5=8F=AF=E6=94=B6=E8=B5=B7=E7=9A=84=E4=B8=89?= =?UTF-8?q?=E6=A0=8F=E5=B7=A5=E4=BD=9C=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 会话列表、对话和右侧窗口可拖宽、可收起,Plan 与文件改动在右侧打开;并去掉开发态顶栏。 Co-authored-by: Cursor --- README.md | 2 +- apps/web/app/chat-host.tsx | 47 +-- apps/web/app/layout.tsx | 7 +- apps/web/app/test-nav.tsx | 38 --- packages/core/chat/files.test.ts | 52 ++++ packages/core/chat/files.ts | 125 ++++++++ packages/core/chat/index.ts | 9 + packages/core/chat/reducer.test.ts | 33 +++ packages/core/chat/reducer.ts | 80 +++++ packages/core/chat/types.ts | 79 ++++- packages/ui/components/live-status.tsx | 23 ++ packages/ui/components/reasoning.tsx | 5 +- packages/ui/components/tool.tsx | 36 ++- packages/ui/index.ts | 1 + packages/ui/styles/tokens.css | 33 +++ packages/views/chat/chat-page.tsx | 264 ++++++++++++----- packages/views/chat/column-layout.test.ts | 26 ++ packages/views/chat/column-layout.ts | 242 +++++++++++++++ packages/views/chat/column-sash.tsx | 84 ++++++ packages/views/chat/conversation-timeline.tsx | 222 +++++++++++--- packages/views/chat/plan-preview.tsx | 36 ++- packages/views/chat/session-sidebar.tsx | 7 +- packages/views/chat/side-dock.tsx | 279 ++++++++++++++++++ packages/views/chat/workbench.ts | 198 +++++++++++++ packages/views/git/git-page.tsx | 9 +- packages/views/git/workspace-panel.tsx | 16 +- 26 files changed, 1744 insertions(+), 209 deletions(-) delete mode 100644 apps/web/app/test-nav.tsx create mode 100644 packages/core/chat/files.test.ts create mode 100644 packages/core/chat/files.ts create mode 100644 packages/ui/components/live-status.tsx create mode 100644 packages/views/chat/column-layout.test.ts create mode 100644 packages/views/chat/column-layout.ts create mode 100644 packages/views/chat/column-sash.tsx create mode 100644 packages/views/chat/side-dock.tsx create mode 100644 packages/views/chat/workbench.ts diff --git a/README.md b/README.md index 5d271a7..d669b04 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Web(默认 `http://localhost:3000`): pnpm dev:web ``` -浏览器打开 [http://localhost:3000](http://localhost:3000)。开发态顶栏可在对话和仓库之间切换。完整环境变量见 [`.env.example`](.env.example) 和 [`apps/web/.env.example`](apps/web/.env.example),不要提交 `.env` 或密钥。 +浏览器打开 [http://localhost:3000](http://localhost:3000)。完整环境变量见 [`.env.example`](.env.example) 和 [`apps/web/.env.example`](apps/web/.env.example),不要提交 `.env` 或密钥。 ## 测试 diff --git a/apps/web/app/chat-host.tsx b/apps/web/app/chat-host.tsx index d882388..73ad11c 100644 --- a/apps/web/app/chat-host.tsx +++ b/apps/web/app/chat-host.tsx @@ -1,8 +1,12 @@ "use client"; +import { GitClient } from "@codedock/core/git"; import { ChatPage, type SessionEngine } from "@codedock/views/chat"; +import { GitProvider } from "@codedock/views/git"; import { usePathname, useRouter } from "next/navigation"; +import { useMemo } from "react"; +import { apiBase } from "@/lib/env"; import { rememberSession } from "@/lib/session"; // ChatHost 解析对话路径并把导航收成回调;views 不知道具体 URL。 @@ -13,26 +17,33 @@ export function ChatHost() { if (parsed.sessionId && parsed.engine === "agent") { rememberSession(parsed.sessionId); } + const gitSessionId = parsed.engine === "agent" ? parsed.sessionId : undefined; + const git = useMemo( + () => new GitClient({ baseUrl: apiBase, sessionId: gitSessionId }), + [gitSessionId], + ); return ( - { - const path = pathFor(id, engine); - if (pathname !== path) { - router.push(path); - } - }} - onNewConversation={() => { - if (pathname !== "/") { - router.push("/"); - } - }} - /> + + { + const path = pathFor(id, engine); + if (pathname !== path) { + router.push(path); + } + }} + onNewConversation={() => { + if (pathname !== "/") { + router.push("/"); + } + }} + /> + ); } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index c532a28..9161462 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -2,7 +2,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { Providers } from "./providers"; -import { TestNav } from "./test-nav"; import "./globals.css"; const geistSans = Geist({ @@ -20,16 +19,16 @@ export const metadata: Metadata = { description: "Local 对话工作台", }; +// RootLayout 包字体、主题和全局 Provider。 export default function RootLayout({ children }: LayoutProps<"/">) { return ( - + - {process.env.NODE_ENV === "development" ? : null} -
{children}
+
{children}
diff --git a/apps/web/app/test-nav.tsx b/apps/web/app/test-nav.tsx deleted file mode 100644 index f9e5137..0000000 --- a/apps/web/app/test-nav.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; - -const items = [ - { href: "/", label: "对话", match: (path: string) => path === "/" || path.startsWith("/s/") }, - { href: "/git", label: "仓库", match: (path: string) => path === "/git" || path.startsWith("/git/") }, -] as const; - -export function TestNav() { - const pathname = usePathname(); - - return ( - - ); -} diff --git a/packages/core/chat/files.test.ts b/packages/core/chat/files.test.ts new file mode 100644 index 0000000..b87f953 --- /dev/null +++ b/packages/core/chat/files.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { compactToolDump, fileChangeFromTool, isFileChangeTool } from "./files.ts"; + +test("write to a regular file is a file change", () => { + assert.deepEqual( + fileChangeFromTool({ + name: "write", + arguments: { path: "store.py", content: "print(1)" }, + }), + { path: "store.py", content: "print(1)", action: "write" }, + ); +}); + +test("write to .cursor plan is not a file change", () => { + assert.equal( + fileChangeFromTool({ + name: "write", + arguments: { path: ".cursor/task.md", content: "# plan" }, + }), + null, + ); +}); + +test("edit collects replacement text", () => { + const got = fileChangeFromTool({ + name: "edit", + arguments: { + path: "server.py", + edits: [{ oldText: "a", newText: "b" }], + }, + }); + assert.equal(got?.path, "server.py"); + assert.equal(got?.action, "edit"); + assert.match(got?.content ?? "", /\+ b/); +}); + +test("compactToolDump strips file bodies", () => { + assert.deepEqual( + compactToolDump({ + name: "write", + arguments: { path: "a.py", content: "huge" }, + }), + { input: { path: "a.py" }, output: undefined }, + ); +}); + +test("isFileChangeTool only marks write and edit", () => { + assert.equal(isFileChangeTool("write"), true); + assert.equal(isFileChangeTool("plan_write"), false); +}); diff --git a/packages/core/chat/files.ts b/packages/core/chat/files.ts new file mode 100644 index 0000000..f0bcfaf --- /dev/null +++ b/packages/core/chat/files.ts @@ -0,0 +1,125 @@ +import { planPreviewFromTool } from "./plan.ts"; + +export type FileChangePreview = { + path: string; + content: string; + action: "write" | "edit"; +}; + +const FILE_TOOLS = new Set(["write", "edit"]); + +// isFileChangeTool 判断工具是否改普通文件(计划文档不算)。 +export function isFileChangeTool(name: string): boolean { + return FILE_TOOLS.has(name); +} + +// fileChangeFromTool 从 write/edit 抽出路径和正文;.cursor 计划走 plan 预览。 +export function fileChangeFromTool(input: { + name: string; + arguments?: unknown; + output?: unknown; +}): FileChangePreview | null { + if (!FILE_TOOLS.has(input.name)) { + return null; + } + if (planPreviewFromTool(input)) { + return null; + } + const args = asRecord(input.arguments); + const path = stringField(args, "path"); + if (!path) { + return null; + } + if (input.name === "write") { + return { path, content: stringField(args, "content"), action: "write" }; + } + return { + path, + content: editContent(args), + action: "edit", + }; +} + +// compactToolDump 去掉计划正文和文件正文,只留路径等元数据给时间线。 +export function compactToolDump(input: { + name: string; + arguments?: unknown; + output?: unknown; +}): { input: unknown; output: unknown } { + if (!planPreviewFromTool(input) && !fileChangeFromTool(input)) { + return { input: input.arguments, output: input.output }; + } + return { + input: omitHeavy(input.arguments), + output: omitHeavy(input.output), + }; +} + +// editContent 拼出 edit 的替换说明,没有完整新文件时也能在右侧看。 +function editContent(args: Record | null): string { + const whole = stringField(args, "newText") || stringField(args, "new_text"); + if (whole) { + return whole; + } + const edits = args?.edits; + if (!Array.isArray(edits) || edits.length === 0) { + return ""; + } + return edits + .map((item, index) => { + const row = asRecord(item); + const next = stringField(row, "newText") || stringField(row, "new_text"); + const prev = stringField(row, "oldText") || stringField(row, "old_text"); + if (!next && !prev) { + return ""; + } + return `@@ edit ${index + 1} @@\n${prev ? `- ${prev}\n` : ""}${next ? `+ ${next}` : ""}`.trim(); + }) + .filter(Boolean) + .join("\n\n"); +} + +function asRecord(value: unknown): Record | null { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + if (!trimmed.startsWith("{")) { + return null; + } + try { + const parsed: unknown = JSON.parse(trimmed); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + return null; + } + return null; +} + +function stringField(record: Record | null, key: string): string { + if (!record) { + return ""; + } + const value = record[key]; + return typeof value === "string" ? value : ""; +} + +function omitHeavy(value: unknown): unknown { + const record = asRecord(value); + if (!record) { + return value; + } + const next = { ...record }; + delete next.content; + delete next.newText; + delete next.new_text; + delete next.oldText; + delete next.old_text; + delete next.edits; + return next; +} diff --git a/packages/core/chat/index.ts b/packages/core/chat/index.ts index 2b8f256..9de9af7 100644 --- a/packages/core/chat/index.ts +++ b/packages/core/chat/index.ts @@ -1,5 +1,11 @@ export { AgentClient, AgentClientError, type AgentClientOptions } from "./client.ts"; export { decodeText, firstLine, parseDelta } from "./content.ts"; +export { + compactToolDump, + fileChangeFromTool, + isFileChangeTool, + type FileChangePreview, +} from "./files.ts"; export { isPlanTool, latestPlanDocIds, @@ -31,13 +37,16 @@ export type { WorkMode, Approval, ApprovalDecision, + ApprovalKind, ApprovalStatus, ApprovalToolCall, CreateSessionRequest, DecideApprovalRequest, EventType, Message, + OverrideAction, PageInfo, + RestoreMode, Run, RunStatus, Session, diff --git a/packages/core/chat/reducer.test.ts b/packages/core/chat/reducer.test.ts index 831506b..3b4eb99 100644 --- a/packages/core/chat/reducer.test.ts +++ b/packages/core/chat/reducer.test.ts @@ -606,6 +606,39 @@ test("decodeText and parseDelta accept backend payloads", () => { assert.equal(parseDelta({ id: "c1", name: "ping" }).kind, "tool"); }); +test("verify and evaluate events become timeline cards", () => { + let state = applyEvent( + emptyState(), + ev({ seq: 1, type: "run.state_changed", payload: { from: "running_llm", to: "verifying", reason: "" } }), + ); + assert.equal(state.runStatus, "verifying"); + state = applyEvent(state, ev({ seq: 2, type: "verify.started", payload: { round: 1 } })); + state = applyEvent(state, ev({ seq: 3, type: "verify.result", payload: { status: "passed", round: 1 } })); + state = applyEvent(state, ev({ seq: 4, type: "evaluate.started", payload: { round: 1 } })); + state = applyEvent( + state, + ev({ seq: 5, type: "evaluate.result", payload: { verdict: "pass", summary: "ok" } }), + ); + const verify = state.items.find((item) => item.kind === "verify"); + const evaluate = state.items.find((item) => item.kind === "evaluate"); + assert.ok(verify && verify.kind === "verify" && verify.status === "passed"); + assert.ok(evaluate && evaluate.kind === "evaluate" && evaluate.status === "pass"); +}); + +test("verify approval cards keep kind for override buttons", () => { + let state = applyEvent( + emptyState(), + ev({ + seq: 1, + type: "tool.approval_required", + payload: { approval_id: "a1", kind: "verify", tool_calls: [{ id: "verify", name: "verify" }] }, + }), + ); + const item = state.items.find((current) => current.kind === "approval"); + assert.ok(item && item.kind === "approval"); + assert.equal(item.approvalKind, "verify"); +}); + test("assistant.completed with encoded empty text does not keep a JSON blob", () => { let state = applyEvent( emptyState(), diff --git a/packages/core/chat/reducer.ts b/packages/core/chat/reducer.ts index fed0105..fbd36b5 100644 --- a/packages/core/chat/reducer.ts +++ b/packages/core/chat/reducer.ts @@ -11,8 +11,10 @@ import { type ApprovalToolCall, type AssistantCompletedPayload, type AssistantDeltaPayload, + type ApprovalKind, type AssistantStartedPayload, type ContextCompactedPayload, + type EvaluateEventPayload, type Message, type RunCreatedPayload, type RunStateChangedPayload, @@ -23,6 +25,7 @@ import { type TimelineItem, type ToolCall, type ToolCallPayload, + type VerifyEventPayload, } from "./types.ts"; export function emptyState(messages: Record = {}): SessionState { @@ -126,6 +129,19 @@ export function applyEvent(state: SessionState, event: AgentEvent): SessionState case "context.compacted": next = applyContextCompacted(next, event); break; + case "verify.started": + next = applyVerify(next, event, "started"); + break; + case "verify.result": + case "verify.skipped": + next = applyVerify(next, event, event.type === "verify.skipped" ? "skipped" : "result"); + break; + case "evaluate.started": + next = applyEvaluate(next, event, "started"); + break; + case "evaluate.result": + next = applyEvaluate(next, event, "result"); + break; case "run.completed": case "run.failed": case "run.cancelled": @@ -302,6 +318,7 @@ function applyApprovalRequired(state: SessionState, event: AgentEvent): SessionS approvalId: payload.approval_id, toolCalls: mergeApprovalCalls(existingApprovalCalls(state, payload.approval_id), payload.tool_calls ?? []), status: "pending", + approvalKind: payload.kind, seq: event.seq, }); } @@ -314,6 +331,7 @@ function applyApprovalDecided(state: SessionState, event: AgentEvent): SessionSt status: payload.status, toolCalls: payload.tool_calls ?? existingApprovalCalls(state, payload.approval_id), decisions: payload.decisions ?? [], + approvalKind: payload.kind ?? existingApprovalKind(state, payload.approval_id), seq: event.seq, }); } @@ -341,6 +359,7 @@ export function applyApprovalRecord(state: SessionState, approval: Approval): Se status: call.status ?? approval.status, reason: call.reason, })), + approvalKind: approval.kind, seq: state.lastSeq, }); } @@ -353,6 +372,7 @@ function applyApprovalDecision( status: ApprovalStatus; toolCalls: ApprovalToolCall[]; decisions: ApprovalDecision[]; + approvalKind?: ApprovalKind; seq: number; }, ): SessionState { @@ -363,6 +383,7 @@ function applyApprovalDecision( approvalId: input.approvalId, toolCalls: input.toolCalls, status: input.status, + approvalKind: input.approvalKind ?? existingApprovalKind(state, input.approvalId), seq: input.seq, }); for (const decision of input.decisions) { @@ -384,6 +405,56 @@ function applyApprovalDecision( return next; } +/** 把收尾验证事件折成时间线卡片。 */ +function applyVerify( + state: SessionState, + event: AgentEvent, + stage: "started" | "result" | "skipped", +): SessionState { + const payload = (event.payload ?? {}) as VerifyEventPayload; + let status: Extract["status"] = "started"; + if (stage === "skipped" || payload.skipped) { + status = "skipped"; + } else if (stage === "result") { + status = payload.status === "failed" || payload.status === "cannot_run" ? payload.status : "passed"; + } + let next = removeItem(state, thinkingId(event.run_id)); + return upsertItem(next, { + kind: "verify", + id: `verify:${event.run_id}`, + runId: event.run_id, + status, + output: payload.output, + round: payload.round, + seq: event.seq, + }); +} + +/** 把独立复审事件折成时间线卡片。 */ +function applyEvaluate( + state: SessionState, + event: AgentEvent, + stage: "started" | "result", +): SessionState { + const payload = (event.payload ?? {}) as EvaluateEventPayload; + const status = + stage === "started" + ? "started" + : payload.verdict === "needs_work" || payload.verdict === "escalate" + ? payload.verdict + : "pass"; + let next = removeItem(state, thinkingId(event.run_id)); + return upsertItem(next, { + kind: "evaluate", + id: `evaluate:${event.run_id}`, + runId: event.run_id, + status, + summary: payload.summary, + round: payload.round, + seq: event.seq, + }); +} + function applyContextCompacted(state: SessionState, event: AgentEvent): SessionState { const payload = event.payload as ContextCompactedPayload; return upsertItem(state, { @@ -635,6 +706,15 @@ function mergeApprovalCalls(current: ApprovalToolCall[], incoming: ApprovalToolC return [...byId.values()]; } +/** 读出时间线上已有审批卡片的 kind。 */ +function existingApprovalKind(state: SessionState, approvalId: string): ApprovalKind | undefined { + const item = state.items.find( + (current): current is Extract => + current.kind === "approval" && current.approvalId === approvalId, + ); + return item?.approvalKind; +} + function existingApprovalCalls(state: SessionState, approvalId: string): ApprovalToolCall[] { const item = state.items.find( (current): current is Extract => diff --git a/packages/core/chat/types.ts b/packages/core/chat/types.ts index 5c97ac8..f8eed37 100644 --- a/packages/core/chat/types.ts +++ b/packages/core/chat/types.ts @@ -10,11 +10,19 @@ export type RunStatus = | "running_llm" | "executing_tools" | "waiting_approval" + | "verifying" + | "evaluating" | "cancelling" | "completed" | "failed" | "cancelled"; +export type ApprovalKind = "tools" | "verify" | "evaluate"; + +export type OverrideAction = "accept" | "retry" | "abort"; + +export type RestoreMode = "restore_files" | "restore_messages" | "restore_all"; + export type ApprovalStatus = "pending" | "approved" | "denied" | "expired"; export type ApprovalScope = "once" | "run" | "session"; @@ -39,9 +47,23 @@ export type EventType = | "turn.completed" | "run.completed" | "run.failed" - | "run.cancelled"; - -export type ThinkingPhase = "queued" | "loading_context" | "running_llm"; + | "run.cancelled" + | "verify.started" + | "verify.result" + | "verify.skipped" + | "evaluate.started" + | "evaluate.result" + | "snapshot.skipped"; + +export type ThinkingPhase = + | "queued" + | "loading_context" + | "running_llm" + | "executing_tools" + | "waiting_approval" + | "verifying" + | "evaluating" + | "cancelling"; export type ToolItemState = | "pending" @@ -114,6 +136,8 @@ export interface Approval { scope: ApprovalScope; status: ApprovalStatus; expires_at: string; + kind?: ApprovalKind; + override?: OverrideAction; } export interface AgentEvent { @@ -171,6 +195,7 @@ export interface ToolCallPayload { export interface ApprovalRequiredPayload { approval_id: string; tool_calls: ApprovalToolCall[]; + kind?: ApprovalKind; } export interface ApprovalDecision { @@ -187,6 +212,23 @@ export interface ApprovalDecidedPayload { reason?: string; decisions?: ApprovalDecision[]; tool_calls?: ApprovalToolCall[]; + kind?: ApprovalKind; + override?: OverrideAction; +} + +export interface VerifyEventPayload { + round?: number; + status?: "passed" | "failed" | "cannot_run"; + output?: string; + skipped?: boolean; + fingerprint?: string; +} + +export interface EvaluateEventPayload { + round?: number; + verdict?: "pass" | "needs_work" | "escalate"; + summary?: string; + issues?: Array<{ file_path?: string; line?: number; category?: string; reason?: string }>; } export interface ContextCompactedPayload { @@ -223,6 +265,8 @@ export const RECOVERABLE_RUN_STATUSES: readonly RunStatus[] = [ "loading_context", "running_llm", "executing_tools", + "verifying", + "evaluating", ]; export function isRecoverableRun(status: string): boolean { @@ -242,10 +286,11 @@ export interface StartRunResponse { } export interface DecideApprovalRequest { - decisions: ApprovalDecision[]; + decisions?: ApprovalDecision[]; scope?: ApprovalScope; actor_id?: string; reason?: string; + override?: OverrideAction; } export type TimelineItem = @@ -286,13 +331,32 @@ export type TimelineItem = error?: string; seq: number; } - | { + | { kind: "approval"; id: string; runId: string; approvalId: string; toolCalls: ApprovalToolCall[]; status: ApprovalStatus; + approvalKind?: ApprovalKind; + seq: number; + } + | { + kind: "verify"; + id: string; + runId: string; + status: "started" | "passed" | "failed" | "skipped" | "cannot_run"; + output?: string; + round?: number; + seq: number; + } + | { + kind: "evaluate"; + id: string; + runId: string; + status: "started" | "pass" | "needs_work" | "escalate"; + summary?: string; + round?: number; seq: number; } | { @@ -324,6 +388,11 @@ export const THINKING_PHASES: readonly ThinkingPhase[] = [ "queued", "loading_context", "running_llm", + "executing_tools", + "waiting_approval", + "verifying", + "evaluating", + "cancelling", ]; export const TERMINAL_RUN_STATUSES: readonly RunStatus[] = [ diff --git a/packages/ui/components/live-status.tsx b/packages/ui/components/live-status.tsx new file mode 100644 index 0000000..8ccd20c --- /dev/null +++ b/packages/ui/components/live-status.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { cn } from "../lib/cn.ts"; + +/** 仅当前进行中的状态带扫光;历史状态保持静态文字。 */ +export function LiveStatus({ + children, + active = false, + className, +}: { + children: string; + active?: boolean; + className?: string; +}) { + return ( + + {children} + + ); +} diff --git a/packages/ui/components/reasoning.tsx b/packages/ui/components/reasoning.tsx index e83526a..7a0a9f0 100644 --- a/packages/ui/components/reasoning.tsx +++ b/packages/ui/components/reasoning.tsx @@ -10,6 +10,7 @@ import { type ReactNode, } from "react"; +import { LiveStatus } from "./live-status.tsx"; import { cn } from "../lib/cn.ts"; import { Collapsible, @@ -76,7 +77,9 @@ export function ReasoningTrigger({ {children ?? ( <> - {ctx?.isStreaming ? "思考中…" : "思考过程"} + + {ctx?.isStreaming ? "Thinking" : "Reasoning"} + diff --git a/packages/ui/components/tool.tsx b/packages/ui/components/tool.tsx index 5ac9129..9ae1171 100644 --- a/packages/ui/components/tool.tsx +++ b/packages/ui/components/tool.tsx @@ -3,6 +3,7 @@ import { ChevronDownIcon, WrenchIcon } from "lucide-react"; import { createContext, useContext, useMemo, useState, type ReactNode } from "react"; +import { LiveStatus } from "./live-status.tsx"; import { cn } from "../lib/cn.ts"; import { formatJSON } from "../lib/json.ts"; import { @@ -13,12 +14,17 @@ import { export type ToolState = "pending" | "running" | "completed" | "error" | "denied"; +// isLiveToolState 判断工具是否仍在排队或执行,需要动态状态文案。 +function isLiveToolState(state: ToolState): boolean { + return state === "pending" || state === "running"; +} + const stateLabel: Record = { - pending: "待执行", - running: "执行中", - completed: "已完成", - error: "失败", - denied: "已拒绝", + pending: "Pending", + running: "Running", + completed: "Done", + error: "Failed", + denied: "Denied", }; type OpenContextValue = { @@ -55,10 +61,12 @@ export function ToolGroup({ export function ToolGroupHeader({ count, state, + live = false, className, }: { count: number; state: ToolState; + live?: boolean; className?: string; }) { const ctx = useContext(ToolGroupContext); @@ -70,8 +78,10 @@ export function ToolGroupHeader({ )} > - {count === 1 ? "调用了 1 个工具" : `调用了 ${count} 个工具`} - {stateLabel[state]} + {count === 1 ? "Used 1 tool" : `Used ${count} tools`} + + {stateLabel[state]} + @@ -116,10 +126,12 @@ export function Tool({ export function ToolHeader({ type, state, + live = false, className, }: { type: string; state: ToolState; + live?: boolean; className?: string; }) { const name = type.startsWith("tool-") ? type.slice(5) : type; @@ -132,7 +144,7 @@ export function ToolHeader({ )} > {name} - {stateLabel[state]} + {stateLabel[state]} @@ -148,11 +160,11 @@ export function ToolContent({ children, className }: { children: ReactNode; clas export function ToolInput({ input }: { input: unknown }) { if (input == null) { - return

无参数

; + return

No arguments

; } return (
-
参数
+
Arguments
         {formatJSON(input)}
       
@@ -170,7 +182,7 @@ export function ToolOutput({ if (errorText) { return (
-
错误
+
Error

{errorText}

); @@ -180,7 +192,7 @@ export function ToolOutput({ } return (
-
输出
+
Output
         {formatJSON(output)}
       
diff --git a/packages/ui/index.ts b/packages/ui/index.ts index 67eed24..30cf580 100644 --- a/packages/ui/index.ts +++ b/packages/ui/index.ts @@ -1,3 +1,4 @@ +export { LiveStatus } from "./components/live-status.tsx"; export { cn } from "./lib/cn.ts"; export { formatJSON } from "./lib/json.ts"; export { isImeConfirm, useImeGuard } from "./lib/ime.ts"; diff --git a/packages/ui/styles/tokens.css b/packages/ui/styles/tokens.css index 2ec881f..4c52487 100644 --- a/packages/ui/styles/tokens.css +++ b/packages/ui/styles/tokens.css @@ -32,3 +32,36 @@ --primary-foreground: var(--color-zinc-950); --radius: 0.625rem; } + +/* 进行中状态:整段文字扫光,不用省略号占位。 */ +.live-status-active { + background-image: linear-gradient( + 100deg, + color-mix(in oklab, var(--muted-foreground) 72%, transparent) 0%, + var(--foreground) 48%, + color-mix(in oklab, var(--muted-foreground) 72%, transparent) 100% + ); + background-size: 180% 100%; + background-clip: text; + -webkit-background-clip: text; + color: transparent; + animation: live-status-shimmer 2s ease-in-out infinite; +} + +@keyframes live-status-shimmer { + 0%, + 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } +} + +@media (prefers-reduced-motion: reduce) { + .live-status-active { + animation: none; + background: none; + color: inherit; + } +} diff --git a/packages/views/chat/chat-page.tsx b/packages/views/chat/chat-page.tsx index 5e64658..60fd0ea 100644 --- a/packages/views/chat/chat-page.tsx +++ b/packages/views/chat/chat-page.tsx @@ -4,6 +4,7 @@ import type { ApprovalMode, Session, TimelineItem, WorkMode } from "@codedock/co import type { ClaudeSession } from "@codedock/core/claude"; import type { Session as CodexSession } from "@codedock/core/codex"; import { Button } from "@codedock/ui"; +import { PanelLeft, PanelLeftClose, PanelRight, PanelRightClose, PlusIcon } from "lucide-react"; import { useEffect, useMemo, useState, type ReactNode } from "react"; import { ClaudePane } from "../claude/claude-pane.tsx"; @@ -14,7 +15,11 @@ import { useCodexSessionList } from "../codex/hooks/use-session-list.ts"; import { useCodex } from "../codex/provider.tsx"; import { useAgent } from "../provider.tsx"; import { ApprovalDock } from "./approval-dock.tsx"; +import { useColumnLayout } from "./column-layout.ts"; +import { ColumnSash } from "./column-sash.tsx"; import { ConversationTimeline } from "./conversation-timeline.tsx"; +import { SideDock } from "./side-dock.tsx"; +import { collectDockArtifacts, fileWindowId, planWindowId, useWorkbench } from "./workbench.ts"; import { useSessionList } from "./hooks/use-session-list.ts"; import { useSessionTimeline } from "./hooks/use-session-timeline.ts"; import { shortWorkspace } from "./lib/format.ts"; @@ -42,7 +47,7 @@ export type ChatPageProps = { headerActions?: ReactNode; }; -// ChatPage 组合会话侧栏、时间线与输入条。 +// ChatPage 组合会话列表、对话和右侧多窗口栏。 export function ChatPage({ sessionId, engine, @@ -71,6 +76,12 @@ export function ChatPage({ const [composerError, setComposerError] = useState(null); const [workspaceDraft, setWorkspaceDraft] = useState(""); const [pickingWorkspace, setPickingWorkspace] = useState(false); + const workbench = useWorkbench(); + const columns = useColumnLayout(); + + useEffect(() => { + workbench.reset(); + }, [sessionId, activeEngine, workbench.reset]); // 上次目录只在本机 localStorage,等 hydration 后再读,避免 SSR 文本对不上。 useEffect(() => { @@ -95,6 +106,35 @@ export function ChatPage({ : "" : shortWorkspace(workspaceTitle); + const artifacts = useMemo( + () => collectDockArtifacts(timeline.state.items), + [timeline.state.items], + ); + + useEffect(() => { + for (const plan of artifacts.plans) { + workbench.refreshOpen({ + id: planWindowId(plan.name), + kind: "plan", + title: plan.name || "Plan", + name: plan.name, + content: plan.content, + toolState: plan.toolState, + error: plan.error, + }); + } + for (const file of artifacts.files) { + workbench.refreshOpen({ + id: fileWindowId(file.path), + kind: "file", + title: file.path.split("/").pop() || file.path, + path: file.path, + content: file.content, + action: file.action, + }); + } + }, [artifacts, workbench.refreshOpen]); + const pendingApprovals = timeline.state.items.filter( (item): item is Extract => item.kind === "approval" && item.status === "pending", @@ -163,76 +203,126 @@ export function ChatPage({ } }; + // openPlan 打开计划窗口;右侧若收起则先展开。 + const openPlan = ( + preview: Parameters[0], + extra?: Parameters[1], + ) => { + columns.setRightOpen(true); + workbench.openPlan(preview, extra); + }; + // openFile 打开文件窗口;右侧若收起则先展开。 + const openFile = (change: Parameters[0]) => { + columns.setRightOpen(true); + workbench.openFile(change); + }; + // createDock 从窗口栏新建;右侧若收起则先展开。 + const createDock = ( + kind: Parameters[0], + seed?: Parameters[1], + ) => { + columns.setRightOpen(true); + workbench.createKind(kind, seed); + }; + return ( -
- void codexList.loadMore() : undefined} - onCreate={onNewConversation} - onSelect={(id, nextEngine) => onOpenSession(id, nextEngine ?? "agent")} - onRecover={async (runId) => { - await timeline.recover(runId); - await list.refresh(); - }} - canRecoverCurrent={activeEngine === "agent" && timeline.canRecover} - onArchive={async (session) => { - await hideSession(session); - }} - brandSrc={brandSrc} - codexIconSrc={codexIconSrc} - claudeIconSrc={claudeIconSrc} - /> -
- {sessionId ? ( -
- - {activeEngine === "codex" - ? "Codex 对话" +
+ {columns.leftOpen ? ( + <> + - {workspaceLabel ? ( - <> - · - void codexList.loadMore() : undefined} + onCreate={onNewConversation} + onSelect={(id, nextEngine) => onOpenSession(id, nextEngine ?? "agent")} + onRecover={async (runId) => { + await timeline.recover(runId); + await list.refresh(); + }} + canRecoverCurrent={activeEngine === "agent" && timeline.canRecover} + onArchive={async (session) => { + await hideSession(session); + }} + brandSrc={brandSrc} + codexIconSrc={codexIconSrc} + claudeIconSrc={claudeIconSrc} + /> + columns.setLeftOpen(false)} + /> + + ) : null} +
+
+ + {columns.leftOpen ? : } + + {!columns.leftOpen ? ( + + ) : null} + {sessionId ? ( + <> + + {activeEngine === "codex" + ? "Codex 对话" + : activeEngine === "claude" + ? "Claude 对话" + : "Local 对话"} + + {workspaceLabel ? ( + <> + · + + {workspaceLabel} + + + ) : null} + {activeEngine === "agent" && timeline.canRecover ? ( + - ) : null} - {headerActions ?
{headerActions}
: null} -
- ) : headerActions ? ( -
-
{headerActions}
-
- ) : null} + 恢复 + + ) : null} + + ) : null} +
+ {headerActions} + + {columns.rightOpen ? : } + +
+
{sessionId ? ( activeEngine === "codex" ? ( <> @@ -277,6 +367,8 @@ export function ChatPage({ state={timeline.state} loading={timeline.loading} scrollKey={sessionId} + onOpenPlan={openPlan} + onOpenFile={openFile} />
)}
+ {columns.rightOpen ? ( + <> + columns.moveRight(-delta, persist)} + onCollapse={() => columns.setRightOpen(false)} + /> + + + ) : null}
); } +// SidebarToggle 收起或展开一侧栏。 +function SidebarToggle({ + label, + onClick, + children, +}: { + label: string; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + // mergeSessions 把 Local / Codex / Claude 会话按更新时间合成侧栏列表,同引擎同 ID 只留更新的一条。 function mergeSessions( agent: Session[], diff --git a/packages/views/chat/column-layout.test.ts b/packages/views/chat/column-layout.test.ts new file mode 100644 index 0000000..8c6a8d2 --- /dev/null +++ b/packages/views/chat/column-layout.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { fitColumns, LEFT_MIN, MID_MIN, RIGHT_MIN } from "./column-layout.ts"; + +test("fitColumns keeps both sides when there is room", () => { + const got = fitColumns(240, 420, 1200); + assert.equal(got.left, 240); + assert.equal(got.right, 420); +}); + +test("fitColumns ignores a collapsed side", () => { + const leftClosed = fitColumns(240, 420, 800, { left: false, right: true }); + assert.equal(leftClosed.left, 0); + assert.ok(leftClosed.right >= RIGHT_MIN); + + const bothClosed = fitColumns(240, 420, 400, { left: false, right: false }); + assert.deepEqual(bothClosed, { left: 0, right: 0 }); +}); + +test("fitColumns still leaves the mid pane when only the left is open", () => { + const got = fitColumns(480, 860, LEFT_MIN + MID_MIN + 20, { left: true, right: false }); + assert.equal(got.right, 0); + assert.ok(got.left >= LEFT_MIN); + assert.ok(got.left + MID_MIN <= LEFT_MIN + MID_MIN + 20); +}); diff --git a/packages/views/chat/column-layout.ts b/packages/views/chat/column-layout.ts new file mode 100644 index 0000000..53871f1 --- /dev/null +++ b/packages/views/chat/column-layout.ts @@ -0,0 +1,242 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +const LEFT_KEY = "codedock.column.left"; +const RIGHT_KEY = "codedock.column.right"; +const LEFT_OPEN_KEY = "codedock.column.leftOpen"; +const RIGHT_OPEN_KEY = "codedock.column.rightOpen"; +const LEFT_DEFAULT = 240; +const RIGHT_DEFAULT = 420; +export const LEFT_MIN = 168; +export const LEFT_MAX = 480; +export const RIGHT_MIN = 240; +export const RIGHT_MAX = 860; +export const MID_MIN = 280; +const SASH = 6; + +type ColumnOpen = { + left: boolean; + right: boolean; +}; + +type ColumnWidths = { + left: number; + right: number; +}; + +// readStoredWidth 读本机记住的栏宽,坏值回默认。 +function readStoredWidth(key: string, fallback: number, min: number, max: number): number { + if (typeof window === "undefined") { + return fallback; + } + try { + const raw = window.localStorage.getItem(key); + const value = raw ? Number.parseInt(raw, 10) : fallback; + if (!Number.isFinite(value)) { + return fallback; + } + return clamp(value, min, max); + } catch { + return fallback; + } +} + +// writeStoredWidth 把栏宽记到本机。 +function writeStoredWidth(key: string, value: number): void { + try { + window.localStorage.setItem(key, String(Math.round(value))); + } catch { + // ignore + } +} + +// readStoredOpen 读本机记住的栏开合,坏值回默认。 +function readStoredOpen(key: string, fallback: boolean): boolean { + if (typeof window === "undefined") { + return fallback; + } + try { + const raw = window.localStorage.getItem(key); + if (raw === "0") { + return false; + } + if (raw === "1") { + return true; + } + return fallback; + } catch { + return fallback; + } +} + +// writeStoredOpen 把栏开合记到本机。 +function writeStoredOpen(key: string, open: boolean): void { + try { + window.localStorage.setItem(key, open ? "1" : "0"); + } catch { + // ignore + } +} + +// clamp 把数字限制在闭区间。 +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +// fitColumns 按总宽收紧仍打开的左右栏,保证中间对话还有 MID_MIN。 +export function fitColumns( + left: number, + right: number, + total: number, + open: ColumnOpen = { left: true, right: true }, +): ColumnWidths { + const sashes = (open.left ? 1 : 0) + (open.right ? 1 : 0); + const minSides = (open.left ? LEFT_MIN : 0) + (open.right ? RIGHT_MIN : 0); + const usable = Math.max(total - SASH * sashes, minSides + MID_MIN); + let nextLeft = open.left ? clamp(left, LEFT_MIN, LEFT_MAX) : 0; + let nextRight = open.right ? clamp(right, RIGHT_MIN, RIGHT_MAX) : 0; + const overflow = nextLeft + nextRight + MID_MIN - usable; + if (overflow <= 0) { + return { left: nextLeft, right: nextRight }; + } + if (open.right) { + const shrinkRight = Math.min(overflow, nextRight - RIGHT_MIN); + nextRight -= shrinkRight; + const remain = overflow - shrinkRight; + if (remain > 0 && open.left) { + nextLeft = Math.max(LEFT_MIN, nextLeft - remain); + } + } else if (open.left) { + nextLeft = Math.max(LEFT_MIN, nextLeft - overflow); + } + return { left: nextLeft, right: nextRight }; +} + +// useColumnLayout 记住并拖动三栏宽度,中间栏吃剩余空间;左右栏可收起。 +export function useColumnLayout() { + const rowRef = useRef(null); + const leftRef = useRef(LEFT_DEFAULT); + const rightRef = useRef(RIGHT_DEFAULT); + const leftOpenRef = useRef(true); + const rightOpenRef = useRef(true); + const [left, setLeft] = useState(LEFT_DEFAULT); + const [right, setRight] = useState(RIGHT_DEFAULT); + const [leftOpen, setLeftOpenState] = useState(true); + const [rightOpen, setRightOpenState] = useState(true); + + const commit = useCallback((nextLeft: number, nextRight: number, persist: boolean) => { + const open = { left: leftOpenRef.current, right: rightOpenRef.current }; + const sashes = (open.left ? 1 : 0) + (open.right ? 1 : 0); + const total = rowRef.current?.clientWidth ?? nextLeft + nextRight + MID_MIN + SASH * sashes; + const fitted = fitColumns(nextLeft, nextRight, total, open); + if (open.left) { + leftRef.current = fitted.left; + } + if (open.right) { + rightRef.current = fitted.right; + } + setLeft(fitted.left); + setRight(fitted.right); + if (persist) { + writeStoredWidth(LEFT_KEY, leftRef.current); + writeStoredWidth(RIGHT_KEY, rightRef.current); + } + }, []); + + useEffect(() => { + leftOpenRef.current = readStoredOpen(LEFT_OPEN_KEY, true); + rightOpenRef.current = readStoredOpen(RIGHT_OPEN_KEY, true); + setLeftOpenState(leftOpenRef.current); + setRightOpenState(rightOpenRef.current); + commit( + readStoredWidth(LEFT_KEY, LEFT_DEFAULT, LEFT_MIN, LEFT_MAX), + readStoredWidth(RIGHT_KEY, RIGHT_DEFAULT, RIGHT_MIN, RIGHT_MAX), + false, + ); + }, [commit]); + + useEffect(() => { + const node = rowRef.current; + if (!node || typeof ResizeObserver === "undefined") { + return; + } + const observer = new ResizeObserver(() => { + commit(leftRef.current, rightRef.current, false); + }); + observer.observe(node); + return () => observer.disconnect(); + }, [commit]); + + const moveLeft = useCallback( + (delta: number, persist = false) => { + if (!leftOpenRef.current) { + return; + } + commit(leftRef.current + delta, rightRef.current, persist); + }, + [commit], + ); + + const moveRight = useCallback( + (delta: number, persist = false) => { + if (!rightOpenRef.current) { + return; + } + commit(leftRef.current, rightRef.current + delta, persist); + }, + [commit], + ); + + // setLeftOpen 展开或收起会话列表,下次仍按这个开合。 + const setLeftOpen = useCallback( + (open: boolean) => { + if (leftOpenRef.current === open) { + return; + } + leftOpenRef.current = open; + setLeftOpenState(open); + writeStoredOpen(LEFT_OPEN_KEY, open); + commit(leftRef.current, rightRef.current, false); + }, + [commit], + ); + + // setRightOpen 展开或收起右侧窗口栏,下次仍按这个开合。 + const setRightOpen = useCallback( + (open: boolean) => { + if (rightOpenRef.current === open) { + return; + } + rightOpenRef.current = open; + setRightOpenState(open); + writeStoredOpen(RIGHT_OPEN_KEY, open); + commit(leftRef.current, rightRef.current, false); + }, + [commit], + ); + + // toggleLeft 切换会话列表开合。 + const toggleLeft = useCallback(() => { + setLeftOpen(!leftOpenRef.current); + }, [setLeftOpen]); + + // toggleRight 切换右侧窗口栏开合。 + const toggleRight = useCallback(() => { + setRightOpen(!rightOpenRef.current); + }, [setRightOpen]); + + return { + rowRef, + left, + right, + leftOpen, + rightOpen, + moveLeft, + moveRight, + setLeftOpen, + setRightOpen, + toggleLeft, + toggleRight, + }; +} diff --git a/packages/views/chat/column-sash.tsx b/packages/views/chat/column-sash.tsx new file mode 100644 index 0000000..decf783 --- /dev/null +++ b/packages/views/chat/column-sash.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useRef } from "react"; + +import { cn } from "@codedock/ui"; + +// ColumnSash 竖向拖条,拖动时连续回调位移,松开时记住宽度;双击收起该侧。 +export function ColumnSash({ + onMove, + onCollapse, + label, +}: { + onMove: (deltaX: number, persist: boolean) => void; + onCollapse?: () => void; + label: string; +}) { + const lastX = useRef(0); + const dragging = useRef(false); + const moveRef = useRef(onMove); + moveRef.current = onMove; + + return ( +
{ + if (event.button !== 0) { + return; + } + dragging.current = true; + lastX.current = event.clientX; + document.documentElement.classList.add("select-none", "cursor-col-resize"); + const onMovePointer = (next: PointerEvent) => { + if (!dragging.current) { + return; + } + const delta = next.clientX - lastX.current; + lastX.current = next.clientX; + if (delta !== 0) { + moveRef.current(delta, false); + } + }; + const onUp = () => { + if (!dragging.current) { + return; + } + dragging.current = false; + document.documentElement.classList.remove("select-none", "cursor-col-resize"); + window.removeEventListener("pointermove", onMovePointer); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + moveRef.current(0, true); + }; + window.addEventListener("pointermove", onMovePointer); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + event.preventDefault(); + }} + onDoubleClick={() => { + onCollapse?.(); + }} + onKeyDown={(event) => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + onMove(-16, true); + } + if (event.key === "ArrowRight") { + event.preventDefault(); + onMove(16, true); + } + }} + > + {label} + +
+ ); +} diff --git a/packages/views/chat/conversation-timeline.tsx b/packages/views/chat/conversation-timeline.tsx index e421d14..16ac335 100644 --- a/packages/views/chat/conversation-timeline.tsx +++ b/packages/views/chat/conversation-timeline.tsx @@ -1,9 +1,12 @@ "use client"; import { + compactToolDump, + fileChangeFromTool, latestPlanDocIds, planPreviewFromTool, - planToolDump, + type FileChangePreview, + type PlanPreview, type SessionState, type ThinkingPhase, type TimelineItem, @@ -13,12 +16,10 @@ import { Conversation, ConversationContent, ConversationEmptyState, + LiveStatus, Message, MessageContent, MessageResponse, - Reasoning, - ReasoningContent, - ReasoningTrigger, Tool, ToolContent, ToolGroup, @@ -31,32 +32,58 @@ import { } from "@codedock/ui"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { PlanPreviewCard } from "./plan-preview.tsx"; +import { FileText, FileCode2 } from "lucide-react"; const thinkingCopy: Record = { - queued: "排队中", - loading_context: "正在装载上下文", - running_llm: "正在思考", + queued: "Queued", + loading_context: "Loading context", + running_llm: "Thinking", + executing_tools: "Running tools", + waiting_approval: "Waiting for approval", + verifying: "Verifying", + evaluating: "Reviewing", + cancelling: "Cancelling", }; type ToolItem = Extract; +// isLiveTimelineItem 判断这条是否仍是进行中的工作状态。 +function isLiveTimelineItem(item: TimelineItem): boolean { + switch (item.kind) { + case "thinking": + return true; + case "assistant": + return item.streaming; + case "verify": + case "evaluate": + return item.status === "started"; + case "tool": + return item.state === "pending" || item.state === "running"; + default: + return false; + } +} + export function ConversationTimeline({ state, loading = false, scrollKey, emptyDescription, + onOpenPlan, + onOpenFile, }: { state: SessionState; loading?: boolean; scrollKey?: string; emptyDescription?: string; + onOpenPlan?: (preview: PlanPreview, extra?: { toolState?: ToolItem["state"]; error?: string }) => void; + onOpenFile?: (change: FileChangePreview) => void; }) { const items = state.items.filter( (item) => !(item.kind === "approval" && item.status === "pending") && !(item.kind === "user" && !item.text.trim()) && - !(item.kind === "assistant" && !item.text.trim() && !item.streaming), + !(item.kind === "assistant" && !item.text.trim()), ); if (items.length === 0) { if (loading) { @@ -93,9 +120,22 @@ export function ConversationTimeline({ {section.map((row, rowIndex) => { const latest = sectionIndex === sections.length - 1 && rowIndex === section.length - 1; return row.kind === "tools" ? ( - + ) : ( - + ); })} @@ -108,9 +148,11 @@ export function ConversationTimeline({ function TimelineRow({ item, latest = false, + live = false, }: { item: Exclude; latest?: boolean; + live?: boolean; }) { const latestProps = latest ? { "data-conversation-latest": "" } : {}; switch (item.kind) { @@ -118,33 +160,62 @@ function TimelineRow({ return ; case "thinking": return ( -
- - - {thinkingCopy[item.phase]} - +
+ {thinkingCopy[item.phase]}
); case "assistant": return ( - - {item.text || (item.streaming ? "…" : "")} - + {item.text} ); case "approval": return (
- {item.status === "denied" ? "已拒绝工具调用" : "已批准工具调用"} + {item.approvalKind === "verify" || item.approvalKind === "evaluate" + ? item.status === "denied" + ? "Rolled back and cancelled" + : "Override resolved" + : item.status === "denied" + ? "Tool calls denied" + : "Tool calls approved"} +
+ ); + case "verify": + return ( +
+ + {item.status === "started" + ? "Verifying" + : item.status === "skipped" + ? "No verify rules configured; treated as passed" + : item.status === "passed" + ? "Verification passed" + : `Verification failed${item.output ? `: ${item.output.slice(0, 120)}` : ""}`} + +
+ ); + case "evaluate": + return ( +
+ + {item.status === "started" + ? "Reviewing" + : item.status === "pass" + ? "Review passed" + : item.summary + ? `Review rejected: ${item.summary}` + : "Review requested more work"} +
); case "context": return (
- 上下文已压缩 + Context compacted seq {item.baseEventSeq}
); @@ -152,10 +223,10 @@ function TimelineRow({ return (
{item.status === "completed" - ? "本轮完成" + ? "Run completed" : item.status === "cancelled" - ? "已取消" - : `运行结束:${item.stopReason ?? item.status}`} + ? "Cancelled" + : `Run ended: ${item.stopReason ?? item.status}`}
); default: @@ -204,35 +275,45 @@ function rollupToolState(tools: ToolItem[]): ToolState { function ToolCallsRow({ tools, latest = false, + live = false, latestDocIds, + onOpenPlan, + onOpenFile, }: { tools: ToolItem[]; latest?: boolean; + live?: boolean; latestDocIds: Set; + onOpenPlan?: (preview: PlanPreview, extra?: { toolState?: ToolItem["state"]; error?: string }) => void; + onOpenFile?: (change: FileChangePreview) => void; }) { - const previews = tools.flatMap((item) => { + const artifacts = tools.flatMap((item) => { const preview = planPreviewFromTool(item); - if (!preview) { - return []; + if (preview) { + return [{ item, preview, change: null }]; } - if (!latestDocIds.has(item.id)) { - return []; + const change = fileChangeFromTool(item); + if (change) { + return [{ item, preview: null, change }]; } - return [{ item, preview }]; + return []; }); return (
0 ? "flex flex-col gap-3" : undefined} + className={artifacts.length > 0 ? "flex flex-col gap-2" : undefined} {...(latest ? { "data-conversation-latest": "" } : {})} > - + {tools.map((item) => { - const dump = planToolDump(item); + if (item.name === "explore") { + return ; + } + const dump = compactToolDump(item); return ( - + @@ -242,18 +323,73 @@ function ToolCallsRow({ })} - {previews.map(({ item, preview }) => ( - - ))} + {artifacts.length > 0 ? ( +
+ {artifacts.map(({ item, preview, change }) => + preview ? ( + onOpenPlan(preview, { toolState: item.state, error: item.error }) + : undefined + } + /> + ) : change ? ( + onOpenFile(change) : undefined} + /> + ) : null, + )} +
+ ) : null}
); } +// ArtifactChip 把计划和文件改动收成一行入口,点开后到右侧窗口。 +function ArtifactChip({ + icon, + label, + onClick, +}: { + icon: "plan" | "file"; + label: string; + onClick?: () => void; +}) { + return ( + + ); +} + +/** 折叠展示 explore 子代理结论,避免把长原文铺进主时间线。 */ +function ExploreCard({ item, live = false }: { item: ToolItem; live?: boolean }) { + const dump = compactToolDump(item); + return ( + + + +
Explore subagent
+ + +
+
+ ); +} + function toolsFollowKey(row: Extract): string { const parts = row.tools.map((tool) => { const preview = planPreviewFromTool(tool); @@ -428,7 +564,7 @@ function UserRow({ > {item.queued ? ( -
排队,当前轮结束后发送
+
Queued; will send after this run
) : null}
>> = { - pending: { write: "正在写入", read: "正在读取", doc: "处理中" }, - running: { write: "正在写入", read: "正在读取", doc: "处理中" }, - completed: { write: "已写入", read: "已读取", doc: "已完成" }, - error: { write: "写入失败", read: "读取失败", doc: "失败" }, - denied: { write: "已拒绝", read: "已拒绝", doc: "已拒绝" }, + pending: { write: "Writing", read: "Reading", doc: "Working" }, + running: { write: "Writing", read: "Reading", doc: "Working" }, + completed: { write: "Wrote", read: "Read", doc: "Done" }, + error: { write: "Write failed", read: "Read failed", doc: "Failed" }, + denied: { write: "Denied", read: "Denied", doc: "Denied" }, }; export function PlanPreviewCard({ preview, state = "completed", + live = false, error, className, }: { preview: PlanPreview; state?: ToolItemState; + live?: boolean; error?: string; className?: string; }) { @@ -49,9 +52,11 @@ export function PlanPreviewCard({ > - 计划 + Plan {preview.name} - {status} + + {status} + @@ -61,7 +66,7 @@ export function PlanPreviewCard({ @@ -69,7 +74,8 @@ export function PlanPreviewCard({ ); } -function PlanDocBody({ +// PlanDocBody 渲染计划 Markdown,给对话卡片和右侧窗口共用。 +export function PlanDocBody({ content, emptyHint, animating, @@ -79,7 +85,11 @@ function PlanDocBody({ animating: boolean; }) { if (!content.trim()) { - return

{emptyHint}

; + return ( +

+ {emptyHint} +

+ ); } return ( @@ -90,10 +100,10 @@ function PlanDocBody({ function emptyDocHint(source: "write" | "read", state: ToolItemState): string { if (source === "read" && (state === "pending" || state === "running")) { - return "正在读取…"; + return "Reading"; } if (source === "write" && (state === "pending" || state === "running")) { - return "正在写入…"; + return "Writing"; } - return "空计划"; + return "Empty plan"; } diff --git a/packages/views/chat/session-sidebar.tsx b/packages/views/chat/session-sidebar.tsx index ff0f38e..e39d6ce 100644 --- a/packages/views/chat/session-sidebar.tsx +++ b/packages/views/chat/session-sidebar.tsx @@ -25,6 +25,7 @@ export function SessionSidebar({ brandSrc, codexIconSrc, claudeIconSrc, + width, }: { sessions: SidebarSession[]; currentId?: string; @@ -40,9 +41,13 @@ export function SessionSidebar({ brandSrc?: string; codexIconSrc?: string; claudeIconSrc?: string; + width?: number; }) { return ( -