From 3359cf3610a53365e9023a3d94e0688d0c49866e Mon Sep 17 00:00:00 2001 From: serafin-garcia Date: Thu, 18 Jun 2026 20:33:50 -0700 Subject: [PATCH] feat(open-knowledge): empty-state Create with Claude CLI (PRD-7139) (#1985) * [US-001] Section Open with AI menu into Desktop and Terminal groups Add 'Desktop' and 'Terminal' section labels to OpenInAgentMenuContent. Installed app launchers sit under Desktop; the docked-terminal launcher sits under Terminal as a 'Claude' row whose accessible name stays 'Claude CLI' (WCAG 2.5.3). The separator renders only when both sections are present. Export OpenInAgentMenuContent so its DOM test can resolve it. The terminal-row test's SKIP_IN_CI 'render anomaly' was actually a missing export plus the autoOpen prop removed in #1945, not a CI-only issue; restore the export, drop the dead prop, and let the test run in CI. Extend both handoff DOM tests for the section labels and gating, and regenerate the Lingui catalogs for the new strings. * [US-002] Section Open-in-Agent submenus into Desktop and Terminal groups Apply the section layout established for the header menu to the two sibling Open-in-Agent submenus. - OpenInAgentContextSubmenu (file-tree right-click, DropdownMenuSub*): add a Desktop DropdownMenuLabel over installed agents and a Terminal label over the CLI row; the separator renders only when both sections are present. - OpenInAgentEmptySpaceSubmenu (sidebar empty space, ContextMenu*): same treatment with ContextMenuLabel/ContextMenuSeparator. The hide-entire-submenu early-return is rewritten in terms of the section flags with identical behavior. The CLI row visible text changes from 'Claude CLI' to 'Claude'; its accessible name stays 'Claude CLI' (WCAG 2.5.3), and the existing data-testids plus No-workspace hint behavior are unchanged. Catalogs regenerated (only new source-location references; no new strings). DOM tests extended in both files for both-sections-present, per-section gating, and the terminal launch path. * [US-003] Add empty-state Terminal Claude CLI launch row Bring the empty-state create composer to parity with the Open-in-Agent menus: the chevron dropdown now groups the agent-selection items under a Desktop label and, on desktop hosts, adds a Terminal section with a Claude row that launches the docked-terminal CLI. - Call useTerminalLaunch() and gate the Terminal section on a non-null launcher (absent on the web host). The separator renders only when both the Desktop and Terminal sections are present. - The Claude row builds its handoff input with buildCreateHandoffInput, the same builder the app Create button uses, so the CLI launch carries the typed brief and scenario with no new prompt logic. It is disabled when the input is null (workspace unresolved) and does not write the preferred agent, keeping select-default and launch distinct. - Visible text is 'Claude'; the accessible name stays 'Claude CLI' (WCAG 2.5.3). Catalogs regenerated (only new source-location references; no new strings). Add CreatePromptComposer.dom.test.tsx covering both-sections rendering, web-host gating, the launch path carrying the typed brief, the disabled no-launch case, and that Desktop items still select the default. * [US-004] Add changeset for empty-state Claude CLI launch + menu sectioning Release-notes changeset ("@inkeep/open-knowledge": patch) covering the new empty-state "Terminal -> Claude" CLI launch row (parity with "Open with AI") and the Desktop/Terminal sectioning of the Open-in-Agent menus and create composer dropdown. * docs(prd-7139): spec for empty-state Claude CLI launch + menu sectioning * fixup! local-review: address findings (pass 1) * fixup! local-review: address findings (pass 2) * fix(prd-7139): empty-state Terminal Claude selects CLI create mode instead of launching on click * fix(prd-7139): host the docked terminal on the empty state so the create CLI launch opens it * feat(prd-7139): collapse empty state to a downward-gazing mascot while the terminal is open * fix(prd-7139): use plain section divs in the Open with AI popover (a11y lint) * fix(prd-7139): fieldset/legend menu sections, +CSS size budget, composer test coverage * fix(prd-7139): match docked-terminal kill-strip background to the xterm canvas --------- GitOrigin-RevId: 1da14ce3ee9ab3b08967c31b430c8d70efc62c9b --- .changeset/create-with-claude-cli.md | 7 + docs/content/features/editor.mdx | 6 +- packages/app/package.json | 2 +- .../src/components/EditorArea.dom.test.tsx | 86 ++++++- packages/app/src/components/EditorArea.tsx | 12 + .../app/src/components/EmptyEditorState.tsx | 11 +- packages/app/src/components/OkBlob.tsx | 33 ++- packages/app/src/components/TerminalPanel.tsx | 11 +- .../CreatePromptComposer.dom.test.tsx | 228 ++++++++++++++++++ .../empty-state/CreatePromptComposer.tsx | 107 ++++++-- .../OpenInAgentContextSubmenu.dom.test.tsx | 82 ++++++- .../handoff/OpenInAgentContextSubmenu.tsx | 119 +++++---- .../OpenInAgentEmptySpaceSubmenu.dom.test.tsx | 87 ++++++- .../handoff/OpenInAgentEmptySpaceSubmenu.tsx | 121 ++++++---- .../handoff/OpenInAgentMenu.dom.test.tsx | 52 +++- .../components/handoff/OpenInAgentMenu.tsx | 77 +++--- packages/app/src/locales/en/messages.json | 3 + packages/app/src/locales/en/messages.po | 24 +- packages/app/src/locales/pseudo/messages.json | 3 + packages/app/src/locales/pseudo/messages.po | 24 +- 20 files changed, 912 insertions(+), 183 deletions(-) create mode 100644 .changeset/create-with-claude-cli.md create mode 100644 packages/app/src/components/empty-state/CreatePromptComposer.dom.test.tsx diff --git a/.changeset/create-with-claude-cli.md b/.changeset/create-with-claude-cli.md new file mode 100644 index 00000000..80a06264 --- /dev/null +++ b/.changeset/create-with-claude-cli.md @@ -0,0 +1,7 @@ +--- +"@inkeep/open-knowledge": patch +--- + +The empty-state create surface can now launch the `claude` CLI in a docked terminal. Alongside the existing app agents, the agent-picker dropdown now offers a **Terminal → Claude** option. Selecting it switches the primary button to **Create with Claude CLI**; clicking Create then opens the docked terminal with the same create-scope prompt built from your typed brief — bringing the new-file empty state to parity with the editor's "Open with AI" menu (desktop only; absent on the web host). + +The Open-in-Agent menus ("Open/Edit with AI", the file-tree right-click submenu, and the sidebar empty-space submenu) and the empty-state agent picker are now organized into labeled **Desktop** (app launches) and **Terminal** (CLI launch) sections, so the two launch modes are visually distinct. The CLI row's visible label is now "Claude" (its accessible name remains "Claude CLI"). Section labels render only for non-empty sections, and existing launch behavior is unchanged. diff --git a/docs/content/features/editor.mdx b/docs/content/features/editor.mdx index 99ab63ae..11198cf4 100644 --- a/docs/content/features/editor.mdx +++ b/docs/content/features/editor.mdx @@ -142,9 +142,13 @@ The Claude **claude.ai** web fallback row appears on file-scope dispatches only; See the [per-integration pages](/docs/integrations/claude-code) for what each agent receives once dispatched. +The menu is organized into two sections. **Desktop** lists the installed agent apps (Claude, Codex, Cursor); selecting one launches it via its deep link. **Terminal** (desktop app only) adds a single **Claude** row that runs the `claude` CLI in Open Knowledge's docked terminal with the same scoped prompt. Both a Desktop **Claude** (the app) and a Terminal **Claude** (the CLI) can therefore appear; screen readers tell them apart by the Terminal row's "Claude CLI" accessible name. Each section renders only when it has something to launch. + +The empty-state create composer on a fresh project carries the same Desktop / Terminal split: its Desktop rows pick the default agent the **Create** button uses, while its Terminal **Claude** row (desktop only, disabled until the workspace resolves) launches the `claude` CLI with the brief you typed. + ### Open in Terminal -Spawns `open -a Terminal.app ` at the right path: the folder for a folder row, the file's parent for a file row, the project root for the empty-space menu, the parent dir for an asset row. Terminal.app is hardcoded for v1; user-configurable terminal preference is on the roadmap. +Spawns `open -a Terminal.app ` at the right path: the folder for a folder row, the file's parent for a file row, the project root for the empty-space menu, the parent dir for an asset row. Terminal.app is hardcoded for v1; user-configurable terminal preference is on the roadmap. This is distinct from the **Terminal** section inside **Open with AI**, which runs the `claude` CLI in Open Knowledge's docked terminal rather than opening Terminal.app. ## Sidebars and window width diff --git a/packages/app/package.json b/packages/app/package.json index 41d37222..88710db7 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -57,7 +57,7 @@ { "name": "main CSS (gzipped)", "path": "dist/assets/index-*.css", - "limit": "49 kB", + "limit": "50 kB", "gzip": true, "running": false } diff --git a/packages/app/src/components/EditorArea.dom.test.tsx b/packages/app/src/components/EditorArea.dom.test.tsx index 179d0a8e..487faa68 100644 --- a/packages/app/src/components/EditorArea.dom.test.tsx +++ b/packages/app/src/components/EditorArea.dom.test.tsx @@ -21,19 +21,44 @@ mock.module('@/components/PropertyContext', () => ({ useProperties: () => ({ requestAddProperty: () => {} }), })); +const FOLDER_DOC_CTX = { + activeDocName: 'folder/index', + activeProvider: null, + activeTarget: { kind: 'folder', target: 'folder', folderPath: 'folder' }, + recycleDocument: () => {}, + docPanelMode: 'timeline', + docPanelAgentId: null, + docPanelExpandSignal: 0, +}; +const EMPTY_DOC_CTX = { + activeDocName: null, + activeProvider: null, + activeTarget: null, + recycleDocument: () => {}, + docPanelMode: 'timeline', + docPanelAgentId: null, + docPanelExpandSignal: 0, +}; +let docCtx: typeof FOLDER_DOC_CTX | typeof EMPTY_DOC_CTX = FOLDER_DOC_CTX; mock.module('@/editor/DocumentContext', () => ({ - useDocumentContext: () => ({ - activeDocName: 'folder/index', - activeProvider: null, - activeTarget: { kind: 'folder', target: 'folder', folderPath: 'folder' }, - recycleDocument: () => {}, - docPanelMode: 'timeline', - docPanelAgentId: null, - docPanelExpandSignal: 0, - }), + useDocumentContext: () => docCtx, useDocumentTransition: () => ({ openDocumentTransition: null }), })); +mock.module('@/components/EmptyEditorState', () => ({ + EmptyEditorState: ({ terminalVisible }: { terminalVisible?: boolean }) => ( +
+ ), +})); + +mock.module('./TerminalDock', () => ({ + TerminalDock: ({ children, visible }: { children: ReactNode; visible?: boolean }) => ( +
+ {children} +
+ ), +})); + mock.module('react-resizable-panels', () => ({ usePanelRef: () => ({ current: { @@ -95,6 +120,7 @@ function renderEditorArea() { describe('EditorArea SettingsDialogPortal runtime wiring', () => { beforeEach(() => { cleanup(); + docCtx = FOLDER_DOC_CTX; settingsRouteOpen = false; closeSettingsRouteMock = mock(() => {}); shellProps = []; @@ -118,3 +144,45 @@ describe('EditorArea SettingsDialogPortal runtime wiring', () => { expect(closeSettingsRouteMock).toHaveBeenCalledTimes(1); }); }); + +describe('EditorArea empty-state terminal host', () => { + beforeEach(() => { + cleanup(); + docCtx = EMPTY_DOC_CTX; + }); + + test('hosts the docked terminal on the empty state when a terminal bridge is present', () => { + render( + {}} + activeTab="timeline" + onActiveTabChange={() => {}} + terminalBridge={{} as never} + terminalVisible + onTerminalVisibleChange={() => {}} + terminalLaunch={null} + />, + ); + + const dock = screen.getByTestId('terminal-dock'); + expect(dock.getAttribute('data-visible')).toBe('true'); + const emptyState = dock.querySelector('[data-testid="empty-editor-state"]'); + expect(emptyState).not.toBeNull(); + expect(emptyState?.getAttribute('data-terminal-visible')).toBe('true'); + }); + + test('renders the empty state with no terminal dock on the web host (no bridge)', () => { + render( + {}} + activeTab="timeline" + onActiveTabChange={() => {}} + />, + ); + + expect(screen.queryByTestId('terminal-dock')).toBeNull(); + expect(screen.getByTestId('empty-editor-state')).toBeTruthy(); + }); +}); diff --git a/packages/app/src/components/EditorArea.tsx b/packages/app/src/components/EditorArea.tsx index 2c7f57f2..892da48c 100644 --- a/packages/app/src/components/EditorArea.tsx +++ b/packages/app/src/components/EditorArea.tsx @@ -368,6 +368,18 @@ function EditorAreaInner({ if (hashDoc !== null) { return ; } + if (terminalBridge != null) { + return ( + {})} + launch={terminalLaunch} + > + + + ); + } return ; } diff --git a/packages/app/src/components/EmptyEditorState.tsx b/packages/app/src/components/EmptyEditorState.tsx index c1450982..7e6a2777 100644 --- a/packages/app/src/components/EmptyEditorState.tsx +++ b/packages/app/src/components/EmptyEditorState.tsx @@ -7,6 +7,7 @@ import { CreatePromptComposer } from '@/components/empty-state/CreatePromptCompo import { CreateView } from '@/components/empty-state/CreateView'; import { EmptyStateHeader } from '@/components/empty-state/EmptyStateHeader'; import { filterVisibleEntries } from '@/components/file-tree-utils'; +import { OkBlob } from '@/components/OkBlob'; import { PackCardGrid } from '@/components/PackCardGrid'; import { SeedDialog } from '@/components/SeedDialog'; import { Button } from '@/components/ui/button'; @@ -15,7 +16,7 @@ import { emitCreateTopLevelFile } from '@/lib/create-file-events'; import type { OkPackId } from '@/lib/desktop-bridge-types'; import { subscribeToDocumentsChanged } from '@/lib/documents-events'; -export function EmptyEditorState() { +export function EmptyEditorState({ terminalVisible = false }: { terminalVisible?: boolean }) { const [seedDialogOpen, setSeedDialogOpen] = useState(false); const [seedDialogInitialPackId, setSeedDialogInitialPackId] = useState( undefined, @@ -84,6 +85,14 @@ export function EmptyEditorState() { if (!next) setSeedDialogInitialPackId(undefined); } + if (terminalVisible) { + return ( +
+ +
+ ); + } + return (
{messageReady ? ( diff --git a/packages/app/src/components/OkBlob.tsx b/packages/app/src/components/OkBlob.tsx index b6ee466b..0aed1ce8 100644 --- a/packages/app/src/components/OkBlob.tsx +++ b/packages/app/src/components/OkBlob.tsx @@ -15,6 +15,10 @@ interface OkBlobProps { trackMouse?: boolean; variant?: 'default' | 'sleeping'; celebrateSignal?: number; + /** Fixed gaze direction. `'down'` holds a "peering down" pose (head tilts + * forward, eyes drop) instead of tracking the cursor — used when the mascot + * sits above the docked terminal on the empty state. */ + gaze?: 'cursor' | 'down'; } const MAX_EYE_OFFSET = 1.8; @@ -84,6 +88,7 @@ export function OkBlob({ trackMouse = true, variant = 'default', celebrateSignal = 0, + gaze = 'cursor', }: OkBlobProps) { const wrapperRef = useRef(null); const svgRef = useRef(null); @@ -132,6 +137,7 @@ export function OkBlob({ useEffect(() => { if (!trackMouse || isSleeping) return; + const gazeDown = gaze === 'down'; const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); if (mq.matches) return; @@ -177,17 +183,24 @@ export function OkBlob({ const dy = mouseY - centerY; const dist = Math.hypot(dx, dy); - const normX = Math.max(-1, Math.min(1, dx / HEAD_DIST_SCALE)); - const normY = Math.max(-1, Math.min(1, dy / HEAD_DIST_SCALE)); - const targetRotY = normX * MAX_HEAD_ROTATION; - const targetRotX = -normY * MAX_HEAD_ROTATION; - + let targetRotX: number; + let targetRotY: number; let targetEyeX = 0; let targetEyeY = 0; - if (dist >= 1) { - const scale = Math.min(dist / EYE_DIST_SCALE, 1) * MAX_EYE_OFFSET; - targetEyeX = (dx / dist) * scale; - targetEyeY = (dy / dist) * scale; + if (gazeDown) { + targetRotY = 0; + targetRotX = -MAX_HEAD_ROTATION * 0.8; + targetEyeY = MAX_EYE_OFFSET; + } else { + const normX = Math.max(-1, Math.min(1, dx / HEAD_DIST_SCALE)); + const normY = Math.max(-1, Math.min(1, dy / HEAD_DIST_SCALE)); + targetRotY = normX * MAX_HEAD_ROTATION; + targetRotX = -normY * MAX_HEAD_ROTATION; + if (dist >= 1) { + const scale = Math.min(dist / EYE_DIST_SCALE, 1) * MAX_EYE_OFFSET; + targetEyeX = (dx / dist) * scale; + targetEyeY = (dy / dist) * scale; + } } const settled = @@ -226,7 +239,7 @@ export function OkBlob({ eyeOffsetRef.current = { x: 0, y: 0 }; eyesGroupRef.current?.removeAttribute('transform'); }; - }, [trackMouse, isSleeping]); + }, [trackMouse, isSleeping, gaze]); useLayoutEffect(() => { const g = eyesGroupRef.current; diff --git a/packages/app/src/components/TerminalPanel.tsx b/packages/app/src/components/TerminalPanel.tsx index 1751f4cb..a3d4b44d 100644 --- a/packages/app/src/components/TerminalPanel.tsx +++ b/packages/app/src/components/TerminalPanel.tsx @@ -39,22 +39,21 @@ export function TerminalPanel({ launch = null, }: TerminalPanelProps) { const { t } = useLingui(); + const { resolvedTheme } = useTheme(); const [restartKey, setRestartKey] = useState(0); return (
{onKill ? ( -
+
+ ), + DropdownMenuLabel: ({ children, ...props }: MenuChild) =>
{children}
, + DropdownMenuSeparator: () =>
, +})); + +const installedAll: Record = { + 'claude-code': { installed: true }, + codex: { installed: true }, + cursor: { installed: true }, +}; + +const launchCalls: HandoffDispatchInput[] = []; + +const { CreatePromptComposer } = await import('./CreatePromptComposer'); +const { TerminalLaunchProvider } = await import('@/components/handoff/TerminalLaunchContext'); + +async function renderComposer( + opts: { withTerminal: boolean; scenario?: CreateScenario } = { withTerminal: true }, +) { + const value = opts.withTerminal + ? { launchInTerminal: (i: HandoffDispatchInput) => launchCalls.push(i) } + : null; + render( + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('create-with-agent-menu')).toBeTruthy(); + }); +} + +describe('CreatePromptComposer Desktop / Terminal sections', () => { + afterEach(() => { + cleanup(); + launchCalls.length = 0; + states = {}; + workspaceValue = null; + }); + + test('renders Desktop and Terminal sections with the CLI launch row when a launcher is present', async () => { + states = { ...installedAll }; + workspaceValue = { contentDir: '/tmp/project', pathSeparator: '/' }; + await renderComposer({ withTerminal: true }); + + expect(screen.getByText('Desktop')).toBeTruthy(); + expect(screen.getByText('Terminal')).toBeTruthy(); + expect(screen.getByTestId('create-with-claude-cli')).toBeTruthy(); + expect(screen.queryByTestId('menu-separator')).not.toBeNull(); + }); + + test('omits the Terminal section (label, row, separator) on the web host while keeping Desktop', async () => { + states = { ...installedAll }; + workspaceValue = { contentDir: '/tmp/project', pathSeparator: '/' }; + await renderComposer({ withTerminal: false }); + + expect(screen.getByText('Desktop')).toBeTruthy(); + expect(screen.queryByText('Terminal')).toBeNull(); + expect(screen.queryByTestId('create-with-claude-cli')).toBeNull(); + expect(screen.queryByTestId('menu-separator')).toBeNull(); + }); + + test('selecting the Terminal Claude row switches the button to CLI mode; Create launches with the typed brief', async () => { + states = { ...installedAll }; + workspaceValue = { contentDir: '/tmp/project', pathSeparator: '/' }; + await renderComposer({ withTerminal: true, scenario: 'new-project' }); + + fireEvent.change(screen.getByLabelText('Describe the project you want to create'), { + target: { value: 'Build a competitor wiki' }, + }); + + fireEvent.click(screen.getByTestId('create-with-claude-cli')); + await waitFor(() => { + expect(screen.getByTestId('create-with-agent').textContent).toContain( + 'Create with Claude CLI', + ); + }); + expect(launchCalls).toEqual([]); + + fireEvent.click(screen.getByTestId('create-with-agent')); + expect(launchCalls).toEqual([ + { + docContext: null, + createDescription: 'Build a competitor wiki', + createScenario: 'new-project', + projectDir: '/tmp/project', + docPath: '', + }, + ]); + }); + + test('CLI mode does not launch when the workspace is unresolved', async () => { + states = { ...installedAll }; + workspaceValue = null; // buildCreateHandoffInput returns null until the workspace resolves. + await renderComposer({ withTerminal: true }); + + fireEvent.click(screen.getByTestId('create-with-claude-cli')); + await waitFor(() => { + expect(screen.getByTestId('create-with-agent').textContent).toContain( + 'Create with Claude CLI', + ); + }); + fireEvent.click(screen.getByTestId('create-with-agent')); + expect(launchCalls).toEqual([]); + }); + + test('Desktop selection items set the default and do not launch the terminal', async () => { + states = { ...installedAll }; + workspaceValue = { contentDir: '/tmp/project', pathSeparator: '/' }; + await renderComposer({ withTerminal: true }); + + fireEvent.click(screen.getByTestId('create-agent-option-codex')); + + await waitFor(() => { + expect(screen.getByTestId('create-with-agent').textContent).toContain('Create with Codex'); + }); + expect(launchCalls).toEqual([]); + }); + + test('the Terminal row shows visible "Claude" with accessible name "Claude CLI"', async () => { + states = { ...installedAll }; + workspaceValue = { contentDir: '/tmp/project', pathSeparator: '/' }; + await renderComposer({ withTerminal: true }); + + const row = screen.getByTestId('create-with-claude-cli'); + expect(row.textContent).toBe('Claude'); + expect(row.getAttribute('aria-label')).toBe('Claude CLI'); + }); + + test('Cmd+Enter in CLI mode launches the terminal with the typed brief', async () => { + states = { ...installedAll }; + workspaceValue = { contentDir: '/tmp/project', pathSeparator: '/' }; + await renderComposer({ withTerminal: true, scenario: 'new-project' }); + + const textarea = screen.getByLabelText('Describe the project you want to create'); + fireEvent.change(textarea, { target: { value: 'Build a wiki' } }); + fireEvent.click(screen.getByTestId('create-with-claude-cli')); // enter CLI mode + await waitFor(() => { + expect(screen.getByTestId('create-with-agent').textContent).toContain( + 'Create with Claude CLI', + ); + }); + + fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true }); + expect(launchCalls).toEqual([ + { + docContext: null, + createDescription: 'Build a wiki', + createScenario: 'new-project', + projectDir: '/tmp/project', + docPath: '', + }, + ]); + }); + + test('selecting a Desktop agent after CLI reverts the button and does not launch', async () => { + states = { ...installedAll }; + workspaceValue = { contentDir: '/tmp/project', pathSeparator: '/' }; + await renderComposer({ withTerminal: true }); + + fireEvent.click(screen.getByTestId('create-with-claude-cli')); // enter CLI mode + await waitFor(() => { + expect(screen.getByTestId('create-with-agent').textContent).toContain( + 'Create with Claude CLI', + ); + }); + + fireEvent.click(screen.getByTestId('create-agent-option-codex')); + await waitFor(() => { + expect(screen.getByTestId('create-with-agent').textContent).toContain('Create with Codex'); + }); + expect(launchCalls).toEqual([]); + }); +}); diff --git a/packages/app/src/components/empty-state/CreatePromptComposer.tsx b/packages/app/src/components/empty-state/CreatePromptComposer.tsx index e77b8897..a388de34 100644 --- a/packages/app/src/components/empty-state/CreatePromptComposer.tsx +++ b/packages/app/src/components/empty-state/CreatePromptComposer.tsx @@ -1,12 +1,13 @@ import type { HandoffTarget } from '@inkeep/open-knowledge-core'; import { Trans, useLingui } from '@lingui/react/macro'; -import { ArrowUpRight, Check, ChevronDown, Sparkles } from 'lucide-react'; +import { ArrowUpRight, Check, ChevronDown, Sparkles, SquareTerminal } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { type CreateScenario, useCreateSuggestions, } from '@/components/empty-state/use-create-suggestions'; import { TargetIcon } from '@/components/handoff/OpenInAgentMenuItem'; +import { useTerminalLaunch } from '@/components/handoff/TerminalLaunchContext'; import { buildCreateHandoffInput, getDisplayNameDefault, @@ -19,7 +20,10 @@ import { ButtonGroup } from '@/components/ui/button-group'; import { DropdownMenu, DropdownMenuContent, + DropdownMenuGroup, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Textarea } from '@/components/ui/textarea'; @@ -42,12 +46,15 @@ export function CreatePromptComposer({ scenario, className }: CreatePromptCompos const { states, refresh } = useInstalledAgents(); const { dispatch } = useHandoffDispatch(); const workspace = useWorkspace(); + const terminalLaunch = useTerminalLaunch(); const [description, setDescription] = useState(''); const [selectedAgentId, setSelectedAgentId] = useState(() => readPreferredAgent(), ); const userPickedRef = useRef(false); + const [cliMode, setCliMode] = useState(false); + const cliSelected = cliMode && terminalLaunch !== null; const textareaRef = useRef(null); @@ -67,10 +74,23 @@ export function CreatePromptComposer({ scenario, className }: CreatePromptCompos function chooseAgent(targetId: HandoffTarget) { userPickedRef.current = true; + setCliMode(false); setSelectedAgentId(targetId); writePreferredAgent(targetId); } + function chooseCli() { + userPickedRef.current = true; + setCliMode(true); + } + + function launchCli() { + if (terminalLaunch === null) return; + const input = buildCreateHandoffInput({ workspace, description: description.trim(), scenario }); + if (input === null) return; // Workspace not resolved yet — disabled-trigger contract. + terminalLaunch.launchInTerminal(input); + } + function handleCreate(targetId: HandoffTarget) { const desc = description.trim(); writePreferredAgent(targetId); @@ -82,7 +102,11 @@ export function CreatePromptComposer({ scenario, className }: CreatePromptCompos function handleKeyDown(event: React.KeyboardEvent) { if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault(); - if (selectedAgentId !== null) handleCreate(selectedAgentId); + if (cliSelected) { + launchCli(); + } else if (selectedAgentId !== null) { + handleCreate(selectedAgentId); + } } } @@ -127,6 +151,9 @@ export function CreatePromptComposer({ scenario, className }: CreatePromptCompos ); } + const showDesktopSection = selectableTargets.length > 0; + const showTerminalSection = terminalLaunch !== null; + return (
{ @@ -209,19 +245,54 @@ export function CreatePromptComposer({ scenario, className }: CreatePromptCompos - {selectableTargets.map((target) => ( - chooseAgent(target.id)} - data-testid={`create-agent-option-${target.id}`} - > - - ))} + {showDesktopSection ? ( + + + Desktop + + {selectableTargets.map((target) => ( + chooseAgent(target.id)} + data-testid={`create-agent-option-${target.id}`} + > + + ))} + + ) : null} + {showTerminalSection ? ( + <> + {showDesktopSection ? : null} + + + Terminal + + {/* Selects the docked-terminal Claude CLI as the create target + (the Create button performs the launch). Visible text is + "Claude" while the accessible name stays "Claude CLI" so AT + users can tell it apart from the Desktop "Claude" (WCAG + 2.5.3 — the name contains the visible label). */} + chooseCli()} + data-testid="create-with-claude-cli" + aria-label={t`Claude CLI`} + > + + + + ) : null} diff --git a/packages/app/src/components/handoff/OpenInAgentContextSubmenu.dom.test.tsx b/packages/app/src/components/handoff/OpenInAgentContextSubmenu.dom.test.tsx index 9e2c275f..28a5f10a 100644 --- a/packages/app/src/components/handoff/OpenInAgentContextSubmenu.dom.test.tsx +++ b/packages/app/src/components/handoff/OpenInAgentContextSubmenu.dom.test.tsx @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event'; import type { ReactNode } from 'react'; import { DropdownMenu, DropdownMenuContent } from '@/components/ui/dropdown-menu'; import { renderLinguiTemplate } from '@/test-utils/lingui-mock'; +import { TerminalLaunchProvider } from './TerminalLaunchContext'; import type { HandoffDispatchInput } from './useHandoffDispatch'; mock.module('@lingui/core/macro', () => ({ @@ -45,6 +46,8 @@ const readyInput: HandoffDispatchInput = { projectDir: '/project', }; +const launchCalls: HandoffDispatchInput[] = []; + function installStates( overrides: Partial> = {}, ): Record { @@ -60,9 +63,11 @@ function installStates( async function renderSubmenu({ input = readyInput, states = installStates(), + withTerminal = false, }: { input?: HandoffDispatchInput | null; states?: Record; + withTerminal?: boolean; } = {}) { const { OpenInAgentContextSubmenu } = await import('./OpenInAgentContextSubmenu'); const dispatchCalls: Array<{ input: HandoffDispatchInput; target: HandoffTarget }> = []; @@ -71,7 +76,7 @@ async function renderSubmenu({ return { ok: true as const }; }); - render( + const submenu = ( - , + + ); + + render( + withTerminal ? ( + launchCalls.push(i) }}> + {submenu} + + ) : ( + submenu + ), ); const trigger = screen.getByRole('menuitem', { name: 'Open with AI' }); @@ -94,7 +109,10 @@ async function renderSubmenu({ } describe('OpenInAgentContextSubmenu runtime behavior', () => { - afterEach(() => cleanup()); + afterEach(() => { + cleanup(); + launchCalls.length = 0; + }); test('renders only installed visible targets and dispatches the selected row', async () => { const { dispatchCalls } = await renderSubmenu(); @@ -158,4 +176,62 @@ describe('OpenInAgentContextSubmenu runtime behavior', () => { const empty = screen.getByTestId('file-tree-open-in-empty'); expect(empty.textContent).toContain('Checking for installed agents'); }); + + test('groups installed agents under Desktop and the CLI launch under Terminal', async () => { + await renderSubmenu({ withTerminal: true }); + + expect(screen.getByText('Desktop')).toBeTruthy(); + expect(screen.getByText('Terminal')).toBeTruthy(); + expect(document.querySelector('[data-slot="dropdown-menu-separator"]')).toBeTruthy(); + + const terminalRow = screen.getByTestId('file-tree-open-in-terminal'); + expect(terminalRow.textContent).toContain('Claude'); + expect(terminalRow.textContent).not.toContain('CLI'); + expect(terminalRow.getAttribute('aria-label')).toBe('Claude CLI'); + }); + + test('terminal row launches via the terminal launcher and does not app-dispatch', async () => { + const { dispatch } = await renderSubmenu({ withTerminal: true }); + + await userEvent.click(screen.getByTestId('file-tree-open-in-terminal')); + + expect(launchCalls).toEqual([readyInput]); + expect(dispatch).not.toHaveBeenCalled(); + }); + + test('terminal row appends the No workspace hint to its accessible name and stays inert while input is missing', async () => { + await renderSubmenu({ input: null, withTerminal: true }); + + const terminalRow = screen.getByTestId('file-tree-open-in-terminal'); + expect(terminalRow.getAttribute('aria-label')).toBe('Claude CLI, No workspace'); + expect(terminalRow.getAttribute('data-disabled')).toBe(''); + + await userEvent.click(terminalRow); + expect(launchCalls).toEqual([]); + }); + + test('omits the Terminal section but keeps Desktop when no terminal launcher is present', async () => { + await renderSubmenu(); + + expect(screen.getByText('Desktop')).toBeTruthy(); + expect(screen.queryByText('Terminal')).toBeNull(); + expect(screen.queryByTestId('file-tree-open-in-terminal')).toBeNull(); + }); + + test('renders only the Terminal section (no Desktop label, no separator) when no agents are installed', async () => { + await renderSubmenu({ + withTerminal: true, + states: installStates({ + 'claude-code': { installed: false, lastChecked: 1 }, + 'claude-cowork': { installed: false, lastChecked: 1 }, + codex: { installed: false, lastChecked: 1 }, + cursor: { installed: false, lastChecked: 1 }, + }), + }); + + expect(screen.getByText('Terminal')).toBeTruthy(); + expect(screen.queryByText('Desktop')).toBeNull(); + expect(screen.getByTestId('file-tree-open-in-terminal')).toBeTruthy(); + expect(document.querySelector('[data-slot="dropdown-menu-separator"]')).toBeNull(); + }); }); diff --git a/packages/app/src/components/handoff/OpenInAgentContextSubmenu.tsx b/packages/app/src/components/handoff/OpenInAgentContextSubmenu.tsx index f018ab67..2e67d578 100644 --- a/packages/app/src/components/handoff/OpenInAgentContextSubmenu.tsx +++ b/packages/app/src/components/handoff/OpenInAgentContextSubmenu.tsx @@ -4,7 +4,9 @@ import { Trans, useLingui } from '@lingui/react/macro'; import { Sparkles, SquareTerminal } from 'lucide-react'; import type { ReactNode } from 'react'; import { + DropdownMenuGroup, DropdownMenuItem, + DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, @@ -56,7 +58,9 @@ export function OpenInAgentContextSubmenu(props: OpenInAgentContextSubmenuProps) (target) => installStates[target.id]?.installed == null, ); - const showEmptyHint = installedTargets.length === 0 && terminalLaunch === null; + const showDesktopSection = installedTargets.length > 0; + const showTerminalSection = terminalLaunch !== null; + const showEmptyHint = !showDesktopSection && !showTerminalSection; return ( @@ -65,33 +69,40 @@ export function OpenInAgentContextSubmenu(props: OpenInAgentContextSubmenuProps) Open with AI - {installedTargets.map((target) => { - const enabled = !inputMissing; - const { displayName } = target; - const accessibleLabel = hint - ? t`Open with AI ${displayName}, ${hint}` - : t`Open with AI ${displayName}`; - return ( - { - if (!input) return; - void dispatch(target.id, input); - }} - data-testid={`file-tree-open-in-${target.id}`} - aria-label={accessibleLabel} - > - - ); - })} + {showDesktopSection ? ( + + + Desktop + + {installedTargets.map((target) => { + const enabled = !inputMissing; + const { displayName } = target; + const accessibleLabel = hint + ? t`Open with AI ${displayName}, ${hint}` + : t`Open with AI ${displayName}`; + return ( + { + if (!input) return; + void dispatch(target.id, input); + }} + data-testid={`file-tree-open-in-${target.id}`} + aria-label={accessibleLabel} + > + + ); + })} + + ) : null} {showEmptyHint ? ( {probePending ? ( @@ -101,28 +112,40 @@ export function OpenInAgentContextSubmenu(props: OpenInAgentContextSubmenuProps) )} ) : null} - {terminalLaunch !== null ? ( + {showTerminalSection ? ( <> - - { - if (input === null) return; - terminalLaunch.launchInTerminal(input); - }} - disabled={inputMissing} - data-testid="file-tree-open-in-terminal" - aria-label={hint ? t`Claude CLI, ${hint}` : t`Claude CLI`} - > - + ) : null} diff --git a/packages/app/src/components/handoff/OpenInAgentEmptySpaceSubmenu.dom.test.tsx b/packages/app/src/components/handoff/OpenInAgentEmptySpaceSubmenu.dom.test.tsx index b4a8f71a..d02127b8 100644 --- a/packages/app/src/components/handoff/OpenInAgentEmptySpaceSubmenu.dom.test.tsx +++ b/packages/app/src/components/handoff/OpenInAgentEmptySpaceSubmenu.dom.test.tsx @@ -6,6 +6,7 @@ import type { ReactNode } from 'react'; import { act } from 'react'; import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu'; import { renderLinguiTemplate } from '@/test-utils/lingui-mock'; +import { TerminalLaunchProvider } from './TerminalLaunchContext'; import type { HandoffDispatchInput } from './useHandoffDispatch'; mock.module('@lingui/core/macro', () => ({ @@ -44,6 +45,8 @@ const readyInput: HandoffDispatchInput = { projectDir: '/project', }; +const launchCalls: HandoffDispatchInput[] = []; + function installStates( overrides: Partial> = {}, ): Record { @@ -59,9 +62,11 @@ function installStates( async function renderSubmenu({ input = readyInput, states = installStates(), + withTerminal = false, }: { input?: HandoffDispatchInput | null; states?: Record; + withTerminal?: boolean; } = {}) { const { OpenInAgentEmptySpaceSubmenu } = await import('./OpenInAgentEmptySpaceSubmenu'); const dispatchCalls: Array<{ input: HandoffDispatchInput; target: HandoffTarget }> = []; @@ -70,7 +75,7 @@ async function renderSubmenu({ return { ok: true as const }; }); - render( + const menu = ( @@ -78,7 +83,17 @@ async function renderSubmenu({ - , + + ); + + render( + withTerminal ? ( + launchCalls.push(i) }}> + {menu} + + ) : ( + menu + ), ); await act(async () => { @@ -99,7 +114,10 @@ async function openEmptySpaceSubmenu() { } describe('OpenInAgentEmptySpaceSubmenu runtime behavior', () => { - afterEach(() => cleanup()); + afterEach(() => { + cleanup(); + launchCalls.length = 0; + }); test('renders as a ContextMenu submenu, filters visible installed targets, and dispatches rows', async () => { const { dispatchCalls } = await renderSubmenu(); @@ -141,4 +159,67 @@ describe('OpenInAgentEmptySpaceSubmenu runtime behavior', () => { }); expect(screen.queryByRole('menuitem', { name: 'Open with AI' }) === null).toBe(true); }); + + test('groups installed agents under Desktop and the CLI launch under Terminal', async () => { + await renderSubmenu({ withTerminal: true }); + await openEmptySpaceSubmenu(); + + expect(screen.getByText('Desktop')).toBeTruthy(); + expect(screen.getByText('Terminal')).toBeTruthy(); + expect(document.querySelector('[data-slot="context-menu-separator"]')).toBeTruthy(); + + const terminalRow = screen.getByTestId('empty-space-open-in-terminal'); + expect(terminalRow.textContent).toContain('Claude'); + expect(terminalRow.textContent).not.toContain('CLI'); + expect(terminalRow.getAttribute('aria-label')).toBe('Claude CLI'); + }); + + test('terminal row launches via the terminal launcher and does not app-dispatch', async () => { + const { dispatch } = await renderSubmenu({ withTerminal: true }); + await openEmptySpaceSubmenu(); + + await userEvent.click(screen.getByTestId('empty-space-open-in-terminal')); + + expect(launchCalls).toEqual([readyInput]); + expect(dispatch).not.toHaveBeenCalled(); + }); + + test('terminal row appends the No workspace hint to its accessible name and stays inert while input is missing', async () => { + await renderSubmenu({ input: null, withTerminal: true }); + await openEmptySpaceSubmenu(); + + const terminalRow = screen.getByTestId('empty-space-open-in-terminal'); + expect(terminalRow.getAttribute('aria-label')).toBe('Claude CLI, No workspace'); + expect(terminalRow.getAttribute('data-disabled')).toBe(''); + + await userEvent.click(terminalRow); + expect(launchCalls).toEqual([]); + }); + + test('omits the Terminal section but keeps Desktop when no terminal launcher is present', async () => { + await renderSubmenu(); + await openEmptySpaceSubmenu(); + + expect(screen.getByText('Desktop')).toBeTruthy(); + expect(screen.queryByText('Terminal')).toBeNull(); + expect(screen.queryByTestId('empty-space-open-in-terminal')).toBeNull(); + }); + + test('renders only the Terminal section (no Desktop label, no separator) when no agents are installed', async () => { + await renderSubmenu({ + withTerminal: true, + states: installStates({ + 'claude-code': { installed: false, lastChecked: 1 }, + 'claude-cowork': { installed: false, lastChecked: 1 }, + codex: { installed: false, lastChecked: 1 }, + cursor: { installed: false, lastChecked: 1 }, + }), + }); + await openEmptySpaceSubmenu(); + + expect(screen.getByText('Terminal')).toBeTruthy(); + expect(screen.queryByText('Desktop')).toBeNull(); + expect(screen.getByTestId('empty-space-open-in-terminal')).toBeTruthy(); + expect(document.querySelector('[data-slot="context-menu-separator"]')).toBeNull(); + }); }); diff --git a/packages/app/src/components/handoff/OpenInAgentEmptySpaceSubmenu.tsx b/packages/app/src/components/handoff/OpenInAgentEmptySpaceSubmenu.tsx index df7e5002..3d793e0f 100644 --- a/packages/app/src/components/handoff/OpenInAgentEmptySpaceSubmenu.tsx +++ b/packages/app/src/components/handoff/OpenInAgentEmptySpaceSubmenu.tsx @@ -4,7 +4,9 @@ import { Trans, useLingui } from '@lingui/react/macro'; import { Sparkles, SquareTerminal } from 'lucide-react'; import type { ReactNode } from 'react'; import { + ContextMenuGroup, ContextMenuItem, + ContextMenuLabel, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, @@ -52,8 +54,10 @@ export function OpenInAgentEmptySpaceSubmenu(props: OpenInAgentEmptySpaceSubmenu (target) => installStates[target.id]?.installed === true, ); - const terminalRowVisible = terminalLaunch !== null; - if (installedTargets.length === 0 && !terminalRowVisible) { + const showDesktopSection = installedTargets.length > 0; + const showTerminalSection = terminalLaunch !== null; + + if (!showDesktopSection && !showTerminalSection) { return null; } @@ -64,55 +68,74 @@ export function OpenInAgentEmptySpaceSubmenu(props: OpenInAgentEmptySpaceSubmenu Open with AI - {installedTargets.map((target) => { - const enabled = !inputMissing; - const { displayName } = target; - const accessibleLabel = hint - ? t`Open with AI ${displayName}, ${hint}` - : t`Open with AI ${displayName}`; - return ( - { - if (!input) return; - void dispatch(target.id, input); - }} - data-testid={`empty-space-open-in-${target.id}`} - aria-label={accessibleLabel} - > - - ); - })} - {terminalLaunch !== null ? ( + {showDesktopSection ? ( + + + Desktop + + {installedTargets.map((target) => { + const enabled = !inputMissing; + const { displayName } = target; + const accessibleLabel = hint + ? t`Open with AI ${displayName}, ${hint}` + : t`Open with AI ${displayName}`; + return ( + { + if (!input) return; + void dispatch(target.id, input); + }} + data-testid={`empty-space-open-in-${target.id}`} + aria-label={accessibleLabel} + > + + ); + })} + + ) : null} + {showTerminalSection ? ( <> - - { - if (input === null) return; - terminalLaunch.launchInTerminal(input); - }} - disabled={inputMissing} - data-testid="empty-space-open-in-terminal" - aria-label={hint ? t`Claude CLI, ${hint}` : t`Claude CLI`} - > - + ) : null} diff --git a/packages/app/src/components/handoff/OpenInAgentMenu.dom.test.tsx b/packages/app/src/components/handoff/OpenInAgentMenu.dom.test.tsx index 9c6ecb4e..cf2e25b0 100644 --- a/packages/app/src/components/handoff/OpenInAgentMenu.dom.test.tsx +++ b/packages/app/src/components/handoff/OpenInAgentMenu.dom.test.tsx @@ -3,6 +3,7 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { ReactNode } from 'react'; import { TooltipProvider } from '@/components/ui/tooltip'; +import { TerminalLaunchProvider } from './TerminalLaunchContext'; import type { HandoffDispatchInput } from './useHandoffDispatch'; mock.module('@lingui/react/macro', () => ({ @@ -15,6 +16,7 @@ mock.module('@lingui/react/macro', () => ({ const refreshCalls: string[] = []; const dispatchCalls: Array<{ target: string; input: HandoffDispatchInput }> = []; +const launchCalls: HandoffDispatchInput[] = []; let states: Record = {}; mock.module('./useInstalledAgents', () => ({ @@ -65,6 +67,17 @@ async function renderMenu(menuInput: HandoffDispatchInput | null = input) { ); } +async function renderMenuWithTerminal(menuInput: HandoffDispatchInput | null = input) { + const { OpenInAgentMenu } = await import('./OpenInAgentMenu'); + render( + + launchCalls.push(i) }}> + + + , + ); +} + async function openMenu() { await userEvent.click(screen.getByTestId('open-in-agent-trigger')); await waitFor(() => { @@ -77,6 +90,7 @@ describe('OpenInAgentMenu runtime behavior', () => { cleanup(); refreshCalls.length = 0; dispatchCalls.length = 0; + launchCalls.length = 0; states = {}; }); @@ -118,7 +132,7 @@ describe('OpenInAgentMenu runtime behavior', () => { expect(screen.getByTestId('open-in-agent-item-cursor')).toBeTruthy(); expect(screen.queryByTestId('open-in-agent-item-claude-cowork')).toBeNull(); - expect(screen.getByTestId('open-in-agent-send-label').textContent).toContain('Send to'); + expect(screen.getByTestId('open-in-agent-desktop-label').textContent).toContain('Desktop'); await userEvent.click(screen.getByTestId('open-in-agent-item-codex')); expect(dispatchCalls).toStrictEqual([{ target: 'codex', input }]); @@ -204,4 +218,40 @@ describe('OpenInAgentMenu runtime behavior', () => { const empty = screen.getByTestId('open-in-agent-empty'); expect(empty.textContent).toContain('Checking for installed agents'); }); + + test('groups installed agents under Desktop and the CLI launch under Terminal', async () => { + states = { + 'claude-code': { installed: true, lastChecked: 1 }, + codex: { installed: true, lastChecked: 1 }, + cursor: { installed: true, lastChecked: 1 }, + }; + await renderMenuWithTerminal(); + await openMenu(); + + expect(screen.getByText('Desktop')).toBeTruthy(); + expect(screen.getByText('Terminal')).toBeTruthy(); + expect(screen.getByTestId('open-in-agent-terminal')).toBeTruthy(); + expect(screen.getByRole('group', { name: 'Desktop' })).toBeTruthy(); + expect(screen.getByRole('group', { name: 'Terminal' })).toBeTruthy(); + }); + + test('terminal row launches via the terminal launcher with the menu input', async () => { + states = { 'claude-code': { installed: true, lastChecked: 1 } }; + await renderMenuWithTerminal(); + await openMenu(); + + await userEvent.click(screen.getByTestId('open-in-agent-terminal')); + expect(launchCalls).toEqual([input]); + expect(dispatchCalls).toEqual([]); + }); + + test('omits the Terminal section but keeps Desktop when no terminal launcher is present', async () => { + states = { 'claude-code': { installed: true, lastChecked: 1 } }; + await renderMenu(); + await openMenu(); + + expect(screen.getByText('Desktop')).toBeTruthy(); + expect(screen.queryByText('Terminal')).toBeNull(); + expect(screen.queryByTestId('open-in-agent-terminal')).toBeNull(); + }); }); diff --git a/packages/app/src/components/handoff/OpenInAgentMenu.tsx b/packages/app/src/components/handoff/OpenInAgentMenu.tsx index 1b7304f8..22b956b6 100644 --- a/packages/app/src/components/handoff/OpenInAgentMenu.tsx +++ b/packages/app/src/components/handoff/OpenInAgentMenu.tsx @@ -51,7 +51,9 @@ function OpenWithAiPanel({ (target) => installStates[target.id]?.installed == null, ); - const hasRows = installedTargets.length > 0 || terminalLaunch !== null; + const showDesktopSection = installedTargets.length > 0; + const showTerminalSection = terminalLaunch !== null; + const hasRows = showDesktopSection || showTerminalSection; return (
@@ -63,32 +65,45 @@ function OpenWithAiPanel({ data-testid="open-in-agent-instruction" /> {hasRows ? ( - <> -
- Send to -
-
- {installedTargets.map((target) => { - const { displayName } = target; - return ( - + ); + })} + + ) : null} + {showTerminalSection ? ( + <> + {showDesktopSection ? : null} +
+ - - - ) : null} -
- + + + ) : null} +
) : (

<1>[1] and a definition shown below."], diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po index 450d24b5..fee5414a 100644 --- a/packages/app/src/locales/en/messages.po +++ b/packages/app/src/locales/en/messages.po @@ -955,6 +955,14 @@ msgstr "Choose agent" msgid "Choose whether this project's Open Knowledge setup, including its AI-tool connections, is saved with the project so teammates get it too, or kept only on your computer." msgstr "Choose whether this project's Open Knowledge setup, including its AI-tool connections, is saved with the project so teammates get it too, or kept only on your computer." +#: src/components/empty-state/CreatePromptComposer.tsx +#: src/components/handoff/OpenInAgentContextSubmenu.tsx +#: src/components/handoff/OpenInAgentEmptySpaceSubmenu.tsx +#: src/components/handoff/OpenInAgentMenu.tsx +msgid "Claude" +msgstr "Claude" + +#: src/components/empty-state/CreatePromptComposer.tsx #: src/components/handoff/OpenInAgentContextSubmenu.tsx #: src/components/handoff/OpenInAgentEmptySpaceSubmenu.tsx #: src/components/handoff/OpenInAgentMenu.tsx @@ -1679,6 +1687,10 @@ msgstr "Create template" msgid "Create with {0}" msgstr "Create with {0}" +#: src/components/empty-state/CreatePromptComposer.tsx +msgid "Create with Claude CLI" +msgstr "Create with Claude CLI" + #: src/components/FileTree.tsx msgid "Create your first file" msgstr "Create your first file" @@ -1809,6 +1821,13 @@ msgstr "Description" msgid "Description (optional)" msgstr "Description (optional)" +#: src/components/empty-state/CreatePromptComposer.tsx +#: src/components/handoff/OpenInAgentContextSubmenu.tsx +#: src/components/handoff/OpenInAgentEmptySpaceSubmenu.tsx +#: src/components/handoff/OpenInAgentMenu.tsx +msgid "Desktop" +msgstr "Desktop" + #: src/components/SyncStatusBadge.tsx msgid "Detached HEAD — checkout a branch to resume" msgstr "Detached HEAD — checkout a branch to resume" @@ -4989,7 +5008,6 @@ msgid "Semantic search is on, but no API key is set — search falls back to key msgstr "Semantic search is on, but no API key is set — search falls back to keyword matching. Add one in <0>Settings → Account (it's stored once for your whole machine)." #: src/components/handoff/EditWithAiPopover.tsx -#: src/components/handoff/OpenInAgentMenu.tsx msgid "Send to" msgstr "Send to" @@ -5522,6 +5540,10 @@ msgstr "Templates" msgid "Templates available" msgstr "Templates available" +#: src/components/empty-state/CreatePromptComposer.tsx +#: src/components/handoff/OpenInAgentContextSubmenu.tsx +#: src/components/handoff/OpenInAgentEmptySpaceSubmenu.tsx +#: src/components/handoff/OpenInAgentMenu.tsx #: src/components/settings/SettingsDialogShell.tsx #: src/components/settings/TerminalSection.tsx #: src/components/TerminalPanel.tsx diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json index 613b38a2..87b7326a 100644 --- a/packages/app/src/locales/pseudo/messages.json +++ b/packages/app/src/locales/pseudo/messages.json @@ -420,6 +420,7 @@ ] ], "BAH4qX": ["Śēĺēćţēď ĝŕàƥĥ ĩţēḿ"], + "BBqGS9": ["Ďēśķţōƥ"], "BD0XSC": ["Ƒàĩĺēď ţō ĺōàď ƥŕōĴēćţ ţēḿƥĺàţēś"], "BEkF-P": [ ["userName"], @@ -1298,6 +1299,7 @@ "fYjgXu": ["Ũńōŕďēŕēď ĺĩśţ ōƒ ĩţēḿś."], "fbdLmk": ["Ţōĝĝĺē ƀōĺď ƒōŕḿàţţĩńĝ."], "fbsiBw": ["Śōḿēţĥĩńĝ ŵēńţ ŵŕōńĝ śţàŕţĩńĝ ţĥē ţēŕḿĩńàĺ."], + "fcgvCA": ["Ćĺàũďē"], "fiMNLl": ["Śŷńć śēţţĩńĝś ńōţ ŷēţ ĺōàďēď — ţŕŷ àĝàĩń ĩń à ḿōḿēńţ"], "fqSfXY": ["Ŕēƥĺàćē"], "fwhX-N": ["Ĩńďēńţ ōŕ ōũţďēńţ śōũŕćē"], @@ -1365,6 +1367,7 @@ "haHgQj": ["Ćōƥĩēď ŕēĺàţĩvē ƥàţĥ"], "he3ygx": ["Ćōƥŷ"], "hjO0Mq": ["Àďď ţàƀ"], + "hozzkE": ["Ćŕēàţē ŵĩţĥ Ćĺàũďē ĆĹĨ"], "hpDtUm": ["Ēxţēŕńàĺ ŵĩķĩ ĺĩńķ"], "hwFOFD": ["Śēţţĩńĝś ƒàĩĺēď ţō ĺōàď"], "hxBHxQ": ["À ĺĩńē ŵĩţĥ à ƒōōţńōţē<0><1>[1] àńď à ďēƒĩńĩţĩōń śĥōŵń ƀēĺōŵ."], diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po index 5b76283b..65001188 100644 --- a/packages/app/src/locales/pseudo/messages.po +++ b/packages/app/src/locales/pseudo/messages.po @@ -950,6 +950,14 @@ msgstr "" msgid "Choose whether this project's Open Knowledge setup, including its AI-tool connections, is saved with the project so teammates get it too, or kept only on your computer." msgstr "" +#: src/components/empty-state/CreatePromptComposer.tsx +#: src/components/handoff/OpenInAgentContextSubmenu.tsx +#: src/components/handoff/OpenInAgentEmptySpaceSubmenu.tsx +#: src/components/handoff/OpenInAgentMenu.tsx +msgid "Claude" +msgstr "" + +#: src/components/empty-state/CreatePromptComposer.tsx #: src/components/handoff/OpenInAgentContextSubmenu.tsx #: src/components/handoff/OpenInAgentEmptySpaceSubmenu.tsx #: src/components/handoff/OpenInAgentMenu.tsx @@ -1674,6 +1682,10 @@ msgstr "" msgid "Create with {0}" msgstr "" +#: src/components/empty-state/CreatePromptComposer.tsx +msgid "Create with Claude CLI" +msgstr "" + #: src/components/FileTree.tsx msgid "Create your first file" msgstr "" @@ -1804,6 +1816,13 @@ msgstr "" msgid "Description (optional)" msgstr "" +#: src/components/empty-state/CreatePromptComposer.tsx +#: src/components/handoff/OpenInAgentContextSubmenu.tsx +#: src/components/handoff/OpenInAgentEmptySpaceSubmenu.tsx +#: src/components/handoff/OpenInAgentMenu.tsx +msgid "Desktop" +msgstr "" + #: src/components/SyncStatusBadge.tsx msgid "Detached HEAD — checkout a branch to resume" msgstr "" @@ -4984,7 +5003,6 @@ msgid "Semantic search is on, but no API key is set — search falls back to key msgstr "" #: src/components/handoff/EditWithAiPopover.tsx -#: src/components/handoff/OpenInAgentMenu.tsx msgid "Send to" msgstr "" @@ -5517,6 +5535,10 @@ msgstr "" msgid "Templates available" msgstr "" +#: src/components/empty-state/CreatePromptComposer.tsx +#: src/components/handoff/OpenInAgentContextSubmenu.tsx +#: src/components/handoff/OpenInAgentEmptySpaceSubmenu.tsx +#: src/components/handoff/OpenInAgentMenu.tsx #: src/components/settings/SettingsDialogShell.tsx #: src/components/settings/TerminalSection.tsx #: src/components/TerminalPanel.tsx