@hachej/boring-ui-kit 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/r/pi-chat.json CHANGED
@@ -4,8 +4,8 @@
4
4
  "title": "Pi chat",
5
5
  "description": "A complete Tailwind chat over the borrowed native chat controller: composer, messages, one collapsible activity block per turn (tool calls and reasoning), message queue, history, a human-in-the-loop question card, an Approve / Deny card for host tools wrapped with requireApproval optional artifact cards that open generated content in the host's panel, and feedback cards for the opt-in feedback tool (lists, and Show offers that the host page resolves). Chat only: no layout. Owned, editable source; no AI SDK. The optional pi-ambient and pi-workspace blocks build on it.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.2.4",
8
- "@hachej/boring-files@^0.2.4",
7
+ "@hachej/boring-ui-kit@^0.2.5",
8
+ "@hachej/boring-files@^0.2.5",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "marked@^17.0.6",
@@ -18,9 +18,9 @@
18
18
  "use-stick-to-bottom@^1.1.6"
19
19
  ],
20
20
  "registryDependencies": [
21
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/theme.json",
22
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json",
23
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/button.json"
21
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.5/r/theme.json",
22
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.5/r/utils.json",
23
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.5/r/button.json"
24
24
  ],
25
25
  "files": [
26
26
  {
@@ -55,7 +55,7 @@
55
55
  },
56
56
  {
57
57
  "path": "registry/pi-chat/composer.tsx",
58
- "content": "'use client';\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ChangeEvent, ClipboardEvent, DragEvent, KeyboardEvent, ReactNode, Ref } from 'react';\nimport { AlertCircleIcon, FileTextIcon, Loader2Icon, MessageSquareIcon, MousePointerClickIcon, SlashIcon, XIcon } from 'lucide-react';\nimport { Button } from '../button/button';\nimport { hasMention, mentionToken, mentionTrigger, removeMention, slashItems, slashQuery } from './config';\nimport type { MentionsConfig, SlashConfig, SlashItem } from './config';\nimport { MentionMenu } from './mention-menu';\nimport { SlashMenu } from './slash-menu';\nimport { useChatText } from './labels';\nimport { cn } from '../utils/utils';\n\nexport interface ComposerAttachment { readonly id: string; readonly name: string; readonly mimeType: string; readonly data: string }\n\nconst SAFE_IMAGE = /^image\\/(png|jpe?g|gif|webp)$/;\nconst BASE64 = /^[A-Za-z0-9+/=\\s]+$/;\n/** A data URL for a thumbnail, only for common image types and well-formed base64. */\nexport function thumbnail(mimeType: string, data: string): string | undefined {\n return SAFE_IMAGE.test(mimeType) && BASE64.test(data) ? `data:${mimeType};base64,${data}` : undefined;\n}\n\n/**\n * The optional Feedback entry point (FEEDBACK.md, \"UX\"): a button beside the \"+\" menu that starts feedback mode on the host's page,\n * and afterwards one chip for the pending feedback. The host owns everything else (the registry `feedback` item's\n * `useComposerFeedback` returns this shape). Absent: the composer is unchanged.\n */\nexport interface ComposerFeedback {\n /** Enters feedback mode on the page: the Feedback button. Absent: no Feedback button. */\n readonly start?: () => void;\n /**\n * Point mode for phones: the « Show in app » action. The host hides its panel while the person taps elements in the app, and the\n * draft comes back as the chip. Absent: no action.\n */\n readonly point?: () => void;\n /** Feedback mode is on: the button shows pressed. */\n readonly active?: boolean;\n /** Feedback is waiting to be sent with the next message: Send is allowed with no text. */\n readonly pending?: boolean;\n /** The pending feedback's chip, shown with the other chips. */\n readonly chip?: ReactNode;\n /** Feedback mode's controls while it is on, docked under the composer's row. */\n readonly bar?: ReactNode;\n /** Send: the text to send instead, with the feedback attached, or why it cannot be sent yet. */\n readonly attach?: (text: string) => Promise<{ readonly kind: 'ok'; readonly text: string } | { readonly kind: 'refused'; readonly reason: string }>;\n /** The message carrying the feedback was accepted. */\n readonly sent?: () => void;\n}\n\n/** One file being uploaded (or that failed to upload), shown as a chip until it settles or is dismissed. */\nexport interface PendingUpload { readonly id: string; readonly name: string; readonly state: 'uploading' | 'failed'; readonly error?: string }\n\nexport interface ComposerProps {\n /** `/` menu of commands and skills. Absent: no menu and no keyboard trigger. */\n readonly slash?: SlashConfig | undefined;\n /** Reports a failing command. */\n readonly onSlashError?: ((cause: unknown) => void) | undefined;\n /** `@` file picker. Absent: no picker. */\n readonly mentions?: MentionsConfig | undefined;\n /** Paths picked or uploaded as mentions; each shows as a chip while its `@path` is still in the text. */\n readonly mentionPaths?: readonly string[];\n readonly onMentionPicked?: ((path: string) => void) | undefined;\n readonly uploads?: readonly PendingUpload[];\n readonly onDismissUpload?: ((id: string) => void) | undefined;\n /** Extra controls in the bar (the model and effort pill) and a short note beside them. */\n readonly barStart?: ReactNode;\n readonly barNote?: ReactNode;\n readonly text: string;\n readonly onText: (text: string) => void;\n readonly attachments: readonly ComposerAttachment[];\n readonly onRemoveAttachment: (id: string) => void;\n /** The agent is working: the primary button stops it and Enter queues the message (steering is chosen on the queued message). */\n readonly working: boolean;\n /** A send would be refused right now (not connected, a submission in flight, uploading). */\n readonly sendBlocked: boolean;\n readonly disabled: boolean;\n readonly stopRequested: boolean;\n readonly onSend: () => void;\n readonly onStop: () => void;\n readonly uploading: boolean;\n readonly canAttach: boolean;\n readonly fileAccept: string;\n readonly onPickFiles: (files: readonly File[]) => void;\n readonly onKeyDown?: ((event: KeyboardEvent<HTMLTextAreaElement>) => void) | undefined;\n readonly textareaRef: Ref<HTMLTextAreaElement>;\n readonly placeholder?: string;\n /** Both layouts have the same controls (the + menu, `/` and `@` menus, the model and effort pill, Send/Stop). `inline` puts them in one compact row; `stacked` is the two-row chat composer. */\n readonly layout?: 'stacked' | 'inline';\n /** Host controls (for example a voice button) as round buttons between the model pill and the send button. */\n readonly barEnd?: ReactNode;\n /** The optional Feedback button and chip. */\n readonly feedback?: ComposerFeedback | undefined;\n}\n\n/** Round bar buttons share one look: a tinted circle, 44px so a thumb can hit it. */\nconst ROUND = 'inline-flex size-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-muted text-foreground transition-colors outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/60 disabled:pointer-events-none disabled:opacity-45 motion-reduce:transition-none';\n\n/** A bar button with a word: the same tint and 44px height as ROUND. */\nconst PILL = 'inline-flex h-11 shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-full bg-muted px-3.5 text-sm font-medium text-foreground transition-colors outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/60 disabled:pointer-events-none disabled:opacity-45 motion-reduce:transition-none';\n\n/** The Feedback button (a round bar button beside the \"+\" menu in both layouts) and « Show in app », each when the host offers it. */\nfunction FeedbackButton({ feedback, disabled }: { readonly feedback: ComposerFeedback; readonly disabled: boolean }) {\n const { labels } = useChatText();\n const busy = disabled || feedback.active === true || feedback.pending === true;\n const { start, point } = feedback;\n return <>\n {start && <button type=\"button\" data-testid=\"composer-feedback\" aria-pressed={feedback.active === true} disabled={busy}\n title={labels.feedbackHint} onClick={() => start()}\n className={cn(ROUND, feedback.active && 'bg-accent')}>\n <MessageSquareIcon className=\"size-4\" aria-hidden=\"true\" /><span className=\"sr-only\">{labels.feedback}</span>\n </button>}\n {point && <button type=\"button\" data-testid=\"composer-point\" disabled={busy} title={labels.pointHint} onClick={() => point()}\n className={PILL}>\n <MousePointerClickIcon className=\"size-4\" aria-hidden=\"true\" /><span className=\"whitespace-nowrap\">{labels.point}</span>\n </button>}\n </>;\n}\n\n/** The \"+\" button and its small menu (Attach files, Commands). Arrow keys move, Enter picks, Escape and outside clicks close. */\nfunction PlusMenu({ disabled, onAttach, onCommands }: { readonly disabled: boolean; readonly onAttach?: (() => void) | undefined; readonly onCommands?: (() => void) | undefined }) {\n const { labels, icons } = useChatText();\n const [open, setOpen] = useState(false);\n const root = useRef<HTMLDivElement>(null), trigger = useRef<HTMLButtonElement>(null);\n useEffect(() => {\n if (!open) return;\n const outside = (event: MouseEvent) => { if (root.current && !root.current.contains(event.target as Node)) setOpen(false); };\n document.addEventListener('mousedown', outside);\n return () => document.removeEventListener('mousedown', outside);\n }, [open]);\n useEffect(() => { if (disabled) setOpen(false); }, [disabled]);\n const entries = [onAttach && { testid: 'composer-attach', label: labels.attachFiles, icon: <icons.attach className=\"size-4 text-muted-foreground\" aria-hidden=\"true\" />, run: onAttach },\n onCommands && { testid: 'composer-commands', label: labels.commands, icon: <SlashIcon className=\"size-4 text-muted-foreground\" aria-hidden=\"true\" />, run: onCommands }].filter(entry => entry);\n const onKeyDown = (event: KeyboardEvent) => {\n if (!open) return;\n const items = Array.from(root.current?.querySelectorAll<HTMLElement>('[role=menuitem]') ?? []);\n const at = items.indexOf(document.activeElement as HTMLElement);\n if (event.key === 'ArrowDown') { event.preventDefault(); items[(at + 1) % items.length]?.focus(); }\n else if (event.key === 'ArrowUp') { event.preventDefault(); items[(at - 1 + items.length) % items.length]?.focus(); }\n else if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); setOpen(false); trigger.current?.focus(); }\n };\n return <div ref={root} className=\"relative shrink-0\" onKeyDown={onKeyDown}>\n <button ref={trigger} type=\"button\" data-testid=\"composer-plus\" aria-label={labels.add} title={labels.add} aria-haspopup=\"menu\" aria-expanded={open} disabled={disabled} onClick={() => setOpen(value => !value)} className={ROUND}>\n <icons.add className=\"size-5\" aria-hidden=\"true\" /></button>\n {open && <div role=\"menu\" aria-label={labels.add} data-testid=\"composer-plus-menu\" className=\"absolute bottom-full left-0 z-30 mb-2 w-52 rounded-2xl border border-border bg-popover p-1.5 text-popover-foreground shadow-lg\">\n {entries.map((entry, index) => entry && <button key={entry.testid} type=\"button\" role=\"menuitem\" data-testid={entry.testid} autoFocus={index === 0}\n onClick={() => { setOpen(false); entry.run(); }}\n className=\"flex min-h-11 w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-sm text-foreground outline-none hover:bg-accent focus-visible:bg-accent\">{entry.icon}{entry.label}</button>)}\n </div>}\n </div>;\n}\n\nconst MAX_HEIGHT = 208;\n\n/** Prompt composer: auto-growing textarea, Enter to send, one primary Send/Stop button. While the agent works a message is queued. */\nexport function Composer(props: ComposerProps) {\n const { text, attachments, working, sendBlocked, disabled, stopRequested, uploading, canAttach } = props;\n const { labels, icons } = useChatText();\n const inner = useRef<HTMLTextAreaElement | null>(null);\n const composing = useRef(false);\n const [dragging, setDragging] = useState(false);\n const [caret, setCaret] = useState(0);\n // A menu closed with Escape stays closed until the text changes.\n const [dismissed, setDismissed] = useState<string | null>(null);\n useEffect(() => { setDismissed(value => value === null || value === text ? value : null); }, [text]);\n const pendingCaret = useRef<number | null>(null);\n const fileInput = useRef<HTMLInputElement>(null);\n const { slash, mentions } = props;\n const items = useMemo<SlashItem[]>(() => slash ? slashItems(slash) : [], [slash]);\n const setRef = (element: HTMLTextAreaElement | null) => {\n inner.current = element;\n if (typeof props.textareaRef === 'function') props.textareaRef(element); else if (props.textareaRef) (props.textareaRef as { current: HTMLTextAreaElement | null }).current = element;\n };\n useLayoutEffect(() => {\n const element = inner.current;\n if (!element) return;\n element.style.height = 'auto';\n element.style.height = `${Math.min(element.scrollHeight, MAX_HEIGHT)}px`;\n element.style.overflowY = element.scrollHeight > MAX_HEIGHT ? 'auto' : 'hidden';\n if (pendingCaret.current !== null) { element.setSelectionRange(pendingCaret.current, pendingCaret.current); element.focus(); pendingCaret.current = null; }\n }, [text]);\n\n const query = slash && items.length ? slashQuery(text, caret) : null;\n const trigger = query === null && mentions ? mentionTrigger(text, caret) : null;\n const slashOpen = query !== null && dismissed !== text, mentionOpen = trigger !== null && dismissed !== text;\n const menuOpen = slashOpen || mentionOpen;\n const edit = (next: string, position: number) => { pendingCaret.current = position; setCaret(position); props.onText(next); };\n const trackCaret = (element: HTMLTextAreaElement) => setCaret(element.selectionStart ?? element.value.length);\n const dismiss = () => setDismissed(text);\n const pickSlash = (item: SlashItem) => {\n const rest = text.slice(caret).replace(/^\\s+/, '');\n if (item.source === 'skill') return edit(`/${item.name} ${rest}`, item.name.length + 2);\n const command = slash?.commands?.find(candidate => candidate.name === item.name);\n props.onText(rest); setCaret(0);\n inner.current?.focus();\n if (!command) return;\n try { void Promise.resolve(command.run({ text: rest, setText: value => props.onText(value) })).catch(cause => props.onSlashError?.(cause)); }\n catch (cause) { props.onSlashError?.(cause); }\n };\n const pickMention = (path: string) => {\n if (!trigger) return;\n const token = `${mentionToken(path)} `;\n edit(`${text.slice(0, trigger.start)}${token}${text.slice(trigger.end)}`, trigger.start + token.length);\n props.onMentionPicked?.(path);\n };\n const openCommands = () => edit(`${text}${text && !/\\s$/.test(text) ? ' ' : ''}/`, text.length + (text && !/\\s$/.test(text) ? 2 : 1));\n const chips = (props.mentionPaths ?? []).filter(path => hasMention(text, path));\n const onChange = (event: ChangeEvent<HTMLTextAreaElement>) => { trackCaret(event.currentTarget); props.onText(event.currentTarget.value); };\n\n const { feedback } = props;\n const hasContent = text.trim().length > 0 || attachments.length > 0 || feedback?.pending === true;\n const canSubmit = hasContent && !sendBlocked && !disabled;\n const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {\n props.onKeyDown?.(event);\n if (event.defaultPrevented) return;\n // Never send while an input method editor is composing (Enter confirms the candidate).\n // A phone keyboard has no Shift+Enter: on a touch device Enter inserts a newline and the Send button sends.\n const touch = globalThis.matchMedia?.('(pointer: coarse)').matches === true;\n if (event.key === 'Enter' && !touch && !event.shiftKey && !composing.current && !event.nativeEvent.isComposing && event.keyCode !== 229) {\n event.preventDefault();\n if (canSubmit) props.onSend();\n }\n };\n const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {\n if (canAttach && event.clipboardData.files.length) { event.preventDefault(); props.onPickFiles(Array.from(event.clipboardData.files)); }\n };\n const drag = (event: DragEvent) => { if (canAttach) { event.preventDefault(); setDragging(event.type === 'dragover'); } };\n const drop = (event: DragEvent) => { if (!canAttach) return; event.preventDefault(); setDragging(false); props.onPickFiles(Array.from(event.dataTransfer.files)); };\n\n const chipRows = <>\n {(chips.length > 0 || (props.uploads?.length ?? 0) > 0 || Boolean(feedback?.chip)) && <div className=\"flex flex-wrap gap-2 px-3 pt-3\">\n {feedback?.chip}\n {chips.length > 0 && <ul data-testid=\"mention-chips\" aria-label={labels.mentionedFiles} className=\"m-0 contents list-none p-0\">\n {chips.map(path => <li key={path} data-testid=\"mention-chip\" data-path={path} title={path} className=\"flex h-9 max-w-full sm:max-w-[16rem] items-center gap-1.5 rounded-full border border-border bg-muted/50 py-1 pr-1 pl-2.5\">\n <FileTextIcon className=\"size-3.5 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <span className=\"min-w-0 flex-1 truncate text-xs font-medium\">{path}</span>\n <button type=\"button\" aria-label={labels.remove(path)} onClick={() => props.onText(removeMention(text, path))}\n className=\"flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground relative after:absolute after:-inset-3 after:content-[''] transition-colors outline-none hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60\"><XIcon className=\"size-3\" aria-hidden=\"true\" /></button>\n </li>)}\n </ul>}\n {(props.uploads?.length ?? 0) > 0 && <ul data-testid=\"uploads\" aria-label={labels.uploads} className=\"m-0 contents list-none p-0\">\n {props.uploads!.map(item => <li key={item.id} data-testid=\"upload-chip\" data-state={item.state} title={item.error ?? item.name}\n className={cn('flex h-9 max-w-full sm:max-w-[20rem] items-center gap-1.5 rounded-full border py-1 pr-1 pl-2.5', item.state === 'failed' ? 'border-destructive/40 bg-destructive/10' : 'border-border bg-muted/50')}>\n {item.state === 'failed' ? <AlertCircleIcon className=\"size-3.5 shrink-0 text-destructive\" aria-hidden=\"true\" />\n : <Loader2Icon className=\"size-3.5 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none\" aria-hidden=\"true\" />}\n <span className=\"min-w-0 flex-1 truncate text-xs font-medium\">{item.state === 'failed' ? labels.uploadFailed(item.name, item.error) : labels.uploading(item.name)}</span>\n {item.state === 'failed' && <button type=\"button\" aria-label={labels.dismiss(item.name)} onClick={() => props.onDismissUpload?.(item.id)}\n className=\"flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground relative after:absolute after:-inset-3 after:content-[''] transition-colors outline-none hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60\"><XIcon className=\"size-3\" aria-hidden=\"true\" /></button>}\n </li>)}\n </ul>}\n </div>}\n </>;\n const attachmentRow = attachments.length > 0 && <ul data-testid=\"attachments\" aria-label={labels.attachments} className=\"m-0 flex list-none flex-wrap gap-2 px-3 pt-3\">\n {attachments.map(attachment => {\n const src = thumbnail(attachment.mimeType, attachment.data);\n return <li key={attachment.id} data-testid=\"attachment\" className=\"flex h-9 max-w-full sm:max-w-[14rem] items-center gap-2 rounded-full border border-border bg-muted/50 py-1 pr-1 pl-1\">\n {src ? <img src={src} alt=\"\" className=\"size-7 shrink-0 rounded-full object-cover\" /> : <span className=\"size-7 shrink-0 rounded-full bg-muted\" />}\n <span className=\"min-w-0 flex-1 truncate text-xs font-medium\">{attachment.name}</span>\n <button type=\"button\" aria-label={labels.remove(attachment.name)} onClick={() => props.onRemoveAttachment(attachment.id)}\n className=\"flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground relative after:absolute after:-inset-3 after:content-[''] transition-colors outline-none hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60\"><XIcon className=\"size-3\" aria-hidden=\"true\" /></button>\n </li>;\n })}\n </ul>;\n const textareaEl = <textarea ref={setRef} data-testid=\"composer-input\" aria-label={labels.messageInput} rows={1} value={text} disabled={disabled}\n placeholder={props.placeholder ?? (working ? labels.placeholderWorking : labels.placeholder)}\n {...(slash || mentions ? { role: 'combobox', 'aria-expanded': menuOpen, 'aria-haspopup': 'listbox', 'aria-autocomplete': 'list' } as const : {})}\n onChange={onChange} onKeyDown={onKeyDown} onPaste={onPaste} onKeyUp={event => trackCaret(event.currentTarget)} onClick={event => trackCaret(event.currentTarget)}\n onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}\n className={props.layout === 'inline'\n ? 'block max-h-40 min-h-11 min-w-0 flex-1 resize-none order-first basis-full border-0 bg-transparent px-1.5 py-2.5 text-base leading-6 text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed sm:text-sm'\n : 'block max-h-52 min-h-[3.5rem] w-full resize-none border-0 bg-transparent px-5 pt-4 pb-2 text-base leading-6 sm:text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed'} />;\n const plus = (canAttach || items.length > 0) && <PlusMenu disabled={disabled} onAttach={canAttach ? () => fileInput.current?.click() : undefined} onCommands={items.length > 0 ? openCommands : undefined} />;\n const fileEl = canAttach && <input ref={fileInput} type=\"file\" multiple accept={props.fileAccept} disabled={disabled} tabIndex={-1} aria-hidden=\"true\" className=\"sr-only\" data-testid=\"composer-file\" data-attach=\"true\"\n onChange={event => { const files = Array.from(event.currentTarget.files ?? []); event.currentTarget.value = ''; props.onPickFiles(files); }} />;\n const send = working\n ? <Button variant=\"default\" size=\"icon\" data-testid=\"composer-submit\" data-state=\"stop\" aria-label={labels.stop} title={labels.stop} disabled={disabled || stopRequested} onClick={props.onStop} className=\"ml-auto size-11 shrink-0\">\n {stopRequested ? <Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" /> : <icons.stop className=\"size-4 fill-current\" aria-hidden=\"true\" />}</Button>\n : <Button variant=\"default\" size=\"icon\" type=\"submit\" data-testid=\"composer-submit\" data-state=\"send\" aria-label={labels.send} title={labels.sendHint} disabled={!canSubmit} className=\"ml-auto size-11 shrink-0 disabled:bg-muted disabled:text-muted-foreground disabled:opacity-100\">\n <icons.send className=\"size-5\" aria-hidden=\"true\" /></Button>;\n\n if (props.layout === 'inline') return <form data-testid=\"composer\" data-layout=\"inline\" onSubmit={event => { event.preventDefault(); if (canSubmit) props.onSend(); }} onDragOver={drag} onDragLeave={drag} onDrop={drop}\n className={cn('relative bg-card', dragging && 'rounded-2xl ring-2 ring-ring/40', disabled && 'opacity-70')}>\n {slashOpen && <SlashMenu query={query!} items={items} onSelect={pickSlash} onDismiss={dismiss} />}\n {mentionOpen && mentions && <MentionMenu query={trigger!.query} search={mentions.search} generation={mentions.generation} onSelect={pickMention} onDismiss={dismiss} />}\n {chipRows}\n {attachmentRow}\n <div className=\"flex flex-wrap items-center gap-1.5 px-2.5 py-2\">\n {fileEl}\n {plus}\n {feedback && <FeedbackButton feedback={feedback} disabled={disabled} />}\n {textareaEl}\n {props.barNote}\n {props.barStart}\n {props.barEnd}\n {uploading && !(props.uploads?.length) && <Loader2Icon role=\"status\" aria-label={labels.preparingAttachmentsShort} className=\"size-3.5 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none\" />}\n {working && hasContent && <Button variant=\"secondary\" size=\"sm\" data-testid=\"composer-queue\" disabled={!canSubmit} onClick={props.onSend} className=\"h-11 shrink-0 rounded-full px-4\"><icons.queue className=\"size-3.5\" aria-hidden=\"true\" />{labels.queue}</Button>}\n {send}\n </div>\n {feedback?.bar}\n </form>;\n\n return <form data-testid=\"composer\" onSubmit={event => { event.preventDefault(); if (canSubmit) props.onSend(); }} onDragOver={drag} onDragLeave={drag} onDrop={drop}\n className={cn('relative rounded-3xl border border-border bg-card shadow-sm transition-[border-color,box-shadow] focus-within:border-ring/60 focus-within:ring-[3px] focus-within:ring-ring/15 motion-reduce:transition-none',\n dragging && 'border-ring ring-[3px] ring-ring/20', disabled && 'opacity-70')}>\n {slashOpen && <SlashMenu query={query!} items={items} onSelect={pickSlash} onDismiss={dismiss} />}\n {mentionOpen && mentions && <MentionMenu query={trigger!.query} search={mentions.search} generation={mentions.generation} onSelect={pickMention} onDismiss={dismiss} />}\n {chipRows}\n {attachmentRow}\n {textareaEl}\n <div className=\"flex flex-nowrap items-center gap-x-2 px-3 pt-1 pb-3\">\n {fileEl}\n {plus}\n {feedback && <FeedbackButton feedback={feedback} disabled={disabled} />}\n {props.barStart}\n {props.barNote}\n {uploading && !(props.uploads?.length) && <span role=\"status\" className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"><Loader2Icon className=\"size-3 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{labels.preparingAttachments}</span>}\n <span className=\"flex-1\" />\n {props.barEnd}\n {working && hasContent && <Button variant=\"secondary\" size=\"sm\" data-testid=\"composer-queue\" disabled={!canSubmit} onClick={props.onSend} className=\"h-11 shrink-0 rounded-full px-4\">\n <icons.queue className=\"size-3.5\" aria-hidden=\"true\" />{labels.queue}</Button>}\n {send}\n </div>\n {feedback?.bar}\n </form>;\n}\n",
58
+ "content": "'use client';\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ChangeEvent, ClipboardEvent, DragEvent, KeyboardEvent, ReactNode, Ref } from 'react';\nimport { AlertCircleIcon, FileTextIcon, Loader2Icon, MessageSquarePlusIcon, SlashIcon, XIcon } from 'lucide-react';\nimport { Button } from '../button/button';\nimport { hasMention, mentionToken, mentionTrigger, removeMention, slashItems, slashQuery } from './config';\nimport type { MentionsConfig, SlashConfig, SlashItem } from './config';\nimport { MentionMenu } from './mention-menu';\nimport { SlashMenu } from './slash-menu';\nimport { useChatText } from './labels';\nimport { cn } from '../utils/utils';\n\nexport interface ComposerAttachment { readonly id: string; readonly name: string; readonly mimeType: string; readonly data: string }\n\nconst SAFE_IMAGE = /^image\\/(png|jpe?g|gif|webp)$/;\nconst BASE64 = /^[A-Za-z0-9+/=\\s]+$/;\n/** A data URL for a thumbnail, only for common image types and well-formed base64. */\nexport function thumbnail(mimeType: string, data: string): string | undefined {\n return SAFE_IMAGE.test(mimeType) && BASE64.test(data) ? `data:${mimeType};base64,${data}` : undefined;\n}\n\n/**\n * The optional annotate toggle (FEEDBACK.md, \"UX\"): one button in the composer's row that arms element targeting on the host's page,\n * and one count chip for the notes taken. Send attaches them. The host owns everything else (the registry `feedback` item's\n * `useComposerFeedback` returns this shape). Absent: the composer is unchanged.\n */\nexport interface ComposerFeedback {\n /** The annotate toggle: arms targeting on the page (point mode on touch and compact screens), or stops it when it is on. */\n readonly annotate: () => void;\n /** Annotating is on: the toggle shows pressed. */\n readonly active: boolean;\n /** Notes taken and waiting to be sent with the next message; above zero, Send is allowed with no text. */\n readonly count: number;\n /** Removes the notes (the count chip's remove button). */\n readonly clear: () => void;\n /** Send: the text to send instead, with the notes attached, or why they cannot be sent yet. */\n readonly attach: (text: string) => Promise<{ readonly kind: 'ok'; readonly text: string } | { readonly kind: 'refused'; readonly reason: string }>;\n /** The message carrying the notes was accepted. */\n readonly sent: () => void;\n}\n\n/** One file being uploaded (or that failed to upload), shown as a chip until it settles or is dismissed. */\nexport interface PendingUpload { readonly id: string; readonly name: string; readonly state: 'uploading' | 'failed'; readonly error?: string }\n\nexport interface ComposerProps {\n /** `/` menu of commands and skills. Absent: no menu and no keyboard trigger. */\n readonly slash?: SlashConfig | undefined;\n /** Reports a failing command. */\n readonly onSlashError?: ((cause: unknown) => void) | undefined;\n /** `@` file picker. Absent: no picker. */\n readonly mentions?: MentionsConfig | undefined;\n /** Paths picked or uploaded as mentions; each shows as a chip while its `@path` is still in the text. */\n readonly mentionPaths?: readonly string[];\n readonly onMentionPicked?: ((path: string) => void) | undefined;\n readonly uploads?: readonly PendingUpload[];\n readonly onDismissUpload?: ((id: string) => void) | undefined;\n /** Extra controls in the bar (the model and effort pill) and a short note beside them. */\n readonly barStart?: ReactNode;\n readonly barNote?: ReactNode;\n readonly text: string;\n readonly onText: (text: string) => void;\n readonly attachments: readonly ComposerAttachment[];\n readonly onRemoveAttachment: (id: string) => void;\n /** The agent is working: the primary button stops it and Enter queues the message (steering is chosen on the queued message). */\n readonly working: boolean;\n /** A send would be refused right now (not connected, a submission in flight, uploading). */\n readonly sendBlocked: boolean;\n readonly disabled: boolean;\n readonly stopRequested: boolean;\n readonly onSend: () => void;\n readonly onStop: () => void;\n readonly uploading: boolean;\n readonly canAttach: boolean;\n readonly fileAccept: string;\n readonly onPickFiles: (files: readonly File[]) => void;\n readonly onKeyDown?: ((event: KeyboardEvent<HTMLTextAreaElement>) => void) | undefined;\n readonly textareaRef: Ref<HTMLTextAreaElement>;\n readonly placeholder?: string;\n /** Both layouts have the same controls (the + menu, `/` and `@` menus, the model and effort pill, Send/Stop). `inline` puts them in one compact row; `stacked` is the two-row chat composer. */\n readonly layout?: 'stacked' | 'inline';\n /** Host controls (for example a voice button) as round buttons between the model pill and the send button. */\n readonly barEnd?: ReactNode;\n /** The optional Feedback button and chip. */\n readonly feedback?: ComposerFeedback | undefined;\n}\n\n/** Round bar buttons share one look: a tinted circle, 44px so a thumb can hit it. */\nconst ROUND = 'inline-flex size-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-muted text-foreground transition-colors outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/60 disabled:pointer-events-none disabled:opacity-45 motion-reduce:transition-none';\n\n/** The annotate toggle (a round bar button beside the \"+\" menu in both layouts). */\nfunction AnnotateButton({ feedback, disabled }: { readonly feedback: ComposerFeedback; readonly disabled: boolean }) {\n const { labels } = useChatText();\n return <button type=\"button\" data-testid=\"composer-annotate\" aria-pressed={feedback.active} disabled={disabled}\n title={labels.annotateHint} onClick={() => feedback.annotate()}\n className={cn(ROUND, feedback.active && 'bg-accent')}>\n <MessageSquarePlusIcon className=\"size-4\" aria-hidden=\"true\" /><span className=\"sr-only\">{labels.annotate}</span>\n </button>;\n}\n\n/** The count chip: how many notes go with the next message, and a way to drop them. */\nfunction NotesChip({ feedback }: { readonly feedback: ComposerFeedback }) {\n const { labels } = useChatText();\n return <span data-testid=\"feedback-count\" data-count={feedback.count} className=\"flex h-9 items-center gap-1.5 rounded-full border border-border bg-muted/50 py-1 pr-1 pl-2.5\">\n <MessageSquarePlusIcon className=\"size-3.5 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <span className=\"text-xs font-medium\">{labels.annotateNotes(feedback.count)}</span>\n <button type=\"button\" data-testid=\"feedback-count-clear\" aria-label={labels.annotateClear} title={labels.annotateClear} onClick={() => feedback.clear()}\n className=\"flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground relative after:absolute after:-inset-3 after:content-[''] transition-colors outline-none hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60\"><XIcon className=\"size-3\" aria-hidden=\"true\" /></button>\n </span>;\n}\n\n/** The \"+\" button and its small menu (Attach files, Commands). Arrow keys move, Enter picks, Escape and outside clicks close. */\nfunction PlusMenu({ disabled, onAttach, onCommands }: { readonly disabled: boolean; readonly onAttach?: (() => void) | undefined; readonly onCommands?: (() => void) | undefined }) {\n const { labels, icons } = useChatText();\n const [open, setOpen] = useState(false);\n const root = useRef<HTMLDivElement>(null), trigger = useRef<HTMLButtonElement>(null);\n useEffect(() => {\n if (!open) return;\n const outside = (event: MouseEvent) => { if (root.current && !root.current.contains(event.target as Node)) setOpen(false); };\n document.addEventListener('mousedown', outside);\n return () => document.removeEventListener('mousedown', outside);\n }, [open]);\n useEffect(() => { if (disabled) setOpen(false); }, [disabled]);\n const entries = [onAttach && { testid: 'composer-attach', label: labels.attachFiles, icon: <icons.attach className=\"size-4 text-muted-foreground\" aria-hidden=\"true\" />, run: onAttach },\n onCommands && { testid: 'composer-commands', label: labels.commands, icon: <SlashIcon className=\"size-4 text-muted-foreground\" aria-hidden=\"true\" />, run: onCommands }].filter(entry => entry);\n const onKeyDown = (event: KeyboardEvent) => {\n if (!open) return;\n const items = Array.from(root.current?.querySelectorAll<HTMLElement>('[role=menuitem]') ?? []);\n const at = items.indexOf(document.activeElement as HTMLElement);\n if (event.key === 'ArrowDown') { event.preventDefault(); items[(at + 1) % items.length]?.focus(); }\n else if (event.key === 'ArrowUp') { event.preventDefault(); items[(at - 1 + items.length) % items.length]?.focus(); }\n else if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); setOpen(false); trigger.current?.focus(); }\n };\n return <div ref={root} className=\"relative shrink-0\" onKeyDown={onKeyDown}>\n <button ref={trigger} type=\"button\" data-testid=\"composer-plus\" aria-label={labels.add} title={labels.add} aria-haspopup=\"menu\" aria-expanded={open} disabled={disabled} onClick={() => setOpen(value => !value)} className={ROUND}>\n <icons.add className=\"size-5\" aria-hidden=\"true\" /></button>\n {open && <div role=\"menu\" aria-label={labels.add} data-testid=\"composer-plus-menu\" className=\"absolute bottom-full left-0 z-30 mb-2 w-52 rounded-2xl border border-border bg-popover p-1.5 text-popover-foreground shadow-lg\">\n {entries.map((entry, index) => entry && <button key={entry.testid} type=\"button\" role=\"menuitem\" data-testid={entry.testid} autoFocus={index === 0}\n onClick={() => { setOpen(false); entry.run(); }}\n className=\"flex min-h-11 w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-sm text-foreground outline-none hover:bg-accent focus-visible:bg-accent\">{entry.icon}{entry.label}</button>)}\n </div>}\n </div>;\n}\n\nconst MAX_HEIGHT = 208;\n\n/** Prompt composer: auto-growing textarea, Enter to send, one primary Send/Stop button. While the agent works a message is queued. */\nexport function Composer(props: ComposerProps) {\n const { text, attachments, working, sendBlocked, disabled, stopRequested, uploading, canAttach } = props;\n const { labels, icons } = useChatText();\n const inner = useRef<HTMLTextAreaElement | null>(null);\n const composing = useRef(false);\n const [dragging, setDragging] = useState(false);\n const [caret, setCaret] = useState(0);\n // A menu closed with Escape stays closed until the text changes.\n const [dismissed, setDismissed] = useState<string | null>(null);\n useEffect(() => { setDismissed(value => value === null || value === text ? value : null); }, [text]);\n const pendingCaret = useRef<number | null>(null);\n const fileInput = useRef<HTMLInputElement>(null);\n const { slash, mentions } = props;\n const items = useMemo<SlashItem[]>(() => slash ? slashItems(slash) : [], [slash]);\n const setRef = (element: HTMLTextAreaElement | null) => {\n inner.current = element;\n if (typeof props.textareaRef === 'function') props.textareaRef(element); else if (props.textareaRef) (props.textareaRef as { current: HTMLTextAreaElement | null }).current = element;\n };\n useLayoutEffect(() => {\n const element = inner.current;\n if (!element) return;\n element.style.height = 'auto';\n element.style.height = `${Math.min(element.scrollHeight, MAX_HEIGHT)}px`;\n element.style.overflowY = element.scrollHeight > MAX_HEIGHT ? 'auto' : 'hidden';\n if (pendingCaret.current !== null) { element.setSelectionRange(pendingCaret.current, pendingCaret.current); element.focus(); pendingCaret.current = null; }\n }, [text]);\n\n const query = slash && items.length ? slashQuery(text, caret) : null;\n const trigger = query === null && mentions ? mentionTrigger(text, caret) : null;\n const slashOpen = query !== null && dismissed !== text, mentionOpen = trigger !== null && dismissed !== text;\n const menuOpen = slashOpen || mentionOpen;\n const edit = (next: string, position: number) => { pendingCaret.current = position; setCaret(position); props.onText(next); };\n const trackCaret = (element: HTMLTextAreaElement) => setCaret(element.selectionStart ?? element.value.length);\n const dismiss = () => setDismissed(text);\n const pickSlash = (item: SlashItem) => {\n const rest = text.slice(caret).replace(/^\\s+/, '');\n if (item.source === 'skill') return edit(`/${item.name} ${rest}`, item.name.length + 2);\n const command = slash?.commands?.find(candidate => candidate.name === item.name);\n props.onText(rest); setCaret(0);\n inner.current?.focus();\n if (!command) return;\n try { void Promise.resolve(command.run({ text: rest, setText: value => props.onText(value) })).catch(cause => props.onSlashError?.(cause)); }\n catch (cause) { props.onSlashError?.(cause); }\n };\n const pickMention = (path: string) => {\n if (!trigger) return;\n const token = `${mentionToken(path)} `;\n edit(`${text.slice(0, trigger.start)}${token}${text.slice(trigger.end)}`, trigger.start + token.length);\n props.onMentionPicked?.(path);\n };\n const openCommands = () => edit(`${text}${text && !/\\s$/.test(text) ? ' ' : ''}/`, text.length + (text && !/\\s$/.test(text) ? 2 : 1));\n const chips = (props.mentionPaths ?? []).filter(path => hasMention(text, path));\n const onChange = (event: ChangeEvent<HTMLTextAreaElement>) => { trackCaret(event.currentTarget); props.onText(event.currentTarget.value); };\n\n const { feedback } = props;\n const hasContent = text.trim().length > 0 || attachments.length > 0 || (feedback?.count ?? 0) > 0;\n const canSubmit = hasContent && !sendBlocked && !disabled;\n const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {\n props.onKeyDown?.(event);\n if (event.defaultPrevented) return;\n // Never send while an input method editor is composing (Enter confirms the candidate).\n // A phone keyboard has no Shift+Enter: on a touch device Enter inserts a newline and the Send button sends.\n const touch = globalThis.matchMedia?.('(pointer: coarse)').matches === true;\n if (event.key === 'Enter' && !touch && !event.shiftKey && !composing.current && !event.nativeEvent.isComposing && event.keyCode !== 229) {\n event.preventDefault();\n if (canSubmit) props.onSend();\n }\n };\n const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {\n if (canAttach && event.clipboardData.files.length) { event.preventDefault(); props.onPickFiles(Array.from(event.clipboardData.files)); }\n };\n const drag = (event: DragEvent) => { if (canAttach) { event.preventDefault(); setDragging(event.type === 'dragover'); } };\n const drop = (event: DragEvent) => { if (!canAttach) return; event.preventDefault(); setDragging(false); props.onPickFiles(Array.from(event.dataTransfer.files)); };\n\n const chipRows = <>\n {(chips.length > 0 || (props.uploads?.length ?? 0) > 0 || (feedback?.count ?? 0) > 0) && <div className=\"flex flex-wrap gap-2 px-3 pt-3\">\n {feedback && feedback.count > 0 && <NotesChip feedback={feedback} />}\n {chips.length > 0 && <ul data-testid=\"mention-chips\" aria-label={labels.mentionedFiles} className=\"m-0 contents list-none p-0\">\n {chips.map(path => <li key={path} data-testid=\"mention-chip\" data-path={path} title={path} className=\"flex h-9 max-w-full sm:max-w-[16rem] items-center gap-1.5 rounded-full border border-border bg-muted/50 py-1 pr-1 pl-2.5\">\n <FileTextIcon className=\"size-3.5 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <span className=\"min-w-0 flex-1 truncate text-xs font-medium\">{path}</span>\n <button type=\"button\" aria-label={labels.remove(path)} onClick={() => props.onText(removeMention(text, path))}\n className=\"flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground relative after:absolute after:-inset-3 after:content-[''] transition-colors outline-none hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60\"><XIcon className=\"size-3\" aria-hidden=\"true\" /></button>\n </li>)}\n </ul>}\n {(props.uploads?.length ?? 0) > 0 && <ul data-testid=\"uploads\" aria-label={labels.uploads} className=\"m-0 contents list-none p-0\">\n {props.uploads!.map(item => <li key={item.id} data-testid=\"upload-chip\" data-state={item.state} title={item.error ?? item.name}\n className={cn('flex h-9 max-w-full sm:max-w-[20rem] items-center gap-1.5 rounded-full border py-1 pr-1 pl-2.5', item.state === 'failed' ? 'border-destructive/40 bg-destructive/10' : 'border-border bg-muted/50')}>\n {item.state === 'failed' ? <AlertCircleIcon className=\"size-3.5 shrink-0 text-destructive\" aria-hidden=\"true\" />\n : <Loader2Icon className=\"size-3.5 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none\" aria-hidden=\"true\" />}\n <span className=\"min-w-0 flex-1 truncate text-xs font-medium\">{item.state === 'failed' ? labels.uploadFailed(item.name, item.error) : labels.uploading(item.name)}</span>\n {item.state === 'failed' && <button type=\"button\" aria-label={labels.dismiss(item.name)} onClick={() => props.onDismissUpload?.(item.id)}\n className=\"flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground relative after:absolute after:-inset-3 after:content-[''] transition-colors outline-none hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60\"><XIcon className=\"size-3\" aria-hidden=\"true\" /></button>}\n </li>)}\n </ul>}\n </div>}\n </>;\n const attachmentRow = attachments.length > 0 && <ul data-testid=\"attachments\" aria-label={labels.attachments} className=\"m-0 flex list-none flex-wrap gap-2 px-3 pt-3\">\n {attachments.map(attachment => {\n const src = thumbnail(attachment.mimeType, attachment.data);\n return <li key={attachment.id} data-testid=\"attachment\" className=\"flex h-9 max-w-full sm:max-w-[14rem] items-center gap-2 rounded-full border border-border bg-muted/50 py-1 pr-1 pl-1\">\n {src ? <img src={src} alt=\"\" className=\"size-7 shrink-0 rounded-full object-cover\" /> : <span className=\"size-7 shrink-0 rounded-full bg-muted\" />}\n <span className=\"min-w-0 flex-1 truncate text-xs font-medium\">{attachment.name}</span>\n <button type=\"button\" aria-label={labels.remove(attachment.name)} onClick={() => props.onRemoveAttachment(attachment.id)}\n className=\"flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground relative after:absolute after:-inset-3 after:content-[''] transition-colors outline-none hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60\"><XIcon className=\"size-3\" aria-hidden=\"true\" /></button>\n </li>;\n })}\n </ul>;\n const textareaEl = <textarea ref={setRef} data-testid=\"composer-input\" aria-label={labels.messageInput} rows={1} value={text} disabled={disabled}\n placeholder={props.placeholder ?? (working ? labels.placeholderWorking : labels.placeholder)}\n {...(slash || mentions ? { role: 'combobox', 'aria-expanded': menuOpen, 'aria-haspopup': 'listbox', 'aria-autocomplete': 'list' } as const : {})}\n onChange={onChange} onKeyDown={onKeyDown} onPaste={onPaste} onKeyUp={event => trackCaret(event.currentTarget)} onClick={event => trackCaret(event.currentTarget)}\n onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}\n className={props.layout === 'inline'\n ? 'block max-h-40 min-h-11 min-w-0 flex-1 resize-none order-first basis-full border-0 bg-transparent px-1.5 py-2.5 text-base leading-6 text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed sm:text-sm'\n : 'block max-h-52 min-h-[3.5rem] w-full resize-none border-0 bg-transparent px-5 pt-4 pb-2 text-base leading-6 sm:text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed'} />;\n const plus = (canAttach || items.length > 0) && <PlusMenu disabled={disabled} onAttach={canAttach ? () => fileInput.current?.click() : undefined} onCommands={items.length > 0 ? openCommands : undefined} />;\n const fileEl = canAttach && <input ref={fileInput} type=\"file\" multiple accept={props.fileAccept} disabled={disabled} tabIndex={-1} aria-hidden=\"true\" className=\"sr-only\" data-testid=\"composer-file\" data-attach=\"true\"\n onChange={event => { const files = Array.from(event.currentTarget.files ?? []); event.currentTarget.value = ''; props.onPickFiles(files); }} />;\n const send = working\n ? <Button variant=\"default\" size=\"icon\" data-testid=\"composer-submit\" data-state=\"stop\" aria-label={labels.stop} title={labels.stop} disabled={disabled || stopRequested} onClick={props.onStop} className=\"ml-auto size-11 shrink-0\">\n {stopRequested ? <Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" /> : <icons.stop className=\"size-4 fill-current\" aria-hidden=\"true\" />}</Button>\n : <Button variant=\"default\" size=\"icon\" type=\"submit\" data-testid=\"composer-submit\" data-state=\"send\" aria-label={labels.send} title={labels.sendHint} disabled={!canSubmit} className=\"ml-auto size-11 shrink-0 disabled:bg-muted disabled:text-muted-foreground disabled:opacity-100\">\n <icons.send className=\"size-5\" aria-hidden=\"true\" /></Button>;\n\n if (props.layout === 'inline') return <form data-testid=\"composer\" data-layout=\"inline\" onSubmit={event => { event.preventDefault(); if (canSubmit) props.onSend(); }} onDragOver={drag} onDragLeave={drag} onDrop={drop}\n className={cn('relative bg-card', dragging && 'rounded-2xl ring-2 ring-ring/40', disabled && 'opacity-70')}>\n {slashOpen && <SlashMenu query={query!} items={items} onSelect={pickSlash} onDismiss={dismiss} />}\n {mentionOpen && mentions && <MentionMenu query={trigger!.query} search={mentions.search} generation={mentions.generation} onSelect={pickMention} onDismiss={dismiss} />}\n {chipRows}\n {attachmentRow}\n <div className=\"flex flex-wrap items-center gap-1.5 px-2.5 py-2\">\n {fileEl}\n {plus}\n {feedback && <AnnotateButton feedback={feedback} disabled={disabled} />}\n {textareaEl}\n {props.barNote}\n {props.barStart}\n {props.barEnd}\n {uploading && !(props.uploads?.length) && <Loader2Icon role=\"status\" aria-label={labels.preparingAttachmentsShort} className=\"size-3.5 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none\" />}\n {working && hasContent && <Button variant=\"secondary\" size=\"sm\" data-testid=\"composer-queue\" disabled={!canSubmit} onClick={props.onSend} className=\"h-11 shrink-0 rounded-full px-4\"><icons.queue className=\"size-3.5\" aria-hidden=\"true\" />{labels.queue}</Button>}\n {send}\n </div>\n </form>;\n\n return <form data-testid=\"composer\" onSubmit={event => { event.preventDefault(); if (canSubmit) props.onSend(); }} onDragOver={drag} onDragLeave={drag} onDrop={drop}\n className={cn('relative rounded-3xl border border-border bg-card shadow-sm transition-[border-color,box-shadow] focus-within:border-ring/60 focus-within:ring-[3px] focus-within:ring-ring/15 motion-reduce:transition-none',\n dragging && 'border-ring ring-[3px] ring-ring/20', disabled && 'opacity-70')}>\n {slashOpen && <SlashMenu query={query!} items={items} onSelect={pickSlash} onDismiss={dismiss} />}\n {mentionOpen && mentions && <MentionMenu query={trigger!.query} search={mentions.search} generation={mentions.generation} onSelect={pickMention} onDismiss={dismiss} />}\n {chipRows}\n {attachmentRow}\n {textareaEl}\n <div className=\"flex flex-nowrap items-center gap-x-2 px-3 pt-1 pb-3\">\n {fileEl}\n {plus}\n {feedback && <AnnotateButton feedback={feedback} disabled={disabled} />}\n {props.barStart}\n {props.barNote}\n {uploading && !(props.uploads?.length) && <span role=\"status\" className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"><Loader2Icon className=\"size-3 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{labels.preparingAttachments}</span>}\n <span className=\"flex-1\" />\n {props.barEnd}\n {working && hasContent && <Button variant=\"secondary\" size=\"sm\" data-testid=\"composer-queue\" disabled={!canSubmit} onClick={props.onSend} className=\"h-11 shrink-0 rounded-full px-4\">\n <icons.queue className=\"size-3.5\" aria-hidden=\"true\" />{labels.queue}</Button>}\n {send}\n </div>\n </form>;\n}\n",
59
59
  "type": "registry:component",
60
60
  "target": "components/pi-chat/composer.tsx"
61
61
  },
@@ -103,7 +103,7 @@
103
103
  },
104
104
  {
105
105
  "path": "registry/pi-chat/pi-chat.tsx",
106
- "content": "'use client';\n\nimport { useMemo, useRef, useState } from 'react';\nimport type { KeyboardEvent, ReactNode } from 'react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport type { ImageContent, ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { ChatAttachment, NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { Loader2Icon } from 'lucide-react';\nimport { Button } from '../button/button';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { Composer } from './composer';\nimport type { ComposerFeedback } from './composer';\nimport type { AttachmentsConfig, ConversationsConfig, EffortConfig, MentionsConfig, ModelConfig, SlashConfig } from './config';\nimport { EmptyState } from './empty-state';\nimport type { Suggestion } from './empty-state';\nimport { ConversationHistory, HistoryPanel } from './history';\nimport type { CommandMentions } from './markdown';\nimport { RowView } from './message';\nimport { MessageQueue } from './queue';\nimport { ChatNotices, Notice } from './notice';\nimport type { ArtifactsConfig } from './artifact';\nimport type { ChatCard, Mode } from './rows';\nimport { Shimmer } from './shimmer';\nimport { useChatSession, useTranscript } from './session';\nimport type { ChatFeatureProps, FilesHandler, PiChatActions } from './session';\nimport { ChatTextProvider, useChatText, useMergedText } from './labels';\nimport { chatTextFor } from './locale';\nimport type { ChatLocale } from './locale';\nimport type { ChatIcons, ChatLabels } from './labels';\nimport { cn } from '../utils/utils';\n\nexport type { PiChatActions } from './session';\nexport type { ChatCard, Mode } from './rows';\nexport type { AnswerOutcome } from './question-card';\nexport { artifactKey, collectArtifacts, detectArtifact, parseArtifact } from './artifact';\nexport type { ArtifactDescriptor, ArtifactsConfig, ArtifactTarget, ArtifactType } from './artifact';\nexport type { CommandMention, CommandMentions } from './markdown';\nexport type { Suggestion } from './empty-state';\nexport { ConversationList, relativeTime } from './history';\nexport { defaultChatIcons, defaultChatLabels } from './labels';\nexport type { ChatIcons, ChatLabels } from './labels';\nexport { frenchChatLabels } from './labels-fr';\nexport { chatTextFor } from './locale';\nexport type { ChatLocale } from './locale';\nexport { ActionMenu, BlockActions } from '../button/actions';\nexport type { BlockAction, BlockIcon } from '../button/actions';\nexport type { AttachmentsConfig, ConversationItem, ConversationsConfig, EffortConfig, MentionResult, MentionsConfig, ModelConfig, ModelRef, ReplyRef, SlashApi, SlashCommand, SlashConfig, SlashSkill, UploadResult } from './config';\n\nexport interface PiChatProps extends ChatFeatureProps {\n readonly controller: NativeChatController;\n /** Every word the chat shows (title, placeholder, buttons, notices, empty states): a partial object over `defaultChatLabels`. */\n /** The chat's language: `en` (default) or `fr`. `labels` override any of its words. */\n readonly locale?: ChatLocale | undefined;\n readonly labels?: Partial<ChatLabels> | undefined;\n /** The chat's icons (send, stop, history, attach, …): any component taking `className`, over `defaultChatIcons`. */\n readonly icons?: Partial<ChatIcons> | undefined;\n /** The agent's avatar (an image or an icon) before the title in the header, and in the empty state instead of the agent icon. */\n readonly avatar?: ReactNode;\n /** Host actions in the header beside History: `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids `chat-action-<id>`, the menu `chat-action-more`. */\n readonly headerActions?: readonly BlockAction[] | undefined;\n readonly className?: string;\n readonly decisions?: ReactNode;\n readonly controls?: ReactNode;\n /** Host controls at the end of the composer bar (for example a microphone), like AmbientChat's tools slot. */\n readonly tools?: ReactNode;\n /** Rendered before the title in the header (for example a menu button on narrow screens). */\n readonly headerStart?: ReactNode;\n readonly onFiles?: FilesHandler;\n /** Shown instead of the default empty state before the first message. */\n readonly emptyState?: ReactNode;\n readonly suggestions?: readonly Suggestion[];\n /** Past conversations. The header's History button then opens a searchable list with the open one marked and a New action. Omit it and History pages through the earlier records of this conversation. */\n readonly conversations?: ConversationsConfig;\n /**\n * `false`: the host shows `conversations` itself (for example the `pi-app` sessions pane), so History pages through the earlier records of this\n * conversation while replies keep their Fork button (`conversations.fork`). Default `true`: History opens the list.\n */\n readonly historyList?: boolean;\n /** Show the header History button. Default true; paging remains available through the controller. */\n readonly showHistory?: boolean;\n /** Show the header connection badge. Default true; connection state still controls actions. */\n readonly showConnectionStatus?: boolean;\n /** The optional Feedback button in the composer (the registry `feedback` item's `useComposerFeedback`). Omit it and nothing changes. */\n readonly feedback?: ComposerFeedback;\n}\n\nconst PAGE = 60, MORE = 40;\n\n/** Borrows a concrete native controller; unmount releases presentation and uploads only. */\nexport function PiChat(props: PiChatProps) {\n const active = useRef(props.controller); active.current = props.controller;\n const [mount, setMount] = useState({ controller: props.controller, sequence: 0 });\n if (mount.controller !== props.controller) setMount({ controller: props.controller, sequence: mount.sequence + 1 });\n const text = useMergedText(props.labels, props.icons, chatTextFor(props.locale));\n return <ChatTextProvider value={text}><PiChatSession key={mount.sequence} {...props} activeController={active} /></ChatTextProvider>;\n}\n\nfunction ConnectionBadge({ kind }: { readonly kind: string }) {\n const { labels } = useChatText();\n const live = kind === 'connected';\n return <span data-testid=\"connection\" data-state={kind} role=\"status\" className=\"inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-0.5 text-xs text-muted-foreground\">\n <span aria-hidden=\"true\" className={cn('size-1.5 rounded-full', live ? 'bg-(--chat-success)' : 'animate-pulse bg-(--chat-warning) motion-reduce:animate-none')} />\n {live ? labels.live : kind === 'connecting' || kind === 'idle' ? labels.connecting : labels.reconnecting}\n </span>;\n}\n\nfunction PiChatSession({ controller, mode = 'expert', className, actions, avatar, headerActions, decisions, controls, tools, headerStart, renderEntry, renderTool, groupTool, commandMentions,\n onOpenImage, onCopy, onComposerKeyDown, onFiles, fileAccept = 'image/*', emptyState, suggestions, messageActions, replyFooter, slash, mentions, attachments, model, effort, artifacts, conversations, historyList = true, showHistory = true, showConnectionStatus = true, feedback, activeController: active }: PiChatProps & { readonly activeController: { readonly current: NativeChatController } }) {\n const { labels, icons } = useChatText();\n const { title } = labels;\n const [browsingHistory, setBrowsingHistory] = useState(false);\n const [pickingConversation, setPickingConversation] = useState(false);\n const scrollToBottom = useRef<() => void>(() => {});\n // A reply's Fork button calls the host's latest `fork`; the row context changes only when forking is switched on or off.\n const fork = useRef(conversations?.fork); fork.current = conversations?.fork;\n const canFork = Boolean(conversations?.fork);\n const rowExtras = useMemo(() => canFork ? { onFork: (entryId: string) => { void fork.current?.(entryId).catch(() => {}); } } : undefined, [canFork]);\n const session = useChatSession({ controller, activeController: active, mode, actions, renderEntry, renderTool, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, onFiles, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => scrollToBottom.current(), feedback, rowExtras });\n const { state, derived, queued, queueActions, working, waitingForAnswer, connected, error, act, textarea, rowContext, composer, empty, loading, developer } = session;\n const { rows, pinned, retry } = derived;\n const transcript = useTranscript(rows, PAGE, MORE);\n const { stick, hidden } = transcript;\n scrollToBottom.current = () => { void stick.scrollToBottom('instant'); };\n const visible = transcript.visible(rows);\n const { barStart, barNote } = session.pickers();\n const connectionKind = state.connection.kind;\n\n return <section data-boring=\"pi-chat\" aria-label={title} className={cn('pi-chat relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-background text-foreground', className)}>\n <header className=\"flex shrink-0 items-center gap-2 border-b border-border px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5\">\n {headerStart}\n {avatar && <span data-testid=\"chat-avatar\" className=\"flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full [&>img]:size-full [&>img]:object-cover [&>svg]:size-4\">{avatar}</span>}\n <h2 className=\"m-0 min-w-0 flex-1 truncate font-(family-name:--chat-display) text-sm font-semibold tracking-tight\">{title}</h2>\n {showHistory && (conversations && historyList\n ? <Button size=\"sm\" data-testid=\"history-open\" className=\"text-muted-foreground\" aria-haspopup=\"dialog\" aria-expanded={pickingConversation} disabled={state.disposed}\n onClick={() => setPickingConversation(open => !open)}><icons.history className=\"size-3.5\" aria-hidden=\"true\" />{labels.history}</Button>\n : state.history.kind !== 'disabled' && !browsingHistory && <Button size=\"sm\" data-testid=\"history-open\" className=\"text-muted-foreground\" disabled={state.disposed || !connected}\n onClick={() => { setBrowsingHistory(true); act(controller.loadEarlier); }}><icons.history className=\"size-3.5\" aria-hidden=\"true\" />{labels.history}</Button>)}\n <BlockActions actions={headerActions} testId=\"chat-action\" menuLabel={labels.moreActions} />\n {showConnectionStatus && <ConnectionBadge kind={connectionKind} />}\n {controls}\n </header>\n\n {decisions && <div data-boring=\"required-decisions\" className=\"shrink-0 border-b border-border px-4 py-2\">{decisions}</div>}\n {pinned.length > 0 && <aside aria-label={labels.requiredActions} className=\"shrink-0 space-y-2 border-b border-border px-4 py-2\">{pinned.map(card => <div key={card.key}>{card.content}</div>)}</aside>}\n {(connectionKind === 'error' || connectionKind === 'closed') && <div className=\"shrink-0 px-4 pt-2\">\n <Notice tone={connectionKind === 'error' ? 'error' : 'info'} testid=\"connection-notice\">\n <span className=\"flex-1\">{connectionKind === 'error' ? labels.loadFailed : labels.disconnected}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}><icons.reconnect className=\"size-3.5\" aria-hidden=\"true\" />{labels.reconnect}</Button>}\n </Notice></div>}\n {pickingConversation && conversations && historyList && <ConversationHistory conversations={conversations} onClose={() => setPickingConversation(false)}\n onBrowseEarlier={state.history.kind !== 'disabled' && connected && Boolean(state.view?.entries.length) ? () => { setBrowsingHistory(true); act(controller.loadEarlier); } : undefined} />}\n {browsingHistory && state.history.kind !== 'disabled' && <HistoryPanel history={state.history} developer={developer} replyFooter={replyFooter} connected={!state.disposed && connected}\n onLoad={() => act(controller.loadEarlier)} onClose={() => { setBrowsingHistory(false); if (!state.disposed) controller.clearHistory(); }} />}\n\n <div className=\"relative min-h-0 flex-1\">\n <div ref={stick.scrollRef} data-testid=\"transcript-scroll\" className=\"h-full overflow-x-hidden overflow-y-auto overscroll-contain\">\n <div ref={stick.contentRef} role=\"log\" aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\" className=\"mx-auto flex min-h-full w-full max-w-3xl flex-col gap-6 px-3 pt-6 pb-8 sm:px-4\">\n {hidden > 0 && <div className=\"flex justify-center\"><Button size=\"sm\" variant=\"outline\" data-testid=\"show-earlier\" className=\"text-muted-foreground\" onClick={transcript.reveal}>{labels.showEarlier}</Button></div>}\n {visible.map(row => <RowView key={row.key} row={row} context={rowContext} />)}\n </div>\n </div>\n {(empty || loading) && <div className=\"pointer-events-none absolute inset-0\">\n <div className=\"pointer-events-auto h-full\">\n {loading ? <p role=\"status\" data-testid=\"loading\" className=\"flex h-full items-center justify-center gap-2 text-sm text-muted-foreground\"><Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{labels.loading}</p>\n : emptyState ?? <EmptyState title={title} description={labels.emptyDescription} {...(avatar ? { avatar } : {})} {...(suggestions ? { suggestions } : {})} onSelect={text => { controller.setText(text); textarea.current?.focus(); }} />}\n </div></div>}\n {!stick.isAtBottom && <Button variant=\"outline\" size=\"icon\" data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={() => { transcript.reset(); void stick.scrollToBottom(); }}\n className=\"absolute bottom-3 left-1/2 -translate-x-1/2 bg-background/95 shadow-md backdrop-blur\"><icons.jumpToLatest className=\"size-4\" aria-hidden=\"true\" /></Button>}\n </div>\n\n <footer className=\"shrink-0 px-3 pt-1 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-4\">\n <div className=\"mx-auto w-full max-w-3xl space-y-2\">\n <div aria-live=\"polite\" className=\"space-y-2 empty:hidden\">\n {working && <p role=\"status\" data-testid=\"agent-status\" className=\"m-0 flex items-center gap-2 px-1 text-xs text-muted-foreground\"><span aria-hidden=\"true\" className=\"size-1.5 animate-pulse rounded-full bg-(--chat-success) motion-reduce:animate-none\" />\n <Shimmer active={!waitingForAnswer}>{waitingForAnswer ? labels.waitingForAnswer : retry ? labels.retryPending : labels.agentWorking}</Shimmer></p>}\n {derived.compacting && <p role=\"status\" className=\"m-0 px-1 text-xs text-muted-foreground\">{labels.compacting}</p>}\n <ChatNotices state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} />\n </div>\n <div>\n <MessageQueue items={queued} sending={state.outbox} withdraw={actions?.withdraw} actions={queueActions} />\n <Composer {...composer} barStart={barStart} barNote={barNote} barEnd={tools} />\n </div>\n </div>\n </footer>\n </section>;\n}\n",
106
+ "content": "'use client';\n\nimport { useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { Loader2Icon } from 'lucide-react';\nimport { Button } from '../button/button';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { Composer } from './composer';\nimport type { ComposerFeedback } from './composer';\nimport type { ConversationsConfig } from './config';\nimport { EmptyState } from './empty-state';\nimport type { Suggestion } from './empty-state';\nimport { ConversationHistory, HistoryPanel } from './history';\nimport { RowView } from './message';\nimport { MessageQueue } from './queue';\nimport { ChatNotices, Notice } from './notice';\nimport { Shimmer } from './shimmer';\nimport { useChatSession, useTranscript } from './session';\nimport { useChatDraft } from './use-chat-draft';\nimport type { ChatFeatureProps, FilesHandler } from './session';\nimport { ChatTextProvider, useChatText, useMergedText } from './labels';\nimport { chatTextFor } from './locale';\nimport type { ChatLocale } from './locale';\nimport type { ChatIcons, ChatLabels } from './labels';\nimport { cn } from '../utils/utils';\n\nexport type { PiChatActions } from './session';\nexport type { ChatCard, Mode } from './rows';\nexport type { AnswerOutcome } from './question-card';\nexport { artifactKey, collectArtifacts, detectArtifact, parseArtifact } from './artifact';\nexport type { ArtifactDescriptor, ArtifactsConfig, ArtifactTarget, ArtifactType } from './artifact';\nexport type { CommandMention, CommandMentions } from './markdown';\nexport type { Suggestion } from './empty-state';\nexport { ConversationList, relativeTime } from './history';\nexport { defaultChatIcons, defaultChatLabels } from './labels';\nexport type { ChatIcons, ChatLabels } from './labels';\nexport { frenchChatLabels } from './labels-fr';\nexport { chatTextFor } from './locale';\nexport type { ChatLocale } from './locale';\nexport { ActionMenu, BlockActions } from '../button/actions';\nexport { useChatDraft } from './use-chat-draft';\nexport type { BlockAction, BlockIcon } from '../button/actions';\nexport type { AttachmentsConfig, ConversationItem, ConversationsConfig, EffortConfig, MentionResult, MentionsConfig, ModelConfig, ModelRef, ReplyRef, SlashApi, SlashCommand, SlashConfig, SlashSkill, UploadResult } from './config';\n\nexport interface PiChatProps extends ChatFeatureProps {\n readonly controller: NativeChatController;\n /** Every word the chat shows (title, placeholder, buttons, notices, empty states): a partial object over `defaultChatLabels`. */\n /** The chat's language: `en` (default) or `fr`. `labels` override any of its words. */\n readonly locale?: ChatLocale | undefined;\n readonly labels?: Partial<ChatLabels> | undefined;\n /** The chat's icons (send, stop, history, attach, …): any component taking `className`, over `defaultChatIcons`. */\n readonly icons?: Partial<ChatIcons> | undefined;\n /** The agent's avatar (an image or an icon) before the title in the header, and in the empty state instead of the agent icon. */\n readonly avatar?: ReactNode;\n /** Host actions in the header beside History: `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids `chat-action-<id>`, the menu `chat-action-more`. */\n readonly headerActions?: readonly BlockAction[] | undefined;\n readonly className?: string;\n readonly decisions?: ReactNode;\n readonly controls?: ReactNode;\n /** Host controls at the end of the composer bar (for example a microphone), like AmbientChat's tools slot. */\n readonly tools?: ReactNode;\n /** Rendered before the title in the header (for example a menu button on narrow screens). */\n readonly headerStart?: ReactNode;\n readonly onFiles?: FilesHandler;\n /** Shown instead of the default empty state before the first message. */\n readonly emptyState?: ReactNode;\n readonly suggestions?: readonly Suggestion[];\n /** Past conversations. The header's History button then opens a searchable list with the open one marked and a New action. Omit it and History pages through the earlier records of this conversation. */\n readonly conversations?: ConversationsConfig;\n /**\n * `false`: the host shows `conversations` itself (for example the `pi-app` sessions pane), so History pages through the earlier records of this\n * conversation while replies keep their Fork button (`conversations.fork`). Default `true`: History opens the list.\n */\n readonly historyList?: boolean;\n /** Show the header History button. Default true; paging remains available through the controller. */\n readonly showHistory?: boolean;\n /** Show the header connection badge. Default true; connection state still controls actions. */\n readonly showConnectionStatus?: boolean;\n /** The optional annotate toggle and notes chip in the composer (the registry `feedback` item's `useComposerFeedback`). Omit it and nothing changes. */\n readonly feedback?: ComposerFeedback;\n /** Keeps the composer's half-typed message in localStorage under this key (a conversation id), across reloads and tabs; see `useChatDraft`. Omit it and nothing is stored. */\n readonly draftKey?: string | undefined;\n}\n\nconst PAGE = 60, MORE = 40;\n\n/** Borrows a concrete native controller; unmount releases presentation and uploads only. */\nexport function PiChat(props: PiChatProps) {\n const active = useRef(props.controller); active.current = props.controller;\n const [mount, setMount] = useState({ controller: props.controller, sequence: 0 });\n if (mount.controller !== props.controller) setMount({ controller: props.controller, sequence: mount.sequence + 1 });\n const text = useMergedText(props.labels, props.icons, chatTextFor(props.locale));\n return <ChatTextProvider value={text}><PiChatSession key={mount.sequence} {...props} activeController={active} /></ChatTextProvider>;\n}\n\nfunction ConnectionBadge({ kind }: { readonly kind: string }) {\n const { labels } = useChatText();\n const live = kind === 'connected';\n return <span data-testid=\"connection\" data-state={kind} role=\"status\" className=\"inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-0.5 text-xs text-muted-foreground\">\n <span aria-hidden=\"true\" className={cn('size-1.5 rounded-full', live ? 'bg-(--chat-success)' : 'animate-pulse bg-(--chat-warning) motion-reduce:animate-none')} />\n {live ? labels.live : kind === 'connecting' || kind === 'idle' ? labels.connecting : labels.reconnecting}\n </span>;\n}\n\nfunction PiChatSession({ controller, mode = 'expert', className, actions, avatar, headerActions, decisions, controls, tools, headerStart, renderEntry, renderTool, groupTool, commandMentions,\n onOpenImage, onCopy, onComposerKeyDown, onFiles, fileAccept = 'image/*', emptyState, suggestions, messageActions, replyFooter, slash, mentions, attachments, model, effort, artifacts, conversations, historyList = true, showHistory = true, showConnectionStatus = true, feedback, draftKey, activeController: active }: PiChatProps & { readonly activeController: { readonly current: NativeChatController } }) {\n const { labels, icons } = useChatText();\n useChatDraft(controller, draftKey);\n const { title } = labels;\n const [browsingHistory, setBrowsingHistory] = useState(false);\n const [pickingConversation, setPickingConversation] = useState(false);\n const scrollToBottom = useRef<() => void>(() => {});\n // A reply's Fork button calls the host's latest `fork`; the row context changes only when forking is switched on or off.\n const fork = useRef(conversations?.fork); fork.current = conversations?.fork;\n const canFork = Boolean(conversations?.fork);\n const rowExtras = useMemo(() => canFork ? { onFork: (entryId: string) => { void fork.current?.(entryId).catch(() => {}); } } : undefined, [canFork]);\n const session = useChatSession({ controller, activeController: active, mode, actions, renderEntry, renderTool, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, onFiles, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => scrollToBottom.current(), feedback, rowExtras });\n const { state, derived, queued, queueActions, working, waitingForAnswer, connected, error, act, textarea, rowContext, composer, empty, loading, developer } = session;\n const { rows, pinned, retry } = derived;\n const transcript = useTranscript(rows, PAGE, MORE);\n const { stick, hidden } = transcript;\n scrollToBottom.current = () => { void stick.scrollToBottom('instant'); };\n const visible = transcript.visible(rows);\n const { barStart, barNote } = session.pickers();\n const connectionKind = state.connection.kind;\n\n return <section data-boring=\"pi-chat\" aria-label={title} className={cn('pi-chat relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-background text-foreground', className)}>\n <header className=\"flex shrink-0 items-center gap-2 border-b border-border px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5\">\n {headerStart}\n {avatar && <span data-testid=\"chat-avatar\" className=\"flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full [&>img]:size-full [&>img]:object-cover [&>svg]:size-4\">{avatar}</span>}\n <h2 className=\"m-0 min-w-0 flex-1 truncate font-(family-name:--chat-display) text-sm font-semibold tracking-tight\">{title}</h2>\n {showHistory && (conversations && historyList\n ? <Button size=\"sm\" data-testid=\"history-open\" className=\"text-muted-foreground\" aria-haspopup=\"dialog\" aria-expanded={pickingConversation} disabled={state.disposed}\n onClick={() => setPickingConversation(open => !open)}><icons.history className=\"size-3.5\" aria-hidden=\"true\" />{labels.history}</Button>\n : state.history.kind !== 'disabled' && !browsingHistory && <Button size=\"sm\" data-testid=\"history-open\" className=\"text-muted-foreground\" disabled={state.disposed || !connected}\n onClick={() => { setBrowsingHistory(true); act(controller.loadEarlier); }}><icons.history className=\"size-3.5\" aria-hidden=\"true\" />{labels.history}</Button>)}\n <BlockActions actions={headerActions} testId=\"chat-action\" menuLabel={labels.moreActions} />\n {showConnectionStatus && <ConnectionBadge kind={connectionKind} />}\n {controls}\n </header>\n\n {decisions && <div data-boring=\"required-decisions\" className=\"shrink-0 border-b border-border px-4 py-2\">{decisions}</div>}\n {pinned.length > 0 && <aside aria-label={labels.requiredActions} className=\"shrink-0 space-y-2 border-b border-border px-4 py-2\">{pinned.map(card => <div key={card.key}>{card.content}</div>)}</aside>}\n {(connectionKind === 'error' || connectionKind === 'closed') && <div className=\"shrink-0 px-4 pt-2\">\n <Notice tone={connectionKind === 'error' ? 'error' : 'info'} testid=\"connection-notice\">\n <span className=\"flex-1\">{connectionKind === 'error' ? labels.loadFailed : labels.disconnected}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}><icons.reconnect className=\"size-3.5\" aria-hidden=\"true\" />{labels.reconnect}</Button>}\n </Notice></div>}\n {pickingConversation && conversations && historyList && <ConversationHistory conversations={conversations} onClose={() => setPickingConversation(false)}\n onBrowseEarlier={state.history.kind !== 'disabled' && connected && Boolean(state.view?.entries.length) ? () => { setBrowsingHistory(true); act(controller.loadEarlier); } : undefined} />}\n {browsingHistory && state.history.kind !== 'disabled' && <HistoryPanel history={state.history} developer={developer} replyFooter={replyFooter} connected={!state.disposed && connected}\n onLoad={() => act(controller.loadEarlier)} onClose={() => { setBrowsingHistory(false); if (!state.disposed) controller.clearHistory(); }} />}\n\n <div className=\"relative min-h-0 flex-1\">\n <div ref={stick.scrollRef} data-testid=\"transcript-scroll\" className=\"h-full overflow-x-hidden overflow-y-auto overscroll-contain\">\n <div ref={stick.contentRef} role=\"log\" aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\" className=\"mx-auto flex min-h-full w-full max-w-3xl flex-col gap-6 px-3 pt-6 pb-8 sm:px-4\">\n {hidden > 0 && <div className=\"flex justify-center\"><Button size=\"sm\" variant=\"outline\" data-testid=\"show-earlier\" className=\"text-muted-foreground\" onClick={transcript.reveal}>{labels.showEarlier}</Button></div>}\n {visible.map(row => <RowView key={row.key} row={row} context={rowContext} />)}\n </div>\n </div>\n {(empty || loading) && <div className=\"pointer-events-none absolute inset-0\">\n <div className=\"pointer-events-auto h-full\">\n {loading ? <p role=\"status\" data-testid=\"loading\" className=\"flex h-full items-center justify-center gap-2 text-sm text-muted-foreground\"><Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{labels.loading}</p>\n : emptyState ?? <EmptyState title={title} description={labels.emptyDescription} {...(avatar ? { avatar } : {})} {...(suggestions ? { suggestions } : {})} onSelect={text => { controller.setText(text); textarea.current?.focus(); }} />}\n </div></div>}\n {!stick.isAtBottom && <Button variant=\"outline\" size=\"icon\" data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={() => { transcript.reset(); void stick.scrollToBottom(); }}\n className=\"absolute bottom-3 left-1/2 -translate-x-1/2 bg-background/95 shadow-md backdrop-blur\"><icons.jumpToLatest className=\"size-4\" aria-hidden=\"true\" /></Button>}\n </div>\n\n <footer className=\"shrink-0 px-3 pt-1 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-4\">\n <div className=\"mx-auto w-full max-w-3xl space-y-2\">\n <div aria-live=\"polite\" className=\"space-y-2 empty:hidden\">\n {working && <p role=\"status\" data-testid=\"agent-status\" className=\"m-0 flex items-center gap-2 px-1 text-xs text-muted-foreground\"><span aria-hidden=\"true\" className=\"size-1.5 animate-pulse rounded-full bg-(--chat-success) motion-reduce:animate-none\" />\n <Shimmer active={!waitingForAnswer}>{waitingForAnswer ? labels.waitingForAnswer : retry ? labels.retryPending : labels.agentWorking}</Shimmer></p>}\n {derived.compacting && <p role=\"status\" className=\"m-0 px-1 text-xs text-muted-foreground\">{labels.compacting}</p>}\n <ChatNotices state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} />\n </div>\n <div>\n <MessageQueue items={queued} sending={state.outbox} withdraw={actions?.withdraw} actions={queueActions} />\n <Composer {...composer} barStart={barStart} barNote={barNote} barEnd={tools} />\n </div>\n </div>\n </footer>\n </section>;\n}\n",
107
107
  "type": "registry:component",
108
108
  "target": "components/pi-chat/pi-chat.tsx"
109
109
  },
@@ -139,7 +139,7 @@
139
139
  },
140
140
  {
141
141
  "path": "registry/pi-chat/session.tsx",
142
- "content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { KeyboardEvent, ReactNode } from 'react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport type { ImageContent, ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { ChatAttachment, NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { useStickToBottom } from 'use-stick-to-bottom';\nimport type { ComposerFeedback, PendingUpload } from './composer';\nimport type { AttachmentsConfig, EffortConfig, MentionsConfig, ModelConfig, ModelRef, SlashConfig } from './config';\nimport { mentionToken } from './config';\nimport { ModelEffortPicker } from './pickers';\nimport type { CommandMentions } from './markdown';\nimport type { RowContext } from './message';\nimport type { QueueActions, QueuedMessage } from './queue';\nimport type { AnswerOutcome } from './question-card';\nimport type { ArtifactsConfig } from './artifact';\nimport { derive, isFileBlock, object, queuedMessages } from './rows';\nimport type { ChatCard, Mode } from './rows';\nimport { useChatText } from './labels';\nimport type { BlockAction } from '../button/actions';\nimport type { ReplyRef } from './config';\n\n/** What the host lets the person do beyond typing. Pass `remote.answer` and `remote.withdraw` from `createRemoteChat()`. */\nexport interface PiChatActions {\n /** Resolve a pending `ask_user` question. Without it, question cards are read-only. */\n readonly answer?: (questionId: string, answer: string) => Promise<AnswerOutcome>;\n /** Withdraw a queued message before Pi places it. Without it, queued messages cannot be cancelled. */\n readonly withdraw?: (id: QueuedMessage['id']) => Promise<unknown>;\n}\n\n/** The props `PiChat` and `AmbientChat` both take and hand to `useChatSession`. */\nexport interface ChatFeatureProps {\n /** `expert` hides successful tool details and reasoning; `developer` shows everything. */\n readonly mode?: Mode;\n readonly actions?: PiChatActions;\n readonly renderEntry?: (entry: EntryRecord) => ChatCard | undefined;\n readonly renderTool?: (call: ToolCall, result: ToolResultMessage | undefined) => ChatCard | undefined;\n readonly groupTool?: (call: ToolCall, result: ToolResultMessage) => boolean;\n readonly commandMentions?: CommandMentions;\n readonly onOpenImage?: (image: ImageContent) => void;\n readonly onCopy?: (text: string) => Promise<void>;\n readonly onComposerKeyDown?: (event: KeyboardEvent<HTMLTextAreaElement>) => void;\n readonly fileAccept?: string;\n /** `/` menu of host commands and skills. Omit it and `/` is just text. */\n readonly slash?: SlashConfig;\n /** `@` workspace file mentions: a picker, composer chips and styled mentions in sent messages. */\n readonly mentions?: MentionsConfig;\n /** Attach button, paste and drop through the host's upload. Takes the place of `onFiles` and `fileAccept`. */\n readonly attachments?: AttachmentsConfig;\n /** Model picker in the composer bar. The current model comes from the view's `pi.agent` document. */\n readonly model?: ModelConfig;\n /** Thinking level picker in the composer bar. The current level comes from the view's `pi.agent` document. */\n readonly effort?: EffortConfig;\n /** Artifact cards: tool results that carry a descriptor become a card that opens the artifact in the host's panel. Omit it and nothing changes. */\n readonly artifacts?: ArtifactsConfig;\n /**\n * Host actions under each settled reply, beside Copy (and Fork): `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids\n * `reply-action-<id>`, the menu `reply-action-more`.\n */\n readonly messageActions?: ((reply: ReplyRef) => readonly BlockAction[]) | undefined;\n /** A line under each settled reply (for example the model that answered and the time it took), for the host to show to some people only. */\n readonly replyFooter?: ((reply: ReplyRef) => ReactNode) | undefined;\n}\n\n\nexport type FilesHandler = (files: readonly File[], target: Pick<ReturnType<NativeChatController['getSnapshot']>, 'identity' | 'conversationId'> & { readonly signal: AbortSignal }) => Promise<readonly ChatAttachment[]>;\n\n/** The chat behaviour every surface over a `NativeChatController` shares (`PiChat`, `AmbientChat`): sending, queue actions, uploads, pickers, rows. */\nexport interface ChatSessionOptions extends Readonly<{ [Key in keyof ChatFeatureProps]: ChatFeatureProps[Key] | undefined }> {\n readonly controller: NativeChatController;\n /** The controller the host currently passes; a surface that outlives its controller stops acting on the old one. */\n readonly activeController: { readonly current: NativeChatController };\n readonly mode: Mode;\n readonly fileAccept: string;\n readonly onFiles?: FilesHandler | undefined;\n /** Runs after a message was accepted for sending (for example to scroll to the bottom). */\n readonly afterSend?: (() => void) | undefined;\n /** Extra attributes of the row context a surface needs (for example per-reply actions). */\n readonly rowExtras?: Partial<RowContext> | undefined;\n /** The optional Feedback button: when feedback is pending, Send attaches it to the message first. */\n readonly feedback?: ComposerFeedback | undefined;\n}\n\nexport function useChatSession(options: ChatSessionOptions) {\n const { controller, activeController: active, mode, actions, renderEntry, renderTool, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, onFiles, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend, rowExtras, feedback } = options;\n const { labels } = useChatText();\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const [error, setError] = useState<string | null>(null);\n const [uploading, setUploading] = useState(0);\n const [picked, setPicked] = useState<readonly string[]>([]);\n const [pending, setPending] = useState<readonly PendingUpload[]>([]);\n const [changing, setChanging] = useState<'model' | 'effort' | null>(null);\n const [changeError, setChangeError] = useState<string | null>(null);\n const [optimistic, setOptimistic] = useState<{ readonly model?: ModelRef; readonly effort?: string }>({});\n const uploadIds = useRef(0);\n const uploads = useRef(new Set<AbortController>()), mounted = useRef(false);\n const textarea = useRef<HTMLTextAreaElement | null>(null);\n const current = () => mounted.current && active.current === controller && !controller.getSnapshot().disposed;\n useEffect(() => {\n mounted.current = true;\n // A controller disposed before mount throws synchronously; the chat then renders its disposed state.\n if (!controller.getSnapshot().disposed) void controller.connect().catch(cause => { if (current()) setError(cause instanceof Error ? cause.message : labels.connectionFailed); });\n return () => { mounted.current = false; for (const upload of uploads.current) upload.abort(); uploads.current.clear(); };\n }, [controller]);\n\n const developer = mode === 'developer';\n // Only the recogniser takes part in deriving rows; which card is open changes without rebuilding them.\n const artifactDetect = useMemo(() => artifacts ? { detect: artifacts.detect } : undefined, [Boolean(artifacts), artifacts?.detect]); // eslint-disable-line react-hooks/exhaustive-deps\n const derived = useMemo(() => derive(state.view, { mode, renderEntry, renderTool, groupTool, artifacts: artifactDetect }), [state.view, mode, renderEntry, renderTool, groupTool, artifactDetect]);\n const queued = useMemo(() => queuedMessages(state.view), [state.view]);\n const { rows, run } = derived;\n\n const report = (cause: unknown) => { if (current()) setError(cause instanceof Error ? cause.message : labels.actionFailedShort); };\n const act = (action: () => Promise<unknown>) => {\n setError(null);\n try { void action().catch(report); } catch (cause) { report(cause); }\n };\n // A send being confirmed does not block the next one: the controller empties the composer at once and queues the next message behind it.\n // Only a submission whose outcome is unknown blocks, until it is checked or retried.\n const unconfirmed = state.send.kind === 'unknown';\n const connected = state.connection.kind === 'connected';\n const sendBlocked = uploading > 0 || state.disposed || !connected || unconfirmed;\n /** Puts a message back into the composer ahead of whatever is there now (nothing is dropped). */\n const putBack = (text: string, images: readonly ChatAttachment[]) => {\n if (controller.getSnapshot().disposed) return;\n const draft = controller.getSnapshot().draft;\n controller.setText([text, draft.text].filter(value => value.trim()).join('\\n'));\n if (images.length) controller.setAttachments([...images, ...draft.attachments.filter(item => !images.some(image => image.id === item.id))]);\n };\n const working = Boolean(run);\n const waitingForAnswer = rows.some(row => row.type === 'assistant' && row.parts.some(part => (part.kind === 'question' || part.kind === 'approval') && !part.live && !part.result));\n const send = () => {\n if (sendBlocked || !current()) return;\n const attach = feedback?.pending ? feedback.attach : undefined;\n if (attach) {\n // The pending feedback goes with this message: the host attaches it to the text, then the native send reads the draft.\n act(async () => {\n // Like any send, the composer empties at once; the person's own text comes back if the feedback or the message is refused.\n const typed = controller.getSnapshot().draft;\n controller.setText(''); controller.setAttachments([]);\n let attached: Awaited<ReturnType<typeof attach>>;\n try { attached = await attach(typed.text); }\n catch (cause) { putBack(typed.text, typed.attachments); throw cause; }\n if (attached.kind === 'refused') { putBack(typed.text, typed.attachments); throw new Error(attached.reason); }\n if (!current()) { putBack(typed.text, typed.attachments); return; }\n const submission = await controller.send('followUp', { text: attached.text, attachments: typed.attachments, restore: { text: typed.text, attachments: typed.attachments } });\n if (submission) feedback?.sent?.();\n });\n afterSend?.();\n textarea.current?.focus();\n return;\n }\n act(() => controller.send('followUp'));\n afterSend?.();\n textarea.current?.focus();\n };\n // A queued message is taken back (withdrawn) before it is edited or steered; if Pi already placed it, that is reported and nothing is lost.\n async function takeBack(item: QueuedMessage) {\n if (!actions?.withdraw) throw new Error(labels.queueLocked);\n const outcome = await actions.withdraw(item.id);\n if (outcome === 'already_placed' || outcome === 'settled') throw new Error(labels.tooLate);\n }\n const queuedContent = (item: QueuedMessage) => {\n const parts = Array.isArray(item.content) ? item.content as readonly { type?: unknown; text?: unknown; data?: unknown; mimeType?: unknown }[] : [];\n const text = typeof item.content === 'string' ? item.content : parts.flatMap(part => part?.type === 'text' && typeof part.text === 'string' && !isFileBlock(part.text) ? [part.text] : []).join('\\n');\n const images: ChatAttachment[] = parts.flatMap((part, index) => part?.type === 'image' && typeof part.data === 'string' && typeof part.mimeType === 'string'\n ? [{ id: `queued-${String(item.id)}-${index}`, name: `image-${index + 1}`, content: { type: 'image' as const, data: part.data, mimeType: part.mimeType } }] : []);\n return { text, images };\n };\n const queueActions: QueueActions | undefined = actions?.withdraw ? {\n edit: async item => {\n await takeBack(item);\n const { text, images } = queuedContent(item), draft = controller.getSnapshot().draft;\n controller.setText(draft.text.trim() ? `${text}\\n${draft.text}` : text);\n if (images.length) controller.setAttachments([...draft.attachments, ...images]);\n textarea.current?.focus();\n },\n steer: async item => {\n if (sendBlocked) throw new Error(labels.waitToSteer);\n await takeBack(item);\n const { text, images } = queuedContent(item);\n // Sent as explicit content, so the person's own draft is never touched; a refused steer comes back into the composer (the controller\n // puts it back), an unknown one is held for checking.\n try {\n const submission = await controller.send('steer', { text, attachments: images });\n if (!submission && current() && controller.getSnapshot().send.kind === 'blocked') setError(labels.steerRefused);\n } catch (cause) { putBack(text, images); if (current()) setError(cause instanceof Error ? cause.message : labels.steerFailed); }\n },\n } : undefined;\n const addMentions = (paths: readonly string[]) => {\n const text = controller.getSnapshot().draft.text;\n controller.setText(`${text}${text && !/\\s$/.test(text) ? ' ' : ''}${paths.map(mentionToken).join(' ')} `);\n setPicked(value => [...new Set([...value, ...paths])]);\n };\n async function upload(files: readonly File[]) {\n if ((!onFiles && !attachments) || !files.length || !current()) return;\n const abort = new AbortController(); uploads.current.add(abort); setUploading(value => value + 1);\n const ids = files.map(() => `upload-${++uploadIds.current}`);\n const settle = (failure?: string) => setPending(items => failure === undefined ? items.filter(item => !ids.includes(item.id))\n : items.map(item => ids.includes(item.id) ? { ...item, state: 'failed', error: failure } : item));\n setPending(items => [...items, ...files.map((file, index): PendingUpload => ({ id: ids[index]!, name: file.name, state: 'uploading' }))]);\n try {\n let added: readonly ChatAttachment[] = [];\n let paths: string[] = [];\n if (attachments) {\n for (const result of await attachments.upload([...files], abort.signal)) {\n if (result.image) added = [...added, { id: `image-${Date.now()}-${++uploadIds.current}`, name: result.name, content: { type: 'image', data: result.image.data, mimeType: result.image.mimeType } }];\n else if (result.path) paths = [...paths, result.path];\n }\n } else added = await onFiles!(files, { identity: state.identity, conversationId: state.conversationId, signal: abort.signal });\n if (current() && !abort.signal.aborted) {\n if (added.length) controller.setAttachments([...controller.getSnapshot().draft.attachments, ...added]);\n if (paths.length) addMentions(paths);\n settle();\n }\n } catch (cause) { if (!abort.signal.aborted) { settle(cause instanceof Error ? cause.message : labels.uploadFailedShort); if (!attachments) report(cause); } }\n finally { uploads.current.delete(abort); if (current()) setUploading(value => value - 1); }\n }\n const agentDoc = object(state.view?.docs['pi.agent']);\n const currentModel = object(agentDoc?.['model']);\n const viewModel: ModelRef | undefined = typeof currentModel?.['provider'] === 'string' && typeof currentModel['modelId'] === 'string' ? { provider: currentModel['provider'], modelId: currentModel['modelId'] } : undefined;\n const viewEffort = typeof agentDoc?.['thinkingLevel'] === 'string' ? agentDoc['thinkingLevel'] : undefined;\n // Show the accepted choice until the watch delivers it, so the label does not flicker back.\n useEffect(() => {\n const same = optimistic.model && viewModel && optimistic.model.provider === viewModel.provider && optimistic.model.modelId === viewModel.modelId;\n if (same || (optimistic.effort !== undefined && optimistic.effort === viewEffort)) setOptimistic(value => ({ ...(same ? {} : value.model ? { model: value.model } : {}), ...(optimistic.effort === viewEffort ? {} : value.effort ? { effort: value.effort } : {}) }));\n }, [optimistic, viewModel?.provider, viewModel?.modelId, viewEffort]);\n useEffect(() => { if (!state.draft.text) setPicked(value => value.length ? [] : value); }, [state.draft.text]);\n async function change(kind: 'model' | 'effort', apply: () => Promise<void>, accepted: () => void, rejected: () => void) {\n if (changing || !current()) return;\n setChanging(kind); setChangeError(null);\n try { await apply(); if (current()) accepted(); }\n catch (cause) { if (current()) { rejected(); setChangeError(cause instanceof Error && cause.message ? cause.message : labels.changeRefused); } }\n finally { if (current()) setChanging(null); }\n }\n const activateMention = (command: { readonly name: string; readonly behavior: 'execute' | 'insert' }) => {\n if (!current() || controller.getSnapshot().connection.kind !== 'connected') return;\n const catalog = commandMentions;\n const selected = catalog?.commands.find(item => item.name === command.name && item.behavior === command.behavior);\n if (catalog && selected) act(async () => catalog.onActivate({ name: selected.name, behavior: selected.behavior }));\n };\n const rowContext = useMemo<RowContext>(() => ({\n developer, groupTool, onOpenImage, onCopy,\n commandMentions: commandMentions ? { commands: commandMentions.commands, onActivate: activateMention } : undefined,\n answer: actions?.answer,\n artifacts: artifacts ? { open: artifacts.open, isOpen: artifacts.isOpen } : undefined,\n messageActions,\n replyFooter,\n pieces: slash?.skills?.length || mentions ? { mentions: Boolean(mentions), skills: (slash?.skills ?? []).map(skill => skill.name), openMention: mentions?.open, renderMention: mentions?.render } : undefined,\n ...rowExtras,\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }), [developer, groupTool, onOpenImage, onCopy, commandMentions, actions?.answer, slash?.skills, mentions, mentions?.open, mentions?.render, artifacts?.open, artifacts?.isOpen, messageActions, replyFooter, rowExtras]);\n\n /** The model-and-effort pill and the note that reports a refused change. */\n const pickers = () => {\n const disabled = changing !== null || state.disposed || !connected;\n const barStart = model || effort ? <ModelEffortPicker model={model} effort={effort} currentModel={optimistic.model ?? viewModel} currentEffort={optimistic.effort ?? viewEffort} disabled={disabled} busy={changing}\n onModel={next => void change('model', () => model!.change(next), () => setOptimistic(value => ({ ...value, model: next })), () => {})}\n onEffort={next => void change('effort', () => effort!.change(next), () => setOptimistic(value => ({ ...value, effort: next })), () => {})} /> : undefined;\n const barNote = changeError ? <button type=\"button\" role=\"alert\" data-testid=\"configure-error\" title={`${changeError} (${labels.dismissHint})`} onClick={() => setChangeError(null)}\n className=\"min-w-0 max-w-[16rem] cursor-pointer truncate rounded-md bg-destructive/10 px-2 py-1 text-xs text-destructive outline-none focus-visible:ring-2 focus-visible:ring-ring/60\">{changeError}</button> : undefined;\n return { barStart, barNote };\n };\n\n const empty = connected && !state.view?.entries.length && !rows.length;\n const loading = (state.connection.kind === 'idle' || state.connection.kind === 'connecting') && !state.view;\n\n /** Everything `Composer` needs from the session; a surface adds its layout and slots. */\n const composer = {\n text: state.draft.text, onText: (text: string) => controller.setText(text), onKeyDown: onComposerKeyDown, textareaRef: textarea,\n attachments: state.draft.attachments.map(item => ({ id: item.id, name: item.name, mimeType: item.content.mimeType, data: item.content.data })),\n onRemoveAttachment: (id: string) => controller.setAttachments(controller.getSnapshot().draft.attachments.filter(item => item.id !== id)),\n working, sendBlocked, disabled: state.disposed, stopRequested: state.stop === 'requested',\n onSend: send, onStop: () => act(controller.stop),\n uploading: uploading > 0, canAttach: Boolean(onFiles || attachments), fileAccept: attachments ? attachments.accept ?? '' : fileAccept, onPickFiles: (files: readonly File[]) => { void upload(files); },\n slash, onSlashError: report, mentions, mentionPaths: picked, onMentionPicked: (path: string) => setPicked(value => value.includes(path) ? value : [...value, path]),\n uploads: pending, onDismissUpload: (id: string) => setPending(items => items.filter(item => item.id !== id)),\n feedback,\n };\n\n return { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, sendBlocked, error, setError, report, act, current, textarea, rowContext, composer, pickers, empty, loading, developer };\n}\n\nexport type ChatSession = ReturnType<typeof useChatSession>;\n\n/** Scroll-to-bottom transcript with older rows revealed in pages as the person scrolls up. */\nexport function useTranscript(rows: readonly unknown[], page = 60, more = 40) {\n const [limit, setLimit] = useState(page);\n const anchor = useRef<{ readonly height: number; readonly top: number } | undefined>(undefined);\n // Growth re-pins at once: a spring trails the rows that grow after insert (streamed text, late images) and leaves the newest one under the composer.\n const library = useStickToBottom({ initial: 'instant', resize: 'instant' });\n // The scroller can mount later (AmbientChat starts collapsed) and unmount on collapse, so the element is held in state.\n const [scroller, setScroller] = useState<HTMLElement | null>(null);\n const scrollRef = useCallback((element: HTMLElement | null) => { library.scrollRef(element); setScroller(element); }, [library.scrollRef]);\n const stick = { ...library, scrollRef };\n // The library observes only the content. A shorter viewport (phone keyboard, a taller composer) leaves the newest row below the fold,\n // so re-pin when the viewport shrinks while the reader is at the bottom.\n useEffect(() => {\n if (!scroller || typeof ResizeObserver === 'undefined') return;\n let height = scroller.clientHeight;\n const observer = new ResizeObserver(() => {\n const shrank = scroller.clientHeight < height;\n height = scroller.clientHeight;\n if (shrank && library.state.isAtBottom) void library.scrollToBottom('instant');\n });\n observer.observe(scroller);\n return () => observer.disconnect();\n }, [scroller, library.state, library.scrollToBottom]);\n // The library ignores scroll events while it is absorbing a growth (a resize difference), so a scroll-up in that window would be\n // snapped back by its pending frame. Release the bottom lock as soon as the reader moves up. The library's own programmatic\n // writes are marked in ignoreScrollToTop, which its listener clears: this listener is a capture listener so it reads the mark first.\n useEffect(() => {\n if (!scroller) return;\n let last = scroller.scrollTop;\n const onScroll = () => {\n const top = scroller.scrollTop;\n const programmatic = library.state.ignoreScrollToTop === top;\n if (!programmatic && top < last && !library.state.isNearBottom && library.state.isAtBottom) library.stopScroll();\n last = top;\n };\n scroller.addEventListener('scroll', onScroll, { passive: true, capture: true });\n return () => scroller.removeEventListener('scroll', onScroll, { capture: true });\n }, [scroller, library.state, library.stopScroll]);\n const hidden = Math.max(0, rows.length - limit);\n // Reveal older rows when the person scrolls near the top, keeping the same rows in view.\n const reveal = useCallback(() => {\n if (scroller) anchor.current = { height: scroller.scrollHeight, top: scroller.scrollTop };\n setLimit(value => value + more);\n }, [scroller, more]);\n useLayoutEffect(() => {\n const saved = anchor.current;\n if (scroller && saved) { scroller.scrollTop = saved.top + scroller.scrollHeight - saved.height; anchor.current = undefined; }\n }, [limit, scroller]);\n useEffect(() => {\n if (!scroller) return;\n const onScroll = () => { if (scroller.scrollTop < 240 && scroller.scrollHeight > scroller.clientHeight && hidden > 0 && !anchor.current) reveal(); };\n scroller.addEventListener('scroll', onScroll, { passive: true });\n return () => scroller.removeEventListener('scroll', onScroll);\n }, [hidden, reveal, scroller]);\n return { stick, hidden, reveal, visible: <T,>(items: readonly T[]): readonly T[] => hidden ? items.slice(hidden) : items, reset: () => setLimit(page) };\n}\n",
142
+ "content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { KeyboardEvent, ReactNode } from 'react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport type { ImageContent, ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { ChatAttachment, NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { useStickToBottom } from 'use-stick-to-bottom';\nimport type { ComposerFeedback, PendingUpload } from './composer';\nimport type { AttachmentsConfig, EffortConfig, MentionsConfig, ModelConfig, ModelRef, SlashConfig } from './config';\nimport { mentionToken } from './config';\nimport { ModelEffortPicker } from './pickers';\nimport type { CommandMentions } from './markdown';\nimport type { RowContext } from './message';\nimport type { QueueActions, QueuedMessage } from './queue';\nimport type { AnswerOutcome } from './question-card';\nimport type { ArtifactsConfig } from './artifact';\nimport { derive, isFileBlock, object, queuedMessages } from './rows';\nimport type { ChatCard, Mode } from './rows';\nimport { useChatText } from './labels';\nimport type { BlockAction } from '../button/actions';\nimport type { ReplyRef } from './config';\n\n/** What the host lets the person do beyond typing. Pass `remote.answer` and `remote.withdraw` from `createRemoteChat()`. */\nexport interface PiChatActions {\n /** Resolve a pending `ask_user` question. Without it, question cards are read-only. */\n readonly answer?: (questionId: string, answer: string) => Promise<AnswerOutcome>;\n /** Withdraw a queued message before Pi places it. Without it, queued messages cannot be cancelled. */\n readonly withdraw?: (id: QueuedMessage['id']) => Promise<unknown>;\n}\n\n/** The props `PiChat` and `AmbientChat` both take and hand to `useChatSession`. */\nexport interface ChatFeatureProps {\n /** `expert` hides successful tool details and reasoning; `developer` shows everything. */\n readonly mode?: Mode;\n readonly actions?: PiChatActions;\n readonly renderEntry?: (entry: EntryRecord) => ChatCard | undefined;\n readonly renderTool?: (call: ToolCall, result: ToolResultMessage | undefined) => ChatCard | undefined;\n readonly groupTool?: (call: ToolCall, result: ToolResultMessage) => boolean;\n readonly commandMentions?: CommandMentions;\n readonly onOpenImage?: (image: ImageContent) => void;\n readonly onCopy?: (text: string) => Promise<void>;\n readonly onComposerKeyDown?: (event: KeyboardEvent<HTMLTextAreaElement>) => void;\n readonly fileAccept?: string;\n /** `/` menu of host commands and skills. Omit it and `/` is just text. */\n readonly slash?: SlashConfig;\n /** `@` workspace file mentions: a picker, composer chips and styled mentions in sent messages. */\n readonly mentions?: MentionsConfig;\n /** Attach button, paste and drop through the host's upload. Takes the place of `onFiles` and `fileAccept`. */\n readonly attachments?: AttachmentsConfig;\n /** Model picker in the composer bar. The current model comes from the view's `pi.agent` document. */\n readonly model?: ModelConfig;\n /** Thinking level picker in the composer bar. The current level comes from the view's `pi.agent` document. */\n readonly effort?: EffortConfig;\n /** Artifact cards: tool results that carry a descriptor become a card that opens the artifact in the host's panel. Omit it and nothing changes. */\n readonly artifacts?: ArtifactsConfig;\n /**\n * Host actions under each settled reply, beside Copy (and Fork): `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids\n * `reply-action-<id>`, the menu `reply-action-more`.\n */\n readonly messageActions?: ((reply: ReplyRef) => readonly BlockAction[]) | undefined;\n /** A line under each settled reply (for example the model that answered and the time it took), for the host to show to some people only. */\n readonly replyFooter?: ((reply: ReplyRef) => ReactNode) | undefined;\n}\n\n\nexport type FilesHandler = (files: readonly File[], target: Pick<ReturnType<NativeChatController['getSnapshot']>, 'identity' | 'conversationId'> & { readonly signal: AbortSignal }) => Promise<readonly ChatAttachment[]>;\n\n/** The chat behaviour every surface over a `NativeChatController` shares (`PiChat`, `AmbientChat`): sending, queue actions, uploads, pickers, rows. */\nexport interface ChatSessionOptions extends Readonly<{ [Key in keyof ChatFeatureProps]: ChatFeatureProps[Key] | undefined }> {\n readonly controller: NativeChatController;\n /** The controller the host currently passes; a surface that outlives its controller stops acting on the old one. */\n readonly activeController: { readonly current: NativeChatController };\n readonly mode: Mode;\n readonly fileAccept: string;\n readonly onFiles?: FilesHandler | undefined;\n /** Runs after a message was accepted for sending (for example to scroll to the bottom). */\n readonly afterSend?: (() => void) | undefined;\n /** Extra attributes of the row context a surface needs (for example per-reply actions). */\n readonly rowExtras?: Partial<RowContext> | undefined;\n /** The optional annotate toggle: when notes are pending (`count` > 0), Send attaches them to the message first. */\n readonly feedback?: ComposerFeedback | undefined;\n}\n\nexport function useChatSession(options: ChatSessionOptions) {\n const { controller, activeController: active, mode, actions, renderEntry, renderTool, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, onFiles, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend, rowExtras, feedback } = options;\n const { labels } = useChatText();\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const [error, setError] = useState<string | null>(null);\n const [uploading, setUploading] = useState(0);\n const [picked, setPicked] = useState<readonly string[]>([]);\n const [pending, setPending] = useState<readonly PendingUpload[]>([]);\n const [changing, setChanging] = useState<'model' | 'effort' | null>(null);\n const [changeError, setChangeError] = useState<string | null>(null);\n const [optimistic, setOptimistic] = useState<{ readonly model?: ModelRef; readonly effort?: string }>({});\n const uploadIds = useRef(0);\n const uploads = useRef(new Set<AbortController>()), mounted = useRef(false);\n const textarea = useRef<HTMLTextAreaElement | null>(null);\n const current = () => mounted.current && active.current === controller && !controller.getSnapshot().disposed;\n useEffect(() => {\n mounted.current = true;\n // A controller disposed before mount throws synchronously; the chat then renders its disposed state.\n if (!controller.getSnapshot().disposed) void controller.connect().catch(cause => { if (current()) setError(cause instanceof Error ? cause.message : labels.connectionFailed); });\n return () => { mounted.current = false; for (const upload of uploads.current) upload.abort(); uploads.current.clear(); };\n }, [controller]);\n\n const developer = mode === 'developer';\n // Only the recogniser takes part in deriving rows; which card is open changes without rebuilding them.\n const artifactDetect = useMemo(() => artifacts ? { detect: artifacts.detect } : undefined, [Boolean(artifacts), artifacts?.detect]); // eslint-disable-line react-hooks/exhaustive-deps\n const derived = useMemo(() => derive(state.view, { mode, renderEntry, renderTool, groupTool, artifacts: artifactDetect }), [state.view, mode, renderEntry, renderTool, groupTool, artifactDetect]);\n const queued = useMemo(() => queuedMessages(state.view), [state.view]);\n const { rows, run } = derived;\n\n const report = (cause: unknown) => { if (current()) setError(cause instanceof Error ? cause.message : labels.actionFailedShort); };\n const act = (action: () => Promise<unknown>) => {\n setError(null);\n try { void action().catch(report); } catch (cause) { report(cause); }\n };\n // A send being confirmed does not block the next one: the controller empties the composer at once and queues the next message behind it.\n // Only a submission whose outcome is unknown blocks, until it is checked or retried.\n const unconfirmed = state.send.kind === 'unknown';\n const connected = state.connection.kind === 'connected';\n const sendBlocked = uploading > 0 || state.disposed || !connected || unconfirmed;\n /** Puts a message back into the composer ahead of whatever is there now (nothing is dropped). */\n const putBack = (text: string, images: readonly ChatAttachment[]) => {\n if (controller.getSnapshot().disposed) return;\n const draft = controller.getSnapshot().draft;\n controller.setText([text, draft.text].filter(value => value.trim()).join('\\n'));\n if (images.length) controller.setAttachments([...images, ...draft.attachments.filter(item => !images.some(image => image.id === item.id))]);\n };\n const working = Boolean(run);\n const waitingForAnswer = rows.some(row => row.type === 'assistant' && row.parts.some(part => (part.kind === 'question' || part.kind === 'approval') && !part.live && !part.result));\n const send = () => {\n if (sendBlocked || !current()) return;\n const attach = feedback && feedback.count > 0 ? feedback.attach : undefined;\n if (attach) {\n // The pending feedback goes with this message: the host attaches it to the text, then the native send reads the draft.\n act(async () => {\n // Like any send, the composer empties at once; the person's own text comes back if the feedback or the message is refused.\n const typed = controller.getSnapshot().draft;\n controller.setText(''); controller.setAttachments([]);\n let attached: Awaited<ReturnType<typeof attach>>;\n try { attached = await attach(typed.text); }\n catch (cause) { putBack(typed.text, typed.attachments); throw cause; }\n if (attached.kind === 'refused') { putBack(typed.text, typed.attachments); throw new Error(attached.reason); }\n if (!current()) { putBack(typed.text, typed.attachments); return; }\n const submission = await controller.send('followUp', { text: attached.text, attachments: typed.attachments, restore: { text: typed.text, attachments: typed.attachments } });\n if (submission) feedback?.sent();\n });\n afterSend?.();\n textarea.current?.focus();\n return;\n }\n act(() => controller.send('followUp'));\n afterSend?.();\n textarea.current?.focus();\n };\n // A queued message is taken back (withdrawn) before it is edited or steered; if Pi already placed it, that is reported and nothing is lost.\n async function takeBack(item: QueuedMessage) {\n if (!actions?.withdraw) throw new Error(labels.queueLocked);\n const outcome = await actions.withdraw(item.id);\n if (outcome === 'already_placed' || outcome === 'settled') throw new Error(labels.tooLate);\n }\n const queuedContent = (item: QueuedMessage) => {\n const parts = Array.isArray(item.content) ? item.content as readonly { type?: unknown; text?: unknown; data?: unknown; mimeType?: unknown }[] : [];\n const text = typeof item.content === 'string' ? item.content : parts.flatMap(part => part?.type === 'text' && typeof part.text === 'string' && !isFileBlock(part.text) ? [part.text] : []).join('\\n');\n const images: ChatAttachment[] = parts.flatMap((part, index) => part?.type === 'image' && typeof part.data === 'string' && typeof part.mimeType === 'string'\n ? [{ id: `queued-${String(item.id)}-${index}`, name: `image-${index + 1}`, content: { type: 'image' as const, data: part.data, mimeType: part.mimeType } }] : []);\n return { text, images };\n };\n const queueActions: QueueActions | undefined = actions?.withdraw ? {\n edit: async item => {\n await takeBack(item);\n const { text, images } = queuedContent(item), draft = controller.getSnapshot().draft;\n controller.setText(draft.text.trim() ? `${text}\\n${draft.text}` : text);\n if (images.length) controller.setAttachments([...draft.attachments, ...images]);\n textarea.current?.focus();\n },\n steer: async item => {\n if (sendBlocked) throw new Error(labels.waitToSteer);\n await takeBack(item);\n const { text, images } = queuedContent(item);\n // Sent as explicit content, so the person's own draft is never touched; a refused steer comes back into the composer (the controller\n // puts it back), an unknown one is held for checking.\n try {\n const submission = await controller.send('steer', { text, attachments: images });\n if (!submission && current() && controller.getSnapshot().send.kind === 'blocked') setError(labels.steerRefused);\n } catch (cause) { putBack(text, images); if (current()) setError(cause instanceof Error ? cause.message : labels.steerFailed); }\n },\n } : undefined;\n const addMentions = (paths: readonly string[]) => {\n const text = controller.getSnapshot().draft.text;\n controller.setText(`${text}${text && !/\\s$/.test(text) ? ' ' : ''}${paths.map(mentionToken).join(' ')} `);\n setPicked(value => [...new Set([...value, ...paths])]);\n };\n async function upload(files: readonly File[]) {\n if ((!onFiles && !attachments) || !files.length || !current()) return;\n const abort = new AbortController(); uploads.current.add(abort); setUploading(value => value + 1);\n const ids = files.map(() => `upload-${++uploadIds.current}`);\n const settle = (failure?: string) => setPending(items => failure === undefined ? items.filter(item => !ids.includes(item.id))\n : items.map(item => ids.includes(item.id) ? { ...item, state: 'failed', error: failure } : item));\n setPending(items => [...items, ...files.map((file, index): PendingUpload => ({ id: ids[index]!, name: file.name, state: 'uploading' }))]);\n try {\n let added: readonly ChatAttachment[] = [];\n let paths: string[] = [];\n if (attachments) {\n for (const result of await attachments.upload([...files], abort.signal)) {\n if (result.image) added = [...added, { id: `image-${Date.now()}-${++uploadIds.current}`, name: result.name, content: { type: 'image', data: result.image.data, mimeType: result.image.mimeType } }];\n else if (result.path) paths = [...paths, result.path];\n }\n } else added = await onFiles!(files, { identity: state.identity, conversationId: state.conversationId, signal: abort.signal });\n if (current() && !abort.signal.aborted) {\n if (added.length) controller.setAttachments([...controller.getSnapshot().draft.attachments, ...added]);\n if (paths.length) addMentions(paths);\n settle();\n }\n } catch (cause) { if (!abort.signal.aborted) { settle(cause instanceof Error ? cause.message : labels.uploadFailedShort); if (!attachments) report(cause); } }\n finally { uploads.current.delete(abort); if (current()) setUploading(value => value - 1); }\n }\n const agentDoc = object(state.view?.docs['pi.agent']);\n const currentModel = object(agentDoc?.['model']);\n const viewModel: ModelRef | undefined = typeof currentModel?.['provider'] === 'string' && typeof currentModel['modelId'] === 'string' ? { provider: currentModel['provider'], modelId: currentModel['modelId'] } : undefined;\n const viewEffort = typeof agentDoc?.['thinkingLevel'] === 'string' ? agentDoc['thinkingLevel'] : undefined;\n // Show the accepted choice until the watch delivers it, so the label does not flicker back.\n useEffect(() => {\n const same = optimistic.model && viewModel && optimistic.model.provider === viewModel.provider && optimistic.model.modelId === viewModel.modelId;\n if (same || (optimistic.effort !== undefined && optimistic.effort === viewEffort)) setOptimistic(value => ({ ...(same ? {} : value.model ? { model: value.model } : {}), ...(optimistic.effort === viewEffort ? {} : value.effort ? { effort: value.effort } : {}) }));\n }, [optimistic, viewModel?.provider, viewModel?.modelId, viewEffort]);\n useEffect(() => { if (!state.draft.text) setPicked(value => value.length ? [] : value); }, [state.draft.text]);\n async function change(kind: 'model' | 'effort', apply: () => Promise<void>, accepted: () => void, rejected: () => void) {\n if (changing || !current()) return;\n setChanging(kind); setChangeError(null);\n try { await apply(); if (current()) accepted(); }\n catch (cause) { if (current()) { rejected(); setChangeError(cause instanceof Error && cause.message ? cause.message : labels.changeRefused); } }\n finally { if (current()) setChanging(null); }\n }\n const activateMention = (command: { readonly name: string; readonly behavior: 'execute' | 'insert' }) => {\n if (!current() || controller.getSnapshot().connection.kind !== 'connected') return;\n const catalog = commandMentions;\n const selected = catalog?.commands.find(item => item.name === command.name && item.behavior === command.behavior);\n if (catalog && selected) act(async () => catalog.onActivate({ name: selected.name, behavior: selected.behavior }));\n };\n const rowContext = useMemo<RowContext>(() => ({\n developer, groupTool, onOpenImage, onCopy,\n commandMentions: commandMentions ? { commands: commandMentions.commands, onActivate: activateMention } : undefined,\n answer: actions?.answer,\n artifacts: artifacts ? { open: artifacts.open, isOpen: artifacts.isOpen } : undefined,\n messageActions,\n replyFooter,\n pieces: slash?.skills?.length || mentions ? { mentions: Boolean(mentions), skills: (slash?.skills ?? []).map(skill => skill.name), openMention: mentions?.open, renderMention: mentions?.render } : undefined,\n ...rowExtras,\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }), [developer, groupTool, onOpenImage, onCopy, commandMentions, actions?.answer, slash?.skills, mentions, mentions?.open, mentions?.render, artifacts?.open, artifacts?.isOpen, messageActions, replyFooter, rowExtras]);\n\n /** The model-and-effort pill and the note that reports a refused change. */\n const pickers = () => {\n const disabled = changing !== null || state.disposed || !connected;\n const barStart = model || effort ? <ModelEffortPicker model={model} effort={effort} currentModel={optimistic.model ?? viewModel} currentEffort={optimistic.effort ?? viewEffort} disabled={disabled} busy={changing}\n onModel={next => void change('model', () => model!.change(next), () => setOptimistic(value => ({ ...value, model: next })), () => {})}\n onEffort={next => void change('effort', () => effort!.change(next), () => setOptimistic(value => ({ ...value, effort: next })), () => {})} /> : undefined;\n const barNote = changeError ? <button type=\"button\" role=\"alert\" data-testid=\"configure-error\" title={`${changeError} (${labels.dismissHint})`} onClick={() => setChangeError(null)}\n className=\"min-w-0 max-w-[16rem] cursor-pointer truncate rounded-md bg-destructive/10 px-2 py-1 text-xs text-destructive outline-none focus-visible:ring-2 focus-visible:ring-ring/60\">{changeError}</button> : undefined;\n return { barStart, barNote };\n };\n\n const empty = connected && !state.view?.entries.length && !rows.length;\n const loading = (state.connection.kind === 'idle' || state.connection.kind === 'connecting') && !state.view;\n\n /** Everything `Composer` needs from the session; a surface adds its layout and slots. */\n const composer = {\n text: state.draft.text, onText: (text: string) => controller.setText(text), onKeyDown: onComposerKeyDown, textareaRef: textarea,\n attachments: state.draft.attachments.map(item => ({ id: item.id, name: item.name, mimeType: item.content.mimeType, data: item.content.data })),\n onRemoveAttachment: (id: string) => controller.setAttachments(controller.getSnapshot().draft.attachments.filter(item => item.id !== id)),\n working, sendBlocked, disabled: state.disposed, stopRequested: state.stop === 'requested',\n onSend: send, onStop: () => act(controller.stop),\n uploading: uploading > 0, canAttach: Boolean(onFiles || attachments), fileAccept: attachments ? attachments.accept ?? '' : fileAccept, onPickFiles: (files: readonly File[]) => { void upload(files); },\n slash, onSlashError: report, mentions, mentionPaths: picked, onMentionPicked: (path: string) => setPicked(value => value.includes(path) ? value : [...value, path]),\n uploads: pending, onDismissUpload: (id: string) => setPending(items => items.filter(item => item.id !== id)),\n feedback,\n };\n\n return { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, sendBlocked, error, setError, report, act, current, textarea, rowContext, composer, pickers, empty, loading, developer };\n}\n\nexport type ChatSession = ReturnType<typeof useChatSession>;\n\n/** Scroll-to-bottom transcript with older rows revealed in pages as the person scrolls up. */\nexport function useTranscript(rows: readonly unknown[], page = 60, more = 40) {\n const [limit, setLimit] = useState(page);\n const anchor = useRef<{ readonly height: number; readonly top: number } | undefined>(undefined);\n // Growth re-pins at once: a spring trails the rows that grow after insert (streamed text, late images) and leaves the newest one under the composer.\n const library = useStickToBottom({ initial: 'instant', resize: 'instant' });\n // The scroller can mount later (AmbientChat starts collapsed) and unmount on collapse, so the element is held in state.\n const [scroller, setScroller] = useState<HTMLElement | null>(null);\n const scrollRef = useCallback((element: HTMLElement | null) => { library.scrollRef(element); setScroller(element); }, [library.scrollRef]);\n const stick = { ...library, scrollRef };\n // The library observes only the content. A shorter viewport (phone keyboard, a taller composer) leaves the newest row below the fold,\n // so re-pin when the viewport shrinks while the reader is at the bottom.\n useEffect(() => {\n if (!scroller || typeof ResizeObserver === 'undefined') return;\n let height = scroller.clientHeight;\n const observer = new ResizeObserver(() => {\n const shrank = scroller.clientHeight < height;\n height = scroller.clientHeight;\n if (shrank && library.state.isAtBottom) void library.scrollToBottom('instant');\n });\n observer.observe(scroller);\n return () => observer.disconnect();\n }, [scroller, library.state, library.scrollToBottom]);\n // The library ignores scroll events while it is absorbing a growth (a resize difference), so a scroll-up in that window would be\n // snapped back by its pending frame. Release the bottom lock as soon as the reader moves up. The library's own programmatic\n // writes are marked in ignoreScrollToTop, which its listener clears: this listener is a capture listener so it reads the mark first.\n useEffect(() => {\n if (!scroller) return;\n let last = scroller.scrollTop;\n const onScroll = () => {\n const top = scroller.scrollTop;\n const programmatic = library.state.ignoreScrollToTop === top;\n if (!programmatic && top < last && !library.state.isNearBottom && library.state.isAtBottom) library.stopScroll();\n last = top;\n };\n scroller.addEventListener('scroll', onScroll, { passive: true, capture: true });\n return () => scroller.removeEventListener('scroll', onScroll, { capture: true });\n }, [scroller, library.state, library.stopScroll]);\n const hidden = Math.max(0, rows.length - limit);\n // Reveal older rows when the person scrolls near the top, keeping the same rows in view.\n const reveal = useCallback(() => {\n if (scroller) anchor.current = { height: scroller.scrollHeight, top: scroller.scrollTop };\n setLimit(value => value + more);\n }, [scroller, more]);\n useLayoutEffect(() => {\n const saved = anchor.current;\n if (scroller && saved) { scroller.scrollTop = saved.top + scroller.scrollHeight - saved.height; anchor.current = undefined; }\n }, [limit, scroller]);\n useEffect(() => {\n if (!scroller) return;\n const onScroll = () => { if (scroller.scrollTop < 240 && scroller.scrollHeight > scroller.clientHeight && hidden > 0 && !anchor.current) reveal(); };\n scroller.addEventListener('scroll', onScroll, { passive: true });\n return () => scroller.removeEventListener('scroll', onScroll);\n }, [hidden, reveal, scroller]);\n return { stick, hidden, reveal, visible: <T,>(items: readonly T[]): readonly T[] => hidden ? items.slice(hidden) : items, reset: () => setLimit(page) };\n}\n",
143
143
  "type": "registry:component",
144
144
  "target": "components/pi-chat/session.tsx"
145
145
  },
@@ -167,15 +167,21 @@
167
167
  "type": "registry:hook",
168
168
  "target": "components/pi-chat/use-remote-chat.ts"
169
169
  },
170
+ {
171
+ "path": "registry/pi-chat/use-chat-draft.ts",
172
+ "content": "'use client';\n\nimport { useEffect, useRef } from 'react';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\n\nconst PREFIX = 'pi-chat:draft:';\n\n/** The stored draft: a string, `null` when the key is missing, `undefined` when storage is unavailable (missing, blocked or throwing). */\nfunction read(storageKey: string): string | null | undefined {\n try {\n const storage = globalThis.localStorage;\n return storage ? storage.getItem(storageKey) : undefined;\n } catch { return undefined; }\n}\nfunction write(storageKey: string, text: string): void {\n try { if (text) globalThis.localStorage?.setItem(storageKey, text); else globalThis.localStorage?.removeItem(storageKey); } catch { /* the composer still holds the text */ }\n}\n\n/**\n * Keeps the composer's half-typed message (the controller's draft) per conversation in localStorage, so it survives a reload and shows in\n * every tab on the same conversation (the `storage` event). The draft is emptied when a message is sent, and a refused send puts the text back,\n * which is written again. Without `key` nothing happens. Storage that is missing or throws only means the draft is not kept.\n *\n * On mount the conversation's saved draft replaces the composer's text; text already in the composer and not yet saved is written at once.\n * On a change of `key` (same controller) the composer shows the new conversation's saved draft, or an empty one, so no text moves across.\n */\nexport function useChatDraft(controller: NativeChatController, key: string | undefined): void {\n // The key the hook last loaded; a different key on a later run is a conversation switch, not a first mount.\n const loaded = useRef<string | undefined>(undefined);\n useEffect(() => {\n if (key === undefined || controller.getSnapshot().disposed) return;\n const storageKey = PREFIX + key;\n const switched = loaded.current !== undefined && loaded.current !== key;\n loaded.current = key;\n const stored = read(storageKey);\n const current = controller.getSnapshot().draft.text;\n // The text last known to be in storage; a write happens only when the composer's text differs from it.\n let saved: string;\n if (stored) {\n if (stored !== current) controller.setText(stored);\n saved = stored;\n } else if (switched) {\n // The new conversation has no saved draft (or storage is unavailable): the old conversation's text must not stay.\n if (current) controller.setText('');\n saved = '';\n } else {\n saved = current;\n if (stored === null && current) write(storageKey, current);\n }\n const persist = () => {\n if (controller.getSnapshot().disposed) return;\n const text = controller.getSnapshot().draft.text;\n if (text === saved) return;\n saved = text;\n write(storageKey, text);\n };\n const unsubscribe = controller.subscribe(persist);\n // Another tab changed this conversation's draft (or cleared all storage: `key` is null). Adopting it does not write it back.\n // Unavailable storage is not a missing draft: the composer keeps its text.\n const onStorage = (event: StorageEvent) => {\n if (event.key !== null && event.key !== storageKey) return;\n if (controller.getSnapshot().disposed) return;\n const next = read(storageKey);\n if (next === undefined) return;\n const text = next ?? '';\n saved = text;\n if (text !== controller.getSnapshot().draft.text) controller.setText(text);\n };\n globalThis.addEventListener?.('storage', onStorage);\n return () => {\n unsubscribe();\n globalThis.removeEventListener?.('storage', onStorage);\n };\n }, [controller, key]);\n}\n",
173
+ "type": "registry:hook",
174
+ "target": "components/pi-chat/use-chat-draft.ts"
175
+ },
170
176
  {
171
177
  "path": "registry/pi-chat/labels.ts",
172
- "content": "/*\n * Every word the chat shows, and the icons it draws, as plain data. A host passes a partial `labels` (and `icons`) object to `PiChat`\n * (or `AmbientChat`, `AgentWorkspace`): what it leaves out keeps the default below. Strings that need a value are functions. No i18n\n * framework: an app that translates passes its own object per language.\n */\nimport { createContext, createElement, useContext, useMemo } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowDownIcon, ArrowUpIcon, GitForkIcon, HistoryIcon, ListEndIcon, MessagesSquareIcon, PaperclipIcon, PlusIcon, RefreshCwIcon, SquareIcon } from 'lucide-react';\nimport type { BlockIcon } from '../button/actions';\nimport { withDefaults } from '../utils/utils';\n\nconst TOOL_VERBS: Readonly<Record<string, string>> = { read: 'Reading', write: 'Writing', edit: 'Editing', ls: 'Listing', list_files: 'Listing', find: 'Searching', grep: 'Searching', load_skill: 'Loading skill', bash: 'Running bash' };\nconst TOOL_NOUNS: Readonly<Record<string, string>> = { bash: 'command', read: 'read', write: 'write', edit: 'edit', find: 'find', grep: 'search', ls: 'list' };\n\nconst EFFORT_NAMES: Readonly<Record<string, string>> = { off: 'Off', minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', max: 'Max' };\nconst EFFORT_DETAILS: Readonly<Record<string, string>> = { off: 'No extra reasoning', minimal: 'Barely any reasoning', low: 'Light reasoning', medium: 'Balanced reasoning', high: 'Deep reasoning', xhigh: 'Deeper reasoning', max: 'Maximum reasoning' };\n\nexport const defaultChatLabels = {\n // Header and transcript\n /** The chat's name: the header title, the region's name and the empty state's title. */\n title: 'Conversation',\n history: 'History',\n live: 'Live',\n connecting: 'Connecting…',\n reconnecting: 'Reconnecting…',\n loadFailed: 'Conversation could not be loaded.',\n disconnected: 'Disconnected. Last observed messages remain visible.',\n reconnect: 'Reconnect',\n requiredActions: 'Required conversation actions',\n messages: 'Messages',\n showEarlier: 'Show earlier messages',\n loading: 'Loading conversation…',\n emptyDescription: 'Start a conversation.',\n jumpToLatest: 'Jump to latest',\n agentWorking: 'Agent is working',\n waitingForAnswer: 'Waiting for your answer',\n retryPending: 'Native retry pending',\n compacting: 'Native context compaction in progress',\n /** The \"…\" menu of host actions (header, replies). */\n moreActions: 'More actions',\n // Notices\n sendUnknown: 'Submission acknowledgement is unknown. The message is kept until you check or retry it.',\n checkSubmission: 'Check original submission',\n retrySame: 'Retry same request',\n stopRequested: 'Stop requested. Waiting for native confirmation.',\n stopUnconfirmed: 'Stop is unconfirmed. Work may still be running.',\n steerRefused: 'The message could not be steered; it is back in the message box.',\n tooLate: 'Too late: that message has already started.',\n tooLateToRemove: 'Too late to remove: that message has already started.',\n queueFailed: 'The message could not be changed.',\n queueLocked: 'Queued messages cannot be changed here.',\n waitToSteer: 'Wait for the current send to finish, then steer.',\n steerFailed: 'The message could not be steered.',\n connectionFailed: 'Connection failed',\n actionFailedShort: 'Action failed',\n uploadFailedShort: 'Upload failed',\n changeRefused: 'The change was refused',\n dismissHint: 'click to dismiss',\n // Composer\n placeholder: 'Message the agent…',\n placeholderWorking: 'Queue a message…',\n messageInput: 'Message',\n send: 'Send',\n sendHint: 'Send (Enter)',\n stop: 'Stop',\n queue: 'Queue',\n add: 'Add',\n attachFiles: 'Attach files',\n commands: 'Commands',\n feedback: 'Feedback',\n feedbackHint: 'Feedback: point at the page and say what is wrong',\n point: 'Show in app',\n pointHint: 'Show in the app: the panel steps aside, tap what you mean',\n remove: (name: string) => `Remove ${name}`,\n dismiss: (name: string) => `Dismiss ${name}`,\n uploading: (name: string) => `Uploading ${name}…`,\n uploadFailed: (name: string, error: string | undefined) => `${name}: ${error ?? 'upload failed'}`,\n preparingAttachments: 'Preparing attachments…',\n preparingAttachmentsShort: 'Preparing attachments',\n mentionedFiles: 'Mentioned files',\n uploads: 'Uploads',\n attachments: 'Attachments',\n searchCommands: 'Search commands',\n filterBySource: 'Filter by source',\n allSources: 'All',\n noCommands: 'No matching commands.',\n /** A `/` menu filter chip: `skills`, `built-in` or a plugin's name. */\n slashGroup: (group: string) => group,\n skillBadge: 'skill',\n workspaceFiles: 'Workspace files',\n searchFailed: 'Search failed',\n model: 'Model',\n effort: 'Effort',\n effortDefault: 'Default',\n modelAndEffort: 'Model and effort',\n modelSummary: (model: string | undefined, effort: string | undefined) => [model && `Model: ${model}`, effort && `Effort: ${effort}`].filter(Boolean).join(', '),\n /** A thinking level's name and short description in the picker. */\n effortName: (level: string) => EFFORT_NAMES[level] ?? level,\n effortDetail: (level: string): string | undefined => EFFORT_DETAILS[level],\n // Voice input (the microphone button beside the composer)\n voiceRecord: 'Record a message',\n voiceStop: 'Stop recording',\n voiceCancel: 'Cancel recording',\n voiceTranscribing: 'Transcribing…',\n voiceNothingHeard: 'Nothing was heard. Try again.',\n voiceTranscribeFailed: 'The recording could not be transcribed. Try again.',\n voiceDenied: 'Microphone blocked. Allow it for this site in the browser, then press record again.',\n voiceNoMicrophone: 'No microphone found. Connect one, then press record again.',\n voiceBusy: 'The microphone is busy or could not be opened. Close other apps using it and try again.',\n voiceInsecure: 'Recording needs a secure page (https or localhost).',\n voiceUnsupported: 'This browser cannot record audio here.',\n voiceStartFailed: 'The microphone could not be started.',\n // Queue\n queuedMessages: 'Queued messages',\n steer: 'Steer',\n steerHint: 'Send this into the running turn now',\n steering: 'Steering',\n queued: 'Queued',\n removeQueued: 'Remove queued message',\n removeHint: 'Remove',\n more: 'More',\n edit: 'Edit',\n sending: 'Sending',\n images: (count: number) => `[${count} image${count === 1 ? '' : 's'}]`,\n // Conversation list and history\n historyDialog: 'Conversation history',\n newConversation: 'New',\n untitled: 'New conversation',\n closeHistory: 'Close history',\n close: 'Close',\n earlierInConversation: 'Earlier messages in this conversation',\n searchConversations: 'Search conversations',\n archived: 'Archived',\n current: 'Current',\n noMatch: (query: string) => `No conversation matches “${query}”.`,\n noArchived: 'No archived conversations.',\n noConversations: 'No conversations yet.',\n loadingConversations: 'Loading conversations…',\n actionFailed: 'That did not work. Try again.',\n rename: 'Rename',\n renameItem: (title: string) => `Rename ${title}`,\n conversationName: 'Conversation name',\n save: 'Save',\n archive: 'Archive',\n restore: 'Restore',\n archiveItem: (title: string, archived: boolean) => `${archived ? 'Restore' : 'Archive'} ${title}`,\n delete: 'Delete',\n deleteItem: (title: string) => `Delete ${title}`,\n keep: 'Keep conversation',\n today: 'Today',\n yesterday: 'Yesterday',\n previousWeek: 'Previous 7 days',\n older: 'Older',\n conversations: 'Conversations',\n earlierMessages: 'Earlier messages',\n readOnly: '(read-only)',\n retry: 'Retry',\n loadingShort: 'Loading…',\n loadEarlier: 'Load earlier',\n returnToConversation: 'Return to active conversation',\n historyFailed: 'Conversation history could not be loaded. The previous page remains visible.',\n loadPage: 'Load a page to browse earlier messages.',\n noRecords: 'No records in this history page.',\n beginning: 'Beginning of conversation.',\n justNow: 'now',\n roleSystem: 'System',\n roleToolResult: (tool: string, failed: boolean) => `Tool result: ${tool}${failed ? ' (failed)' : ''}`,\n roleUser: 'You',\n roleAssistant: (outcome: 'ok' | 'interrupted' | 'failed'): string => outcome === 'interrupted' ? 'Assistant (interrupted)' : outcome === 'failed' ? 'Assistant (failed)' : 'Assistant',\n roleEvent: 'Event',\n contextBoundary: 'Context boundary',\n // Messages\n copy: 'Copy',\n copied: 'Copied',\n copyFailed: 'Copy failed',\n failed: 'Failed',\n copyResponse: 'Copy response',\n copyCode: 'Copy code',\n fork: 'Fork from here',\n forkHint: 'Fork from here: a new conversation with the messages up to this reply',\n responseFailed: 'The response failed',\n interrupted: 'Response interrupted',\n thinking: 'Thinking…',\n openImage: 'Open image attachment',\n imageAttachment: 'Image attachment',\n systemContext: 'System context',\n toolFailed: (tool: string) => `${tool} failed`,\n event: (label: string) => `Event: ${label}`,\n // Activity and tools\n reasoning: 'Reasoning',\n thinkingStep: 'Thinking',\n steps: (count: number) => `${count} ${count === 1 ? 'step' : 'steps'}`,\n toolRunning: 'Running',\n toolCompleted: 'Completed',\n toolFailedStatus: 'Failed',\n toolUnfinished: 'No result',\n arguments: 'Arguments',\n result: 'Result',\n error: 'Error',\n openImageResult: 'Open image result',\n emptyResult: 'Empty result',\n /** The live line of a running tool call; `hint` is what it acts on (a path, a command), possibly empty. */\n toolActivity: (tool: string, hint: string) => {\n const verb = TOOL_VERBS[tool] ?? `Running ${tool}`;\n return hint ? `${verb}${tool === 'bash' ? ':' : ''} ${hint}` : verb;\n },\n /** How a tool is named in a finished activity summary (\"Used read ×3 · command\"). */\n toolNoun: (tool: string) => TOOL_NOUNS[tool] ?? tool,\n // Approval and question cards\n approvalNeeded: 'Approval needed',\n needsApproval: 'Needs your approval',\n approve: 'Approve',\n deny: 'Deny',\n approved: 'Approved',\n denied: 'Denied',\n notDecided: 'Not decided: the run was stopped',\n wantsToRun: 'The agent wants to run',\n decision: 'Decision',\n stillWritingCall: 'The agent is still writing this call…',\n decidingUnavailable: 'Deciding is not available in this view.',\n decisionSent: (approved: boolean) => `${approved ? 'Approved.' : 'Denied.'} Waiting for the agent…`,\n decisionRefused: (kind: 'denied' | 'conflict' | 'unknown-question'): string => kind === 'denied' ? 'The host did not accept this answer.' : kind === 'conflict' ? 'This call was already decided.' : 'This call is no longer waiting for a decision.',\n decisionNotSent: 'The decision could not be sent.',\n waitingForAgent: 'Waiting for the agent…',\n question: 'Question',\n questionForYou: 'Question for you',\n notAnswered: 'Not answered',\n answered: 'Answered',\n askedQuestion: 'The agent asked a question.',\n answerOptions: 'Answer options',\n ownAnswer: 'Or type your own answer',\n typeAnswer: 'Type your answer',\n yourAnswerInput: 'Your answer',\n answer: 'Answer',\n stillWritingQuestion: 'The agent is still writing this question…',\n answeringUnavailable: 'Answering is not available in this view.',\n answerRefused: (kind: 'denied' | 'conflict' | 'unknown-question'): string => kind === 'denied' ? 'The host did not accept this answer.' : kind === 'conflict' ? 'This question was already answered.' : 'This question is no longer waiting for an answer.',\n answerNotSent: 'The answer could not be sent.',\n yourAnswer: 'Your answer:',\n // Artifact cards\n artifact: 'Artifact',\n opening: 'Opening…',\n viewing: 'Viewing',\n version: (ordinal: number) => `Version ${ordinal}`,\n runCommand: (name: string, execute: boolean) => `${execute ? 'Run' : 'Insert'} /${name} command`,\n taskStatus: 'Task status',\n activityFailed: (count: number, tools: string) => `${count > 1 ? `${count} steps failed` : 'Step failed'}: ${tools}`,\n activityUsed: (summary: string, stopped: boolean) => `${stopped ? 'Stopped · ' : 'Used '}${summary}`,\n activityThought: 'Thought it through',\n activityMany: (steps: number) => `Worked through ${steps} steps`,\n open: 'Open',\n openArtifact: (title: string, version: number | undefined) => version === undefined ? `Open ${title}` : `Open ${title}, version ${version}`,\n};\n\nexport type ChatLabels = typeof defaultChatLabels;\n\n/** The chat's icons. Any component that takes `className` (every lucide icon does). */\nexport const defaultChatIcons = {\n /** The agent's glyph in the empty state (the header shows the host's `avatar`, if any). */\n agent: MessagesSquareIcon as BlockIcon,\n send: ArrowUpIcon as BlockIcon,\n stop: SquareIcon as BlockIcon,\n history: HistoryIcon as BlockIcon,\n newChat: PlusIcon as BlockIcon,\n add: PlusIcon as BlockIcon,\n attach: PaperclipIcon as BlockIcon,\n queue: ListEndIcon as BlockIcon,\n jumpToLatest: ArrowDownIcon as BlockIcon,\n reconnect: RefreshCwIcon as BlockIcon,\n fork: GitForkIcon as BlockIcon,\n};\n\nexport type ChatIcons = typeof defaultChatIcons;\n\n/** What the chat's pieces read: the merged labels and icons. */\nexport interface ChatText { readonly labels: ChatLabels; readonly icons: ChatIcons }\n/** The defaults, as `useMergedText`'s base. */\nexport const defaultChatText: ChatText = { labels: defaultChatLabels, icons: defaultChatIcons };\nconst DEFAULT_TEXT = defaultChatText;\nconst ChatTextContext = createContext<ChatText>(DEFAULT_TEXT);\n\n/** The labels and icons of the surrounding chat (the defaults outside one). */\nexport const useChatText = (): ChatText => useContext(ChatTextContext);\n\n/** Merges the host's partial labels and icons over the defaults (or over `base`), keeping one object while they do not change. */\nexport function useMergedText<L extends ChatLabels = ChatLabels, I extends ChatIcons = ChatIcons>(labels: Partial<L> | undefined, icons: Partial<I> | undefined,\n base: { readonly labels: L; readonly icons: I } = DEFAULT_TEXT as { readonly labels: L; readonly icons: I }): { readonly labels: L; readonly icons: I } {\n return useMemo(() => ({ labels: withDefaults(base.labels, labels), icons: withDefaults(base.icons, icons) }), [labels, icons, base]);\n}\n\n/** Provides the chat's labels and icons to everything inside (composer, queue, rows, history). */\nexport function ChatTextProvider({ value, children }: { readonly value: ChatText; readonly children: ReactNode }) {\n return createElement(ChatTextContext.Provider, { value }, children);\n}\n",
178
+ "content": "/*\n * Every word the chat shows, and the icons it draws, as plain data. A host passes a partial `labels` (and `icons`) object to `PiChat`\n * (or `AmbientChat`, `AgentWorkspace`): what it leaves out keeps the default below. Strings that need a value are functions. No i18n\n * framework: an app that translates passes its own object per language.\n */\nimport { createContext, createElement, useContext, useMemo } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowDownIcon, ArrowUpIcon, GitForkIcon, HistoryIcon, ListEndIcon, MessagesSquareIcon, PaperclipIcon, PlusIcon, RefreshCwIcon, SquareIcon } from 'lucide-react';\nimport type { BlockIcon } from '../button/actions';\nimport { withDefaults } from '../utils/utils';\n\nconst TOOL_VERBS: Readonly<Record<string, string>> = { read: 'Reading', write: 'Writing', edit: 'Editing', ls: 'Listing', list_files: 'Listing', find: 'Searching', grep: 'Searching', load_skill: 'Loading skill', bash: 'Running bash' };\nconst TOOL_NOUNS: Readonly<Record<string, string>> = { bash: 'command', read: 'read', write: 'write', edit: 'edit', find: 'find', grep: 'search', ls: 'list' };\n\nconst EFFORT_NAMES: Readonly<Record<string, string>> = { off: 'Off', minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', max: 'Max' };\nconst EFFORT_DETAILS: Readonly<Record<string, string>> = { off: 'No extra reasoning', minimal: 'Barely any reasoning', low: 'Light reasoning', medium: 'Balanced reasoning', high: 'Deep reasoning', xhigh: 'Deeper reasoning', max: 'Maximum reasoning' };\n\nexport const defaultChatLabels = {\n // Header and transcript\n /** The chat's name: the header title, the region's name and the empty state's title. */\n title: 'Conversation',\n history: 'History',\n live: 'Live',\n connecting: 'Connecting…',\n reconnecting: 'Reconnecting…',\n loadFailed: 'Conversation could not be loaded.',\n disconnected: 'Disconnected. Last observed messages remain visible.',\n reconnect: 'Reconnect',\n requiredActions: 'Required conversation actions',\n messages: 'Messages',\n showEarlier: 'Show earlier messages',\n loading: 'Loading conversation…',\n emptyDescription: 'Start a conversation.',\n jumpToLatest: 'Jump to latest',\n agentWorking: 'Agent is working',\n waitingForAnswer: 'Waiting for your answer',\n retryPending: 'Native retry pending',\n compacting: 'Native context compaction in progress',\n /** The \"…\" menu of host actions (header, replies). */\n moreActions: 'More actions',\n // Notices\n sendUnknown: 'Submission acknowledgement is unknown. The message is kept until you check or retry it.',\n checkSubmission: 'Check original submission',\n retrySame: 'Retry same request',\n stopRequested: 'Stop requested. Waiting for native confirmation.',\n stopUnconfirmed: 'Stop is unconfirmed. Work may still be running.',\n steerRefused: 'The message could not be steered; it is back in the message box.',\n tooLate: 'Too late: that message has already started.',\n tooLateToRemove: 'Too late to remove: that message has already started.',\n queueFailed: 'The message could not be changed.',\n queueLocked: 'Queued messages cannot be changed here.',\n waitToSteer: 'Wait for the current send to finish, then steer.',\n steerFailed: 'The message could not be steered.',\n connectionFailed: 'Connection failed',\n actionFailedShort: 'Action failed',\n uploadFailedShort: 'Upload failed',\n changeRefused: 'The change was refused',\n dismissHint: 'click to dismiss',\n // Composer\n placeholder: 'Message the agent…',\n placeholderWorking: 'Queue a message…',\n messageInput: 'Message',\n send: 'Send',\n sendHint: 'Send (Enter)',\n stop: 'Stop',\n queue: 'Queue',\n add: 'Add',\n attachFiles: 'Attach files',\n commands: 'Commands',\n annotate: 'Annotate',\n annotateHint: 'Annotate: click what you want to comment on (Esc to stop)',\n annotateNotes: (count: number) => `${count} ${count === 1 ? 'note' : 'notes'}`,\n annotateClear: 'Remove the notes',\n remove: (name: string) => `Remove ${name}`,\n dismiss: (name: string) => `Dismiss ${name}`,\n uploading: (name: string) => `Uploading ${name}…`,\n uploadFailed: (name: string, error: string | undefined) => `${name}: ${error ?? 'upload failed'}`,\n preparingAttachments: 'Preparing attachments…',\n preparingAttachmentsShort: 'Preparing attachments',\n mentionedFiles: 'Mentioned files',\n uploads: 'Uploads',\n attachments: 'Attachments',\n searchCommands: 'Search commands',\n filterBySource: 'Filter by source',\n allSources: 'All',\n noCommands: 'No matching commands.',\n /** A `/` menu filter chip: `skills`, `built-in` or a plugin's name. */\n slashGroup: (group: string) => group,\n skillBadge: 'skill',\n workspaceFiles: 'Workspace files',\n searchFailed: 'Search failed',\n model: 'Model',\n effort: 'Effort',\n effortDefault: 'Default',\n modelAndEffort: 'Model and effort',\n modelSummary: (model: string | undefined, effort: string | undefined) => [model && `Model: ${model}`, effort && `Effort: ${effort}`].filter(Boolean).join(', '),\n /** A thinking level's name and short description in the picker. */\n effortName: (level: string) => EFFORT_NAMES[level] ?? level,\n effortDetail: (level: string): string | undefined => EFFORT_DETAILS[level],\n // Voice input (the microphone button beside the composer)\n voiceRecord: 'Record a message',\n voiceStop: 'Stop recording',\n voiceCancel: 'Cancel recording',\n voiceTranscribing: 'Transcribing…',\n voiceNothingHeard: 'Nothing was heard. Try again.',\n voiceTranscribeFailed: 'The recording could not be transcribed. Try again.',\n voiceDenied: 'Microphone blocked. Allow it for this site in the browser, then press record again.',\n voiceNoMicrophone: 'No microphone found. Connect one, then press record again.',\n voiceBusy: 'The microphone is busy or could not be opened. Close other apps using it and try again.',\n voiceInsecure: 'Recording needs a secure page (https or localhost).',\n voiceUnsupported: 'This browser cannot record audio here.',\n voiceStartFailed: 'The microphone could not be started.',\n // Queue\n queuedMessages: 'Queued messages',\n steer: 'Steer',\n steerHint: 'Send this into the running turn now',\n steering: 'Steering',\n queued: 'Queued',\n removeQueued: 'Remove queued message',\n removeHint: 'Remove',\n more: 'More',\n edit: 'Edit',\n sending: 'Sending',\n images: (count: number) => `[${count} image${count === 1 ? '' : 's'}]`,\n // Conversation list and history\n historyDialog: 'Conversation history',\n newConversation: 'New',\n untitled: 'New conversation',\n closeHistory: 'Close history',\n close: 'Close',\n earlierInConversation: 'Earlier messages in this conversation',\n searchConversations: 'Search conversations',\n archived: 'Archived',\n current: 'Current',\n noMatch: (query: string) => `No conversation matches “${query}”.`,\n noArchived: 'No archived conversations.',\n noConversations: 'No conversations yet.',\n loadingConversations: 'Loading conversations…',\n actionFailed: 'That did not work. Try again.',\n rename: 'Rename',\n renameItem: (title: string) => `Rename ${title}`,\n conversationName: 'Conversation name',\n save: 'Save',\n archive: 'Archive',\n restore: 'Restore',\n archiveItem: (title: string, archived: boolean) => `${archived ? 'Restore' : 'Archive'} ${title}`,\n delete: 'Delete',\n deleteItem: (title: string) => `Delete ${title}`,\n keep: 'Keep conversation',\n today: 'Today',\n yesterday: 'Yesterday',\n previousWeek: 'Previous 7 days',\n older: 'Older',\n conversations: 'Conversations',\n earlierMessages: 'Earlier messages',\n readOnly: '(read-only)',\n retry: 'Retry',\n loadingShort: 'Loading…',\n loadEarlier: 'Load earlier',\n returnToConversation: 'Return to active conversation',\n historyFailed: 'Conversation history could not be loaded. The previous page remains visible.',\n loadPage: 'Load a page to browse earlier messages.',\n noRecords: 'No records in this history page.',\n beginning: 'Beginning of conversation.',\n justNow: 'now',\n roleSystem: 'System',\n roleToolResult: (tool: string, failed: boolean) => `Tool result: ${tool}${failed ? ' (failed)' : ''}`,\n roleUser: 'You',\n roleAssistant: (outcome: 'ok' | 'interrupted' | 'failed'): string => outcome === 'interrupted' ? 'Assistant (interrupted)' : outcome === 'failed' ? 'Assistant (failed)' : 'Assistant',\n roleEvent: 'Event',\n contextBoundary: 'Context boundary',\n // Messages\n copy: 'Copy',\n copied: 'Copied',\n copyFailed: 'Copy failed',\n failed: 'Failed',\n copyResponse: 'Copy response',\n copyCode: 'Copy code',\n fork: 'Fork from here',\n forkHint: 'Fork from here: a new conversation with the messages up to this reply',\n responseFailed: 'The response failed',\n interrupted: 'Response interrupted',\n thinking: 'Thinking…',\n openImage: 'Open image attachment',\n imageAttachment: 'Image attachment',\n systemContext: 'System context',\n toolFailed: (tool: string) => `${tool} failed`,\n event: (label: string) => `Event: ${label}`,\n // Activity and tools\n reasoning: 'Reasoning',\n thinkingStep: 'Thinking',\n steps: (count: number) => `${count} ${count === 1 ? 'step' : 'steps'}`,\n toolRunning: 'Running',\n toolCompleted: 'Completed',\n toolFailedStatus: 'Failed',\n toolUnfinished: 'No result',\n arguments: 'Arguments',\n result: 'Result',\n error: 'Error',\n openImageResult: 'Open image result',\n emptyResult: 'Empty result',\n /** The live line of a running tool call; `hint` is what it acts on (a path, a command), possibly empty. */\n toolActivity: (tool: string, hint: string) => {\n const verb = TOOL_VERBS[tool] ?? `Running ${tool}`;\n return hint ? `${verb}${tool === 'bash' ? ':' : ''} ${hint}` : verb;\n },\n /** How a tool is named in a finished activity summary (\"Used read ×3 · command\"). */\n toolNoun: (tool: string) => TOOL_NOUNS[tool] ?? tool,\n // Approval and question cards\n approvalNeeded: 'Approval needed',\n needsApproval: 'Needs your approval',\n approve: 'Approve',\n deny: 'Deny',\n approved: 'Approved',\n denied: 'Denied',\n notDecided: 'Not decided: the run was stopped',\n wantsToRun: 'The agent wants to run',\n decision: 'Decision',\n stillWritingCall: 'The agent is still writing this call…',\n decidingUnavailable: 'Deciding is not available in this view.',\n decisionSent: (approved: boolean) => `${approved ? 'Approved.' : 'Denied.'} Waiting for the agent…`,\n decisionRefused: (kind: 'denied' | 'conflict' | 'unknown-question'): string => kind === 'denied' ? 'The host did not accept this answer.' : kind === 'conflict' ? 'This call was already decided.' : 'This call is no longer waiting for a decision.',\n decisionNotSent: 'The decision could not be sent.',\n waitingForAgent: 'Waiting for the agent…',\n question: 'Question',\n questionForYou: 'Question for you',\n notAnswered: 'Not answered',\n answered: 'Answered',\n askedQuestion: 'The agent asked a question.',\n answerOptions: 'Answer options',\n ownAnswer: 'Or type your own answer',\n typeAnswer: 'Type your answer',\n yourAnswerInput: 'Your answer',\n answer: 'Answer',\n stillWritingQuestion: 'The agent is still writing this question…',\n answeringUnavailable: 'Answering is not available in this view.',\n answerRefused: (kind: 'denied' | 'conflict' | 'unknown-question'): string => kind === 'denied' ? 'The host did not accept this answer.' : kind === 'conflict' ? 'This question was already answered.' : 'This question is no longer waiting for an answer.',\n answerNotSent: 'The answer could not be sent.',\n yourAnswer: 'Your answer:',\n // Artifact cards\n artifact: 'Artifact',\n opening: 'Opening…',\n viewing: 'Viewing',\n version: (ordinal: number) => `Version ${ordinal}`,\n runCommand: (name: string, execute: boolean) => `${execute ? 'Run' : 'Insert'} /${name} command`,\n taskStatus: 'Task status',\n activityFailed: (count: number, tools: string) => `${count > 1 ? `${count} steps failed` : 'Step failed'}: ${tools}`,\n activityUsed: (summary: string, stopped: boolean) => `${stopped ? 'Stopped · ' : 'Used '}${summary}`,\n activityThought: 'Thought it through',\n activityMany: (steps: number) => `Worked through ${steps} steps`,\n open: 'Open',\n openArtifact: (title: string, version: number | undefined) => version === undefined ? `Open ${title}` : `Open ${title}, version ${version}`,\n};\n\nexport type ChatLabels = typeof defaultChatLabels;\n\n/** The chat's icons. Any component that takes `className` (every lucide icon does). */\nexport const defaultChatIcons = {\n /** The agent's glyph in the empty state (the header shows the host's `avatar`, if any). */\n agent: MessagesSquareIcon as BlockIcon,\n send: ArrowUpIcon as BlockIcon,\n stop: SquareIcon as BlockIcon,\n history: HistoryIcon as BlockIcon,\n newChat: PlusIcon as BlockIcon,\n add: PlusIcon as BlockIcon,\n attach: PaperclipIcon as BlockIcon,\n queue: ListEndIcon as BlockIcon,\n jumpToLatest: ArrowDownIcon as BlockIcon,\n reconnect: RefreshCwIcon as BlockIcon,\n fork: GitForkIcon as BlockIcon,\n};\n\nexport type ChatIcons = typeof defaultChatIcons;\n\n/** What the chat's pieces read: the merged labels and icons. */\nexport interface ChatText { readonly labels: ChatLabels; readonly icons: ChatIcons }\n/** The defaults, as `useMergedText`'s base. */\nexport const defaultChatText: ChatText = { labels: defaultChatLabels, icons: defaultChatIcons };\nconst DEFAULT_TEXT = defaultChatText;\nconst ChatTextContext = createContext<ChatText>(DEFAULT_TEXT);\n\n/** The labels and icons of the surrounding chat (the defaults outside one). */\nexport const useChatText = (): ChatText => useContext(ChatTextContext);\n\n/** Merges the host's partial labels and icons over the defaults (or over `base`), keeping one object while they do not change. */\nexport function useMergedText<L extends ChatLabels = ChatLabels, I extends ChatIcons = ChatIcons>(labels: Partial<L> | undefined, icons: Partial<I> | undefined,\n base: { readonly labels: L; readonly icons: I } = DEFAULT_TEXT as { readonly labels: L; readonly icons: I }): { readonly labels: L; readonly icons: I } {\n return useMemo(() => ({ labels: withDefaults(base.labels, labels), icons: withDefaults(base.icons, icons) }), [labels, icons, base]);\n}\n\n/** Provides the chat's labels and icons to everything inside (composer, queue, rows, history). */\nexport function ChatTextProvider({ value, children }: { readonly value: ChatText; readonly children: ReactNode }) {\n return createElement(ChatTextContext.Provider, { value }, children);\n}\n",
173
179
  "type": "registry:lib",
174
180
  "target": "components/pi-chat/labels.ts"
175
181
  },
176
182
  {
177
183
  "path": "registry/pi-chat/labels-fr.ts",
178
- "content": "/* The chat's words in French, the `locale=\"fr\"` base of `PiChat`, `AmbientChat` and `AgentWorkspace`. Typed as `ChatLabels`, so a key added in English must be added here too. */\nimport type { ChatLabels } from './labels';\n\nconst TOOL_VERBS: Readonly<Record<string, string>> = { read: 'Lecture', write: 'Écriture', edit: 'Modification', ls: 'Liste', list_files: 'Liste', find: 'Recherche', grep: 'Recherche', load_skill: 'Chargement de la compétence', bash: 'Exécution bash' };\nconst TOOL_NOUNS: Readonly<Record<string, string>> = { bash: 'commande', read: 'lecture', write: 'écriture', edit: 'modification', find: 'recherche', grep: 'recherche', ls: 'liste' };\nconst EFFORT_NAMES: Readonly<Record<string, string>> = { off: 'Désactivé', minimal: 'Minimal', low: 'Faible', medium: 'Moyen', high: 'Élevé', xhigh: 'Très élevé', max: 'Maximum' };\nconst EFFORT_DETAILS: Readonly<Record<string, string>> = { off: 'Sans réflexion supplémentaire', minimal: 'Presque aucune réflexion', low: 'Réflexion légère', medium: 'Réflexion équilibrée', high: 'Réflexion approfondie', xhigh: 'Réflexion très approfondie', max: 'Réflexion maximale' };\n\nexport const frenchChatLabels: ChatLabels = {\n title: 'Conversation',\n history: 'Historique',\n live: 'En direct',\n connecting: 'Connexion…',\n reconnecting: 'Reconnexion…',\n loadFailed: 'La conversation n’a pas pu être chargée.',\n disconnected: 'Déconnecté. Les derniers messages reçus restent affichés.',\n reconnect: 'Se reconnecter',\n requiredActions: 'Actions requises dans la conversation',\n messages: 'Messages',\n showEarlier: 'Afficher les messages précédents',\n loading: 'Chargement de la conversation…',\n emptyDescription: 'Commencez une conversation.',\n jumpToLatest: 'Aller au dernier message',\n agentWorking: 'L’agent travaille',\n waitingForAnswer: 'En attente de votre réponse',\n retryPending: 'Nouvelle tentative en attente',\n compacting: 'Résumé du contexte en cours',\n moreActions: 'Plus d’actions',\n sendUnknown: 'L’envoi n’est pas confirmé. Le message est conservé jusqu’à ce que vous le vérifiiez ou le renvoyiez.',\n checkSubmission: 'Vérifier l’envoi',\n retrySame: 'Renvoyer la même demande',\n stopRequested: 'Arrêt demandé. En attente de confirmation.',\n stopUnconfirmed: 'Arrêt non confirmé. Le travail continue peut-être.',\n steerRefused: 'Le message n’a pas pu être ajouté en cours de réponse ; il est revenu dans la zone de saisie.',\n tooLate: 'Trop tard : ce message a déjà commencé.',\n tooLateToRemove: 'Trop tard pour le retirer : ce message a déjà commencé.',\n queueFailed: 'Le message n’a pas pu être modifié.',\n queueLocked: 'Les messages en attente ne peuvent pas être modifiés ici.',\n waitToSteer: 'Attendez la fin de l’envoi en cours, puis réessayez.',\n steerFailed: 'Le message n’a pas pu être ajouté en cours de réponse.',\n connectionFailed: 'Échec de la connexion',\n actionFailedShort: 'Échec de l’action',\n uploadFailedShort: 'Échec de l’envoi',\n changeRefused: 'La modification a été refusée',\n dismissHint: 'cliquez pour fermer',\n placeholder: 'Écrivez à l’agent…',\n placeholderWorking: 'Mettre un message en attente…',\n messageInput: 'Message',\n send: 'Envoyer',\n sendHint: 'Envoyer (Entrée)',\n stop: 'Arrêter',\n queue: 'Mettre en attente',\n add: 'Ajouter',\n attachFiles: 'Joindre des fichiers',\n // Voice input (the microphone button beside the composer)\n voiceRecord: 'Enregistrer un message',\n voiceStop: 'Arrêter l’enregistrement',\n voiceCancel: 'Annuler l’enregistrement',\n voiceTranscribing: 'Transcription…',\n voiceNothingHeard: 'Rien n’a été entendu. Réessayez.',\n voiceTranscribeFailed: 'L’enregistrement n’a pas pu être transcrit. Réessayez.',\n voiceDenied: 'Microphone bloqué. Autorisez-le pour ce site dans le navigateur, puis appuyez de nouveau sur enregistrer.',\n voiceNoMicrophone: 'Aucun microphone trouvé. Branchez-en un, puis appuyez de nouveau sur enregistrer.',\n voiceBusy: 'Le microphone est occupé ou n’a pas pu être ouvert. Fermez les autres applications qui l’utilisent et réessayez.',\n voiceInsecure: 'L’enregistrement exige une page sécurisée (https ou localhost).',\n voiceUnsupported: 'Ce navigateur ne peut pas enregistrer le son ici.',\n voiceStartFailed: 'Le microphone n’a pas pu être démarré.',\n commands: 'Commandes',\n feedback: 'Retour',\n feedbackHint: 'Retour : montrez l’endroit de la page et dites ce qui ne va pas',\n point: 'Montrer dans l’app',\n pointHint: 'Montrer dans l’app : le panneau s’efface, touchez ce dont vous parlez',\n remove: name => `Retirer ${name}`,\n dismiss: name => `Fermer ${name}`,\n uploading: name => `Envoi de ${name}…`,\n uploadFailed: (name, error) => `${name} : ${error ?? 'échec de l’envoi'}`,\n preparingAttachments: 'Préparation des pièces jointes…',\n preparingAttachmentsShort: 'Préparation des pièces jointes',\n mentionedFiles: 'Fichiers mentionnés',\n uploads: 'Envois',\n attachments: 'Pièces jointes',\n searchCommands: 'Rechercher une commande',\n filterBySource: 'Filtrer par source',\n allSources: 'Toutes',\n noCommands: 'Aucune commande ne correspond.',\n slashGroup: group => group === 'skills' ? 'compétences' : group === 'built-in' ? 'intégrées' : group,\n skillBadge: 'compétence',\n workspaceFiles: 'Fichiers de l’espace',\n searchFailed: 'La recherche a échoué',\n model: 'Modèle',\n effort: 'Effort',\n effortDefault: 'Par défaut',\n modelAndEffort: 'Modèle et effort',\n modelSummary: (model, effort) => [model && `Modèle : ${model}`, effort && `Effort : ${effort}`].filter(Boolean).join(', '),\n effortName: level => EFFORT_NAMES[level] ?? level,\n effortDetail: level => EFFORT_DETAILS[level],\n queuedMessages: 'Messages en attente',\n steer: 'Envoyer maintenant',\n steerHint: 'Ajouter ce message à la réponse en cours',\n steering: 'Envoi en cours de réponse',\n queued: 'En attente',\n removeQueued: 'Retirer le message en attente',\n removeHint: 'Retirer',\n more: 'Plus',\n edit: 'Modifier',\n sending: 'Envoi',\n images: count => `[${count} image${count === 1 ? '' : 's'}]`,\n historyDialog: 'Historique des conversations',\n newConversation: 'Nouvelle',\n untitled: 'Nouvelle conversation',\n closeHistory: 'Fermer l’historique',\n close: 'Fermer',\n earlierInConversation: 'Messages précédents de cette conversation',\n searchConversations: 'Rechercher une conversation',\n archived: 'Archivées',\n current: 'En cours',\n noMatch: query => `Aucune conversation ne correspond à « ${query} ».`,\n noArchived: 'Aucune conversation archivée.',\n noConversations: 'Aucune conversation pour le moment.',\n loadingConversations: 'Chargement des conversations…',\n actionFailed: 'Cela n’a pas fonctionné. Réessayez.',\n rename: 'Renommer',\n renameItem: title => `Renommer ${title}`,\n conversationName: 'Nom de la conversation',\n save: 'Enregistrer',\n archive: 'Archiver',\n restore: 'Restaurer',\n archiveItem: (title, archived) => `${archived ? 'Restaurer' : 'Archiver'} ${title}`,\n delete: 'Supprimer',\n deleteItem: title => `Supprimer ${title}`,\n keep: 'Garder la conversation',\n today: 'Aujourd’hui',\n yesterday: 'Hier',\n previousWeek: 'Les 7 derniers jours',\n older: 'Plus anciennes',\n conversations: 'Conversations',\n earlierMessages: 'Messages précédents',\n readOnly: '(lecture seule)',\n retry: 'Réessayer',\n loadingShort: 'Chargement…',\n loadEarlier: 'Charger les précédents',\n returnToConversation: 'Revenir à la conversation en cours',\n historyFailed: 'L’historique n’a pas pu être chargé. La page précédente reste affichée.',\n loadPage: 'Chargez une page pour parcourir les messages précédents.',\n noRecords: 'Aucun élément sur cette page d’historique.',\n beginning: 'Début de la conversation.',\n justNow: 'à l’instant',\n roleSystem: 'Système',\n roleToolResult: (tool, failed) => `Résultat de l’outil : ${tool}${failed ? ' (échec)' : ''}`,\n roleUser: 'Vous',\n roleAssistant: outcome => outcome === 'interrupted' ? 'Assistant (interrompu)' : outcome === 'failed' ? 'Assistant (échec)' : 'Assistant',\n roleEvent: 'Événement',\n contextBoundary: 'Limite du contexte',\n copy: 'Copier',\n copied: 'Copié',\n copyFailed: 'Échec de la copie',\n failed: 'Échec',\n copyResponse: 'Copier la réponse',\n copyCode: 'Copier le code',\n fork: 'Repartir d’ici',\n forkHint: 'Repartir d’ici : une nouvelle conversation avec les messages jusqu’à cette réponse',\n responseFailed: 'La réponse a échoué',\n interrupted: 'Réponse interrompue',\n thinking: 'Réflexion…',\n openImage: 'Ouvrir l’image jointe',\n imageAttachment: 'Image jointe',\n systemContext: 'Contexte système',\n toolFailed: tool => `Échec de ${tool}`,\n event: label => `Événement : ${label}`,\n reasoning: 'Réflexion',\n thinkingStep: 'Réflexion',\n steps: count => `${count} ${count === 1 ? 'étape' : 'étapes'}`,\n toolRunning: 'En cours',\n toolCompleted: 'Terminé',\n toolFailedStatus: 'Échec',\n toolUnfinished: 'Sans résultat',\n arguments: 'Arguments',\n result: 'Résultat',\n error: 'Erreur',\n openImageResult: 'Ouvrir l’image du résultat',\n emptyResult: 'Résultat vide',\n toolActivity: (tool, hint) => {\n const verb = TOOL_VERBS[tool] ?? `Exécution de ${tool}`;\n return hint ? `${verb}${tool === 'bash' ? ' :' : ''} ${hint}` : verb;\n },\n toolNoun: tool => TOOL_NOUNS[tool] ?? tool,\n approvalNeeded: 'Approbation requise',\n needsApproval: 'Votre approbation est nécessaire',\n approve: 'Approuver',\n deny: 'Refuser',\n approved: 'Approuvé',\n denied: 'Refusé',\n notDecided: 'Non décidé : l’exécution a été arrêtée',\n wantsToRun: 'L’agent souhaite exécuter',\n decision: 'Décision',\n stillWritingCall: 'L’agent est encore en train d’écrire cet appel…',\n decidingUnavailable: 'La décision n’est pas disponible dans cette vue.',\n decisionSent: approved => `${approved ? 'Approuvé.' : 'Refusé.'} En attente de l’agent…`,\n decisionRefused: kind => kind === 'denied' ? 'Cette réponse n’a pas été acceptée.' : kind === 'conflict' ? 'Cet appel a déjà été décidé.' : 'Cet appel n’attend plus de décision.',\n decisionNotSent: 'La décision n’a pas pu être envoyée.',\n waitingForAgent: 'En attente de l’agent…',\n question: 'Question',\n questionForYou: 'Une question pour vous',\n notAnswered: 'Sans réponse',\n answered: 'Répondu',\n askedQuestion: 'L’agent a posé une question.',\n answerOptions: 'Réponses possibles',\n ownAnswer: 'Ou écrivez votre propre réponse',\n typeAnswer: 'Écrivez votre réponse',\n yourAnswerInput: 'Votre réponse',\n answer: 'Répondre',\n stillWritingQuestion: 'L’agent est encore en train d’écrire cette question…',\n answeringUnavailable: 'La réponse n’est pas disponible dans cette vue.',\n answerRefused: kind => kind === 'denied' ? 'Cette réponse n’a pas été acceptée.' : kind === 'conflict' ? 'Cette question a déjà reçu une réponse.' : 'Cette question n’attend plus de réponse.',\n answerNotSent: 'La réponse n’a pas pu être envoyée.',\n yourAnswer: 'Votre réponse :',\n artifact: 'Document',\n opening: 'Ouverture…',\n viewing: 'Affiché',\n version: ordinal => `Version ${ordinal}`,\n runCommand: (name, execute) => `${execute ? 'Exécuter' : 'Insérer'} la commande /${name}`,\n taskStatus: 'État de la tâche',\n activityFailed: (count, tools) => `${count > 1 ? `${count} étapes ont échoué` : 'Une étape a échoué'} : ${tools}`,\n activityUsed: (summary, stopped) => `${stopped ? 'Arrêté · ' : 'Utilisé : '}${summary}`,\n activityThought: 'Réflexion terminée',\n activityMany: steps => `${steps} étapes réalisées`,\n open: 'Ouvrir',\n openArtifact: (title, version) => version === undefined ? `Ouvrir ${title}` : `Ouvrir ${title}, version ${version}`,\n};\n",
184
+ "content": "/* The chat's words in French, the `locale=\"fr\"` base of `PiChat`, `AmbientChat` and `AgentWorkspace`. Typed as `ChatLabels`, so a key added in English must be added here too. */\nimport type { ChatLabels } from './labels';\n\nconst TOOL_VERBS: Readonly<Record<string, string>> = { read: 'Lecture', write: 'Écriture', edit: 'Modification', ls: 'Liste', list_files: 'Liste', find: 'Recherche', grep: 'Recherche', load_skill: 'Chargement de la compétence', bash: 'Exécution bash' };\nconst TOOL_NOUNS: Readonly<Record<string, string>> = { bash: 'commande', read: 'lecture', write: 'écriture', edit: 'modification', find: 'recherche', grep: 'recherche', ls: 'liste' };\nconst EFFORT_NAMES: Readonly<Record<string, string>> = { off: 'Désactivé', minimal: 'Minimal', low: 'Faible', medium: 'Moyen', high: 'Élevé', xhigh: 'Très élevé', max: 'Maximum' };\nconst EFFORT_DETAILS: Readonly<Record<string, string>> = { off: 'Sans réflexion supplémentaire', minimal: 'Presque aucune réflexion', low: 'Réflexion légère', medium: 'Réflexion équilibrée', high: 'Réflexion approfondie', xhigh: 'Réflexion très approfondie', max: 'Réflexion maximale' };\n\nexport const frenchChatLabels: ChatLabels = {\n title: 'Conversation',\n history: 'Historique',\n live: 'En direct',\n connecting: 'Connexion…',\n reconnecting: 'Reconnexion…',\n loadFailed: 'La conversation n’a pas pu être chargée.',\n disconnected: 'Déconnecté. Les derniers messages reçus restent affichés.',\n reconnect: 'Se reconnecter',\n requiredActions: 'Actions requises dans la conversation',\n messages: 'Messages',\n showEarlier: 'Afficher les messages précédents',\n loading: 'Chargement de la conversation…',\n emptyDescription: 'Commencez une conversation.',\n jumpToLatest: 'Aller au dernier message',\n agentWorking: 'L’agent travaille',\n waitingForAnswer: 'En attente de votre réponse',\n retryPending: 'Nouvelle tentative en attente',\n compacting: 'Résumé du contexte en cours',\n moreActions: 'Plus d’actions',\n sendUnknown: 'L’envoi n’est pas confirmé. Le message est conservé jusqu’à ce que vous le vérifiiez ou le renvoyiez.',\n checkSubmission: 'Vérifier l’envoi',\n retrySame: 'Renvoyer la même demande',\n stopRequested: 'Arrêt demandé. En attente de confirmation.',\n stopUnconfirmed: 'Arrêt non confirmé. Le travail continue peut-être.',\n steerRefused: 'Le message n’a pas pu être ajouté en cours de réponse ; il est revenu dans la zone de saisie.',\n tooLate: 'Trop tard : ce message a déjà commencé.',\n tooLateToRemove: 'Trop tard pour le retirer : ce message a déjà commencé.',\n queueFailed: 'Le message n’a pas pu être modifié.',\n queueLocked: 'Les messages en attente ne peuvent pas être modifiés ici.',\n waitToSteer: 'Attendez la fin de l’envoi en cours, puis réessayez.',\n steerFailed: 'Le message n’a pas pu être ajouté en cours de réponse.',\n connectionFailed: 'Échec de la connexion',\n actionFailedShort: 'Échec de l’action',\n uploadFailedShort: 'Échec de l’envoi',\n changeRefused: 'La modification a été refusée',\n dismissHint: 'cliquez pour fermer',\n placeholder: 'Écrivez à l’agent…',\n placeholderWorking: 'Mettre un message en attente…',\n messageInput: 'Message',\n send: 'Envoyer',\n sendHint: 'Envoyer (Entrée)',\n stop: 'Arrêter',\n queue: 'Mettre en attente',\n add: 'Ajouter',\n attachFiles: 'Joindre des fichiers',\n // Voice input (the microphone button beside the composer)\n voiceRecord: 'Enregistrer un message',\n voiceStop: 'Arrêter l’enregistrement',\n voiceCancel: 'Annuler l’enregistrement',\n voiceTranscribing: 'Transcription…',\n voiceNothingHeard: 'Rien n’a été entendu. Réessayez.',\n voiceTranscribeFailed: 'L’enregistrement n’a pas pu être transcrit. Réessayez.',\n voiceDenied: 'Microphone bloqué. Autorisez-le pour ce site dans le navigateur, puis appuyez de nouveau sur enregistrer.',\n voiceNoMicrophone: 'Aucun microphone trouvé. Branchez-en un, puis appuyez de nouveau sur enregistrer.',\n voiceBusy: 'Le microphone est occupé ou n’a pas pu être ouvert. Fermez les autres applications qui l’utilisent et réessayez.',\n voiceInsecure: 'L’enregistrement exige une page sécurisée (https ou localhost).',\n voiceUnsupported: 'Ce navigateur ne peut pas enregistrer le son ici.',\n voiceStartFailed: 'Le microphone n’a pas pu être démarré.',\n commands: 'Commandes',\n annotate: 'Annoter',\n annotateHint: 'Annoter : cliquez sur ce que vous voulez commenter (Échap pour arrêter)',\n annotateNotes: count => `${count} ${count === 1 ? 'note' : 'notes'}`,\n annotateClear: 'Retirer les notes',\n remove: name => `Retirer ${name}`,\n dismiss: name => `Fermer ${name}`,\n uploading: name => `Envoi de ${name}…`,\n uploadFailed: (name, error) => `${name} : ${error ?? 'échec de l’envoi'}`,\n preparingAttachments: 'Préparation des pièces jointes…',\n preparingAttachmentsShort: 'Préparation des pièces jointes',\n mentionedFiles: 'Fichiers mentionnés',\n uploads: 'Envois',\n attachments: 'Pièces jointes',\n searchCommands: 'Rechercher une commande',\n filterBySource: 'Filtrer par source',\n allSources: 'Toutes',\n noCommands: 'Aucune commande ne correspond.',\n slashGroup: group => group === 'skills' ? 'compétences' : group === 'built-in' ? 'intégrées' : group,\n skillBadge: 'compétence',\n workspaceFiles: 'Fichiers de l’espace',\n searchFailed: 'La recherche a échoué',\n model: 'Modèle',\n effort: 'Effort',\n effortDefault: 'Par défaut',\n modelAndEffort: 'Modèle et effort',\n modelSummary: (model, effort) => [model && `Modèle : ${model}`, effort && `Effort : ${effort}`].filter(Boolean).join(', '),\n effortName: level => EFFORT_NAMES[level] ?? level,\n effortDetail: level => EFFORT_DETAILS[level],\n queuedMessages: 'Messages en attente',\n steer: 'Envoyer maintenant',\n steerHint: 'Ajouter ce message à la réponse en cours',\n steering: 'Envoi en cours de réponse',\n queued: 'En attente',\n removeQueued: 'Retirer le message en attente',\n removeHint: 'Retirer',\n more: 'Plus',\n edit: 'Modifier',\n sending: 'Envoi',\n images: count => `[${count} image${count === 1 ? '' : 's'}]`,\n historyDialog: 'Historique des conversations',\n newConversation: 'Nouvelle',\n untitled: 'Nouvelle conversation',\n closeHistory: 'Fermer l’historique',\n close: 'Fermer',\n earlierInConversation: 'Messages précédents de cette conversation',\n searchConversations: 'Rechercher une conversation',\n archived: 'Archivées',\n current: 'En cours',\n noMatch: query => `Aucune conversation ne correspond à « ${query} ».`,\n noArchived: 'Aucune conversation archivée.',\n noConversations: 'Aucune conversation pour le moment.',\n loadingConversations: 'Chargement des conversations…',\n actionFailed: 'Cela n’a pas fonctionné. Réessayez.',\n rename: 'Renommer',\n renameItem: title => `Renommer ${title}`,\n conversationName: 'Nom de la conversation',\n save: 'Enregistrer',\n archive: 'Archiver',\n restore: 'Restaurer',\n archiveItem: (title, archived) => `${archived ? 'Restaurer' : 'Archiver'} ${title}`,\n delete: 'Supprimer',\n deleteItem: title => `Supprimer ${title}`,\n keep: 'Garder la conversation',\n today: 'Aujourd’hui',\n yesterday: 'Hier',\n previousWeek: 'Les 7 derniers jours',\n older: 'Plus anciennes',\n conversations: 'Conversations',\n earlierMessages: 'Messages précédents',\n readOnly: '(lecture seule)',\n retry: 'Réessayer',\n loadingShort: 'Chargement…',\n loadEarlier: 'Charger les précédents',\n returnToConversation: 'Revenir à la conversation en cours',\n historyFailed: 'L’historique n’a pas pu être chargé. La page précédente reste affichée.',\n loadPage: 'Chargez une page pour parcourir les messages précédents.',\n noRecords: 'Aucun élément sur cette page d’historique.',\n beginning: 'Début de la conversation.',\n justNow: 'à l’instant',\n roleSystem: 'Système',\n roleToolResult: (tool, failed) => `Résultat de l’outil : ${tool}${failed ? ' (échec)' : ''}`,\n roleUser: 'Vous',\n roleAssistant: outcome => outcome === 'interrupted' ? 'Assistant (interrompu)' : outcome === 'failed' ? 'Assistant (échec)' : 'Assistant',\n roleEvent: 'Événement',\n contextBoundary: 'Limite du contexte',\n copy: 'Copier',\n copied: 'Copié',\n copyFailed: 'Échec de la copie',\n failed: 'Échec',\n copyResponse: 'Copier la réponse',\n copyCode: 'Copier le code',\n fork: 'Repartir d’ici',\n forkHint: 'Repartir d’ici : une nouvelle conversation avec les messages jusqu’à cette réponse',\n responseFailed: 'La réponse a échoué',\n interrupted: 'Réponse interrompue',\n thinking: 'Réflexion…',\n openImage: 'Ouvrir l’image jointe',\n imageAttachment: 'Image jointe',\n systemContext: 'Contexte système',\n toolFailed: tool => `Échec de ${tool}`,\n event: label => `Événement : ${label}`,\n reasoning: 'Réflexion',\n thinkingStep: 'Réflexion',\n steps: count => `${count} ${count === 1 ? 'étape' : 'étapes'}`,\n toolRunning: 'En cours',\n toolCompleted: 'Terminé',\n toolFailedStatus: 'Échec',\n toolUnfinished: 'Sans résultat',\n arguments: 'Arguments',\n result: 'Résultat',\n error: 'Erreur',\n openImageResult: 'Ouvrir l’image du résultat',\n emptyResult: 'Résultat vide',\n toolActivity: (tool, hint) => {\n const verb = TOOL_VERBS[tool] ?? `Exécution de ${tool}`;\n return hint ? `${verb}${tool === 'bash' ? ' :' : ''} ${hint}` : verb;\n },\n toolNoun: tool => TOOL_NOUNS[tool] ?? tool,\n approvalNeeded: 'Approbation requise',\n needsApproval: 'Votre approbation est nécessaire',\n approve: 'Approuver',\n deny: 'Refuser',\n approved: 'Approuvé',\n denied: 'Refusé',\n notDecided: 'Non décidé : l’exécution a été arrêtée',\n wantsToRun: 'L’agent souhaite exécuter',\n decision: 'Décision',\n stillWritingCall: 'L’agent est encore en train d’écrire cet appel…',\n decidingUnavailable: 'La décision n’est pas disponible dans cette vue.',\n decisionSent: approved => `${approved ? 'Approuvé.' : 'Refusé.'} En attente de l’agent…`,\n decisionRefused: kind => kind === 'denied' ? 'Cette réponse n’a pas été acceptée.' : kind === 'conflict' ? 'Cet appel a déjà été décidé.' : 'Cet appel n’attend plus de décision.',\n decisionNotSent: 'La décision n’a pas pu être envoyée.',\n waitingForAgent: 'En attente de l’agent…',\n question: 'Question',\n questionForYou: 'Une question pour vous',\n notAnswered: 'Sans réponse',\n answered: 'Répondu',\n askedQuestion: 'L’agent a posé une question.',\n answerOptions: 'Réponses possibles',\n ownAnswer: 'Ou écrivez votre propre réponse',\n typeAnswer: 'Écrivez votre réponse',\n yourAnswerInput: 'Votre réponse',\n answer: 'Répondre',\n stillWritingQuestion: 'L’agent est encore en train d’écrire cette question…',\n answeringUnavailable: 'La réponse n’est pas disponible dans cette vue.',\n answerRefused: kind => kind === 'denied' ? 'Cette réponse n’a pas été acceptée.' : kind === 'conflict' ? 'Cette question a déjà reçu une réponse.' : 'Cette question n’attend plus de réponse.',\n answerNotSent: 'La réponse n’a pas pu être envoyée.',\n yourAnswer: 'Votre réponse :',\n artifact: 'Document',\n opening: 'Ouverture…',\n viewing: 'Affiché',\n version: ordinal => `Version ${ordinal}`,\n runCommand: (name, execute) => `${execute ? 'Exécuter' : 'Insérer'} la commande /${name}`,\n taskStatus: 'État de la tâche',\n activityFailed: (count, tools) => `${count > 1 ? `${count} étapes ont échoué` : 'Une étape a échoué'} : ${tools}`,\n activityUsed: (summary, stopped) => `${stopped ? 'Arrêté · ' : 'Utilisé : '}${summary}`,\n activityThought: 'Réflexion terminée',\n activityMany: steps => `${steps} étapes réalisées`,\n open: 'Ouvrir',\n openArtifact: (title, version) => version === undefined ? `Ouvrir ${title}` : `Ouvrir ${title}, version ${version}`,\n};\n",
179
185
  "type": "registry:lib",
180
186
  "target": "components/pi-chat/labels-fr.ts"
181
187
  },
@@ -8,8 +8,8 @@
8
8
  "react-dom@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/pi-chat.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.5/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.5/r/utils.json"
13
13
  ],
14
14
  "files": [
15
15
  {
@@ -9,8 +9,8 @@
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/theme.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.5/r/theme.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.5/r/utils.json"
14
14
  ],
15
15
  "files": [
16
16
  {