import React, { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { agentToolHostName } from '../utils/agentToolSetting'; /** * One-time offer to turn on the `plannotator` agent tool on Pi and OpenCode 2, * where it is off by default. Question first ("Do you use Plannotator as a * skill?"), two example requests, one line of why, the cost, and two buttons, * under a small diagram of the round trip (the agent opens a review right * away, keeps working, your feedback comes back). Same shell as the other * first-run announcements (portal, z-[100], hand-rolled Escape + Tab wrap + * focus restore, backdrop dismiss, capture-phase keydown that swallows * Mod+Enter so a keystroke aimed here cannot approve a plan or post a review * behind it). * * Unlike those, it collects a decision, so it never dismisses itself while a * save is in flight, and it reports the outcome in place: a failed save shows * the server's reason and keeps both buttons; a successful one says when the * tool arrives and offers Done. * * LAST in each app's first-run dialog chain; the Apps gate rendering through * agentToolAnnouncementEligible and useFirstRunAnnouncementWindow. */ export type AgentToolOfferHost = 'pi' | 'opencode'; interface AgentToolAnnouncementDialogProps { readonly isOpen: boolean; /** The session's host; names the agent and when the change applies. */ readonly host: AgentToolOfferHost; /** * "Yes, turn it on": writes the setting (POST /api/config `{ agentTool: true }`). * Rejects with an Error whose message is shown. The App marks the offer * seen when it resolves. */ readonly onTurnOn: () => Promise; /** The decline button ("No, I’ll just use slash commands"), Escape, the backdrop and Done: marks the offer seen and closes it. */ readonly onDismiss: () => void; } type SaveState = | { readonly kind: 'idle' } | { readonly kind: 'saving' } | { readonly kind: 'saved' } | { readonly kind: 'failed'; readonly message: string }; const SECONDARY_BUTTON_CLASS = 'inline-flex min-h-9 items-center gap-2 rounded-lg border border-border bg-surface-0/40 px-3 text-sm font-medium text-foreground outline-none transition-colors motion-reduce:transition-none hover:bg-surface-1/70 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-card disabled:cursor-not-allowed disabled:opacity-50'; const PRIMARY_BUTTON_CLASS = 'min-h-9 rounded-lg bg-primary px-5 text-sm font-medium text-primary-foreground outline-none transition-opacity motion-reduce:transition-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-card disabled:cursor-not-allowed disabled:opacity-50'; export function AgentToolAnnouncementDialog({ isOpen, host, onTurnOn, onDismiss, }: AgentToolAnnouncementDialogProps) { const notNowRef = useRef(null); const doneRef = useRef(null); const previousFocusRef = useRef(null); const onDismissRef = useRef(onDismiss); const [save, setSave] = useState({ kind: 'idle' }); const saveRef = useRef(save); saveRef.current = save; const mountedRef = useRef(true); const agent = agentToolHostName(host); useEffect(() => { onDismissRef.current = onDismiss; }, [onDismiss]); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); // Never closed mid-save: the outcome has to be shown. const dismiss = useCallback(() => { if (saveRef.current.kind === 'saving') return; onDismissRef.current(); }, []); const turnOn = useCallback(async () => { if (saveRef.current.kind === 'saving' || saveRef.current.kind === 'saved') return; setSave({ kind: 'saving' }); try { await onTurnOn(); if (mountedRef.current) setSave({ kind: 'saved' }); } catch (error) { if (!mountedRef.current) return; setSave({ kind: 'failed', message: error instanceof Error && error.message ? error.message : 'Could not save the setting.', }); } }, [onTurnOn]); // Focus follows the outcome: Done once saved. useEffect(() => { if (save.kind === 'saved') doneRef.current?.focus(); }, [save.kind]); useEffect(() => { if (!isOpen) return; previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; // The decline button takes focus, so a stray Enter or Space never changes a setting. notNowRef.current?.focus(); const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); dismiss(); return; } // Both apps submit their decision on Mod+Enter from a window-level // handler; swallowing it here on the capture phase keeps a keystroke // aimed at this dialog from approving a plan or posting a review. if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); event.stopPropagation(); return; } if (event.key !== 'Tab') return; const dialog = document.querySelector('[data-agent-tool-announcement-dialog]'); const focusable = Array.from( dialog?.querySelectorAll('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])') ?? [], ).filter((element) => element.getAttribute('tabindex') !== '-1'); if (focusable.length === 0) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } }; document.addEventListener('keydown', handleKeyDown, true); return () => { document.removeEventListener('keydown', handleKeyDown, true); previousFocusRef.current?.focus(); previousFocusRef.current = null; }; }, [isOpen, dismiss]); if (!isOpen) return null; const saving = save.kind === 'saving'; return createPortal(
{ if (event.target === event.currentTarget) dismiss(); }} >

Do you use Plannotator as a skill?

Like asking {agent} to “use Plannotator to show me the HTML plan” or “open a Plannotator code review”.

If so, turn on the Plannotator tool. Those requests then open right away while {agent} keeps working, and your feedback comes back as a message.

Adds about 780 tokens to each request.{save.kind === 'saved' ? '' : ` ${startsLine(host)}`}

{save.kind === 'saved' ? (

) : save.kind === 'failed' ? (

) : null} {/* Stacks full-width below sm (secondary first, so focus order matches reading order); one row from sm. */}
{save.kind === 'saved' ? ( ) : ( <> )}
, document.body, ); } /* * Layout rules, the graphic band tint and the graphic colors are plain CSS (theme * tokens through var()) rather than Tailwind utilities on purpose: this file * is scanned by the review stylesheet, which the portable guides.show viewer * shares, and utilities used nowhere else would change the pinned viewer CSS * for a dialog the viewer never renders. Same result as the approved markup: * the graphic hides below 420px; the buttons stack full-width below `sm` * (40rem) and sit in one right-aligned row from it. */ const DIALOG_LAYOUT_CSS = ` .pn-ato-graphic { display: none; background-color: color-mix(in oklab, var(--muted) 45%, transparent); } @media (min-width: 420px) { .pn-ato-graphic { display: block; } } .pn-ato-actions { display: flex; flex-direction: column; gap: 0.5rem; } @media (min-width: 40rem) { .pn-ato-actions { flex-direction: row; flex-wrap: wrap; align-items: center; justify-content: flex-end; } } `; /** When the change applies on each host; part of the small print and the saved status. */ function startsLine(host: AgentToolOfferHost): string { return host === 'opencode' ? 'Starts the next time OpenCode starts.' : 'Starts with your next Pi session.'; } /* * The round trip, as inline SVG painted only from theme tokens (var(--token) * values from theme.css), so it follows every palette in light and dark. * aria-hidden: the copy beside it carries the whole meaning. * * Motion, in one sentence: your feedback travelling back to the agent is what * the tool adds, and drawing that one arrow once shows the direction the * static picture only implies. Runs once on mount (900ms draw, then a 200ms * fade of its head and label), never loops, and is off under * prefers-reduced-motion, where the resting frame is the final one. */ const ROUND_TRIP_MOTION_CSS = ` .pn-ato-return { stroke-dasharray: 1; stroke-dashoffset: 0; animation: pn-ato-draw 900ms cubic-bezier(0.23, 1, 0.32, 1) 300ms both; } .pn-ato-return-head, .pn-ato-return-label { animation: pn-ato-fade 200ms ease-out 1050ms both; } @keyframes pn-ato-draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } } @keyframes pn-ato-fade { from { opacity: 0; } to { opacity: 1; } } @media (prefers-reduced-motion: reduce) { .pn-ato-return, .pn-ato-return-head, .pn-ato-return-label { animation: none; } } `; function RoundTripGraphic({ agent }: { agent: string }) { return ( {/* The agent: a terminal tile */} {agent} keeps working {/* Plannotator: a document with one highlighted, commented line */} Plannotator {/* opens right away */} opens right away {/* your feedback comes back */} your feedback, as a message ); }