@hachej/boring-ui-kit 0.4.11 → 0.4.13

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.
Files changed (50) hide show
  1. package/dist/shared-chat/socket.js +3 -1
  2. package/dist/shared-chat/source.d.ts +7 -1
  3. package/package.json +2 -2
  4. package/r/agent-target.json +4 -4
  5. package/r/app-shell.json +10 -8
  6. package/r/board-viewer.json +12 -6
  7. package/r/button.json +1 -1
  8. package/r/diagram-viewer.json +8 -2
  9. package/r/dock.json +1 -1
  10. package/r/documents.json +14 -12
  11. package/r/feedback-preview.json +1 -1
  12. package/r/feedback.json +7 -6
  13. package/r/file-tree.json +5 -5
  14. package/r/highlights.json +3 -3
  15. package/r/html-viewer.json +3 -3
  16. package/r/looks-viewer.json +9 -3
  17. package/r/maquette-viewer.json +9 -3
  18. package/r/markdown-editor.json +3 -3
  19. package/r/mockup-viewer.json +14 -2
  20. package/r/pi-ambient.json +11 -8
  21. package/r/pi-app.json +31 -18
  22. package/r/pi-artifacts.json +32 -0
  23. package/r/pi-chat-approval.json +26 -0
  24. package/r/pi-chat-attachments.json +25 -0
  25. package/r/pi-chat-markdown.json +26 -0
  26. package/r/pi-chat-mentions.json +23 -0
  27. package/r/pi-chat-pickers.json +24 -0
  28. package/r/pi-chat-plugins.json +31 -0
  29. package/r/pi-chat-question.json +29 -0
  30. package/r/pi-chat-slash.json +26 -0
  31. package/r/pi-chat-subagent.json +34 -0
  32. package/r/pi-chat-voice.json +39 -0
  33. package/r/pi-chat.json +15 -97
  34. package/r/pi-workspace.json +4 -4
  35. package/r/placed-agent.json +4 -4
  36. package/r/provider-setup.json +2 -2
  37. package/r/record-viewer.json +8 -2
  38. package/r/registry.json +667 -242
  39. package/r/requests-board.json +1 -1
  40. package/r/shared-chat.json +13 -9
  41. package/r/task-list-viewer.json +2 -2
  42. package/r/version-history.json +3 -3
  43. package/r/viewer-code.json +36 -0
  44. package/r/viewer-frame.json +447 -0
  45. package/r/viewer-html.json +32 -0
  46. package/r/viewer-image.json +36 -0
  47. package/r/viewer-markdown.json +47 -0
  48. package/r/viewer-pdf.json +45 -0
  49. package/r/viewers.json +8 -491
  50. package/r/windows.json +1 -1
package/r/pi-chat.json CHANGED
@@ -2,15 +2,13 @@
2
2
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
3
  "name": "pi-chat",
4
4
  "title": "Pi chat",
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.",
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 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 question, approval, sub-agent and artifact cards are separate items (pi-chat-question, pi-chat-approval, pi-chat-subagent, pi-artifacts); without them a question or an approval shows a \"waiting for your answer\" notice, a sub-agent call is an ordinary tool step and no artifact is offered. The optional pi-ambient and pi-workspace blocks build on it.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.11",
8
- "@hachej/boring-files@^0.4.11",
9
- "@hachej/boring-feedback@^0.4.11",
7
+ "@hachej/boring-ui-kit@^0.4.13",
8
+ "@hachej/boring-files@^0.4.13",
9
+ "@hachej/boring-feedback@^0.4.13",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0",
12
- "marked@^17.0.6",
13
- "entities@^8.1.0",
14
12
  "@base-ui/react@1.9.0",
15
13
  "@earendil-works/pi-durable@~1.2.0",
16
14
  "@earendil-works/chord@~1.2.0",
@@ -21,8 +19,8 @@
21
19
  "zod@^4.6.5"
22
20
  ],
23
21
  "registryDependencies": [
24
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/utils.json",
25
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/button.json",
22
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/utils.json",
23
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/button.json",
26
24
  "dialog",
27
25
  "tooltip",
28
26
  "trace",
@@ -32,8 +30,6 @@
32
30
  "attachment",
33
31
  "badge",
34
32
  "alert",
35
- "checkbox",
36
- "table",
37
33
  "card",
38
34
  "spinner",
39
35
  "collapsible",
@@ -61,30 +57,6 @@
61
57
  "type": "registry:component",
62
58
  "target": "components/pi-chat/activity.tsx"
63
59
  },
64
- {
65
- "path": "registry/pi-chat/artifact-card.tsx",
66
- "content": "'use client';\n\nimport type { ComponentType } from 'react';\nimport { CheckIcon, ChevronRightIcon, FileCodeIcon, FileTextIcon, GlobeIcon, ImageIcon, PenToolIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Shimmer } from './shimmer';\nimport { artifactKey, artifactKind, typeLabel } from './artifact';\nimport type { ArtifactDescriptor, ArtifactKind, ArtifactType } from './artifact';\nimport { useChatText } from './labels';\n\nconst ICONS: Record<ArtifactType, ComponentType<{ readonly 'aria-hidden'?: boolean | 'true' }>> = {\n markdown: FileTextIcon, html: GlobeIcon, svg: ImageIcon, code: FileCodeIcon, canvas: PenToolIcon,\n};\n\n/**\n * An artifact in the transcript, as in Claude's chat apps: a compact card with a type icon, the title, the type and the\n * version. Clicking it asks the host to open the artifact. While the `present` call that makes it is still running it is a quiet\n * \"opening\" card with a shimmer. Rendered outside the activity block, like the question card.\n */\nexport function ArtifactCard({ artifact, pending, title, open = false, onOpen, kinds }: {\n readonly artifact?: ArtifactDescriptor | undefined;\n readonly pending?: 'presenting';\n readonly title?: string | undefined;\n readonly open?: boolean;\n readonly onOpen?: ((artifact: ArtifactDescriptor) => void) | undefined;\n /** The app's artifact kinds (`ArtifactsConfig.kinds`): their label and icon win over the type's. */\n readonly kinds?: readonly ArtifactKind[] | undefined;\n}) {\n const { labels } = useChatText();\n if (!artifact) {\n return <Item variant=\"outline\" size=\"sm\" data-testid=\"artifact-card\" data-state={pending ?? 'presenting'} role=\"status\" aria-busy=\"true\" className=\"my-2 text-start\">\n <ItemMedia variant=\"image\"><Item variant=\"muted\" size=\"xs\" className=\"min-h-full justify-center\"><ItemMedia variant=\"icon\"><FileTextIcon aria-hidden=\"true\" /></ItemMedia></Item></ItemMedia>\n <ItemContent className=\"min-w-0 basis-0\">\n <ItemTitle data-testid=\"artifact-title\"><Shimmer>{title ?? labels.artifact}</Shimmer></ItemTitle>\n <ItemDescription><Shimmer>{labels.opening}</Shimmer></ItemDescription>\n </ItemContent>\n </Item>;\n }\n const kind = artifactKind(artifact, kinds);\n const Icon = kind?.icon ?? ICONS[artifact.type];\n return <Button variant={open ? 'secondary' : 'outline'} disabled={!onOpen} onClick={() => onOpen?.(artifact)}\n data-testid=\"artifact-card\" data-state=\"ready\" data-artifact-id={artifactKey(artifact)} data-artifact-revision={artifact.revision} data-artifact-version={artifact.ordinal} data-artifact-type={artifact.type} data-artifact-kind={kind?.label}\n data-open={open ? 'true' : undefined} aria-pressed={onOpen ? open : undefined} aria-label={labels.openArtifact(artifact.title, artifact.ordinal)}\n className=\"my-2 h-auto min-h-12! w-full max-w-md justify-start gap-2.5 text-left\">\n <ItemMedia variant=\"image\"><Item variant=\"muted\" size=\"xs\" className=\"min-h-full justify-center\"><Icon aria-hidden=\"true\" /></Item></ItemMedia>\n <span className=\"flex min-w-0 flex-1 flex-col\">\n <span data-testid=\"artifact-title\" className=\"truncate\">{artifact.title}</span>\n <span className=\"truncate text-xs text-muted-foreground\"><span data-testid=\"artifact-type\">{typeLabel(artifact, kinds, labels.artifactTypes)}</span>{artifact.ordinal !== undefined && <> · <span data-testid=\"artifact-version\">{labels.version(artifact.ordinal)}</span></>}</span>\n </span>\n {open ? <CheckIcon data-icon=\"inline-end\" aria-hidden=\"true\" /> : <ChevronRightIcon data-icon=\"inline-end\" aria-hidden=\"true\" />}\n </Button>;\n}\n",
67
- "type": "registry:component",
68
- "target": "components/pi-chat/artifact-card.tsx"
69
- },
70
- {
71
- "path": "registry/pi-chat/approval-card.tsx",
72
- "content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { CheckIcon, ShieldAlertIcon, XIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemActions, ItemContent, ItemDescription, ItemFooter, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport type { AnswerOutcome } from './question-card';\nimport { useChatText } from './labels';\n\n// The strings `requireApproval` (`@hachej/boring-agent/approval`) asks with and puts at the start of a denial result.\nexport const APPROVE = 'Approve';\nexport const DENY = 'Deny';\nexport const DENIED_PREFIX = 'Denied by the person.';\n\n\n/** The arguments the call would run with, one line each. They come from the model's call itself: the card shows what will actually execute. */\nexport function describeArguments(call: ToolCall): { readonly name: string; readonly value: string }[] {\n return Object.entries(call.arguments as Record<string, unknown>).map(([name, value]) => {\n const text = typeof value === 'string' ? value : JSON.stringify(value) ?? '';\n return { name, value: text.length > 600 ? `${text.slice(0, 600)}…` : text };\n });\n}\n\n/**\n * The settled state. The wrapper records its decision in the call's details; a call that settled without one was never\n * decided (the run was stopped while it waited), so it is not shown as approved.\n */\nfunction decisionOf(result: ToolResultMessage | undefined, decision: 'approved' | 'denied' | 'cancelled' | undefined): 'pending' | 'denied' | 'approved' | 'cancelled' {\n if (!result) return 'pending';\n if (decision) return decision;\n const text = result.content.map(part => part.type === 'text' ? part.text : '').join('');\n return result.isError && text.startsWith(DENIED_PREFIX) ? 'denied' : 'cancelled';\n}\n\n/** Human approval for a gated tool call: pending (Approve and Deny), then approved or denied. Answering uses the host's `answer`. */\nexport interface ApprovalCardProps {\n readonly call: ToolCall;\n /** The question ID bound to this call's assistant entry (`derive` provides it); answers use it, never the bare call ID. */\n readonly questionId?: string | undefined;\n /** The decision `requireApproval` recorded in the call's details (`derive` finds it). Without one, a settled call shows as not decided. */\n readonly decision?: 'approved' | 'denied' | 'cancelled' | undefined;\n /** The one-line summary `requireApproval` published for this call (`derive` finds it). Shown first; the raw arguments are then collapsed under it. */\n readonly summary?: string | undefined;\n readonly result: ToolResultMessage | undefined;\n readonly answer?: ((callId: string, answer: string) => Promise<AnswerOutcome>) | undefined;\n /** The call is still being generated by the model: it cannot be decided yet. */\n readonly live?: boolean;\n}\nexport function ApprovalCard({ call, result, answer, questionId, summary, decision, live = false }: ApprovalCardProps) {\n const { labels } = useChatText();\n const [sent, setSent] = useState<string | undefined>();\n const [sending, setSending] = useState<string | undefined>();\n const [refusal, setRefusal] = useState<string | undefined>();\n const mounted = useRef(true);\n useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\n const settled = decisionOf(result, decision);\n // `pending` means the decision waits on the person; `writing` means the model is still generating the call.\n const state = settled !== 'pending' ? settled : sent === APPROVE ? 'approved-sent' : sent === DENY ? 'denied-sent' : live ? 'writing' : 'pending';\n const open = state === 'pending' || state === 'writing';\n const args = describeArguments(call);\n const busy = sending !== undefined || live;\n\n async function decide(value: string) {\n if (!answer || !questionId || busy) return;\n setRefusal(undefined); setSending(value);\n try {\n const outcome = await answer(questionId, value);\n if (outcome.kind === 'answered') setSent(value);\n else setRefusal(outcome.reason || labels.decisionRefused(outcome.kind));\n } catch (cause) { setRefusal(cause instanceof Error ? cause.message : labels.decisionNotSent); }\n finally { if (mounted.current) setSending(undefined); }\n }\n const approved = state === 'approved' || state === 'approved-sent', denied = state === 'denied' || state === 'denied-sent';\n\n const status = open ? labels.needsApproval : approved ? labels.approved : denied ? labels.denied : labels.notDecided;\n const unavailable = !answer || !questionId || busy;\n\n // shadcn's Confirmation recipe: an outline Item with the question and Approve/Deny actions; pending approvals are prompts, not activity.\n return <Item variant=\"outline\" size=\"sm\" render={<section />} data-testid=\"approval-card\" data-state={state} data-call-id={call.id} data-tool={call.name} aria-label={labels.approvalNeeded} className=\"my-2 text-start\">\n <ItemMedia variant=\"icon\">\n {approved ? <CheckIcon aria-hidden=\"true\" /> : denied ? <XIcon aria-hidden=\"true\" /> : <ShieldAlertIcon aria-hidden=\"true\" />}\n </ItemMedia>\n <ItemContent className=\"min-w-0 basis-0\">\n <ItemDescription className=\"line-clamp-none\"><Badge variant={open ? 'outline' : denied ? 'destructive' : 'secondary'}>{status}</Badge></ItemDescription>\n {summary && <ItemTitle data-testid=\"approval-summary\" className=\"line-clamp-none [overflow-wrap:anywhere]\">{summary}</ItemTitle>}\n <ItemDescription data-testid=\"approval-tool\" className=\"line-clamp-none [overflow-wrap:anywhere]\">{labels.wantsToRun} <code className=\"font-mono\">{call.name}</code></ItemDescription>\n {args.length > 0 && (() => {\n const list = <dl data-testid=\"approval-args\" className=\"m-0 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs\">\n {args.map(item => [<dt key={`${item.name}:n`} className=\"text-muted-foreground\">{item.name}</dt>, <dd key={`${item.name}:v`} className=\"m-0 font-mono whitespace-pre-wrap [overflow-wrap:anywhere]\">{item.value}</dd>])}\n </dl>;\n // With a summary the raw arguments are the detail behind it; without one they are all the person has to decide on.\n return summary ? <details data-testid=\"approval-details\" className=\"text-xs\"><summary className=\"inline-flex min-h-8 cursor-pointer items-center text-muted-foreground select-none max-sm:min-h-11 pointer-coarse:min-h-11\">{labels.arguments}</summary>{list}</details> : list;\n })()}\n </ItemContent>\n {open && <ItemActions role=\"group\" aria-label={labels.decision}>\n <Button variant=\"default\" data-testid=\"approval-approve\" disabled={unavailable} onClick={() => { void decide(APPROVE); }}><CheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.approve}</Button>\n <Button variant=\"outline\" data-testid=\"approval-deny\" disabled={unavailable} onClick={() => { void decide(DENY); }}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.deny}</Button>\n </ItemActions>}\n {(open && (live || !answer || !questionId)) && <ItemFooter><ItemDescription>{live ? labels.stillWritingCall : labels.decidingUnavailable}</ItemDescription></ItemFooter>}\n {(state === 'approved-sent' || state === 'denied-sent') && <ItemFooter><ItemDescription>{labels.decisionSent(approved)}</ItemDescription></ItemFooter>}\n {refusal && open && <ItemFooter><p role=\"alert\" data-testid=\"approval-refusal\" className=\"m-0 text-sm text-destructive\">{refusal}</p></ItemFooter>}\n </Item>;\n}\n",
73
- "type": "registry:component",
74
- "target": "components/pi-chat/approval-card.tsx"
75
- },
76
- {
77
- "path": "registry/pi-chat/artifact.ts",
78
- "content": "import type { ConversationView } from '@earendil-works/pi-durable';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { ComponentType, ReactNode } from 'react';\nimport { defaultChatLabels } from './labels';\n\n/*\n * Artifacts in the chat: substantial content (a document, an HTML page, an SVG, code, a canvas) that the transcript shows as a\n * card and the host opens in its own panel. The descriptor and `parseArtifact` mirror `@hachej/boring-agent/artifacts` on purpose: this\n * is a copied registry item and cannot import that package (it carries Pi runtime code). Keep the two validators in step;\n * `test/contracts/pi-chat-source.test.mjs` checks that they accept and refuse the same values.\n */\nexport const ARTIFACT_TYPES = ['markdown', 'html', 'svg', 'code', 'canvas'] as const;\nexport type ArtifactType = typeof ARTIFACT_TYPES[number];\n\n/** Structural twin of the `ResourceLocator` in `@hachej/boring-files`. */\nexport interface ArtifactTarget {\n readonly resource: { readonly providerId: string; readonly path: string };\n readonly view: { readonly kind: 'published' } | { readonly kind: 'working'; readonly viewId: string };\n}\nexport interface ArtifactDescriptor {\n readonly schema: 'boring.artifact';\n readonly version: 1;\n /** Set by the artifact tools; the descriptor of `present` has none (the file path is its identity). */\n readonly id?: string;\n readonly title: string;\n readonly type: ArtifactType;\n readonly mediaType: string;\n readonly language?: string;\n readonly target: ArtifactTarget;\n /** The revision this message produced. */\n readonly revision: string;\n /** The 1-based version number of this artifact (artifact tools only). */\n readonly ordinal?: number;\n}\n\n/** What identifies an artifact across its versions: its id, or for a presented file its path. */\nexport const artifactKey = (artifact: Pick<ArtifactDescriptor, 'id' | 'target'>): string => artifact.id ?? artifact.target.resource.path;\n\n/**\n * What an artifact kind's renderer receives: the artifact and its text at the revision shown, the page's URL fragment without `#`\n * (a link such as `#screen=2` can open a place inside the artifact; the panel follows its changes), and `read`: another file of the same\n * workspace as text (its latest revision; undefined when it is missing or not text), for an artifact that links to a file. `revision` is\n * the shown revision, the one a conditional save is made against; `pinned` is true when the panel shows a pinned historical version,\n * which is read-only.\n */\nexport interface ArtifactKindViewProps {\n readonly artifact: ArtifactDescriptor;\n readonly text: string;\n readonly revision: string;\n readonly pinned: boolean;\n /**\n * The view is loaded and on screen. A kind with `waitsForReady` must call it (once per revision shown): only then does the page\n * acknowledge a `present` call for this file. Other kinds are acknowledged as soon as their file is read, and may ignore it.\n */\n readonly onReady?: (() => void) | undefined;\n /**\n * The view could not be shown (its data failed to load, for example), with a short reason for the agent (at most 200 characters are\n * sent). A `present` call waiting for this file is refused with that reason. A renderer that throws is reported the same way.\n */\n readonly onFailed?: ((reason: string) => void) | undefined;\n readonly fragment?: string | undefined;\n readonly read?: ((path: string) => Promise<string | undefined>) | undefined;\n}\n\n/**\n * An app's own kind of artifact (for example `.maquette.json` files): how its card and the panel name it, its icon, and optionally\n * how the panel draws it. Kinds are consulted in order before the built-in types; the first whose `match` is true wins. A kind\n * adds a presentation only: the descriptor, its type and how it is read are unchanged.\n */\nexport interface ArtifactKind {\n /** True for the artifacts of this kind, for example `target => target.resource.path.endsWith('.maquette.json')`. */\n readonly match: (target: ArtifactTarget, artifact: ArtifactDescriptor) => boolean;\n /** The kind's name on the card and in the panel bar (\"Maquette\"). */\n readonly label: string;\n /** The card's icon (any component taking `className`, such as a lucide icon). The type's icon otherwise. */\n readonly icon?: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }> | undefined;\n /** Draws the artifact in the panel (`AgentWorkspace`, `ArtifactPanel`) inside the standard viewer bar, with copy and download. */\n readonly renderer?: ((props: ArtifactKindViewProps) => ReactNode) | undefined;\n /**\n * The renderer loads asynchronously (a dashboard fetching its data, a sandboxed frame): the panel confirms a `present` of this file\n * only after the renderer calls `onReady`, and refuses it when it calls `onFailed` or throws. With no call, the call ends unconfirmed.\n */\n readonly waitsForReady?: boolean | undefined;\n}\n\n/** The first kind that matches the artifact; a `match` that throws counts as no match. */\nexport function artifactKind(artifact: ArtifactDescriptor, kinds: readonly ArtifactKind[] | undefined): ArtifactKind | undefined {\n return kinds?.find(kind => { try { return kind.match(artifact.target, artifact); } catch { return false; } });\n}\n\n/** What the host passes to `PiChat` to turn artifact tool results into cards. */\nexport interface ArtifactsConfig {\n /** The app's own artifact kinds (label, icon, renderer), consulted before the built-in types. */\n readonly kinds?: readonly ArtifactKind[] | undefined;\n /** The person clicked a card: show this artifact (and revision) in the host's panel. */\n readonly open: (artifact: ArtifactDescriptor) => void;\n /** True for the card whose artifact version is on display. */\n readonly isOpen?: (artifact: ArtifactDescriptor) => boolean;\n /** Recognise a descriptor in a successful tool result that does not carry one itself (for example a document save). */\n readonly detect?: (call: ToolCall, result: ToolResultMessage) => ArtifactDescriptor | undefined;\n}\n\nexport const PRESENT_TOOL = 'present';\n\n/** A `present` call with no result yet, while its conversation runs: its answer ID (`[assistantEntryId, callId]`) and the path it names. */\nexport interface PendingPresent { readonly id: string; readonly callId: string; readonly path: string }\n\n/** The path a `present` call or a page acknowledgement names, as the server compares it (a leading `./` is the same file). */\nexport const presentPath = (path: string): string => path.replace(/^\\.\\//, '');\n\n/**\n * The `present` calls still waiting for the page (the server's view-open record, `@hachej/boring-agent/artifacts`). Mirrors\n * the preview's pending-call scan (the feedback plug-in's `pendingBrowserTasks`): a call with no result, in a conversation that is running.\n */\nexport function pendingPresents(view: { readonly entries: readonly { readonly id: number | bigint | string; readonly model?: readonly unknown[] | undefined }[]; readonly docs?: Readonly<Record<string, unknown>> } | undefined): readonly PendingPresent[] {\n if (!view) return [];\n const live = view.docs?.['pi.live'] as { readonly run?: unknown } | undefined;\n if (!live?.run) return [];\n const calls = new Map<string, PendingPresent>();\n for (const entry of view.entries) for (const message of entry.model ?? []) {\n const item = message as { readonly role?: string; readonly content?: unknown; readonly toolCallId?: string };\n if (item.role === 'assistant' && Array.isArray(item.content)) {\n for (const part of item.content as { type?: string; id?: string; name?: string; arguments?: Record<string, unknown> }[]) {\n const path = part.arguments?.['path'];\n if (part.type === 'toolCall' && part.name === PRESENT_TOOL && typeof part.id === 'string' && typeof path === 'string') {\n calls.set(part.id, { id: JSON.stringify([Number(entry.id), part.id]), callId: part.id, path: presentPath(path) });\n }\n }\n }\n if (item.role === 'toolResult' && typeof item.toolCallId === 'string') calls.delete(item.toolCallId);\n }\n return [...calls.values()];\n}\n\nconst ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;\nconst LANGUAGE = /^[A-Za-z0-9+#._-]{1,32}$/;\nconst isObject = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === 'object' && !Array.isArray(value);\nconst hasOnly = (value: Record<string, unknown>, keys: readonly string[]) => Object.keys(value).every(key => keys.includes(key));\n\n/** Strict shape check of an untrusted value. Returns a fresh copy or undefined. */\nexport function parseArtifact(value: unknown): ArtifactDescriptor | undefined {\n if (!isObject(value) || !hasOnly(value, ['schema', 'version', 'id', 'title', 'type', 'mediaType', 'language', 'target', 'revision', 'ordinal'])) return undefined;\n const { id, title, type, mediaType, language, target, revision, ordinal } = value;\n if (value['schema'] !== 'boring.artifact' || value['version'] !== 1) return undefined;\n if (id !== undefined && (typeof id !== 'string' || !ID.test(id))) return undefined;\n if (typeof title !== 'string' || !title.trim() || title.length > 200) return undefined;\n if (typeof type !== 'string' || !(ARTIFACT_TYPES as readonly string[]).includes(type)) return undefined;\n if (typeof mediaType !== 'string' || !/^[a-z0-9][a-z0-9!#$&^_.+-]*\\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i.test(mediaType)) return undefined;\n if (language !== undefined && (typeof language !== 'string' || !LANGUAGE.test(language))) return undefined;\n if (typeof revision !== 'string' || !revision || revision.length > 256) return undefined;\n if (ordinal !== undefined && (typeof ordinal !== 'number' || !Number.isSafeInteger(ordinal) || ordinal < 1)) return undefined;\n if (!isObject(target) || !hasOnly(target, ['resource', 'view']) || !isObject(target['resource']) || !hasOnly(target['resource'], ['providerId', 'path'])) return undefined;\n const { providerId, path } = target['resource'];\n if (typeof providerId !== 'string' || !providerId || typeof path !== 'string' || !path || path.length > 512) return undefined;\n const view = target['view'];\n if (!isObject(view)) return undefined;\n let parsedView: ArtifactTarget['view'];\n if (view['kind'] === 'published' && hasOnly(view, ['kind'])) parsedView = { kind: 'published' };\n else if (view['kind'] === 'working' && hasOnly(view, ['kind', 'viewId']) && typeof view['viewId'] === 'string' && view['viewId']) parsedView = { kind: 'working', viewId: view['viewId'] };\n else return undefined;\n return { schema: 'boring.artifact', version: 1, ...(id === undefined ? {} : { id }), title, type: type as ArtifactType, mediaType, ...(language === undefined ? {} : { language: language as string }),\n target: { resource: { providerId, path }, view: parsedView }, revision, ...(ordinal === undefined ? {} : { ordinal }) };\n}\n\n/** The artifact's type name: the matching kind's label first, then the chat's name of its type. */\nexport function typeLabel(artifact: ArtifactDescriptor, kinds?: readonly ArtifactKind[], labels: Readonly<Record<ArtifactType, string>> = defaultChatLabels.artifactTypes): string {\n const kind = artifactKind(artifact, kinds);\n if (kind) return kind.label;\n return artifact.type === 'code' && artifact.language ? `${labels.code} · ${artifact.language}` : labels[artifact.type];\n}\n\nconst textOf = (result: ToolResultMessage): string => result.content.map(part => part.type === 'text' ? part.text : '').join('');\n\n/**\n * The descriptor a successful tool result carries: the host's `detect` first, then the `artifact` key of a JSON text result.\n * Failed results never produce one, and a host callback that throws or returns something malformed is ignored. Without\n * the host's `detect`, only a result of `present` counts: any other tool whose text happens\n * to be that JSON (an MCP tool, `cat x.json`) cannot forge a card. A host with renamed tools says so in `detect`.\n */\nexport function detectArtifact(call: ToolCall, result: ToolResultMessage, detect?: ArtifactsConfig['detect']): ArtifactDescriptor | undefined {\n if (result.isError) return undefined;\n if (detect) {\n try { const found = parseArtifact(detect(call, result)); if (found) return found; } catch { /* a faulty host callback shows no card */ }\n }\n if (call.name !== PRESENT_TOOL) return undefined;\n const text = textOf(result);\n if (!text.includes('\"boring.artifact\"')) return undefined;\n try {\n const value: unknown = JSON.parse(text);\n return isObject(value) ? parseArtifact(value['artifact']) : undefined;\n } catch { return undefined; }\n}\n\n/** The title a still-running `present` call announces in its streamed arguments: the file name. */\nexport function pendingTitle(call: ToolCall): string | undefined {\n const path = (call.arguments as Record<string, unknown>)['path'];\n const title = typeof path === 'string' ? path.slice(path.lastIndexOf('/') + 1) : undefined;\n return title?.trim() ? title.trim().slice(0, 200) : undefined;\n}\n/** What a still-running `present` call will show: the file path. */\nexport function pendingId(call: ToolCall): string | undefined {\n const path = (call.arguments as Record<string, unknown>)['path'];\n return typeof path === 'string' && path ? path.replace(/^(\\.\\/)+/, '') : undefined;\n}\n\n/**\n * Every artifact version a conversation's tool results produced, oldest first. Hosts use it to list the versions known\n * from the conversation (a version switcher); the chat itself shows only the last version of an artifact per turn.\n */\nexport function collectArtifacts(view: ConversationView | undefined, detect?: ArtifactsConfig['detect']): ArtifactDescriptor[] {\n const calls = new Map<string, ToolCall>(), found: ArtifactDescriptor[] = [];\n for (const entry of view?.entries ?? []) for (const message of entry.model ?? []) {\n if (message.role === 'assistant') for (const part of message.content) { if (part.type === 'toolCall') calls.set(part.id, part); }\n else if (message.role === 'toolResult') {\n const call = calls.get(message.toolCallId);\n const descriptor = call && detectArtifact(call, message, detect);\n if (descriptor) found.push(descriptor);\n }\n }\n return found;\n}\n\n/** What the host's chat answer returns: the kind is all the presents bookkeeping reads. */\nexport interface PresentAnswer { readonly kind: string }\n\nexport interface PresentAcksHost {\n /** The chat's `answer` (the transport's `?op=answer`); undefined when the host has none. */\n readonly answer: (id: string, answer: string) => Promise<PresentAnswer> | undefined;\n /** Open the file in the panel; false when the panel refuses (unsaved changes the person keeps). */\n readonly open: (path: string) => boolean;\n /** This page's identity in the acknowledgement. */\n readonly instanceId: string;\n /** Test seams: the wait between retries and the clock of the retry bound. Default: real timers. */\n readonly wait?: (ms: number) => Promise<void>;\n readonly now?: () => number;\n}\n\n/** The page's state of the presents it must answer, as `AgentWorkspace` reads it on each change. */\nexport interface PresentContext {\n /** The person closed the panel in this turn. */\n readonly closedThisTurn: boolean;\n /** The open file has unsaved edits. */\n readonly unsaved: boolean;\n /** The screen is a phone (the panel is a sheet and is not opened for an agent). */\n readonly narrow: boolean;\n /** `autoOpen`: when false the panel is not opened for an agent; the call waits for a manual open. */\n readonly autoOpen: boolean;\n /** Whether the panel already shows this file. */\n readonly showing: (path: string) => boolean;\n}\n\n/**\n * The page's side of the view-open acknowledgement. A `present` call is confirmed only by a viewer's mount report (`shown`) of the\n * revision it names. A call is decided once; an answer the server denies (a revision the page showed before the agent wrote a new one)\n * keeps the call waiting for the next mount report, and an answer is retried briefly while the server has not recorded the call yet.\n */\nexport function createPresentAcks(host: PresentAcksHost) {\n const decided = new Set<string>();\n /** Calls waiting for their file's mount report: answer ID to path. */\n const mounting = new Map<string, string>();\n const wait = host.wait ?? (ms => new Promise<void>(resolve => setTimeout(resolve, ms)));\n const now = host.now ?? (() => Date.now());\n /** The last mount report: the file on screen and its revision. */\n let lastShown: { readonly path: string; readonly revision: string } | undefined;\n\n /** Sends one answer; returns the server's outcome kind, or undefined when it was not sent. Retries while the record is not written yet. */\n async function send(id: string, body: Record<string, string>): Promise<string | undefined> {\n const until = now() + 3000;\n for (;;) {\n try {\n const sent = host.answer(id, JSON.stringify(body));\n if (sent === undefined) return undefined;\n const outcome = await sent;\n if (outcome.kind !== 'unknown-question' || now() > until) return outcome.kind;\n } catch { return undefined; /* the call ends at its deadline as unconfirmed */ }\n await wait(20);\n }\n }\n /** Confirms a shown revision. The call keeps its entry while the answer is in flight, so a mount during the flight still confirms it. */\n async function confirm(id: string, path: string, revision: string): Promise<void> {\n const kind = await send(id, { kind: 'opened', instanceId: host.instanceId, path, revision });\n // Only an accepted or conflicting answer ends the call. A denial (another revision), a failed request or an unrecorded call keeps it waiting for a later mount or a live-call check.\n if (kind === 'answered' || kind === 'conflict') mounting.delete(id);\n }\n\n /** Refuses a waiting call; like `confirm`, only an accepted or conflicting answer ends it. */\n async function refuse(id: string, reason: string): Promise<void> {\n const kind = await send(id, { kind: 'refused', reason });\n if (kind === 'answered' || kind === 'conflict') mounting.delete(id);\n }\n\n return {\n /** A viewer mounted and shows `path` at `revision`: confirms the calls waiting for that file. */\n shown(view: { readonly path: string; readonly revision: string }): void {\n for (const [id, path] of mounting) if (path === view.path) void confirm(id, path, view.revision);\n lastShown = view;\n },\n /** A viewer could not show `path` (a kind's `onFailed`, or its renderer threw): refuses the calls waiting for that file with `reason`. */\n failed(view: { readonly path: string; readonly reason: string }): void {\n if (lastShown?.path === view.path) lastShown = undefined;\n const reason = view.reason.trim().slice(0, 200) || 'the view failed';\n for (const [id, path] of mounting) if (path === view.path) void refuse(id, reason);\n },\n /** A viewer stopped showing `path` at `revision` (it unmounted, or a custom viewer or the canvas took the file): it no longer confirms. */\n hidden(view: { readonly path: string; readonly revision: string }): void {\n if (lastShown?.path === view.path && lastShown.revision === view.revision) lastShown = undefined;\n },\n /** The presents the server waits for: each is decided once, in order. Decided calls and waits that are no longer live are forgotten. */\n consider(calls: readonly PendingPresent[], context: PresentContext): void {\n const live = new Set(calls.map(call => call.id));\n for (const id of decided) if (!live.has(id)) decided.delete(id);\n for (const id of mounting.keys()) if (!live.has(id)) mounting.delete(id);\n for (const call of calls) {\n if (decided.has(call.id)) continue;\n decided.add(call.id);\n if (context.closedThisTurn) void send(call.id, { kind: 'refused', reason: 'closed by user' });\n else if (context.unsaved) void send(call.id, { kind: 'refused', reason: 'unsaved changes' });\n else if (context.narrow) void send(call.id, { kind: 'refused', reason: 'narrow screen' });\n else {\n mounting.set(call.id, call.path);\n if (lastShown?.path === call.path && context.showing(call.path)) void confirm(call.id, call.path, lastShown.revision);\n else if (context.autoOpen && !host.open(call.path)) {\n mounting.delete(call.id);\n void send(call.id, { kind: 'refused', reason: 'unsaved changes' });\n }\n }\n }\n },\n /** Calls still waiting for a mount (test and debugging view). */\n waiting: (): readonly string[] => [...mounting.keys()],\n };\n}\n\n/** What `createPresentAcks` returns. */\nexport type PresentAcks = ReturnType<typeof createPresentAcks>;\n",
79
- "type": "registry:lib",
80
- "target": "components/pi-chat/artifact.ts"
81
- },
82
- {
83
- "path": "registry/pi-chat/camera-sheet.tsx",
84
- "content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport { createPortal } from 'react-dom';\nimport { CameraIcon, XIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { useChatText } from './labels';\n\n// The in-page camera behind the composer's \"Take a photo\" (phones). getUserMedia is secure-context only and needs permission, so it is\n// opened only when this sheet mounts (the person's tap on the menu item), through an injectable `getUserMedia` whose default\n// feature-detects `navigator.mediaDevices.getUserMedia`. Where the stream cannot open (refused, unsupported, a Permissions-Policy, an\n// insecure page) the sheet offers the phone's own camera through a file input instead. Same shape as feedback/voice/capture.ts.\n\n/** The longest side of a captured photo, in pixels. */\nexport const CAMERA_MAX_SIDE = 1600;\nconst JPEG_QUALITY = 0.85;\n\ntype GetUserMedia = (constraints: MediaStreamConstraints) => Promise<MediaStream>;\n\n/** The browser's camera, or null where the page cannot have one (insecure origin, no media devices). */\nfunction browserGetUserMedia(): GetUserMedia | null {\n if (typeof globalThis.navigator?.mediaDevices?.getUserMedia !== 'function') return null;\n const devices = globalThis.navigator.mediaDevices;\n return constraints => devices.getUserMedia(constraints);\n}\n\nexport interface CameraSheetProps {\n /** The photo, as a JPEG File scaled to at most `CAMERA_MAX_SIDE` on its longest side (or the phone camera's own file, from the fallback). */\n readonly onCapture: (file: File) => void;\n readonly onClose: () => void;\n /** Opens the camera; defaults to the browser's, when it has one. */\n readonly getUserMedia?: GetUserMedia | undefined;\n}\n\n/** Full-screen camera above the agent layer (the app shell's point layer + 10): a live preview, a shutter and a close button. */\nexport function CameraSheet({ onCapture, onClose, getUserMedia }: CameraSheetProps) {\n const { labels } = useChatText();\n const video = useRef<HTMLVideoElement>(null);\n const stream = useRef<MediaStream | null>(null);\n const fallback = useRef<HTMLInputElement>(null);\n const [ready, setReady] = useState(false);\n const [failed, setFailed] = useState(false);\n const [encoding, setEncoding] = useState(false);\n // The latest callbacks, so the stream effect runs once per mount.\n const latest = useRef({ onClose, getUserMedia });\n latest.current = { onClose, getUserMedia };\n\n const release = () => { stream.current?.getTracks().forEach(track => track.stop()); stream.current = null; };\n useEffect(() => {\n let live = true;\n const open = latest.current.getUserMedia ?? browserGetUserMedia();\n (async () => {\n if (!open) throw new Error('no camera');\n // The rear camera if there is one; any camera otherwise.\n const media = await open({ video: { facingMode: 'environment' } }).catch(() => open({ video: true }));\n if (!live) { media.getTracks().forEach(track => track.stop()); return; }\n stream.current = media;\n const element = video.current;\n if (!element) return;\n element.srcObject = media;\n await element.play();\n if (live) setReady(true);\n })().catch(() => { if (live) { release(); setFailed(true); } });\n return () => { live = false; release(); };\n }, []);\n // Escape closes the camera only: captured before the drawer or the dropdown behind it see it.\n useEffect(() => {\n const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.stopPropagation(); event.preventDefault(); release(); latest.current.onClose(); } };\n document.addEventListener('keydown', onKey, true);\n return () => document.removeEventListener('keydown', onKey, true);\n }, []);\n\n const close = () => { release(); onClose(); };\n const shoot = () => {\n const element = video.current;\n if (!element || !ready || encoding || !element.videoWidth || !element.videoHeight) return;\n const scale = Math.min(1, CAMERA_MAX_SIDE / Math.max(element.videoWidth, element.videoHeight));\n const canvas = document.createElement('canvas');\n canvas.width = Math.max(1, Math.round(element.videoWidth * scale));\n canvas.height = Math.max(1, Math.round(element.videoHeight * scale));\n const context = canvas.getContext('2d');\n if (!context) { setFailed(true); return; }\n context.drawImage(element, 0, 0, canvas.width, canvas.height);\n setEncoding(true);\n canvas.toBlob(blob => {\n setEncoding(false);\n if (!blob) { setFailed(true); return; }\n release();\n onCapture(new File([blob], `photo-${Date.now()}.jpg`, { type: 'image/jpeg' }));\n }, 'image/jpeg', JPEG_QUALITY);\n };\n\n return createPortal(\n <div role=\"dialog\" aria-modal=\"true\" aria-label={labels.takePhoto} data-boring-overlay=\"\" data-testid=\"camera-sheet\"\n style={{ zIndex: 'calc(var(--app-shell-z-point, 60) + 10)' }}\n className=\"fixed inset-0 flex flex-col bg-background text-foreground\">\n <div className=\"flex min-h-0 flex-1 items-center justify-center overflow-hidden\">\n {failed\n ? <div className=\"flex flex-col items-center gap-3 p-6 text-center\">\n <p role=\"alert\" data-testid=\"camera-unavailable\" className=\"text-sm text-muted-foreground\">{labels.cameraUnavailable}</p>\n {/* Synchronous click: the browser opens the phone camera only from the person's tap. */}\n <Button type=\"button\" data-testid=\"camera-fallback\" className=\"min-h-11\" onClick={() => fallback.current?.click()}>\n <CameraIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.cameraFallback}</Button>\n <Input ref={fallback} type=\"file\" accept=\"image/*\" capture=\"environment\" tabIndex={-1} aria-hidden=\"true\" className=\"sr-only\" data-testid=\"camera-fallback-input\"\n onChange={event => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ''; if (file) onCapture(file); }} />\n </div>\n : <video ref={video} playsInline muted autoPlay data-testid=\"camera-video\" className=\"size-full object-cover\" />}\n </div>\n <div className=\"flex items-center justify-between p-4 pb-[max(1rem,env(safe-area-inset-bottom))]\">\n <Button type=\"button\" variant=\"ghost\" size=\"icon-lg\" data-testid=\"camera-close\" aria-label={labels.cameraClose} title={labels.cameraClose} onClick={close}>\n <XIcon aria-hidden=\"true\" /></Button>\n {!failed && <Button type=\"button\" size=\"icon-lg\" data-testid=\"camera-shoot\" aria-label={labels.cameraShoot} title={labels.cameraShoot}\n disabled={!ready || encoding} onClick={shoot}><CameraIcon aria-hidden=\"true\" /></Button>}\n <span className=\"w-9\" aria-hidden=\"true\" />\n </div>\n </div>, document.body);\n}\n",
85
- "type": "registry:component",
86
- "target": "components/pi-chat/camera-sheet.tsx"
87
- },
88
60
  {
89
61
  "path": "registry/pi-chat/code-block.tsx",
90
62
  "content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport { CheckIcon, CopyIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemHeader, ItemTitle } from '@/components/ui/item';\nimport { copyText } from '../utils/utils';\nimport { useChatText } from './labels';\n\n/** Copy button with a short \"copied\" confirmation. Failures stay visible as a title instead of throwing. */\nexport function CopyButton({ text, label: given, onCopy, className, iconOnly = false }: { readonly text: string; readonly label?: string; readonly onCopy?: (text: string) => Promise<void>; readonly className?: string; readonly iconOnly?: boolean }) {\n const { labels } = useChatText();\n const label = given ?? labels.copy;\n const [state, setState] = useState<'idle' | 'copied' | 'failed'>('idle');\n const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n useEffect(() => () => clearTimeout(timer.current), []);\n const settle = (next: 'copied' | 'failed') => { setState(next); clearTimeout(timer.current); timer.current = setTimeout(() => setState('idle'), 1600); };\n return <Button variant=\"ghost\" size={iconOnly ? 'icon-sm' : 'sm'} className={className} data-testid=\"copy\" aria-label={label} title={state === 'failed' ? labels.copyFailed : label}\n onClick={() => { copyText(text, onCopy).then(() => settle('copied'), () => settle('failed')); }}>\n {state === 'copied' ? <CheckIcon data-icon=\"inline-start\" /> : <CopyIcon data-icon=\"inline-start\" />}\n <span className={iconOnly ? 'sr-only' : 'sr-only md:not-sr-only'}>{state === 'copied' ? labels.copied : state === 'failed' ? labels.failed : label}</span>\n </Button>;\n}\n\n/** Fenced code: language label, copy action, horizontal scroll. Text only, never interpreted as markup. */\nexport function CodeBlock({ code, language, onCopy }: { readonly code: string; readonly language?: string; readonly onCopy?: (text: string) => Promise<void> }) {\n const { labels } = useChatText();\n const label = language?.trim().split(/\\s+/)[0]?.slice(0, 24);\n // shadcn's own Code Block installs a syntax highlighter (shiki) this block never uses, so the frame is an outline Item.\n return <Item variant=\"outline\" size=\"xs\" data-testid=\"code-block\" className=\"my-3 flex-col items-stretch text-start\">\n <ItemHeader>\n <ItemTitle data-testid=\"code-language\" className=\"lowercase\">{label || 'text'}</ItemTitle>\n <CopyButton text={code} label={labels.copyCode} {...(onCopy ? { onCopy } : {})} />\n </ItemHeader>\n <pre className=\"m-0 overflow-x-auto font-mono text-xs leading-5\"><code>{code}</code></pre>\n </Item>;\n}\n",
@@ -93,7 +65,7 @@
93
65
  },
94
66
  {
95
67
  "path": "registry/pi-chat/composer.tsx",
96
- "content": "'use client';\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ChangeEvent, ClipboardEvent, ComponentProps, DragEvent, KeyboardEvent, ReactNode, Ref } from 'react';\nimport { AlertCircleIcon, FileTextIcon, Loader2Icon, MessageSquarePlusIcon, SlashIcon, XIcon } from 'lucide-react';\nimport { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentMedia, AttachmentTitle } from '@/components/ui/attachment';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { Input } from '@/components/ui/input';\nimport { InputGroup, InputGroupAddon, InputGroupTextarea } from '@/components/ui/input-group';\nimport { Toggle } from '@/components/ui/toggle';\nimport { hasMention, isLongPaste, mentionToken, mentionTrigger, removeMention, slashItems, slashQuery } from './config';\nimport type { AttachmentsConfig, MentionsConfig, PastedText, PasteToFile, SlashConfig, SlashItem } from './config';\nimport { CameraSheet } from './camera-sheet';\nimport { MentionPicker } from './mention-menu';\nimport { SlashPicker } 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 {\n readonly id: string; readonly name: string; readonly state: 'uploading' | 'failed'; readonly error?: string;\n /** A pasted text file: abandons the upload and puts the text back in the message (its restore button, or Ctrl+Z). */\n readonly cancel?: (() => void) | undefined;\n /** The message text right after the paste was accepted: Ctrl+Z restores only while the text is still this (any later edit leaves it alone). */\n readonly restoreWhile?: string | undefined;\n}\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 /** A paste above `pasteToFile`'s limits, taken by the composer: the host uploads it as a file. Absent: pastes stay inline. */\n readonly onPasteText?: ((paste: PastedText) => void) | undefined;\n readonly pasteToFile?: PasteToFile | 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 /** Optional host mode/model chips beside the attach control. */\n readonly chips?: ReactNode;\n /** Optional host context above the input. */\n readonly quote?: ReactNode;\n readonly text: string;\n readonly onText: (text: string) => void;\n readonly attachments: readonly ComposerAttachment[];\n /** Host-owned content contributes to send readiness. */\n readonly hasFiles?: boolean;\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 /** The phone camera sheet (`attachments.Camera`). Default: the built-in `CameraSheet`. */\n readonly camera?: AttachmentsConfig['Camera'];\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 /** `card` (default): chips, then full-width text, then a row of controls, in the host's shadcn InputGroup. `pill` puts the text between\n * the controls on one row. `stacked` and `inline` are compatibility values that render as `card`. */\n readonly layout?: 'card' | 'pill' | '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/** The annotate toggle beside the \"+\" menu in both layouts. */\nfunction AnnotateButton({ feedback, disabled }: { readonly feedback: ComposerFeedback; readonly disabled: boolean }) {\n const { labels } = useChatText();\n return <Toggle className=\"min-h-11 min-w-11\" data-testid=\"composer-annotate\" pressed={feedback.active} disabled={disabled}\n aria-label={labels.annotate} title={labels.annotateHint} onPressedChange={() => feedback.annotate()}>\n <MessageSquarePlusIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n </Toggle>;\n}\n\n/**\n * One chip above the text (a mention, an upload, an attachment or the notes count) as the host's shadcn Attachment. The remove\n * buttons keep a 44px touch target on coarse pointers; negative margins let it extend beyond the chip instead of growing it.\n */\nfunction Chip({ media, image = false, label, actions, ...props }: ComponentProps<typeof Attachment> & { readonly media: ReactNode; readonly image?: boolean; readonly label: ReactNode; readonly actions?: ReactNode }) {\n return <Attachment size=\"xs\" role=\"listitem\" {...props}>\n <AttachmentMedia variant={image ? 'image' : 'icon'}>{media}</AttachmentMedia>\n <AttachmentContent><AttachmentTitle>{label}</AttachmentTitle></AttachmentContent>\n {actions && <AttachmentActions className=\"max-sm:-my-2.5 pointer-coarse:-my-2.5\">{actions}</AttachmentActions>}\n </Attachment>;\n}\n\nfunction RemoveChip({ label, onClick }: { readonly label: string; readonly onClick: () => void }) {\n return <AttachmentAction aria-label={label} title={label} onClick={onClick}><XIcon aria-hidden=\"true\" /></AttachmentAction>;\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 <Chip data-testid=\"feedback-count\" data-count={feedback.count} media={<MessageSquarePlusIcon aria-hidden=\"true\" />} label={labels.annotateNotes(feedback.count)}\n actions={<AttachmentAction data-testid=\"feedback-count-clear\" aria-label={labels.annotateClear} title={labels.annotateClear} onClick={() => feedback.clear()}><XIcon aria-hidden=\"true\" /></AttachmentAction>} />;\n}\n\n/** Whether a file input's `accept` admits a JPEG photo (empty admits everything). */\nexport function acceptsPhotos(accept: string): boolean {\n const tokens = accept.split(',').map(token => token.trim().toLowerCase()).filter(Boolean);\n return tokens.length === 0 || tokens.some(token => token === 'image/*' || token === '*/*' || token === 'image/jpeg' || token === '.jpg' || token === '.jpeg');\n}\n\n/** True on a touch-first device (a phone or tablet); false on the server, so the menu renders the same there. */\nfunction useCoarsePointer(): boolean {\n const [coarse, setCoarse] = useState(() => globalThis.matchMedia?.('(pointer: coarse)').matches ?? false);\n useEffect(() => {\n const query = globalThis.matchMedia?.('(pointer: coarse)');\n if (!query) return;\n const update = () => setCoarse(query.matches);\n update();\n query.addEventListener?.('change', update);\n return () => query.removeEventListener?.('change', update);\n }, []);\n return coarse;\n}\n\n/** The \"+\" button and the host's shadcn dropdown menu (Attach files, Take a photo, Commands). */\nfunction PlusActions({ disabled, onAttach, onTakePhoto, onCommands, textarea }: { readonly disabled: boolean; readonly onAttach?: (() => void) | undefined; readonly onTakePhoto?: (() => void) | undefined; readonly onCommands?: (() => void) | undefined; readonly textarea: { readonly current: HTMLTextAreaElement | null } }) {\n const { labels, icons } = useChatText();\n const [open, setOpen] = useState(false);\n // Commands puts `/` in the message: focus goes back to the text, not to the \"+\" button.\n const toText = useRef(false);\n useEffect(() => { if (disabled) setOpen(false); }, [disabled]);\n return <DropdownMenu open={open} onOpenChange={setOpen}>\n <DropdownMenuTrigger render={<Button variant=\"ghost\" size=\"icon\" />} data-testid=\"composer-plus\" aria-label={labels.add} title={labels.add} disabled={disabled}>\n <icons.add data-icon=\"inline-start\" aria-hidden=\"true\" /></DropdownMenuTrigger>\n <DropdownMenuContent side=\"top\" align=\"start\" aria-label={labels.add} data-testid=\"composer-plus-menu\" className=\"w-52\"\n finalFocus={() => { const text = toText.current; toText.current = false; return text ? textarea.current : true; }}>\n {onAttach && <DropdownMenuItem data-testid=\"composer-attach\" onClick={onAttach} className=\"max-sm:min-h-11 pointer-coarse:min-h-11\">\n <icons.attach aria-hidden=\"true\" />{labels.attachFiles}</DropdownMenuItem>}\n {onTakePhoto && <DropdownMenuItem data-testid=\"composer-take-photo\" onClick={onTakePhoto} className=\"max-sm:min-h-11 pointer-coarse:min-h-11\">\n <icons.camera aria-hidden=\"true\" />{labels.takePhoto}</DropdownMenuItem>}\n {onCommands && <DropdownMenuItem data-testid=\"composer-commands\" onClick={() => { toText.current = true; onCommands(); }} className=\"max-sm:min-h-11 pointer-coarse:min-h-11\">\n <SlashIcon aria-hidden=\"true\" />{labels.commands}</DropdownMenuItem>}\n </DropdownMenuContent>\n </DropdownMenu>;\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, layout = 'card' } = 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 // The camera sheet lives here, not in the menu: the menu content unmounts as soon as it closes.\n const [cameraOpen, setCameraOpen] = useState(false);\n const coarse = useCoarsePointer();\n const canPhoto = canAttach && coarse && acceptsPhotos(props.fileAccept);\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 const resize = () => {\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 };\n resize();\n if (pendingCaret.current !== null) { element.setSelectionRange(pendingCaret.current, pendingCaret.current); element.focus(); pendingCaret.current = null; }\n if (typeof ResizeObserver === 'undefined') return;\n let width = element.clientWidth;\n const observer = new ResizeObserver(() => {\n const next = element.clientWidth;\n if (next === width) return;\n width = next; resize();\n });\n observer.observe(element);\n return () => observer.disconnect();\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 || props.hasFiles || (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 // Ctrl+Z right after a paste that is still uploading puts the pasted text back, instead of undoing the paste in the textarea.\n const undo = (props.uploads ?? []).filter(item => item.state === 'uploading' && item.cancel && item.restoreWhile === props.text).pop();\n if (undo && (event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'z') { event.preventDefault(); undo.cancel!(); 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)); return; }\n if (!props.onPasteText) return;\n const pasted = event.clipboardData.getData('text/plain');\n if (!isLongPaste(pasted, props.pasteToFile)) return;\n event.preventDefault();\n props.onPasteText({ text: pasted, start: event.currentTarget.selectionStart, end: event.currentTarget.selectionEnd });\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 chipList = (props.quote || chips.length > 0 || (props.uploads?.length ?? 0) > 0 || (feedback?.count ?? 0) > 0 || attachments.length > 0) && <>\n {props.quote}\n {feedback && feedback.count > 0 && <div role=\"list\" className=\"contents\"><NotesChip feedback={feedback} /></div>}\n {chips.length > 0 && <div role=\"list\" data-testid=\"mention-chips\" aria-label={labels.mentionedFiles} className=\"contents\">\n {chips.map(path => <Chip key={path} data-testid=\"mention-chip\" data-path={path} title={path} className=\"max-w-full sm:max-w-[16rem]\"\n media={mentions?.icon ?? <FileTextIcon aria-hidden=\"true\" />} label={mentions?.label?.(path) ?? path} actions={<RemoveChip label={labels.remove(mentions?.label?.(path) ?? path)} onClick={() => props.onText(removeMention(text, path))} />} />)}\n </div>}\n {(props.uploads?.length ?? 0) > 0 && <div role=\"list\" data-testid=\"uploads\" aria-label={labels.uploads} className=\"contents\">\n {props.uploads!.map(item => <Chip key={item.id} data-testid=\"upload-chip\" state={item.state === 'failed' ? 'error' : 'uploading'} data-state={item.state} title={item.error ?? item.name} className=\"max-w-full sm:max-w-[20rem]\"\n media={item.state === 'failed' ? <AlertCircleIcon className=\"text-destructive\" aria-hidden=\"true\" /> : <Loader2Icon className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />}\n label={item.state === 'failed' ? labels.uploadFailed(item.name, item.error) : labels.uploading(item.name)}\n actions={<>\n {item.cancel && <RemoveChip label={labels.restorePaste} onClick={() => item.cancel!()} />}\n {item.state === 'failed' && <RemoveChip label={labels.dismiss(item.name)} onClick={() => props.onDismissUpload?.(item.id)} />}\n </>} />)}\n </div>}\n {attachments.length > 0 && <div role=\"list\" data-testid=\"attachments\" aria-label={labels.attachments} className=\"contents\">\n {attachments.map(attachment => {\n const src = thumbnail(attachment.mimeType, attachment.data);\n return <Chip key={attachment.id} data-testid=\"attachment\" className=\"max-w-full sm:max-w-[14rem]\" image={!!src}\n media={src ? <img src={src} alt=\"\" /> : <FileTextIcon aria-hidden=\"true\" />} label={attachment.name}\n actions={<RemoveChip label={labels.remove(attachment.name)} onClick={() => props.onRemoveAttachment(attachment.id)} />} />;\n })}\n </div>}\n </>;\n const pill = layout === 'pill';\n const textareaEl = <InputGroupTextarea 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={pill ? 'max-h-52 min-h-0 min-w-0' : 'max-h-52 min-h-11'} />;\n const plus = (canAttach || items.length > 0) && <PlusActions disabled={disabled} textarea={inner} onAttach={canAttach ? () => fileInput.current?.click() : undefined} onTakePhoto={canPhoto ? () => setCameraOpen(true) : 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=\"shrink-0\">\n {stopRequested ? <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" /> : <icons.stop data-icon=\"inline-start\" 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=\"shrink-0\">\n <icons.send data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>;\n const end = <>\n {props.barStart}\n {props.chips && <div className=\"flex min-w-0 items-center gap-2\">{props.chips}</div>}\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 {!pill && <span className=\"min-w-0 flex-1\" />}\n {props.barEnd && <div data-testid=\"composer-tools\" className=\"flex shrink-0 items-center gap-2\">{props.barEnd}</div>}\n {working && hasContent && <Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-queue\" aria-label={labels.queue} title={labels.queue} disabled={!canSubmit} onClick={props.onSend} className=\"shrink-0\">\n <icons.queue data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {send}\n </>;\n // A feature config may carry its own component; the built-in one is the default.\n const Slash = slash?.Picker ?? SlashPicker, Mention = mentions?.Picker ?? MentionPicker, Camera = props.camera ?? CameraSheet;\n\n // One shadcn InputGroup: the chips, the text and a row of controls (`card`, also used for the `stacked` and `inline` compatibility\n // values), or one row with the text between the controls (`pill`, chips above it). Its look is the host's; ours is layout only.\n return <form data-testid=\"composer\" data-layout={layout} data-dragging={dragging || undefined} onSubmit={event => { event.preventDefault(); if (canSubmit) props.onSend(); }} onDragOver={drag} onDragLeave={drag} onDrop={drop}\n className={cn('relative flex flex-col gap-2', pill && '@container/composer')}>\n {fileEl}\n {slashOpen && <Slash query={query!} items={items} onSelect={pickSlash} onDismiss={dismiss} />}\n {mentionOpen && mentions && <Mention query={trigger!.query} listLabel={mentions.listLabel} renderOption={mentions.renderOption} search={mentions.search} generation={mentions.generation} onSelect={pickMention} onDismiss={dismiss} />}\n {pill && chipList && <div className=\"flex flex-wrap gap-2\">{chipList}</div>}\n {pill ? <InputGroup>\n <InputGroupAddon>{plus}{feedback && <AnnotateButton feedback={feedback} disabled={disabled} />}</InputGroupAddon>\n {textareaEl}\n <InputGroupAddon align=\"inline-end\" data-testid=\"composer-controls\">{end}</InputGroupAddon>\n </InputGroup> : <InputGroup>\n {chipList && <InputGroupAddon align=\"block-start\" className=\"flex-wrap\">{chipList}</InputGroupAddon>}\n {textareaEl}\n <InputGroupAddon align=\"block-end\" data-testid=\"composer-controls\">\n {plus}\n {feedback && <AnnotateButton feedback={feedback} disabled={disabled} />}\n {end}\n </InputGroupAddon>\n </InputGroup>}\n {cameraOpen && <Camera onClose={() => setCameraOpen(false)} onCapture={file => { setCameraOpen(false); props.onPickFiles([file]); }} />}\n </form>;\n}\n",
68
+ "content": "'use client';\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ChangeEvent, ClipboardEvent, ComponentProps, DragEvent, KeyboardEvent, ReactNode, Ref } from 'react';\nimport { AlertCircleIcon, FileTextIcon, Loader2Icon, MessageSquarePlusIcon, SlashIcon, XIcon } from 'lucide-react';\nimport { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentMedia, AttachmentTitle } from '@/components/ui/attachment';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { Input } from '@/components/ui/input';\nimport { InputGroup, InputGroupAddon, InputGroupTextarea } from '@/components/ui/input-group';\nimport { Toggle } from '@/components/ui/toggle';\nimport { hasMention, isLongPaste, mentionToken, mentionTrigger, removeMention, slashItems, slashQuery } from './config';\nimport type { AttachmentsConfig, MentionsConfig, PastedText, PasteToFile, SlashConfig, SlashItem } from './config';\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 {\n readonly id: string; readonly name: string; readonly state: 'uploading' | 'failed'; readonly error?: string;\n /** A pasted text file: abandons the upload and puts the text back in the message (its restore button, or Ctrl+Z). */\n readonly cancel?: (() => void) | undefined;\n /** The message text right after the paste was accepted: Ctrl+Z restores only while the text is still this (any later edit leaves it alone). */\n readonly restoreWhile?: string | undefined;\n}\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 /** A paste above `pasteToFile`'s limits, taken by the composer: the host uploads it as a file. Absent: pastes stay inline. */\n readonly onPasteText?: ((paste: PastedText) => void) | undefined;\n readonly pasteToFile?: PasteToFile | 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 /** Optional host mode/model chips beside the attach control. */\n readonly chips?: ReactNode;\n /** Optional host context above the input. */\n readonly quote?: ReactNode;\n readonly text: string;\n readonly onText: (text: string) => void;\n readonly attachments: readonly ComposerAttachment[];\n /** Host-owned content contributes to send readiness. */\n readonly hasFiles?: boolean;\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 /** The phone camera sheet (`attachments.Camera`, from `pi-chat-attachments`). Without it there is no Take a photo entry. */\n readonly camera?: AttachmentsConfig['Camera'];\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 /** `card` (default): chips, then full-width text, then a row of controls, in the host's shadcn InputGroup. `pill` puts the text between\n * the controls on one row. `stacked` and `inline` are compatibility values that render as `card`. */\n readonly layout?: 'card' | 'pill' | '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 /** Host controls at the very left edge of the bar, before \"+\" (`PiChat`'s `composerStart`). */\n readonly start?: ReactNode;\n /** A control shown in place of Send while there is nothing to send (WhatsApp's microphone): Send comes back as soon as there is text or a file. */\n readonly whenEmpty?: ReactNode;\n /** The optional Feedback button and chip. */\n readonly feedback?: ComposerFeedback | undefined;\n}\n\n/** The annotate toggle beside the \"+\" menu in both layouts. */\nfunction AnnotateButton({ feedback, disabled }: { readonly feedback: ComposerFeedback; readonly disabled: boolean }) {\n const { labels } = useChatText();\n return <Toggle className=\"min-h-11 min-w-11\" data-testid=\"composer-annotate\" pressed={feedback.active} disabled={disabled}\n aria-label={labels.annotate} title={labels.annotateHint} onPressedChange={() => feedback.annotate()}>\n <MessageSquarePlusIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n </Toggle>;\n}\n\n/**\n * One chip above the text (a mention, an upload, an attachment or the notes count) as the host's shadcn Attachment. The remove\n * buttons keep a 44px touch target on coarse pointers; negative margins let it extend beyond the chip instead of growing it.\n */\nfunction Chip({ media, image = false, label, actions, ...props }: ComponentProps<typeof Attachment> & { readonly media: ReactNode; readonly image?: boolean; readonly label: ReactNode; readonly actions?: ReactNode }) {\n return <Attachment size=\"xs\" role=\"listitem\" {...props}>\n <AttachmentMedia variant={image ? 'image' : 'icon'}>{media}</AttachmentMedia>\n <AttachmentContent><AttachmentTitle>{label}</AttachmentTitle></AttachmentContent>\n {actions && <AttachmentActions className=\"max-sm:-my-2.5 pointer-coarse:-my-2.5\">{actions}</AttachmentActions>}\n </Attachment>;\n}\n\nfunction RemoveChip({ label, onClick }: { readonly label: string; readonly onClick: () => void }) {\n return <AttachmentAction aria-label={label} title={label} onClick={onClick}><XIcon aria-hidden=\"true\" /></AttachmentAction>;\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 <Chip data-testid=\"feedback-count\" data-count={feedback.count} media={<MessageSquarePlusIcon aria-hidden=\"true\" />} label={labels.annotateNotes(feedback.count)}\n actions={<AttachmentAction data-testid=\"feedback-count-clear\" aria-label={labels.annotateClear} title={labels.annotateClear} onClick={() => feedback.clear()}><XIcon aria-hidden=\"true\" /></AttachmentAction>} />;\n}\n\n/** Whether a file input's `accept` admits a JPEG photo (empty admits everything). */\nexport function acceptsPhotos(accept: string): boolean {\n const tokens = accept.split(',').map(token => token.trim().toLowerCase()).filter(Boolean);\n return tokens.length === 0 || tokens.some(token => token === 'image/*' || token === '*/*' || token === 'image/jpeg' || token === '.jpg' || token === '.jpeg');\n}\n\n/** True on a touch-first device (a phone or tablet); false on the server, so the menu renders the same there. */\nfunction useCoarsePointer(): boolean {\n const [coarse, setCoarse] = useState(() => globalThis.matchMedia?.('(pointer: coarse)').matches ?? false);\n useEffect(() => {\n const query = globalThis.matchMedia?.('(pointer: coarse)');\n if (!query) return;\n const update = () => setCoarse(query.matches);\n update();\n query.addEventListener?.('change', update);\n return () => query.removeEventListener?.('change', update);\n }, []);\n return coarse;\n}\n\n/** The \"+\" button and the host's shadcn dropdown menu (Attach files, Take a photo, Commands). */\nfunction PlusActions({ disabled, onAttach, onTakePhoto, onCommands, textarea }: { readonly disabled: boolean; readonly onAttach?: (() => void) | undefined; readonly onTakePhoto?: (() => void) | undefined; readonly onCommands?: (() => void) | undefined; readonly textarea: { readonly current: HTMLTextAreaElement | null } }) {\n const { labels, icons } = useChatText();\n const [open, setOpen] = useState(false);\n // Commands puts `/` in the message: focus goes back to the text, not to the \"+\" button.\n const toText = useRef(false);\n useEffect(() => { if (disabled) setOpen(false); }, [disabled]);\n return <DropdownMenu open={open} onOpenChange={setOpen}>\n <DropdownMenuTrigger render={<Button variant=\"ghost\" size=\"icon\" />} data-testid=\"composer-plus\" aria-label={labels.add} title={labels.add} disabled={disabled}>\n <icons.add data-icon=\"inline-start\" aria-hidden=\"true\" /></DropdownMenuTrigger>\n <DropdownMenuContent side=\"top\" align=\"start\" aria-label={labels.add} data-testid=\"composer-plus-menu\" className=\"w-52\"\n finalFocus={() => { const text = toText.current; toText.current = false; return text ? textarea.current : true; }}>\n {onAttach && <DropdownMenuItem data-testid=\"composer-attach\" onClick={onAttach} className=\"max-sm:min-h-11 pointer-coarse:min-h-11\">\n <icons.attach aria-hidden=\"true\" />{labels.attachFiles}</DropdownMenuItem>}\n {onTakePhoto && <DropdownMenuItem data-testid=\"composer-take-photo\" onClick={onTakePhoto} className=\"max-sm:min-h-11 pointer-coarse:min-h-11\">\n <icons.camera aria-hidden=\"true\" />{labels.takePhoto}</DropdownMenuItem>}\n {onCommands && <DropdownMenuItem data-testid=\"composer-commands\" onClick={() => { toText.current = true; onCommands(); }} className=\"max-sm:min-h-11 pointer-coarse:min-h-11\">\n <SlashIcon aria-hidden=\"true\" />{labels.commands}</DropdownMenuItem>}\n </DropdownMenuContent>\n </DropdownMenu>;\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, layout = 'card' } = 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 // The camera sheet lives here, not in the menu: the menu content unmounts as soon as it closes.\n const [cameraOpen, setCameraOpen] = useState(false);\n const coarse = useCoarsePointer();\n // A feature shows only when its config carries the component that draws it (`pi-chat-slash`, `pi-chat-mentions`, `pi-chat-attachments`).\n const Camera = props.camera, Slash = props.slash?.Picker, Mention = props.mentions?.Picker;\n const canPhoto = canAttach && coarse && acceptsPhotos(props.fileAccept) && Camera !== undefined;\n const slash = Slash ? props.slash : undefined, mentions = Mention ? props.mentions : undefined;\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 const resize = () => {\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 };\n resize();\n if (pendingCaret.current !== null) { element.setSelectionRange(pendingCaret.current, pendingCaret.current); element.focus(); pendingCaret.current = null; }\n if (typeof ResizeObserver === 'undefined') return;\n let width = element.clientWidth;\n const observer = new ResizeObserver(() => {\n const next = element.clientWidth;\n if (next === width) return;\n width = next; resize();\n });\n observer.observe(element);\n return () => observer.disconnect();\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 || props.hasFiles || (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 // Ctrl+Z right after a paste that is still uploading puts the pasted text back, instead of undoing the paste in the textarea.\n const undo = (props.uploads ?? []).filter(item => item.state === 'uploading' && item.cancel && item.restoreWhile === props.text).pop();\n if (undo && (event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'z') { event.preventDefault(); undo.cancel!(); 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)); return; }\n if (!props.onPasteText) return;\n const pasted = event.clipboardData.getData('text/plain');\n if (!isLongPaste(pasted, props.pasteToFile)) return;\n event.preventDefault();\n props.onPasteText({ text: pasted, start: event.currentTarget.selectionStart, end: event.currentTarget.selectionEnd });\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 chipList = (props.quote || chips.length > 0 || (props.uploads?.length ?? 0) > 0 || (feedback?.count ?? 0) > 0 || attachments.length > 0) && <>\n {props.quote}\n {feedback && feedback.count > 0 && <div role=\"list\" className=\"contents\"><NotesChip feedback={feedback} /></div>}\n {chips.length > 0 && <div role=\"list\" data-testid=\"mention-chips\" aria-label={labels.mentionedFiles} className=\"contents\">\n {chips.map(path => <Chip key={path} data-testid=\"mention-chip\" data-path={path} title={path} className=\"max-w-full sm:max-w-[16rem]\"\n media={props.mentions?.icon ?? <FileTextIcon aria-hidden=\"true\" />} label={props.mentions?.label?.(path) ?? path} actions={<RemoveChip label={labels.remove(props.mentions?.label?.(path) ?? path)} onClick={() => props.onText(removeMention(text, path))} />} />)}\n </div>}\n {(props.uploads?.length ?? 0) > 0 && <div role=\"list\" data-testid=\"uploads\" aria-label={labels.uploads} className=\"contents\">\n {props.uploads!.map(item => <Chip key={item.id} data-testid=\"upload-chip\" state={item.state === 'failed' ? 'error' : 'uploading'} data-state={item.state} title={item.error ?? item.name} className=\"max-w-full sm:max-w-[20rem]\"\n media={item.state === 'failed' ? <AlertCircleIcon className=\"text-destructive\" aria-hidden=\"true\" /> : <Loader2Icon className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />}\n label={item.state === 'failed' ? labels.uploadFailed(item.name, item.error) : labels.uploading(item.name)}\n actions={<>\n {item.cancel && <RemoveChip label={labels.restorePaste} onClick={() => item.cancel!()} />}\n {item.state === 'failed' && <RemoveChip label={labels.dismiss(item.name)} onClick={() => props.onDismissUpload?.(item.id)} />}\n </>} />)}\n </div>}\n {attachments.length > 0 && <div role=\"list\" data-testid=\"attachments\" aria-label={labels.attachments} className=\"contents\">\n {attachments.map(attachment => {\n const src = thumbnail(attachment.mimeType, attachment.data);\n return <Chip key={attachment.id} data-testid=\"attachment\" className=\"max-w-full sm:max-w-[14rem]\" image={!!src}\n media={src ? <img src={src} alt=\"\" /> : <FileTextIcon aria-hidden=\"true\" />} label={attachment.name}\n actions={<RemoveChip label={labels.remove(attachment.name)} onClick={() => props.onRemoveAttachment(attachment.id)} />} />;\n })}\n </div>}\n </>;\n const pill = layout === 'pill';\n const textareaEl = <InputGroupTextarea 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={pill ? 'max-h-52 min-h-0 min-w-0' : 'max-h-52 min-h-11'} />;\n const plus = (canAttach || items.length > 0) && <PlusActions disabled={disabled} textarea={inner} onAttach={canAttach ? () => fileInput.current?.click() : undefined} onTakePhoto={canPhoto ? () => setCameraOpen(true) : undefined} onCommands={items.length > 0 ? openCommands : undefined} />;\n // One child slot either way, so a composer without `start` keeps its exact markup (React ids included).\n const startAndPlus = props.start ? <><div data-testid=\"composer-start\" className=\"flex shrink-0 items-center gap-1\">{props.start}</div>{plus}</> : plus;\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=\"shrink-0\">\n {stopRequested ? <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" /> : <icons.stop data-icon=\"inline-start\" aria-hidden=\"true\" />}</Button>\n : props.whenEmpty && !hasContent ? <div data-testid=\"composer-when-empty\" className=\"flex shrink-0 items-center\">{props.whenEmpty}</div>\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=\"shrink-0\">\n <icons.send data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>;\n const end = <>\n {props.barStart}\n {props.chips && <div className=\"flex min-w-0 items-center gap-2\">{props.chips}</div>}\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 {!pill && <span className=\"min-w-0 flex-1\" />}\n {props.barEnd && <div data-testid=\"composer-tools\" className=\"flex shrink-0 items-center gap-2\">{props.barEnd}</div>}\n {working && hasContent && <Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-queue\" aria-label={labels.queue} title={labels.queue} disabled={!canSubmit} onClick={props.onSend} className=\"shrink-0\">\n <icons.queue data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {send}\n </>;\n\n // One shadcn InputGroup: the chips, the text and a row of controls (`card`, also used for the `stacked` and `inline` compatibility\n // values), or one row with the text between the controls (`pill`, chips above it). Its look is the host's; ours is layout only.\n return <form data-testid=\"composer\" data-layout={layout} data-dragging={dragging || undefined} onSubmit={event => { event.preventDefault(); if (canSubmit) props.onSend(); }} onDragOver={drag} onDragLeave={drag} onDrop={drop}\n className={cn('relative flex flex-col gap-2', pill && '@container/composer')}>\n {fileEl}\n {slashOpen && Slash && <Slash query={query!} items={items} onSelect={pickSlash} onDismiss={dismiss} />}\n {mentionOpen && mentions && Mention && <Mention query={trigger!.query} listLabel={mentions.listLabel} renderOption={mentions.renderOption} search={mentions.search} generation={mentions.generation} onSelect={pickMention} onDismiss={dismiss} />}\n {pill && chipList && <div className=\"flex flex-wrap gap-2\">{chipList}</div>}\n {pill ? <InputGroup>\n <InputGroupAddon>{startAndPlus}{feedback && <AnnotateButton feedback={feedback} disabled={disabled} />}</InputGroupAddon>\n {textareaEl}\n <InputGroupAddon align=\"inline-end\" data-testid=\"composer-controls\">{end}</InputGroupAddon>\n </InputGroup> : <InputGroup>\n {chipList && <InputGroupAddon align=\"block-start\" className=\"flex-wrap\">{chipList}</InputGroupAddon>}\n {textareaEl}\n <InputGroupAddon align=\"block-end\" data-testid=\"composer-controls\">\n {startAndPlus}\n {feedback && <AnnotateButton feedback={feedback} disabled={disabled} />}\n {end}\n </InputGroupAddon>\n </InputGroup>}\n {cameraOpen && Camera && <Camera onClose={() => setCameraOpen(false)} onCapture={file => { setCameraOpen(false); props.onPickFiles([file]); }} />}\n </form>;\n}\n",
97
69
  "type": "registry:component",
98
70
  "target": "components/pi-chat/composer.tsx"
99
71
  },
@@ -105,7 +77,7 @@
105
77
  },
106
78
  {
107
79
  "path": "registry/pi-chat/config.ts",
108
- "content": "import type { ComponentType, ReactNode } from 'react';\nimport type { BlockAction } from '../button/actions';\nimport type { CameraSheetProps } from './camera-sheet';\n\n/*\n * Composer features the host switches on by configuration. Each prop is optional: leave it out and the composer has\n * no menu, no button and no keyboard trigger for it. The current model and thinking level are read from the native\n * view's `pi.agent` document, so there is no extra state to keep in sync. A config may carry the component that draws its feature\n * (`Picker`, `Camera`, `Button`, `Bubble`); leave it out and the built-in one is used.\n */\n\n/** Where a `/` menu entry comes from. Skills and host commands are grouped separately in the menu. */\nexport type SlashSource = 'local' | 'extension' | 'prompt' | 'skill';\n\nexport interface SlashApi {\n /** Replace the composer text. */\n readonly setText: (text: string) => void;\n /** The composer text with the typed `/query` already removed. */\n readonly text: string;\n}\nexport interface SlashCommand {\n readonly name: string;\n readonly description?: string;\n /** Shown as a badge and as a filter chip; commands without one are grouped as `built-in`. */\n readonly sourcePlugin?: string;\n readonly run: (api: SlashApi) => void | Promise<void>;\n}\nexport interface SlashSkill {\n readonly name: string;\n readonly description: string;\n readonly sourcePlugin?: string;\n}\nexport interface SlashConfig {\n readonly commands?: readonly SlashCommand[];\n readonly skills?: readonly SlashSkill[];\n /** The `/` menu. Default: the built-in `SlashPicker`. */\n readonly Picker?: ComponentType<SlashPickerProps> | undefined;\n}\n\nexport interface MentionResult { readonly path: string; readonly kind?: 'file' | 'directory'; readonly label?: string }\nexport interface MentionsConfig {\n /** Accessible name and optional option/media presentation for a host catalog. */\n readonly listLabel?: string;\n readonly icon?: ReactNode;\n readonly renderOption?: (item: MentionResult, query: string) => ReactNode;\n readonly label?: (id: string) => string;\n /** Workspace search for the `@` picker. Empty `query` asks for a default listing. Abort `signal` when the query changes. */\n readonly search: (query: string, signal: AbortSignal) => Promise<readonly MentionResult[]>;\n /** Open a mentioned workspace file (for example in the right panel). When given, a mention in a sent message is a button. */\n readonly open?: ((path: string) => void) | undefined;\n /** Changes whenever what `search` would return may have changed (a workspace change event, another workspace). An open picker searches again. */\n readonly generation?: string | number | undefined;\n /** Host-supplied look for a mention in a sent message (a plug-in such as feedback). Return undefined to keep the default mention. */\n readonly render?: ((path: string) => ReactNode | undefined) | undefined;\n /** Sent-message rendering, independent of the composer's token presentation. */\n readonly renderSent?: ((path: string) => ReactNode | undefined) | undefined;\n /**\n * Upload bytes through the host's provider. Image cards read thumbnails; voice bubbles read audio only on Play.\n * Thumbnails accept png, jpeg, gif and webp. Audio must retain its recorded audio MIME type.\n */\n readonly preview?: PreviewRead | undefined;\n /** Optional authorized playback URL, requested only on Play; otherwise voice audio uses `preview`. */\n readonly audioUrl?: AudioUrlRead | undefined;\n /** The `@` picker. Default: the built-in `MentionPicker`. */\n readonly Picker?: ComponentType<MentionPickerProps> | undefined;\n}\nexport type AudioUrlRead = (path: string, signal: AbortSignal) => Promise<string | undefined>;\n/** Reads an upload for its thumbnail or lazy voice playback: its media type and base64 data, or undefined. */\nexport type PreviewRead = (path: string, signal: AbortSignal) => Promise<{ readonly mimeType: string; readonly data: string } | undefined>;\n\n/** Paste-to-file thresholds: a paste with more characters or more lines than these becomes a file. Defaults 2000 and 40. */\nexport interface PasteToFile { readonly minChars?: number | undefined; readonly minLines?: number | undefined }\n/** A paste the composer took, with the textarea range it replaces (restored if the upload is cancelled). */\nexport interface PastedText { readonly text: string; readonly start: number; readonly end: number }\n\nexport interface UploadResult {\n /** The host saved the file in the workspace: it becomes an `@path` mention. */\n readonly path?: string;\n /** Sent as a native image attachment instead of a path mention when both are supplied. */\n readonly image?: { readonly data: string; readonly mimeType: string };\n readonly name: string;\n}\nexport interface VoiceMessageConfig {\n readonly transcribe: (audio: Blob, signal: AbortSignal) => Promise<string>;\n /** Capture limit (default five minutes). Upload and send still wait for release. */\n readonly maxMs?: number;\n /** Same workspace upload contract as `attachments.upload`, used instead when supplied. Preserve the voice- filename prefix. */\n readonly upload?: AttachmentsConfig['upload'];\n /** The hold-to-record button at the end of the composer bar. Default: the built-in `VoiceMessageButton`. */\n readonly Button?: ComponentType<VoiceMessageButtonProps> | undefined;\n /** A voice message on its way (recording, transcribing, uploading). Default: the built-in `PendingVoiceBubble`. */\n readonly Bubble?: ComponentType<PendingVoiceBubbleProps> | undefined;\n}\n\nexport interface AttachmentsConfig {\n /** The file input's `accept`. Any file when omitted. */\n readonly accept?: string;\n readonly upload: (files: File[], signal: AbortSignal) => Promise<readonly UploadResult[]>;\n /** The full-screen camera the attach menu opens on a phone. Default: the built-in `CameraSheet`. */\n readonly Camera?: ComponentType<CameraSheetProps> | undefined;\n}\n\n/** One past conversation in the history list. `title` is its name or first message; `updatedAt` is when it last had activity. */\nexport interface ConversationItem {\n readonly id: string;\n readonly title?: string | undefined;\n readonly updatedAt?: number | string | Date | undefined;\n /** A preview of the last message (the row's tooltip). */\n readonly lastMessage?: string | null | undefined;\n readonly archived?: boolean | undefined;\n}\n/** The host's conversations. Give it to `PiChat` and the header's History button opens a list of them instead of the earlier records of this one. */\nexport interface ConversationsConfig {\n readonly items: readonly ConversationItem[];\n /** The conversation that is open; it is marked in the list. */\n readonly activeId?: string | undefined;\n readonly onSelect: (id: string) => void;\n readonly onNew?: (() => void) | undefined;\n readonly loading?: boolean | undefined;\n /**\n * Server-side search: with it, a typed query (or the Archived filter) lists what this returns instead of filtering `items`\n * by title. Abort `signal` when the query changes.\n */\n readonly search?: ((query: string, options: { readonly archived: boolean }, signal: AbortSignal) => Promise<readonly ConversationItem[]>) | undefined;\n /** Each action below adds its button to the rows; the host refreshes `items` when it resolves. */\n readonly rename?: ((id: string, title: string) => Promise<void>) | undefined;\n /** Archive (`true`) or restore (`false`). With it, the list also offers an Archived filter (which needs `search`). */\n readonly archive?: ((id: string, archived: boolean) => Promise<void>) | undefined;\n readonly remove?: ((id: string) => Promise<void>) | undefined;\n /** Fork the open conversation after the entry of a settled reply: the reply's Fork button. */\n readonly fork?: ((atEntryId: string) => Promise<void>) | undefined;\n /**\n * Host actions on each row, after rename, archive and delete: `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids\n * `conversation-action-<id>`, the menu `conversation-action-more`.\n */\n readonly rowActions?: ((item: ConversationItem) => readonly BlockAction[]) | undefined;\n}\n\n/** A settled reply, as host message actions receive it: its row key, its text and the native entry it ends with. */\nexport interface ReplyRef {\n readonly key: string; readonly text: string; readonly entryId?: string | undefined;\n /** When the last model round of the reply was written (ms), the same value as its message's `timestamp`: a host keys its own per-reply data on it. */\n readonly timestamp?: number | undefined;\n /** The model that wrote the last round of the reply, as its message names it (`provider` and `model` ids). */\n readonly model?: { readonly provider: string; readonly modelId: string } | undefined;\n /**\n * Sum of the model rounds that form the reply (ms): Pi's own `AssistantMessage.durationMs`, measured monotonically\n * and kept in the transcript. It excludes queue time, tool runs and retry delays, and is absent for a message written\n * before Pi recorded it. It is never computed from the clock at render time.\n */\n readonly durationMs?: number | undefined;\n}\n\nexport interface ModelRef { readonly provider: string; readonly modelId: string }\nexport interface ModelConfig {\n readonly options: readonly (ModelRef & { readonly label?: string })[];\n /** Reject to refuse: the message is shown inline. */\n readonly change: (model: ModelRef) => Promise<void>;\n /** The one model-and-effort pill. Default: the built-in `ModelEffortPicker` (`effort.Picker` is used when only it is set). */\n readonly Picker?: ComponentType<ModelEffortPickerProps> | undefined;\n}\nexport interface EffortConfig {\n readonly options: readonly string[];\n readonly change: (level: string) => Promise<void>;\n /** The one model-and-effort pill, when `model` does not carry one. Default: the built-in `ModelEffortPicker`. */\n readonly Picker?: ComponentType<ModelEffortPickerProps> | undefined;\n}\n\n/** The unified, internal menu entry. */\nexport interface SlashItem {\n readonly name: string;\n readonly description: string;\n readonly source: SlashSource;\n readonly sourcePlugin?: string;\n}\n\nexport function slashItems(config: SlashConfig): SlashItem[] {\n return [\n ...(config.commands ?? []).map((command): SlashItem => ({ name: command.name, description: command.description ?? '', source: 'local', ...(command.sourcePlugin ? { sourcePlugin: command.sourcePlugin } : {}) })),\n ...(config.skills ?? []).map((skill): SlashItem => ({ name: skill.name, description: skill.description, source: 'skill', ...(skill.sourcePlugin ? { sourcePlugin: skill.sourcePlugin } : {}) })),\n ];\n}\n\n/** The group an item lives under in the filter chips: skills, its plugin, or `built-in`. */\nexport function slashGroup(item: Pick<SlashItem, 'source' | 'sourcePlugin'>): string {\n if (item.source === 'skill') return 'skills';\n return item.sourcePlugin || 'built-in';\n}\n\n/** `/` opens the menu only when the text before the caret is one `/token`, as in v2. */\nexport function slashQuery(text: string, caret: number): string | null {\n const match = /^\\/(\\S*)$/.exec(text.slice(0, caret));\n return match ? match[1]! : null;\n}\n\nexport interface MentionTrigger { readonly query: string; readonly start: number; readonly end: number }\n/** An `@` at the start or after whitespace, up to the caret. */\nexport function mentionTrigger(text: string, caret: number): MentionTrigger | null {\n const before = text.slice(0, caret);\n const match = /(^|\\s)@(\\S*)$/.exec(before);\n // A typed opening quote (`@\"Meeting`) is not part of the search; the token starts at the `@` either way.\n return match ? { query: match[2]!.replace(/^\"/, ''), start: before.length - match[2]!.length - 1, end: caret } : null;\n}\n\n/**\n * The text of a mention of `path`. A path with whitespace or a quote is written in double quotes, `@\"docs/Meeting notes.md\"`, with `\\`\n * and `\"` escaped, because a bare mention ends at a space. Any other path stays bare (`@docs/plan.md`), as it always was.\n */\nexport function mentionToken(path: string): string {\n return /[\\s\"]/.test(path) ? `@\"${path.replace(/[\\\\\"]/g, '\\\\$&')}\"` : `@${path}`;\n}\n/**\n * The `@` tokens of a message, bare or quoted: group 1 is the character before the `@` (or the start), group 2 the quoted body (escapes\n * still in it), group 3 the bare path. `test/contracts/pi-chat-source.test.mjs` keeps `mentionedPaths` in the agent package equal to this.\n */\nexport const MENTION_TOKEN = /(^|\\s)@(?:\"((?:[^\"\\\\]|\\\\.)*)\"|([^\\s]+))/g;\n\nconst escape = (value: string) => value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\nconst BOUNDARY = '[^A-Za-z0-9_./-]';\nexport function hasMention(text: string, path: string): boolean {\n return new RegExp(`(^|${BOUNDARY})${escape(mentionToken(path))}($|${BOUNDARY})`).test(text);\n}\nexport function removeMention(text: string, path: string): string {\n return text.replace(new RegExp(`(^|${BOUNDARY})${escape(mentionToken(path))}(?=$|${BOUNDARY})[ ]?`), '$1').replace(/^[ ]+/, '');\n}\n\nexport type Piece = { readonly text: string; readonly kind?: 'mention' | 'skill'; readonly value?: string };\n\n/** Upload paths are `uploads/<name>`: the attachments of a message, shown as cards rather than as text. */\nexport const isUploadPath = (path: string): boolean => path.startsWith('uploads/');\n\nexport type FileKind = 'document' | 'image' | 'pdf' | 'sheet' | 'slides' | 'audio' | 'file';\nconst KIND_BY_EXTENSION: Readonly<Record<string, FileKind>> = {\n png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image', svg: 'image', bmp: 'image', avif: 'image',\n pdf: 'pdf',\n webm: 'audio', ogg: 'audio', m4a: 'audio', mp4: 'audio', mp3: 'audio', wav: 'audio',\n csv: 'sheet', tsv: 'sheet', xls: 'sheet', xlsx: 'sheet', xlsm: 'sheet', ods: 'sheet', numbers: 'sheet',\n ppt: 'slides', pptx: 'slides', odp: 'slides', key: 'slides',\n md: 'document', markdown: 'document', txt: 'document', doc: 'document', docx: 'document', odt: 'document', rtf: 'document',\n};\n/** The card's kind, from the extension alone. */\nexport function fileKind(path: string): FileKind {\n const extension = /\\.([^./]+)$/.exec(path)?.[1]?.toLowerCase() ?? '';\n return KIND_BY_EXTENSION[extension] ?? 'file';\n}\n/** Recorded voice uses a reserved upload filename prefix; other audio remains a plain file card. */\nexport const isVoicePath = (path: string): boolean => /^uploads\\/voice-[^/]+$/.test(path) && fileKind(path) === 'audio';\n\n/** The name a card shows: the upload's file name without the random suffix `uploadPath` adds (`report-1a2b3c4d.pdf` is `report.pdf`). */\nexport function displayName(path: string): string {\n const name = path.slice(path.lastIndexOf('/') + 1);\n return name.replace(/-[0-9a-f]{8}(?=\\.[^.]*$|$)/i, '');\n}\n\n/**\n * The pieces a message shows once `hidden` ones are taken out: a space that a removed piece leaves at a join is dropped, and the ends are\n * trimmed where it was. The message's own text is not changed, only what the bubble draws.\n */\nexport function visibleParts(parts: readonly Piece[], hidden: (part: Piece) => boolean): Piece[] {\n const out: Piece[] = [];\n let gap = false;\n for (const part of parts) {\n if (hidden(part)) { gap = true; continue; }\n const prev = out[out.length - 1];\n const joins = gap && part.kind === undefined && (prev === undefined || (prev.kind === undefined && /\\s$/.test(prev.text)));\n out.push(joins ? { text: part.text.replace(/^\\s+/, '') } : part);\n gap = false;\n }\n const last = out[out.length - 1];\n if (gap && last && last.kind === undefined) out[out.length - 1] = { text: last.text.replace(/\\s+$/, '') };\n return out.filter(part => part.kind !== undefined || part.text !== '');\n}\n\n/** Whether pasted text is long enough to become a file. `more` than the limits: the characters, or the lines, of the text. */\nexport function isLongPaste(text: string, limits: PasteToFile = {}): boolean {\n const { minChars = 2000, minLines = 40 } = limits;\n return text.length > minChars || text.split('\\n').length > minLines;\n}\n/** Markdown in pasted text: a heading, a list or quote item, a fence, a table row, emphasis or a link. Anything else is plain text. */\nexport function looksLikeMarkdown(text: string): boolean {\n return /^\\s{0,3}(#{1,6}\\s|[-*+]\\s|\\d+[.)]\\s|>\\s|```|~~~)/m.test(text) || /\\*\\*[^*\\n]+\\*\\*|\\]\\([^)\\n]+\\)|^\\|.*\\|\\s*$/m.test(text);\n}\n/** Split a sent message into plain text, `@path` mentions and a leading `/skill` token. */\nexport function pieces(text: string, options: { readonly mentions: boolean; readonly skills: readonly string[]; readonly uploads?: boolean }): Piece[] {\n const found: { start: number; end: number; kind: 'mention' | 'skill'; value: string }[] = [];\n const skill = options.skills.length ? new RegExp(`^/(${options.skills.map(escape).join('|')})(?=\\\\s|$)`).exec(text) : null;\n if (skill) found.push({ start: 0, end: skill[0].length, kind: 'skill', value: skill[1]! });\n if (options.mentions || options.uploads) {\n for (const match of text.matchAll(MENTION_TOKEN)) {\n // A quoted path keeps its punctuation (the closing quote ends it); a bare one drops trailing punctuation, as a sentence would.\n const quoted = match[2] !== undefined;\n const path = quoted ? match[2]!.replace(/\\\\(.)/g, '$1') : match[3]!.replace(/[.,;:!?)\\]}'\"]+$/, '');\n if (!path || (!options.mentions && !isUploadPath(path))) continue;\n const start = match.index! + match[1]!.length;\n const end = quoted ? match.index! + match[0].length : start + 1 + path.length;\n found.push({ start, end, kind: 'mention', value: path });\n }\n }\n const out: Piece[] = [];\n let cursor = 0;\n for (const item of found.sort((a, b) => a.start - b.start)) {\n if (item.start < cursor) continue;\n if (item.start > cursor) out.push({ text: text.slice(cursor, item.start) });\n out.push({ text: text.slice(item.start, item.end), kind: item.kind, value: item.value });\n cursor = item.end;\n }\n if (cursor < text.length) out.push({ text: text.slice(cursor) });\n return out;\n}\n\n/** Props of the slash picker; `SlashConfig.Picker` replaces the built-in one. */\nexport interface SlashPickerProps {\n readonly query: string;\n readonly items: readonly SlashItem[];\n readonly onSelect: (item: SlashItem) => void;\n readonly onDismiss: () => void;\n}\n\n/** Props of the mention picker; `MentionsConfig.Picker` replaces the built-in one. */\nexport interface MentionPickerProps {\n readonly query: string;\n readonly listLabel?: MentionsConfig['listLabel'];\n readonly renderOption?: MentionsConfig['renderOption'];\n readonly generation?: MentionsConfig['generation'];\n readonly search: MentionsConfig['search'];\n readonly onSelect: (path: string) => void;\n readonly onDismiss: () => void;\n}\n\n/** Props of the model and effort picker; `ModelConfig.Picker` or `EffortConfig.Picker` replaces the built-in one. */\nexport interface ModelEffortPickerProps {\n readonly model: ModelConfig | undefined; readonly effort: EffortConfig | undefined;\n readonly currentModel: ModelRef | undefined; readonly currentEffort: string | undefined;\n readonly disabled: boolean; readonly busy: 'model' | 'effort' | null;\n readonly onModel: (model: ModelRef) => void; readonly onEffort: (level: string) => void;\n}\n\nexport type VoicePhase = 'starting' | 'recording' | 'processing' | 'transcribe-failed' | 'upload-failed' | 'sending' | 'unconfirmed' | 'blocked';\n/** A voice message on its way: recorded, then transcribed, uploaded and sent. */\nexport interface PendingVoice {\n readonly id: number;\n readonly path: string | undefined;\n readonly phase: VoicePhase;\n readonly seconds: number;\n readonly retry: () => void;\n readonly audioOnly: (() => void) | undefined;\n readonly discard: () => void;\n}\n/** The recorder the chat hands to the voice button. */\nexport interface VoiceMessageHandle {\n readonly canStart: boolean;\n readonly phase: VoicePhase | 'idle';\n readonly seconds: number;\n readonly captureSequence: number;\n readonly problem: string | undefined;\n readonly pending: readonly PendingVoice[];\n readonly start: () => void;\n readonly finish: () => void;\n readonly cancel: () => void;\n}\n/** Props of the hold-to-record button; `VoiceMessageConfig.Button` replaces the built-in one. */\nexport interface VoiceMessageButtonProps { readonly voice: VoiceMessageHandle; readonly disabled?: boolean }\n/** Props of a pending voice message bubble; `VoiceMessageConfig.Bubble` replaces the built-in one. */\nexport interface PendingVoiceBubbleProps { readonly pending: PendingVoice | undefined }\n",
80
+ "content": "import type { ComponentType, ReactNode } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { ChatController } from '@hachej/boring-ui-kit/native-chat';\nimport type { BlockAction } from '../button/actions';\nimport type { ChatLabels } from './labels';\n\n/*\n * Composer features the host switches on by configuration. Each prop is optional: leave it out and the composer has\n * no menu, no button and no keyboard trigger for it. The current model and thinking level are read from the native\n * view's `pi.agent` document, so there is no extra state to keep in sync. The component that draws a feature comes from its own\n * registry item and rides on the config (`Picker`, `Camera`, `useRecorder` with `Button` and `Bubble`): without it the feature is\n * simply not offered (no menu, no pill, no camera entry, no record button). `withChatPlugins` of `pi-chat-plugins` fills them all in.\n */\n\n/** Where a `/` menu entry comes from. Skills and host commands are grouped separately in the menu. */\nexport type SlashSource = 'local' | 'extension' | 'prompt' | 'skill';\n\nexport interface SlashApi {\n /** Replace the composer text. */\n readonly setText: (text: string) => void;\n /** The composer text with the typed `/query` already removed. */\n readonly text: string;\n}\nexport interface SlashCommand {\n readonly name: string;\n readonly description?: string;\n /** Shown as a badge and as a filter chip; commands without one are grouped as `built-in`. */\n readonly sourcePlugin?: string;\n readonly run: (api: SlashApi) => void | Promise<void>;\n}\nexport interface SlashSkill {\n readonly name: string;\n readonly description: string;\n readonly sourcePlugin?: string;\n}\nexport interface SlashConfig {\n readonly commands?: readonly SlashCommand[];\n readonly skills?: readonly SlashSkill[];\n /** The `/` menu (`SlashPicker` of `pi-chat-slash`). Without it there is no `/` menu and no Commands entry. */\n readonly Picker?: ComponentType<SlashPickerProps> | undefined;\n}\n\nexport interface MentionResult { readonly path: string; readonly kind?: 'file' | 'directory'; readonly label?: string }\nexport interface MentionsConfig {\n /** Accessible name and optional option/media presentation for a host catalog. */\n readonly listLabel?: string;\n readonly icon?: ReactNode;\n readonly renderOption?: (item: MentionResult, query: string) => ReactNode;\n readonly label?: (id: string) => string;\n /** Workspace search for the `@` picker. Empty `query` asks for a default listing. Abort `signal` when the query changes. */\n readonly search: (query: string, signal: AbortSignal) => Promise<readonly MentionResult[]>;\n /** Open a mentioned workspace file (for example in the right panel). When given, a mention in a sent message is a button. */\n readonly open?: ((path: string) => void) | undefined;\n /** Changes whenever what `search` would return may have changed (a workspace change event, another workspace). An open picker searches again. */\n readonly generation?: string | number | undefined;\n /** Host-supplied look for a mention in a sent message (a plug-in such as feedback). Return undefined to keep the default mention. */\n readonly render?: ((path: string) => ReactNode | undefined) | undefined;\n /** Sent-message rendering, independent of the composer's token presentation. */\n readonly renderSent?: ((path: string) => ReactNode | undefined) | undefined;\n /**\n * Upload bytes through the host's provider. Image cards read thumbnails; voice bubbles read audio only on Play.\n * Thumbnails accept png, jpeg, gif and webp. Audio must retain its recorded audio MIME type.\n */\n readonly preview?: PreviewRead | undefined;\n /** Optional authorized playback URL, requested only on Play; otherwise voice audio uses `preview`. */\n readonly audioUrl?: AudioUrlRead | undefined;\n /** The `@` picker (`MentionPicker` of `pi-chat-mentions`). Without it typing `@` opens nothing. */\n readonly Picker?: ComponentType<MentionPickerProps> | undefined;\n}\nexport type AudioUrlRead = (path: string, signal: AbortSignal) => Promise<string | undefined>;\n/** Reads an upload for its thumbnail or lazy voice playback: its media type and base64 data, or undefined. */\nexport type PreviewRead = (path: string, signal: AbortSignal) => Promise<{ readonly mimeType: string; readonly data: string } | undefined>;\n\n/** Paste-to-file thresholds: a paste with more characters or more lines than these becomes a file. Defaults 2000 and 40. */\nexport interface PasteToFile { readonly minChars?: number | undefined; readonly minLines?: number | undefined }\n/** A paste the composer took, with the textarea range it replaces (restored if the upload is cancelled). */\nexport interface PastedText { readonly text: string; readonly start: number; readonly end: number }\n\nexport interface UploadResult {\n /** The host saved the file in the workspace: it becomes an `@path` mention. */\n readonly path?: string;\n /** Sent as a native image attachment instead of a path mention when both are supplied. */\n readonly image?: { readonly data: string; readonly mimeType: string };\n readonly name: string;\n}\nexport interface VoiceMessageConfig {\n readonly transcribe: (audio: Blob, signal: AbortSignal) => Promise<string>;\n /** Capture limit (default five minutes). Upload and send still wait for release. */\n readonly maxMs?: number;\n /** Same workspace upload contract as `attachments.upload`, used instead when supplied. Preserve the voice- filename prefix. */\n readonly upload?: AttachmentsConfig['upload'];\n /** The recorder (`useVoiceMessage` of `pi-chat-voice`). Without it there is no voice message: no button, no pending bubble. */\n readonly useRecorder?: VoiceRecorderHook | undefined;\n /** The hold-to-record button at the end of the composer bar (`VoiceMessageButton` of `pi-chat-voice`). */\n readonly Button?: ComponentType<VoiceMessageButtonProps> | undefined;\n /** A voice message on its way: recording, transcribing, uploading (`PendingVoiceBubble` of `pi-chat-voice`). */\n readonly Bubble?: ComponentType<PendingVoiceBubbleProps> | undefined;\n}\n\nexport interface AttachmentsConfig {\n /** The file input's `accept`. Any file when omitted. */\n readonly accept?: string;\n readonly upload: (files: File[], signal: AbortSignal) => Promise<readonly UploadResult[]>;\n /** The full-screen camera the attach menu opens on a phone (`CameraSheet` of `pi-chat-attachments`). Without it there is no Take a photo entry. */\n readonly Camera?: ComponentType<CameraSheetProps> | undefined;\n}\n\n/** One past conversation in the history list. `title` is its name or first message; `updatedAt` is when it last had activity. */\nexport interface ConversationItem {\n readonly id: string;\n readonly title?: string | undefined;\n readonly updatedAt?: number | string | Date | undefined;\n /** A preview of the last message (the row's tooltip). */\n readonly lastMessage?: string | null | undefined;\n readonly archived?: boolean | undefined;\n}\n/** The host's conversations. Give it to `PiChat` and the header's History button opens a list of them instead of the earlier records of this one. */\nexport interface ConversationsConfig {\n readonly items: readonly ConversationItem[];\n /** The conversation that is open; it is marked in the list. */\n readonly activeId?: string | undefined;\n readonly onSelect: (id: string) => void;\n readonly onNew?: (() => void) | undefined;\n readonly loading?: boolean | undefined;\n /**\n * Server-side search: with it, a typed query (or the Archived filter) lists what this returns instead of filtering `items`\n * by title. Abort `signal` when the query changes.\n */\n readonly search?: ((query: string, options: { readonly archived: boolean }, signal: AbortSignal) => Promise<readonly ConversationItem[]>) | undefined;\n /** Each action below adds its button to the rows; the host refreshes `items` when it resolves. */\n readonly rename?: ((id: string, title: string) => Promise<void>) | undefined;\n /** Archive (`true`) or restore (`false`). With it, the list also offers an Archived filter (which needs `search`). */\n readonly archive?: ((id: string, archived: boolean) => Promise<void>) | undefined;\n readonly remove?: ((id: string) => Promise<void>) | undefined;\n /** Fork the open conversation after the entry of a settled reply: the reply's Fork button. */\n readonly fork?: ((atEntryId: string) => Promise<void>) | undefined;\n /**\n * Host actions on each row, after rename, archive and delete: `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids\n * `conversation-action-<id>`, the menu `conversation-action-more`.\n */\n readonly rowActions?: ((item: ConversationItem) => readonly BlockAction[]) | undefined;\n}\n\n/** A settled reply, as host message actions receive it: its row key, its text and the native entry it ends with. */\nexport interface ReplyRef {\n readonly key: string; readonly text: string; readonly entryId?: string | undefined;\n /** When the last model round of the reply was written (ms), the same value as its message's `timestamp`: a host keys its own per-reply data on it. */\n readonly timestamp?: number | undefined;\n /** The model that wrote the last round of the reply, as its message names it (`provider` and `model` ids). */\n readonly model?: { readonly provider: string; readonly modelId: string } | undefined;\n /**\n * Sum of the model rounds that form the reply (ms): Pi's own `AssistantMessage.durationMs`, measured monotonically\n * and kept in the transcript. It excludes queue time, tool runs and retry delays, and is absent for a message written\n * before Pi recorded it. It is never computed from the clock at render time.\n */\n readonly durationMs?: number | undefined;\n}\n\nexport interface ModelRef { readonly provider: string; readonly modelId: string }\nexport interface ModelConfig {\n readonly options: readonly (ModelRef & { readonly label?: string })[];\n /** Reject to refuse: the message is shown inline. */\n readonly change: (model: ModelRef) => Promise<void>;\n /** The one model-and-effort pill (`ModelEffortPicker` of `pi-chat-pickers`; `effort.Picker` is used when only it is set). Without one there is no pill. */\n readonly Picker?: ComponentType<ModelEffortPickerProps> | undefined;\n}\nexport interface EffortConfig {\n readonly options: readonly string[];\n readonly change: (level: string) => Promise<void>;\n /** The one model-and-effort pill, when `model` does not carry one (`ModelEffortPicker` of `pi-chat-pickers`). */\n readonly Picker?: ComponentType<ModelEffortPickerProps> | undefined;\n}\n\n/** The unified, internal menu entry. */\nexport interface SlashItem {\n readonly name: string;\n readonly description: string;\n readonly source: SlashSource;\n readonly sourcePlugin?: string;\n}\n\nexport function slashItems(config: SlashConfig): SlashItem[] {\n return [\n ...(config.commands ?? []).map((command): SlashItem => ({ name: command.name, description: command.description ?? '', source: 'local', ...(command.sourcePlugin ? { sourcePlugin: command.sourcePlugin } : {}) })),\n ...(config.skills ?? []).map((skill): SlashItem => ({ name: skill.name, description: skill.description, source: 'skill', ...(skill.sourcePlugin ? { sourcePlugin: skill.sourcePlugin } : {}) })),\n ];\n}\n\n/** The group an item lives under in the filter chips: skills, its plugin, or `built-in`. */\nexport function slashGroup(item: Pick<SlashItem, 'source' | 'sourcePlugin'>): string {\n if (item.source === 'skill') return 'skills';\n return item.sourcePlugin || 'built-in';\n}\n\n/** `/` opens the menu only when the text before the caret is one `/token`, as in v2. */\nexport function slashQuery(text: string, caret: number): string | null {\n const match = /^\\/(\\S*)$/.exec(text.slice(0, caret));\n return match ? match[1]! : null;\n}\n\nexport interface MentionTrigger { readonly query: string; readonly start: number; readonly end: number }\n/** An `@` at the start or after whitespace, up to the caret. */\nexport function mentionTrigger(text: string, caret: number): MentionTrigger | null {\n const before = text.slice(0, caret);\n const match = /(^|\\s)@(\\S*)$/.exec(before);\n // A typed opening quote (`@\"Meeting`) is not part of the search; the token starts at the `@` either way.\n return match ? { query: match[2]!.replace(/^\"/, ''), start: before.length - match[2]!.length - 1, end: caret } : null;\n}\n\n/**\n * The text of a mention of `path`. A path with whitespace or a quote is written in double quotes, `@\"docs/Meeting notes.md\"`, with `\\`\n * and `\"` escaped, because a bare mention ends at a space. Any other path stays bare (`@docs/plan.md`), as it always was.\n */\nexport function mentionToken(path: string): string {\n return /[\\s\"]/.test(path) ? `@\"${path.replace(/[\\\\\"]/g, '\\\\$&')}\"` : `@${path}`;\n}\n/**\n * The `@` tokens of a message, bare or quoted: group 1 is the character before the `@` (or the start), group 2 the quoted body (escapes\n * still in it), group 3 the bare path. `test/contracts/pi-chat-source.test.mjs` keeps `mentionedPaths` in the agent package equal to this.\n */\nexport const MENTION_TOKEN = /(^|\\s)@(?:\"((?:[^\"\\\\]|\\\\.)*)\"|([^\\s]+))/g;\n\nconst escape = (value: string) => value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\nconst BOUNDARY = '[^A-Za-z0-9_./-]';\nexport function hasMention(text: string, path: string): boolean {\n return new RegExp(`(^|${BOUNDARY})${escape(mentionToken(path))}($|${BOUNDARY})`).test(text);\n}\nexport function removeMention(text: string, path: string): string {\n return text.replace(new RegExp(`(^|${BOUNDARY})${escape(mentionToken(path))}(?=$|${BOUNDARY})[ ]?`), '$1').replace(/^[ ]+/, '');\n}\n\nexport type Piece = { readonly text: string; readonly kind?: 'mention' | 'skill'; readonly value?: string };\n\n/** Upload paths are `uploads/<name>`: the attachments of a message, shown as cards rather than as text. */\nexport const isUploadPath = (path: string): boolean => path.startsWith('uploads/');\n\nexport type FileKind = 'document' | 'image' | 'pdf' | 'sheet' | 'slides' | 'audio' | 'file';\nconst KIND_BY_EXTENSION: Readonly<Record<string, FileKind>> = {\n png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image', svg: 'image', bmp: 'image', avif: 'image',\n pdf: 'pdf',\n webm: 'audio', ogg: 'audio', m4a: 'audio', mp4: 'audio', mp3: 'audio', wav: 'audio',\n csv: 'sheet', tsv: 'sheet', xls: 'sheet', xlsx: 'sheet', xlsm: 'sheet', ods: 'sheet', numbers: 'sheet',\n ppt: 'slides', pptx: 'slides', odp: 'slides', key: 'slides',\n md: 'document', markdown: 'document', txt: 'document', doc: 'document', docx: 'document', odt: 'document', rtf: 'document',\n};\n/** The card's kind, from the extension alone. */\nexport function fileKind(path: string): FileKind {\n const extension = /\\.([^./]+)$/.exec(path)?.[1]?.toLowerCase() ?? '';\n return KIND_BY_EXTENSION[extension] ?? 'file';\n}\n/** Recorded voice uses a reserved upload filename prefix; other audio remains a plain file card. */\nexport const isVoicePath = (path: string): boolean => /^uploads\\/voice-[^/]+$/.test(path) && fileKind(path) === 'audio';\n\n/** The name a card shows: the upload's file name without the random suffix `uploadPath` adds (`report-1a2b3c4d.pdf` is `report.pdf`). */\nexport function displayName(path: string): string {\n const name = path.slice(path.lastIndexOf('/') + 1);\n return name.replace(/-[0-9a-f]{8}(?=\\.[^.]*$|$)/i, '');\n}\n\n/**\n * The pieces a message shows once `hidden` ones are taken out: a space that a removed piece leaves at a join is dropped, and the ends are\n * trimmed where it was. The message's own text is not changed, only what the bubble draws.\n */\nexport function visibleParts(parts: readonly Piece[], hidden: (part: Piece) => boolean): Piece[] {\n const out: Piece[] = [];\n let gap = false;\n for (const part of parts) {\n if (hidden(part)) { gap = true; continue; }\n const prev = out[out.length - 1];\n const joins = gap && part.kind === undefined && (prev === undefined || (prev.kind === undefined && /\\s$/.test(prev.text)));\n out.push(joins ? { text: part.text.replace(/^\\s+/, '') } : part);\n gap = false;\n }\n const last = out[out.length - 1];\n if (gap && last && last.kind === undefined) out[out.length - 1] = { text: last.text.replace(/\\s+$/, '') };\n return out.filter(part => part.kind !== undefined || part.text !== '');\n}\n\n/** Whether pasted text is long enough to become a file. `more` than the limits: the characters, or the lines, of the text. */\nexport function isLongPaste(text: string, limits: PasteToFile = {}): boolean {\n const { minChars = 2000, minLines = 40 } = limits;\n return text.length > minChars || text.split('\\n').length > minLines;\n}\n/** Markdown in pasted text: a heading, a list or quote item, a fence, a table row, emphasis or a link. Anything else is plain text. */\nexport function looksLikeMarkdown(text: string): boolean {\n return /^\\s{0,3}(#{1,6}\\s|[-*+]\\s|\\d+[.)]\\s|>\\s|```|~~~)/m.test(text) || /\\*\\*[^*\\n]+\\*\\*|\\]\\([^)\\n]+\\)|^\\|.*\\|\\s*$/m.test(text);\n}\n/** Split a sent message into plain text, `@path` mentions and a leading `/skill` token. */\nexport function pieces(text: string, options: { readonly mentions: boolean; readonly skills: readonly string[]; readonly uploads?: boolean }): Piece[] {\n const found: { start: number; end: number; kind: 'mention' | 'skill'; value: string }[] = [];\n const skill = options.skills.length ? new RegExp(`^/(${options.skills.map(escape).join('|')})(?=\\\\s|$)`).exec(text) : null;\n if (skill) found.push({ start: 0, end: skill[0].length, kind: 'skill', value: skill[1]! });\n if (options.mentions || options.uploads) {\n for (const match of text.matchAll(MENTION_TOKEN)) {\n // A quoted path keeps its punctuation (the closing quote ends it); a bare one drops trailing punctuation, as a sentence would.\n const quoted = match[2] !== undefined;\n const path = quoted ? match[2]!.replace(/\\\\(.)/g, '$1') : match[3]!.replace(/[.,;:!?)\\]}'\"]+$/, '');\n if (!path || (!options.mentions && !isUploadPath(path))) continue;\n const start = match.index! + match[1]!.length;\n const end = quoted ? match.index! + match[0].length : start + 1 + path.length;\n found.push({ start, end, kind: 'mention', value: path });\n }\n }\n const out: Piece[] = [];\n let cursor = 0;\n for (const item of found.sort((a, b) => a.start - b.start)) {\n if (item.start < cursor) continue;\n if (item.start > cursor) out.push({ text: text.slice(cursor, item.start) });\n out.push({ text: text.slice(item.start, item.end), kind: item.kind, value: item.value });\n cursor = item.end;\n }\n if (cursor < text.length) out.push({ text: text.slice(cursor) });\n return out;\n}\n\n/** Props of the slash picker that `SlashConfig.Picker` carries. */\nexport interface SlashPickerProps {\n readonly query: string;\n readonly items: readonly SlashItem[];\n readonly onSelect: (item: SlashItem) => void;\n readonly onDismiss: () => void;\n}\n\n/** Props of the mention picker that `MentionsConfig.Picker` carries. */\nexport interface MentionPickerProps {\n readonly query: string;\n readonly listLabel?: MentionsConfig['listLabel'];\n readonly renderOption?: MentionsConfig['renderOption'];\n readonly generation?: MentionsConfig['generation'];\n readonly search: MentionsConfig['search'];\n readonly onSelect: (path: string) => void;\n readonly onDismiss: () => void;\n}\n\n/** Props of the model and effort picker that `ModelConfig.Picker` or `EffortConfig.Picker` carries. */\nexport interface ModelEffortPickerProps {\n readonly model: ModelConfig | undefined; readonly effort: EffortConfig | undefined;\n readonly currentModel: ModelRef | undefined; readonly currentEffort: string | undefined;\n readonly disabled: boolean; readonly busy: 'model' | 'effort' | null;\n readonly onModel: (model: ModelRef) => void; readonly onEffort: (level: string) => void;\n}\n\nexport type VoicePhase = 'starting' | 'recording' | 'processing' | 'transcribe-failed' | 'upload-failed' | 'sending' | 'unconfirmed' | 'blocked';\n/** A voice message on its way: recorded, then transcribed, uploaded and sent. */\nexport interface PendingVoice {\n readonly id: number;\n readonly path: string | undefined;\n readonly phase: VoicePhase;\n readonly seconds: number;\n readonly retry: () => void;\n readonly audioOnly: (() => void) | undefined;\n readonly discard: () => void;\n}\n/** The recorder the chat hands to the voice button. */\nexport interface VoiceMessageHandle {\n readonly canStart: boolean;\n readonly phase: VoicePhase | 'idle';\n readonly seconds: number;\n readonly captureSequence: number;\n readonly problem: string | undefined;\n readonly pending: readonly PendingVoice[];\n readonly start: () => void;\n readonly finish: () => void;\n readonly cancel: () => void;\n}\n/** Props of the hold-to-record button that `VoiceMessageConfig.Button` carries. */\n/** `primary`: the button stands in for Send (`voiceReplacesSend`), so it takes Send's primary look. */\nexport interface VoiceMessageButtonProps { readonly voice: VoiceMessageHandle; readonly disabled?: boolean; readonly primary?: boolean }\n/** Props of a pending voice message bubble that `VoiceMessageConfig.Bubble` carries. */\nexport interface PendingVoiceBubbleProps { readonly pending: PendingVoice | undefined }\n/** What the chat hands its recorder (`VoiceMessageConfig.useRecorder`). `config` is undefined when voice is off. */\nexport interface VoiceRecorderOptions {\n readonly controller: ChatController | undefined;\n readonly config: VoiceMessageConfig | undefined;\n readonly attachments?: AttachmentsConfig | undefined;\n readonly labels: ChatLabels;\n readonly admitted?: (() => void) | undefined;\n readonly activeController?: { readonly current: ChatController } | undefined;\n}\n/** A React hook: the chat calls it on every render of a mounted chat (a new hook remounts the chat). */\nexport type VoiceRecorderHook = (options: VoiceRecorderOptions) => VoiceMessageHandle;\n\n/** Props of the phone camera sheet that `AttachmentsConfig.Camera` carries. */\nexport interface CameraSheetProps {\n /** The photo, as a JPEG File scaled to at most `CAMERA_MAX_SIDE` on its longest side (or the phone camera's own file, from the fallback). */\n readonly onCapture: (file: File) => void;\n readonly onClose: () => void;\n /** Opens the camera; defaults to the browser's, when it has one. */\n readonly getUserMedia?: ((constraints: MediaStreamConstraints) => Promise<MediaStream>) | undefined;\n}\n\n/** A `/command` a settled reply may turn into a button (`commandMentions`; drawn by a text renderer such as `pi-chat-markdown`). */\nexport interface CommandMention { readonly name: string; readonly behavior: 'execute' | 'insert' }\nexport interface CommandMentions {\n readonly commands: readonly CommandMention[];\n readonly onActivate: (command: CommandMention) => void;\n}\n\n/** A recording's length as m:ss. */\nexport const voiceClock = (seconds: number) => `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;\n\n/*\n * The cards of a reply are items too (`pi-chat-question`, `pi-chat-approval`, `pi-chat-subagent`, `pi-artifacts`). The core keeps only what\n * it needs to place them: the props each card receives, and a visible \"waiting for your answer\" notice when a question or an approval has\n * no card, so a run never waits silently. `withChatPlugins` of `pi-chat-plugins` plugs every card in.\n */\n\n/** What the host answers when asked to resolve a pending question. */\nexport type AnswerOutcome = { readonly kind: 'answered' } | { readonly kind: 'denied' | 'conflict' | 'unknown-question'; readonly reason?: string };\n/** The tool that asks the person a question (`ask_user`). Its calls are question rows. */\nexport const ASK_USER_TOOL = 'ask_user';\n/** The decision `requireApproval` records for a gated call. */\nexport type ApprovalDecision = 'approved' | 'denied' | 'cancelled';\n\n/** Props of the question card that `ChatCards.question` carries (`QuestionCard` of `pi-chat-question`). */\nexport interface QuestionCardProps {\n readonly call: ToolCall;\n readonly result: ToolResultMessage | undefined;\n readonly questionId: string | undefined;\n readonly answer?: ((questionId: string, answer: string) => Promise<AnswerOutcome>) | undefined;\n /** The call is still being generated by the model: it cannot be answered yet. */\n readonly live?: boolean;\n}\n/** Props of the approval card that `ChatCards.approval` carries (`ApprovalCard` of `pi-chat-approval`). Answering uses the host's `answer`. */\nexport interface ApprovalCardProps {\n readonly call: ToolCall;\n /** The question ID bound to this call's assistant entry (`derive` provides it); answers use it, never the bare call ID. */\n readonly questionId?: string | undefined;\n /** The decision `requireApproval` recorded in the call's details (`derive` finds it). Without one, a settled call shows as not decided. */\n readonly decision?: ApprovalDecision | undefined;\n /** The one-line summary `requireApproval` published for this call (`derive` finds it). Shown first; the raw arguments are then collapsed under it. */\n readonly summary?: string | undefined;\n readonly result: ToolResultMessage | undefined;\n readonly answer?: ((callId: string, answer: string) => Promise<AnswerOutcome>) | undefined;\n /** The call is still being generated by the model: it cannot be decided yet. */\n readonly live?: boolean;\n}\n/**\n * Draws a tool call that ran a sub-agent (`subagentCard` of `pi-chat-subagent`), from the details its running slot published (`details`)\n * or that it finished with. Undefined: the call is not a sub-agent call and stays an ordinary tool step.\n */\nexport type SubagentRenderer = (call: ToolCall, details: unknown, result: ToolResultMessage | undefined) => ReactNode | undefined;\n\n/** The cards the chat draws for a human decision or a sub-agent. Each one left out has no card (see `PiChat`'s `cards`). */\nexport interface ChatCards {\n /** An `ask_user` question (`QuestionCard` of `pi-chat-question`). Without it the chat says it waits for an answer, with no answer UI. */\n readonly question?: ComponentType<QuestionCardProps> | undefined;\n /** A call gated by `requireApproval` (`ApprovalCard` of `pi-chat-approval`). Without it the chat says it waits for an answer, with no answer UI. */\n readonly approval?: ComponentType<ApprovalCardProps> | undefined;\n /** A sub-agent call (`subagentCard` of `pi-chat-subagent`). Without it the call is an ordinary tool step. */\n readonly subagent?: SubagentRenderer | undefined;\n}\n\n/**\n * An artifact a tool call made (or a running `present` call is making), as the core places it. `descriptor` is the item's own value\n * (`ArtifactDescriptor` of `pi-artifacts`): the core hands it back to the host's `open` and `isOpen` and never reads it.\n */\nexport interface ChatArtifact {\n readonly state: 'presenting' | 'ready';\n /** One card per id in a reply: the last version, at the place of its last step. */\n readonly id: string | undefined;\n readonly title: string | undefined;\n /** The workspace path, for the conversation's file list. */\n readonly path: string | undefined;\n /** The tool's own result carries it, so its card replaces the step; a host-detected one is shown in addition to the step. */\n readonly own: boolean;\n readonly descriptor: unknown;\n}\n/** Props of the artifact card that `ArtifactsConfig.Card` carries (`PluggedArtifactCard` of `pi-artifacts`). */\nexport interface ChatArtifactCardProps {\n readonly artifact: ChatArtifact;\n readonly open: boolean;\n readonly onOpen: (() => void) | undefined;\n readonly kinds: readonly unknown[] | undefined;\n}\n/**\n * Artifact cards, as the core sees `ArtifactsConfig` of `pi-artifacts`: the host's opener and the item's `recognise` and `Card`. Without\n * `recognise` and `Card` no artifact is offered: the calls stay ordinary tool steps.\n */\nexport interface ChatArtifactsConfig {\n readonly kinds?: readonly unknown[] | undefined;\n readonly open: (artifact: never) => void;\n readonly isOpen?: ((artifact: never) => boolean) | undefined;\n readonly detect?: ((call: ToolCall, result: ToolResultMessage) => unknown) | undefined;\n /** The artifact a call made, or that a running call (`running`) is making; undefined for any other call. */\n readonly recognise?: ((call: ToolCall, result: ToolResultMessage | undefined, running: boolean, detect: ChatArtifactsConfig['detect']) => ChatArtifact | undefined) | undefined;\n readonly Card?: ComponentType<ChatArtifactCardProps> | undefined;\n}\n",
109
81
  "type": "registry:lib",
110
82
  "target": "components/pi-chat/config.ts"
111
83
  },
@@ -139,21 +111,9 @@
139
111
  "type": "registry:component",
140
112
  "target": "components/pi-chat/sent-feedback.tsx"
141
113
  },
142
- {
143
- "path": "registry/pi-chat/markdown.tsx",
144
- "content": "'use client';\n\nimport { createElement, Fragment, memo, useMemo } from 'react';\nimport type { ReactNode } from 'react';\nimport { Marked } from 'marked';\nimport { decodeHTML } from 'entities';\nimport { CodeBlock } from './code-block';\nimport { pieces } from './config';\nimport { useChatText } from './labels';\nimport type { ChatLabels } from './labels';\nimport { Button } from '@/components/ui/button';\nimport { Checkbox } from '@/components/ui/checkbox';\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';\n\n/*\n * Safe Markdown for model output. Tokens are mapped to React elements one by one: raw HTML is shown as text, images are\n * shown as their alt text (nothing is fetched), and a link is only rendered when it is a plain http(s) URL.\n * Ported from the v4 `@hachej/boring-ui-kit` chat markdown renderer; the styling is Tailwind and fenced code gets a code block card.\n */\nexport interface CommandMention { readonly name: string; readonly behavior: 'execute' | 'insert' }\nexport interface CommandMentions {\n readonly commands: readonly CommandMention[];\n readonly onActivate: (command: CommandMention) => void;\n}\n\nconst markdown = new Marked({ gfm: true, breaks: false, pedantic: false });\n\nfunction isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === 'object' && !Array.isArray(value); }\nfunction text(value: unknown): string { return typeof value === 'string' ? value : ''; }\nfunction visible(value: unknown): string {\n if (!isRecord(value)) return '';\n if (value['type'] === 'br') return '\\n';\n if (Array.isArray(value['tokens'])) return value['tokens'].map(visible).join('');\n const content = text(value['text']);\n return value['type'] === 'text' && value['escaped'] !== true ? decodeHTML(content) : content;\n}\nfunction containsHtml(value: unknown): boolean {\n if (!isRecord(value)) return false;\n return value['type'] === 'html' || Array.isArray(value['tokens']) && value['tokens'].some(containsHtml);\n}\nfunction literal(value: string): ReactNode { return <span className=\"whitespace-pre-wrap\">{value}</span>; }\n\ninterface Context { readonly renderMention: ((path: string, text: string) => ReactNode) | undefined; readonly mentions: CommandMentions | undefined; readonly onCopy: ((text: string) => Promise<void>) | undefined; readonly labels: ChatLabels }\n\nfunction children(value: unknown, context: Context, before = '', after = '', inline = false): ReactNode {\n if (!Array.isArray(value)) return null;\n if (!context.mentions) return value.map((token: unknown, index) => <Fragment key={index}>{renderToken(token, context)}</Fragment>);\n const shown = value.map(visible), source = before + shown.join('') + after;\n const active = inline && value.some(containsHtml) ? { ...context, mentions: undefined } : context;\n let position = before.length;\n return value.map((token: unknown, index) => {\n const start = position;\n position += shown[index]?.length ?? 0;\n return <Fragment key={index}>{renderToken(token, active, source.slice(0, start), source.slice(position))}</Fragment>;\n });\n}\n\nconst mentionPattern = /(^|[\\s(\\[{\"'“‘])\\/([A-Za-z0-9_][A-Za-z0-9_-]*)(?=$|\\s|[.,;!?)}\\]\"'”’]+(?=$|\\s))/g;\nfunction mentionText(value: string, mentions: CommandMentions | undefined, before: string, after: string, labels: ChatLabels): ReactNode {\n if (!mentions?.commands.length || !value.includes('/')) return value;\n const commands = new Map(mentions.commands.filter(item => /^[A-Za-z0-9_][A-Za-z0-9_-]*$/.test(item.name)\n && (item.behavior === 'execute' || item.behavior === 'insert')).map(item => [item.name, item]));\n if (!commands.size) return value;\n const source = before + value + after, start = before.length, end = start + value.length;\n const pieces: ReactNode[] = [];\n let cursor = 0;\n for (const match of source.matchAll(mentionPattern)) {\n if (match.index === undefined) continue;\n const boundary = match[1] ?? '', name = match[2];\n if (!name) continue;\n const offset = match.index + boundary.length, limit = offset + name.length + 1;\n const command: CommandMention | undefined = commands.get(name);\n if (!command || offset < start || limit > end || offset - start < cursor) continue;\n const selected = { name: command.name, behavior: command.behavior };\n const at = offset - start;\n if (at > cursor) pieces.push(value.slice(cursor, at));\n pieces.push(<Button key={at} variant=\"secondary\" size=\"sm\" data-testid=\"command-mention\" aria-label={labels.runCommand(selected.name, selected.behavior === 'execute')}\n onClick={() => mentions.onActivate(selected)}>/{selected.name}</Button>);\n cursor = limit - start;\n }\n if (!pieces.length) return value;\n if (cursor < value.length) pieces.push(value.slice(cursor));\n return pieces;\n}\n\nfunction linkTarget(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined;\n const decoded = decodeHTML(value);\n if (!/^https?:\\/\\//i.test(decoded) || /[\\u0000- \\u007f-\\u009f\\\\]/.test(decoded)) return undefined;\n const authority = /^https?:\\/\\/([^/?#]*)/i.exec(decoded)?.[1];\n if (!authority || authority.includes('@')) return undefined;\n try {\n const url = new URL(decoded);\n if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return undefined;\n return url.href;\n } catch { return undefined; }\n}\n\nfunction cells(value: unknown, header: boolean, context: Context): ReactNode {\n if (!Array.isArray(value)) return null;\n return value.map((cell: unknown, index) => {\n if (!isRecord(cell)) return null;\n const alignment = cell['align'];\n const textAlign = alignment === 'left' || alignment === 'center' || alignment === 'right' ? alignment : undefined;\n return createElement(header ? TableHead : TableCell, { key: index, style: { textAlign }, ...(header ? { scope: 'col' } : {}) },\n children(cell['tokens'], context, '', '', true));\n });\n}\n\nconst HEADINGS: Record<number, string> = {\n 1: 'mt-6 mb-2 text-xl font-semibold tracking-tight', 2: 'mt-5 mb-2 text-base font-semibold tracking-tight', 3: 'mt-4 mb-1.5 text-sm font-semibold',\n 4: 'mt-4 mb-1 text-sm font-semibold', 5: 'mt-3 mb-1 text-sm font-medium', 6: 'mt-3 mb-1 text-sm font-medium text-muted-foreground',\n};\n\n// Marked's extension token includes arbitrary fields. Narrow those fields here before creating React nodes.\nfunction renderToken(value: unknown, context: Context, before = '', after = ''): ReactNode {\n if (!isRecord(value)) return null;\n const raw = text(value['raw']), content = text(value['text']), mentions = context.mentions;\n switch (value['type']) {\n case 'space': case 'def': return null;\n case 'paragraph': return <p className=\"my-3 first:mt-0 last:mb-0\">{children(value['tokens'], context, '', '', true)}</p>;\n case 'heading': {\n const depth = value['depth'];\n return typeof depth === 'number' && Number.isInteger(depth) && depth >= 1 && depth <= 6\n ? createElement(`h${depth}`, { className: `${HEADINGS[depth]} first:mt-0` }, children(value['tokens'], context, '', '', true)) : literal(raw);\n }\n case 'text': return Array.isArray(value['tokens']) ? children(value['tokens'], context, before, after, true)\n : value['escaped'] === true ? literal(content) : context.renderMention\n ? pieces(decodeHTML(content), { mentions: true, skills: [] }).map((piece, index) => <Fragment key={index}>{piece.kind === 'mention' && piece.value !== undefined\n ? context.renderMention?.(piece.value, piece.text) : mentionText(piece.text, mentions, before, after, context.labels)}</Fragment>)\n : mentionText(decodeHTML(content), mentions, before, after, context.labels);\n case 'escape': return content;\n case 'strong': return <strong className=\"font-semibold text-foreground\">{children(value['tokens'], context, before, after, true)}</strong>;\n case 'em': return <em>{children(value['tokens'], context, before, after, true)}</em>;\n case 'del': return <del className=\"text-muted-foreground\">{children(value['tokens'], context, before, after, true)}</del>;\n case 'blockquote': return <blockquote className=\"my-3 px-4 py-2 text-muted-foreground [&_p]:my-1\">{children(value['tokens'], context)}</blockquote>;\n case 'br': return <br />;\n case 'hr': return <hr className=\"my-5 border-border\" />;\n case 'codespan': return <code className=\"bg-muted px-1.5 py-0.5 font-mono text-sm whitespace-pre-wrap\">{content}</code>;\n case 'code': return <CodeBlock code={content} {...(typeof value['lang'] === 'string' && value['lang'] ? { language: value['lang'] } : {})} {...(context.onCopy ? { onCopy: context.onCopy } : {})} />;\n case 'html': return value['block'] === true ? <pre className=\"my-3 overflow-x-auto font-mono text-xs whitespace-pre-wrap\">{raw}</pre> : literal(raw);\n case 'image': return literal(decodeHTML(content) || raw);\n case 'link': {\n const href = linkTarget(value['href']), title = typeof value['title'] === 'string' ? decodeHTML(value['title']) : undefined;\n return href ? <a href={href} title={title} target=\"_blank\" rel=\"noopener noreferrer\"\n className=\"font-medium text-foreground underline decoration-muted-foreground/50 underline-offset-4 hover:decoration-foreground\">{children(value['tokens'], context)}</a> : children(value['tokens'], context);\n }\n case 'list': {\n const start = value['start'];\n return value['ordered'] === true\n ? <ol start={typeof start === 'number' && Number.isSafeInteger(start) ? start : undefined} className=\"my-3 list-decimal ps-6 marker:text-muted-foreground\">{children(value['items'], context)}</ol>\n : <ul className=\"my-3 list-disc ps-6 marker:text-muted-foreground\">{children(value['items'], context)}</ul>;\n }\n case 'list_item': return <li className=\"ps-1 [&+li]:mt-1 [&>p]:my-1 [&>ol]:my-1 [&>ul]:my-1\">{children(value['tokens'], context, '', '', true)}</li>;\n case 'checkbox': return <Checkbox className=\"inline-flex\" checked={value['checked'] === true} disabled aria-label={context.labels.taskStatus} />;\n case 'table': return <Table>\n <TableHeader><TableRow>{cells(value['header'], true, context)}</TableRow></TableHeader>\n <TableBody>{Array.isArray(value['rows']) ? value['rows'].map((row: unknown, index) => <TableRow key={index}>{cells(row, false, context)}</TableRow>) : null}</TableBody></Table>;\n default: return literal(raw);\n }\n}\n\n/** One top-level block; unchanged blocks skip re-rendering while a long answer streams. */\nconst Block = memo(function Block({ token, context }: { readonly token: unknown; readonly context: Context }) {\n return <>{renderToken(token, context)}</>;\n}, (a, b) => isRecord(a.token) && isRecord(b.token) && a.token['raw'] === b.token['raw'] && a.token['type'] === b.token['type'] && a.context.mentions === b.context.mentions && a.context.renderMention === b.context.renderMention && a.context.onCopy === b.context.onCopy && a.context.labels === b.context.labels);\n\nexport const Markdown = memo(function Markdown({ text: source, commandMentions, renderMention, onCopy, streaming = false }: {\n readonly text: string; readonly renderMention?: ((path: string, text: string) => ReactNode) | undefined; readonly commandMentions?: CommandMentions; readonly onCopy?: (text: string) => Promise<void>; readonly streaming?: boolean;\n}) {\n const tokens = useMemo(() => { try { return markdown.lexer(source); } catch { return null; } }, [source]);\n const mentions = source.includes('/') && commandMentions?.commands.length ? commandMentions : undefined;\n const { labels } = useChatText();\n const context = useMemo<Context>(() => ({ mentions, renderMention, onCopy, labels }), [mentions, renderMention, onCopy, labels]);\n const body = useMemo(() => {\n if (!tokens) return literal(source);\n // With mentions enabled, positions are relative to the whole text, so render the token list as one unit.\n if (mentions) return children(tokens, context);\n return tokens.map((token, index) => <Block key={index} token={token} context={context} />);\n }, [tokens, source, mentions, context]);\n return <div data-testid=\"message-text\" data-streaming={streaming ? 'true' : undefined} className=\"min-w-0 [overflow-wrap:anywhere]\">\n {body}\n </div>;\n});\n",
145
- "type": "registry:component",
146
- "target": "components/pi-chat/markdown.tsx"
147
- },
148
- {
149
- "path": "registry/pi-chat/mention-menu.tsx",
150
- "content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { FileTextIcon, FolderIcon } from 'lucide-react';\nimport { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { usePickerKeyboard } from './picker-keyboard';\nimport type { MentionPickerProps, MentionResult, MentionsConfig } from './config';\nimport { useChatText } from './labels';\n\n/**\n * The host's shadcn Popover above the composer, anchored to the nearest positioned ancestor's top edge and as wide as it.\n * Focus stays in the message box (the picker keyboard drives the rows); outside presses dismiss it.\n */\nexport function ComposerSuggestions({ testId, onDismiss, children }: { readonly testId: string; readonly onDismiss: () => void; readonly children: ReactNode }) {\n return <Popover open modal={false} onOpenChange={open => { if (!open) onDismiss(); }}>\n <PopoverTrigger nativeButton={false} render={<span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-0 top-0\" />} />\n <PopoverContent data-testid={testId} side=\"top\" align=\"start\" initialFocus={false} finalFocus={false} className=\"pi-chat w-(--anchor-width) max-w-[calc(100vw-1rem)]\">\n {children}\n </PopoverContent>\n </Popover>;\n}\n\nfunction highlight(text: string, query: string) {\n const at = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1;\n if (at < 0) return <>{text}</>;\n return <>{text.slice(0, at)}<mark className=\"bg-transparent text-foreground underline underline-offset-2\">{text.slice(at, at + query.length)}</mark>{text.slice(at + query.length)}</>;\n}\n\n/** The `@` file picker, ported from v2's `MentionPicker`: debounced search, one highlighted row, name over path. */\nexport function MentionPicker({ query, search, generation, listLabel, renderOption, onSelect, onDismiss }: MentionPickerProps) {\n const { labels } = useChatText();\n // Results are tagged with the search function that produced them: a binding switch hides the previous binding's rows at once.\n const [outcome, setOutcome] = useState<{ readonly search: MentionsConfig['search']; readonly results: readonly MentionResult[]; readonly failure: string | null }>();\n const shown = outcome?.search === search ? outcome : undefined;\n const results = shown?.results ?? [];\n const failure = shown?.failure ?? null;\n const [active, setActive] = useState(0);\n const list = useRef<HTMLUListElement>(null);\n const searcher = useRef(search); searcher.current = search;\n useEffect(() => {\n setActive(0);\n const abort = new AbortController();\n const timer = setTimeout(() => {\n searcher.current(query, abort.signal).then(found => { if (!abort.signal.aborted) setOutcome({ search, results: found.slice(0, 8), failure: null }); },\n cause => { if (!abort.signal.aborted) setOutcome({ search, results: [], failure: cause instanceof Error ? cause.message : labels.searchFailed }); });\n }, 120);\n return () => { clearTimeout(timer); abort.abort(); };\n }, [query, search, generation]);\n usePickerKeyboard({ count: results.length, activeIdx: active, setActiveIdx: setActive, listRef: list, onSelect: index => { const item = results[index]; if (item) onSelect(item.path); }, onDismiss });\n\n if (results.length === 0 && !failure) return null;\n return <ComposerSuggestions testId=\"mention-menu\" onDismiss={onDismiss}>\n {failure ? <div role=\"alert\" data-testid=\"mention-error\" className=\"text-xs text-destructive\">{failure}</div>\n : <ul ref={list} role=\"listbox\" aria-label={listLabel ?? labels.workspaceFiles} className=\"m-0 flex max-h-[min(14rem,40dvh)] list-none flex-col overflow-y-auto p-0\">\n {results.map((item, index) => {\n const name = item.label ?? item.path.split('/').pop() ?? item.path;\n const Icon = item.kind === 'directory' ? FolderIcon : FileTextIcon;\n return <Item key={item.path} render={<li />} size=\"xs\" variant={index === active ? 'muted' : 'default'} role=\"option\" aria-selected={index === active} data-testid=\"mention-item\" data-path={item.path} title={item.path}\n onMouseEnter={() => setActive(index)} onMouseDown={event => { event.preventDefault(); onSelect(item.path); }}\n className=\"cursor-pointer flex-nowrap max-sm:min-h-11 pointer-coarse:min-h-11\">\n {renderOption ? renderOption(item, query) : <><ItemMedia variant=\"icon\"><Icon aria-hidden=\"true\" /></ItemMedia>\n <ItemContent className=\"min-w-0\"><ItemTitle>{highlight(name, query)}</ItemTitle>\n <ItemDescription className=\"m-0 truncate\">{item.path}</ItemDescription></ItemContent></>}\n </Item>;\n })}\n </ul>}\n </ComposerSuggestions>;\n}\n",
151
- "type": "registry:component",
152
- "target": "components/pi-chat/mention-menu.tsx"
153
- },
154
114
  {
155
115
  "path": "registry/pi-chat/message.tsx",
156
- "content": "'use client';\n\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport type { ImageContent, ToolResultMessage, UserMessage } from '@earendil-works/pi-ai';\nimport { AlertCircleIcon, CircleSlashIcon, FileAudioIcon, FileIcon, FileSpreadsheetIcon, FileTextIcon, ImageIcon, PauseIcon, PlayIcon, PresentationIcon } from 'lucide-react';\nimport { CopyButton } from './code-block';\nimport { Markdown } from './markdown';\nimport type { CommandMentions } from './markdown';\nimport { ArtifactCard } from './artifact-card';\nimport { feedbackContent } from './feedback-content';\nimport { SentFeedback } from './sent-feedback';\nimport type { ArtifactsConfig } from './artifact';\nimport { ApprovalCard } from './approval-card';\nimport type { ApprovalCardProps } from './approval-card';\nimport { SubagentCard } from './subagent-card';\nimport { QuestionCard } from './question-card';\nimport type { AnswerOutcome, QuestionCardProps } from './question-card';\nimport { ActivityBlock } from './activity';\nimport type { ActivityState } from './activity';\nimport { Shimmer } from './shimmer';\nimport { ToolCard } from './tool';\nimport { thumbnail } from './composer';\nimport { ImageViewer } from './image-viewer';\nimport { displayName, fileKind, isUploadPath, isVoicePath, pieces, visibleParts } from './config';\nimport type { AudioUrlRead, FileKind, Piece, PreviewRead } from './config';\nimport { voiceClock } from './voice-message';\nimport { Spinner } from '@/components/ui/spinner';\nimport { isFileBlock, segments, targetContent } from './rows';\nimport type { DeriveOptions, Row } from './rows';\nimport { Button } from '@/components/ui/button';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { useChatText } from './labels';\nimport { cn } from '../utils/utils';\nimport type { ReplyRef } from './config';\nimport { Message, MessageContent, MessageFooter } from '@/components/ui/message';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { Attachment, AttachmentContent, AttachmentDescription, AttachmentMedia, AttachmentTitle, AttachmentTrigger } from '@/components/ui/attachment';\nimport { Badge } from '@/components/ui/badge';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';\n\n/** One assistant text part, as `renderText` receives it. `streaming` is true while the model still writes it. */\nexport interface ChatTextPart {\n readonly text: string;\n readonly streaming: boolean;\n /** Draws an `@path` mention (when mentions are on). */\n readonly renderMention?: ((path: string, text: string) => ReactNode) | undefined;\n /** The `/` commands a settled reply may turn into buttons. */\n readonly commandMentions?: CommandMentions | undefined;\n readonly onCopy?: ((text: string) => Promise<void>) | undefined;\n}\n/** The default text renderer: the built-in Markdown. */\nconst markdownText = ({ text, streaming, renderMention, commandMentions, onCopy }: ChatTextPart): ReactNode =>\n <Markdown text={text} streaming={streaming} renderMention={renderMention} {...(commandMentions ? { commandMentions } : {})} {...(onCopy ? { onCopy } : {})} />;\n\n/** The cards the chat draws for a human decision. Each one left out uses the built-in card. */\nexport interface ChatCards {\n /** An `ask_user` question. Default: `QuestionCard`. */\n readonly question?: ComponentType<QuestionCardProps> | undefined;\n /** A call gated by `requireApproval`. Default: `ApprovalCard`. */\n readonly approval?: ComponentType<ApprovalCardProps> | undefined;\n}\n\nexport interface RowContext {\n readonly developer: boolean;\n /** Draws an assistant text part. Default: the built-in Markdown. */\n readonly renderText?: ((part: ChatTextPart) => ReactNode) | undefined;\n readonly cards?: ChatCards | undefined;\n /** `presentation=\"natural\"`: no \"working\" shimmer in a reply (the chat shows one status instead). */\n readonly natural?: boolean | undefined;\n readonly groupTool: DeriveOptions['groupTool'];\n readonly commandMentions: CommandMentions | undefined;\n /** Host openers keep their single image argument; the workspace's default also accepts the message gallery. */\n readonly onOpenImage: ((image: ImageContent, gallery?: readonly ImageContent[]) => void) | undefined;\n readonly onCopy: ((text: string) => Promise<void>) | undefined;\n /** Which tokens of a sent message get a styled treatment (`@path` mentions, a leading `/skill`). */\n readonly pieces: { readonly mentions: boolean; readonly uploads?: boolean; readonly skills: readonly string[]; readonly openMention?: ((path: string) => void) | undefined; readonly renderMention?: ((path: string) => ReactNode | undefined) | undefined;\n readonly preview?: PreviewRead | undefined; readonly audioUrl?: AudioUrlRead | undefined } | undefined;\n readonly fileChanges?: boolean | undefined;\n /** Opens a file card through the host's workspace opener. */\n readonly openFile?: ((path: string) => void) | undefined;\n readonly answer: ((questionId: string, answer: string) => Promise<AnswerOutcome>) | undefined;\n readonly artifacts: { readonly open: ArtifactsConfig['open']; readonly isOpen?: ArtifactsConfig['isOpen'] | undefined; readonly kinds?: ArtifactsConfig['kinds'] } | undefined;\n /** Replaces the default copy button under a settled reply (the ambient window adds feedback and the time); `host` holds the host's message actions. */\n readonly replyActions?: ((reply: { readonly key: string; readonly text: string; readonly timestamp?: number | undefined }, host: ReactNode) => ReactNode) | undefined;\n /** The host's actions for a settled reply (`messageActions`). */\n readonly messageActions?: ((reply: ReplyRef) => readonly BlockAction[]) | undefined;\n readonly historicalMessageActions?: boolean | undefined;\n /** Host-owned per-answer unread indicator; rendering never marks an answer seen. */\n readonly unread?: ((reply: ReplyRef) => ReactNode) | undefined;\n readonly replyError?: ((reply: ReplyRef) => ReactNode) | undefined;\n /** A line under a finished reply (not while it streams, runs a tool or waits to retry), after its actions (a host shows which model answered and how long it took). */\n readonly replyFooter?: ((reply: ReplyRef) => ReactNode) | undefined;\n /** Fork the conversation after a settled reply (its last native entry): adds a Fork button next to Copy. */\n readonly onFork?: ((entryId: string) => void) | undefined;\n}\n\nconst replyRef = (row: Extract<Row, { type: 'assistant' }>): ReplyRef => ({ key: row.key, text: row.text, entryId: row.entryId, timestamp: row.timestamp, model: row.model, durationMs: row.durationMs });\n\nfunction ImageThumbnails({ images, onOpenImage }: { readonly images: readonly ImageContent[]; readonly onOpenImage: RowContext['onOpenImage'] }) {\n const { labels } = useChatText();\n if (!images.length) return null;\n return <div data-testid=\"message-images\" data-slot=\"message-images\" className={cn('grid max-w-full gap-2', images.length === 1 ? 'w-60' : 'w-120 grid-cols-2')}>\n {images.map((image, at) => <div key={at} className=\"relative min-h-12 min-w-12 self-start\">\n <img src={thumbnail(image.mimeType, image.data)} alt={labels.imageAttachment} className=\"block h-auto w-full rounded-(--radius-xl)\" />\n <Button variant=\"link\" className=\"absolute inset-0 h-auto min-h-12 min-w-12 w-full\" data-testid=\"message-image\" aria-label={images.length > 1 ? `${labels.openImage}, ${labels.imagePosition(at + 1, images.length)}` : labels.openImage} onClick={() => onOpenImage?.(image, images)} />\n </div>)}\n </div>;\n}\n\n/** Inline sent mention; workspace openers and host renderers keep their existing authority. */\nfunction UserMention({ path, text, open, render }: { readonly path: string; readonly text: string; readonly open: ((path: string) => void) | undefined; readonly render: ((path: string) => ReactNode | undefined) | undefined }) {\n const custom = render?.(path);\n if (custom !== undefined) return <>{custom}</>;\n return open\n ? <span role=\"link\" tabIndex={0} className=\"font-medium text-primary\" data-testid=\"message-mention\" data-path={path} aria-label={`Open ${path}`} onClick={() => open(path)} onKeyDown={event => { if (event.key === 'Enter') { event.preventDefault(); open(path); } }}>{text}</span>\n : <span className=\"font-medium text-primary\" data-testid=\"message-mention\" data-path={path}>{text}</span>;\n}\n\n/** The icon a file card shows for its kind. */\nfunction FileGlyph({ kind }: { readonly kind: FileKind }) {\n switch (kind) {\n case 'image': return <ImageIcon aria-hidden=\"true\" />;\n case 'sheet': return <FileSpreadsheetIcon aria-hidden=\"true\" />;\n case 'audio': return <FileAudioIcon aria-hidden=\"true\" />;\n case 'slides': return <PresentationIcon aria-hidden=\"true\" />;\n case 'document': return <FileTextIcon aria-hidden=\"true\" />;\n default: return <FileIcon aria-hidden=\"true\" />;\n }\n}\n\n/** A file a person sent (an `uploads/` mention): its icon or thumbnail, name and type. A button when it can be opened. */\nfunction FileCard({ path, open, preview, change }: { readonly path: string; readonly open: ((path: string) => void) | undefined; readonly preview: PreviewRead | undefined; readonly change?: 'created' | 'edited' }) {\n const { labels } = useChatText();\n const kind = fileKind(path), name = change ? path.split('/').pop() || path : displayName(path);\n const [src, setSrc] = useState<string | undefined>();\n // A thumbnail belongs to the provider that read it: a new provider, or a read that is refused or unavailable, shows the icon.\n useEffect(() => {\n setSrc(undefined);\n if (kind !== 'image' || !preview) return;\n const abort = new AbortController();\n preview(path, abort.signal).then(\n read => { if (!abort.signal.aborted) setSrc(read ? thumbnail(read.mimeType, read.data) : undefined); },\n () => { if (!abort.signal.aborted) setSrc(undefined); });\n return () => abort.abort();\n }, [kind, path, preview]);\n return <Attachment className=\"max-w-64\" {...(!open ? { 'data-testid': 'message-mention', 'data-path': path } : {})}>\n <AttachmentMedia variant={src ? 'image' : 'icon'}>{src ? <img src={src} alt=\"\" /> : <FileGlyph kind={kind} />}</AttachmentMedia>\n <AttachmentContent>\n <AttachmentTitle>{name}</AttachmentTitle>\n <AttachmentDescription>{change ? labels.fileChange[change] : labels.fileKind[kind]}</AttachmentDescription>\n </AttachmentContent>\n {open && <AttachmentTrigger data-testid=\"message-mention\" data-path={path} aria-label={labels.openFile(name)} onClick={() => open(path)} />}\n </Attachment>;\n}\n\n/** The file cards of a message, above its text bubble. */\nfunction FileCards({ paths, open, preview }: { readonly paths: readonly string[]; readonly open: ((path: string) => void) | undefined; readonly preview: PreviewRead | undefined }) {\n return <ul data-testid=\"message-files\" className=\"m-0 flex max-w-[85%] list-none flex-wrap justify-end gap-2 p-0\">\n {paths.map(path => <li key={path} data-testid=\"message-file\"><FileCard path={path} open={open} preview={preview} /></li>)}\n </ul>;\n}\n\n/** Retained audio is read only on Play, through the same authorized workspace preview as file cards. */\nexport function VoiceBubble({ path, transcript, preview, audioUrl, align = 'end' }: { readonly path: string; readonly transcript: ReactNode; readonly preview: PreviewRead | undefined; readonly audioUrl: AudioUrlRead | undefined; readonly align?: 'start' | 'end' }) {\n const { labels } = useChatText();\n const audio = useRef<HTMLAudioElement>(null), work = useRef<AbortController | null>(null), loadingRequest = useRef(false);\n const [playing, setPlaying] = useState(false), [loading, setLoading] = useState(false), [failed, setFailed] = useState(false), [duration, setDuration] = useState<number>();\n const source = useRef<string | undefined>(undefined), loader = useRef<() => Promise<boolean>>(async () => false);\n useEffect(() => {\n const element = audio.current;\n let objectUrl: string | undefined;\n source.current = undefined; loadingRequest.current = false; setPlaying(false); setLoading(false); setFailed(false); setDuration(undefined);\n const release = () => { if (objectUrl) { URL.revokeObjectURL(objectUrl); objectUrl = undefined; } };\n const abort = new AbortController(); work.current = abort;\n const load = async () => {\n release();\n const signed = await audioUrl?.(path, abort.signal);\n if (abort.signal.aborted) return;\n if (signed) {\n const url = new URL(signed, window.location.href);\n if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('Unsupported audio URL');\n source.current = url.href;\n } else {\n const read = await preview?.(path, abort.signal);\n if (abort.signal.aborted) return;\n if (!read || !/^audio\\/(?:webm|ogg|mp4|m4a|x-m4a|mpeg|mp3|wav|x-wav)(?:;|$)/i.test(read.mimeType)) throw new Error('Audio unavailable');\n const bytes = Uint8Array.from(atob(read.data), character => character.charCodeAt(0));\n // MediaRecorder WebM often has no finite metadata duration; decode only these already-requested bytes.\n try {\n if (typeof OfflineAudioContext === 'function') void new OfflineAudioContext(1, 1, 48_000).decodeAudioData(bytes.slice().buffer).then(buffer => {\n if (!abort.signal.aborted && Number.isFinite(buffer.duration)) setDuration(Math.round(buffer.duration));\n }).catch(() => {});\n } catch { /* Optional duration decoding must not prevent native playback. */ }\n objectUrl = URL.createObjectURL(new Blob([bytes], { type: read.mimeType })); source.current = objectUrl;\n }\n if (element && source.current) element.src = source.current;\n };\n // A loader belongs to this path/provider epoch, not to a later row with the same DOM node.\n loader.current = async () => { await load(); return !abort.signal.aborted; };\n return () => { abort.abort(); if (work.current === abort) work.current = null; element?.pause(); element?.removeAttribute('src'); element?.load(); source.current = undefined; release(); };\n }, [path, preview, audioUrl]);\n const toggle = async () => {\n const element = audio.current, abort = work.current;\n if (!element || !abort || loadingRequest.current) return;\n if (!element.paused) { element.pause(); return; }\n loadingRequest.current = true; setFailed(false); setLoading(true);\n try {\n if (!source.current && !await loader.current()) return;\n if (!abort.signal.aborted) await element.play();\n } catch { if (!abort.signal.aborted) setFailed(true); }\n finally { if (work.current === abort && !abort.signal.aborted) { loadingRequest.current = false; setLoading(false); } }\n };\n return <Bubble variant=\"muted\" align={align} data-testid=\"voice-bubble\"><BubbleContent>\n <div className=\"flex items-center gap-2\">\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"voice-play\" aria-label={playing ? labels.voicePause : labels.voicePlay} disabled={loading || (!preview && !audioUrl)} onClick={() => { void toggle(); }}>\n {loading ? <Spinner data-icon=\"inline-start\" aria-hidden=\"true\" /> : playing ? <PauseIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <PlayIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Button>\n <span data-testid=\"voice-duration\">{duration === undefined ? '--:--' : voiceClock(duration)}</span>\n <audio ref={audio} preload=\"none\" onPlay={() => { setPlaying(true); setFailed(false); }} onPause={() => setPlaying(false)} onEnded={() => {\n setPlaying(false); const element = audio.current;\n if (element && !Number.isFinite(element.duration) && Number.isFinite(element.currentTime)) setDuration(Math.round(element.currentTime));\n }} onError={() => { setFailed(true); setPlaying(false); source.current = undefined; }} onDurationChange={() => {\n const element = audio.current; if (element && Number.isFinite(element.duration)) setDuration(Math.round(element.duration));\n }} onLoadedMetadata={() => {\n const element = audio.current; if (element && Number.isFinite(element.duration)) setDuration(Math.round(element.duration));\n }} />\n </div>\n {failed && <p role=\"alert\">{labels.voicePlayFailed}</p>}\n {transcript && <div data-testid=\"user-text\" className=\"whitespace-pre-wrap\">{transcript}</div>}\n </BubbleContent></Bubble>;\n}\n\n/**\n * A text part of a sent message. Its `uploads/` mentions become file cards above the bubble and leave the text; the message content keeps\n * them (only what is drawn changes).\n */\n/** `variant` picks the bubble: `muted` (the chat's user bubble) or `default` (the theme's primary, used for my own messages in a shared chat). */\nexport function MessageText({ text, context, align = 'end', variant = 'muted' }: { readonly text: string; readonly context: Pick<RowContext, 'pieces' | 'openFile'>; readonly align?: 'start' | 'end'; readonly variant?: 'muted' | 'default' }) {\n const target = targetContent(text);\n if (target) return <>\n <Badge variant=\"secondary\" data-testid=\"message-target\" title={JSON.stringify(target.target)} className=\"max-w-full truncate\">{target.label}</Badge>\n <MessageText text={target.speech} context={context} align={align} variant={variant} />\n </>;\n const feedback = feedbackContent(text);\n if (feedback) return <>\n {feedback.before && <MessageText text={feedback.before} context={context} align={align} variant={variant} />}\n <SentFeedback report={feedback.report} align={align} />\n {feedback.after && <MessageText text={feedback.after} context={context} align={align} variant={variant} />}\n </>;\n const open = context.pieces?.openMention;\n const render = context.pieces?.renderMention;\n const styledUploads = Boolean(context.pieces?.mentions || context.pieces?.uploads);\n const all = pieces(text, { mentions: context.pieces?.mentions ?? false, uploads: true, skills: context.pieces?.skills ?? [] }).map((piece): Piece =>\n !styledUploads && piece.kind === 'mention' && isUploadPath(piece.value!) && !isVoicePath(piece.value!) ? { text: piece.text } : piece);\n const isFile = (piece: Piece) => piece.kind === 'mention' && isUploadPath(piece.value!);\n const files = all.filter(isFile).map(piece => piece.value!);\n const voice = files.find(isVoicePath), cards = files.filter(path => path !== voice);\n if (!context.pieces && !voice) return <Bubble variant={variant} align={align}><BubbleContent data-testid=\"user-text\"><span className=\"whitespace-pre-wrap\">{text}</span></BubbleContent></Bubble>;\n const shown = files.length ? visibleParts(all, isFile) : all;\n const empty = shown.every(piece => piece.kind === undefined && piece.text === '');\n const content = shown.map((piece, at) => piece.kind === 'mention'\n ? <UserMention key={at} path={piece.value!} text={piece.text} open={open} render={render} />\n : piece.kind === 'skill' ? <Badge key={at} variant=\"secondary\" data-testid=\"message-skill\" data-skill={piece.value}>{piece.text}</Badge>\n : piece.text);\n return <>\n {cards.length > 0 && <FileCards paths={cards} open={context.openFile ?? open} preview={context.pieces?.preview} />}\n {voice ? <VoiceBubble path={voice} transcript={empty ? null : content} preview={context.pieces?.preview} audioUrl={context.pieces?.audioUrl} align={align} />\n : !(files.length > 0 && empty) && <Bubble variant={variant} align={align}><BubbleContent data-testid=\"user-text\"><span className=\"whitespace-pre-wrap\">{content}</span></BubbleContent></Bubble>}\n </>;\n}\n\nfunction UserBubble({ message, images, context, align = 'end' }: { readonly message: UserMessage; readonly images: readonly ImageContent[]; readonly context: RowContext; readonly align?: 'start' | 'end' }) {\n const parts = typeof message.content === 'string' ? [{ type: 'text' as const, text: message.content }] : message.content;\n return <Message align={align}><MessageContent>\n <ImageThumbnails images={images} onOpenImage={context.onOpenImage} />\n {parts.map((part, index) => part.type === 'text' ? (isFileBlock(part.text) ? null : <MessageText key={index} text={part.text} context={context} align={align} />)\n : null)}\n </MessageContent></Message>;\n}\n\nfunction Notice({ tone, icon, children, testid }: { readonly tone: 'error' | 'muted'; readonly icon: ReactNode; readonly children: ReactNode; readonly testid: string }) {\n return <Alert variant={tone === 'error' ? 'destructive' : 'default'} role={tone === 'error' ? 'alert' : 'status'} data-testid={testid}>\n {icon}<AlertDescription>{children}</AlertDescription>\n </Alert>;\n}\n\nfunction resultText(message: ToolResultMessage): string { return message.content.map(part => part.type === 'text' ? part.text : '').join('\\n'); }\n\nfunction messageImages(row: Row): readonly ImageContent[] {\n const images = (content: ToolResultMessage['content']) => content.flatMap(part => part.type === 'image' && thumbnail(part.mimeType, part.data) ? [part] : []);\n if (row.type === 'user') return typeof row.message.content === 'string' ? [] : images(row.message.content);\n if (row.type === 'orphan-result') return images(row.message.content);\n return row.type === 'assistant' ? row.images?.filter(image => thumbnail(image.mimeType, image.data)) ?? row.parts.flatMap(part => part.kind === 'tool' && part.entry.result ? images(part.entry.result.content) : []) : [];\n}\n\nexport function RowView({ row, context }: { readonly row: Row; readonly context: RowContext }) {\n const images = messageImages(row);\n const [selection, setSelection] = useState<{ readonly index: number; readonly images: readonly ImageContent[]; readonly key: string; readonly opener: RowContext['onOpenImage'] }>();\n const current = selection && selection.key === row.key && selection.opener === context.onOpenImage && selection.images.length === images.length\n && selection.images.every((image, at) => image.mimeType === images[at]?.mimeType && image.data === images[at]?.data) ? selection : undefined;\n useEffect(() => { if (selection && !current) setSelection(undefined); }, [selection, current]);\n const openImage: NonNullable<RowContext['onOpenImage']> = image => {\n if (!thumbnail(image.mimeType, image.data)) return;\n if (context.onOpenImage) { context.onOpenImage(image, images); return; }\n const index = images.indexOf(image);\n if (index >= 0) setSelection({ index, images, key: row.key, opener: context.onOpenImage });\n };\n return <><RowBody row={row} images={images} context={{ ...context, onOpenImage: openImage }} />\n {current && <ImageViewer images={images} index={current.index} onIndexChange={index => setSelection({ ...current, index })} onClose={() => setSelection(undefined)} />}\n </>;\n}\n\nfunction RowBody({ row, images, context: liveContext }: { readonly row: Row; readonly images: readonly ImageContent[]; readonly context: RowContext }) {\n const context: RowContext = row.historical ? { ...liveContext, answer: undefined, onFork: undefined, messageActions: liveContext.historicalMessageActions ? liveContext.messageActions : undefined, replyActions: undefined, commandMentions: undefined } : liveContext;\n const { labels, icons } = useChatText();\n const renderMention = useMemo(() => context.pieces?.mentions ? (path: string, text: string) => <UserMention path={path} text={text} open={context.pieces?.openMention} render={context.pieces?.renderMention} /> : undefined, [context.pieces]);\n switch (row.type) {\n case 'user': return <article data-row-id={row.key} data-role=\"user\" aria-live={row.historical ? 'off' : undefined}><UserBubble message={row.message} images={images} context={context} /></article>;\n case 'card': return <article data-row-id={row.key} data-role=\"event\">{row.card.content}</article>;\n case 'event': return <article data-row-id={row.key} data-role=\"event\" className=\"text-center text-xs text-muted-foreground\">{labels.event(row.label)}</article>;\n case 'system': return <article data-row-id={row.key} data-role=\"system\"><Collapsible>\n <CollapsibleTrigger render={<Button variant=\"ghost\" />}>{labels.systemContext}</CollapsibleTrigger>\n <CollapsibleContent><pre className=\"whitespace-pre-wrap\">{row.text}</pre></CollapsibleContent>\n </Collapsible></article>;\n case 'orphan-result': return <article data-row-id={row.key} data-role=\"toolResult\"><Notice tone=\"error\" testid=\"tool-error\" icon={<AlertCircleIcon className=\"mt-0.5 size-4 shrink-0\" aria-hidden=\"true\" />}>\n <strong className=\"font-semibold\">{labels.toolFailed(row.message.toolName)}</strong><span className=\"mt-1 block font-mono text-xs whitespace-pre-wrap\">{resultText(row.message)}</span></Notice><ImageThumbnails images={images} onOpenImage={context.onOpenImage} /></article>;\n case 'assistant': {\n const settled = !row.streaming;\n const nativeActions = settled;\n const files = new Map<string, 'created' | 'edited'>();\n if (context.fileChanges) for (const part of row.parts) {\n if (part.kind !== 'tool' || part.entry.status !== 'completed') continue;\n const { call, result } = part.entry;\n const path = call.arguments['path'];\n if (!result || result.isError || result.toolName !== call.name || (call.name !== 'write' && call.name !== 'edit') || typeof path !== 'string' || !path.trim()) continue;\n const normalized = path.replace(/^(\\.\\/)+/, '');\n if (normalized) files.set(normalized, files.get(normalized) === 'created' || call.name === 'write' ? 'created' : 'edited');\n }\n const chunks = segments(row.parts, context.groupTool);\n const Question = context.cards?.question ?? QuestionCard, Approval = context.cards?.approval ?? ApprovalCard;\n const active = row.active === true || row.streaming;\n const lastText = [...row.parts].reverse().find(part => part.kind === 'text');\n const host = nativeActions && row.text && context.messageActions\n ? <BlockActions iconOnly actions={context.messageActions(replyRef(row))} testId=\"reply-action\" menuLabel={labels.moreActions} />\n : null;\n const unread = nativeActions && row.text ? context.unread?.(replyRef(row)) : null;\n const footer = nativeActions && !row.active && row.text ? context.replyFooter?.(replyRef(row)) : null;\n return <article data-row-id={row.key} data-role=\"assistant\" aria-live={row.historical ? 'off' : undefined} data-streaming={row.streaming ? 'true' : undefined} className=\"group/message\">\n <Message><MessageContent>\n <div className=\"flex flex-col gap-3\">\n {chunks.map((chunk, at) => {\n if (chunk.kind === 'activity') {\n const failed = chunk.steps.some(step => step.kind === 'tool' && step.entry.status === 'failed');\n const unfinished = chunk.steps.some(step => step.kind === 'tool' && step.entry.status === 'unfinished');\n const state: ActivityState = failed || row.stopReason === 'error' ? 'failed' : row.stopReason === 'aborted' || (!active && unfinished) ? 'stopped' : active && at === chunks.length - 1 ? 'running' : 'done';\n return <ActivityBlock key={chunk.key} steps={chunk.steps} state={state} streaming={row.streaming} developer={context.developer} {...(context.onOpenImage ? { onOpenImage: context.onOpenImage } : {})} />;\n }\n const part = chunk.part;\n if (part.kind === 'card') return <div key={part.key}>{part.content}</div>;\n if (part.kind === 'text') return <Bubble key={part.key} variant=\"ghost\"><BubbleContent>{(context.renderText ?? markdownText)({ text: part.text, renderMention, streaming: row.streaming && part === lastText && row.parts[row.parts.length - 1] === part,\n ...(settled && context.commandMentions ? { commandMentions: context.commandMentions } : {}), ...(context.onCopy ? { onCopy: context.onCopy } : {}) })}</BubbleContent></Bubble>;\n if (part.kind === 'thinking') return null; // reasoning is a step of an activity block\n if (part.kind === 'approval') return <Approval key={part.key} call={part.call} result={part.result} questionId={part.questionId} answer={context.answer} summary={part.summary} decision={part.decision} live={part.live} />;\n if (part.kind === 'subagent') return <SubagentCard key={part.key} details={part.details} />;\n if (part.kind === 'question') return <Question key={part.key} call={part.call} result={part.result} questionId={part.questionId} answer={context.answer} live={part.live} />;\n if (part.kind === 'artifact' && part.artifact && files.has(part.artifact.target.resource.path.replace(/^(\\.\\/)+/, ''))) return null;\n if (part.kind === 'artifact') return <ArtifactCard key={part.key} artifact={part.artifact} title={part.title} {...(part.state === 'ready' ? {} : { pending: part.state })}\n open={part.artifact !== undefined && (context.artifacts?.isOpen?.(part.artifact) ?? false)} onOpen={context.artifacts?.open} kinds={context.artifacts?.kinds} />;\n return part.entry.custom ? <div key={part.key}>{part.entry.custom}</div> : <ToolCard key={part.key} entry={part.entry} {...(context.onOpenImage ? { onOpenImage: context.onOpenImage } : {})} />;\n })}\n <ImageThumbnails images={images} onOpenImage={context.onOpenImage} />\n {files.size > 0 && <ul data-testid=\"reply-files\" className=\"m-0 flex list-none flex-wrap gap-2 p-0\">\n {[...files].map(([path, change]) => <li key={path} data-testid=\"message-file\"><FileCard path={path} change={change} open={context.openFile} preview={context.pieces?.preview} /></li>)}\n </ul>}\n {row.streaming && row.parts.length === 0 && !context.natural && <p data-testid=\"working\" className=\"m-0 text-sm\"><Shimmer>{labels.thinking}</Shimmer></p>}\n </div>\n {row.stopReason === 'error' && <Notice tone=\"error\" testid=\"response-error\" icon={<AlertCircleIcon className=\"mt-0.5 size-4 shrink-0\" aria-hidden=\"true\" />}>{context.replyError?.(replyRef(row)) ?? row.errorMessage ?? labels.responseFailed}</Notice>}\n {row.stopReason === 'aborted' && <Notice tone=\"muted\" testid=\"interrupted\" icon={<CircleSlashIcon className=\"mt-0.5 size-4 shrink-0\" aria-hidden=\"true\" />}>{labels.interrupted}</Notice>}\n {unread != null && unread !== false && <div data-testid=\"reply-unread\" className=\"flex flex-col items-start\">{unread}</div>}\n {nativeActions && row.text && context.replyActions?.({ key: row.key, text: row.text, timestamp: row.timestamp }, host)}\n {nativeActions && row.text && !context.replyActions && <div className=\"mt-1 -ml-2 flex opacity-0 transition-opacity group-focus-within/message:opacity-100 group-hover/message:opacity-100 pointer-coarse:opacity-100 max-sm:opacity-100 motion-reduce:transition-none\">\n <CopyButton iconOnly text={row.text} label={labels.copyResponse} {...(context.onCopy ? { onCopy: context.onCopy } : {})} />\n {context.onFork && row.entryId && <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.fork} title={labels.forkHint} data-testid=\"fork-reply\"\n onClick={() => context.onFork!(row.entryId!)}><icons.fork data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {host}</div>}\n {footer != null && footer !== false && <MessageFooter>{footer}</MessageFooter>}\n </MessageContent></Message>\n </article>;\n }\n }\n}\n",
116
+ "content": "'use client';\n\nimport { Fragment, useEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ImageContent, ToolResultMessage, UserMessage } from '@earendil-works/pi-ai';\nimport { AlertCircleIcon, CircleSlashIcon, FileAudioIcon, FileIcon, FileSpreadsheetIcon, FileTextIcon, ImageIcon, PauseIcon, PlayIcon, PresentationIcon } from 'lucide-react';\nimport { CopyButton } from './code-block';\nimport type { AnswerOutcome, ChatArtifactsConfig, ChatCards, CommandMentions } from './config';\nimport { feedbackContent } from './feedback-content';\nimport { SentFeedback } from './sent-feedback';\nimport { ActivityBlock } from './activity';\nimport type { ActivityState } from './activity';\nimport { Shimmer } from './shimmer';\nimport { ToolCard } from './tool';\nimport { thumbnail } from './composer';\nimport { ImageViewer } from './image-viewer';\nimport { displayName, fileKind, isUploadPath, isVoicePath, pieces, visibleParts } from './config';\nimport type { AudioUrlRead, FileKind, Piece, PreviewRead } from './config';\nimport { voiceClock } from './config';\nimport { Spinner } from '@/components/ui/spinner';\nimport { isFileBlock, segments, targetContent } from './rows';\nimport type { DeriveOptions, Row } from './rows';\nimport { Button } from '@/components/ui/button';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { useChatText } from './labels';\nimport { cn } from '../utils/utils';\nimport type { ReplyRef } from './config';\nimport { Message, MessageContent, MessageFooter } from '@/components/ui/message';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { Attachment, AttachmentContent, AttachmentDescription, AttachmentMedia, AttachmentTitle, AttachmentTrigger } from '@/components/ui/attachment';\nimport { Badge } from '@/components/ui/badge';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';\n\n/** One assistant text part, as `renderText` receives it. `streaming` is true while the model still writes it. */\nexport interface ChatTextPart {\n readonly text: string;\n readonly streaming: boolean;\n /** Draws an `@path` mention (when mentions are on). */\n readonly renderMention?: ((path: string, text: string) => ReactNode) | undefined;\n /** The `/` commands a settled reply may turn into buttons. */\n readonly commandMentions?: CommandMentions | undefined;\n readonly onCopy?: ((text: string) => Promise<void>) | undefined;\n}\n/** The default text renderer: the reply as plain text. `markdownText` of `pi-chat-markdown` draws it as Markdown. */\nconst plainText = ({ text, streaming }: ChatTextPart): ReactNode =>\n <div data-testid=\"message-text\" data-streaming={streaming ? 'true' : undefined} className=\"min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere]\">{text}</div>;\n\nexport type { ChatCards } from './config';\n\nexport interface RowContext {\n readonly developer: boolean;\n /** Draws an assistant text part. Default: plain text (`markdownText` of `pi-chat-markdown` draws Markdown). */\n readonly renderText?: ((part: ChatTextPart) => ReactNode) | undefined;\n readonly cards?: ChatCards | undefined;\n /** `presentation=\"natural\"`: no \"working\" shimmer in a reply (the chat shows one status instead). */\n readonly natural?: boolean | undefined;\n readonly groupTool: DeriveOptions['groupTool'];\n readonly commandMentions: CommandMentions | undefined;\n /** Host openers keep their single image argument; the workspace's default also accepts the message gallery. */\n readonly onOpenImage: ((image: ImageContent, gallery?: readonly ImageContent[]) => void) | undefined;\n readonly onCopy: ((text: string) => Promise<void>) | undefined;\n /** Which tokens of a sent message get a styled treatment (`@path` mentions, a leading `/skill`). */\n readonly pieces: { readonly mentions: boolean; readonly uploads?: boolean; readonly skills: readonly string[]; readonly openMention?: ((path: string) => void) | undefined; readonly renderMention?: ((path: string) => ReactNode | undefined) | undefined;\n readonly preview?: PreviewRead | undefined; readonly audioUrl?: AudioUrlRead | undefined } | undefined;\n readonly fileChanges?: boolean | undefined;\n /** Opens a file card through the host's workspace opener. */\n readonly openFile?: ((path: string) => void) | undefined;\n readonly answer: ((questionId: string, answer: string) => Promise<AnswerOutcome>) | undefined;\n readonly artifacts: { readonly open: ChatArtifactsConfig['open']; readonly isOpen?: ChatArtifactsConfig['isOpen'] | undefined; readonly kinds?: ChatArtifactsConfig['kinds']; readonly Card: NonNullable<ChatArtifactsConfig['Card']> } | undefined;\n /** Replaces the default copy button under a settled reply (the ambient window adds feedback and the time); `host` holds the host's message actions. */\n readonly replyActions?: ((reply: { readonly key: string; readonly text: string; readonly timestamp?: number | undefined }, host: ReactNode) => ReactNode) | undefined;\n /** The host's actions for a settled reply (`messageActions`). */\n readonly messageActions?: ((reply: ReplyRef) => readonly BlockAction[]) | undefined;\n readonly historicalMessageActions?: boolean | undefined;\n /** Host-owned per-answer unread indicator; rendering never marks an answer seen. */\n readonly unread?: ((reply: ReplyRef) => ReactNode) | undefined;\n readonly replyError?: ((reply: ReplyRef) => ReactNode) | undefined;\n /** A line under a finished reply (not while it streams, runs a tool or waits to retry), after its actions (a host shows which model answered and how long it took). */\n readonly replyFooter?: ((reply: ReplyRef) => ReactNode) | undefined;\n /** Fork the conversation after a settled reply (its last native entry): adds a Fork button next to Copy. */\n readonly onFork?: ((entryId: string) => void) | undefined;\n}\n\nconst replyRef = (row: Extract<Row, { type: 'assistant' }>): ReplyRef => ({ key: row.key, text: row.text, entryId: row.entryId, timestamp: row.timestamp, model: row.model, durationMs: row.durationMs });\n\nfunction ImageThumbnails({ images, onOpenImage }: { readonly images: readonly ImageContent[]; readonly onOpenImage: RowContext['onOpenImage'] }) {\n const { labels } = useChatText();\n if (!images.length) return null;\n return <div data-testid=\"message-images\" data-slot=\"message-images\" className={cn('grid max-w-full gap-2', images.length === 1 ? 'w-60' : 'w-120 grid-cols-2')}>\n {images.map((image, at) => <div key={at} className=\"relative min-h-12 min-w-12 self-start\">\n <img src={thumbnail(image.mimeType, image.data)} alt={labels.imageAttachment} className=\"block h-auto w-full rounded-(--radius-xl)\" />\n <Button variant=\"link\" className=\"absolute inset-0 h-auto min-h-12 min-w-12 w-full\" data-testid=\"message-image\" aria-label={images.length > 1 ? `${labels.openImage}, ${labels.imagePosition(at + 1, images.length)}` : labels.openImage} onClick={() => onOpenImage?.(image, images)} />\n </div>)}\n </div>;\n}\n\n/** Inline sent mention; workspace openers and host renderers keep their existing authority. */\nfunction UserMention({ path, text, open, render }: { readonly path: string; readonly text: string; readonly open: ((path: string) => void) | undefined; readonly render: ((path: string) => ReactNode | undefined) | undefined }) {\n const custom = render?.(path);\n if (custom !== undefined) return <>{custom}</>;\n return open\n ? <span role=\"link\" tabIndex={0} className=\"font-medium text-primary\" data-testid=\"message-mention\" data-path={path} aria-label={`Open ${path}`} onClick={() => open(path)} onKeyDown={event => { if (event.key === 'Enter') { event.preventDefault(); open(path); } }}>{text}</span>\n : <span className=\"font-medium text-primary\" data-testid=\"message-mention\" data-path={path}>{text}</span>;\n}\n\n/** The icon a file card shows for its kind. */\nfunction FileGlyph({ kind }: { readonly kind: FileKind }) {\n switch (kind) {\n case 'image': return <ImageIcon aria-hidden=\"true\" />;\n case 'sheet': return <FileSpreadsheetIcon aria-hidden=\"true\" />;\n case 'audio': return <FileAudioIcon aria-hidden=\"true\" />;\n case 'slides': return <PresentationIcon aria-hidden=\"true\" />;\n case 'document': return <FileTextIcon aria-hidden=\"true\" />;\n default: return <FileIcon aria-hidden=\"true\" />;\n }\n}\n\n/** A file a person sent (an `uploads/` mention): its icon or thumbnail, name and type. A button when it can be opened. */\nfunction FileCard({ path, open, preview, change }: { readonly path: string; readonly open: ((path: string) => void) | undefined; readonly preview: PreviewRead | undefined; readonly change?: 'created' | 'edited' }) {\n const { labels } = useChatText();\n const kind = fileKind(path), name = change ? path.split('/').pop() || path : displayName(path);\n const [src, setSrc] = useState<string | undefined>();\n // A thumbnail belongs to the provider that read it: a new provider, or a read that is refused or unavailable, shows the icon.\n useEffect(() => {\n setSrc(undefined);\n if (kind !== 'image' || !preview) return;\n const abort = new AbortController();\n preview(path, abort.signal).then(\n read => { if (!abort.signal.aborted) setSrc(read ? thumbnail(read.mimeType, read.data) : undefined); },\n () => { if (!abort.signal.aborted) setSrc(undefined); });\n return () => abort.abort();\n }, [kind, path, preview]);\n return <Attachment className=\"max-w-64\" {...(!open ? { 'data-testid': 'message-mention', 'data-path': path } : {})}>\n <AttachmentMedia variant={src ? 'image' : 'icon'}>{src ? <img src={src} alt=\"\" /> : <FileGlyph kind={kind} />}</AttachmentMedia>\n <AttachmentContent>\n <AttachmentTitle>{name}</AttachmentTitle>\n <AttachmentDescription>{change ? labels.fileChange[change] : labels.fileKind[kind]}</AttachmentDescription>\n </AttachmentContent>\n {open && <AttachmentTrigger data-testid=\"message-mention\" data-path={path} aria-label={labels.openFile(name)} onClick={() => open(path)} />}\n </Attachment>;\n}\n\n/** The file cards of a message, above its text bubble. */\nfunction FileCards({ paths, open, preview }: { readonly paths: readonly string[]; readonly open: ((path: string) => void) | undefined; readonly preview: PreviewRead | undefined }) {\n return <ul data-testid=\"message-files\" className=\"m-0 flex max-w-[85%] list-none flex-wrap justify-end gap-2 p-0\">\n {paths.map(path => <li key={path} data-testid=\"message-file\"><FileCard path={path} open={open} preview={preview} /></li>)}\n </ul>;\n}\n\n/** Retained audio is read only on Play, through the same authorized workspace preview as file cards. */\nexport function VoiceBubble({ path, transcript, preview, audioUrl, align = 'end' }: { readonly path: string; readonly transcript: ReactNode; readonly preview: PreviewRead | undefined; readonly audioUrl: AudioUrlRead | undefined; readonly align?: 'start' | 'end' }) {\n const { labels } = useChatText();\n const audio = useRef<HTMLAudioElement>(null), work = useRef<AbortController | null>(null), loadingRequest = useRef(false);\n const [playing, setPlaying] = useState(false), [loading, setLoading] = useState(false), [failed, setFailed] = useState(false), [duration, setDuration] = useState<number>();\n const source = useRef<string | undefined>(undefined), loader = useRef<() => Promise<boolean>>(async () => false);\n useEffect(() => {\n const element = audio.current;\n let objectUrl: string | undefined;\n source.current = undefined; loadingRequest.current = false; setPlaying(false); setLoading(false); setFailed(false); setDuration(undefined);\n const release = () => { if (objectUrl) { URL.revokeObjectURL(objectUrl); objectUrl = undefined; } };\n const abort = new AbortController(); work.current = abort;\n const load = async () => {\n release();\n const signed = await audioUrl?.(path, abort.signal);\n if (abort.signal.aborted) return;\n if (signed) {\n const url = new URL(signed, window.location.href);\n if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('Unsupported audio URL');\n source.current = url.href;\n } else {\n const read = await preview?.(path, abort.signal);\n if (abort.signal.aborted) return;\n if (!read || !/^audio\\/(?:webm|ogg|mp4|m4a|x-m4a|mpeg|mp3|wav|x-wav)(?:;|$)/i.test(read.mimeType)) throw new Error('Audio unavailable');\n const bytes = Uint8Array.from(atob(read.data), character => character.charCodeAt(0));\n // MediaRecorder WebM often has no finite metadata duration; decode only these already-requested bytes.\n try {\n if (typeof OfflineAudioContext === 'function') void new OfflineAudioContext(1, 1, 48_000).decodeAudioData(bytes.slice().buffer).then(buffer => {\n if (!abort.signal.aborted && Number.isFinite(buffer.duration)) setDuration(Math.round(buffer.duration));\n }).catch(() => {});\n } catch { /* Optional duration decoding must not prevent native playback. */ }\n objectUrl = URL.createObjectURL(new Blob([bytes], { type: read.mimeType })); source.current = objectUrl;\n }\n if (element && source.current) element.src = source.current;\n };\n // A loader belongs to this path/provider epoch, not to a later row with the same DOM node.\n loader.current = async () => { await load(); return !abort.signal.aborted; };\n return () => { abort.abort(); if (work.current === abort) work.current = null; element?.pause(); element?.removeAttribute('src'); element?.load(); source.current = undefined; release(); };\n }, [path, preview, audioUrl]);\n const toggle = async () => {\n const element = audio.current, abort = work.current;\n if (!element || !abort || loadingRequest.current) return;\n if (!element.paused) { element.pause(); return; }\n loadingRequest.current = true; setFailed(false); setLoading(true);\n try {\n if (!source.current && !await loader.current()) return;\n if (!abort.signal.aborted) await element.play();\n } catch { if (!abort.signal.aborted) setFailed(true); }\n finally { if (work.current === abort && !abort.signal.aborted) { loadingRequest.current = false; setLoading(false); } }\n };\n return <Bubble variant=\"muted\" align={align} data-testid=\"voice-bubble\"><BubbleContent>\n <div className=\"flex items-center gap-2\">\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"voice-play\" aria-label={playing ? labels.voicePause : labels.voicePlay} disabled={loading || (!preview && !audioUrl)} onClick={() => { void toggle(); }}>\n {loading ? <Spinner data-icon=\"inline-start\" aria-hidden=\"true\" /> : playing ? <PauseIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <PlayIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Button>\n <span data-testid=\"voice-duration\">{duration === undefined ? '--:--' : voiceClock(duration)}</span>\n <audio ref={audio} preload=\"none\" onPlay={() => { setPlaying(true); setFailed(false); }} onPause={() => setPlaying(false)} onEnded={() => {\n setPlaying(false); const element = audio.current;\n if (element && !Number.isFinite(element.duration) && Number.isFinite(element.currentTime)) setDuration(Math.round(element.currentTime));\n }} onError={() => { setFailed(true); setPlaying(false); source.current = undefined; }} onDurationChange={() => {\n const element = audio.current; if (element && Number.isFinite(element.duration)) setDuration(Math.round(element.duration));\n }} onLoadedMetadata={() => {\n const element = audio.current; if (element && Number.isFinite(element.duration)) setDuration(Math.round(element.duration));\n }} />\n </div>\n {failed && <p role=\"alert\">{labels.voicePlayFailed}</p>}\n {transcript && <div data-testid=\"user-text\" className=\"whitespace-pre-wrap\">{transcript}</div>}\n </BubbleContent></Bubble>;\n}\n\n/**\n * A text part of a sent message. Its `uploads/` mentions become file cards above the bubble and leave the text; the message content keeps\n * them (only what is drawn changes).\n */\n/** `variant` picks the bubble: `muted` (the chat's user bubble) or `default` (the theme's primary, used for my own messages in a shared chat). */\n/** `decoration` sits inside the Bubble before its content (a tail); `header` and `footer` inside the content, around the text (a quote, the time). */\nexport function MessageText({ text, context, align = 'end', variant = 'muted', decoration, header, footer }: { readonly text: string; readonly context: Pick<RowContext, 'pieces' | 'openFile'>; readonly align?: 'start' | 'end'; readonly variant?: 'muted' | 'default'; readonly decoration?: ReactNode; readonly header?: ReactNode; readonly footer?: ReactNode }) {\n const framed = decoration !== undefined || header !== undefined || footer !== undefined;\n const wrap = (body: ReactNode) => <Bubble variant={variant} align={align}>{decoration}<BubbleContent data-testid=\"user-text\" className={framed ? 'relative' : undefined}>{header}{body}{footer}</BubbleContent></Bubble>;\n const target = targetContent(text);\n if (target) return <>\n <Badge variant=\"secondary\" data-testid=\"message-target\" title={JSON.stringify(target.target)} className=\"max-w-full truncate\">{target.label}</Badge>\n <MessageText text={target.speech} context={context} align={align} variant={variant} decoration={decoration} header={header} footer={footer} />\n </>;\n const feedback = feedbackContent(text);\n if (feedback) return <>\n {feedback.before && <MessageText text={feedback.before} context={context} align={align} variant={variant} />}\n <SentFeedback report={feedback.report} align={align} />\n {feedback.after && <MessageText text={feedback.after} context={context} align={align} variant={variant} />}\n </>;\n const open = context.pieces?.openMention;\n const render = context.pieces?.renderMention;\n const styledUploads = Boolean(context.pieces?.mentions || context.pieces?.uploads);\n const all = pieces(text, { mentions: context.pieces?.mentions ?? false, uploads: true, skills: context.pieces?.skills ?? [] }).map((piece): Piece =>\n !styledUploads && piece.kind === 'mention' && isUploadPath(piece.value!) && !isVoicePath(piece.value!) ? { text: piece.text } : piece);\n const isFile = (piece: Piece) => piece.kind === 'mention' && isUploadPath(piece.value!);\n const files = all.filter(isFile).map(piece => piece.value!);\n const voice = files.find(isVoicePath), cards = files.filter(path => path !== voice);\n if (!context.pieces && !voice) return wrap(<span className=\"whitespace-pre-wrap\">{text}</span>);\n const shown = files.length ? visibleParts(all, isFile) : all;\n const empty = shown.every(piece => piece.kind === undefined && piece.text === '');\n const content = shown.map((piece, at) => piece.kind === 'mention'\n ? <UserMention key={at} path={piece.value!} text={piece.text} open={open} render={render} />\n : piece.kind === 'skill' ? <Badge key={at} variant=\"secondary\" data-testid=\"message-skill\" data-skill={piece.value}>{piece.text}</Badge>\n : piece.text);\n return <>\n {cards.length > 0 && <FileCards paths={cards} open={context.openFile ?? open} preview={context.pieces?.preview} />}\n {voice ? <VoiceBubble path={voice} transcript={empty ? null : content} preview={context.pieces?.preview} audioUrl={context.pieces?.audioUrl} align={align} />\n : !(files.length > 0 && empty) && wrap(<span className=\"whitespace-pre-wrap\">{content}</span>)}\n </>;\n}\n\nfunction UserBubble({ message, images, context, align = 'end' }: { readonly message: UserMessage; readonly images: readonly ImageContent[]; readonly context: RowContext; readonly align?: 'start' | 'end' }) {\n const parts = typeof message.content === 'string' ? [{ type: 'text' as const, text: message.content }] : message.content;\n return <Message align={align}><MessageContent>\n <ImageThumbnails images={images} onOpenImage={context.onOpenImage} />\n {parts.map((part, index) => part.type === 'text' ? (isFileBlock(part.text) ? null : <MessageText key={index} text={part.text} context={context} align={align} />)\n : null)}\n </MessageContent></Message>;\n}\n\nfunction Notice({ tone, icon, children, testid }: { readonly tone: 'error' | 'muted'; readonly icon: ReactNode; readonly children: ReactNode; readonly testid: string }) {\n return <Alert variant={tone === 'error' ? 'destructive' : 'default'} role={tone === 'error' ? 'alert' : 'status'} data-testid={testid}>\n {icon}<AlertDescription>{children}</AlertDescription>\n </Alert>;\n}\n\n/**\n * A question or an approval waits for the person but this chat has no card for it (no `pi-chat-question` or `pi-chat-approval`): say so,\n * so the run is never silently blocked. It offers no answer UI.\n */\nfunction AnswerWaiting({ kind }: { readonly kind: 'question' | 'approval' }) {\n const { labels } = useChatText();\n return <Alert role=\"status\" data-testid=\"answer-waiting\" data-kind={kind}>\n <AlertCircleIcon className=\"mt-0.5 size-4 shrink-0\" aria-hidden=\"true\" /><AlertDescription>{labels.waitingForAnswer}</AlertDescription>\n </Alert>;\n}\n\nfunction resultText(message: ToolResultMessage): string { return message.content.map(part => part.type === 'text' ? part.text : '').join('\\n'); }\n\nfunction messageImages(row: Row): readonly ImageContent[] {\n const images = (content: ToolResultMessage['content']) => content.flatMap(part => part.type === 'image' && thumbnail(part.mimeType, part.data) ? [part] : []);\n if (row.type === 'user') return typeof row.message.content === 'string' ? [] : images(row.message.content);\n if (row.type === 'orphan-result') return images(row.message.content);\n return row.type === 'assistant' ? row.images?.filter(image => thumbnail(image.mimeType, image.data)) ?? row.parts.flatMap(part => part.kind === 'tool' && part.entry.result ? images(part.entry.result.content) : []) : [];\n}\n\nexport function RowView({ row, context }: { readonly row: Row; readonly context: RowContext }) {\n const images = messageImages(row);\n const [selection, setSelection] = useState<{ readonly index: number; readonly images: readonly ImageContent[]; readonly key: string; readonly opener: RowContext['onOpenImage'] }>();\n const current = selection && selection.key === row.key && selection.opener === context.onOpenImage && selection.images.length === images.length\n && selection.images.every((image, at) => image.mimeType === images[at]?.mimeType && image.data === images[at]?.data) ? selection : undefined;\n useEffect(() => { if (selection && !current) setSelection(undefined); }, [selection, current]);\n const openImage: NonNullable<RowContext['onOpenImage']> = image => {\n if (!thumbnail(image.mimeType, image.data)) return;\n if (context.onOpenImage) { context.onOpenImage(image, images); return; }\n const index = images.indexOf(image);\n if (index >= 0) setSelection({ index, images, key: row.key, opener: context.onOpenImage });\n };\n return <><RowBody row={row} images={images} context={{ ...context, onOpenImage: openImage }} />\n {current && <ImageViewer images={images} index={current.index} onIndexChange={index => setSelection({ ...current, index })} onClose={() => setSelection(undefined)} />}\n </>;\n}\n\nfunction RowBody({ row, images, context: liveContext }: { readonly row: Row; readonly images: readonly ImageContent[]; readonly context: RowContext }) {\n const context: RowContext = row.historical ? { ...liveContext, answer: undefined, onFork: undefined, messageActions: liveContext.historicalMessageActions ? liveContext.messageActions : undefined, replyActions: undefined, commandMentions: undefined } : liveContext;\n const { labels, icons } = useChatText();\n const renderMention = useMemo(() => context.pieces?.mentions ? (path: string, text: string) => <UserMention path={path} text={text} open={context.pieces?.openMention} render={context.pieces?.renderMention} /> : undefined, [context.pieces]);\n switch (row.type) {\n case 'user': return <article data-row-id={row.key} data-role=\"user\" aria-live={row.historical ? 'off' : undefined}><UserBubble message={row.message} images={images} context={context} /></article>;\n case 'card': return <article data-row-id={row.key} data-role=\"event\">{row.card.content}</article>;\n case 'event': return <article data-row-id={row.key} data-role=\"event\" className=\"text-center text-xs text-muted-foreground\">{labels.event(row.label)}</article>;\n case 'system': return <article data-row-id={row.key} data-role=\"system\"><Collapsible>\n <CollapsibleTrigger render={<Button variant=\"ghost\" />}>{labels.systemContext}</CollapsibleTrigger>\n <CollapsibleContent><pre className=\"whitespace-pre-wrap\">{row.text}</pre></CollapsibleContent>\n </Collapsible></article>;\n case 'orphan-result': return <article data-row-id={row.key} data-role=\"toolResult\"><Notice tone=\"error\" testid=\"tool-error\" icon={<AlertCircleIcon className=\"mt-0.5 size-4 shrink-0\" aria-hidden=\"true\" />}>\n <strong className=\"font-semibold\">{labels.toolFailed(row.message.toolName)}</strong><span className=\"mt-1 block font-mono text-xs whitespace-pre-wrap\">{resultText(row.message)}</span></Notice><ImageThumbnails images={images} onOpenImage={context.onOpenImage} /></article>;\n case 'assistant': {\n const settled = !row.streaming;\n const nativeActions = settled;\n const files = new Map<string, 'created' | 'edited'>();\n if (context.fileChanges) for (const part of row.parts) {\n if (part.kind !== 'tool' || part.entry.status !== 'completed') continue;\n const { call, result } = part.entry;\n const path = call.arguments['path'];\n if (!result || result.isError || result.toolName !== call.name || (call.name !== 'write' && call.name !== 'edit') || typeof path !== 'string' || !path.trim()) continue;\n const normalized = path.replace(/^(\\.\\/)+/, '');\n if (normalized) files.set(normalized, files.get(normalized) === 'created' || call.name === 'write' ? 'created' : 'edited');\n }\n const chunks = segments(row.parts, context.groupTool);\n const { question: Question, approval: Approval } = context.cards ?? {};\n const active = row.active === true || row.streaming;\n const lastText = [...row.parts].reverse().find(part => part.kind === 'text');\n const host = nativeActions && row.text && context.messageActions\n ? <BlockActions iconOnly actions={context.messageActions(replyRef(row))} testId=\"reply-action\" menuLabel={labels.moreActions} />\n : null;\n const unread = nativeActions && row.text ? context.unread?.(replyRef(row)) : null;\n const footer = nativeActions && !row.active && row.text ? context.replyFooter?.(replyRef(row)) : null;\n return <article data-row-id={row.key} data-role=\"assistant\" aria-live={row.historical ? 'off' : undefined} data-streaming={row.streaming ? 'true' : undefined} className=\"group/message\">\n <Message><MessageContent>\n <div className=\"flex flex-col gap-3\">\n {chunks.map((chunk, at) => {\n if (chunk.kind === 'activity') {\n const failed = chunk.steps.some(step => step.kind === 'tool' && step.entry.status === 'failed');\n const unfinished = chunk.steps.some(step => step.kind === 'tool' && step.entry.status === 'unfinished');\n const state: ActivityState = failed || row.stopReason === 'error' ? 'failed' : row.stopReason === 'aborted' || (!active && unfinished) ? 'stopped' : active && at === chunks.length - 1 ? 'running' : 'done';\n return <ActivityBlock key={chunk.key} steps={chunk.steps} state={state} streaming={row.streaming} developer={context.developer} {...(context.onOpenImage ? { onOpenImage: context.onOpenImage } : {})} />;\n }\n const part = chunk.part;\n if (part.kind === 'card') return <div key={part.key}>{part.content}</div>;\n if (part.kind === 'text') return <Bubble key={part.key} variant=\"ghost\"><BubbleContent>{(context.renderText ?? plainText)({ text: part.text, renderMention, streaming: row.streaming && part === lastText && row.parts[row.parts.length - 1] === part,\n ...(settled && context.commandMentions ? { commandMentions: context.commandMentions } : {}), ...(context.onCopy ? { onCopy: context.onCopy } : {}) })}</BubbleContent></Bubble>;\n if (part.kind === 'thinking') return null; // reasoning is a step of an activity block\n if (part.kind === 'approval') return Approval ? <Approval key={part.key} call={part.call} result={part.result} questionId={part.questionId} answer={context.answer} summary={part.summary} decision={part.decision} live={part.live} />\n : !part.live && !part.result && !part.decision ? <AnswerWaiting key={part.key} kind=\"approval\" /> : null;\n if (part.kind === 'subagent') return <Fragment key={part.key}>{part.content}</Fragment>;\n if (part.kind === 'question') return Question ? <Question key={part.key} call={part.call} result={part.result} questionId={part.questionId} answer={context.answer} live={part.live} />\n : !part.live && !part.result ? <AnswerWaiting key={part.key} kind=\"question\" /> : null;\n if (part.kind === 'artifact') {\n const { artifact } = part, artifacts = context.artifacts;\n if (!artifacts || (artifact.path !== undefined && files.has(artifact.path.replace(/^(\\.\\/)+/, '')))) return null;\n const descriptor = artifact.descriptor as never;\n return <artifacts.Card key={part.key} artifact={artifact} open={artifact.state === 'ready' && (artifacts.isOpen?.(descriptor) ?? false)}\n onOpen={artifact.state === 'ready' && artifacts.open ? () => artifacts.open(descriptor) : undefined} kinds={artifacts.kinds} />;\n }\n return part.entry.custom ? <div key={part.key}>{part.entry.custom}</div> : <ToolCard key={part.key} entry={part.entry} {...(context.onOpenImage ? { onOpenImage: context.onOpenImage } : {})} />;\n })}\n <ImageThumbnails images={images} onOpenImage={context.onOpenImage} />\n {files.size > 0 && <ul data-testid=\"reply-files\" className=\"m-0 flex list-none flex-wrap gap-2 p-0\">\n {[...files].map(([path, change]) => <li key={path} data-testid=\"message-file\"><FileCard path={path} change={change} open={context.openFile} preview={context.pieces?.preview} /></li>)}\n </ul>}\n {row.streaming && row.parts.length === 0 && !context.natural && <p data-testid=\"working\" className=\"m-0 text-sm\"><Shimmer>{labels.thinking}</Shimmer></p>}\n </div>\n {row.stopReason === 'error' && <Notice tone=\"error\" testid=\"response-error\" icon={<AlertCircleIcon className=\"mt-0.5 size-4 shrink-0\" aria-hidden=\"true\" />}>{context.replyError?.(replyRef(row)) ?? row.errorMessage ?? labels.responseFailed}</Notice>}\n {row.stopReason === 'aborted' && <Notice tone=\"muted\" testid=\"interrupted\" icon={<CircleSlashIcon className=\"mt-0.5 size-4 shrink-0\" aria-hidden=\"true\" />}>{labels.interrupted}</Notice>}\n {unread != null && unread !== false && <div data-testid=\"reply-unread\" className=\"flex flex-col items-start\">{unread}</div>}\n {nativeActions && row.text && context.replyActions?.({ key: row.key, text: row.text, timestamp: row.timestamp }, host)}\n {nativeActions && row.text && !context.replyActions && <div className=\"mt-1 -ml-2 flex opacity-0 transition-opacity group-focus-within/message:opacity-100 group-hover/message:opacity-100 pointer-coarse:opacity-100 max-sm:opacity-100 motion-reduce:transition-none\">\n <CopyButton iconOnly text={row.text} label={labels.copyResponse} {...(context.onCopy ? { onCopy: context.onCopy } : {})} />\n {context.onFork && row.entryId && <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.fork} title={labels.forkHint} data-testid=\"fork-reply\"\n onClick={() => context.onFork!(row.entryId!)}><icons.fork data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {host}</div>}\n {footer != null && footer !== false && <MessageFooter>{footer}</MessageFooter>}\n </MessageContent></Message>\n </article>;\n }\n }\n}\n",
157
117
  "type": "registry:component",
158
118
  "target": "components/pi-chat/message.tsx"
159
119
  },
@@ -165,7 +125,7 @@
165
125
  },
166
126
  {
167
127
  "path": "registry/pi-chat/pi-chat.tsx",
168
- "content": "'use client';\n\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { Loader2Icon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Avatar, AvatarFallback } from '@/components/ui/avatar';\nimport { Badge } from '@/components/ui/badge';\nimport { Input } from '@/components/ui/input';\nimport { Separator } from '@/components/ui/separator';\nimport { MessageScroller, MessageScrollerProvider, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem, MessageScrollerButton } from '@/components/ui/message-scroller';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { Composer } from './composer';\nimport type { ComposerFeedback, ComposerProps } from './composer';\nimport { ConversationFiles } from './conversation-files';\nimport type { ConversationsConfig, VoiceMessageConfig } from './config';\nimport { useVoiceMessage } from './use-voice-message';\nimport type { PendingVoice } from './use-voice-message';\nimport { displayName } from './config';\nimport { PendingVoiceBubble, VoiceMessageButton } from './voice-message';\nimport { EmptyState } from './empty-state';\nimport type { Suggestion } from './empty-state';\nimport { ConversationHistory, TranscriptHistory } from './history';\nimport { RowView } from './message';\nimport type { Row } from './rows';\nimport { MessageQueue } from './queue';\nimport { ChatNotices, Notice, PreparingStatus, preparing } from './notice';\nimport { Shimmer } from './shimmer';\nimport { useChatSession, useTranscript } from './session';\nimport { useChatDraft } from './use-chat-draft';\nimport { useChatPlacement } from './use-remote-chat';\nimport type { ChatPlacement } from './use-remote-chat';\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, QuestionCardProps } from './question-card';\nexport { ApprovalCard } from './approval-card';\nexport type { ApprovalCardProps } from './approval-card';\nexport type { ChatCards, ChatTextPart } from './message';\nexport type { MentionPickerProps, ModelEffortPickerProps, SlashPickerProps } from './config';\nexport type { PendingVoice, PendingVoiceBubbleProps, VoiceMessageButtonProps, VoiceMessageHandle } from './config';\nexport type { CameraSheetProps } from './camera-sheet';\nexport { artifactKey, artifactKind, collectArtifacts, detectArtifact, parseArtifact, typeLabel } from './artifact';\nexport type { ArtifactDescriptor, ArtifactKind, ArtifactKindViewProps, ArtifactsConfig, ArtifactTarget, ArtifactType } from './artifact';\nexport { QuestionCard, formAnswer, parseQuestion, ASK_USER_TOOL } from './question-card';\nexport type { FormQuestion } from './question-card';\nexport { ChatTextProvider } from './labels';\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 { BlockActions } from '../button/actions';\nexport { useChatDraft } from './use-chat-draft';\nexport { useConversations } from './use-conversations';\nexport type { ChatController, ChatSnapshot } from '@hachej/boring-ui-kit/native-chat';\nexport { MessageText, VoiceBubble } from './message';\nexport { Markdown } from './markdown';\nexport { useTranscript } from './session';\nexport { RunCard, parseRun } from './run-card';\nexport { SubagentCard, parseDelegateDetails, DELEGATE_DETAILS_KIND } from './subagent-card';\nexport type { SubagentDetails, SubagentStatus } from './subagent-card';\nexport type { RunRecord, RunStatus } from './run-card';\nexport type { BlockAction, BlockIcon } from '../button/actions';\nexport type { AudioUrlRead, VoiceMessageConfig, AttachmentsConfig, ConversationItem, ConversationsConfig, EffortConfig, FileKind, MentionResult, MentionsConfig, ModelConfig, ModelRef, PasteToFile, PreviewRead, ReplyRef, SlashApi, SlashCommand, SlashConfig, SlashSkill, UploadResult } from './config';\n\nexport interface ChatTimeline {\n readonly count: number;\n readonly render: (context: { readonly focusComposer: () => void; readonly openFile?: ((path: string) => void) | undefined; readonly pendingVoice?: ReactNode }) => ReactNode;\n readonly afterSend?: () => void;\n}\n\nexport interface PiChatProps extends ChatFeatureProps {\n /** Optional host transcript. Absent: native rows, history and scroll behavior are unchanged. */\n readonly timeline?: ChatTimeline | undefined;\n /** Authorized host errors may provide their own message. */\n readonly renderConnectionError?: ((error: unknown) => ReactNode) | undefined;\n readonly controller: ChatController;\n /** Where this chat is shown, reported with each submit (advisory, never authority). `docked` by default; a host that floats or tucks the same chat in a drawer says `floating`. */\n readonly placement?: ChatPlacement | undefined;\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 /** `false` lets the host supply one shared header around chat and viewers. Default true. */\n readonly header?: boolean;\n readonly className?: string;\n /** Pad the chat's bottom by how far it reaches below the visual viewport (the phone's soft keyboard). Default true; `false` for a host that already follows the keyboard itself, such as a bottom drawer whose lower part sits off screen by design. */\n readonly keyboardInset?: boolean;\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 /** Hold-to-send one retained audio upload plus transcript. Omit to keep the existing composer unchanged. */\n readonly voiceMessage?: VoiceMessageConfig | undefined;\n /** Local voice rows owned by the shell's shared microphone; rendered with the native transcript, never the composer. */\n readonly pendingVoice?: readonly PendingVoice[] | undefined;\n /** Host content under the composer (for example a collapsed list of the person's requests). Absent: nothing is shown. */\n readonly belowComposer?: 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 /** Empty desktop composition. `centered` puts the composer beneath the greeting; `docked` keeps the former layout. Phones always dock it. */\n readonly emptyLayout?: 'centered' | 'docked';\n /** Two-row reference composer by default. Explicitly select `pill`, `stacked` or `inline` for the earlier layouts. */\n readonly composerLayout?: ComposerProps['layout'];\n /** A conversation title, separate from the agent name, with optional click-to-rename. */\n readonly conversationTitle?: string | undefined;\n readonly onRenameTitle?: ((title: string) => void | Promise<void>) | undefined;\n /** Show the header connection badge. Default false; connection state still controls actions and failures stay visible. */\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;\ntype TranscriptRow = { readonly key: string; readonly kind: 'native'; readonly row: Row }\n | { readonly key: string; readonly kind: 'voice'; readonly voice: PendingVoice };\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}><MessageScrollerProvider key={mount.sequence} autoScroll><PiChatSession {...props} activeController={active} /></MessageScrollerProvider></ChatTextProvider>;\n}\n\nfunction ConnectionBadge({ kind }: { readonly kind: string }) {\n const { labels } = useChatText();\n const live = kind === 'connected';\n return <Badge variant={live ? 'outline' : 'secondary'} data-testid=\"connection\" data-state={kind} role=\"status\">\n {live ? labels.live : kind === 'connecting' || kind === 'idle' ? labels.connecting : labels.reconnecting}\n </Badge>;\n}\n\nfunction PiChatSession({ controller, placement = 'docked', mode = 'expert', presentation, className, keyboardInset = true, actions, avatar, headerActions, header = true, decisions, controls, tools, voiceMessage, pendingVoice, belowComposer, composerChips, composerHeader, composerHasContent, onPickFiles, timeline, renderConnectionError, headerStart, renderEntry, hideLine, displayLimit, historicalMessageActions, unread, replyError, renderTool, toolRenderers, renderText, cards, 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 = false, emptyLayout = 'centered', composerLayout = 'card', conversationTitle, onRenameTitle, feedback, draftKey, onOpenFile, fileChanges, pasteToFile, activeController: active }: PiChatProps & { readonly activeController: { readonly current: ChatController } }) {\n const { labels, icons } = useChatText();\n useChatDraft(controller, draftKey);\n useChatPlacement(controller, placement);\n const { title } = labels;\n const [pickingConversation, setPickingConversation] = useState(false);\n const scrollToBottom = useRef<() => void>(() => {});\n const voice = useVoiceMessage({ controller, config: voiceMessage, attachments, labels, activeController: active, admitted: () => timeline ? timeline.afterSend?.() : scrollToBottom.current() });\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, presentation, actions, renderEntry, hideLine, historicalMessageActions, unread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, onFiles, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => timeline ? timeline.afterSend?.() : scrollToBottom.current(), feedback, rowExtras, onOpenFile, fileChanges, pasteToFile, composerChips, composerHeader, composerHasContent, onPickFiles });\n const { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, error, act, textarea, rowContext, composer, files, loading } = session;\n const voiceRows = pendingVoice ?? voice.pending;\n const VoiceButton = voiceMessage?.Button ?? VoiceMessageButton, VoiceBubbleView = voiceMessage?.Bubble ?? PendingVoiceBubble;\n const transcriptRows: readonly TranscriptRow[] = [\n ...rows.map(row => ({ key: row.key, kind: 'native' as const, row })),\n ...voiceRows.map(voice => ({ key: `voice:${voice.id}`, kind: 'voice' as const, voice })),\n ];\n const pendingRows = voiceRows.map(item => <MessageScrollerItem key={`voice:${item.id}`} messageId={`voice:${item.id}`} data-local-voice=\"\" className=\"[content-visibility:visible]\"><VoiceBubbleView pending={item} /></MessageScrollerItem>);\n const empty = (timeline ? timeline.count === 0 : session.empty) && voiceRows.length === 0;\n const conversationFiles = new Map(files.map(file => [file.id, file]));\n for (const item of voiceRows) if (item.path && !conversationFiles.has(`file:${item.path}`)) {\n const path = item.path, open = onOpenFile ?? mentions?.open;\n conversationFiles.set(`file:${path}`, { id: `file:${path}`, name: displayName(path), open: open ? () => open(path) : undefined });\n }\n const { pinned, retry } = derived;\n const showPreparing = preparing({ natural: session.natural, waitingForAnswer, run: derived.run, retry, compacting: derived.compacting, stop: state.stop });\n const transcript = useTranscript(transcriptRows, displayLimit ?? PAGE, MORE, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier }, displayLimit === undefined);\n const { stick, hidden } = transcript;\n scrollToBottom.current = () => { transcript.reset(); void stick.scrollToBottom('instant'); };\n const visible = transcript.visible(transcriptRows);\n const { barStart, barNote } = session.pickers();\n const connectionKind = state.connection.kind;\n const [renamingTitle, setRenamingTitle] = useState(false);\n const [titleError, setTitleError] = useState(false);\n const page = useRef<HTMLElement>(null);\n useEffect(() => {\n const viewport = globalThis.visualViewport;\n if (!keyboardInset || !viewport) return;\n const element = page.current;\n if (!element) return;\n const measure = () => element.style.setProperty('--composer-keyboard-inset', `${Math.max(0, element.getBoundingClientRect().bottom - viewport.height - viewport.offsetTop)}px`);\n const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure);\n observer?.observe(element);\n measure(); viewport.addEventListener('resize', measure); viewport.addEventListener('scroll', measure);\n return () => { observer?.disconnect(); viewport.removeEventListener('resize', measure); viewport.removeEventListener('scroll', measure); };\n }, []);\n\n return <section ref={page} data-boring=\"pi-chat\" data-connection={connectionKind} data-empty={empty && !loading ? emptyLayout : undefined} aria-label={title} className={cn('pi-chat relative box-border flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-background text-foreground ', keyboardInset && 'max-[641px]:pb-(--composer-keyboard-inset)', empty && emptyLayout === 'centered' && 'min-[641px]:justify-center', className)}>\n {header && <header className={cn(empty && emptyLayout === 'centered' && 'min-[641px]:absolute min-[641px]:inset-x-0 min-[641px]:top-0', 'flex h-13 shrink-0 items-center gap-2 px-3 sm:gap-3 sm:px-4')}>\n {headerStart}\n {avatar && <Avatar data-testid=\"chat-avatar\" className=\"overflow-hidden\"><AvatarFallback className=\"*:max-h-full *:max-w-full\">{avatar}</AvatarFallback></Avatar>}\n {renamingTitle && onRenameTitle ? <form className=\"min-w-0 flex-1\" onSubmit={event => { event.preventDefault(); const value = new FormData(event.currentTarget).get('title')?.toString().trim(); if (value) { setTitleError(false); void Promise.resolve().then(() => onRenameTitle(value)).catch(() => setTitleError(true)); } setRenamingTitle(false); }}>\n <Input name=\"title\" aria-label={labels.conversationName} maxLength={200} autoFocus defaultValue={conversationTitle ?? title} onBlur={() => setRenamingTitle(false)} onKeyDown={event => { if (event.key === 'Escape') setRenamingTitle(false); }} className=\"w-full min-w-0\" />\n </form> : <h2 className=\"m-0 min-w-0 flex-1 truncate font-(family-name:--chat-display) text-sm font-medium tracking-tight max-[641px]:text-center\">{onRenameTitle ? <Button variant=\"ghost\" className=\"max-w-full\" onClick={() => setRenamingTitle(true)}><span className=\"truncate\">{conversationTitle ?? title}</span></Button> : conversationTitle ?? title}</h2>}\n {showHistory && conversations && historyList && <Button variant=\"ghost\" size=\"sm\" data-testid=\"history-open\" aria-haspopup=\"dialog\" aria-expanded={pickingConversation} disabled={state.disposed}\n onClick={() => setPickingConversation(open => !open)}><icons.history data-icon=\"inline-start\" 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 {titleError && <p role=\"alert\" className=\"m-0 shrink-0 px-4 py-2 text-sm text-destructive\">{labels.actionFailed}</p>}\n {decisions && <><div data-boring=\"required-decisions\" className=\"shrink-0 px-4 py-2\">{decisions}</div><Separator /></>}\n {pinned.length > 0 && <><aside aria-label={labels.requiredActions} className=\"shrink-0 space-y-2 px-4 py-2\">{pinned.map(card => <div key={card.key}>{card.content}</div>)}</aside><Separator /></>}\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' ? state.connection.kind === 'error' && renderConnectionError ? renderConnectionError(state.connection.error) : labels.loadFailed : labels.disconnected}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}><icons.reconnect data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.reconnect}</Button>}\n </Notice></div>}\n {pickingConversation && conversations && historyList && <ConversationHistory conversations={conversations} onClose={() => setPickingConversation(false)} />}\n\n <div data-testid=\"conversation-content\" className={cn('relative min-h-0 flex-1', empty && emptyLayout === 'centered' && 'min-[641px]:min-h-60 min-[641px]:flex-none')}>\n {timeline ? timeline.render({ focusComposer: () => textarea.current?.focus(), openFile: onOpenFile ?? mentions?.open, pendingVoice: pendingRows }) : <MessageScroller>\n <MessageScrollerViewport ref={stick.scrollRef} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"min-h-0 flex-1 scrollbar-thumb-border scrollbar-track-transparent\">\n {!empty && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div className=\"mx-auto w-full max-w-[720px] px-4 pt-6 pb-8 sm:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\">\n {visible.map(item => <MessageScrollerItem key={item.key} messageId={item.key} data-local-voice={item.kind === 'voice' ? '' : undefined} className=\"[content-visibility:visible]\">{item.kind === 'native' ? <RowView row={item.row} context={rowContext} /> : <VoiceBubbleView pending={item.voice} />}</MessageScrollerItem>)}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {!empty && <div data-testid=\"transcript-navigation\" className=\"flex h-12 shrink-0 items-center justify-center\">{!stick.isAtBottom && <MessageScrollerButton className=\"static inset-auto translate-x-0\" data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={transcript.reset}><icons.jumpToLatest aria-hidden=\"true\" /></MessageScrollerButton>}</div>}\n </MessageScroller>}\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 </div>\n\n <footer data-testid=\"composer-dock\" className=\"shrink-0 px-4 pt-2 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-6\">\n <div className=\"mx-auto w-full max-w-[720px] space-y-2\">\n <div {...(session.natural ? {} : { 'aria-live': 'polite' as const })} className=\"space-y-2 empty:hidden\">\n {session.natural ? showPreparing && <PreparingStatus>{derived.stageStatus}</PreparingStatus> : working && <p role=\"status\" data-testid=\"agent-status\" className=\"m-0 flex items-center gap-2 px-1 text-xs text-muted-foreground\">\n <Shimmer active={!waitingForAnswer}>{waitingForAnswer ? labels.waitingForAnswer : retry ? labels.retryPending : labels.agentWorking}</Shimmer></p>}\n {!session.natural && derived.compacting && <p role=\"status\" className=\"m-0 px-1 text-xs text-muted-foreground\">{labels.compacting}</p>}\n <ChatNotices natural={session.natural} state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} />\n </div>\n <div>\n <ConversationFiles files={[...conversationFiles.values()]} />\n <MessageQueue items={queued} sending={state.outbox} withdraw={actions?.withdraw} actions={queueActions} />\n {voice.problem && <p role=\"alert\" data-testid=\"voice-message-problem\">{voice.problem}</p>}\n <Composer {...composer} layout={composerLayout} placeholder={labels.placeholder === 'Message the agent…' ? `Message ${title}…` : labels.placeholder} barStart={barStart} barNote={barNote} barEnd={voiceMessage ? <>{tools}<VoiceButton voice={voice} disabled={state.disposed || !connected || state.send.kind === 'unknown'} /></> : tools} />\n {belowComposer !== undefined && <div data-testid=\"below-composer\" className=\"pt-2\">{belowComposer}</div>}\n </div>\n </div>\n </footer>\n </section>;\n}\n",
128
+ "content": "'use client';\n\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { Loader2Icon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Avatar, AvatarFallback } from '@/components/ui/avatar';\nimport { Badge } from '@/components/ui/badge';\nimport { Input } from '@/components/ui/input';\nimport { Separator } from '@/components/ui/separator';\nimport { MessageScroller, MessageScrollerProvider, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem, MessageScrollerButton } from '@/components/ui/message-scroller';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { Composer } from './composer';\nimport type { ComposerFeedback, ComposerProps } from './composer';\nimport { ConversationFiles } from './conversation-files';\nimport type { AttachmentsConfig, ConversationsConfig, PendingVoice, VoiceMessageConfig, VoiceMessageHandle, VoiceRecorderHook } from './config';\nimport { displayName } from './config';\nimport { EmptyState } from './empty-state';\nimport type { Suggestion } from './empty-state';\nimport { ConversationHistory, TranscriptHistory } from './history';\nimport { RowView } from './message';\nimport type { Row } from './rows';\nimport { MessageQueue } from './queue';\nimport { ChatNotices, Notice, PreparingStatus, preparing } from './notice';\nimport { Shimmer } from './shimmer';\nimport { useChatSession, useTranscript } from './session';\nimport { useChatDraft } from './use-chat-draft';\nimport { useChatPlacement } from './use-remote-chat';\nimport type { ChatPlacement } from './use-remote-chat';\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, ApprovalCardProps, ApprovalDecision, ChatArtifact, ChatArtifactCardProps, ChatArtifactsConfig, ChatCards, QuestionCardProps, SubagentRenderer } from './config';\nexport { ASK_USER_TOOL } from './config';\nexport type { ChatTextPart } from './message';\nexport type { MentionPickerProps, ModelEffortPickerProps, SlashPickerProps } from './config';\nexport type { PendingVoice, PendingVoiceBubbleProps, VoiceMessageButtonProps, VoiceMessageHandle } from './config';\nexport type { CameraSheetProps, VoiceRecorderHook, VoiceRecorderOptions } from './config';\nexport { voiceClock } from './config';\nexport { ChatTextProvider } from './labels';\nexport type { CommandMention, CommandMentions } from './config';\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 { BlockActions } from '../button/actions';\nexport { useChatDraft } from './use-chat-draft';\nexport { useConversations } from './use-conversations';\nexport type { ChatController, ChatSnapshot } from '@hachej/boring-ui-kit/native-chat';\nexport { MessageText, VoiceBubble } from './message';\nexport { useTranscript } from './session';\nexport type { BlockAction, BlockIcon } from '../button/actions';\nexport type { AudioUrlRead, VoiceMessageConfig, AttachmentsConfig, ConversationItem, ConversationsConfig, EffortConfig, FileKind, MentionResult, MentionsConfig, ModelConfig, ModelRef, PasteToFile, PreviewRead, ReplyRef, SlashApi, SlashCommand, SlashConfig, SlashSkill, UploadResult } from './config';\n\nexport interface ChatTimeline {\n readonly count: number;\n readonly render: (context: { readonly focusComposer: () => void; readonly openFile?: ((path: string) => void) | undefined; readonly pendingVoice?: ReactNode }) => ReactNode;\n readonly afterSend?: () => void;\n}\n\nexport interface PiChatProps extends ChatFeatureProps {\n /** Optional host transcript. Absent: native rows, history and scroll behavior are unchanged. */\n readonly timeline?: ChatTimeline | undefined;\n /** Authorized host errors may provide their own message. */\n readonly renderConnectionError?: ((error: unknown) => ReactNode) | undefined;\n readonly controller: ChatController;\n /** Where this chat is shown, reported with each submit (advisory, never authority). `docked` by default; a host that floats or tucks the same chat in a drawer says `floating`. */\n readonly placement?: ChatPlacement | undefined;\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 /** `false` lets the host supply one shared header around chat and viewers. Default true. */\n readonly header?: boolean;\n readonly className?: string;\n /** Pad the chat's bottom by how far it reaches below the visual viewport (the phone's soft keyboard). Default true; `false` for a host that already follows the keyboard itself, such as a bottom drawer whose lower part sits off screen by design. */\n readonly keyboardInset?: boolean;\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 /** Host controls at the left edge of the composer bar, before \"+\" (for example an app's own menu). Absent: nothing changes. */\n readonly composerStart?: ReactNode;\n /** With `voiceMessage`: the microphone takes Send's place while there is nothing to send, and Send comes back with the first character or file (WhatsApp's composer). Default false. */\n readonly voiceReplacesSend?: boolean;\n /** Hold-to-send one retained audio upload plus transcript. Omit to keep the existing composer unchanged. */\n readonly voiceMessage?: VoiceMessageConfig | undefined;\n /** Local voice rows owned by the shell's shared microphone; rendered with the native transcript, never the composer. */\n readonly pendingVoice?: readonly PendingVoice[] | undefined;\n /** Host content under the composer (for example a collapsed list of the person's requests). Absent: nothing is shown. */\n readonly belowComposer?: 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 /** The phone camera's Take a photo entry for an `onFiles` chat (`CameraSheet` of `pi-chat-attachments`); with `attachments`, set `attachments.Camera` instead. */\n readonly camera?: AttachmentsConfig['Camera'];\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 /** Empty desktop composition. `centered` puts the composer beneath the greeting; `docked` keeps the former layout. Phones always dock it. */\n readonly emptyLayout?: 'centered' | 'docked';\n /** Two-row reference composer by default. Explicitly select `pill`, `stacked` or `inline` for the earlier layouts. */\n readonly composerLayout?: ComposerProps['layout'];\n /** A conversation title, separate from the agent name, with optional click-to-rename. */\n readonly conversationTitle?: string | undefined;\n readonly onRenameTitle?: ((title: string) => void | Promise<void>) | undefined;\n /** Show the header connection badge. Default false; connection state still controls actions and failures stay visible. */\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;\ntype TranscriptRow = { readonly key: string; readonly kind: 'native'; readonly row: Row }\n | { readonly key: string; readonly kind: 'voice'; readonly voice: PendingVoice };\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 // A new controller or a new voice recorder hook starts a fresh session, so the recorder's hooks keep their order.\n const recorder = props.voiceMessage?.useRecorder ?? noVoice;\n const [mount, setMount] = useState({ controller: props.controller, recorder, sequence: 0 });\n if (mount.controller !== props.controller || mount.recorder !== recorder) setMount({ controller: props.controller, recorder, sequence: mount.sequence + 1 });\n const text = useMergedText(props.labels, props.icons, chatTextFor(props.locale));\n return <ChatTextProvider value={text}><MessageScrollerProvider key={mount.sequence} autoScroll><PiChatSession {...props} activeController={active} /></MessageScrollerProvider></ChatTextProvider>;\n}\n\nfunction ConnectionBadge({ kind }: { readonly kind: string }) {\n const { labels } = useChatText();\n const live = kind === 'connected';\n return <Badge variant={live ? 'outline' : 'secondary'} data-testid=\"connection\" data-state={kind} role=\"status\">\n {live ? labels.live : kind === 'connecting' || kind === 'idle' ? labels.connecting : labels.reconnecting}\n </Badge>;\n}\n\n/** No voice message: without `voiceMessage.useRecorder` (from `pi-chat-voice`) the chat records nothing. */\nconst silent: VoiceMessageHandle = { canStart: false, phase: 'idle', seconds: 0, captureSequence: 0, problem: undefined, pending: [], start: () => {}, finish: () => {}, cancel: () => {} };\nconst noVoice: VoiceRecorderHook = () => silent;\n/** A shell's pending voice row when no `voiceMessage.Bubble` is plugged in: nothing to draw. */\nfunction NoVoiceBubble(_: { readonly pending: PendingVoice | undefined }) { return null; }\n\nfunction PiChatSession({ controller, placement = 'docked', mode = 'expert', presentation, className, keyboardInset = true, actions, avatar, headerActions, header = true, decisions, controls, tools, composerStart, voiceReplacesSend = false, voiceMessage, pendingVoice, belowComposer, composerChips, composerHeader, composerHasContent, onPickFiles, timeline, renderConnectionError, headerStart, renderEntry, hideLine, displayLimit, historicalMessageActions, unread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions,\n onOpenImage, onCopy, onComposerKeyDown, onFiles, camera, fileAccept = 'image/*', emptyState, suggestions, messageActions, replyFooter, slash, mentions, attachments, model, effort, artifacts, conversations, historyList = true, showHistory = true, showConnectionStatus = false, emptyLayout = 'centered', composerLayout = 'card', conversationTitle, onRenameTitle, feedback, draftKey, onOpenFile, fileChanges, pasteToFile, activeController: active }: PiChatProps & { readonly activeController: { readonly current: ChatController } }) {\n const { labels, icons } = useChatText();\n useChatDraft(controller, draftKey);\n useChatPlacement(controller, placement);\n const { title } = labels;\n const [pickingConversation, setPickingConversation] = useState(false);\n const scrollToBottom = useRef<() => void>(() => {});\n const voice = (voiceMessage?.useRecorder ?? noVoice)({ controller, config: voiceMessage, attachments, labels, activeController: active, admitted: () => timeline ? timeline.afterSend?.() : scrollToBottom.current() });\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, presentation, actions, renderEntry, hideLine, historicalMessageActions, unread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, onFiles, camera, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => timeline ? timeline.afterSend?.() : scrollToBottom.current(), feedback, rowExtras, onOpenFile, fileChanges, pasteToFile, composerChips, composerHeader, composerHasContent, onPickFiles });\n const { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, error, act, textarea, rowContext, composer, files, loading } = session;\n const voiceRows = pendingVoice ?? voice.pending;\n const VoiceButton = voiceMessage?.Button, VoiceBubbleView = voiceMessage?.Bubble ?? NoVoiceBubble;\n const transcriptRows: readonly TranscriptRow[] = [\n ...rows.map(row => ({ key: row.key, kind: 'native' as const, row })),\n ...voiceRows.map(voice => ({ key: `voice:${voice.id}`, kind: 'voice' as const, voice })),\n ];\n const pendingRows = voiceRows.map(item => <MessageScrollerItem key={`voice:${item.id}`} messageId={`voice:${item.id}`} data-local-voice=\"\" className=\"[content-visibility:visible]\"><VoiceBubbleView pending={item} /></MessageScrollerItem>);\n const empty = (timeline ? timeline.count === 0 : session.empty) && voiceRows.length === 0;\n const conversationFiles = new Map(files.map(file => [file.id, file]));\n for (const item of voiceRows) if (item.path && !conversationFiles.has(`file:${item.path}`)) {\n const path = item.path, open = onOpenFile ?? mentions?.open;\n conversationFiles.set(`file:${path}`, { id: `file:${path}`, name: displayName(path), open: open ? () => open(path) : undefined });\n }\n const { pinned, retry } = derived;\n const showPreparing = preparing({ natural: session.natural, waitingForAnswer, run: derived.run, retry, compacting: derived.compacting, stop: state.stop });\n const transcript = useTranscript(transcriptRows, displayLimit ?? PAGE, MORE, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier }, displayLimit === undefined);\n const { stick, hidden } = transcript;\n scrollToBottom.current = () => { transcript.reset(); void stick.scrollToBottom('instant'); };\n const visible = transcript.visible(transcriptRows);\n const { barStart, barNote } = session.pickers();\n const connectionKind = state.connection.kind;\n const [renamingTitle, setRenamingTitle] = useState(false);\n const [titleError, setTitleError] = useState(false);\n const page = useRef<HTMLElement>(null);\n useEffect(() => {\n const viewport = globalThis.visualViewport;\n if (!keyboardInset || !viewport) return;\n const element = page.current;\n if (!element) return;\n const measure = () => element.style.setProperty('--composer-keyboard-inset', `${Math.max(0, element.getBoundingClientRect().bottom - viewport.height - viewport.offsetTop)}px`);\n const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure);\n observer?.observe(element);\n measure(); viewport.addEventListener('resize', measure); viewport.addEventListener('scroll', measure);\n return () => { observer?.disconnect(); viewport.removeEventListener('resize', measure); viewport.removeEventListener('scroll', measure); };\n }, []);\n\n return <section ref={page} data-boring=\"pi-chat\" data-connection={connectionKind} data-empty={empty && !loading ? emptyLayout : undefined} aria-label={title} className={cn('pi-chat relative box-border flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-background text-foreground ', keyboardInset && 'max-[641px]:pb-(--composer-keyboard-inset)', empty && emptyLayout === 'centered' && 'min-[641px]:justify-center', className)}>\n {header && <header className={cn(empty && emptyLayout === 'centered' && 'min-[641px]:absolute min-[641px]:inset-x-0 min-[641px]:top-0', 'flex h-13 shrink-0 items-center gap-2 px-3 sm:gap-3 sm:px-4')}>\n {headerStart}\n {avatar && <Avatar data-testid=\"chat-avatar\" className=\"overflow-hidden\"><AvatarFallback className=\"*:max-h-full *:max-w-full\">{avatar}</AvatarFallback></Avatar>}\n {renamingTitle && onRenameTitle ? <form className=\"min-w-0 flex-1\" onSubmit={event => { event.preventDefault(); const value = new FormData(event.currentTarget).get('title')?.toString().trim(); if (value) { setTitleError(false); void Promise.resolve().then(() => onRenameTitle(value)).catch(() => setTitleError(true)); } setRenamingTitle(false); }}>\n <Input name=\"title\" aria-label={labels.conversationName} maxLength={200} autoFocus defaultValue={conversationTitle ?? title} onBlur={() => setRenamingTitle(false)} onKeyDown={event => { if (event.key === 'Escape') setRenamingTitle(false); }} className=\"w-full min-w-0\" />\n </form> : <h2 className=\"m-0 min-w-0 flex-1 truncate font-(family-name:--chat-display) text-sm font-medium tracking-tight max-[641px]:text-center\">{onRenameTitle ? <Button variant=\"ghost\" className=\"max-w-full\" onClick={() => setRenamingTitle(true)}><span className=\"truncate\">{conversationTitle ?? title}</span></Button> : conversationTitle ?? title}</h2>}\n {showHistory && conversations && historyList && <Button variant=\"ghost\" size=\"sm\" data-testid=\"history-open\" aria-haspopup=\"dialog\" aria-expanded={pickingConversation} disabled={state.disposed}\n onClick={() => setPickingConversation(open => !open)}><icons.history data-icon=\"inline-start\" 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 {titleError && <p role=\"alert\" className=\"m-0 shrink-0 px-4 py-2 text-sm text-destructive\">{labels.actionFailed}</p>}\n {decisions && <><div data-boring=\"required-decisions\" className=\"shrink-0 px-4 py-2\">{decisions}</div><Separator /></>}\n {pinned.length > 0 && <><aside aria-label={labels.requiredActions} className=\"shrink-0 space-y-2 px-4 py-2\">{pinned.map(card => <div key={card.key}>{card.content}</div>)}</aside><Separator /></>}\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' ? state.connection.kind === 'error' && renderConnectionError ? renderConnectionError(state.connection.error) : labels.loadFailed : labels.disconnected}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}><icons.reconnect data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.reconnect}</Button>}\n </Notice></div>}\n {pickingConversation && conversations && historyList && <ConversationHistory conversations={conversations} onClose={() => setPickingConversation(false)} />}\n\n <div data-testid=\"conversation-content\" className={cn('relative min-h-0 flex-1', empty && emptyLayout === 'centered' && 'min-[641px]:min-h-60 min-[641px]:flex-none')}>\n {timeline ? timeline.render({ focusComposer: () => textarea.current?.focus(), openFile: onOpenFile ?? mentions?.open, pendingVoice: pendingRows }) : <MessageScroller>\n <MessageScrollerViewport ref={stick.scrollRef} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"min-h-0 flex-1 scrollbar-thumb-border scrollbar-track-transparent\">\n {!empty && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div className=\"mx-auto w-full max-w-[720px] px-4 pt-6 pb-8 sm:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\">\n {visible.map(item => <MessageScrollerItem key={item.key} messageId={item.key} data-local-voice={item.kind === 'voice' ? '' : undefined} className=\"[content-visibility:visible]\">{item.kind === 'native' ? <RowView row={item.row} context={rowContext} /> : <VoiceBubbleView pending={item.voice} />}</MessageScrollerItem>)}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {!empty && <div data-testid=\"transcript-navigation\" className=\"flex h-12 shrink-0 items-center justify-center\">{!stick.isAtBottom && <MessageScrollerButton className=\"static inset-auto translate-x-0\" data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={transcript.reset}><icons.jumpToLatest aria-hidden=\"true\" /></MessageScrollerButton>}</div>}\n </MessageScroller>}\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 </div>\n\n <footer data-testid=\"composer-dock\" className=\"shrink-0 px-4 pt-2 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-6\">\n <div className=\"mx-auto w-full max-w-[720px] space-y-2\">\n <div {...(session.natural ? {} : { 'aria-live': 'polite' as const })} className=\"space-y-2 empty:hidden\">\n {session.natural ? showPreparing && <PreparingStatus>{derived.stageStatus}</PreparingStatus> : working && <p role=\"status\" data-testid=\"agent-status\" className=\"m-0 flex items-center gap-2 px-1 text-xs text-muted-foreground\">\n <Shimmer active={!waitingForAnswer}>{waitingForAnswer ? labels.waitingForAnswer : retry ? labels.retryPending : labels.agentWorking}</Shimmer></p>}\n {!session.natural && derived.compacting && <p role=\"status\" className=\"m-0 px-1 text-xs text-muted-foreground\">{labels.compacting}</p>}\n <ChatNotices natural={session.natural} state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} />\n </div>\n <div>\n <ConversationFiles files={[...conversationFiles.values()]} />\n <MessageQueue items={queued} sending={state.outbox} withdraw={actions?.withdraw} actions={queueActions} />\n {voice.problem && <p role=\"alert\" data-testid=\"voice-message-problem\">{voice.problem}</p>}\n <Composer {...composer} layout={composerLayout} placeholder={labels.placeholder === 'Message the agent…' ? `Message ${title}…` : labels.placeholder} barStart={barStart} barNote={barNote} start={composerStart}\n {...(voiceMessage && VoiceButton && voiceReplacesSend ? { barEnd: tools, whenEmpty: <VoiceButton voice={voice} primary disabled={state.disposed || !connected || state.send.kind === 'unknown'} /> }\n : { barEnd: voiceMessage && VoiceButton ? <>{tools}<VoiceButton voice={voice} disabled={state.disposed || !connected || state.send.kind === 'unknown'} /></> : tools })} />\n {belowComposer !== undefined && <div data-testid=\"below-composer\" className=\"pt-2\">{belowComposer}</div>}\n </div>\n </div>\n </footer>\n </section>;\n}\n",
169
129
  "type": "registry:component",
170
130
  "target": "components/pi-chat/pi-chat.tsx"
171
131
  },
@@ -175,18 +135,6 @@
175
135
  "type": "registry:lib",
176
136
  "target": "components/pi-chat/picker-keyboard.ts"
177
137
  },
178
- {
179
- "path": "registry/pi-chat/pickers.tsx",
180
- "content": "'use client';\n\nimport { useEffect, useState } from 'react';\nimport { ChevronDownIcon, Loader2Icon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport type { ModelEffortPickerProps, ModelRef } from './config';\nimport { useChatText } from './labels';\n\ninterface Choice { readonly section: 'model' | 'effort'; readonly value: string; readonly label: string; readonly detail?: string }\n\nconst key = (model: ModelRef) => `${model.provider}/${model.modelId}`;\n\n/**\n * One pill for the model and the thinking level (\"GPT-5 mini · Medium\") that opens the host's shadcn dropdown menu with a radio\n * group for each. Only the configured parts show; with neither there is no pill.\n */\nexport function ModelEffortPicker({ model, effort, currentModel, currentEffort, disabled, busy, onModel, onEffort }: ModelEffortPickerProps) {\n const { labels } = useChatText();\n const name = labels.effortName;\n const [open, setOpen] = useState(false);\n useEffect(() => { if (disabled) setOpen(false); }, [disabled]);\n const models: Choice[] = (model?.options ?? []).map(option => ({ section: 'model', value: key(option), label: option.label ?? option.modelId, detail: option.provider }));\n // A model or level outside the offered list is still shown, so the label never lies about what runs.\n if (model && currentModel && !models.some(choice => choice.value === key(currentModel))) models.unshift({ section: 'model', value: key(currentModel), label: currentModel.modelId, detail: currentModel.provider });\n const levels: Choice[] = (effort?.options ?? []).map(level => ({ section: 'effort', value: level, label: name(level), ...(labels.effortDetail(level) ? { detail: labels.effortDetail(level)! } : {}) }));\n if (effort && currentEffort && !levels.some(choice => choice.value === currentEffort)) levels.unshift({ section: 'effort', value: currentEffort, label: name(currentEffort) });\n if (!models.length && !levels.length) return null;\n const modelValue = currentModel ? key(currentModel) : '';\n\n const pickModel = (value: string) => {\n const option = model?.options.find(item => key(item) === value);\n if (option && value !== modelValue) onModel({ provider: option.provider, modelId: option.modelId });\n };\n const modelLabel = model ? models.find(choice => choice.value === modelValue)?.label || currentModel?.modelId || labels.model : undefined;\n const effortLabel = effort ? currentEffort ? name(currentEffort) : labels.effortDefault : undefined;\n const summary = [modelLabel, effortLabel].filter(Boolean).join(' · ');\n const description = labels.modelSummary(modelLabel, effortLabel);\n const section = (title: string, items: readonly Choice[], value: string, pick: (value: string) => void) => items.length > 0 && <DropdownMenuGroup>\n <DropdownMenuLabel>{title}</DropdownMenuLabel>\n <DropdownMenuRadioGroup value={value} onValueChange={next => pick(String(next))}>\n {items.map(choice => <DropdownMenuRadioItem key={choice.value} value={choice.value} data-testid={`composer-${choice.section}-option`} data-value={choice.value} closeOnClick className=\"max-sm:min-h-11 pointer-coarse:min-h-11\">\n <span className=\"flex min-w-0 flex-1 flex-col\"><span className=\"truncate\">{choice.label}</span>\n {choice.detail && <span className=\"truncate text-xs text-muted-foreground\">{choice.detail}</span>}</span>\n </DropdownMenuRadioItem>)}\n </DropdownMenuRadioGroup>\n </DropdownMenuGroup>;\n return <DropdownMenu open={open} onOpenChange={setOpen}>\n <div className=\"min-w-0 max-w-[65%] shrink @max-[480px]/composer:max-w-20\">\n <DropdownMenuTrigger render={<Button variant=\"ghost\" />} data-testid=\"composer-model\" data-value={modelValue} data-effort={currentEffort ?? ''} disabled={disabled} aria-label={description} title={description} className=\"min-w-0 max-w-full\">\n {busy && <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />}\n <span data-testid=\"composer-model-label\" className=\"min-w-0 truncate\">{summary}</span>\n <ChevronDownIcon data-icon=\"inline-end\" aria-hidden=\"true\" />\n </DropdownMenuTrigger>\n </div>\n <DropdownMenuContent side=\"top\" align=\"start\" aria-label={labels.modelAndEffort} data-testid=\"composer-model-menu\" className=\"w-56 max-w-[calc(100vw-1rem)]\">\n {model && section(labels.model, models, modelValue, pickModel)}\n {model && effort && models.length > 0 && levels.length > 0 && <DropdownMenuSeparator />}\n {effort && section(labels.effort, levels, currentEffort ?? '', value => { if (value !== currentEffort) onEffort(value); })}\n </DropdownMenuContent>\n </DropdownMenu>;\n}\n",
181
- "type": "registry:component",
182
- "target": "components/pi-chat/pickers.tsx"
183
- },
184
- {
185
- "path": "registry/pi-chat/question-card.tsx",
186
- "content": "'use client';\n\nimport { useEffect, useId, useRef, useState } from 'react';\nimport type { FormEvent } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { CheckIcon, CircleSlashIcon, SendIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Spinner } from '@/components/ui/spinner';\nimport { useChatText } from './labels';\n\n/** What the host answers when asked to resolve a pending question. */\nexport type AnswerOutcome = { readonly kind: 'answered' } | { readonly kind: 'denied' | 'conflict' | 'unknown-question'; readonly reason?: string };\nexport const ASK_USER_TOOL = 'ask_user';\n\n/** One question of a form: its text and its options (chips); every question of a form also takes free text. */\nexport interface FormQuestion { readonly question: string; readonly options: readonly string[] }\ninterface Question {\n readonly question: string; readonly options: readonly string[]; readonly allowFreeText: boolean;\n /** Several questions in one form (`questions`), answered together as one text. Empty for a single question. */\n readonly items: readonly FormQuestion[];\n}\n\nconst strings = (value: unknown, max: number) => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string' && item.length > 0).slice(0, max) : [];\n\n/**\n * Read `{ question, options?, allowFreeText?, questions? }` defensively: model arguments are untrusted input. Free text is allowed\n * unless `allowFreeText` is `false` (and always when there are no options). `questions` (up to 8) makes a form.\n */\nexport function parseQuestion(call: ToolCall): Question {\n const args = call.arguments as Record<string, unknown>;\n const options = strings(args['options'], 6);\n const items = Array.isArray(args['questions']) ? args['questions'].flatMap((item: unknown) => {\n const value = item !== null && typeof item === 'object' ? item as Record<string, unknown> : {};\n return typeof value['question'] === 'string' && value['question'] ? [{ question: value['question'], options: strings(value['options'], 6) }] : [];\n }).slice(0, 8) : [];\n return { question: typeof args['question'] === 'string' ? args['question'] : '', options, allowFreeText: args['allowFreeText'] !== false || options.length === 0, items };\n}\n\n/** The answers of a form as the one text the agent reads: each question, then its answer, or `skipped` when left blank. */\nexport function formAnswer(items: readonly FormQuestion[], values: readonly string[], skipped: string): string {\n return items.map((item, i) => `${i + 1}. ${item.question}\\n→ ${values[i]?.trim() || skipped}`).join('\\n\\n');\n}\n\n/** The answers of a `formAnswer` text, one per question; undefined when the text does not have that shape (then it is shown whole). */\nfunction splitFormAnswer(items: readonly FormQuestion[], text: string): readonly string[] | undefined {\n const values: string[] = [];\n let at = 0;\n for (let i = 0; i < items.length; i++) {\n const head = `${i + 1}. ${items[i]!.question}\\n→ `;\n if (!text.startsWith(head, at)) return undefined;\n at += head.length;\n const end = i + 1 < items.length ? text.indexOf(`\\n\\n${i + 2}. ${items[i + 1]!.question}\\n→ `, at) : text.length;\n if (end < 0) return undefined;\n values.push(text.slice(at, end));\n at = end + 2;\n }\n return values;\n}\n\n/** The answered text from a result such as `{\"kind\":\"answered\",\"answer\":\"...\"}`; undefined when the call did not produce an answer. */\nexport function parseAnswer(result: ToolResultMessage | undefined): { readonly answer: string } | { readonly failure: string } | undefined {\n if (!result) return undefined;\n const text = result.content.map(part => part.type === 'text' ? part.text : '').join('');\n if (!result.isError) {\n try {\n const value: unknown = JSON.parse(text);\n if (value !== null && typeof value === 'object' && (value as { kind?: unknown }).kind === 'answered' && typeof (value as { answer?: unknown }).answer === 'string') return { answer: (value as { answer: string }).answer };\n } catch { /* fall through to the failure text */ }\n }\n return { failure: text || 'The question was not answered.' };\n}\n\n\n/**\n * Human-in-the-loop question: writing (still generated, not answerable), pending (options and free text), then answered with the chosen\n * answer. With `questions` it is one form: each question has its option chips and a free-text field, and Answer sends one text\n * (`formAnswer`). It needs only a tool call and an `answer` callback, so a host can show it outside the chat too (for example in a\n * popover answering a request of its own); wrap it in `ChatTextProvider` for the chat's words there.\n */\nexport interface QuestionCardProps {\n readonly call: ToolCall;\n readonly result: ToolResultMessage | undefined;\n readonly questionId: string | undefined;\n readonly answer?: ((questionId: string, answer: string) => Promise<AnswerOutcome>) | undefined;\n /** The call is still being generated by the model: it cannot be answered yet. */\n readonly live?: boolean;\n}\nexport function QuestionCard({ call, result, answer, questionId, live = false }: QuestionCardProps) {\n const { labels } = useChatText();\n const question = parseQuestion(call);\n const settled = parseAnswer(result);\n const [sent, setSent] = useState<string | undefined>();\n const [sending, setSending] = useState<string | undefined>();\n const [refusal, setRefusal] = useState<string | undefined>();\n const [text, setText] = useState('');\n const [values, setValues] = useState<readonly string[]>([]);\n const formId = useId();\n const setValue = (i: number, value: string) => setValues(previous => question.items.map((_, j) => j === i ? value : previous[j] ?? ''));\n const mounted = useRef(true);\n useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\n const current = settled && 'answer' in settled ? settled.answer : sent;\n // `pending` means the question waits on the person; `writing` means the model is still generating it (options may be partial).\n const state = settled ? ('failure' in settled ? 'failed' : 'answered') : sent !== undefined ? 'sent' : live ? 'writing' : 'pending';\n const open = state === 'pending' || state === 'writing';\n\n async function submit(value: string) {\n const trimmed = value.trim();\n if (!answer || !questionId || live || !trimmed || sending !== undefined) return;\n setRefusal(undefined); setSending(trimmed);\n try {\n const outcome = await answer(questionId, trimmed);\n if (outcome.kind === 'answered') setSent(trimmed);\n else setRefusal(outcome.reason || labels.answerRefused(outcome.kind));\n } catch (cause) { setRefusal(cause instanceof Error ? cause.message : labels.answerNotSent); }\n finally { if (mounted.current) setSending(undefined); }\n }\n const onSubmit = (event: FormEvent) => { event.preventDefault(); void submit(text); };\n const onSubmitForm = (event: FormEvent) => { event.preventDefault(); if (values.some(value => value.trim())) void submit(formAnswer(question.items, values, labels.skipped)); };\n const busy = sending !== undefined || live;\n\n const unavailable = !answer || !questionId || busy;\n // `m-0`: a host without a global preflight keeps the paragraph margin of ItemDescription.\n const wrap = 'm-0 line-clamp-none [overflow-wrap:anywhere]';\n\n if (!open) {\n const values = question.items.length > 0 && current !== undefined ? splitFormAnswer(question.items, current) : undefined;\n return <Item role=\"group\" data-testid=\"question-card\" data-state={state} data-call-id={call.id} aria-label={labels.question}>\n <ItemMedia variant=\"icon\">\n {state === 'answered' ? <CheckIcon role=\"img\" aria-label={labels.answered} /> : state === 'sent' ? <Spinner aria-label={labels.waitingForAgent} /> : <CircleSlashIcon role=\"img\" aria-label={labels.notAnswered} />}\n </ItemMedia>\n <ItemContent className=\"min-w-0\">\n <ItemDescription data-testid=\"question-text\" className={wrap}>{question.question || labels.askedQuestion}</ItemDescription>\n {values ? <div className=\"flex min-w-0 flex-col gap-2\">{question.items.map((item, i) => <div key={i} data-testid=\"question-answer-item\" className=\"flex min-w-0 flex-col gap-1\">\n <ItemDescription className={wrap}>{item.question}</ItemDescription>\n {values[i] === labels.skipped\n ? <ItemDescription data-testid=\"question-answer\" data-skipped=\"true\" className={wrap}>{values[i]}</ItemDescription>\n : <ItemTitle data-testid=\"question-answer\" className={wrap}>{values[i]}</ItemTitle>}\n </div>)}</div>\n : current !== undefined && <ItemTitle className={wrap}><span className=\"sr-only\">{labels.yourAnswer} </span><span data-testid=\"question-answer\" className=\"whitespace-pre-line\">{current}</span></ItemTitle>}\n {state === 'failed' && settled && 'failure' in settled && <ItemDescription role=\"alert\" className={wrap}>{settled.failure}</ItemDescription>}\n </ItemContent>\n </Item>;\n }\n\n const option = (value: string, selected: boolean, onClick: () => void) => <Button key={value} variant={selected ? 'secondary' : 'outline'} data-testid=\"question-option\" data-option={value}\n aria-pressed={question.items.length > 0 ? selected : undefined} disabled={unavailable} onClick={onClick} className=\"h-auto min-h-8 w-full justify-start whitespace-normal text-start\">\n {selected && <CheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}{value}\n </Button>;\n\n return <Card role=\"group\" data-testid=\"question-card\" data-state={state} data-call-id={call.id} aria-label={labels.question}>\n <CardHeader>\n <CardDescription>{labels.questionForYou}</CardDescription>\n <CardTitle data-testid=\"question-text\" className=\"[overflow-wrap:anywhere]\">{question.question || labels.askedQuestion}</CardTitle>\n </CardHeader>\n <CardContent className=\"flex flex-col gap-4\">\n {question.items.length > 0 ? <form data-testid=\"question-form\" onSubmit={onSubmitForm} className=\"flex flex-col gap-4\">\n {question.items.map((item, i) => <div key={i} role=\"group\" aria-labelledby={`${formId}-${i}`} data-testid=\"question-item\" className=\"flex min-w-0 flex-col gap-2\">\n <p id={`${formId}-${i}`} className=\"m-0 [overflow-wrap:anywhere]\">{i + 1}. {item.question}</p>\n {item.options.length > 0 && <div role=\"group\" aria-label={labels.answerOptions} className=\"flex flex-col gap-2\">\n {item.options.map(value => option(value, values[i] === value, () => setValue(i, values[i] === value ? '' : value)))}\n </div>}\n <Input data-testid=\"question-input\" value={item.options.includes(values[i] ?? '') ? '' : values[i] ?? ''} maxLength={2000} disabled={unavailable}\n placeholder={item.options.length ? labels.ownAnswer : labels.typeAnswer} aria-label={`${labels.yourAnswerInput} ${i + 1}`} onChange={event => setValue(i, event.currentTarget.value)} />\n </div>)}\n <div><Button type=\"submit\" data-testid=\"question-submit\" disabled={unavailable || !values.some(value => value.trim())}><SendIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.answer}</Button></div>\n </form> : <>\n {question.options.length > 0 && <div role=\"group\" aria-label={labels.answerOptions} className=\"flex flex-col gap-2\">\n {question.options.map(value => option(value, false, () => { void submit(value); }))}\n </div>}\n {question.allowFreeText && <form onSubmit={onSubmit} className=\"flex gap-2\">\n <Input data-testid=\"question-input\" value={text} maxLength={2000} disabled={unavailable} placeholder={question.options.length ? labels.ownAnswer : labels.typeAnswer}\n aria-label={labels.yourAnswerInput} onChange={event => setText(event.currentTarget.value)} />\n <Button type=\"submit\" data-testid=\"question-submit\" disabled={unavailable || !text.trim()}><SendIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.answer}</Button>\n </form>}\n </>}\n {live && <CardDescription>{labels.stillWritingQuestion}</CardDescription>}\n {!answer && !live && <CardDescription>{labels.answeringUnavailable}</CardDescription>}\n {refusal && <Alert variant=\"destructive\" data-testid=\"question-refusal\"><AlertDescription>{refusal}</AlertDescription></Alert>}\n </CardContent>\n </Card>;\n}\n",
187
- "type": "registry:component",
188
- "target": "components/pi-chat/question-card.tsx"
189
- },
190
138
  {
191
139
  "path": "registry/pi-chat/queue.tsx",
192
140
  "content": "'use client';\n\nimport { useState } from 'react';\nimport type { ChatDraft } from '@hachej/boring-ui-kit/native-chat';\nimport { CornerDownRightIcon, EllipsisIcon, ListEndIcon, PencilIcon, RouteIcon, Trash2Icon } from 'lucide-react';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { Card, CardContent } from '@/components/ui/card';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Spinner } from '@/components/ui/spinner';\nimport { TraceIcon, TraceLine, TraceTitle } from '@/components/ui/trace';\nimport { Button } from '@/components/ui/button';\nimport { isFileBlock, type QueuedMessage } from './rows';\nimport { defaultChatLabels, useChatText } from './labels';\n\nexport type { QueuedMessage } from './rows';\n\n/** Display text of a queued submission: the text parts of its content, plus a count of images. */\nexport function queuedText(item: QueuedMessage, imagesLabel: (count: number) => string = defaultChatLabels.images): string {\n const content = item.content as unknown;\n if (typeof content === 'string') return content;\n if (!Array.isArray(content)) return '';\n const parts = content as readonly { type?: unknown; text?: unknown }[];\n const text = parts.flatMap(part => part?.type === 'text' && typeof part.text === 'string' && !isFileBlock(part.text) ? [part.text] : []).join('\\n');\n const images = parts.filter(part => part?.type === 'image').length;\n return images ? `${text}${text ? '\\n' : ''}${imagesLabel(images)}` : text;\n}\n\n/** What a queued message can be taken back for. Both take the message out of the queue first and throw when it is too late. */\nexport interface QueueActions {\n /** Take it out of the queue and put its text (and images) back in the composer. */\n readonly edit?: ((item: QueuedMessage) => Promise<void>) | undefined;\n /** Take it out of the queue and send it into the running turn now. */\n readonly steer?: ((item: QueuedMessage) => Promise<void>) | undefined;\n}\n\n/** The \"…\" button of one queued message: a small menu of the secondary actions (Edit). */\nfunction QueueEditAction({ disabled, onEdit }: { readonly disabled: boolean; readonly onEdit: () => void }) {\n const { labels } = useChatText();\n return <DropdownMenu>\n <DropdownMenuTrigger render={<Button variant=\"ghost\" size=\"icon\" />} data-testid=\"queue-more\" aria-label={labels.moreActions} title={labels.more} disabled={disabled}>\n <EllipsisIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n </DropdownMenuTrigger>\n <DropdownMenuContent side=\"top\" align=\"end\" data-testid=\"queue-menu\">\n <DropdownMenuGroup><DropdownMenuItem data-testid=\"queue-edit\" onClick={onEdit}><PencilIcon aria-hidden=\"true\" />{labels.edit}</DropdownMenuItem></DropdownMenuGroup>\n </DropdownMenuContent>\n </DropdownMenu>;\n}\n\n/**\n * Messages sent while the agent was working, waiting for Pi to place them, drawn as a slim tab tucked behind the top edge of the composer\n * (render it directly above `Composer`; the composer overlaps its bottom). One row per message: icon, text, Steer, remove and a \"…\" menu\n * (Edit). The choice is made here, per message, never in the composer. Rows leave on their own once Pi starts them. `sending` are messages\n * sent from the composer that wait for the previous send to be confirmed (the controller's `outbox`): shown last, without actions.\n */\nexport function MessageQueue({ items, sending = [], withdraw, actions }: {\n readonly items: readonly QueuedMessage[];\n readonly sending?: readonly ChatDraft[] | undefined;\n readonly withdraw?: ((id: QueuedMessage['id']) => Promise<unknown>) | undefined;\n readonly actions?: QueueActions | undefined;\n}) {\n const { labels } = useChatText();\n const [failed, setFailed] = useState<string | undefined>();\n const [busy, setBusy] = useState<ReadonlySet<string>>(new Set());\n if (!items.length && !sending.length) return null;\n async function run(item: QueuedMessage, work: () => Promise<unknown>) {\n const id = String(item.id);\n setFailed(undefined); setBusy(current => new Set(current).add(id));\n try { await work(); }\n catch (cause) { setFailed(cause instanceof Error ? cause.message : labels.queueFailed); }\n finally { setBusy(current => { const next = new Set(current); next.delete(id); return next; }); }\n }\n const remove = (item: QueuedMessage) => run(item, async () => {\n const outcome = await withdraw!(item.id);\n if (outcome === 'already_placed' || outcome === 'settled') throw new Error(labels.tooLateToRemove);\n });\n return <section data-testid=\"queue\" aria-label={labels.queuedMessages} className=\"relative mx-3 -mb-3 pb-3\">\n <Card size=\"sm\"><CardContent><ul className=\"m-0 flex list-none flex-col p-0\">\n {items.map(item => {\n const working = busy.has(String(item.id));\n const steering = item.mode === 'steer';\n return <li key={String(item.id)} data-testid=\"queue-item\" data-mode={item.mode} className=\"flex min-h-10 items-center gap-1.5\">\n <TraceLine status=\"pending\" className=\"min-w-0 flex-1\">\n <TraceIcon>{steering ? <RouteIcon /> : <ListEndIcon />}</TraceIcon>\n <TraceTitle><span className=\"sr-only\">{steering ? labels.steering : labels.queued}: </span><span data-testid=\"queue-text\">{queuedText(item, labels.images)}</span></TraceTitle>\n </TraceLine>\n {!steering && actions?.steer && <Button variant=\"ghost\" size=\"icon\" data-testid=\"queue-steer\" aria-label={labels.steer} title={labels.steerHint} disabled={working} onClick={() => { void run(item, () => actions.steer!(item)); }}>\n <CornerDownRightIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"queue-cancel\" aria-label={labels.removeQueued} title={labels.removeHint} disabled={!withdraw || working} onClick={() => { void remove(item); }}>\n <Trash2Icon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n {actions?.edit && <QueueEditAction disabled={working} onEdit={() => { void run(item, () => actions.edit!(item)); }} />}\n </li>;\n })}\n {sending.map((draft, index) => <li key={`sending-${index}`} data-testid=\"queue-sending\" className=\"flex min-h-10 items-center gap-1.5\">\n <TraceLine status=\"running\" className=\"min-w-0 flex-1\">\n <TraceIcon><Spinner aria-hidden=\"true\" /></TraceIcon>\n <TraceTitle data-testid=\"queue-text\">{draft.text}{draft.attachments.length ? `${draft.text ? '\\n' : ''}${labels.images(draft.attachments.length)}` : ''}</TraceTitle>\n </TraceLine>\n <span className=\"shrink-0 px-1 text-xs text-muted-foreground\">{labels.sending}</span>\n </li>)}\n </ul></CardContent></Card>\n {failed && <Alert variant=\"destructive\" data-testid=\"queue-error\"><AlertDescription>{failed}</AlertDescription></Alert>}\n </section>;\n}\n",
@@ -195,13 +143,13 @@
195
143
  },
196
144
  {
197
145
  "path": "registry/pi-chat/rows.ts",
198
- "content": "import type { ReactNode } from 'react';\nimport type { ConversationView, EntryRecord, InboxItem, JsonObject } from '@earendil-works/pi-durable';\nimport type { JsonValue } from '@earendil-works/chord';\nimport type { AssistantMessage, ImageContent, Message, ToolCall, ToolResultMessage, UserMessage } from '@earendil-works/pi-ai';\nimport type { ToolEntry, ToolStatus } from './tool';\nimport { ASK_USER_TOOL } from './question-card';\nimport { parseDelegateDetails } from './subagent-card';\nimport type { SubagentDetails } from './subagent-card';\nimport { PRESENT_TOOL, artifactKey, detectArtifact, pendingId, pendingTitle } from './artifact';\nimport type { ArtifactDescriptor, ArtifactsConfig } from './artifact';\n\n/** The host adds file content for `@path` mentions as parts that start like this; the message already shows the mention itself. */\nexport const isFileBlock = (text: string): boolean => text.startsWith('<file path=\"');\n\n/** A marked app context stays in the native message; presentation collapses only a valid leading target block to a chip. */\nexport function targetContent(text: string): { readonly target: JsonObject; readonly speech: string; readonly label: string } | undefined {\n if (!text.startsWith('<target>')) return;\n const end = text.indexOf('</target>');\n if (end < 0 || text.slice(end + 9, end + 11) !== '\\n\\n') return;\n const json = text.slice(8, end);\n if (new TextEncoder().encode(json).length > 1024) return;\n try {\n const target = object(JSON.parse(json));\n if (!target) return;\n const label = Object.entries(target).map(([key, value]) => typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? `${key}: ${value}` : key).join(' · ') || 'Target';\n return { target, speech: text.slice(end + 11), label };\n } catch { return; }\n}\n\n/** A submission waiting in the native inbox (steer or follow-up); `queuedMessages` reads them from the view, the queue renders them. */\nexport type QueuedMessage = Extract<InboxItem, { readonly mode: 'steer' | 'followUp' }>;\n\n/*\n * The one message model is the native Pi view: entries (with their model messages) plus the live `pi.live` document.\n * This module turns a view into display rows with these semantics: tool calls are matched with tool results, expert mode hides successful tool details, the live\n * generation is appended as a streaming row whose key matches the row its committed message will take.\n */\n/** `required` pins the card above the transcript. `inline` keeps an entry/tool card in place in expert mode too;\n * inline entries remain part of the answer. Other custom tool cards show only in developer mode or on failure. */\nexport interface ChatCard { readonly content: ReactNode; readonly required?: boolean; readonly inline?: boolean; /** Host stage wording for the single natural status; null clears it. */ readonly status?: ReactNode }\nexport type Mode = 'expert' | 'developer';\nexport type StreamPart = { readonly type: 'text'; readonly text: string } | { readonly type: 'thinking'; readonly thinking: string } | ToolCall;\n\nexport type Part =\n | { readonly kind: 'card'; readonly key: string; readonly content: ReactNode }\n | { readonly kind: 'text'; readonly key: string; readonly text: string }\n | { readonly kind: 'thinking'; readonly key: string; readonly text: string; readonly streaming: boolean }\n | { readonly kind: 'question'; readonly key: string; readonly call: ToolCall; readonly result: ToolResultMessage | undefined; readonly live: boolean; readonly questionId: string | undefined }\n | { readonly kind: 'approval'; readonly key: string; readonly call: ToolCall; readonly result: ToolResultMessage | undefined; readonly live: boolean; /** The bound question ID the answer must use (as for `question`). */ readonly questionId: string | undefined; /** The one-line summary `requireApproval` published for this call. */ readonly summary: string; /** The decision it recorded, once made. */ readonly decision?: ApprovalDecision; /** The call's own step, shown in the activity block once it has a result. */ readonly entry?: ToolEntry }\n | { readonly kind: 'subagent'; readonly key: string; readonly call: ToolCall; readonly result: ToolResultMessage | undefined; /** The `DelegateDetails` the call published (live) or finished with, settled from its result. */ readonly details: SubagentDetails; /** The call's own step, shown in the activity block once it has a result. */ readonly entry?: ToolEntry }\n | { readonly kind: 'artifact'; readonly key: string; readonly call: ToolCall; readonly state: 'presenting' | 'ready'; readonly artifact: ArtifactDescriptor | undefined; readonly title: string | undefined; /** Set when the host's `detect` found the descriptor: the call stays a step of the activity block too. */ readonly entry?: ToolEntry }\n | { readonly kind: 'tool'; readonly key: string; readonly entry: ToolEntry };\n\nexport type Row = (\n | { readonly key: string; readonly type: 'user'; readonly message: UserMessage }\n | { readonly key: string; readonly type: 'assistant'; readonly parts: readonly Part[]; /** Native result images remain visible when their tool chrome is omitted. */ readonly images?: readonly ImageContent[]; readonly streaming: boolean; /** The turn is still running (the last row while a run is active). */ readonly active?: boolean; readonly stopReason?: AssistantMessage['stopReason']; readonly errorMessage?: string; readonly text: string; /** When the reply was written (the last round of a merged turn), in ms. Absent while it streams. */ readonly timestamp?: number; /** The model that wrote the last round of the reply. */ readonly model?: { readonly provider: string; readonly modelId: string }; /** Sum of the merged rounds' native `durationMs`, when recorded. */ readonly durationMs?: number; /** The native entry of its last committed round: where a fork of the conversation after this reply starts. */ readonly entryId?: string }\n | { readonly key: string; readonly type: 'orphan-result'; readonly message: ToolResultMessage }\n | { readonly key: string; readonly type: 'system'; readonly text: string }\n | { readonly key: string; readonly type: 'event'; readonly label: string }\n | { readonly key: string; readonly type: 'card'; readonly card: ChatCard }) & { readonly historical?: boolean };\n\nexport function object(value: JsonValue | undefined): JsonObject | undefined {\n return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;\n}\nexport function streamParts(value: JsonValue | undefined): StreamPart[] {\n if (!Array.isArray(value)) return [];\n return value.flatMap((item): StreamPart[] => {\n const part = object(item);\n if (part?.['type'] === 'text' && typeof part['text'] === 'string') return [{ type: 'text', text: part['text'] }];\n if (part?.['type'] === 'thinking' && typeof part['thinking'] === 'string') return [{ type: 'thinking', thinking: part['thinking'] }];\n const args = object(part?.['arguments']);\n if (part?.['type'] === 'toolCall' && typeof part['id'] === 'string' && typeof part['name'] === 'string' && args) return [{ type: 'toolCall', id: part['id'], name: part['name'], arguments: args }];\n return [];\n });\n}\n\nexport function queuedMessages(view: ConversationView | undefined): QueuedMessage[] {\n const items = object(view?.docs['pi.inbox'])?.['items'];\n if (!Array.isArray(items)) return [];\n return items.filter((item): item is QueuedMessage => {\n const value = object(item as JsonValue);\n return value !== undefined && (value['mode'] === 'steer' || value['mode'] === 'followUp') && value['id'] !== undefined;\n });\n}\n\nexport interface Derived {\n readonly rows: readonly Row[];\n /** Required entry and tool cards: pinned above the transcript. */\n readonly pinned: readonly { readonly key: string; readonly content: ReactNode }[];\n readonly run: JsonObject | undefined;\n readonly retry: JsonObject | undefined;\n readonly compacting: boolean;\n readonly stageStatus?: ReactNode;\n}\nexport interface DeriveOptions {\n readonly mode: Mode;\n /** Complete assistant lines to omit. With live text, the unfinished last line waits for its newline or settlement. */\n readonly hideLine?: ((line: string) => boolean) | undefined;\n readonly renderEntry: ((entry: EntryRecord) => ChatCard | undefined) | undefined;\n readonly renderTool: ((call: ToolCall, result: ToolResultMessage | undefined) => ChatCard | undefined) | undefined;\n readonly groupTool: ((call: ToolCall, result: ToolResultMessage) => boolean) | undefined;\n /** Turn artifact tool results into cards (`detect` recognises descriptors the tool result does not carry itself). */\n readonly artifacts?: { readonly detect?: ArtifactsConfig['detect'] | undefined } | undefined;\n /** `natural` shows only what a non-developer reads: their messages, the assistant's prose and the question, approval and artifact cards. Tool steps, reasoning, system rows and raw error text are left out of the rows (the entries stay as they are). */\n readonly presentation?: 'natural' | undefined;\n}\n\n/** Key of `{ summary, decision? }` in a gated call's details: `APPROVAL_DETAILS` of `@hachej/boring-agent/approval` (a contract test keeps them equal). */\nexport const APPROVAL_DETAILS = 'boring.approval';\nexport type ApprovalDecision = 'approved' | 'denied' | 'cancelled';\n/** The approval record a gated call publishes: its summary and, once made, the decision (`approved`, `denied` or `cancelled`). */\nconst approvalRecord = (details: unknown): { summary: string; decision?: ApprovalDecision } | undefined => {\n const record = object(object(details as JsonValue | undefined)?.[APPROVAL_DETAILS]);\n const summary = record?.['summary'], decision = record?.['decision'];\n if (typeof summary !== 'string' || !summary.trim()) return undefined;\n return { summary: summary.trim(), ...(decision === 'approved' || decision === 'denied' || decision === 'cancelled' ? { decision } : {}) };\n};\n\nfunction visibleText(text: string, streaming: boolean, hideLine: DeriveOptions['hideLine']): string {\n if (!hideLine) return text;\n const lines = text.match(/[^\\n]*\\n|[^\\n]+$/g) ?? [];\n return lines.filter(line => (!streaming || line.endsWith('\\n')) && !hideLine(line.replace(/\\r?\\n$/, ''))).join('');\n}\nconst textOf = (parts: readonly Part[]): string => parts.flatMap(part => part.kind === 'text' ? [part.text] : []).join('\\n');\n\nexport function derive(view: ConversationView | undefined, options: DeriveOptions): Derived {\n const { mode, renderEntry, renderTool } = options;\n const natural = options.presentation === 'natural';\n const developer = mode === 'developer' && !natural;\n const live = object(view?.docs['pi.live']);\n const run = object(live?.['run']), generation = object(live?.['generation']);\n const liveMessage = object(generation?.['message']), retry = object(generation?.['retry']);\n const taskId = run?.['taskId'];\n\n // Pass 1: match each result to its preceding invocation. Model call IDs may be reused.\n type Item = { readonly key: string; readonly entry: EntryRecord; readonly message?: Message; readonly card?: ChatCard };\n let stageStatus: ReactNode;\n const items: Item[] = [], results = new Map<ToolCall, ToolResultMessage>(), counts = new Map<number, number>(), questionIds = new Map<ToolCall, string>();\n const pendingCalls = new Map<string, ToolCall>();\n for (const entry of view?.entries ?? []) {\n const custom = renderEntry?.(entry);\n if (custom && 'status' in custom) stageStatus = custom.status;\n const card = custom && (developer || custom.required || custom.inline) ? custom : undefined;\n if (card) items.push({ key: `entry:${entry.id}`, entry, card });\n if (!entry.model?.length) { if (!card && developer) items.push({ key: `entry:${entry.id}`, entry }); continue; }\n for (const [index, message] of entry.model.entries()) {\n if (message.role === 'assistant') for (const part of message.content) if (part.type === 'toolCall') { pendingCalls.set(part.id, part); questionIds.set(part, JSON.stringify([entry.id, part.id])); }\n if (message.role === 'toolResult') {\n const call = pendingCalls.get(message.toolCallId);\n if (call) { results.set(call, message); pendingCalls.delete(message.toolCallId); }\n }\n let key = `entry:${entry.id}:${index}`;\n if (message.role === 'assistant' && entry.byTaskId !== undefined) {\n const ordinal = counts.get(entry.byTaskId) ?? 0; counts.set(entry.byTaskId, ordinal + 1);\n key = `assistant:${entry.byTaskId}:${ordinal}`;\n }\n if (!card) items.push({ key, entry, message });\n }\n }\n const matched = new Set(results.values());\n\n const pinned: { key: string; content: ReactNode }[] = [];\n for (const item of items) if (item.card?.required) pinned.push({ key: item.key, content: item.card.content });\n\n const status = (result: ToolResultMessage | undefined): ToolStatus => result ? (result.isError ? 'failed' : 'completed') : run ? 'running' : 'unfinished';\n function toolPart(call: ToolCall, key: string, committed: boolean): Part | undefined {\n const result = results.get(call);\n if (call.name === ASK_USER_TOOL) return { kind: 'question', key, call, result, live: !committed, questionId: questionIds.get(call) };\n // A call gated by `requireApproval` identifies itself: it publishes its summary under APPROVAL_DETAILS before it asks.\n const slotDetails = result ? undefined : (Array.isArray(live?.['tools']) ? live['tools'] : []).map(object).find(slot => slot?.['callId'] === call.id)?.['details'];\n const record = approvalRecord(result ? result.details : slotDetails);\n if (record) return { kind: 'approval', key, call, result, live: !committed, questionId: questionIds.get(call), summary: record.summary, ...(record.decision ? { decision: record.decision } : {}), ...(result ? { entry: { key: `${key}:step`, call, result, status: status(result) } } : {}) };\n // A `delegate` call (or any tool publishing `DelegateDetails`) is a sub-agent card, live from its running slot.\n const delegated = parseDelegateDetails(result ? result.details : slotDetails, result);\n if (delegated) return { kind: 'subagent', key, call, result, details: delegated, ...(result ? { entry: { key: `${key}:step`, call, result, status: status(result) } } : {}) };\n if (options.artifacts) {\n // A finished call that carries a descriptor becomes a card; a running `present` shows an \"opening\" card. A failed call stays an ordinary (failed) tool step.\n const artifact = result ? detectArtifact(call, result, options.artifacts.detect) : undefined;\n if (artifact) {\n // A tool whose own result carries the descriptor is replaced by its card; a host-detected card is shown in addition to the step.\n const own = result && detectArtifact(call, result);\n return { kind: 'artifact', key, call, state: 'ready', artifact, title: artifact.title, ...(own ? {} : { entry: { key, call, result, status: status(result) } }) };\n }\n if (!result && run && call.name === PRESENT_TOOL) {\n return { kind: 'artifact', key, call, state: 'presenting', artifact: undefined, title: pendingTitle(call) };\n }\n }\n const custom = committed ? renderTool?.(call, result) : undefined;\n if (custom?.required) { pinned.push({ key, content: custom.content }); return undefined; }\n if (natural) return undefined;\n if (custom && (developer || result?.isError || custom.inline)) return { kind: 'tool', key, entry: { key, call, result, status: status(result), custom: custom.content } };\n // A host that folds only some calls (`groupTool`) keeps the rest as cards, which expert mode hides unless they failed.\n if (!developer && !result?.isError && options.groupTool && !(result && safe(options.groupTool, { key, call, result, status: status(result) }))) return undefined;\n return { kind: 'tool', key, entry: { key, call, result, status: status(result) } };\n }\n function partsOf(content: readonly StreamPart[], rowKey: string, streaming: boolean, committed: boolean): Part[] {\n const parts: Part[] = [];\n for (const [index, part] of content.entries()) {\n if (part.type === 'text') { const text = visibleText(part.text, streaming && index === content.length - 1, options.hideLine); if (text.length) parts.push({ kind: 'text', key: `${rowKey}:text:${index}`, text }); }\n else if (part.type === 'thinking') { if (developer && part.thinking.length) parts.push({ kind: 'thinking', key: `${rowKey}:thinking:${index}`, text: part.thinking, streaming: streaming && index === content.length - 1 }); }\n else { const made = toolPart(part, `${rowKey}:tool:${index}`, committed); if (!natural && (made?.kind === 'artifact' || made?.kind === 'approval' || made?.kind === 'subagent') && made.entry) parts.push({ kind: 'tool', key: `${made.key}:step`, entry: made.entry }); if (made) parts.push(made); }\n }\n return parts;\n }\n\n const imagesOf = (content: readonly StreamPart[]): ImageContent[] => content.flatMap(part => part.type === 'toolCall'\n ? (results.get(part)?.content ?? []).filter((item): item is ImageContent => item.type === 'image') : []);\n\n // Pass 2: rows.\n const rows: Row[] = [];\n for (const item of items) {\n if (item.card) { if (!item.card.required) rows.push({ key: item.key, type: 'card', card: item.card }); continue; }\n const message = item.message;\n if (!message) { if (developer) rows.push({ key: item.key, type: 'event', label: item.entry.kind }); continue; }\n if (message.role === 'system') {\n if (developer) rows.push({ key: item.key, type: 'system', text: typeof message.content === 'string' ? message.content : message.content.map(part => part.text).join('\\n') });\n } else if (message.role === 'toolResult') {\n // A result with its call is shown on the call's card. Only an unmatched failure gets its own row.\n if (!natural && message.isError && !matched.has(message)) rows.push({ key: item.key, type: 'orphan-result', message });\n } else if (message.role === 'user') rows.push({ key: item.key, type: 'user', message });\n else {\n const parts = partsOf(message.content, item.key, false, true), images = imagesOf(message.content);\n // Empty/tool-only rounds still contribute their native metadata to the answer.\n rows.push({ key: item.key, type: 'assistant', parts, ...(images.length ? { images } : {}), streaming: false, stopReason: message.stopReason, ...(message.errorMessage && !natural ? { errorMessage: message.errorMessage } : {}), text: textOf(parts), ...replyMeta(message), entryId: String(item.entry.id) });\n }\n }\n if (typeof taskId === 'number' && liveMessage) {\n const key = `assistant:${taskId}:${counts.get(taskId) ?? 0}`, content = streamParts(liveMessage['content']);\n const parts = partsOf(content, key, true, false), images = imagesOf(content);\n if (!natural || parts.length || images.length) rows.push({ key, type: 'assistant', parts, ...(images.length ? { images } : {}), streaming: true, text: textOf(parts) });\n }\n const merged = mergeTurns(rows).filter(row => row.type !== 'assistant' || row.parts.length || row.images?.length || row.stopReason === 'error' || row.stopReason === 'aborted' || row.streaming && !natural).map(row => row.type === 'assistant' && options.artifacts ? { ...row, parts: oneCardPerArtifact(row.parts) } : row);\n const last = merged[merged.length - 1];\n if (run && last?.type === 'assistant') merged[merged.length - 1] = { ...last, active: true };\n return { rows: merged, pinned, run, retry, stageStatus, compacting: Array.isArray(live?.['compactions']) && live['compactions'].length > 0 };\n}\n\n/** What a reply reports about the model round that wrote it, read from Pi's own message (each field only when it is there). */\nexport function replyMeta(message: AssistantMessage): { readonly timestamp?: number; readonly model?: { readonly provider: string; readonly modelId: string }; readonly durationMs?: number } {\n return {\n ...(typeof message.timestamp === 'number' ? { timestamp: message.timestamp } : {}),\n ...(typeof message.provider === 'string' && typeof message.model === 'string' ? { model: { provider: message.provider, modelId: message.model } } : {}),\n ...(typeof message.durationMs === 'number' ? { durationMs: message.durationMs } : {}),\n };\n}\n\n/**\n * Pi commits one assistant message per model round, so a turn with tool use is several messages in a row.\n * They read as one answer: merge consecutive assistant rows (unless one ended in an error or stop) so tool calls\n * group across rounds and the turn has a single copy action. The first row's key is kept, so identity is stable.\n */\nfunction mergeTurns(rows: readonly Row[]): Row[] {\n const out: Row[] = [];\n for (const row of rows) {\n const last = out[out.length - 1];\n if (last?.type === 'assistant' && row.type === 'card' && row.card.inline) {\n out[out.length - 1] = { ...last, parts: [...last.parts, { kind: 'card', key: row.key, content: row.card.content }] };\n } else if (last?.type === 'assistant' && row.type === 'assistant' && !last.stopReason?.match(/^(error|aborted)$/)) {\n out[out.length - 1] = { key: last.key, type: 'assistant', parts: [...last.parts, ...row.parts], ...((last.images?.length || row.images?.length) ? { images: [...(last.images ?? []), ...(row.images ?? [])] } : {}), streaming: row.streaming,\n ...(row.stopReason ? { stopReason: row.stopReason } : {}), ...(row.errorMessage ? { errorMessage: row.errorMessage } : {}),\n text: [last.text, row.text].filter(Boolean).join('\\n\\n'), ...(row.timestamp !== undefined ? { timestamp: row.timestamp } : {}), ...(row.model ? { model: row.model } : {}), ...(last.durationMs !== undefined || row.durationMs !== undefined ? { durationMs: (last.durationMs ?? 0) + (row.durationMs ?? 0) } : {}),\n ...(row.entryId !== undefined ? { entryId: row.entryId } : last.entryId !== undefined ? { entryId: last.entryId } : {}) };\n } else out.push(row);\n }\n return out;\n}\n\n/**\n * A turn that revises an artifact several times shows one card for it: the last version, at the place of the last step.\n * A running `present` of a file already shown takes over the title of its card.\n */\nfunction oneCardPerArtifact(parts: readonly Part[]): Part[] {\n const idOf = (part: Part) => part.kind === 'artifact' ? part.artifact ? artifactKey(part.artifact) : (part.state === 'presenting' ? pendingId(part.call) : undefined) : undefined;\n const last = new Map<string, number>();\n for (const [index, part] of parts.entries()) { const id = idOf(part); if (id !== undefined) last.set(id, index); }\n const out: Part[] = [];\n for (const [index, part] of parts.entries()) {\n const id = idOf(part);\n if (id === undefined || last.get(id) === index) {\n if (part.kind === 'artifact' && part.artifact === undefined && part.title === undefined && id !== undefined) {\n const earlier = parts.find(other => other.kind === 'artifact' && other.artifact !== undefined && artifactKey(other.artifact) === id);\n if (earlier?.kind === 'artifact') { out.push({ ...part, title: earlier.title }); continue; }\n }\n out.push(part);\n }\n }\n return out;\n}\n\n/** One step of an activity block: a default tool call or a stretch of reasoning. */\nexport type Step = { readonly kind: 'tool'; readonly key: string; readonly entry: ToolEntry }\n | { readonly kind: 'thinking'; readonly key: string; readonly text: string; readonly streaming: boolean };\n/** An assistant turn is text parts and activity blocks: one block per uninterrupted run of steps. */\nexport type Segment = { readonly kind: 'part'; readonly part: Part } | { readonly kind: 'activity'; readonly key: string; readonly steps: readonly Step[] };\nexport function segments(parts: readonly Part[], groupTool: DeriveOptions['groupTool']): Segment[] {\n const out: Segment[] = [];\n let run: Step[] = [];\n const flush = () => {\n const first = run[0];\n if (first) out.push({ kind: 'activity', key: `activity:${first.key}`, steps: run });\n run = [];\n };\n for (const part of parts) {\n if (part.kind === 'thinking') run.push({ kind: 'thinking', key: part.key, text: part.text, streaming: part.streaming });\n else if (part.kind === 'tool' && !part.entry.custom && (!groupTool || safe(groupTool, part.entry))) run.push({ kind: 'tool', key: part.key, entry: part.entry });\n else { flush(); out.push({ kind: 'part', part }); }\n }\n flush();\n return out;\n}\nfunction safe(groupTool: NonNullable<DeriveOptions['groupTool']>, entry: ToolEntry): boolean {\n try { return entry.result !== undefined && !entry.result.isError && groupTool(entry.call, entry.result) === true; } catch { return false; }\n}\n",
146
+ "content": "import type { ReactNode } from 'react';\nimport type { ConversationView, EntryRecord, InboxItem, JsonObject } from '@earendil-works/pi-durable';\nimport type { JsonValue } from '@earendil-works/chord';\nimport type { AssistantMessage, ImageContent, Message, ToolCall, ToolResultMessage, UserMessage } from '@earendil-works/pi-ai';\nimport type { ToolEntry, ToolStatus } from './tool';\nimport { ASK_USER_TOOL } from './config';\nimport type { ApprovalDecision, ChatArtifact, ChatArtifactsConfig, SubagentRenderer } from './config';\n\n/** The host adds file content for `@path` mentions as parts that start like this; the message already shows the mention itself. */\nexport const isFileBlock = (text: string): boolean => text.startsWith('<file path=\"');\n\n/** A marked app context stays in the native message; presentation collapses only a valid leading target block to a chip. */\nexport function targetContent(text: string): { readonly target: JsonObject; readonly speech: string; readonly label: string } | undefined {\n if (!text.startsWith('<target>')) return;\n const end = text.indexOf('</target>');\n if (end < 0 || text.slice(end + 9, end + 11) !== '\\n\\n') return;\n const json = text.slice(8, end);\n if (new TextEncoder().encode(json).length > 1024) return;\n try {\n const target = object(JSON.parse(json));\n if (!target) return;\n const label = Object.entries(target).map(([key, value]) => typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? `${key}: ${value}` : key).join(' · ') || 'Target';\n return { target, speech: text.slice(end + 11), label };\n } catch { return; }\n}\n\n/** A submission waiting in the native inbox (steer or follow-up); `queuedMessages` reads them from the view, the queue renders them. */\nexport type QueuedMessage = Extract<InboxItem, { readonly mode: 'steer' | 'followUp' }>;\n\n/*\n * The one message model is the native Pi view: entries (with their model messages) plus the live `pi.live` document.\n * This module turns a view into display rows with these semantics: tool calls are matched with tool results, expert mode hides successful tool details, the live\n * generation is appended as a streaming row whose key matches the row its committed message will take.\n */\n/** `required` pins the card above the transcript. `inline` keeps an entry/tool card in place in expert mode too;\n * inline entries remain part of the answer. Other custom tool cards show only in developer mode or on failure. */\nexport interface ChatCard { readonly content: ReactNode; readonly required?: boolean; readonly inline?: boolean; /** Host stage wording for the single natural status; null clears it. */ readonly status?: ReactNode }\nexport type Mode = 'expert' | 'developer';\nexport type StreamPart = { readonly type: 'text'; readonly text: string } | { readonly type: 'thinking'; readonly thinking: string } | ToolCall;\n\nexport type Part =\n | { readonly kind: 'card'; readonly key: string; readonly content: ReactNode }\n | { readonly kind: 'text'; readonly key: string; readonly text: string }\n | { readonly kind: 'thinking'; readonly key: string; readonly text: string; readonly streaming: boolean }\n | { readonly kind: 'question'; readonly key: string; readonly call: ToolCall; readonly result: ToolResultMessage | undefined; readonly live: boolean; readonly questionId: string | undefined }\n | { readonly kind: 'approval'; readonly key: string; readonly call: ToolCall; readonly result: ToolResultMessage | undefined; readonly live: boolean; /** The bound question ID the answer must use (as for `question`). */ readonly questionId: string | undefined; /** The one-line summary `requireApproval` published for this call. */ readonly summary: string; /** The decision it recorded, once made. */ readonly decision?: ApprovalDecision; /** The call's own step, shown in the activity block once it has a result. */ readonly entry?: ToolEntry }\n | { readonly kind: 'subagent'; readonly key: string; readonly call: ToolCall; readonly result: ToolResultMessage | undefined; /** The sub-agent card (`ChatCards.subagent`), from the details the call published (live) or finished with. */ readonly content: ReactNode; /** The call's own step, shown in the activity block once it has a result. */ readonly entry?: ToolEntry }\n | { readonly kind: 'artifact'; readonly key: string; readonly call: ToolCall; readonly artifact: ChatArtifact; /** Set when the host's `detect` found the descriptor: the call stays a step of the activity block too. */ readonly entry?: ToolEntry }\n | { readonly kind: 'tool'; readonly key: string; readonly entry: ToolEntry };\n\nexport type Row = (\n | { readonly key: string; readonly type: 'user'; readonly message: UserMessage }\n | { readonly key: string; readonly type: 'assistant'; readonly parts: readonly Part[]; /** Native result images remain visible when their tool chrome is omitted. */ readonly images?: readonly ImageContent[]; readonly streaming: boolean; /** The turn is still running (the last row while a run is active). */ readonly active?: boolean; readonly stopReason?: AssistantMessage['stopReason']; readonly errorMessage?: string; readonly text: string; /** When the reply was written (the last round of a merged turn), in ms. Absent while it streams. */ readonly timestamp?: number; /** The model that wrote the last round of the reply. */ readonly model?: { readonly provider: string; readonly modelId: string }; /** Sum of the merged rounds' native `durationMs`, when recorded. */ readonly durationMs?: number; /** The native entry of its last committed round: where a fork of the conversation after this reply starts. */ readonly entryId?: string }\n | { readonly key: string; readonly type: 'orphan-result'; readonly message: ToolResultMessage }\n | { readonly key: string; readonly type: 'system'; readonly text: string }\n | { readonly key: string; readonly type: 'event'; readonly label: string }\n | { readonly key: string; readonly type: 'card'; readonly card: ChatCard }) & { readonly historical?: boolean };\n\nexport function object(value: JsonValue | undefined): JsonObject | undefined {\n return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;\n}\nexport function streamParts(value: JsonValue | undefined): StreamPart[] {\n if (!Array.isArray(value)) return [];\n return value.flatMap((item): StreamPart[] => {\n const part = object(item);\n if (part?.['type'] === 'text' && typeof part['text'] === 'string') return [{ type: 'text', text: part['text'] }];\n if (part?.['type'] === 'thinking' && typeof part['thinking'] === 'string') return [{ type: 'thinking', thinking: part['thinking'] }];\n const args = object(part?.['arguments']);\n if (part?.['type'] === 'toolCall' && typeof part['id'] === 'string' && typeof part['name'] === 'string' && args) return [{ type: 'toolCall', id: part['id'], name: part['name'], arguments: args }];\n return [];\n });\n}\n\nexport function queuedMessages(view: ConversationView | undefined): QueuedMessage[] {\n const items = object(view?.docs['pi.inbox'])?.['items'];\n if (!Array.isArray(items)) return [];\n return items.filter((item): item is QueuedMessage => {\n const value = object(item as JsonValue);\n return value !== undefined && (value['mode'] === 'steer' || value['mode'] === 'followUp') && value['id'] !== undefined;\n });\n}\n\nexport interface Derived {\n readonly rows: readonly Row[];\n /** Required entry and tool cards: pinned above the transcript. */\n readonly pinned: readonly { readonly key: string; readonly content: ReactNode }[];\n readonly run: JsonObject | undefined;\n readonly retry: JsonObject | undefined;\n readonly compacting: boolean;\n readonly stageStatus?: ReactNode;\n}\nexport interface DeriveOptions {\n readonly mode: Mode;\n /** Complete assistant lines to omit. With live text, the unfinished last line waits for its newline or settlement. */\n readonly hideLine?: ((line: string) => boolean) | undefined;\n readonly renderEntry: ((entry: EntryRecord) => ChatCard | undefined) | undefined;\n readonly renderTool: ((call: ToolCall, result: ToolResultMessage | undefined) => ChatCard | undefined) | undefined;\n readonly groupTool: ((call: ToolCall, result: ToolResultMessage) => boolean) | undefined;\n /** Turn artifact tool results into cards (`pi-artifacts` recognises them; `detect` finds descriptors the tool result does not carry itself). */\n readonly artifacts?: { readonly recognise: NonNullable<ChatArtifactsConfig['recognise']>; readonly detect?: ChatArtifactsConfig['detect'] } | undefined;\n /** Draws sub-agent calls (`ChatCards.subagent`). Without it they are ordinary tool steps. */\n readonly subagent?: SubagentRenderer | undefined;\n /** `natural` shows only what a non-developer reads: their messages, the assistant's prose and the question, approval and artifact cards. Tool steps, reasoning, system rows and raw error text are left out of the rows (the entries stay as they are). */\n readonly presentation?: 'natural' | undefined;\n}\n\n/** Key of `{ summary, decision? }` in a gated call's details: `APPROVAL_DETAILS` of `@hachej/boring-agent/approval` (a contract test keeps them equal). */\nexport const APPROVAL_DETAILS = 'boring.approval';\nexport type { ApprovalDecision } from './config';\n/** The approval record a gated call publishes: its summary and, once made, the decision (`approved`, `denied` or `cancelled`). */\nconst approvalRecord = (details: unknown): { summary: string; decision?: ApprovalDecision } | undefined => {\n const record = object(object(details as JsonValue | undefined)?.[APPROVAL_DETAILS]);\n const summary = record?.['summary'], decision = record?.['decision'];\n if (typeof summary !== 'string' || !summary.trim()) return undefined;\n return { summary: summary.trim(), ...(decision === 'approved' || decision === 'denied' || decision === 'cancelled' ? { decision } : {}) };\n};\n\nfunction visibleText(text: string, streaming: boolean, hideLine: DeriveOptions['hideLine']): string {\n if (!hideLine) return text;\n const lines = text.match(/[^\\n]*\\n|[^\\n]+$/g) ?? [];\n return lines.filter(line => (!streaming || line.endsWith('\\n')) && !hideLine(line.replace(/\\r?\\n$/, ''))).join('');\n}\nconst textOf = (parts: readonly Part[]): string => parts.flatMap(part => part.kind === 'text' ? [part.text] : []).join('\\n');\n\nexport function derive(view: ConversationView | undefined, options: DeriveOptions): Derived {\n const { mode, renderEntry, renderTool } = options;\n const natural = options.presentation === 'natural';\n const developer = mode === 'developer' && !natural;\n const live = object(view?.docs['pi.live']);\n const run = object(live?.['run']), generation = object(live?.['generation']);\n const liveMessage = object(generation?.['message']), retry = object(generation?.['retry']);\n const taskId = run?.['taskId'];\n\n // Pass 1: match each result to its preceding invocation. Model call IDs may be reused.\n type Item = { readonly key: string; readonly entry: EntryRecord; readonly message?: Message; readonly card?: ChatCard };\n let stageStatus: ReactNode;\n const items: Item[] = [], results = new Map<ToolCall, ToolResultMessage>(), counts = new Map<number, number>(), questionIds = new Map<ToolCall, string>();\n const pendingCalls = new Map<string, ToolCall>();\n for (const entry of view?.entries ?? []) {\n const custom = renderEntry?.(entry);\n if (custom && 'status' in custom) stageStatus = custom.status;\n const card = custom && (developer || custom.required || custom.inline) ? custom : undefined;\n if (card) items.push({ key: `entry:${entry.id}`, entry, card });\n if (!entry.model?.length) { if (!card && developer) items.push({ key: `entry:${entry.id}`, entry }); continue; }\n for (const [index, message] of entry.model.entries()) {\n if (message.role === 'assistant') for (const part of message.content) if (part.type === 'toolCall') { pendingCalls.set(part.id, part); questionIds.set(part, JSON.stringify([entry.id, part.id])); }\n if (message.role === 'toolResult') {\n const call = pendingCalls.get(message.toolCallId);\n if (call) { results.set(call, message); pendingCalls.delete(message.toolCallId); }\n }\n let key = `entry:${entry.id}:${index}`;\n if (message.role === 'assistant' && entry.byTaskId !== undefined) {\n const ordinal = counts.get(entry.byTaskId) ?? 0; counts.set(entry.byTaskId, ordinal + 1);\n key = `assistant:${entry.byTaskId}:${ordinal}`;\n }\n if (!card) items.push({ key, entry, message });\n }\n }\n const matched = new Set(results.values());\n\n const pinned: { key: string; content: ReactNode }[] = [];\n for (const item of items) if (item.card?.required) pinned.push({ key: item.key, content: item.card.content });\n\n const status = (result: ToolResultMessage | undefined): ToolStatus => result ? (result.isError ? 'failed' : 'completed') : run ? 'running' : 'unfinished';\n function toolPart(call: ToolCall, key: string, committed: boolean): Part | undefined {\n const result = results.get(call);\n if (call.name === ASK_USER_TOOL) return { kind: 'question', key, call, result, live: !committed, questionId: questionIds.get(call) };\n // A call gated by `requireApproval` identifies itself: it publishes its summary under APPROVAL_DETAILS before it asks.\n const slotDetails = result ? undefined : (Array.isArray(live?.['tools']) ? live['tools'] : []).map(object).find(slot => slot?.['callId'] === call.id)?.['details'];\n const record = approvalRecord(result ? result.details : slotDetails);\n if (record) return { kind: 'approval', key, call, result, live: !committed, questionId: questionIds.get(call), summary: record.summary, ...(record.decision ? { decision: record.decision } : {}), ...(result ? { entry: { key: `${key}:step`, call, result, status: status(result) } } : {}) };\n // A `delegate` call (or any tool publishing `DelegateDetails`) is a sub-agent card, live from its running slot, when the sub-agent item draws it.\n const delegated = options.subagent?.(call, result ? result.details : slotDetails, result);\n if (delegated !== undefined) return { kind: 'subagent', key, call, result, content: delegated, ...(result ? { entry: { key: `${key}:step`, call, result, status: status(result) } } : {}) };\n // With the artifacts item, a finished call that carries a descriptor becomes a card and a running `present` an \"opening\" card. A failed call stays an ordinary (failed) tool step.\n const artifact = options.artifacts?.recognise(call, result, !result && run !== undefined, options.artifacts.detect);\n // A tool whose own result carries the descriptor is replaced by its card; a host-detected card is shown in addition to the step.\n if (artifact) return { kind: 'artifact', key, call, artifact, ...(result && !artifact.own ? { entry: { key, call, result, status: status(result) } } : {}) };\n const custom = committed ? renderTool?.(call, result) : undefined;\n if (custom?.required) { pinned.push({ key, content: custom.content }); return undefined; }\n if (natural) return undefined;\n if (custom && (developer || result?.isError || custom.inline)) return { kind: 'tool', key, entry: { key, call, result, status: status(result), custom: custom.content } };\n // A host that folds only some calls (`groupTool`) keeps the rest as cards, which expert mode hides unless they failed.\n if (!developer && !result?.isError && options.groupTool && !(result && safe(options.groupTool, { key, call, result, status: status(result) }))) return undefined;\n return { kind: 'tool', key, entry: { key, call, result, status: status(result) } };\n }\n function partsOf(content: readonly StreamPart[], rowKey: string, streaming: boolean, committed: boolean): Part[] {\n const parts: Part[] = [];\n for (const [index, part] of content.entries()) {\n if (part.type === 'text') { const text = visibleText(part.text, streaming && index === content.length - 1, options.hideLine); if (text.length) parts.push({ kind: 'text', key: `${rowKey}:text:${index}`, text }); }\n else if (part.type === 'thinking') { if (developer && part.thinking.length) parts.push({ kind: 'thinking', key: `${rowKey}:thinking:${index}`, text: part.thinking, streaming: streaming && index === content.length - 1 }); }\n else { const made = toolPart(part, `${rowKey}:tool:${index}`, committed); if (!natural && (made?.kind === 'artifact' || made?.kind === 'approval' || made?.kind === 'subagent') && made.entry) parts.push({ kind: 'tool', key: `${made.key}:step`, entry: made.entry }); if (made) parts.push(made); }\n }\n return parts;\n }\n\n const imagesOf = (content: readonly StreamPart[]): ImageContent[] => content.flatMap(part => part.type === 'toolCall'\n ? (results.get(part)?.content ?? []).filter((item): item is ImageContent => item.type === 'image') : []);\n\n // Pass 2: rows.\n const rows: Row[] = [];\n for (const item of items) {\n if (item.card) { if (!item.card.required) rows.push({ key: item.key, type: 'card', card: item.card }); continue; }\n const message = item.message;\n if (!message) { if (developer) rows.push({ key: item.key, type: 'event', label: item.entry.kind }); continue; }\n if (message.role === 'system') {\n if (developer) rows.push({ key: item.key, type: 'system', text: typeof message.content === 'string' ? message.content : message.content.map(part => part.text).join('\\n') });\n } else if (message.role === 'toolResult') {\n // A result with its call is shown on the call's card. Only an unmatched failure gets its own row.\n if (!natural && message.isError && !matched.has(message)) rows.push({ key: item.key, type: 'orphan-result', message });\n } else if (message.role === 'user') rows.push({ key: item.key, type: 'user', message });\n else {\n const parts = partsOf(message.content, item.key, false, true), images = imagesOf(message.content);\n // Empty/tool-only rounds still contribute their native metadata to the answer.\n rows.push({ key: item.key, type: 'assistant', parts, ...(images.length ? { images } : {}), streaming: false, stopReason: message.stopReason, ...(message.errorMessage && !natural ? { errorMessage: message.errorMessage } : {}), text: textOf(parts), ...replyMeta(message), entryId: String(item.entry.id) });\n }\n }\n if (typeof taskId === 'number' && liveMessage) {\n const key = `assistant:${taskId}:${counts.get(taskId) ?? 0}`, content = streamParts(liveMessage['content']);\n const parts = partsOf(content, key, true, false), images = imagesOf(content);\n if (!natural || parts.length || images.length) rows.push({ key, type: 'assistant', parts, ...(images.length ? { images } : {}), streaming: true, text: textOf(parts) });\n }\n const merged = mergeTurns(rows).filter(row => row.type !== 'assistant' || row.parts.length || row.images?.length || row.stopReason === 'error' || row.stopReason === 'aborted' || row.streaming && !natural).map(row => row.type === 'assistant' && options.artifacts ? { ...row, parts: oneCardPerArtifact(row.parts) } : row);\n const last = merged[merged.length - 1];\n if (run && last?.type === 'assistant') merged[merged.length - 1] = { ...last, active: true };\n return { rows: merged, pinned, run, retry, stageStatus, compacting: Array.isArray(live?.['compactions']) && live['compactions'].length > 0 };\n}\n\n/** What a reply reports about the model round that wrote it, read from Pi's own message (each field only when it is there). */\nexport function replyMeta(message: AssistantMessage): { readonly timestamp?: number; readonly model?: { readonly provider: string; readonly modelId: string }; readonly durationMs?: number } {\n return {\n ...(typeof message.timestamp === 'number' ? { timestamp: message.timestamp } : {}),\n ...(typeof message.provider === 'string' && typeof message.model === 'string' ? { model: { provider: message.provider, modelId: message.model } } : {}),\n ...(typeof message.durationMs === 'number' ? { durationMs: message.durationMs } : {}),\n };\n}\n\n/**\n * Pi commits one assistant message per model round, so a turn with tool use is several messages in a row.\n * They read as one answer: merge consecutive assistant rows (unless one ended in an error or stop) so tool calls\n * group across rounds and the turn has a single copy action. The first row's key is kept, so identity is stable.\n */\nfunction mergeTurns(rows: readonly Row[]): Row[] {\n const out: Row[] = [];\n for (const row of rows) {\n const last = out[out.length - 1];\n if (last?.type === 'assistant' && row.type === 'card' && row.card.inline) {\n out[out.length - 1] = { ...last, parts: [...last.parts, { kind: 'card', key: row.key, content: row.card.content }] };\n } else if (last?.type === 'assistant' && row.type === 'assistant' && !last.stopReason?.match(/^(error|aborted)$/)) {\n out[out.length - 1] = { key: last.key, type: 'assistant', parts: [...last.parts, ...row.parts], ...((last.images?.length || row.images?.length) ? { images: [...(last.images ?? []), ...(row.images ?? [])] } : {}), streaming: row.streaming,\n ...(row.stopReason ? { stopReason: row.stopReason } : {}), ...(row.errorMessage ? { errorMessage: row.errorMessage } : {}),\n text: [last.text, row.text].filter(Boolean).join('\\n\\n'), ...(row.timestamp !== undefined ? { timestamp: row.timestamp } : {}), ...(row.model ? { model: row.model } : {}), ...(last.durationMs !== undefined || row.durationMs !== undefined ? { durationMs: (last.durationMs ?? 0) + (row.durationMs ?? 0) } : {}),\n ...(row.entryId !== undefined ? { entryId: row.entryId } : last.entryId !== undefined ? { entryId: last.entryId } : {}) };\n } else out.push(row);\n }\n return out;\n}\n\n/**\n * A turn that revises an artifact several times shows one card for it: the last version, at the place of the last step.\n * A running `present` of a file already shown takes over the title of its card.\n */\nfunction oneCardPerArtifact(parts: readonly Part[]): Part[] {\n const idOf = (part: Part) => part.kind === 'artifact' ? part.artifact.id : undefined;\n const last = new Map<string, number>();\n for (const [index, part] of parts.entries()) { const id = idOf(part); if (id !== undefined) last.set(id, index); }\n const out: Part[] = [];\n for (const [index, part] of parts.entries()) {\n const id = idOf(part);\n if (id === undefined || last.get(id) === index) {\n if (part.kind === 'artifact' && part.artifact.state === 'presenting' && part.artifact.title === undefined && id !== undefined) {\n const earlier = parts.find(other => other.kind === 'artifact' && other.artifact.state === 'ready' && other.artifact.id === id);\n if (earlier?.kind === 'artifact') { out.push({ ...part, artifact: { ...part.artifact, title: earlier.artifact.title } }); continue; }\n }\n out.push(part);\n }\n }\n return out;\n}\n\n/** One step of an activity block: a default tool call or a stretch of reasoning. */\nexport type Step = { readonly kind: 'tool'; readonly key: string; readonly entry: ToolEntry }\n | { readonly kind: 'thinking'; readonly key: string; readonly text: string; readonly streaming: boolean };\n/** An assistant turn is text parts and activity blocks: one block per uninterrupted run of steps. */\nexport type Segment = { readonly kind: 'part'; readonly part: Part } | { readonly kind: 'activity'; readonly key: string; readonly steps: readonly Step[] };\nexport function segments(parts: readonly Part[], groupTool: DeriveOptions['groupTool']): Segment[] {\n const out: Segment[] = [];\n let run: Step[] = [];\n const flush = () => {\n const first = run[0];\n if (first) out.push({ kind: 'activity', key: `activity:${first.key}`, steps: run });\n run = [];\n };\n for (const part of parts) {\n if (part.kind === 'thinking') run.push({ kind: 'thinking', key: part.key, text: part.text, streaming: part.streaming });\n else if (part.kind === 'tool' && !part.entry.custom && (!groupTool || safe(groupTool, part.entry))) run.push({ kind: 'tool', key: part.key, entry: part.entry });\n else { flush(); out.push({ kind: 'part', part }); }\n }\n flush();\n return out;\n}\nfunction safe(groupTool: NonNullable<DeriveOptions['groupTool']>, entry: ToolEntry): boolean {\n try { return entry.result !== undefined && !entry.result.isError && groupTool(entry.call, entry.result) === true; } catch { return false; }\n}\n",
199
147
  "type": "registry:lib",
200
148
  "target": "components/pi-chat/rows.ts"
201
149
  },
202
150
  {
203
151
  "path": "registry/pi-chat/session.tsx",
204
- "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, ChatHistoryState } from '@hachej/boring-ui-kit/native-chat';\nimport type { ChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { useMessageScroller, useMessageScrollerScrollable } from '@/components/ui/message-scroller';\nimport { Button } from '@/components/ui/button';\nimport type { ComposerFeedback, ComposerProps, PendingUpload } from './composer';\nimport type { ConversationFile } from './conversation-files';\nimport type { AttachmentsConfig, EffortConfig, MentionsConfig, ModelConfig, ModelRef, PastedText, PasteToFile, SlashConfig } from './config';\nimport { displayName, looksLikeMarkdown, mentionToken, pieces } from './config';\nimport { ModelEffortPicker } from './pickers';\nimport type { CommandMentions } from './markdown';\nimport type { ChatCards, ChatTextPart, 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, Row } 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 /** `natural` shows only messages, replies and question, approval and artifact cards, with one \"Preparing…\" status while the agent works: no tool steps, reasoning, file discovery or raw error text. Omit it and nothing changes. */\n readonly presentation?: 'natural';\n readonly actions?: PiChatActions;\n readonly renderEntry?: (entry: EntryRecord) => ChatCard | undefined;\n /** Omit complete assistant lines from display, copy and actions. Live trailing fragments wait for a newline or settlement. Not transport redaction. */\n readonly hideLine?: (line: string) => boolean;\n /** Initial number of displayed rows (default 60). An explicit limit uses Show earlier, not automatic paging; nothing is deleted. */\n readonly displayLimit?: number;\n /** Explicitly enable messageActions for paged history too; other historical controls remain inert. */\n readonly historicalMessageActions?: boolean;\n /** Host-rendered unread indicator, keyed by the reply. The host owns seen state and its persistence. */\n readonly unread?: (reply: ReplyRef) => ReactNode;\n /** Message inside the single native response-error notice. Null/undefined keeps the default; native status and metadata stay unchanged. */\n readonly replyError?: (reply: ReplyRef) => ReactNode;\n /** Card for any tool call; the catch-all after `toolRenderers`. */\n readonly renderTool?: (call: ToolCall, result: ToolResultMessage | undefined) => ChatCard | undefined;\n /** Cards for tool calls, keyed by tool name (same contract as `renderTool`). An entry that returns undefined falls back to `renderTool`. */\n readonly toolRenderers?: Readonly<Record<string, (call: ToolCall, result: ToolResultMessage | undefined) => ChatCard | undefined>> | undefined;\n /** Draws an assistant text part. Default: the built-in Markdown. */\n readonly renderText?: ((part: ChatTextPart) => ReactNode) | undefined;\n /** The question and approval cards. Each one left out uses the built-in card. */\n readonly cards?: ChatCards | 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 /** Optional pills at the left of the composer's controls row. */\n readonly composerChips?: ComposerProps['chips'];\n /** Contextual host content above the input, for example a removable quote. */\n readonly composerHeader?: ReactNode;\n /** Host-owned content participates in send readiness without native image attachments. */\n readonly composerHasContent?: boolean;\n /** Retain files for the host's send operation instead of uploading immediately. */\n readonly onPickFiles?: (files: readonly File[]) => void;\n /** `/` menu of host commands and skills. Omit it and `/` is just text. */\n readonly slash?: SlashConfig;\n /** Show successful native write/edit paths as file cards under each reply. Off by default: only hosts with workspace file tools should opt in. */\n readonly fileChanges?: boolean;\n /** Opens a file from an upload or write/edit card. Falls back to mentions.open. */\n readonly onOpenFile?: (path: string) => void;\n /** Pastes above these limits become a file through `attachments` (on by default, 2000 characters or 40 lines). Needs `attachments`. */\n readonly pasteToFile?: PasteToFile;\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<ChatController['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: ChatController;\n /** The controller the host currently passes; a surface that outlives its controller stops acting on the old one. */\n readonly activeController: { readonly current: ChatController };\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, presentation, actions, renderEntry, hideLine, historicalMessageActions, unread, replyError, renderTool: catchAll, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, onFiles, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend, rowExtras, feedback, onOpenFile, fileChanges, pasteToFile, composerChips, composerHeader, composerHasContent, onPickFiles } = 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 natural = presentation === 'natural';\n // A tool's own renderer comes first; `renderTool` stays the catch-all.\n const renderTool = useMemo(() => toolRenderers ? (call: ToolCall, result: ToolResultMessage | undefined) => (Object.hasOwn(toolRenderers, call.name) ? toolRenderers[call.name]?.(call, result) : undefined) ?? catchAll?.(call, result) : catchAll, [toolRenderers, catchAll]);\n const developer = mode === 'developer' && !natural;\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, presentation, hideLine, renderEntry, renderTool, groupTool, artifacts: artifactDetect }), [state.view, mode, presentation, hideLine, renderEntry, renderTool, groupTool, artifactDetect]);\n const queued = useMemo(() => queuedMessages(state.view), [state.view]);\n const { run } = derived;\n const earlier = useMemo<readonly Row[]>(() => {\n if (!state.view || !('entries' in state.history)) return [];\n const activeIds = new Set(state.view.entries.map(entry => entry.id));\n const entries = state.history.entries.filter(entry => !activeIds.has(entry.id));\n return derive({ ...state.view, entries, docs: {} }, { mode, presentation, hideLine, renderEntry: undefined, renderTool: undefined, groupTool: undefined, artifacts: artifactDetect }).rows\n .map(row => ({ ...row, key: `history:${row.key}`, historical: true }));\n }, [state.view, state.history, mode, presentation, hideLine, artifactDetect]);\n const rows = useMemo(() => [...earlier, ...derived.rows], [earlier, derived.rows]);\n const files = useMemo(() => {\n const found = new Map<string, ConversationFile>();\n const open = onOpenFile ?? mentions?.open;\n const add = (path: string) => {\n const normalized = path.replace(/^\\.\\//, '');\n const id = `file:${normalized}`;\n if (!found.has(id)) found.set(id, { id, name: displayName(normalized), open: open ? () => open(normalized) : undefined });\n };\n for (const path of picked) add(path);\n for (const item of state.draft.attachments) found.set(`draft:${item.id}`, { id: `draft:${item.id}`, name: item.name, open: onOpenImage ? () => onOpenImage(item.content) : undefined });\n for (const row of derived.rows) {\n if (row.type === 'user') {\n const content = typeof row.message.content === 'string' ? [{ type: 'text' as const, text: row.message.content }] : row.message.content;\n for (const [index, part] of content.entries()) {\n if (part.type === 'text' && !isFileBlock(part.text)) for (const piece of pieces(part.text, { mentions: true, skills: [] })) { if (piece.kind === 'mention' && piece.value) add(piece.value); }\n if (part.type === 'image') { const id = `image:${row.key}:${index}`; found.set(id, { id, name: labels.imageAttachment, open: onOpenImage ? () => onOpenImage(part) : undefined }); }\n }\n }\n if (row.type !== 'assistant') continue;\n for (const part of row.parts) {\n if (part.kind === 'artifact' && part.artifact) {\n const artifact = part.artifact, id = `file:${artifact.target.resource.path.replace(/^\\.\\//, '')}`;\n found.set(id, { id, name: displayName(artifact.target.resource.path), open: artifacts ? () => artifacts.open(artifact) : undefined });\n }\n if (!natural && part.kind === 'tool' && part.entry.result && !part.entry.result.isError && ['read', 'write', 'edit'].includes(part.entry.call.name)) {\n const path = part.entry.call.arguments['path'];\n if (typeof path === 'string') add(path);\n }\n }\n }\n return [...found.values()];\n }, [rows, picked, state.draft.attachments, artifacts, onOpenFile, mentions?.open, onOpenImage, labels.imageAttachment]);\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 = derived.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 /** Uploads files; `restore` (a pasted text) is what their pending cards undo: it abandons the upload and puts the text back. */\n async function upload(files: readonly File[], paste?: { readonly restore: () => void; readonly after: string }) {\n if (!files.length || !current()) return;\n if (onPickFiles) { onPickFiles(files); return; }\n if (!onFiles && !attachments) 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 const cancel = paste && (() => { abort.abort(); setPending(items => items.filter(item => !ids.includes(item.id))); paste.restore(); });\n setPending(items => [...items, ...files.map((file, index): PendingUpload => ({ id: ids[index]!, name: file.name, state: 'uploading',\n ...(cancel && paste ? { cancel, restoreWhile: paste.after } : {}) }))]);\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 /** A paste the composer took: saved as a file under `uploads/` (Send then references it). Cancelling puts the text back where it was pasted. */\n const pasteText = (paste: PastedText) => {\n if (!attachments || !current()) return;\n const at = new Date(), two = (value: number) => String(value).padStart(2, '0');\n const stamp = `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}-${two(at.getMinutes())}-${two(at.getSeconds())}`;\n const markdown = looksLikeMarkdown(paste.text);\n const file = new File([paste.text], `${labels.pastedFileName(stamp)}${markdown ? '.md' : '.txt'}`, { type: markdown ? 'text/markdown' : 'text/plain' });\n // The pasted text replaces its selection when the paste is accepted, so the draft shows what is left. Restoring inserts the text at\n // the same place and deletes nothing: whatever was typed since stays.\n const draft = controller.getSnapshot().draft.text;\n const start = Math.min(paste.start, draft.length), end = Math.min(Math.max(paste.end, start), draft.length);\n const after = `${draft.slice(0, start)}${draft.slice(end)}`;\n controller.setText(after);\n const restore = () => {\n if (!current()) return;\n const now = controller.getSnapshot().draft.text;\n const at = Math.min(start, now.length);\n controller.setText(`${now.slice(0, at)}${paste.text}${now.slice(at)}`);\n textarea.current?.focus();\n };\n void upload([file], { restore, after });\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, natural, renderText, cards, groupTool, onOpenImage, onCopy, openFile: onOpenFile ?? mentions?.open, fileChanges,\n commandMentions: commandMentions ? { commands: commandMentions.commands, onActivate: activateMention } : undefined,\n answer: actions?.answer,\n artifacts: artifacts ? { open: artifacts.open, isOpen: artifacts.isOpen, kinds: artifacts.kinds } : undefined,\n messageActions, historicalMessageActions, unread, replyError,\n replyFooter,\n pieces: slash?.skills?.length || mentions || attachments ? { mentions: Boolean(mentions), uploads: Boolean(attachments), skills: (slash?.skills ?? []).map(skill => skill.name), openMention: mentions?.open, renderMention: mentions?.renderSent ?? mentions?.render, preview: mentions?.preview, audioUrl: mentions?.audioUrl } : undefined,\n ...rowExtras,\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }), [controller, developer, natural, renderText, cards, groupTool, onOpenImage, onCopy, onOpenFile, fileChanges, commandMentions, actions?.answer, slash?.skills, mentions, mentions?.open, mentions?.render, mentions?.renderSent, mentions?.preview, mentions?.audioUrl, attachments, artifacts?.open, artifacts?.isOpen, artifacts?.kinds, messageActions, historicalMessageActions, unread, replyError, 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 Picker = model?.Picker ?? effort?.Picker ?? ModelEffortPicker;\n const barStart = model || effort ? <Picker 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 variant=\"ghost\" size=\"sm\" role=\"alert\" data-testid=\"configure-error\" title={`${changeError} (${labels.dismissHint})`} onClick={() => setChangeError(null)}\n className=\"min-w-0 max-w-[16rem]\"><span className=\"truncate\">{changeError}</span></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 hasFiles: Boolean(composerHasContent),\n uploading: uploading > 0, canAttach: Boolean(onPickFiles || onFiles || attachments), ...(attachments?.Camera ? { camera: attachments.Camera } : {}), 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 onPasteText: attachments ? pasteText : undefined, pasteToFile,\n quote: composerHeader,\n feedback, chips: composerChips,\n };\n\n return { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, sendBlocked, error, setError, report, act, current, textarea, rowContext, composer, files, pickers, empty, loading, developer, natural };\n}\n\nexport type ChatSession = ReturnType<typeof useChatSession>;\n\n/** One scroll root for current and archived rows. Native paging stays in the borrowed controller. */\nexport function useTranscript(rows: readonly { readonly key: string }[], page = 60, more = 40, history?: { readonly state: ChatHistoryState; readonly connected: boolean; readonly load: () => Promise<void> }, automatic = true) {\n if (!Number.isSafeInteger(page) || page < 1) throw new TypeError('Chat displayLimit must be a positive integer');\n const [limit, setLimit] = useState(page);\n const [sentinel, setSentinel] = useState<HTMLDivElement | null>(null);\n const [loaded, setLoaded] = useState(0);\n const pending = useRef(false);\n const anchor = useRef<{ readonly length: number; readonly limit: number } | undefined>(undefined);\n const { scrollToEnd } = useMessageScroller();\n const scrollable = useMessageScrollerScrollable();\n const [scroller, setScroller] = useState<HTMLDivElement | null>(null);\n const scrollToBottom = useCallback((behavior?: 'instant') => scrollToEnd({ behavior: behavior === 'instant' ? 'instant' : 'smooth' }), [scrollToEnd]);\n const stick = { scrollRef: setScroller, scrollToBottom, isAtBottom: !scrollable.end };\n const hidden = Math.max(0, rows.length - limit);\n const reveal = useCallback(() => {\n if (pending.current || anchor.current) return;\n const state = history?.state;\n if (hidden === 0 && (!history?.connected || !state || state.kind === 'disabled' || state.kind === 'loading' || state.kind === 'ready' && !state.hasMore)) return;\n anchor.current = { length: rows.length, limit };\n if (hidden > 0) setLimit(value => value + more);\n else if (history) {\n pending.current = true;\n void Promise.resolve().then(history.load).catch(() => { anchor.current = undefined; }).finally(() => { pending.current = false; });\n }\n }, [hidden, history?.state, history?.connected, history?.load, rows.length, limit, more]);\n useLayoutEffect(() => {\n const saved = anchor.current;\n if (!scroller || !saved || history?.state.kind === 'loading') return;\n const added = Math.max(0, rows.length - saved.length);\n if (limit < saved.limit + added) { setLimit(saved.limit + added); return; }\n anchor.current = undefined;\n setLoaded(count => count + Math.max(0, Math.min(rows.length, limit) - Math.min(saved.length, saved.limit)));\n }, [rows, limit, scroller, history?.state]);\n useEffect(() => {\n if (!automatic || !scroller || !sentinel || typeof IntersectionObserver === 'undefined' || history?.state.kind === 'loading' || history?.state.kind === 'error') return;\n const observer = new IntersectionObserver(entries => {\n if (entries.some(entry => entry.isIntersecting) && (scroller.scrollHeight <= scroller.clientHeight || scrollable.start)) reveal();\n }, { root: scroller, rootMargin: '240px 0px 0px' });\n observer.observe(sentinel);\n return () => observer.disconnect();\n }, [automatic, scroller, sentinel, reveal, scrollable.start, history?.state]);\n const revealTo = (key: string) => { const index = rows.findIndex(row => row.key === key); if (index >= 0) setLimit(value => Math.max(value, rows.length - index)); };\n return { stick, hidden, reveal, revealTo, sentinelRef: setSentinel, loaded, visible: <T,>(items: readonly T[]): readonly T[] => hidden ? items.slice(hidden) : items, reset: () => { anchor.current = undefined; setLimit(page); } };\n}\n",
152
+ "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, ChatHistoryState } from '@hachej/boring-ui-kit/native-chat';\nimport type { ChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { useMessageScroller, useMessageScrollerScrollable } from '@/components/ui/message-scroller';\nimport { Button } from '@/components/ui/button';\nimport type { ComposerFeedback, ComposerProps, PendingUpload } from './composer';\nimport type { ConversationFile } from './conversation-files';\nimport type { AttachmentsConfig, EffortConfig, MentionsConfig, ModelConfig, ModelRef, PastedText, PasteToFile, SlashConfig } from './config';\nimport { displayName, looksLikeMarkdown, mentionToken, pieces } from './config';\nimport type { CommandMentions } from './config';\nimport type { ChatCards, ChatTextPart, RowContext } from './message';\nimport type { QueueActions, QueuedMessage } from './queue';\nimport type { AnswerOutcome, ChatArtifactsConfig } from './config';\nimport { derive, isFileBlock, object, queuedMessages } from './rows';\nimport type { ChatCard, Mode, Row } 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 /** `natural` shows only messages, replies and question, approval and artifact cards, with one \"Preparing…\" status while the agent works: no tool steps, reasoning, file discovery or raw error text. Omit it and nothing changes. */\n readonly presentation?: 'natural';\n readonly actions?: PiChatActions;\n readonly renderEntry?: (entry: EntryRecord) => ChatCard | undefined;\n /** Omit complete assistant lines from display, copy and actions. Live trailing fragments wait for a newline or settlement. Not transport redaction. */\n readonly hideLine?: (line: string) => boolean;\n /** Initial number of displayed rows (default 60). An explicit limit uses Show earlier, not automatic paging; nothing is deleted. */\n readonly displayLimit?: number;\n /** Explicitly enable messageActions for paged history too; other historical controls remain inert. */\n readonly historicalMessageActions?: boolean;\n /** Host-rendered unread indicator, keyed by the reply. The host owns seen state and its persistence. */\n readonly unread?: (reply: ReplyRef) => ReactNode;\n /** Message inside the single native response-error notice. Null/undefined keeps the default; native status and metadata stay unchanged. */\n readonly replyError?: (reply: ReplyRef) => ReactNode;\n /** Card for any tool call; the catch-all after `toolRenderers`. */\n readonly renderTool?: (call: ToolCall, result: ToolResultMessage | undefined) => ChatCard | undefined;\n /** Cards for tool calls, keyed by tool name (same contract as `renderTool`). An entry that returns undefined falls back to `renderTool`. */\n readonly toolRenderers?: Readonly<Record<string, (call: ToolCall, result: ToolResultMessage | undefined) => ChatCard | undefined>> | undefined;\n /** Draws an assistant text part. Default: plain text (`markdownText` of `pi-chat-markdown` draws Markdown). */\n readonly renderText?: ((part: ChatTextPart) => ReactNode) | undefined;\n /**\n * The question, approval and sub-agent cards (`pi-chat-question`, `pi-chat-approval`, `pi-chat-subagent`; `withChatPlugins` adds them all).\n * Without a question or approval card the chat shows that it waits for the person's answer, with no answer UI; without the sub-agent card a\n * sub-agent call is an ordinary tool step.\n */\n readonly cards?: ChatCards | 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 /** Optional pills at the left of the composer's controls row. */\n readonly composerChips?: ComposerProps['chips'];\n /** Contextual host content above the input, for example a removable quote. */\n readonly composerHeader?: ReactNode;\n /** Host-owned content participates in send readiness without native image attachments. */\n readonly composerHasContent?: boolean;\n /** Retain files for the host's send operation instead of uploading immediately. */\n readonly onPickFiles?: (files: readonly File[]) => void;\n /** `/` menu of host commands and skills. Omit it and `/` is just text. */\n readonly slash?: SlashConfig;\n /** Show successful native write/edit paths as file cards under each reply. Off by default: only hosts with workspace file tools should opt in. */\n readonly fileChanges?: boolean;\n /** Opens a file from an upload or write/edit card. Falls back to mentions.open. */\n readonly onOpenFile?: (path: string) => void;\n /** Pastes above these limits become a file through `attachments` (on by default, 2000 characters or 40 lines). Needs `attachments`. */\n readonly pasteToFile?: PasteToFile;\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 /**\n * Artifact cards: tool results that carry a descriptor become a card that opens the artifact in the host's panel. The card and its recogniser\n * come from `pi-artifacts` (`withChatPlugins` adds them); without them, or without this prop, no artifact is offered.\n */\n readonly artifacts?: ChatArtifactsConfig;\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<ChatController['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: ChatController;\n /** The controller the host currently passes; a surface that outlives its controller stops acting on the old one. */\n readonly activeController: { readonly current: ChatController };\n readonly mode: Mode;\n readonly fileAccept: string;\n readonly onFiles?: FilesHandler | undefined;\n /** The phone camera for an `onFiles` chat (`attachments.Camera` is used with `attachments`). */\n readonly camera?: AttachmentsConfig['Camera'];\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, presentation, actions, renderEntry, hideLine, historicalMessageActions, unread, replyError, renderTool: catchAll, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, onFiles, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend, rowExtras, feedback, onOpenFile, fileChanges, pasteToFile, composerChips, composerHeader, composerHasContent, onPickFiles } = 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 natural = presentation === 'natural';\n // A tool's own renderer comes first; `renderTool` stays the catch-all.\n const renderTool = useMemo(() => toolRenderers ? (call: ToolCall, result: ToolResultMessage | undefined) => (Object.hasOwn(toolRenderers, call.name) ? toolRenderers[call.name]?.(call, result) : undefined) ?? catchAll?.(call, result) : catchAll, [toolRenderers, catchAll]);\n const developer = mode === 'developer' && !natural;\n // Only the recogniser takes part in deriving rows; which card is open changes without rebuilding them.\n const offered = artifacts?.recognise && artifacts.Card ? artifacts : undefined;\n const artifactDetect = useMemo(() => offered?.recognise ? { recognise: offered.recognise, detect: offered.detect } : undefined, [offered?.recognise, offered?.detect]);\n const subagent = cards?.subagent;\n const derived = useMemo(() => derive(state.view, { mode, presentation, hideLine, renderEntry, renderTool, groupTool, artifacts: artifactDetect, subagent }), [state.view, mode, presentation, hideLine, renderEntry, renderTool, groupTool, artifactDetect, subagent]);\n const queued = useMemo(() => queuedMessages(state.view), [state.view]);\n const { run } = derived;\n const earlier = useMemo<readonly Row[]>(() => {\n if (!state.view || !('entries' in state.history)) return [];\n const activeIds = new Set(state.view.entries.map(entry => entry.id));\n const entries = state.history.entries.filter(entry => !activeIds.has(entry.id));\n return derive({ ...state.view, entries, docs: {} }, { mode, presentation, hideLine, renderEntry: undefined, renderTool: undefined, groupTool: undefined, artifacts: artifactDetect, subagent }).rows\n .map(row => ({ ...row, key: `history:${row.key}`, historical: true }));\n }, [state.view, state.history, mode, presentation, hideLine, artifactDetect, subagent]);\n const rows = useMemo(() => [...earlier, ...derived.rows], [earlier, derived.rows]);\n const files = useMemo(() => {\n const found = new Map<string, ConversationFile>();\n const open = onOpenFile ?? mentions?.open;\n const add = (path: string) => {\n const normalized = path.replace(/^\\.\\//, '');\n const id = `file:${normalized}`;\n if (!found.has(id)) found.set(id, { id, name: displayName(normalized), open: open ? () => open(normalized) : undefined });\n };\n for (const path of picked) add(path);\n for (const item of state.draft.attachments) found.set(`draft:${item.id}`, { id: `draft:${item.id}`, name: item.name, open: onOpenImage ? () => onOpenImage(item.content) : undefined });\n for (const row of derived.rows) {\n if (row.type === 'user') {\n const content = typeof row.message.content === 'string' ? [{ type: 'text' as const, text: row.message.content }] : row.message.content;\n for (const [index, part] of content.entries()) {\n if (part.type === 'text' && !isFileBlock(part.text)) for (const piece of pieces(part.text, { mentions: true, skills: [] })) { if (piece.kind === 'mention' && piece.value) add(piece.value); }\n if (part.type === 'image') { const id = `image:${row.key}:${index}`; found.set(id, { id, name: labels.imageAttachment, open: onOpenImage ? () => onOpenImage(part) : undefined }); }\n }\n }\n if (row.type !== 'assistant') continue;\n for (const part of row.parts) {\n if (part.kind === 'artifact' && part.artifact.state === 'ready' && part.artifact.path !== undefined) {\n const descriptor = part.artifact.descriptor as never, id = `file:${part.artifact.path.replace(/^\\.\\//, '')}`;\n found.set(id, { id, name: displayName(part.artifact.path), open: artifacts?.open ? () => artifacts.open(descriptor) : undefined });\n }\n if (!natural && part.kind === 'tool' && part.entry.result && !part.entry.result.isError && ['read', 'write', 'edit'].includes(part.entry.call.name)) {\n const path = part.entry.call.arguments['path'];\n if (typeof path === 'string') add(path);\n }\n }\n }\n return [...found.values()];\n }, [rows, picked, state.draft.attachments, artifacts, onOpenFile, mentions?.open, onOpenImage, labels.imageAttachment]);\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 = derived.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 /** Uploads files; `restore` (a pasted text) is what their pending cards undo: it abandons the upload and puts the text back. */\n async function upload(files: readonly File[], paste?: { readonly restore: () => void; readonly after: string }) {\n if (!files.length || !current()) return;\n if (onPickFiles) { onPickFiles(files); return; }\n if (!onFiles && !attachments) 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 const cancel = paste && (() => { abort.abort(); setPending(items => items.filter(item => !ids.includes(item.id))); paste.restore(); });\n setPending(items => [...items, ...files.map((file, index): PendingUpload => ({ id: ids[index]!, name: file.name, state: 'uploading',\n ...(cancel && paste ? { cancel, restoreWhile: paste.after } : {}) }))]);\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 /** A paste the composer took: saved as a file under `uploads/` (Send then references it). Cancelling puts the text back where it was pasted. */\n const pasteText = (paste: PastedText) => {\n if (!attachments || !current()) return;\n const at = new Date(), two = (value: number) => String(value).padStart(2, '0');\n const stamp = `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}-${two(at.getMinutes())}-${two(at.getSeconds())}`;\n const markdown = looksLikeMarkdown(paste.text);\n const file = new File([paste.text], `${labels.pastedFileName(stamp)}${markdown ? '.md' : '.txt'}`, { type: markdown ? 'text/markdown' : 'text/plain' });\n // The pasted text replaces its selection when the paste is accepted, so the draft shows what is left. Restoring inserts the text at\n // the same place and deletes nothing: whatever was typed since stays.\n const draft = controller.getSnapshot().draft.text;\n const start = Math.min(paste.start, draft.length), end = Math.min(Math.max(paste.end, start), draft.length);\n const after = `${draft.slice(0, start)}${draft.slice(end)}`;\n controller.setText(after);\n const restore = () => {\n if (!current()) return;\n const now = controller.getSnapshot().draft.text;\n const at = Math.min(start, now.length);\n controller.setText(`${now.slice(0, at)}${paste.text}${now.slice(at)}`);\n textarea.current?.focus();\n };\n void upload([file], { restore, after });\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, natural, renderText, cards, groupTool, onOpenImage, onCopy, openFile: onOpenFile ?? mentions?.open, fileChanges,\n commandMentions: commandMentions ? { commands: commandMentions.commands, onActivate: activateMention } : undefined,\n answer: actions?.answer,\n artifacts: offered?.Card ? { open: offered.open, isOpen: offered.isOpen, kinds: offered.kinds, Card: offered.Card } : undefined,\n messageActions, historicalMessageActions, unread, replyError,\n replyFooter,\n pieces: slash?.skills?.length || mentions || attachments ? { mentions: Boolean(mentions), uploads: Boolean(attachments), skills: (slash?.skills ?? []).map(skill => skill.name), openMention: mentions?.open, renderMention: mentions?.renderSent ?? mentions?.render, preview: mentions?.preview, audioUrl: mentions?.audioUrl } : undefined,\n ...rowExtras,\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }), [controller, developer, natural, renderText, cards, groupTool, onOpenImage, onCopy, onOpenFile, fileChanges, commandMentions, actions?.answer, slash?.skills, mentions, mentions?.open, mentions?.render, mentions?.renderSent, mentions?.preview, mentions?.audioUrl, attachments, offered?.open, offered?.isOpen, offered?.kinds, offered?.Card, messageActions, historicalMessageActions, unread, replyError, 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 // The pill is drawn by the component the config carries (`pi-chat-pickers`); without one there is no pill.\n const Picker = model?.Picker ?? effort?.Picker;\n const barStart = (model || effort) && Picker ? <Picker 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 variant=\"ghost\" size=\"sm\" role=\"alert\" data-testid=\"configure-error\" title={`${changeError} (${labels.dismissHint})`} onClick={() => setChangeError(null)}\n className=\"min-w-0 max-w-[16rem]\"><span className=\"truncate\">{changeError}</span></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 const camera = attachments ? attachments.Camera : options.camera;\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 hasFiles: Boolean(composerHasContent),\n uploading: uploading > 0, canAttach: Boolean(onPickFiles || onFiles || attachments), ...(camera ? { camera } : {}), 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 onPasteText: attachments ? pasteText : undefined, pasteToFile,\n quote: composerHeader,\n feedback, chips: composerChips,\n };\n\n return { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, sendBlocked, error, setError, report, act, current, textarea, rowContext, composer, files, pickers, empty, loading, developer, natural };\n}\n\nexport type ChatSession = ReturnType<typeof useChatSession>;\n\n/** One scroll root for current and archived rows. Native paging stays in the borrowed controller. */\nexport function useTranscript(rows: readonly { readonly key: string }[], page = 60, more = 40, history?: { readonly state: ChatHistoryState; readonly connected: boolean; readonly load: () => Promise<void> }, automatic = true) {\n if (!Number.isSafeInteger(page) || page < 1) throw new TypeError('Chat displayLimit must be a positive integer');\n const [limit, setLimit] = useState(page);\n const [sentinel, setSentinel] = useState<HTMLDivElement | null>(null);\n const [loaded, setLoaded] = useState(0);\n const pending = useRef(false);\n const anchor = useRef<{ readonly length: number; readonly limit: number } | undefined>(undefined);\n const { scrollToEnd } = useMessageScroller();\n const scrollable = useMessageScrollerScrollable();\n const [scroller, setScroller] = useState<HTMLDivElement | null>(null);\n const scrollToBottom = useCallback((behavior?: 'instant') => scrollToEnd({ behavior: behavior === 'instant' ? 'instant' : 'smooth' }), [scrollToEnd]);\n const stick = { scrollRef: setScroller, scrollToBottom, isAtBottom: !scrollable.end };\n const hidden = Math.max(0, rows.length - limit);\n const reveal = useCallback(() => {\n if (pending.current || anchor.current) return;\n const state = history?.state;\n if (hidden === 0 && (!history?.connected || !state || state.kind === 'disabled' || state.kind === 'loading' || state.kind === 'ready' && !state.hasMore)) return;\n anchor.current = { length: rows.length, limit };\n if (hidden > 0) setLimit(value => value + more);\n else if (history) {\n pending.current = true;\n void Promise.resolve().then(history.load).catch(() => { anchor.current = undefined; }).finally(() => { pending.current = false; });\n }\n }, [hidden, history?.state, history?.connected, history?.load, rows.length, limit, more]);\n useLayoutEffect(() => {\n const saved = anchor.current;\n if (!scroller || !saved || history?.state.kind === 'loading') return;\n const added = Math.max(0, rows.length - saved.length);\n if (limit < saved.limit + added) { setLimit(saved.limit + added); return; }\n anchor.current = undefined;\n setLoaded(count => count + Math.max(0, Math.min(rows.length, limit) - Math.min(saved.length, saved.limit)));\n }, [rows, limit, scroller, history?.state]);\n useEffect(() => {\n if (!automatic || !scroller || !sentinel || typeof IntersectionObserver === 'undefined' || history?.state.kind === 'loading' || history?.state.kind === 'error') return;\n const observer = new IntersectionObserver(entries => {\n if (entries.some(entry => entry.isIntersecting) && (scroller.scrollHeight <= scroller.clientHeight || scrollable.start)) reveal();\n }, { root: scroller, rootMargin: '240px 0px 0px' });\n observer.observe(sentinel);\n return () => observer.disconnect();\n }, [automatic, scroller, sentinel, reveal, scrollable.start, history?.state]);\n const revealTo = (key: string) => { const index = rows.findIndex(row => row.key === key); if (index >= 0) setLimit(value => Math.max(value, rows.length - index)); };\n return { stick, hidden, reveal, revealTo, sentinelRef: setSentinel, loaded, visible: <T,>(items: readonly T[]): readonly T[] => hidden ? items.slice(hidden) : items, reset: () => { anchor.current = undefined; setLimit(page); } };\n}\n",
205
153
  "type": "registry:component",
206
154
  "target": "components/pi-chat/session.tsx"
207
155
  },
@@ -212,10 +160,10 @@
212
160
  "target": "components/pi-chat/shimmer.tsx"
213
161
  },
214
162
  {
215
- "path": "registry/pi-chat/slash-menu.tsx",
216
- "content": "'use client';\n\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport { SearchIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group';\nimport { Item, ItemContent, ItemDescription, ItemTitle } from '@/components/ui/item';\nimport { usePickerKeyboard } from './picker-keyboard';\nimport { ComposerSuggestions } from './mention-menu';\nimport { slashGroup } from './config';\nimport type { SlashPickerProps } from './config';\nimport { useChatText } from './labels';\n\nconst ALL = '__all__';\n\n/**\n * The `/` picker, ported from v2's `SlashCommandPicker`: a search box seeded from what was typed after `/`, filter chips\n * (All, built-in, skills, plugin names), then rows with a name, badges and a one line description.\n */\nexport function SlashPicker({ query, items, onSelect, onDismiss }: SlashPickerProps) {\n const { labels } = useChatText();\n const [group, setGroup] = useState(ALL);\n const [search, setSearch] = useState(query);\n const [active, setActive] = useState(0);\n const list = useRef<HTMLUListElement>(null);\n // Follow the composer text.\n useEffect(() => { setSearch(query); setActive(0); }, [query]);\n const groups = useMemo(() => [...new Set(items.map(slashGroup))].sort((a, b) => a.localeCompare(b)), [items]);\n const filtered = useMemo(() => {\n const needle = search.trim().toLowerCase();\n return items.filter(item => (group === ALL || slashGroup(item) === group)\n && (!needle || item.name.toLowerCase().includes(needle) || item.description.toLowerCase().includes(needle)));\n }, [items, search, group]);\n useEffect(() => { setActive(index => filtered.length === 0 ? 0 : Math.min(index, filtered.length - 1)); }, [filtered.length]);\n usePickerKeyboard({ count: filtered.length, activeIdx: active, setActiveIdx: setActive, listRef: list, onSelect: index => { const item = filtered[index]; if (item) onSelect(item); }, onDismiss });\n\n const optionId = (index: number) => `pi-chat-slash-${index}`;\n return <ComposerSuggestions testId=\"slash-menu\" onDismiss={onDismiss}>\n <InputGroup>\n <InputGroupAddon><SearchIcon aria-hidden=\"true\" /></InputGroupAddon>\n <InputGroupInput className=\"max-sm:min-h-11 pointer-coarse:min-h-11\" aria-label={labels.searchCommands} data-testid=\"slash-search\" type=\"text\" autoComplete=\"off\" spellCheck={false} value={search}\n onChange={event => { setSearch(event.currentTarget.value); setActive(0); }} placeholder={labels.searchCommands} />\n </InputGroup>\n <div role=\"tablist\" aria-label={labels.filterBySource} className=\"flex flex-wrap gap-1\">\n {[ALL, ...groups].map(name => <Button key={name} size=\"xs\" variant={group === name ? 'secondary' : 'ghost'} role=\"tab\" aria-selected={group === name} data-testid=\"slash-chip\" data-group={name === ALL ? 'all' : name}\n onMouseDown={event => { event.preventDefault(); setGroup(name); setActive(0); }}>{name === ALL ? labels.allSources : labels.slashGroup(name)}</Button>)}\n </div>\n {filtered.length === 0\n ? <div data-testid=\"slash-empty\" className=\"text-xs text-muted-foreground\">{labels.noCommands}</div>\n : <ul ref={list} role=\"listbox\" aria-label={labels.commands} className=\"m-0 flex max-h-[min(20rem,45dvh)] list-none flex-col overflow-y-auto p-0\">\n {filtered.map((item, index) => <Item key={`${item.source}:${item.name}`} render={<li />} size=\"xs\" variant={index === active ? 'muted' : 'default'} id={optionId(index)} role=\"option\" aria-selected={index === active} title={item.description || undefined}\n data-testid=\"slash-item\" data-name={item.name} data-source={item.source} data-active={index === active ? 'true' : undefined}\n onMouseEnter={() => setActive(index)} onMouseDown={event => { event.preventDefault(); onSelect(item); }}\n className=\"cursor-pointer flex-nowrap max-sm:min-h-11 pointer-coarse:min-h-11\">\n <ItemContent className=\"min-w-0\">\n <ItemTitle>/{item.name}\n {item.source === 'skill' && <Badge variant=\"secondary\" data-testid=\"slash-badge-skill\">{labels.skillBadge}</Badge>}\n {item.sourcePlugin && <Badge variant=\"outline\" data-testid=\"slash-badge-source\">{item.sourcePlugin}</Badge>}\n </ItemTitle>\n {item.description && <ItemDescription className=\"m-0 truncate\">{item.description}</ItemDescription>}\n </ItemContent>\n </Item>)}\n </ul>}\n </ComposerSuggestions>;\n}\n",
163
+ "path": "registry/pi-chat/suggestions.tsx",
164
+ "content": "'use client';\n\nimport type { ReactNode } from 'react';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\n\n/**\n * The host's shadcn Popover above the composer, anchored to the nearest positioned ancestor's top edge and as wide as it.\n * Focus stays in the message box (the picker keyboard drives the rows); outside presses dismiss it.\n */\nexport function ComposerSuggestions({ testId, onDismiss, children }: { readonly testId: string; readonly onDismiss: () => void; readonly children: ReactNode }) {\n return <Popover open modal={false} onOpenChange={open => { if (!open) onDismiss(); }}>\n <PopoverTrigger nativeButton={false} render={<span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-0 top-0\" />} />\n <PopoverContent data-testid={testId} side=\"top\" align=\"start\" initialFocus={false} finalFocus={false} className=\"pi-chat w-(--anchor-width) max-w-[calc(100vw-1rem)]\">\n {children}\n </PopoverContent>\n </Popover>;\n}\n",
217
165
  "type": "registry:component",
218
- "target": "components/pi-chat/slash-menu.tsx"
166
+ "target": "components/pi-chat/suggestions.tsx"
219
167
  },
220
168
  {
221
169
  "path": "registry/pi-chat/tool.tsx",
@@ -258,36 +206,6 @@
258
206
  "content": "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport type { ConversationItem, ConversationsConfig } from './config';\n\n/** One row as `createConversationsHandler` of `@hachej/boring-agent/conversations` returns it. */\ninterface ServedConversation {\n readonly id: number | string;\n readonly title: string | null;\n readonly lastMessage: string | null;\n readonly updatedAt: number;\n readonly archived: boolean;\n}\n\nconst item = (served: ServedConversation): ConversationItem => ({ id: String(served.id), title: served.title ?? undefined, updatedAt: served.updatedAt, lastMessage: served.lastMessage, archived: served.archived });\n\n/**\n * The sessions list over the host's conversations handler (`createConversationsHandler` of `@hachej/boring-agent/conversations`, mounted at\n * `endpoint`): the active list (polled every `pollMs` while mounted), server-side search with the Archived filter, New, rename, archive,\n * delete and fork. New and fork select the conversation they made through `onSelect`. `onChange` runs after every change the person made\n * (the host may reload what depends on the list). `fetch` is the host's authenticated fetch. Returns `undefined` until the first list arrives.\n */\nexport function useConversations({ endpoint, fetch, activeId, onSelect, onChange, pollMs = 4000 }: {\n /** Undefined: nothing is listed yet (for example while the host is still loading). */\n readonly endpoint: string | URL | undefined;\n readonly fetch: (request: Request) => Promise<Response>;\n readonly activeId: string | undefined;\n readonly onSelect: (id: string) => void;\n readonly onChange?: (() => void | Promise<void>) | undefined;\n readonly pollMs?: number;\n}): (ConversationsConfig & { readonly reload: () => Promise<void> }) | undefined {\n const [items, setItems] = useState<readonly ConversationItem[] | undefined>();\n const latest = useRef({ fetch, onSelect, onChange, activeId });\n latest.current = { fetch, onSelect, onChange, activeId };\n const base = endpoint === undefined ? undefined : String(endpoint);\n const call = useCallback(async (params: Record<string, string>, body?: unknown, signal?: AbortSignal) => {\n if (base === undefined) throw new Error('No conversations endpoint');\n const url = new URL(base, globalThis.location?.href);\n for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);\n const init: RequestInit = body === undefined ? { ...(signal ? { signal } : {}) } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), ...(signal ? { signal } : {}) };\n const response = await latest.current.fetch(new Request(url, init));\n if (!response.ok) throw new Error(`Conversations: ${response.status}`);\n return await response.json() as { conversations?: readonly ServedConversation[]; conversationId?: number | string };\n }, [base]);\n const reload = useCallback(async () => { const result = await call({}); setItems((result.conversations ?? []).map(item)); }, [call]);\n // Another endpoint (another owner's list) starts empty; switching conversations only reloads.\n useEffect(() => { setItems(undefined); }, [base]);\n useEffect(() => {\n if (base === undefined) return;\n let off = false;\n const load = () => call({}).then(result => { if (!off) setItems((result.conversations ?? []).map(item)); }).catch(() => {});\n void load();\n const timer = setInterval(load, pollMs);\n return () => { off = true; clearInterval(timer); };\n }, [call, pollMs, activeId]); // eslint-disable-line react-hooks/exhaustive-deps\n return useMemo(() => {\n if (!items) return undefined;\n const changed = async () => { await reload().catch(() => {}); await latest.current.onChange?.(); };\n const op = (name: string, body: unknown) => call({ op: name }, body);\n return {\n items, activeId, reload,\n onSelect: (id: string) => latest.current.onSelect(id),\n onNew: () => { void op('create', {}).then(async result => { await changed(); if (result.conversationId !== undefined) latest.current.onSelect(String(result.conversationId)); }).catch(() => {}); },\n search: async (query: string, { archived }: { readonly archived: boolean }, signal: AbortSignal) =>\n ((await call({ ...(query ? { q: query } : {}), ...(archived ? { archived: '1' } : {}) }, undefined, signal)).conversations ?? []).map(item),\n rename: async (id: string, title: string) => { await op('rename', { conversationId: id, title }); await changed(); },\n archive: async (id: string, archived: boolean) => { await op('archive', { conversationId: id, archived }); await changed(); },\n remove: async (id: string) => { await op('delete', { conversationId: id }); await changed(); },\n fork: async (at: string) => {\n const from = latest.current.activeId;\n if (from === undefined) return;\n const { conversationId } = await op('fork', { conversationId: from, at });\n await changed();\n if (conversationId !== undefined) latest.current.onSelect(String(conversationId));\n },\n };\n }, [items, activeId, call, reload]);\n}\n",
259
207
  "type": "registry:hook",
260
208
  "target": "components/pi-chat/use-conversations.ts"
261
- },
262
- {
263
- "path": "registry/pi-chat/run-card.tsx",
264
- "content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemContent, ItemDescription, ItemFooter, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Spinner } from '@/components/ui/spinner';\nimport { CheckIcon, CircleAlertIcon, RefreshCwIcon } from 'lucide-react';\nimport { CodeBlock } from './code-block';\nimport { Markdown } from './markdown';\nimport { useChatText } from './labels';\n\nexport type RunStatus = 'queued' | 'running' | 'completed' | 'failed';\n/** A headless run as the host's `GET _agent/<name>/runs/<id>` returns it. */\nexport interface RunRecord {\n readonly id: string;\n readonly status: RunStatus;\n /** The structured result: shown as Markdown when it is text, as JSON otherwise. */\n readonly output?: unknown;\n /** The run's final text, shown as Markdown when there is no `output`. */\n readonly text?: string | undefined;\n readonly error?: string | undefined;\n /** The agent's title. */\n readonly agent?: string | undefined;\n /** Epoch milliseconds or an ISO date. */\n readonly startedAt?: number | string | undefined;\n readonly finishedAt?: number | string | undefined;\n}\n\nconst STATUSES: readonly RunStatus[] = ['queued', 'running', 'completed', 'failed'];\nconst time = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) ? value : typeof value === 'string' && Number.isFinite(Date.parse(value)) ? Date.parse(value) : undefined;\nconst optionalText = (value: unknown): string | undefined => typeof value === 'string' && value ? value : undefined;\n\n/** Reads a run record defensively (it comes over the network); undefined when it has no id or no known status. */\nexport function parseRun(value: unknown): RunRecord | undefined {\n if (value === null || typeof value !== 'object') return undefined;\n const record = value as Record<string, unknown>;\n const { id, status } = record;\n if ((typeof id !== 'string' || !id) || typeof status !== 'string' || !(STATUSES as readonly string[]).includes(status)) return undefined;\n const startedAt = time(record['startedAt']), finishedAt = time(record['finishedAt']);\n return { id, status: status as RunStatus, ...(record['output'] === undefined || record['output'] === null ? {} : { output: record['output'] }),\n ...(optionalText(record['text']) ? { text: record['text'] as string } : {}), ...(optionalText(record['error']) ? { error: record['error'] as string } : {}),\n ...(optionalText(record['agent']) ? { agent: record['agent'] as string } : {}), ...(startedAt === undefined ? {} : { startedAt }), ...(finishedAt === undefined ? {} : { finishedAt }) };\n}\n\n/** `42 s`, `3 min 5 s`, `1 h 2 min`. */\nconst span = (ms: number): string => {\n const seconds = Math.max(0, Math.round(ms / 1000));\n if (seconds < 60) return `${seconds} s`;\n const minutes = Math.floor(seconds / 60);\n if (minutes < 60) return `${minutes} min${seconds % 60 ? ` ${seconds % 60} s` : ''}`;\n return `${Math.floor(minutes / 60)} h${minutes % 60 ? ` ${minutes % 60} min` : ''}`;\n};\n\n/**\n * A headless run (`POST _agent/<name>/runs` → `GET runs/<id>`) as a card: the agent's title, a status badge, a spinner while it is queued or\n * running with the elapsed time, then the duration and the output (or text) as Markdown, or the error. The host fetches: give `run` (a\n * record it keeps current), and optionally `fetchRun(id, signal)` to have the card poll every `pollMs` until the run settles and offer\n * Refresh. URLs are the host's (relative to its page). Render it from `renderTool` or `renderEntry`, or anywhere in the page.\n */\nexport function RunCard({ run: given, fetchRun, pollMs = 2000, now: clock = Date.now }: {\n readonly run: RunRecord;\n readonly fetchRun?: ((id: string, signal: AbortSignal) => Promise<unknown>) | undefined;\n readonly pollMs?: number;\n /** Test seam: the clock of the elapsed time. */\n readonly now?: () => number;\n}) {\n const { labels } = useChatText();\n const [fetched, setFetched] = useState<RunRecord | undefined>();\n const [failed, setFailed] = useState(false);\n const run = fetched?.id === given.id ? fetched : given;\n const settled = run.status === 'completed' || run.status === 'failed';\n const [now, setNow] = useState(clock);\n const latest = useRef(fetchRun); latest.current = fetchRun;\n\n const refresh = (signal: AbortSignal) => latest.current?.(given.id, signal).then(value => {\n if (signal.aborted) return;\n const parsed = parseRun(value);\n setFailed(!parsed);\n if (parsed?.id === given.id) setFetched(parsed);\n }).catch(() => { if (!signal.aborted) setFailed(true); });\n const polling = Boolean(fetchRun) && !settled;\n useEffect(() => {\n if (!polling) return;\n const abort = new AbortController();\n void refresh(abort.signal);\n const timer = setInterval(() => { void refresh(abort.signal); }, pollMs);\n return () => { abort.abort(); clearInterval(timer); };\n }, [polling, given.id, pollMs]); // eslint-disable-line react-hooks/exhaustive-deps\n useEffect(() => {\n if (settled || run.startedAt === undefined) return;\n const timer = setInterval(() => setNow(clock()), 1000);\n return () => clearInterval(timer);\n }, [settled, run.startedAt, clock]);\n\n const started = time(run.startedAt), finished = time(run.finishedAt);\n const timing = started === undefined ? undefined : finished !== undefined ? labels.runDuration(span(finished - started)) : settled ? undefined : labels.runElapsed(span(now - started));\n const body = run.output !== undefined ? (typeof run.output === 'string' ? <Markdown text={run.output} /> : <CodeBlock code={JSON.stringify(run.output, null, 2)} language=\"json\" />)\n : run.text ? <Markdown text={run.text} /> : undefined;\n\n return <Item variant=\"outline\" size=\"sm\" render={<section />} data-testid=\"run-card\" data-state={run.status} data-run-id={run.id} aria-label={run.agent ?? labels.run} aria-busy={!settled} className=\"my-2 text-start\">\n <ItemMedia variant=\"icon\">\n {settled ? run.status === 'completed' ? <CheckIcon aria-hidden=\"true\" /> : <CircleAlertIcon aria-hidden=\"true\" /> : <Spinner data-testid=\"run-spinner\" aria-label={labels.runStatus(run.status)} />}\n </ItemMedia>\n <ItemContent className=\"min-w-0 basis-0\">\n <ItemTitle className=\"line-clamp-none [overflow-wrap:anywhere]\">{run.agent ?? labels.run}</ItemTitle>\n <ItemDescription className=\"line-clamp-none flex flex-wrap items-center gap-2\">\n <Badge data-testid=\"run-status\" variant={run.status === 'failed' ? 'destructive' : run.status === 'completed' ? 'secondary' : 'outline'}>{labels.runStatus(run.status)}</Badge>\n {timing && <span data-testid=\"run-time\">{timing}</span>}\n </ItemDescription>\n </ItemContent>\n {fetchRun && <Button variant=\"ghost\" size=\"icon\" data-testid=\"run-refresh\" aria-label={labels.runRefresh} title={labels.runRefresh}\n onClick={() => { void refresh(new AbortController().signal); }}><RefreshCwIcon aria-hidden=\"true\" /></Button>}\n {(body || run.error || failed) && <ItemFooter className=\"flex-col items-stretch\">\n {body && <div data-testid=\"run-output\" className=\"min-w-0\">{body}</div>}\n {run.error && <Alert variant=\"destructive\" data-testid=\"run-error\"><AlertDescription>{run.error}</AlertDescription></Alert>}\n {failed && <p role=\"alert\" className=\"m-0 text-sm text-destructive\">{labels.runNotLoaded}</p>}\n </ItemFooter>}\n </Item>;\n}\n",
265
- "type": "registry:component",
266
- "target": "components/pi-chat/run-card.tsx"
267
- },
268
- {
269
- "path": "registry/pi-chat/subagent-card.tsx",
270
- "content": "'use client';\n\nimport { useEffect, useState } from 'react';\nimport type { ToolResultMessage } from '@earendil-works/pi-ai';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemActions, ItemContent, ItemFooter, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Spinner } from '@/components/ui/spinner';\nimport { ArrowDownIcon, ArrowUpIcon, ArrowUpRightIcon, CheckIcon, CircleAlertIcon, CircleSlashIcon, ClockIcon, WrenchIcon } from 'lucide-react';\nimport { useChatText } from './labels';\n\n/** `DELEGATE_DETAILS_KIND` of `@hachej/boring-agent/agents` (a contract test keeps them equal). */\nexport const DELEGATE_DETAILS_KIND = 'boring.delegate';\nexport type SubagentStatus = 'running' | 'done' | 'failed' | 'cancelled';\n/** A delegate call's details (`DelegateDetails` of `@hachej/boring-agent/agents`, version 1) as the card reads them. */\nexport interface SubagentDetails {\n readonly agent: string;\n /** The child conversation (Pi's conversation id). */\n readonly conversationId: number;\n readonly status: SubagentStatus;\n /** Epoch milliseconds. */\n readonly startedAt: number;\n readonly durationMs: number;\n readonly toolCalls: number;\n readonly tokens: { readonly input: number; readonly output: number };\n readonly error?: string;\n}\n\nconst STATUSES: readonly SubagentStatus[] = ['running', 'done', 'failed', 'cancelled'];\nconst count = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;\n\n/**\n * Reads a delegate call's details defensively (they come from the conversation): undefined unless they are version 1\n * `DelegateDetails`. With the call's `result`, a value still `running` is settled from it: Pi writes the result of a stopped call\n * from the last running details, so a settled `running` reads as `cancelled` (or `failed` with the result's text when it is not\n * an abort), with the duration up to the result.\n */\nexport function parseDelegateDetails(value: unknown, result?: Pick<ToolResultMessage, 'isError' | 'timestamp' | 'content'>): SubagentDetails | undefined {\n if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;\n const record = value as Record<string, unknown>;\n if (record['kind'] !== DELEGATE_DETAILS_KIND || record['version'] !== 1) return undefined;\n const { agent, conversationId, status } = record;\n const tokens = record['tokens'] !== null && typeof record['tokens'] === 'object' ? record['tokens'] as Record<string, unknown> : {};\n const startedAt = count(record['startedAt']), durationMs = count(record['durationMs']), toolCalls = count(record['toolCalls']);\n const input = count(tokens['input']), output = count(tokens['output']);\n if (typeof agent !== 'string' || count(conversationId) === undefined || typeof status !== 'string' || !(STATUSES as readonly string[]).includes(status)\n || startedAt === undefined || durationMs === undefined || toolCalls === undefined || input === undefined || output === undefined) return undefined;\n const error = typeof record['error'] === 'string' && record['error'] ? record['error'] : undefined;\n const details: SubagentDetails = { agent, conversationId: conversationId as number, status: status as SubagentStatus, startedAt, durationMs, toolCalls, tokens: { input, output }, ...(error ? { error } : {}) };\n if (!result || details.status !== 'running') return details;\n const text = result.content.flatMap(part => part.type === 'text' ? [part.text] : []).join('\\n').trim();\n const ended = Math.max(details.durationMs, typeof result.timestamp === 'number' ? result.timestamp - details.startedAt : 0);\n if (!result.isError) return { ...details, status: 'done', durationMs: ended };\n return /\\babort/i.test(text) || !text ? { ...details, status: 'cancelled', durationMs: ended } : { ...details, status: 'failed', durationMs: ended, error: text };\n}\n\n/** `42 s`, `3 min 5 s`, `1 h 2 min` (under a second: `0.4 s`). */\nconst span = (ms: number): string => {\n if (ms < 1000) return `${(Math.max(0, ms) / 1000).toFixed(1)} s`;\n const seconds = Math.round(ms / 1000);\n if (seconds < 60) return `${seconds} s`;\n const minutes = Math.floor(seconds / 60);\n if (minutes < 60) return `${minutes} min${seconds % 60 ? ` ${seconds % 60} s` : ''}`;\n return `${Math.floor(minutes / 60)} h${minutes % 60 ? ` ${minutes % 60} min` : ''}`;\n};\nconst compact = (value: number): string => value < 1000 ? String(value) : value < 1_000_000 ? `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)}k` : `${(value / 1_000_000).toFixed(1)}M`;\n\n/**\n * A sub-agent (`delegate`) call as a card: the child agent, its status, the time it has run, its tool calls and tokens, and the\n * child conversation's id; the error when it failed. The chat renders it for any tool call whose details are `DelegateDetails`,\n * live while the child runs. `onOpen(conversationId)` makes the id a button that opens the child conversation.\n */\nexport function SubagentCard({ details, onOpen, now: clock = Date.now }: {\n readonly details: SubagentDetails;\n readonly onOpen?: ((conversationId: number) => void) | undefined;\n /** Test seam: the clock of the elapsed time. */\n readonly now?: () => number;\n}) {\n const { labels } = useChatText();\n const running = details.status === 'running';\n const [now, setNow] = useState(clock);\n useEffect(() => {\n if (!running) return;\n setNow(clock());\n const timer = setInterval(() => setNow(clock()), 1000);\n return () => clearInterval(timer);\n }, [running, clock]);\n const elapsed = running ? Math.max(details.durationMs, now - details.startedAt) : details.durationMs;\n const conversation = labels.subagentConversation(details.conversationId);\n const status = labels.subagentStatus(details.status);\n const tokens = labels.subagentTokens(details.tokens.input, details.tokens.output);\n const calls = labels.subagentToolCalls(details.toolCalls);\n const time = `${labels.subagentTime}: ${span(elapsed)}`;\n return <Item variant=\"outline\" size=\"xs\" render={<section />} data-testid=\"subagent-card\" data-state={details.status} data-conversation-id={details.conversationId}\n aria-label={`${details.agent}, ${status}`} aria-busy={running} className=\"my-1 text-start\">\n <ItemMedia variant=\"icon\" data-testid=\"subagent-status\" role=\"img\" aria-label={status} title={status}>\n <span className={details.status === 'failed' ? 'contents text-destructive' : details.status === 'done' ? 'contents' : 'contents text-muted-foreground'}>\n {running ? <Spinner data-testid=\"subagent-spinner\" aria-hidden=\"true\" />\n : details.status === 'done' ? <CheckIcon aria-hidden=\"true\" /> : details.status === 'failed' ? <CircleAlertIcon aria-hidden=\"true\" /> : <CircleSlashIcon aria-hidden=\"true\" />}\n </span>\n </ItemMedia>\n <ItemContent className=\"min-w-0\">\n <div className=\"flex flex-wrap items-center gap-x-3 gap-y-0.5\">\n <ItemTitle className=\"min-w-0 [overflow-wrap:anywhere]\">{details.agent}</ItemTitle>\n <span className=\"inline-flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground tabular-nums [&_svg]:size-3.5 [&_svg]:shrink-0\">\n <span data-testid=\"subagent-time\" className=\"inline-flex items-center gap-1\" title={time} aria-label={time}><ClockIcon aria-hidden=\"true\" />{span(elapsed)}</span>\n <span data-testid=\"subagent-tool-calls\" data-count={details.toolCalls} className=\"inline-flex items-center gap-1\" title={calls} aria-label={calls}><WrenchIcon aria-hidden=\"true\" />{details.toolCalls}</span>\n <span data-testid=\"subagent-tokens\" className=\"inline-flex items-center gap-0.5\" title={tokens} aria-label={tokens}>\n <ArrowUpIcon aria-hidden=\"true\" />{compact(details.tokens.input)}<ArrowDownIcon aria-hidden=\"true\" className=\"ms-1\" />{compact(details.tokens.output)}\n </span>\n </span>\n </div>\n </ItemContent>\n <ItemActions>\n {onOpen\n ? <Button variant=\"ghost\" size=\"xs\" data-testid=\"subagent-open\" title={conversation} aria-label={conversation} onClick={() => onOpen(details.conversationId)}>\n #{details.conversationId}<ArrowUpRightIcon aria-hidden=\"true\" data-icon=\"inline-end\" />\n </Button>\n : <span data-testid=\"subagent-conversation\" className=\"text-xs text-muted-foreground tabular-nums\" title={conversation}>#{details.conversationId}</span>}\n </ItemActions>\n {details.status === 'failed' && details.error && <ItemFooter>\n <p data-testid=\"subagent-error\" role=\"alert\" className=\"m-0 ps-6 text-xs text-destructive [overflow-wrap:anywhere]\">{details.error}</p>\n </ItemFooter>}\n </Item>;\n}\n",
271
- "type": "registry:component",
272
- "target": "components/pi-chat/subagent-card.tsx"
273
- },
274
- {
275
- "path": "registry/pi-chat/use-hold-to-record.ts",
276
- "content": "'use client';\n\nimport { useEffect, useRef } from 'react';\nimport type { KeyboardEvent, PointerEvent } from 'react';\n\nexport interface HoldToRecordActions {\n readonly start: () => void;\n readonly finish: () => void;\n readonly cancel: () => void;\n}\n\n/** The immediate microphone and delayed shell trigger share cancellation, but retain ownership of their own held gesture. */\nexport function useRecordingCancellation(cancel: () => void) {\n const latest = useRef(cancel); latest.current = cancel;\n useEffect(() => {\n const stop = () => latest.current();\n const escape = (event: globalThis.KeyboardEvent) => { if (event.key === 'Escape') stop(); };\n const hidden = () => { if (document.hidden) stop(); };\n window.addEventListener('keydown', escape, true); window.addEventListener('blur', stop); document.addEventListener('visibilitychange', hidden);\n return () => {\n window.removeEventListener('keydown', escape, true); window.removeEventListener('blur', stop); document.removeEventListener('visibilitychange', hidden);\n stop();\n };\n }, []);\n}\n\n/** Gesture policy shared by the chat and shell. Pointer cancellation keeps audio; deliberate slide/Escape/lost visibility discards it. */\nexport function useHoldToRecord(actions: HoldToRecordActions, disabled = false) {\n const ref = useRef<HTMLButtonElement>(null);\n const latest = useRef({ actions, disabled }); latest.current = { actions, disabled };\n const held = useRef<number | string | null>(null);\n const cancel = () => { if (held.current === null) return; held.current = null; latest.current.actions.cancel(); };\n useRecordingCancellation(cancel);\n useEffect(() => {\n const node = ref.current;\n const keep = (event: Event) => { if (event.cancelable) event.preventDefault(); };\n node?.addEventListener('touchstart', keep, { passive: false });\n return () => node?.removeEventListener('touchstart', keep);\n }, []);\n const release = (event: PointerEvent<HTMLButtonElement>) => {\n if (held.current !== event.pointerId) return;\n held.current = null;\n if (event.currentTarget.hasPointerCapture?.(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);\n latest.current.actions.finish();\n };\n const key = (event: KeyboardEvent<HTMLButtonElement>, down: boolean) => {\n if (event.key !== ' ' && event.key !== 'Enter') return;\n event.preventDefault();\n if (down) {\n if (event.repeat || latest.current.disabled || held.current !== null) return;\n held.current = event.key; latest.current.actions.start();\n } else if (held.current === event.key) { held.current = null; latest.current.actions.finish(); }\n };\n return {\n ref,\n onPointerDown: (event: PointerEvent<HTMLButtonElement>) => {\n event.preventDefault();\n if (event.button !== 0 || latest.current.disabled || held.current !== null) return;\n held.current = event.pointerId; event.currentTarget.setPointerCapture?.(event.pointerId); latest.current.actions.start();\n },\n onPointerUp: release,\n onPointerCancel: release,\n onPointerMove: (event: PointerEvent<HTMLButtonElement>) => {\n if (held.current !== event.pointerId) return;\n const box = event.currentTarget.getBoundingClientRect(), slack = 24;\n if (event.clientX < box.left - slack || event.clientX > box.right + slack || event.clientY < box.top - slack || event.clientY > box.bottom + slack) cancel();\n },\n onMouseDown: (event: { preventDefault: () => void }) => event.preventDefault(),\n onContextMenu: (event: { preventDefault: () => void }) => event.preventDefault(),\n onKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => key(event, true),\n onKeyUp: (event: KeyboardEvent<HTMLButtonElement>) => key(event, false),\n onBlur: cancel,\n };\n}\n",
277
- "type": "registry:hook",
278
- "target": "components/pi-chat/use-hold-to-record.ts"
279
- },
280
- {
281
- "path": "registry/pi-chat/use-voice-message.ts",
282
- "content": "'use client';\n\nimport { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';\nimport { createVoiceCapture, DEFAULT_MAX_VOICE_MS } from '@hachej/boring-ui-kit/voice-capture';\nimport type { VoiceCapture, VoiceRecording, VoiceRefusal } from '@hachej/boring-ui-kit/voice-capture';\nimport type { ChatController, ChatDraft } from '@hachej/boring-ui-kit/native-chat';\nimport { isVoicePath, mentionToken } from './config';\nimport type { AttachmentsConfig, PendingVoice, VoiceMessageConfig, VoiceMessageHandle, VoicePhase } from './config';\nimport type { ChatLabels } from './labels';\n\ninterface Attempt {\n readonly id: number;\n readonly controller: ChatController;\n readonly target: string;\n readonly transcribe: VoiceMessageConfig['transcribe'];\n readonly upload: AttachmentsConfig['upload'] | undefined;\n readonly abort: AbortController;\n readonly capture: VoiceCapture;\n readonly maxMs: number;\n phase: VoicePhase;\n seconds: number;\n file?: File;\n path?: string;\n transcript?: string;\n requestId?: string;\n}\nexport type { PendingVoice } from './config';\nconst REFUSAL: Readonly<Record<VoiceRefusal, keyof Pick<ChatLabels, 'voiceDenied' | 'voiceNoMicrophone' | 'voiceBusy' | 'voiceInsecure' | 'voiceUnsupported' | 'voiceStartFailed'>>> = {\n denied: 'voiceDenied', 'no-microphone': 'voiceNoMicrophone', busy: 'voiceBusy', insecure: 'voiceInsecure', unsupported: 'voiceUnsupported', failed: 'voiceStartFailed',\n};\nconst targetOf = (controller: ChatController) => {\n const { identity, conversationId } = controller.getSnapshot();\n return JSON.stringify([identity.runtimeId, identity.scopeId, identity.principalId, conversationId]);\n};\nconst noSubscribe = () => () => {};\n\n/** Local admission preparation only. Native send/reconcile owns the submission; uploaded bytes stay in the host workspace. */\nexport function useVoiceMessage({ controller, config, attachments, labels, admitted, activeController }: {\n readonly controller: ChatController | undefined;\n readonly config: VoiceMessageConfig | undefined;\n readonly attachments?: AttachmentsConfig | undefined;\n readonly labels: ChatLabels;\n readonly admitted?: (() => void) | undefined;\n readonly activeController?: { readonly current: ChatController } | undefined;\n}): VoiceMessageHandle {\n const selected = config ? controller : undefined;\n const readObservation = useCallback(() => {\n if (!selected) return '';\n const snapshot = selected.getSnapshot(), send = snapshot.send;\n return JSON.stringify([targetOf(selected), snapshot.disposed, send.kind, 'attempt' in send ? send.attempt.requestId : undefined]);\n }, [selected]);\n const observation = useSyncExternalStore(selected?.subscribe ?? noSubscribe, readObservation, readObservation);\n const latest = useRef({ controller, config, admitted, activeController }); latest.current = { controller, config, admitted, activeController };\n const mounted = useRef(false), attempts = useRef<Attempt[]>([]), captureSequence = useRef(0);\n const [, changed] = useState(0), [problem, setProblem] = useState<string>();\n const update = () => changed(value => value + 1);\n const valid = (item: Attempt) => mounted.current && attempts.current.includes(item) && latest.current.controller === item.controller && (!latest.current.activeController || latest.current.activeController.current === item.controller) && Boolean(latest.current.config) && !item.abort.signal.aborted && !item.controller.getSnapshot().disposed && targetOf(item.controller) === item.target;\n const remove = (item: Attempt) => {\n attempts.current = attempts.current.filter(current => current !== item);\n item.abort.abort(); item.capture.cancel();\n if (mounted.current) update();\n };\n const active = () => attempts.current.find(item => item.phase === 'starting' || item.phase === 'recording') ?? attempts.current.find(item => item.phase === 'processing' || item.phase === 'sending' || item.phase === 'unconfirmed');\n const cancel = () => { const item = active(); if (item) remove(item); };\n const clear = () => { for (const item of [...attempts.current]) remove(item); };\n const target = selected ? targetOf(selected) : undefined;\n useEffect(() => { mounted.current = true; return () => { mounted.current = false; clear(); }; }, []);\n useEffect(() => { clear(); setProblem(undefined); }, [controller, target, Boolean(config)]);\n useEffect(() => {\n for (const item of [...attempts.current]) {\n if (!valid(item)) { remove(item); continue; }\n if (item.phase !== 'unconfirmed') continue;\n const snapshot = selected?.getSnapshot();\n if (snapshot?.send.kind === 'admitted' && 'attempt' in snapshot.send && snapshot.send.attempt.requestId === item.requestId) {\n remove(item); latest.current.admitted?.();\n } else if (snapshot?.send.kind === 'blocked') { item.phase = 'blocked'; update(); }\n }\n }, [selected, observation]);\n const recording = active(), phase = recording?.phase ?? 'idle';\n useEffect(() => {\n const item = recording;\n if (item?.phase !== 'recording') return;\n const started = performance.now();\n const timer = setInterval(() => { if (valid(item)) { item.seconds = Math.floor(Math.min(performance.now() - started, item.maxMs) / 1000); update(); } }, 250);\n return () => clearInterval(timer);\n }, [recording, phase]);\n\n const send = async (item: Attempt, audioOnly = false) => {\n if (!valid(item) || !item.path || item.phase === 'sending' || item.phase === 'unconfirmed') return;\n const transcript = audioOnly ? '' : item.transcript ?? '';\n const text = `${mentionToken(item.path)}${transcript ? ` ${transcript}` : ''}`;\n const before = item.controller.getSnapshot();\n if (before.send.kind === 'unknown') {\n // This recording was never submitted. Do not bind it to another message's uncertain request.\n const draft = item.controller.getSnapshot().draft.text;\n if (transcript) item.controller.setText(`${draft}${draft && !/\\s$/.test(draft) ? ' ' : ''}${transcript}`);\n item.phase = 'blocked'; update(); return;\n }\n item.phase = 'sending'; update();\n let submittedDraft: ChatDraft | undefined;\n try {\n const pending = item.controller.send('followUp', { text, restore: { text: transcript } });\n const after = item.controller.getSnapshot();\n // Native send synchronously publishes this immutable draft in validation or at its outbox position; equal text is not identity.\n submittedDraft = after.send.kind === 'validating' && (before.send.kind !== 'validating' || after.send.draft !== before.send.draft)\n ? after.send.draft : after.outbox[before.outbox.length];\n const result = await pending;\n if (!valid(item)) return;\n if (result) { remove(item); latest.current.admitted?.(); return; }\n } catch { if (!valid(item)) return; }\n const native = item.controller.getSnapshot().send;\n if (native.kind === 'admitted' && 'attempt' in native && submittedDraft !== undefined && native.attempt.draft === submittedDraft) {\n remove(item); latest.current.admitted?.(); return;\n }\n const ownUnknown = native.kind === 'unknown' && 'attempt' in native && submittedDraft !== undefined && native.attempt.draft === submittedDraft;\n item.phase = native.kind === 'blocked' || (native.kind === 'unknown' && 'attempt' in native && !ownUnknown) ? 'blocked' : 'unconfirmed';\n if (ownUnknown) item.requestId = native.attempt.requestId;\n update();\n };\n const process = async (item: Attempt) => {\n if (!valid(item) || !item.file) return;\n item.phase = 'processing'; update();\n // Cache each successful half independently: retrying transcription must not upload the same recording again.\n await Promise.all([\n item.path ? undefined : (async () => {\n try {\n const results = await item.upload?.([item.file!], item.abort.signal);\n const path = results?.length === 1 ? results[0]?.path : undefined;\n if (valid(item) && path && isVoicePath(path)) item.path = path;\n } catch { /* Failed uploads remain pending for explicit retry. */ }\n })(),\n item.transcript !== undefined ? undefined : (async () => {\n try { const text = (await item.transcribe(item.file!, item.abort.signal)).trim(); if (valid(item) && text) item.transcript = text; }\n catch { /* Never send automatically when transcription fails. */ }\n })(),\n ]);\n if (!valid(item)) return;\n if (!item.path) { item.phase = 'upload-failed'; update(); }\n else if (!item.transcript) { item.phase = 'transcribe-failed'; update(); }\n else await send(item);\n };\n const finish = () => {\n const item = active();\n if (!item || !valid(item)) return;\n if (item.phase === 'starting') { cancel(); return; }\n if (item.phase !== 'recording') return;\n item.phase = 'processing'; update();\n void (async () => {\n const recording: VoiceRecording | null = await item.capture.stop().catch(() => null);\n if (!valid(item)) return;\n if (!recording) { remove(item); setProblem(labels.voiceNothingHeard); update(); return; }\n item.seconds = Math.max(0, Math.round((recording.endedAt - recording.startedAt) / 1000));\n const type = recording.mimeType.split(';')[0]!, ext = type === 'audio/mp4' ? 'mp4' : type === 'audio/ogg' ? 'ogg' : 'webm';\n const stamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').replace(/\\.(\\d+)Z$/, '-$1');\n item.file = new File([recording.audio], `voice-${stamp}.${ext}`, { type });\n await process(item);\n })();\n };\n const start = () => {\n if (!controller || !config || active() || controller.getSnapshot().disposed || controller.getSnapshot().send.kind === 'unknown') return;\n captureSequence.current += 1; setProblem(undefined);\n const maxMs = config.maxMs ?? DEFAULT_MAX_VOICE_MS;\n const item: Attempt = { id: captureSequence.current, maxMs: Number.isFinite(maxMs) && maxMs > 0 ? maxMs : Infinity, controller, transcribe: config.transcribe.bind(config), upload: config.upload?.bind(config) ?? attachments?.upload.bind(attachments), target: targetOf(controller), abort: new AbortController(), capture: createVoiceCapture({ ...(config.maxMs !== undefined ? { maxMs: config.maxMs } : {}) }), phase: 'starting', seconds: 0 };\n attempts.current = [...attempts.current, item]; update();\n void item.capture.start().then(result => {\n if (!valid(item)) { item.capture.cancel(); return; }\n if (result.kind === 'refused') { remove(item); setProblem(result.code ? labels[REFUSAL[result.code]] : result.reason); update(); return; }\n item.phase = 'recording'; update();\n });\n };\n const item = active();\n const pending: readonly PendingVoice[] = attempts.current.filter(item => item.phase !== 'starting' && item.phase !== 'recording').map(item => ({\n id: item.id, path: item.path, phase: item.phase, seconds: item.seconds,\n retry: () => { if (item.phase === 'upload-failed' || item.phase === 'transcribe-failed') void process(item); },\n audioOnly: item.phase === 'transcribe-failed' && item.path ? () => { if (item.phase === 'transcribe-failed') void send(item, true); } : undefined,\n discard: () => { if (valid(item)) remove(item); },\n }));\n return { canStart: Boolean(controller && config && !item && !controller.getSnapshot().disposed && controller.getSnapshot().send.kind !== 'unknown'), phase: item?.phase ?? 'idle', seconds: item?.seconds ?? 0, captureSequence: captureSequence.current, problem, pending, start, finish, cancel };\n}\n",
283
- "type": "registry:hook",
284
- "target": "components/pi-chat/use-voice-message.ts"
285
- },
286
- {
287
- "path": "registry/pi-chat/voice-message.tsx",
288
- "content": "'use client';\n\nimport { MicIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Spinner } from '@/components/ui/spinner';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { Message, MessageContent } from '@/components/ui/message';\nimport { useChatText } from './labels';\nimport { useHoldToRecord } from './use-hold-to-record';\nimport type { PendingVoiceBubbleProps, VoiceMessageButtonProps } from './config';\n\nexport const voiceClock = (seconds: number) => `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;\n\nexport function VoiceMessageButton({ voice, disabled }: VoiceMessageButtonProps) {\n const { labels } = useChatText();\n const active = voice.phase === 'starting' || voice.phase === 'recording';\n const blocked = !active && (disabled || voice.phase !== 'idle');\n const hold = useHoldToRecord(voice, Boolean(blocked));\n return <div className=\"flex items-center gap-2\"><Button {...hold} variant=\"ghost\" size=\"icon\" className=\"touch-none\" data-testid=\"voice-message-record\" data-state={voice.phase} disabled={blocked} aria-label={labels.voiceHold} title={labels.voiceHold} aria-pressed={voice.phase === 'recording'}>\n <MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n </Button>{(voice.phase === 'starting' || voice.phase === 'recording') && <span role=\"status\" data-testid=\"voice-recording\">{labels.voiceRecording} {voiceClock(voice.seconds)}</span>}</div>;\n}\n\nexport function PendingVoiceBubble({ pending }: PendingVoiceBubbleProps) {\n const { labels } = useChatText();\n if (!pending) return null;\n const failed = pending.phase === 'transcribe-failed' || pending.phase === 'upload-failed';\n const blocked = pending.phase === 'blocked' || pending.phase === 'unconfirmed';\n return <Message align=\"end\" data-testid=\"voice-pending\" data-voice-id={pending.id} data-state={pending.phase}><MessageContent><Bubble variant=\"muted\" align=\"end\" className={failed || blocked ? 'w-full max-w-full' : undefined}><BubbleContent>\n <div role={failed ? 'alert' : 'status'} className=\"flex items-center gap-2\">\n {!failed && !blocked && <Spinner aria-hidden=\"true\" />}\n {Number.isFinite(pending.seconds) && pending.seconds > 0 && <span data-testid=\"voice-pending-duration\">{voiceClock(pending.seconds)}</span>}\n <span>{pending.phase === 'upload-failed' ? labels.uploadFailedShort : pending.phase === 'transcribe-failed' ? labels.voiceTranscribeFailed : pending.phase === 'blocked' ? labels.voiceBlocked : pending.phase === 'unconfirmed' ? labels.voiceUnconfirmed : labels.voicePreparing}</span>\n </div>\n {(failed || blocked) && <div className=\"mt-2 grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2\">\n {failed && <Button size=\"sm\" className=\"h-auto min-h-11 min-w-0 whitespace-normal\" variant=\"outline\" data-testid=\"voice-retry\" onClick={pending.retry}>{labels.retry}</Button>}\n {pending.audioOnly && <Button size=\"sm\" className=\"h-auto min-h-11 min-w-0 whitespace-normal\" variant=\"outline\" data-testid=\"voice-audio-only\" onClick={pending.audioOnly}>{labels.voiceAudioOnly}</Button>}\n {pending.phase !== 'unconfirmed' && <Button size=\"sm\" className=\"h-auto min-h-11 min-w-0 whitespace-normal\" variant=\"ghost\" data-testid=\"voice-discard\" onClick={pending.discard}>{labels.voiceDiscard}</Button>}\n </div>}\n </BubbleContent></Bubble></MessageContent></Message>;\n}\n",
289
- "type": "registry:component",
290
- "target": "components/pi-chat/voice-message.tsx"
291
209
  }
292
210
  ],
293
211
  "css": {