diff --git a/cmd/cyberagent-desktop/main.go b/cmd/cyberagent-desktop/main.go index 91d153565..f67c2da56 100644 --- a/cmd/cyberagent-desktop/main.go +++ b/cmd/cyberagent-desktop/main.go @@ -883,7 +883,7 @@ func runDesktop(config desktopOptions) error { appOptions := &options.App{ Title: app.Name, Width: defaultDesktopWidth, Height: defaultDesktopHeight, - MinWidth: 320, MinHeight: 320, + MinWidth: 480, MinHeight: 600, Frameless: true, WindowStartState: options.Normal, BackgroundColour: options.NewRGBA(0, 0, 0, 0), diff --git a/docs/images/appearance/narrow-dark.png b/docs/images/appearance/narrow-dark.png new file mode 100644 index 000000000..08388466c Binary files /dev/null and b/docs/images/appearance/narrow-dark.png differ diff --git a/docs/images/appearance/narrow-mist.png b/docs/images/appearance/narrow-mist.png new file mode 100644 index 000000000..68eb09c1a Binary files /dev/null and b/docs/images/appearance/narrow-mist.png differ diff --git a/docs/images/composer/new-conversation.png b/docs/images/composer/new-conversation.png new file mode 100644 index 000000000..95c731e9c Binary files /dev/null and b/docs/images/composer/new-conversation.png differ diff --git a/docs/images/composer/queued-messages.png b/docs/images/composer/queued-messages.png new file mode 100644 index 000000000..0feda4485 Binary files /dev/null and b/docs/images/composer/queued-messages.png differ diff --git a/docs/usage.md b/docs/usage.md index f5ff00b2c..79fa00ee5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -51,6 +51,35 @@ Screenshots were captured from a production build in Edge on Windows at 1440 × 2026-10-19. Project content, replies, and usage are offline demonstration data. The README combines the Light and Dark captures of the same conversation. +### 窄窗口 / Narrow windows + +桌面窗口最小尺寸为 **480 × 600 逻辑像素**;系统显示缩放会影响截图中的实际像素数。 +The desktop window has a minimum size of **480 × 600 logical pixels**; display scaling changes the physical pixel count. + +两侧面板按窗口宽度依次收起:左侧栏展开时,文件或终端面板在 ≤960px 收起; +左侧栏在 ≤720px 收起。左侧栏已隐藏时,右侧面板的阈值为 ≤720px。 +重新拉宽后恢复原文件或终端,手动关闭的面板保持关闭。左侧使用 300ms、右侧使用 500ms +的推拉过渡;系统开启「减少动态效果」后立即切换。 +The file or terminal pane collapses at ≤960px with the sidebar open, or ≤720px with it hidden. +The sidebar collapses at ≤720px. Widening restores the selected file or terminal; a manually closed pane stays closed. +Transitions take 300ms on the left and 500ms on the right, and are disabled when reduced motion is enabled. + +窗口变窄时,侧栏收为可打开的浮层,并保留宽屏时的显示偏好。标题与常用工具合为一行, +「审阅改动」「查看上下文」可从右上角对话菜单打开。输入框保留常用图标和模型选择, +窄输入框的编辑模式切换移入「+」菜单;窗口缩放、切换编辑模式都保留草稿。 +底部统计在窄窗口保持一行,点击轮次或用量可查看生成速度、缓存命中等完整说明。 +The sidebar becomes an overlay in narrow windows and remembers its desktop visibility. +The title and frequent tools share one row. Review and context move into the conversation menu; editor mode moves into the composer’s +**+** menu. Drafts remain intact when resizing or switching editors. +Statistics stay on one line; click activity or usage to see generation speed, cache usage and metric details. + +| 深色 / Dark | 雾透 / Mist | +| --- | --- | +| ![480px 深色对话](images/appearance/narrow-dark.png) | ![480px 雾透对话](images/appearance/narrow-mist.png) | + +以上为 480 × 844 的 Windows / Edge 生产构建截图,使用本地离线演示数据。 +These 480 × 844 captures use the production build in Edge on Windows with local demo data. + ## Portable Build Diagnostics ```powershell @@ -68,6 +97,24 @@ false until that matrix is signed. ## Threads, Runs, and advanced diagnostics / Thread、Run 与高级诊断 +### 补充消息与排队 / Follow-up messages and queue + +任务运行中,发送的补充消息会排到输入框上方,当前执行结束后依次处理。文字消息可点击 +条目右侧的「引导」加入当前任务;「撤回」取消待处理消息,更多菜单提供编辑和全文、附件预览。 +图片和文件附件保留在下一轮消息中。输入框增高时,排队卡片随它向上移动;多条消息在卡片内滚动。 +底栏按输入框的可用宽度收起次要标签,模型名称过长时省略显示,完整名称仍可在模型菜单查看。 + +![紧凑宽度下的新对话输入框](images/composer/new-conversation.png) + +![输入框上方的待处理消息与引导操作](images/composer/queued-messages.png) + +以上为 Windows / Edge 生产构建截图,任务与回复使用本地离线演示数据。 + +Follow-up messages sent during execution appear above the composer and run in order afterward. +Use **Steer** on a text-only queued message to add it to the current task, **Withdraw** to cancel it, +or the more menu to edit and preview its full content and attachments. Images and files stay queued +for the next turn. The queue moves with the composer as it grows and scrolls internally when needed. + ### 用量统计 / Usage 在「设置 → 用量统计」查看 Universal-Code 自身的模型调用。今日、近 30 天和全部 diff --git a/web/e2e/workbench.spec.ts b/web/e2e/workbench.spec.ts index 3dae34da0..dde8bac15 100644 --- a/web/e2e/workbench.spec.ts +++ b/web/e2e/workbench.spec.ts @@ -85,7 +85,7 @@ async function narrowConversation(page: Page) { await page.setViewportSize({ width: 390, height: 844 }); const toggle = page.getByRole("button", { name: /^(?:显示|隐藏)侧栏$/u }); await expect(toggle).toBeVisible(); - if (await toggle.getAttribute("aria-pressed") === "true") await toggle.click(); + // Resizing closes the sidebar automatically; clicking during that change can reopen it. await expect(toggle).toHaveAttribute("aria-pressed", "false"); await expect(page.getByRole("navigation", { name: "对话导航", exact: true })).toBeHidden(); } diff --git a/web/src/styles.css b/web/src/styles.css index caafe4e88..01dcad0fb 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -28,10 +28,9 @@ } :root { - --prayu-font-ui: "JetBrains Mono Variable", "JetBrains Mono", "HarmonyOS Sans SC", - "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", monospace; - --prayu-font-display: "JetBrains Mono Variable", "JetBrains Mono", "HarmonyOS Sans SC", - "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", monospace; + --prayu-font-ui: "Segoe UI", "HarmonyOS Sans SC", "Microsoft YaHei UI", + "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", system-ui, sans-serif; + --prayu-font-display: var(--prayu-font-ui); --prayu-font-mono: "JetBrains Mono Variable", "JetBrains Mono", "HarmonyOS Sans SC", "Cascadia Code", Consolas, monospace; --prayu-prose-size: 15px; @@ -2643,8 +2642,8 @@ body { background: transparent; } --prayu-selected: var(--prayu-primary-bg); --prayu-selected-ink: var(--prayu-primary-fg); --prayu-surface-solid: #eff1f5; - --prayu-text: #4c4f69; - --prayu-muted: #5c5f77; + --prayu-text: #30363d; + --prayu-muted: #697078; --prayu-border: var(--dsh-glass-rim-soft); --prayu-border-strong: var(--dsh-glass-rim); --prayu-shadow: rgb(0 0 0 / 12%); diff --git a/web/src/styles.test.ts b/web/src/styles.test.ts index b0e807cab..01f44ff5b 100644 --- a/web/src/styles.test.ts +++ b/web/src/styles.test.ts @@ -32,20 +32,17 @@ describe("workbench Composer layout", () => { }); describe("product typography roles", () => { - it("uses the approved JetBrains and HarmonyOS stacks for interface and display text", () => { + it("uses proportional interface fonts and shares them with display text", () => { const uiStack = styles.match(/--prayu-font-ui:\s*([^;]+);/u)?.[1] ?? ""; const displayStack = styles.match(/--prayu-font-display:\s*([^;]+);/u)?.[1] ?? ""; - expect(uiStack).toContain("JetBrains Mono Variable"); + expect(uiStack).toMatch(/^"Segoe UI",/u); expect(uiStack).toContain("HarmonyOS Sans SC"); expect(uiStack).toContain("Microsoft YaHei UI"); - expect(uiStack).toContain("monospace"); - expect(uiStack).not.toContain("Cascadia Code"); - expect(displayStack).toContain("JetBrains Mono Variable"); - expect(displayStack).toContain("HarmonyOS Sans SC"); - expect(displayStack).toContain("Microsoft YaHei UI"); - expect(displayStack).toContain("PingFang SC"); - expect(displayStack).toContain("monospace"); + expect(uiStack).toContain("PingFang SC"); + expect(uiStack).toMatch(/system-ui,\s*sans-serif$/u); + expect(uiStack).not.toMatch(/JetBrains Mono|Cascadia Code|monospace/u); + expect(displayStack).toBe("var(--prayu-font-ui)"); }); it("loads the four byte-identical vendor HarmonyOS Sans TTF weights", () => { diff --git a/web/src/v2/app.test.tsx b/web/src/v2/app.test.tsx index e235830b5..a31ef398e 100644 --- a/web/src/v2/app.test.tsx +++ b/web/src/v2/app.test.tsx @@ -160,7 +160,7 @@ describe("V2Workbench inspector navigation", () => { }); it("closes the narrow navigation drawer after new-task and settings navigation while retaining the draft", async () => { vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ - matches: query === "(max-width: 760px)", media: query, + matches: query === "(max-width: 720px)", media: query, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(), onchange: null, }))); @@ -206,6 +206,48 @@ describe("V2Workbench inspector navigation", () => { expectDrawerClosed(); }); + it("automatically collapses the sidebar when squeezed to narrow width and restores it when widened", async () => { + let narrowListener: ((event: MediaQueryListEvent) => void) | undefined; + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: false, media: query, + addEventListener: vi.fn((type: string, listener: (event: MediaQueryListEvent) => void) => { + if (query === "(max-width: 720px)" && type === "change") narrowListener = listener; + }), + removeEventListener: vi.fn(), + addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(), onchange: null, + }))); + window.history.replaceState({}, "", `#/threads/${createdThread.id}`); + const client = { hasThreadControl: true, getPage: vi.fn(async (path: string) => ({ + items: path === "/workspaces" ? [workspace] : [createdThread], + page: { limit: 100 }, requestID: path, + })) } as unknown as APIClient; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + await screen.findByTestId("v2-conversation"); + expect(screen.getByRole("navigation", { name: "对话导航" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "隐藏侧栏" })).toHaveAttribute("aria-pressed", "true"); + + act(() => { narrowListener?.({ matches: true } as MediaQueryListEvent); }); + expect(screen.queryByRole("navigation", { name: "对话导航" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "显示侧栏" })).toHaveAttribute("aria-pressed", "false"); + + act(() => { narrowListener?.({ matches: false } as MediaQueryListEvent); }); + expect(screen.getByRole("navigation", { name: "对话导航" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "隐藏侧栏" })).toHaveAttribute("aria-pressed", "true"); + + screen.getByRole("button", { name: "新对话" }).focus(); + act(() => { narrowListener?.({ matches: true } as MediaQueryListEvent); }); + expect(screen.getByRole("button", { name: "显示侧栏" })).toHaveFocus(); + const sidebar = document.querySelector(".v2-sidebar-slot"); + expect(sidebar).toHaveAttribute("inert"); + expect(sidebar).toHaveAttribute("aria-hidden", "true"); + act(() => { narrowListener?.({ matches: false } as MediaQueryListEvent); }); + fireEvent.click(screen.getByRole("button", { name: "隐藏侧栏" })); + act(() => { narrowListener?.({ matches: true } as MediaQueryListEvent); }); + act(() => { narrowListener?.({ matches: false } as MediaQueryListEvent); }); + expect(screen.getByRole("button", { name: "显示侧栏" })).toHaveAttribute("aria-pressed", "false"); + }); + it("loads past 100 Threads and preserves an older URL selection through failed paging and refresh", async () => { const user = userEvent.setup(); const first = Array.from({ length: 100 }, (_, index) => ({ ...createdThread, diff --git a/web/src/v2/app.tsx b/web/src/v2/app.tsx index be81f6e72..a7cf8904e 100644 --- a/web/src/v2/app.tsx +++ b/web/src/v2/app.tsx @@ -1,6 +1,6 @@ import { lazy, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { QueryClient, QueryClientProvider, useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Folder } from "lucide-react"; +import { Folder, SlidersHorizontal } from "lucide-react"; import type { APIClient } from "../api/client"; import type { ProviderDefinitionView, TaskBudgetSettings, ThreadDetailView, ThreadView, WorkspaceView } from "../api/types"; import { normalizedTaskBudget } from "../api/task-configuration"; @@ -221,8 +221,9 @@ function NewConversation({ client, workspaces, workspaceID, onWorkspaceChange, o onManageModels={onManageModels} onPendingModelRouteChange={(route) => onOptionsChange({ ...options, modelRoute: route })} pendingModelRoute={modelRoute} - newThreadControls={<> + newThreadControls={<> !window.matchMedia?.("(max-width: 760px)").matches); + const [narrowLayout, setNarrowLayout] = useState(() => Boolean(window.matchMedia?.("(max-width: 720px)").matches)); + const [desktopSidebarVisible, setDesktopSidebarVisible] = useState(true); + const [narrowSidebarVisible, setNarrowSidebarVisible] = useState(false); + const sidebarVisible = narrowLayout ? narrowSidebarVisible : desktopSidebarVisible; + const sidebarSlot = useRef(null); + const sidebarToggle = useRef(null); + const setSidebarVisible = (visible: boolean) => { + if (!visible) sidebarToggle.current?.focus(); + if (narrowLayout) setNarrowSidebarVisible(visible); + else setDesktopSidebarVisible(visible); + }; + useEffect(() => { + const media = window.matchMedia?.("(max-width: 720px)"); + if (!media) return; + const onChange = (event: MediaQueryListEvent) => { + if (sidebarSlot.current?.contains(document.activeElement)) sidebarToggle.current?.focus(); + setNarrowLayout(event.matches); + setNarrowSidebarVisible(false); + }; + if (typeof media.addEventListener === "function") { + media.addEventListener("change", onChange); + return () => media.removeEventListener("change", onChange); + } + media.addListener?.(onChange); + return () => media.removeListener?.(onChange); + }, []); const [searchOpen, setSearchOpen] = useState(false); const [threadSearch, setThreadSearch] = useState(""); const [appliedThreadSearch, setAppliedThreadSearch] = useState(""); @@ -397,7 +423,7 @@ function V2WorkbenchContent({ client }: { client: APIClient }) { }, }); const closeNavigationSidebar = () => { - if (window.matchMedia?.("(max-width: 760px)").matches) setSidebarVisible(false); + if (window.matchMedia?.("(max-width: 720px)").matches) setSidebarVisible(false); }; const openConversation = (threadID: string) => { setBackgroundCreations((current) => current.filter((thread) => thread.id !== threadID)); @@ -475,11 +501,12 @@ function V2WorkbenchContent({ client }: { client: APIClient }) { return
setSidebarVisible((visible) => !visible)} sidebarVisible={sidebarVisible} /> - {sidebarVisible &&
diff --git a/web/src/v2/components/approval-mode-control.tsx b/web/src/v2/components/approval-mode-control.tsx index b48cae3e3..1a21ddabf 100644 --- a/web/src/v2/components/approval-mode-control.tsx +++ b/web/src/v2/components/approval-mode-control.tsx @@ -142,14 +142,15 @@ export function V2ApprovalModeControl({ mode, fullActivation, fullUnavailableRea {variant === "menu" ? <> {open &&
void; } @@ -31,7 +32,8 @@ export function V2ComposerAddMenu({ actions, disabled, triggerRef }: { useEffect(() => { if (!open) return; tabbingRef.current = false; - const items = Array.from(menuRef.current?.querySelectorAll('button:not(:disabled)') ?? []); + const items = Array.from(menuRef.current?.querySelectorAll('button:not(:disabled)') ?? []) + .filter((item) => getComputedStyle(item).display !== "none"); (items[focusLastRef.current ? items.length - 1 : 0] ?? menuRef.current)?.focus(); const outside = (event: MouseEvent) => { if (!shellRef.current?.contains(event.target as Node)) { @@ -61,7 +63,8 @@ export function V2ComposerAddMenu({ actions, disabled, triggerRef }: { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(true); return; } if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; event.preventDefault(); event.stopPropagation(); - const items = Array.from(menuRef.current?.querySelectorAll('button:not(:disabled)') ?? []); + const items = Array.from(menuRef.current?.querySelectorAll('button:not(:disabled)') ?? []) + .filter((item) => getComputedStyle(item).display !== "none"); const current = items.indexOf(document.activeElement as HTMLButtonElement); const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : (current + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length; @@ -69,6 +72,7 @@ export function V2ComposerAddMenu({ actions, disabled, triggerRef }: { }}>
{t("添加到对话", "Add to conversation")}
{actions.map((action) => } {stats.total_tokens > 0 && } {percent !== undefined && + onClick={(event) => toggleEditorMode(event.currentTarget)}>
diff --git a/web/src/v2/components/conversation.test.tsx b/web/src/v2/components/conversation.test.tsx index dc4d0a794..bbc7c0541 100644 --- a/web/src/v2/components/conversation.test.tsx +++ b/web/src/v2/components/conversation.test.tsx @@ -406,9 +406,8 @@ describe("V2Conversation", () => { expect(within(ui.container.querySelector(".v2-narrative")!).getByText("同样的排队要求")).toBeInTheDocument(); }); - it("sends an explicit current-task correction through the Session endpoint", async () => { - const submitSessionMessage = vi.fn(() => Promise.resolve({ steering: { id: "steer-current" } } as Awaited< - ReturnType>)); + it("queues ordinary sends during an active run without a delivery-mode selector", async () => { + const submitSessionMessage = vi.fn(); const submitThreadTurn = vi.fn(); const client = baseClient({ get: vi.fn(() => Promise.resolve({ ...detail("thread-a"), @@ -417,16 +416,14 @@ describe("V2Conversation", () => { } as Partial); renderConversation(client); const user = userEvent.setup(); - await screen.findByRole("combobox", { name: "发送方式" }); - await user.selectOptions(screen.getByRole("combobox", { name: "发送方式" }), "steer"); - await user.click(screen.getByRole("button", { name: "发送 thread-a" })); - await waitFor(() => expect(submitSessionMessage).toHaveBeenCalledWith("sess-thread-a", - { version: "session_message_submission.v1", content: "pending-thread-a", delivery_mode: "steer" }, - expect.any(String))); - expect(submitThreadTurn).not.toHaveBeenCalled(); + await user.click(await screen.findByRole("button", { name: "发送 thread-a" })); + expect(screen.queryByRole("combobox", { name: "发送方式" })).not.toBeInTheDocument(); + await waitFor(() => expect(submitThreadTurn).toHaveBeenCalledWith("thread-a", + expect.objectContaining({ content: "pending-thread-a" }), expect.any(String))); + expect(submitSessionMessage).not.toHaveBeenCalled(); }); - it("keeps an explicit correction draft when the task ends before sending", async () => { + it("sends the same draft normally when the task ends before sending", async () => { const execution: ThreadExecutionView = { version: "thread_execution.v1", thread_id: "thread-a", state: "running", execution_id: "execution-a", queued_messages: 0, capability_grant: false }; const submitSessionMessage = vi.fn(); @@ -437,22 +434,10 @@ describe("V2Conversation", () => { threadExecution: vi.fn(() => Promise.resolve(execution)), submitSessionMessage, submitThreadTurn, } as Partial)); const user = userEvent.setup(); - await screen.findByRole("combobox", { name: "发送方式" }); - await user.selectOptions(screen.getByRole("combobox", { name: "发送方式" }), "steer"); + await screen.findByRole("button", { name: "发送 thread-a" }); await act(async () => { view.queryClient.setQueryData(v2QueryKeys.execution("thread-a"), { ...execution, state: "idle", execution_id: undefined }); }); - expect(screen.getByRole("combobox", { name: "发送方式" })).toHaveValue("steer"); - // The invalid mode is explained before sending, and sending stays blocked. - expect(await screen.findByText(/当前任务已停止或不再运行/)).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "发送 thread-a" })); - expect(submitSessionMessage).not.toHaveBeenCalled(); - expect(submitThreadTurn).not.toHaveBeenCalled(); - // Switching back to next-turn clears the hint; the selector hides again - // because no running task can accept a correction. - await user.click(screen.getByRole("button", { name: "切换为下一轮处理" })); - expect(screen.queryByText(/当前任务已停止或不再运行/)).not.toBeInTheDocument(); expect(screen.queryByRole("combobox", { name: "发送方式" })).not.toBeInTheDocument(); - // The retained draft now goes out as a regular next-turn message. await user.click(screen.getByRole("button", { name: "发送 thread-a" })); await waitFor(() => expect(submitThreadTurn).toHaveBeenCalledWith("thread-a", expect.objectContaining({ content: "pending-thread-a" }), expect.any(String))); @@ -859,29 +844,30 @@ describe("V2Conversation", () => { expect(onDraftChange).toHaveBeenCalledWith(""); }); - it("explains an unavailable correction as paused when the run is paused", async () => { + it("retains the paused state and sends a normal continuation after a run pauses", async () => { const execution: ThreadExecutionView = { version: "thread_execution.v1", thread_id: "thread-a", state: "running", execution_id: "execution-a", queued_messages: 0, capability_grant: false }; const pausedDetail: ThreadDetailView = { ...detail("thread-a"), active_run: { id: "run-thread-a", session_id: "sess-thread-a", status: "paused" } } as ThreadDetailView; - const view = renderConversation(baseClient({ hasThreadExecutionRead: true, + const submitThreadTurn = vi.fn(); + const submitSessionMessage = vi.fn(); + const view = renderConversation(baseClient({ hasThreadExecutionRead: true, submitThreadTurn, submitSessionMessage, get: vi.fn(() => Promise.resolve({ ...detail("thread-a"), active_run: { id: "run-thread-a", session_id: "sess-thread-a", status: "running" } })), threadExecution: vi.fn(() => Promise.resolve(execution)), } as Partial)); const user = userEvent.setup(); - await screen.findByRole("combobox", { name: "发送方式" }); - await user.selectOptions(screen.getByRole("combobox", { name: "发送方式" }), "steer"); + await screen.findByRole("button", { name: "发送 thread-a" }); await act(async () => { view.queryClient.setQueryData(v2QueryKeys.thread("thread-a"), pausedDetail); view.queryClient.setQueryData(v2QueryKeys.execution("thread-a"), { ...execution, state: "idle", execution_id: undefined }); }); - // The pause is named explicitly instead of implying the task ended. - expect(await screen.findByText(/当前任务已暂停,不能更新当前任务/)).toBeInTheDocument(); - expect(screen.queryByText(/当前任务已停止或不再运行/)).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "切换为下一轮处理" })); - expect(screen.queryByText(/当前任务已暂停,不能更新当前任务/)).not.toBeInTheDocument(); + expect(screen.getByText("发送消息即可继续,也可以仅解除暂停。")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "发送 thread-a" })); + await waitFor(() => expect(submitThreadTurn).toHaveBeenCalledWith("thread-a", + expect.objectContaining({ content: "pending-thread-a" }), expect.any(String))); + expect(submitSessionMessage).not.toHaveBeenCalled(); }); it("shows confirmed Agent activity and stopping while the submission response is still pending", async () => { @@ -996,11 +982,13 @@ describe("V2Conversation", () => { expect(fileTrigger).toHaveFocus(); }); - it("docks without trapping Tab and retains the selected file when narrowed", async () => { + it("collapses workbench panels in stages and restores the selected file without taking focus", async () => { let width = 1140; const resizes = new Set<() => void>(); const clientWidth = vi.spyOn(HTMLElement.prototype, "clientWidth", "get") - .mockImplementation(function (this: HTMLElement) { return this.classList.contains("v2-conversation") ? width : 0; }); + .mockImplementation(function (this: HTMLElement) { + return this.classList.contains("v2-shell") ? width : this.classList.contains("v2-conversation") ? width - 299 : 0; + }); vi.stubGlobal("ResizeObserver", class { private resize: () => void; constructor(callback: ResizeObserverCallback) { this.resize = () => callback([], this as unknown as ResizeObserver); resizes.add(this.resize); } @@ -1009,22 +997,45 @@ describe("V2Conversation", () => { }); try { const user = userEvent.setup(); - const client = baseClient({ fileEditChangeSet: vi.fn().mockResolvedValue({ workspace_id: "workspace-1" }) }); + const client = baseClient({ + fileEditChangeSet: vi.fn().mockResolvedValue({ workspace_id: "workspace-1" }), + getPage: vi.fn(() => Promise.resolve(page([ + transcriptItem("layout-link", "查看 [src/index.ts](src/index.ts)", 1), + ]))) as unknown as APIClient["getPage"], + }); const directory = await client.workspaceExplore("workspace-1", "."); vi.mocked(client.workspaceExplore).mockImplementation(async (_workspaceID, path = ".") => path === "." ? directory : { ...directory, path, kind: "file", entries: [], content: "export const value = 1;", }); - renderConversation(client); + const view = renderConversation(client); + view.container.classList.add("v2-shell", "has-sidebar"); const trigger = await screen.findByRole("button", { name: "工作区文件" }); - await user.click(trigger); + await user.click(await screen.findByRole("link", { name: "src/index.ts" })); const dock = await screen.findByRole("complementary", { name: "工作区文件" }); expect(dock.closest(".v2-conversation-stage")).not.toBeNull(); expect(dock).not.toHaveAttribute("aria-modal"); + within(dock).getByRole("button", { name: "关闭文件面板" }).focus(); await user.tab({ shift: true }); expect(screen.getByRole("button", { name: "发送 thread-a" })).toHaveFocus(); - await user.click(await within(dock).findByRole("button", { name: /index.ts/u })); expect(await within(dock).findByText("export const value = 1;")).toBeInTheDocument(); - await act(async () => { width = 800; resizes.forEach((resize) => resize()); }); + within(dock).getByRole("button", { name: "关闭文件面板" }).focus(); + await act(async () => { width = 960; resizes.forEach((resize) => resize()); }); + expect(screen.queryByRole("complementary", { name: "工作区文件" })).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog", { name: "工作区文件" })).not.toBeInTheDocument(); + expect(dock.closest(".v2-workbench-slot")).toHaveAttribute("inert"); + expect(trigger).toHaveFocus(); + screen.getByRole("button", { name: "发送 thread-a" }).focus(); + await act(async () => { width = 961; resizes.forEach((resize) => resize()); }); + expect(screen.getByRole("complementary", { name: "工作区文件" })).toBe(dock); + expect(within(dock).getByText("export const value = 1;")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "发送 thread-a" })).toHaveFocus(); + await act(async () => { + view.container.classList.remove("has-sidebar"); width = 800; resizes.forEach((resize) => resize()); + }); + expect(screen.getByRole("complementary", { name: "工作区文件" })).toBe(dock); + await act(async () => { width = 720; resizes.forEach((resize) => resize()); }); + expect(screen.queryByRole("complementary", { name: "工作区文件" })).not.toBeInTheDocument(); + await user.click(trigger); const modal = screen.getByRole("dialog", { name: "工作区文件" }); expect(modal).toHaveAttribute("aria-modal", "true"); expect(modal.parentElement?.parentElement).toBe(document.body); @@ -1033,6 +1044,8 @@ describe("V2Conversation", () => { await user.keyboard("{Escape}"); expect(screen.queryByRole("dialog", { name: "工作区文件" })).not.toBeInTheDocument(); expect(trigger).toHaveFocus(); + await act(async () => { width = 1140; resizes.forEach((resize) => resize()); }); + expect(screen.queryByRole("complementary", { name: "工作区文件" })).not.toBeInTheDocument(); } finally { cleanup(); clientWidth.mockRestore(); vi.unstubAllGlobals(); } diff --git a/web/src/v2/components/conversation.tsx b/web/src/v2/components/conversation.tsx index bd0421277..5be15cbdc 100644 --- a/web/src/v2/components/conversation.tsx +++ b/web/src/v2/components/conversation.tsx @@ -112,7 +112,7 @@ export function V2FileDrawer({ const closeButton = useRef(null); const leaveFileEdit = useFileEditLeave(); const onClose = () => leaveFileEdit(close); - const dialog = useModalFocusTrap(true, onClose, false, closeButton, { + const dialog = useModalFocusTrap(!docked, onClose, false, closeButton, { isolateBackground: !docked, trapFocus: !docked, returnFocusRef, @@ -164,6 +164,7 @@ export function V2FileDrawer({ className={`v2-inspector-drawer v2-file-drawer${docked ? " is-docked" : ""}`} ref={dialog} role={docked ? "complementary" : "dialog"} + onKeyDown={(event) => { if (docked && event.key === "Escape") { event.stopPropagation(); onClose(); } }} tabIndex={-1} >
@@ -232,7 +233,7 @@ export function V2TerminalDrawer({ }) { const { t } = useLocale(); const closeButton = useRef(null); - const dialog = useModalFocusTrap(true, onClose, false, closeButton, { + const dialog = useModalFocusTrap(!docked, onClose, false, closeButton, { isolateBackground: !docked, trapFocus: !docked, returnFocusRef, @@ -263,6 +264,7 @@ export function V2TerminalDrawer({ aria-label={t("任务终端", "Task terminal")} aria-modal={docked ? undefined : "true"} className={`v2-inspector-drawer v2-terminal-drawer${docked ? " is-docked" : ""}`} + onKeyDown={(event) => { if (docked && event.key === "Escape") { event.stopPropagation(); onClose(); } }} ref={dialog} role={docked ? "complementary" : "dialog"} tabIndex={-1} @@ -372,15 +374,22 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana const [checking, setChecking] = useState([]); const [observations, setObservations] = useState>({}); const [menuOpen, setMenuOpen] = useState(false); - const [activePane, setActivePane] = useState<"review" | "preview" | "context" | "files" | "terminal" | null>(null); + const [activePane, setSelectedPane] = useState<"review" | "preview" | "context" | "files" | "terminal" | null>(null); + const [roomForDock, setRoomForDock] = useState(false); + const [compactPaneOpen, setCompactPaneOpen] = useState(false); + const docked = view === "conversation" && roomForDock; + const setActivePane = useCallback((pane: typeof activePane) => { + setSelectedPane(pane); + setCompactPaneOpen(pane !== null && !docked); + }, [docked]); const leaveFileEdit = useFileEditLeave(); const setPaneOpen = (pane: NonNullable, open: boolean) => (open || activePane === pane) && leaveFileEdit(() => setActivePane(open ? pane : null)); const reviewOpen = activePane === "review"; const previewOpen = activePane === "preview"; const contextOpen = activePane === "context"; - const filesOpen = activePane === "files"; - const terminalDrawerOpen = activePane === "terminal"; + const filesOpen = activePane === "files" && (docked || compactPaneOpen); + const terminalDrawerOpen = activePane === "terminal" && (docked || compactPaneOpen); const setReviewOpen = (open: boolean) => setPaneOpen("review", open); const setPreviewOpen = (open: boolean) => setPaneOpen("preview", open); const setContextOpen = (open: boolean) => setPaneOpen("context", open); @@ -389,12 +398,12 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana const [reviewToolTarget, setReviewToolTarget] = useState<{ runID: string; tool: "github-review" }>(); const reviewReturnFocus = useRef(null); const contextTrigger = useRef(null); + const contextReturnFocus = useRef(null); const previewTrigger = useRef(null); const [inspectorComposerOpen, setInspectorComposerOpen] = useState(false); const composerContainerRef = useRef(null); const [composerFocusRequest, setComposerFocusRequest] = useState<{ threadID: string } | null>(null); const [confirmedSubmission, setConfirmedSubmission] = useState(null); - const [deliveryMode, setDeliveryMode] = useState<"next_turn" | "steer">("next_turn"); const reviewTrigger = useRef(null); const [fileDrawerState, setFileDrawerState] = useState<{ path: string; @@ -433,7 +442,7 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana setActivePane("files"); setFileDrawerState({ path, line, runID }); }); - }, [leaveFileEdit]); + }, [leaveFileEdit, setActivePane]); const turn = useV2ThreadTurn(client); const submissions = useV2ThreadSubmissions(threadID); const submitTrackedTurn = async (input: V2TurnInput) => { @@ -514,10 +523,15 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana }, [submissions]); const executionQuery = useV2ThreadExecution(client, threadID); const conversationRef = useRef(null); - const [roomForDock, setRoomForDock] = useState(false); - const docked = view === "conversation" && roomForDock; + const workbenchSlot = useRef(null); const scrollRef = useRef(null); const menuTriggerRef = useRef(null); + const workflowFocusTarget = (target: HTMLButtonElement | null) => { + const group = target?.closest(".v2-task-workflows"); + return group && getComputedStyle(group).display === "none" ? menuTriggerRef.current : target; + }; + const contextFocusTarget = { get current() { return workflowFocusTarget(contextReturnFocus.current); } }; + const reviewFocusTarget = { get current() { return workflowFocusTarget(reviewReturnFocus.current); } }; const menuRef = useRef(null); const firstMenuItemRef = useRef(null); const olderScrollAnchorRef = useRef<{ threadID: string; height: number; top: number; pages: number; @@ -709,7 +723,6 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana setFileDrawerState({ path: ".", line: undefined, runID: undefined }); setComposerFocusRequest(null); olderScrollAnchorRef.current = null; - setDeliveryMode("next_turn"); }, [threadID, view]); useEffect(() => { if (!reviewEntry || reviewEntry.threadID !== threadID) return; @@ -762,15 +775,24 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana useLayoutEffect(() => { const container = conversationRef.current; if (!container) return; - // Reserve at least 600px for the conversation beside a 340px panel. - const measure = () => setRoomForDock(container.clientWidth >= 960); + const shell = container.closest(".v2-shell"); + const measure = () => { + const fits = (shell ?? container).clientWidth > (shell?.classList.contains("has-sidebar") ? 960 : 720); + if (!fits && workbenchSlot.current?.contains(document.activeElement)) { + (workbenchSlot.current.querySelector(".v2-file-drawer") ? fileTrigger : terminalTrigger).current?.focus(); + } + setRoomForDock(fits); + }; measure(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(measure); observer.observe(container); + if (shell) observer.observe(shell); return () => observer.disconnect(); }, [detailQuery.isSuccess]); + useEffect(() => { if (docked) setCompactPaneOpen(false); }, [docked]); + if (detailQuery.isLoading) return
{t("正在打开对话…", "Opening conversation…")}
; if (detailQuery.isError || !detailQuery.data) return
@@ -795,10 +817,6 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana }; const canSteer = executionQuery.data?.state === "running" || (!executionQuery.data && detail.active_run?.status === "running"); - // A paused run is not gone: steer stays unavailable, but the explanation - // names the pause so the operator does not think the task ended. - const steerUnavailable = currentRun.status === "paused" ? t("当前任务已暂停", "The current task is paused") - : t("当前任务已停止或不再运行", "The current task has stopped or is no longer running"); const activityLabel = threadActivityLabel({ threadID, execution: executionQuery.data, readable: client.hasThreadExecutionRead === true, error: executionQuery.isError }); const fileReferenceUnavailableReason = currentRun.status === "waiting_approval" @@ -811,13 +829,7 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana : undefined; const send = async (content: string, files?: V2FileReference[], images?: WorkspaceImageAttachment[], draftVersion?: V2DraftVersion, attachments?: WorkspaceFileAttachment[], mcpReferences?: MCPDraftReference[]) => { - if (deliveryMode === "steer" && !canSteer) { - throw new Error(`${steerUnavailable}。纠正草稿已保留;请选择“下一轮处理”发送。`); - } - if (deliveryMode === "steer" && (files?.length || images?.length || attachments?.length || mcpReferences?.length)) { - throw new Error("更新当前任务目前只支持文字。附件和引用已保留,请选择“下一轮处理”发送完整消息。"); - } - const fingerprint = JSON.stringify([deliveryMode, content, files ?? [], imageIdentities(images), fileAttachmentIdentities(attachments), mcpReferenceIdentities(mcpReferences)]); + const fingerprint = JSON.stringify(["next_turn", content, files ?? [], imageIdentities(images), fileAttachmentIdentities(attachments), mcpReferenceIdentities(mcpReferences)]); let replaced = submissions.findLast(({ error }) => v2TurnOutcomeKnown(error))?.input.operationKey; // The user's new request waits for unknown earlier submissions to settle. // Confirmation uses the original payload and key, never the edited draft. @@ -837,7 +849,6 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana { text: draft ?? content, files: files ?? [], images: images ?? [], attachments, mcpReferences }); const operationKey = `v2-thread-turn-${globalThis.crypto.randomUUID()}`; const input: V2TurnInput = { threadID, workspaceID: detail.thread.workspace_id ?? "", content, - ...(deliveryMode === "steer" ? { deliveryMode: "steer" as const, sessionID: currentRun.session_id } : {}), ...(draft !== undefined ? { draft } : {}), operationKey, createdAt: new Date().toISOString(), ...(files?.length ? { files } : {}), ...(images?.length ? { images } : {}), @@ -891,20 +902,21 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana setReviewOpen(false); setPreviewOpen(false); setContextOpen(false); setComposerFocusRequest({ threadID }); }; + const companionDocked = view === "conversation" && !compactPaneOpen; const companion = <> - {filesOpen && setFileDrawerState((current) => ({ ...current, path, line: undefined }))} onClose={() => { setActivePane(null); if (fileReturnFocus.current?.isConnected) fileReturnFocus.current.focus(); else fileTrigger.current?.focus(); }} returnFocusRef={fileReturnFocus} />} - {terminalDrawerOpen && { setTerminalDrawerOpen(false); if (terminalReturnFocus.current?.isConnected) terminalReturnFocus.current.focus(); @@ -928,6 +940,13 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana onArchive(detail.thread); }} ref={firstMenuItemRef} role="menuitem" type="button">
}
- + {view === "inspector" && (onOpenTool || onOpenInspectorHome) &&