@hachej/boring-ui-kit 0.4.23 → 0.4.24

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 (52) hide show
  1. package/README.md +4 -0
  2. package/dist/chat-view.d.ts +278 -0
  3. package/dist/chat-view.js +393 -0
  4. package/package.json +6 -2
  5. package/r/agent-target.json +4 -4
  6. package/r/app-shell.json +9 -9
  7. package/r/board-viewer.json +5 -5
  8. package/r/button.json +1 -1
  9. package/r/diagram-viewer.json +4 -4
  10. package/r/dock.json +1 -1
  11. package/r/documents.json +15 -14
  12. package/r/feedback-preview.json +1 -1
  13. package/r/feedback.json +6 -6
  14. package/r/file-tree.json +5 -5
  15. package/r/highlights.json +3 -3
  16. package/r/html-viewer.json +3 -3
  17. package/r/looks-viewer.json +1 -1
  18. package/r/maquette-viewer.json +5 -5
  19. package/r/markdown-editor.json +3 -3
  20. package/r/mockup-viewer.json +1 -1
  21. package/r/pi-ambient.json +10 -10
  22. package/r/pi-app-kinds.json +27 -0
  23. package/r/pi-app.json +16 -25
  24. package/r/pi-artifacts.json +1 -1
  25. package/r/pi-chat-approval.json +1 -1
  26. package/r/pi-chat-attachments.json +1 -1
  27. package/r/pi-chat-markdown.json +1 -1
  28. package/r/pi-chat-mentions.json +1 -1
  29. package/r/pi-chat-pickers.json +1 -1
  30. package/r/pi-chat-plugins.json +11 -11
  31. package/r/pi-chat-question.json +1 -1
  32. package/r/pi-chat-slash.json +1 -1
  33. package/r/pi-chat-subagent.json +1 -1
  34. package/r/pi-chat-voice.json +2 -2
  35. package/r/pi-chat.json +11 -11
  36. package/r/pi-workspace.json +4 -4
  37. package/r/placed-agent.json +4 -4
  38. package/r/provider-setup.json +2 -2
  39. package/r/record-viewer.json +1 -1
  40. package/r/registry.json +186 -169
  41. package/r/requests-board.json +1 -1
  42. package/r/shared-chat.json +7 -7
  43. package/r/task-list-viewer.json +2 -2
  44. package/r/version-history.json +3 -3
  45. package/r/viewer-code.json +2 -2
  46. package/r/viewer-frame.json +5 -5
  47. package/r/viewer-html.json +4 -4
  48. package/r/viewer-image.json +2 -2
  49. package/r/viewer-markdown.json +5 -5
  50. package/r/viewer-pdf.json +3 -3
  51. package/r/viewers.json +6 -6
  52. package/r/windows.json +1 -1
package/r/pi-chat.json CHANGED
@@ -4,9 +4,9 @@
4
4
  "title": "Pi chat",
5
5
  "description": "A complete Tailwind chat over the borrowed native chat controller: composer, messages, one collapsible activity block per turn (tool calls and reasoning), message queue, history 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.23",
8
- "@hachej/boring-files@^0.4.23",
9
- "@hachej/boring-feedback@^0.4.23",
7
+ "@hachej/boring-ui-kit@^0.4.24",
8
+ "@hachej/boring-files@^0.4.24",
9
+ "@hachej/boring-feedback@^0.4.24",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0",
12
12
  "@base-ui/react@1.9.0",
@@ -19,8 +19,8 @@
19
19
  "zod@^4.6.5"
20
20
  ],
21
21
  "registryDependencies": [
22
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.23/r/utils.json",
23
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.23/r/button.json",
22
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/utils.json",
23
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/button.json",
24
24
  "dialog",
25
25
  "tooltip",
26
26
  "trace",
@@ -77,7 +77,7 @@
77
77
  },
78
78
  {
79
79
  "path": "registry/pi-chat/config.ts",
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",
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 { ApprovalDecision, ArtifactDetect, ArtifactRecognise, ChatArtifact, SubagentRenderer as ViewSubagentRenderer } from '@hachej/boring-ui-kit/chat-view';\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/** `ask_user` calls are question rows; `ApprovalDecision` is what `requireApproval` records (both owned by the kit's chat view). */\nexport { ASK_USER_TOOL } from '@hachej/boring-ui-kit/chat-view';\nexport type { ApprovalDecision } from '@hachej/boring-ui-kit/chat-view';\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 = ViewSubagentRenderer<ReactNode>;\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/** An artifact a tool call made, as the chat places it (owned by the kit's chat view). */\nexport type { ChatArtifact };\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?: ArtifactDetect | undefined;\n /** The artifact a call made, or that a running call (`running`) is making; undefined for any other call. */\n readonly recognise?: ArtifactRecognise | undefined;\n readonly Card?: ComponentType<ChatArtifactCardProps> | undefined;\n}\n",
81
81
  "type": "registry:lib",
82
82
  "target": "components/pi-chat/config.ts"
83
83
  },
@@ -113,13 +113,13 @@
113
113
  },
114
114
  {
115
115
  "path": "registry/pi-chat/message.tsx",
116
- "content": "'use client';\n\nimport { Fragment, memo, useEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ImageContent, ToolResultMessage, UserMessage } from '@earendil-works/pi-ai';\nimport { AlertCircleIcon, CircleSlashIcon, ClockIcon, 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 { MessageScrollerItem } from '@/components/ui/message-scroller';\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 /** `messaging`: primary bubble for me, muted bubble for the reply, a tail on the first bubble of a run and the time inside (`PiChat messageStyle`). */\n readonly messageStyle?: 'default' | 'messaging' | undefined;\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\n/** A messenger's tail on the first bubble of a run. Its square part fills the bubble's top corner under the (positioned) content. */\nexport function BubbleTail({ side }: { readonly side: 'start' | 'end' }) {\n return <svg data-testid=\"bubble-tail\" data-side={side} aria-hidden=\"true\" viewBox=\"0 0 22 14\" width=\"22\" height=\"14\"\n className={`pointer-events-none absolute top-0 ${side === 'end' ? '-end-2 -scale-x-100 fill-primary' : '-start-2 fill-muted'}`}><path d=\"M0 0H22V14H8V9C8 5 5 2 0 0Z\" /></svg>;\n}\n/** Text whose last line leaves room for the time at the bottom right of the bubble (the spacer rides at the end of the last paragraph). */\nexport const clockRoom = \"[&_:is(p,li):last-child]:after:inline-block [&_:is(p,li):last-child]:after:w-14 [&_:is(p,li):last-child]:after:content-['']\";\n/** Room for a floating clock after plain inline text. */\nexport const ClockSpacer = () => <span aria-hidden=\"true\" className=\"inline-block w-14\" />;\n/**\n * The time inside a bubble (« 14:02 »). `floating` pins it bottom right over the room the text left; otherwise it sits in the line.\n * `text` and `title` replace the chat's own clock and date labels, and `className` the size (the shared chat brings its own).\n */\nexport function MessageClock({ at, tone, floating = true, pending = false, text, title, className }: { readonly at: string; readonly tone: 'muted' | 'primary'; readonly floating?: boolean; readonly pending?: boolean | undefined; readonly text?: string | undefined; readonly title?: string | undefined; readonly className?: string | undefined }) {\n const { labels } = useChatText();\n // Not confirmed by the host yet: a small clock where the time will be.\n if (pending) return <span data-testid=\"message-pending\" role=\"img\" aria-label={labels.sending} title={labels.sending}\n className={`${floating ? 'absolute end-3 bottom-1.5' : 'shrink-0 self-end'} leading-none ${tone === 'primary' ? 'opacity-80' : 'text-muted-foreground'}`}><ClockIcon aria-hidden=\"true\" className=\"size-3\" /></span>;\n return <time data-testid=\"message-time\" dateTime={at} title={title ?? labels.messageTimestamp(at)}\n className={cn(floating ? 'absolute end-3 bottom-1.5' : 'shrink-0 self-end', 'text-xs leading-none whitespace-nowrap tabular-nums', tone === 'primary' ? 'opacity-80' : 'text-muted-foreground', className)}>{text ?? labels.messageClock(at)}</time>;\n}\nconst isoAt = (ms: number | undefined) => ms !== undefined && Number.isFinite(ms) ? new Date(ms).toISOString() : undefined;\n\nfunction UserBubble({ message, images, context, align = 'end', lead = false }: { readonly message: UserMessage; readonly images: readonly ImageContent[]; readonly context: RowContext; readonly align?: 'start' | 'end'; readonly lead?: boolean }) {\n const messaging = context.messageStyle === 'messaging', at = isoAt(message.timestamp);\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 : messaging\n ? <MessageText key={index} text={part.text} context={context} align={align} variant=\"default\" decoration={lead ? <BubbleTail side=\"end\" /> : undefined}\n footer={at ? <><ClockSpacer /><MessageClock at={at} tone=\"primary\" /></> : undefined} />\n : <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\n/**\n * A run of transcript rows as one scroller item (`useRowChunks`). Memoized, like each row: a row renders again only when its own row\n * or the chat's row context changes, never on a scroll or another row's update. Pass stable callbacks (`messageActions`, `unread`…)\n * so the context stays the same.\n */\nexport const TranscriptChunk = memo(function TranscriptChunk({ rows, context }: { readonly rows: readonly Row[]; readonly context: RowContext }) {\n return <MessageScrollerItem messageId={rows[0]!.key} data-transcript-chunk=\"\" className=\"flex flex-col [content-visibility:visible] [gap:inherit]\">\n {rows.map(row => <TranscriptRow key={row.key} row={row} context={context} />)}\n </MessageScrollerItem>;\n});\nconst TranscriptRow = memo(function TranscriptRow({ row, context }: { readonly row: Row; readonly context: RowContext }) {\n return <div data-row-key={row.key} className=\"min-w-0 shrink-0\"><RowView row={row} context={context} /></div>;\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} lead={row.lead === true} /></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 firstText = row.parts.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') {\n const text = (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 } : {}) });\n if (context.messageStyle !== 'messaging') return <Bubble key={part.key} variant=\"ghost\"><BubbleContent>{text}</BubbleContent></Bubble>;\n const time = part === lastText ? isoAt(row.timestamp) : undefined;\n return <Bubble key={part.key} variant=\"muted\" align=\"start\">{row.lead && part === firstText && <BubbleTail side=\"start\" />}\n <BubbleContent data-testid=\"reply-text\" className=\"relative\"><div className={time ? clockRoom : undefined}>{text}</div>{time && <MessageClock at={time} tone=\"muted\" />}</BubbleContent></Bubble>;\n }\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",
116
+ "content": "'use client';\n\nimport { Fragment, memo, useEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ImageContent, ToolResultMessage, UserMessage } from '@earendil-works/pi-ai';\nimport { AlertCircleIcon, CircleSlashIcon, ClockIcon, 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, waits } 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 { MessageScrollerItem } from '@/components/ui/message-scroller';\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 /** `messaging`: primary bubble for me, muted bubble for the reply, a tail on the first bubble of a run and the time inside (`PiChat messageStyle`). */\n readonly messageStyle?: 'default' | 'messaging' | undefined;\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\n/** A messenger's tail on the first bubble of a run. Its square part fills the bubble's top corner under the (positioned) content. */\nexport function BubbleTail({ side }: { readonly side: 'start' | 'end' }) {\n return <svg data-testid=\"bubble-tail\" data-side={side} aria-hidden=\"true\" viewBox=\"0 0 22 14\" width=\"22\" height=\"14\"\n className={`pointer-events-none absolute top-0 ${side === 'end' ? '-end-2 -scale-x-100 fill-primary' : '-start-2 fill-muted'}`}><path d=\"M0 0H22V14H8V9C8 5 5 2 0 0Z\" /></svg>;\n}\n/** Text whose last line leaves room for the time at the bottom right of the bubble (the spacer rides at the end of the last paragraph). */\nexport const clockRoom = \"[&_:is(p,li):last-child]:after:inline-block [&_:is(p,li):last-child]:after:w-14 [&_:is(p,li):last-child]:after:content-['']\";\n/** Room for a floating clock after plain inline text. */\nexport const ClockSpacer = () => <span aria-hidden=\"true\" className=\"inline-block w-14\" />;\n/**\n * The time inside a bubble (« 14:02 »). `floating` pins it bottom right over the room the text left; otherwise it sits in the line.\n * `text` and `title` replace the chat's own clock and date labels, and `className` the size (the shared chat brings its own).\n */\nexport function MessageClock({ at, tone, floating = true, pending = false, text, title, className }: { readonly at: string; readonly tone: 'muted' | 'primary'; readonly floating?: boolean; readonly pending?: boolean | undefined; readonly text?: string | undefined; readonly title?: string | undefined; readonly className?: string | undefined }) {\n const { labels } = useChatText();\n // Not confirmed by the host yet: a small clock where the time will be.\n if (pending) return <span data-testid=\"message-pending\" role=\"img\" aria-label={labels.sending} title={labels.sending}\n className={`${floating ? 'absolute end-3 bottom-1.5' : 'shrink-0 self-end'} leading-none ${tone === 'primary' ? 'opacity-80' : 'text-muted-foreground'}`}><ClockIcon aria-hidden=\"true\" className=\"size-3\" /></span>;\n return <time data-testid=\"message-time\" dateTime={at} title={title ?? labels.messageTimestamp(at)}\n className={cn(floating ? 'absolute end-3 bottom-1.5' : 'shrink-0 self-end', 'text-xs leading-none whitespace-nowrap tabular-nums', tone === 'primary' ? 'opacity-80' : 'text-muted-foreground', className)}>{text ?? labels.messageClock(at)}</time>;\n}\nconst isoAt = (ms: number | undefined) => ms !== undefined && Number.isFinite(ms) ? new Date(ms).toISOString() : undefined;\n\nfunction UserBubble({ message, images, context, align = 'end', lead = false }: { readonly message: UserMessage; readonly images: readonly ImageContent[]; readonly context: RowContext; readonly align?: 'start' | 'end'; readonly lead?: boolean }) {\n const messaging = context.messageStyle === 'messaging', at = isoAt(message.timestamp);\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 : messaging\n ? <MessageText key={index} text={part.text} context={context} align={align} variant=\"default\" decoration={lead ? <BubbleTail side=\"end\" /> : undefined}\n footer={at ? <><ClockSpacer /><MessageClock at={at} tone=\"primary\" /></> : undefined} />\n : <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\n/**\n * A run of transcript rows as one scroller item (`useRowChunks`). Memoized, like each row: a row renders again only when its own row\n * or the chat's row context changes, never on a scroll or another row's update. Pass stable callbacks (`messageActions`, `unread`…)\n * so the context stays the same.\n */\nexport const TranscriptChunk = memo(function TranscriptChunk({ rows, context }: { readonly rows: readonly Row[]; readonly context: RowContext }) {\n return <MessageScrollerItem messageId={rows[0]!.key} data-transcript-chunk=\"\" className=\"flex flex-col [content-visibility:visible] [gap:inherit]\">\n {rows.map(row => <TranscriptRow key={row.key} row={row} context={context} />)}\n </MessageScrollerItem>;\n});\nconst TranscriptRow = memo(function TranscriptRow({ row, context }: { readonly row: Row; readonly context: RowContext }) {\n return <div data-row-key={row.key} className=\"min-w-0 shrink-0\"><RowView row={row} context={context} /></div>;\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} lead={row.lead === true} /></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 firstText = row.parts.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') {\n const text = (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 } : {}) });\n if (context.messageStyle !== 'messaging') return <Bubble key={part.key} variant=\"ghost\"><BubbleContent>{text}</BubbleContent></Bubble>;\n const time = part === lastText ? isoAt(row.timestamp) : undefined;\n return <Bubble key={part.key} variant=\"muted\" align=\"start\">{row.lead && part === firstText && <BubbleTail side=\"start\" />}\n <BubbleContent data-testid=\"reply-text\" className=\"relative\"><div className={time ? clockRoom : undefined}>{text}</div>{time && <MessageClock at={time} tone=\"muted\" />}</BubbleContent></Bubble>;\n }\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 : waits(part) ? <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 : waits(part) ? <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",
117
117
  "type": "registry:component",
118
118
  "target": "components/pi-chat/message.tsx"
119
119
  },
120
120
  {
121
121
  "path": "registry/pi-chat/notice.tsx",
122
- "content": "'use client';\n\nimport type { ReactNode } from 'react';\nimport type { ChatSnapshot } from '@hachej/boring-ui-kit/native-chat';\nimport { Button } from '@/components/ui/button';\nimport { Alert } from '@/components/ui/alert';\nimport { Shimmer } from './shimmer';\nimport { useChatText } from './labels';\n\nexport function Notice({ tone = 'error', children, testid }: { readonly tone?: 'error' | 'info'; readonly children: ReactNode; readonly testid?: string }) {\n return <Alert variant={tone === 'error' ? 'destructive' : 'default'} role={tone === 'error' ? 'alert' : 'status'} {...(testid ? { 'data-testid': testid } : {})}\n className=\"flex flex-wrap items-center gap-2\">{children}</Alert>;\n}\n\n/** What went wrong with sending or stopping, and the recovery for an unknown submission. Shared by every chat surface. */\nexport function ChatNotices({ state, error, onReconcile, onRetry, natural = false }: {\n readonly state: Pick<ChatSnapshot, 'send' | 'stop'>;\n readonly error: string | null;\n readonly onReconcile: () => void;\n readonly onRetry: () => void;\n /** `presentation=\"natural\"`: a blocked send shows a plain label, not the controller's reason. */\n readonly natural?: boolean;\n}) {\n const { labels } = useChatText();\n return <>\n {error && <Notice testid=\"chat-error\">{error}</Notice>}\n {state.send.kind === 'blocked' && <Notice testid=\"send-blocked\">{natural ? labels.sendUnavailable : state.send.reason}</Notice>}\n {state.send.kind === 'unknown' && <Notice testid=\"send-unknown\"><span className=\"flex-1\">{'reason' in state.send ? state.send.reason : labels.sendUnknown}</span>\n <Button size=\"sm\" variant=\"outline\" onClick={onReconcile}>{labels.checkSubmission}</Button>\n {(!('retryable' in state.send) || state.send.retryable) && <Button size=\"sm\" variant=\"outline\" onClick={onRetry}>{labels.retrySame}</Button>}</Notice>}\n {state.stop === 'requested' && <Notice tone=\"info\" testid=\"stop-requested\">{labels.stopRequested}</Notice>}\n {state.stop === 'unconfirmed' && <Notice testid=\"stop-unconfirmed\">{labels.stopUnconfirmed}</Notice>}\n </>;\n}\n\n/** `presentation=\"natural\"`: whether the one \"preparing\" status shows. Any work (a run, a retry, a compaction) counts, except a confirmed stop with no run left; a question waiting for the person does not. */\nexport function preparing(input: { readonly natural: boolean; readonly waitingForAnswer: boolean; readonly run: unknown; readonly retry: unknown; readonly compacting: boolean; readonly stop: ChatSnapshot['stop'] }): boolean {\n const { natural, waitingForAnswer, run, retry, compacting, stop } = input;\n return natural && !waitingForAnswer && Boolean(run || retry || compacting) && !(stop === 'confirmed' && !run);\n}\n\n/** The one status line of natural presentation. */\nexport function PreparingStatus({ children }: { readonly children?: ReactNode }) {\n const { labels } = useChatText();\n return <p role=\"status\" data-testid=\"preparing\" className=\"m-0 flex items-center gap-2 px-1 text-xs text-muted-foreground\"><Shimmer>{children ?? labels.preparing}</Shimmer></p>;\n}\n",
122
+ "content": "'use client';\n\nimport type { ReactNode } from 'react';\nimport type { ChatSnapshot } from '@hachej/boring-ui-kit/native-chat';\nimport { Button } from '@/components/ui/button';\nimport { Alert } from '@/components/ui/alert';\nimport { Shimmer } from './shimmer';\nimport { useChatText } from './labels';\n\nexport function Notice({ tone = 'error', children, testid }: { readonly tone?: 'error' | 'info'; readonly children: ReactNode; readonly testid?: string }) {\n return <Alert variant={tone === 'error' ? 'destructive' : 'default'} role={tone === 'error' ? 'alert' : 'status'} {...(testid ? { 'data-testid': testid } : {})}\n className=\"flex flex-wrap items-center gap-2\">{children}</Alert>;\n}\n\n/** What went wrong with sending or stopping, and the recovery for an unknown submission. Shared by every chat surface. */\nexport function ChatNotices({ state, error, onReconcile, onRetry, natural = false }: {\n readonly state: Pick<ChatSnapshot, 'send' | 'stop'>;\n readonly error: string | null;\n readonly onReconcile: () => void;\n readonly onRetry: () => void;\n /** `presentation=\"natural\"`: a blocked send shows a plain label, not the controller's reason. */\n readonly natural?: boolean;\n}) {\n const { labels } = useChatText();\n return <>\n {error && <Notice testid=\"chat-error\">{error}</Notice>}\n {state.send.kind === 'blocked' && <Notice testid=\"send-blocked\">{natural ? labels.sendUnavailable : state.send.reason}</Notice>}\n {state.send.kind === 'unknown' && <Notice testid=\"send-unknown\"><span className=\"flex-1\">{'reason' in state.send ? state.send.reason : labels.sendUnknown}</span>\n <Button size=\"sm\" variant=\"outline\" onClick={onReconcile}>{labels.checkSubmission}</Button>\n {(!('retryable' in state.send) || state.send.retryable) && <Button size=\"sm\" variant=\"outline\" onClick={onRetry}>{labels.retrySame}</Button>}</Notice>}\n {state.stop === 'requested' && <Notice tone=\"info\" testid=\"stop-requested\">{labels.stopRequested}</Notice>}\n {state.stop === 'unconfirmed' && <Notice testid=\"stop-unconfirmed\">{labels.stopUnconfirmed}</Notice>}\n </>;\n}\n\n/** `presentation=\"natural\"`: whether the one \"preparing\" status shows (owned by the kit's chat view). */\nexport { preparing } from '@hachej/boring-ui-kit/chat-view';\n\n/** The one status line of natural presentation. */\nexport function PreparingStatus({ children }: { readonly children?: ReactNode }) {\n const { labels } = useChatText();\n return <p role=\"status\" data-testid=\"preparing\" className=\"m-0 flex items-center gap-2 px-1 text-xs text-muted-foreground\"><Shimmer>{children ?? labels.preparing}</Shimmer></p>;\n}\n",
123
123
  "type": "registry:component",
124
124
  "target": "components/pi-chat/notice.tsx"
125
125
  },
@@ -143,13 +143,13 @@
143
143
  },
144
144
  {
145
145
  "path": "registry/pi-chat/rows.ts",
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; /** `messageStyle=\"messaging\"`: first of a run (see `markLeads`). */ readonly lead?: boolean }\n | { readonly key: string; readonly type: 'assistant'; readonly lead?: boolean; 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\nconst LEAD_GAP_MS = 15 * 60_000;\nconst leads = new WeakMap<Row, { readonly yes?: Row; readonly no?: Row }>();\nconst stamp = (row: Row): number | undefined => row.type === 'user' ? row.message.timestamp : row.type === 'assistant' ? row.timestamp : undefined;\n/**\n * `messageStyle=\"messaging\"`: flags the first user or assistant row of each run (a change of side, or more than 15 minutes since the\n * last message) so only it gets a tail. A row keeps its identity while its flag does not change, so memoized rows stay still.\n */\nexport function markLeads(rows: readonly Row[]): readonly Row[] {\n return rows.map((row, at) => {\n if (row.type !== 'user' && row.type !== 'assistant') return row;\n const before = rows[at - 1], time = stamp(row), last = before && stamp(before);\n const lead = !before || before.type !== row.type || (time !== undefined && last !== undefined && time - last > LEAD_GAP_MS);\n if (row.lead === lead) return row;\n const cached = leads.get(row), hit = lead ? cached?.yes : cached?.no;\n if (hit) return hit;\n const next = { ...row, lead } as Row;\n leads.set(row, { ...cached, [lead ? 'yes' : 'no']: next });\n return next;\n });\n}\n",
146
+ "content": "import type { ReactNode } from 'react';\nimport type { ConversationView } from '@earendil-works/pi-durable';\nimport { derive as deriveView } from '@hachej/boring-ui-kit/chat-view';\nimport type * as View from '@hachej/boring-ui-kit/chat-view';\n\n/*\n * The row model lives in the kit (`@hachej/boring-ui-kit/chat-view`): native Pi view → display rows, the waiting rule and the run\n * notices. This file only fixes its card content to React nodes and keeps the names the chat's files import.\n */\nexport { APPROVAL_DETAILS, isFileBlock, markLeads, object, queuedMessages, replyMeta, segments, streamParts, targetContent, waitingForAnswer, waitingPart, waits } from '@hachej/boring-ui-kit/chat-view';\nexport type { ApprovalDecision, Mode, QueuedMessage, StreamPart } from '@hachej/boring-ui-kit/chat-view';\nexport type ChatCard = View.ChatCard<ReactNode>;\nexport type Part = View.Part<ReactNode>;\nexport type Row = View.Row<ReactNode>;\nexport type Derived = View.Derived<ReactNode>;\nexport type DeriveOptions = View.DeriveOptions<ReactNode>;\nexport type Step = View.Step<ReactNode>;\nexport type Segment = View.Segment<ReactNode>;\nexport const derive: (view: ConversationView | undefined, options: DeriveOptions) => Derived = deriveView;\n",
147
147
  "type": "registry:lib",
148
148
  "target": "components/pi-chat/rows.ts"
149
149
  },
150
150
  {
151
151
  "path": "registry/pi-chat/session.tsx",
152
- "content": "'use client';\n\nimport { startTransition, 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 /** Automatic paging: rows shown first (default 60) and rows added each time the reader reaches the top (default 40). Ignored with `displayLimit`. */\n readonly paging?: ChatPaging;\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\nexport interface ChatPaging { readonly first?: number; readonly more?: number }\nexport const PAGE_FIRST = 60, PAGE_MORE = 40, CHUNK = 20;\n\n/**\n * Rows grouped into runs of at most `size` keys, kept stable across renders. Rows already placed never change run: rows revealed\n * above form new runs (the run nearest the old top is the full one), rows added below fill the last run, then start new ones.\n */\nexport function chunkKeys(previous: readonly (readonly string[])[], keys: readonly string[], size = CHUNK): string[][] {\n const owner = new Map<string, number>();\n previous.forEach((run, index) => { for (const key of run) owner.set(key, index); });\n const known = keys.findIndex(key => owner.has(key));\n const lead = known < 0 ? keys.length : known;\n const runs: string[][] = [];\n for (let end = lead; end > 0; end -= size) runs.unshift(keys.slice(Math.max(0, end - size), end));\n let current: string[] = [], from: number | undefined;\n for (const key of keys.slice(lead)) {\n const placed = owner.get(key);\n if (!current.length || (placed !== undefined ? placed !== from : current.length >= size)) { current = []; runs.push(current); from = placed; }\n current.push(key);\n }\n return runs;\n}\n\n/**\n * The scroller measures every one of its items on each scroll event and frame (@shadcn/react 0.3.1), so a long transcript gives it\n * runs of `CHUNK` rows instead of single rows: 500 messages are 25 measurements, not 500. A run keeps its element while rows are\n * revealed above it, so the scroller's prepend anchor still holds; a run's array is reused while its rows are unchanged.\n */\nexport function useRowChunks<T extends { readonly key: string }>(rows: readonly T[], size = CHUNK): readonly (readonly T[])[] {\n const previous = useRef<{ readonly keys: readonly (readonly string[])[]; readonly runs: ReadonlyMap<string, readonly T[]> }>({ keys: [], runs: new Map() });\n return useMemo(() => {\n const byKey = new Map(rows.map(row => [row.key, row]));\n const keys = chunkKeys(previous.current.keys, rows.map(row => row.key), size);\n const runs = keys.map(run => {\n const items = run.map(key => byKey.get(key)!);\n const before = previous.current.runs.get(run[0]!);\n return before && before.length === items.length && before.every((item, index) => item === items[index]) ? before : items;\n });\n previous.current = { keys, runs: new Map(runs.map(run => [run[0]!.key, run])) };\n return runs;\n }, [rows, size]);\n}\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 = PAGE_FIRST, more = PAGE_MORE, 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 if (!Number.isSafeInteger(more) || more < 1) throw new TypeError('Chat paging.more 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 // A revealed page renders as a transition: React mounts it in small slices, so scrolling stays responsive meanwhile.\n if (hidden > 0) startTransition(() => 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) { startTransition(() => 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 // Automatic paging: the sentinel near the top, once the reader has been away from the top (so the mount, which starts at the\n // top before the scroller settles at the end, loads nothing). A fast scroll lands on 0 before the sentinel is seen: the scroll\n // listener and every new observer (after each page) check again, instead of the reader's distance at that one moment.\n const near = useRef(false), away = useRef(false), revealNow = useRef(reveal);\n revealNow.current = reveal;\n useEffect(() => {\n if (!automatic || !scroller) return;\n const onScroll = () => { if (scroller.scrollTop > 8) away.current = true; if (near.current && away.current) revealNow.current(); };\n scroller.addEventListener('scroll', onScroll, { passive: true });\n return () => scroller.removeEventListener('scroll', onScroll);\n }, [automatic, scroller]);\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 near.current = entries.at(-1)?.isIntersecting ?? near.current;\n if (near.current && (scroller.scrollHeight <= scroller.clientHeight || away.current)) reveal();\n }, { root: scroller, rootMargin: '240px 0px 0px' });\n observer.observe(sentinel);\n return () => { observer.disconnect(); near.current = false; };\n }, [automatic, scroller, sentinel, reveal, 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 { startTransition, 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, waitingForAnswer as waitsForAnswer } 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 /** Automatic paging: rows shown first (default 60) and rows added each time the reader reaches the top (default 40). Ignored with `displayLimit`. */\n readonly paging?: ChatPaging;\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 = waitsForAnswer(derived.rows);\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\nexport interface ChatPaging { readonly first?: number; readonly more?: number }\nexport const PAGE_FIRST = 60, PAGE_MORE = 40, CHUNK = 20;\n\n/**\n * Rows grouped into runs of at most `size` keys, kept stable across renders. Rows already placed never change run: rows revealed\n * above form new runs (the run nearest the old top is the full one), rows added below fill the last run, then start new ones.\n */\nexport function chunkKeys(previous: readonly (readonly string[])[], keys: readonly string[], size = CHUNK): string[][] {\n const owner = new Map<string, number>();\n previous.forEach((run, index) => { for (const key of run) owner.set(key, index); });\n const known = keys.findIndex(key => owner.has(key));\n const lead = known < 0 ? keys.length : known;\n const runs: string[][] = [];\n for (let end = lead; end > 0; end -= size) runs.unshift(keys.slice(Math.max(0, end - size), end));\n let current: string[] = [], from: number | undefined;\n for (const key of keys.slice(lead)) {\n const placed = owner.get(key);\n if (!current.length || (placed !== undefined ? placed !== from : current.length >= size)) { current = []; runs.push(current); from = placed; }\n current.push(key);\n }\n return runs;\n}\n\n/**\n * The scroller measures every one of its items on each scroll event and frame (@shadcn/react 0.3.1), so a long transcript gives it\n * runs of `CHUNK` rows instead of single rows: 500 messages are 25 measurements, not 500. A run keeps its element while rows are\n * revealed above it, so the scroller's prepend anchor still holds; a run's array is reused while its rows are unchanged.\n */\nexport function useRowChunks<T extends { readonly key: string }>(rows: readonly T[], size = CHUNK): readonly (readonly T[])[] {\n const previous = useRef<{ readonly keys: readonly (readonly string[])[]; readonly runs: ReadonlyMap<string, readonly T[]> }>({ keys: [], runs: new Map() });\n return useMemo(() => {\n const byKey = new Map(rows.map(row => [row.key, row]));\n const keys = chunkKeys(previous.current.keys, rows.map(row => row.key), size);\n const runs = keys.map(run => {\n const items = run.map(key => byKey.get(key)!);\n const before = previous.current.runs.get(run[0]!);\n return before && before.length === items.length && before.every((item, index) => item === items[index]) ? before : items;\n });\n previous.current = { keys, runs: new Map(runs.map(run => [run[0]!.key, run])) };\n return runs;\n }, [rows, size]);\n}\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 = PAGE_FIRST, more = PAGE_MORE, 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 if (!Number.isSafeInteger(more) || more < 1) throw new TypeError('Chat paging.more 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 // A revealed page renders as a transition: React mounts it in small slices, so scrolling stays responsive meanwhile.\n if (hidden > 0) startTransition(() => 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) { startTransition(() => 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 // Automatic paging: the sentinel near the top, once the reader has been away from the top (so the mount, which starts at the\n // top before the scroller settles at the end, loads nothing). A fast scroll lands on 0 before the sentinel is seen: the scroll\n // listener and every new observer (after each page) check again, instead of the reader's distance at that one moment.\n const near = useRef(false), away = useRef(false), revealNow = useRef(reveal);\n revealNow.current = reveal;\n useEffect(() => {\n if (!automatic || !scroller) return;\n const onScroll = () => { if (scroller.scrollTop > 8) away.current = true; if (near.current && away.current) revealNow.current(); };\n scroller.addEventListener('scroll', onScroll, { passive: true });\n return () => scroller.removeEventListener('scroll', onScroll);\n }, [automatic, scroller]);\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 near.current = entries.at(-1)?.isIntersecting ?? near.current;\n if (near.current && (scroller.scrollHeight <= scroller.clientHeight || away.current)) reveal();\n }, { root: scroller, rootMargin: '240px 0px 0px' });\n observer.observe(sentinel);\n return () => { observer.disconnect(); near.current = false; };\n }, [automatic, scroller, sentinel, reveal, 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",
153
153
  "type": "registry:component",
154
154
  "target": "components/pi-chat/session.tsx"
155
155
  },
@@ -167,7 +167,7 @@
167
167
  },
168
168
  {
169
169
  "path": "registry/pi-chat/tool.tsx",
170
- "content": "'use client';\n\nimport { useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { WrenchIcon } from 'lucide-react';\nimport { TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceDescription, TraceIcon, TraceLine, TraceTitle } from '@/components/ui/trace';\nimport { Card, CardContent } from '@/components/ui/card';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Button } from '@/components/ui/button';\nimport { defaultChatLabels, useChatText } from './labels';\nimport type { ChatLabels } from './labels';\n\nexport type ToolStatus = 'running' | 'completed' | 'failed' | 'unfinished';\nexport interface ToolEntry { readonly key: string; readonly call: ToolCall; readonly result: ToolResultMessage | undefined; readonly status: ToolStatus; readonly custom?: ReactNode }\n\nconst STATUS_LABEL = { running: 'toolRunning', completed: 'toolCompleted', failed: 'toolFailedStatus', unfinished: 'toolUnfinished' } as const satisfies Record<ToolStatus, keyof ChatLabels>;\nconst PREVIEW_KEYS = ['path', 'file_path', 'filePath', 'command', 'query', 'pattern', 'name', 'url', 'id'];\n\n/** A short hint of what the call acts on, shown next to its name. */\nexport function preview(args: ToolCall['arguments']): string {\n const values = args as Record<string, unknown>;\n for (const key of PREVIEW_KEYS) { const value = values[key]; if (typeof value === 'string' && value) return value.replace(/\\s+/g, ' ').slice(0, 90); }\n const first = Object.values(values).find(value => typeof value === 'string' && value);\n return typeof first === 'string' ? first.replace(/\\s+/g, ' ').slice(0, 90) : '';\n}\n\nfunction Section({ title, children }: { readonly title: string; readonly children: ReactNode }) {\n return <div className=\"space-y-1.5\"><h4 className=\"m-0 text-xs font-medium tracking-wider text-muted-foreground uppercase\">{title}</h4>{children}</div>;\n}\n\n/** A short live label for a call: \"Reading notes/consultation.md\", \"Running bash\", \"Running working_git\" (`labels.toolActivity`). */\nexport function liveLabel(call: ToolCall, labels: Pick<ChatLabels, 'toolActivity'> = defaultChatLabels): string {\n return labels.toolActivity(call.name, preview(call.arguments));\n}\n\n/** One Trace step. Failures open by default; quiet successful steps have no disclosure. */\nexport function ToolCard({ entry, onOpenImage, details = true }: { readonly entry: ToolEntry; readonly details?: boolean; readonly onOpenImage?: (image: Extract<ToolResultMessage['content'][number], { type: 'image' }>) => void }) {\n const { labels } = useChatText();\n const [chosen, setChosen] = useState<boolean | undefined>();\n const { call, result, status } = entry;\n const open = details && (chosen ?? status === 'failed');\n const hint = preview(call.arguments);\n const line = <TraceLine {...(status === 'running' ? { status: 'running' } : status === 'failed' ? { status: 'error' } : {})}>\n <TraceIcon><WrenchIcon /></TraceIcon>\n <TraceTitle><span data-testid=\"tool-name\">{call.name}</span>{hint && ` · ${hint}`}</TraceTitle>\n <TraceDescription data-testid=\"tool-status\">{labels[STATUS_LABEL[status]]}</TraceDescription>\n </TraceLine>;\n return <TraceCollapsible data-testid=\"tool-card\" data-boring=\"tool-card\" data-status={status} data-tool={call.name} data-call-id={call.id} open={open} onOpenChange={setChosen}>\n {details ? <TraceCollapsibleTrigger className=\"min-h-8 max-sm:min-h-11 pointer-coarse:min-h-11\">{line}</TraceCollapsibleTrigger> : line}\n {details && <TraceCollapsibleContent><div data-testid=\"tool-details\" className=\"flex flex-col gap-3\">\n <Section title={labels.arguments}><Card><CardContent><pre className=\"m-0 max-h-60 overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere]\">{JSON.stringify(call.arguments, null, 2)}</pre></CardContent></Card></Section>\n {result && <Section title={result.isError ? labels.error : labels.result}>\n <Alert variant={result.isError ? 'destructive' : 'default'} role={result.isError ? 'alert' : 'status'}><AlertDescription>\n {result.content.map((part, index) => part.type === 'text'\n ? <pre key={index} className=\"m-0 max-h-72 overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere]\">{part.text}</pre>\n : <Button key={index} variant=\"outline\" size=\"sm\" className=\"max-sm:min-h-12! pointer-coarse:min-h-12!\" disabled={!onOpenImage} onClick={() => onOpenImage?.(part)}>{labels.openImageResult}</Button>)}\n {result.content.length === 0 && <span>{labels.emptyResult}</span>}\n </AlertDescription></Alert></Section>}\n </div></TraceCollapsibleContent>}\n </TraceCollapsible>;\n}\n\n",
170
+ "content": "'use client';\n\nimport { useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { ToolStatus, ToolEntry as ViewToolEntry } from '@hachej/boring-ui-kit/chat-view';\nimport { WrenchIcon } from 'lucide-react';\nimport { TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceDescription, TraceIcon, TraceLine, TraceTitle } from '@/components/ui/trace';\nimport { Card, CardContent } from '@/components/ui/card';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Button } from '@/components/ui/button';\nimport { defaultChatLabels, useChatText } from './labels';\nimport type { ChatLabels } from './labels';\n\nexport type { ToolStatus } from '@hachej/boring-ui-kit/chat-view';\nexport type ToolEntry = ViewToolEntry<ReactNode>;\n\nconst STATUS_LABEL = { running: 'toolRunning', completed: 'toolCompleted', failed: 'toolFailedStatus', unfinished: 'toolUnfinished' } as const satisfies Record<ToolStatus, keyof ChatLabels>;\nconst PREVIEW_KEYS = ['path', 'file_path', 'filePath', 'command', 'query', 'pattern', 'name', 'url', 'id'];\n\n/** A short hint of what the call acts on, shown next to its name. */\nexport function preview(args: ToolCall['arguments']): string {\n const values = args as Record<string, unknown>;\n for (const key of PREVIEW_KEYS) { const value = values[key]; if (typeof value === 'string' && value) return value.replace(/\\s+/g, ' ').slice(0, 90); }\n const first = Object.values(values).find(value => typeof value === 'string' && value);\n return typeof first === 'string' ? first.replace(/\\s+/g, ' ').slice(0, 90) : '';\n}\n\nfunction Section({ title, children }: { readonly title: string; readonly children: ReactNode }) {\n return <div className=\"space-y-1.5\"><h4 className=\"m-0 text-xs font-medium tracking-wider text-muted-foreground uppercase\">{title}</h4>{children}</div>;\n}\n\n/** A short live label for a call: \"Reading notes/consultation.md\", \"Running bash\", \"Running working_git\" (`labels.toolActivity`). */\nexport function liveLabel(call: ToolCall, labels: Pick<ChatLabels, 'toolActivity'> = defaultChatLabels): string {\n return labels.toolActivity(call.name, preview(call.arguments));\n}\n\n/** One Trace step. Failures open by default; quiet successful steps have no disclosure. */\nexport function ToolCard({ entry, onOpenImage, details = true }: { readonly entry: ToolEntry; readonly details?: boolean; readonly onOpenImage?: (image: Extract<ToolResultMessage['content'][number], { type: 'image' }>) => void }) {\n const { labels } = useChatText();\n const [chosen, setChosen] = useState<boolean | undefined>();\n const { call, result, status } = entry;\n const open = details && (chosen ?? status === 'failed');\n const hint = preview(call.arguments);\n const line = <TraceLine {...(status === 'running' ? { status: 'running' } : status === 'failed' ? { status: 'error' } : {})}>\n <TraceIcon><WrenchIcon /></TraceIcon>\n <TraceTitle><span data-testid=\"tool-name\">{call.name}</span>{hint && ` · ${hint}`}</TraceTitle>\n <TraceDescription data-testid=\"tool-status\">{labels[STATUS_LABEL[status]]}</TraceDescription>\n </TraceLine>;\n return <TraceCollapsible data-testid=\"tool-card\" data-boring=\"tool-card\" data-status={status} data-tool={call.name} data-call-id={call.id} open={open} onOpenChange={setChosen}>\n {details ? <TraceCollapsibleTrigger className=\"min-h-8 max-sm:min-h-11 pointer-coarse:min-h-11\">{line}</TraceCollapsibleTrigger> : line}\n {details && <TraceCollapsibleContent><div data-testid=\"tool-details\" className=\"flex flex-col gap-3\">\n <Section title={labels.arguments}><Card><CardContent><pre className=\"m-0 max-h-60 overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere]\">{JSON.stringify(call.arguments, null, 2)}</pre></CardContent></Card></Section>\n {result && <Section title={result.isError ? labels.error : labels.result}>\n <Alert variant={result.isError ? 'destructive' : 'default'} role={result.isError ? 'alert' : 'status'}><AlertDescription>\n {result.content.map((part, index) => part.type === 'text'\n ? <pre key={index} className=\"m-0 max-h-72 overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere]\">{part.text}</pre>\n : <Button key={index} variant=\"outline\" size=\"sm\" className=\"max-sm:min-h-12! pointer-coarse:min-h-12!\" disabled={!onOpenImage} onClick={() => onOpenImage?.(part)}>{labels.openImageResult}</Button>)}\n {result.content.length === 0 && <span>{labels.emptyResult}</span>}\n </AlertDescription></Alert></Section>}\n </div></TraceCollapsibleContent>}\n </TraceCollapsible>;\n}\n\n",
171
171
  "type": "registry:component",
172
172
  "target": "components/pi-chat/tool.tsx"
173
173
  },