diff --git a/docs/convergence/protocol-registry.md b/docs/convergence/protocol-registry.md index 5b1be8a5e..1e3d9d0ed 100644 --- a/docs/convergence/protocol-registry.md +++ b/docs/convergence/protocol-registry.md @@ -21,7 +21,7 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi | [credential-provider-ledgers](#credential-provider-ledgers) | `internal-durable` | Credential, provider, model-route, and pricing maintainers | 24 | true | | [desktop-risk-restart-session](#desktop-risk-restart-session) | `ephemeral` | Desktop shell lifecycle maintainers | 2 | false | | [desktop-verification-settings](#desktop-verification-settings) | `internal-durable` | Desktop verification settings maintainers | 1 | true | -| [desktop-web-presentation-state](#desktop-web-presentation-state) | `projection` | Desktop and React workbench maintainers | 20 | true | +| [desktop-web-presentation-state](#desktop-web-presentation-state) | `projection` | Desktop and React workbench maintainers | 21 | true | | [docker-attach-process-session](#docker-attach-process-session) | `ephemeral` | Docker runtime transport maintainers | 1 | false | | [exported-evidence-and-handoff](#exported-evidence-and-handoff) | `external-durable` | Evidence, verification, report, and handoff maintainers | 35 | true | | [extension-package-contracts](#extension-package-contracts) | `external-durable` | Skill, Plugin, Hook, and extension maintainers | 48 | true | @@ -782,7 +782,7 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi - Readers: - `desktop-web-presentation-state-reader` (`v1`, active) at `web/src` -
20 active identifiers +
21 active identifiers - `desktop_chat_resource.v1` - `desktop_clipboard_files.v1` @@ -802,6 +802,7 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi - `prayu.run-navigation.v1` - `universal-code.batch-workbench.v1` - `universal-code.chat-preferences.v1` +- `universal-code.sidebar-width.v1` - `universal-code.specialist-continuation.v1` - `v2_file_upload.v1` diff --git a/docs/evidence/sidebar-width/README.md b/docs/evidence/sidebar-width/README.md new file mode 100644 index 000000000..61aa92776 --- /dev/null +++ b/docs/evidence/sidebar-width/README.md @@ -0,0 +1,27 @@ +# Sidebar width + +The desktop sidebar edge can be dragged between 232 and 420 px. Its default remains 299 px. The current browser profile remembers the released width; a smaller window temporarily limits the sidebar to leave room for the conversation. Widening the window restores the preferred width. A click without a width change does not replace that preference. + +The edge is keyboard accessible: Left/Right adjusts 12 px, Home/End selects the available minimum/maximum, and Enter or a double-click restores the default. Escape, pointer cancellation, losing pointer capture, or leaving the window cancels an unfinished drag. At 720 px and below, the existing overlay drawer retains its own width and the resize handle is unavailable. + +## Actual browser verification + +Windows Chrome, production Go API and SQLite, using the offline `workbenchjourney` provider. Both bundles displayed the same saved project and conversation. No screenshot content or styles were injected. + +| Capture | Result | +| --- | --- | +| [Before](before-fixed.png) | Base `3dac0ecc`, 1440 × 1000; fixed 299 px sidebar truncates the sample names. | +| [After](after-expanded.png) | This change, 1440 × 1000; real pointer drag to 420 px reveals more of the same names. | +| [Dark theme](after-dark.png) | Existing dark theme selected through Settings, 420 px sidebar. | +| [Narrow screen](after-narrow.png) | 390 × 844; existing 286 px overlay, no resize handle. | + +Observed interactions: + +- A 121 px pointer move changed 299 px to 420 px during the drag and on release. The draft stayed intact. +- At an 800 px viewport, the sidebar displayed 380 px while retaining the saved 420 px. A click on the edge kept the stored preference; widening restored 420 px. +- Keyboard Left changed 420 to 408 px; Enter restored 299 px. End restored the available 420 px maximum. +- Navigating away and reconnecting restored both the saved width and the draft. Settings also uses the same sidebar width. +- The narrow drawer still opens and closes through its normal controls. Returning to a wide viewport restores the desktop width. +- A smaller conversation area exposed the existing user-message tooltip overflow already addressed separately by #441. This PR does not duplicate that patch. + +68 related component, app and search tests passed. After fixing the no-drag preference case, the 5 resize tests passed again. Production build, protocol registration check against `3dac0ecc`, and `git diff --check` passed. The removed legacy resize component and CSS had no remaining consumers. Native packaged desktop and touch hardware were not tested. diff --git a/docs/evidence/sidebar-width/after-dark.png b/docs/evidence/sidebar-width/after-dark.png new file mode 100644 index 000000000..91d9218bb Binary files /dev/null and b/docs/evidence/sidebar-width/after-dark.png differ diff --git a/docs/evidence/sidebar-width/after-expanded.png b/docs/evidence/sidebar-width/after-expanded.png new file mode 100644 index 000000000..3cbd43908 Binary files /dev/null and b/docs/evidence/sidebar-width/after-expanded.png differ diff --git a/docs/evidence/sidebar-width/after-narrow.png b/docs/evidence/sidebar-width/after-narrow.png new file mode 100644 index 000000000..452838db1 Binary files /dev/null and b/docs/evidence/sidebar-width/after-narrow.png differ diff --git a/docs/evidence/sidebar-width/before-fixed.png b/docs/evidence/sidebar-width/before-fixed.png new file mode 100644 index 000000000..5face4a31 Binary files /dev/null and b/docs/evidence/sidebar-width/before-fixed.png differ diff --git a/protocols/registry.json b/protocols/registry.json index ca8b7dc45..73c8d152b 100644 --- a/protocols/registry.json +++ b/protocols/registry.json @@ -1316,6 +1316,7 @@ "prayu.run-navigation.v1", "universal-code.batch-workbench.v1", "universal-code.chat-preferences.v1", + "universal-code.sidebar-width.v1", "universal-code.specialist-continuation.v1", "v2_file_upload.v1" ], diff --git a/web/src/components/workbench-frame.test.tsx b/web/src/components/workbench-frame.test.tsx deleted file mode 100644 index 7aad284d6..000000000 --- a/web/src/components/workbench-frame.test.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { SidebarResizeHandle } from "./workbench-frame"; - -vi.mock("../lib/locale", () => ({ - useLocale: () => ({ locale: "zh-CN", setLocale: () => undefined, - t: (chinese: string) => chinese }), -})); - -describe("SidebarResizeHandle", () => { - it("supports bounded keyboard resizing and double-click reset", () => { - const onChange = vi.fn(); - render(); - const separator = screen.getByRole("separator", { name: "调整侧栏宽度" }); - - fireEvent.keyDown(separator, { key: "ArrowLeft" }); - fireEvent.keyDown(separator, { key: "ArrowRight" }); - fireEvent.keyDown(separator, { key: "Home" }); - fireEvent.keyDown(separator, { key: "End" }); - fireEvent.doubleClick(separator); - - expect(onChange.mock.calls.map((call) => call[0])).toEqual([274, 298, 232, 420, 286]); - }); -}); diff --git a/web/src/components/workbench-frame.tsx b/web/src/components/workbench-frame.tsx index 14beee5ee..104ec6e06 100644 --- a/web/src/components/workbench-frame.tsx +++ b/web/src/components/workbench-frame.tsx @@ -1,58 +1,7 @@ -import { useRef, type KeyboardEvent, type PointerEvent, type ReactNode } from "react"; +import type { ReactNode } from "react"; import type { APIClient } from "../api/client"; -import { useLocale } from "../lib/locale"; import { WorkbenchDock, type WorkbenchResourceKind } from "./workbench-dock"; -export const minimumSidebarWidth = 232; -export const maximumSidebarWidth = 420; -export const defaultSidebarWidth = 286; - -export function clampSidebarWidth(width: number): number { - return Math.min(maximumSidebarWidth, Math.max(minimumSidebarWidth, Math.round(width))); -} - -export function SidebarResizeHandle({ value, onChange }: { - value: number; - onChange: (value: number) => void; -}) { - const { t } = useLocale(); - const drag = useRef<{ pointerID: number; originX: number; originWidth: number } | null>(null); - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "ArrowLeft" || event.key === "ArrowRight") { - event.preventDefault(); - onChange(clampSidebarWidth(value + (event.key === "ArrowLeft" ? -12 : 12))); - } else if (event.key === "Home") { - event.preventDefault(); - onChange(minimumSidebarWidth); - } else if (event.key === "End") { - event.preventDefault(); - onChange(maximumSidebarWidth); - } - }; - const onPointerDown = (event: PointerEvent) => { - if (event.button !== 0) return; - drag.current = { pointerID: event.pointerId, originX: event.clientX, originWidth: value }; - event.currentTarget.setPointerCapture?.(event.pointerId); - }; - const onPointerMove = (event: PointerEvent) => { - if (!drag.current || drag.current.pointerID !== event.pointerId) return; - onChange(clampSidebarWidth(drag.current.originWidth + event.clientX - drag.current.originX)); - }; - const finishDrag = (event: PointerEvent) => { - if (!drag.current || drag.current.pointerID !== event.pointerId) return; - drag.current = null; - if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - }; - return
onChange(defaultSidebarWidth)} onKeyDown={onKeyDown} - onPointerCancel={finishDrag} onPointerDown={onPointerDown} - onPointerMove={onPointerMove} onPointerUp={finishDrag} role="separator" tabIndex={0} />; -} - export function WorkbenchFrame({ title, children, client, desktop, resourceKind, runID, sessionID, threadID = "" }: { title: string; diff --git a/web/src/styles.css b/web/src/styles.css index 01dcad0fb..c7a4001d6 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1664,16 +1664,6 @@ body { background: transparent; } grid-template-columns: minmax(0, 1fr); } -.sidebar-resize-handle { - position: relative; - z-index: 20; - width: 16px; - cursor: col-resize; - touch-action: none; - user-select: none; -} -.sidebar-resize-handle:focus-visible { outline: 0; } - .prayu-shell .main-workspace.prayu-conversation-panel { min-width: 0; min-height: 0; @@ -2497,7 +2487,6 @@ body { background: transparent; } display: grid; grid-template-columns: minmax(0, 1fr); } - .sidebar-resize-handle { display: none; } .prayu-shell .main-workspace.prayu-conversation-panel { height: 100%; min-height: 0; @@ -2929,25 +2918,6 @@ summary:focus-visible { background: var(--prayu-control-hover); } -.sidebar-resize-handle { - background: transparent; -} - -.sidebar-resize-handle::after { - position: absolute; - inset: 14px 7px; - width: 2px; - border-radius: 1px; - background: transparent; - content: ""; - transition: background 120ms ease; -} - -.sidebar-resize-handle:hover::after, -.sidebar-resize-handle:focus-visible::after { - background: var(--prayu-border-strong); -} - .about-mark { border-color: var(--prayu-border-strong); color: var(--prayu-text); diff --git a/web/src/v2/app.test.tsx b/web/src/v2/app.test.tsx index 790e2aa5a..3dd45bc15 100644 --- a/web/src/v2/app.test.tsx +++ b/web/src/v2/app.test.tsx @@ -270,13 +270,20 @@ describe("V2Workbench inspector navigation", () => { expect(screen.getByRole("navigation", { name: "对话导航" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "隐藏侧栏" })).toHaveAttribute("aria-pressed", "true"); + const resizeHandle = screen.getByRole("separator", { name: "调整侧栏宽度" }); + resizeHandle.focus(); + fireEvent.keyDown(resizeHandle, { key: "ArrowRight" }); + expect(resizeHandle).toHaveAttribute("aria-valuenow", "311"); act(() => { narrowListener?.({ matches: true } as MediaQueryListEvent); }); expect(screen.queryByRole("navigation", { name: "对话导航" })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "显示侧栏" })).toHaveAttribute("aria-pressed", "false"); + expect(screen.getByRole("button", { name: "显示侧栏" })).toHaveFocus(); + expect(screen.queryByRole("separator", { name: "调整侧栏宽度" })).not.toBeInTheDocument(); act(() => { narrowListener?.({ matches: false } as MediaQueryListEvent); }); expect(screen.getByRole("navigation", { name: "对话导航" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "隐藏侧栏" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("separator", { name: "调整侧栏宽度" })).toHaveAttribute("aria-valuenow", "311"); screen.getByRole("button", { name: "新对话" }).focus(); act(() => { narrowListener?.({ matches: true } as MediaQueryListEvent); }); diff --git a/web/src/v2/app.tsx b/web/src/v2/app.tsx index f54a66dde..1ff656a47 100644 --- a/web/src/v2/app.tsx +++ b/web/src/v2/app.tsx @@ -12,6 +12,7 @@ import { V2ConfirmDialog } from "./components/dialog"; import { V2LazySurface } from "./components/lazy-surface"; import { V2NetworkScopeControl, type V2NetworkMode } from "./components/network-scope-control"; import { V2SettingsSidebar, V2Sidebar, type V2SettingsSection } from "./components/sidebar"; +import { V2SidebarLayout } from "./components/sidebar-layout"; import { V2Titlebar } from "./components/titlebar"; import { useV2Client } from "./client-session"; import { v2QueryKeys } from "./query-keys"; @@ -633,9 +634,8 @@ function V2WorkbenchContent({ client }: { client: APIClient }) { onToggleSidebar={() => setSidebarVisible(!sidebarVisible)} sidebarVisible={sidebarVisible} sidebarToggleRef={sidebarToggle} />