@hachej/boring-ui-kit 0.4.58 → 0.4.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/boring-ui-blocks.mjs +49 -4
- package/package.json +6 -6
- package/r/_shared.json +19 -0
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +17 -17
- package/r/board-viewer.json +10 -10
- package/r/button.json +4 -4
- package/r/data-table.json +21 -0
- package/r/diagram-viewer.json +6 -4
- package/r/dock.json +1 -1
- package/r/documents.json +13 -14
- package/r/drawing-viewer.json +6 -4
- package/r/empty-state.json +22 -0
- package/r/feedback.json +14 -14
- package/r/file-tree.json +4 -4
- package/r/highlights.json +3 -3
- package/r/html-viewer.json +3 -3
- package/r/kpi-stat.json +22 -0
- package/r/list-row.json +21 -0
- package/r/looks-viewer.json +6 -6
- package/r/maquette-viewer.json +7 -6
- package/r/markdown-editor.json +10 -9
- package/r/mockup-viewer.json +5 -5
- package/r/page-header.json +22 -0
- package/r/pi-ambient.json +12 -12
- package/r/pi-app-kinds.json +10 -10
- package/r/pi-app.json +17 -17
- package/r/pi-artifacts.json +4 -4
- package/r/pi-chat-approval.json +4 -4
- package/r/pi-chat-attachments.json +4 -4
- package/r/pi-chat-markdown.json +3 -3
- package/r/pi-chat-mentions.json +4 -4
- package/r/pi-chat-pickers.json +4 -4
- package/r/pi-chat-plugins.json +11 -11
- package/r/pi-chat-question.json +3 -3
- package/r/pi-chat-slash.json +5 -5
- package/r/pi-chat-subagent.json +4 -4
- package/r/pi-chat-voice.json +7 -7
- package/r/pi-chat.json +17 -17
- package/r/pi-workspace.json +3 -3
- package/r/placed-agent.json +7 -7
- package/r/provider-setup.json +4 -4
- package/r/record-viewer.json +5 -3
- package/r/registry.json +494 -280
- package/r/shared-chat.json +11 -11
- package/r/spaces.json +7 -5
- package/r/task-list-viewer.json +28 -3
- package/r/theme.json +115 -50
- package/r/version-history.json +2 -2
- package/r/viewer-code.json +5 -5
- package/r/viewer-frame.json +9 -9
- package/r/viewer-html.json +7 -7
- package/r/viewer-image.json +5 -5
- package/r/viewer-markdown.json +7 -7
- package/r/viewer-pdf.json +6 -6
- package/r/viewers.json +6 -6
- package/r/windows.json +4 -4
package/r/pi-chat.json
CHANGED
|
@@ -4,45 +4,45 @@
|
|
|
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.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.59",
|
|
8
|
+
"@hachej/boring-files@^0.4.59",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"@base-ui/react@1.9.0",
|
|
12
12
|
"@earendil-works/pi-durable@~1.4.0",
|
|
13
13
|
"@earendil-works/chord@~1.4.0",
|
|
14
14
|
"@earendil-works/pi-ai@~1.4.0",
|
|
15
|
-
"@modelcontextprotocol/sdk@^1.32.
|
|
16
|
-
"lucide-react@^1.51.0",
|
|
15
|
+
"@modelcontextprotocol/sdk@^1.32.2",
|
|
17
16
|
"@shadcn/react@0.3.1",
|
|
18
17
|
"zod@^4.6.5"
|
|
19
18
|
],
|
|
20
19
|
"registryDependencies": [
|
|
21
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
22
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
20
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/utils.json",
|
|
21
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/button.json",
|
|
22
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
|
|
23
23
|
],
|
|
24
24
|
"files": [
|
|
25
25
|
{
|
|
26
26
|
"path": "registry/pi-chat/activity.tsx",
|
|
27
|
-
"content": "'use client';\n\nimport { useState } from 'react';\nimport type { ToolResultMessage } from '@earendil-works/pi-ai';\nimport { AlertCircleIcon, BrainIcon, CheckIcon, CircleSlashIcon } from '
|
|
27
|
+
"content": "'use client';\n\nimport { useState } from 'react';\nimport type { ToolResultMessage } from '@earendil-works/pi-ai';\nimport { AlertCircleIcon, BrainIcon, CheckIcon, CircleSlashIcon } from '../_shared/icons';\nimport { Spinner } from '@/components/ui/spinner';\nimport { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceDescription, TraceIcon, TraceLine, TraceTitle } from '@/components/ui/trace';\nimport { ToolCard, liveLabel } from './tool';\nimport type { ToolStatus } from './tool';\nimport type { Step } from './rows';\nimport { useChatText } from './labels';\nimport type { ChatLabels } from './labels';\n\ntype Image = Extract<ToolResultMessage['content'][number], { type: 'image' }>;\nexport type ActivityState = 'running' | 'done' | 'failed' | 'stopped';\n\nconst stepStatus = (step: Step): ToolStatus => step.kind === 'tool' ? step.entry.status : step.streaming ? 'running' : 'completed';\n\nfunction summary(steps: readonly Step[], labels: ChatLabels): string | undefined {\n const counts = new Map<string, number>();\n for (const step of steps) if (step.kind === 'tool') counts.set(step.entry.call.name, (counts.get(step.entry.call.name) ?? 0) + 1);\n if (counts.size === 0 || counts.size > 4) return undefined;\n return [...counts].map(([name, count]) => `${labels.toolNoun(name)}${count > 1 ? ` ×${count}` : ''}`).join(' · ');\n}\n\nfunction header(steps: readonly Step[], state: ActivityState, streaming: boolean, labels: ChatLabels): string {\n if (state === 'running') {\n const running = steps.filter(step => stepStatus(step) === 'running');\n const current = streaming ? running[running.length - 1] : running[0];\n if (!current) return labels.thinkingStep;\n return current.kind === 'tool' ? liveLabel(current.entry.call, labels) : labels.thinkingStep;\n }\n if (state === 'failed') {\n const failed = steps.flatMap(step => step.kind === 'tool' && step.entry.status === 'failed' ? [step.entry.call.name] : []);\n return labels.activityFailed(failed.length, [...new Set(failed)].join(' · '));\n }\n const used = summary(steps, labels);\n const tools = steps.some(step => step.kind === 'tool');\n if (used === undefined && state !== 'stopped') return tools ? labels.activityMany(steps.length) : labels.activityThought;\n return labels.activityUsed(used ?? (tools ? labels.activityMany(steps.length) : labels.activityThought), state === 'stopped');\n}\n\nfunction Thinking({ text, streaming, details }: { readonly text: string; readonly streaming: boolean; readonly details: boolean }) {\n const { labels } = useChatText();\n const [open, setOpen] = useState(false);\n const line = <TraceLine {...(streaming ? { status: 'running' } : {})}>\n <TraceIcon><BrainIcon /></TraceIcon><TraceTitle>{streaming ? labels.thinking : labels.reasoning}</TraceTitle>\n </TraceLine>;\n return <TraceCollapsible data-testid=\"reasoning\" data-state={open ? 'open' : 'closed'} data-streaming={streaming ? 'true' : undefined} open={details && open} onOpenChange={setOpen}>\n {details ? <TraceCollapsibleTrigger className=\"min-h-8 max-md:min-h-11 pointer-coarse:min-h-11\">{line}</TraceCollapsibleTrigger> : line}\n {details && <TraceCollapsibleContent><div data-testid=\"reasoning-text\" className=\"max-h-72 overflow-y-auto text-sm whitespace-pre-wrap [overflow-wrap:anywhere]\">{text}</div></TraceCollapsibleContent>}\n </TraceCollapsible>;\n}\n\n/** Presentation only: native Pi steps and host cards keep their existing ownership and order. */\nexport function ActivityBlock({ steps, state, streaming, developer, onOpenImage }: {\n readonly steps: readonly Step[]; readonly state: ActivityState; readonly streaming: boolean; readonly developer: boolean; readonly onOpenImage?: (image: Image) => void;\n}) {\n const { labels } = useChatText();\n const [chosen, setChosen] = useState<boolean | undefined>();\n const open = chosen ?? state === 'failed';\n const Icon = state === 'running' ? Spinner : state === 'failed' ? AlertCircleIcon : state === 'stopped' ? CircleSlashIcon : CheckIcon;\n return <Trace data-testid=\"activity\" data-boring=\"activity\" data-state={state} data-open={open ? 'true' : 'false'} className=\"my-1.5\">\n <TraceCollapsible open={open} onOpenChange={setChosen}>\n <TraceCollapsibleTrigger className=\"min-h-8 max-md:min-h-11 pointer-coarse:min-h-11\">\n <TraceLine {...(state === 'running' ? { status: 'running' } : state === 'failed' ? { status: 'error' } : {})}>\n <TraceIcon><Icon aria-hidden=\"true\" /></TraceIcon>\n <TraceTitle data-testid=\"activity-label\">{header(steps, state, streaming, labels)}</TraceTitle>\n <TraceDescription data-testid=\"activity-count\">{labels.steps(steps.length)}</TraceDescription>\n </TraceLine>\n </TraceCollapsibleTrigger>\n <TraceCollapsibleContent>\n <Trace data-testid=\"activity-steps\">\n {steps.map(step => <div key={step.key} data-testid=\"activity-step\" data-kind={step.kind} data-status={stepStatus(step)}>\n {step.kind === 'tool'\n ? <ToolCard entry={step.entry} details={developer || step.entry.status === 'failed'} {...(onOpenImage ? { onOpenImage } : {})} />\n : <Thinking text={step.text} streaming={step.streaming} details={developer} />}\n </div>)}\n </Trace>\n </TraceCollapsibleContent>\n </TraceCollapsible>\n </Trace>;\n}\n",
|
|
28
28
|
"type": "registry:component",
|
|
29
29
|
"target": "components/pi-chat/activity.tsx"
|
|
30
30
|
},
|
|
31
31
|
{
|
|
32
32
|
"path": "registry/pi-chat/code-block.tsx",
|
|
33
|
-
"content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport { CheckIcon, CopyIcon } from '
|
|
33
|
+
"content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport { CheckIcon, CopyIcon } from '../_shared/icons';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemHeader, ItemTitle } from '@/components/ui/item';\nimport { copyText } from '../utils/utils';\nimport { useChatText } from './labels';\n\n/** Copy button with a short \"copied\" confirmation. Failures stay visible as a title instead of throwing. */\nexport function CopyButton({ text, label: given, onCopy, className, iconOnly = false }: { readonly text: string; readonly label?: string; readonly onCopy?: (text: string) => Promise<void>; readonly className?: string; readonly iconOnly?: boolean }) {\n const { labels } = useChatText();\n const label = given ?? labels.copy;\n const [state, setState] = useState<'idle' | 'copied' | 'failed'>('idle');\n const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n useEffect(() => () => clearTimeout(timer.current), []);\n const settle = (next: 'copied' | 'failed') => { setState(next); clearTimeout(timer.current); timer.current = setTimeout(() => setState('idle'), 1600); };\n return <Button variant=\"ghost\" size={iconOnly ? 'icon-sm' : 'sm'} className={className} data-testid=\"copy\" aria-label={label} title={state === 'failed' ? labels.copyFailed : label}\n onClick={() => { copyText(text, onCopy).then(() => settle('copied'), () => settle('failed')); }}>\n {state === 'copied' ? <CheckIcon data-icon=\"inline-start\" /> : <CopyIcon data-icon=\"inline-start\" />}\n <span className={iconOnly ? 'sr-only' : 'sr-only md:not-sr-only'}>{state === 'copied' ? labels.copied : state === 'failed' ? labels.failed : label}</span>\n </Button>;\n}\n\n/** Fenced code: language label, copy action, horizontal scroll. Text only, never interpreted as markup. */\nexport function CodeBlock({ code, language, onCopy }: { readonly code: string; readonly language?: string; readonly onCopy?: (text: string) => Promise<void> }) {\n const { labels } = useChatText();\n const label = language?.trim().split(/\\s+/)[0]?.slice(0, 24);\n // shadcn's own Code Block installs a syntax highlighter (shiki) this block never uses, so the frame is an outline Item.\n return <Item variant=\"outline\" size=\"xs\" data-testid=\"code-block\" className=\"my-3 flex-col items-stretch text-start\">\n <ItemHeader>\n <ItemTitle data-testid=\"code-language\" className=\"lowercase\">{label || 'text'}</ItemTitle>\n <CopyButton text={code} label={labels.copyCode} {...(onCopy ? { onCopy } : {})} />\n </ItemHeader>\n <pre className=\"m-0 overflow-x-auto font-mono text-xs leading-5\"><code>{code}</code></pre>\n </Item>;\n}\n",
|
|
34
34
|
"type": "registry:component",
|
|
35
35
|
"target": "components/pi-chat/code-block.tsx"
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
38
|
"path": "registry/pi-chat/composer.tsx",
|
|
39
|
-
"content": "'use client';\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ChangeEvent, ClipboardEvent, ComponentProps, DragEvent, KeyboardEvent, ReactNode, Ref } from 'react';\nimport { AlertCircleIcon, FileTextIcon, Loader2Icon, MessageSquarePlusIcon, SlashIcon, XIcon } from 'lucide-react';\nimport { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentMedia, AttachmentTitle } from '@/components/ui/attachment';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { Input } from '@/components/ui/input';\nimport { InputGroup, InputGroupAddon, InputGroupTextarea } from '@/components/ui/input-group';\nimport { Toggle } from '@/components/ui/toggle';\nimport { hasMention, isLongPaste, kindMentionDraft, mentionedPaths, mentionKind, mentionToken, mentionTrigger, removeMention, slashItems, slashQuery, writeKindMentionDraft } from '@hachej/boring-ui-kit/chat-text';\nimport type { PasteToFile, SlashItem } from '@hachej/boring-ui-kit/chat-text';\nimport type { AttachmentsConfig, MentionsConfig, PastedText, SlashConfig } from './config';\nimport { useChatText } from './labels';\nimport { cn } from '../utils/utils';\n\nexport interface ComposerAttachment { readonly id: string; readonly name: string; readonly mimeType: string; readonly data: string }\n\nconst SAFE_IMAGE = /^image\\/(png|jpe?g|gif|webp)$/;\nconst BASE64 = /^[A-Za-z0-9+/=\\s]+$/;\n/** A data URL for a thumbnail, only for common image types and well-formed base64. */\nexport function thumbnail(mimeType: string, data: string): string | undefined {\n return SAFE_IMAGE.test(mimeType) && BASE64.test(data) ? `data:${mimeType};base64,${data}` : undefined;\n}\n\n/**\n * Where an image part is drawn from: its bytes as a data URL, or, for a part the host sent as a link (`data: ''` and a `url`, which the\n * remote chat has resolved against its endpoint), that link when it is http(s). `undefined`: nothing safe to draw.\n */\nexport function imageSource(image: { readonly mimeType: string; readonly data: string; readonly url?: unknown }): string | undefined {\n if (image.data !== '' || typeof image.url !== 'string') return thumbnail(image.mimeType, image.data);\n try {\n const link = new URL(image.url, globalThis.location?.href);\n return link.protocol === 'https:' || link.protocol === 'http:' ? link.href : undefined;\n } catch { return undefined; }\n}\n\n/**\n * The optional annotate toggle (FEEDBACK.md, \"UX\"): one button in the composer's row that arms element targeting on the host's page,\n * and one count chip for the notes taken. Send attaches them. The host owns everything else (the registry `feedback` item's\n * `useComposerFeedback` returns this shape). Absent: the composer is unchanged.\n */\nexport interface ComposerFeedback extends ChatSessionFeedback {\n /** The annotate toggle: arms targeting on the page (point mode on touch and compact screens), or stops it when it is on. */\n readonly annotate: () => void;\n /** Annotating is on: the toggle shows pressed. */\n readonly active: boolean;\n /** Notes taken and waiting to be sent with the next message; above zero, Send is allowed with no text. */\n readonly count: number;\n /** Removes the notes (the count chip's remove button). */\n readonly clear: () => void;\n\n}\n\n/** One file being uploaded (or that failed to upload), shown as a chip until it settles or is dismissed. */\nexport type { PendingUpload } from '@hachej/boring-ui-kit/chat-react';\nimport type { PendingUpload, ChatSessionFeedback } from '@hachej/boring-ui-kit/chat-react';\n\nexport interface ComposerProps {\n /** `/` menu of commands and skills. Absent: no menu and no keyboard trigger. */\n readonly slash?: SlashConfig | undefined;\n /** Reports a failing command. */\n readonly onSlashError?: ((cause: unknown) => void) | undefined;\n /** `@` file picker. Absent: no picker. */\n readonly mentions?: MentionsConfig | undefined;\n /** Paths picked or uploaded as mentions; each shows as a chip while its `@path` is still in the text. */\n readonly mentionPaths?: readonly string[];\n readonly onMentionPicked?: ((path: string) => void) | undefined;\n readonly uploads?: readonly PendingUpload[];\n readonly onDismissUpload?: ((id: string) => void) | undefined;\n /** A paste above `pasteToFile`'s limits, taken by the composer: the host uploads it as a file. Absent: pastes stay inline. */\n readonly onPasteText?: ((paste: PastedText) => void) | undefined;\n readonly pasteToFile?: PasteToFile | undefined;\n /** Extra controls in the bar (the model and effort pill) and a short note beside them. */\n readonly barStart?: ReactNode;\n readonly barNote?: ReactNode;\n /** Optional host mode/model chips beside the attach control. */\n readonly chips?: ReactNode;\n /** Optional host context above the input. */\n readonly quote?: ReactNode;\n readonly text: string;\n readonly onText: (text: string) => void;\n readonly attachments: readonly ComposerAttachment[];\n /** Host-owned content contributes to send readiness. */\n readonly hasFiles?: boolean;\n readonly onRemoveAttachment: (id: string) => void;\n /** The agent is working: the primary button stops it and Enter queues the message (steering is chosen on the queued message). */\n readonly working: boolean;\n /** A send would be refused right now (not connected, a submission in flight, uploading). */\n readonly sendBlocked: boolean;\n readonly disabled: boolean;\n /** Stop still works while `disabled`: the composer is held, the running reply is still the person's to stop. */\n readonly stopOpen?: boolean | undefined;\n readonly stopRequested: boolean;\n readonly onSend: () => void;\n readonly onStop: () => void;\n readonly uploading: boolean;\n readonly canAttach: boolean;\n /** The phone camera sheet (`attachments.Camera`, from `pi-chat-attachments`). Without it there is no Take a photo entry. */\n readonly camera?: AttachmentsConfig['Camera'];\n readonly fileAccept: string;\n readonly onPickFiles: (files: readonly File[]) => void;\n readonly onKeyDown?: ((event: KeyboardEvent<HTMLTextAreaElement>) => void) | undefined;\n readonly textareaRef: Ref<HTMLTextAreaElement>;\n readonly placeholder?: string;\n /** Optional visual placeholder (for an inline icon); the textarea keeps its accessible label. */\n readonly placeholderContent?: ReactNode;\n /** While the `@` picker is open, mark the typed query (`@d-2`) as an inline token behind the text (`data-slot=\"composer-mention-query\"`). */\n readonly markMentionQuery?: boolean;\n /** An optional host input editor; normal textarea/IME behavior is unchanged when omitted. */\n readonly input?: ReactNode;\n readonly renderInput?: ((input: { readonly text: string; readonly onText: (text: string) => void; readonly onCaret: (offset: number) => void; readonly onSend: () => void }) => ReactNode) | undefined;\n /** `card` (default): chips, then full-width text, then a row of controls, in the host's shadcn InputGroup. `pill` puts the text between\n * the controls on one row. `stacked` and `inline` are compatibility values that render as `card`. */\n readonly layout?: 'card' | 'pill' | 'stacked' | 'inline';\n /** Host controls (for example a voice button) as round buttons between the model pill and the send button. */\n readonly barEnd?: ReactNode;\n /** Host controls at the very left edge of the bar, before \"+\" (`PiChat`'s `composerStart`). */\n readonly start?: ReactNode;\n /** A control shown in place of Send while there is nothing to send (WhatsApp's microphone): Send comes back as soon as there is text or a file. */\n readonly whenEmpty?: ReactNode;\n /** An active capture replaces the editable input and ordinary controls with this one recording row. */\n readonly recording?: ReactNode;\n /** The optional Feedback button and chip. */\n readonly feedback?: ComposerFeedback | undefined;\n}\n\n/** The annotate toggle beside the \"+\" menu in both layouts. */\nfunction AnnotateButton({ feedback, disabled }: { readonly feedback: ComposerFeedback; readonly disabled: boolean }) {\n const { labels } = useChatText();\n return <Toggle className=\"min-h-11 min-w-11\" data-testid=\"composer-annotate\" pressed={feedback.active} disabled={disabled}\n aria-label={labels.annotate} title={labels.annotateHint} onPressedChange={() => feedback.annotate()}>\n <MessageSquarePlusIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n </Toggle>;\n}\n\n/**\n * One chip above the text (a mention, an upload, an attachment or the notes count) as the host's shadcn Attachment. The remove\n * buttons keep a 44px touch target on coarse pointers; negative margins let it extend beyond the chip instead of growing it.\n */\nfunction Chip({ media, image = false, label, actions, ...props }: ComponentProps<typeof Attachment> & { readonly media: ReactNode; readonly image?: boolean; readonly label: ReactNode; readonly actions?: ReactNode }) {\n return <Attachment size=\"xs\" role=\"listitem\" {...props}>\n <AttachmentMedia variant={image ? 'image' : 'icon'}>{media}</AttachmentMedia>\n <AttachmentContent><AttachmentTitle>{label}</AttachmentTitle></AttachmentContent>\n {actions && <AttachmentActions className=\"max-md:-my-2.5 pointer-coarse:-my-2.5\">{actions}</AttachmentActions>}\n </Attachment>;\n}\n\nfunction RemoveChip({ label, onClick }: { readonly label: string; readonly onClick: () => void }) {\n return <AttachmentAction aria-label={label} title={label} onClick={onClick}><XIcon aria-hidden=\"true\" /></AttachmentAction>;\n}\n\n/** The count chip: how many notes go with the next message, and a way to drop them. */\nfunction NotesChip({ feedback }: { readonly feedback: ComposerFeedback }) {\n const { labels } = useChatText();\n return <Chip data-testid=\"feedback-count\" data-count={feedback.count} media={<MessageSquarePlusIcon aria-hidden=\"true\" />} label={labels.annotateNotes(feedback.count)}\n actions={<AttachmentAction data-testid=\"feedback-count-clear\" aria-label={labels.annotateClear} title={labels.annotateClear} onClick={() => feedback.clear()}><XIcon aria-hidden=\"true\" /></AttachmentAction>} />;\n}\n\n/** Whether a file input's `accept` admits a JPEG photo (empty admits everything). */\nexport function acceptsPhotos(accept: string): boolean {\n const tokens = accept.split(',').map(token => token.trim().toLowerCase()).filter(Boolean);\n return tokens.length === 0 || tokens.some(token => token === 'image/*' || token === '*/*' || token === 'image/jpeg' || token === '.jpg' || token === '.jpeg');\n}\n\n/** True on a touch-first device (a phone or tablet); false on the server, so the menu renders the same there. */\nfunction useCoarsePointer(): boolean {\n const [coarse, setCoarse] = useState(() => globalThis.matchMedia?.('(pointer: coarse)').matches ?? false);\n useEffect(() => {\n const query = globalThis.matchMedia?.('(pointer: coarse)');\n if (!query) return;\n const update = () => setCoarse(query.matches);\n update();\n query.addEventListener?.('change', update);\n return () => query.removeEventListener?.('change', update);\n }, []);\n return coarse;\n}\n\n/** The \"+\" button and the host's shadcn dropdown menu (Attach files, Take a photo, Commands). */\nfunction PlusActions({ disabled, onAttach, onTakePhoto, onCommands, textarea }: { readonly disabled: boolean; readonly onAttach?: (() => void) | undefined; readonly onTakePhoto?: (() => void) | undefined; readonly onCommands?: (() => void) | undefined; readonly textarea: { readonly current: HTMLTextAreaElement | null } }) {\n const { labels, icons } = useChatText();\n const [open, setOpen] = useState(false);\n // Commands puts `/` in the message: focus goes back to the text, not to the \"+\" button.\n const toText = useRef(false);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a menu left open when the composer gets disabled closes once, after commit\n useEffect(() => { if (disabled) setOpen(false); }, [disabled]);\n return <DropdownMenu open={open} onOpenChange={setOpen}>\n <DropdownMenuTrigger render={<Button variant=\"ghost\" size=\"icon\" />} data-testid=\"composer-plus\" aria-label={labels.add} title={labels.add} disabled={disabled}>\n <icons.add data-icon=\"inline-start\" aria-hidden=\"true\" /></DropdownMenuTrigger>\n <DropdownMenuContent side=\"top\" align=\"start\" aria-label={labels.add} data-testid=\"composer-plus-menu\" className=\"w-52\"\n finalFocus={() => { const text = toText.current; toText.current = false; return text ? textarea.current : true; }}>\n {onAttach && <DropdownMenuItem data-testid=\"composer-attach\" onClick={onAttach} className=\"max-md:min-h-11 pointer-coarse:min-h-11\">\n <icons.attach aria-hidden=\"true\" />{labels.attachFiles}</DropdownMenuItem>}\n {onTakePhoto && <DropdownMenuItem data-testid=\"composer-take-photo\" onClick={onTakePhoto} className=\"max-md:min-h-11 pointer-coarse:min-h-11\">\n <icons.camera aria-hidden=\"true\" />{labels.takePhoto}</DropdownMenuItem>}\n {onCommands && <DropdownMenuItem data-testid=\"composer-commands\" onClick={() => { toText.current = true; onCommands(); }} className=\"max-md:min-h-11 pointer-coarse:min-h-11\">\n <SlashIcon aria-hidden=\"true\" />{labels.commands}</DropdownMenuItem>}\n </DropdownMenuContent>\n </DropdownMenu>;\n}\n\n/** Draws the text with `start..end` wrapped in a mark, at the textarea's exact font, padding and scroll, under its (transparent) background. */\nfunction QueryBackdrop({ text, start, end }: { readonly text: string; readonly start: number; readonly end: number }) {\n const layer = useRef<HTMLDivElement>(null);\n useLayoutEffect(() => {\n const node = layer.current, field = node?.parentElement?.querySelector('textarea');\n if (!node || !field) return;\n const copy = () => {\n const style = getComputedStyle(field);\n for (const name of ['fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'lineHeight', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'textIndent', 'wordSpacing'] as const) node.style[name] = style[name];\n node.style.top = `${field.offsetTop}px`; node.style.left = `${field.offsetLeft}px`; node.style.width = `${field.clientWidth}px`; node.style.height = `${field.clientHeight}px`;\n node.scrollTop = field.scrollTop;\n };\n copy();\n field.addEventListener('scroll', copy);\n return () => field.removeEventListener('scroll', copy);\n });\n return <div ref={layer} aria-hidden=\"true\" data-slot=\"composer-mention-backdrop\" className=\"pointer-events-none absolute overflow-hidden whitespace-pre-wrap break-words text-transparent\">\n {text.slice(0, start)}<mark data-slot=\"composer-mention-query\" className=\"bg-muted text-transparent\">{text.slice(start, end)}</mark>{text.slice(end)}\n </div>;\n}\n\nconst MAX_HEIGHT = 208;\n\n/** Hands the textarea to the caller's ref, a callback or an object. */\nfunction assignRef(ref: Ref<HTMLTextAreaElement>, element: HTMLTextAreaElement | null) {\n if (typeof ref === 'function') ref(element); else if (ref) (ref as { current: HTMLTextAreaElement | null }).current = element;\n}\n\n/** Prompt composer: auto-growing textarea, Enter to send, one primary Send/Stop button. While the agent works a message is queued. */\nexport function Composer(props: ComposerProps) {\n const { attachments, working, sendBlocked, disabled, stopRequested, uploading, canAttach, layout = 'card' } = props;\n const draft = kindMentionDraft(props.text, props.mentions);\n const text = draft.text;\n const tokens = useRef(draft.tokens);\n useLayoutEffect(() => { tokens.current = draft.tokens; });\n // An asynchronous slash command must use today's tokens, not restore a chip removed since it began.\n const onText = (next: string) => props.onText(writeKindMentionDraft(next, tokens.current, props.slash?.skills?.map(skill => skill.name)));\n const { labels, icons } = useChatText();\n const inner = useRef<HTMLTextAreaElement | null>(null);\n const composing = useRef(false);\n const [dragging, setDragging] = useState(false);\n const [caret, setCaret] = useState(0);\n // A menu closed with Escape stays closed until the text changes.\n const [dismissed, setDismissed] = useState<string | null>(null);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- new text re-arms a dismissed menu once, after commit; the updater keeps the value when nothing changed\n useEffect(() => { setDismissed(value => value === null || value === text ? value : null); }, [text]);\n const pendingCaret = useRef<number | null>(null);\n const fileInput = useRef<HTMLInputElement>(null);\n // The camera sheet lives here, not in the menu: the menu content unmounts as soon as it closes.\n const [cameraOpen, setCameraOpen] = useState(false);\n const coarse = useCoarsePointer();\n // A feature shows only when its config carries the component that draws it (`pi-chat-slash`, `pi-chat-mentions`, `pi-chat-attachments`).\n const Camera = props.camera, Slash = props.slash?.Picker, Mention = props.mentions?.Picker;\n const canPhoto = canAttach && coarse && acceptsPhotos(props.fileAccept) && Camera !== undefined;\n const slash = Slash ? props.slash : undefined, mentions = Mention ? props.mentions : undefined;\n const items = useMemo<SlashItem[]>(() => slash ? slashItems(slash) : [], [slash]);\n const recording = Boolean(props.recording);\n const setRef = (element: HTMLTextAreaElement | null) => {\n inner.current = element;\n assignRef(props.textareaRef, element);\n };\n useLayoutEffect(() => {\n if (recording) return;\n const element = inner.current;\n if (!element) return;\n const resize = () => {\n element.style.height = 'auto';\n element.style.height = `${Math.min(element.scrollHeight, MAX_HEIGHT)}px`;\n element.style.overflowY = element.scrollHeight > MAX_HEIGHT ? 'auto' : 'hidden';\n };\n resize();\n if (pendingCaret.current !== null) { element.setSelectionRange(pendingCaret.current, pendingCaret.current); element.focus(); pendingCaret.current = null; }\n if (typeof ResizeObserver === 'undefined') return;\n let width = element.clientWidth;\n const observer = new ResizeObserver(() => {\n const next = element.clientWidth;\n if (next === width) return;\n width = next; resize();\n });\n observer.observe(element);\n return () => observer.disconnect();\n }, [text, recording]);\n\n const query = slash && items.length ? slashQuery(text, caret) : null;\n const trigger = query === null && mentions ? mentionTrigger(text, caret) : null;\n const slashOpen = query !== null && dismissed !== text, mentionOpen = trigger !== null && dismissed !== text;\n const menuOpen = slashOpen || mentionOpen;\n const edit = (next: string, position: number) => { pendingCaret.current = position; setCaret(position); onText(next); };\n const trackCaret = (element: HTMLTextAreaElement) => setCaret(element.selectionStart ?? element.value.length);\n const dismiss = () => setDismissed(text);\n const pickSlash = (item: SlashItem) => {\n const rest = text.slice(caret).replace(/^\\s+/, '');\n if (item.source === 'skill') return edit(`/${item.name} ${rest}`, item.name.length + 2);\n const command = slash?.commands?.find(candidate => candidate.name === item.name);\n onText(rest); setCaret(0);\n inner.current?.focus();\n if (!command) return;\n try { void Promise.resolve(command.run({ text: rest, setText: value => onText(value) })).catch(cause => props.onSlashError?.(cause)); }\n catch (cause) { props.onSlashError?.(cause); }\n };\n const pickMention = (path: string) => {\n if (!trigger) return;\n const token = `${mentionToken(path)} `;\n edit(`${text.slice(0, trigger.start)}${token}${text.slice(trigger.end)}`, trigger.start + token.length);\n props.onMentionPicked?.(path);\n };\n const openCommands = () => edit(`${text}${text && !/\\s$/.test(text) ? ' ' : ''}/`, text.length + (text && !/\\s$/.test(text) ? 2 : 1));\n const chips = [...new Set([...(props.mentionPaths ?? []), ...mentionedPaths(props.text).filter(path => mentionKind(path, props.mentions))])].filter(path => hasMention(props.text, path));\n const onChange = (event: ChangeEvent<HTMLTextAreaElement>) => { trackCaret(event.currentTarget); onText(event.currentTarget.value); };\n\n const { feedback } = props;\n const hasContent = props.text.trim().length > 0 || attachments.length > 0 || props.hasFiles || (feedback?.count ?? 0) > 0;\n const canSubmit = hasContent && !sendBlocked && !disabled && !props.recording;\n const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {\n props.onKeyDown?.(event);\n if (event.defaultPrevented) return;\n // Ctrl+Z right after a paste that is still uploading puts the pasted text back, instead of undoing the paste in the textarea.\n const undo = (props.uploads ?? []).filter(item => item.state === 'uploading' && item.cancel && item.restoreWhile === props.text).pop();\n if (undo && (event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'z') { event.preventDefault(); undo.cancel!(); return; }\n // Never send while an input method editor is composing (Enter confirms the candidate).\n // A phone keyboard has no Shift+Enter: on a touch device Enter inserts a newline and the Send button sends.\n const touch = globalThis.matchMedia?.('(pointer: coarse)').matches === true;\n if (event.key === 'Enter' && !touch && !event.shiftKey && !composing.current && !event.nativeEvent.isComposing && event.keyCode !== 229) {\n event.preventDefault();\n if (canSubmit) props.onSend();\n }\n };\n const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {\n if (canAttach && event.clipboardData.files.length) { event.preventDefault(); props.onPickFiles(Array.from(event.clipboardData.files)); return; }\n if (!props.onPasteText) return;\n const pasted = event.clipboardData.getData('text/plain');\n if (!isLongPaste(pasted, props.pasteToFile)) return;\n event.preventDefault();\n props.onPasteText({ text: pasted, start: event.currentTarget.selectionStart, end: event.currentTarget.selectionEnd });\n };\n const drag = (event: DragEvent) => { if (canAttach) { event.preventDefault(); setDragging(event.type === 'dragover'); } };\n const drop = (event: DragEvent) => { if (!canAttach) return; event.preventDefault(); setDragging(false); props.onPickFiles(Array.from(event.dataTransfer.files)); };\n\n const chipList = (props.quote || chips.length > 0 || (props.uploads?.length ?? 0) > 0 || (feedback?.count ?? 0) > 0 || attachments.length > 0) && <>\n {props.quote}\n {feedback && feedback.count > 0 && <div role=\"list\" className=\"contents\"><NotesChip feedback={feedback} /></div>}\n {chips.length > 0 && <div role=\"list\" data-testid=\"mention-chips\" aria-label={labels.mentionedFiles} className=\"contents\">\n {chips.map(path => {\n const registered = mentionKind(path, props.mentions);\n const label = registered ? registered.kind.label(registered.path) : props.mentions?.label?.(path) ?? path;\n return <Chip key={path} data-testid=\"mention-chip\" data-path={path} title={label} className=\"max-w-full md:max-w-[16rem]\"\n media={registered?.kind.icon ?? props.mentions?.icon ?? <FileTextIcon aria-hidden=\"true\" />} label={registered?.kind.open ? <Button variant=\"link\" size=\"sm\" onClick={() => registered.kind.open?.(registered.path)} className=\"min-w-0 max-w-full justify-start truncate\">{label}</Button> : label}\n actions={<RemoveChip label={labels.remove(label)} onClick={() => props.onText(removeMention(props.text, path))} />} />;\n })}\n </div>}\n {(props.uploads?.length ?? 0) > 0 && <div role=\"list\" data-testid=\"uploads\" aria-label={labels.uploads} className=\"contents\">\n {props.uploads!.map(item => <Chip key={item.id} data-testid=\"upload-chip\" state={item.state === 'failed' ? 'error' : 'uploading'} data-state={item.state} title={item.error ?? item.name} className=\"max-w-full md:max-w-[20rem]\"\n media={item.state === 'failed' ? <AlertCircleIcon className=\"text-destructive\" aria-hidden=\"true\" /> : <Loader2Icon className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />}\n label={item.state === 'failed' ? labels.uploadFailed(item.name, item.error) : labels.uploading(item.name)}\n actions={<>\n {item.cancel && <RemoveChip label={labels.restorePaste} onClick={() => item.cancel!()} />}\n {item.state === 'failed' && <RemoveChip label={labels.dismiss(item.name)} onClick={() => props.onDismissUpload?.(item.id)} />}\n </>} />)}\n </div>}\n {attachments.length > 0 && <div role=\"list\" data-testid=\"attachments\" aria-label={labels.attachments} className=\"contents\">\n {attachments.map(attachment => {\n const src = thumbnail(attachment.mimeType, attachment.data);\n return <Chip key={attachment.id} data-testid=\"attachment\" className=\"max-w-full md:max-w-[14rem]\" image={!!src}\n media={src ? <img src={src} alt=\"\" /> : <FileTextIcon aria-hidden=\"true\" />} label={attachment.name}\n actions={<RemoveChip label={labels.remove(attachment.name)} onClick={() => props.onRemoveAttachment(attachment.id)} />} />;\n })}\n </div>}\n </>;\n const pill = layout === 'pill';\n const textareaEl = <InputGroupTextarea ref={setRef} data-testid=\"composer-input\" aria-label={labels.messageInput} rows={1} value={text} disabled={disabled}\n placeholder={props.placeholderContent ? '' : props.placeholder ?? (working ? labels.placeholderWorking : labels.placeholder)}\n {...(slash || mentions ? { role: 'combobox', 'aria-expanded': menuOpen, 'aria-haspopup': 'listbox', 'aria-autocomplete': 'list' } as const : {})}\n onChange={onChange} onKeyDown={onKeyDown} onPaste={onPaste} onKeyUp={event => trackCaret(event.currentTarget)} onClick={event => trackCaret(event.currentTarget)}\n onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}\n className={cn(props.markMentionQuery && 'relative', pill ? 'max-h-52 min-h-0 min-w-0' : 'max-h-52 min-h-11')} />;\n // The query mark is a backdrop with the textarea's own metrics behind its transparent text: the text the person edits stays the textarea's.\n const marked = props.markMentionQuery && mentionOpen && trigger ? <QueryBackdrop text={text} start={trigger.start} end={trigger.end} /> : null;\n // eslint-disable-next-line react-hooks/refs -- renderInput receives event callbacks, not ref values; an input calls them only on interaction\n const input = props.renderInput?.({ text, onText, onCaret: setCaret, onSend: () => { if (canSubmit && !menuOpen) props.onSend(); } }) ?? props.input ?? (props.placeholderContent || marked ? <div data-slot=\"composer-field\" className=\"relative flex min-h-16 w-full min-w-0 flex-1\">{marked}{textareaEl}{!text && props.placeholderContent && <span aria-hidden=\"true\" data-slot=\"composer-placeholder\" className=\"pointer-events-none absolute inset-x-3 top-2 text-sm text-muted-foreground\">{props.placeholderContent}</span>}</div> : textareaEl);\n const plus = (canAttach || items.length > 0) && <PlusActions disabled={disabled} textarea={inner} onAttach={canAttach ? () => fileInput.current?.click() : undefined} onTakePhoto={canPhoto ? () => setCameraOpen(true) : undefined} onCommands={items.length > 0 ? openCommands : undefined} />;\n // One child slot either way, so a composer without `start` keeps its exact markup (React ids included).\n const startAndPlus = props.start ? <><div data-testid=\"composer-start\" className=\"flex shrink-0 items-center gap-1\">{props.start}</div>{plus}</> : plus;\n const fileEl = canAttach && <Input ref={fileInput} type=\"file\" multiple accept={props.fileAccept} disabled={disabled} tabIndex={-1} aria-hidden=\"true\" className=\"sr-only\" data-testid=\"composer-file\" data-attach=\"true\"\n onChange={event => { const files = Array.from(event.currentTarget.files ?? []); event.currentTarget.value = ''; props.onPickFiles(files); }} />;\n const send = working\n ? <Button variant=\"default\" size=\"icon\" data-testid=\"composer-submit\" data-state=\"stop\" aria-label={labels.stop} title={labels.stop} disabled={(disabled && !props.stopOpen) || stopRequested} onClick={props.onStop} className=\"shrink-0\">\n {stopRequested ? <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" /> : <icons.stop data-icon=\"inline-start\" aria-hidden=\"true\" />}</Button>\n : props.whenEmpty && !hasContent ? <div data-testid=\"composer-when-empty\" className=\"flex shrink-0 items-center\">{props.whenEmpty}</div>\n : <Button variant=\"default\" size=\"icon\" type=\"submit\" data-testid=\"composer-submit\" data-state=\"send\" aria-label={labels.send} title={labels.sendHint} disabled={!canSubmit} className=\"shrink-0\">\n <icons.send data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>;\n const end = <>\n {props.barStart}\n {props.chips && <div className=\"flex min-w-0 items-center gap-2\">{props.chips}</div>}\n {props.barNote}\n {uploading && !(props.uploads?.length) && <span role=\"status\" className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"><Loader2Icon className=\"size-3 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{labels.preparingAttachments}</span>}\n {!pill && <span className=\"min-w-0 flex-1\" />}\n {props.barEnd && <div data-testid=\"composer-tools\" className=\"flex min-w-0 items-center gap-2\">{props.barEnd}</div>}\n {working && hasContent && <Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-queue\" aria-label={labels.queue} title={labels.queue} disabled={!canSubmit} onClick={props.onSend} className=\"shrink-0\">\n <icons.queue data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {send}\n </>;\n\n // One shadcn InputGroup: the chips, the text and a row of controls (`card`, also used for the `stacked` and `inline` compatibility\n // values), or one row with the text between the controls (`pill`, chips above it). Its look is the host's; ours is layout only.\n return <form data-testid=\"composer\" data-layout={layout} data-dragging={dragging || undefined} onSubmit={event => { event.preventDefault(); if (canSubmit) props.onSend(); }} onDragOver={drag} onDragLeave={drag} onDrop={drop}\n className={cn('relative flex flex-col gap-2', pill && '@container/composer')}>\n {fileEl}\n {slashOpen && Slash && <Slash query={query!} items={items} onSelect={pickSlash} onDismiss={dismiss} />}\n {mentionOpen && mentions && Mention && <Mention query={trigger!.query} listLabel={mentions.listLabel} renderOption={mentions.renderOption} header={typeof mentions.pickerHeader === 'function' ? mentions.pickerHeader(trigger!.query) : mentions.pickerHeader} search={mentions.search} generation={mentions.generation} onSelect={pickMention} onDismiss={dismiss} />}\n {pill && !props.recording && chipList && <div className=\"flex flex-wrap gap-2\">{chipList}</div>}\n {props.recording ? <InputGroup>\n {chipList && <InputGroupAddon align=\"block-start\" className=\"flex-wrap\">{chipList}</InputGroupAddon>}\n <InputGroupAddon align=\"block-end\" data-testid=\"composer-controls\" className=\"w-full\">{props.recording}</InputGroupAddon>\n </InputGroup> : pill ? <InputGroup>\n <InputGroupAddon>{startAndPlus}{feedback && <AnnotateButton feedback={feedback} disabled={disabled} />}</InputGroupAddon>\n {input}\n <InputGroupAddon align=\"inline-end\" data-testid=\"composer-controls\">{end}</InputGroupAddon>\n </InputGroup> : <InputGroup>\n {chipList && <InputGroupAddon align=\"block-start\" className=\"flex-wrap\">{chipList}</InputGroupAddon>}\n {input}\n <InputGroupAddon align=\"block-end\" data-testid=\"composer-controls\">\n {startAndPlus}\n {feedback && <AnnotateButton feedback={feedback} disabled={disabled} />}\n {end}\n </InputGroupAddon>\n </InputGroup>}\n {cameraOpen && Camera && <Camera onClose={() => setCameraOpen(false)} onCapture={file => { setCameraOpen(false); props.onPickFiles([file]); }} />}\n </form>;\n}\n",
|
|
39
|
+
"content": "'use client';\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ChangeEvent, ClipboardEvent, ComponentProps, DragEvent, KeyboardEvent, ReactNode, Ref } from 'react';\nimport { AlertCircleIcon, FileTextIcon, Loader2Icon, MessageSquarePlusIcon, SlashIcon, XIcon } from '../_shared/icons';\nimport { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentMedia, AttachmentTitle } from '@/components/ui/attachment';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { Input } from '@/components/ui/input';\nimport { InputGroup, InputGroupAddon, InputGroupTextarea } from '@/components/ui/input-group';\nimport { Toggle } from '@/components/ui/toggle';\nimport { hasMention, isLongPaste, kindMentionDraft, mentionedPaths, mentionKind, mentionToken, mentionTrigger, removeMention, slashItems, slashQuery, writeKindMentionDraft } from '@hachej/boring-ui-kit/chat-text';\nimport type { PasteToFile, SlashItem } from '@hachej/boring-ui-kit/chat-text';\nimport type { AttachmentsConfig, MentionsConfig, PastedText, SlashConfig } from './config';\nimport { useChatText } from './labels';\nimport { cn } from '../utils/utils';\n\nexport interface ComposerAttachment { readonly id: string; readonly name: string; readonly mimeType: string; readonly data: string }\n\nconst SAFE_IMAGE = /^image\\/(png|jpe?g|gif|webp)$/;\nconst BASE64 = /^[A-Za-z0-9+/=\\s]+$/;\n/** A data URL for a thumbnail, only for common image types and well-formed base64. */\nexport function thumbnail(mimeType: string, data: string): string | undefined {\n return SAFE_IMAGE.test(mimeType) && BASE64.test(data) ? `data:${mimeType};base64,${data}` : undefined;\n}\n\n/**\n * Where an image part is drawn from: its bytes as a data URL, or, for a part the host sent as a link (`data: ''` and a `url`, which the\n * remote chat has resolved against its endpoint), that link when it is http(s). `undefined`: nothing safe to draw.\n */\nexport function imageSource(image: { readonly mimeType: string; readonly data: string; readonly url?: unknown }): string | undefined {\n if (image.data !== '' || typeof image.url !== 'string') return thumbnail(image.mimeType, image.data);\n try {\n const link = new URL(image.url, globalThis.location?.href);\n return link.protocol === 'https:' || link.protocol === 'http:' ? link.href : undefined;\n } catch { return undefined; }\n}\n\n/**\n * The optional annotate toggle (FEEDBACK.md, \"UX\"): one button in the composer's row that arms element targeting on the host's page,\n * and one count chip for the notes taken. Send attaches them. The host owns everything else (the registry `feedback` item's\n * `useComposerFeedback` returns this shape). Absent: the composer is unchanged.\n */\nexport interface ComposerFeedback extends ChatSessionFeedback {\n /** The annotate toggle: arms targeting on the page (point mode on touch and compact screens), or stops it when it is on. */\n readonly annotate: () => void;\n /** Annotating is on: the toggle shows pressed. */\n readonly active: boolean;\n /** Notes taken and waiting to be sent with the next message; above zero, Send is allowed with no text. */\n readonly count: number;\n /** Removes the notes (the count chip's remove button). */\n readonly clear: () => void;\n\n}\n\n/** One file being uploaded (or that failed to upload), shown as a chip until it settles or is dismissed. */\nexport type { PendingUpload } from '@hachej/boring-ui-kit/chat-react';\nimport type { PendingUpload, ChatSessionFeedback } from '@hachej/boring-ui-kit/chat-react';\n\nexport interface ComposerProps {\n /** `/` menu of commands and skills. Absent: no menu and no keyboard trigger. */\n readonly slash?: SlashConfig | undefined;\n /** Reports a failing command. */\n readonly onSlashError?: ((cause: unknown) => void) | undefined;\n /** `@` file picker. Absent: no picker. */\n readonly mentions?: MentionsConfig | undefined;\n /** Paths picked or uploaded as mentions; each shows as a chip while its `@path` is still in the text. */\n readonly mentionPaths?: readonly string[];\n readonly onMentionPicked?: ((path: string) => void) | undefined;\n readonly uploads?: readonly PendingUpload[];\n readonly onDismissUpload?: ((id: string) => void) | undefined;\n /** A paste above `pasteToFile`'s limits, taken by the composer: the host uploads it as a file. Absent: pastes stay inline. */\n readonly onPasteText?: ((paste: PastedText) => void) | undefined;\n readonly pasteToFile?: PasteToFile | undefined;\n /** Extra controls in the bar (the model and effort pill) and a short note beside them. */\n readonly barStart?: ReactNode;\n readonly barNote?: ReactNode;\n /** Optional host mode/model chips beside the attach control. */\n readonly chips?: ReactNode;\n /** Optional host context above the input. */\n readonly quote?: ReactNode;\n readonly text: string;\n readonly onText: (text: string) => void;\n readonly attachments: readonly ComposerAttachment[];\n /** Host-owned content contributes to send readiness. */\n readonly hasFiles?: boolean;\n readonly onRemoveAttachment: (id: string) => void;\n /** The agent is working: the primary button stops it and Enter queues the message (steering is chosen on the queued message). */\n readonly working: boolean;\n /** A send would be refused right now (not connected, a submission in flight, uploading). */\n readonly sendBlocked: boolean;\n readonly disabled: boolean;\n /** Stop still works while `disabled`: the composer is held, the running reply is still the person's to stop. */\n readonly stopOpen?: boolean | undefined;\n readonly stopRequested: boolean;\n readonly onSend: () => void;\n readonly onStop: () => void;\n readonly uploading: boolean;\n readonly canAttach: boolean;\n /** The phone camera sheet (`attachments.Camera`, from `pi-chat-attachments`). Without it there is no Take a photo entry. */\n readonly camera?: AttachmentsConfig['Camera'];\n readonly fileAccept: string;\n readonly onPickFiles: (files: readonly File[]) => void;\n readonly onKeyDown?: ((event: KeyboardEvent<HTMLTextAreaElement>) => void) | undefined;\n readonly textareaRef: Ref<HTMLTextAreaElement>;\n readonly placeholder?: string;\n /** Optional visual placeholder (for an inline icon); the textarea keeps its accessible label. */\n readonly placeholderContent?: ReactNode;\n /** While the `@` picker is open, mark the typed query (`@d-2`) as an inline token behind the text (`data-slot=\"composer-mention-query\"`). */\n readonly markMentionQuery?: boolean;\n /** An optional host input editor; normal textarea/IME behavior is unchanged when omitted. */\n readonly input?: ReactNode;\n readonly renderInput?: ((input: { readonly text: string; readonly onText: (text: string) => void; readonly onCaret: (offset: number) => void; readonly onSend: () => void }) => ReactNode) | undefined;\n /** `card` (default): chips, then full-width text, then a row of controls, in the host's shadcn InputGroup. `pill` puts the text between\n * the controls on one row. `stacked` and `inline` are compatibility values that render as `card`. */\n readonly layout?: 'card' | 'pill' | 'stacked' | 'inline';\n /** Host controls (for example a voice button) as round buttons between the model pill and the send button. */\n readonly barEnd?: ReactNode;\n /** Host controls at the very left edge of the bar, before \"+\" (`PiChat`'s `composerStart`). */\n readonly start?: ReactNode;\n /** A control shown in place of Send while there is nothing to send (WhatsApp's microphone): Send comes back as soon as there is text or a file. */\n readonly whenEmpty?: ReactNode;\n /** An active capture replaces the editable input and ordinary controls with this one recording row. */\n readonly recording?: ReactNode;\n /** The optional Feedback button and chip. */\n readonly feedback?: ComposerFeedback | undefined;\n}\n\n/** The annotate toggle beside the \"+\" menu in both layouts. */\nfunction AnnotateButton({ feedback, disabled }: { readonly feedback: ComposerFeedback; readonly disabled: boolean }) {\n const { labels } = useChatText();\n return <Toggle className=\"min-h-11 min-w-11\" data-testid=\"composer-annotate\" pressed={feedback.active} disabled={disabled}\n aria-label={labels.annotate} title={labels.annotateHint} onPressedChange={() => feedback.annotate()}>\n <MessageSquarePlusIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n </Toggle>;\n}\n\n/**\n * One chip above the text (a mention, an upload, an attachment or the notes count) as the host's shadcn Attachment. The remove\n * buttons keep a 44px touch target on coarse pointers; negative margins let it extend beyond the chip instead of growing it.\n */\nfunction Chip({ media, image = false, label, actions, ...props }: ComponentProps<typeof Attachment> & { readonly media: ReactNode; readonly image?: boolean; readonly label: ReactNode; readonly actions?: ReactNode }) {\n return <Attachment size=\"xs\" role=\"listitem\" {...props}>\n <AttachmentMedia variant={image ? 'image' : 'icon'}>{media}</AttachmentMedia>\n <AttachmentContent><AttachmentTitle>{label}</AttachmentTitle></AttachmentContent>\n {actions && <AttachmentActions className=\"max-md:-my-2.5 pointer-coarse:-my-2.5\">{actions}</AttachmentActions>}\n </Attachment>;\n}\n\nfunction RemoveChip({ label, onClick }: { readonly label: string; readonly onClick: () => void }) {\n return <AttachmentAction aria-label={label} title={label} onClick={onClick}><XIcon aria-hidden=\"true\" /></AttachmentAction>;\n}\n\n/** The count chip: how many notes go with the next message, and a way to drop them. */\nfunction NotesChip({ feedback }: { readonly feedback: ComposerFeedback }) {\n const { labels } = useChatText();\n return <Chip data-testid=\"feedback-count\" data-count={feedback.count} media={<MessageSquarePlusIcon aria-hidden=\"true\" />} label={labels.annotateNotes(feedback.count)}\n actions={<AttachmentAction data-testid=\"feedback-count-clear\" aria-label={labels.annotateClear} title={labels.annotateClear} onClick={() => feedback.clear()}><XIcon aria-hidden=\"true\" /></AttachmentAction>} />;\n}\n\n/** Whether a file input's `accept` admits a JPEG photo (empty admits everything). */\nexport function acceptsPhotos(accept: string): boolean {\n const tokens = accept.split(',').map(token => token.trim().toLowerCase()).filter(Boolean);\n return tokens.length === 0 || tokens.some(token => token === 'image/*' || token === '*/*' || token === 'image/jpeg' || token === '.jpg' || token === '.jpeg');\n}\n\n/** True on a touch-first device (a phone or tablet); false on the server, so the menu renders the same there. */\nfunction useCoarsePointer(): boolean {\n const [coarse, setCoarse] = useState(() => globalThis.matchMedia?.('(pointer: coarse)').matches ?? false);\n useEffect(() => {\n const query = globalThis.matchMedia?.('(pointer: coarse)');\n if (!query) return;\n const update = () => setCoarse(query.matches);\n update();\n query.addEventListener?.('change', update);\n return () => query.removeEventListener?.('change', update);\n }, []);\n return coarse;\n}\n\n/** The \"+\" button and the host's shadcn dropdown menu (Attach files, Take a photo, Commands). */\nfunction PlusActions({ disabled, onAttach, onTakePhoto, onCommands, textarea }: { readonly disabled: boolean; readonly onAttach?: (() => void) | undefined; readonly onTakePhoto?: (() => void) | undefined; readonly onCommands?: (() => void) | undefined; readonly textarea: { readonly current: HTMLTextAreaElement | null } }) {\n const { labels, icons } = useChatText();\n const [open, setOpen] = useState(false);\n // Commands puts `/` in the message: focus goes back to the text, not to the \"+\" button.\n const toText = useRef(false);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a menu left open when the composer gets disabled closes once, after commit\n useEffect(() => { if (disabled) setOpen(false); }, [disabled]);\n return <DropdownMenu open={open} onOpenChange={setOpen}>\n <DropdownMenuTrigger render={<Button variant=\"ghost\" size=\"icon\" />} data-testid=\"composer-plus\" aria-label={labels.add} title={labels.add} disabled={disabled}>\n <icons.add data-icon=\"inline-start\" aria-hidden=\"true\" /></DropdownMenuTrigger>\n <DropdownMenuContent side=\"top\" align=\"start\" aria-label={labels.add} data-testid=\"composer-plus-menu\" className=\"w-52\"\n finalFocus={() => { const text = toText.current; toText.current = false; return text ? textarea.current : true; }}>\n {onAttach && <DropdownMenuItem data-testid=\"composer-attach\" onClick={onAttach} className=\"max-md:min-h-11 pointer-coarse:min-h-11\">\n <icons.attach aria-hidden=\"true\" />{labels.attachFiles}</DropdownMenuItem>}\n {onTakePhoto && <DropdownMenuItem data-testid=\"composer-take-photo\" onClick={onTakePhoto} className=\"max-md:min-h-11 pointer-coarse:min-h-11\">\n <icons.camera aria-hidden=\"true\" />{labels.takePhoto}</DropdownMenuItem>}\n {onCommands && <DropdownMenuItem data-testid=\"composer-commands\" onClick={() => { toText.current = true; onCommands(); }} className=\"max-md:min-h-11 pointer-coarse:min-h-11\">\n <SlashIcon aria-hidden=\"true\" />{labels.commands}</DropdownMenuItem>}\n </DropdownMenuContent>\n </DropdownMenu>;\n}\n\n/** Draws the text with `start..end` wrapped in a mark, at the textarea's exact font, padding and scroll, under its (transparent) background. */\nfunction QueryBackdrop({ text, start, end }: { readonly text: string; readonly start: number; readonly end: number }) {\n const layer = useRef<HTMLDivElement>(null);\n useLayoutEffect(() => {\n const node = layer.current, field = node?.parentElement?.querySelector('textarea');\n if (!node || !field) return;\n const copy = () => {\n const style = getComputedStyle(field);\n for (const name of ['fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'lineHeight', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'textIndent', 'wordSpacing'] as const) node.style[name] = style[name];\n node.style.top = `${field.offsetTop}px`; node.style.left = `${field.offsetLeft}px`; node.style.width = `${field.clientWidth}px`; node.style.height = `${field.clientHeight}px`;\n node.scrollTop = field.scrollTop;\n };\n copy();\n field.addEventListener('scroll', copy);\n return () => field.removeEventListener('scroll', copy);\n });\n return <div ref={layer} aria-hidden=\"true\" data-slot=\"composer-mention-backdrop\" className=\"pointer-events-none absolute overflow-hidden whitespace-pre-wrap break-words text-transparent\">\n {text.slice(0, start)}<mark data-slot=\"composer-mention-query\" className=\"bg-muted text-transparent\">{text.slice(start, end)}</mark>{text.slice(end)}\n </div>;\n}\n\nconst MAX_HEIGHT = 208;\n\n/** Hands the textarea to the caller's ref, a callback or an object. */\nfunction assignRef(ref: Ref<HTMLTextAreaElement>, element: HTMLTextAreaElement | null) {\n if (typeof ref === 'function') ref(element); else if (ref) (ref as { current: HTMLTextAreaElement | null }).current = element;\n}\n\n/** Prompt composer: auto-growing textarea, Enter to send, one primary Send/Stop button. While the agent works a message is queued. */\nexport function Composer(props: ComposerProps) {\n const { attachments, working, sendBlocked, disabled, stopRequested, uploading, canAttach, layout = 'card' } = props;\n const draft = kindMentionDraft(props.text, props.mentions);\n const text = draft.text;\n const tokens = useRef(draft.tokens);\n useLayoutEffect(() => { tokens.current = draft.tokens; });\n // An asynchronous slash command must use today's tokens, not restore a chip removed since it began.\n const onText = (next: string) => props.onText(writeKindMentionDraft(next, tokens.current, props.slash?.skills?.map(skill => skill.name)));\n const { labels, icons } = useChatText();\n const inner = useRef<HTMLTextAreaElement | null>(null);\n const composing = useRef(false);\n const [dragging, setDragging] = useState(false);\n const [caret, setCaret] = useState(0);\n // A menu closed with Escape stays closed until the text changes.\n const [dismissed, setDismissed] = useState<string | null>(null);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- new text re-arms a dismissed menu once, after commit; the updater keeps the value when nothing changed\n useEffect(() => { setDismissed(value => value === null || value === text ? value : null); }, [text]);\n const pendingCaret = useRef<number | null>(null);\n const fileInput = useRef<HTMLInputElement>(null);\n // The camera sheet lives here, not in the menu: the menu content unmounts as soon as it closes.\n const [cameraOpen, setCameraOpen] = useState(false);\n const coarse = useCoarsePointer();\n // A feature shows only when its config carries the component that draws it (`pi-chat-slash`, `pi-chat-mentions`, `pi-chat-attachments`).\n const Camera = props.camera, Slash = props.slash?.Picker, Mention = props.mentions?.Picker;\n const canPhoto = canAttach && coarse && acceptsPhotos(props.fileAccept) && Camera !== undefined;\n const slash = Slash ? props.slash : undefined, mentions = Mention ? props.mentions : undefined;\n const items = useMemo<SlashItem[]>(() => slash ? slashItems(slash) : [], [slash]);\n const recording = Boolean(props.recording);\n const setRef = (element: HTMLTextAreaElement | null) => {\n inner.current = element;\n assignRef(props.textareaRef, element);\n };\n useLayoutEffect(() => {\n if (recording) return;\n const element = inner.current;\n if (!element) return;\n const resize = () => {\n element.style.height = 'auto';\n element.style.height = `${Math.min(element.scrollHeight, MAX_HEIGHT)}px`;\n element.style.overflowY = element.scrollHeight > MAX_HEIGHT ? 'auto' : 'hidden';\n };\n resize();\n if (pendingCaret.current !== null) { element.setSelectionRange(pendingCaret.current, pendingCaret.current); element.focus(); pendingCaret.current = null; }\n if (typeof ResizeObserver === 'undefined') return;\n let width = element.clientWidth;\n const observer = new ResizeObserver(() => {\n const next = element.clientWidth;\n if (next === width) return;\n width = next; resize();\n });\n observer.observe(element);\n return () => observer.disconnect();\n }, [text, recording]);\n\n const query = slash && items.length ? slashQuery(text, caret) : null;\n const trigger = query === null && mentions ? mentionTrigger(text, caret) : null;\n const slashOpen = query !== null && dismissed !== text, mentionOpen = trigger !== null && dismissed !== text;\n const menuOpen = slashOpen || mentionOpen;\n const edit = (next: string, position: number) => { pendingCaret.current = position; setCaret(position); onText(next); };\n const trackCaret = (element: HTMLTextAreaElement) => setCaret(element.selectionStart ?? element.value.length);\n const dismiss = () => setDismissed(text);\n const pickSlash = (item: SlashItem) => {\n const rest = text.slice(caret).replace(/^\\s+/, '');\n if (item.source === 'skill') return edit(`/${item.name} ${rest}`, item.name.length + 2);\n const command = slash?.commands?.find(candidate => candidate.name === item.name);\n onText(rest); setCaret(0);\n inner.current?.focus();\n if (!command) return;\n try { void Promise.resolve(command.run({ text: rest, setText: value => onText(value) })).catch(cause => props.onSlashError?.(cause)); }\n catch (cause) { props.onSlashError?.(cause); }\n };\n const pickMention = (path: string) => {\n if (!trigger) return;\n const token = `${mentionToken(path)} `;\n edit(`${text.slice(0, trigger.start)}${token}${text.slice(trigger.end)}`, trigger.start + token.length);\n props.onMentionPicked?.(path);\n };\n const openCommands = () => edit(`${text}${text && !/\\s$/.test(text) ? ' ' : ''}/`, text.length + (text && !/\\s$/.test(text) ? 2 : 1));\n const chips = [...new Set([...(props.mentionPaths ?? []), ...mentionedPaths(props.text).filter(path => mentionKind(path, props.mentions))])].filter(path => hasMention(props.text, path));\n const onChange = (event: ChangeEvent<HTMLTextAreaElement>) => { trackCaret(event.currentTarget); onText(event.currentTarget.value); };\n\n const { feedback } = props;\n const hasContent = props.text.trim().length > 0 || attachments.length > 0 || props.hasFiles || (feedback?.count ?? 0) > 0;\n const canSubmit = hasContent && !sendBlocked && !disabled && !props.recording;\n const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {\n props.onKeyDown?.(event);\n if (event.defaultPrevented) return;\n // Ctrl+Z right after a paste that is still uploading puts the pasted text back, instead of undoing the paste in the textarea.\n const undo = (props.uploads ?? []).filter(item => item.state === 'uploading' && item.cancel && item.restoreWhile === props.text).pop();\n if (undo && (event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'z') { event.preventDefault(); undo.cancel!(); return; }\n // Never send while an input method editor is composing (Enter confirms the candidate).\n // A phone keyboard has no Shift+Enter: on a touch device Enter inserts a newline and the Send button sends.\n const touch = globalThis.matchMedia?.('(pointer: coarse)').matches === true;\n if (event.key === 'Enter' && !touch && !event.shiftKey && !composing.current && !event.nativeEvent.isComposing && event.keyCode !== 229) {\n event.preventDefault();\n if (canSubmit) props.onSend();\n }\n };\n const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {\n if (canAttach && event.clipboardData.files.length) { event.preventDefault(); props.onPickFiles(Array.from(event.clipboardData.files)); return; }\n if (!props.onPasteText) return;\n const pasted = event.clipboardData.getData('text/plain');\n if (!isLongPaste(pasted, props.pasteToFile)) return;\n event.preventDefault();\n props.onPasteText({ text: pasted, start: event.currentTarget.selectionStart, end: event.currentTarget.selectionEnd });\n };\n const drag = (event: DragEvent) => { if (canAttach) { event.preventDefault(); setDragging(event.type === 'dragover'); } };\n const drop = (event: DragEvent) => { if (!canAttach) return; event.preventDefault(); setDragging(false); props.onPickFiles(Array.from(event.dataTransfer.files)); };\n\n const chipList = (props.quote || chips.length > 0 || (props.uploads?.length ?? 0) > 0 || (feedback?.count ?? 0) > 0 || attachments.length > 0) && <>\n {props.quote}\n {feedback && feedback.count > 0 && <div role=\"list\" className=\"contents\"><NotesChip feedback={feedback} /></div>}\n {chips.length > 0 && <div role=\"list\" data-testid=\"mention-chips\" aria-label={labels.mentionedFiles} className=\"contents\">\n {chips.map(path => {\n const registered = mentionKind(path, props.mentions);\n const label = registered ? registered.kind.label(registered.path) : props.mentions?.label?.(path) ?? path;\n return <Chip key={path} data-testid=\"mention-chip\" data-path={path} title={label} className=\"max-w-full md:max-w-[16rem]\"\n media={registered?.kind.icon ?? props.mentions?.icon ?? <FileTextIcon aria-hidden=\"true\" />} label={registered?.kind.open ? <Button variant=\"link\" size=\"sm\" onClick={() => registered.kind.open?.(registered.path)} className=\"min-w-0 max-w-full justify-start truncate\">{label}</Button> : label}\n actions={<RemoveChip label={labels.remove(label)} onClick={() => props.onText(removeMention(props.text, path))} />} />;\n })}\n </div>}\n {(props.uploads?.length ?? 0) > 0 && <div role=\"list\" data-testid=\"uploads\" aria-label={labels.uploads} className=\"contents\">\n {props.uploads!.map(item => <Chip key={item.id} data-testid=\"upload-chip\" state={item.state === 'failed' ? 'error' : 'uploading'} data-state={item.state} title={item.error ?? item.name} className=\"max-w-full md:max-w-[20rem]\"\n media={item.state === 'failed' ? <AlertCircleIcon className=\"text-destructive\" aria-hidden=\"true\" /> : <Loader2Icon className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />}\n label={item.state === 'failed' ? labels.uploadFailed(item.name, item.error) : labels.uploading(item.name)}\n actions={<>\n {item.cancel && <RemoveChip label={labels.restorePaste} onClick={() => item.cancel!()} />}\n {item.state === 'failed' && <RemoveChip label={labels.dismiss(item.name)} onClick={() => props.onDismissUpload?.(item.id)} />}\n </>} />)}\n </div>}\n {attachments.length > 0 && <div role=\"list\" data-testid=\"attachments\" aria-label={labels.attachments} className=\"contents\">\n {attachments.map(attachment => {\n const src = thumbnail(attachment.mimeType, attachment.data);\n return <Chip key={attachment.id} data-testid=\"attachment\" className=\"max-w-full md:max-w-[14rem]\" image={!!src}\n media={src ? <img src={src} alt=\"\" /> : <FileTextIcon aria-hidden=\"true\" />} label={attachment.name}\n actions={<RemoveChip label={labels.remove(attachment.name)} onClick={() => props.onRemoveAttachment(attachment.id)} />} />;\n })}\n </div>}\n </>;\n const pill = layout === 'pill';\n const textareaEl = <InputGroupTextarea ref={setRef} data-testid=\"composer-input\" aria-label={labels.messageInput} rows={1} value={text} disabled={disabled}\n placeholder={props.placeholderContent ? '' : props.placeholder ?? (working ? labels.placeholderWorking : labels.placeholder)}\n {...(slash || mentions ? { role: 'combobox', 'aria-expanded': menuOpen, 'aria-haspopup': 'listbox', 'aria-autocomplete': 'list' } as const : {})}\n onChange={onChange} onKeyDown={onKeyDown} onPaste={onPaste} onKeyUp={event => trackCaret(event.currentTarget)} onClick={event => trackCaret(event.currentTarget)}\n onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}\n className={cn(props.markMentionQuery && 'relative', pill ? 'max-h-52 min-h-0 min-w-0' : 'max-h-52 min-h-11')} />;\n // The query mark is a backdrop with the textarea's own metrics behind its transparent text: the text the person edits stays the textarea's.\n const marked = props.markMentionQuery && mentionOpen && trigger ? <QueryBackdrop text={text} start={trigger.start} end={trigger.end} /> : null;\n // eslint-disable-next-line react-hooks/refs -- renderInput receives event callbacks, not ref values; an input calls them only on interaction\n const input = props.renderInput?.({ text, onText, onCaret: setCaret, onSend: () => { if (canSubmit && !menuOpen) props.onSend(); } }) ?? props.input ?? (props.placeholderContent || marked ? <div data-slot=\"composer-field\" className=\"relative flex min-h-16 w-full min-w-0 flex-1\">{marked}{textareaEl}{!text && props.placeholderContent && <span aria-hidden=\"true\" data-slot=\"composer-placeholder\" className=\"pointer-events-none absolute inset-x-3 top-2 text-xs text-muted-foreground\">{props.placeholderContent}</span>}</div> : textareaEl);\n const plus = (canAttach || items.length > 0) && <PlusActions disabled={disabled} textarea={inner} onAttach={canAttach ? () => fileInput.current?.click() : undefined} onTakePhoto={canPhoto ? () => setCameraOpen(true) : undefined} onCommands={items.length > 0 ? openCommands : undefined} />;\n // One child slot either way, so a composer without `start` keeps its exact markup (React ids included).\n const startAndPlus = props.start ? <><div data-testid=\"composer-start\" className=\"flex shrink-0 items-center gap-1\">{props.start}</div>{plus}</> : plus;\n const fileEl = canAttach && <Input ref={fileInput} type=\"file\" multiple accept={props.fileAccept} disabled={disabled} tabIndex={-1} aria-hidden=\"true\" className=\"sr-only\" data-testid=\"composer-file\" data-attach=\"true\"\n onChange={event => { const files = Array.from(event.currentTarget.files ?? []); event.currentTarget.value = ''; props.onPickFiles(files); }} />;\n const send = working\n ? <Button variant=\"default\" size=\"icon\" data-testid=\"composer-submit\" data-state=\"stop\" aria-label={labels.stop} title={labels.stop} disabled={(disabled && !props.stopOpen) || stopRequested} onClick={props.onStop} className=\"shrink-0\">\n {stopRequested ? <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" /> : <icons.stop data-icon=\"inline-start\" aria-hidden=\"true\" />}</Button>\n : props.whenEmpty && !hasContent ? <div data-testid=\"composer-when-empty\" className=\"flex shrink-0 items-center\">{props.whenEmpty}</div>\n : <Button variant=\"default\" size=\"icon\" type=\"submit\" data-testid=\"composer-submit\" data-state=\"send\" aria-label={labels.send} title={labels.sendHint} disabled={!canSubmit} className=\"shrink-0\">\n <icons.send data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>;\n const end = <>\n {props.barStart}\n {props.chips && <div className=\"flex min-w-0 items-center gap-2\">{props.chips}</div>}\n {props.barNote}\n {uploading && !(props.uploads?.length) && <span role=\"status\" className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"><Loader2Icon className=\"size-3 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{labels.preparingAttachments}</span>}\n {!pill && <span className=\"min-w-0 flex-1\" />}\n {props.barEnd && <div data-testid=\"composer-tools\" className=\"flex min-w-0 items-center gap-2\">{props.barEnd}</div>}\n {working && hasContent && <Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-queue\" aria-label={labels.queue} title={labels.queue} disabled={!canSubmit} onClick={props.onSend} className=\"shrink-0\">\n <icons.queue data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {send}\n </>;\n\n // One shadcn InputGroup: the chips, the text and a row of controls (`card`, also used for the `stacked` and `inline` compatibility\n // values), or one row with the text between the controls (`pill`, chips above it). Its look is the host's; ours is layout only.\n return <form data-testid=\"composer\" data-layout={layout} data-dragging={dragging || undefined} onSubmit={event => { event.preventDefault(); if (canSubmit) props.onSend(); }} onDragOver={drag} onDragLeave={drag} onDrop={drop}\n className={cn('relative flex flex-col gap-2', pill && '@container/composer')}>\n {fileEl}\n {slashOpen && Slash && <Slash query={query!} items={items} onSelect={pickSlash} onDismiss={dismiss} />}\n {mentionOpen && mentions && Mention && <Mention query={trigger!.query} listLabel={mentions.listLabel} renderOption={mentions.renderOption} header={typeof mentions.pickerHeader === 'function' ? mentions.pickerHeader(trigger!.query) : mentions.pickerHeader} search={mentions.search} generation={mentions.generation} onSelect={pickMention} onDismiss={dismiss} />}\n {pill && !props.recording && chipList && <div className=\"flex flex-wrap gap-2\">{chipList}</div>}\n {props.recording ? <InputGroup>\n {chipList && <InputGroupAddon align=\"block-start\" className=\"flex-wrap\">{chipList}</InputGroupAddon>}\n <InputGroupAddon align=\"block-end\" data-testid=\"composer-controls\" className=\"w-full\">{props.recording}</InputGroupAddon>\n </InputGroup> : pill ? <InputGroup>\n <InputGroupAddon>{startAndPlus}{feedback && <AnnotateButton feedback={feedback} disabled={disabled} />}</InputGroupAddon>\n {input}\n <InputGroupAddon align=\"inline-end\" data-testid=\"composer-controls\">{end}</InputGroupAddon>\n </InputGroup> : <InputGroup>\n {chipList && <InputGroupAddon align=\"block-start\" className=\"flex-wrap\">{chipList}</InputGroupAddon>}\n {input}\n <InputGroupAddon align=\"block-end\" data-testid=\"composer-controls\">\n {startAndPlus}\n {feedback && <AnnotateButton feedback={feedback} disabled={disabled} />}\n {end}\n </InputGroupAddon>\n </InputGroup>}\n {cameraOpen && Camera && <Camera onClose={() => setCameraOpen(false)} onCapture={file => { setCameraOpen(false); props.onPickFiles([file]); }} />}\n </form>;\n}\n",
|
|
40
40
|
"type": "registry:component",
|
|
41
41
|
"target": "components/pi-chat/composer.tsx"
|
|
42
42
|
},
|
|
43
43
|
{
|
|
44
44
|
"path": "registry/pi-chat/conversation-files.tsx",
|
|
45
|
-
"content": "'use client';\n\nimport { useEffect, useState } from 'react';\nimport { FileTextIcon, ShapesIcon } from '
|
|
45
|
+
"content": "'use client';\n\nimport { useEffect, useState } from 'react';\nimport { FileTextIcon, ShapesIcon } from '../_shared/icons';\nimport { Button } from '@/components/ui/button';\nimport { Drawer, DrawerContent, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { useChatText } from './labels';\nimport { usePhone } from '../utils/use-media';\n\n/** A conversation's observed file, opened only through the host's existing callback. */\nexport type { ConversationFile } from '@hachej/boring-ui-kit/chat-react';\nimport type { ConversationFile } from '@hachej/boring-ui-kit/chat-react';\n\n/** Conversation files, not a second workspace: the native menu on desktop and the installed drawer on phones. */\nexport function ConversationFiles({ files }: { readonly files: readonly ConversationFile[] }) {\n const { labels } = useChatText();\n const [open, setOpen] = useState(false);\n const phone = usePhone();\n // eslint-disable-next-line react-hooks/set-state-in-effect -- switching presentation closes the old menu once, after commit\n useEffect(() => { setOpen(false); }, [phone]);\n const available = files.filter(file => file.open);\n if (!available.length) return null;\n const trigger = <Button variant=\"outline\" data-testid=\"conversation-files\" className=\"mb-3 max-w-full gap-2\"><ShapesIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.artifacts}<span className=\"text-muted-foreground\">· {available.length}</span></Button>;\n const label = (file: ConversationFile) => <><FileTextIcon data-icon=\"inline-start\" className=\"shrink-0\" aria-hidden=\"true\" /><span className=\"min-w-0 truncate\">{file.name}</span></>;\n const select = (file: ConversationFile) => { setOpen(false); file.open?.(); };\n return phone ? <Drawer open={open} onOpenChange={setOpen} showSwipeHandle><DrawerTrigger render={trigger} /><DrawerContent data-testid=\"conversation-files-list\" className=\"pi-chat max-h-[80dvh]\">\n <div className=\"flex min-h-0 flex-col px-2 pt-2 pb-[env(safe-area-inset-bottom)]\"><div className=\"px-2\"><DrawerTitle className=\"m-0\">{labels.artifacts}</DrawerTitle></div>\n <ul className=\"m-0 max-h-[60dvh] list-none overflow-y-auto p-0\">{available.map(file => <li key={file.id} className=\"flex min-h-12\"><Button variant=\"ghost\" size=\"sm\" data-testid=\"conversation-file\" data-file={file.id} className=\"h-auto w-full justify-start gap-2 text-start\" onClick={() => select(file)}>{label(file)}</Button></li>)}</ul>\n </div></DrawerContent></Drawer>\n : <DropdownMenu open={open} onOpenChange={setOpen}><DropdownMenuTrigger render={trigger} /><DropdownMenuContent side=\"top\" align=\"start\" sideOffset={6} data-testid=\"conversation-files-list\" aria-label={labels.artifacts} onKeyDown={event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); setOpen(false); } }} className=\"pi-chat w-80 max-w-[calc(100vw-32px)]\">\n <DropdownMenuGroup className=\"max-h-[60dvh] overflow-y-auto\"><DropdownMenuLabel>{labels.artifacts}</DropdownMenuLabel>\n {available.map(file => <DropdownMenuItem key={file.id} data-testid=\"conversation-file\" data-file={file.id} className=\"min-h-8 pointer-coarse:min-h-12\" onClick={() => select(file)}>{label(file)}</DropdownMenuItem>)}\n </DropdownMenuGroup></DropdownMenuContent></DropdownMenu>;\n}\n",
|
|
46
46
|
"type": "registry:component",
|
|
47
47
|
"target": "components/pi-chat/conversation-files.tsx"
|
|
48
48
|
},
|
|
@@ -60,19 +60,19 @@
|
|
|
60
60
|
},
|
|
61
61
|
{
|
|
62
62
|
"path": "registry/pi-chat/history.tsx",
|
|
63
|
-
"content": "'use client';\n\nimport { usePhone } from '../utils/use-media';\n\nimport { Fragment, useEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ChatHistoryState } from '@hachej/boring-ui-kit/native-chat';\nimport { ArchiveIcon, ArchiveRestoreIcon, ArrowLeftIcon, CheckIcon, EllipsisIcon, Loader2Icon, MessageSquareIcon, PencilIcon, SearchIcon, Trash2Icon, XIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { targetContent } from './rows';\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { Input } from '@/components/ui/input';\nimport { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { ScrollArea } from '@/components/ui/scroll-area';\nimport { Separator } from '@/components/ui/separator';\nimport { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuItem } from '@/components/ui/sidebar';\nimport { Toggle } from '@/components/ui/toggle';\nimport { ActionButton, actionMenuItem, headerActions, menuActions } from '../button/actions';\nimport type { BlockAction, MenuItem } from '../button/actions';\nimport type { ConversationItem, ConversationsConfig } from './config';\nimport { cn } from '../utils/utils';\nimport { defaultChatLabels, useChatText } from './labels';\nimport type { ChatLabels } from './labels';\n\nconst DAY = 86_400_000;\nconst toMs = (value: ConversationItem['updatedAt']): number | undefined => {\n if (value === undefined) return undefined;\n const ms = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : Date.parse(value);\n return Number.isFinite(ms) ? ms : undefined;\n};\n\n/** Short relative time for a list row: \"now\", \"5m\", \"3h\", \"Yesterday\", \"4d\", then a date. */\n/** The tooltip's last message without a leading `<target>` block (point-and-speak); a preview clipped inside the block gets no tooltip. */\nconst lastSaid = (text: string | null | undefined): string | undefined => !text ? undefined : text.startsWith('<target>') ? targetContent(text)?.speech || undefined : text;\n\nexport function relativeTime(value: ConversationItem['updatedAt'], now = Date.now(), labels: Pick<ChatLabels, 'justNow' | 'yesterday'> = defaultChatLabels): string {\n const ms = toMs(value);\n if (ms === undefined) return '';\n const age = Math.max(0, now - ms);\n if (age < 60_000) return labels.justNow;\n if (age < 3_600_000) return `${Math.floor(age / 60_000)}m`;\n if (age < DAY) return `${Math.floor(age / 3_600_000)}h`;\n const days = Math.floor(age / DAY);\n if (days === 1) return labels.yesterday;\n if (days < 7) return `${days}d`;\n return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric', ...(days > 300 ? { year: 'numeric' } : {}) });\n}\n\ntype Group = 'today' | 'yesterday' | 'previousWeek' | 'older' | 'conversations';\nfunction groupOf(value: ConversationItem['updatedAt'], now: number): Group {\n const ms = toMs(value);\n if (ms === undefined) return 'conversations';\n const start = new Date(now); start.setHours(0, 0, 0, 0);\n const age = start.getTime() - ms;\n return age <= 0 ? 'today' : age <= DAY ? 'yesterday' : age < 7 * DAY ? 'previousWeek' : 'older';\n}\nconst ORDER: readonly Group[] = ['today', 'yesterday', 'previousWeek', 'older', 'conversations'];\n\n/** The row's \"…\" menu: host actions in the consumer's shadcn dropdown-menu. */\nfunction RowActions({ label, items }: { readonly label: string; readonly items: readonly MenuItem[] }) {\n const portal = useRef<HTMLElement | null>(null);\n return <DropdownMenu>\n <DropdownMenuTrigger render={<Button ref={node => { portal.current = node?.ownerDocument.body ?? null; }} variant=\"ghost\" size=\"icon-sm\" aria-label={label} title={label} data-testid=\"conversation-action-more\"><EllipsisIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>} />\n <DropdownMenuPortal container={portal}>\n <DropdownMenuContent align=\"end\" aria-label={label} data-testid=\"conversation-action-more-list\" className=\"w-64 max-w-[calc(100vw-1rem)]\">\n {items.map(item => <Fragment key={item.id}>\n {item.separatorBefore && <DropdownMenuSeparator />}\n <DropdownMenuItem variant={item.destructive ? 'destructive' : 'default'} disabled={item.disabled} data-testid={item.testId ?? `conversation-action-more-${item.id}`} data-item={item.id} onClick={item.onSelect} className=\"min-h-8 max-md:min-h-10 pointer-coarse:min-h-10\">\n {item.icon}{item.label}\n </DropdownMenuItem>\n </Fragment>)}\n </DropdownMenuContent>\n </DropdownMenuPortal>\n </DropdownMenu>;\n}\n\n/**\n * The searchable list of past conversations: a search box (server-side when the host offers `search`), the Archived filter, rows grouped by\n * recency with the open one marked, and the row actions the host offers (rename, archive, delete). It fills its container; `ConversationHistory`\n * puts it in a popover or sheet, the `pi-app` block in its sessions pane. `onPicked` runs after a row is chosen.\n */\nexport function ConversationList({ conversations, onPicked, autoFocus = false, searchMode = 'disclosure', rowActionLayout = 'menu', navigation }: {\n readonly conversations: ConversationsConfig;\n /** `inline` retains the always-visible search field. */\n readonly searchMode?: 'disclosure' | 'inline';\n /** `inline` retains the separate rename/archive/delete buttons. */\n readonly rowActionLayout?: 'menu' | 'inline';\n readonly navigation?: ReactNode;\n readonly onPicked?: (() => void) | undefined;\n /** Focus the search box on mount (not on touch screens). */\n readonly autoFocus?: boolean;\n}) {\n const { labels } = useChatText();\n const [query, setQuery] = useState('');\n const [searchOpen, setSearchOpen] = useState(autoFocus || searchMode === 'inline');\n const [archived, setArchived] = useState(false);\n // Server-side results while a query is typed or the Archived filter is on (when the host offers `search`).\n const [found, setFound] = useState<{ readonly items: readonly ConversationItem[]; readonly loading: boolean; readonly failed?: boolean } | null>(null);\n const [revision, setRevision] = useState(0);\n const [renaming, setRenaming] = useState<string | null>(null);\n const [confirming, setConfirming] = useState<string | null>(null);\n const [failure, setFailure] = useState<string | null>(null);\n const search = useRef<HTMLInputElement>(null);\n // The time the list arrived: rows group and age against it until the host sends new items.\n // eslint-disable-next-line react-hooks/purity, react-hooks/exhaustive-deps -- read once per items list on purpose, so rows keep one clock between list updates\n const now = useMemo(() => Date.now(), [conversations.items]);\n useEffect(() => { if (autoFocus && !globalThis.matchMedia?.('(pointer: coarse)').matches) search.current?.focus(); }, [autoFocus]);\n const serverSearch = conversations.search;\n useEffect(() => {\n const needle = query.trim();\n // eslint-disable-next-line react-hooks/set-state-in-effect -- leaving server search drops its results once, after commit\n if (!serverSearch || (!needle && !archived)) { setFound(null); return; }\n const controller = new AbortController();\n setFound(previous => ({ items: previous?.items ?? [], loading: true }));\n const timer = setTimeout(() => {\n serverSearch(needle, { archived }, controller.signal).then(items => { if (!controller.signal.aborted) setFound({ items, loading: false }); })\n .catch(() => { if (!controller.signal.aborted) setFound({ items: [], loading: false, failed: true }); });\n }, 150);\n return () => { clearTimeout(timer); controller.abort(); };\n }, [serverSearch, query, archived, revision]);\n /** Runs one row action; the host refreshes `items`, and a server-side search runs again. */\n const run = (action: () => Promise<void>) => {\n setFailure(null);\n action().then(() => setRevision(value => value + 1), () => setFailure(labels.actionFailed));\n };\n const shown = useMemo(() => {\n const needle = query.trim().toLowerCase();\n const items = found ? found.items : conversations.items.filter(item => !item.archived && (!needle || (item.title ?? labels.untitled).toLowerCase().includes(needle)));\n const sorted = [...items].sort((a, b) => (toMs(b.updatedAt) ?? 0) - (toMs(a.updatedAt) ?? 0));\n const groups = new Map<Group, ConversationItem[]>();\n for (const item of sorted) { const key = groupOf(item.updatedAt, now); groups.set(key, [...(groups.get(key) ?? []), item]); }\n return ORDER.flatMap(key => groups.has(key) ? [{ key, items: groups.get(key)! }] : []);\n }, [conversations.items, found, query, now, labels.untitled]);\n const loading = found ? found.loading : conversations.loading;\n const total = shown.reduce((sum, group) => sum + group.items.length, 0);\n const rowActionItems = (item: ConversationItem, extraButtons: readonly BlockAction[], extraMenu: readonly BlockAction[]): MenuItem[] => [\n ...(conversations.rename ? [{ id: 'rename', icon: <PencilIcon />, label: labels.rename, testId: 'conversation-rename', onSelect: () => setRenaming(item.id) }] : []),\n ...(conversations.archive ? [{ id: 'archive', icon: item.archived ? <ArchiveRestoreIcon /> : <ArchiveIcon />, label: item.archived ? labels.restore : labels.archive, testId: 'conversation-archive', onSelect: () => run(() => conversations.archive!(item.id, !item.archived)) }] : []),\n ...(conversations.remove ? [{ id: 'delete', icon: <Trash2Icon />, destructive: true, separatorBefore: true, label: labels.delete, testId: 'conversation-delete', onSelect: () => setConfirming(item.id) }] : []),\n ...[...extraButtons, ...extraMenu].map(entry => actionMenuItem(entry, 'conversation-action')),\n ];\n return <>\n {!searchOpen && <Button variant=\"ghost\" data-testid=\"conversation-search-open\" className=\"mx-2 justify-start\" onClick={() => { setSearchOpen(true); requestAnimationFrame(() => search.current?.focus()); }}><SearchIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.search}</Button>}\n {searchOpen && <div className=\"shrink-0 px-3 py-2\">\n <InputGroup>\n <InputGroupAddon><SearchIcon aria-hidden=\"true\" /></InputGroupAddon>\n <InputGroupInput ref={search} type=\"search\" value={query} onChange={event => setQuery(event.currentTarget.value)} placeholder={labels.searchConversations} aria-label={labels.searchConversations} data-testid=\"conversation-search\" />\n {conversations.archive && serverSearch && <InputGroupAddon align=\"inline-end\"><Toggle pressed={archived} aria-label={labels.archived} title={labels.archived} data-testid=\"conversations-archived\"\n onPressedChange={setArchived}><ArchiveIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.archived}</Toggle></InputGroupAddon>}\n </InputGroup>\n </div>}\n {navigation}\n {failure && <p role=\"alert\" className=\"m-0 shrink-0 px-3 pt-2 text-xs text-destructive\">{failure}</p>}\n {/* A sheet has only a max-height, so the viewport's stock height of 100% resolves to its content: a flex column makes it shrink to the room left, and fill it in a parent with a set height. */}\n <ScrollArea className=\"flex min-h-0 flex-1 flex-col [&>[data-slot=scroll-area-viewport]]:h-auto [&>[data-slot=scroll-area-viewport]]:grow [&>[data-slot=scroll-area-viewport]]:overscroll-contain\" aria-busy={loading ? 'true' : undefined}>\n {found?.failed && <p role=\"alert\" data-testid=\"conversations-search-failed\" className=\"m-0 px-3 py-8 text-center text-sm text-destructive\">{labels.searchFailed}</p>}\n {total === 0 && !found?.failed && <p role=\"status\" data-testid=\"conversations-empty\" className=\"m-0 px-3 py-8 text-center text-sm text-muted-foreground\">\n {loading ? labels.loadingConversations : query.trim() ? labels.noMatch(query.trim()) : archived ? labels.noArchived : labels.noConversations}</p>}\n {shown.map(group => <SidebarGroup key={group.key} aria-label={labels[group.key]} role=\"group\">\n <SidebarGroupLabel render={<h4 />}>{labels[group.key]}</SidebarGroupLabel>\n <SidebarMenu>\n {group.items.map(item => {\n const active = item.id === conversations.activeId;\n const title = (item.title?.startsWith('<target>') ? lastSaid(item.title) : item.title)?.trim() || labels.untitled;\n const extra = conversations.rowActions?.(item);\n // A row has room for icons only: a header action without an icon goes in the row's \"…\" menu.\n const extraButtons = headerActions(extra).filter(entry => entry.icon), extraMenu = [...headerActions(extra).filter(entry => !entry.icon), ...menuActions(extra)];\n if (renaming === item.id && conversations.rename) {\n const rename = conversations.rename;\n return <SidebarMenuItem key={item.id}>\n <form className=\"flex items-center gap-2\" onSubmit={event => {\n event.preventDefault();\n const value = new FormData(event.currentTarget).get('title')?.toString().trim();\n setRenaming(null);\n if (value && value !== item.title) run(() => rename(item.id, value));\n }}>\n <PencilIcon className=\"size-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <Input name=\"title\" autoFocus defaultValue={title} maxLength={200} aria-label={labels.conversationName} data-testid=\"conversation-rename-input\"\n onKeyDown={event => { if (event.key === 'Escape') { event.stopPropagation(); setRenaming(null); } }}\n className=\"min-w-0 flex-1\" />\n <Button type=\"submit\" size=\"sm\" variant=\"outline\" data-testid=\"conversation-rename-save\">{labels.save}</Button>\n </form>\n </SidebarMenuItem>;\n }\n return <SidebarMenuItem key={item.id} className=\"flex items-center gap-0.5\">\n <Button type=\"button\" variant={active ? 'secondary' : 'ghost'} data-testid=\"conversation-row\" data-conversation-id={item.id} data-active={active ? 'true' : undefined} aria-current={active ? 'true' : undefined}\n title={lastSaid(item.lastMessage)}\n onClick={() => { if (!active) conversations.onSelect(item.id); onPicked?.(); }}\n className=\"h-auto min-h-10 min-w-0 flex-1 justify-start max-md:min-h-12\">\n <MessageSquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n <span className=\"min-w-0 flex-1 truncate text-left\">{title}</span>\n {active ? <span className=\"inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground\"><CheckIcon className=\"size-3.5\" aria-hidden=\"true\" /><span className=\"sr-only\">{labels.current}</span></span>\n : <span className=\"shrink-0 text-xs text-muted-foreground tabular-nums\">{relativeTime(item.updatedAt, now, labels)}</span>}\n </Button>\n {(conversations.rename || conversations.archive || conversations.remove || extraButtons.length > 0 || extraMenu.length > 0) && <div data-testid=\"conversation-actions\"\n className={cn('flex shrink-0 items-center gap-0.5', confirming === item.id ? '' : 'opacity-0 group-hover/menu-item:opacity-100 group-focus-within/menu-item:opacity-100 pointer-coarse:opacity-100')}>\n {confirming === item.id && conversations.remove ? <>\n <Button size=\"sm\" variant=\"destructive\" data-testid=\"conversation-delete-confirm\" onClick={() => { const remove = conversations.remove!; setConfirming(null); run(() => remove(item.id)); }}>{labels.delete}</Button>\n <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.keep} title={labels.keep} onClick={() => setConfirming(null)}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </> : rowActionLayout === 'menu' ? <RowActions label={labels.moreActions} items={rowActionItems(item, extraButtons, extraMenu)} /> : <>\n {conversations.rename && <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.renameItem(title)} title={labels.rename} data-testid=\"conversation-rename\" onClick={() => setRenaming(item.id)}><PencilIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {conversations.archive && <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.archiveItem(title, item.archived === true)} title={item.archived ? labels.restore : labels.archive} data-testid=\"conversation-archive\"\n onClick={() => { const archive = conversations.archive!; run(() => archive(item.id, !item.archived)); }}>{item.archived ? <ArchiveRestoreIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <ArchiveIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}</Button>}\n {conversations.remove && <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.deleteItem(title)} title={labels.delete} data-testid=\"conversation-delete\" onClick={() => setConfirming(item.id)}><Trash2Icon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {extraButtons.map(entry => <ActionButton key={entry.id} action={entry} testId=\"conversation-action\" />)}\n {extraMenu.length > 0 && <RowActions label={labels.moreActions} items={extraMenu.map(entry => actionMenuItem(entry, 'conversation-action'))} />}\n </>}\n </div>}\n </SidebarMenuItem>;\n })}\n </SidebarMenu>\n </SidebarGroup>)}\n </ScrollArea>\n </>;\n}\n\n/**\n * The list of past conversations in a dialog: a header with New, then `ConversationList`.\n * An anchored card on a wide screen and a bottom drawer on a phone. Escape or a click outside closes it.\n */\nexport function ConversationHistory({ conversations, onClose, placement = 'below' }: {\n readonly conversations: ConversationsConfig;\n readonly onClose: () => void;\n /** `above` opens the card upwards from a bar docked at the bottom of the screen; the backdrop then covers the whole page. */\n readonly placement?: 'below' | 'above';\n}) {\n const { labels, icons } = useChatText();\n const phone = usePhone();\n useEffect(() => {\n const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); };\n document.addEventListener('keydown', escape);\n return () => document.removeEventListener('keydown', escape);\n }, [onClose]);\n const content = <>\n <header className=\"flex shrink-0 items-center gap-2 px-3 py-2.5 max-md:pt-[max(0.625rem,env(safe-area-inset-top))]\">\n <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.closeHistory} title={labels.close} data-testid=\"conversations-close\" onClick={onClose} className=\"md:hidden\"><ArrowLeftIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n <h3 className=\"m-0 min-w-0 flex-1 text-sm font-semibold\">{labels.history}</h3>\n {conversations.onNew && <Button size=\"sm\" variant=\"outline\" data-testid=\"conversation-new\" onClick={() => { conversations.onNew!(); onClose(); }}><icons.newChat data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.newConversation}</Button>}\n <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.closeHistory} title={labels.close} onClick={onClose} className=\"max-md:hidden\"><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </header>\n <Separator />\n <ConversationList conversations={conversations} onPicked={onClose} autoFocus />\n </>;\n return phone ? <Drawer open showSwipeHandle onOpenChange={next => { if (!next) onClose(); }}><DrawerContent data-testid=\"conversations\" aria-label={labels.historyDialog} className=\"pi-chat min-h-[80dvh]\"><DrawerTitle className=\"sr-only\">{labels.historyDialog}</DrawerTitle><div className=\"flex min-h-0 flex-1 flex-col pb-[env(safe-area-inset-bottom)]\">{content}</div></DrawerContent></Drawer>\n : <Popover open onOpenChange={next => { if (!next) onClose(); }} modal={false}>\n <PopoverTrigger nativeButton={false} render={<span aria-hidden=\"true\" className={cn('pointer-events-none absolute', placement === 'above' ? 'bottom-0 left-0' : 'top-12 right-3')} />} />\n <PopoverContent role=\"dialog\" aria-label={labels.historyDialog} data-testid=\"conversations\" side={placement === 'above' ? 'top' : 'bottom'} align={placement === 'above' ? 'start' : 'end'} className=\"pi-chat w-[23rem] max-w-[calc(100vw-1rem)] max-h-[min(34rem,70dvh)]\">{content}</PopoverContent>\n </Popover>;\n}\n\n/** The only history chrome inside the normal transcript: a sentinel, spinner and accessible fallback. */\nexport function TranscriptHistory({ history, connected, onLoad, sentinelRef, hidden, loaded }: {\n readonly history: ChatHistoryState;\n readonly connected: boolean;\n readonly onLoad: () => void;\n readonly sentinelRef: (element: HTMLDivElement | null) => void;\n readonly hidden: number;\n readonly loaded: number;\n}) {\n const { labels } = useChatText();\n if (history.kind === 'disabled' && hidden === 0) return null;\n const exhausted = hidden === 0 && history.kind === 'ready' && !history.hasMore;\n return <div ref={sentinelRef} data-testid=\"transcript-history\" className=\"flex min-h-8 shrink-0 items-center justify-center text-xs text-muted-foreground\">\n {!exhausted && <Button variant=\"ghost\" data-testid=\"transcript-history-load\" className={history.kind === 'error' ? undefined : 'sr-only focus:not-sr-only'} disabled={!connected || history.kind === 'loading'} onClick={onLoad}>{labels.loadEarlierMessages}</Button>}\n {history.kind === 'loading' && <Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-label={labels.loadingShort} />}\n {history.kind === 'error' && <span role=\"alert\">{labels.historyFailed}</span>}\n {exhausted && <span>{labels.beginning}</span>}\n <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">{loaded > 0 ? labels.earlierMessagesLoaded(loaded) : ''}</span>\n </div>;\n}\n",
|
|
63
|
+
"content": "'use client';\n\nimport { usePhone } from '../utils/use-media';\n\nimport { Fragment, useEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ChatHistoryState } from '@hachej/boring-ui-kit/native-chat';\nimport { ArchiveIcon, ArchiveRestoreIcon, ArrowLeftIcon, CheckIcon, EllipsisIcon, Loader2Icon, MessageSquareIcon, PencilIcon, SearchIcon, Trash2Icon, XIcon } from '../_shared/icons';\nimport { Button } from '@/components/ui/button';\nimport { targetContent } from './rows';\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { Input } from '@/components/ui/input';\nimport { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { ScrollArea } from '@/components/ui/scroll-area';\nimport { Separator } from '@/components/ui/separator';\nimport { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuItem } from '@/components/ui/sidebar';\nimport { Toggle } from '@/components/ui/toggle';\nimport { ActionButton, actionMenuItem, headerActions, menuActions } from '../button/actions';\nimport type { BlockAction, MenuItem } from '../button/actions';\nimport type { ConversationItem, ConversationsConfig } from './config';\nimport { cn } from '../utils/utils';\nimport { defaultChatLabels, useChatText } from './labels';\nimport type { ChatLabels } from './labels';\n\nconst DAY = 86_400_000;\nconst toMs = (value: ConversationItem['updatedAt']): number | undefined => {\n if (value === undefined) return undefined;\n const ms = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : Date.parse(value);\n return Number.isFinite(ms) ? ms : undefined;\n};\n\n/** Short relative time for a list row: \"now\", \"5m\", \"3h\", \"Yesterday\", \"4d\", then a date. */\n/** The tooltip's last message without a leading `<target>` block (point-and-speak); a preview clipped inside the block gets no tooltip. */\nconst lastSaid = (text: string | null | undefined): string | undefined => !text ? undefined : text.startsWith('<target>') ? targetContent(text)?.speech || undefined : text;\n\nexport function relativeTime(value: ConversationItem['updatedAt'], now = Date.now(), labels: Pick<ChatLabels, 'justNow' | 'yesterday'> = defaultChatLabels): string {\n const ms = toMs(value);\n if (ms === undefined) return '';\n const age = Math.max(0, now - ms);\n if (age < 60_000) return labels.justNow;\n if (age < 3_600_000) return `${Math.floor(age / 60_000)}m`;\n if (age < DAY) return `${Math.floor(age / 3_600_000)}h`;\n const days = Math.floor(age / DAY);\n if (days === 1) return labels.yesterday;\n if (days < 7) return `${days}d`;\n return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric', ...(days > 300 ? { year: 'numeric' } : {}) });\n}\n\ntype Group = 'today' | 'yesterday' | 'previousWeek' | 'older' | 'conversations';\nfunction groupOf(value: ConversationItem['updatedAt'], now: number): Group {\n const ms = toMs(value);\n if (ms === undefined) return 'conversations';\n const start = new Date(now); start.setHours(0, 0, 0, 0);\n const age = start.getTime() - ms;\n return age <= 0 ? 'today' : age <= DAY ? 'yesterday' : age < 7 * DAY ? 'previousWeek' : 'older';\n}\nconst ORDER: readonly Group[] = ['today', 'yesterday', 'previousWeek', 'older', 'conversations'];\n\n/** The row's \"…\" menu: host actions in the consumer's shadcn dropdown-menu. */\nfunction RowActions({ label, items }: { readonly label: string; readonly items: readonly MenuItem[] }) {\n const portal = useRef<HTMLElement | null>(null);\n return <DropdownMenu>\n <DropdownMenuTrigger render={<Button ref={node => { portal.current = node?.ownerDocument.body ?? null; }} variant=\"ghost\" size=\"icon-sm\" aria-label={label} title={label} data-testid=\"conversation-action-more\"><EllipsisIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>} />\n <DropdownMenuPortal container={portal}>\n <DropdownMenuContent align=\"end\" aria-label={label} data-testid=\"conversation-action-more-list\" className=\"w-64 max-w-[calc(100vw-1rem)]\">\n {items.map(item => <Fragment key={item.id}>\n {item.separatorBefore && <DropdownMenuSeparator />}\n <DropdownMenuItem variant={item.destructive ? 'destructive' : 'default'} disabled={item.disabled} data-testid={item.testId ?? `conversation-action-more-${item.id}`} data-item={item.id} onClick={item.onSelect} className=\"min-h-8 max-md:min-h-10 pointer-coarse:min-h-10\">\n {item.icon}{item.label}\n </DropdownMenuItem>\n </Fragment>)}\n </DropdownMenuContent>\n </DropdownMenuPortal>\n </DropdownMenu>;\n}\n\n/**\n * The searchable list of past conversations: a search box (server-side when the host offers `search`), the Archived filter, rows grouped by\n * recency with the open one marked, and the row actions the host offers (rename, archive, delete). It fills its container; `ConversationHistory`\n * puts it in a popover or sheet, the `pi-app` block in its sessions pane. `onPicked` runs after a row is chosen.\n */\nexport function ConversationList({ conversations, onPicked, autoFocus = false, searchMode = 'disclosure', rowActionLayout = 'menu', navigation }: {\n readonly conversations: ConversationsConfig;\n /** `inline` retains the always-visible search field. */\n readonly searchMode?: 'disclosure' | 'inline';\n /** `inline` retains the separate rename/archive/delete buttons. */\n readonly rowActionLayout?: 'menu' | 'inline';\n readonly navigation?: ReactNode;\n readonly onPicked?: (() => void) | undefined;\n /** Focus the search box on mount (not on touch screens). */\n readonly autoFocus?: boolean;\n}) {\n const { labels } = useChatText();\n const [query, setQuery] = useState('');\n const [searchOpen, setSearchOpen] = useState(autoFocus || searchMode === 'inline');\n const [archived, setArchived] = useState(false);\n // Server-side results while a query is typed or the Archived filter is on (when the host offers `search`).\n const [found, setFound] = useState<{ readonly items: readonly ConversationItem[]; readonly loading: boolean; readonly failed?: boolean } | null>(null);\n const [revision, setRevision] = useState(0);\n const [renaming, setRenaming] = useState<string | null>(null);\n const [confirming, setConfirming] = useState<string | null>(null);\n const [failure, setFailure] = useState<string | null>(null);\n const search = useRef<HTMLInputElement>(null);\n // The time the list arrived: rows group and age against it until the host sends new items.\n // eslint-disable-next-line react-hooks/purity, react-hooks/exhaustive-deps -- read once per items list on purpose, so rows keep one clock between list updates\n const now = useMemo(() => Date.now(), [conversations.items]);\n useEffect(() => { if (autoFocus && !globalThis.matchMedia?.('(pointer: coarse)').matches) search.current?.focus(); }, [autoFocus]);\n const serverSearch = conversations.search;\n useEffect(() => {\n const needle = query.trim();\n // eslint-disable-next-line react-hooks/set-state-in-effect -- leaving server search drops its results once, after commit\n if (!serverSearch || (!needle && !archived)) { setFound(null); return; }\n const controller = new AbortController();\n setFound(previous => ({ items: previous?.items ?? [], loading: true }));\n const timer = setTimeout(() => {\n serverSearch(needle, { archived }, controller.signal).then(items => { if (!controller.signal.aborted) setFound({ items, loading: false }); })\n .catch(() => { if (!controller.signal.aborted) setFound({ items: [], loading: false, failed: true }); });\n }, 150);\n return () => { clearTimeout(timer); controller.abort(); };\n }, [serverSearch, query, archived, revision]);\n /** Runs one row action; the host refreshes `items`, and a server-side search runs again. */\n const run = (action: () => Promise<void>) => {\n setFailure(null);\n action().then(() => setRevision(value => value + 1), () => setFailure(labels.actionFailed));\n };\n const shown = useMemo(() => {\n const needle = query.trim().toLowerCase();\n const items = found ? found.items : conversations.items.filter(item => !item.archived && (!needle || (item.title ?? labels.untitled).toLowerCase().includes(needle)));\n const sorted = [...items].sort((a, b) => (toMs(b.updatedAt) ?? 0) - (toMs(a.updatedAt) ?? 0));\n const groups = new Map<Group, ConversationItem[]>();\n for (const item of sorted) { const key = groupOf(item.updatedAt, now); groups.set(key, [...(groups.get(key) ?? []), item]); }\n return ORDER.flatMap(key => groups.has(key) ? [{ key, items: groups.get(key)! }] : []);\n }, [conversations.items, found, query, now, labels.untitled]);\n const loading = found ? found.loading : conversations.loading;\n const total = shown.reduce((sum, group) => sum + group.items.length, 0);\n const rowActionItems = (item: ConversationItem, extraButtons: readonly BlockAction[], extraMenu: readonly BlockAction[]): MenuItem[] => [\n ...(conversations.rename ? [{ id: 'rename', icon: <PencilIcon />, label: labels.rename, testId: 'conversation-rename', onSelect: () => setRenaming(item.id) }] : []),\n ...(conversations.archive ? [{ id: 'archive', icon: item.archived ? <ArchiveRestoreIcon /> : <ArchiveIcon />, label: item.archived ? labels.restore : labels.archive, testId: 'conversation-archive', onSelect: () => run(() => conversations.archive!(item.id, !item.archived)) }] : []),\n ...(conversations.remove ? [{ id: 'delete', icon: <Trash2Icon />, destructive: true, separatorBefore: true, label: labels.delete, testId: 'conversation-delete', onSelect: () => setConfirming(item.id) }] : []),\n ...[...extraButtons, ...extraMenu].map(entry => actionMenuItem(entry, 'conversation-action')),\n ];\n return <>\n {!searchOpen && <Button variant=\"ghost\" data-testid=\"conversation-search-open\" className=\"mx-2 justify-start\" onClick={() => { setSearchOpen(true); requestAnimationFrame(() => search.current?.focus()); }}><SearchIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.search}</Button>}\n {searchOpen && <div className=\"shrink-0 px-3 py-2\">\n <InputGroup>\n <InputGroupAddon><SearchIcon aria-hidden=\"true\" /></InputGroupAddon>\n <InputGroupInput ref={search} type=\"search\" value={query} onChange={event => setQuery(event.currentTarget.value)} placeholder={labels.searchConversations} aria-label={labels.searchConversations} data-testid=\"conversation-search\" />\n {conversations.archive && serverSearch && <InputGroupAddon align=\"inline-end\"><Toggle pressed={archived} aria-label={labels.archived} title={labels.archived} data-testid=\"conversations-archived\"\n onPressedChange={setArchived}><ArchiveIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.archived}</Toggle></InputGroupAddon>}\n </InputGroup>\n </div>}\n {navigation}\n {failure && <p role=\"alert\" className=\"m-0 shrink-0 px-3 pt-2 text-xs text-destructive\">{failure}</p>}\n {/* A sheet has only a max-height, so the viewport's stock height of 100% resolves to its content: a flex column makes it shrink to the room left, and fill it in a parent with a set height. */}\n <ScrollArea className=\"flex min-h-0 flex-1 flex-col [&>[data-slot=scroll-area-viewport]]:h-auto [&>[data-slot=scroll-area-viewport]]:grow [&>[data-slot=scroll-area-viewport]]:overscroll-contain\" aria-busy={loading ? 'true' : undefined}>\n {found?.failed && <p role=\"alert\" data-testid=\"conversations-search-failed\" className=\"m-0 px-3 py-8 text-center text-sm text-destructive\">{labels.searchFailed}</p>}\n {total === 0 && !found?.failed && <p role=\"status\" data-testid=\"conversations-empty\" className=\"m-0 px-3 py-8 text-center text-sm text-muted-foreground\">\n {loading ? labels.loadingConversations : query.trim() ? labels.noMatch(query.trim()) : archived ? labels.noArchived : labels.noConversations}</p>}\n {shown.map(group => <SidebarGroup key={group.key} aria-label={labels[group.key]} role=\"group\">\n <SidebarGroupLabel render={<h4 />}>{labels[group.key]}</SidebarGroupLabel>\n <SidebarMenu>\n {group.items.map(item => {\n const active = item.id === conversations.activeId;\n const title = (item.title?.startsWith('<target>') ? lastSaid(item.title) : item.title)?.trim() || labels.untitled;\n const extra = conversations.rowActions?.(item);\n // A row has room for icons only: a header action without an icon goes in the row's \"…\" menu.\n const extraButtons = headerActions(extra).filter(entry => entry.icon), extraMenu = [...headerActions(extra).filter(entry => !entry.icon), ...menuActions(extra)];\n if (renaming === item.id && conversations.rename) {\n const rename = conversations.rename;\n return <SidebarMenuItem key={item.id}>\n <form className=\"flex items-center gap-2\" onSubmit={event => {\n event.preventDefault();\n const value = new FormData(event.currentTarget).get('title')?.toString().trim();\n setRenaming(null);\n if (value && value !== item.title) run(() => rename(item.id, value));\n }}>\n <PencilIcon className=\"size-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <Input name=\"title\" autoFocus defaultValue={title} maxLength={200} aria-label={labels.conversationName} data-testid=\"conversation-rename-input\"\n onKeyDown={event => { if (event.key === 'Escape') { event.stopPropagation(); setRenaming(null); } }}\n className=\"min-w-0 flex-1\" />\n <Button type=\"submit\" size=\"sm\" variant=\"outline\" data-testid=\"conversation-rename-save\">{labels.save}</Button>\n </form>\n </SidebarMenuItem>;\n }\n return <SidebarMenuItem key={item.id} className=\"flex items-center gap-0.5\">\n <Button type=\"button\" variant={active ? 'secondary' : 'ghost'} data-testid=\"conversation-row\" data-conversation-id={item.id} data-active={active ? 'true' : undefined} aria-current={active ? 'true' : undefined}\n title={lastSaid(item.lastMessage)}\n onClick={() => { if (!active) conversations.onSelect(item.id); onPicked?.(); }}\n className=\"h-auto min-h-10 min-w-0 flex-1 justify-start max-md:min-h-12\">\n <MessageSquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n <span className=\"min-w-0 flex-1 truncate text-left\">{title}</span>\n {active ? <span className=\"inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground\"><CheckIcon className=\"size-3.5\" aria-hidden=\"true\" /><span className=\"sr-only\">{labels.current}</span></span>\n : <span className=\"shrink-0 text-xs text-muted-foreground tabular-nums\">{relativeTime(item.updatedAt, now, labels)}</span>}\n </Button>\n {(conversations.rename || conversations.archive || conversations.remove || extraButtons.length > 0 || extraMenu.length > 0) && <div data-testid=\"conversation-actions\"\n className={cn('flex shrink-0 items-center gap-0.5', confirming === item.id ? '' : 'opacity-0 group-hover/menu-item:opacity-100 group-focus-within/menu-item:opacity-100 pointer-coarse:opacity-100')}>\n {confirming === item.id && conversations.remove ? <>\n <Button size=\"sm\" variant=\"destructive\" data-testid=\"conversation-delete-confirm\" onClick={() => { const remove = conversations.remove!; setConfirming(null); run(() => remove(item.id)); }}>{labels.delete}</Button>\n <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.keep} title={labels.keep} onClick={() => setConfirming(null)}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </> : rowActionLayout === 'menu' ? <RowActions label={labels.moreActions} items={rowActionItems(item, extraButtons, extraMenu)} /> : <>\n {conversations.rename && <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.renameItem(title)} title={labels.rename} data-testid=\"conversation-rename\" onClick={() => setRenaming(item.id)}><PencilIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {conversations.archive && <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.archiveItem(title, item.archived === true)} title={item.archived ? labels.restore : labels.archive} data-testid=\"conversation-archive\"\n onClick={() => { const archive = conversations.archive!; run(() => archive(item.id, !item.archived)); }}>{item.archived ? <ArchiveRestoreIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <ArchiveIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}</Button>}\n {conversations.remove && <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.deleteItem(title)} title={labels.delete} data-testid=\"conversation-delete\" onClick={() => setConfirming(item.id)}><Trash2Icon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {extraButtons.map(entry => <ActionButton key={entry.id} action={entry} testId=\"conversation-action\" />)}\n {extraMenu.length > 0 && <RowActions label={labels.moreActions} items={extraMenu.map(entry => actionMenuItem(entry, 'conversation-action'))} />}\n </>}\n </div>}\n </SidebarMenuItem>;\n })}\n </SidebarMenu>\n </SidebarGroup>)}\n </ScrollArea>\n </>;\n}\n\n/**\n * The list of past conversations in a dialog: a header with New, then `ConversationList`.\n * An anchored card on a wide screen and a bottom drawer on a phone. Escape or a click outside closes it.\n */\nexport function ConversationHistory({ conversations, onClose, placement = 'below' }: {\n readonly conversations: ConversationsConfig;\n readonly onClose: () => void;\n /** `above` opens the card upwards from a bar docked at the bottom of the screen; the backdrop then covers the whole page. */\n readonly placement?: 'below' | 'above';\n}) {\n const { labels, icons } = useChatText();\n const phone = usePhone();\n useEffect(() => {\n const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); };\n document.addEventListener('keydown', escape);\n return () => document.removeEventListener('keydown', escape);\n }, [onClose]);\n const content = <>\n <header className=\"flex shrink-0 items-center gap-2 px-3 py-2.5 max-md:pt-[max(0.625rem,env(safe-area-inset-top))]\">\n <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.closeHistory} title={labels.close} data-testid=\"conversations-close\" onClick={onClose} className=\"md:hidden\"><ArrowLeftIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n <h3 className=\"m-0 min-w-0 flex-1 text-sm font-semibold\">{labels.history}</h3>\n {conversations.onNew && <Button size=\"sm\" variant=\"outline\" data-testid=\"conversation-new\" onClick={() => { conversations.onNew!(); onClose(); }}><icons.newChat data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.newConversation}</Button>}\n <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.closeHistory} title={labels.close} onClick={onClose} className=\"max-md:hidden\"><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </header>\n <Separator />\n <ConversationList conversations={conversations} onPicked={onClose} autoFocus />\n </>;\n return phone ? <Drawer open showSwipeHandle onOpenChange={next => { if (!next) onClose(); }}><DrawerContent data-testid=\"conversations\" aria-label={labels.historyDialog} className=\"pi-chat min-h-[80dvh]\"><DrawerTitle className=\"sr-only\">{labels.historyDialog}</DrawerTitle><div className=\"flex min-h-0 flex-1 flex-col pb-[env(safe-area-inset-bottom)]\">{content}</div></DrawerContent></Drawer>\n : <Popover open onOpenChange={next => { if (!next) onClose(); }} modal={false}>\n <PopoverTrigger nativeButton={false} render={<span aria-hidden=\"true\" className={cn('pointer-events-none absolute', placement === 'above' ? 'bottom-0 left-0' : 'top-12 right-3')} />} />\n <PopoverContent role=\"dialog\" aria-label={labels.historyDialog} data-testid=\"conversations\" side={placement === 'above' ? 'top' : 'bottom'} align={placement === 'above' ? 'start' : 'end'} className=\"pi-chat w-[23rem] max-w-[calc(100vw-1rem)] max-h-[min(34rem,70dvh)]\">{content}</PopoverContent>\n </Popover>;\n}\n\n/** The only history chrome inside the normal transcript: a sentinel, spinner and accessible fallback. */\nexport function TranscriptHistory({ history, connected, onLoad, sentinelRef, hidden, loaded }: {\n readonly history: ChatHistoryState;\n readonly connected: boolean;\n readonly onLoad: () => void;\n readonly sentinelRef: (element: HTMLDivElement | null) => void;\n readonly hidden: number;\n readonly loaded: number;\n}) {\n const { labels } = useChatText();\n if (history.kind === 'disabled' && hidden === 0) return null;\n const exhausted = hidden === 0 && history.kind === 'ready' && !history.hasMore;\n return <div ref={sentinelRef} data-testid=\"transcript-history\" className=\"flex min-h-8 shrink-0 items-center justify-center text-xs text-muted-foreground\">\n {!exhausted && <Button variant=\"ghost\" data-testid=\"transcript-history-load\" className={history.kind === 'error' ? undefined : 'sr-only focus:not-sr-only'} disabled={!connected || history.kind === 'loading'} onClick={onLoad}>{labels.loadEarlierMessages}</Button>}\n {history.kind === 'loading' && <Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-label={labels.loadingShort} />}\n {history.kind === 'error' && <span role=\"alert\">{labels.historyFailed}</span>}\n {exhausted && <span>{labels.beginning}</span>}\n <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">{loaded > 0 ? labels.earlierMessagesLoaded(loaded) : ''}</span>\n </div>;\n}\n",
|
|
64
64
|
"type": "registry:component",
|
|
65
65
|
"target": "components/pi-chat/history.tsx"
|
|
66
66
|
},
|
|
67
67
|
{
|
|
68
68
|
"path": "registry/pi-chat/image-viewer.tsx",
|
|
69
|
-
"content": "'use client';\n\nimport { useRef, useState } from 'react';\nimport type { PointerEvent } from 'react';\nimport type { ImageContent } from '@earendil-works/pi-ai';\nimport { ChevronLeftIcon, ChevronRightIcon, XIcon } from '
|
|
69
|
+
"content": "'use client';\n\nimport { useRef, useState } from 'react';\nimport type { PointerEvent } from 'react';\nimport type { ImageContent } from '@earendil-works/pi-ai';\nimport { ChevronLeftIcon, ChevronRightIcon, XIcon } from '../_shared/icons';\nimport { Button } from '@/components/ui/button';\nimport { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';\nimport { imageSource } from './composer';\nimport { useChatText } from './labels';\nimport type { ChatLabels } from './labels';\n\ntype Point = { readonly x: number; readonly y: number };\ntype View = { readonly scale: number; readonly x: number; readonly y: number };\ntype Gesture = {\n readonly pointers: Map<number, Point>;\n basis: { readonly mode: 'pan' | 'pinch' | 'pinch-pan' | 'swipe'; readonly point: Point; readonly distance: number; readonly view: View } | undefined;\n tap: { readonly at: number; readonly point: Point } | undefined;\n view: View;\n pointerType: string;\n};\nconst FIT: View = { scale: 1, x: 0, y: 0 };\nconst distance = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y);\nconst midpoint = (a: Point, b: Point): Point => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });\n\n/** Presentation only: local raster bytes, one message's gallery, and the host's native Dialog lifecycle. */\nexport function ImageViewer({ images, index, onIndexChange, onClose, labels: supplied, testId = 'image-viewer' }: {\n readonly images: readonly ImageContent[]; readonly index: number; readonly onIndexChange: (index: number) => void; readonly onClose: () => void;\n readonly labels?: ChatLabels | undefined; readonly testId?: string | undefined;\n}) {\n const { labels: defaults } = useChatText();\n const labels = supplied ?? defaults;\n const src = images[index] && imageSource(images[index]!);\n const move = (offset: number) => onIndexChange((index + offset + images.length) % images.length);\n if (!src) return null;\n return <Dialog open onOpenChange={open => { if (!open) onClose(); }}><DialogContent showCloseButton={false} aria-describedby={undefined} data-testid=\"image-viewer\"\n className=\"pi-chat flex min-h-0 flex-col overflow-hidden max-md:inset-0 max-md:max-h-dvh max-md:min-h-dvh max-md:max-w-none max-md:translate-x-0 max-md:translate-y-0 md:max-h-[90dvh] md:min-h-[80dvh] md:max-w-5xl\"\n onKeyDown={event => {\n if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); event.stopPropagation(); move(event.key === 'ArrowLeft' ? -1 : 1); }\n }}>\n <DialogTitle className=\"sr-only\">{labels.imageAttachment}</DialogTitle>\n <div className=\"flex shrink-0 items-center justify-between gap-2 pt-[env(safe-area-inset-top)]\">\n <span aria-live=\"polite\" data-testid=\"image-viewer-count\">{labels.imagePosition(index + 1, images.length)}</span>\n <div className=\"flex items-center gap-2\">\n {images.length > 1 && <>\n <Button variant=\"ghost\" size=\"icon\" className=\"min-h-12! min-w-12!\" aria-label={labels.previousImage} onClick={() => move(-1)}><ChevronLeftIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n <Button variant=\"ghost\" size=\"icon\" className=\"min-h-12! min-w-12!\" aria-label={labels.nextImage} onClick={() => move(1)}><ChevronRightIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </>}\n <Button variant=\"ghost\" size=\"icon\" className=\"min-h-12! min-w-12!\" data-testid={`${testId}-close`} aria-label={labels.close} onClick={onClose}\n onTouchEnd={event => {\n // Pinch can suppress clicks; cancel the compatibility click before exposing the page beneath the close target.\n const point = event.changedTouches[0], box = event.currentTarget.getBoundingClientRect();\n if (point && point.clientX >= box.left && point.clientX <= box.right && point.clientY >= box.top && point.clientY <= box.bottom) { event.preventDefault(); onClose(); }\n }}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </div>\n </div>\n <ImageSurface key={`${index}:${src}`} src={src} alt={labels.imagePosition(index + 1, images.length)} onClose={onClose} testId={testId} />\n </DialogContent></Dialog>;\n}\n\nfunction ImageSurface({ src, alt, onClose, testId }: { readonly src: string; readonly alt: string; readonly onClose: () => void; readonly testId: string }) {\n const [view, setView] = useState(FIT);\n const image = useRef<HTMLImageElement>(null);\n const gesture = useRef<Gesture>({ pointers: new Map(), basis: undefined, tap: undefined, view: FIT, pointerType: '' });\n const update = (next: View) => { gesture.current.view = next; setView(next); };\n const bounded = (scale: number, x: number, y: number, element: HTMLElement): View => {\n const box = element.getBoundingClientRect();\n const maxX = Math.max(0, ((image.current?.clientWidth || box.width) * scale - box.width) / 2), maxY = Math.max(0, ((image.current?.clientHeight || box.height) * scale - box.height) / 2);\n return { scale, x: Math.max(-maxX, Math.min(maxX, x)), y: Math.max(-maxY, Math.min(maxY, y)) };\n };\n const zoom = (point: Point, element: HTMLElement) => {\n const state = gesture.current;\n state.tap = undefined;\n if (state.view.scale > 1) { update(FIT); return; }\n const box = element.getBoundingClientRect();\n update(bounded(2, box.left + box.width / 2 - point.x, box.top + box.height / 2 - point.y, element));\n };\n const rebase = (mode?: 'pan' | 'pinch-pan') => {\n const state = gesture.current, points = [...state.pointers.values()], first = points[0];\n state.basis = first ? { mode: points.length > 1 ? 'pinch' : mode ?? (state.view.scale > 1 ? 'pan' : 'swipe'), point: points[1] ? midpoint(first, points[1]) : first,\n distance: points[1] ? Math.max(1, distance(first, points[1])) : 0, view: state.view } : undefined;\n };\n const pointer = (event: PointerEvent<HTMLDivElement>) => ({ x: event.clientX, y: event.clientY });\n const end = (event: PointerEvent<HTMLDivElement>, cancelled: boolean) => {\n const state = gesture.current, basis = state.basis;\n if (!state.pointers.has(event.pointerId) || !basis) return;\n const point = pointer(event), dx = point.x - basis.point.x, dy = point.y - basis.point.y;\n state.pointers.delete(event.pointerId);\n if (!cancelled && basis.mode === 'swipe' && state.view.scale === 1 && dy > 96 && dy > Math.abs(dx) * 1.25) { onClose(); return; }\n if (!cancelled && event.pointerType === 'touch' && basis.mode !== 'pinch' && basis.mode !== 'pinch-pan' && Math.hypot(dx, dy) < 12) {\n if (state.tap && event.timeStamp - state.tap.at < 300 && distance(point, state.tap.point) < 24) zoom(point, event.currentTarget);\n else state.tap = { at: event.timeStamp, point };\n } else state.tap = undefined;\n rebase(basis.mode === 'pinch' || basis.mode === 'pinch-pan' ? 'pinch-pan' : 'pan');\n };\n return <div data-testid=\"image-viewer-surface\" data-scale={view.scale} className=\"relative min-h-0 flex-1 touch-none overflow-hidden max-md:-mx-4 max-md:-mb-4\"\n onDoubleClick={event => { if (gesture.current.pointerType === 'touch') return; event.preventDefault(); zoom({ x: event.clientX, y: event.clientY }, event.currentTarget); }}\n onPointerDown={event => {\n if (event.pointerType === 'mouse' && event.button !== 0) return;\n event.preventDefault(); event.currentTarget.setPointerCapture?.(event.pointerId);\n gesture.current.pointerType = event.pointerType; gesture.current.pointers.set(event.pointerId, pointer(event)); rebase();\n }}\n onPointerMove={event => {\n const state = gesture.current;\n if (!state.pointers.has(event.pointerId) || !state.basis) return;\n event.preventDefault(); state.pointers.set(event.pointerId, pointer(event));\n const points = [...state.pointers.values()], basis = state.basis, first = points[0]!;\n if (points[1]) {\n const centre = midpoint(first, points[1]), box = event.currentTarget.getBoundingClientRect();\n const scale = Math.max(1, Math.min(5, basis.view.scale * distance(first, points[1]) / basis.distance)), ratio = scale / basis.view.scale;\n update(bounded(scale, centre.x - box.left - box.width / 2 + (basis.view.x - basis.point.x + box.left + box.width / 2) * ratio,\n centre.y - box.top - box.height / 2 + (basis.view.y - basis.point.y + box.top + box.height / 2) * ratio, event.currentTarget));\n } else if (basis.mode === 'pan' || basis.mode === 'pinch-pan') update(bounded(basis.view.scale, basis.view.x + first.x - basis.point.x, basis.view.y + first.y - basis.point.y, event.currentTarget));\n }}\n onPointerUp={event => end(event, false)} onPointerCancel={event => end(event, true)} onLostPointerCapture={event => end(event, true)}>\n <div className=\"absolute inset-0 flex items-center justify-center\">\n <img ref={image} data-testid={`${testId}-image`} src={src} alt={alt} draggable={false} className=\"max-h-full max-w-full select-none object-contain\" style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})` }} />\n </div>\n </div>;\n}\n",
|
|
70
70
|
"type": "registry:component",
|
|
71
71
|
"target": "components/pi-chat/image-viewer.tsx"
|
|
72
72
|
},
|
|
73
73
|
{
|
|
74
74
|
"path": "registry/pi-chat/message.tsx",
|
|
75
|
-
"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 type { JsonObject } from '@earendil-works/pi-durable';\nimport { AlertCircleIcon, CircleSlashIcon, ClockIcon, FileAudioIcon, FileIcon, FileSpreadsheetIcon, FileTextIcon, ImageIcon, ImageOffIcon, PauseIcon, PlayIcon, PresentationIcon } from 'lucide-react';\nimport { CopyButton } from './code-block';\nimport type { AnswerOutcome, ChatArtifactsConfig, ChatCards, CommandMentions } from './config';\nimport { ActivityBlock } from './activity';\nimport type { ActivityState } from './activity';\nimport { Shimmer } from './shimmer';\nimport { ToolCard } from './tool';\nimport { imageSource, thumbnail } from './composer';\nimport { ImageViewer } from './image-viewer';\nimport { displayName, fileKind, isUploadPath, isVoicePath, mentionKind, pieces, visibleParts } from '@hachej/boring-ui-kit/chat-text';\nimport type { FileKind, Piece } from '@hachej/boring-ui-kit/chat-text';\nimport type { AudioUrlRead, MentionsConfig, PreviewRead, SentContent } from './config';\nimport { voiceClock } from './config';\nimport { Spinner } from '@/components/ui/spinner';\nimport { isFileBlock, segments, targetContent, waits } from './rows';\nimport type { DeriveOptions, Row, Segment, Step } 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 { AnsweredBy, 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 kinds?: MentionsConfig['kinds']; 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 voiceReplay?: boolean | undefined;\n /** A host's own card for a sent text (`PiChat sentContent`). */\n readonly sentContent?: SentContent | undefined;\n /** The host's words for a sent target chip (`PiChat targetLabel`). */\n readonly targetLabel?: ((target: JsonObject) => string | 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 /** Developer mode: the host's per-turn `answeredBy` for a reply; absent, the line under the reply names the message's own provider/model. */\n readonly answeredBy?: ((reply: ReplyRef) => AnsweredBy | undefined) | 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\nfunction AnsweredByLine({ answered }: { readonly answered: AnsweredBy }) {\n const { labels } = useChatText();\n return <p data-testid=\"answered-by\" className=\"m-0 text-xs text-muted-foreground\">\n {labels.answeredBy(`${answered.provider}/${answered.model}`)}\n {answered.fallback && <span data-testid=\"answered-fallback\"> · {labels.answeredFallback(answered.fallback)}</span>}\n </p>;\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 <MessageImage key={imageSource(image) ?? ''} image={image} alt={labels.imageAttachment} />\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/** One photo of a message; a linked photo the host no longer serves (a 404) shows a placeholder in its place. */\nfunction MessageImage({ image, alt }: { readonly image: ImageContent; readonly alt: string }) {\n // Keyed by its source by the caller: a new source starts again, a failed one is not requested again on every render.\n const src = imageSource(image);\n const [failed, setFailed] = useState(false);\n if (!src || failed) return <div data-testid=\"message-image-missing\" role=\"img\" aria-label={alt}\n className=\"flex aspect-square w-full items-center justify-center rounded-(--radius-xl) bg-muted text-muted-foreground\"><ImageOffIcon aria-hidden=\"true\" /></div>;\n return <img src={src} alt={alt} onError={() => setFailed(true)} className=\"block h-auto w-full rounded-(--radius-xl)\" />;\n}\n\n/** Inline sent mention; workspace openers and host renderers keep their existing authority. */\nexport function UserMention({ path, text, open, render, kinds }: { readonly path: string; readonly text: string; readonly open: ((path: string) => void) | undefined; readonly render: ((path: string) => ReactNode | undefined) | undefined; readonly kinds?: MentionsConfig['kinds'] }) {\n const registered = mentionKind(path, { kinds });\n const custom = registered ? registered.kind.renderSent?.(registered.path) : render?.(path);\n if (custom !== undefined) return <>{custom}</>;\n const label = registered ? registered.kind.label(registered.path) : text;\n const activate = registered ? registered.kind.open ? () => registered.kind.open!(registered.path) : undefined : open ? () => open(path) : undefined;\n const content = <>{registered?.kind.icon}{label}</>;\n const className = 'inline-flex items-center gap-1 align-middle font-medium text-primary';\n return activate\n ? <span role=\"link\" tabIndex={0} className={className} data-testid=\"message-mention\" data-path={path} aria-label={`Open ${label}`} onClick={activate} onKeyDown={event => { if (event.key === 'Enter') { event.preventDefault(); activate(); } }}>{content}</span>\n : <span className={className} data-testid=\"message-mention\" data-path={path}>{content}</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 [thumb, setThumb] = useState<{ readonly path: string; readonly preview: PreviewRead; readonly src: 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 const src = kind === 'image' && thumb?.path === path && thumb.preview === preview ? thumb.src : undefined;\n useEffect(() => {\n if (kind !== 'image' || !preview) return;\n const abort = new AbortController();\n preview(path, abort.signal).then(\n read => { if (!abort.signal.aborted) setThumb({ path, preview, src: read ? thumbnail(read.mimeType, read.data) : undefined }); },\n () => { if (!abort.signal.aborted) setThumb({ path, preview, src: 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\ninterface VoiceBubbleProps {\n readonly path: string; readonly transcript: ReactNode; readonly preview: PreviewRead | undefined; readonly audioUrl: AudioUrlRead | undefined; readonly align?: 'start' | 'end'; readonly replay?: boolean | undefined;\n}\n/** Voice messages show their transcript by default; the retained audio player is an explicit presentation choice. */\nexport function VoiceBubble({ replay = false, ...props }: VoiceBubbleProps) {\n const { labels } = useChatText();\n return replay ? <VoiceReplayBubble {...props} /> : <Bubble variant=\"muted\" align={props.align ?? 'end'} data-testid=\"voice-bubble\"><BubbleContent data-testid=\"user-text\" className=\"whitespace-pre-wrap\">{props.transcript ?? <span data-testid=\"voice-transcript-unavailable\">{labels.voiceTranscriptUnavailable}</span>}</BubbleContent></Bubble>;\n}\n\n/** Retained audio is read only on Play, through the same authorized workspace preview as file cards. */\nfunction VoiceReplayBubble({ path, transcript, preview, audioUrl, align = 'end' }: VoiceBubbleProps) {\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 // eslint-disable-next-line react-hooks/set-state-in-effect -- another recording or provider resets the player this effect owns, once per source\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, 'sentContent' | 'pieces' | 'openFile' | 'voiceReplay' | 'targetLabel'>; 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\" className=\"max-w-full truncate\">{context.targetLabel?.(target.target) ?? target.label}</Badge>\n <MessageText text={target.speech} context={context} align={align} variant={variant} decoration={decoration} header={header} footer={footer} />\n </>;\n const hosted = context.sentContent?.(text, align);\n if (hosted) return <>\n {hosted.before && <MessageText text={hosted.before} context={context} align={align} variant={variant} />}\n {hosted.node}\n {hosted.after && <MessageText text={hosted.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} kinds={context.pieces?.kinds} />\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} replay={context.voiceReplay} />\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\n// (A message just sent reads no file previews: the server's own row will, and the same file is not read twice.)\nfunction NotSent() {\n const { labels } = useChatText();\n return <p role=\"status\" data-testid=\"message-not-sent\" className=\"m-0 text-end text-xs text-destructive\">{labels.notSent}</p>;\n}\n\nfunction UserBubble({ message, images, context, align = 'end', lead = false, local }: { readonly message: UserMessage; readonly images: readonly ImageContent[]; readonly context: RowContext; readonly align?: 'start' | 'end'; readonly lead?: boolean; readonly local?: 'sending' | 'failed' | undefined }) {\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\" pending={local === 'sending'} /></> : 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' && imageSource(part) ? [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 => imageSource(image)) ?? 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\nconst failedStep = (step: Step) => step.kind === 'tool' && step.entry.status === 'failed';\nconst finishedStep = (step: Step) => step.kind === 'tool' && step.entry.status === 'completed';\n/** After this block's last failed step a later step finished or something else came, and the reply ended on a round that saw every result (`stop`, not `toolUse`): nothing is left for a person to act on. */\nfunction gotPast(row: Extract<Row, { type: 'assistant' }>, steps: readonly Step[], later: readonly Segment[]): boolean {\n return row.stopReason === 'stop' && (later.length > 0 || steps.slice(steps.map(failedStep).lastIndexOf(true) + 1).some(finishedStep));\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 && imageSource(image) === (images[at] && imageSource(images[at]))) ? selection : undefined;\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a stale selection (other row, opener or images) is dropped once, after commit\n useEffect(() => { if (selection && !current) setSelection(undefined); }, [selection, current]);\n const openImage: NonNullable<RowContext['onOpenImage']> = image => {\n if (!imageSource(image)) 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} kinds={context.pieces?.kinds} /> : undefined, [context.pieces]);\n switch (row.type) {\n case 'user': return <article data-row-id={row.key} data-role=\"user\" data-send-state={row.local} aria-live={row.historical ? 'off' : undefined} className={row.local === 'sending' ? 'opacity-70' : undefined}><UserBubble message={row.message} images={images} context={row.local && context.pieces?.preview ? { ...context, pieces: { ...context.pieces, preview: undefined } } : context} lead={row.lead === true} local={row.local} />{row.local === 'failed' && <NotSent />}</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 const answered = context.developer ? context.answeredBy?.(replyRef(row)) ?? (row.model ? { provider: row.model.provider, model: row.model.modelId } : undefined) : undefined;\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(failedStep) && (context.developer || (!active && !gotPast(row, chunk.steps, chunks.slice(at + 1))));\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-md: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 {answered && <AnsweredByLine answered={answered} />}\n {footer != null && footer !== false && <MessageFooter>{footer}</MessageFooter>}\n </MessageContent></Message>\n </article>;\n }\n }\n}\n",
|
|
75
|
+
"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 type { JsonObject } from '@earendil-works/pi-durable';\nimport { AlertCircleIcon, CircleSlashIcon, ClockIcon, FileAudioIcon, FileIcon, FileSpreadsheetIcon, FileTextIcon, ImageIcon, ImageOffIcon, PauseIcon, PlayIcon, PresentationIcon } from '../_shared/icons';\nimport { CopyButton } from './code-block';\nimport type { AnswerOutcome, ChatArtifactsConfig, ChatCards, CommandMentions } from './config';\nimport { ActivityBlock } from './activity';\nimport type { ActivityState } from './activity';\nimport { Shimmer } from './shimmer';\nimport { ToolCard } from './tool';\nimport { imageSource, thumbnail } from './composer';\nimport { ImageViewer } from './image-viewer';\nimport { displayName, fileKind, isUploadPath, isVoicePath, mentionKind, pieces, visibleParts } from '@hachej/boring-ui-kit/chat-text';\nimport type { FileKind, Piece } from '@hachej/boring-ui-kit/chat-text';\nimport type { AudioUrlRead, MentionsConfig, PreviewRead, SentContent } from './config';\nimport { voiceClock } from './config';\nimport { Spinner } from '@/components/ui/spinner';\nimport { isFileBlock, segments, targetContent, waits } from './rows';\nimport type { DeriveOptions, Row, Segment, Step } 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 { AnsweredBy, 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 kinds?: MentionsConfig['kinds']; 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 voiceReplay?: boolean | undefined;\n /** A host's own card for a sent text (`PiChat sentContent`). */\n readonly sentContent?: SentContent | undefined;\n /** The host's words for a sent target chip (`PiChat targetLabel`). */\n readonly targetLabel?: ((target: JsonObject) => string | 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 /** Developer mode: the host's per-turn `answeredBy` for a reply; absent, the line under the reply names the message's own provider/model. */\n readonly answeredBy?: ((reply: ReplyRef) => AnsweredBy | undefined) | 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\nfunction AnsweredByLine({ answered }: { readonly answered: AnsweredBy }) {\n const { labels } = useChatText();\n return <p data-testid=\"answered-by\" className=\"m-0 text-xs text-muted-foreground\">\n {labels.answeredBy(`${answered.provider}/${answered.model}`)}\n {answered.fallback && <span data-testid=\"answered-fallback\"> · {labels.answeredFallback(answered.fallback)}</span>}\n </p>;\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 <MessageImage key={imageSource(image) ?? ''} image={image} alt={labels.imageAttachment} />\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/** One photo of a message; a linked photo the host no longer serves (a 404) shows a placeholder in its place. */\nfunction MessageImage({ image, alt }: { readonly image: ImageContent; readonly alt: string }) {\n // Keyed by its source by the caller: a new source starts again, a failed one is not requested again on every render.\n const src = imageSource(image);\n const [failed, setFailed] = useState(false);\n if (!src || failed) return <div data-testid=\"message-image-missing\" role=\"img\" aria-label={alt}\n className=\"flex aspect-square w-full items-center justify-center rounded-(--radius-xl) bg-muted text-muted-foreground\"><ImageOffIcon aria-hidden=\"true\" /></div>;\n return <img src={src} alt={alt} onError={() => setFailed(true)} className=\"block h-auto w-full rounded-(--radius-xl)\" />;\n}\n\n/** Inline sent mention; workspace openers and host renderers keep their existing authority. */\nexport function UserMention({ path, text, open, render, kinds }: { readonly path: string; readonly text: string; readonly open: ((path: string) => void) | undefined; readonly render: ((path: string) => ReactNode | undefined) | undefined; readonly kinds?: MentionsConfig['kinds'] }) {\n const registered = mentionKind(path, { kinds });\n const custom = registered ? registered.kind.renderSent?.(registered.path) : render?.(path);\n if (custom !== undefined) return <>{custom}</>;\n const label = registered ? registered.kind.label(registered.path) : text;\n const activate = registered ? registered.kind.open ? () => registered.kind.open!(registered.path) : undefined : open ? () => open(path) : undefined;\n const content = <>{registered?.kind.icon}{label}</>;\n const className = 'inline-flex items-center gap-1 align-middle font-medium text-primary';\n return activate\n ? <span role=\"link\" tabIndex={0} className={className} data-testid=\"message-mention\" data-path={path} aria-label={`Open ${label}`} onClick={activate} onKeyDown={event => { if (event.key === 'Enter') { event.preventDefault(); activate(); } }}>{content}</span>\n : <span className={className} data-testid=\"message-mention\" data-path={path}>{content}</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 [thumb, setThumb] = useState<{ readonly path: string; readonly preview: PreviewRead; readonly src: 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 const src = kind === 'image' && thumb?.path === path && thumb.preview === preview ? thumb.src : undefined;\n useEffect(() => {\n if (kind !== 'image' || !preview) return;\n const abort = new AbortController();\n preview(path, abort.signal).then(\n read => { if (!abort.signal.aborted) setThumb({ path, preview, src: read ? thumbnail(read.mimeType, read.data) : undefined }); },\n () => { if (!abort.signal.aborted) setThumb({ path, preview, src: 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\ninterface VoiceBubbleProps {\n readonly path: string; readonly transcript: ReactNode; readonly preview: PreviewRead | undefined; readonly audioUrl: AudioUrlRead | undefined; readonly align?: 'start' | 'end'; readonly replay?: boolean | undefined;\n}\n/** Voice messages show their transcript by default; the retained audio player is an explicit presentation choice. */\nexport function VoiceBubble({ replay = false, ...props }: VoiceBubbleProps) {\n const { labels } = useChatText();\n return replay ? <VoiceReplayBubble {...props} /> : <Bubble variant=\"muted\" align={props.align ?? 'end'} data-testid=\"voice-bubble\"><BubbleContent data-testid=\"user-text\" className=\"whitespace-pre-wrap\">{props.transcript ?? <span data-testid=\"voice-transcript-unavailable\">{labels.voiceTranscriptUnavailable}</span>}</BubbleContent></Bubble>;\n}\n\n/** Retained audio is read only on Play, through the same authorized workspace preview as file cards. */\nfunction VoiceReplayBubble({ path, transcript, preview, audioUrl, align = 'end' }: VoiceBubbleProps) {\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 // eslint-disable-next-line react-hooks/set-state-in-effect -- another recording or provider resets the player this effect owns, once per source\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, 'sentContent' | 'pieces' | 'openFile' | 'voiceReplay' | 'targetLabel'>; 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\" className=\"max-w-full truncate\">{context.targetLabel?.(target.target) ?? target.label}</Badge>\n <MessageText text={target.speech} context={context} align={align} variant={variant} decoration={decoration} header={header} footer={footer} />\n </>;\n const hosted = context.sentContent?.(text, align);\n if (hosted) return <>\n {hosted.before && <MessageText text={hosted.before} context={context} align={align} variant={variant} />}\n {hosted.node}\n {hosted.after && <MessageText text={hosted.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} kinds={context.pieces?.kinds} />\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} replay={context.voiceReplay} />\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\n// (A message just sent reads no file previews: the server's own row will, and the same file is not read twice.)\nfunction NotSent() {\n const { labels } = useChatText();\n return <p role=\"status\" data-testid=\"message-not-sent\" className=\"m-0 text-end text-xs text-destructive\">{labels.notSent}</p>;\n}\n\nfunction UserBubble({ message, images, context, align = 'end', lead = false, local }: { readonly message: UserMessage; readonly images: readonly ImageContent[]; readonly context: RowContext; readonly align?: 'start' | 'end'; readonly lead?: boolean; readonly local?: 'sending' | 'failed' | undefined }) {\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\" pending={local === 'sending'} /></> : 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' && imageSource(part) ? [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 => imageSource(image)) ?? 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\nconst failedStep = (step: Step) => step.kind === 'tool' && step.entry.status === 'failed';\nconst finishedStep = (step: Step) => step.kind === 'tool' && step.entry.status === 'completed';\n/** After this block's last failed step a later step finished or something else came, and the reply ended on a round that saw every result (`stop`, not `toolUse`): nothing is left for a person to act on. */\nfunction gotPast(row: Extract<Row, { type: 'assistant' }>, steps: readonly Step[], later: readonly Segment[]): boolean {\n return row.stopReason === 'stop' && (later.length > 0 || steps.slice(steps.map(failedStep).lastIndexOf(true) + 1).some(finishedStep));\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 && imageSource(image) === (images[at] && imageSource(images[at]))) ? selection : undefined;\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a stale selection (other row, opener or images) is dropped once, after commit\n useEffect(() => { if (selection && !current) setSelection(undefined); }, [selection, current]);\n const openImage: NonNullable<RowContext['onOpenImage']> = image => {\n if (!imageSource(image)) 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} kinds={context.pieces?.kinds} /> : undefined, [context.pieces]);\n switch (row.type) {\n case 'user': return <article data-row-id={row.key} data-role=\"user\" data-send-state={row.local} aria-live={row.historical ? 'off' : undefined} className={row.local === 'sending' ? 'opacity-70' : undefined}><UserBubble message={row.message} images={images} context={row.local && context.pieces?.preview ? { ...context, pieces: { ...context.pieces, preview: undefined } } : context} lead={row.lead === true} local={row.local} />{row.local === 'failed' && <NotSent />}</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 const answered = context.developer ? context.answeredBy?.(replyRef(row)) ?? (row.model ? { provider: row.model.provider, model: row.model.modelId } : undefined) : undefined;\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(failedStep) && (context.developer || (!active && !gotPast(row, chunk.steps, chunks.slice(at + 1))));\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-md: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 {answered && <AnsweredByLine answered={answered} />}\n {footer != null && footer !== false && <MessageFooter>{footer}</MessageFooter>}\n </MessageContent></Message>\n </article>;\n }\n }\n}\n",
|
|
76
76
|
"type": "registry:component",
|
|
77
77
|
"target": "components/pi-chat/message.tsx"
|
|
78
78
|
},
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
},
|
|
85
85
|
{
|
|
86
86
|
"path": "registry/pi-chat/pi-chat.tsx",
|
|
87
|
-
"content": "'use client';\n\nimport { useKeyboardInset } from '../utils/use-media';\n\nimport { useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { viewHasEarlier } from '@hachej/boring-ui-kit/native-chat';\nimport type { ChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { Loader2Icon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Avatar, AvatarFallback } from '@/components/ui/avatar';\nimport { Badge } from '@/components/ui/badge';\nimport { Input } from '@/components/ui/input';\nimport { Separator } from '@/components/ui/separator';\nimport { MessageScroller, MessageScrollerProvider, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem, MessageScrollerButton } from '@/components/ui/message-scroller';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { Composer } from './composer';\nimport type { ComposerFeedback, ComposerProps } from './composer';\nimport { ConversationFiles } from './conversation-files';\nimport type { AnsweredBy, AttachmentsConfig, ConversationsConfig, PendingVoice, ReplyRef, VoiceMessageConfig, VoiceMessageHandle, VoiceRecorderHook } from './config';\nimport { displayName } from '@hachej/boring-ui-kit/chat-text';\nimport { EmptyState } from './empty-state';\nimport type { Suggestion } from './empty-state';\nimport { ConversationHistory, TranscriptHistory } from './history';\nimport { TranscriptChunk } from './message';\nimport { markLeads } from './rows';\nimport type { Row } from './rows';\nimport { MessageQueue } from './queue';\nimport { ChatNotices, ChatConnectionNotice, ChatStatusLine } from './notice';\nimport { PAGE_FIRST, PAGE_MORE, useChatSession, useCursorInOpened, useRowChunks, useTranscript } from './session';\nimport { useChatDraft } from './use-chat-draft';\nimport { useChatPlacement } from './use-remote-chat';\nimport type { ChatPlacement } from './use-remote-chat';\nimport type { ChatFeatureProps, ChatPaging, FilesHandler } from './session';\nimport { ChatTextProvider, useChatText, useMergedText } from './labels';\nimport { chatTextFor } from './locale';\nimport type { ChatLocale } from './locale';\nimport type { ChatIcons, ChatLabels } from './labels';\nimport { cn } from '../utils/utils';\n\nexport type { ChatPaging, PiChatActions } from './session';\nexport type { ChatCard, Mode } from './rows';\nexport type { AnswerOutcome, ApprovalCardProps, ApprovalDecision, ChatArtifact, ChatArtifactCardProps, ChatArtifactsConfig, ChatCards, QuestionCardProps, SubagentRenderer } from './config';\nexport { ASK_USER_TOOL } from './config';\nexport type { ChatTextPart } from './message';\nexport type { MentionPickerProps, ModelEffortPickerProps, SlashPickerProps } from './config';\nexport type { PendingVoice, PendingVoiceBubbleProps, VoiceMessageButtonProps, VoiceMessageHandle } from './config';\nexport type { CameraSheetProps, VoiceRecorderHook, VoiceRecorderOptions } from './config';\nexport { voiceClock } from './config';\nexport { ChatTextProvider } from './labels';\nexport type { CommandMention, CommandMentions } from './config';\nexport type { Suggestion } from './empty-state';\nexport { ConversationList, relativeTime } from './history';\nexport { defaultChatIcons, defaultChatLabels } from './labels';\nexport type { ChatIcons, ChatLabels } from './labels';\nexport { frenchChatLabels } from './labels-fr';\nexport { chatTextFor } from './locale';\nexport type { ChatLocale } from './locale';\nexport { BlockActions } from '../button/actions';\nexport { useChatDraft } from './use-chat-draft';\nexport { useConversations } from './use-conversations';\nexport type { ChatController, ChatSnapshot } from '@hachej/boring-ui-kit/native-chat';\nexport { MessageText, VoiceBubble } from './message';\nexport { useTranscript } from './session';\nexport type { BlockAction, BlockIcon } from '../button/actions';\nexport type { AnsweredBy, AudioUrlRead, VoiceMessageConfig, AttachmentsConfig, ConversationItem, ConversationsConfig, EffortConfig, FileKind, MentionResult, MentionsConfig, ModelConfig, ModelRef, PasteToFile, PreviewRead, ReplyRef, SlashApi, SlashCommand, SlashConfig, SlashSkill, UploadResult } from './config';\n\nexport interface ChatTimeline {\n readonly count: number;\n readonly render: (context: { readonly focusComposer: () => void; readonly openFile?: ((path: string) => void) | undefined; readonly pendingVoice?: ReactNode; readonly paging?: ChatPaging | undefined; readonly targetLabel?: ChatFeatureProps['targetLabel'] }) => ReactNode;\n readonly afterSend?: () => void;\n}\n\nexport interface PiChatProps extends ChatFeatureProps {\n /** Optional host transcript. Absent: native rows, history and scroll behavior are unchanged. */\n readonly timeline?: ChatTimeline | undefined;\n /** Authorized host errors may provide their own message. */\n readonly renderConnectionError?: ((error: unknown) => ReactNode) | undefined;\n readonly controller: ChatController;\n /** Where this chat is shown, reported with each submit (advisory, never authority). `docked` by default; a host that floats or tucks the same chat in a drawer says `floating`. */\n readonly placement?: ChatPlacement | undefined;\n /** Every word the chat shows (title, placeholder, buttons, notices, empty states): a partial object over `defaultChatLabels`. */\n /** The chat's language: `en` (default) or `fr`. `labels` override any of its words. */\n readonly locale?: ChatLocale | undefined;\n readonly labels?: Partial<ChatLabels> | undefined;\n /**\n * How a message looks. `default` (today): my message is a muted bubble on the right, the reply plain text. `messaging`: a messenger look\n * for a one-to-one conversation: my message a primary bubble on the right, the reply a muted bubble on the left, a tail on the first\n * bubble of each run and the time inside. Tool steps, traces, voice and cards are unchanged. Theme tokens only.\n */\n readonly messageStyle?: 'default' | 'messaging' | undefined;\n /** `mode=\"developer\"`: the host's per-turn `answeredBy` for a reply, shown under it as « answered by provider/model » (and « fallback: reason »). Absent or undefined: the reply's own provider/model. */\n readonly answeredBy?: ((reply: ReplyRef) => AnsweredBy | undefined) | undefined;\n /** The chat's icons (send, stop, history, attach, …): any component taking `className`, over `defaultChatIcons`. */\n readonly icons?: Partial<ChatIcons> | undefined;\n /** The agent's avatar (an image or an icon) before the title in the header, and in the empty state instead of the agent icon. */\n readonly avatar?: ReactNode;\n /** Host actions in the header beside History: `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids `chat-action-<id>`, the menu `chat-action-more`. */\n readonly headerActions?: readonly BlockAction[] | undefined;\n /** `false` lets the host supply one shared header around chat and viewers. Default true. */\n readonly header?: boolean;\n readonly className?: string;\n /** Pad the chat's bottom by how far it reaches below the visual viewport (the phone's soft keyboard). Default true; `false` for a host that already follows the keyboard itself, such as a bottom drawer whose lower part sits off screen by design. */\n readonly keyboardInset?: boolean;\n readonly decisions?: ReactNode;\n readonly controls?: ReactNode;\n /** Host controls at the end of the composer bar (for example a microphone), like AmbientChat's tools slot. */\n readonly tools?: ReactNode;\n /** Host controls at the left edge of the composer bar, before \"+\" (for example an app's own menu). Absent: nothing changes. */\n readonly composerStart?: ReactNode;\n /** With `voiceMessage`: the microphone takes Send's place while there is nothing to send, and Send comes back with the first character or file (WhatsApp's composer). Default false. */\n readonly voiceReplacesSend?: boolean;\n /** Hold-to-send one retained audio upload plus transcript. Omit to keep the existing composer unchanged. */\n readonly voiceMessage?: VoiceMessageConfig | undefined;\n /** Local voice rows owned by the shell's shared microphone; rendered with the native transcript, never the composer. */\n readonly pendingVoice?: readonly PendingVoice[] | undefined;\n /** Host content under the composer (for example a collapsed list of the person's requests). Absent: nothing is shown. */\n readonly belowComposer?: ReactNode;\n /** Rendered before the title in the header (for example a menu button on narrow screens). */\n readonly headerStart?: ReactNode;\n readonly onFiles?: FilesHandler;\n /** The phone camera's Take a photo entry for an `onFiles` chat (`CameraSheet` of `pi-chat-attachments`); with `attachments`, set `attachments.Camera` instead. */\n readonly camera?: AttachmentsConfig['Camera'];\n /** Shown instead of the default empty state before the first message. */\n readonly emptyState?: ReactNode;\n readonly suggestions?: readonly Suggestion[];\n /** Past conversations. The header's History button then opens a searchable list with the open one marked and a New action. Omit it and History pages through the earlier records of this conversation. */\n readonly conversations?: ConversationsConfig;\n /**\n * `false`: the host shows `conversations` itself (for example the `pi-app` sessions pane), so History pages through the earlier records of this\n * conversation while replies keep their Fork button (`conversations.fork`). Default `true`: History opens the list.\n */\n readonly historyList?: boolean;\n /** Show the header History button. Default true; paging remains available through the controller. */\n readonly showHistory?: boolean;\n /** Empty desktop composition. `centered` puts the composer beneath the greeting; `docked` keeps the former layout. Phones always dock it. */\n readonly emptyLayout?: 'centered' | 'docked';\n /** Two-row reference composer by default. Explicitly select `pill`, `stacked` or `inline` for the earlier layouts. */\n readonly composerLayout?: ComposerProps['layout'];\n /** A shell-owned capture uses the same recording-row replacement as native voice messages. */\n readonly composerRecording?: ReactNode;\n /** Shared host composer presentation over the normal kit session props. */\n readonly renderComposer?: (props: ComposerProps) => ReactNode;\n /** A conversation title, separate from the agent name, with optional click-to-rename. */\n readonly conversationTitle?: string | undefined;\n readonly onRenameTitle?: ((title: string) => void | Promise<void>) | undefined;\n /** Show the header connection badge. Default false; connection state still controls actions and failures stay visible. */\n readonly showConnectionStatus?: boolean;\n /** The optional annotate toggle and notes chip in the composer (the registry `feedback` item's `useComposerFeedback`). Omit it and nothing changes. */\n readonly feedback?: ComposerFeedback;\n /** Send-time context without the annotate toggle/count UI. Uses the same native admission and restoration path. */\n readonly sendContext?: import('@hachej/boring-ui-kit/chat-react').ChatSessionFeedback;\n /** Keeps the composer's half-typed message in localStorage under this key (a conversation id), across reloads and tabs; see `useChatDraft`. Omit it and nothing is stored. */\n readonly draftKey?: string | undefined;\n}\n\ntype TranscriptRow = { readonly key: string; readonly kind: 'native'; readonly row: Row }\n | { readonly key: string; readonly kind: 'voice'; readonly voice: PendingVoice };\n\nconst DEVELOPER_SWITCH = 'boring-ui:chat-mode';\n/** `?chatMode=developer` turns developer mode on in this browser until `?chatMode=expert`: the same transcript the browser already holds, drawn with its details. */\nfunction developerSwitch(): boolean {\n if (typeof window === 'undefined') return false;\n const asked = new URLSearchParams(window.location.search).get('chatMode');\n try {\n if (asked === 'developer') window.localStorage.setItem(DEVELOPER_SWITCH, 'developer');\n if (asked === 'expert') window.localStorage.removeItem(DEVELOPER_SWITCH);\n return asked !== 'expert' && (asked === 'developer' || window.localStorage.getItem(DEVELOPER_SWITCH) === 'developer');\n } catch { return asked === 'developer'; }\n}\nconst neverChanges = () => () => {};\n\n/** Borrows a concrete native controller; unmount releases presentation and uploads only. */\nexport function PiChat(hostProps: PiChatProps) {\n const developer = useSyncExternalStore(neverChanges, developerSwitch, () => false);\n const props = useMemo<PiChatProps>(() => {\n if (!developer) return hostProps;\n const { presentation: _natural, ...rest } = hostProps;\n return { ...rest, mode: 'developer' };\n }, [developer, hostProps]);\n const active = useRef(props.controller);\n useLayoutEffect(() => { active.current = props.controller; });\n // A new controller or a new voice recorder hook starts a fresh session, so the recorder's hooks keep their order.\n const recorder = props.voiceMessage?.useRecorder ?? noVoice;\n const [mount, setMount] = useState({ controller: props.controller, recorder, sequence: 0 });\n if (mount.controller !== props.controller || mount.recorder !== recorder) setMount({ controller: props.controller, recorder, sequence: mount.sequence + 1 });\n const text = useMergedText(props.labels, props.icons, chatTextFor(props.locale));\n return <ChatTextProvider value={text}><MessageScrollerProvider key={mount.sequence} autoScroll><PiChatSession {...props} activeController={active} /></MessageScrollerProvider></ChatTextProvider>;\n}\n\nfunction ConnectionBadge({ kind }: { readonly kind: string }) {\n const { labels } = useChatText();\n const live = kind === 'connected';\n return <Badge variant={live ? 'outline' : 'secondary'} data-testid=\"connection\" data-state={kind} role=\"status\">\n {live ? labels.live : kind === 'connecting' || kind === 'idle' ? labels.connecting : labels.reconnecting}\n </Badge>;\n}\n\n/** No voice message: without `voiceMessage.useRecorder` (from `pi-chat-voice`) the chat records nothing. */\nconst silent: VoiceMessageHandle = { canStart: false, phase: 'idle', seconds: 0, captureSequence: 0, problem: undefined, pending: [], start: () => {}, finish: () => {}, cancel: () => {} };\nconst noVoice: VoiceRecorderHook = () => silent;\n/** A shell's pending voice row when no `voiceMessage.Bubble` is plugged in: nothing to draw. */\nfunction NoVoiceBubble(_: { readonly pending: PendingVoice | undefined }) { return null; }\n\nfunction PiChatSession({ controller, placement = 'docked', mode = 'expert', presentation, voiceReplay, targetLabel, sentContent, messageStyle = 'default', answeredBy, className, keyboardInset = true, actions, avatar, headerActions, header = true, decisions, controls, tools, composerStart, voiceReplacesSend = false, voiceMessage, pendingVoice, belowComposer, composerChips, composerHeader, composerHasContent, onPickFiles, timeline, renderConnectionError, headerStart, renderEntry, hideLine, displayLimit, paging, historicalMessageActions, unread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions,\n onOpenImage, onCopy, onComposerKeyDown, onFiles, camera, fileAccept = 'image/*', emptyState, suggestions, messageActions, replyFooter, slash, mentions, attachments, model, effort, artifacts, conversations, historyList = true, showHistory = true, showConnectionStatus = false, emptyLayout = 'centered', composerLayout = 'card', composerRecording, renderComposer, conversationTitle, onRenameTitle, feedback, sendContext, draftKey, onOpenFile, fileChanges, pasteToFile, activeController: active }: PiChatProps & { readonly activeController: { readonly current: ChatController } }) {\n const { labels, icons } = useChatText();\n useChatDraft(controller, draftKey);\n useChatPlacement(controller, placement);\n const { title } = labels;\n const [pickingConversation, setPickingConversation] = useState(false);\n const scrollToBottom = useRef<() => void>(() => {});\n const useRecorder = voiceMessage?.useRecorder ?? noVoice;\n // eslint-disable-next-line react-hooks/refs -- the recorder is the host's hook, picked by prop, so the compiler cannot see it calls admitted only after a recording is admitted, never during render.\n const voice = useRecorder({ controller, config: voiceMessage, attachments, labels, activeController: active, admitted: () => timeline ? timeline.afterSend?.() : scrollToBottom.current() });\n // A reply's Fork button calls the host's latest `fork`; the row context changes only when forking is switched on or off.\n const fork = useRef(conversations?.fork);\n useLayoutEffect(() => { fork.current = conversations?.fork; });\n const canFork = Boolean(conversations?.fork);\n const rowExtras = useMemo(() => canFork || messageStyle === 'messaging' || answeredBy ? { ...(canFork ? { onFork: (entryId: string) => { void fork.current?.(entryId).catch(() => {}); } } : {}), ...(messageStyle === 'messaging' ? { messageStyle } : {}), ...(answeredBy ? { answeredBy } : {}) } : undefined, [canFork, messageStyle, answeredBy]);\n const session = useChatSession({ controller, activeController: active, mode, presentation, voiceReplay, targetLabel, sentContent, actions, renderEntry, hideLine, historicalMessageActions, unread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, onFiles, camera, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => timeline ? timeline.afterSend?.() : scrollToBottom.current(), feedback, sendContext, rowExtras, onOpenFile, fileChanges, pasteToFile, composerChips, composerHeader, composerHasContent, onPickFiles });\n const { state, derived, rows: nativeRows, queued, queueActions, working, waitingForAnswer, connected, error, act, textarea, rowContext, composer, files, loading } = session;\n // New or fork is on its way: nothing may go to this conversation (D-1499).\n const leaving = conversations?.creating === true;\n useCursorInOpened(textarea, conversations, composer, leaving, state.conversationId);\n const voiceRows = pendingVoice ?? voice.pending;\n const VoiceButton = voiceMessage?.Button, VoiceBubbleView = voiceMessage?.Bubble ?? NoVoiceBubble;\n const rows = useMemo(() => messageStyle === 'messaging' ? markLeads(nativeRows) : nativeRows, [nativeRows, messageStyle]);\n const transcriptRows: readonly TranscriptRow[] = [\n ...rows.map(row => ({ key: row.key, kind: 'native' as const, row })),\n ...voiceRows.map(voice => ({ key: `voice:${voice.id}`, kind: 'voice' as const, voice })),\n ];\n const pendingRows = voiceRows.map(item => <MessageScrollerItem key={`voice:${item.id}`} messageId={`voice:${item.id}`} data-local-voice=\"\" className=\"[content-visibility:visible]\"><VoiceBubbleView pending={item} /></MessageScrollerItem>);\n const empty = (timeline ? timeline.count === 0 : session.empty) && voiceRows.length === 0;\n const conversationFiles = new Map(files.map(file => [file.id, file]));\n for (const item of voiceRows) if (item.path && !conversationFiles.has(`file:${item.path}`)) {\n const path = item.path, open = onOpenFile ?? mentions?.open;\n conversationFiles.set(`file:${path}`, { id: `file:${path}`, name: displayName(path), open: open ? () => open(path) : undefined });\n }\n const { pinned } = derived;\n const transcript = useTranscript(transcriptRows, displayLimit ?? paging?.first ?? PAGE_FIRST, paging?.more ?? PAGE_MORE, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier }, displayLimit === undefined);\n const { stick, hidden } = transcript, { scrollRef, isAtBottom } = stick;\n useLayoutEffect(() => { scrollToBottom.current = () => { transcript.reset(); void stick.scrollToBottom('instant'); }; });\n const visible = transcript.visible(transcriptRows);\n // Native rows come before pending voice rows, so the hidden ones are the oldest native rows.\n const chunks = useRowChunks(useMemo(() => rows.slice(Math.min(rows.length, hidden)), [rows, hidden]));\n const { barStart, barNote } = session.pickers();\n const connectionKind = state.connection.kind;\n const [renamingTitle, setRenamingTitle] = useState(false);\n const [titleError, setTitleError] = useState(false);\n const page = useRef<HTMLElement>(null);\n useKeyboardInset(page, keyboardInset);\n\n return <section ref={page} data-boring=\"pi-chat\" data-connection={connectionKind} data-empty={empty && !loading ? emptyLayout : undefined} aria-label={title} className={cn('pi-chat relative box-border flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-background text-foreground ', keyboardInset && 'max-md:pb-(--composer-keyboard-inset)', empty && emptyLayout === 'centered' && 'md:justify-center', className)}>\n {header && <header className={cn(empty && emptyLayout === 'centered' && 'md:absolute md:inset-x-0 md:top-0', 'flex h-13 shrink-0 items-center gap-2 px-3 md:gap-3 md:px-4')}>\n {headerStart}\n {avatar && <Avatar data-testid=\"chat-avatar\" className=\"overflow-hidden\"><AvatarFallback className=\"*:max-h-full *:max-w-full\">{avatar}</AvatarFallback></Avatar>}\n {renamingTitle && onRenameTitle ? <form className=\"min-w-0 flex-1\" onSubmit={event => { event.preventDefault(); const value = new FormData(event.currentTarget).get('title')?.toString().trim(); if (value) { setTitleError(false); void Promise.resolve().then(() => onRenameTitle(value)).catch(() => setTitleError(true)); } setRenamingTitle(false); }}>\n <Input name=\"title\" aria-label={labels.conversationName} maxLength={200} autoFocus defaultValue={conversationTitle ?? title} onBlur={() => setRenamingTitle(false)} onKeyDown={event => { if (event.key === 'Escape') setRenamingTitle(false); }} className=\"w-full min-w-0\" />\n </form> : <h2 className=\"m-0 min-w-0 flex-1 truncate font-(family-name:--chat-display) text-sm font-medium tracking-tight max-md:text-center\">{onRenameTitle ? <Button variant=\"ghost\" className=\"max-w-full\" onClick={() => setRenamingTitle(true)}><span className=\"truncate\">{conversationTitle ?? title}</span></Button> : conversationTitle ?? title}</h2>}\n {showHistory && conversations && historyList && <Button variant=\"ghost\" size=\"sm\" data-testid=\"history-open\" aria-haspopup=\"dialog\" aria-expanded={pickingConversation} disabled={state.disposed}\n onClick={() => setPickingConversation(open => !open)}><icons.history data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.history}</Button>}\n <BlockActions actions={headerActions} testId=\"chat-action\" menuLabel={labels.moreActions} />\n {showConnectionStatus && <ConnectionBadge kind={connectionKind} />}\n {controls}\n </header>}\n\n {titleError && <p role=\"alert\" className=\"m-0 shrink-0 px-4 py-2 text-sm text-destructive\">{labels.actionFailed}</p>}\n {decisions && <><div data-boring=\"required-decisions\" className=\"shrink-0 px-4 py-2\">{decisions}</div><Separator /></>}\n {pinned.length > 0 && <><aside aria-label={labels.requiredActions} className=\"shrink-0 space-y-2 px-4 py-2\">{pinned.map(card => <div key={card.key}>{card.content}</div>)}</aside><Separator /></>}\n {(connectionKind === 'error' || connectionKind === 'closed') && <div className=\"shrink-0 px-4 pt-2\">\n <ChatConnectionNotice state={state} renderConnectionError={renderConnectionError} onReconnect={() => act(controller.connect)} /></div>}\n {pickingConversation && conversations && historyList && <ConversationHistory conversations={conversations} onClose={() => setPickingConversation(false)} />}\n\n <div data-testid=\"conversation-content\" className={cn('relative min-h-0 flex-1', empty && emptyLayout === 'centered' && 'md:min-h-60 md:flex-none')}>\n {timeline ? timeline.render({ focusComposer: () => textarea.current?.focus(), openFile: onOpenFile ?? mentions?.open, pendingVoice: pendingRows, paging, targetLabel }) : <MessageScroller>\n <MessageScrollerViewport ref={scrollRef} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"min-h-0 flex-1 scrollbar-thumb-border scrollbar-track-transparent\">\n {!empty && (hidden > 0 || state.history.kind !== 'idle' || (viewHasEarlier(state.view) ?? state.view?.entries.some(entry => entry.head !== undefined))) && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div className=\"mx-auto w-full max-w-[720px] px-4 pt-6 pb-8 md:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\">\n {chunks.map(run => <TranscriptChunk key={run[0]!.key} rows={run} context={rowContext} />)}\n {visible.map(item => item.kind === 'voice' && <MessageScrollerItem key={item.key} messageId={item.key} data-local-voice=\"\" className=\"[content-visibility:visible]\"><VoiceBubbleView pending={item.voice} /></MessageScrollerItem>)}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {!empty && !isAtBottom && <div data-testid=\"transcript-navigation\" className=\"flex shrink-0 items-center justify-center py-1\"><MessageScrollerButton className=\"static inset-auto translate-x-0\" data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={transcript.reset}><icons.jumpToLatest aria-hidden=\"true\" /></MessageScrollerButton></div>}\n </MessageScroller>}\n {(empty || loading) && <div className=\"pointer-events-none absolute inset-0\">\n <div className=\"pointer-events-auto h-full\">\n {loading ? <p role=\"status\" data-testid=\"loading\" className=\"flex h-full items-center justify-center gap-2 text-sm text-muted-foreground\"><Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{labels.loading}</p>\n : emptyState ?? <EmptyState title={title} description={labels.emptyDescription} {...(avatar ? { avatar } : {})} {...(suggestions ? { suggestions } : {})} onSelect={text => { if (leaving) return; controller.setText(text); textarea.current?.focus(); }} />}\n </div></div>}\n </div>\n\n <footer data-testid=\"composer-dock\" className=\"shrink-0 px-4 pt-2 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-6\">\n <div className=\"mx-auto w-full max-w-[720px] space-y-2\">\n <div {...(session.natural ? {} : { 'aria-live': 'polite' as const })} className=\"space-y-2 empty:hidden\">\n <ChatStatusLine natural={session.natural} working={working} waitingForAnswer={waitingForAnswer} derived={derived} stop={state.stop} sending={session.sending} />\n <ChatNotices natural={session.natural} state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} conversations={conversations} />\n </div>\n <div>\n <ConversationFiles files={[...conversationFiles.values()]} />\n <MessageQueue items={queued} sending={state.outbox} withdraw={actions?.withdraw} actions={queueActions} />\n {voice.problem && <p role=\"alert\" data-testid=\"voice-message-problem\">{voice.problem}</p>}\n {(renderComposer ?? (value => <Composer {...value} />))({ ...composer, disabled: composer.disabled || leaving, stopOpen: leaving && !composer.disabled,\n recording: composerRecording ?? (VoiceButton && (voice.phase === 'starting' || voice.phase === 'recording') ? <VoiceButton voice={voice} /> : undefined), layout: composerLayout,\n placeholder: labels.placeholder === 'Message the agent…' ? `Message ${title}…` : labels.placeholder, barStart, barNote, start: composerStart,\n ...(voiceMessage && VoiceButton && voiceReplacesSend ? { barEnd: tools, whenEmpty: <VoiceButton voice={voice} primary disabled={leaving || state.disposed || !connected || state.send.kind === 'unknown'} /> }\n : { barEnd: voiceMessage && VoiceButton ? <>{tools}<VoiceButton voice={voice} disabled={leaving || state.disposed || !connected || state.send.kind === 'unknown'} /></> : tools }),\n })}\n {belowComposer !== undefined && <div data-testid=\"below-composer\" className=\"pt-2\">{belowComposer}</div>}\n </div>\n </div>\n </footer>\n </section>;\n}\n",
|
|
87
|
+
"content": "'use client';\n\nimport { useKeyboardInset } from '../utils/use-media';\n\nimport { useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { viewHasEarlier } from '@hachej/boring-ui-kit/native-chat';\nimport type { ChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { Loader2Icon } from '../_shared/icons';\nimport { Button } from '@/components/ui/button';\nimport { Avatar, AvatarFallback } from '@/components/ui/avatar';\nimport { Badge } from '@/components/ui/badge';\nimport { Input } from '@/components/ui/input';\nimport { Separator } from '@/components/ui/separator';\nimport { MessageScroller, MessageScrollerProvider, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem, MessageScrollerButton } from '@/components/ui/message-scroller';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { Composer } from './composer';\nimport type { ComposerFeedback, ComposerProps } from './composer';\nimport { ConversationFiles } from './conversation-files';\nimport type { AnsweredBy, AttachmentsConfig, ConversationsConfig, PendingVoice, ReplyRef, VoiceMessageConfig, VoiceMessageHandle, VoiceRecorderHook } from './config';\nimport { displayName } from '@hachej/boring-ui-kit/chat-text';\nimport { EmptyState } from './empty-state';\nimport type { Suggestion } from './empty-state';\nimport { ConversationHistory, TranscriptHistory } from './history';\nimport { TranscriptChunk } from './message';\nimport { markLeads } from './rows';\nimport type { Row } from './rows';\nimport { MessageQueue } from './queue';\nimport { ChatNotices, ChatConnectionNotice, ChatStatusLine } from './notice';\nimport { PAGE_FIRST, PAGE_MORE, useChatSession, useCursorInOpened, useRowChunks, useTranscript } from './session';\nimport { useChatDraft } from './use-chat-draft';\nimport { useChatPlacement } from './use-remote-chat';\nimport type { ChatPlacement } from './use-remote-chat';\nimport type { ChatFeatureProps, ChatPaging, FilesHandler } from './session';\nimport { ChatTextProvider, useChatText, useMergedText } from './labels';\nimport { chatTextFor } from './locale';\nimport type { ChatLocale } from './locale';\nimport type { ChatIcons, ChatLabels } from './labels';\nimport { cn } from '../utils/utils';\n\nexport type { ChatPaging, PiChatActions } from './session';\nexport type { ChatCard, Mode } from './rows';\nexport type { AnswerOutcome, ApprovalCardProps, ApprovalDecision, ChatArtifact, ChatArtifactCardProps, ChatArtifactsConfig, ChatCards, QuestionCardProps, SubagentRenderer } from './config';\nexport { ASK_USER_TOOL } from './config';\nexport type { ChatTextPart } from './message';\nexport type { MentionPickerProps, ModelEffortPickerProps, SlashPickerProps } from './config';\nexport type { PendingVoice, PendingVoiceBubbleProps, VoiceMessageButtonProps, VoiceMessageHandle } from './config';\nexport type { CameraSheetProps, VoiceRecorderHook, VoiceRecorderOptions } from './config';\nexport { voiceClock } from './config';\nexport { ChatTextProvider } from './labels';\nexport type { CommandMention, CommandMentions } from './config';\nexport type { Suggestion } from './empty-state';\nexport { ConversationList, relativeTime } from './history';\nexport { defaultChatIcons, defaultChatLabels } from './labels';\nexport type { ChatIcons, ChatLabels } from './labels';\nexport { frenchChatLabels } from './labels-fr';\nexport { chatTextFor } from './locale';\nexport type { ChatLocale } from './locale';\nexport { BlockActions } from '../button/actions';\nexport { useChatDraft } from './use-chat-draft';\nexport { useConversations } from './use-conversations';\nexport type { ChatController, ChatSnapshot } from '@hachej/boring-ui-kit/native-chat';\nexport { MessageText, VoiceBubble } from './message';\nexport { useTranscript } from './session';\nexport type { BlockAction, BlockIcon } from '../button/actions';\nexport type { AnsweredBy, AudioUrlRead, VoiceMessageConfig, AttachmentsConfig, ConversationItem, ConversationsConfig, EffortConfig, FileKind, MentionResult, MentionsConfig, ModelConfig, ModelRef, PasteToFile, PreviewRead, ReplyRef, SlashApi, SlashCommand, SlashConfig, SlashSkill, UploadResult } from './config';\n\nexport interface ChatTimeline {\n readonly count: number;\n readonly render: (context: { readonly focusComposer: () => void; readonly openFile?: ((path: string) => void) | undefined; readonly pendingVoice?: ReactNode; readonly paging?: ChatPaging | undefined; readonly targetLabel?: ChatFeatureProps['targetLabel'] }) => ReactNode;\n readonly afterSend?: () => void;\n}\n\nexport interface PiChatProps extends ChatFeatureProps {\n /** Optional host transcript. Absent: native rows, history and scroll behavior are unchanged. */\n readonly timeline?: ChatTimeline | undefined;\n /** Authorized host errors may provide their own message. */\n readonly renderConnectionError?: ((error: unknown) => ReactNode) | undefined;\n readonly controller: ChatController;\n /** Where this chat is shown, reported with each submit (advisory, never authority). `docked` by default; a host that floats or tucks the same chat in a drawer says `floating`. */\n readonly placement?: ChatPlacement | undefined;\n /** Every word the chat shows (title, placeholder, buttons, notices, empty states): a partial object over `defaultChatLabels`. */\n /** The chat's language: `en` (default) or `fr`. `labels` override any of its words. */\n readonly locale?: ChatLocale | undefined;\n readonly labels?: Partial<ChatLabels> | undefined;\n /**\n * How a message looks. `default` (today): my message is a muted bubble on the right, the reply plain text. `messaging`: a messenger look\n * for a one-to-one conversation: my message a primary bubble on the right, the reply a muted bubble on the left, a tail on the first\n * bubble of each run and the time inside. Tool steps, traces, voice and cards are unchanged. Theme tokens only.\n */\n readonly messageStyle?: 'default' | 'messaging' | undefined;\n /** `mode=\"developer\"`: the host's per-turn `answeredBy` for a reply, shown under it as « answered by provider/model » (and « fallback: reason »). Absent or undefined: the reply's own provider/model. */\n readonly answeredBy?: ((reply: ReplyRef) => AnsweredBy | undefined) | undefined;\n /** The chat's icons (send, stop, history, attach, …): any component taking `className`, over `defaultChatIcons`. */\n readonly icons?: Partial<ChatIcons> | undefined;\n /** The agent's avatar (an image or an icon) before the title in the header, and in the empty state instead of the agent icon. */\n readonly avatar?: ReactNode;\n /** Host actions in the header beside History: `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids `chat-action-<id>`, the menu `chat-action-more`. */\n readonly headerActions?: readonly BlockAction[] | undefined;\n /** `false` lets the host supply one shared header around chat and viewers. Default true. */\n readonly header?: boolean;\n readonly className?: string;\n /** Pad the chat's bottom by how far it reaches below the visual viewport (the phone's soft keyboard). Default true; `false` for a host that already follows the keyboard itself, such as a bottom drawer whose lower part sits off screen by design. */\n readonly keyboardInset?: boolean;\n readonly decisions?: ReactNode;\n readonly controls?: ReactNode;\n /** Host controls at the end of the composer bar (for example a microphone), like AmbientChat's tools slot. */\n readonly tools?: ReactNode;\n /** Host controls at the left edge of the composer bar, before \"+\" (for example an app's own menu). Absent: nothing changes. */\n readonly composerStart?: ReactNode;\n /** With `voiceMessage`: the microphone takes Send's place while there is nothing to send, and Send comes back with the first character or file (WhatsApp's composer). Default false. */\n readonly voiceReplacesSend?: boolean;\n /** Hold-to-send one retained audio upload plus transcript. Omit to keep the existing composer unchanged. */\n readonly voiceMessage?: VoiceMessageConfig | undefined;\n /** Local voice rows owned by the shell's shared microphone; rendered with the native transcript, never the composer. */\n readonly pendingVoice?: readonly PendingVoice[] | undefined;\n /** Host content under the composer (for example a collapsed list of the person's requests). Absent: nothing is shown. */\n readonly belowComposer?: ReactNode;\n /** Rendered before the title in the header (for example a menu button on narrow screens). */\n readonly headerStart?: ReactNode;\n readonly onFiles?: FilesHandler;\n /** The phone camera's Take a photo entry for an `onFiles` chat (`CameraSheet` of `pi-chat-attachments`); with `attachments`, set `attachments.Camera` instead. */\n readonly camera?: AttachmentsConfig['Camera'];\n /** Shown instead of the default empty state before the first message. */\n readonly emptyState?: ReactNode;\n readonly suggestions?: readonly Suggestion[];\n /** Past conversations. The header's History button then opens a searchable list with the open one marked and a New action. Omit it and History pages through the earlier records of this conversation. */\n readonly conversations?: ConversationsConfig;\n /**\n * `false`: the host shows `conversations` itself (for example the `pi-app` sessions pane), so History pages through the earlier records of this\n * conversation while replies keep their Fork button (`conversations.fork`). Default `true`: History opens the list.\n */\n readonly historyList?: boolean;\n /** Show the header History button. Default true; paging remains available through the controller. */\n readonly showHistory?: boolean;\n /** Empty desktop composition. `centered` puts the composer beneath the greeting; `docked` keeps the former layout. Phones always dock it. */\n readonly emptyLayout?: 'centered' | 'docked';\n /** Two-row reference composer by default. Explicitly select `pill`, `stacked` or `inline` for the earlier layouts. */\n readonly composerLayout?: ComposerProps['layout'];\n /** A shell-owned capture uses the same recording-row replacement as native voice messages. */\n readonly composerRecording?: ReactNode;\n /** Shared host composer presentation over the normal kit session props. */\n readonly renderComposer?: (props: ComposerProps) => ReactNode;\n /** A conversation title, separate from the agent name, with optional click-to-rename. */\n readonly conversationTitle?: string | undefined;\n readonly onRenameTitle?: ((title: string) => void | Promise<void>) | undefined;\n /** Show the header connection badge. Default false; connection state still controls actions and failures stay visible. */\n readonly showConnectionStatus?: boolean;\n /** The optional annotate toggle and notes chip in the composer (the registry `feedback` item's `useComposerFeedback`). Omit it and nothing changes. */\n readonly feedback?: ComposerFeedback;\n /** Send-time context without the annotate toggle/count UI. Uses the same native admission and restoration path. */\n readonly sendContext?: import('@hachej/boring-ui-kit/chat-react').ChatSessionFeedback;\n /** Keeps the composer's half-typed message in localStorage under this key (a conversation id), across reloads and tabs; see `useChatDraft`. Omit it and nothing is stored. */\n readonly draftKey?: string | undefined;\n}\n\ntype TranscriptRow = { readonly key: string; readonly kind: 'native'; readonly row: Row }\n | { readonly key: string; readonly kind: 'voice'; readonly voice: PendingVoice };\n\nconst DEVELOPER_SWITCH = 'boring-ui:chat-mode';\n/** `?chatMode=developer` turns developer mode on in this browser until `?chatMode=expert`: the same transcript the browser already holds, drawn with its details. */\nfunction developerSwitch(): boolean {\n if (typeof window === 'undefined') return false;\n const asked = new URLSearchParams(window.location.search).get('chatMode');\n try {\n if (asked === 'developer') window.localStorage.setItem(DEVELOPER_SWITCH, 'developer');\n if (asked === 'expert') window.localStorage.removeItem(DEVELOPER_SWITCH);\n return asked !== 'expert' && (asked === 'developer' || window.localStorage.getItem(DEVELOPER_SWITCH) === 'developer');\n } catch { return asked === 'developer'; }\n}\nconst neverChanges = () => () => {};\n\n/** Borrows a concrete native controller; unmount releases presentation and uploads only. */\nexport function PiChat(hostProps: PiChatProps) {\n const developer = useSyncExternalStore(neverChanges, developerSwitch, () => false);\n const props = useMemo<PiChatProps>(() => {\n if (!developer) return hostProps;\n const { presentation: _natural, ...rest } = hostProps;\n return { ...rest, mode: 'developer' };\n }, [developer, hostProps]);\n const active = useRef(props.controller);\n useLayoutEffect(() => { active.current = props.controller; });\n // A new controller or a new voice recorder hook starts a fresh session, so the recorder's hooks keep their order.\n const recorder = props.voiceMessage?.useRecorder ?? noVoice;\n const [mount, setMount] = useState({ controller: props.controller, recorder, sequence: 0 });\n if (mount.controller !== props.controller || mount.recorder !== recorder) setMount({ controller: props.controller, recorder, sequence: mount.sequence + 1 });\n const text = useMergedText(props.labels, props.icons, chatTextFor(props.locale));\n return <ChatTextProvider value={text}><MessageScrollerProvider key={mount.sequence} autoScroll><PiChatSession {...props} activeController={active} /></MessageScrollerProvider></ChatTextProvider>;\n}\n\nfunction ConnectionBadge({ kind }: { readonly kind: string }) {\n const { labels } = useChatText();\n const live = kind === 'connected';\n return <Badge variant={live ? 'outline' : 'secondary'} data-testid=\"connection\" data-state={kind} role=\"status\">\n {live ? labels.live : kind === 'connecting' || kind === 'idle' ? labels.connecting : labels.reconnecting}\n </Badge>;\n}\n\n/** No voice message: without `voiceMessage.useRecorder` (from `pi-chat-voice`) the chat records nothing. */\nconst silent: VoiceMessageHandle = { canStart: false, phase: 'idle', seconds: 0, captureSequence: 0, problem: undefined, pending: [], start: () => {}, finish: () => {}, cancel: () => {} };\nconst noVoice: VoiceRecorderHook = () => silent;\n/** A shell's pending voice row when no `voiceMessage.Bubble` is plugged in: nothing to draw. */\nfunction NoVoiceBubble(_: { readonly pending: PendingVoice | undefined }) { return null; }\n\nfunction PiChatSession({ controller, placement = 'docked', mode = 'expert', presentation, voiceReplay, targetLabel, sentContent, messageStyle = 'default', answeredBy, className, keyboardInset = true, actions, avatar, headerActions, header = true, decisions, controls, tools, composerStart, voiceReplacesSend = false, voiceMessage, pendingVoice, belowComposer, composerChips, composerHeader, composerHasContent, onPickFiles, timeline, renderConnectionError, headerStart, renderEntry, hideLine, displayLimit, paging, historicalMessageActions, unread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions,\n onOpenImage, onCopy, onComposerKeyDown, onFiles, camera, fileAccept = 'image/*', emptyState, suggestions, messageActions, replyFooter, slash, mentions, attachments, model, effort, artifacts, conversations, historyList = true, showHistory = true, showConnectionStatus = false, emptyLayout = 'centered', composerLayout = 'card', composerRecording, renderComposer, conversationTitle, onRenameTitle, feedback, sendContext, draftKey, onOpenFile, fileChanges, pasteToFile, activeController: active }: PiChatProps & { readonly activeController: { readonly current: ChatController } }) {\n const { labels, icons } = useChatText();\n useChatDraft(controller, draftKey);\n useChatPlacement(controller, placement);\n const { title } = labels;\n const [pickingConversation, setPickingConversation] = useState(false);\n const scrollToBottom = useRef<() => void>(() => {});\n const useRecorder = voiceMessage?.useRecorder ?? noVoice;\n // eslint-disable-next-line react-hooks/refs -- the recorder is the host's hook, picked by prop, so the compiler cannot see it calls admitted only after a recording is admitted, never during render.\n const voice = useRecorder({ controller, config: voiceMessage, attachments, labels, activeController: active, admitted: () => timeline ? timeline.afterSend?.() : scrollToBottom.current() });\n // A reply's Fork button calls the host's latest `fork`; the row context changes only when forking is switched on or off.\n const fork = useRef(conversations?.fork);\n useLayoutEffect(() => { fork.current = conversations?.fork; });\n const canFork = Boolean(conversations?.fork);\n const rowExtras = useMemo(() => canFork || messageStyle === 'messaging' || answeredBy ? { ...(canFork ? { onFork: (entryId: string) => { void fork.current?.(entryId).catch(() => {}); } } : {}), ...(messageStyle === 'messaging' ? { messageStyle } : {}), ...(answeredBy ? { answeredBy } : {}) } : undefined, [canFork, messageStyle, answeredBy]);\n const session = useChatSession({ controller, activeController: active, mode, presentation, voiceReplay, targetLabel, sentContent, actions, renderEntry, hideLine, historicalMessageActions, unread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, onFiles, camera, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => timeline ? timeline.afterSend?.() : scrollToBottom.current(), feedback, sendContext, rowExtras, onOpenFile, fileChanges, pasteToFile, composerChips, composerHeader, composerHasContent, onPickFiles });\n const { state, derived, rows: nativeRows, queued, queueActions, working, waitingForAnswer, connected, error, act, textarea, rowContext, composer, files, loading } = session;\n // New or fork is on its way: nothing may go to this conversation (D-1499).\n const leaving = conversations?.creating === true;\n useCursorInOpened(textarea, conversations, composer, leaving, state.conversationId);\n const voiceRows = pendingVoice ?? voice.pending;\n const VoiceButton = voiceMessage?.Button, VoiceBubbleView = voiceMessage?.Bubble ?? NoVoiceBubble;\n const rows = useMemo(() => messageStyle === 'messaging' ? markLeads(nativeRows) : nativeRows, [nativeRows, messageStyle]);\n const transcriptRows: readonly TranscriptRow[] = [\n ...rows.map(row => ({ key: row.key, kind: 'native' as const, row })),\n ...voiceRows.map(voice => ({ key: `voice:${voice.id}`, kind: 'voice' as const, voice })),\n ];\n const pendingRows = voiceRows.map(item => <MessageScrollerItem key={`voice:${item.id}`} messageId={`voice:${item.id}`} data-local-voice=\"\" className=\"[content-visibility:visible]\"><VoiceBubbleView pending={item} /></MessageScrollerItem>);\n const empty = (timeline ? timeline.count === 0 : session.empty) && voiceRows.length === 0;\n const conversationFiles = new Map(files.map(file => [file.id, file]));\n for (const item of voiceRows) if (item.path && !conversationFiles.has(`file:${item.path}`)) {\n const path = item.path, open = onOpenFile ?? mentions?.open;\n conversationFiles.set(`file:${path}`, { id: `file:${path}`, name: displayName(path), open: open ? () => open(path) : undefined });\n }\n const { pinned } = derived;\n const transcript = useTranscript(transcriptRows, displayLimit ?? paging?.first ?? PAGE_FIRST, paging?.more ?? PAGE_MORE, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier }, displayLimit === undefined);\n const { stick, hidden } = transcript, { scrollRef, isAtBottom } = stick;\n useLayoutEffect(() => { scrollToBottom.current = () => { transcript.reset(); void stick.scrollToBottom('instant'); }; });\n const visible = transcript.visible(transcriptRows);\n // Native rows come before pending voice rows, so the hidden ones are the oldest native rows.\n const chunks = useRowChunks(useMemo(() => rows.slice(Math.min(rows.length, hidden)), [rows, hidden]));\n const { barStart, barNote } = session.pickers();\n const connectionKind = state.connection.kind;\n const [renamingTitle, setRenamingTitle] = useState(false);\n const [titleError, setTitleError] = useState(false);\n const page = useRef<HTMLElement>(null);\n useKeyboardInset(page, keyboardInset);\n\n return <section ref={page} data-boring=\"pi-chat\" data-connection={connectionKind} data-empty={empty && !loading ? emptyLayout : undefined} aria-label={title} className={cn('pi-chat relative box-border flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-background text-foreground ', keyboardInset && 'max-md:pb-(--composer-keyboard-inset)', empty && emptyLayout === 'centered' && 'md:justify-center', className)}>\n {header && <header className={cn(empty && emptyLayout === 'centered' && 'md:absolute md:inset-x-0 md:top-0', 'flex h-13 shrink-0 items-center gap-2 px-3 md:gap-3 md:px-4')}>\n {headerStart}\n {avatar && <Avatar data-testid=\"chat-avatar\" className=\"overflow-hidden\"><AvatarFallback className=\"*:max-h-full *:max-w-full\">{avatar}</AvatarFallback></Avatar>}\n {renamingTitle && onRenameTitle ? <form className=\"min-w-0 flex-1\" onSubmit={event => { event.preventDefault(); const value = new FormData(event.currentTarget).get('title')?.toString().trim(); if (value) { setTitleError(false); void Promise.resolve().then(() => onRenameTitle(value)).catch(() => setTitleError(true)); } setRenamingTitle(false); }}>\n <Input name=\"title\" aria-label={labels.conversationName} maxLength={200} autoFocus defaultValue={conversationTitle ?? title} onBlur={() => setRenamingTitle(false)} onKeyDown={event => { if (event.key === 'Escape') setRenamingTitle(false); }} className=\"w-full min-w-0\" />\n </form> : <h2 className=\"m-0 min-w-0 flex-1 truncate font-(family-name:--chat-display) text-sm font-medium tracking-tight max-md:text-center\">{onRenameTitle ? <Button variant=\"ghost\" className=\"max-w-full\" onClick={() => setRenamingTitle(true)}><span className=\"truncate\">{conversationTitle ?? title}</span></Button> : conversationTitle ?? title}</h2>}\n {showHistory && conversations && historyList && <Button variant=\"ghost\" size=\"sm\" data-testid=\"history-open\" aria-haspopup=\"dialog\" aria-expanded={pickingConversation} disabled={state.disposed}\n onClick={() => setPickingConversation(open => !open)}><icons.history data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.history}</Button>}\n <BlockActions actions={headerActions} testId=\"chat-action\" menuLabel={labels.moreActions} />\n {showConnectionStatus && <ConnectionBadge kind={connectionKind} />}\n {controls}\n </header>}\n\n {titleError && <p role=\"alert\" className=\"m-0 shrink-0 px-4 py-2 text-xs text-destructive\">{labels.actionFailed}</p>}\n {decisions && <><div data-boring=\"required-decisions\" className=\"shrink-0 px-4 py-2\">{decisions}</div><Separator /></>}\n {pinned.length > 0 && <><aside aria-label={labels.requiredActions} className=\"shrink-0 space-y-2 px-4 py-2\">{pinned.map(card => <div key={card.key}>{card.content}</div>)}</aside><Separator /></>}\n {(connectionKind === 'error' || connectionKind === 'closed') && <div className=\"shrink-0 px-4 pt-2\">\n <ChatConnectionNotice state={state} renderConnectionError={renderConnectionError} onReconnect={() => act(controller.connect)} /></div>}\n {pickingConversation && conversations && historyList && <ConversationHistory conversations={conversations} onClose={() => setPickingConversation(false)} />}\n\n <div data-testid=\"conversation-content\" className={cn('relative min-h-0 flex-1', empty && emptyLayout === 'centered' && 'md:min-h-60 md:flex-none')}>\n {timeline ? timeline.render({ focusComposer: () => textarea.current?.focus(), openFile: onOpenFile ?? mentions?.open, pendingVoice: pendingRows, paging, targetLabel }) : <MessageScroller>\n <MessageScrollerViewport ref={scrollRef} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"min-h-0 flex-1 scrollbar-thumb-border scrollbar-track-transparent\">\n {!empty && (hidden > 0 || state.history.kind !== 'idle' || (viewHasEarlier(state.view) ?? state.view?.entries.some(entry => entry.head !== undefined))) && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div className=\"mx-auto w-full max-w-[720px] px-4 pt-6 pb-8 md:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\">\n {chunks.map(run => <TranscriptChunk key={run[0]!.key} rows={run} context={rowContext} />)}\n {visible.map(item => item.kind === 'voice' && <MessageScrollerItem key={item.key} messageId={item.key} data-local-voice=\"\" className=\"[content-visibility:visible]\"><VoiceBubbleView pending={item.voice} /></MessageScrollerItem>)}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {!empty && !isAtBottom && <div data-testid=\"transcript-navigation\" className=\"flex shrink-0 items-center justify-center py-1\"><MessageScrollerButton className=\"static inset-auto translate-x-0\" data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={transcript.reset}><icons.jumpToLatest aria-hidden=\"true\" /></MessageScrollerButton></div>}\n </MessageScroller>}\n {(empty || loading) && <div className=\"pointer-events-none absolute inset-0\">\n <div className=\"pointer-events-auto h-full\">\n {loading ? <p role=\"status\" data-testid=\"loading\" className=\"flex h-full items-center justify-center gap-2 text-xs text-muted-foreground\"><Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{labels.loading}</p>\n : emptyState ?? <EmptyState title={title} description={labels.emptyDescription} {...(avatar ? { avatar } : {})} {...(suggestions ? { suggestions } : {})} onSelect={text => { if (leaving) return; controller.setText(text); textarea.current?.focus(); }} />}\n </div></div>}\n </div>\n\n <footer data-testid=\"composer-dock\" className=\"shrink-0 px-4 pt-2 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-6\">\n <div className=\"mx-auto w-full max-w-[720px] space-y-2\">\n <div {...(session.natural ? {} : { 'aria-live': 'polite' as const })} className=\"space-y-2 empty:hidden\">\n <ChatStatusLine natural={session.natural} working={working} waitingForAnswer={waitingForAnswer} derived={derived} stop={state.stop} sending={session.sending} />\n <ChatNotices natural={session.natural} state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} conversations={conversations} />\n </div>\n <div>\n <ConversationFiles files={[...conversationFiles.values()]} />\n <MessageQueue items={queued} sending={state.outbox} withdraw={actions?.withdraw} actions={queueActions} />\n {voice.problem && <p role=\"alert\" data-testid=\"voice-message-problem\">{voice.problem}</p>}\n {(renderComposer ?? (value => <Composer {...value} />))({ ...composer, disabled: composer.disabled || leaving, stopOpen: leaving && !composer.disabled,\n recording: composerRecording ?? (VoiceButton && (voice.phase === 'starting' || voice.phase === 'recording') ? <VoiceButton voice={voice} /> : undefined), layout: composerLayout,\n placeholder: labels.placeholder === 'Message the agent…' ? `Message ${title}…` : labels.placeholder, barStart, barNote, start: composerStart,\n ...(voiceMessage && VoiceButton && voiceReplacesSend ? { barEnd: tools, whenEmpty: <VoiceButton voice={voice} primary disabled={leaving || state.disposed || !connected || state.send.kind === 'unknown'} /> }\n : { barEnd: voiceMessage && VoiceButton ? <>{tools}<VoiceButton voice={voice} disabled={leaving || state.disposed || !connected || state.send.kind === 'unknown'} /></> : tools }),\n })}\n {belowComposer !== undefined && <div data-testid=\"below-composer\" className=\"pt-2\">{belowComposer}</div>}\n </div>\n </div>\n </footer>\n </section>;\n}\n",
|
|
88
88
|
"type": "registry:component",
|
|
89
89
|
"target": "components/pi-chat/pi-chat.tsx"
|
|
90
90
|
},
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
},
|
|
97
97
|
{
|
|
98
98
|
"path": "registry/pi-chat/queue.tsx",
|
|
99
|
-
"content": "'use client';\n\nimport { useState } from 'react';\nimport type { ChatDraft } from '@hachej/boring-ui-kit/native-chat';\nimport { CornerDownRightIcon, EllipsisIcon, ListEndIcon, PencilIcon, RouteIcon, Trash2Icon } from '
|
|
99
|
+
"content": "'use client';\n\nimport { useState } from 'react';\nimport type { ChatDraft } from '@hachej/boring-ui-kit/native-chat';\nimport { CornerDownRightIcon, EllipsisIcon, ListEndIcon, PencilIcon, RouteIcon, Trash2Icon } from '../_shared/icons';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { Card, CardContent } from '@/components/ui/card';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Spinner } from '@/components/ui/spinner';\nimport { TraceIcon, TraceLine, TraceTitle } from '@/components/ui/trace';\nimport { Button } from '@/components/ui/button';\nimport { isFileBlock, targetContent, type QueuedMessage } from './rows';\n\n/** A pending message shows what the person said, never its leading `<target>` block's raw fields. */\nconst spoken = (text: string): string => targetContent(text)?.speech ?? text;\nimport { defaultChatLabels, useChatText } from './labels';\n\nexport type { QueuedMessage } from './rows';\n\n/** Display text of a queued submission: the text parts of its content, plus a count of images. */\nexport function queuedText(item: QueuedMessage, imagesLabel: (count: number) => string = defaultChatLabels.images): string {\n const content = item.content as unknown;\n if (typeof content === 'string') return spoken(content);\n if (!Array.isArray(content)) return '';\n const parts = content as readonly { type?: unknown; text?: unknown }[];\n const text = parts.flatMap(part => part?.type === 'text' && typeof part.text === 'string' && !isFileBlock(part.text) ? [spoken(part.text)] : []).join('\\n');\n const images = parts.filter(part => part?.type === 'image').length;\n return images ? `${text}${text ? '\\n' : ''}${imagesLabel(images)}` : text;\n}\n\n/** What a queued message can be taken back for. Both take the message out of the queue first and throw when it is too late. */\nexport type { QueueActions } from '@hachej/boring-ui-kit/chat-react';\nimport type { QueueActions } from '@hachej/boring-ui-kit/chat-react';\n\n/** The \"…\" button of one queued message: a small menu of the secondary actions (Edit). */\nfunction QueueEditAction({ disabled, onEdit }: { readonly disabled: boolean; readonly onEdit: () => void }) {\n const { labels } = useChatText();\n return <DropdownMenu>\n <DropdownMenuTrigger render={<Button variant=\"ghost\" size=\"icon\" />} data-testid=\"queue-more\" aria-label={labels.moreActions} title={labels.more} disabled={disabled}>\n <EllipsisIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n </DropdownMenuTrigger>\n <DropdownMenuContent side=\"top\" align=\"end\" data-testid=\"queue-menu\">\n <DropdownMenuGroup><DropdownMenuItem data-testid=\"queue-edit\" onClick={onEdit}><PencilIcon aria-hidden=\"true\" />{labels.edit}</DropdownMenuItem></DropdownMenuGroup>\n </DropdownMenuContent>\n </DropdownMenu>;\n}\n\n/**\n * Messages sent while the agent was working, waiting for Pi to place them, drawn as a slim tab tucked behind the top edge of the composer\n * (render it directly above `Composer`; the composer overlaps its bottom). One row per message: icon, text, Steer, remove and a \"…\" menu\n * (Edit). The choice is made here, per message, never in the composer. Rows leave on their own once Pi starts them. `sending` are messages\n * sent from the composer that wait for the previous send to be confirmed (the controller's `outbox`): shown last, without actions.\n */\nexport function MessageQueue({ items, sending = [], withdraw, actions }: {\n readonly items: readonly QueuedMessage[];\n readonly sending?: readonly ChatDraft[] | undefined;\n readonly withdraw?: ((id: QueuedMessage['id']) => Promise<unknown>) | undefined;\n readonly actions?: QueueActions | undefined;\n}) {\n const { labels } = useChatText();\n const [failed, setFailed] = useState<string | undefined>();\n const [busy, setBusy] = useState<ReadonlySet<string>>(new Set());\n if (!items.length && !sending.length) return null;\n async function run(item: QueuedMessage, work: () => Promise<unknown>) {\n const id = String(item.id);\n setFailed(undefined); setBusy(current => new Set(current).add(id));\n try { await work(); }\n catch (cause) { setFailed(cause instanceof Error ? cause.message : labels.queueFailed); }\n finally { setBusy(current => { const next = new Set(current); next.delete(id); return next; }); }\n }\n const remove = (item: QueuedMessage) => run(item, async () => {\n const outcome = await withdraw!(item.id);\n if (outcome === 'already_placed' || outcome === 'settled') throw new Error(labels.tooLateToRemove);\n });\n return <section data-testid=\"queue\" aria-label={labels.queuedMessages} className=\"relative mx-3 -mb-3 pb-3\">\n <Card size=\"sm\"><CardContent><ul className=\"m-0 flex list-none flex-col p-0\">\n {items.map(item => {\n const working = busy.has(String(item.id));\n const steering = item.mode === 'steer';\n return <li key={String(item.id)} data-testid=\"queue-item\" data-mode={item.mode} className=\"flex min-h-10 items-center gap-1.5\">\n <TraceLine status=\"pending\" className=\"min-w-0 flex-1\">\n <TraceIcon>{steering ? <RouteIcon /> : <ListEndIcon />}</TraceIcon>\n <TraceTitle><span className=\"sr-only\">{steering ? labels.steering : labels.queued}: </span><span data-testid=\"queue-text\">{queuedText(item, labels.images)}</span></TraceTitle>\n </TraceLine>\n {!steering && actions?.steer && <Button variant=\"ghost\" size=\"icon\" data-testid=\"queue-steer\" aria-label={labels.steer} title={labels.steerHint} disabled={working} onClick={() => { void run(item, () => actions.steer!(item)); }}>\n <CornerDownRightIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"queue-cancel\" aria-label={labels.removeQueued} title={labels.removeHint} disabled={!withdraw || working} onClick={() => { void remove(item); }}>\n <Trash2Icon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n {actions?.edit && <QueueEditAction disabled={working} onEdit={() => { void run(item, () => actions.edit!(item)); }} />}\n </li>;\n })}\n {sending.map((draft, index) => <li key={`sending-${index}`} data-testid=\"queue-sending\" className=\"flex min-h-10 items-center gap-1.5\">\n <TraceLine status=\"running\" className=\"min-w-0 flex-1\">\n <TraceIcon><Spinner aria-hidden=\"true\" /></TraceIcon>\n <TraceTitle data-testid=\"queue-text\">{spoken(draft.text)}{draft.attachments.length ? `${draft.text ? '\\n' : ''}${labels.images(draft.attachments.length)}` : ''}</TraceTitle>\n </TraceLine>\n <span className=\"shrink-0 px-1 text-xs text-muted-foreground\">{labels.sending}</span>\n </li>)}\n </ul></CardContent></Card>\n {failed && <Alert variant=\"destructive\" data-testid=\"queue-error\"><AlertDescription>{failed}</AlertDescription></Alert>}\n </section>;\n}\n",
|
|
100
100
|
"type": "registry:component",
|
|
101
101
|
"target": "components/pi-chat/queue.tsx"
|
|
102
102
|
},
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
"path": "registry/pi-chat/tool.tsx",
|
|
129
|
-
"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 '
|
|
129
|
+
"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 '../_shared/icons';\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-md: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-md: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",
|
|
130
130
|
"type": "registry:component",
|
|
131
131
|
"target": "components/pi-chat/tool.tsx"
|
|
132
132
|
},
|
|
@@ -144,7 +144,7 @@
|
|
|
144
144
|
},
|
|
145
145
|
{
|
|
146
146
|
"path": "registry/pi-chat/labels.ts",
|
|
147
|
-
"content": "/*\n * Every word the chat shows, and the icons it draws, as plain data. A host passes a partial `labels` (and `icons`) object to `PiChat`\n * (or `AmbientChat`, `AgentWorkspace`): what it leaves out keeps the default below. Strings that need a value are functions. No i18n\n * framework: an app that translates passes its own object per language.\n */\nimport { createContext, createElement, useContext, useMemo, useRef } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowDownIcon, ArrowUpIcon, CameraIcon, GitForkIcon, HistoryIcon, ListEndIcon, MessagesSquareIcon, PaperclipIcon, PlusIcon, RefreshCwIcon, SquareIcon } from 'lucide-react';\nimport type { BlockIcon } from '../button/actions';\nimport { withDefaults } from '../utils/utils';\n\n// An Intl.DateTimeFormat costs far more than formatting with one; every bubble's clock reuses the same few.\nconst formats = new Map<string, Intl.DateTimeFormat>();\nfunction intlFormat(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {\n const key = `${locale}|${JSON.stringify(options)}`;\n let found = formats.get(key);\n if (!found) { found = new Intl.DateTimeFormat(locale, options); formats.set(key, found); }\n return found;\n}\n/** « Tue 22 Oct, 14:02 ». */\nexport function formatTimestamp(at: string, locale: string) {\n const date = new Date(at);\n if (!Number.isFinite(date.getTime())) return at;\n return `${intlFormat(locale, { weekday: 'short', day: 'numeric', month: 'short' }).format(date)}, ${intlFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(date)}`;\n}\n/** The time inside a bubble (« 14:02 », « 2:02 PM »). */\nexport function formatClock(at: string, locale: string) {\n const date = new Date(at);\n return Number.isFinite(date.getTime()) ? intlFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(date) : '';\n}\n\nconst TOOL_VERBS: Readonly<Record<string, string>> = { read: 'Reading', write: 'Writing', edit: 'Editing', ls: 'Listing', list_files: 'Listing', find: 'Searching', grep: 'Searching', load_skill: 'Loading skill', bash: 'Running bash' };\nconst TOOL_NOUNS: Readonly<Record<string, string>> = { bash: 'command', read: 'read', write: 'write', edit: 'edit', find: 'find', grep: 'search', ls: 'list' };\n\nconst EFFORT_NAMES: Readonly<Record<string, string>> = { off: 'Off', minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', max: 'Max' };\nconst EFFORT_DETAILS: Readonly<Record<string, string>> = { off: 'No extra reasoning', minimal: 'Barely any reasoning', low: 'Light reasoning', medium: 'Balanced reasoning', high: 'Deep reasoning', xhigh: 'Deeper reasoning', max: 'Maximum reasoning' };\n\nexport const defaultChatLabels = {\n // Header and transcript\n /** The chat's name: the header title, the region's name and the empty state's title. */\n title: 'Conversation',\n history: 'History',\n artifacts: 'Artifacts',\n search: 'Search',\n live: 'Live',\n connecting: 'Connecting…',\n reconnecting: 'Reconnecting…',\n loadFailed: 'Conversation could not be loaded.',\n disconnected: 'Disconnected. Last observed messages remain visible.',\n reconnect: 'Reconnect',\n requiredActions: 'Required conversation actions',\n messages: 'Messages',\n showEarlier: 'Show earlier messages',\n loading: 'Loading conversation…',\n emptyDescription: 'Start a conversation.',\n jumpToLatest: 'Jump to latest',\n agentWorking: 'Agent is working',\n waitingForAnswer: 'Waiting for your answer',\n retryPending: 'Native retry pending',\n compacting: 'Native context compaction in progress',\n /** The one status of `presentation=\"natural\"` while the agent works (any tool, retry or compaction), instead of the activity wording above. */\n preparing: 'Preparing…',\n /** In `presentation=\"natural\"`, replaces the controller's own reason when a send is blocked. */\n sendUnavailable: 'Sending is not available right now.',\n /** The \"…\" menu of host actions (header, replies). */\n moreActions: 'More actions',\n // Notices\n sendUnknown: 'Submission acknowledgement is unknown. The message is kept until you check or retry it.',\n checkSubmission: 'Check original submission',\n retrySame: 'Retry same request',\n stopRequested: 'Stop requested. Waiting for native confirmation.',\n stopUnconfirmed: 'Stop is unconfirmed. Work may still be running.',\n newChatFailed: \"The new conversation wasn't made. You are still in this one.\",\n newChatFailedTyped: \"The new conversation wasn't made, and what you typed wasn't kept. You are still in this one.\",\n steerRefused: 'The message could not be steered; it is back in the message box.',\n tooLate: 'Too late: that message has already started.',\n tooLateToRemove: 'Too late to remove: that message has already started.',\n queueFailed: 'The message could not be changed.',\n queueLocked: 'Queued messages cannot be changed here.',\n waitToSteer: 'Wait for the current send to finish, then steer.',\n steerFailed: 'The message could not be steered.',\n connectionFailed: 'Connection failed',\n actionFailedShort: 'Action failed',\n uploadFailedShort: 'Upload failed',\n changeRefused: 'The change was refused',\n dismissHint: 'click to dismiss',\n // Composer\n placeholder: 'Message the agent…',\n placeholderWorking: 'Queue a message…',\n messageInput: 'Message',\n send: 'Send',\n sendHint: 'Send (Enter)',\n stop: 'Stop',\n queue: 'Queue',\n add: 'Add',\n attachFiles: 'Attach files',\n takePhoto: 'Take a photo',\n cameraShoot: 'Take the photo',\n cameraClose: 'Close the camera',\n cameraUnavailable: 'The camera cannot open on this page.',\n cameraFallback: \"Use the phone's camera\",\n commands: 'Commands',\n annotate: 'Annotate',\n annotateHint: 'Annotate: click what you want to comment on (Esc to stop)',\n annotateNotes: (count: number) => `${count} ${count === 1 ? 'note' : 'notes'}`,\n annotateClear: 'Remove the notes',\n feedbackSent: (count: number) => `${count} ${count === 1 ? 'comment' : 'comments'} on the page`,\n remove: (name: string) => `Remove ${name}`,\n dismiss: (name: string) => `Dismiss ${name}`,\n uploading: (name: string) => `Uploading ${name}…`,\n uploadFailed: (name: string, error: string | undefined) => `${name}: ${error ?? 'upload failed'}`,\n preparingAttachments: 'Preparing attachments…',\n preparingAttachmentsShort: 'Preparing attachments',\n mentionedFiles: 'Mentioned files',\n uploads: 'Uploads',\n attachments: 'Attachments',\n /** The type line of a file card in a sent message. */\n fileKind: { document: 'Document', image: 'Image', pdf: 'PDF', sheet: 'Spreadsheet', slides: 'Presentation', audio: 'Audio', file: 'File' },\n openFile: (name: string) => `Open ${name}`,\n fileChange: { created: 'Created', edited: 'Edited' },\n /** The name of a pasted text file: the words and the time it was pasted. */\n pastedFileName: (stamp: string) => `Pasted text ${stamp}`,\n restorePaste: 'Put the pasted text back in the message',\n searchCommands: 'Search commands',\n filterBySource: 'Filter by source',\n allSources: 'All',\n noCommands: 'No matching commands.',\n /** A `/` menu filter chip: `skills`, `built-in` or a plugin's name. */\n slashGroup: (group: string) => group,\n skillBadge: 'skill',\n workspaceFiles: 'Workspace files',\n searchFailed: 'Search failed. Try again.',\n model: 'Model',\n effort: 'Effort',\n effortDefault: 'Default',\n modelAndEffort: 'Model and effort',\n modelSummary: (model: string | undefined, effort: string | undefined) => [model && `Model: ${model}`, effort && `Effort: ${effort}`].filter(Boolean).join(', '),\n /** A thinking level's name and short description in the picker. */\n effortName: (level: string) => EFFORT_NAMES[level] ?? level,\n effortDetail: (level: string): string | undefined => EFFORT_DETAILS[level],\n // Voice input (the microphone button beside the composer)\n voiceRecord: 'Record a message',\n voiceHold: 'Hold to record, release to send',\n voicePauseRecording: 'Pause recording',\n voiceResumeRecording: 'Resume recording',\n voicePaused: 'Recording paused',\n voiceRecording: 'Recording',\n voicePreparing: 'Preparing voice message…',\n voiceAudioOnly: 'Send audio only',\n voiceDiscard: 'Discard',\n voicePlay: 'Play voice message',\n voicePause: 'Pause voice message',\n voicePlayFailed: 'The audio could not be played. Try again.',\n voiceBlocked: 'Not sent. Review the chat notice before trying again.',\n voiceUnconfirmed: 'Message admission is unconfirmed. Use the chat notice to reconcile.',\n voiceStop: 'Stop recording',\n voiceCancel: 'Cancel recording',\n voiceTranscribing: 'Transcribing…',\n voiceNothingHeard: 'Nothing was heard. Try again.',\n voiceTranscribeFailed: 'The recording could not be transcribed. Try again.',\n voiceTranscriptUnavailable: 'Voice message — transcription unavailable.',\n voiceDenied: 'Microphone blocked. Allow it for this site in the browser, then press record again.',\n voiceNoMicrophone: 'No microphone found. Connect one, then press record again.',\n voiceBusy: 'The microphone is busy or could not be opened. Close other apps using it and try again.',\n voiceInsecure: 'Recording needs a secure page (https or localhost).',\n voiceUnsupported: 'This browser cannot record audio here.',\n voiceStartFailed: 'The microphone could not be started.',\n // Queue\n queuedMessages: 'Queued messages',\n steer: 'Steer',\n steerHint: 'Send this into the running turn now',\n steering: 'Steering',\n queued: 'Queued',\n removeQueued: 'Remove queued message',\n removeHint: 'Remove',\n more: 'More',\n edit: 'Edit',\n sending: 'Sending',\n /** Under a message whose send was not confirmed; the notice below offers Check and Retry. */\n notSent: 'Not sent',\n /** The time inside a bubble in the `messaging` style (« 14:02 »), and its full date as the tooltip. */\n messageClock: (at: string) => formatClock(at, 'en'),\n messageTimestamp: (at: string) => formatTimestamp(at, 'en'),\n images: (count: number) => `[${count} image${count === 1 ? '' : 's'}]`,\n // Conversation list and history\n historyDialog: 'Conversation history',\n newConversation: 'New',\n untitled: 'New conversation',\n closeHistory: 'Close history',\n close: 'Close',\n earlierInConversation: 'Earlier messages in this conversation',\n searchConversations: 'Search conversations',\n archived: 'Archived',\n current: 'Current',\n noMatch: (query: string) => `No conversation matches “${query}”.`,\n noArchived: 'No archived conversations.',\n noConversations: 'No conversations yet.',\n loadingConversations: 'Loading conversations…',\n actionFailed: 'That did not work. Try again.',\n rename: 'Rename',\n renameItem: (title: string) => `Rename ${title}`,\n conversationName: 'Conversation name',\n save: 'Save',\n archive: 'Archive',\n restore: 'Restore',\n archiveItem: (title: string, archived: boolean) => `${archived ? 'Restore' : 'Archive'} ${title}`,\n delete: 'Delete',\n deleteItem: (title: string) => `Delete ${title}`,\n keep: 'Keep conversation',\n today: 'Today',\n yesterday: 'Yesterday',\n previousWeek: 'Previous 7 days',\n older: 'Older',\n conversations: 'Conversations',\n earlierMessages: 'Earlier messages',\n readOnly: '(read-only)',\n retry: 'Retry',\n loadingShort: 'Loading…',\n loadEarlier: 'Load earlier',\n loadEarlierMessages: 'Load earlier messages',\n earlierMessagesLoaded: (count: number) => `${count} earlier messages loaded`,\n returnToConversation: 'Return to active conversation',\n historyFailed: 'Conversation history could not be loaded. The previous page remains visible.',\n loadPage: 'Load a page to browse earlier messages.',\n noRecords: 'No records in this history page.',\n beginning: 'Start of conversation',\n justNow: 'now',\n roleSystem: 'System',\n roleToolResult: (tool: string, failed: boolean) => `Tool result: ${tool}${failed ? ' (failed)' : ''}`,\n roleUser: 'You',\n roleAssistant: (outcome: 'ok' | 'interrupted' | 'failed'): string => outcome === 'interrupted' ? 'Assistant (interrupted)' : outcome === 'failed' ? 'Assistant (failed)' : 'Assistant',\n roleEvent: 'Event',\n contextBoundary: 'Context boundary',\n // Messages\n copy: 'Copy',\n copied: 'Copied',\n copyFailed: 'Copy failed',\n failed: 'Failed',\n copyResponse: 'Copy response',\n copyCode: 'Copy code',\n fork: 'Fork from here',\n forkHint: 'Fork from here: a new conversation with the messages up to this reply',\n responseFailed: 'The response failed',\n interrupted: 'Response interrupted',\n answeredBy: (model: string) => `answered by ${model}`,\n answeredFallback: (reason: string) => `fallback: ${reason}`,\n thinking: 'Thinking…',\n openImage: 'Open image attachment',\n imageAttachment: 'Image attachment',\n imagePosition: (index: number, count: number) => `Image ${index} of ${count}`,\n previousImage: 'Previous image',\n nextImage: 'Next image',\n systemContext: 'System context',\n toolFailed: (tool: string) => `${tool} failed`,\n event: (label: string) => `Event: ${label}`,\n // Activity and tools\n reasoning: 'Reasoning',\n thinkingStep: 'Thinking',\n steps: (count: number) => `${count} ${count === 1 ? 'step' : 'steps'}`,\n toolRunning: 'Running',\n toolCompleted: 'Completed',\n toolFailedStatus: 'Failed',\n toolUnfinished: 'No result',\n arguments: 'Arguments',\n result: 'Result',\n error: 'Error',\n openImageResult: 'Open image result',\n emptyResult: 'Empty result',\n /** The live line of a running tool call; `hint` is what it acts on (a path, a command), possibly empty. */\n toolActivity: (tool: string, hint: string) => {\n const verb = TOOL_VERBS[tool] ?? `Running ${tool}`;\n return hint ? `${verb}${tool === 'bash' ? ':' : ''} ${hint}` : verb;\n },\n /** How a tool is named in a finished activity summary (\"Used read ×3 · command\"). */\n toolNoun: (tool: string) => TOOL_NOUNS[tool] ?? tool,\n // Approval and question cards\n approvalNeeded: 'Approval needed',\n needsApproval: 'Needs your approval',\n approve: 'Approve',\n deny: 'Deny',\n approved: 'Approved',\n denied: 'Denied',\n notDecided: 'Not decided: the run was stopped',\n wantsToRun: 'The agent wants to run',\n decision: 'Decision',\n stillWritingCall: 'The agent is still writing this call…',\n decidingUnavailable: 'Deciding is not available in this view.',\n decisionSent: (approved: boolean) => `${approved ? 'Approved.' : 'Denied.'} Waiting for the agent…`,\n decisionRefused: (kind: 'denied' | 'conflict' | 'unknown-question'): string => kind === 'denied' ? 'The host did not accept this answer.' : kind === 'conflict' ? 'This call was already decided.' : 'This call is no longer waiting for a decision.',\n decisionNotSent: 'The decision could not be sent.',\n waitingForAgent: 'Waiting for the agent…',\n question: 'Question',\n questionForYou: 'Question for you',\n notAnswered: 'Not answered',\n answered: 'Answered',\n askedQuestion: 'The agent asked a question.',\n answerOptions: 'Answer options',\n ownAnswer: 'Or type your own answer',\n typeAnswer: 'Type your answer',\n yourAnswerInput: 'Your answer',\n /** A question of a form left blank, in the answer sent to the agent. */\n skipped: '(no answer)',\n answer: 'Answer',\n stillWritingQuestion: 'The agent is still writing this question…',\n answeringUnavailable: 'Answering is not available in this view.',\n answerRefused: (kind: 'denied' | 'conflict' | 'unknown-question'): string => kind === 'denied' ? 'The host did not accept this answer.' : kind === 'conflict' ? 'This question was already answered.' : 'This question is no longer waiting for an answer.',\n answerNotSent: 'The answer could not be sent.',\n questionNotAnswered: 'The question was not answered.',\n yourAnswer: 'Your answer:',\n // Run card (a headless run: `runs/<id>`)\n run: 'Run',\n runStatus: (status: 'queued' | 'running' | 'completed' | 'failed'): string => ({ queued: 'Queued', running: 'Running', completed: 'Completed', failed: 'Failed' })[status],\n runElapsed: (time: string) => `Running for ${time}`,\n runDuration: (time: string) => `Took ${time}`,\n runRefresh: 'Refresh',\n runNotLoaded: 'The run could not be loaded.',\n // Sub-agent card (a `delegate` call)\n subagentStatus: (status: 'running' | 'done' | 'failed' | 'cancelled'): string => ({ running: 'Running', done: 'Done', failed: 'Failed', cancelled: 'Stopped' })[status],\n subagentTime: 'Time',\n subagentToolCalls: (count: number) => `${count} tool ${count === 1 ? 'call' : 'calls'}`,\n subagentTokens: (input: number, output: number) => `${input} tokens in, ${output} tokens out`,\n subagentConversation: (id: number) => `Open conversation ${id}`,\n // Artifact cards\n artifact: 'Artifact',\n artifactTypes: { markdown: 'Document', html: 'HTML page', svg: 'SVG image', code: 'Code', canvas: 'Canvas' },\n opening: 'Opening…',\n viewing: 'Viewing',\n version: (ordinal: number) => `Version ${ordinal}`,\n runCommand: (name: string, execute: boolean) => `${execute ? 'Run' : 'Insert'} /${name} command`,\n taskStatus: 'Task status',\n activityFailed: (count: number, tools: string) => `${count > 1 ? `${count} steps failed` : 'Step failed'}: ${tools}`,\n activityUsed: (summary: string, stopped: boolean) => `${stopped ? 'Stopped · ' : 'Used '}${summary}`,\n activityThought: 'Thought it through',\n activityMany: (steps: number) => `Worked through ${steps} steps`,\n open: 'Open',\n openArtifact: (title: string, version: number | undefined) => version === undefined ? `Open ${title}` : `Open ${title}, version ${version}`,\n};\n\nexport type ChatLabels = typeof defaultChatLabels;\n\n/** The chat's icons. Any component that takes `className` (every lucide icon does). */\nexport const defaultChatIcons = {\n /** The agent's glyph in the empty state (the header shows the host's `avatar`, if any). */\n agent: MessagesSquareIcon as BlockIcon,\n send: ArrowUpIcon as BlockIcon,\n stop: SquareIcon as BlockIcon,\n history: HistoryIcon as BlockIcon,\n newChat: PlusIcon as BlockIcon,\n add: PlusIcon as BlockIcon,\n attach: PaperclipIcon as BlockIcon,\n camera: CameraIcon as BlockIcon,\n queue: ListEndIcon as BlockIcon,\n jumpToLatest: ArrowDownIcon as BlockIcon,\n reconnect: RefreshCwIcon as BlockIcon,\n fork: GitForkIcon as BlockIcon,\n};\n\nexport type ChatIcons = typeof defaultChatIcons;\n\n/** What the chat's pieces read: the merged labels and icons. */\nexport interface ChatText { readonly labels: ChatLabels; readonly icons: ChatIcons }\n/** The defaults, as `useMergedText`'s base. */\nexport const defaultChatText: ChatText = { labels: defaultChatLabels, icons: defaultChatIcons };\nconst DEFAULT_TEXT = defaultChatText;\nconst ChatTextContext = createContext<ChatText>(DEFAULT_TEXT);\n\n/** The labels and icons of the surrounding chat (the defaults outside one). */\nexport const useChatText = (): ChatText => useContext(ChatTextContext);\n\n/** Merges the host's partial labels and icons over the defaults (or over `base`), keeping one object while they do not change. */\nexport function useMergedText<L extends ChatLabels = ChatLabels, I extends ChatIcons = ChatIcons>(labels: Partial<L> | undefined, icons: Partial<I> | undefined,\n base: { readonly labels: L; readonly icons: I } = DEFAULT_TEXT as { readonly labels: L; readonly icons: I }): { readonly labels: L; readonly icons: I } {\n const stableLabels = useShallowStable(labels), stableIcons = useShallowStable(icons);\n return useMemo(() => ({ labels: withDefaults(base.labels, stableLabels), icons: withDefaults(base.icons, stableIcons) }), [stableLabels, stableIcons, base]);\n}\n\nconst shallowEqual = (a: unknown, b: unknown): boolean => Object.is(a, b) || (!!a && !!b && typeof a === 'object' && typeof b === 'object' && Array.isArray(a) === Array.isArray(b)\n && Object.keys(a).length === Object.keys(b).length && Object.keys(a).every(key => Object.is((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])));\n/** The previous value while the new one is equal key by key (functions by identity): inline `labels={{ title }}` keeps one text context. */\nexport function useShallowStable<T>(value: T): T {\n const previous = useRef(value);\n // eslint-disable-next-line react-hooks/refs -- a render-time identity cache: it only swaps in a value equal key by key, so any render reads the same result\n if (!shallowEqual(previous.current, value)) previous.current = value;\n // eslint-disable-next-line react-hooks/refs -- returns the cached identity (see above); never setState in render\n return previous.current;\n}\n\n/** Provides the chat's labels and icons to everything inside (composer, queue, rows, history). */\nexport function ChatTextProvider({ value, children }: { readonly value: ChatText; readonly children: ReactNode }) {\n return createElement(ChatTextContext.Provider, { value }, children);\n}\n",
|
|
147
|
+
"content": "/*\n * Every word the chat shows, and the icons it draws, as plain data. A host passes a partial `labels` (and `icons`) object to `PiChat`\n * (or `AmbientChat`, `AgentWorkspace`): what it leaves out keeps the default below. Strings that need a value are functions. No i18n\n * framework: an app that translates passes its own object per language.\n */\nimport { createContext, createElement, useContext, useMemo, useRef } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowDownIcon, ArrowUpIcon, CameraIcon, GitForkIcon, HistoryIcon, ListEndIcon, MessagesSquareIcon, PaperclipIcon, PlusIcon, RefreshCwIcon, SquareIcon } from '../_shared/icons';\nimport type { BlockIcon } from '../button/actions';\nimport { withDefaults } from '../utils/utils';\n\n// An Intl.DateTimeFormat costs far more than formatting with one; every bubble's clock reuses the same few.\nconst formats = new Map<string, Intl.DateTimeFormat>();\nfunction intlFormat(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {\n const key = `${locale}|${JSON.stringify(options)}`;\n let found = formats.get(key);\n if (!found) { found = new Intl.DateTimeFormat(locale, options); formats.set(key, found); }\n return found;\n}\n/** « Tue 22 Oct, 14:02 ». */\nexport function formatTimestamp(at: string, locale: string) {\n const date = new Date(at);\n if (!Number.isFinite(date.getTime())) return at;\n return `${intlFormat(locale, { weekday: 'short', day: 'numeric', month: 'short' }).format(date)}, ${intlFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(date)}`;\n}\n/** The time inside a bubble (« 14:02 », « 2:02 PM »). */\nexport function formatClock(at: string, locale: string) {\n const date = new Date(at);\n return Number.isFinite(date.getTime()) ? intlFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(date) : '';\n}\n\nconst TOOL_VERBS: Readonly<Record<string, string>> = { read: 'Reading', write: 'Writing', edit: 'Editing', ls: 'Listing', list_files: 'Listing', find: 'Searching', grep: 'Searching', load_skill: 'Loading skill', bash: 'Running bash' };\nconst TOOL_NOUNS: Readonly<Record<string, string>> = { bash: 'command', read: 'read', write: 'write', edit: 'edit', find: 'find', grep: 'search', ls: 'list' };\n\nconst EFFORT_NAMES: Readonly<Record<string, string>> = { off: 'Off', minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', max: 'Max' };\nconst EFFORT_DETAILS: Readonly<Record<string, string>> = { off: 'No extra reasoning', minimal: 'Barely any reasoning', low: 'Light reasoning', medium: 'Balanced reasoning', high: 'Deep reasoning', xhigh: 'Deeper reasoning', max: 'Maximum reasoning' };\n\nexport const defaultChatLabels = {\n // Header and transcript\n /** The chat's name: the header title, the region's name and the empty state's title. */\n title: 'Conversation',\n history: 'History',\n artifacts: 'Artifacts',\n search: 'Search',\n live: 'Live',\n connecting: 'Connecting…',\n reconnecting: 'Reconnecting…',\n loadFailed: 'Conversation could not be loaded.',\n disconnected: 'Disconnected. Last observed messages remain visible.',\n reconnect: 'Reconnect',\n requiredActions: 'Required conversation actions',\n messages: 'Messages',\n showEarlier: 'Show earlier messages',\n loading: 'Loading conversation…',\n emptyDescription: 'Start a conversation.',\n jumpToLatest: 'Jump to latest',\n agentWorking: 'Agent is working',\n waitingForAnswer: 'Waiting for your answer',\n retryPending: 'Native retry pending',\n compacting: 'Native context compaction in progress',\n /** The one status of `presentation=\"natural\"` while the agent works (any tool, retry or compaction), instead of the activity wording above. */\n preparing: 'Preparing…',\n /** In `presentation=\"natural\"`, replaces the controller's own reason when a send is blocked. */\n sendUnavailable: 'Sending is not available right now.',\n /** The \"…\" menu of host actions (header, replies). */\n moreActions: 'More actions',\n // Notices\n sendUnknown: 'Submission acknowledgement is unknown. The message is kept until you check or retry it.',\n checkSubmission: 'Check original submission',\n retrySame: 'Retry same request',\n stopRequested: 'Stop requested. Waiting for native confirmation.',\n stopUnconfirmed: 'Stop is unconfirmed. Work may still be running.',\n newChatFailed: \"The new conversation wasn't made. You are still in this one.\",\n newChatFailedTyped: \"The new conversation wasn't made, and what you typed wasn't kept. You are still in this one.\",\n steerRefused: 'The message could not be steered; it is back in the message box.',\n tooLate: 'Too late: that message has already started.',\n tooLateToRemove: 'Too late to remove: that message has already started.',\n queueFailed: 'The message could not be changed.',\n queueLocked: 'Queued messages cannot be changed here.',\n waitToSteer: 'Wait for the current send to finish, then steer.',\n steerFailed: 'The message could not be steered.',\n connectionFailed: 'Connection failed',\n actionFailedShort: 'Action failed',\n uploadFailedShort: 'Upload failed',\n changeRefused: 'The change was refused',\n dismissHint: 'click to dismiss',\n // Composer\n placeholder: 'Message the agent…',\n placeholderWorking: 'Queue a message…',\n messageInput: 'Message',\n send: 'Send',\n sendHint: 'Send (Enter)',\n stop: 'Stop',\n queue: 'Queue',\n add: 'Add',\n attachFiles: 'Attach files',\n takePhoto: 'Take a photo',\n cameraShoot: 'Take the photo',\n cameraClose: 'Close the camera',\n cameraUnavailable: 'The camera cannot open on this page.',\n cameraFallback: \"Use the phone's camera\",\n commands: 'Commands',\n annotate: 'Annotate',\n annotateHint: 'Annotate: click what you want to comment on (Esc to stop)',\n annotateNotes: (count: number) => `${count} ${count === 1 ? 'note' : 'notes'}`,\n annotateClear: 'Remove the notes',\n feedbackSent: (count: number) => `${count} ${count === 1 ? 'comment' : 'comments'} on the page`,\n remove: (name: string) => `Remove ${name}`,\n dismiss: (name: string) => `Dismiss ${name}`,\n uploading: (name: string) => `Uploading ${name}…`,\n uploadFailed: (name: string, error: string | undefined) => `${name}: ${error ?? 'upload failed'}`,\n preparingAttachments: 'Preparing attachments…',\n preparingAttachmentsShort: 'Preparing attachments',\n mentionedFiles: 'Mentioned files',\n uploads: 'Uploads',\n attachments: 'Attachments',\n /** The type line of a file card in a sent message. */\n fileKind: { document: 'Document', image: 'Image', pdf: 'PDF', sheet: 'Spreadsheet', slides: 'Presentation', audio: 'Audio', file: 'File' },\n openFile: (name: string) => `Open ${name}`,\n fileChange: { created: 'Created', edited: 'Edited' },\n /** The name of a pasted text file: the words and the time it was pasted. */\n pastedFileName: (stamp: string) => `Pasted text ${stamp}`,\n restorePaste: 'Put the pasted text back in the message',\n searchCommands: 'Search commands',\n filterBySource: 'Filter by source',\n allSources: 'All',\n noCommands: 'No matching commands.',\n /** A `/` menu filter chip: `skills`, `built-in` or a plugin's name. */\n slashGroup: (group: string) => group,\n skillBadge: 'skill',\n workspaceFiles: 'Workspace files',\n searchFailed: 'Search failed. Try again.',\n model: 'Model',\n effort: 'Effort',\n effortDefault: 'Default',\n modelAndEffort: 'Model and effort',\n modelSummary: (model: string | undefined, effort: string | undefined) => [model && `Model: ${model}`, effort && `Effort: ${effort}`].filter(Boolean).join(', '),\n /** A thinking level's name and short description in the picker. */\n effortName: (level: string) => EFFORT_NAMES[level] ?? level,\n effortDetail: (level: string): string | undefined => EFFORT_DETAILS[level],\n // Voice input (the microphone button beside the composer)\n voiceRecord: 'Record a message',\n voiceHold: 'Hold to record, release to send',\n voicePauseRecording: 'Pause recording',\n voiceResumeRecording: 'Resume recording',\n voicePaused: 'Recording paused',\n voiceRecording: 'Recording',\n voicePreparing: 'Preparing voice message…',\n voiceAudioOnly: 'Send audio only',\n voiceDiscard: 'Discard',\n voicePlay: 'Play voice message',\n voicePause: 'Pause voice message',\n voicePlayFailed: 'The audio could not be played. Try again.',\n voiceBlocked: 'Not sent. Review the chat notice before trying again.',\n voiceUnconfirmed: 'Message admission is unconfirmed. Use the chat notice to reconcile.',\n voiceStop: 'Stop recording',\n voiceCancel: 'Cancel recording',\n voiceTranscribing: 'Transcribing…',\n voiceNothingHeard: 'Nothing was heard. Try again.',\n voiceTranscribeFailed: 'The recording could not be transcribed. Try again.',\n voiceTranscriptUnavailable: 'Voice message — transcription unavailable.',\n voiceDenied: 'Microphone blocked. Allow it for this site in the browser, then press record again.',\n voiceNoMicrophone: 'No microphone found. Connect one, then press record again.',\n voiceBusy: 'The microphone is busy or could not be opened. Close other apps using it and try again.',\n voiceInsecure: 'Recording needs a secure page (https or localhost).',\n voiceUnsupported: 'This browser cannot record audio here.',\n voiceStartFailed: 'The microphone could not be started.',\n // Queue\n queuedMessages: 'Queued messages',\n steer: 'Steer',\n steerHint: 'Send this into the running turn now',\n steering: 'Steering',\n queued: 'Queued',\n removeQueued: 'Remove queued message',\n removeHint: 'Remove',\n more: 'More',\n edit: 'Edit',\n sending: 'Sending',\n /** Under a message whose send was not confirmed; the notice below offers Check and Retry. */\n notSent: 'Not sent',\n /** The time inside a bubble in the `messaging` style (« 14:02 »), and its full date as the tooltip. */\n messageClock: (at: string) => formatClock(at, 'en'),\n messageTimestamp: (at: string) => formatTimestamp(at, 'en'),\n images: (count: number) => `[${count} image${count === 1 ? '' : 's'}]`,\n // Conversation list and history\n historyDialog: 'Conversation history',\n newConversation: 'New',\n untitled: 'New conversation',\n closeHistory: 'Close history',\n close: 'Close',\n earlierInConversation: 'Earlier messages in this conversation',\n searchConversations: 'Search conversations',\n archived: 'Archived',\n current: 'Current',\n noMatch: (query: string) => `No conversation matches “${query}”.`,\n noArchived: 'No archived conversations.',\n noConversations: 'No conversations yet.',\n loadingConversations: 'Loading conversations…',\n actionFailed: 'That did not work. Try again.',\n rename: 'Rename',\n renameItem: (title: string) => `Rename ${title}`,\n conversationName: 'Conversation name',\n save: 'Save',\n archive: 'Archive',\n restore: 'Restore',\n archiveItem: (title: string, archived: boolean) => `${archived ? 'Restore' : 'Archive'} ${title}`,\n delete: 'Delete',\n deleteItem: (title: string) => `Delete ${title}`,\n keep: 'Keep conversation',\n today: 'Today',\n yesterday: 'Yesterday',\n previousWeek: 'Previous 7 days',\n older: 'Older',\n conversations: 'Conversations',\n earlierMessages: 'Earlier messages',\n readOnly: '(read-only)',\n retry: 'Retry',\n loadingShort: 'Loading…',\n loadEarlier: 'Load earlier',\n loadEarlierMessages: 'Load earlier messages',\n earlierMessagesLoaded: (count: number) => `${count} earlier messages loaded`,\n returnToConversation: 'Return to active conversation',\n historyFailed: 'Conversation history could not be loaded. The previous page remains visible.',\n loadPage: 'Load a page to browse earlier messages.',\n noRecords: 'No records in this history page.',\n beginning: 'Start of conversation',\n justNow: 'now',\n roleSystem: 'System',\n roleToolResult: (tool: string, failed: boolean) => `Tool result: ${tool}${failed ? ' (failed)' : ''}`,\n roleUser: 'You',\n roleAssistant: (outcome: 'ok' | 'interrupted' | 'failed'): string => outcome === 'interrupted' ? 'Assistant (interrupted)' : outcome === 'failed' ? 'Assistant (failed)' : 'Assistant',\n roleEvent: 'Event',\n contextBoundary: 'Context boundary',\n // Messages\n copy: 'Copy',\n copied: 'Copied',\n copyFailed: 'Copy failed',\n failed: 'Failed',\n copyResponse: 'Copy response',\n copyCode: 'Copy code',\n fork: 'Fork from here',\n forkHint: 'Fork from here: a new conversation with the messages up to this reply',\n responseFailed: 'The response failed',\n interrupted: 'Response interrupted',\n answeredBy: (model: string) => `answered by ${model}`,\n answeredFallback: (reason: string) => `fallback: ${reason}`,\n thinking: 'Thinking…',\n openImage: 'Open image attachment',\n imageAttachment: 'Image attachment',\n imagePosition: (index: number, count: number) => `Image ${index} of ${count}`,\n previousImage: 'Previous image',\n nextImage: 'Next image',\n systemContext: 'System context',\n toolFailed: (tool: string) => `${tool} failed`,\n event: (label: string) => `Event: ${label}`,\n // Activity and tools\n reasoning: 'Reasoning',\n thinkingStep: 'Thinking',\n steps: (count: number) => `${count} ${count === 1 ? 'step' : 'steps'}`,\n toolRunning: 'Running',\n toolCompleted: 'Completed',\n toolFailedStatus: 'Failed',\n toolUnfinished: 'No result',\n arguments: 'Arguments',\n result: 'Result',\n error: 'Error',\n openImageResult: 'Open image result',\n emptyResult: 'Empty result',\n /** The live line of a running tool call; `hint` is what it acts on (a path, a command), possibly empty. */\n toolActivity: (tool: string, hint: string) => {\n const verb = TOOL_VERBS[tool] ?? `Running ${tool}`;\n return hint ? `${verb}${tool === 'bash' ? ':' : ''} ${hint}` : verb;\n },\n /** How a tool is named in a finished activity summary (\"Used read ×3 · command\"). */\n toolNoun: (tool: string) => TOOL_NOUNS[tool] ?? tool,\n // Approval and question cards\n approvalNeeded: 'Approval needed',\n needsApproval: 'Needs your approval',\n approve: 'Approve',\n deny: 'Deny',\n approved: 'Approved',\n denied: 'Denied',\n notDecided: 'Not decided: the run was stopped',\n wantsToRun: 'The agent wants to run',\n decision: 'Decision',\n stillWritingCall: 'The agent is still writing this call…',\n decidingUnavailable: 'Deciding is not available in this view.',\n decisionSent: (approved: boolean) => `${approved ? 'Approved.' : 'Denied.'} Waiting for the agent…`,\n decisionRefused: (kind: 'denied' | 'conflict' | 'unknown-question'): string => kind === 'denied' ? 'The host did not accept this answer.' : kind === 'conflict' ? 'This call was already decided.' : 'This call is no longer waiting for a decision.',\n decisionNotSent: 'The decision could not be sent.',\n waitingForAgent: 'Waiting for the agent…',\n question: 'Question',\n questionForYou: 'Question for you',\n notAnswered: 'Not answered',\n answered: 'Answered',\n askedQuestion: 'The agent asked a question.',\n answerOptions: 'Answer options',\n ownAnswer: 'Or type your own answer',\n typeAnswer: 'Type your answer',\n yourAnswerInput: 'Your answer',\n /** A question of a form left blank, in the answer sent to the agent. */\n skipped: '(no answer)',\n answer: 'Answer',\n stillWritingQuestion: 'The agent is still writing this question…',\n answeringUnavailable: 'Answering is not available in this view.',\n answerRefused: (kind: 'denied' | 'conflict' | 'unknown-question'): string => kind === 'denied' ? 'The host did not accept this answer.' : kind === 'conflict' ? 'This question was already answered.' : 'This question is no longer waiting for an answer.',\n answerNotSent: 'The answer could not be sent.',\n questionNotAnswered: 'The question was not answered.',\n yourAnswer: 'Your answer:',\n // Run card (a headless run: `runs/<id>`)\n run: 'Run',\n runStatus: (status: 'queued' | 'running' | 'completed' | 'failed'): string => ({ queued: 'Queued', running: 'Running', completed: 'Completed', failed: 'Failed' })[status],\n runElapsed: (time: string) => `Running for ${time}`,\n runDuration: (time: string) => `Took ${time}`,\n runRefresh: 'Refresh',\n runNotLoaded: 'The run could not be loaded.',\n // Sub-agent card (a `delegate` call)\n subagentStatus: (status: 'running' | 'done' | 'failed' | 'cancelled'): string => ({ running: 'Running', done: 'Done', failed: 'Failed', cancelled: 'Stopped' })[status],\n subagentTime: 'Time',\n subagentToolCalls: (count: number) => `${count} tool ${count === 1 ? 'call' : 'calls'}`,\n subagentTokens: (input: number, output: number) => `${input} tokens in, ${output} tokens out`,\n subagentConversation: (id: number) => `Open conversation ${id}`,\n // Artifact cards\n artifact: 'Artifact',\n artifactTypes: { markdown: 'Document', html: 'HTML page', svg: 'SVG image', code: 'Code', canvas: 'Canvas' },\n opening: 'Opening…',\n viewing: 'Viewing',\n version: (ordinal: number) => `Version ${ordinal}`,\n runCommand: (name: string, execute: boolean) => `${execute ? 'Run' : 'Insert'} /${name} command`,\n taskStatus: 'Task status',\n activityFailed: (count: number, tools: string) => `${count > 1 ? `${count} steps failed` : 'Step failed'}: ${tools}`,\n activityUsed: (summary: string, stopped: boolean) => `${stopped ? 'Stopped · ' : 'Used '}${summary}`,\n activityThought: 'Thought it through',\n activityMany: (steps: number) => `Worked through ${steps} steps`,\n open: 'Open',\n openArtifact: (title: string, version: number | undefined) => version === undefined ? `Open ${title}` : `Open ${title}, version ${version}`,\n};\n\nexport type ChatLabels = typeof defaultChatLabels;\n\n/** The chat's icons. Any component that takes `className` (every lucide icon does). */\nexport const defaultChatIcons = {\n /** The agent's glyph in the empty state (the header shows the host's `avatar`, if any). */\n agent: MessagesSquareIcon as BlockIcon,\n send: ArrowUpIcon as BlockIcon,\n stop: SquareIcon as BlockIcon,\n history: HistoryIcon as BlockIcon,\n newChat: PlusIcon as BlockIcon,\n add: PlusIcon as BlockIcon,\n attach: PaperclipIcon as BlockIcon,\n camera: CameraIcon as BlockIcon,\n queue: ListEndIcon as BlockIcon,\n jumpToLatest: ArrowDownIcon as BlockIcon,\n reconnect: RefreshCwIcon as BlockIcon,\n fork: GitForkIcon as BlockIcon,\n};\n\nexport type ChatIcons = typeof defaultChatIcons;\n\n/** What the chat's pieces read: the merged labels and icons. */\nexport interface ChatText { readonly labels: ChatLabels; readonly icons: ChatIcons }\n/** The defaults, as `useMergedText`'s base. */\nexport const defaultChatText: ChatText = { labels: defaultChatLabels, icons: defaultChatIcons };\nconst DEFAULT_TEXT = defaultChatText;\nconst ChatTextContext = createContext<ChatText>(DEFAULT_TEXT);\n\n/** The labels and icons of the surrounding chat (the defaults outside one). */\nexport const useChatText = (): ChatText => useContext(ChatTextContext);\n\n/** Merges the host's partial labels and icons over the defaults (or over `base`), keeping one object while they do not change. */\nexport function useMergedText<L extends ChatLabels = ChatLabels, I extends ChatIcons = ChatIcons>(labels: Partial<L> | undefined, icons: Partial<I> | undefined,\n base: { readonly labels: L; readonly icons: I } = DEFAULT_TEXT as { readonly labels: L; readonly icons: I }): { readonly labels: L; readonly icons: I } {\n const stableLabels = useShallowStable(labels), stableIcons = useShallowStable(icons);\n return useMemo(() => ({ labels: withDefaults(base.labels, stableLabels), icons: withDefaults(base.icons, stableIcons) }), [stableLabels, stableIcons, base]);\n}\n\nconst shallowEqual = (a: unknown, b: unknown): boolean => Object.is(a, b) || (!!a && !!b && typeof a === 'object' && typeof b === 'object' && Array.isArray(a) === Array.isArray(b)\n && Object.keys(a).length === Object.keys(b).length && Object.keys(a).every(key => Object.is((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])));\n/** The previous value while the new one is equal key by key (functions by identity): inline `labels={{ title }}` keeps one text context. */\nexport function useShallowStable<T>(value: T): T {\n const previous = useRef(value);\n // eslint-disable-next-line react-hooks/refs -- a render-time identity cache: it only swaps in a value equal key by key, so any render reads the same result\n if (!shallowEqual(previous.current, value)) previous.current = value;\n // eslint-disable-next-line react-hooks/refs -- returns the cached identity (see above); never setState in render\n return previous.current;\n}\n\n/** Provides the chat's labels and icons to everything inside (composer, queue, rows, history). */\nexport function ChatTextProvider({ value, children }: { readonly value: ChatText; readonly children: ReactNode }) {\n return createElement(ChatTextContext.Provider, { value }, children);\n}\n",
|
|
148
148
|
"type": "registry:lib",
|
|
149
149
|
"target": "components/pi-chat/labels.ts"
|
|
150
150
|
},
|