From 88d2770189fc5152f24723bac4fcae2a1bbc32a4 Mon Sep 17 00:00:00 2001 From: Yuchao Wang Date: Wed, 21 Oct 2026 10:40:04 +0000 Subject: [PATCH 1/4] feat(workspace): add keyboard command palette for projected navigation Signed-off-by: Yuchao Wang --- apps/presentation/dashboard/package.json | 5 +- ...orkspace-command-palette-browser-smoke.mjs | 217 ++++++++++++++++++ .../personal-workspace/goal-sidebar.tsx | 5 +- .../src/features/personal-workspace/i18n.tsx | 28 +++ .../personal-workspace-page.tsx | 17 ++ .../workspace-command-entries.test.mjs | 50 ++++ .../workspace-command-entries.ts | 57 +++++ .../workspace-command-palette.css | 16 ++ .../workspace-command-palette.tsx | 105 +++++++++ .../personal-workspace/workspace-shell.tsx | 3 + .../dashboard/src/views/dashboard-page.tsx | 5 +- .../rfcs/goal-workspace-surface-v0.md | 12 + .../rfcs/goal-workspace-surface-v0.zh-CN.md | 9 + .../command-palette-after.png | Bin 0 -> 172302 bytes .../command-palette-before.png | Bin 0 -> 169994 bytes .../command-palette-desktop.png | Bin 0 -> 174839 bytes .../command-palette-narrow.png | Bin 0 -> 49865 bytes docs/guides/personal-workspace-user-guide.md | 31 +++ 18 files changed, 556 insertions(+), 4 deletions(-) create mode 100644 apps/presentation/dashboard/smoke/workspace-command-palette-browser-smoke.mjs create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/workspace-command-entries.test.mjs create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/workspace-command-entries.ts create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/workspace-command-palette.css create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/workspace-command-palette.tsx create mode 100644 docs/assets/personal-workspace/command-palette-after.png create mode 100644 docs/assets/personal-workspace/command-palette-before.png create mode 100644 docs/assets/personal-workspace/command-palette-desktop.png create mode 100644 docs/assets/personal-workspace/command-palette-narrow.png diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 416c247495..b8d38d1770 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -44,7 +44,7 @@ "smoke:personal-workspace-packaged": "npm run smoke:chat-model-menu && npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs src/features/personal-workspace/composer-keyboard.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/runtime-readiness.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs", "smoke:personal-workspace-packaged:catalog": "npm run smoke:chat-model-menu && npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs src/features/personal-workspace/composer-keyboard.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/runtime-readiness.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:personal-workspace-packaged:team": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs", - "smoke:personal-workspace-packaged:craft": "LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs", + "smoke:personal-workspace-packaged:craft": "LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" npm run smoke:workspace-command-palette", "smoke:todo-resume-condition": "rm -rf /tmp/loopx-todo-resume-condition-smoke && tsc --ignoreConfig --target ES2022 --module commonjs --moduleResolution node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-todo-resume-condition-smoke smoke/todo-resume-condition-smoke.ts src/features/personal-workspace/todo-resume-condition.ts && node /tmp/loopx-todo-resume-condition-smoke/smoke/todo-resume-condition-smoke.js", "smoke:presentation-surface-schema": "rm -rf /tmp/loopx-presentation-surface-schema-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --resolveJsonModule --esModuleInterop --outDir /tmp/loopx-presentation-surface-schema-smoke smoke/presentation-surface-schema-smoke.ts src/data/status.ts src/data/decision-research.ts src/data/goal-channel-frontstage.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-presentation-surface-schema-smoke/apps/presentation/dashboard/smoke/presentation-surface-schema-smoke.js", "smoke:projection-localization": "rm -rf /tmp/loopx-projection-localization-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck --strict --outDir /tmp/loopx-projection-localization-smoke smoke/projection-localization-smoke.ts src/features/personal-workspace/projection-localization.ts && node /tmp/loopx-projection-localization-smoke/smoke/projection-localization-smoke.js", @@ -77,7 +77,8 @@ "smoke:workspace-scope-packaged": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/workspace-scope-browser-smoke.mjs", "smoke:recent-completions": "tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --resolveJsonModule --esModuleInterop --jsx react-jsx --skipLibCheck --strict --types node --outDir /tmp/loopx-recent-completions-smoke smoke/recent-completions-smoke.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-recent-completions-smoke/apps/presentation/dashboard/smoke/recent-completions-smoke.js", "smoke:task-inspector-route": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/task-inspector-route-smoke.mjs", - "smoke:bound-conversation": "vite build --ssr smoke/bound-conversation-smoke.ts --outDir node_modules/.cache/loopx-bound-conversation --emptyOutDir && node node_modules/.cache/loopx-bound-conversation/bound-conversation-smoke.js" + "smoke:bound-conversation": "vite build --ssr smoke/bound-conversation-smoke.ts --outDir node_modules/.cache/loopx-bound-conversation --emptyOutDir && node node_modules/.cache/loopx-bound-conversation/bound-conversation-smoke.js", + "smoke:workspace-command-palette": "node --experimental-strip-types --test src/features/personal-workspace/workspace-command-entries.test.mjs && node --experimental-strip-types smoke/workspace-command-palette-browser-smoke.mjs" }, "dependencies": { "@fontsource-variable/geist": "^5.3.0", diff --git a/apps/presentation/dashboard/smoke/workspace-command-palette-browser-smoke.mjs b/apps/presentation/dashboard/smoke/workspace-command-palette-browser-smoke.mjs new file mode 100644 index 0000000000..4edb307f53 --- /dev/null +++ b/apps/presentation/dashboard/smoke/workspace-command-palette-browser-smoke.mjs @@ -0,0 +1,217 @@ +// RFC workspace V6/V9: navigation is keyboard accessible and never executes work. +// Synthetic browser coverage plus real disposable Todo/status readback below. +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { spawn, execFile } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { promisify } from 'node:util'; +import { mkdir, mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { startServer, port, outputDir, repoRoot } from '../../../../examples/personal-workspace-browser/fixture.mjs'; +import { launchBrowser, loadPlaywright, waitForHttp } from '../../../../examples/dashboard-browser-smoke-support.mjs'; +import { openWorkspacePage, fetchWorkspaceBackend } from '../../../../examples/personal-workspace-browser/scenario-context.mjs'; +import { installShowcaseWorkspace, openShowcaseGoal } from '../../../../examples/personal-workspace-browser/showcase-workspace.mjs'; +import { resolveTestPython } from '../../../../scripts/test-python.mjs'; + +const server = await startServer(); +let browser; +const url = `http://127.0.0.1:${port}/chat/?statusUrl=/status.json`; +const preview = { + schema_version: 'loopx_chat_action_proposal_v1', proposal_id: 'palette-review-draft', action_kind: 'todo.create', + summary: 'Review the export checklist', normalized_parameters: {goal_id: 'progress-projection', text: 'Review the export checklist'}, + context: {kind: 'goal', goal_id: 'progress-projection'}, expected_state_fingerprint: 'synthetic-palette-basis', + permission_classification: 'durable_write', validation_evidence: ['Synthetic pending preview'], + available_transitions: ['apply', 'cancel', 'regenerate'], status: 'preview_ready', receipt: null, stale: null, + created_at: '2026-09-01T10:00:00Z', updated_at: '2026-09-01T10:00:00Z', +}; +const dialogFor = page => page.locator('dialog.workspace-command-palette'); +async function openPalette(page, shortcut = 'Control+k') { + await page.keyboard.press(shortcut); + await dialogFor(page).waitFor({state: 'visible'}); + return dialogFor(page).getByRole('combobox'); +} +try { + await waitForHttp(url); + await mkdir(outputDir, {recursive: true}); + browser = await launchBrowser(loadPlaywright().chromium); + for (const locale of ['en', 'zh-CN']) { + const session = await openWorkspacePage(browser, url, {apiOptions: {initialActionProposals: [preview]}, beforeGoto: async (api, page) => { + await page.addInitScript(locale => localStorage.setItem('loopx-pw-locale', locale), locale); + await installShowcaseWorkspace(api, page, {done: 23, cost: 24.8, deliveryFails: false}); + }}); + const {page} = session; + try { + await openShowcaseGoal(page); + await page.screenshot({path: resolve(outputDir, `commands-closed-${locale}.png`), animations: 'disabled'}); + const launcher = page.getByRole('button', {name: locale === 'en' ? 'Quick navigation' : '快速导航', exact: true}); + await launcher.focus(); + const writes = []; + page.on('request', request => {if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) writes.push(new URL(request.url()).pathname);}); + const search = await openPalette(page, locale === 'en' ? 'Meta+k' : 'Control+k'); + assert.equal(await search.evaluate(element => element === document.activeElement), true); + const initialId = await search.getAttribute('aria-activedescendant'); + await search.press('ArrowDown'); + assert.notEqual(await search.getAttribute('aria-activedescendant'), initialId); + await search.press('ArrowUp'); + assert.equal(await search.getAttribute('aria-activedescendant'), initialId); + await search.fill('no-such-destination'); + assert.equal(await dialogFor(page).getByRole('option').count(), 0); + assert.equal(await search.getAttribute('aria-activedescendant'), null); + await search.press('Enter'); + assert.equal(await dialogFor(page).isVisible(), true); + await search.fill('map legacy'); + assert.equal(await dialogFor(page).getByRole('option').count(), 1); + await search.dispatchEvent('compositionstart'); + await search.dispatchEvent('keydown', {key: 'Enter', keyCode: 229, isComposing: true}); + await search.dispatchEvent('keydown', {key: 'Escape', keyCode: 229, isComposing: true}); + assert.equal(await dialogFor(page).isVisible(), true, 'IME confirmation must not navigate or close'); + await search.dispatchEvent('compositionend'); + await search.press('Escape'); + await dialogFor(page).waitFor({state: 'hidden'}); + assert.equal(await launcher.evaluate(element => element === document.activeElement), true); + await launcher.click(); + await dialogFor(page).waitFor({state: 'visible'}); + await dialogFor(page).getByRole('combobox').fill('map legacy'); + await page.keyboard.press('Enter'); + await dialogFor(page).waitFor({state: 'hidden'}); + await page.locator('[data-context-drawer]').waitFor({state: 'visible'}); + assert.match(page.url(), /todo=showcase-map-columns/); + // The palette on top of an inspector must not close that inspector on Escape. + await openPalette(page); + await page.keyboard.press('Escape'); + assert.equal(await page.locator('[data-context-drawer]').isVisible(), true); + await page.keyboard.press('Escape'); + await page.locator('[data-context-drawer]').waitFor({state: 'hidden'}); + const previewSearch = await openPalette(page); + await previewSearch.fill('palette-review-draft'); + await dialogFor(page).getByRole('option').waitFor({state: 'visible'}); + await previewSearch.press('Enter'); + await page.locator('[data-context-kind="proposal"]').waitFor({state: 'visible'}); + assert.deepEqual(session.api.actionApplies, [], 'opening a stored preview cannot apply it'); + assert.deepEqual(session.api.actionPreviews, [], 'opening does not create another preview'); + await page.keyboard.press('Escape'); + await openPalette(page); + await page.screenshot({path: resolve(outputDir, `commands-open-${locale}.png`), animations: 'disabled'}); + await page.setViewportSize({width: 390, height: 844}); + await page.emulateMedia({reducedMotion: 'reduce'}); + const box = await dialogFor(page).boundingBox(); + assert.ok(box.x >= 0 && box.x + box.width <= 390 && box.height <= 844); + assert.equal(await dialogFor(page).evaluate(element => element.scrollWidth <= element.clientWidth), true); + await page.screenshot({path: resolve(outputDir, `commands-narrow-${locale}.png`), animations: 'disabled'}); + // Native modal keeps keyboard focus inside it. + for (let index = 0; index < 4; index++) { + await page.keyboard.press('Tab'); + assert.equal(await dialogFor(page).evaluate(element => element.contains(document.activeElement)), true); + } + await page.keyboard.press('Escape'); + await page.locator('.personal-mobile-menu').click(); + await launcher.click(); + await dialogFor(page).waitFor({state: 'visible'}); + assert.equal(await dialogFor(page).getByRole('combobox').evaluate(element => element === document.activeElement), true); + await page.keyboard.press('Escape'); + await page.setViewportSize({width: 1440, height: 900}); + await openPalette(page); + await dialogFor(page).getByRole('combobox').fill('Onboarding email'); + await page.keyboard.press('Enter'); + await dialogFor(page).waitFor({state: 'hidden'}); + await page.waitForURL(/goalId=product-release/); + await openPalette(page); + await dialogFor(page).getByRole('combobox').fill('map legacy'); + assert.equal(await dialogFor(page).getByRole('option').count(), 0, 'old Goal tasks disappear'); + // Browser history changes the Goal while the palette is open. Its old + // search and selection must not reopen when that Goal is revisited. + await page.goBack(); + await dialogFor(page).waitFor({state: 'hidden'}); + await page.goForward(); + await page.waitForURL(/goalId=product-release/); + assert.equal(await dialogFor(page).isVisible(), false); + assert.deepEqual(writes, [], 'open/search/navigation must not submit any request'); + await page.keyboard.press('Escape'); + const sessionRequest = page.waitForRequest(request => new URL(request.url()).pathname === '/api/chat/sessions' && request.method() === 'POST'); + await page.getByRole('navigation', {name: /^(Goal 视图|Goal view)$/}).getByRole('button', {name: /^(Chat|对话)$/}).click(); + await sessionRequest; + await page.getByRole('navigation', {name: /^(Goal 视图|Goal view)$/}).getByRole('button', {name: /^(Overview|概览)$/}).click(); + const resultSearch = await openPalette(page); + await resultSearch.fill('publication-workspace-smoke'); + await dialogFor(page).getByRole('option').waitFor({state: 'visible'}); + await resultSearch.press('Enter'); + await page.locator('[data-context-kind="output"]').waitFor({state: 'visible'}); + } finally {await session.close();} + } + + const remote = await openWorkspacePage(browser, `http://127.0.0.1:${port}/chat/?statusUrl=http%3A%2F%2F127.0.0.1%3A8976%2Fstatus.json`, {beforeGoto: async (_api, page) => { + const snapshot = JSON.parse(await readFile(resolve(repoRoot, 'examples/status.example.json'), 'utf8')); + await page.route('http://127.0.0.1:8976/status.json*', route => route.fulfill({json: snapshot})); + }}); + try { + await remote.page.locator('.personal-goal-row').first().locator('.personal-goal-link').click(); + await openPalette(remote.page); + assert.match(await dialogFor(remote.page).innerText(), /Read-only source|只读来源/); + assert.equal(await dialogFor(remote.page).getByRole('option', {name: /Review preview|查看预览/}).count(), 0); + } finally {await remote.close();} + + // A real owner, disposable state and an interrupted status read establish + // more than fixture-only component coverage. No Agent/model is launched. + const python = resolveTestPython({repoRoot}); + const root = await mkdtemp(join(tmpdir(), 'loopx-command-owner-')); + const child = spawn(python, ['-u', resolve(repoRoot, 'examples/personal-workspace-browser/goal-workspace-todo-backend.py'), root], { + cwd: repoRoot, env: {...process.env, PYTHONPATH: repoRoot}, stdio: ['ignore', 'pipe', 'pipe'], + }); + const closed = once(child, 'close'); + const lines = createInterface({input: child.stdout}); + let diagnostic = '', session; + child.stderr.on('data', chunk => {diagnostic = (diagnostic + chunk).slice(-4000);}); + try { + const ready = await Promise.race([once(lines, 'line').then(([line]) => JSON.parse(line)), closed.then(() => {throw new Error(diagnostic);})]); + const backend = `http://127.0.0.1:${ready.port}`; + const cli = async (...args) => JSON.parse((await promisify(execFile)(python, + ['-c', 'from loopx.entrypoint import main; main()', '--format', 'json', '--registry', ready.registry, 'todo', ...args], + {cwd: repoRoot, env: {...process.env, PYTHONPATH: repoRoot}})).stdout); + const text = '核对导出报告 Validate export report'; + const added = await cli('add', '--goal-id', 'progress-projection', '--role', 'agent', '--task-class', 'advancement_task', '--text', text); + assert.equal(added.ok, true); + let failStatus = false; + session = await openWorkspacePage(browser, url, {beforeGoto: async (_api, page) => { + await page.route(target => target.pathname === '/status.json', async route => { + if (failStatus) return route.fulfill({status: 503, json: {error: 'Synthetic source outage'}}); + const response = await fetchWorkspaceBackend(route, backend); + await route.fulfill({response}); + }); + await page.route('**/api/chat/todos/*', async route => { + const response = await fetchWorkspaceBackend(route, backend); + await route.fulfill({response}); + }); + }}); + const {page} = session; + await page.locator('.personal-goal-row').first().locator('.personal-goal-link').click(); + await page.getByTestId('goal-vitals').waitFor({state: 'visible'}); + const search = await openPalette(page); + await search.fill('核对'); + await dialogFor(page).getByRole('option', {name: /核对导出报告/}).waitFor({state: 'visible'}); + await search.press('Enter'); + await page.locator('[data-context-drawer]').waitFor({state: 'visible'}); + assert.match(page.url(), new RegExp(`todo=${added.todo_id}`)); + await page.keyboard.press('Escape'); + failStatus = true; + await page.getByRole('button', {name: /^(Refresh status|刷新状态)$/}).click(); + await page.waitForTimeout(1500); + await openPalette(page); + await page.waitForFunction(() => document.querySelector('dialog[open] [role="listbox"]')?.children.length === 0); + assert.match(await dialogFor(page).innerText(), /Source unavailable|来源不可用/); + await page.keyboard.press('Escape'); + failStatus = false; + await page.getByRole('button', {name: /^(Refresh status|刷新状态)$/}).click(); + await page.getByTestId('goal-vitals').waitFor({state: 'visible'}); + await openPalette(page); + await dialogFor(page).getByRole('combobox').fill('核对'); + await dialogFor(page).getByRole('option', {name: /核对导出报告/}).waitFor({state: 'visible'}); + } finally { + await session?.close(); lines.close(); + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGTERM'); const kill = setTimeout(() => child.kill('SIGKILL'), 5000); + try {await closed;} finally {clearTimeout(kill);} + } + } + console.log('workspace command palette: en/zh, keyboard/IME/focus, narrow/reduced motion, navigation/no writes, real Todo readback/recovery passed'); +} finally {await browser?.close(); server.kill('SIGTERM');} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-sidebar.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-sidebar.tsx index 025f2e651c..3a03d9841b 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-sidebar.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-sidebar.tsx @@ -1,4 +1,4 @@ -import { AlertTriangle, ArrowDown, ArrowUp, ArrowUpDown, Bot, ChevronDown, ChevronRight, Focus, LoaderCircle, Pause, Plus, RotateCcw, Settings2, Trash2 } from "lucide-react"; +import { AlertTriangle, ArrowDown, ArrowUp, ArrowUpDown, Bot, ChevronDown, ChevronRight, Focus, LoaderCircle, Pause, Plus, RotateCcw, Search, Settings2, Trash2 } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { DesktopUpdate } from "./desktop-update"; import { useGoalOrder } from "./use-goal-order"; @@ -28,6 +28,7 @@ export function GoalSidebar({ lifecycleBusyGoalIds, goalLifecycleOperations, onRequestGoalCreate, + onOpenCommands, onOpenSettings, onRetryGoalArchive, onRequestGoalLifecycle, @@ -43,6 +44,7 @@ export function GoalSidebar({ lifecycleBusyGoalIds?: ReadonlySet; goalLifecycleOperations?: readonly ("stop" | "resume" | "delete")[]; onRequestGoalCreate?: () => void; + onOpenCommands?: () => void; onOpenSettings?: () => void; onRetryGoalArchive?: () => void; onRequestGoalLifecycle?: (goal: WorkspaceGoal, operation: "stop" | "resume" | "delete") => void; @@ -199,6 +201,7 @@ export function GoalSidebar({
+ {onOpenCommands ? : null} {onOpenSettings ? ( +

{t(readOnly ? "palette.readOnly" : "palette.scope")}

+ + { setQuery(event.target.value); setActiveId(null); }} + onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }} + onKeyDown={event => { + if (composing.current || event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + const available = matches.filter(entry => !entry.disabled); + const index = available.findIndex(entry => entry.id === active?.id); + const next = available[(index + (event.key === "ArrowDown" ? 1 : -1) + available.length) % available.length]; + if (next) setActiveId(next.id); + } else if (event.key === "Enter") { + event.preventDefault(); + if (active) choose(active); + } + }} /> +
    + {matches.map((entry, index) =>
  • event.preventDefault()} onClick={() => choose(entry)}> + {entry.title}{t(entry.disabled ? `palette.${entry.disabled}` : `palette.${entry.group}`)} +
  • )} +
+ {!matches.length ?

{t(unavailable ? "palette.unavailable" : query.trim() ? "palette.noMatches" : "palette.empty")}

: null} +
{t("palette.keys")}
+ ; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/workspace-shell.tsx b/apps/presentation/dashboard/src/features/personal-workspace/workspace-shell.tsx index ed6b2b592c..0cf175de64 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/workspace-shell.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/workspace-shell.tsx @@ -4,6 +4,7 @@ import { useWorkspaceI18n } from "./i18n"; import type { WorkspaceTheme } from "./workspace-theme"; export function WorkspaceShell({ + overlay, drawer, drawerMode = "panel", drawerOpen, @@ -14,6 +15,7 @@ export function WorkspaceShell({ sidebar, theme = "loopx", }: { + overlay?: ReactNode; drawer?: ReactNode; drawerMode?: "inspector" | "inspector-full" | "panel"; drawerOpen: boolean; @@ -62,6 +64,7 @@ export function WorkspaceShell({ return (
+ {overlay} {mobileSidebarOpen ?