- {legalLinks.map(({ href, label }) => (
+ {legalLinks.map(({ href, label, external }) => (
{label}
diff --git a/docs/src/app/(home)/marketing-button.tsx b/docs/src/app/(home)/marketing-button.tsx
index ef5e0434a..07caf8cf1 100644
--- a/docs/src/app/(home)/marketing-button.tsx
+++ b/docs/src/app/(home)/marketing-button.tsx
@@ -52,7 +52,7 @@ const getRadiusClasses = (variant: MarketingButtonVariant) => {
const getDimensionClasses = (variant: MarketingButtonVariant, size: MarketingButtonSize) => {
if (variant === 'minimal') return 'h-auto px-0';
if (variant === 'outline') return 'py-[13px] px-5';
- if (variant === 'tertiary') return 'py-[13px] px-[14px]';
+ if (variant === 'tertiary') return size === 'sm' ? 'py-1.5 px-2.5' : 'py-[13px] px-[14px]';
if (variant === 'icon') return 'p-2';
if (variant === 'secondary') return 'h-10 px-[22px] py-[14px]';
if (size === 'sm') return 'h-[36px] px-[22px] py-2.5';
@@ -74,7 +74,8 @@ const getTextClasses = (variant: MarketingButtonVariant, size: MarketingButtonSi
return 'text-night-sky-800 text-sm font-medium leading-none uppercase';
}
if (variant === 'tertiary') {
- return 'text-night-sky text-base font-normal leading-[115%] tracking-[-0.64px] uppercase';
+ const tertiaryTextSize = size === 'sm' ? 'text-xs' : 'text-base';
+ return `text-night-sky ${tertiaryTextSize} font-normal leading-[115%] tracking-[-0.64px] uppercase`;
}
if (variant === 'outline') {
return 'text-azure-blue text-sm sm:text-base font-normal leading-[115%] tracking-normal';
@@ -185,7 +186,7 @@ export function MarketingButton({
const isFileLink = typeof href === 'string' && href.split('?')[0]?.toLowerCase().endsWith('.pdf');
const isRedirectRoute =
typeof href === 'string' && (href.startsWith('/download/') || href.startsWith('/updates/'));
- const useRawAnchor = isFileLink || isRedirectRoute;
+ const useRawAnchor = isFileLink || isRedirectRoute || typeof download !== 'undefined';
const computedDownload = typeof download !== 'undefined' ? download : isFileLink ? '' : undefined;
const buttonContent = (
diff --git a/docs/src/app/(home)/site-nav.tsx b/docs/src/app/(home)/site-nav.tsx
index b7b883283..cdc7f012d 100644
--- a/docs/src/app/(home)/site-nav.tsx
+++ b/docs/src/app/(home)/site-nav.tsx
@@ -7,6 +7,7 @@ import { useEffect, useState } from 'react';
import { DiscordIcon } from '@/components/icons/discord';
import { GitHubIcon } from '@/components/icons/github';
import { XIcon } from '@/components/icons/x';
+import { LogoContextMenu } from '@/components/logo-context-menu';
import { OkWordmark } from '@/components/ok-wordmark';
import { Sheet, SheetClose, SheetContent, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { DISCORD_URL, DOWNLOAD_ROUTE, GITHUB_URL, X_URL } from '@/lib/site';
@@ -195,9 +196,11 @@ export function SiteNav({ stars }: { stars: number | null }) {
-
-
-
+
+
+
+
+
setCopied(false), 1500);
+ } catch {}
+ }
+
+ return (
+ <>
+ {/* Always-mounted live region so the copy confirmation is announced to
+ screen readers — a text swap on the already-focused menu item isn't
+ reliably announced on its own. */}
+
+ {copied ? 'Copied to clipboard' : ''}
+
+
+ {children}
+
+ {
+ event.preventDefault();
+ void copy();
+ }}
+ >
+ {copied ? : }
+ {copied ? 'Copied' : 'Copy as SVG'}
+
+
+
+
+ Download SVG
+
+
+
+
+
+ Download PNG
+
+
+
+
+
+
+ All brand assets
+
+
+
+
+ >
+ );
+}
diff --git a/docs/src/components/ui/context-menu.tsx b/docs/src/components/ui/context-menu.tsx
new file mode 100644
index 000000000..0e7362188
--- /dev/null
+++ b/docs/src/components/ui/context-menu.tsx
@@ -0,0 +1,104 @@
+'use client';
+
+import { ContextMenu as ContextMenuPrimitive } from 'radix-ui';
+import type * as React from 'react';
+
+import { cn } from '@/lib/utils';
+
+function ContextMenu({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function ContextMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function ContextMenuGroup({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function ContextMenuContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function ContextMenuItem({
+ className,
+ inset,
+ variant = 'default',
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+ variant?: 'default' | 'destructive';
+}) {
+ return (
+
+ );
+}
+
+function ContextMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ );
+}
+
+function ContextMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuGroup,
+ ContextMenuItem,
+ ContextMenuLabel,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+};
diff --git a/docs/src/lib/brand-assets.ts b/docs/src/lib/brand-assets.ts
new file mode 100644
index 000000000..cfaacc78e
--- /dev/null
+++ b/docs/src/lib/brand-assets.ts
@@ -0,0 +1,40 @@
+export const BRAND_ROUTE = '/brand';
+export const BRAND_ZIP = '/brand/openknowledge-brand.zip';
+
+export type BrandAsset = {
+ id: string;
+ alt: string;
+ svg: string;
+ png: string;
+ downloadName: string;
+ tile: 'white' | 'brand' | 'muted';
+};
+
+export const BRAND_ASSETS: BrandAsset[] = [
+ {
+ id: 'logo',
+ alt: 'OpenKnowledge logo',
+ svg: '/brand/openknowledge-logo.svg',
+ png: '/brand/openknowledge-logo.png',
+ downloadName: 'openknowledge-logo',
+ tile: 'white',
+ },
+ {
+ id: 'logo-white',
+ alt: 'OpenKnowledge logo, white',
+ svg: '/brand/openknowledge-logo-white.svg',
+ png: '/brand/openknowledge-logo-white.png',
+ downloadName: 'openknowledge-logo-white',
+ tile: 'brand',
+ },
+ {
+ id: 'icon',
+ alt: 'OpenKnowledge icon',
+ svg: '/brand/openknowledge-icon.svg',
+ png: '/brand/openknowledge-icon.png',
+ downloadName: 'openknowledge-icon',
+ tile: 'muted',
+ },
+];
+
+export const PRIMARY_BRAND_ASSET = BRAND_ASSETS[0];
diff --git a/docs/src/lib/copy-svg.ts b/docs/src/lib/copy-svg.ts
new file mode 100644
index 000000000..fdcadc025
--- /dev/null
+++ b/docs/src/lib/copy-svg.ts
@@ -0,0 +1,5 @@
+export async function copySvgToClipboard(url: string): Promise {
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
+ await navigator.clipboard.writeText(await res.text());
+}
diff --git a/packages/app/src/components/BottomComposer.dom.test.tsx b/packages/app/src/components/BottomComposer.dom.test.tsx
index f57c2b359..4cbed51a3 100644
--- a/packages/app/src/components/BottomComposer.dom.test.tsx
+++ b/packages/app/src/components/BottomComposer.dom.test.tsx
@@ -120,6 +120,9 @@ mock.module('@/hooks/use-selection-context', () => ({
usePublishFrontmatterSelection: () => {},
}));
+const recordAskedAiSpy = mock(() => {});
+mock.module('@/lib/onboarding-signals', () => ({ recordOnboardingAskedAi: recordAskedAiSpy }));
+
const dispatchCalls: Array<{ target: string; input: unknown }> = [];
const buildArgs: Array<{
docName: string | null;
@@ -204,6 +207,22 @@ async function renderComposerWithTerminal(docName = 'notes') {
);
}
+async function renderComposerWithThrowingTerminal(docName = 'notes') {
+ const { BottomComposer } = await import('./BottomComposer');
+ const { TerminalLaunchProvider } = await import('./handoff/TerminalLaunchContext');
+ return render(
+ {
+ throw new Error('no terminal session');
+ },
+ }}
+ >
+
+ ,
+ );
+}
+
async function renderFolderComposer(folderPath = 'specs/foo') {
const { BottomComposer } = await import('./BottomComposer');
return render( );
@@ -260,6 +279,7 @@ beforeEach(() => {
mockInlineMentions = [];
emitMentions = null;
dispatchCalls.length = 0;
+ recordAskedAiSpy.mockClear();
buildArgs.length = 0;
terminalLaunchCalls.length = 0;
toastErrors.length = 0;
@@ -416,6 +436,26 @@ describe('BottomComposer (dispatch + picker + sticky default)', () => {
expect(dispatchCalls[0]?.target).toBe('claude-code');
});
+ test('a successful dispatch records the Ask-AI onboarding step', async () => {
+ await renderComposer('specs/foo/SPEC');
+ fireEvent.change(getInput(), { target: { value: 'condense this doc' } });
+ fireEvent.keyDown(getInput(), { key: 'Enter' });
+
+ await waitFor(() => expect(dispatchCalls).toHaveLength(1));
+ await waitFor(() => expect(recordAskedAiSpy).toHaveBeenCalledTimes(1));
+ });
+
+ test('an aborted submit (null compose input) does not record the Ask-AI step', async () => {
+ builderReturnsNull = true;
+ await renderComposer('specs/foo/SPEC');
+ fireEvent.change(getInput(), { target: { value: 'this submit aborts' } });
+ fireEvent.keyDown(getInput(), { key: 'Enter' });
+
+ await waitFor(() => expect(toastErrors.length).toBeGreaterThan(0));
+ expect(dispatchCalls).toHaveLength(0);
+ expect(recordAskedAiSpy).not.toHaveBeenCalled();
+ });
+
test('picking a non-default agent dispatches to it and persists the choice', async () => {
const user = userEvent.setup();
await renderComposer();
@@ -450,6 +490,24 @@ describe('BottomComposer (dispatch + picker + sticky default)', () => {
compose: { instruction: 'summarize this doc' },
});
expect(dispatchCalls).toHaveLength(0);
+ expect(recordAskedAiSpy).toHaveBeenCalledTimes(1);
+ });
+
+ test('a terminal launch that throws keeps the draft, toasts, and records no Ask-AI step', async () => {
+ const user = userEvent.setup();
+ await renderComposerWithThrowingTerminal();
+
+ await user.click(screen.getByTestId('ask-ai-agent-trigger'));
+ await user.click(await screen.findByTestId('ask-ai-agent-option-terminal'));
+
+ fireEvent.change(getInput(), { target: { value: 'summarize this doc' } });
+ fireEvent.keyDown(getInput(), { key: 'Enter' });
+
+ await waitFor(() => expect(toastErrors.length).toBeGreaterThan(0));
+ expect(toastErrors.some((m) => m.includes('open the terminal'))).toBe(true);
+ expect(getInput().value).toBe('summarize this doc');
+ expect(recordAskedAiSpy).not.toHaveBeenCalled();
+ expect(dispatchCalls).toHaveLength(0);
});
test('the Codex CLI option launches the docked terminal with cli=codex', async () => {
@@ -891,6 +949,18 @@ describe('BottomComposer (failure + defensive guards)', () => {
expect(toastErrors).toHaveLength(0);
});
+ test('an unsuccessful ({ok:false}) dispatch does not record the Ask-AI onboarding step', async () => {
+ dispatchImpl = () => Promise.resolve({ ok: false });
+
+ await renderComposer();
+ fireEvent.change(getInput(), { target: { value: 'condense this doc' } });
+ fireEvent.click(screen.getByTestId('ask-ai-send'));
+
+ await waitFor(() => expect(dispatchCalls).toHaveLength(1));
+ await waitFor(() => expect(getInput().value).toBe(''));
+ expect(recordAskedAiSpy).not.toHaveBeenCalled();
+ });
+
test('a null build result surfaces a toast instead of a silent no-op', async () => {
builderReturnsNull = true;
diff --git a/packages/app/src/components/BottomComposer.tsx b/packages/app/src/components/BottomComposer.tsx
index df381a7cf..d703fa9d2 100644
--- a/packages/app/src/components/BottomComposer.tsx
+++ b/packages/app/src/components/BottomComposer.tsx
@@ -34,6 +34,7 @@ import type { EditorSurface } from '@/editor/selection-stats';
import { useSelectionContext } from '@/hooks/use-selection-context';
import { VISIBLE_TARGETS } from '@/lib/handoff/targets';
import { matchesKeyboardShortcut } from '@/lib/keyboard-shortcuts';
+import { recordOnboardingAskedAi } from '@/lib/onboarding-signals';
import {
loadStickyAgent,
parseStickyCliId,
@@ -342,15 +343,20 @@ export function BottomComposer({
toast.error(t`Couldn't open the terminal — please try again.`);
return;
}
+ recordOnboardingAskedAi();
clearComposer();
return;
}
if (resolvedTarget === null) return;
setPending(true);
- void dispatch(resolvedTarget.id, input).finally(() => {
- setPending(false);
- clearComposer();
- });
+ void dispatch(resolvedTarget.id, input)
+ .then((outcome) => {
+ if (outcome.ok) recordOnboardingAskedAi();
+ })
+ .finally(() => {
+ setPending(false);
+ clearComposer();
+ });
};
const submit = () => {
diff --git a/packages/app/src/components/FileSidebar.tsx b/packages/app/src/components/FileSidebar.tsx
index 9c054f846..ee08acda2 100644
--- a/packages/app/src/components/FileSidebar.tsx
+++ b/packages/app/src/components/FileSidebar.tsx
@@ -24,6 +24,7 @@ import {
useHandoffDispatch,
} from '@/components/handoff/useHandoffDispatch';
import { useInstalledAgents } from '@/components/handoff/useInstalledAgents';
+import { OnboardingCardMount } from '@/components/OnboardingCard';
import { ProjectSwitcher } from '@/components/ProjectSwitcher';
import { onPillRenderError, SidebarSearchBar } from '@/components/SidebarSearchBar';
import { SkillsSidebarSection } from '@/components/SkillsSidebarSection';
@@ -666,6 +667,7 @@ function FileSidebarInner({ onOpenSearch }: FileSidebarProps) {
/>
+
{typeof window !== 'undefined' && window.okDesktop ? (
diff --git a/packages/app/src/components/OnboardingCard.dom.test.tsx b/packages/app/src/components/OnboardingCard.dom.test.tsx
new file mode 100644
index 000000000..a0b87856c
--- /dev/null
+++ b/packages/app/src/components/OnboardingCard.dom.test.tsx
@@ -0,0 +1,91 @@
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+
+mock.module('@/components/OkBlob', () => ({
+ OkBlob: () => ,
+}));
+
+import { formatShortcut } from '@/lib/keyboard-shortcuts';
+import {
+ createOnboardingCardStore,
+ type OnboardingCardStorage,
+ type OnboardingCardStore,
+} from '@/lib/onboarding-card-store';
+import { OnboardingCard } from './OnboardingCard';
+
+function freshStore(): OnboardingCardStore {
+ const map = new Map();
+ const storage: OnboardingCardStorage = {
+ getItem: (k) => map.get(k) ?? null,
+ setItem: (k, v) => {
+ map.set(k, v);
+ },
+ };
+ return createOnboardingCardStore(storage);
+}
+
+let originalFetch: typeof globalThis.fetch;
+beforeEach(() => {
+ originalFetch = globalThis.fetch;
+ globalThis.fetch = mock(() =>
+ Promise.resolve(new Response(JSON.stringify({ documents: [] }), { status: 200 })),
+ ) as never;
+});
+afterEach(() => {
+ cleanup();
+ globalThis.fetch = originalFetch;
+});
+
+describe('OnboardingCard', () => {
+ test('renders the three steps with step 1 pre-checked and 1/3 progress', () => {
+ render( );
+ expect(screen.getByText('Get set up')).toBeTruthy();
+ expect(screen.getByText('1 / 3')).toBeTruthy();
+ expect(screen.getByText('Create your first project')).toBeTruthy();
+ expect(screen.getByText('Create your first file')).toBeTruthy();
+ expect(screen.getByText('Ask AI')).toBeTruthy();
+ expect(screen.getByRole('status').textContent?.trim()).toBe('');
+ });
+
+ test('step rows are informational, not interactive buttons', () => {
+ render( );
+ expect(screen.queryByRole('button', { name: /Create your first file/ })).toBeNull();
+ expect(screen.queryByRole('button', { name: /Ask AI/ })).toBeNull();
+ expect(screen.queryByRole('button', { name: /Create your first project/ })).toBeNull();
+ });
+
+ test('shows the registry shortcut glyphs for the active platform', () => {
+ render( );
+ expect(screen.getByText(formatShortcut('new-item'))).toBeTruthy();
+ expect(screen.getByText(formatShortcut('open-ask-ai'))).toBeTruthy();
+ });
+
+ test('Dismiss permanently dismisses via the store', () => {
+ const store = freshStore();
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
+ expect(store.getSnapshot().dismissed).toBe(true);
+ });
+
+ test('a completed step advances progress and stays non-interactive', async () => {
+ const store = freshStore();
+ render( );
+ expect(screen.getByText('1 / 3')).toBeTruthy();
+ act(() => {
+ store.markStepComplete('file');
+ });
+ await waitFor(() => expect(screen.getByText('2 / 3')).toBeTruthy());
+ expect(screen.queryByRole('button', { name: /Create your first file/ })).toBeNull();
+ });
+
+ test('completing all steps celebrates in-card (blob) then auto-closes', async () => {
+ const store = freshStore();
+ store.markStepComplete('file');
+ store.markStepComplete('askedAi');
+ render( );
+ expect(screen.getByTestId('ok-blob')).toBeTruthy();
+ expect(screen.getAllByText(/all set up/).length).toBeGreaterThanOrEqual(1);
+ expect(screen.queryByText('Create your first file')).toBeNull();
+ await waitFor(() => expect(store.getSnapshot().completed).toBe(true));
+ });
+});
diff --git a/packages/app/src/components/OnboardingCard.tsx b/packages/app/src/components/OnboardingCard.tsx
new file mode 100644
index 000000000..26b0f2585
--- /dev/null
+++ b/packages/app/src/components/OnboardingCard.tsx
@@ -0,0 +1,169 @@
+import { Trans, useLingui } from '@lingui/react/macro';
+import { useEffect, useState } from 'react';
+import { OkBlob } from '@/components/OkBlob';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Kbd } from '@/components/ui/kbd';
+import { useOnboardingCardVisible } from '@/hooks/use-onboarding-card-visible';
+import { useOnboardingFileCompletion } from '@/hooks/use-onboarding-file-completion';
+import { formatShortcut, type KeyboardShortcutId } from '@/lib/keyboard-shortcuts';
+import {
+ type OnboardingCardStore,
+ onboardingCardStore,
+ useOnboardingCardState,
+} from '@/lib/onboarding-card-store';
+import { cn } from '@/lib/utils';
+
+const TOTAL_STEPS = 3;
+const SUCCESS_LINGER_MS = 5200;
+const CELEBRATE_BURST_MS = 1500;
+/** Exit-animation length — the card stays mounted this long so the fade-out can
+ play before `markCompleted` unmounts it. Matches the `duration-200` class. */
+const EXIT_MS = 220;
+
+function StepRow({
+ complete,
+ label,
+ shortcutId,
+}: {
+ complete: boolean;
+ label: React.ReactNode;
+ shortcutId?: KeyboardShortcutId;
+}) {
+ return (
+
+
+
+ {complete ? (
+
+ Completed: {' '}
+
+ ) : null}
+ {label}
+
+ {shortcutId ? {formatShortcut(shortcutId)} : null}
+
+ );
+}
+
+export function OnboardingCard({
+ store = onboardingCardStore,
+ lingerMs = SUCCESS_LINGER_MS,
+}: {
+ store?: OnboardingCardStore;
+ lingerMs?: number;
+}) {
+ const { t } = useLingui();
+ const { steps } = useOnboardingCardState(store);
+ useOnboardingFileCompletion(store);
+ const [celebrateSignal, setCelebrateSignal] = useState(0);
+ const [exiting, setExiting] = useState(false);
+
+ const completedCount = 1 + (steps.file ? 1 : 0) + (steps.askedAi ? 1 : 0);
+ const allComplete = completedCount === TOTAL_STEPS;
+
+ useEffect(() => {
+ if (!allComplete) return;
+ setCelebrateSignal((n) => n + 1);
+ const burst = setInterval(() => setCelebrateSignal((n) => n + 1), CELEBRATE_BURST_MS);
+ const startExit = setTimeout(() => {
+ clearInterval(burst);
+ setExiting(true);
+ }, lingerMs);
+ return () => {
+ clearInterval(burst);
+ clearTimeout(startExit);
+ };
+ }, [allComplete, lingerMs]);
+
+ useEffect(() => {
+ if (!exiting) return;
+ const done = setTimeout(() => store.markCompleted(), EXIT_MS);
+ return () => clearTimeout(done);
+ }, [exiting, store]);
+
+ return (
+ <>
+ {/* Live region mounted unconditionally (pre-registered empty) so the
+ completion announcement is reliable on VoiceOver/Safari — a region
+ added and populated in the same render cycle is missed. Reduced-motion
+ users who can't see the blob celebration rely on this. WCAG 4.1.3. */}
+
+ {allComplete ? t`You're all set up!` : ''}
+
+
+ {allComplete ? (
+
+ {/* celebrateSignal fires Blobby's happy-eyes + firework burst; bumped
+ on an interval so the celebration keeps popping over its dwell. */}
+
+
+ You're all set up!
+
+
+ ) : (
+
+
+
+ Get set up
+
+
+ {`${completedCount} / ${TOTAL_STEPS}`}
+
+
+
+
+ Create your first project} />
+ Create your first file}
+ shortcutId="new-item"
+ />
+ Ask AI}
+ shortcutId="open-ask-ai"
+ />
+
+
+
+ store.dismiss()}
+ >
+ Dismiss
+
+
+
+ )}
+ >
+ );
+}
+
+export function OnboardingCardMount() {
+ const visible = useOnboardingCardVisible();
+ if (!visible) return null;
+ return ;
+}
diff --git a/packages/app/src/components/OnboardingCardMount.dom.test.tsx b/packages/app/src/components/OnboardingCardMount.dom.test.tsx
new file mode 100644
index 000000000..e3feec27c
--- /dev/null
+++ b/packages/app/src/components/OnboardingCardMount.dom.test.tsx
@@ -0,0 +1,35 @@
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+import { cleanup, render, screen } from '@testing-library/react';
+
+let mockVisible = false;
+mock.module('@/hooks/use-onboarding-card-visible', () => ({
+ useOnboardingCardVisible: () => mockVisible,
+}));
+
+import { OnboardingCardMount } from './OnboardingCard';
+
+let originalFetch: typeof globalThis.fetch;
+beforeEach(() => {
+ originalFetch = globalThis.fetch;
+ globalThis.fetch = mock(() =>
+ Promise.resolve(new Response(JSON.stringify({ documents: [] }), { status: 200 })),
+ ) as never;
+});
+afterEach(() => {
+ cleanup();
+ globalThis.fetch = originalFetch;
+});
+
+describe('OnboardingCardMount', () => {
+ test('renders nothing when the visibility predicate is false', () => {
+ mockVisible = false;
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ test('renders the onboarding card when visible', () => {
+ mockVisible = true;
+ render( );
+ expect(screen.getByText('Get set up')).toBeTruthy();
+ });
+});
diff --git a/packages/app/src/components/TerminalDock.dom.test.tsx b/packages/app/src/components/TerminalDock.dom.test.tsx
index 7e40dad68..6c6f42086 100644
--- a/packages/app/src/components/TerminalDock.dom.test.tsx
+++ b/packages/app/src/components/TerminalDock.dom.test.tsx
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event';
import { useEffect, useRef, useState } from 'react';
import { TooltipProvider } from '@/components/ui/tooltip';
import type { OkDesktopBridge } from '@/lib/desktop-bridge-types';
+import { requestActiveTerminalInput } from './handoff/terminal-input-events';
const TERMINAL_PANEL_ID = 'terminal-dock-panel';
@@ -60,12 +61,14 @@ function emitTitle(ptyId: string, title: string): boolean {
mock.module('./TerminalGate', () => ({
// biome-ignore lint/suspicious/noExplicitAny: test stub
- TerminalGate: ({ bridge, launch, onTitleChange }: any) => {
+ TerminalGate: ({ bridge, launch, onTitleChange, onPtyId }: any) => {
const ptyIdRef = useRef(null);
const cancelledRef = useRef(false);
const onTitleChangeRef = useRef(onTitleChange);
+ const onPtyIdRef = useRef(onPtyId);
useEffect(() => {
onTitleChangeRef.current = onTitleChange;
+ onPtyIdRef.current = onPtyId;
});
useEffect(() => {
cancelledRef.current = false;
@@ -75,6 +78,7 @@ mock.module('./TerminalGate', () => ({
if (cancelledRef.current) bridge?.terminal?.kill?.(result.ptyId);
else {
ptyIdRef.current = result.ptyId;
+ onPtyIdRef.current?.(result.ptyId);
titleEmitters.set(result.ptyId, (title: string) => onTitleChangeRef.current?.(title));
}
},
@@ -82,6 +86,7 @@ mock.module('./TerminalGate', () => ({
return () => {
cancelledRef.current = true;
if (ptyIdRef.current != null) {
+ onPtyIdRef.current?.(null);
titleEmitters.delete(ptyIdRef.current);
bridge?.terminal?.kill?.(ptyIdRef.current);
}
@@ -115,6 +120,7 @@ function makeBridge() {
return { ok: true as const, ptyId: `pty-${ptyCounter}` };
});
const kill = mock(async (_id: string) => {});
+ const input = mock((_ptyId: string, _data: string) => {});
const bridge = {
onMenuAction(cb: (action: string) => void) {
menuHandlers.push(cb);
@@ -128,12 +134,13 @@ function makeBridge() {
viewMenuPushes.push(state);
},
},
- terminal: { create, kill },
+ terminal: { create, kill, input },
} as unknown as OkDesktopBridge;
return {
bridge,
create,
kill,
+ input,
viewMenuPushes,
dispatchMenuAction(action: string) {
for (const cb of menuHandlers) cb(action);
@@ -171,10 +178,13 @@ function DockHarness({ v, l, onVisibleChange, bridge }: any) {
);
}
-function renderDock(visible: boolean, launch?: { prompt: string; nonce: number } | null) {
+function renderDock(
+ visible: boolean,
+ launch?: { prompt: string; nonce: number; cli?: string } | null,
+) {
const onVisibleChange = mock((_v: boolean) => {});
- const { bridge, create, kill, viewMenuPushes, dispatchMenuAction } = makeBridge();
- const ui = (v: boolean, l?: { prompt: string; nonce: number } | null) => (
+ const { bridge, create, kill, input, viewMenuPushes, dispatchMenuAction } = makeBridge();
+ const ui = (v: boolean, l?: { prompt: string; nonce: number; cli?: string } | null) => (
);
const utils = render(ui(visible, launch));
@@ -183,9 +193,10 @@ function renderDock(visible: boolean, launch?: { prompt: string; nonce: number }
onVisibleChange,
create,
kill,
+ input,
viewMenuPushes,
dispatchMenuAction,
- rerender: (v: boolean, l?: { prompt: string; nonce: number } | null) =>
+ rerender: (v: boolean, l?: { prompt: string; nonce: number; cli?: string } | null) =>
utils.rerender(ui(v, l)),
};
}
@@ -498,37 +509,69 @@ describe('TerminalDock multi-session', () => {
expect(sessions[0]?.getAttribute('data-launch')).toBe('9');
});
- test('a launch while a tab is running opens a NEW tab and leaves the running one untouched', () => {
+ test('a launch always opens its own tab, even when a terminal is already live', async () => {
const view = renderDock(true);
+ await waitFor(() => expect(emitTitle('pty-1', 'zsh')).toBe(true));
+ const runningId = activePanelId();
expect(screen.getAllByTestId('terminal-session')).toHaveLength(1);
+
+ act(() => view.rerender(true, { prompt: 'work on docs', cli: 'claude', nonce: 1 }));
+
+ const sessions = screen.getAllByTestId('terminal-session');
+ expect(sessions).toHaveLength(2);
+ const launchedId = activePanelId();
+ expect(launchedId).not.toBe(runningId);
+ expect(launchNonceOf(launchedId)).toBe('1');
+ expect(view.input).not.toHaveBeenCalled();
+ });
+
+ test('the selection input reuses the live terminal — raw PTY write, no new tab', async () => {
+ const view = renderDock(true);
+ await waitFor(() => expect(emitTitle('pty-1', 'zsh')).toBe(true));
const runningId = activePanelId();
- expect(launchNonceOf(runningId)).toBe('none');
+ expect(screen.getAllByTestId('terminal-session')).toHaveLength(1);
+
+ await act(async () => {
+ requestActiveTerminalInput('explain this');
+ });
+
+ await waitFor(() => expect(view.input).toHaveBeenCalledWith('pty-1', 'explain this'));
+ expect(screen.getAllByTestId('terminal-session')).toHaveLength(1);
+ expect(activePanelId()).toBe(runningId);
+ });
- act(() => view.rerender(true, { prompt: 'work on docs', nonce: 1 }));
+ test('a launch before the seed terminal PTY is live also opens its own tab', () => {
+ const view = renderDock(true);
+ expect(screen.getAllByTestId('terminal-session')).toHaveLength(1);
+ const seedId = activePanelId();
+
+ act(() => view.rerender(true, { prompt: 'work on docs', cli: 'claude', nonce: 1 }));
expect(screen.getAllByTestId('terminal-session')).toHaveLength(2);
const launchedId = activePanelId();
- expect(launchedId).not.toBe(runningId);
+ expect(launchedId).not.toBe(seedId);
expect(launchNonceOf(launchedId)).toBe('1');
- expect(launchNonceOf(runningId)).toBe('none');
});
- test('distinct launch nonces each open their own tab', () => {
+ test('distinct launches each open their own tab', async () => {
const view = renderDock(true);
+ await waitFor(() => expect(emitTitle('pty-1', 'zsh')).toBe(true));
- act(() => view.rerender(true, { prompt: 'a', nonce: 1 }));
- act(() => view.rerender(true, { prompt: 'b', nonce: 2 }));
+ act(() => view.rerender(true, { prompt: 'a', cli: 'claude', nonce: 1 }));
+ act(() => view.rerender(true, { prompt: 'b', cli: 'claude', nonce: 2 }));
expect(screen.getAllByTestId('terminal-session')).toHaveLength(3);
+ expect(view.input).not.toHaveBeenCalled();
});
- test('a repeated launch with the same nonce does not open a second tab', () => {
+ test('a repeated launch with the same nonce opens only one tab', async () => {
const view = renderDock(true);
+ await waitFor(() => expect(emitTitle('pty-1', 'zsh')).toBe(true));
- act(() => view.rerender(true, { prompt: 'a', nonce: 1 }));
+ act(() => view.rerender(true, { prompt: 'a', cli: 'claude', nonce: 1 }));
expect(screen.getAllByTestId('terminal-session')).toHaveLength(2);
- act(() => view.rerender(true, { prompt: 'a', nonce: 1 }));
+ act(() => view.rerender(true, { prompt: 'a', cli: 'claude', nonce: 1 }));
expect(screen.getAllByTestId('terminal-session')).toHaveLength(2);
});
diff --git a/packages/app/src/components/TerminalDock.reload-survival.dom.test.tsx b/packages/app/src/components/TerminalDock.reload-survival.dom.test.tsx
index e8136fd6c..1fcdbbf48 100644
--- a/packages/app/src/components/TerminalDock.reload-survival.dom.test.tsx
+++ b/packages/app/src/components/TerminalDock.reload-survival.dom.test.tsx
@@ -1,8 +1,9 @@
import { afterEach, describe, expect, mock, test } from 'bun:test';
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
-import { useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { TooltipProvider } from '@/components/ui/tooltip';
import type { OkDesktopBridge } from '@/lib/desktop-bridge-types';
+import { requestActiveTerminalInput } from './handoff/terminal-input-events';
const TERMINAL_PANEL_ID = 'terminal-dock-panel';
@@ -29,9 +30,18 @@ mock.module('@/components/ui/resizable', () => ({
}));
mock.module('./TerminalGate', () => ({
- TerminalGate: () => (
-
- ),
+ // biome-ignore lint/suspicious/noExplicitAny: test stub
+ TerminalGate: ({ adoptPtyId, onPtyId }: any) => {
+ const onPtyIdRef = useRef(onPtyId);
+ useEffect(() => {
+ onPtyIdRef.current = onPtyId;
+ });
+ useEffect(() => {
+ if (adoptPtyId != null) onPtyIdRef.current?.(adoptPtyId);
+ return () => onPtyIdRef.current?.(null);
+ }, [adoptPtyId]);
+ return ;
+ },
}));
mock.module('@/lib/terminal-height-store', () => ({
@@ -42,7 +52,15 @@ mock.module('@/lib/terminal-height-store', () => ({
const { TerminalDock } = await import('./TerminalDock');
const { TerminalSessionsHost } = await import('./TerminalSessionsHost');
-function ReloadHarness({ bridge, visible }: { bridge: OkDesktopBridge; visible: boolean }) {
+function ReloadHarness({
+ bridge,
+ visible,
+ launch = null,
+}: {
+ bridge: OkDesktopBridge;
+ visible: boolean;
+ launch?: { prompt: string; cli: string; nonce: number } | null;
+}) {
const [bottomContainer, setBottomContainer] = useState(null);
return (
@@ -59,7 +77,8 @@ function ReloadHarness({ bridge, visible }: { bridge: OkDesktopBridge; visible:
bridge={bridge}
visible={visible}
onVisibleChange={() => {}}
- launch={null}
+ // biome-ignore lint/suspicious/noExplicitAny: test launch shape
+ launch={launch as any}
container={bottomContainer}
isShowing={visible && bottomContainer != null}
onRequestEditorFocus={() => {}}
@@ -77,6 +96,7 @@ function makeSurvivingMainBridge(preExisting: ReadonlyArray<{ ptyId: string }>)
return { ok: true as const, ptyId: `fresh-pty-${freshCounter}` };
});
const kill = mock(async (_id: string) => {});
+ const input = mock((_id: string, _d: string) => {});
const listLive = mock(async () => preExisting);
const bridge = {
onMenuAction: () => () => {},
@@ -84,6 +104,7 @@ function makeSurvivingMainBridge(preExisting: ReadonlyArray<{ ptyId: string }>)
terminal: {
create,
kill,
+ input,
list: listLive,
listSessions: listLive,
getSessions: listLive,
@@ -91,7 +112,7 @@ function makeSurvivingMainBridge(preExisting: ReadonlyArray<{ ptyId: string }>)
restoreSessions: listLive,
},
} as unknown as OkDesktopBridge;
- return { bridge, create, listLive };
+ return { bridge, create, input, listLive };
}
function renderDock(bridge: OkDesktopBridge, visible: boolean) {
@@ -159,4 +180,22 @@ describe('issue #351 — the terminal dock rehydrates surviving sessions after a
timeout: 2000,
});
});
+
+ test('an Ask-AI selection reuses a reload-rehydrated (adopted) session via a direct PTY write', async () => {
+ const { bridge, create, input } = makeSurvivingMainBridge([{ ptyId: 'pty-1' }]);
+ render(dockUi(bridge, true));
+
+ await waitFor(() => expect(screen.getAllByTestId('terminal-session')).toHaveLength(1), {
+ timeout: 2000,
+ });
+ await act(async () => {}); // flush the onPtyId report into the host's reuse map
+
+ await act(async () => {
+ requestActiveTerminalInput('explain');
+ });
+
+ await waitFor(() => expect(input).toHaveBeenCalledWith('pty-1', 'explain'));
+ expect(screen.getAllByTestId('terminal-session')).toHaveLength(1);
+ expect(create).not.toHaveBeenCalled();
+ });
});
diff --git a/packages/app/src/components/TerminalGate.dom.test.tsx b/packages/app/src/components/TerminalGate.dom.test.tsx
index b8c6b9c5a..bae09c486 100644
--- a/packages/app/src/components/TerminalGate.dom.test.tsx
+++ b/packages/app/src/components/TerminalGate.dom.test.tsx
@@ -70,9 +70,10 @@ describe('TerminalGate', () => {
expect(notice()).toBeNull();
});
- test('forwards onClose, onTitleChange, and the launch intent to the mounted terminal panel', async () => {
+ test('forwards onClose, onTitleChange, launch, onPtyId, and adoptPtyId to the mounted terminal panel', async () => {
const onClose = mock(() => {});
const onTitleChange = mock((_title: string) => {});
+ const onPtyId = mock((_ptyId: string | null) => {});
const launch = { prompt: 'work on docs/notes', nonce: 1 };
consentState = { enabled: null, synced: true };
render(
@@ -81,12 +82,16 @@ describe('TerminalGate', () => {
onClose={onClose}
onTitleChange={onTitleChange}
launch={launch}
+ onPtyId={onPtyId}
+ adoptPtyId="pty-survivor"
/>,
);
await screen.findByTestId('terminal-panel');
expect(lastPanelProps?.onClose).toBe(onClose);
expect(lastPanelProps?.onTitleChange).toBe(onTitleChange);
expect(lastPanelProps?.launch).toBe(launch);
+ expect(lastPanelProps?.onPtyId).toBe(onPtyId);
+ expect(lastPanelProps?.adoptPtyId).toBe('pty-survivor');
});
test('enabled === true mounts the terminal', async () => {
diff --git a/packages/app/src/components/TerminalGate.tsx b/packages/app/src/components/TerminalGate.tsx
index 8e255515d..7b0e69480 100644
--- a/packages/app/src/components/TerminalGate.tsx
+++ b/packages/app/src/components/TerminalGate.tsx
@@ -19,6 +19,9 @@ interface TerminalGateProps {
/** Surviving PTY to adopt after a renderer reload, forwarded to the session;
* `null` for a freshly-opened tab. */
readonly adoptPtyId?: string | null;
+ /** Reports the session's live PTY id up to the host — see
+ * {@link TerminalPanelProps.onPtyId}. */
+ readonly onPtyId?: (ptyId: string | null) => void;
}
export function TerminalGate({
@@ -27,6 +30,7 @@ export function TerminalGate({
onTitleChange,
launch = null,
adoptPtyId = null,
+ onPtyId,
}: TerminalGateProps) {
const { enabled, synced } = useTerminalConsentState();
const writer = useTerminalEnabledWriter();
@@ -63,6 +67,7 @@ export function TerminalGate({
onTitleChange={onTitleChange}
launch={launch}
adoptPtyId={adoptPtyId}
+ onPtyId={onPtyId}
/>
diff --git a/packages/app/src/components/TerminalPanel.dom.test.tsx b/packages/app/src/components/TerminalPanel.dom.test.tsx
index def101382..9e007e81e 100644
--- a/packages/app/src/components/TerminalPanel.dom.test.tsx
+++ b/packages/app/src/components/TerminalPanel.dom.test.tsx
@@ -136,6 +136,7 @@ function makeBridge(
terminal,
shell: { openExternal },
config: { e2eSmoke: false },
+ getPathForFile: (file: File) => `/dropped/${file.name}`,
} as unknown as OkDesktopBridge,
terminal,
openExternal,
@@ -298,6 +299,95 @@ describe('TerminalPanel', () => {
expect(terminal.input).toHaveBeenCalledWith('pty-1', 'ls\r');
});
+ test('dropping files inserts their shell-escaped paths at the prompt (PRD-7238)', async () => {
+ const { bridge, terminal } = makeBridge({ ok: true, ptyId: 'pty-1' });
+ render( );
+ await waitFor(() => expect(lastTerm?.onDataCb).toBeTruthy());
+
+ const container = document.querySelector('[data-terminal-status]');
+ if (container === null) throw new Error('terminal container not found');
+
+ const fileA = new File(['x'], 'shot.png', { type: 'image/png' });
+ const fileB = new File(['y'], "a b's.png", { type: 'image/png' });
+ const dataTransfer = { types: ['Files'], files: [fileA, fileB] };
+ fireEvent.dragOver(container, { dataTransfer });
+ fireEvent.drop(container, { dataTransfer });
+
+ expect(terminal.input).toHaveBeenCalledWith(
+ 'pty-1',
+ "'/dropped/shot.png' '/dropped/a b'\\''s.png' ",
+ );
+ });
+
+ test('a drop where every file resolves to no disk path writes nothing (clipboard blobs)', async () => {
+ const { bridge, terminal } = makeBridge({ ok: true, ptyId: 'pty-1' });
+ (bridge as unknown as { getPathForFile: (f: File) => string }).getPathForFile = () => '';
+ render( );
+ await waitFor(() => expect(lastTerm?.onDataCb).toBeTruthy());
+
+ const container = document.querySelector('[data-terminal-status]');
+ if (container === null) throw new Error('terminal container not found');
+
+ const blob = new File(['x'], 'pasted.png', { type: 'image/png' });
+ const dataTransfer = { types: ['Files'], files: [blob] };
+ fireEvent.dragOver(container, { dataTransfer });
+ fireEvent.drop(container, { dataTransfer });
+
+ expect(terminal.input).not.toHaveBeenCalled();
+ });
+
+ test('a mixed drop writes only the files that resolve to a disk path', async () => {
+ const { bridge, terminal } = makeBridge({ ok: true, ptyId: 'pty-1' });
+ (bridge as unknown as { getPathForFile: (f: File) => string | null }).getPathForFile = (
+ file,
+ ) => (file.name === 'ghost.png' ? null : `/dropped/${file.name}`);
+ render( );
+ await waitFor(() => expect(lastTerm?.onDataCb).toBeTruthy());
+
+ const container = document.querySelector('[data-terminal-status]');
+ if (container === null) throw new Error('terminal container not found');
+
+ const real = new File(['x'], 'shot.png', { type: 'image/png' });
+ const ghost = new File(['y'], 'ghost.png', { type: 'image/png' });
+ const dataTransfer = { types: ['Files'], files: [real, ghost] };
+ fireEvent.dragOver(container, { dataTransfer });
+ fireEvent.drop(container, { dataTransfer });
+
+ expect(terminal.input).toHaveBeenCalledTimes(1);
+ expect(terminal.input).toHaveBeenCalledWith('pty-1', "'/dropped/shot.png' ");
+ });
+
+ test('a dropped path containing a control char is filtered (no PTY command injection)', async () => {
+ const { bridge, terminal } = makeBridge({ ok: true, ptyId: 'pty-1' });
+ render( );
+ await waitFor(() => expect(lastTerm?.onDataCb).toBeTruthy());
+
+ const container = document.querySelector('[data-terminal-status]');
+ if (container === null) throw new Error('terminal container not found');
+
+ const clean = new File(['x'], 'shot.png', { type: 'image/png' });
+ const tainted = new File(['y'], 'a\nrm -rf ~.png', { type: 'image/png' });
+ const dataTransfer = { types: ['Files'], files: [clean, tainted] };
+ fireEvent.dragOver(container, { dataTransfer });
+ fireEvent.drop(container, { dataTransfer });
+
+ expect(terminal.input).toHaveBeenCalledTimes(1);
+ expect(terminal.input).toHaveBeenCalledWith('pty-1', "'/dropped/shot.png' ");
+ });
+
+ test('a drag that carries no external files is ignored (no PTY write)', async () => {
+ const { bridge, terminal } = makeBridge({ ok: true, ptyId: 'pty-1' });
+ render( );
+ await waitFor(() => expect(lastTerm?.onDataCb).toBeTruthy());
+
+ const container = document.querySelector('[data-terminal-status]');
+ if (container === null) throw new Error('terminal container not found');
+
+ const dataTransfer = { types: ['text/plain'], files: [] };
+ fireEvent.drop(container, { dataTransfer });
+ expect(terminal.input).not.toHaveBeenCalled();
+ });
+
test('re-fits and resizes the PTY when the container resizes', async () => {
const { bridge, terminal } = makeBridge({ ok: true, ptyId: 'pty-1' });
render( );
diff --git a/packages/app/src/components/TerminalPanel.tsx b/packages/app/src/components/TerminalPanel.tsx
index 1eb5edb9f..114887700 100644
--- a/packages/app/src/components/TerminalPanel.tsx
+++ b/packages/app/src/components/TerminalPanel.tsx
@@ -1,6 +1,10 @@
import '@xterm/xterm/css/xterm.css';
-import { buildCliLaunchArgString, type TerminalCli } from '@inkeep/open-knowledge-core';
+import {
+ buildCliLaunchArgString,
+ shellSingleQuote,
+ type TerminalCli,
+} from '@inkeep/open-knowledge-core';
import { useLingui } from '@lingui/react/macro';
import { FitAddon } from '@xterm/addon-fit';
import { Unicode11Addon } from '@xterm/addon-unicode11';
@@ -13,6 +17,7 @@ import type { ClaudeReadiness, OkDesktopBridge } from '@/lib/desktop-bridge-type
import { cn } from '@/lib/utils';
import { ClaudeReadinessBanner } from './ClaudeReadinessBanner';
import type { TerminalLaunchIntent } from './EditorPane';
+import { filesFromExternalDrop, isExternalFileDrag } from './file-tree-adapter';
import { TerminalCliMissingBanner } from './TerminalCliMissingBanner';
import { type TerminalExitInfo, TerminalExitNotice } from './TerminalExitNotice';
import { TerminalRefusalNotice } from './TerminalRefusalNotice';
@@ -29,6 +34,7 @@ interface TerminalPanelProps {
readonly onTitleChange?: (title: string) => void;
readonly launch?: TerminalLaunchIntent | null;
readonly adoptPtyId?: string | null;
+ readonly onPtyId?: (ptyId: string | null) => void;
}
export function TerminalPanel({
@@ -39,6 +45,7 @@ export function TerminalPanel({
onTitleChange,
launch = null,
adoptPtyId = null,
+ onPtyId,
}: TerminalPanelProps) {
const { t } = useLingui();
const { resolvedTheme } = useTheme();
@@ -62,6 +69,7 @@ export function TerminalPanel({
onRestart={() => setRestartKey((k) => k + 1)}
launch={launch}
adoptPtyId={adoptForThisMount}
+ onPtyId={onPtyId}
/>
@@ -82,6 +90,9 @@ interface TerminalSessionProps {
/** Surviving PTY to adopt on mount instead of spawning a fresh shell; `null`
* spawns fresh (the normal path). */
readonly adoptPtyId?: string | null;
+ /** Reports the live PTY id up (or `null` on teardown) — see
+ * {@link TerminalPanelProps.onPtyId}. */
+ readonly onPtyId?: (ptyId: string | null) => void;
}
function TerminalSession({
@@ -92,10 +103,12 @@ function TerminalSession({
onRestart,
launch = null,
adoptPtyId = null,
+ onPtyId,
}: TerminalSessionProps) {
const containerRef = useRef
(null);
const onExitRef = useRef(onExit);
const onTitleChangeRef = useRef(onTitleChange);
+ const onPtyIdRef = useRef(onPtyId);
const { resolvedTheme } = useTheme();
const termRef = useRef(null);
const initialResolvedThemeRef = useRef(resolvedTheme);
@@ -108,6 +121,7 @@ function TerminalSession({
useEffect(() => {
onExitRef.current = onExit;
onTitleChangeRef.current = onTitleChange;
+ onPtyIdRef.current = onPtyId;
});
useEffect(() => {
@@ -223,6 +237,7 @@ function TerminalSession({
const attachSession = (livePtyId: string) => {
ptyId = livePtyId;
ptyIdRef.current = livePtyId;
+ onPtyIdRef.current?.(livePtyId);
setStatus('running');
term.onData((data) => {
@@ -360,6 +375,7 @@ function TerminalSession({
return () => {
cancelled = true;
ptyIdRef.current = null;
+ onPtyIdRef.current?.(null);
termRef.current = null;
observer?.disconnect();
unsubData?.();
@@ -379,6 +395,35 @@ function TerminalSession({
term.options.theme = xtermThemeForMode(resolvedTheme);
}, [resolvedTheme]);
+ useEffect(() => {
+ const container = containerRef.current;
+ if (container === null) return;
+ function onDragOver(event: DragEvent) {
+ if (!isExternalFileDrag(event)) return;
+ event.preventDefault();
+ }
+ function onDrop(event: DragEvent) {
+ if (!isExternalFileDrag(event)) return;
+ event.preventDefault();
+ const livePtyId = ptyIdRef.current;
+ if (livePtyId === null) return;
+ const paths = filesFromExternalDrop(event)
+ .map((file) => bridge.getPathForFile(file))
+ .filter(
+ (path): path is string =>
+ path !== null && path !== '' && !Array.from(path).some((ch) => ch.charCodeAt(0) < 0x20),
+ );
+ if (paths.length === 0) return;
+ bridge.terminal.input(livePtyId, `${paths.map(shellSingleQuote).join(' ')} `);
+ }
+ container.addEventListener('dragover', onDragOver, { capture: true });
+ container.addEventListener('drop', onDrop, { capture: true });
+ return () => {
+ container.removeEventListener('dragover', onDragOver, { capture: true });
+ container.removeEventListener('drop', onDrop, { capture: true });
+ };
+ }, [bridge]);
+
return (
{status === 'running' && readiness ? (
diff --git a/packages/app/src/components/TerminalSessionsHost.tsx b/packages/app/src/components/TerminalSessionsHost.tsx
index 3bc7855b0..e023251c5 100644
--- a/packages/app/src/components/TerminalSessionsHost.tsx
+++ b/packages/app/src/components/TerminalSessionsHost.tsx
@@ -4,7 +4,9 @@ import { createPortal } from 'react-dom';
import { TabsContent } from '@/components/ui/tabs';
import type { OkDesktopBridge } from '@/lib/desktop-bridge-types';
import type { TerminalDockPosition } from '@/lib/terminal-dock-store';
+import { emitOpenAskAiComposer } from './ask-ai-composer-events';
import type { TerminalLaunchIntent } from './EditorPane';
+import { subscribeToActiveTerminalInput } from './handoff/terminal-input-events';
import { TerminalGate } from './TerminalGate';
import { TerminalTabStrip } from './TerminalTabStrip';
@@ -94,6 +96,11 @@ export function TerminalSessionsHost({
const sessionCounterRef = useRef(!canRehydrate && visible ? 1 : 0);
const lastHandledLaunchNonceRef = useRef
(visible && launch ? launch.nonce : null);
const prevVisibleRef = useRef(visible);
+ const ptyIdBySessionRef = useRef(new Map());
+ function setSessionPtyId(id: string, ptyId: string | null) {
+ if (ptyId === null) ptyIdBySessionRef.current.delete(id);
+ else ptyIdBySessionRef.current.set(id, ptyId);
+ }
function openSession(launchForSession: TerminalLaunchIntent | null) {
sessionCounterRef.current += 1;
@@ -191,6 +198,19 @@ export function TerminalSessionsHost({
};
}, [bridge]);
+ useEffect(() => {
+ return subscribeToActiveTerminalInput((text) => {
+ const activeId = activeSessionIdRef.current;
+ const livePtyId = activeId === '' ? undefined : ptyIdBySessionRef.current.get(activeId);
+ if (livePtyId != null) {
+ bridge.terminal.input(livePtyId, text);
+ queueMicrotask(() => focusTerminalSession(activeId));
+ } else {
+ emitOpenAskAiComposer();
+ }
+ });
+ }, [bridge]);
+
useEffect(() => {
return bridge.onMenuAction((action) => {
if (action === 'new-terminal') openSessionRef.current(null);
@@ -260,6 +280,7 @@ export function TerminalSessionsHost({
bridge={bridge}
launch={session.launch}
adoptPtyId={session.adoptPtyId}
+ onPtyId={(ptyId) => setSessionPtyId(session.id, ptyId)}
onTitleChange={(title) => setSessionTitle(session.id, title)}
onClose={() => closeSession(session.id)}
/>
diff --git a/packages/app/src/components/handoff/terminal-input-events.ts b/packages/app/src/components/handoff/terminal-input-events.ts
new file mode 100644
index 000000000..c1a5e9d91
--- /dev/null
+++ b/packages/app/src/components/handoff/terminal-input-events.ts
@@ -0,0 +1,25 @@
+const ACTIVE_TERMINAL_INPUT_EVENT = 'open-knowledge:active-terminal-input';
+
+export function requestActiveTerminalInput(
+ text: string,
+ target: Pick | EventTarget = typeof window === 'undefined'
+ ? new EventTarget()
+ : window,
+): void {
+ target.dispatchEvent(new CustomEvent(ACTIVE_TERMINAL_INPUT_EVENT, { detail: text }));
+}
+
+export function subscribeToActiveTerminalInput(
+ onRequest: (text: string) => void,
+ target: Pick | EventTarget = typeof window ===
+ 'undefined'
+ ? new EventTarget()
+ : window,
+): () => void {
+ const listener = (event: Event) => {
+ const text = event instanceof CustomEvent ? (event as CustomEvent).detail : undefined;
+ if (typeof text === 'string') onRequest(text);
+ };
+ target.addEventListener(ACTIVE_TERMINAL_INPUT_EVENT, listener as EventListener);
+ return () => target.removeEventListener(ACTIVE_TERMINAL_INPUT_EVENT, listener as EventListener);
+}
diff --git a/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.dom.test.tsx b/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.dom.test.tsx
index 907e5850d..d33fcf672 100644
--- a/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.dom.test.tsx
+++ b/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.dom.test.tsx
@@ -1,14 +1,22 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
-import { act, cleanup, render, screen } from '@testing-library/react';
+import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Schema } from '@tiptap/pm/model';
import type { Editor } from '@tiptap/react';
import type { ReactNode } from 'react';
import { subscribeToOpenAskAiComposer } from '@/components/ask-ai-composer-events';
+import { subscribeToActiveTerminalInput } from '@/components/handoff/terminal-input-events';
import { setEditorDocName } from '../extensions/doc-context.ts';
mock.module('sonner', () => ({ toast: { error: () => {}, success: () => {} } }));
+if (typeof globalThis.requestAnimationFrame === 'undefined') {
+ globalThis.requestAnimationFrame = ((callback) => {
+ queueMicrotask(() => callback(0));
+ return 0;
+ }) as typeof globalThis.requestAnimationFrame;
+}
+
const { EditWithAiBubbleButton } = await import('./EditWithAiBubbleButton');
const schema = new Schema({
@@ -19,12 +27,18 @@ const schema = new Schema({
},
});
-function makeEditor(docName: string, text: string): Editor {
+/** A minimal fake TipTap editor. The click handler reads the selection text via
+ * `state.doc.textBetween(from, to)`; `collapsed` yields an empty selection so
+ * the caret-only → composer fallback can be exercised. */
+function makeEditor(docName: string, text: string, collapsed = false): Editor {
const doc = schema.node('doc', null, [schema.node('paragraph', null, [schema.text(text)])]);
+ const from = 1;
+ const to = collapsed ? 1 : 1 + text.length;
const editor = {
state: {
schema,
- selection: { content: () => doc.slice(0, doc.content.size) },
+ doc,
+ selection: { from, to, content: () => doc.slice(from, to) },
},
} as unknown as Editor;
setEditorDocName(editor, docName);
@@ -50,6 +64,8 @@ const EMBEDDED_UA = `${PLAIN_UA} Cursor/1.2.3`;
let openRequests = 0;
let unsubscribe: (() => void) | null = null;
+let terminalInputs: string[] = [];
+let unsubscribeTerminal: (() => void) | null = null;
function renderButton({
editor,
@@ -83,14 +99,20 @@ function dispatchEditWithAiShortcut(target: EventTarget): void {
beforeEach(() => {
openRequests = 0;
+ terminalInputs = [];
unsubscribe = subscribeToOpenAskAiComposer(() => {
openRequests += 1;
});
+ unsubscribeTerminal = subscribeToActiveTerminalInput((text) => {
+ terminalInputs.push(text);
+ });
});
afterEach(() => {
unsubscribe?.();
unsubscribe = null;
+ unsubscribeTerminal?.();
+ unsubscribeTerminal = null;
cleanup();
setUserAgent(PLAIN_UA);
});
@@ -125,7 +147,7 @@ describe('EditWithAiBubbleButton', () => {
expect(container.firstChild).toBeNull();
});
- test('clicking the trigger requests the Ask AI composer open+focus', async () => {
+ test('clicking the trigger sends the selected passage to the active terminal', async () => {
setPlatform('MacIntel');
const user = userEvent.setup();
const editor = makeEditor('specs/foo/SPEC', 'A passage.');
@@ -133,7 +155,20 @@ describe('EditWithAiBubbleButton', () => {
await user.click(screen.getByTestId('edit-with-ai-bubble-button'));
- expect(openRequests).toBe(1);
+ await waitFor(() => expect(terminalInputs).toEqual(['A passage.']));
+ expect(openRequests).toBe(0);
+ });
+
+ test('clicking with an empty selection opens the Ask AI composer instead', async () => {
+ setPlatform('MacIntel');
+ const user = userEvent.setup();
+ const editor = makeEditor('specs/foo/SPEC', 'A passage.', /* collapsed */ true);
+ renderButton({ editor });
+
+ await user.click(screen.getByTestId('edit-with-ai-bubble-button'));
+
+ await waitFor(() => expect(openRequests).toBe(1));
+ expect(terminalInputs).toEqual([]);
});
test('Cmd+Shift+I requests the Ask AI composer open+focus', async () => {
diff --git a/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.tsx b/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.tsx
index 842427e06..8d66872d3 100644
--- a/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.tsx
+++ b/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.tsx
@@ -4,6 +4,7 @@ import type { Editor } from '@tiptap/react';
import { Sparkles } from 'lucide-react';
import { type ReactNode, useEffect } from 'react';
import { emitOpenAskAiComposer } from '@/components/ask-ai-composer-events';
+import { requestActiveTerminalInput } from '@/components/handoff/terminal-input-events';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useIsEmbedded } from '@/hooks/use-is-embedded';
@@ -16,7 +17,7 @@ function isNativeTextControl(target: EventTarget | null): boolean {
}
export function EditWithAiBubbleButton({
- editor: _editor,
+ editor,
shortcutEnabled = false,
}: {
editor: Editor;
@@ -26,10 +27,16 @@ export function EditWithAiBubbleButton({
const isEmbedded = useIsEmbedded();
if (!isMac || isEmbedded) return null;
- return ;
+ return ;
}
-function EditWithAiBubbleMenu({ shortcutEnabled }: { shortcutEnabled: boolean }): ReactNode {
+function EditWithAiBubbleMenu({
+ editor,
+ shortcutEnabled,
+}: {
+ editor: Editor;
+ shortcutEnabled: boolean;
+}): ReactNode {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent): void => {
if (!shortcutEnabled) return;
@@ -55,7 +62,14 @@ function EditWithAiBubbleMenu({ shortcutEnabled }: { shortcutEnabled: boolean })
size="sm"
data-testid="edit-with-ai-bubble-button"
className="gap-1 px-2 text-sm font-medium text-accent-foreground/80"
- onClick={() => emitOpenAskAiComposer()}
+ onClick={() => {
+ const { from, to } = editor.state.selection;
+ const text = editor.state.doc.textBetween(from, to, '\n');
+ requestAnimationFrame(() => {
+ if (text.trim() === '') emitOpenAskAiComposer();
+ else requestActiveTerminalInput(text);
+ });
+ }}
>
diff --git a/packages/app/src/hooks/use-onboarding-card-visible.dom.test.tsx b/packages/app/src/hooks/use-onboarding-card-visible.dom.test.tsx
new file mode 100644
index 000000000..3ab7104c7
--- /dev/null
+++ b/packages/app/src/hooks/use-onboarding-card-visible.dom.test.tsx
@@ -0,0 +1,155 @@
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
+import {
+ createOnboardingCardStore,
+ type OnboardingCardStorage,
+ type OnboardingCardStore,
+} from '@/lib/onboarding-card-store';
+import { useOnboardingCardVisible } from './use-onboarding-card-visible';
+
+const CURRENT_PATH = '/Users/me/project';
+
+function memoryStorage(): OnboardingCardStorage {
+ const map = new Map();
+ return {
+ getItem: (k) => map.get(k) ?? null,
+ setItem: (k, v) => {
+ map.set(k, v);
+ },
+ };
+}
+
+function setDesktopHost(recents: Array<{ path: string }>): void {
+ (window as unknown as { okDesktop: unknown }).okDesktop = {
+ project: { listRecent: () => Promise.resolve(recents) },
+ config: { projectPath: CURRENT_PATH },
+ };
+}
+
+function clearDesktopHost(): void {
+ (window as unknown as { okDesktop?: unknown }).okDesktop = undefined;
+}
+
+function mockDocuments(documents: unknown[]): void {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(new Response(JSON.stringify({ documents }), { status: 200 })),
+ ) as never;
+}
+
+function Probe({ store }: { store: OnboardingCardStore }) {
+ const visible = useOnboardingCardVisible(store);
+ return {String(visible)}
;
+}
+
+let originalFetch: typeof globalThis.fetch;
+beforeEach(() => {
+ originalFetch = globalThis.fetch;
+});
+afterEach(() => {
+ cleanup();
+ globalThis.fetch = originalFetch;
+ clearDesktopHost();
+});
+
+describe('useOnboardingCardVisible', () => {
+ test('no desktop host → never visible, never activates', async () => {
+ clearDesktopHost();
+ const fetchMock = mock(() =>
+ Promise.resolve(new Response(JSON.stringify({ documents: [] }), { status: 200 })),
+ );
+ globalThis.fetch = fetchMock as never;
+ const store = createOnboardingCardStore(memoryStorage());
+ render( );
+ await act(async () => {});
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(screen.getByTestId('visible').textContent).toBe('false');
+ expect(store.getSnapshot().initialized).toBe(false);
+ });
+
+ test('fresh single-project desktop session → activates and becomes visible', async () => {
+ setDesktopHost([{ path: CURRENT_PATH }]);
+ mockDocuments([]);
+ const store = createOnboardingCardStore(memoryStorage());
+ render( );
+ await waitFor(() => expect(screen.getByTestId('visible').textContent).toBe('true'));
+ expect(store.getSnapshot().initialized).toBe(true);
+ });
+
+ test('established session (other project present) → stays hidden', async () => {
+ const listRecent = mock(() => Promise.resolve([{ path: CURRENT_PATH }, { path: '/other' }]));
+ (window as unknown as { okDesktop: unknown }).okDesktop = {
+ project: { listRecent },
+ config: { projectPath: CURRENT_PATH },
+ };
+ mockDocuments([]);
+ const store = createOnboardingCardStore(memoryStorage());
+ render( );
+ await waitFor(() => expect(listRecent).toHaveBeenCalled());
+ await act(async () => {});
+ expect(screen.getByTestId('visible').textContent).toBe('false');
+ expect(store.getSnapshot().initialized).toBe(false);
+ });
+
+ test('dismissed store → hidden and skips evaluation', async () => {
+ const fetchMock = mock(() =>
+ Promise.resolve(new Response(JSON.stringify({ documents: [] }), { status: 200 })),
+ );
+ globalThis.fetch = fetchMock as never;
+ setDesktopHost([{ path: CURRENT_PATH }]);
+ const store = createOnboardingCardStore(memoryStorage());
+ store.dismiss();
+ render( );
+ await act(async () => {});
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(screen.getByTestId('visible').textContent).toBe('false');
+ });
+
+ test('latch: stays visible after a step completes (entry count no longer 0)', async () => {
+ setDesktopHost([{ path: CURRENT_PATH }]);
+ mockDocuments([]);
+ const store = createOnboardingCardStore(memoryStorage());
+ render( );
+ await waitFor(() => expect(screen.getByTestId('visible').textContent).toBe('true'));
+ act(() => {
+ store.markStepComplete('file');
+ });
+ expect(screen.getByTestId('visible').textContent).toBe('true');
+ });
+
+ test('completed store → hidden forever', async () => {
+ const fetchMock = mock(() =>
+ Promise.resolve(new Response(JSON.stringify({ documents: [] }), { status: 200 })),
+ );
+ globalThis.fetch = fetchMock as never;
+ setDesktopHost([{ path: CURRENT_PATH }]);
+ const store = createOnboardingCardStore(memoryStorage());
+ store.markCompleted();
+ render( );
+ await act(async () => {});
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(screen.getByTestId('visible').textContent).toBe('false');
+ });
+
+ test('cancels a pending activation when the probe unmounts mid-flight', async () => {
+ let resolveRecents!: (r: Array<{ path: string }>) => void;
+ const listRecent = mock(
+ () =>
+ new Promise>((res) => {
+ resolveRecents = res;
+ }),
+ );
+ (window as unknown as { okDesktop: unknown }).okDesktop = {
+ project: { listRecent },
+ config: { projectPath: CURRENT_PATH },
+ };
+ mockDocuments([]);
+ const store = createOnboardingCardStore(memoryStorage());
+ const view = render( );
+ await waitFor(() => expect(listRecent).toHaveBeenCalled());
+ view.unmount();
+ await act(async () => {
+ resolveRecents([{ path: CURRENT_PATH }]);
+ });
+ expect(store.getSnapshot().initialized).toBe(false);
+ });
+});
diff --git a/packages/app/src/hooks/use-onboarding-card-visible.test.ts b/packages/app/src/hooks/use-onboarding-card-visible.test.ts
new file mode 100644
index 000000000..15287f4ed
--- /dev/null
+++ b/packages/app/src/hooks/use-onboarding-card-visible.test.ts
@@ -0,0 +1,76 @@
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+import type { OkDesktopBridge } from '@/lib/desktop-bridge-types';
+import { evaluateFreshProject } from './use-onboarding-card-visible';
+
+const CURRENT_PATH = '/Users/me/project';
+
+function bridgeWith(recents: Array<{ path: string }>): OkDesktopBridge {
+ return {
+ project: { listRecent: () => Promise.resolve(recents) },
+ config: { projectPath: CURRENT_PATH },
+ } as unknown as OkDesktopBridge;
+}
+
+function rejectingBridge(): OkDesktopBridge {
+ return {
+ project: { listRecent: () => Promise.reject(new Error('IPC down')) },
+ config: { projectPath: CURRENT_PATH },
+ } as unknown as OkDesktopBridge;
+}
+
+function mockDocumentsResponse(body: unknown, status = 200): void {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(new Response(JSON.stringify(body), { status })),
+ ) as never;
+}
+
+const aDocument = { kind: 'document', docName: 'welcome', size: 0, modified: '2026-06-30' };
+
+let originalFetch: typeof globalThis.fetch;
+beforeEach(() => {
+ originalFetch = globalThis.fetch;
+});
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+});
+
+describe('evaluateFreshProject', () => {
+ test('fresh single project with zero entries → true', async () => {
+ mockDocumentsResponse({ documents: [] });
+ expect(await evaluateFreshProject(bridgeWith([{ path: CURRENT_PATH }]))).toBe(true);
+ });
+
+ test('an empty recents list still counts as no-other-project → true when empty', async () => {
+ mockDocumentsResponse({ documents: [] });
+ expect(await evaluateFreshProject(bridgeWith([]))).toBe(true);
+ });
+
+ test('a second switchable project → false (does not fetch documents)', async () => {
+ const fetchSpy = mock(() => Promise.resolve(new Response('{}', { status: 200 })));
+ globalThis.fetch = fetchSpy as never;
+ expect(
+ await evaluateFreshProject(bridgeWith([{ path: CURRENT_PATH }, { path: '/other/project' }])),
+ ).toBe(false);
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ test('project already has content → false', async () => {
+ mockDocumentsResponse({ documents: [aDocument] });
+ expect(await evaluateFreshProject(bridgeWith([{ path: CURRENT_PATH }]))).toBe(false);
+ });
+
+ test('listRecent rejection is suppressed → false (fail-safe)', async () => {
+ mockDocumentsResponse({ documents: [] });
+ expect(await evaluateFreshProject(rejectingBridge())).toBe(false);
+ });
+
+ test('non-ok /api/documents response → false (fail-safe)', async () => {
+ mockDocumentsResponse({ error: 'boom' }, 500);
+ expect(await evaluateFreshProject(bridgeWith([{ path: CURRENT_PATH }]))).toBe(false);
+ });
+
+ test('schema-violating /api/documents body → false (fail-safe)', async () => {
+ mockDocumentsResponse({ unexpected: 'shape' });
+ expect(await evaluateFreshProject(bridgeWith([{ path: CURRENT_PATH }]))).toBe(false);
+ });
+});
diff --git a/packages/app/src/hooks/use-onboarding-card-visible.ts b/packages/app/src/hooks/use-onboarding-card-visible.ts
new file mode 100644
index 000000000..222ffcd92
--- /dev/null
+++ b/packages/app/src/hooks/use-onboarding-card-visible.ts
@@ -0,0 +1,44 @@
+import { useEffect, useSyncExternalStore } from 'react';
+import type { OkDesktopBridge } from '@/lib/desktop-bridge-types';
+import { type OnboardingCardStore, onboardingCardStore } from '@/lib/onboarding-card-store';
+import { fetchDocumentEntryCount } from '@/lib/onboarding-document-count';
+
+export async function evaluateFreshProject(bridge: OkDesktopBridge): Promise {
+ try {
+ const recents = await bridge.project.listRecent();
+ const currentPath = bridge.config.projectPath;
+ const hasOtherProject = recents.some((entry) => entry.path !== currentPath);
+ if (hasOtherProject) return false;
+ return (await fetchDocumentEntryCount()) === 0;
+ } catch (err) {
+ console.warn('[onboarding-card-visible] fresh-project probe failed; suppressing card', err);
+ return false;
+ }
+}
+
+export function useOnboardingCardVisible(
+ store: OnboardingCardStore = onboardingCardStore,
+): boolean {
+ const { initialized, dismissed, completed } = useSyncExternalStore(
+ store.subscribe,
+ store.getSnapshot,
+ store.getSnapshot,
+ );
+ const suppressed = dismissed || completed;
+ const shouldEvaluate = !initialized && !suppressed;
+
+ useEffect(() => {
+ if (!shouldEvaluate) return;
+ const bridge = window.okDesktop;
+ if (bridge == null) return;
+ let cancelled = false;
+ void evaluateFreshProject(bridge).then((isFresh) => {
+ if (!cancelled && isFresh) store.activate();
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [shouldEvaluate, store]);
+
+ return initialized && !suppressed;
+}
diff --git a/packages/app/src/hooks/use-onboarding-file-completion.dom.test.tsx b/packages/app/src/hooks/use-onboarding-file-completion.dom.test.tsx
new file mode 100644
index 000000000..aedcf8a88
--- /dev/null
+++ b/packages/app/src/hooks/use-onboarding-file-completion.dom.test.tsx
@@ -0,0 +1,108 @@
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+import { act, cleanup, render, waitFor } from '@testing-library/react';
+import { emitDocumentsChanged } from '@/lib/documents-events';
+import {
+ createOnboardingCardStore,
+ type OnboardingCardStorage,
+ type OnboardingCardStore,
+} from '@/lib/onboarding-card-store';
+import { useOnboardingFileCompletion } from './use-onboarding-file-completion';
+
+function freshStore(): OnboardingCardStore {
+ const map = new Map();
+ const storage: OnboardingCardStorage = {
+ getItem: (k) => map.get(k) ?? null,
+ setItem: (k, v) => {
+ map.set(k, v);
+ },
+ };
+ return createOnboardingCardStore(storage);
+}
+
+function mockDocuments(documents: unknown[]): void {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(new Response(JSON.stringify({ documents }), { status: 200 })),
+ ) as never;
+}
+
+const aDocument = { kind: 'document', docName: 'welcome', size: 0, modified: '2026-06-30' };
+
+function Probe({ store }: { store: OnboardingCardStore }) {
+ useOnboardingFileCompletion(store);
+ return null;
+}
+
+let originalFetch: typeof globalThis.fetch;
+beforeEach(() => {
+ originalFetch = globalThis.fetch;
+});
+afterEach(() => {
+ cleanup();
+ globalThis.fetch = originalFetch;
+});
+
+describe('useOnboardingFileCompletion', () => {
+ test('marks the file step when a files change reveals content', async () => {
+ mockDocuments([]);
+ const store = freshStore();
+ store.activate();
+ render( );
+ mockDocuments([aDocument]);
+ emitDocumentsChanged(['files']);
+ await waitFor(() => expect(store.getSnapshot().steps.file).toBe(true));
+ });
+
+ test('ignores non-files channels — no extra fetch on graph/backlinks updates', async () => {
+ const fetchMock = mock(() =>
+ Promise.resolve(new Response(JSON.stringify({ documents: [] }), { status: 200 })),
+ );
+ globalThis.fetch = fetchMock as never;
+ const store = freshStore();
+ store.activate();
+ render( );
+ await waitFor(() => expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(1));
+ const afterMount = fetchMock.mock.calls.length;
+ emitDocumentsChanged(['graph']);
+ emitDocumentsChanged(['backlinks']);
+ await act(async () => {});
+ expect(fetchMock.mock.calls.length).toBe(afterMount);
+ });
+
+ test('marks the file step at mount when content already exists', async () => {
+ mockDocuments([aDocument]);
+ const store = freshStore();
+ store.activate();
+ render( );
+ await waitFor(() => expect(store.getSnapshot().steps.file).toBe(true));
+ });
+
+ test('does not mark the step while the project stays empty', async () => {
+ const fetchMock = mock(() =>
+ Promise.resolve(new Response(JSON.stringify({ documents: [] }), { status: 200 })),
+ );
+ globalThis.fetch = fetchMock as never;
+ const store = freshStore();
+ store.activate();
+ render( );
+ emitDocumentsChanged(['files']);
+ await waitFor(() => expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(2));
+ await act(async () => {});
+ expect(store.getSnapshot().steps.file).toBe(false);
+ });
+
+ test('skips subscribing and fetching when the file step is already complete', async () => {
+ const fetchMock = mock(() =>
+ Promise.resolve(new Response(JSON.stringify({ documents: [] }), { status: 200 })),
+ );
+ globalThis.fetch = fetchMock as never;
+ const store = freshStore();
+ store.activate();
+ store.markStepComplete('file'); // already complete before mount
+ render( );
+ await act(async () => {});
+ expect(fetchMock).not.toHaveBeenCalled();
+ emitDocumentsChanged(['files']);
+ await act(async () => {});
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/app/src/hooks/use-onboarding-file-completion.ts b/packages/app/src/hooks/use-onboarding-file-completion.ts
new file mode 100644
index 000000000..f36638e59
--- /dev/null
+++ b/packages/app/src/hooks/use-onboarding-file-completion.ts
@@ -0,0 +1,17 @@
+import { useEffect } from 'react';
+import { subscribeToDocumentsChanged } from '@/lib/documents-events';
+import { type OnboardingCardStore, onboardingCardStore } from '@/lib/onboarding-card-store';
+import { recordOnboardingFileStep } from '@/lib/onboarding-signals';
+
+export function useOnboardingFileCompletion(
+ store: OnboardingCardStore = onboardingCardStore,
+): void {
+ useEffect(() => {
+ if (store.getSnapshot().steps.file) return;
+ const unsubscribe = subscribeToDocumentsChanged((channels) => {
+ if (channels.includes('files')) void recordOnboardingFileStep(store);
+ });
+ void recordOnboardingFileStep(store);
+ return unsubscribe;
+ }, [store]);
+}
diff --git a/packages/app/src/lib/desktop-bridge-types.ts b/packages/app/src/lib/desktop-bridge-types.ts
index 9c1fa4c7c..4a0a5dd1c 100644
--- a/packages/app/src/lib/desktop-bridge-types.ts
+++ b/packages/app/src/lib/desktop-bridge-types.ts
@@ -755,6 +755,7 @@ export interface OkDesktopBridge {
readonly platform: 'darwin' | 'win32' | 'linux';
readonly appVersion: string;
+ getPathForFile(file: File): string | null;
debug?: {
keyringSmoke(): Promise;
};
diff --git a/packages/app/src/lib/onboarding-card-store.test.ts b/packages/app/src/lib/onboarding-card-store.test.ts
new file mode 100644
index 000000000..7fea65529
--- /dev/null
+++ b/packages/app/src/lib/onboarding-card-store.test.ts
@@ -0,0 +1,281 @@
+import { describe, expect, test } from 'bun:test';
+import {
+ createOnboardingCardStore,
+ DEFAULT_ONBOARDING_CARD_STATE,
+ ONBOARDING_CARD_STORAGE_KEY,
+ type OnboardingCardStorage,
+ readPersistedState,
+ writePersistedState,
+} from './onboarding-card-store.ts';
+
+function memoryStorage(initial: Record = {}): OnboardingCardStorage {
+ const data = new Map(Object.entries(initial));
+ return {
+ getItem(key) {
+ return data.get(key) ?? null;
+ },
+ setItem(key, value) {
+ data.set(key, value);
+ },
+ };
+}
+
+describe('readPersistedState', () => {
+ test('absent key returns default', () => {
+ expect(readPersistedState(memoryStorage())).toEqual(DEFAULT_ONBOARDING_CARD_STATE);
+ });
+
+ test('round-trips a fully populated state', () => {
+ const stored = {
+ initialized: true,
+ steps: { file: true, askedAi: true },
+ dismissed: false,
+ completed: true,
+ };
+ const s = memoryStorage({ [ONBOARDING_CARD_STORAGE_KEY]: JSON.stringify(stored) });
+ expect(readPersistedState(s)).toEqual(stored);
+ });
+
+ test('missing fields default to false', () => {
+ const s = memoryStorage({
+ [ONBOARDING_CARD_STORAGE_KEY]: JSON.stringify({ steps: { file: true } }),
+ });
+ expect(readPersistedState(s)).toEqual({
+ initialized: false,
+ steps: { file: true, askedAi: false },
+ dismissed: false,
+ completed: false,
+ });
+ });
+
+ test('non-boolean field values are coerced to false', () => {
+ const s = memoryStorage({
+ [ONBOARDING_CARD_STORAGE_KEY]: JSON.stringify({
+ dismissed: 'yes',
+ completed: 1,
+ steps: { file: 'true', askedAi: null },
+ }),
+ });
+ const read = readPersistedState(s);
+ expect(read.dismissed).toBe(false);
+ expect(read.completed).toBe(false);
+ expect(read.steps).toEqual({ file: false, askedAi: false });
+ });
+
+ test('corrupt JSON falls back to default', () => {
+ const s = memoryStorage({ [ONBOARDING_CARD_STORAGE_KEY]: '{not valid json' });
+ expect(readPersistedState(s)).toEqual(DEFAULT_ONBOARDING_CARD_STATE);
+ });
+
+ test('non-object JSON falls back to default', () => {
+ const s = memoryStorage({ [ONBOARDING_CARD_STORAGE_KEY]: '"a string"' });
+ expect(readPersistedState(s)).toEqual(DEFAULT_ONBOARDING_CARD_STATE);
+ });
+
+ test('returns default when getItem throws (SecurityError / private browsing)', () => {
+ const throwingStorage: OnboardingCardStorage = {
+ getItem: () => {
+ throw new DOMException('SecurityError');
+ },
+ setItem: () => {},
+ };
+ expect(readPersistedState(throwingStorage)).toEqual(DEFAULT_ONBOARDING_CARD_STATE);
+ });
+});
+
+describe('writePersistedState', () => {
+ test('a written state reads back identically (reload round-trip)', () => {
+ const s = memoryStorage();
+ const next = { ...DEFAULT_ONBOARDING_CARD_STATE, dismissed: true, completed: true };
+ writePersistedState(next, s);
+ expect(readPersistedState(s)).toEqual(next);
+ });
+
+ test('does not throw when setItem throws (quota exceeded)', () => {
+ const throwingStorage: OnboardingCardStorage = {
+ getItem: () => null,
+ setItem: () => {
+ throw new DOMException('QuotaExceededError');
+ },
+ };
+ expect(() => writePersistedState(DEFAULT_ONBOARDING_CARD_STATE, throwingStorage)).not.toThrow();
+ });
+});
+
+describe('createOnboardingCardStore — mutations', () => {
+ test('markStepComplete checks the named step', () => {
+ const store = createOnboardingCardStore(memoryStorage());
+ store.markStepComplete('file');
+ expect(store.getSnapshot().steps).toEqual({ file: true, askedAi: false });
+ });
+
+ test('steps are independent', () => {
+ const store = createOnboardingCardStore(memoryStorage());
+ store.markStepComplete('askedAi');
+ expect(store.getSnapshot().steps).toEqual({ file: false, askedAi: true });
+ });
+
+ test('activate latches initialized', () => {
+ const store = createOnboardingCardStore(memoryStorage());
+ expect(store.getSnapshot().initialized).toBe(false);
+ store.activate();
+ expect(store.getSnapshot().initialized).toBe(true);
+ });
+
+ test('dismiss sets the dismissed flag', () => {
+ const store = createOnboardingCardStore(memoryStorage());
+ store.dismiss();
+ expect(store.getSnapshot().dismissed).toBe(true);
+ });
+
+ test('markCompleted sets the completed flag', () => {
+ const store = createOnboardingCardStore(memoryStorage());
+ store.markCompleted();
+ expect(store.getSnapshot().completed).toBe(true);
+ });
+});
+
+describe('createOnboardingCardStore — persistence across remount', () => {
+ test('dismiss persists so a fresh store reads it back true', () => {
+ const shared = memoryStorage();
+ createOnboardingCardStore(shared).dismiss();
+ expect(createOnboardingCardStore(shared).getSnapshot().dismissed).toBe(true);
+ });
+
+ test('markCompleted persists so a fresh store reads it back true', () => {
+ const shared = memoryStorage();
+ createOnboardingCardStore(shared).markCompleted();
+ expect(createOnboardingCardStore(shared).getSnapshot().completed).toBe(true);
+ });
+
+ test('step completion persists across remount', () => {
+ const shared = memoryStorage();
+ createOnboardingCardStore(shared).markStepComplete('file');
+ expect(createOnboardingCardStore(shared).getSnapshot().steps.file).toBe(true);
+ });
+
+ test('completion state persists across remount', () => {
+ const shared = memoryStorage();
+ createOnboardingCardStore(shared).markCompleted();
+ expect(createOnboardingCardStore(shared).getSnapshot().completed).toBe(true);
+ });
+});
+
+describe('createOnboardingCardStore — idempotence', () => {
+ test('markStepComplete twice does not notify the second time', () => {
+ const store = createOnboardingCardStore(memoryStorage());
+ let notifications = 0;
+ store.subscribe(() => {
+ notifications += 1;
+ });
+ store.markStepComplete('file');
+ store.markStepComplete('file');
+ expect(notifications).toBe(1);
+ expect(store.getSnapshot().steps.file).toBe(true);
+ });
+
+ test('dismiss twice does not notify the second time', () => {
+ const store = createOnboardingCardStore(memoryStorage());
+ let notifications = 0;
+ store.subscribe(() => {
+ notifications += 1;
+ });
+ store.dismiss();
+ store.dismiss();
+ expect(notifications).toBe(1);
+ });
+
+ test('activate twice does not notify the second time', () => {
+ const store = createOnboardingCardStore(memoryStorage());
+ let notifications = 0;
+ store.subscribe(() => {
+ notifications += 1;
+ });
+ store.activate();
+ store.activate();
+ expect(notifications).toBe(1);
+ });
+
+ test('markStepComplete for an already-complete step is a no-op', () => {
+ const store = createOnboardingCardStore(memoryStorage());
+ store.markStepComplete('file');
+ let notifications = 0;
+ store.subscribe(() => {
+ notifications += 1;
+ });
+ store.markStepComplete('file');
+ expect(notifications).toBe(0);
+ });
+});
+
+describe('createOnboardingCardStore — subscription', () => {
+ test('a subscribed listener is notified on mutation', () => {
+ const store = createOnboardingCardStore(memoryStorage());
+ let notified = false;
+ store.subscribe(() => {
+ notified = true;
+ });
+ store.markStepComplete('askedAi');
+ expect(notified).toBe(true);
+ });
+
+ test('an unsubscribed listener is not notified', () => {
+ const store = createOnboardingCardStore(memoryStorage());
+ let notifications = 0;
+ const unsubscribe = store.subscribe(() => {
+ notifications += 1;
+ });
+ unsubscribe();
+ store.dismiss();
+ expect(notifications).toBe(0);
+ });
+
+ test('getSnapshot returns a stable reference until a mutation occurs', () => {
+ const store = createOnboardingCardStore(memoryStorage());
+ const first = store.getSnapshot();
+ expect(store.getSnapshot()).toBe(first);
+ store.markStepComplete('file');
+ expect(store.getSnapshot()).not.toBe(first);
+ });
+});
+
+describe('createOnboardingCardStore — install', () => {
+ test('install re-syncs from storage seeded after construction', () => {
+ const shared = memoryStorage();
+ const store = createOnboardingCardStore(shared);
+ expect(store.getSnapshot().dismissed).toBe(false);
+ shared.setItem(
+ ONBOARDING_CARD_STORAGE_KEY,
+ JSON.stringify({ ...DEFAULT_ONBOARDING_CARD_STATE, dismissed: true }),
+ );
+ store.install();
+ expect(store.getSnapshot().dismissed).toBe(true);
+ });
+
+ test('install is idempotent — a second call does not re-sync', () => {
+ const shared = memoryStorage();
+ const store = createOnboardingCardStore(shared);
+ store.install();
+ shared.setItem(
+ ONBOARDING_CARD_STORAGE_KEY,
+ JSON.stringify({ ...DEFAULT_ONBOARDING_CARD_STATE, completed: true }),
+ );
+ store.install();
+ expect(store.getSnapshot().completed).toBe(false);
+ });
+
+ test('install notifies subscribers when it re-syncs', () => {
+ const shared = memoryStorage();
+ const store = createOnboardingCardStore(shared);
+ let notified = false;
+ store.subscribe(() => {
+ notified = true;
+ });
+ store.install();
+ expect(notified).toBe(true);
+ });
+
+ test('construct + install over the default storage path does not throw', () => {
+ expect(() => createOnboardingCardStore().install()).not.toThrow();
+ });
+});
diff --git a/packages/app/src/lib/onboarding-card-store.ts b/packages/app/src/lib/onboarding-card-store.ts
new file mode 100644
index 000000000..0695b7ffa
--- /dev/null
+++ b/packages/app/src/lib/onboarding-card-store.ts
@@ -0,0 +1,148 @@
+import { useSyncExternalStore } from 'react';
+
+export const ONBOARDING_CARD_STORAGE_KEY = 'ok-onboarding-card-v1';
+
+type OnboardingStep = 'file' | 'askedAi';
+
+export interface OnboardingCardState {
+ readonly initialized: boolean;
+ readonly steps: {
+ readonly file: boolean;
+ readonly askedAi: boolean;
+ };
+ readonly dismissed: boolean;
+ readonly completed: boolean;
+}
+
+export const DEFAULT_ONBOARDING_CARD_STATE: OnboardingCardState = {
+ initialized: false,
+ steps: { file: false, askedAi: false },
+ dismissed: false,
+ completed: false,
+};
+
+export interface OnboardingCardStorage {
+ getItem(key: string): string | null;
+ setItem(key: string, value: string): void;
+}
+
+export interface OnboardingCardStore {
+ getSnapshot(): OnboardingCardState;
+ subscribe(listener: () => void): () => void;
+ activate(): void;
+ markStepComplete(step: OnboardingStep): void;
+ dismiss(): void;
+ markCompleted(): void;
+ install(): void;
+}
+
+function asFlag(value: unknown): boolean {
+ return value === true;
+}
+
+function coerceState(parsed: unknown): OnboardingCardState {
+ if (typeof parsed !== 'object' || parsed === null) return DEFAULT_ONBOARDING_CARD_STATE;
+ const obj = parsed as Record;
+ const steps =
+ typeof obj.steps === 'object' && obj.steps !== null
+ ? (obj.steps as Record)
+ : {};
+ return {
+ initialized: asFlag(obj.initialized),
+ steps: { file: asFlag(steps.file), askedAi: asFlag(steps.askedAi) },
+ dismissed: asFlag(obj.dismissed),
+ completed: asFlag(obj.completed),
+ };
+}
+
+export function readPersistedState(storage?: OnboardingCardStorage): OnboardingCardState {
+ try {
+ const s = storage ?? localStorage;
+ const raw = s.getItem(ONBOARDING_CARD_STORAGE_KEY);
+ if (raw == null) return DEFAULT_ONBOARDING_CARD_STATE;
+ return coerceState(JSON.parse(raw));
+ } catch (err) {
+ console.warn('[onboarding-card-store] readPersistedState failed (corrupt/privacy/SSR)', err);
+ return DEFAULT_ONBOARDING_CARD_STATE;
+ }
+}
+
+export function writePersistedState(
+ state: OnboardingCardState,
+ storage?: OnboardingCardStorage,
+): void {
+ try {
+ const s = storage ?? localStorage;
+ s.setItem(ONBOARDING_CARD_STORAGE_KEY, JSON.stringify(state));
+ } catch (err) {
+ console.warn('[onboarding-card-store] writePersistedState failed (quota/privacy/SSR)', err);
+ }
+}
+
+export function createOnboardingCardStore(storage?: OnboardingCardStorage): OnboardingCardStore {
+ let state = readPersistedState(storage);
+ const listeners = new Set<() => void>();
+ let installed = false;
+
+ function notify(): void {
+ for (const listener of listeners) listener();
+ }
+
+ function commit(next: OnboardingCardState): void {
+ state = next;
+ writePersistedState(state, storage);
+ notify();
+ }
+
+ return {
+ getSnapshot(): OnboardingCardState {
+ return state;
+ },
+
+ subscribe(listener): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+
+ activate(): void {
+ if (state.initialized) return;
+ commit({ ...state, initialized: true });
+ },
+
+ markStepComplete(step): void {
+ if (state.steps[step]) return;
+ commit({ ...state, steps: { ...state.steps, [step]: true } });
+ },
+
+ dismiss(): void {
+ if (state.dismissed) return;
+ commit({ ...state, dismissed: true });
+ },
+
+ markCompleted(): void {
+ if (state.completed) return;
+ commit({ ...state, completed: true });
+ },
+
+ install(): void {
+ if (installed) return;
+ installed = true;
+ state = readPersistedState(storage);
+ notify();
+ },
+ };
+}
+
+export const onboardingCardStore: OnboardingCardStore = createOnboardingCardStore();
+
+export function useOnboardingCardState(
+ store: OnboardingCardStore = onboardingCardStore,
+): OnboardingCardState {
+ return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
+}
+
+export function installOnboardingCardStore(): void {
+ onboardingCardStore.install();
+}
diff --git a/packages/app/src/lib/onboarding-document-count.test.ts b/packages/app/src/lib/onboarding-document-count.test.ts
new file mode 100644
index 000000000..a2507c499
--- /dev/null
+++ b/packages/app/src/lib/onboarding-document-count.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, test } from 'bun:test';
+import type { DocumentListSuccess } from '@inkeep/open-knowledge-core';
+import { countVisibleEntries } from './onboarding-document-count';
+
+type Entry = DocumentListSuccess['documents'][number];
+
+const base = {
+ docExt: '.md',
+ size: 0,
+ modified: '2026-06-30',
+ isSymlink: false,
+ canonicalDocName: null,
+ targetPath: null,
+} as const;
+
+const doc = (docName: string): Entry => ({ ...base, kind: 'document', docName }) as Entry;
+const folder = (path: string): Entry => ({ ...base, kind: 'folder', path }) as Entry;
+const asset = (path: string): Entry =>
+ ({ ...base, kind: 'asset', path, assetExt: '.png', referencedBy: [] }) as Entry;
+const file = (path: string): Entry => ({ ...base, kind: 'file', path }) as Entry;
+
+describe('countVisibleEntries', () => {
+ test('an empty project counts zero', () => {
+ expect(countVisibleEntries([])).toBe(0);
+ });
+
+ test('counts documents', () => {
+ expect(countVisibleEntries([doc('welcome'), doc('notes')])).toBe(2);
+ });
+
+ test('counts folders — starter packs often scaffold only folders', () => {
+ expect(countVisibleEntries([folder('guides'), folder('references')])).toBe(2);
+ });
+
+ test('counts documents and folders together', () => {
+ expect(countVisibleEntries([doc('welcome'), folder('guides'), doc('notes')])).toBe(3);
+ });
+
+ test('excludes assets and bare non-markdown files', () => {
+ expect(countVisibleEntries([doc('welcome'), asset('logo.png'), file('data.csv')])).toBe(1);
+ });
+
+ test('excludes hidden entries (dotfiles, .ok config, .git)', () => {
+ expect(countVisibleEntries([doc('welcome'), doc('.ok/config'), folder('.git')])).toBe(1);
+ });
+
+ test('excludes entries that ship without a docName or path', () => {
+ expect(countVisibleEntries([doc(''), folder('')])).toBe(0);
+ });
+});
diff --git a/packages/app/src/lib/onboarding-document-count.ts b/packages/app/src/lib/onboarding-document-count.ts
new file mode 100644
index 000000000..e8c86fafa
--- /dev/null
+++ b/packages/app/src/lib/onboarding-document-count.ts
@@ -0,0 +1,18 @@
+import { type DocumentListSuccess, DocumentListSuccessSchema } from '@inkeep/open-knowledge-core';
+import { filterVisibleEntries } from '@/components/file-tree-utils';
+
+export function countVisibleEntries(documents: DocumentListSuccess['documents']): number {
+ return filterVisibleEntries(documents).filter(
+ (entry) => entry.kind === 'document' || entry.kind === 'folder',
+ ).length;
+}
+
+export async function fetchDocumentEntryCount(): Promise {
+ const response = await fetch('/api/documents');
+ if (!response.ok) throw new Error(`documents request failed: ${response.status}`);
+ const body = (await response.json()) as unknown;
+ const parsed = DocumentListSuccessSchema.safeParse(body);
+ if (!parsed.success)
+ throw new Error('documents response did not match schema', { cause: parsed.error });
+ return countVisibleEntries(parsed.data.documents);
+}
diff --git a/packages/app/src/lib/onboarding-signals.test.ts b/packages/app/src/lib/onboarding-signals.test.ts
new file mode 100644
index 000000000..c83c9ea79
--- /dev/null
+++ b/packages/app/src/lib/onboarding-signals.test.ts
@@ -0,0 +1,95 @@
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+import {
+ createOnboardingCardStore,
+ type OnboardingCardStorage,
+ type OnboardingCardStore,
+} from './onboarding-card-store';
+import { recordOnboardingAskedAi, recordOnboardingFileStep } from './onboarding-signals';
+
+function freshStore(): OnboardingCardStore {
+ const map = new Map();
+ const storage: OnboardingCardStorage = {
+ getItem: (k) => map.get(k) ?? null,
+ setItem: (k, v) => {
+ map.set(k, v);
+ },
+ };
+ return createOnboardingCardStore(storage);
+}
+
+function mockDocuments(documents: unknown[], status = 200): void {
+ globalThis.fetch = mock(() =>
+ Promise.resolve(new Response(JSON.stringify({ documents }), { status })),
+ ) as never;
+}
+
+const aDocument = { kind: 'document', docName: 'welcome', size: 0, modified: '2026-06-30' };
+
+let originalFetch: typeof globalThis.fetch;
+beforeEach(() => {
+ originalFetch = globalThis.fetch;
+});
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+});
+
+describe('recordOnboardingAskedAi', () => {
+ test('marks the step when onboarding is active', () => {
+ const store = freshStore();
+ store.activate();
+ recordOnboardingAskedAi(store);
+ expect(store.getSnapshot().steps.askedAi).toBe(true);
+ });
+
+ test('no-op when onboarding was never activated (established user)', () => {
+ const store = freshStore();
+ recordOnboardingAskedAi(store);
+ expect(store.getSnapshot().steps.askedAi).toBe(false);
+ expect(store.getSnapshot().initialized).toBe(false);
+ });
+});
+
+describe('recordOnboardingFileStep', () => {
+ test('marks the file step when the project has at least one entry', async () => {
+ mockDocuments([aDocument]);
+ const store = freshStore();
+ store.activate();
+ await recordOnboardingFileStep(store);
+ expect(store.getSnapshot().steps.file).toBe(true);
+ });
+
+ test('does not mark the file step when the project is still empty', async () => {
+ mockDocuments([]);
+ const store = freshStore();
+ store.activate();
+ await recordOnboardingFileStep(store);
+ expect(store.getSnapshot().steps.file).toBe(false);
+ });
+
+ test('no-op when onboarding is not active (file created by an established user)', async () => {
+ const fetchSpy = mock(() => Promise.resolve(new Response('{}', { status: 200 })));
+ globalThis.fetch = fetchSpy as never;
+ const store = freshStore();
+ await recordOnboardingFileStep(store);
+ expect(fetchSpy).not.toHaveBeenCalled();
+ expect(store.getSnapshot().steps.file).toBe(false);
+ });
+
+ test('is a no-op (no fetch) when the step is already complete', async () => {
+ const fetchSpy = mock(() => Promise.resolve(new Response('{}', { status: 200 })));
+ globalThis.fetch = fetchSpy as never;
+ const store = freshStore();
+ store.activate();
+ store.markStepComplete('file');
+ await recordOnboardingFileStep(store);
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ test('swallows a failed count read and leaves the step incomplete', async () => {
+ mockDocuments({ error: 'boom' } as never, 500);
+ const store = freshStore();
+ store.activate();
+ await recordOnboardingFileStep(store);
+ expect(store.getSnapshot().steps.file).toBe(false);
+ });
+});
diff --git a/packages/app/src/lib/onboarding-signals.ts b/packages/app/src/lib/onboarding-signals.ts
new file mode 100644
index 000000000..bf898e3c2
--- /dev/null
+++ b/packages/app/src/lib/onboarding-signals.ts
@@ -0,0 +1,18 @@
+import { type OnboardingCardStore, onboardingCardStore } from '@/lib/onboarding-card-store';
+import { fetchDocumentEntryCount } from '@/lib/onboarding-document-count';
+
+export async function recordOnboardingFileStep(
+ store: OnboardingCardStore = onboardingCardStore,
+): Promise {
+ const snapshot = store.getSnapshot();
+ if (snapshot.steps.file || !snapshot.initialized) return;
+ try {
+ if ((await fetchDocumentEntryCount()) >= 1) store.markStepComplete('file');
+ } catch (err) {
+ console.warn('[onboarding-signals] file-step count read failed; leaving step incomplete', err);
+ }
+}
+
+export function recordOnboardingAskedAi(store: OnboardingCardStore = onboardingCardStore): void {
+ if (store.getSnapshot().initialized) store.markStepComplete('askedAi');
+}
diff --git a/packages/app/src/locales/en/messages.json b/packages/app/src/locales/en/messages.json
index 510089b03..ea818f9fc 100644
--- a/packages/app/src/locales/en/messages.json
+++ b/packages/app/src/locales/en/messages.json
@@ -176,6 +176,7 @@
"4A3Yzu": ["Code block"],
"4BITzH": ["Error:"],
"4CfgPM": ["Project folder created, but git init failed. Try again."],
+ "4E9jPa": ["Create your first project"],
"4Gz2wC": ["Failed to upload file"],
"4KmEoC": ["Hotkeys"],
"4NC2Ym": ["Move to the next or previous table cell."],
@@ -320,6 +321,7 @@
],
"7d1a0d": ["Public"],
"7hctf2": ["Describe the project you want to create"],
+ "7jIIFX": ["You're all set up!"],
"7qRHf0": ["Install for Claude Chat & Cowork"],
"80c4-P": ["Code copied to clipboard"],
"81H_23": ["Add properties"],
@@ -973,6 +975,7 @@
"QYfQdv": [["displayName"], " CLI"],
"QcpeeE": ["Navigate to ", ["docName"]],
"QfFBdJ": ["The template's filename (without `.md`)."],
+ "Qj4RL5": ["Completed:"],
"QlxHAH": ["Showing results for \"", ["semanticResultsLabel"], "\""],
"QqBQu1": ["Label <0>(visible link text)0>"],
"Qvls7J": ["Eng specs"],
@@ -1198,6 +1201,7 @@
"XyOUTJ": ["Exit merge"],
"Xzy0s0": ["Remove ", ["fullLabel"], " from context"],
"Y-Urt2": ["Block math equation rendered with KaTeX from a LaTeX source string."],
+ "Y1QMlM": ["Get set up"],
"Y1kX2z": [
"OpenKnowledge initializes at <0>",
["projectDir"],
diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po
index 128ff9475..b16d428aa 100644
--- a/packages/app/src/locales/en/messages.po
+++ b/packages/app/src/locales/en/messages.po
@@ -737,6 +737,7 @@ msgstr "Ask {0} CLI"
#: src/components/BottomComposer.tsx
#: src/components/EditorFooter.tsx
+#: src/components/OnboardingCard.tsx
#: src/editor/bubble-menu/EditWithAiBubbleButton.tsx
#: src/lib/keyboard-shortcuts.ts
msgid "Ask AI"
@@ -1293,6 +1294,10 @@ msgstr "Competitor research"
msgid "Complete"
msgstr "Complete"
+#: src/components/OnboardingCard.tsx
+msgid "Completed:"
+msgstr "Completed:"
+
#: src/lib/keyboard-shortcuts.ts
msgid "Component boundary navigation"
msgstr "Component boundary navigation"
@@ -1901,9 +1906,14 @@ msgid "Create with {0} CLI"
msgstr "Create with {0} CLI"
#: src/components/FileTree.tsx
+#: src/components/OnboardingCard.tsx
msgid "Create your first file"
msgstr "Create your first file"
+#: src/components/OnboardingCard.tsx
+msgid "Create your first project"
+msgstr "Create your first project"
+
#: src/components/FolderTimelineCard.tsx
msgid "created folder"
msgstr "created folder"
@@ -2116,6 +2126,7 @@ msgstr "Disconnecting"
#: src/components/ClaudeReadinessBanner.tsx
#: src/components/NavigatorApp.tsx
+#: src/components/OnboardingCard.tsx
#: src/components/settings/SharingSection.tsx
#: src/components/TerminalCliMissingBanner.tsx
msgid "Dismiss"
@@ -2932,6 +2943,10 @@ msgstr "Get Claude Code"
msgid "Get product updates in your inbox."
msgstr "Get product updates in your inbox."
+#: src/components/OnboardingCard.tsx
+msgid "Get set up"
+msgstr "Get set up"
+
#: src/components/settings/SettingsDialogBody.tsx
msgid "Git auto-sync"
msgstr "Git auto-sync"
@@ -7040,6 +7055,10 @@ msgstr "You picked the filesystem root (/). Scaffolding here will scan every fil
msgid "You picked your home directory. OpenKnowledge will index everything in your home tree — large and may surface personal files."
msgstr "You picked your home directory. OpenKnowledge will index everything in your home tree — large and may surface personal files."
+#: src/components/OnboardingCard.tsx
+msgid "You're all set up!"
+msgstr "You're all set up!"
+
#: src/components/SubscribeForm.tsx
msgid "You're subscribed!"
msgstr "You're subscribed!"
diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json
index bf4924d40..cd1ce2931 100644
--- a/packages/app/src/locales/pseudo/messages.json
+++ b/packages/app/src/locales/pseudo/messages.json
@@ -176,6 +176,7 @@
"4A3Yzu": ["Ćōďē ƀĺōćķ"],
"4BITzH": ["Ēŕŕōŕ:"],
"4CfgPM": ["ƤŕōĴēćţ ƒōĺďēŕ ćŕēàţēď, ƀũţ ĝĩţ ĩńĩţ ƒàĩĺēď. Ţŕŷ àĝàĩń."],
+ "4E9jPa": ["Ćŕēàţē ŷōũŕ ƒĩŕśţ ƥŕōĴēćţ"],
"4Gz2wC": ["Ƒàĩĺēď ţō ũƥĺōàď ƒĩĺē"],
"4KmEoC": ["Ĥōţķēŷś"],
"4NC2Ym": ["Ḿōvē ţō ţĥē ńēxţ ōŕ ƥŕēvĩōũś ţàƀĺē ćēĺĺ."],
@@ -320,6 +321,7 @@
],
"7d1a0d": ["Ƥũƀĺĩć"],
"7hctf2": ["Ďēśćŕĩƀē ţĥē ƥŕōĴēćţ ŷōũ ŵàńţ ţō ćŕēàţē"],
+ "7jIIFX": ["Ŷōũ'ŕē àĺĺ śēţ ũƥ!"],
"7qRHf0": ["Ĩńśţàĺĺ ƒōŕ Ćĺàũďē Ćĥàţ & Ćōŵōŕķ"],
"80c4-P": ["Ćōďē ćōƥĩēď ţō ćĺĩƥƀōàŕď"],
"81H_23": ["Àďď ƥŕōƥēŕţĩēś"],
@@ -973,6 +975,7 @@
"QYfQdv": [["displayName"], " ĆĹĨ"],
"QcpeeE": ["Ńàvĩĝàţē ţō ", ["docName"]],
"QfFBdJ": ["Ţĥē ţēḿƥĺàţē'ś ƒĩĺēńàḿē (ŵĩţĥōũţ `.ḿď`)."],
+ "Qj4RL5": ["Ćōḿƥĺēţēď:"],
"QlxHAH": ["Śĥōŵĩńĝ ŕēśũĺţś ƒōŕ \"", ["semanticResultsLabel"], "\""],
"QqBQu1": ["Ĺàƀēĺ <0>(vĩśĩƀĺē ĺĩńķ ţēxţ)0>"],
"Qvls7J": ["Ēńĝ śƥēćś"],
@@ -1198,6 +1201,7 @@
"XyOUTJ": ["Ēxĩţ ḿēŕĝē"],
"Xzy0s0": ["Ŕēḿōvē ", ["fullLabel"], " ƒŕōḿ ćōńţēxţ"],
"Y-Urt2": ["ßĺōćķ ḿàţĥ ēǫũàţĩōń ŕēńďēŕēď ŵĩţĥ ĶàŢēX ƒŕōḿ à ĹàŢēX śōũŕćē śţŕĩńĝ."],
+ "Y1QMlM": ["Ĝēţ śēţ ũƥ"],
"Y1kX2z": [
"ŌƥēńĶńōŵĺēďĝē ĩńĩţĩàĺĩźēś àţ <0>",
["projectDir"],
diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po
index 9f8f2dac2..b45765b0c 100644
--- a/packages/app/src/locales/pseudo/messages.po
+++ b/packages/app/src/locales/pseudo/messages.po
@@ -732,6 +732,7 @@ msgstr ""
#: src/components/BottomComposer.tsx
#: src/components/EditorFooter.tsx
+#: src/components/OnboardingCard.tsx
#: src/editor/bubble-menu/EditWithAiBubbleButton.tsx
#: src/lib/keyboard-shortcuts.ts
msgid "Ask AI"
@@ -1288,6 +1289,10 @@ msgstr ""
msgid "Complete"
msgstr ""
+#: src/components/OnboardingCard.tsx
+msgid "Completed:"
+msgstr ""
+
#: src/lib/keyboard-shortcuts.ts
msgid "Component boundary navigation"
msgstr ""
@@ -1896,9 +1901,14 @@ msgid "Create with {0} CLI"
msgstr ""
#: src/components/FileTree.tsx
+#: src/components/OnboardingCard.tsx
msgid "Create your first file"
msgstr ""
+#: src/components/OnboardingCard.tsx
+msgid "Create your first project"
+msgstr ""
+
#: src/components/FolderTimelineCard.tsx
msgid "created folder"
msgstr ""
@@ -2111,6 +2121,7 @@ msgstr ""
#: src/components/ClaudeReadinessBanner.tsx
#: src/components/NavigatorApp.tsx
+#: src/components/OnboardingCard.tsx
#: src/components/settings/SharingSection.tsx
#: src/components/TerminalCliMissingBanner.tsx
msgid "Dismiss"
@@ -2927,6 +2938,10 @@ msgstr ""
msgid "Get product updates in your inbox."
msgstr ""
+#: src/components/OnboardingCard.tsx
+msgid "Get set up"
+msgstr ""
+
#: src/components/settings/SettingsDialogBody.tsx
msgid "Git auto-sync"
msgstr ""
@@ -7035,6 +7050,10 @@ msgstr ""
msgid "You picked your home directory. OpenKnowledge will index everything in your home tree — large and may surface personal files."
msgstr ""
+#: src/components/OnboardingCard.tsx
+msgid "You're all set up!"
+msgstr ""
+
#: src/components/SubscribeForm.tsx
msgid "You're subscribed!"
msgstr ""
diff --git a/packages/app/src/main.tsx b/packages/app/src/main.tsx
index bc0553d98..ac459d6bb 100644
--- a/packages/app/src/main.tsx
+++ b/packages/app/src/main.tsx
@@ -21,6 +21,7 @@ import { installDeepLinkListener } from '@/lib/install-deep-link-listener';
import { installOnboardingToastListener } from '@/lib/install-onboarding-toast';
import { installServerDriftListener } from '@/lib/install-server-drift-listener';
import { installMcpConsentListener } from '@/lib/mcp-consent-store';
+import { installOnboardingCardStore } from '@/lib/onboarding-card-store';
import { initWebVitals } from '@/lib/perf';
import {
installColdMountInstrumentation,
@@ -52,6 +53,8 @@ if (import.meta.env.DEV || import.meta.env.MODE === 'test') {
installUpdateNoticesBridge();
+installOnboardingCardStore();
+
installRelaunchStateBridge();
if (typeof window !== 'undefined') {
diff --git a/packages/app/tests/stress/fixtures/handoff-mocks.ts b/packages/app/tests/stress/fixtures/handoff-mocks.ts
index 655195a98..cc011f288 100644
--- a/packages/app/tests/stress/fixtures/handoff-mocks.ts
+++ b/packages/app/tests/stress/fixtures/handoff-mocks.ts
@@ -369,6 +369,7 @@ export async function installHandoffMocks(page: Page, cfg: HandoffMockConfig): P
},
platform: 'darwin' as const,
appVersion: 'test-0.0.0',
+ getPathForFile: () => null,
} satisfies OkDesktopBridge;
// biome-ignore lint/suspicious/noExplicitAny: test-only global attachment.
diff --git a/packages/core/src/desktop-bridge.ts b/packages/core/src/desktop-bridge.ts
index 1a27d67ee..f4651bcfb 100644
--- a/packages/core/src/desktop-bridge.ts
+++ b/packages/core/src/desktop-bridge.ts
@@ -747,6 +747,8 @@ export interface OkDesktopBridge {
readonly platform: 'darwin' | 'win32' | 'linux';
readonly appVersion: string;
+ getPathForFile(file: File): string | null;
+
debug?: {
keyringSmoke(): Promise;
};
diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts
index e48d005bc..21ab2d418 100644
--- a/packages/desktop/src/preload/index.ts
+++ b/packages/desktop/src/preload/index.ts
@@ -1,4 +1,4 @@
-import { contextBridge, type IpcRendererEvent, ipcRenderer } from 'electron';
+import { contextBridge, type IpcRendererEvent, ipcRenderer, webUtils } from 'electron';
import type {
OkDesktopBridge,
OkDesktopConfig,
@@ -531,6 +531,11 @@ const bridge: OkDesktopBridge = {
platform: process.platform as 'darwin' | 'win32' | 'linux',
appVersion: parseArg('app-version') ?? '0.0.0',
+
+ getPathForFile: (file) => {
+ const path = webUtils.getPathForFile(file);
+ return path === '' ? null : path;
+ },
};
if (parseArg('debug-keyring-smoke') === '1') {
diff --git a/packages/desktop/src/shared/bridge-contract.ts b/packages/desktop/src/shared/bridge-contract.ts
index d2be31e19..86d0a1aa0 100644
--- a/packages/desktop/src/shared/bridge-contract.ts
+++ b/packages/desktop/src/shared/bridge-contract.ts
@@ -752,6 +752,8 @@ export interface OkDesktopBridge {
readonly platform: 'darwin' | 'win32' | 'linux';
readonly appVersion: string;
+ getPathForFile(file: File): string | null;
+
debug?: {
keyringSmoke(): Promise;
};