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