diff --git a/packages/shared/package.json b/packages/shared/package.json index 0813f35eda4..1cab59ec9b7 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -125,6 +125,7 @@ "@tiptap/extension-placeholder": "^3.22.5", "@tiptap/react": "^3.22.5", "@tiptap/starter-kit": "^3.22.5", + "border-beam": "1.3.0", "check-password-strength": "^2.0.10", "cmdk": "^1.0.0", "edge-aura": "0.6.0", diff --git a/packages/shared/src/features/interests/AgentContext.tsx b/packages/shared/src/features/interests/AgentContext.tsx index 99f1a0f62e3..c14cf3ebb63 100644 --- a/packages/shared/src/features/interests/AgentContext.tsx +++ b/packages/shared/src/features/interests/AgentContext.tsx @@ -12,6 +12,7 @@ import type { UpdateInterestInput, UserInterest, } from '../../graphql/interests'; +import { UserInterestStatus } from '../../graphql/interests'; import { useSendInterestCommand } from './hooks/useSendInterestCommand'; import { useUpdateInterest } from './hooks/useUpdateInterest'; import { useToastNotification } from '../../hooks/useToastNotification'; @@ -21,7 +22,21 @@ import { cannedReply } from './chat'; export type AgentContentTarget = | { type: 'post'; post: Post } - | { type: 'feed'; label: string; posts: Post[] }; + | { type: 'feed'; label: string; posts: Post[] } + | { type: 'activity' } + | { type: 'debug' }; + +export const contentTargetId = (target: AgentContentTarget): string => { + if (target.type === 'post') { + return `post:${target.post.id}`; + } + + if (target.type === 'feed') { + return `feed:${target.label}`; + } + + return target.type; +}; export type AgentActivityKind = | 'run' @@ -47,19 +62,36 @@ type RunCommandArgs = { type AgentContextValue = { id: string; interest?: UserInterest; + /** + * The agent's run state. Held here rather than read off `interest` so the + * toggle responds on the demo surface too, where there is no API to write + * back to. + */ + status: UserInterestStatus; isDemo: boolean; isWorking: boolean; workingLabel?: string; + /** Epoch ms the current run started, for the elapsed counter. */ + workingSince?: number; isTargetWorking: (targetId: string) => boolean; runCommand: (args: RunCommandArgs) => void; + stopCommand: () => void; + /** Prompts sent while a run is in flight; they start as the runs finish. */ + queuedCommands: { id: string; text: string }[]; + removeQueuedCommand: (id: string) => void; update: (data: UpdateInterestInput) => void; isUpdating: boolean; activity: AgentActivityItem[]; messages: AgentMessage[]; isSettingsOpen: boolean; setSettingsOpen: (open: boolean) => void; + openContent: AgentContentTarget[]; + activeContentId?: string; activeContent?: AgentContentTarget; - setActiveContent: (target?: AgentContentTarget) => void; + openContentTarget: (target: AgentContentTarget) => void; + focusContent: (targetId: string) => void; + closeContent: (targetId: string) => void; + closeAllContent: () => void; }; const AgentContext = createContext({} as AgentContextValue); @@ -87,19 +119,34 @@ export const AgentProvider = ({ const [working, setWorking] = useState<{ label: string; targetId?: string; + startedAt: number; } | null>(null); const [activity, setActivity] = useState([]); const [messages, setMessages] = useState(initialMessages); const [isSettingsOpen, setSettingsOpen] = useState(false); - const [activeContent, setActiveContent] = useState(); + const [statusOverride, setStatusOverride] = useState(); + const status = + statusOverride ?? interest?.status ?? UserInterestStatus.Active; + const [content, setContent] = useState<{ + items: AgentContentTarget[]; + activeId?: string; + }>({ items: [] }); + const [queuedCommands, setQueuedCommands] = useState< + { id: string; args: RunCommandArgs }[] + >([]); const timeoutRef = useRef>(); + // The completion timeout needs the *current* starter to drain the queue, and + // a plain closure would freeze the one from its own render. + const startRunRef = useRef<(args: RunCommandArgs) => void>(); + const workingRef = useRef(false); useEffect(() => { return () => clearTimeout(timeoutRef.current); }, []); - const runCommand = useCallback( + const startRun = useCallback( ({ text, label, targetId, onComplete }: RunCommandArgs) => { + workingRef.current = true; if (!isDemo && interest) { sendCommand(text).catch(() => undefined); } else { @@ -108,7 +155,20 @@ export const AgentProvider = ({ const stamp = `${Date.now()}`; setMessages((current) => [ - ...current, + // Only one turn is ever in flight, and the timer below resolves the + // new one by id — so a turn sent over a pending one would leave the + // earlier reply spinning forever. Close it out instead. + ...current.map((message) => + message.isPending + ? { + ...message, + isPending: false, + blocks: [ + { type: 'text' as const, html: '

Interrupted.

' }, + ], + } + : message, + ), { id: `${stamp}-user`, role: 'user', @@ -123,7 +183,7 @@ export const AgentProvider = ({ }, ]); - setWorking({ label: label ?? text, targetId }); + setWorking({ label: label ?? text, targetId, startedAt: Date.now() }); setActivity((current) => [ { id: `${Date.now()}`, @@ -136,6 +196,7 @@ export const AgentProvider = ({ clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => { + workingRef.current = false; setWorking(null); setMessages((current) => current.map((message) => @@ -159,42 +220,186 @@ export const AgentProvider = ({ ...current, ]); onComplete?.(); + // Drain the queue the way Claude Code does: the next queued prompt + // becomes a real turn only once the previous one has resolved. + setQueuedCommands((current) => { + const [next, ...rest] = current; + + if (next) { + setTimeout(() => startRunRef.current?.(next.args), 0); + } + + return rest; + }); }, workDurationMs); }, [displayToast, interest, isDemo, sendCommand], ); + useEffect(() => { + startRunRef.current = startRun; + }, [startRun]); + + const runCommand = useCallback( + (args: RunCommandArgs) => { + if (workingRef.current) { + setQueuedCommands((current) => [ + ...current, + { id: `${Date.now()}-${current.length}`, args }, + ]); + return; + } + + startRun(args); + }, + [startRun], + ); + + const removeQueuedCommand = useCallback( + (queuedId: string) => + setQueuedCommands((current) => + current.filter((command) => command.id !== queuedId), + ), + [], + ); + + // Interrupts the in-flight run the way Enter-then-Stop works in a terminal + // agent: the pending turn resolves into a visible "stopped" note rather than + // vanishing, so the transcript still reads as a record of what happened. + const stopCommand = useCallback(() => { + clearTimeout(timeoutRef.current); + workingRef.current = false; + setWorking(null); + // Stop is a brake on everything: restarting queued prompts after an + // explicit stop would resume work the user just refused. + setQueuedCommands([]); + setMessages((current) => + current.map((message) => + message.isPending + ? { + ...message, + isPending: false, + at: new Date().toISOString(), + blocks: [{ type: 'text', html: '

Stopped.

' }], + } + : message, + ), + ); + setActivity((current) => [ + { + id: `${Date.now()}-stopped`, + at: new Date().toISOString(), + kind: 'command', + text: 'You stopped the run', + }, + ...current, + ]); + }, []); + + const openContentTarget = useCallback((target: AgentContentTarget) => { + const targetId = contentTargetId(target); + + setContent(({ items }) => ({ + items: items.some((item) => contentTargetId(item) === targetId) + ? items + : [...items, target], + activeId: targetId, + })); + }, []); + + const focusContent = useCallback( + (targetId: string) => + setContent(({ items }) => ({ items, activeId: targetId })), + [], + ); + + const closeContent = useCallback((targetId: string) => { + setContent(({ items, activeId }) => { + const index = items.findIndex( + (item) => contentTargetId(item) === targetId, + ); + + if (index < 0) { + return { items, activeId }; + } + + const next = items.filter((_, position) => position !== index); + // Focus the tab that slid into the closed one's slot, falling back to the + // new last tab when the closed one was rightmost. + const successor = next[index] ?? next[next.length - 1]; + + return { + items: next, + activeId: + activeId === targetId && successor + ? contentTargetId(successor) + : activeId, + }; + }); + }, []); + + const closeAllContent = useCallback(() => setContent({ items: [] }), []); + const update = useCallback( (data: UpdateInterestInput) => { + if (data.status) { + setStatusOverride(data.status); + + // Switching the agent off stops it: leaving a run in flight would + // contradict the control the user just used. + if (data.status !== UserInterestStatus.Active && working) { + stopCommand(); + } + } + if (isDemo || !interest) { return; } updateInterest(data).catch(() => undefined); }, - [interest, isDemo, updateInterest], + [interest, isDemo, stopCommand, updateInterest, working], ); const value = useMemo( () => ({ id, interest, + status, isDemo, isWorking: !!working, workingLabel: working?.label, + workingSince: working?.startedAt, isTargetWorking: (targetId) => working?.targetId === targetId, runCommand, + stopCommand, + queuedCommands: queuedCommands.map(({ id: queuedId, args }) => ({ + id: queuedId, + text: args.text, + })), + removeQueuedCommand, update, isUpdating, activity, messages, isSettingsOpen, setSettingsOpen, - activeContent, - setActiveContent, + openContent: content.items, + activeContentId: content.activeId, + activeContent: content.items.find( + (item) => contentTargetId(item) === content.activeId, + ), + openContentTarget, + focusContent, + closeContent, + closeAllContent, }), [ - activeContent, + closeAllContent, + closeContent, + content, + focusContent, + openContentTarget, activity, messages, id, @@ -202,7 +407,11 @@ export const AgentProvider = ({ isDemo, isSettingsOpen, isUpdating, + queuedCommands, + removeQueuedCommand, runCommand, + status, + stopCommand, update, working, ], diff --git a/packages/shared/src/features/interests/chat.ts b/packages/shared/src/features/interests/chat.ts index c36a7eb3060..a5eb09f2e34 100644 --- a/packages/shared/src/features/interests/chat.ts +++ b/packages/shared/src/features/interests/chat.ts @@ -15,6 +15,9 @@ export type AgentMessage = { blocks?: AgentBlock[]; isPending?: boolean; isScheduled?: boolean; + isError?: boolean; + /** The command a failed turn re-sends when the reader hits Retry. */ + retryText?: string; }; const minutesAgo = (minutes: number) => diff --git a/packages/shared/src/features/interests/components/AgentActivitySection.tsx b/packages/shared/src/features/interests/components/AgentActivitySection.tsx index bea44d3e96a..562e281231c 100644 --- a/packages/shared/src/features/interests/components/AgentActivitySection.tsx +++ b/packages/shared/src/features/interests/components/AgentActivitySection.tsx @@ -33,7 +33,7 @@ const ActivityRow = ({ item }: { item: AgentActivityItem }): ReactElement => ( {kindIcon[item.kind]} - + {item.text} undefined; +import { AgentThinkingStrip } from './AgentThinkingStrip'; +import { AgentPostCard } from './AgentPostCard'; +import { AgentEmbedCard } from './blocks/AgentEmbedCard'; -const PendingBubble = (): ReactElement => ( - - {[0, 150, 300].map((delay) => ( - - ))} - +// The shared markdown styles are tuned for reading an article (17px on a 1.7 +// leading, 24px paragraph gaps), which is far too loose at transcript density. +// Step it down to our callout token on a prose leading, scoped to the chat so +// post pages keep the reading rhythm. The shared rules are `:where()`-wrapped, +// so these class-based overrides win without `!important`. +// The `!leading-*` overrides are required, not stylistic: `typo-*` ships a +// line-height with its size, and it wins the cascade over a plain `leading-*`. +const transcriptProse = classNames( + '[&_p]:my-3 [&_p]:!leading-relaxed [&_p]:typo-callout', + '[&_li]:!leading-relaxed [&_li]:typo-callout [&_ol]:my-3 [&_ul]:my-3', + '[&_h1]:mb-1.5 [&_h1]:mt-5 [&_h1]:!leading-snug [&_h1]:typo-body', + '[&_h2]:mb-1.5 [&_h2]:mt-5 [&_h2]:!leading-snug [&_h2]:typo-body', + '[&_h3]:mb-1.5 [&_h3]:mt-5 [&_h3]:!leading-snug [&_h3]:typo-callout', + '[&>*:first-child]:mt-0 [&>*:last-child]:mb-0', ); const BlockRenderer = ({ block, onPostClick, onFeedClick, - isNarrow, activePostId, }: { block: AgentBlock; onPostClick: (post: Post) => void; onFeedClick: (label: string, posts: Post[]) => void; - isNarrow: boolean; activePostId?: string; }): ReactElement => { if (block.type === 'text') { - return ; + return ; } if (block.type === 'feedLink') { return ( - + } + title={block.label} + subtitle={`Feed · ${block.posts.length} posts`} + actionLabel="Open" + onAction={() => onFeedClick(block.label, block.posts)} + /> ); } if (block.type === 'picks') { return ( - + {block.caption && ( {block.caption} @@ -91,149 +99,228 @@ const BlockRenderer = ({ } return ( - + {block.caption && ( {block.caption} )} - {block.posts.map((post) => { - const CardComponent = isNarrow ? ArticleGrid : ArticleList; - const isViewing = post.id === activePostId; - - return ( - { - event?.preventDefault(); - onPostClick(clicked); - }} - onPostAuxClick={noop} - onUpvoteClick={noop} - onDownvoteClick={noop} - onCommentClick={noop} - onBookmarkClick={noop} - onCopyLinkClick={noop} - onShare={noop} - > - {isViewing && ( -
- -
- )} -
- ); - })} + {block.posts.map((post) => ( + + ))}
); }; +// The reply as flat text for the clipboard: markup stripped, block gaps kept. +const messageAsText = (message: AgentMessage): string => + (message.blocks ?? []) + .filter((block) => block.type === 'text') + .map( + (block) => + new DOMParser().parseFromString( + (block as { html: string }).html, + 'text/html', + ).body.textContent ?? '', + ) + .join('\n\n') + .trim(); + +// Hover-revealed, the way Claude and Codex keep reply actions out of the +// reading flow until the pointer says they are wanted. +const MessageActions = ({ + message, +}: { + message: AgentMessage; +}): ReactElement => { + const { displayToast } = useToastNotification(); + const [, copyText] = useCopyText(); + + return ( + + + )} + + ); +}; + const MessageRow = ({ message, onPostClick, onFeedClick, - isNarrow, activePostId, }: { message: AgentMessage; onPostClick: (post: Post) => void; onFeedClick: (label: string, posts: Post[]) => void; - isNarrow: boolean; activePostId?: string; }): ReactElement => { if (message.role === 'user') { return ( - -
- {message.text} + +
+ + {message.text} +
- - -
); } + // Agent turns carry no avatar or name: the right-aligned user bubbles are + // what separates the two voices, so repeating "Your agent" on every reply is + // noise. Only a scheduled run gets a marker, because that one arrived on its + // own rather than as an answer. return ( - - - - - - - - Your agent - - {message.isScheduled && ( - - - - Scheduled run - - - )} + + {message.isScheduled && ( + + + {'Scheduled run · '} - {message.isPending ? ( - - ) : ( - (message.blocks ?? []).map((block, index) => ( + )} + {message.isPending && } + {message.isError && } + {!message.isPending && !message.isError && ( + <> + {(message.blocks ?? []).map((block, index) => ( - )) - )} - - + ))} + {!!message.blocks?.length && } + + )} + ); }; export const AgentChatSection = (): ReactElement => { - const { messages, setActiveContent, activeContent } = useAgent(); - const { ref, isNarrow } = useNarrowContainer(); + const { + messages, + openContentTarget, + activeContent, + queuedCommands, + removeQueuedCommand, + } = useAgent(); const activePostId = activeContent?.type === 'post' ? activeContent.post.id : undefined; return ( - + {messages.map((message) => ( setActiveContent({ type: 'post', post })} + onPostClick={(post) => openContentTarget({ type: 'post', post })} onFeedClick={(label, posts) => - setActiveContent({ type: 'feed', label, posts }) + openContentTarget({ type: 'feed', label, posts }) } - isNarrow={isNarrow} activePostId={activePostId} /> ))} + {/* Prompts waiting behind the in-flight run sit as muted bubbles under + the transcript, Claude Code's queued-message pattern: visible, + removable, and not yet part of the conversation. */} + {queuedCommands.map(({ id, text }) => ( + + + + + Queued + + + {text} + + +