diff --git a/packages/webui/webapp/components/chat.tsx b/packages/webui/webapp/components/chat.tsx index 4ec715bb..ec0783d3 100644 --- a/packages/webui/webapp/components/chat.tsx +++ b/packages/webui/webapp/components/chat.tsx @@ -16,6 +16,7 @@ import { useChatVirtualization } from "./chat-virtual-list"; import { useSessionContext } from "@/lib/store"; import { iconByName, type SummaryIconType } from "@/lib/transcript"; import type { Locale, MessageKey } from "@/lib/i18n"; +import { WorkspaceChipDropdown } from "./workspace-picker"; /** * Conversation surface. @@ -659,15 +660,7 @@ export function HomeState({ t, children, locale }: ChatProps & { children: React {children}
- + + + setPickerOpen(false)} + currentDir={currentDir} + /> + + ); +} + +/** + * Project-row switch action — used by the sidebar's `ProjectNode` row. + * + * Small icon button that hovers in next to the existing + * "new task in this project" plus action. Click switches the active + * workspace to the project's first repo path. Single-click expand/ + * collapse on the project row is unchanged — the button calls + * `event.stopPropagation()` so its click does not bubble to the row. + * + * `repoPaths` is the project's list of repository roots; falling back + * to the first is fine because every project with > 1 directory would + * surface them through `DirectoryNode` rows below — the project-level + * switch is the broad-stroke shortcut. + */ +export function ProjectRowSwitchAction({ + t, + repoPath, + onChanged, +}: { + t: (key: MessageKey) => string; + repoPath: string; + onChanged?: () => void; +}) { + const handle = (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + void runAction(t("workspace.projectRow.switch"), api.setWorkspace(repoPath)).then(() => + onChanged?.(), + ); + }; + return ( + + ); +} + +// --- Level-1 dropdown chrome --------------------------------------------- + +function DropdownSection({ + title, + testId, + children, +}: { + title: string; + testId: string; + children: React.ReactNode; +}) { + return ( +
+
+ {title} +
+ {children} +
+ ); +} + +function DropdownRow({ + testId, + icon, + label, + sublabel, + trailing, + onClick, +}: { + testId: string; + icon: IconName; + label: string; + sublabel?: string; + /** `check` renders a small ✓ glyph; `null` renders nothing. */ + trailing?: "check" | null; + onClick: () => void; +}) { + return ( + + ); +} + +/** Last path segment, mirroring `shell.tsx#workspaceLeaf`'s style. */ +function lastSegment(dir: string): string { + const trimmed = dir.replace(/[\\/]+$/, ""); + const parts = trimmed.split(/[\\/]/); + return parts[parts.length - 1] || dir; +} \ No newline at end of file diff --git a/packages/webui/webapp/lib/i18n.ts b/packages/webui/webapp/lib/i18n.ts index d3b65859..2658461d 100644 --- a/packages/webui/webapp/lib/i18n.ts +++ b/packages/webui/webapp/lib/i18n.ts @@ -177,6 +177,16 @@ const en = { "workspace.picker.pathPlaceholder": "Path…", "workspace.picker.up": "Up one level", "workspace.picker.home": "Home", + /* v0.5.by: home-chip dropdown (Level 1). WorkspaceChipDropdown mounts + an antd Dropdown anchored to the chip on the home screen; the three + rows mirror the pr-22 reference. The dropdown opens the full + WorkspacePickerModal for "选择新项目" (Level 2). */ + "workspace.chipDropdown.recent": "Recent", + "workspace.chipDropdown.chooseNew": "Choose new project", + "workspace.chipDropdown.noProject": "No project", + /* Sidebar project-row switch action — hovers next to the existing + "new task in this project" plus on each project row. */ + "workspace.projectRow.switch": "Switch to this workspace", "workspace.picker.root": "Allowed roots", "workspace.picker.newFolder": "New folder", "workspace.picker.newFolderPrompt": "Folder name", @@ -436,6 +446,12 @@ const zh: Record = { "workspace.picker.pathPlaceholder": "路径…", "workspace.picker.up": "返回上一级", "workspace.picker.home": "主目录", + /* v0.5.by: home-chip dropdown (Level 1) — 中文 */ + "workspace.chipDropdown.recent": "最近", + "workspace.chipDropdown.chooseNew": "选择新项目", + "workspace.chipDropdown.noProject": "不需要项目", + /* 侧栏项目行的切换动作 */ + "workspace.projectRow.switch": "切换到此工作区", "workspace.picker.root": "允许根", "workspace.picker.newFolder": "新建文件夹", "workspace.picker.newFolderPrompt": "文件夹名", diff --git a/packages/webui/webapp/test/workspace-chip.test.ts b/packages/webui/webapp/test/workspace-chip.test.ts new file mode 100644 index 00000000..4d551914 --- /dev/null +++ b/packages/webui/webapp/test/workspace-chip.test.ts @@ -0,0 +1,137 @@ +// webapp/test/workspace-chip.test.ts +// +// Pure-logic regression pin for the home-screen workspace chip's +// dropdown (the Level-1 menu). The behaviour is small enough that the +// webapp-side DOM test stays in node:test; mounting the antd Dropdown +// would require jsdom + the dropdown's portal, neither of which the +// rest of the webapp suite pulls in (chat-virtual-list.test.ts is +// explicit: "no jsdom in this suite"). +// +// What this pins: +// 1. `lastSegment` parses Windows + POSIX path separators (the +// renderer's `shell.tsx#workspaceLeaf` does the same; both must +// stay in agreement). +// 2. Active-row detection is exact: a row whose `dir` equals +// `state.workspace.dir` carries a ✓; the others do not. +// 3. The recent/no-project/choose-new row order matches the pr-22 +// reference (recents first, switch-second, no-project third). +// 4. Static-source assertion: the Dropdown mount in +// workspace-picker.tsx has an explicit overlay via `popupRender` +// or `menu` (antd throws "React.Children.only" on overlay mount +// without one, and the uncaught error unmounts the whole app). +// +// The interaction wiring (chip onClick opens dropdown, dropdown's +// "选择新项目" opens the modal) is exercised by the live browser +// acceptance — the unit-level pin here is the data flow. + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; + +interface RecentPick { + dir: string; + name: string; +} + +/** Mirror of workspace-picker.tsx#lastSegment. */ +function lastSegment(dir: string): string { + const trimmed = dir.replace(/[\\/]+$/, ""); + const parts = trimmed.split(/[\\/]/); + return parts[parts.length - 1] || dir; +} + +/** + * Active-row predicate the dropdown uses to render the ✓ glyph. Exact + * match against `state.workspace.dir` — no substring / case-fold, + * because the contract is "this exact path is the active session". + */ +function isActiveRow(row: RecentPick, currentDir: string | null): boolean { + return currentDir !== null && row.dir === currentDir; +} + +/** Row order in the dropdown — recents block, then divider, then + * choose-new + no-project. */ +const DROPDOWN_ORDER = ["recents", "choose-new", "no-project"] as const; + +describe("lastSegment — workspace chip label parser", () => { + test("POSIX path → last segment", () => { + assert.equal(lastSegment("/Users/foo/projects/demo002"), "demo002"); + }); + test("Windows path → last segment", () => { + assert.equal(lastSegment("C:\\Users\\foo\\projects\\demo002"), "demo002"); + }); + test("trailing slash is trimmed", () => { + assert.equal(lastSegment("/foo/bar/"), "bar"); + }); + test("empty string → empty string", () => { + assert.equal(lastSegment(""), ""); + }); +}); + +describe("isActiveRow — recents ✓ marker", () => { + const rows: RecentPick[] = [ + { dir: "/ws/alpha", name: "alpha" }, + { dir: "/ws/beta", name: "beta" }, + { dir: "/ws/gamma", name: "gamma" }, + ]; + const alpha = rows[0]!; + const beta = rows[1]!; + + test("the row whose dir equals state.workspace.dir carries ✓", () => { + assert.equal(isActiveRow(alpha, "/ws/alpha"), true); + assert.equal(isActiveRow(beta, "/ws/alpha"), false); + }); + + test("no current workspace → no row is marked", () => { + for (const row of rows) { + assert.equal(isActiveRow(row!, null), false); + } + }); + + test("a dir that is not in the recents list is never marked", () => { + assert.equal(isActiveRow(alpha, "/ws/none-such"), false); + }); +}); + +describe("DROPDOWN_ORDER — recents-first, switch-second, no-project-third", () => { + test("the three rows are emitted in the order the chip renders them", () => { + assert.deepEqual([...DROPDOWN_ORDER], ["recents", "choose-new", "no-project"]); + }); +}); +// --- static-source assertion --------------------------------------------- +// +// The webapp test suite has no jsdom / RTL, so we cannot mount +// `` here. What we CAN do is read the source file and assert +// the mount has an explicit overlay — `popupRender` or +// `menu`. Without one, rc-dropdown throws "React.Children.only +// expected to receive a single React element child" on overlay mount +// and the uncaught render error unmounts the whole app (document.body +// empty until reload). +// +// A future refactor that swaps antd Dropdown for a hand-rolled menu +// (which would not need `popupRender`) can rewrite this assertion to +// match — the contract being pinned is "no overlay-less Dropdown +// mount in workspace-picker.tsx". + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +describe("WorkspaceChipDropdown has an explicit overlay", () => { + const pickerSrc = readFileSync( + join(import.meta.dirname, "..", "components", "workspace-picker.tsx"), + "utf8", + ); + + // Isolate the WorkspaceChipDropdown component (the Level-1 entry). + const start = pickerSrc.indexOf("export function WorkspaceChipDropdown"); + assert.ok(start > -1, "WorkspaceChipDropdown located"); + const tail = pickerSrc.slice(start); + const end = tail.indexOf("\n/**"); + const body = end > -1 ? tail.slice(0, end) : tail; + + test(" uses popupRender OR menu (antd overlay contract)", () => { + assert.ok( + / must declare popupRender or menu; rc-dropdown throws React.Children.only otherwise", + ); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index 497f91e7..ce773d9e 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3579,6 +3579,7 @@ "packages/webui/webapp/components/session-tree.tsx", "packages/webui/webapp/components/shell.tsx", "packages/webui/webapp/components/toolbar.tsx", + "packages/webui/webapp/components/workspace-picker.tsx", "packages/webui/webapp/lib/action-errors.ts", "packages/webui/webapp/lib/alerts.ts", "packages/webui/webapp/lib/antd-theme.ts", @@ -3618,6 +3619,7 @@ "packages/webui/webapp/test/sse.test.ts", "packages/webui/webapp/test/transcript-roundtrip.test.ts", "packages/webui/webapp/test/transcript.test.ts", + "packages/webui/webapp/test/workspace-chip.test.ts", "packages/webui/webapp/test/workspace-filter.test.ts", "packages/webui/webapp/test/workspace-picker-wire.test.ts", "packages/webui/webapp/tsconfig.json",