Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions packages/app/src/components/EditorPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ import { EditorArea } from './EditorArea';
import { EditorHeader } from './EditorHeader';
import { OpenInAgentMenuRequestProvider } from './handoff/OpenInAgentMenuRequestContext';
import {
buildHandoffInput,
buildSelectionHandoffInput,
buildSelectionOrDocHandoffInput,
type HandoffDispatchInput,
} from './handoff/useHandoffDispatch';

Expand Down Expand Up @@ -85,13 +84,12 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) {
<OpenInAgentMenuRequestProvider
value={{
openSelection(request) {
const input =
buildSelectionHandoffInput({
docName: request.docName,
workspace,
instruction: request.instruction,
selectionMarkdown: request.selectionMarkdown,
}) ?? buildHandoffInput({ docName: activeDocName, workspace });
const input = buildSelectionOrDocHandoffInput({
docName: request.docName ?? activeDocName,
workspace,
instruction: request.instruction,
selectionMarkdown: request.selectionMarkdown,
});
if (input === null) {
toast.error(t`Couldn't send the selection — please try again.`);
return false;
Expand Down
164 changes: 102 additions & 62 deletions packages/app/src/components/handoff/OpenInAgentMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { composeFilePrompt, type TargetData } from '@inkeep/open-knowledge-core';
import {
composeFilePrompt,
type HandoffOutcome,
type HandoffTarget,
type InstallState,
type TargetData,
} from '@inkeep/open-knowledge-core';
import { Trans, useLingui } from '@lingui/react/macro';
import { ExternalLink, Sparkles } from 'lucide-react';
import type { ReactNode } from 'react';
import type { ComponentProps, ReactNode } from 'react';
import { useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Expand Down Expand Up @@ -31,27 +37,29 @@ interface OpenInAgentMenuProps {
readonly onOpenChange?: (open: boolean) => void;
}

export function OpenInAgentMenu({ input, open, onOpenChange }: OpenInAgentMenuProps): ReactNode {
const { t } = useLingui();
const { states, refresh } = useInstalledAgents();
const { dispatch } = useHandoffDispatch();
const { merged } = useConfigContext();
const autoOpen = merged?.appearance?.preview?.autoOpen ?? true;
const [internalOpen, setInternalOpen] = useState(false);
const sawPointerDownRef = useRef(false);
const isEmbedded = useIsEmbedded();
if (isEmbedded) return null;

const isElectronHost = typeof window !== 'undefined' && window.okDesktop != null;
const menuOpen = open ?? internalOpen;

const handleOpenChange = (next: boolean): void => {
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
if (next) void refresh();
};
interface OpenInAgentMenuContentProps {
readonly input: HandoffDispatchInput | null;
readonly states: Record<HandoffTarget, InstallState>;
readonly dispatch: (
target: HandoffTarget,
input: HandoffDispatchInput,
) => Promise<HandoffOutcome>;
readonly isElectronHost: boolean;
readonly autoOpen: boolean;
readonly align?: ComponentProps<typeof DropdownMenuContent>['align'];
readonly className?: string;
}

const triggerDisabled = input === null;
export function OpenInAgentMenuContent({
input,
states,
dispatch,
isElectronHost,
autoOpen,
align = 'end',
className = 'min-w-[220px]',
}: OpenInAgentMenuContentProps): ReactNode {
const { t } = useLingui();
const isSelectionScope = Boolean(input?.selection);
const prompt =
input !== null && input.docContext !== null
Expand All @@ -75,6 +83,71 @@ export function OpenInAgentMenu({ input, open, onOpenChange }: OpenInAgentMenuPr
void dispatchClaudeWebFallback(prompt);
};

return (
<DropdownMenuContent align={align} className={className} data-testid="open-in-agent-menu">
{installedTargets.map((target) => {
const installState = states[target.id];
return (
<OpenInAgentMenuItem
key={target.id}
target={target}
installState={installState}
isElectronHost={isElectronHost}
prompt={prompt}
onSelect={() => handleSelect(target)}
/>
);
})}
{isSelectionScope && installedTargets.length === 0 ? (
<DropdownMenuItem disabled data-testid="open-in-agent-selection-empty">
{probePending ? (
<Trans>Checking for installed agents</Trans>
) : (
<Trans>No installed agents found</Trans>
)}
</DropdownMenuItem>
) : null}
{!claudeInstalled && !isSelectionScope ? (
<DropdownMenuItem
onSelect={handleClaudeWebFallback}
disabled={input === null}
data-testid="open-in-agent-claude-web-fallback"
aria-label={t`Open in claude.ai, opens in browser with prompt pre-filled`}
>
<ExternalLink className="size-4" aria-hidden="true" />
<span className="flex-1">
<Trans>Open in claude.ai →</Trans>
</span>
<span aria-hidden="true" className="ml-2 text-muted-foreground text-xs">
<Trans>opens in browser</Trans>
</span>
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
);
}

export function OpenInAgentMenu({ input, open, onOpenChange }: OpenInAgentMenuProps): ReactNode {
const { states, refresh } = useInstalledAgents();
const { dispatch } = useHandoffDispatch();
const { merged } = useConfigContext();
const autoOpen = merged?.appearance?.preview?.autoOpen ?? true;
const [internalOpen, setInternalOpen] = useState(false);
const sawPointerDownRef = useRef(false);
const isEmbedded = useIsEmbedded();
if (isEmbedded) return null;

const isElectronHost = typeof window !== 'undefined' && window.okDesktop != null;
const menuOpen = open ?? internalOpen;

const handleOpenChange = (next: boolean): void => {
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
if (next) void refresh();
};

const triggerDisabled = input === null;

return (
<DropdownMenu open={menuOpen} onOpenChange={handleOpenChange} modal={false}>
<DropdownMenuTrigger asChild>
Expand Down Expand Up @@ -107,46 +180,13 @@ export function OpenInAgentMenu({ input, open, onOpenChange }: OpenInAgentMenuPr
<Trans>Open with AI</Trans>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[220px]" data-testid="open-in-agent-menu">
{installedTargets.map((target) => {
const installState = states[target.id];
return (
<OpenInAgentMenuItem
key={target.id}
target={target}
installState={installState}
isElectronHost={isElectronHost}
prompt={prompt}
onSelect={() => handleSelect(target)}
/>
);
})}
{isSelectionScope && installedTargets.length === 0 ? (
<DropdownMenuItem disabled data-testid="open-in-agent-selection-empty">
{probePending ? (
<Trans>Checking for installed agents</Trans>
) : (
<Trans>No installed agents found</Trans>
)}
</DropdownMenuItem>
) : null}
{!claudeInstalled && !isSelectionScope ? (
<DropdownMenuItem
onSelect={handleClaudeWebFallback}
disabled={input === null}
data-testid="open-in-agent-claude-web-fallback"
aria-label={t`Open in claude.ai, opens in browser with prompt pre-filled`}
>
<ExternalLink className="size-4" aria-hidden="true" />
<span className="flex-1">
<Trans>Open in claude.ai →</Trans>
</span>
<span aria-hidden="true" className="ml-2 text-muted-foreground text-xs">
<Trans>opens in browser</Trans>
</span>
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
<OpenInAgentMenuContent
input={input}
states={states}
dispatch={dispatch}
isElectronHost={isElectronHost}
autoOpen={autoOpen}
/>
</DropdownMenu>
);
}
48 changes: 48 additions & 0 deletions packages/app/src/components/handoff/useHandoffDispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,54 @@ describe('buildSelectionHandoffInput — selection-scoped helper', () => {
});
});

describe('buildSelectionOrDocHandoffInput — selection/file fallback helper', () => {
test('prefers selection scope when the serialized selection is non-empty', async () => {
const { buildSelectionOrDocHandoffInput } = await import('./useHandoffDispatch');
const input = buildSelectionOrDocHandoffInput({
docName: 'specs/foo/SPEC',
workspace: { contentDir: '/repo', pathSeparator: '/' },
instruction: 'rewrite',
selectionMarkdown: 'selected passage',
});

expect(input?.docContext).toBeNull();
expect(input?.selection).toEqual({
relativePath: 'specs/foo/SPEC.md',
instruction: 'rewrite',
selectionMarkdown: 'selected passage',
});
expect(input?.docPath).toBe('/repo/specs/foo/SPEC.md');
});

test('falls back to file scope when the serialized selection is empty', async () => {
const { buildSelectionOrDocHandoffInput } = await import('./useHandoffDispatch');
const input = buildSelectionOrDocHandoffInput({
docName: 'specs/foo/SPEC',
workspace: { contentDir: '/repo', pathSeparator: '/' },
instruction: 'rewrite',
selectionMarkdown: '',
});

expect(input).toEqual({
docContext: { relativePath: 'specs/foo/SPEC.md' },
projectDir: '/repo',
docPath: '/repo/specs/foo/SPEC.md',
});
});

test('returns null when neither selection nor file scope can be built', async () => {
const { buildSelectionOrDocHandoffInput } = await import('./useHandoffDispatch');
expect(
buildSelectionOrDocHandoffInput({
docName: null,
workspace: { contentDir: '/repo', pathSeparator: '/' },
instruction: 'rewrite',
selectionMarkdown: '',
}),
).toBeNull();
});
});

describe('runHandoffDispatch — selection scope', () => {
function selectionInput(): HandoffDispatchInput {
return {
Expand Down
9 changes: 9 additions & 0 deletions packages/app/src/components/handoff/useHandoffDispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ export function buildSelectionHandoffInput(args: {
};
}

export function buildSelectionOrDocHandoffInput(args: {
readonly docName: string | null;
readonly workspace: Workspace | null;
readonly instruction: string;
readonly selectionMarkdown: string;
}): HandoffDispatchInput | null {
return buildSelectionHandoffInput(args) ?? buildHandoffInput(args);
}

export function openInstallUrl(target: TargetData): Promise<void> {
return defaultOpenExternal(target.installUrl).then(() => undefined);
}
Expand Down
Loading