From cdcdd2221edd2b5e55b18a62070e4f776068d4c8 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 12:52:59 +0800 Subject: [PATCH 001/179] feat(host): show-hidden toggle in the directory browser footer --- .../src/client/DirectoryBrowser.module.css | 26 +++++++++++++++++++ .../src/client/DirectoryBrowser.tsx | 22 +++++++++++++--- .../src/client/index.ts | 4 +++ .../tests/client-flow.spec.tsx | 2 ++ .../tests/directory-browser.spec.tsx | 17 ++++++++++++ 5 files changed, 67 insertions(+), 4 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index 800af854a1..bdb57ca848 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -242,6 +242,32 @@ border-top: 1px solid var(--dsw-alias-border-l3); } +/* Show-hidden toggle: a subtle text button in the footer, left of the gap. */ +.showHiddenToggle { + border: none; + background: transparent; + padding: 0; + font-size: 13px; + line-height: 20px; + font-weight: 500; + color: var(--dsw-alias-label-secondary); + cursor: pointer; + white-space: nowrap; +} + +.showHiddenToggle:hover { + color: var(--dsw-alias-label-primary); +} + +.showHiddenToggle:disabled { + color: var(--dsw-alias-label-caption); + cursor: default; +} + +.showHiddenToggleActive { + color: var(--dsw-alias-label-primary); +} + .footerGap { flex: 1 1 0; } diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index f348f1dd8d..20fef7a827 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -10,8 +10,8 @@ * selects the created folder. Open adopts the selected folder, falling back * to the listed level. Pure consumer of the injected browse calls — the * owning flow decides what "Open" means and owns the workspace-creation - * error surface. Hidden entries are host-flagged and filtered here (a - * show-hidden toggle is deferred work, client-side only). + * error surface. Hidden entries are host-flagged and hidden by default; + * a "Show hidden files" toggle in the footer reveals them (client-side only). */ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' @@ -60,16 +60,17 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE } /** One column of folder rows (the Miller view renders one or two of these). */ -function LevelColumn({ entries, selectedPath, busy, onPick, wide }: { +function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden }: { entries: readonly DirectoryEntry[] selectedPath: string | null busy: boolean onPick: (entry: DirectoryEntry) => void wide: boolean + showHidden: boolean }) { return (
- {entries.filter(entry => !entry.hidden).map((entry) => { + {entries.filter(entry => showHidden || !entry.hidden).map((entry) => { const selected = entry.path === selectedPath return ( // The wrapper carries the list semantics; the row keeps its NATIVE @@ -110,6 +111,8 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const [error, setError] = useState(null) // Path-edit state: null = breadcrumb mode; a string = the draft being typed. const [pathDraft, setPathDraft] = useState(null) + // Show-hidden toggle state (pure client-side filter, reset on close). + const [showHidden, setShowHidden] = useState(false) // Create-folder state: null = closed; a string = the nested dialog's draft. const [folderDraft, setFolderDraft] = useState(null) const [creatingFolder, setCreatingFolder] = useState(false) @@ -213,6 +216,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setSelected(null) setChild(null) setCreatingFolder(false) + setShowHidden(false) navigate() return } @@ -409,6 +413,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, busy={parentInert} onPick={select} wide={!twoPane} + showHidden={showHidden} /> )} {twoPane && } @@ -419,6 +424,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, busy={parentInert} onPick={advance} wide={false} + showHidden={showHidden} /> )}
@@ -442,6 +448,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, > {t('browser.newFolder')} + diff --git a/packages/host/directory-picker-browse/src/client/index.ts b/packages/host/directory-picker-browse/src/client/index.ts index e47613cdaf..a458ca94c7 100644 --- a/packages/host/directory-picker-browse/src/client/index.ts +++ b/packages/host/directory-picker-browse/src/client/index.ts @@ -47,7 +47,6 @@ export function apply(ctx: ClientContext): void { 'browser.loading': '加载中…', 'browser.truncated': '文件夹过多,仅显示开头部分。', 'browser.showHidden': '显示隐藏文件', - 'browser.hideHidden': '隐藏隐藏文件', }], ['en', { 'browser.title': 'Select Workspace Directory', @@ -63,7 +62,6 @@ export function apply(ctx: ClientContext): void { 'browser.loading': 'Loading…', 'browser.truncated': 'Too many folders to list; only the beginning is shown.', 'browser.showHidden': 'Show hidden files', - 'browser.hideHidden': 'Hide hidden files', }], ] try { diff --git a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx index c29afc935c..31ec5a4927 100644 --- a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx @@ -163,7 +163,6 @@ describe('directory-picker-browse client half', () => { expect(injected.t('browser.title')).toBe('选择工作区目录') expect(injected.t('browser.newFolder')).toBe('新建文件夹') expect(injected.t('browser.showHidden')).toBe('显示隐藏文件') - expect(injected.t('browser.hideHidden')).toBe('隐藏隐藏文件') }) it('drives the injected browse calls through the hole entry', async () => { diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 624a2c8705..bc2ad81f76 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -107,11 +107,14 @@ describe('DirectoryBrowser', () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) expect(screen.queryByText('.config')).toBeNull() - // Toggle hidden files on. - fireEvent.click(screen.getByRole('button', { name: 'browser.showHidden' })) + // The fixed-label toggle reports its state through aria-pressed. + const toggle = screen.getByRole('button', { name: 'browser.showHidden' }) + expect(toggle.getAttribute('aria-pressed')).toBe('false') + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-pressed')).toBe('true') expect(screen.getByText('.config')).toBeTruthy() - // Toggle hidden files off. - fireEvent.click(screen.getByRole('button', { name: 'browser.hideHidden' })) + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-pressed')).toBe('false') expect(screen.queryByText('.config')).toBeNull() // Close resets the toggle. b.view.rerender() From 60383efef93e083763f7b86239afc7e4798c181e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 13:42:11 +0800 Subject: [PATCH 003/179] feat(host): blur cancels path editing; scrollbar clearance in the miller columns --- .../src/client/DirectoryBrowser.module.css | 11 ++++- .../src/client/DirectoryBrowser.tsx | 42 +++++++++++-------- .../tests/directory-browser.spec.tsx | 14 +++++++ 3 files changed, 48 insertions(+), 19 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index a2c712c811..444ff19cbf 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -55,7 +55,9 @@ align-items: stretch; flex: 1 1 0; min-height: 0; - gap: 20px; + /* Columns already end in an 8px scrollbar clearance, so the divider only + * needs a slim gap of its own on each side. */ + gap: 12px; overflow-x: auto; scrollbar-width: none; } @@ -136,7 +138,9 @@ flex-direction: column; flex: 1 1 0; min-height: 0; - padding: 16px 24px; + /* Right inset is slimmer than the left: the trailing column's own 8px + * scrollbar clearance makes up the optical difference. */ + padding: 16px 16px 16px 24px; } /* Two-pane columns split the row evenly around the divider; 256px is the @@ -149,6 +153,9 @@ flex: 1 1 0; min-width: 256px; overflow-y: auto; + /* The overlay scrollbar paints at the column's edge; keep the row pills + * clear of the thumb. */ + padding-right: 8px; } .columnWide { diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 149e04d87e..5c3391454e 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -200,6 +200,25 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) }, [launchListing]) + /** Abandon path editing (Escape or clicking away) and restore the crumb view. */ + const cancelPathEdit = useCallback(() => { + // Cancel also withdraws a navigation the editor already launched: its + // late success must not jump to the cancelled path, so the pending + // request is superseded and the view leaves the loading state. + supersede() + setLoading(false) + setPathDraft(null) + setError(null) + // Editing may have superseded the selection's preview request; a + // selection with no preview would render a half-empty two-pane view, so + // cancel falls back to the single-pane level. + if (child === null) setSelected(null) + // With no level listed yet (the editor superseded the initial home + // listing), plain cancellation would leave a permanently blank picker: + // restart the home listing. + if (parent === null) navigate() + }, [supersede, child, parent, navigate]) + /** A right-column pick advances the view one level: child becomes the level. */ const advance = useCallback((entry: DirectoryEntry) => { /* v8 ignore next -- narrowing guard: the right column only renders with a child listing. */ @@ -382,25 +401,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, } if (event.key === 'Escape') { event.stopPropagation() - // Cancel also withdraws a navigation the editor already - // launched: its late success must not jump to the - // cancelled path, so the pending request is superseded - // and the view leaves the loading state. - supersede() - setLoading(false) - setPathDraft(null) - setError(null) - // Editing may have superseded the selection's preview - // request; a selection with no preview would render a - // half-empty two-pane view, so cancel falls back to the - // single-pane level. - if (child === null) setSelected(null) - // With no level listed yet (the editor superseded the - // initial home listing), plain cancellation would leave a - // permanently blank picker: restart the home listing. - if (parent === null) navigate() + cancelPathEdit() } }} + // Clicking anywhere outside the editor reads as leaving it: + // focus loss cancels the edit like Escape. Enter keeps focus + // in the input while its navigation is in flight, so a + // submitted path is never withdrawn by this handler. + onBlur={cancelPathEdit} /> )} diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index bc2ad81f76..d4fc9e2b89 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -220,6 +220,20 @@ describe('DirectoryBrowser', () => { expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() }) + it('clicking away from the path editor cancels it back to the crumb view', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: '/somewhere/else' } }) + // Focus moving anywhere outside the editor abandons the draft like Escape. + fireEvent.blur(input) + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + // The crumb view is back and the abandoned draft was never navigated to. + expect(screen.getByRole('button', { name: 'browser.editPath' })).toBeTruthy() + expect(screen.getByRole('listitem').textContent).toBe('Documents') + }) + it('restarts the home listing when Escape cancels an edit opened before any level listed', async () => { // The initial home listing hangs; Edit Path supersedes it while parent // is still null, and Escape must not strand a blank picker. From e561c28232a273f834e66e557e8375a3bb2cf7d0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 13:47:56 +0800 Subject: [PATCH 004/179] feat(host): seed path editor with a trailing separator; prefix-filter levels from the draft tail --- .../src/client/DirectoryBrowser.tsx | 44 +++++++++++++++-- .../tests/directory-browser.spec.tsx | 47 ++++++++++++++++++- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 5c3391454e..01e69f314c 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -12,7 +12,10 @@ * owning flow decides what "Open" means and owns the workspace-creation * error surface. Hidden entries are host-flagged and hidden by default; the * footer's fixed-label "Show hidden files" toggle (aria-pressed, check when - * on) reveals them (client-side only). + * on) reveals them (client-side only). The path editor opens seeded with a + * trailing separator, and while the draft's directory part names a listed + * level, its final segment prefix-filters that level's rows (a dot-led + * prefix also reveals the hidden entries it names). */ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' @@ -60,18 +63,45 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] } +/** The separator a Host path's own platform uses (Windows listings carry backslashes). */ +function separatorOf(path: string): string { + return path.includes('\\') ? '\\' : '/' +} + +/** + * The path draft's final segment, when its directory part is exactly the + * level `listing` lists — the segment the level prefix-filters on while the + * user types. Any other draft (no separator yet, or naming some other + * directory) leaves the level unfiltered. + */ +function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { + if (draft === null) return null + const sep = separatorOf(draft) + const cut = draft.lastIndexOf(sep) + if (cut === -1) return null + const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` + return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null +} + /** One column of folder rows (the Miller view renders one or two of these). */ -function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden }: { +function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden, filterPrefix }: { entries: readonly DirectoryEntry[] selectedPath: string | null busy: boolean onPick: (entry: DirectoryEntry) => void wide: boolean showHidden: boolean + filterPrefix: string | null }) { + const visible = entries.filter((entry) => { + if (filterPrefix !== null && !entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase())) return false + // A dot-led prefix names hidden entries explicitly, so matching ones + // surface even while the toggle keeps the rest hidden. + return showHidden || !entry.hidden || filterPrefix?.startsWith('.') === true + }) return (
- {entries.filter(entry => showHidden || !entry.hidden).map((entry) => { + {visible.map((entry) => { const selected = entry.path === selectedPath return ( // The wrapper carries the list semantics; the row keeps its NATIVE @@ -370,7 +400,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // otherwise close the editor via navigate's draft reset. supersede() setLoading(false) - setPathDraft(selected?.path ?? parent?.path ?? '') + // Seed with a trailing separator so typing immediately + // continues into child names (and prefix-filters below). + const base = selected?.path ?? parent?.path ?? '' + const sep = separatorOf(base) + setPathDraft(base === '' || base.endsWith(sep) ? base : `${base}${sep}`) }} /> @@ -423,6 +457,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onPick={select} wide={!twoPane} showHidden={showHidden} + filterPrefix={draftPrefixFor(parent, pathDraft)} /> )} {twoPane && } @@ -434,6 +469,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onPick={advance} wide={false} showHidden={showHidden} + filterPrefix={draftPrefixFor(child, pathDraft)} /> )}
diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index d4fc9e2b89..98f3bd6e4f 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -206,7 +206,9 @@ describe('DirectoryBrowser', () => { await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) const input = screen.getByLabelText('browser.editPath') - expect(input.value).toBe(HOME) + // The editor seeds with a trailing separator so typing continues into + // child names. + expect(input.value).toBe(`${HOME}/`) fireEvent.change(input, { target: { value: DOCS } }) fireEvent.keyDown(input, { key: 'Enter' }) await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) @@ -220,6 +222,49 @@ describe('DirectoryBrowser', () => { expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() }) + it('prefix-filters the listed level from the draft tail, dot revealing hidden matches', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // The seeded empty segment leaves the level as-is: hidden stays hidden. + expect(screen.getByRole('listitem').textContent).toBe('Documents') + // Case-insensitive prefix narrows the rows. + fireEvent.change(input, { target: { value: `${HOME}/do` } }) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + // A dot-led prefix names hidden entries, so it reveals the match. + fireEvent.change(input, { target: { value: `${HOME}/.co` } }) + expect(screen.getByRole('listitem').textContent).toBe('.config') + // A prefix matching nothing empties the level (no stale rows linger). + fireEvent.change(input, { target: { value: `${HOME}/zzz` } }) + expect(screen.queryByRole('listitem')).toBeNull() + // A draft naming some other directory (or none) leaves the level whole. + fireEvent.change(input, { target: { value: 'no-separator' } }) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + }) + + it('seeds and filters with backslashes on a Windows-rooted listing', async () => { + const ROOT = 'C:\\' + const windowsListing: DirectoryListing = { + path: ROOT, + home: ROOT, + crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }], + entries: [ + { name: 'Program Files', path: `${ROOT}Program Files`, hidden: false }, + { name: 'Users', path: `${ROOT}Users`, hidden: false }, + ], + truncated: false, + } + mount({ listDirectory: vi.fn(async () => windowsListing) }) + await waitFor(() => { expect(screen.getAllByRole('listitem')).toHaveLength(2) }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // The root already ends in its separator: no doubled backslash. + expect(input.value).toBe(ROOT) + fireEvent.change(input, { target: { value: `${ROOT}u` } }) + expect(screen.getByRole('listitem').textContent).toBe('Users') + }) + it('clicking away from the path editor cancels it back to the crumb view', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) From 7714c9fa8b4c890ebc495e766a9d2f778e141953 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 13:52:44 +0800 Subject: [PATCH 005/179] doc(host): document the show-hidden toggle and path-draft prefix filter; snapshot the flow --- ...directory-picker-capability-seam.i18n.yaml | 4 +-- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- apps/web/tests/workspace-flow.snapshot.ts | 33 +++++++++++++++++++ .../directory-picker-browse/README.i18n.yaml | 4 +-- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- 7 files changed, 41 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index bb9425fa64..6f21a60e83 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 7c8f8cb67690cb4c5858cefb52b8cd79e649ec38 -2026-07-28-directory-picker-capability-seam.zh.md: 05545fc3cd758523814b31afa705249972d86464 +2026-07-28-directory-picker-capability-seam.md: 3f5e1436f3af14ce06ffab00ceca90167e16afd0 +2026-07-28-directory-picker-capability-seam.zh.md: 09a3e20f7c12e3c22d63875091593f9a6ca8a1ad diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 7c8f8cb676..3f5e1436f3 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -18,7 +18,7 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. -- **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the planned show-hidden toggle becomes a client-only change. Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. +- **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change (the browse client's footer toggle). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 05545fc3cd..09a3e20f7c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -18,7 +18,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 -- **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,计划中的"显示隐藏"开关变成纯客户端改动。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 +- **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地(browse 客户端的 footer 开关)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index 86a20e73fe..32d44b6fff 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -214,6 +214,39 @@ it('adopts a directory through the composed in-app browse flow and lands in its }) }) +it('reveals hidden fixture entries via the footer toggle and prefix-filters from the path draft', async () => { + boot('?fixture=empty') + + await findLockedComposer() + fireEvent.click(workspaceChip()) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Open local folder…' })) + const dialog = await screen.findByRole('dialog', { name: '选择工作区目录' }, { timeout: 10_000 }) + await within(dialog).findByText('Documents', {}, { timeout: 10_000 }) + // The host flags .config hidden; the level filters it until the + // fixed-label footer toggle presses on (state lives in aria-pressed). + expect(within(dialog).queryByText('.config')).toBeNull() + const toggle = within(dialog).getByRole('button', { name: '显示隐藏文件' }) + expect(toggle.getAttribute('aria-pressed')).toBe('false') + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-pressed')).toBe('true') + await within(dialog).findByText('.config', {}, { timeout: 10_000 }) + fireEvent.click(toggle) + expect(within(dialog).queryByText('.config')).toBeNull() + // The path editor seeds the level's path with a trailing separator and the + // draft's final segment prefix-filters the listed rows while typing. + fireEvent.click(within(dialog).getByRole('button', { name: '编辑路径' })) + const input = within(dialog).getByLabelText('编辑路径') + expect(input.value).toBe('/home/fixture/') + fireEvent.change(input, { target: { value: '/home/fixture/do' } }) + expect(within(dialog).getByText('Documents')).toBeDefined() + expect(within(dialog).getByText('Downloads')).toBeDefined() + expect(within(dialog).queryByText('.config')).toBeNull() + // A dot-led prefix names hidden entries, so its matches surface. + fireEvent.change(input, { target: { value: '/home/fixture/.c' } }) + await within(dialog).findByText('.config', {}, { timeout: 10_000 }) + expect(within(dialog).queryByText('Documents')).toBeNull() +}) + it('selects the recent Workspace and opens its blank Session on first load', async () => { boot('?fixture') diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 4454afde3a..f40742fc0d 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md -README.md: 318380405214d5f25ad77e348c4e134a8981ffb3 -README.zh.md: 2f88f64cc2974b8535e34eb9798f512ea109b754 +README.md: 9772baa2a6e632a5f0f18cc18b9b55b45cd845ca +README.zh.md: 682495fe10bdeed41f709a438dcd222d612129e3 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 3183804052..9772baa2a6 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view, breadcrumb with a click-to-edit path zone, nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view; breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing, and cancels on Escape or focus loss; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 2f88f64cc2..682495fe10 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,7 +6,7 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图、带点击即编辑路径区的面包屑、嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图;带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤、按 Escape 或失焦即取消;基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 From 7639f4cb68e32102dce67a7caf30260cf5ff104f Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Wed, 29 Jul 2026 14:12:01 +0800 Subject: [PATCH 006/179] feat(web): answerable ask_user_question flow with toolview verdict row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pending question now owns exactly two surfaces: the redesigned QuestionComposer takeover (footer pager, checkbox multi-select, always-visible custom input, locale-injected bilingual chrome) collects the answers, and a dedicated ask_user_question toolview row reports the interaction outcome — waiting, N/M answered, cancelled (ASK_CANCELLED), or interrupted with stopped semantics (ASK_ABORTED). PendingCard narrows to approval waits only. Toolview leading icons and the hover chevron unify on the tertiary label color, the checklist glyph matches the 14px figma extract, and dev-watch registers CSS modules so css-only edits rebuild. --- ...29-ask-question-web-presentation.i18n.yaml | 6 + ...026-07-29-ask-question-web-presentation.md | 45 +++ ...-07-29-ask-question-web-presentation.zh.md | 45 +++ docs/event-producer-consumer.md | 2 +- packages/client/tsdown.client.ts | 5 +- .../ui-conversation/src/client/apply.ts | 4 + .../src/client/chat/ChatView.tsx | 5 +- .../src/client/chat/PendingCard.tsx | 27 +- .../src/client/chat/ToolRow.module.css | 10 - .../src/client/chat/ToolRow.tsx | 5 +- .../src/client/toolviews/ask-question-row.tsx | 94 ++++++ .../src/client/toolviews/todo-row.module.css | 58 ---- .../src/client/toolviews/todo-row.tsx | 63 ++-- .../tests/ask-question-row.spec.tsx | 130 ++++++++ .../ui-conversation/tests/chat-apply.spec.tsx | 6 +- .../tests/coverage-tails.spec.tsx | 14 +- .../ui-conversation/tests/todo-panel.spec.tsx | 32 +- .../client/ui-primitives/src/icons/index.tsx | 39 ++- .../client/ui-primitives/tests/icons.spec.tsx | 4 +- packages/client/ui-question/README.i18n.yaml | 6 +- packages/client/ui-question/README.md | 2 + packages/client/ui-question/README.zh.md | 2 + packages/client/ui-question/package.json | 4 +- .../src/client/QuestionComposer.module.css | 280 ++++++++++-------- .../src/client/QuestionComposer.tsx | 206 +++++++------ .../ui-question/src/client/contract/slots.ts | 30 +- .../client/ui-question/src/client/index.ts | 50 +++- .../client/ui-question/src/client/locales.ts | 39 +++ .../ui-question/tests/browser-plugin.spec.ts | 52 +++- .../tests/question-composer.spec.tsx | 48 +-- packages/client/ui-question/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 32 files changed, 869 insertions(+), 450 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md create mode 100644 .agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md create mode 100644 packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx delete mode 100644 packages/client/ui-conversation/src/client/toolviews/todo-row.module.css create mode 100644 packages/client/ui-conversation/tests/ask-question-row.spec.tsx create mode 100644 packages/client/ui-question/src/client/locales.ts diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml new file mode 100644 index 0000000000..6954c289bd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md +2026-07-29-ask-question-web-presentation.md: 90eeb3cdcc1a851b7d5e184c0f31cbccd82cbf55 +2026-07-29-ask-question-web-presentation.zh.md: 5bb19d3a68dc0510ea766d7a22abdc1cff9c326a diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md new file mode 100644 index 0000000000..90eeb3cdcc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md @@ -0,0 +1,45 @@ +# Agent Note: Ask-question Web presentation + +Status: implemented + +English | [中文](2026-07-29-ask-question-web-presentation.zh.md) + +## Problem + +The Web GUI could already collect answers through the `QuestionComposer` composer takeover, but the transcript around it was wrong on three counts. A pending question rendered twice: once as the composer takeover and once as the read-only `PendingCard` placeholder that predates the takeover. A settled `ask_user_question` call rendered as the generic "Tool call" row dumping raw args JSON, so the two composer verdicts — the user dismissing the whole set (`ASK_CANCELLED`) and a turn interrupt landing while the question was pending (`ASK_ABORTED`) — both read as anonymous red-dot failures. And the composer's own chrome copy (pager, buttons, placeholders, validation feedback) was hardcoded Chinese while the surrounding client is bilingual through `dsh-client-locale`. + +Separately, the composer visuals had drifted from the current design: an expand-to-open custom answer entry, no multi-select affordance beyond a trailing check, header-mounted paging, and a `(可多选)` title-suffix convention parsed out of model text. + +## Decision + +A pending question owns exactly two surfaces: the composer takeover collects the answers, and a dedicated `ask_user_question` toolview row in the transcript names the interaction outcome. The row registers into the keyed `conversation.chat.toolview` hole exactly like `todo_write` and composes the shared `ToolRow` (chrome, running sweep, leading expansion). Its summary is the interaction verdict rather than args: `waiting` while running, `N/M answered` from the result JSON once settled (a skipped answer — empty `selected`, no `custom` — stays out of the count), `cancelled` for `ASK_CANCELLED`, and `interrupted` with the shared amber stopped semantics for `ASK_ABORTED`. Malformed or truncated results fall back to the generic summary. `PendingCard` narrows to `PendingWait<'approval'>` and `ChatView` filters the pending list to approval waits, so the placeholder card now exists only for the approval takeover still on the roadmap. + +The composer redesign moves paging into the footer next to the actions, renders multi-select options with explicit checkboxes, keeps single-select numbered rows, and replaces the expand-to-open custom entry with an always-visible custom input row (textarea for optionless questions). The `parseQuestionTitle` multi-select suffix convention is deleted; `multi_select` is already structured metadata, so the title renders verbatim. + +Composer chrome copy becomes bilingual: the plugin registers zh/en dictionaries under the `question` namespace of `dsh-client-locale` and hands the entry a namespace-bound translator plus the locale snapshot as a hooks-compartment source through the slot inject face, so a locale flip re-renders a mounted composer. Validation feedback is stored as a dictionary key and re-translated on flip; carrier failure messages and all model-authored question/option text render verbatim. + +Two adjacent fixes ride along. All generic toolview leading icons (and the hover chevron) now inherit the single tertiary label color — the others-variant secondary override and the separate chevron color rule are deleted, leaving only the intentional cordis business-primary accent. And the client dev-watch bundler registers each CSS module with `addWatchFile`, because the virtual-module indirection previously hid css-only edits from the watcher. + +## Alternatives considered + +**Keep rendering questions through `PendingCard`.** Rejected: the card was a read-only placeholder from before the takeover existed, so a pending question showed the same content twice with one copy not answerable. The toolview row plus takeover covers both the transcript record and the collection surface. + +**Show the questions or answers inline in the transcript row.** Rejected: the composer takeover owns question rendering and answer collection, and the row convention (`todo_write`) is one line with details in the panel. The row therefore reports only the outcome, mirroring how the todo row reports counts while the panel owns the list. + +**Render `ASK_CANCELLED`/`ASK_ABORTED` through the generic error shape.** Rejected: dismissal is the user's own deliberate action and an interrupt is the shared stop gesture; both are expected outcomes, not tool failures. Naming the verdict (and keeping amber stopped semantics for the abort) matches how interrupted tool calls read elsewhere. + +**Translate the row verdicts now.** Deferred by explicit product decision: the row's `waiting`/`answered`/`cancelled`/`interrupted` strings stay English for this change; the composer chrome i18n landed because its Chinese-only copy was already wrong for the en locale. + +**Keep the title-suffix multi-select convention.** Rejected: `multi_select` is structured request metadata and the checkbox affordance now carries the signal, so parsing `(可多选)` out of model text was a fragile duplicate channel. + +## Consequences + +`ask_user_question` and `todo_write` now demonstrate the intended toolview pattern: compose `ToolRow`, summarize from call args or result JSON with shape-checked fallbacks, and register through the keyed slot. The bespoke `todo-row.module.css` is gone. + +The row verdict strings are the one remaining hardcoded-English surface of the question flow; localizing them is deferred follow-up. `PendingCard` remains a visible-but-not-answerable approval placeholder until the approval composer takeover ships. + +`ui-question` gains a `dsh-client-locale` dependency and an inject face where it previously had none; its contract (`QuestionComposerInjected`) lives with the consumer in `contract/slots.ts`. + +## Verification + +`ui-conversation` tests pin the row's waiting/answered/skipped/cancelled/interrupted/fallback matrix, the approval-only pending filter, and the slot registration; `ui-question` tests pin the redesigned composer (checkbox multi-select, always-visible custom row, footer pager, dictionary-key feedback re-translation, IME-safe Enter) and the plugin's dictionary registration plus inject face; `ui-primitives` tests pin the icon set. The assembled Web GUI was exercised against a live session covering answer, cancel, and turn-interrupt paths. diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md new file mode 100644 index 0000000000..5bb19d3a68 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md @@ -0,0 +1,45 @@ +# Agent Note:Ask-question Web 呈现 + +Status: implemented + +[English](2026-07-29-ask-question-web-presentation.md) | 中文 + +## 问题 + +Web GUI 已经可以通过 `QuestionComposer` 的输入区接管收集回答,但其周边的会话记录呈现在三个方面是错的。待回答的问题会渲染两次:一次是输入区接管,一次是早于接管存在的只读 `PendingCard` 占位卡片。已结算的 `ask_user_question` 调用渲染为通用 "Tool call" 行并直接倾倒原始 args JSON,因此两种输入区裁决 —— 用户放弃整组问题(`ASK_CANCELLED`)与问题待回答期间轮次被打断(`ASK_ABORTED`)—— 都显示为无名的红点失败。而且输入区自身的界面文案(分页、按钮、占位符、校验反馈)是硬编码中文,而周边客户端已通过 `dsh-client-locale` 实现双语。 + +另外,输入区视觉也偏离了当前设计:自定义回答需展开才能输入、多选除尾部对勾外没有可见标识、分页挂在头部、还有从模型文本里解析 `(可多选)` 标题后缀的约定。 + +## 决定 + +一个待回答的问题恰好拥有两个界面:输入区接管收集回答,会话记录中一个专门的 `ask_user_question` toolview 行陈述交互结果。该行与 `todo_write` 完全一样注册进带 key 的 `conversation.chat.toolview` 槽位,并复用共享的 `ToolRow`(外观、运行扫光、前导展开)。其摘要是交互裁决而非参数:运行中显示 `waiting`,结算后从结果 JSON 得出 `N/M answered`(被跳过的回答 —— `selected` 为空且无 `custom` —— 不计入),`ASK_CANCELLED` 显示 `cancelled`,`ASK_ABORTED` 显示 `interrupted` 并沿用共享的琥珀色 stopped 语义。畸形或截断的结果回退到通用摘要。`PendingCard` 收窄为 `PendingWait<'approval'>`,`ChatView` 将待处理列表过滤为仅审批等待,占位卡片从此只服务于仍在路线图上的审批接管。 + +输入区重设计将分页移到底部操作区旁,多选选项渲染显式复选框,单选保留编号行,并用始终可见的自定义输入行取代展开式自定义入口(无选项问题用多行文本框)。删除 `parseQuestionTitle` 的多选后缀约定;`multi_select` 已是结构化元数据,标题原样渲染。 + +输入区界面文案实现双语:插件在 `dsh-client-locale` 的 `question` 命名空间下注册中英词典,并通过槽位 inject face 向条目提供绑定命名空间的翻译器和作为 hooks 舱源的 locale 快照,语言切换时已挂载的输入区会重新渲染。校验反馈以词典 key 存储、切换时重新翻译;载体失败消息与所有模型撰写的问题/选项文本原样渲染。 + +两个相邻修复随行。所有通用 toolview 前导图标(含悬停箭头)现在统一继承三级标签色 —— 删除了 others 变体的二级色覆盖和独立的箭头颜色规则,只保留有意为之的 cordis 业务主色强调。客户端 dev-watch 打包器用 `addWatchFile` 注册每个 CSS 模块,因为虚拟模块间接层此前使仅改 CSS 的编辑对 watcher 不可见。 + +## 曾考虑的替代方案 + +**继续通过 `PendingCard` 渲染问题。** 否决:该卡片是接管存在之前的只读占位,导致同一内容显示两份且其中一份不可作答。toolview 行加接管同时覆盖了记录与收集两个面。 + +**在会话记录行内联显示问题或回答。** 否决:输入区接管拥有问题渲染与回答收集,而行的约定(`todo_write`)是单行、详情在面板。因此行只报告结果,正如 todo 行报告计数而面板拥有列表。 + +**用通用错误形态渲染 `ASK_CANCELLED`/`ASK_ABORTED`。** 否决:放弃是用户自己的主动操作,打断是共享的停止手势;两者都是预期结果而非工具失败。命名裁决(且中止保持琥珀色 stopped 语义)与其他被打断的工具调用的呈现一致。 + +**现在就翻译行内裁决文案。** 依明确的产品决定推迟:本次改动中行的 `waiting`/`answered`/`cancelled`/`interrupted` 字符串保持英文;输入区界面文案的国际化落地是因为其仅中文的文案在 en 语言下本就是错的。 + +**保留标题后缀的多选约定。** 否决:`multi_select` 是结构化请求元数据且复选框标识已承载该信号,从模型文本解析 `(可多选)` 是脆弱的重复通道。 + +## 后果 + +`ask_user_question` 与 `todo_write` 现在共同示范预期的 toolview 模式:复用 `ToolRow`、从调用参数或结果 JSON 做带形状校验回退的摘要、通过带 key 的槽位注册。专用的 `todo-row.module.css` 已删除。 + +行内裁决字符串是问题流程仅剩的硬编码英文面;将其本地化是推迟的后续工作。在审批输入区接管交付之前,`PendingCard` 仍是可见但不可操作的审批占位。 + +`ui-question` 新增 `dsh-client-locale` 依赖和此前没有的 inject face;其契约(`QuestionComposerInjected`)与消费者一起放在 `contract/slots.ts`。 + +## 验证 + +`ui-conversation` 测试钉住行的 waiting/answered/skipped/cancelled/interrupted/回退矩阵、仅审批的待处理过滤和槽位注册;`ui-question` 测试钉住重设计的输入区(复选框多选、始终可见的自定义行、底部分页、词典 key 反馈重翻译、IME 安全的 Enter)以及插件的词典注册与 inject face;`ui-primitives` 测试钉住图标集。组装后的 Web GUI 在真实会话中演练了回答、取消与轮次打断路径。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e9e5bb6e6a..2ab220305c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -68,7 +68,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | -| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` | +| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-question`, `ui-settings-general` | | `slots/changed` | `runtime` (`emit`) | - | | `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` | diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 9b93feae8b..6b004c80b4 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -124,9 +124,12 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi const abs = importer !== undefined ? resolvePath(dirname(importer), source) : source return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX }, - async load(virtualId: string) { + async load(this: { addWatchFile?: (id: string) => void }, virtualId: string) { if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length) + // Virtual modules hide the real file from the watcher; register it so + // dev-web rebuilds on a css-only edit. + this.addWatchFile?.(fileId) const source = await readFile(fileId) const { code, exports: cssExports } = transform({ filename: fileId, diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 62801ca0b8..48dee62787 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -14,6 +14,7 @@ import { InputBar } from './skeleton/InputBar.tsx' import { ChatView } from './chat/ChatView.tsx' import { bashToolviewSample } from './toolviews/bash-sample.tsx' import { todoToolview } from './toolviews/todo-row.tsx' +import { askQuestionToolview } from './toolviews/ask-question-row.tsx' import { todoDockEntry } from './skeleton/TodoPanel.tsx' import { queueDockEntry } from './queue/QueueDock.tsx' import { ConversationRoot } from './skeleton/ConversationRoot.tsx' @@ -187,6 +188,9 @@ export function apply(ctx: Context): void { // The todo_write row rides the same seam (a product registration, not a sample). ctx.plugin(todoToolview) + // The ask_user_question row: waiting/answered/cancelled interaction outcome. + ctx.plugin(askQuestionToolview) + // The plan strip rides the input dock above the queue rows (same posture). ctx.plugin(todoDockEntry) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index deb7f09f6c..e5d80d52c6 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -361,7 +361,10 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio ))} )} - {pending.map(item => )} + {/* Approval waits only: a pending question already shows as the + ask_user_question row (waiting state) plus the composer takeover. */} + {pending.filter(item => item.kind === 'approval') + .map(item => )} {/* Turn-level loading signal: rides the whole running turn (first-token wait, tool execution, streaming) so it never flickers per step. */} {running && } diff --git a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx index b6825aed9a..5a2076fe85 100644 --- a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx @@ -1,31 +1,22 @@ -// PendingCard: approval/question placeholder card (visible, not answerable — -// the composer-takeover approval panel is a P-II item; wire pending semantics -// already exist so the flow must show them). +// PendingCard: approval placeholder card (visible, not answerable — the +// composer-takeover approval panel is a P-II item; wire pending semantics +// already exist so the flow must show them). Question waits render through +// the ask_user_question toolview row + the composer takeover instead. import { memo } from 'react' -import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client' -import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' import css from './PendingCard.module.css' export interface PendingCardProps { - item: PendingInteraction + item: PendingWait<'approval'> } export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) { return (
- {item.kind === 'approval' ? ( - <> -
等待审批:{item.payload.toolName}
- {item.payload.reason !== undefined &&
{item.payload.reason}
} - - ) : ( - <> -
等待回答({item.payload.questions.length} 题)
- - - )} -
请在原客户端处理(web 端作答后续里程碑提供)
+
等待审批:{item.payload.toolName}
+ {item.payload.reason !== undefined &&
{item.payload.reason}
} +
请在原客户端处理(web 端审批后续里程碑提供)
) }) diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index 018529961f..c18bbefb01 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -62,12 +62,6 @@ color: var(--dsw-alias-label-tertiary); } -/* The others-variant sparkle glyph is one gray step darker than the icon - family in the source design. */ -.root[data-variant='others'] .leading { - color: var(--dsw-alias-label-secondary); -} - /* Cordis lifecycle tools retain their generic row mechanics while carrying a shared product accent and tool-owned action title. */ .root[data-tool^='cordis_'] .leading, @@ -87,10 +81,6 @@ button.leading { cursor: pointer; } -.chevron { - color: var(--dsw-alias-label-secondary); -} - /* Hover preview on expandable rows: the idle tool icon crossfades (100ms) into a down chevron before the row is opened. The chevron overlays the icon cell absolutely so both can stay mounted for the opacity transition. */ diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index 5c5d059292..6abca0d739 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -7,7 +7,6 @@ // expandable content, retiring the details-panel handoff where feasible. import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' -import clsx from 'clsx' import { CodeBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts' @@ -74,12 +73,12 @@ export function ToolRow({ ? ( <> {icon} - + ) : icon const leading = open - ? + ? : leadingFor(state, collapsedIcon) return (
diff --git a/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx b/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx new file mode 100644 index 0000000000..3ba94cc438 --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx @@ -0,0 +1,94 @@ +// ask_user_question toolview: question-flavored summary row replacing the +// generic "Tool call" card, registered into the keyed +// 'conversation.chat.toolview' hole like todo-row. The row composes ToolRow +// (chrome, running sweep, leading expansion) and swaps in the interaction +// outcome — `waiting` while pending, answered-count once settled, `cancelled` +// when the user dismissed the whole set — because the questions themselves +// render in the composer takeover. + +import { IconQuestionOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { Context } from 'cordis' +import type { ToolRowProps } from '../contract/slots.ts' +import { toolRowModel } from '../contract/tool-call-model.ts' +import { ToolRow } from '../chat/ToolRow.tsx' + +/** One parsed answer entry, shape-checked (result JSON crosses the wire). */ +interface AnswerEntry { selected?: unknown; custom?: unknown } + +function isAnswer(value: unknown): value is AnswerEntry { + return typeof value === 'object' && value !== null +} + +/** `${answered}/${total} answered` off the result JSON (a skipped question has + * empty `selected` and no `custom`); null on unexpected shape (generic fallback). */ +function answeredSummary(text: string): string | null { + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + return null + } + if (typeof parsed !== 'object' || parsed === null) return null + const answers = (parsed as { answers?: unknown }).answers + if (!Array.isArray(answers) || !answers.every(isAnswer)) return null + const answered = answers.filter(a => + (Array.isArray(a.selected) && a.selected.length > 0) + || (typeof a.custom === 'string' && a.custom !== '')).length + return `${answered}/${answers.length} answered` +} + +/** One-line question-interaction row (row click opens details; leading toggle + * expands the raw args). */ +export function AskQuestionRow({ toolName, block, openDetails }: ToolRowProps) { + const model = toolRowModel(toolName, block) + // Composer verdicts settle the call as specific UserInteractionErrors + // (apiproxy ask_user_question handler): 'ASK_CANCELLED' is the user's own + // dismissal of the set, 'ASK_ABORTED' is a turn interrupt landing while the + // question was pending. Both name their verdict instead of the generic + // failed shape, and the abort keeps the shared stopped (amber) semantics of + // any other interrupted tool call. + const code = 'kind' in block ? block.error?.code : undefined + let summary = model.summary + let state = model.state + if (code === 'ASK_CANCELLED') { + summary = 'cancelled' + } else if (code === 'ASK_ABORTED') { + summary = 'interrupted' + state = 'stopped' + } else if (model.state === 'running') { + summary = 'waiting' + } else if ('kind' in block && model.state === 'ok') { + const text = block.content.filter(b => b.type === 'text').map(b => b.text).join('') + summary = answeredSummary(text) ?? model.summary + } + return ( + } + title="Ask question" + summary={summary} + body={model.body} + state={state} + onOpenDetails={openDetails} + /> + ) +} + +/** + * The ask-question row as a plain registrant plugin, riding the same + * load-order seam as todo-toolview: `inject: ['conversation']` guarantees the + * chat entry (and with it the 'conversation.chat.toolview' declaration) is on + * the ledger. + */ +export const askQuestionToolview = { + name: 'ask-question-toolview', + inject: ['slots', 'conversation'], + /** + * Register the ask-question row into the chat view's keyed toolview hole. + * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). + */ + apply(ctx: Context): void { + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'ask_user_question' }, AskQuestionRow) + }, +} diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css deleted file mode 100644 index 1a1b142b3a..0000000000 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css +++ /dev/null @@ -1,58 +0,0 @@ -/* todo_write plan-update row: ToolRow chrome (figma 780:53675) — - [16 checklist] gap6 [title 14/24] gap8 [2x2 dot] gap8 [summary FILL truncate]. */ - -.row { - display: flex; - align-items: center; - height: 24px; - min-width: 0; - cursor: pointer; - border-radius: 6px; -} - -.leading { - flex: none; - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - margin-right: 6px; - color: var(--dsw-alias-label-tertiary); -} - -.title { - flex: none; - font-size: 14px; - line-height: 24px; - font-weight: 500; /* figma wt510, rendered 500 */ - color: var(--dsw-alias-label-primary-dimmed); -} - -.sep { - flex: none; - width: 2px; - height: 2px; - border-radius: 1px; - margin: 0 8px; - background: var(--dsw-alias-label-caption); -} - -.summary { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 14px; - line-height: 24px; - color: var(--dsw-alias-label-tertiary); -} - -.err { - flex: none; - margin-left: 8px; - color: var(--dsw-alias-state-error-primary); - font-size: 11px; - line-height: 16px; -} diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx index a47322b614..2d72cfc700 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx @@ -1,16 +1,16 @@ // todo_write toolview: plan-flavored summary row replacing the generic // "Tool call" card, registered into the keyed 'conversation.chat.toolview' // hole like the bash sample (a product registration, not a sample). The row -// summarizes the written list (counts + active item) from the call args; the +// composes ToolRow (chrome, running sweep, leading expansion) and swaps in a +// summary of the written list (counts + active item) from the call args; the // durable list itself renders in the TodoPanel above the composer, so the -// row stays one line. Chrome matches ToolRow (figma 780:53675). +// row stays one line. -import type { KeyboardEvent } from 'react' +import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from 'cordis' -import { IconChecklistOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowProps } from '../contract/slots.ts' -import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' -import css from './todo-row.module.css' +import { toolRowModel } from '../contract/tool-call-model.ts' +import { ToolRow } from '../chat/ToolRow.tsx' /** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */ interface TodoWriteItem { content?: unknown; status?: unknown } @@ -40,48 +40,25 @@ function summarize(argsRaw: string): string | null { : head } -/** Leading-slot state substitution matches ToolRow / bash: icon yields to the - * state semantic while running or failed; ok keeps the checklist glyph. */ -function leadingFor(state: ToolRowState) { - switch (state) { - case 'running': return - case 'error': return - case 'stopped': return - default: return - } -} - -/** One-line plan update row (click opens the raw args in details). Non-ok - * execution states keep the generic row's dot semantics — a cancelled call - * wrote no todo/write, so it must not read as a completed update. */ +/** One-line plan update row (row click opens details; leading toggle expands + * the raw args). Non-ok execution states keep the shared row's dot semantics + * — a cancelled call wrote no todo/write, so it must not read as a completed + * update. */ export function TodoRow({ toolName, block, openDetails }: ToolRowProps) { const model = toolRowModel(toolName, block) const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? '' const summary = summarize(argsRaw) ?? model.summary - // Button semantics, not a - - -
+
@@ -211,7 +189,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) { return ( ) })} -
- {hasOptions && ( - - )} - {draft.customOpen && ( + {hasOptions + ? ( +
+ {question.multiSelect === true + ? ( + + ) + : ( + + )} + { + const value = event.target.value + updateDraft(current => ({ + ...current, selected: [], custom: value, skipped: false, + })) + }} + onKeyDown={(event) => { + if (event.key === 'Enter' && !isComposing(event)) { + event.preventDefault() + continueFlow() + } + }} + /> +
+ ) + : (