import { PathExt } from '@jupyterlab/coreutils'; import type { TranslationBundle } from '@jupyterlab/translation'; import { ReactWidget } from '@jupyterlab/ui-components'; import { CommandRegistry } from '@lumino/commands'; import * as React from 'react'; import type { IAgent } from '../launcher/agents'; import { AgentChoices, ISessionTarget, TargetChips } from './targetPicker'; import type { AskAgentTarget, IAskAgentContext } from './tokens'; /** * Gap between the popup and its anchor rectangle, in pixels. */ const ANCHOR_GAP = 6; /** * Minimum distance kept between the popup and the viewport edges. */ const VIEWPORT_MARGIN = 8; /** * Short human-readable descriptor of the prompted range, shown in the popup * header and the queue panel: file name, notebook cell (when applicable), * line range and, for ranges that are not current file content (such as the * old side of a diff), a clarifying tag. */ export function contextSummary( context: IAskAgentContext, trans: TranslationBundle ): string { const parts = [PathExt.basename(context.path)]; if (context.cell !== undefined) { parts.push(trans.__('cell %1', context.cell.index + 1)); } if (context.startLine !== undefined) { const endLine = context.endLine ?? context.startLine; parts.push( endLine > context.startLine ? trans.__('L%1–%2', context.startLine, endLine) : trans.__('L%1', context.startLine) ); } if (context.note !== undefined && context.note.length > 0) { parts.push(context.note); } return parts.join(' · '); } function AskAgentPopupComponent(props: AskAgentPopup.IOptions): JSX.Element { const { context, anchor, agents, targets, initialAgentId, initialTargetName, queueCount, trans, onSubmit, onQueue, onCancel } = props; const [instruction, setInstruction] = React.useState(''); const [agentId, setAgentId] = React.useState(() => { const known = agents.find(agent => agent.id === initialAgentId); return (known ?? agents[0])?.id ?? ''; }); // The running session the prompt goes to, or `null` for a new terminal. const [targetName, setTargetName] = React.useState( initialTargetName ); const [position, setPosition] = React.useState<{ left: number; top: number; } | null>(null); const popupRef = React.useRef(null); const textareaRef = React.useRef(null); // Place the popup once its size is known: below the anchor, flipped above // when there is no room, clamped into the viewport. Hidden until placed so // the measurement never flashes at the wrong position. React.useLayoutEffect(() => { const node = popupRef.current; if (node === null) { return; } const rect = node.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; let left: number; let top: number; if (anchor) { left = anchor.left; top = anchor.bottom + ANCHOR_GAP; if (top + rect.height > viewportHeight - VIEWPORT_MARGIN) { top = anchor.top - rect.height - ANCHOR_GAP; } } else { left = (viewportWidth - rect.width) / 2; top = viewportHeight * 0.2; } left = Math.max( VIEWPORT_MARGIN, Math.min(left, viewportWidth - rect.width - VIEWPORT_MARGIN) ); top = Math.max( VIEWPORT_MARGIN, Math.min(top, viewportHeight - rect.height - VIEWPORT_MARGIN) ); setPosition({ left, top }); }, [anchor]); // Focus the input only once the popup is placed: while it is still // unpositioned it sits `visibility: hidden`, and hidden elements silently // refuse focus. The empty state renders no textarea; focus the dialog // root itself so it is announced and Escape works without a pointer. React.useEffect(() => { if (position !== null) { (textareaRef.current ?? popupRef.current)?.focus(); } }, [position]); const dismiss = React.useCallback( (restoreFocus: boolean) => { // Defer so unmounting this React root never happens synchronously // inside the event handler that triggered it (same pattern as the // omnibox). window.setTimeout(() => onCancel(restoreFocus), 0); }, [onCancel] ); // Close when the user clicks anywhere outside the popup. Capture phase so // a click into widgets that swallow events still dismisses it. The click // places focus itself, so none is restored. React.useEffect(() => { const onPointerDown = (event: PointerEvent): void => { const node = popupRef.current; if ( node && event.target instanceof Node && !node.contains(event.target) ) { dismiss(false); } }; document.addEventListener('pointerdown', onPointerDown, true); return () => { document.removeEventListener('pointerdown', onPointerDown, true); }; }, [dismiss]); const selectedAgent = agents.find(agent => agent.id === agentId); const selectedSession = targets.find(target => target.name === targetName); const canSubmit = instruction.trim().length > 0 && (selectedSession !== undefined || selectedAgent !== undefined); // The destination both actions use: send delivers to it now, queue stamps // it on the queued prompt (still editable in the panel later). const resolveTarget = React.useCallback((): AskAgentTarget | null => { const session = targets.find(entry => entry.name === targetName); if (session !== undefined) { return { kind: 'session', name: session.name }; } const agent = agents.find(entry => entry.id === agentId); if (agent !== undefined) { return { kind: 'new', agentId: agent.id }; } return null; }, [agents, agentId, targets, targetName]); const submit = React.useCallback(() => { const trimmed = instruction.trim(); const target = resolveTarget(); if (trimmed.length === 0 || target === null) { return; } // Defer so unmounting this React root never happens synchronously // inside the event handler that triggered it. window.setTimeout(() => { onSubmit(target, trimmed); }, 0); }, [instruction, resolveTarget, onSubmit]); const queueInstruction = React.useCallback(() => { const trimmed = instruction.trim(); const target = resolveTarget(); if (trimmed.length === 0 || target === null || onQueue === undefined) { return; } // Same deferred pattern as submit. window.setTimeout(() => { onQueue(target, trimmed); }, 0); }, [instruction, resolveTarget, onQueue]); const onRootKeyDown = (event: React.KeyboardEvent): void => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); // A keyboard dismissal places no focus of its own; ask the plugin to // hand it back to the widget the ask came from. dismiss(true); } }; const onTextareaKeyDown = ( event: React.KeyboardEvent ): void => { if (event.key !== 'Enter' || event.nativeEvent.isComposing) { return; } if ((event.metaKey || event.ctrlKey) && onQueue !== undefined) { event.preventDefault(); event.stopPropagation(); queueInstruction(); return; } if (!event.shiftKey) { event.preventDefault(); event.stopPropagation(); submit(); } }; const summary = contextSummary(context, trans); const placeholder = trans.__('Describe the change…'); return (
{summary}
{agents.length === 0 && targets.length === 0 ? (
{trans.__( 'No agent accepts an initial prompt. Configure agents in the launcher settings.' )}
) : ( <>