diff --git a/CHANGELOG.md b/CHANGELOG.md index fddeb39..8bf049b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ ### 新增 +- 桌面侧栏增加会话归档、归档列表和恢复入口;已归档会话可阅读,恢复后继续对话,重启后仍保留归档状态。 - 桌面输入框和 CLI/TUI 显示缓存命中率、上下文用量/容量、累计输入输出和费用估算;共享 Core 计算,区分缺失统计与零命中,支持容量预警、会话恢复与切换。非交互文本摘要写入 stderr,不污染 JSON 输出。 - 桌面右侧工作栏增加侧边对话、变更、交互终端、浏览器和文件预览;支持多标签、拖动宽度与窄窗口。侧边 Agent 具有独立上下文和审批,变更支持会话及上一轮快照,切换会话时清理原生资源,并补充真实 Runtime/PTY/浏览器冒烟测试。 diff --git a/README.md b/README.md index 875a2df..fed0e58 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,8 @@ Exit codes: ### Session restoration +In the desktop sidebar, hover a session to archive it. The archive button beside the sidebar heading opens archived sessions, where you can read or restore them. Archiving preserves history; restore a session to continue chatting. Automatic resume skips archived sessions. See [session archiving](docs/specs/session-archive.md). + ```powershell # Continue the latest session for the current project uv run coding-agent --continue diff --git a/README.zh-CN.md b/README.zh-CN.md index 73f9841..9413a15 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -144,6 +144,8 @@ uv run coding-agent --mode json "分析 packages/core" ### 会话恢复 +桌面侧栏悬停会话可点击归档图标;标题旁的归档入口可查看和恢复已归档会话。归档保留聊天记录,恢复后可继续对话;自动恢复最近会话会跳过归档记录。详见[会话归档](docs/specs/session-archive.md)。 + ```powershell # 恢复当前项目最近的会话 uv run coding-agent --continue diff --git a/apps/desktop/src/renderer/App.archive.test.tsx b/apps/desktop/src/renderer/App.archive.test.tsx new file mode 100644 index 0000000..ca51a2a --- /dev/null +++ b/apps/desktop/src/renderer/App.archive.test.tsx @@ -0,0 +1,243 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { VirtuosoMockContext } from "react-virtuoso"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEvent, SessionInfo, WorkspacePayload } from "../shared/types"; +import { App } from "./App"; + +type Record = { id: string; name: string; archived: boolean }; +const project = "G:\\Coding-agent"; +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +}; + +describe("desktop session archive", () => { + const request = vi.fn(); + let records: Map; + let listener: ((event: RuntimeEvent) => void) | undefined; + let sequence = 0; + let selected = "session-1"; + + const payload = (id = selected, path = project): WorkspacePayload => ({ + path, sessionId: id, archived: records.get(id)?.archived ?? false, + model: { id: "offline", name: "Offline", provider: "offline" }, + thinkingLevel: null, tools: [], collaborationMode: "default", + messages: [ + { role: "user", content: `Question in ${id}` }, + { role: "assistant", content: [{ type: "text", text: `Answer in ${id}` }] }, + ], + planState: { phase: "idle", activePlanId: null, latestRevision: null, pendingQuestion: null }, + memory: { enabled: false, userId: null, projectId: null }, + }); + const list = (archived: boolean): SessionInfo[] => [...records.values()] + .filter((record) => record.archived === archived) + .map((record) => ({ ...record, first_message: "", message_count: 2, modified: 1 })); + const mount = () => render( + + ); + const rowAction = (name: string, archived = false) => within(screen.getByRole("group", { name })) + .getByRole("button", { name: archived ? "恢复会话" : "归档会话" }); + const findRowAction = async (name: string, archived = false) => { + await screen.findByRole("group", { name }); + return rowAction(name, archived); + }; + const emit = async (type: string, body: object, sessionId = selected) => { + await act(async () => { + listener?.({ v: 1, type: "event", seq: ++sequence, timestamp: sequence, + sessionId, runId: null, event: { type, payload: body as { [key: string]: unknown } } }); + await new Promise(requestAnimationFrame); + }); + }; + + beforeEach(() => { + sequence = 0; + selected = "session-1"; + listener = undefined; + records = new Map([ + ["session-1", { id: "session-1", name: "Current conversation", archived: false }], + ["session-2", { id: "session-2", name: "Other conversation", archived: false }], + ["session-3", { id: "session-3", name: "Archived conversation", archived: true }], + ]); + request.mockReset(); + request.mockImplementation(async (method: string, params?: { sessionId?: string; archived?: boolean; path?: string }) => { + if (method === "workspace.open") return payload(selected, params?.path ?? project); + if (method === "session.open") { + selected = params!.sessionId!; + return payload(); + } + if (method === "session.list") return list(params?.archived === true); + if (method === "session.archive" || method === "session.unarchive") { + const record = records.get(params!.sessionId!)!; + record.archived = method === "session.archive"; + return { sessionId: record.id, archived: record.archived }; + } + if (method === "session.snapshot") return payload(); + if (method === "command.list") return []; + return {}; + }); + window.agent = { request, onEvent: (cb) => { listener = cb; return () => undefined; }, onStatus: () => () => undefined }; + window.desktop = { chooseWorkspace: async () => null, + getBootstrap: async () => ({ defaultWorkspace: project, platform: "win32" }) }; + }); + afterEach(() => { cleanup(); vi.clearAllMocks(); }); + + it("archives another conversation without opening it or replacing the current transcript", async () => { + mount(); + fireEvent.click(await findRowAction("Other conversation")); + await waitFor(() => expect(screen.queryByRole("group", { name: "Other conversation" })).not.toBeInTheDocument()); + expect(request).toHaveBeenCalledWith("session.archive", { sessionId: "session-2" }); + expect(request).not.toHaveBeenCalledWith("session.open", expect.anything()); + expect(screen.getByText("Answer in session-1")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("描述你想完成的任务…")).toBeEnabled(); + expect(screen.queryByText("此会话已归档")).not.toBeInTheDocument(); + }); + + it("archives the current conversation while preserving its transcript and draft", async () => { + mount(); + const composer = await screen.findByPlaceholderText("描述你想完成的任务…"); + fireEvent.change(composer, { target: { value: "preserved draft" } }); + fireEvent.click(await findRowAction("Current conversation")); + expect(await screen.findByText("此会话已归档")).toBeInTheDocument(); + expect(screen.getByText("Answer in session-1")).toBeInTheDocument(); + expect(composer).toBeDisabled(); + expect(composer).toHaveValue("preserved draft"); + expect(screen.getByRole("button", { name: "Plan" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Default" })).toBeDisabled(); + expect(request).not.toHaveBeenCalledWith("session.new"); + expect(request).not.toHaveBeenCalledWith("run.start", expect.anything()); + }); + + it("restores the selected conversation and returns focus to its enabled composer", async () => { + records.get(selected)!.archived = true; + mount(); + expect(await screen.findByText("此会话已归档")).toBeInTheDocument(); + const composer = screen.getByPlaceholderText("描述你想完成的任务…"); + expect(composer).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "恢复会话" })); + await waitFor(() => expect(composer).toBeEnabled()); + expect(request).toHaveBeenCalledWith("session.unarchive", { sessionId: "session-1" }); + expect(screen.queryByText("此会话已归档")).not.toBeInTheDocument(); + expect(screen.getByText("Answer in session-1")).toBeInTheDocument(); + await waitFor(() => expect(composer).toHaveFocus()); + }); + + it("opens the archived list and reads a conversation without silently restoring it", async () => { + mount(); + fireEvent.click(await screen.findByRole("button", { name: "查看已归档会话" })); + const item = await screen.findByRole("button", { name: /^Archived conversation/ }); + expect(request).toHaveBeenCalledWith("session.list", { archived: true }); + fireEvent.click(item); + expect(await screen.findByText("Answer in session-3")).toBeInTheDocument(); + expect(screen.getByText("此会话已归档")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("描述你想完成的任务…")).toBeDisabled(); + expect(request).toHaveBeenCalledWith("session.open", { sessionId: "session-3" }); + expect(request).not.toHaveBeenCalledWith("session.unarchive", expect.anything()); + }); + + it("restores another archived conversation without changing the selected conversation", async () => { + mount(); + fireEvent.click(await screen.findByRole("button", { name: "查看已归档会话" })); + fireEvent.click(await findRowAction("Archived conversation", true)); + await waitFor(() => expect(screen.queryByRole("group", { name: "Archived conversation" })).not.toBeInTheDocument()); + expect(request).toHaveBeenCalledWith("session.unarchive", { sessionId: "session-3" }); + expect(screen.getByText("Answer in session-1")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "返回会话" })); + expect(await findRowAction("Archived conversation")).toBeInTheDocument(); + }); + + it("reads persisted archive metadata from the backend after remounting", async () => { + const first = mount(); + fireEvent.click(await findRowAction("Other conversation")); + await waitFor(() => expect(records.get("session-2")!.archived).toBe(true)); + await waitFor(() => expect(screen.queryByRole("group", { name: "Other conversation" })).not.toBeInTheDocument()); + first.unmount(); + mount(); + await findRowAction("Current conversation"); + expect(screen.queryByRole("group", { name: "Other conversation" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "查看已归档会话" })); + expect(await findRowAction("Other conversation", true)).toBeInTheDocument(); + }); + + it("keeps the conversation and authoritative state when archive RPC fails", async () => { + const fallback = request.getMockImplementation()!; + request.mockImplementation((method: string, params?: object) => method === "session.archive" + ? Promise.reject(new Error("disk write failed")) : fallback(method, params)); + mount(); + fireEvent.click(await findRowAction("Current conversation")); + expect(await screen.findByRole("alert")).toHaveTextContent("归档会话失败:disk write failed"); + expect(rowAction("Current conversation")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("描述你想完成的任务…")).toBeEnabled(); + expect(screen.queryByText("此会话已归档")).not.toBeInTheDocument(); + }); + + it("waits for the RPC before removing the row and suppresses duplicate archive clicks", async () => { + const pending = deferred<{ sessionId: string; archived: boolean }>(); + const fallback = request.getMockImplementation()!; + request.mockImplementation((method: string, params?: object) => method === "session.archive" + ? pending.promise : fallback(method, params)); + mount(); + const archive = await findRowAction("Other conversation"); + fireEvent.click(archive); + fireEvent.click(archive); + expect(archive).toBeDisabled(); + expect(screen.getByRole("button", { name: /^Other conversation/ })).toBeInTheDocument(); + expect(request.mock.calls.filter(([method]) => method === "session.archive")).toHaveLength(1); + await act(async () => { + records.get("session-2")!.archived = true; + pending.resolve({ sessionId: "session-2", archived: true }); + }); + await waitFor(() => expect(screen.queryByRole("group", { name: "Other conversation" })).not.toBeInTheDocument()); + }); + + it("disables archive controls while a run is active", async () => { + mount(); + const archive = await findRowAction("Current conversation"); + await emit("run.started", {}); + expect(archive).toBeDisabled(); + expect(screen.getByRole("button", { name: "查看已归档会话" })).toBeDisabled(); + fireEvent.click(archive); + expect(request).not.toHaveBeenCalledWith("session.archive", expect.anything()); + }); + + it("ignores an archived-list response after returning to the active filter", async () => { + const pending = deferred(); + const fallback = request.getMockImplementation()!; + request.mockImplementation((method: string, params?: { archived?: boolean }) => method === "session.list" && params?.archived + ? pending.promise : fallback(method, params)); + mount(); + await findRowAction("Current conversation"); + fireEvent.click(screen.getByRole("button", { name: "查看已归档会话" })); + await waitFor(() => expect(request).toHaveBeenCalledWith("session.list", { archived: true })); + fireEvent.click(screen.getByRole("button", { name: "返回会话" })); + await findRowAction("Current conversation"); + await act(async () => { pending.resolve(list(true)); }); + expect(rowAction("Current conversation")).toBeInTheDocument(); + expect(screen.queryByRole("group", { name: "Archived conversation" })).not.toBeInTheDocument(); + }); + + it("resets the archived filter when a different project opens", async () => { + window.desktop.chooseWorkspace = async () => "G:\\Other-project"; + mount(); + fireEvent.click(await screen.findByRole("button", { name: "查看已归档会话" })); + await findRowAction("Archived conversation", true); + fireEvent.click(screen.getByRole("button", { name: "打开项目" })); + expect(await screen.findByRole("button", { name: "查看已归档会话" })).toBeInTheDocument(); + expect(request).toHaveBeenCalledWith("workspace.open", { path: "G:\\Other-project", resume: true }); + expect(await findRowAction("Current conversation")).toBeInTheDocument(); + }); + + it("applies authoritative archive and restore events to the selected conversation", async () => { + mount(); + const composer = await screen.findByPlaceholderText("描述你想完成的任务…"); + records.get("session-1")!.archived = true; + await emit("session.archived", { sessionId: "session-1", archived: true }); + expect(screen.getByText("此会话已归档")).toBeInTheDocument(); + expect(composer).toBeDisabled(); + records.get("session-1")!.archived = false; + await emit("session.unarchived", { sessionId: "session-1", archived: false }); + expect(screen.queryByText("此会话已归档")).not.toBeInTheDocument(); + expect(composer).toBeEnabled(); + }); +}); diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index c9f8b73..3bab8e9 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -16,6 +16,7 @@ import type { } from "../shared/types"; import { PermissionPicker } from "./components/PermissionPicker"; import { UsageMetrics } from "./components/UsageMetrics"; +import { ArchiveIcon, SessionList } from "./components/SessionList"; import { SubagentPanel } from "./components/SubagentPanel"; import { ToolCard } from "./components/tool-card"; import { Workbench } from "./workbench/Workbench"; @@ -243,6 +244,15 @@ export function App() { const [sidecarStatus, setSidecarStatus] = useState("starting"); const [workspace, setWorkspace] = useState(null); const [sessions, setSessions] = useState([]); + const [showArchivedSessions, setShowArchivedSessions] = useState(false); + const archivedViewRef = useRef(false); + const sessionListRequestRef = useRef(0); + const workspacePathRef = useRef(null); + const [sessionsLoading, setSessionsLoading] = useState(false); + const [sessionListError, setSessionListError] = useState(null); + const [pendingSessionArchive, setPendingSessionArchive] = useState(null); + const sessionArchiveRef = useRef(null); + const restoredSessionFocusRef = useRef(null); const [timelineState, setTimelineState] = useState(() => createTimelineState()); const [commandOptions, setCommandOptions] = useState([]); const [commandMenuOpen, setCommandMenuOpen] = useState(false); @@ -280,13 +290,67 @@ export function App() { const workspaceGenerationRef = useRef(0); const refreshSessions = async () => { + const requestId = ++sessionListRequestRef.current; + const path = workspacePathRef.current; + setSessionsLoading(true); + setSessionListError(null); try { - setSessions(await window.agent.request("session.list")); - } catch { - setSessions([]); + const result = archivedViewRef.current + ? await window.agent.request("session.list", { archived: true }) + : await window.agent.request("session.list"); + if (requestId === sessionListRequestRef.current && path === workspacePathRef.current) setSessions(result); + } catch (reason) { + if (requestId === sessionListRequestRef.current && path === workspacePathRef.current) { + setSessionListError(`加载会话失败:${reason instanceof Error ? reason.message : String(reason)}`); + } + } finally { + if (requestId === sessionListRequestRef.current) setSessionsLoading(false); + } + }; + + const toggleArchivedSessions = () => { + archivedViewRef.current = !archivedViewRef.current; + setShowArchivedSessions(archivedViewRef.current); + setSessions([]); + void refreshSessions(); + }; + + const setSessionArchived = async (sessionId: string, archived: boolean) => { + if (sessionArchiveRef.current || sessionNavigationRef.current || running || compacting) return; + const path = workspacePathRef.current; + sessionArchiveRef.current = sessionId; + setPendingSessionArchive(sessionId); + setError(null); + try { + const result = await window.agent.request<{ sessionId: string; archived: boolean }>( + archived ? "session.archive" : "session.unarchive", { sessionId }, + ); + if (path !== workspacePathRef.current) return; + if (!result.archived) restoredSessionFocusRef.current = result.sessionId; + if (result.archived && workspaceSessionIdRef.current === result.sessionId) { + setCommandMenuOpen(false); + setModelPickerOpen(false); + setPlanCommandMenuOpen(false); + } + setWorkspace((current) => current?.sessionId === result.sessionId + ? { ...current, archived: result.archived } : current); + await refreshSessions(); + } catch (reason) { + if (path !== workspacePathRef.current) return; + setError(`${archived ? "归档" : "恢复"}会话失败:${reason instanceof Error ? reason.message : String(reason)}`); + await refreshSessions(); + } finally { + sessionArchiveRef.current = null; + setPendingSessionArchive(null); } }; + useEffect(() => { + if (pendingSessionArchive || !restoredSessionFocusRef.current) return; + if (workspace?.sessionId === restoredSessionFocusRef.current && !workspace.archived) composerRef.current?.focus(); + restoredSessionFocusRef.current = null; + }, [pendingSessionArchive, workspace?.sessionId, workspace?.archived]); + const refreshCommands = async () => { try { setCommandOptions(await window.agent.request("command.list")); @@ -297,6 +361,12 @@ export function App() { const applyWorkspace = (payload: WorkspacePayload, restoreTimeline = true) => { workspaceGenerationRef.current += 1; + if (workspacePathRef.current !== payload.path) { + archivedViewRef.current = false; + setShowArchivedSessions(false); + setSessions([]); + } + workspacePathRef.current = payload.path; if (restoreTimeline) { // RPC results are already authoritative. A snapshot queued for the old // session must not undo a successful switch in the next animation frame. @@ -339,6 +409,7 @@ export function App() { if (!current) return current; return { ...current, + archived: snapshot.archived ?? current.archived, collaborationMode: snapshot.collaborationMode, planState: snapshot.planState, messages: snapshot.messages, @@ -493,6 +564,12 @@ export function App() { const handleRuntimeSideEffects = (envelope: RuntimeEvent) => { const { type, payload } = envelope.event; + if (type === "session.archived" || type === "session.unarchived") { + setWorkspace((current) => current && current.sessionId === payload.sessionId + ? { ...current, archived: payload.archived === true } : current); + void refreshSessions(); + return; + } if (type !== "session.changed" && envelope.sessionId && envelope.sessionId !== workspaceSessionIdRef.current) return; const activeSubmission = planSubmissionRef.current; @@ -910,7 +987,8 @@ export function App() { const submit = async (event?: FormEvent) => { event?.preventDefault(); const text = input.trim(); - if (!text || !workspace || running || compacting || planSubmissionRef.current || sessionNavigationRef.current) return; + if (!text || !workspace || running || compacting || planSubmissionRef.current + || sessionNavigationRef.current || sessionArchiveRef.current || workspace.archived) return; if (text.startsWith("/")) { const [rawName, ...rest] = text.slice(1).split(/\s+/); const command = commandOptions.find((item) => item.name === rawName.toLocaleLowerCase()); @@ -1274,7 +1352,7 @@ export function App() { }, []); const navigateSession = async (sessionId?: string) => { - if (running || compacting || !workspace || sessionNavigationRef.current + if (running || compacting || !workspace || sessionNavigationRef.current || sessionArchiveRef.current || (sessionId && sessionId === workspaceSessionIdRef.current)) return; sessionNavigationRef.current = true; workspaceGenerationRef.current += 1; @@ -1284,6 +1362,10 @@ export function App() { const result = sessionId ? await window.agent.request("session.open", { sessionId }) : await window.agent.request("session.new"); + if (!sessionId) { + archivedViewRef.current = false; + setShowArchivedSessions(false); + } applyWorkspace(result); requestAnimationFrame(() => composerRef.current?.focus()); } catch (reason) { @@ -1325,6 +1407,9 @@ export function App() { const planBadgeLabel = planStatusPhase === "ready" ? "PLAN READY" : planStatusPhase === "uncertain" ? "PLAN UNCERTAIN" : planStatusPhase === "recovery_error" ? "PLAN RECOVERY" : "PLAN"; + const conversationReadOnly = Boolean(workspace?.archived) || Boolean(pendingSessionArchive); + const composerBlocked = !workspace || awaitingPlanDecision || planCommandMenuOpen + || compacting || sessionNavigating || Boolean(planStatusMessage) || conversationReadOnly; const timelineItems = useMemo(() => selectTimelineItems(timelineState), [timelineState]); const renderedPlan = ["ready", "uncertain", "recovery_error"].includes(planStatusPhase ?? "") @@ -1425,40 +1510,16 @@ export function App() {
diff --git a/apps/desktop/src/renderer/components/SessionList.tsx b/apps/desktop/src/renderer/components/SessionList.tsx new file mode 100644 index 0000000..09926e5 --- /dev/null +++ b/apps/desktop/src/renderer/components/SessionList.tsx @@ -0,0 +1,77 @@ +import type { SessionInfo } from "../../shared/types"; + +export function ArchiveIcon({ restore = false }: { restore?: boolean }) { + return ; +} + +interface Props { + sessions: SessionInfo[]; + archived: boolean; + activeId?: string; + disabled: boolean; + loading: boolean; + error: string | null; + pendingNavigation: string | null; + pendingArchive: string | null; + onToggleArchive: () => void; + onNew: () => void; + onOpen: (id: string) => void; + onArchive: (id: string, archived: boolean) => void; + onRetry: () => void; +} + +export function SessionList(props: Props) { + return <> +
+ {props.archived ? "已归档" : "会话"} +
+ + +
+
+ {props.pendingNavigation &&
+
} +
+ {props.error &&
{props.error} + +
} + {props.sessions.map((session) => { + const label = session.name || session.first_message || "新会话"; + return
+ + +
; + })} + {!props.error && !props.sessions.length &&
+ {props.loading ? "正在加载会话…" : props.archived ? "暂无已归档会话。" : "发送第一条消息后,会话会保存到 JSONL。"} +
} +
+ ; +} diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index 955b844..94fca51 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -119,6 +119,29 @@ button:focus-visible, textarea:focus-visible { outline: 2px solid #8e8a82; outli .session-item.active { background: var(--sidebar-active); box-shadow: none; } .session-item strong { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 12px; font-weight: 590; } .session-item span, .empty-sidebar { color: var(--muted); font-size: 10px; } +.session-heading-actions { display: flex; align-items: center; gap: 3px; } +.session-heading-actions .icon-button { display: grid; place-items: center; } +.session-row { position: relative; } +.session-row .session-item { padding-right: 40px; } +.session-row .session-item strong { max-width: 100%; } +.session-archive-action { + position: absolute; right: 5px; top: 10px; display: grid; place-items: center; + width: 28px; height: 28px; padding: 0; border: 0; border-radius: 6px; + color: var(--muted); background: var(--sidebar); cursor: pointer; opacity: 0; +} +.session-row:hover .session-archive-action, .session-row:focus-within .session-archive-action, +.session-archive-action:focus-visible { opacity: 1; } +.session-archive-action:hover { color: var(--text); background: var(--sidebar-hover); } +.session-archive-action:disabled { cursor: wait; } +.session-retry { margin-left: 8px; border: 0; background: transparent; color: inherit; text-decoration: underline; cursor: pointer; } +.session-archive-banner { + display: flex; align-items: center; gap: 10px; padding: 12px 14px; + border: 1px solid var(--line); border-radius: 10px; background: var(--surface); color: var(--muted); +} +.session-archive-banner div { display: flex; flex: 1; flex-direction: column; gap: 3px; font-size: 12px; } +.session-archive-banner strong { color: var(--text); font-weight: 550; } +.session-plan-controls { display: contents; } +@media (hover: none) { .session-archive-action { opacity: 1; } } .empty-sidebar { padding: 12px 10px; line-height: 1.6; } .workspace-meta { margin: 8px; diff --git a/apps/desktop/src/renderer/workbench/Workbench.test.tsx b/apps/desktop/src/renderer/workbench/Workbench.test.tsx index d467160..9d60b15 100644 --- a/apps/desktop/src/renderer/workbench/Workbench.test.tsx +++ b/apps/desktop/src/renderer/workbench/Workbench.test.tsx @@ -82,6 +82,49 @@ describe("workspace workbench", () => { expect(screen.getByRole("button", { name: /^侧边对话/ })).toBeVisible(); }); + it("allows reading archived side conversations without creating or sending one", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /^侧边对话/ })); + await screen.findByRole("option", { name: "Side question" }); + expect(screen.getByRole("button", { name: "新建侧边对话" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "开始侧边对话" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "新建侧边对话" })); + fireEvent.click(screen.getByRole("button", { name: "开始侧边对话" })); + expect(request).not.toHaveBeenCalledWith("workbench.chat.open", scope); + + fireEvent.change(screen.getByRole("combobox", { name: "选择侧边对话" }), { target: { value: "child" } }); + const input = await screen.findByLabelText("侧边对话消息"); + expect(request).toHaveBeenCalledWith("workbench.chat.open", { ...scope, conversationId: "child" }); + expect(input).toBeDisabled(); + expect(screen.getByRole("button", { name: "发送 ↑" })).toBeDisabled(); + await waitFor(() => expect(screen.getByRole("combobox", { name: "选择侧边对话" })).toBeEnabled()); + }); + + it("updates an open side conversation to read-only on archive and preserves its draft on restore", async () => { + const view = render(); + fireEvent.click(screen.getByRole("button", { name: /^侧边对话/ })); + fireEvent.click(screen.getByRole("button", { name: "开始侧边对话" })); + const input = await screen.findByLabelText("侧边对话消息"); + await waitFor(() => expect(input).toBeEnabled()); + fireEvent.change(input, { target: { value: "Preserved side draft" } }); + + view.rerender(); + expect(input).toBeDisabled(); + expect(screen.getByRole("button", { name: "发送 ↑" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "新建侧边对话" })).toBeDisabled(); + fireEvent.submit(input.closest("form")!); + fireEvent.keyDown(input, { key: "Enter" }); + expect(request.mock.calls.some(([method]) => method === "workbench.chat.send")).toBe(false); + expect(input).toHaveValue("Preserved side draft"); + + view.rerender(); + expect(input).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "发送 ↑" })); + await waitFor(() => expect(request).toHaveBeenCalledWith("workbench.chat.send", { + ...scope, conversationId: "child", text: "Preserved side draft", + })); + }); + it("limits pointer and keyboard resizing by sidebar and conversation widths", () => { vi.stubGlobal("innerWidth", 1440); vi.stubGlobal("PointerEvent", MouseEvent); diff --git a/apps/desktop/src/renderer/workbench/Workbench.tsx b/apps/desktop/src/renderer/workbench/Workbench.tsx index add1f9e..5126e74 100644 --- a/apps/desktop/src/renderer/workbench/Workbench.tsx +++ b/apps/desktop/src/renderer/workbench/Workbench.tsx @@ -81,7 +81,7 @@ export const Workbench = memo(function Workbench({ workspace, open, onClose, wid return ; diff --git a/apps/desktop/src/renderer/workbench/panels.tsx b/apps/desktop/src/renderer/workbench/panels.tsx index ce78ae4..81886ec 100644 --- a/apps/desktop/src/renderer/workbench/panels.tsx +++ b/apps/desktop/src/renderer/workbench/panels.tsx @@ -149,7 +149,7 @@ function messageText(message: AgentMessage): string { return typeof message.content === "string" ? message.content : message.content.map((block) => block.text ?? "").join(""); } -export function SideChatPanel({ scope }: { scope: WorkbenchScope }) { +export function SideChatPanel({ scope, readOnly = false }: { scope: WorkbenchScope; readOnly?: boolean }) { const [conversations, setConversations] = useState<{ conversationId: string; title: string }[]>([]); const [snapshot, setSnapshot] = useState(null); const [drafts, setDrafts] = useState>({}); @@ -171,6 +171,7 @@ export function SideChatPanel({ scope }: { scope: WorkbenchScope }) { }, [scope, refreshList]); useEffect(() => { bottom.current?.scrollIntoView?.({ block: "nearest" }); }, [snapshot?.streaming, snapshot?.messages.length]); const open = async (conversationId?: string) => { + if (readOnly && !conversationId) return; const generation = ++navigation.current; const seq = eventSeq.current; selected.current = conversationId ?? null; @@ -185,7 +186,8 @@ export function SideChatPanel({ scope }: { scope: WorkbenchScope }) { finally { if (generation === navigation.current) setBusy(false); } }; const request = async (method: string, extra: Record = {}) => { - if (!snapshot || busy) return; + if (!snapshot || busy || (readOnly && (method === "workbench.chat.send" + || (method === "workbench.chat.approve" && extra.allowed === true)))) return; const id = snapshot.conversationId, generation = navigation.current, seq = eventSeq.current; setBusy(true); setError(null); try { @@ -197,10 +199,10 @@ export function SideChatPanel({ scope }: { scope: WorkbenchScope }) { }; const draft = drafts[snapshot?.conversationId ?? ""] ?? ""; return
-
+

独立上下文 · 使用当前会话可用的开发工具 · 不会向主对话注入消息

- {!snapshot &&

在独立对话中追问,不打断当前任务。

} + {!snapshot &&

在独立对话中追问,不打断当前任务。

} {snapshot?.messages.map((message, index) => message.role === "toolResult" ?
{message.tool_name || "工具结果"}{message.is_error ? " · 失败" : ""}
{messageText(message)}
:
{message.role === "user" ? "你" : "AI"}{messageText(message)}
)} {snapshot?.streaming &&
AI{snapshot.streaming}
} {snapshot?.status === "running" && !snapshot.streaming &&

侧边对话正在处理…

} @@ -208,10 +210,10 @@ export function SideChatPanel({ scope }: { scope: WorkbenchScope }) { {error &&

{error}

}
- {snapshot?.approval &&
批准 {snapshot.approval.toolName}?
{JSON.stringify(snapshot.approval.args, null, 2)}
} - {snapshot &&
{ event.preventDefault(); if (draft.trim() && snapshot.status !== "running") void request("workbench.chat.send", { text: draft }); }}> -