@hachej/boring-ui-kit 0.3.2 → 0.4.0
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/README.md +46 -18
- package/THIRD_PARTY_NOTICES.md +35 -0
- package/dist/canvas-editor-native.d.ts +4 -1
- package/dist/canvas-editor-native.js +8 -6
- package/dist/canvas-editor.js +1 -1
- package/dist/canvas-proposals.d.ts +3 -1
- package/dist/canvas-proposals.js +4 -3
- package/dist/experience-document-viewer.d.ts +3 -0
- package/dist/experience-document-viewer.js +6 -5
- package/dist/experience.d.ts +3 -0
- package/dist/experience.js +2 -1
- package/dist/file-tree-view.d.ts +76 -3
- package/dist/file-tree-view.js +172 -39
- package/dist/file-tree.d.ts +3 -0
- package/dist/file-tree.js +17 -2
- package/dist/html-viewer.d.ts +4 -0
- package/dist/html-viewer.js +5 -3
- package/dist/markdown-autosave.d.ts +13 -0
- package/dist/markdown-autosave.js +94 -0
- package/dist/markdown-editor.d.ts +16 -0
- package/dist/markdown-editor.js +81 -17
- package/dist/markdown-extensions.js +9 -1
- package/dist/markdown-proposal-diff.d.ts +2 -1
- package/dist/markdown-proposal-diff.js +3 -2
- package/dist/markdown-safety.js +3 -1
- package/dist/native-chat.js +1 -1
- package/dist/remote-chat.js +5 -1
- package/dist/text-buffer.js +3 -2
- package/dist/text-draft-controls.d.ts +3 -1
- package/dist/text-draft-controls.js +3 -2
- package/dist/viewer-components.d.ts +14 -0
- package/dist/viewer-components.js +1 -0
- package/package.json +4 -4
- package/r/app-shell.json +39 -20
- package/r/button.json +19 -11
- package/r/dock.json +2 -2
- package/r/documents.json +17 -14
- package/r/feedback-preview.json +8 -2
- package/r/feedback.json +46 -325
- package/r/file-tree.json +14 -6
- package/r/html-viewer.json +8 -42
- package/r/markdown-editor.json +38 -87
- package/r/pi-ambient.json +15 -8
- package/r/pi-app.json +29 -18
- package/r/pi-chat.json +88 -69
- package/r/pi-workspace.json +7 -5
- package/r/provider-setup.json +9 -4
- package/r/registry.json +460 -888
- package/r/task-list-viewer.json +11 -21
- package/r/theme.json +23 -4
- package/r/version-history.json +24 -0
- package/r/viewers.json +139 -320
- package/styles/file-tree.css +41 -42
- package/dist/file-tree-shadcn.d.ts +0 -8
- package/dist/file-tree-shadcn.js +0 -14
package/r/pi-chat.json
CHANGED
|
@@ -4,39 +4,68 @@
|
|
|
4
4
|
"title": "Pi chat",
|
|
5
5
|
"description": "A complete Tailwind chat over the borrowed native chat controller: composer, messages, one collapsible activity block per turn (tool calls and reasoning), message queue, history, a human-in-the-loop question card, an Approve / Deny card for host tools wrapped with requireApproval optional artifact cards that open generated content in the host's panel, and feedback cards for the opt-in feedback tool (lists, and Show offers that the host page resolves). Chat only: no layout. Owned, editable source; no AI SDK. The optional pi-ambient and pi-workspace blocks build on it.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.
|
|
8
|
-
"@hachej/boring-files@^0.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.0",
|
|
8
|
+
"@hachej/boring-files@^0.4.0",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"marked@^17.0.6",
|
|
12
12
|
"entities@^8.1.0",
|
|
13
|
+
"@base-ui/react@1.9.0",
|
|
13
14
|
"@earendil-works/pi-durable@~1.2.0",
|
|
14
15
|
"@earendil-works/chord@~1.2.0",
|
|
15
16
|
"@earendil-works/pi-ai@~1.2.0",
|
|
16
|
-
"@modelcontextprotocol/sdk@^1.32.
|
|
17
|
+
"@modelcontextprotocol/sdk@^1.32.1",
|
|
17
18
|
"lucide-react@^1.51.0",
|
|
18
|
-
"
|
|
19
|
+
"@shadcn/react@0.3.1"
|
|
19
20
|
],
|
|
20
21
|
"registryDependencies": [
|
|
21
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.
|
|
22
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.
|
|
22
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.0/r/utils.json",
|
|
23
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.0/r/button.json",
|
|
24
|
+
"trace",
|
|
25
|
+
"message",
|
|
26
|
+
"message-scroller",
|
|
27
|
+
"bubble",
|
|
28
|
+
"attachment",
|
|
29
|
+
"badge",
|
|
30
|
+
"alert",
|
|
31
|
+
"checkbox",
|
|
32
|
+
"table",
|
|
33
|
+
"card",
|
|
34
|
+
"spinner",
|
|
35
|
+
"collapsible",
|
|
36
|
+
"drawer",
|
|
37
|
+
"button",
|
|
38
|
+
"toggle",
|
|
39
|
+
"dropdown-menu",
|
|
40
|
+
"empty",
|
|
41
|
+
"input",
|
|
42
|
+
"input-group",
|
|
43
|
+
"popover",
|
|
44
|
+
"scroll-area",
|
|
45
|
+
"separator",
|
|
46
|
+
"sidebar",
|
|
47
|
+
"badge",
|
|
48
|
+
"item",
|
|
49
|
+
"drawer",
|
|
50
|
+
"alert",
|
|
51
|
+
"avatar"
|
|
23
52
|
],
|
|
24
53
|
"files": [
|
|
25
54
|
{
|
|
26
55
|
"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,
|
|
56
|
+
"content": "'use client';\n\nimport { useState } from 'react';\nimport type { ToolResultMessage } from '@earendil-works/pi-ai';\nimport { AlertCircleIcon, BrainIcon, CheckIcon, CircleSlashIcon } from 'lucide-react';\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-sm: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-sm: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
57
|
"type": "registry:component",
|
|
29
58
|
"target": "components/pi-chat/activity.tsx"
|
|
30
59
|
},
|
|
31
60
|
{
|
|
32
61
|
"path": "registry/pi-chat/artifact-card.tsx",
|
|
33
|
-
"content": "'use client';\n\nimport type { ComponentType } from 'react';\nimport { ChevronRightIcon, FileCodeIcon, FileTextIcon, GlobeIcon, ImageIcon, PenToolIcon } from 'lucide-react';\nimport {
|
|
62
|
+
"content": "'use client';\n\nimport type { ComponentType } from 'react';\nimport { ChevronRightIcon, FileCodeIcon, FileTextIcon, GlobeIcon, ImageIcon, PenToolIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Shimmer } from './shimmer';\nimport { artifactKey, typeLabel } from './artifact';\nimport type { ArtifactDescriptor, ArtifactType } from './artifact';\nimport { useChatText } from './labels';\n\nconst ICONS: Record<ArtifactType, ComponentType<{ readonly 'aria-hidden'?: boolean | 'true' }>> = {\n markdown: FileTextIcon, html: GlobeIcon, svg: ImageIcon, code: FileCodeIcon, canvas: PenToolIcon,\n};\n\n/**\n * An artifact in the transcript, as in Claude's chat apps: a compact card with a type icon, the title, the type and the\n * version. Clicking it asks the host to open the artifact. While the `present` call that makes it is still running it is a quiet\n * \"opening\" card with a shimmer. Rendered outside the activity block, like the question card.\n */\nexport function ArtifactCard({ artifact, pending, title, open = false, onOpen }: {\n readonly artifact?: ArtifactDescriptor | undefined;\n readonly pending?: 'presenting';\n readonly title?: string | undefined;\n readonly open?: boolean;\n readonly onOpen?: ((artifact: ArtifactDescriptor) => void) | undefined;\n}) {\n const { labels } = useChatText();\n if (!artifact) {\n return <Item variant=\"outline\" size=\"sm\" data-testid=\"artifact-card\" data-state={pending ?? 'presenting'} role=\"status\" aria-busy=\"true\" className=\"my-2 text-start\">\n <ItemMedia variant=\"icon\"><FileTextIcon aria-hidden=\"true\" /></ItemMedia>\n <ItemContent className=\"min-w-0 basis-0\">\n <ItemTitle data-testid=\"artifact-title\"><Shimmer>{title ?? labels.artifact}</Shimmer></ItemTitle>\n <ItemDescription><Shimmer>{labels.opening}</Shimmer></ItemDescription>\n </ItemContent>\n </Item>;\n }\n const Icon = ICONS[artifact.type];\n return <Button variant=\"outline\" disabled={!onOpen} onClick={() => onOpen?.(artifact)}\n data-testid=\"artifact-card\" data-state=\"ready\" data-artifact-id={artifactKey(artifact)} data-artifact-revision={artifact.revision} data-artifact-version={artifact.ordinal} data-artifact-type={artifact.type}\n data-open={open ? 'true' : undefined} aria-pressed={onOpen ? open : undefined} aria-label={labels.openArtifact(artifact.title, artifact.ordinal)}\n className=\"my-2 h-auto w-full max-w-md justify-start gap-2.5 text-left\">\n <Icon aria-hidden=\"true\" />\n <span className=\"flex min-w-0 flex-1 flex-col\">\n <span data-testid=\"artifact-title\" className=\"truncate\">{artifact.title}</span>\n <span className=\"truncate text-xs text-muted-foreground\"><span data-testid=\"artifact-type\">{typeLabel(artifact)}</span>{artifact.ordinal !== undefined && <> · <span data-testid=\"artifact-version\">{labels.version(artifact.ordinal)}</span></>}</span>\n </span>\n {open ? <Badge variant=\"secondary\" data-testid=\"artifact-hint\">{labels.open}</Badge> : <span data-testid=\"artifact-hint\" className=\"text-xs text-muted-foreground\">{labels.open}</span>}\n <ChevronRightIcon aria-hidden=\"true\" />\n </Button>;\n}\n",
|
|
34
63
|
"type": "registry:component",
|
|
35
64
|
"target": "components/pi-chat/artifact-card.tsx"
|
|
36
65
|
},
|
|
37
66
|
{
|
|
38
67
|
"path": "registry/pi-chat/approval-card.tsx",
|
|
39
|
-
"content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { CheckIcon, ShieldAlertIcon, XIcon } from 'lucide-react';\nimport { Button } from '
|
|
68
|
+
"content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { CheckIcon, ShieldAlertIcon, XIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemActions, ItemContent, ItemDescription, ItemFooter, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport type { AnswerOutcome } from './question-card';\nimport { useChatText } from './labels';\n\n// The strings `requireApproval` (`@hachej/boring-agent/approval`) asks with and puts at the start of a denial result.\nexport const APPROVE = 'Approve';\nexport const DENY = 'Deny';\nexport const DENIED_PREFIX = 'Denied by the person.';\n\n\n/** The arguments the call would run with, one line each. They come from the model's call itself: the card shows what will actually execute. */\nexport function describeArguments(call: ToolCall): { readonly name: string; readonly value: string }[] {\n return Object.entries(call.arguments as Record<string, unknown>).map(([name, value]) => {\n const text = typeof value === 'string' ? value : JSON.stringify(value) ?? '';\n return { name, value: text.length > 600 ? `${text.slice(0, 600)}…` : text };\n });\n}\n\n/**\n * The settled state. The wrapper records its decision in the call's details; a call that settled without one was never\n * decided (the run was stopped while it waited), so it is not shown as approved.\n */\nfunction decisionOf(result: ToolResultMessage | undefined, decision: 'approved' | 'denied' | 'cancelled' | undefined): 'pending' | 'denied' | 'approved' | 'cancelled' {\n if (!result) return 'pending';\n if (decision) return decision;\n const text = result.content.map(part => part.type === 'text' ? part.text : '').join('');\n return result.isError && text.startsWith(DENIED_PREFIX) ? 'denied' : 'cancelled';\n}\n\n/** Human approval for a gated tool call: pending (Approve and Deny), then approved or denied. Answering uses the host's `answer`. */\nexport function ApprovalCard({ call, result, answer, questionId, summary, decision, live = false }: {\n readonly call: ToolCall;\n /** The question ID bound to this call's assistant entry (`derive` provides it); answers use it, never the bare call ID. */\n readonly questionId?: string | undefined;\n /** The decision `requireApproval` recorded in the call's details (`derive` finds it). Without one, a settled call shows as not decided. */\n readonly decision?: 'approved' | 'denied' | 'cancelled' | undefined;\n /** The one-line summary `requireApproval` published for this call (`derive` finds it). Shown first; the raw arguments are then collapsed under it. */\n readonly summary?: string | undefined;\n readonly result: ToolResultMessage | undefined;\n readonly answer?: ((callId: string, answer: string) => Promise<AnswerOutcome>) | undefined;\n /** The call is still being generated by the model: it cannot be decided yet. */\n readonly live?: boolean;\n}) {\n const { labels } = useChatText();\n const [sent, setSent] = useState<string | undefined>();\n const [sending, setSending] = useState<string | undefined>();\n const [refusal, setRefusal] = useState<string | undefined>();\n const mounted = useRef(true);\n useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\n const settled = decisionOf(result, decision);\n // `pending` means the decision waits on the person; `writing` means the model is still generating the call.\n const state = settled !== 'pending' ? settled : sent === APPROVE ? 'approved-sent' : sent === DENY ? 'denied-sent' : live ? 'writing' : 'pending';\n const open = state === 'pending' || state === 'writing';\n const args = describeArguments(call);\n const busy = sending !== undefined || live;\n\n async function decide(value: string) {\n if (!answer || !questionId || busy) return;\n setRefusal(undefined); setSending(value);\n try {\n const outcome = await answer(questionId, value);\n if (outcome.kind === 'answered') setSent(value);\n else setRefusal(outcome.reason || labels.decisionRefused(outcome.kind));\n } catch (cause) { setRefusal(cause instanceof Error ? cause.message : labels.decisionNotSent); }\n finally { if (mounted.current) setSending(undefined); }\n }\n const approved = state === 'approved' || state === 'approved-sent', denied = state === 'denied' || state === 'denied-sent';\n\n const status = open ? labels.needsApproval : approved ? labels.approved : denied ? labels.denied : labels.notDecided;\n const unavailable = !answer || !questionId || busy;\n\n // shadcn's Confirmation recipe: an outline Item with the question and Approve/Deny actions; pending approvals are prompts, not activity.\n return <Item variant=\"outline\" size=\"sm\" render={<section />} data-testid=\"approval-card\" data-state={state} data-call-id={call.id} data-tool={call.name} aria-label={labels.approvalNeeded} className=\"my-2 text-start\">\n <ItemMedia variant=\"icon\">\n {approved ? <CheckIcon aria-hidden=\"true\" /> : denied ? <XIcon aria-hidden=\"true\" /> : <ShieldAlertIcon aria-hidden=\"true\" />}\n </ItemMedia>\n <ItemContent className=\"min-w-0 basis-0\">\n <ItemDescription className=\"line-clamp-none\"><Badge variant={open ? 'outline' : denied ? 'destructive' : 'secondary'}>{status}</Badge></ItemDescription>\n {summary && <ItemTitle data-testid=\"approval-summary\" className=\"line-clamp-none [overflow-wrap:anywhere]\">{summary}</ItemTitle>}\n <ItemDescription data-testid=\"approval-tool\" className=\"line-clamp-none [overflow-wrap:anywhere]\">{labels.wantsToRun} <code className=\"font-mono\">{call.name}</code></ItemDescription>\n {args.length > 0 && (() => {\n const list = <dl data-testid=\"approval-args\" className=\"m-0 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs\">\n {args.map(item => [<dt key={`${item.name}:n`} className=\"text-muted-foreground\">{item.name}</dt>, <dd key={`${item.name}:v`} className=\"m-0 font-mono whitespace-pre-wrap [overflow-wrap:anywhere]\">{item.value}</dd>])}\n </dl>;\n // With a summary the raw arguments are the detail behind it; without one they are all the person has to decide on.\n return summary ? <details data-testid=\"approval-details\" className=\"text-xs\"><summary className=\"inline-flex min-h-8 cursor-pointer items-center text-muted-foreground select-none max-sm:min-h-11 pointer-coarse:min-h-11\">{labels.arguments}</summary>{list}</details> : list;\n })()}\n </ItemContent>\n {open && <ItemActions role=\"group\" aria-label={labels.decision}>\n <Button variant=\"default\" data-testid=\"approval-approve\" disabled={unavailable} onClick={() => { void decide(APPROVE); }}><CheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.approve}</Button>\n <Button variant=\"outline\" data-testid=\"approval-deny\" disabled={unavailable} onClick={() => { void decide(DENY); }}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.deny}</Button>\n </ItemActions>}\n {(open && (live || !answer || !questionId)) && <ItemFooter><ItemDescription>{live ? labels.stillWritingCall : labels.decidingUnavailable}</ItemDescription></ItemFooter>}\n {(state === 'approved-sent' || state === 'denied-sent') && <ItemFooter><ItemDescription>{labels.decisionSent(approved)}</ItemDescription></ItemFooter>}\n {refusal && open && <ItemFooter><p role=\"alert\" data-testid=\"approval-refusal\" className=\"m-0 text-sm text-destructive\">{refusal}</p></ItemFooter>}\n </Item>;\n}\n",
|
|
40
69
|
"type": "registry:component",
|
|
41
70
|
"target": "components/pi-chat/approval-card.tsx"
|
|
42
71
|
},
|
|
@@ -48,16 +77,22 @@
|
|
|
48
77
|
},
|
|
49
78
|
{
|
|
50
79
|
"path": "registry/pi-chat/code-block.tsx",
|
|
51
|
-
"content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport { CheckIcon, CopyIcon } from 'lucide-react';\nimport { Button } from '
|
|
80
|
+
"content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport { CheckIcon, CopyIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemHeader, ItemTitle } from '@/components/ui/item';\nimport { copyText } from '../utils/utils';\nimport { useChatText } from './labels';\n\n/** Copy button with a short \"copied\" confirmation. Failures stay visible as a title instead of throwing. */\nexport function CopyButton({ text, label: given, onCopy, className, iconOnly = false }: { readonly text: string; readonly label?: string; readonly onCopy?: (text: string) => Promise<void>; readonly className?: string; readonly iconOnly?: boolean }) {\n const { labels } = useChatText();\n const label = given ?? labels.copy;\n const [state, setState] = useState<'idle' | 'copied' | 'failed'>('idle');\n const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n useEffect(() => () => clearTimeout(timer.current), []);\n const settle = (next: 'copied' | 'failed') => { setState(next); clearTimeout(timer.current); timer.current = setTimeout(() => setState('idle'), 1600); };\n return <Button variant=\"ghost\" size={iconOnly ? 'icon-sm' : 'sm'} className={className} data-testid=\"copy\" aria-label={label} title={state === 'failed' ? labels.copyFailed : label}\n onClick={() => { copyText(text, onCopy).then(() => settle('copied'), () => settle('failed')); }}>\n {state === 'copied' ? <CheckIcon data-icon=\"inline-start\" /> : <CopyIcon data-icon=\"inline-start\" />}\n <span className={iconOnly ? 'sr-only' : 'sr-only md:not-sr-only'}>{state === 'copied' ? labels.copied : state === 'failed' ? labels.failed : label}</span>\n </Button>;\n}\n\n/** Fenced code: language label, copy action, horizontal scroll. Text only, never interpreted as markup. */\nexport function CodeBlock({ code, language, onCopy }: { readonly code: string; readonly language?: string; readonly onCopy?: (text: string) => Promise<void> }) {\n const { labels } = useChatText();\n const label = language?.trim().split(/\\s+/)[0]?.slice(0, 24);\n // shadcn's own Code Block installs a syntax highlighter (shiki) this block never uses, so the frame is an outline Item.\n return <Item variant=\"outline\" size=\"xs\" data-testid=\"code-block\" className=\"my-3 flex-col items-stretch text-start\">\n <ItemHeader>\n <ItemTitle data-testid=\"code-language\" className=\"lowercase\">{label || 'text'}</ItemTitle>\n <CopyButton text={code} label={labels.copyCode} {...(onCopy ? { onCopy } : {})} />\n </ItemHeader>\n <pre className=\"m-0 overflow-x-auto font-mono text-xs leading-5\"><code>{code}</code></pre>\n </Item>;\n}\n",
|
|
52
81
|
"type": "registry:component",
|
|
53
82
|
"target": "components/pi-chat/code-block.tsx"
|
|
54
83
|
},
|
|
55
84
|
{
|
|
56
85
|
"path": "registry/pi-chat/composer.tsx",
|
|
57
|
-
"content": "'use client';\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ChangeEvent, ClipboardEvent, DragEvent, KeyboardEvent, ReactNode, Ref } from 'react';\nimport { AlertCircleIcon, FileTextIcon, Loader2Icon, MessageSquarePlusIcon, SlashIcon, XIcon } from 'lucide-react';\nimport { Button } from '../button/button';\nimport { hasMention, isLongPaste, mentionToken, mentionTrigger, removeMention, slashItems, slashQuery } from './config';\nimport type { MentionsConfig, PastedText, PasteToFile, SlashConfig, SlashItem } from './config';\nimport { MentionMenu } from './mention-menu';\nimport { SlashMenu } from './slash-menu';\nimport { useChatText } from './labels';\nimport { cn } from '../utils/utils';\n\nexport interface ComposerAttachment { readonly id: string; readonly name: string; readonly mimeType: string; readonly data: string }\n\nconst SAFE_IMAGE = /^image\\/(png|jpe?g|gif|webp)$/;\nconst BASE64 = /^[A-Za-z0-9+/=\\s]+$/;\n/** A data URL for a thumbnail, only for common image types and well-formed base64. */\nexport function thumbnail(mimeType: string, data: string): string | undefined {\n return SAFE_IMAGE.test(mimeType) && BASE64.test(data) ? `data:${mimeType};base64,${data}` : undefined;\n}\n\n/**\n * The optional annotate toggle (FEEDBACK.md, \"UX\"): one button in the composer's row that arms element targeting on the host's page,\n * and one count chip for the notes taken. Send attaches them. The host owns everything else (the registry `feedback` item's\n * `useComposerFeedback` returns this shape). Absent: the composer is unchanged.\n */\nexport interface ComposerFeedback {\n /** The annotate toggle: arms targeting on the page (point mode on touch and compact screens), or stops it when it is on. */\n readonly annotate: () => void;\n /** Annotating is on: the toggle shows pressed. */\n readonly active: boolean;\n /** Notes taken and waiting to be sent with the next message; above zero, Send is allowed with no text. */\n readonly count: number;\n /** Removes the notes (the count chip's remove button). */\n readonly clear: () => void;\n /** Send: the text to send instead, with the notes attached, or why they cannot be sent yet. */\n readonly attach: (text: string) => Promise<{ readonly kind: 'ok'; readonly text: string } | { readonly kind: 'refused'; readonly reason: string }>;\n /** The message carrying the notes was accepted. */\n readonly sent: () => void;\n}\n\n/** One file being uploaded (or that failed to upload), shown as a chip until it settles or is dismissed. */\nexport interface PendingUpload {\n readonly id: string; readonly name: string; readonly state: 'uploading' | 'failed'; readonly error?: string;\n /** A pasted text file: abandons the upload and puts the text back in the message (its restore button, or Ctrl+Z). */\n readonly cancel?: (() => void) | undefined;\n /** The message text right after the paste was accepted: Ctrl+Z restores only while the text is still this (any later edit leaves it alone). */\n readonly restoreWhile?: string | undefined;\n}\n\nexport interface ComposerProps {\n /** `/` menu of commands and skills. Absent: no menu and no keyboard trigger. */\n readonly slash?: SlashConfig | undefined;\n /** Reports a failing command. */\n readonly onSlashError?: ((cause: unknown) => void) | undefined;\n /** `@` file picker. Absent: no picker. */\n readonly mentions?: MentionsConfig | undefined;\n /** Paths picked or uploaded as mentions; each shows as a chip while its `@path` is still in the text. */\n readonly mentionPaths?: readonly string[];\n readonly onMentionPicked?: ((path: string) => void) | undefined;\n readonly uploads?: readonly PendingUpload[];\n readonly onDismissUpload?: ((id: string) => void) | undefined;\n /** A paste above `pasteToFile`'s limits, taken by the composer: the host uploads it as a file. Absent: pastes stay inline. */\n readonly onPasteText?: ((paste: PastedText) => void) | undefined;\n readonly pasteToFile?: PasteToFile | undefined;\n /** Extra controls in the bar (the model and effort pill) and a short note beside them. */\n readonly barStart?: ReactNode;\n readonly barNote?: ReactNode;\n readonly text: string;\n readonly onText: (text: string) => void;\n readonly attachments: readonly ComposerAttachment[];\n readonly onRemoveAttachment: (id: string) => void;\n /** The agent is working: the primary button stops it and Enter queues the message (steering is chosen on the queued message). */\n readonly working: boolean;\n /** A send would be refused right now (not connected, a submission in flight, uploading). */\n readonly sendBlocked: boolean;\n readonly disabled: boolean;\n readonly stopRequested: boolean;\n readonly onSend: () => void;\n readonly onStop: () => void;\n readonly uploading: boolean;\n readonly canAttach: boolean;\n readonly fileAccept: string;\n readonly onPickFiles: (files: readonly File[]) => void;\n readonly onKeyDown?: ((event: KeyboardEvent<HTMLTextAreaElement>) => void) | undefined;\n readonly textareaRef: Ref<HTMLTextAreaElement>;\n readonly placeholder?: string;\n /** Both layouts have the same controls (the + menu, `/` and `@` menus, the model and effort pill, Send/Stop). `inline` puts them in one compact row; `stacked` is the two-row chat composer. */\n readonly layout?: 'stacked' | 'inline';\n /** Host controls (for example a voice button) as round buttons between the model pill and the send button. */\n readonly barEnd?: ReactNode;\n /** The optional Feedback button and chip. */\n readonly feedback?: ComposerFeedback | undefined;\n}\n\n/** Round bar buttons share one look: a tinted circle, 44px so a thumb can hit it. */\nconst ROUND = 'inline-flex size-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-muted text-foreground transition-colors outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/60 disabled:pointer-events-none disabled:opacity-45 motion-reduce:transition-none';\n\n/** The annotate toggle (a round bar button beside the \"+\" menu in both layouts). */\nfunction AnnotateButton({ feedback, disabled }: { readonly feedback: ComposerFeedback; readonly disabled: boolean }) {\n const { labels } = useChatText();\n return <button type=\"button\" data-testid=\"composer-annotate\" aria-pressed={feedback.active} disabled={disabled}\n title={labels.annotateHint} onClick={() => feedback.annotate()}\n className={cn(ROUND, feedback.active && 'bg-accent')}>\n <MessageSquarePlusIcon className=\"size-4\" aria-hidden=\"true\" /><span className=\"sr-only\">{labels.annotate}</span>\n </button>;\n}\n\n/** The count chip: how many notes go with the next message, and a way to drop them. */\nfunction NotesChip({ feedback }: { readonly feedback: ComposerFeedback }) {\n const { labels } = useChatText();\n return <span data-testid=\"feedback-count\" data-count={feedback.count} className=\"flex h-9 items-center gap-1.5 rounded-full border border-border bg-muted/50 py-1 pr-1 pl-2.5\">\n <MessageSquarePlusIcon className=\"size-3.5 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <span className=\"text-xs font-medium\">{labels.annotateNotes(feedback.count)}</span>\n <button type=\"button\" data-testid=\"feedback-count-clear\" aria-label={labels.annotateClear} title={labels.annotateClear} onClick={() => feedback.clear()}\n className=\"flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground relative after:absolute after:-inset-3 after:content-[''] transition-colors outline-none hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60\"><XIcon className=\"size-3\" aria-hidden=\"true\" /></button>\n </span>;\n}\n\n/** The \"+\" button and its small menu (Attach files, Commands). Arrow keys move, Enter picks, Escape and outside clicks close. */\nfunction PlusMenu({ disabled, onAttach, onCommands }: { readonly disabled: boolean; readonly onAttach?: (() => void) | undefined; readonly onCommands?: (() => void) | undefined }) {\n const { labels, icons } = useChatText();\n const [open, setOpen] = useState(false);\n const root = useRef<HTMLDivElement>(null), trigger = useRef<HTMLButtonElement>(null);\n useEffect(() => {\n if (!open) return;\n const outside = (event: MouseEvent) => { if (root.current && !root.current.contains(event.target as Node)) setOpen(false); };\n document.addEventListener('mousedown', outside);\n return () => document.removeEventListener('mousedown', outside);\n }, [open]);\n useEffect(() => { if (disabled) setOpen(false); }, [disabled]);\n const entries = [onAttach && { testid: 'composer-attach', label: labels.attachFiles, icon: <icons.attach className=\"size-4 text-muted-foreground\" aria-hidden=\"true\" />, run: onAttach },\n onCommands && { testid: 'composer-commands', label: labels.commands, icon: <SlashIcon className=\"size-4 text-muted-foreground\" aria-hidden=\"true\" />, run: onCommands }].filter(entry => entry);\n const onKeyDown = (event: KeyboardEvent) => {\n if (!open) return;\n const items = Array.from(root.current?.querySelectorAll<HTMLElement>('[role=menuitem]') ?? []);\n const at = items.indexOf(document.activeElement as HTMLElement);\n if (event.key === 'ArrowDown') { event.preventDefault(); items[(at + 1) % items.length]?.focus(); }\n else if (event.key === 'ArrowUp') { event.preventDefault(); items[(at - 1 + items.length) % items.length]?.focus(); }\n else if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); setOpen(false); trigger.current?.focus(); }\n };\n return <div ref={root} className=\"relative shrink-0\" onKeyDown={onKeyDown}>\n <button ref={trigger} type=\"button\" data-testid=\"composer-plus\" aria-label={labels.add} title={labels.add} aria-haspopup=\"menu\" aria-expanded={open} disabled={disabled} onClick={() => setOpen(value => !value)} className={ROUND}>\n <icons.add className=\"size-5\" aria-hidden=\"true\" /></button>\n {open && <div role=\"menu\" aria-label={labels.add} data-testid=\"composer-plus-menu\" className=\"absolute bottom-full left-0 z-30 mb-2 w-52 rounded-2xl border border-border bg-popover p-1.5 text-popover-foreground shadow-lg\">\n {entries.map((entry, index) => entry && <button key={entry.testid} type=\"button\" role=\"menuitem\" data-testid={entry.testid} autoFocus={index === 0}\n onClick={() => { setOpen(false); entry.run(); }}\n className=\"flex min-h-11 w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-sm text-foreground outline-none hover:bg-accent focus-visible:bg-accent\">{entry.icon}{entry.label}</button>)}\n </div>}\n </div>;\n}\n\nconst MAX_HEIGHT = 208;\n\n/** Prompt composer: auto-growing textarea, Enter to send, one primary Send/Stop button. While the agent works a message is queued. */\nexport function Composer(props: ComposerProps) {\n const { text, attachments, working, sendBlocked, disabled, stopRequested, uploading, canAttach } = props;\n const { labels, icons } = useChatText();\n const inner = useRef<HTMLTextAreaElement | null>(null);\n const composing = useRef(false);\n const [dragging, setDragging] = useState(false);\n const [caret, setCaret] = useState(0);\n // A menu closed with Escape stays closed until the text changes.\n const [dismissed, setDismissed] = useState<string | null>(null);\n useEffect(() => { setDismissed(value => value === null || value === text ? value : null); }, [text]);\n const pendingCaret = useRef<number | null>(null);\n const fileInput = useRef<HTMLInputElement>(null);\n const { slash, mentions } = props;\n const items = useMemo<SlashItem[]>(() => slash ? slashItems(slash) : [], [slash]);\n const setRef = (element: HTMLTextAreaElement | null) => {\n inner.current = element;\n if (typeof props.textareaRef === 'function') props.textareaRef(element); else if (props.textareaRef) (props.textareaRef as { current: HTMLTextAreaElement | null }).current = element;\n };\n useLayoutEffect(() => {\n const element = inner.current;\n if (!element) return;\n element.style.height = 'auto';\n element.style.height = `${Math.min(element.scrollHeight, MAX_HEIGHT)}px`;\n element.style.overflowY = element.scrollHeight > MAX_HEIGHT ? 'auto' : 'hidden';\n if (pendingCaret.current !== null) { element.setSelectionRange(pendingCaret.current, pendingCaret.current); element.focus(); pendingCaret.current = null; }\n }, [text]);\n\n const query = slash && items.length ? slashQuery(text, caret) : null;\n const trigger = query === null && mentions ? mentionTrigger(text, caret) : null;\n const slashOpen = query !== null && dismissed !== text, mentionOpen = trigger !== null && dismissed !== text;\n const menuOpen = slashOpen || mentionOpen;\n const edit = (next: string, position: number) => { pendingCaret.current = position; setCaret(position); props.onText(next); };\n const trackCaret = (element: HTMLTextAreaElement) => setCaret(element.selectionStart ?? element.value.length);\n const dismiss = () => setDismissed(text);\n const pickSlash = (item: SlashItem) => {\n const rest = text.slice(caret).replace(/^\\s+/, '');\n if (item.source === 'skill') return edit(`/${item.name} ${rest}`, item.name.length + 2);\n const command = slash?.commands?.find(candidate => candidate.name === item.name);\n props.onText(rest); setCaret(0);\n inner.current?.focus();\n if (!command) return;\n try { void Promise.resolve(command.run({ text: rest, setText: value => props.onText(value) })).catch(cause => props.onSlashError?.(cause)); }\n catch (cause) { props.onSlashError?.(cause); }\n };\n const pickMention = (path: string) => {\n if (!trigger) return;\n const token = `${mentionToken(path)} `;\n edit(`${text.slice(0, trigger.start)}${token}${text.slice(trigger.end)}`, trigger.start + token.length);\n props.onMentionPicked?.(path);\n };\n const openCommands = () => edit(`${text}${text && !/\\s$/.test(text) ? ' ' : ''}/`, text.length + (text && !/\\s$/.test(text) ? 2 : 1));\n const chips = (props.mentionPaths ?? []).filter(path => hasMention(text, path));\n const onChange = (event: ChangeEvent<HTMLTextAreaElement>) => { trackCaret(event.currentTarget); props.onText(event.currentTarget.value); };\n\n const { feedback } = props;\n const hasContent = text.trim().length > 0 || attachments.length > 0 || (feedback?.count ?? 0) > 0;\n const canSubmit = hasContent && !sendBlocked && !disabled;\n const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {\n props.onKeyDown?.(event);\n if (event.defaultPrevented) return;\n // 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 chipRows = <>\n {(chips.length > 0 || (props.uploads?.length ?? 0) > 0 || (feedback?.count ?? 0) > 0) && <div className=\"flex flex-wrap gap-2 px-3 pt-3\">\n {feedback && feedback.count > 0 && <NotesChip feedback={feedback} />}\n {chips.length > 0 && <ul data-testid=\"mention-chips\" aria-label={labels.mentionedFiles} className=\"m-0 contents list-none p-0\">\n {chips.map(path => <li key={path} data-testid=\"mention-chip\" data-path={path} title={path} className=\"flex h-9 max-w-full sm:max-w-[16rem] items-center gap-1.5 rounded-full border border-border bg-muted/50 py-1 pr-1 pl-2.5\">\n <FileTextIcon className=\"size-3.5 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <span className=\"min-w-0 flex-1 truncate text-xs font-medium\">{path}</span>\n <button type=\"button\" aria-label={labels.remove(path)} onClick={() => props.onText(removeMention(text, path))}\n className=\"flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground relative after:absolute after:-inset-3 after:content-[''] transition-colors outline-none hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60\"><XIcon className=\"size-3\" aria-hidden=\"true\" /></button>\n </li>)}\n </ul>}\n {(props.uploads?.length ?? 0) > 0 && <ul data-testid=\"uploads\" aria-label={labels.uploads} className=\"m-0 contents list-none p-0\">\n {props.uploads!.map(item => <li key={item.id} data-testid=\"upload-chip\" data-state={item.state} title={item.error ?? item.name}\n className={cn('flex h-9 max-w-full sm:max-w-[20rem] items-center gap-1.5 rounded-full border py-1 pr-1 pl-2.5', item.state === 'failed' ? 'border-destructive/40 bg-destructive/10' : 'border-border bg-muted/50')}>\n {item.state === 'failed' ? <AlertCircleIcon className=\"size-3.5 shrink-0 text-destructive\" aria-hidden=\"true\" />\n : <Loader2Icon className=\"size-3.5 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none\" aria-hidden=\"true\" />}\n <span className=\"min-w-0 flex-1 truncate text-xs font-medium\">{item.state === 'failed' ? labels.uploadFailed(item.name, item.error) : labels.uploading(item.name)}</span>\n {item.cancel && <button type=\"button\" aria-label={labels.restorePaste} title={labels.restorePaste} onClick={() => item.cancel!()}\n className=\"flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground relative after:absolute after:-inset-3 after:content-[''] transition-colors outline-none hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60\"><XIcon className=\"size-3\" aria-hidden=\"true\" /></button>}\n {item.state === 'failed' && <button type=\"button\" aria-label={labels.dismiss(item.name)} onClick={() => props.onDismissUpload?.(item.id)}\n className=\"flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground relative after:absolute after:-inset-3 after:content-[''] transition-colors outline-none hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60\"><XIcon className=\"size-3\" aria-hidden=\"true\" /></button>}\n </li>)}\n </ul>}\n </div>}\n </>;\n const attachmentRow = attachments.length > 0 && <ul data-testid=\"attachments\" aria-label={labels.attachments} className=\"m-0 flex list-none flex-wrap gap-2 px-3 pt-3\">\n {attachments.map(attachment => {\n const src = thumbnail(attachment.mimeType, attachment.data);\n return <li key={attachment.id} data-testid=\"attachment\" className=\"flex h-9 max-w-full sm:max-w-[14rem] items-center gap-2 rounded-full border border-border bg-muted/50 py-1 pr-1 pl-1\">\n {src ? <img src={src} alt=\"\" className=\"size-7 shrink-0 rounded-full object-cover\" /> : <span className=\"size-7 shrink-0 rounded-full bg-muted\" />}\n <span className=\"min-w-0 flex-1 truncate text-xs font-medium\">{attachment.name}</span>\n <button type=\"button\" aria-label={labels.remove(attachment.name)} onClick={() => props.onRemoveAttachment(attachment.id)}\n className=\"flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground relative after:absolute after:-inset-3 after:content-[''] transition-colors outline-none hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60\"><XIcon className=\"size-3\" aria-hidden=\"true\" /></button>\n </li>;\n })}\n </ul>;\n const textareaEl = <textarea ref={setRef} data-testid=\"composer-input\" aria-label={labels.messageInput} rows={1} value={text} disabled={disabled}\n placeholder={props.placeholder ?? (working ? labels.placeholderWorking : labels.placeholder)}\n {...(slash || mentions ? { role: 'combobox', 'aria-expanded': menuOpen, 'aria-haspopup': 'listbox', 'aria-autocomplete': 'list' } as const : {})}\n onChange={onChange} onKeyDown={onKeyDown} onPaste={onPaste} onKeyUp={event => trackCaret(event.currentTarget)} onClick={event => trackCaret(event.currentTarget)}\n onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}\n className={props.layout === 'inline'\n ? 'block max-h-40 min-h-11 min-w-0 flex-1 resize-none order-first basis-full border-0 bg-transparent px-1.5 py-2.5 text-base leading-6 text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed sm:text-sm'\n : 'block max-h-52 min-h-[3.5rem] w-full resize-none border-0 bg-transparent px-5 pt-4 pb-2 text-base leading-6 sm:text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed'} />;\n const plus = (canAttach || items.length > 0) && <PlusMenu disabled={disabled} onAttach={canAttach ? () => fileInput.current?.click() : undefined} onCommands={items.length > 0 ? openCommands : undefined} />;\n const fileEl = canAttach && <input ref={fileInput} type=\"file\" multiple accept={props.fileAccept} disabled={disabled} tabIndex={-1} aria-hidden=\"true\" className=\"sr-only\" data-testid=\"composer-file\" data-attach=\"true\"\n onChange={event => { const files = Array.from(event.currentTarget.files ?? []); event.currentTarget.value = ''; props.onPickFiles(files); }} />;\n const send = working\n ? <Button variant=\"default\" size=\"icon\" data-testid=\"composer-submit\" data-state=\"stop\" aria-label={labels.stop} title={labels.stop} disabled={disabled || stopRequested} onClick={props.onStop} className=\"ml-auto size-11 shrink-0\">\n {stopRequested ? <Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" /> : <icons.stop className=\"size-4 fill-current\" aria-hidden=\"true\" />}</Button>\n : <Button variant=\"default\" size=\"icon\" type=\"submit\" data-testid=\"composer-submit\" data-state=\"send\" aria-label={labels.send} title={labels.sendHint} disabled={!canSubmit} className=\"ml-auto size-11 shrink-0 disabled:bg-muted disabled:text-muted-foreground disabled:opacity-100\">\n <icons.send className=\"size-5\" aria-hidden=\"true\" /></Button>;\n\n if (props.layout === 'inline') return <form data-testid=\"composer\" data-layout=\"inline\" onSubmit={event => { event.preventDefault(); if (canSubmit) props.onSend(); }} onDragOver={drag} onDragLeave={drag} onDrop={drop}\n className={cn('relative bg-card', dragging && 'rounded-2xl ring-2 ring-ring/40', disabled && 'opacity-70')}>\n {slashOpen && <SlashMenu query={query!} items={items} onSelect={pickSlash} onDismiss={dismiss} />}\n {mentionOpen && mentions && <MentionMenu query={trigger!.query} search={mentions.search} generation={mentions.generation} onSelect={pickMention} onDismiss={dismiss} />}\n {chipRows}\n {attachmentRow}\n <div className=\"flex flex-wrap items-center gap-1.5 px-2.5 py-2\">\n {fileEl}\n {plus}\n {feedback && <AnnotateButton feedback={feedback} disabled={disabled} />}\n {textareaEl}\n {props.barNote}\n {props.barStart}\n {props.barEnd}\n {uploading && !(props.uploads?.length) && <Loader2Icon role=\"status\" aria-label={labels.preparingAttachmentsShort} className=\"size-3.5 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none\" />}\n {working && hasContent && <Button variant=\"secondary\" size=\"sm\" data-testid=\"composer-queue\" disabled={!canSubmit} onClick={props.onSend} className=\"h-11 shrink-0 rounded-full px-4\"><icons.queue className=\"size-3.5\" aria-hidden=\"true\" />{labels.queue}</Button>}\n {send}\n </div>\n </form>;\n\n return <form data-testid=\"composer\" onSubmit={event => { event.preventDefault(); if (canSubmit) props.onSend(); }} onDragOver={drag} onDragLeave={drag} onDrop={drop}\n className={cn('relative rounded-3xl border border-border bg-card shadow-sm transition-[border-color,box-shadow] focus-within:border-ring/60 focus-within:ring-[3px] focus-within:ring-ring/15 motion-reduce:transition-none',\n dragging && 'border-ring ring-[3px] ring-ring/20', disabled && 'opacity-70')}>\n {slashOpen && <SlashMenu query={query!} items={items} onSelect={pickSlash} onDismiss={dismiss} />}\n {mentionOpen && mentions && <MentionMenu query={trigger!.query} search={mentions.search} generation={mentions.generation} onSelect={pickMention} onDismiss={dismiss} />}\n {chipRows}\n {attachmentRow}\n {textareaEl}\n <div className=\"flex flex-nowrap items-center gap-x-2 px-3 pt-1 pb-3\">\n {fileEl}\n {plus}\n {feedback && <AnnotateButton feedback={feedback} disabled={disabled} />}\n {props.barStart}\n {props.barNote}\n {uploading && !(props.uploads?.length) && <span role=\"status\" className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"><Loader2Icon className=\"size-3 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{labels.preparingAttachments}</span>}\n <span className=\"flex-1\" />\n {props.barEnd}\n {working && hasContent && <Button variant=\"secondary\" size=\"sm\" data-testid=\"composer-queue\" disabled={!canSubmit} onClick={props.onSend} className=\"h-11 shrink-0 rounded-full px-4\">\n <icons.queue className=\"size-3.5\" aria-hidden=\"true\" />{labels.queue}</Button>}\n {send}\n </div>\n </form>;\n}\n",
|
|
86
|
+
"content": "'use client';\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ChangeEvent, ClipboardEvent, ComponentProps, DragEvent, KeyboardEvent, ReactNode, Ref } from 'react';\nimport { AlertCircleIcon, FileTextIcon, Loader2Icon, MessageSquarePlusIcon, SlashIcon, XIcon } from 'lucide-react';\nimport { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentMedia, AttachmentTitle } from '@/components/ui/attachment';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { Input } from '@/components/ui/input';\nimport { InputGroup, InputGroupAddon, InputGroupTextarea } from '@/components/ui/input-group';\nimport { Toggle } from '@/components/ui/toggle';\nimport { hasMention, isLongPaste, mentionToken, mentionTrigger, removeMention, slashItems, slashQuery } from './config';\nimport type { MentionsConfig, PastedText, PasteToFile, SlashConfig, SlashItem } from './config';\nimport { MentionPicker } from './mention-menu';\nimport { SlashPicker } from './slash-menu';\nimport { useChatText } from './labels';\nimport { cn } from '../utils/utils';\n\nexport interface ComposerAttachment { readonly id: string; readonly name: string; readonly mimeType: string; readonly data: string }\n\nconst SAFE_IMAGE = /^image\\/(png|jpe?g|gif|webp)$/;\nconst BASE64 = /^[A-Za-z0-9+/=\\s]+$/;\n/** A data URL for a thumbnail, only for common image types and well-formed base64. */\nexport function thumbnail(mimeType: string, data: string): string | undefined {\n return SAFE_IMAGE.test(mimeType) && BASE64.test(data) ? `data:${mimeType};base64,${data}` : undefined;\n}\n\n/**\n * The optional annotate toggle (FEEDBACK.md, \"UX\"): one button in the composer's row that arms element targeting on the host's page,\n * and one count chip for the notes taken. Send attaches them. The host owns everything else (the registry `feedback` item's\n * `useComposerFeedback` returns this shape). Absent: the composer is unchanged.\n */\nexport interface ComposerFeedback {\n /** The annotate toggle: arms targeting on the page (point mode on touch and compact screens), or stops it when it is on. */\n readonly annotate: () => void;\n /** Annotating is on: the toggle shows pressed. */\n readonly active: boolean;\n /** Notes taken and waiting to be sent with the next message; above zero, Send is allowed with no text. */\n readonly count: number;\n /** Removes the notes (the count chip's remove button). */\n readonly clear: () => void;\n /** Send: the text to send instead, with the notes attached, or why they cannot be sent yet. */\n readonly attach: (text: string) => Promise<{ readonly kind: 'ok'; readonly text: string } | { readonly kind: 'refused'; readonly reason: string }>;\n /** The message carrying the notes was accepted. */\n readonly sent: () => void;\n}\n\n/** One file being uploaded (or that failed to upload), shown as a chip until it settles or is dismissed. */\nexport interface PendingUpload {\n readonly id: string; readonly name: string; readonly state: 'uploading' | 'failed'; readonly error?: string;\n /** A pasted text file: abandons the upload and puts the text back in the message (its restore button, or Ctrl+Z). */\n readonly cancel?: (() => void) | undefined;\n /** The message text right after the paste was accepted: Ctrl+Z restores only while the text is still this (any later edit leaves it alone). */\n readonly restoreWhile?: string | undefined;\n}\n\nexport interface ComposerProps {\n /** `/` menu of commands and skills. Absent: no menu and no keyboard trigger. */\n readonly slash?: SlashConfig | undefined;\n /** Reports a failing command. */\n readonly onSlashError?: ((cause: unknown) => void) | undefined;\n /** `@` file picker. Absent: no picker. */\n readonly mentions?: MentionsConfig | undefined;\n /** Paths picked or uploaded as mentions; each shows as a chip while its `@path` is still in the text. */\n readonly mentionPaths?: readonly string[];\n readonly onMentionPicked?: ((path: string) => void) | undefined;\n readonly uploads?: readonly PendingUpload[];\n readonly onDismissUpload?: ((id: string) => void) | undefined;\n /** A paste above `pasteToFile`'s limits, taken by the composer: the host uploads it as a file. Absent: pastes stay inline. */\n readonly onPasteText?: ((paste: PastedText) => void) | undefined;\n readonly pasteToFile?: PasteToFile | undefined;\n /** Extra controls in the bar (the model and effort pill) and a short note beside them. */\n readonly barStart?: ReactNode;\n readonly barNote?: ReactNode;\n /** Optional host mode/model chips beside the attach control. */\n readonly chips?: ReactNode;\n readonly text: string;\n readonly onText: (text: string) => void;\n readonly attachments: readonly ComposerAttachment[];\n readonly onRemoveAttachment: (id: string) => void;\n /** The agent is working: the primary button stops it and Enter queues the message (steering is chosen on the queued message). */\n readonly working: boolean;\n /** A send would be refused right now (not connected, a submission in flight, uploading). */\n readonly sendBlocked: boolean;\n readonly disabled: boolean;\n readonly stopRequested: boolean;\n readonly onSend: () => void;\n readonly onStop: () => void;\n readonly uploading: boolean;\n readonly canAttach: boolean;\n readonly fileAccept: string;\n readonly onPickFiles: (files: readonly File[]) => void;\n readonly onKeyDown?: ((event: KeyboardEvent<HTMLTextAreaElement>) => void) | undefined;\n readonly textareaRef: Ref<HTMLTextAreaElement>;\n readonly placeholder?: string;\n /** `card` (default): chips, then full-width text, then a row of controls, in the host's shadcn InputGroup. `pill` puts the text between\n * the controls on one row. `stacked` and `inline` are compatibility values that render as `card`. */\n readonly layout?: 'card' | 'pill' | 'stacked' | 'inline';\n /** Host controls (for example a voice button) as round buttons between the model pill and the send button. */\n readonly barEnd?: ReactNode;\n /** The optional Feedback button and chip. */\n readonly feedback?: ComposerFeedback | undefined;\n}\n\n/** The annotate toggle beside the \"+\" menu in both layouts. */\nfunction AnnotateButton({ feedback, disabled }: { readonly feedback: ComposerFeedback; readonly disabled: boolean }) {\n const { labels } = useChatText();\n return <Toggle data-testid=\"composer-annotate\" pressed={feedback.active} disabled={disabled}\n aria-label={labels.annotate} title={labels.annotateHint} onPressedChange={() => feedback.annotate()}>\n <MessageSquarePlusIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n </Toggle>;\n}\n\n/**\n * One chip above the text (a mention, an upload, an attachment or the notes count) as the host's shadcn Attachment. The remove\n * buttons keep a 44px touch target on coarse pointers; negative margins let it extend beyond the chip instead of growing it.\n */\nfunction Chip({ media, image = false, label, actions, ...props }: ComponentProps<typeof Attachment> & { readonly media: ReactNode; readonly image?: boolean; readonly label: ReactNode; readonly actions?: ReactNode }) {\n return <Attachment size=\"xs\" role=\"listitem\" {...props}>\n <AttachmentMedia variant={image ? 'image' : 'icon'}>{media}</AttachmentMedia>\n <AttachmentContent><AttachmentTitle>{label}</AttachmentTitle></AttachmentContent>\n {actions && <AttachmentActions className=\"max-sm:-my-2.5 pointer-coarse:-my-2.5\">{actions}</AttachmentActions>}\n </Attachment>;\n}\n\nfunction RemoveChip({ label, onClick }: { readonly label: string; readonly onClick: () => void }) {\n return <AttachmentAction aria-label={label} title={label} onClick={onClick}><XIcon aria-hidden=\"true\" /></AttachmentAction>;\n}\n\n/** The count chip: how many notes go with the next message, and a way to drop them. */\nfunction NotesChip({ feedback }: { readonly feedback: ComposerFeedback }) {\n const { labels } = useChatText();\n return <Chip data-testid=\"feedback-count\" data-count={feedback.count} media={<MessageSquarePlusIcon aria-hidden=\"true\" />} label={labels.annotateNotes(feedback.count)}\n actions={<AttachmentAction data-testid=\"feedback-count-clear\" aria-label={labels.annotateClear} title={labels.annotateClear} onClick={() => feedback.clear()}><XIcon aria-hidden=\"true\" /></AttachmentAction>} />;\n}\n\n/** The \"+\" button and the host's shadcn dropdown menu (Attach files, Commands). */\nfunction PlusActions({ disabled, onAttach, onCommands, textarea }: { readonly disabled: boolean; readonly onAttach?: (() => void) | undefined; readonly onCommands?: (() => void) | undefined; readonly textarea: { readonly current: HTMLTextAreaElement | null } }) {\n const { labels, icons } = useChatText();\n const [open, setOpen] = useState(false);\n // Commands puts `/` in the message: focus goes back to the text, not to the \"+\" button.\n const toText = useRef(false);\n useEffect(() => { if (disabled) setOpen(false); }, [disabled]);\n return <DropdownMenu open={open} onOpenChange={setOpen}>\n <DropdownMenuTrigger render={<Button variant=\"ghost\" size=\"icon\" />} data-testid=\"composer-plus\" aria-label={labels.add} title={labels.add} disabled={disabled}>\n <icons.add data-icon=\"inline-start\" aria-hidden=\"true\" /></DropdownMenuTrigger>\n <DropdownMenuContent side=\"top\" align=\"start\" aria-label={labels.add} data-testid=\"composer-plus-menu\" className=\"w-52\"\n finalFocus={() => { const text = toText.current; toText.current = false; return text ? textarea.current : true; }}>\n {onAttach && <DropdownMenuItem data-testid=\"composer-attach\" onClick={onAttach} className=\"max-sm:min-h-11 pointer-coarse:min-h-11\">\n <icons.attach aria-hidden=\"true\" />{labels.attachFiles}</DropdownMenuItem>}\n {onCommands && <DropdownMenuItem data-testid=\"composer-commands\" onClick={() => { toText.current = true; onCommands(); }} className=\"max-sm:min-h-11 pointer-coarse:min-h-11\">\n <SlashIcon aria-hidden=\"true\" />{labels.commands}</DropdownMenuItem>}\n </DropdownMenuContent>\n </DropdownMenu>;\n}\n\nconst MAX_HEIGHT = 208;\n\n/** Prompt composer: auto-growing textarea, Enter to send, one primary Send/Stop button. While the agent works a message is queued. */\nexport function Composer(props: ComposerProps) {\n const { text, attachments, working, sendBlocked, disabled, stopRequested, uploading, canAttach, layout = 'card' } = props;\n const { labels, icons } = useChatText();\n const inner = useRef<HTMLTextAreaElement | null>(null);\n const composing = useRef(false);\n const [dragging, setDragging] = useState(false);\n const [caret, setCaret] = useState(0);\n // A menu closed with Escape stays closed until the text changes.\n const [dismissed, setDismissed] = useState<string | null>(null);\n useEffect(() => { setDismissed(value => value === null || value === text ? value : null); }, [text]);\n const pendingCaret = useRef<number | null>(null);\n const fileInput = useRef<HTMLInputElement>(null);\n const { slash, mentions } = props;\n const items = useMemo<SlashItem[]>(() => slash ? slashItems(slash) : [], [slash]);\n const setRef = (element: HTMLTextAreaElement | null) => {\n inner.current = element;\n if (typeof props.textareaRef === 'function') props.textareaRef(element); else if (props.textareaRef) (props.textareaRef as { current: HTMLTextAreaElement | null }).current = element;\n };\n useLayoutEffect(() => {\n const element = inner.current;\n if (!element) return;\n const resize = () => {\n element.style.height = 'auto';\n element.style.height = `${Math.min(element.scrollHeight, MAX_HEIGHT)}px`;\n element.style.overflowY = element.scrollHeight > MAX_HEIGHT ? 'auto' : 'hidden';\n };\n resize();\n if (pendingCaret.current !== null) { element.setSelectionRange(pendingCaret.current, pendingCaret.current); element.focus(); pendingCaret.current = null; }\n if (typeof ResizeObserver === 'undefined') return;\n let width = element.clientWidth;\n const observer = new ResizeObserver(() => {\n const next = element.clientWidth;\n if (next === width) return;\n width = next; resize();\n });\n observer.observe(element);\n return () => observer.disconnect();\n }, [text]);\n\n const query = slash && items.length ? slashQuery(text, caret) : null;\n const trigger = query === null && mentions ? mentionTrigger(text, caret) : null;\n const slashOpen = query !== null && dismissed !== text, mentionOpen = trigger !== null && dismissed !== text;\n const menuOpen = slashOpen || mentionOpen;\n const edit = (next: string, position: number) => { pendingCaret.current = position; setCaret(position); props.onText(next); };\n const trackCaret = (element: HTMLTextAreaElement) => setCaret(element.selectionStart ?? element.value.length);\n const dismiss = () => setDismissed(text);\n const pickSlash = (item: SlashItem) => {\n const rest = text.slice(caret).replace(/^\\s+/, '');\n if (item.source === 'skill') return edit(`/${item.name} ${rest}`, item.name.length + 2);\n const command = slash?.commands?.find(candidate => candidate.name === item.name);\n props.onText(rest); setCaret(0);\n inner.current?.focus();\n if (!command) return;\n try { void Promise.resolve(command.run({ text: rest, setText: value => props.onText(value) })).catch(cause => props.onSlashError?.(cause)); }\n catch (cause) { props.onSlashError?.(cause); }\n };\n const pickMention = (path: string) => {\n if (!trigger) return;\n const token = `${mentionToken(path)} `;\n edit(`${text.slice(0, trigger.start)}${token}${text.slice(trigger.end)}`, trigger.start + token.length);\n props.onMentionPicked?.(path);\n };\n const openCommands = () => edit(`${text}${text && !/\\s$/.test(text) ? ' ' : ''}/`, text.length + (text && !/\\s$/.test(text) ? 2 : 1));\n const chips = (props.mentionPaths ?? []).filter(path => hasMention(text, path));\n const onChange = (event: ChangeEvent<HTMLTextAreaElement>) => { trackCaret(event.currentTarget); props.onText(event.currentTarget.value); };\n\n const { feedback } = props;\n const hasContent = text.trim().length > 0 || attachments.length > 0 || (feedback?.count ?? 0) > 0;\n const canSubmit = hasContent && !sendBlocked && !disabled;\n const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {\n props.onKeyDown?.(event);\n if (event.defaultPrevented) return;\n // Ctrl+Z right after a paste that is still uploading puts the pasted text back, instead of undoing the paste in the textarea.\n const undo = (props.uploads ?? []).filter(item => item.state === 'uploading' && item.cancel && item.restoreWhile === props.text).pop();\n if (undo && (event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'z') { event.preventDefault(); undo.cancel!(); return; }\n // Never send while an input method editor is composing (Enter confirms the candidate).\n // A phone keyboard has no Shift+Enter: on a touch device Enter inserts a newline and the Send button sends.\n const touch = globalThis.matchMedia?.('(pointer: coarse)').matches === true;\n if (event.key === 'Enter' && !touch && !event.shiftKey && !composing.current && !event.nativeEvent.isComposing && event.keyCode !== 229) {\n event.preventDefault();\n if (canSubmit) props.onSend();\n }\n };\n const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {\n if (canAttach && event.clipboardData.files.length) { event.preventDefault(); props.onPickFiles(Array.from(event.clipboardData.files)); return; }\n if (!props.onPasteText) return;\n const pasted = event.clipboardData.getData('text/plain');\n if (!isLongPaste(pasted, props.pasteToFile)) return;\n event.preventDefault();\n props.onPasteText({ text: pasted, start: event.currentTarget.selectionStart, end: event.currentTarget.selectionEnd });\n };\n const drag = (event: DragEvent) => { if (canAttach) { event.preventDefault(); setDragging(event.type === 'dragover'); } };\n const drop = (event: DragEvent) => { if (!canAttach) return; event.preventDefault(); setDragging(false); props.onPickFiles(Array.from(event.dataTransfer.files)); };\n\n const chipList = (chips.length > 0 || (props.uploads?.length ?? 0) > 0 || (feedback?.count ?? 0) > 0 || attachments.length > 0) && <>\n {feedback && feedback.count > 0 && <div role=\"list\" className=\"contents\"><NotesChip feedback={feedback} /></div>}\n {chips.length > 0 && <div role=\"list\" data-testid=\"mention-chips\" aria-label={labels.mentionedFiles} className=\"contents\">\n {chips.map(path => <Chip key={path} data-testid=\"mention-chip\" data-path={path} title={path} className=\"max-w-full sm:max-w-[16rem]\"\n media={<FileTextIcon aria-hidden=\"true\" />} label={path} actions={<RemoveChip label={labels.remove(path)} onClick={() => props.onText(removeMention(text, path))} />} />)}\n </div>}\n {(props.uploads?.length ?? 0) > 0 && <div role=\"list\" data-testid=\"uploads\" aria-label={labels.uploads} className=\"contents\">\n {props.uploads!.map(item => <Chip key={item.id} data-testid=\"upload-chip\" state={item.state === 'failed' ? 'error' : 'uploading'} data-state={item.state} title={item.error ?? item.name} className=\"max-w-full sm:max-w-[20rem]\"\n media={item.state === 'failed' ? <AlertCircleIcon className=\"text-destructive\" aria-hidden=\"true\" /> : <Loader2Icon className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />}\n label={item.state === 'failed' ? labels.uploadFailed(item.name, item.error) : labels.uploading(item.name)}\n actions={<>\n {item.cancel && <RemoveChip label={labels.restorePaste} onClick={() => item.cancel!()} />}\n {item.state === 'failed' && <RemoveChip label={labels.dismiss(item.name)} onClick={() => props.onDismissUpload?.(item.id)} />}\n </>} />)}\n </div>}\n {attachments.length > 0 && <div role=\"list\" data-testid=\"attachments\" aria-label={labels.attachments} className=\"contents\">\n {attachments.map(attachment => {\n const src = thumbnail(attachment.mimeType, attachment.data);\n return <Chip key={attachment.id} data-testid=\"attachment\" className=\"max-w-full sm:max-w-[14rem]\" image={!!src}\n media={src ? <img src={src} alt=\"\" /> : <FileTextIcon aria-hidden=\"true\" />} label={attachment.name}\n actions={<RemoveChip label={labels.remove(attachment.name)} onClick={() => props.onRemoveAttachment(attachment.id)} />} />;\n })}\n </div>}\n </>;\n const pill = layout === 'pill';\n const textareaEl = <InputGroupTextarea ref={setRef} data-testid=\"composer-input\" aria-label={labels.messageInput} rows={1} value={text} disabled={disabled}\n placeholder={props.placeholder ?? (working ? labels.placeholderWorking : labels.placeholder)}\n {...(slash || mentions ? { role: 'combobox', 'aria-expanded': menuOpen, 'aria-haspopup': 'listbox', 'aria-autocomplete': 'list' } as const : {})}\n onChange={onChange} onKeyDown={onKeyDown} onPaste={onPaste} onKeyUp={event => trackCaret(event.currentTarget)} onClick={event => trackCaret(event.currentTarget)}\n onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}\n className={pill ? 'max-h-52 min-h-0 min-w-0' : 'max-h-52 min-h-11'} />;\n const plus = (canAttach || items.length > 0) && <PlusActions disabled={disabled} textarea={inner} onAttach={canAttach ? () => fileInput.current?.click() : undefined} onCommands={items.length > 0 ? openCommands : undefined} />;\n const fileEl = canAttach && <Input ref={fileInput} type=\"file\" multiple accept={props.fileAccept} disabled={disabled} tabIndex={-1} aria-hidden=\"true\" className=\"sr-only\" data-testid=\"composer-file\" data-attach=\"true\"\n onChange={event => { const files = Array.from(event.currentTarget.files ?? []); event.currentTarget.value = ''; props.onPickFiles(files); }} />;\n const send = working\n ? <Button variant=\"default\" size=\"icon\" data-testid=\"composer-submit\" data-state=\"stop\" aria-label={labels.stop} title={labels.stop} disabled={disabled || stopRequested} onClick={props.onStop} className=\"shrink-0\">\n {stopRequested ? <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" /> : <icons.stop data-icon=\"inline-start\" aria-hidden=\"true\" />}</Button>\n : <Button variant=\"default\" size=\"icon\" type=\"submit\" data-testid=\"composer-submit\" data-state=\"send\" aria-label={labels.send} title={labels.sendHint} disabled={!canSubmit} className=\"shrink-0\">\n <icons.send data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>;\n const end = <>\n {props.barStart}\n {props.chips && <div className=\"flex min-w-0 items-center gap-2\">{props.chips}</div>}\n {props.barNote}\n {uploading && !(props.uploads?.length) && <span role=\"status\" className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"><Loader2Icon className=\"size-3 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{labels.preparingAttachments}</span>}\n {!pill && <span className=\"min-w-0 flex-1\" />}\n {props.barEnd && <div data-testid=\"composer-tools\" className=\"flex shrink-0 items-center gap-2\">{props.barEnd}</div>}\n {working && hasContent && <Button variant=\"secondary\" size=\"sm\" data-testid=\"composer-queue\" disabled={!canSubmit} onClick={props.onSend} className=\"shrink-0\">\n <icons.queue data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.queue}</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 && <SlashPicker query={query!} items={items} onSelect={pickSlash} onDismiss={dismiss} />}\n {mentionOpen && mentions && <MentionPicker query={trigger!.query} search={mentions.search} generation={mentions.generation} onSelect={pickMention} onDismiss={dismiss} />}\n {pill && chipList && <div className=\"flex flex-wrap gap-2\">{chipList}</div>}\n {pill ? <InputGroup>\n <InputGroupAddon>{plus}{feedback && <AnnotateButton feedback={feedback} disabled={disabled} />}</InputGroupAddon>\n {textareaEl}\n <InputGroupAddon align=\"inline-end\" data-testid=\"composer-controls\">{end}</InputGroupAddon>\n </InputGroup> : <InputGroup>\n {chipList && <InputGroupAddon align=\"block-start\" className=\"flex-wrap\">{chipList}</InputGroupAddon>}\n {textareaEl}\n <InputGroupAddon align=\"block-end\" data-testid=\"composer-controls\">\n {plus}\n {feedback && <AnnotateButton feedback={feedback} disabled={disabled} />}\n {end}\n </InputGroupAddon>\n </InputGroup>}\n </form>;\n}\n",
|
|
58
87
|
"type": "registry:component",
|
|
59
88
|
"target": "components/pi-chat/composer.tsx"
|
|
60
89
|
},
|
|
90
|
+
{
|
|
91
|
+
"path": "registry/pi-chat/conversation-files.tsx",
|
|
92
|
+
"content": "'use client';\n\nimport { useEffect, useState } from 'react';\nimport { FileTextIcon, ShapesIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Drawer, DrawerContent, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer';\nimport { Popover, PopoverContent, PopoverHeader, PopoverTitle, PopoverTrigger } from '@/components/ui/popover';\nimport { useChatText } from './labels';\n\n/** A conversation's observed file, opened only through the host's existing callback. */\nexport interface ConversationFile {\n readonly id: string;\n readonly name: string;\n readonly open: (() => void) | undefined;\n}\n\n/** Conversation files, not a second workspace: an anchored list 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, setPhone] = useState(() => globalThis.matchMedia?.('(max-width: 640px)').matches === true);\n useEffect(() => {\n const media = globalThis.matchMedia?.('(max-width: 640px)');\n if (!media) return;\n const change = () => { setPhone(media.matches); setOpen(false); };\n media.addEventListener('change', change);\n return () => media.removeEventListener('change', change);\n }, []);\n if (!files.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\">· {files.length}</span></Button>;\n const content = <ul className=\"m-0 max-h-[60dvh] list-none overflow-y-auto\">\n {files.map(file => <li key={file.id}><Button variant=\"ghost\" disabled={!file.open} data-testid=\"conversation-file\" data-file={file.id} className=\"w-full justify-start gap-2 text-start\" onClick={() => { setOpen(false); file.open?.(); }}><FileTextIcon className=\"shrink-0\" aria-hidden=\"true\" /><span className=\"min-w-0 truncate\">{file.name}</span></Button></li>)}\n </ul>;\n return phone ? <Drawer open={open} onOpenChange={setOpen} showSwipeHandle><DrawerTrigger render={trigger} /><DrawerContent data-testid=\"conversation-files-list\" className=\"pi-chat max-h-[80dvh]\"><DrawerTitle>{labels.artifacts}</DrawerTitle><div className=\"pb-[env(safe-area-inset-bottom)]\">{content}</div></DrawerContent></Drawer>\n : <Popover open={open} onOpenChange={setOpen}><PopoverTrigger render={trigger} /><PopoverContent side=\"top\" align=\"start\" sideOffset={8} 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)]\"><PopoverHeader><PopoverTitle>{labels.artifacts}</PopoverTitle></PopoverHeader>{content}</PopoverContent></Popover>;\n}\n",
|
|
93
|
+
"type": "registry:component",
|
|
94
|
+
"target": "components/pi-chat/conversation-files.tsx"
|
|
95
|
+
},
|
|
61
96
|
{
|
|
62
97
|
"path": "registry/pi-chat/config.ts",
|
|
63
98
|
"content": "import type { ReactNode } from 'react';\nimport type { BlockAction } from '../button/actions';\n\n/*\n * Composer features the host switches on by configuration. Each prop is optional: leave it out and the composer has\n * no menu, no button and no keyboard trigger for it. The current model and thinking level are read from the native\n * view's `pi.agent` document, so there is no extra state to keep in sync.\n */\n\n/** Where a `/` menu entry comes from. Skills and host commands are grouped separately in the menu. */\nexport type SlashSource = 'local' | 'extension' | 'prompt' | 'skill';\n\nexport interface SlashApi {\n /** Replace the composer text. */\n readonly setText: (text: string) => void;\n /** The composer text with the typed `/query` already removed. */\n readonly text: string;\n}\nexport interface SlashCommand {\n readonly name: string;\n readonly description?: string;\n /** Shown as a badge and as a filter chip; commands without one are grouped as `built-in`. */\n readonly sourcePlugin?: string;\n readonly run: (api: SlashApi) => void | Promise<void>;\n}\nexport interface SlashSkill {\n readonly name: string;\n readonly description: string;\n readonly sourcePlugin?: string;\n}\nexport interface SlashConfig {\n readonly commands?: readonly SlashCommand[];\n readonly skills?: readonly SlashSkill[];\n}\n\nexport interface MentionResult { readonly path: string; readonly kind?: 'file' | 'directory' }\nexport interface MentionsConfig {\n /** Workspace search for the `@` picker. Empty `query` asks for a default listing. Abort `signal` when the query changes. */\n readonly search: (query: string, signal: AbortSignal) => Promise<readonly MentionResult[]>;\n /** Open a mentioned workspace file (for example in the right panel). When given, a mention in a sent message is a button. */\n readonly open?: ((path: string) => void) | undefined;\n /** Changes whenever what `search` would return may have changed (a workspace change event, another workspace). An open picker searches again. */\n readonly generation?: string | number | undefined;\n /** Host-supplied look for a mention in a sent message (a plug-in such as feedback). Return undefined to keep the default mention. */\n readonly render?: ((path: string) => ReactNode | undefined) | undefined;\n /**\n * The bytes of an image upload, for its card's thumbnail: the host reads them through its own provider. Absent: the card shows its icon.\n * Only png, jpeg, gif and webp are shown (`thumbnail`); anything else, or a refusal, leaves the icon.\n */\n readonly preview?: PreviewRead | undefined;\n}\n/** Reads an image upload for its card's thumbnail: its media type and base64 data, or undefined. */\nexport type PreviewRead = (path: string, signal: AbortSignal) => Promise<{ readonly mimeType: string; readonly data: string } | undefined>;\n\n/** Paste-to-file thresholds: a paste with more characters or more lines than these becomes a file. Defaults 2000 and 40. */\nexport interface PasteToFile { readonly minChars?: number | undefined; readonly minLines?: number | undefined }\n/** A paste the composer took, with the textarea range it replaces (restored if the upload is cancelled). */\nexport interface PastedText { readonly text: string; readonly start: number; readonly end: number }\n\nexport interface UploadResult {\n /** The host saved the file in the workspace: it becomes an `@path` mention. */\n readonly path?: string;\n /** Sent as a native image attachment. */\n readonly image?: { readonly data: string; readonly mimeType: string };\n readonly name: string;\n}\nexport interface AttachmentsConfig {\n /** The file input's `accept`. Any file when omitted. */\n readonly accept?: string;\n readonly upload: (files: File[], signal: AbortSignal) => Promise<readonly UploadResult[]>;\n}\n\n/** One past conversation in the history list. `title` is its name or first message; `updatedAt` is when it last had activity. */\nexport interface ConversationItem {\n readonly id: string;\n readonly title?: string | undefined;\n readonly updatedAt?: number | string | Date | undefined;\n /** A preview of the last message (the row's tooltip). */\n readonly lastMessage?: string | null | undefined;\n readonly archived?: boolean | undefined;\n}\n/** The host's conversations. Give it to `PiChat` and the header's History button opens a list of them instead of the earlier records of this one. */\nexport interface ConversationsConfig {\n readonly items: readonly ConversationItem[];\n /** The conversation that is open; it is marked in the list. */\n readonly activeId?: string | undefined;\n readonly onSelect: (id: string) => void;\n readonly onNew?: (() => void) | undefined;\n readonly loading?: boolean | undefined;\n /**\n * Server-side search: with it, a typed query (or the Archived filter) lists what this returns instead of filtering `items`\n * by title. Abort `signal` when the query changes.\n */\n readonly search?: ((query: string, options: { readonly archived: boolean }, signal: AbortSignal) => Promise<readonly ConversationItem[]>) | undefined;\n /** Each action below adds its button to the rows; the host refreshes `items` when it resolves. */\n readonly rename?: ((id: string, title: string) => Promise<void>) | undefined;\n /** Archive (`true`) or restore (`false`). With it, the list also offers an Archived filter (which needs `search`). */\n readonly archive?: ((id: string, archived: boolean) => Promise<void>) | undefined;\n readonly remove?: ((id: string) => Promise<void>) | undefined;\n /** Fork the open conversation after the entry of a settled reply: the reply's Fork button. */\n readonly fork?: ((atEntryId: string) => Promise<void>) | undefined;\n /**\n * Host actions on each row, after rename, archive and delete: `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids\n * `conversation-action-<id>`, the menu `conversation-action-more`.\n */\n readonly rowActions?: ((item: ConversationItem) => readonly BlockAction[]) | undefined;\n}\n\n/** A settled reply, as host message actions receive it: its row key, its text and the native entry it ends with. */\nexport interface ReplyRef {\n readonly key: string; readonly text: string; readonly entryId?: string | undefined;\n /** When the last model round of the reply was written (ms), the same value as its message's `timestamp`: a host keys its own per-reply data on it. */\n readonly timestamp?: number | undefined;\n /** The model that wrote the last round of the reply, as its message names it (`provider` and `model` ids). */\n readonly model?: { readonly provider: string; readonly modelId: string } | undefined;\n /**\n * How long the model took to answer in the last round of the reply (ms): Pi's own `AssistantMessage.durationMs`, measured monotonically\n * and kept in the transcript. It excludes queue time, earlier rounds, tool runs and retry delays, and is absent for a message written\n * before Pi recorded it. It is never computed from the clock at render time.\n */\n readonly durationMs?: number | undefined;\n}\n\nexport interface ModelRef { readonly provider: string; readonly modelId: string }\nexport interface ModelConfig {\n readonly options: readonly (ModelRef & { readonly label?: string })[];\n /** Reject to refuse: the message is shown inline. */\n readonly change: (model: ModelRef) => Promise<void>;\n}\nexport interface EffortConfig {\n readonly options: readonly string[];\n readonly change: (level: string) => Promise<void>;\n}\n\n/** The unified, internal menu entry. */\nexport interface SlashItem {\n readonly name: string;\n readonly description: string;\n readonly source: SlashSource;\n readonly sourcePlugin?: string;\n}\n\nexport function slashItems(config: SlashConfig): SlashItem[] {\n return [\n ...(config.commands ?? []).map((command): SlashItem => ({ name: command.name, description: command.description ?? '', source: 'local', ...(command.sourcePlugin ? { sourcePlugin: command.sourcePlugin } : {}) })),\n ...(config.skills ?? []).map((skill): SlashItem => ({ name: skill.name, description: skill.description, source: 'skill', ...(skill.sourcePlugin ? { sourcePlugin: skill.sourcePlugin } : {}) })),\n ];\n}\n\n/** The group an item lives under in the filter chips: skills, its plugin, or `built-in`. */\nexport function slashGroup(item: Pick<SlashItem, 'source' | 'sourcePlugin'>): string {\n if (item.source === 'skill') return 'skills';\n return item.sourcePlugin || 'built-in';\n}\n\n/** `/` opens the menu only when the text before the caret is one `/token`, as in v2. */\nexport function slashQuery(text: string, caret: number): string | null {\n const match = /^\\/(\\S*)$/.exec(text.slice(0, caret));\n return match ? match[1]! : null;\n}\n\nexport interface MentionTrigger { readonly query: string; readonly start: number; readonly end: number }\n/** An `@` at the start or after whitespace, up to the caret. */\nexport function mentionTrigger(text: string, caret: number): MentionTrigger | null {\n const before = text.slice(0, caret);\n const match = /(^|\\s)@(\\S*)$/.exec(before);\n // A typed opening quote (`@\"Meeting`) is not part of the search; the token starts at the `@` either way.\n return match ? { query: match[2]!.replace(/^\"/, ''), start: before.length - match[2]!.length - 1, end: caret } : null;\n}\n\n/**\n * The text of a mention of `path`. A path with whitespace or a quote is written in double quotes, `@\"docs/Meeting notes.md\"`, with `\\`\n * and `\"` escaped, because a bare mention ends at a space. Any other path stays bare (`@docs/plan.md`), as it always was.\n */\nexport function mentionToken(path: string): string {\n return /[\\s\"]/.test(path) ? `@\"${path.replace(/[\\\\\"]/g, '\\\\$&')}\"` : `@${path}`;\n}\n/**\n * The `@` tokens of a message, bare or quoted: group 1 is the character before the `@` (or the start), group 2 the quoted body (escapes\n * still in it), group 3 the bare path. `test/contracts/pi-chat-source.test.mjs` keeps `mentionedPaths` in the agent package equal to this.\n */\nexport const MENTION_TOKEN = /(^|\\s)@(?:\"((?:[^\"\\\\]|\\\\.)*)\"|([^\\s]+))/g;\n\nconst escape = (value: string) => value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\nconst BOUNDARY = '[^A-Za-z0-9_./-]';\nexport function hasMention(text: string, path: string): boolean {\n return new RegExp(`(^|${BOUNDARY})${escape(mentionToken(path))}($|${BOUNDARY})`).test(text);\n}\nexport function removeMention(text: string, path: string): string {\n return text.replace(new RegExp(`(^|${BOUNDARY})${escape(mentionToken(path))}(?=$|${BOUNDARY})[ ]?`), '$1').replace(/^[ ]+/, '');\n}\n\nexport type Piece = { readonly text: string; readonly kind?: 'mention' | 'skill'; readonly value?: string };\n\n/** Upload paths are `uploads/<name>`: the attachments of a message, shown as cards rather than as text. */\nexport const isUploadPath = (path: string): boolean => path.startsWith('uploads/');\n\nexport type FileKind = 'document' | 'image' | 'pdf' | 'sheet' | 'slides' | 'file';\nconst KIND_BY_EXTENSION: Readonly<Record<string, FileKind>> = {\n png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image', svg: 'image', bmp: 'image', avif: 'image',\n pdf: 'pdf',\n csv: 'sheet', tsv: 'sheet', xls: 'sheet', xlsx: 'sheet', xlsm: 'sheet', ods: 'sheet', numbers: 'sheet',\n ppt: 'slides', pptx: 'slides', odp: 'slides', key: 'slides',\n md: 'document', markdown: 'document', txt: 'document', doc: 'document', docx: 'document', odt: 'document', rtf: 'document',\n};\n/** The card's kind, from the extension alone. */\nexport function fileKind(path: string): FileKind {\n const extension = /\\.([^./]+)$/.exec(path)?.[1]?.toLowerCase() ?? '';\n return KIND_BY_EXTENSION[extension] ?? 'file';\n}\n/** The name a card shows: the upload's file name without the random suffix `uploadPath` adds (`report-1a2b3c4d.pdf` is `report.pdf`). */\nexport function displayName(path: string): string {\n const name = path.slice(path.lastIndexOf('/') + 1);\n return name.replace(/-[0-9a-f]{8}(?=\\.[^.]*$|$)/i, '');\n}\n\n/**\n * The pieces a message shows once `hidden` ones are taken out: a space that a removed piece leaves at a join is dropped, and the ends are\n * trimmed where it was. The message's own text is not changed, only what the bubble draws.\n */\nexport function visibleParts(parts: readonly Piece[], hidden: (part: Piece) => boolean): Piece[] {\n const out: Piece[] = [];\n let gap = false;\n for (const part of parts) {\n if (hidden(part)) { gap = true; continue; }\n const prev = out[out.length - 1];\n const joins = gap && part.kind === undefined && (prev === undefined || (prev.kind === undefined && /\\s$/.test(prev.text)));\n out.push(joins ? { text: part.text.replace(/^\\s+/, '') } : part);\n gap = false;\n }\n const last = out[out.length - 1];\n if (gap && last && last.kind === undefined) out[out.length - 1] = { text: last.text.replace(/\\s+$/, '') };\n return out.filter(part => part.kind !== undefined || part.text !== '');\n}\n\n/** Whether pasted text is long enough to become a file. `more` than the limits: the characters, or the lines, of the text. */\nexport function isLongPaste(text: string, limits: PasteToFile = {}): boolean {\n const { minChars = 2000, minLines = 40 } = limits;\n return text.length > minChars || text.split('\\n').length > minLines;\n}\n/** Markdown in pasted text: a heading, a list or quote item, a fence, a table row, emphasis or a link. Anything else is plain text. */\nexport function looksLikeMarkdown(text: string): boolean {\n return /^\\s{0,3}(#{1,6}\\s|[-*+]\\s|\\d+[.)]\\s|>\\s|```|~~~)/m.test(text) || /\\*\\*[^*\\n]+\\*\\*|\\]\\([^)\\n]+\\)|^\\|.*\\|\\s*$/m.test(text);\n}\n/** Split a sent message into plain text, `@path` mentions and a leading `/skill` token. */\nexport function pieces(text: string, options: { readonly mentions: boolean; readonly skills: readonly string[]; readonly uploads?: boolean }): Piece[] {\n const found: { start: number; end: number; kind: 'mention' | 'skill'; value: string }[] = [];\n const skill = options.skills.length ? new RegExp(`^/(${options.skills.map(escape).join('|')})(?=\\\\s|$)`).exec(text) : null;\n if (skill) found.push({ start: 0, end: skill[0].length, kind: 'skill', value: skill[1]! });\n if (options.mentions || options.uploads) {\n for (const match of text.matchAll(MENTION_TOKEN)) {\n // A quoted path keeps its punctuation (the closing quote ends it); a bare one drops trailing punctuation, as a sentence would.\n const quoted = match[2] !== undefined;\n const path = quoted ? match[2]!.replace(/\\\\(.)/g, '$1') : match[3]!.replace(/[.,;:!?)\\]}'\"]+$/, '');\n if (!path || (!options.mentions && !isUploadPath(path))) continue;\n const start = match.index! + match[1]!.length;\n const end = quoted ? match.index! + match[0].length : start + 1 + path.length;\n found.push({ start, end, kind: 'mention', value: path });\n }\n }\n const out: Piece[] = [];\n let cursor = 0;\n for (const item of found.sort((a, b) => a.start - b.start)) {\n if (item.start < cursor) continue;\n if (item.start > cursor) out.push({ text: text.slice(cursor, item.start) });\n out.push({ text: text.slice(item.start, item.end), kind: item.kind, value: item.value });\n cursor = item.end;\n }\n if (cursor < text.length) out.push({ text: text.slice(cursor) });\n return out;\n}\n",
|
|
@@ -66,43 +101,43 @@
|
|
|
66
101
|
},
|
|
67
102
|
{
|
|
68
103
|
"path": "registry/pi-chat/empty-state.tsx",
|
|
69
|
-
"content": "import type { ReactNode } from 'react';\nimport { useChatText } from './labels';\n\nexport interface Suggestion { readonly label: string; readonly hint?: string; readonly text: string }\n\n/** Shown before the first message: a quiet title and optional starter prompts that fill the composer. */\nexport function EmptyState({ title, description, avatar, suggestions, onSelect, children }: {\n readonly title: string; readonly description?: string; readonly avatar?: ReactNode; readonly suggestions?: readonly Suggestion[]; readonly onSelect?: (text: string) => void; readonly children?: ReactNode;\n}) {\n const { labels
|
|
104
|
+
"content": "import type { ReactNode } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty';\nimport { useChatText } from './labels';\n\nexport interface Suggestion { readonly label: string; readonly hint?: string; readonly text: string }\n\n/** Shown before the first message: a quiet title and optional starter prompts that fill the composer. */\nexport function EmptyState({ title, description, avatar, suggestions, onSelect, children }: {\n readonly title: string; readonly description?: string; readonly avatar?: ReactNode; readonly suggestions?: readonly Suggestion[]; readonly onSelect?: (text: string) => void; readonly children?: ReactNode;\n}) {\n const { labels } = useChatText();\n return <Empty data-testid=\"empty-state\" className=\"mx-auto min-h-full w-full max-w-[720px] max-sm:mb-24\">\n <EmptyHeader>\n {avatar && <EmptyMedia data-testid=\"empty-state-icon\"><span className=\"flex size-8 items-center justify-center overflow-hidden [&>img]:size-full [&>img]:object-cover [&>svg]:size-5\">{avatar}</span></EmptyMedia>}\n <EmptyTitle>{title}</EmptyTitle>\n <EmptyDescription>{description ?? labels.emptyDescription}</EmptyDescription>\n </EmptyHeader>\n {(children || (suggestions && suggestions.length > 0)) && <EmptyContent>\n {children}\n {suggestions && suggestions.length > 0 && <ul className=\"m-0 flex w-full max-w-lg list-none flex-wrap justify-center gap-2 p-0\">\n {suggestions.map(item => <li key={item.label}><Button variant=\"ghost\" onClick={() => onSelect?.(item.text)} data-testid=\"suggestion\"\n className=\"h-auto flex-col gap-1 whitespace-normal\">\n <span>{item.label}</span>{item.hint && <span>{item.hint}</span>}</Button></li>)}\n </ul>}\n </EmptyContent>}\n </Empty>;\n}\n",
|
|
70
105
|
"type": "registry:component",
|
|
71
106
|
"target": "components/pi-chat/empty-state.tsx"
|
|
72
107
|
},
|
|
73
108
|
{
|
|
74
109
|
"path": "registry/pi-chat/history.tsx",
|
|
75
|
-
"content": "'use client';\n\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport type { AssistantMessage, Message } from '@earendil-works/pi-ai';\nimport type { ChatHistoryState } from '@hachej/boring-ui-kit/native-chat';\nimport { ArchiveIcon, ArchiveRestoreIcon, ArrowLeftIcon, CheckIcon, EllipsisIcon, MessageSquareIcon, PencilIcon, SearchIcon, Trash2Icon, XIcon } from 'lucide-react';\nimport { Button } from '../button/button';\nimport { ActionButton, ActionMenu, actionMenuItem, headerActions, menuActions } from '../button/actions';\nimport type { ConversationItem, ConversationsConfig, ReplyRef } from './config';\nimport { Markdown } from './markdown';\nimport { cn } from '../utils/utils';\nimport { isFileBlock, replyMeta } from './rows';\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. */\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 age = now - ms;\n return age < DAY ? 'today' : age < 2 * DAY ? 'yesterday' : age < 7 * DAY ? 'previousWeek' : 'older';\n}\nconst ORDER: readonly Group[] = ['today', 'yesterday', 'previousWeek', 'older', 'conversations'];\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 }: {\n readonly conversations: ConversationsConfig;\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 [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 } | 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 const now = useMemo(() => Date.now(), [conversations.items]); // eslint-disable-line react-hooks/exhaustive-deps\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 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 }); });\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 action = 'text-muted-foreground';\n const total = shown.reduce((sum, group) => sum + group.items.length, 0);\n return <>\n <div className=\"relative flex shrink-0 items-center gap-2 border-b border-border px-3 py-2\">\n <SearchIcon className=\"pointer-events-none absolute top-1/2 left-5.5 size-4 -translate-y-1/2 text-muted-foreground\" aria-hidden=\"true\" />\n <input ref={search} type=\"search\" value={query} onChange={event => setQuery(event.currentTarget.value)} placeholder={labels.searchConversations} aria-label={labels.searchConversations} data-testid=\"conversation-search\"\n className=\"h-9 w-full rounded-lg border border-border bg-background pr-3 pl-8 text-base outline-none max-sm:h-11 pointer-coarse:h-11 placeholder:text-muted-foreground focus-visible:border-ring/60 focus-visible:ring-[3px] focus-visible:ring-ring/15 sm:text-sm\" />\n {conversations.archive && serverSearch && <Button size=\"sm\" variant={archived ? 'secondary' : 'ghost'} aria-pressed={archived} data-testid=\"conversations-archived\" className=\"shrink-0 text-muted-foreground\"\n onClick={() => setArchived(value => !value)}><ArchiveIcon className=\"size-3.5\" aria-hidden=\"true\" />{labels.archived}</Button>}\n </div>\n {failure && <p role=\"alert\" className=\"m-0 shrink-0 px-3 pt-2 text-xs text-destructive\">{failure}</p>}\n <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain px-1.5 py-1.5\" aria-busy={loading ? 'true' : undefined}>\n {total === 0 && <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 => <section key={group.key} aria-label={labels[group.key]} className=\"mb-1\">\n <h4 className=\"m-0 px-2.5 pt-2 pb-1 text-xs font-medium tracking-wide text-muted-foreground\">{labels[group.key]}</h4>\n <ul className=\"m-0 list-none p-0\">\n {group.items.map(item => {\n const active = item.id === conversations.activeId;\n const 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 <li key={item.id}>\n <form className=\"flex min-h-10 items-center gap-2 px-2.5 py-1\" 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=\"h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-base outline-none focus-visible:border-ring/60 focus-visible:ring-[3px] focus-visible:ring-ring/15 sm:text-sm\" />\n <Button type=\"submit\" size=\"sm\" variant=\"outline\" data-testid=\"conversation-rename-save\">{labels.save}</Button>\n </form>\n </li>;\n }\n return <li key={item.id} className=\"group/row relative flex items-center\">\n <button type=\"button\" data-testid=\"conversation-row\" data-conversation-id={item.id} data-active={active ? 'true' : undefined} aria-current={active ? 'true' : undefined}\n title={item.lastMessage ?? undefined}\n onClick={() => { if (!active) conversations.onSelect(item.id); onPicked?.(); }}\n className={cn('flex min-h-10 w-full min-w-0 cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/60 max-sm:min-h-12 motion-reduce:transition-none',\n active ? 'bg-muted font-medium' : 'hover:bg-muted/60')}>\n <MessageSquareIcon className=\"size-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <span className=\"min-w-0 flex-1 truncate\">{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\" />{labels.current}</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 pr-1 pointer-fine:absolute pointer-fine:right-1 pointer-fine:rounded-md pointer-fine:bg-background pointer-fine:shadow-sm',\n confirming === item.id ? '' : 'pointer-fine:opacity-0 pointer-fine:group-hover/row:opacity-100 pointer-fine:group-focus-within/row: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} className={action} onClick={() => setConfirming(null)}><XIcon className=\"size-3.5\" aria-hidden=\"true\" /></Button>\n </> : <>\n {conversations.rename && <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.renameItem(title)} title={labels.rename} data-testid=\"conversation-rename\" className={action} onClick={() => setRenaming(item.id)}><PencilIcon className=\"size-3.5\" 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\" className={action}\n onClick={() => { const archive = conversations.archive!; run(() => archive(item.id, !item.archived)); }}>{item.archived ? <ArchiveRestoreIcon className=\"size-3.5\" aria-hidden=\"true\" /> : <ArchiveIcon className=\"size-3.5\" 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\" className={action} onClick={() => setConfirming(item.id)}><Trash2Icon className=\"size-3.5\" aria-hidden=\"true\" /></Button>}\n {extraButtons.map(entry => <ActionButton key={entry.id} action={entry} testId=\"conversation-action\" className={action} />)}\n {extraMenu.length > 0 && <ActionMenu label={labels.moreActions} size=\"icon-sm\" testId=\"conversation-action-more\" icon={<EllipsisIcon className=\"size-3.5\" aria-hidden=\"true\" />}\n items={extraMenu.map(entry => actionMenuItem(entry, 'conversation-action'))} />}\n </>}\n </div>}\n </li>;\n })}\n </ul>\n </section>)}\n </div>\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 full-screen sheet on a phone. Escape or a click outside closes it.\n */\nexport function ConversationHistory({ conversations, onClose, onBrowseEarlier, placement = 'below' }: {\n readonly conversations: ConversationsConfig;\n readonly onClose: () => void;\n /** When given, a footer action opens the earlier records of the open conversation. */\n readonly onBrowseEarlier?: (() => void) | undefined;\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 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 return <>\n <button type=\"button\" tabIndex={-1} aria-hidden=\"true\" data-testid=\"conversations-backdrop\" onClick={onClose} className={cn('z-20 hidden cursor-default sm:block', placement === 'above' ? 'fixed inset-0' : 'absolute inset-0')} />\n <section role=\"dialog\" aria-label={labels.historyDialog} data-testid=\"conversations\"\n className={cn('fixed inset-0 z-50 flex flex-col bg-background text-foreground sm:absolute sm:inset-auto sm:z-30 sm:w-[23rem] sm:rounded-2xl sm:border sm:border-border sm:shadow-lg',\n placement === 'above' ? 'sm:bottom-full sm:left-0 sm:mb-2 sm:max-h-[min(26rem,60dvh)]' : 'sm:top-12 sm:right-3 sm:max-h-[min(34rem,calc(100%-4rem))]')}>\n <header className=\"flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5 max-sm: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=\"sm:hidden\"><ArrowLeftIcon className=\"size-4\" aria-hidden=\"true\" /></Button>\n <h3 className=\"m-0 min-w-0 flex-1 font-(family-name:--chat-display) text-sm font-semibold tracking-tight\">{labels.history}</h3>\n {conversations.onNew && <Button size=\"sm\" variant=\"outline\" data-testid=\"conversation-new\" onClick={() => { conversations.onNew!(); onClose(); }}><icons.newChat className=\"size-3.5\" aria-hidden=\"true\" />{labels.newConversation}</Button>}\n <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.closeHistory} title={labels.close} onClick={onClose} className=\"max-sm:hidden\"><XIcon className=\"size-4\" aria-hidden=\"true\" /></Button>\n </header>\n <ConversationList conversations={conversations} onPicked={onClose} autoFocus />\n {onBrowseEarlier && <footer className=\"shrink-0 border-t border-border p-1.5 max-sm:pb-[max(0.375rem,env(safe-area-inset-bottom))]\">\n <Button size=\"sm\" variant=\"ghost\" data-testid=\"history-earlier\" className=\"w-full justify-start text-muted-foreground\" onClick={() => { onBrowseEarlier(); onClose(); }}>\n <icons.history className=\"size-3.5\" aria-hidden=\"true\" />{labels.earlierInConversation}</Button>\n </footer>}\n </section>\n </>;\n}\n\nfunction line(message: Message, developer: boolean, labels: ChatLabels): { readonly role: string; readonly body: string; readonly assistant?: AssistantMessage } | undefined {\n if (message.role === 'system') return developer ? { role: labels.roleSystem, body: typeof message.content === 'string' ? message.content : message.content.map(part => part.text).join('\\n') } : undefined;\n if (message.role === 'toolResult') return { role: labels.roleToolResult(message.toolName, message.isError), body: message.content.map(part => part.type === 'text' ? part.text : '[image result]').join('\\n') };\n if (message.role === 'user') return { role: labels.roleUser, body: typeof message.content === 'string' ? message.content : message.content.filter(part => part.type !== 'text' || !isFileBlock(part.text)).map(part => part.type === 'text' ? part.text : '[image attachment]').join('\\n') };\n const body = message.content.map(part => part.type === 'text' ? part.text : part.type === 'toolCall' ? `[tool call: ${part.name}]` : developer ? `[reasoning]\\n${part.thinking}` : '').filter(Boolean).join('\\n\\n');\n return { role: labels.roleAssistant(message.stopReason === 'aborted' ? 'interrupted' : message.stopReason === 'error' ? 'failed' : 'ok'), body, ...(message.stopReason === 'error' || message.stopReason === 'aborted' ? {} : { assistant: message }) };\n}\n\ntype HistoryRow = { readonly key: string; readonly role: string; readonly body: string; readonly entryId: string; readonly assistant: AssistantMessage | undefined };\nconst replyText = (message: AssistantMessage) => message.content.flatMap(part => part.type === 'text' && part.text ? [part.text] : []).join('\\n\\n');\n\n/** Read-only older records of the open conversation, paged in by the controller. Historical tool results are not current actions. */\nexport function HistoryPanel({ history, developer, connected, onLoad, onClose, replyFooter }: {\n readonly history: ChatHistoryState; readonly developer: boolean; readonly connected: boolean; readonly onLoad: () => void; readonly onClose: () => void;\n /** The host's reply footer, shown under each earlier reply that has text. Earlier records get no actions. */\n readonly replyFooter?: ((reply: ReplyRef) => ReactNode) | undefined;\n}) {\n const { labels, icons } = useChatText();\n const entries: readonly EntryRecord[] = 'entries' in history ? history.entries : [];\n const rows = entries.flatMap((entry): HistoryRow[] => entry.model?.length\n ? entry.model.flatMap((message, index) => { const made = line(message, developer, labels); return made ? [{ key: `history:${entry.id}:${index}`, entryId: String(entry.id), assistant: undefined, ...made }] : []; })\n : [{ key: `history:${entry.id}`, role: labels.roleEvent, body: entry.head === undefined ? entry.kind : labels.contextBoundary, entryId: String(entry.id), assistant: undefined }]);\n const exhausted = history.kind === 'ready' && !history.hasMore;\n return <section aria-label={labels.earlierMessages} data-testid=\"history\" aria-live=\"off\" className=\"shrink-0 border-b border-border bg-muted/20\">\n <div className=\"mx-auto flex max-w-3xl items-center gap-2 px-4 py-2\">\n <icons.history className=\"size-4 text-muted-foreground\" aria-hidden=\"true\" />\n <h3 className=\"m-0 min-w-0 flex-1 truncate font-(family-name:--chat-display) text-sm font-medium\">{labels.earlierMessages} <span className=\"font-normal text-muted-foreground\">{labels.readOnly}</span></h3>\n <Button size=\"sm\" variant=\"outline\" data-testid=\"history-load\" onClick={onLoad} disabled={!connected || history.kind === 'loading' || history.kind === 'disabled' || exhausted}>\n {history.kind === 'error' ? labels.retry : history.kind === 'loading' ? labels.loadingShort : labels.loadEarlier}</Button>\n <Button size=\"icon-sm\" aria-label={labels.returnToConversation} title={labels.returnToConversation} onClick={onClose}><XIcon className=\"size-4\" aria-hidden=\"true\" /></Button>\n </div>\n <div className=\"mx-auto max-h-[40dvh] max-w-3xl space-y-3 overflow-y-auto px-4 pb-3\">\n {history.kind === 'error' && <p role=\"alert\" className=\"m-0 text-sm text-destructive\">{labels.historyFailed}</p>}\n {history.kind === 'idle' && <p className=\"m-0 text-sm text-muted-foreground\">{labels.loadPage}</p>}\n {rows.map(row => <article key={row.key} data-history-row-id={row.key} className=\"rounded-lg border border-border bg-background/60 px-3 py-2\">\n <p className=\"m-0 mb-1 text-xs font-medium tracking-wide text-muted-foreground uppercase\">{row.role}</p>\n <div className=\"text-sm opacity-90\"><Markdown text={row.body} /></div>\n {replyFooter && row.assistant && replyText(row.assistant) && replyFooter({ key: row.key, text: replyText(row.assistant), entryId: row.entryId, ...replyMeta(row.assistant) })}</article>)}\n {history.kind === 'ready' && !rows.length && <p className=\"m-0 text-sm text-muted-foreground\">{labels.noRecords}</p>}\n {exhausted && <p className=\"m-0 text-center text-xs text-muted-foreground\">{labels.beginning}</p>}\n </div>\n </section>;\n}\n",
|
|
110
|
+
"content": "'use client';\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 { 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. */\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-sm: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 } | 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 const now = useMemo(() => Date.now(), [conversations.items]); // eslint-disable-line react-hooks/exhaustive-deps\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 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 }); });\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 <ScrollArea className=\"min-h-0 flex-1\" aria-busy={loading ? 'true' : undefined}>\n {total === 0 && <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?.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={item.lastMessage ?? undefined}\n onClick={() => { if (!active) conversations.onSelect(item.id); onPicked?.(); }}\n className=\"h-auto min-h-10 min-w-0 flex-1 justify-start max-sm: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, setPhone] = useState(() => globalThis.matchMedia?.('(max-width: 640px)').matches === true);\n useEffect(() => { const media = globalThis.matchMedia?.('(max-width: 640px)'); if (!media) return; const change = () => setPhone(media.matches); media.addEventListener('change', change); return () => media.removeEventListener('change', change); }, []);\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-sm: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=\"sm: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-sm: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 <Button variant=\"ghost\" data-testid=\"transcript-history-load\" className=\"sr-only focus:not-sr-only\" disabled={!connected || history.kind === 'loading' || exhausted} 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",
|
|
76
111
|
"type": "registry:component",
|
|
77
112
|
"target": "components/pi-chat/history.tsx"
|
|
78
113
|
},
|
|
79
114
|
{
|
|
80
115
|
"path": "registry/pi-chat/markdown.tsx",
|
|
81
|
-
"content": "'use client';\n\nimport { createElement, Fragment, memo, useMemo } from 'react';\nimport type { ReactNode } from 'react';\nimport { Marked } from 'marked';\nimport { decodeHTML } from 'entities';\nimport { CodeBlock } from './code-block';\nimport { useChatText } from './labels';\nimport type { ChatLabels } from './labels';\n\n/*\n * Safe Markdown for model output. Tokens are mapped to React elements one by one: raw HTML is shown as text, images are\n * shown as their alt text (nothing is fetched), and a link is only rendered when it is a plain http(s) URL.\n * Ported from the v4 `@hachej/boring-ui-kit` chat markdown renderer; the styling is Tailwind and fenced code gets a code block card.\n */\nexport interface CommandMention { readonly name: string; readonly behavior: 'execute' | 'insert' }\nexport interface CommandMentions {\n readonly commands: readonly CommandMention[];\n readonly onActivate: (command: CommandMention) => void;\n}\n\nconst markdown = new Marked({ gfm: true, breaks: false, pedantic: false });\n\nfunction isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === 'object' && !Array.isArray(value); }\nfunction text(value: unknown): string { return typeof value === 'string' ? value : ''; }\nfunction visible(value: unknown): string {\n if (!isRecord(value)) return '';\n if (value['type'] === 'br') return '\\n';\n if (Array.isArray(value['tokens'])) return value['tokens'].map(visible).join('');\n const content = text(value['text']);\n return value['type'] === 'text' && value['escaped'] !== true ? decodeHTML(content) : content;\n}\nfunction containsHtml(value: unknown): boolean {\n if (!isRecord(value)) return false;\n return value['type'] === 'html' || Array.isArray(value['tokens']) && value['tokens'].some(containsHtml);\n}\nfunction literal(value: string): ReactNode { return <span className=\"whitespace-pre-wrap\">{value}</span>; }\n\ninterface Context { readonly mentions: CommandMentions | undefined; readonly onCopy: ((text: string) => Promise<void>) | undefined; readonly labels: ChatLabels }\n\nfunction children(value: unknown, context: Context, before = '', after = '', inline = false): ReactNode {\n if (!Array.isArray(value)) return null;\n if (!context.mentions) return value.map((token: unknown, index) => <Fragment key={index}>{renderToken(token, context)}</Fragment>);\n const shown = value.map(visible), source = before + shown.join('') + after;\n const active = inline && value.some(containsHtml) ? { ...context, mentions: undefined } : context;\n let position = before.length;\n return value.map((token: unknown, index) => {\n const start = position;\n position += shown[index]?.length ?? 0;\n return <Fragment key={index}>{renderToken(token, active, source.slice(0, start), source.slice(position))}</Fragment>;\n });\n}\n\nconst mentionPattern = /(^|[\\s(\\[{\"'“‘])\\/([A-Za-z0-9_][A-Za-z0-9_-]*)(?=$|\\s|[.,;!?)}\\]\"'”’]+(?=$|\\s))/g;\nfunction mentionText(value: string, mentions: CommandMentions | undefined, before: string, after: string, labels: ChatLabels): ReactNode {\n if (!mentions?.commands.length || !value.includes('/')) return value;\n const commands = new Map(mentions.commands.filter(item => /^[A-Za-z0-9_][A-Za-z0-9_-]*$/.test(item.name)\n && (item.behavior === 'execute' || item.behavior === 'insert')).map(item => [item.name, item]));\n if (!commands.size) return value;\n const source = before + value + after, start = before.length, end = start + value.length;\n const pieces: ReactNode[] = [];\n let cursor = 0;\n for (const match of source.matchAll(mentionPattern)) {\n if (match.index === undefined) continue;\n const boundary = match[1] ?? '', name = match[2];\n if (!name) continue;\n const offset = match.index + boundary.length, limit = offset + name.length + 1;\n const command: CommandMention | undefined = commands.get(name);\n if (!command || offset < start || limit > end || offset - start < cursor) continue;\n const selected = { name: command.name, behavior: command.behavior };\n const at = offset - start;\n if (at > cursor) pieces.push(value.slice(cursor, at));\n pieces.push(<button key={at} type=\"button\" data-testid=\"command-mention\" aria-label={labels.runCommand(selected.name, selected.behavior === 'execute')}\n className=\"cursor-pointer rounded bg-muted px-1 font-mono text-[0.92em] text-foreground hover:bg-accent\" onClick={() => mentions.onActivate(selected)}>/{selected.name}</button>);\n cursor = limit - start;\n }\n if (!pieces.length) return value;\n if (cursor < value.length) pieces.push(value.slice(cursor));\n return pieces;\n}\n\nfunction linkTarget(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined;\n const decoded = decodeHTML(value);\n if (!/^https?:\\/\\//i.test(decoded) || /[\\u0000- \\u007f-\\u009f\\\\]/.test(decoded)) return undefined;\n const authority = /^https?:\\/\\/([^/?#]*)/i.exec(decoded)?.[1];\n if (!authority || authority.includes('@')) return undefined;\n try {\n const url = new URL(decoded);\n if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return undefined;\n return url.href;\n } catch { return undefined; }\n}\n\nfunction cells(value: unknown, header: boolean, context: Context): ReactNode {\n if (!Array.isArray(value)) return null;\n return value.map((cell: unknown, index) => {\n if (!isRecord(cell)) return null;\n const alignment = cell['align'];\n const textAlign = alignment === 'left' || alignment === 'center' || alignment === 'right' ? alignment : undefined;\n return createElement(header ? 'th' : 'td', { key: index, style: { textAlign }, className: header ? 'px-3 py-1.5 font-semibold' : 'px-3 py-1.5', ...(header ? { scope: 'col' } : {}) },\n children(cell['tokens'], context, '', '', true));\n });\n}\n\nconst HEADINGS: Record<number, string> = {\n 1: 'mt-6 mb-2 text-xl font-semibold tracking-tight', 2: 'mt-5 mb-2 text-base font-semibold tracking-tight', 3: 'mt-4 mb-1.5 text-sm font-semibold',\n 4: 'mt-4 mb-1 text-sm font-semibold', 5: 'mt-3 mb-1 text-sm font-medium', 6: 'mt-3 mb-1 text-sm font-medium text-muted-foreground',\n};\n\n// Marked's extension token includes arbitrary fields. Narrow those fields here before creating React nodes.\nfunction renderToken(value: unknown, context: Context, before = '', after = ''): ReactNode {\n if (!isRecord(value)) return null;\n const raw = text(value['raw']), content = text(value['text']), mentions = context.mentions;\n switch (value['type']) {\n case 'space': case 'def': return null;\n case 'paragraph': return <p className=\"my-3 first:mt-0 last:mb-0\">{children(value['tokens'], context, '', '', true)}</p>;\n case 'heading': {\n const depth = value['depth'];\n return typeof depth === 'number' && Number.isInteger(depth) && depth >= 1 && depth <= 6\n ? createElement(`h${depth}`, { className: `${HEADINGS[depth]} first:mt-0` }, children(value['tokens'], context, '', '', true)) : literal(raw);\n }\n case 'text': return Array.isArray(value['tokens']) ? children(value['tokens'], context, before, after, true)\n : value['escaped'] === true ? literal(content) : mentionText(decodeHTML(content), mentions, before, after, context.labels);\n case 'escape': return content;\n case 'strong': return <strong className=\"font-semibold text-foreground\">{children(value['tokens'], context, before, after, true)}</strong>;\n case 'em': return <em>{children(value['tokens'], context, before, after, true)}</em>;\n case 'del': return <del className=\"text-muted-foreground\">{children(value['tokens'], context, before, after, true)}</del>;\n case 'blockquote': return <blockquote className=\"my-3 rounded-lg border border-border bg-muted/40 px-4 py-2 text-muted-foreground [&_p]:my-1\">{children(value['tokens'], context)}</blockquote>;\n case 'br': return <br />;\n case 'hr': return <hr className=\"my-5 border-border\" />;\n case 'codespan': return <code className=\"rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.85em] whitespace-pre-wrap\">{content}</code>;\n case 'code': return <CodeBlock code={content} {...(typeof value['lang'] === 'string' && value['lang'] ? { language: value['lang'] } : {})} {...(context.onCopy ? { onCopy: context.onCopy } : {})} />;\n case 'html': return value['block'] === true ? <pre className=\"my-3 overflow-x-auto rounded-lg bg-muted/50 p-3 font-mono text-xs whitespace-pre-wrap\">{raw}</pre> : literal(raw);\n case 'image': return literal(decodeHTML(content) || raw);\n case 'link': {\n const href = linkTarget(value['href']), title = typeof value['title'] === 'string' ? decodeHTML(value['title']) : undefined;\n return href ? <a href={href} title={title} target=\"_blank\" rel=\"noopener noreferrer\"\n className=\"font-medium text-foreground underline decoration-muted-foreground/50 underline-offset-4 hover:decoration-foreground\">{children(value['tokens'], context)}</a> : children(value['tokens'], context);\n }\n case 'list': {\n const start = value['start'];\n return value['ordered'] === true\n ? <ol start={typeof start === 'number' && Number.isSafeInteger(start) ? start : undefined} className=\"my-3 list-decimal ps-6 marker:text-muted-foreground\">{children(value['items'], context)}</ol>\n : <ul className=\"my-3 list-disc ps-6 marker:text-muted-foreground\">{children(value['items'], context)}</ul>;\n }\n case 'list_item': return <li className=\"ps-1 [&+li]:mt-1 [&>p]:my-1 [&>ol]:my-1 [&>ul]:my-1\">{children(value['tokens'], context, '', '', true)}</li>;\n case 'checkbox': return <input type=\"checkbox\" checked={value['checked'] === true} disabled readOnly aria-label={context.labels.taskStatus} className=\"me-1.5 align-middle accent-foreground\" />;\n case 'table': return <div className=\"my-3 overflow-x-auto rounded-lg border border-border\"><table className=\"w-full border-collapse text-left tabular-nums\">\n <thead className=\"bg-muted/50\">{<tr>{cells(value['header'], true, context)}</tr>}</thead>\n <tbody className=\"[&_tr]:border-t [&_tr]:border-border\">{Array.isArray(value['rows']) ? value['rows'].map((row: unknown, index) => <tr key={index}>{cells(row, false, context)}</tr>) : null}</tbody></table></div>;\n default: return literal(raw);\n }\n}\n\n/** One top-level block; unchanged blocks skip re-rendering while a long answer streams. */\nconst Block = memo(function Block({ token, context }: { readonly token: unknown; readonly context: Context }) {\n return <>{renderToken(token, context)}</>;\n}, (a, b) => isRecord(a.token) && isRecord(b.token) && a.token['raw'] === b.token['raw'] && a.token['type'] === b.token['type'] && a.context.mentions === b.context.mentions && a.context.onCopy === b.context.onCopy && a.context.labels === b.context.labels);\n\nexport const Markdown = memo(function Markdown({ text: source, commandMentions, onCopy, streaming = false }: {\n readonly text: string; readonly commandMentions?: CommandMentions; readonly onCopy?: (text: string) => Promise<void>; readonly streaming?: boolean;\n}) {\n const tokens = useMemo(() => { try { return markdown.lexer(source); } catch { return null; } }, [source]);\n const mentions = source.includes('/') && commandMentions?.commands.length ? commandMentions : undefined;\n const { labels } = useChatText();\n const context = useMemo<Context>(() => ({ mentions, onCopy, labels }), [mentions, onCopy, labels]);\n const body = useMemo(() => {\n if (!tokens) return literal(source);\n // With mentions enabled, positions are relative to the whole text, so render the token list as one unit.\n if (mentions) return children(tokens, context);\n return tokens.map((token, index) => <Block key={index} token={token} context={context} />);\n }, [tokens, source, mentions, context]);\n return <div data-testid=\"message-text\" data-streaming={streaming ? 'true' : undefined} className=\"min-w-0 text-sm leading-relaxed [overflow-wrap:anywhere]\">\n {body}\n </div>;\n});\n",
|
|
116
|
+
"content": "'use client';\n\nimport { createElement, Fragment, memo, useMemo } from 'react';\nimport type { ReactNode } from 'react';\nimport { Marked } from 'marked';\nimport { decodeHTML } from 'entities';\nimport { CodeBlock } from './code-block';\nimport { useChatText } from './labels';\nimport type { ChatLabels } from './labels';\nimport { Button } from '@/components/ui/button';\nimport { Checkbox } from '@/components/ui/checkbox';\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';\n\n/*\n * Safe Markdown for model output. Tokens are mapped to React elements one by one: raw HTML is shown as text, images are\n * shown as their alt text (nothing is fetched), and a link is only rendered when it is a plain http(s) URL.\n * Ported from the v4 `@hachej/boring-ui-kit` chat markdown renderer; the styling is Tailwind and fenced code gets a code block card.\n */\nexport interface CommandMention { readonly name: string; readonly behavior: 'execute' | 'insert' }\nexport interface CommandMentions {\n readonly commands: readonly CommandMention[];\n readonly onActivate: (command: CommandMention) => void;\n}\n\nconst markdown = new Marked({ gfm: true, breaks: false, pedantic: false });\n\nfunction isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === 'object' && !Array.isArray(value); }\nfunction text(value: unknown): string { return typeof value === 'string' ? value : ''; }\nfunction visible(value: unknown): string {\n if (!isRecord(value)) return '';\n if (value['type'] === 'br') return '\\n';\n if (Array.isArray(value['tokens'])) return value['tokens'].map(visible).join('');\n const content = text(value['text']);\n return value['type'] === 'text' && value['escaped'] !== true ? decodeHTML(content) : content;\n}\nfunction containsHtml(value: unknown): boolean {\n if (!isRecord(value)) return false;\n return value['type'] === 'html' || Array.isArray(value['tokens']) && value['tokens'].some(containsHtml);\n}\nfunction literal(value: string): ReactNode { return <span className=\"whitespace-pre-wrap\">{value}</span>; }\n\ninterface Context { readonly mentions: CommandMentions | undefined; readonly onCopy: ((text: string) => Promise<void>) | undefined; readonly labels: ChatLabels }\n\nfunction children(value: unknown, context: Context, before = '', after = '', inline = false): ReactNode {\n if (!Array.isArray(value)) return null;\n if (!context.mentions) return value.map((token: unknown, index) => <Fragment key={index}>{renderToken(token, context)}</Fragment>);\n const shown = value.map(visible), source = before + shown.join('') + after;\n const active = inline && value.some(containsHtml) ? { ...context, mentions: undefined } : context;\n let position = before.length;\n return value.map((token: unknown, index) => {\n const start = position;\n position += shown[index]?.length ?? 0;\n return <Fragment key={index}>{renderToken(token, active, source.slice(0, start), source.slice(position))}</Fragment>;\n });\n}\n\nconst mentionPattern = /(^|[\\s(\\[{\"'“‘])\\/([A-Za-z0-9_][A-Za-z0-9_-]*)(?=$|\\s|[.,;!?)}\\]\"'”’]+(?=$|\\s))/g;\nfunction mentionText(value: string, mentions: CommandMentions | undefined, before: string, after: string, labels: ChatLabels): ReactNode {\n if (!mentions?.commands.length || !value.includes('/')) return value;\n const commands = new Map(mentions.commands.filter(item => /^[A-Za-z0-9_][A-Za-z0-9_-]*$/.test(item.name)\n && (item.behavior === 'execute' || item.behavior === 'insert')).map(item => [item.name, item]));\n if (!commands.size) return value;\n const source = before + value + after, start = before.length, end = start + value.length;\n const pieces: ReactNode[] = [];\n let cursor = 0;\n for (const match of source.matchAll(mentionPattern)) {\n if (match.index === undefined) continue;\n const boundary = match[1] ?? '', name = match[2];\n if (!name) continue;\n const offset = match.index + boundary.length, limit = offset + name.length + 1;\n const command: CommandMention | undefined = commands.get(name);\n if (!command || offset < start || limit > end || offset - start < cursor) continue;\n const selected = { name: command.name, behavior: command.behavior };\n const at = offset - start;\n if (at > cursor) pieces.push(value.slice(cursor, at));\n pieces.push(<Button key={at} variant=\"secondary\" size=\"sm\" data-testid=\"command-mention\" aria-label={labels.runCommand(selected.name, selected.behavior === 'execute')}\n onClick={() => mentions.onActivate(selected)}>/{selected.name}</Button>);\n cursor = limit - start;\n }\n if (!pieces.length) return value;\n if (cursor < value.length) pieces.push(value.slice(cursor));\n return pieces;\n}\n\nfunction linkTarget(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined;\n const decoded = decodeHTML(value);\n if (!/^https?:\\/\\//i.test(decoded) || /[\\u0000- \\u007f-\\u009f\\\\]/.test(decoded)) return undefined;\n const authority = /^https?:\\/\\/([^/?#]*)/i.exec(decoded)?.[1];\n if (!authority || authority.includes('@')) return undefined;\n try {\n const url = new URL(decoded);\n if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return undefined;\n return url.href;\n } catch { return undefined; }\n}\n\nfunction cells(value: unknown, header: boolean, context: Context): ReactNode {\n if (!Array.isArray(value)) return null;\n return value.map((cell: unknown, index) => {\n if (!isRecord(cell)) return null;\n const alignment = cell['align'];\n const textAlign = alignment === 'left' || alignment === 'center' || alignment === 'right' ? alignment : undefined;\n return createElement(header ? TableHead : TableCell, { key: index, style: { textAlign }, ...(header ? { scope: 'col' } : {}) },\n children(cell['tokens'], context, '', '', true));\n });\n}\n\nconst HEADINGS: Record<number, string> = {\n 1: 'mt-6 mb-2 text-xl font-semibold tracking-tight', 2: 'mt-5 mb-2 text-base font-semibold tracking-tight', 3: 'mt-4 mb-1.5 text-sm font-semibold',\n 4: 'mt-4 mb-1 text-sm font-semibold', 5: 'mt-3 mb-1 text-sm font-medium', 6: 'mt-3 mb-1 text-sm font-medium text-muted-foreground',\n};\n\n// Marked's extension token includes arbitrary fields. Narrow those fields here before creating React nodes.\nfunction renderToken(value: unknown, context: Context, before = '', after = ''): ReactNode {\n if (!isRecord(value)) return null;\n const raw = text(value['raw']), content = text(value['text']), mentions = context.mentions;\n switch (value['type']) {\n case 'space': case 'def': return null;\n case 'paragraph': return <p className=\"my-3 first:mt-0 last:mb-0\">{children(value['tokens'], context, '', '', true)}</p>;\n case 'heading': {\n const depth = value['depth'];\n return typeof depth === 'number' && Number.isInteger(depth) && depth >= 1 && depth <= 6\n ? createElement(`h${depth}`, { className: `${HEADINGS[depth]} first:mt-0` }, children(value['tokens'], context, '', '', true)) : literal(raw);\n }\n case 'text': return Array.isArray(value['tokens']) ? children(value['tokens'], context, before, after, true)\n : value['escaped'] === true ? literal(content) : mentionText(decodeHTML(content), mentions, before, after, context.labels);\n case 'escape': return content;\n case 'strong': return <strong className=\"font-semibold text-foreground\">{children(value['tokens'], context, before, after, true)}</strong>;\n case 'em': return <em>{children(value['tokens'], context, before, after, true)}</em>;\n case 'del': return <del className=\"text-muted-foreground\">{children(value['tokens'], context, before, after, true)}</del>;\n case 'blockquote': return <blockquote className=\"my-3 px-4 py-2 text-muted-foreground [&_p]:my-1\">{children(value['tokens'], context)}</blockquote>;\n case 'br': return <br />;\n case 'hr': return <hr className=\"my-5 border-border\" />;\n case 'codespan': return <code className=\"bg-muted px-1.5 py-0.5 font-mono text-sm whitespace-pre-wrap\">{content}</code>;\n case 'code': return <CodeBlock code={content} {...(typeof value['lang'] === 'string' && value['lang'] ? { language: value['lang'] } : {})} {...(context.onCopy ? { onCopy: context.onCopy } : {})} />;\n case 'html': return value['block'] === true ? <pre className=\"my-3 overflow-x-auto font-mono text-xs whitespace-pre-wrap\">{raw}</pre> : literal(raw);\n case 'image': return literal(decodeHTML(content) || raw);\n case 'link': {\n const href = linkTarget(value['href']), title = typeof value['title'] === 'string' ? decodeHTML(value['title']) : undefined;\n return href ? <a href={href} title={title} target=\"_blank\" rel=\"noopener noreferrer\"\n className=\"font-medium text-foreground underline decoration-muted-foreground/50 underline-offset-4 hover:decoration-foreground\">{children(value['tokens'], context)}</a> : children(value['tokens'], context);\n }\n case 'list': {\n const start = value['start'];\n return value['ordered'] === true\n ? <ol start={typeof start === 'number' && Number.isSafeInteger(start) ? start : undefined} className=\"my-3 list-decimal ps-6 marker:text-muted-foreground\">{children(value['items'], context)}</ol>\n : <ul className=\"my-3 list-disc ps-6 marker:text-muted-foreground\">{children(value['items'], context)}</ul>;\n }\n case 'list_item': return <li className=\"ps-1 [&+li]:mt-1 [&>p]:my-1 [&>ol]:my-1 [&>ul]:my-1\">{children(value['tokens'], context, '', '', true)}</li>;\n case 'checkbox': return <Checkbox className=\"inline-flex\" checked={value['checked'] === true} disabled aria-label={context.labels.taskStatus} />;\n case 'table': return <Table>\n <TableHeader><TableRow>{cells(value['header'], true, context)}</TableRow></TableHeader>\n <TableBody>{Array.isArray(value['rows']) ? value['rows'].map((row: unknown, index) => <TableRow key={index}>{cells(row, false, context)}</TableRow>) : null}</TableBody></Table>;\n default: return literal(raw);\n }\n}\n\n/** One top-level block; unchanged blocks skip re-rendering while a long answer streams. */\nconst Block = memo(function Block({ token, context }: { readonly token: unknown; readonly context: Context }) {\n return <>{renderToken(token, context)}</>;\n}, (a, b) => isRecord(a.token) && isRecord(b.token) && a.token['raw'] === b.token['raw'] && a.token['type'] === b.token['type'] && a.context.mentions === b.context.mentions && a.context.onCopy === b.context.onCopy && a.context.labels === b.context.labels);\n\nexport const Markdown = memo(function Markdown({ text: source, commandMentions, onCopy, streaming = false }: {\n readonly text: string; readonly commandMentions?: CommandMentions; readonly onCopy?: (text: string) => Promise<void>; readonly streaming?: boolean;\n}) {\n const tokens = useMemo(() => { try { return markdown.lexer(source); } catch { return null; } }, [source]);\n const mentions = source.includes('/') && commandMentions?.commands.length ? commandMentions : undefined;\n const { labels } = useChatText();\n const context = useMemo<Context>(() => ({ mentions, onCopy, labels }), [mentions, onCopy, labels]);\n const body = useMemo(() => {\n if (!tokens) return literal(source);\n // With mentions enabled, positions are relative to the whole text, so render the token list as one unit.\n if (mentions) return children(tokens, context);\n return tokens.map((token, index) => <Block key={index} token={token} context={context} />);\n }, [tokens, source, mentions, context]);\n return <div data-testid=\"message-text\" data-streaming={streaming ? 'true' : undefined} className=\"min-w-0 [overflow-wrap:anywhere]\">\n {body}\n </div>;\n});\n",
|
|
82
117
|
"type": "registry:component",
|
|
83
118
|
"target": "components/pi-chat/markdown.tsx"
|
|
84
119
|
},
|
|
85
120
|
{
|
|
86
121
|
"path": "registry/pi-chat/mention-menu.tsx",
|
|
87
|
-
"content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport { FileTextIcon, FolderIcon } from 'lucide-react';\nimport { usePickerKeyboard } from './picker-keyboard';\nimport type { MentionResult, MentionsConfig } from './config';\nimport {
|
|
122
|
+
"content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { FileTextIcon, FolderIcon } from 'lucide-react';\nimport { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { usePickerKeyboard } from './picker-keyboard';\nimport type { MentionResult, MentionsConfig } from './config';\nimport { useChatText } from './labels';\n\n/**\n * The host's shadcn Popover above the composer, anchored to the nearest positioned ancestor's top edge and as wide as it.\n * Focus stays in the message box (the picker keyboard drives the rows); outside presses dismiss it.\n */\nexport function ComposerSuggestions({ testId, onDismiss, children }: { readonly testId: string; readonly onDismiss: () => void; readonly children: ReactNode }) {\n return <Popover open modal={false} onOpenChange={open => { if (!open) onDismiss(); }}>\n <PopoverTrigger nativeButton={false} render={<span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-0 top-0\" />} />\n <PopoverContent data-testid={testId} side=\"top\" align=\"start\" initialFocus={false} finalFocus={false} className=\"pi-chat w-(--anchor-width) max-w-[calc(100vw-1rem)]\">\n {children}\n </PopoverContent>\n </Popover>;\n}\n\nfunction highlight(text: string, query: string) {\n const at = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1;\n if (at < 0) return <>{text}</>;\n return <>{text.slice(0, at)}<mark className=\"bg-transparent text-foreground underline underline-offset-2\">{text.slice(at, at + query.length)}</mark>{text.slice(at + query.length)}</>;\n}\n\n/** The `@` file picker, ported from v2's `MentionPicker`: debounced search, one highlighted row, name over path. */\nexport function MentionPicker({ query, search, generation, onSelect, onDismiss }: {\n readonly query: string;\n readonly generation?: MentionsConfig['generation'];\n readonly search: MentionsConfig['search'];\n readonly onSelect: (path: string) => void;\n readonly onDismiss: () => void;\n}) {\n const { labels } = useChatText();\n // Results are tagged with the search function that produced them: a binding switch hides the previous binding's rows at once.\n const [outcome, setOutcome] = useState<{ readonly search: MentionsConfig['search']; readonly results: readonly MentionResult[]; readonly failure: string | null }>();\n const shown = outcome?.search === search ? outcome : undefined;\n const results = shown?.results ?? [];\n const failure = shown?.failure ?? null;\n const [active, setActive] = useState(0);\n const list = useRef<HTMLUListElement>(null);\n const searcher = useRef(search); searcher.current = search;\n useEffect(() => {\n setActive(0);\n const abort = new AbortController();\n const timer = setTimeout(() => {\n searcher.current(query, abort.signal).then(found => { if (!abort.signal.aborted) setOutcome({ search, results: found.slice(0, 8), failure: null }); },\n cause => { if (!abort.signal.aborted) setOutcome({ search, results: [], failure: cause instanceof Error ? cause.message : labels.searchFailed }); });\n }, 120);\n return () => { clearTimeout(timer); abort.abort(); };\n }, [query, search, generation]);\n usePickerKeyboard({ count: results.length, activeIdx: active, setActiveIdx: setActive, listRef: list, onSelect: index => { const item = results[index]; if (item) onSelect(item.path); }, onDismiss });\n\n if (results.length === 0 && !failure) return null;\n return <ComposerSuggestions testId=\"mention-menu\" onDismiss={onDismiss}>\n {failure ? <div role=\"alert\" data-testid=\"mention-error\" className=\"text-xs text-destructive\">{failure}</div>\n : <ul ref={list} role=\"listbox\" aria-label={labels.workspaceFiles} className=\"m-0 flex max-h-[min(14rem,40dvh)] list-none flex-col overflow-y-auto p-0\">\n {results.map((item, index) => {\n const name = item.path.split('/').pop() ?? item.path;\n const Icon = item.kind === 'directory' ? FolderIcon : FileTextIcon;\n return <Item key={item.path} render={<li />} size=\"xs\" variant={index === active ? 'muted' : 'default'} role=\"option\" aria-selected={index === active} data-testid=\"mention-item\" data-path={item.path} title={item.path}\n onMouseEnter={() => setActive(index)} onMouseDown={event => { event.preventDefault(); onSelect(item.path); }}\n className=\"cursor-pointer flex-nowrap max-sm:min-h-11 pointer-coarse:min-h-11\">\n <ItemMedia variant=\"icon\"><Icon aria-hidden=\"true\" /></ItemMedia>\n <ItemContent className=\"min-w-0\"><ItemTitle>{highlight(name, query)}</ItemTitle>\n <ItemDescription className=\"m-0 truncate\">{item.path}</ItemDescription></ItemContent>\n </Item>;\n })}\n </ul>}\n </ComposerSuggestions>;\n}\n",
|
|
88
123
|
"type": "registry:component",
|
|
89
124
|
"target": "components/pi-chat/mention-menu.tsx"
|
|
90
125
|
},
|
|
91
126
|
{
|
|
92
127
|
"path": "registry/pi-chat/message.tsx",
|
|
93
|
-
"content": "'use client';\n\nimport { useEffect, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ImageContent, ToolResultMessage, UserMessage } from '@earendil-works/pi-ai';\nimport { AlertCircleIcon, CircleSlashIcon, FileIcon, FileSpreadsheetIcon, FileTextIcon, ImageIcon, PresentationIcon } from 'lucide-react';\nimport { CopyButton } from './code-block';\nimport { Markdown } from './markdown';\nimport type { CommandMentions } from './markdown';\nimport { ArtifactCard } from './artifact-card';\nimport type { ArtifactsConfig } from './artifact';\nimport { ApprovalCard } from './approval-card';\nimport { QuestionCard } from './question-card';\nimport type { AnswerOutcome } from './question-card';\nimport { ActivityBlock } from './activity';\nimport type { ActivityState } from './activity';\nimport { Shimmer } from './shimmer';\nimport { ToolCard } from './tool';\nimport { thumbnail } from './composer';\nimport { displayName, fileKind, isUploadPath, pieces, visibleParts } from './config';\nimport type { FileKind, Piece, PreviewRead } from './config';\nimport { isFileBlock, segments } from './rows';\nimport type { DeriveOptions, Row } from './rows';\nimport { Button } from '../button/button';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { useChatText } from './labels';\nimport type { ReplyRef } from './config';\nimport { cn } from '../utils/utils';\n\nexport interface RowContext {\n readonly developer: boolean;\n readonly groupTool: DeriveOptions['groupTool'];\n readonly commandMentions: CommandMentions | undefined;\n readonly onOpenImage: ((image: ImageContent) => void) | undefined;\n readonly onCopy: ((text: string) => Promise<void>) | undefined;\n /** Which tokens of a sent message get a styled treatment (`@path` mentions, a leading `/skill`). */\n readonly pieces: { readonly mentions: boolean; readonly uploads?: boolean; readonly skills: readonly string[]; readonly openMention?: ((path: string) => void) | undefined; readonly renderMention?: ((path: string) => ReactNode | undefined) | undefined;\n readonly preview?: PreviewRead | undefined } | undefined;\n /** Opens an `uploads/` file from its card in a sent message (`PiChat`'s `onOpenFile`). Absent: the card is not clickable. */\n readonly openFile?: ((path: string) => void) | undefined;\n readonly answer: ((questionId: string, answer: string) => Promise<AnswerOutcome>) | undefined;\n readonly artifacts: { readonly open: ArtifactsConfig['open']; readonly isOpen?: ArtifactsConfig['isOpen'] | undefined } | 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 /** A line under a finished reply (not while it streams, runs a tool or waits to retry), after its actions (a host shows which model answered and how long it took). */\n readonly replyFooter?: ((reply: ReplyRef) => ReactNode) | undefined;\n /** Fork the conversation after a settled reply (its last native entry): adds a Fork button next to Copy. */\n readonly onFork?: ((entryId: string) => void) | undefined;\n}\n\nconst replyRef = (row: Extract<Row, { type: 'assistant' }>): ReplyRef => ({ key: row.key, text: row.text, entryId: row.entryId, timestamp: row.timestamp, model: row.model, durationMs: row.durationMs });\n\nfunction Image({ image, onOpenImage }: { readonly image: ImageContent; readonly onOpenImage: RowContext['onOpenImage'] }) {\n const { labels } = useChatText();\n const src = thumbnail(image.mimeType, image.data);\n return <button type=\"button\" data-testid=\"message-image\" disabled={!onOpenImage} aria-label={labels.openImage} onClick={() => onOpenImage?.(image)}\n className=\"block cursor-pointer overflow-hidden rounded-xl border border-border outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:cursor-default\">\n {src ? <img src={src} alt={labels.imageAttachment} className=\"max-h-48 max-w-full object-cover\" /> : <span className=\"block px-3 py-2 text-xs text-muted-foreground\">{labels.imageAttachment}</span>}\n </button>;\n}\n\nconst MENTION_CLASS = 'rounded-md bg-background px-1.5 py-0.5 font-mono text-sm font-medium text-foreground ring-1 ring-border';\n\n/** A `@path` mention in a sent message: the host's own look when its `render` returns one, else the default chip (a button when it can be opened). */\nfunction UserMention({ path, text, open, render }: { readonly path: string; readonly text: string; readonly open: ((path: string) => void) | undefined; readonly render: ((path: string) => ReactNode | undefined) | undefined }) {\n const custom = render?.(path);\n if (custom !== undefined) return <>{custom}</>;\n return open\n ? <button type=\"button\" data-testid=\"message-mention\" data-path={path} aria-label={`Open ${path}`} onClick={() => open(path)} className={cn(MENTION_CLASS, 'cursor-pointer outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/60 pointer-coarse:inline-flex pointer-coarse:min-h-10 pointer-coarse:items-center')}>{text}</button>\n : <span data-testid=\"message-mention\" data-path={path} className={MENTION_CLASS}>{text}</span>;\n}\n\n/** The icon a file card shows for its kind. */\nfunction FileGlyph({ kind }: { readonly kind: FileKind }) {\n const className = 'size-5 text-muted-foreground';\n switch (kind) {\n case 'image': return <ImageIcon className={className} aria-hidden=\"true\" />;\n case 'sheet': return <FileSpreadsheetIcon className={className} aria-hidden=\"true\" />;\n case 'slides': return <PresentationIcon className={className} aria-hidden=\"true\" />;\n case 'document': return <FileTextIcon className={className} aria-hidden=\"true\" />;\n default: return <FileIcon className={className} 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 }: { readonly path: string; readonly open: ((path: string) => void) | undefined; readonly preview: PreviewRead | undefined }) {\n const { labels } = useChatText();\n const kind = fileKind(path), name = displayName(path);\n const [src, setSrc] = useState<string | undefined>();\n // A thumbnail belongs to the provider that read it: a new provider, or a read that is refused or unavailable, shows the icon.\n useEffect(() => {\n setSrc(undefined);\n if (kind !== 'image' || !preview) return;\n const abort = new AbortController();\n preview(path, abort.signal).then(\n read => { if (!abort.signal.aborted) setSrc(read ? thumbnail(read.mimeType, read.data) : undefined); },\n () => { if (!abort.signal.aborted) setSrc(undefined); });\n return () => abort.abort();\n }, [kind, path, preview]);\n const body = <>\n <span className=\"flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-background ring-1 ring-border\">\n {src ? <img src={src} alt=\"\" className=\"size-full object-cover\" /> : <FileGlyph kind={kind} />}\n </span>\n <span className=\"flex min-w-0 flex-col text-left\">\n <span className=\"truncate text-sm font-medium text-foreground\">{name}</span>\n <span className=\"truncate text-xs text-muted-foreground\">{labels.fileKind[kind]}</span>\n </span>\n </>;\n return open\n // The card keeps the `message-mention` test id of the chip it replaces, so a journey that clicks a sent mention still finds it.\n ? <button type=\"button\" data-testid=\"message-mention\" data-path={path} aria-label={labels.openFile(name)} onClick={() => open(path)}\n className=\"flex min-h-11 max-w-[16rem] cursor-pointer items-center gap-2.5 rounded-xl border border-border bg-card p-1.5 pr-3 outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/60\">{body}</button>\n : <div data-testid=\"message-mention\" data-path={path} className=\"flex min-h-11 max-w-[16rem] items-center gap-2.5 rounded-xl border border-border bg-card p-1.5 pr-3\">{body}</div>;\n}\n\n/** The file cards of a message, above its text bubble. */\nfunction FileCards({ paths, open, preview }: { readonly paths: readonly string[]; readonly open: ((path: string) => void) | undefined; readonly preview: PreviewRead | undefined }) {\n return <ul data-testid=\"message-files\" className=\"m-0 flex max-w-[85%] list-none flex-wrap justify-end gap-2 p-0\">\n {paths.map(path => <li key={path} data-testid=\"message-file\"><FileCard path={path} open={open} preview={preview} /></li>)}\n </ul>;\n}\n\n/**\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 */\nfunction UserText({ text, context }: { readonly text: string; readonly context: RowContext }) {\n const open = context.pieces?.openMention;\n const render = context.pieces?.renderMention;\n const bubble = 'max-w-[85%] rounded-2xl rounded-br-md bg-muted px-4 py-2.5 text-sm leading-6 whitespace-pre-wrap text-foreground [overflow-wrap:anywhere]';\n if (!context.pieces) return <div data-testid=\"user-text\" className={bubble}>{text}</div>;\n const all = pieces(text, context.pieces);\n const isFile = (piece: Piece) => piece.kind === 'mention' && isUploadPath(piece.value!);\n const files = all.filter(isFile).map(piece => piece.value!);\n const shown = files.length ? visibleParts(all, isFile) : all;\n const empty = shown.every(piece => piece.kind === undefined && piece.text === '');\n return <>\n {files.length > 0 && <FileCards paths={files} open={context.openFile ?? open} preview={context.pieces.preview} />}\n {!(files.length > 0 && empty) && <div data-testid=\"user-text\" className={bubble}>{shown.map((piece, at) => piece.kind === 'mention'\n ? <UserMention key={at} path={piece.value!} text={piece.text} open={open} render={render} />\n : piece.kind === 'skill' ? <span key={at} data-testid=\"message-skill\" data-skill={piece.value} className=\"rounded-md bg-primary/10 px-1.5 py-0.5 font-medium text-foreground\">{piece.text}</span>\n : piece.text)}</div>}\n </>;\n}\n\nfunction UserBubble({ message, context }: { readonly message: UserMessage; readonly context: RowContext }) {\n const parts = typeof message.content === 'string' ? [{ type: 'text' as const, text: message.content }] : message.content;\n return <div className=\"flex flex-col items-end gap-2\">\n {parts.map((part, index) => part.type === 'text' ? (isFileBlock(part.text) ? null : <UserText key={index} text={part.text} context={context} />)\n : <Image key={index} image={part} onOpenImage={context.onOpenImage} />)}\n </div>;\n}\n\nfunction Notice({ tone, icon, children, testid }: { readonly tone: 'error' | 'muted'; readonly icon: ReactNode; readonly children: ReactNode; readonly testid: string }) {\n return <p role={tone === 'error' ? 'alert' : 'status'} data-testid={testid}\n className={cn('my-2 mb-0 flex items-start gap-2 rounded-lg px-3 py-2 text-sm', tone === 'error' ? 'bg-destructive/10 text-destructive' : 'bg-muted/60 text-muted-foreground')}>{icon}<span className=\"min-w-0 [overflow-wrap:anywhere]\">{children}</span></p>;\n}\n\nfunction resultText(message: ToolResultMessage): string { return message.content.map(part => part.type === 'text' ? part.text : '').join('\\n'); }\n\nexport function RowView({ row, context }: { readonly row: Row; readonly context: RowContext }) {\n const { labels, icons } = useChatText();\n switch (row.type) {\n case 'user': return <article data-row-id={row.key} data-role=\"user\"><UserBubble message={row.message} context={context} /></article>;\n case 'card': return <article data-row-id={row.key} data-role=\"event\">{row.card.content}</article>;\n case 'event': return <article data-row-id={row.key} data-role=\"event\" className=\"text-center text-xs text-muted-foreground\">{labels.event(row.label)}</article>;\n case 'system': return <article data-row-id={row.key} data-role=\"system\"><details className=\"rounded-lg border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground\"><summary className=\"cursor-pointer max-sm:py-3 pointer-coarse:py-3\">{labels.systemContext}</summary>\n <pre className=\"mt-2 mb-0 whitespace-pre-wrap\">{row.text}</pre></details></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></article>;\n case 'assistant': {\n const settled = !row.streaming;\n const chunks = segments(row.parts, context.groupTool);\n const active = row.active === true || row.streaming;\n const lastText = [...row.parts].reverse().find(part => part.kind === 'text');\n const host = settled && row.text && context.messageActions\n ? <BlockActions actions={context.messageActions(replyRef(row))} testId=\"reply-action\" menuLabel={labels.moreActions} className=\"text-muted-foreground\" />\n : null;\n return <article data-row-id={row.key} data-role=\"assistant\" data-streaming={row.streaming ? 'true' : undefined} className=\"group/message\">\n <div className=\"space-y-3\">\n {chunks.map((chunk, at) => {\n if (chunk.kind === 'activity') {\n const failed = chunk.steps.some(step => step.kind === 'tool' && step.entry.status === 'failed');\n const unfinished = chunk.steps.some(step => step.kind === 'tool' && step.entry.status === 'unfinished');\n const state: ActivityState = failed || row.stopReason === 'error' ? 'failed' : row.stopReason === 'aborted' || (!active && unfinished) ? 'stopped' : active && at === chunks.length - 1 ? 'running' : 'done';\n return <ActivityBlock key={chunk.key} steps={chunk.steps} state={state} streaming={row.streaming} developer={context.developer} {...(context.onOpenImage ? { onOpenImage: context.onOpenImage } : {})} />;\n }\n const part = chunk.part;\n if (part.kind === 'text') return <Markdown key={part.key} text={part.text} 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 (part.kind === 'thinking') return null; // reasoning is a step of an activity block\n if (part.kind === 'approval') return <ApprovalCard key={part.key} call={part.call} result={part.result} questionId={part.questionId} answer={context.answer} summary={part.summary} decision={part.decision} live={part.live} />;\n if (part.kind === 'question') return <QuestionCard key={part.key} call={part.call} result={part.result} questionId={part.questionId} answer={context.answer} live={part.live} />;\n if (part.kind === 'artifact') return <ArtifactCard key={part.key} artifact={part.artifact} title={part.title} {...(part.state === 'ready' ? {} : { pending: part.state })}\n open={part.artifact !== undefined && (context.artifacts?.isOpen?.(part.artifact) ?? false)} onOpen={context.artifacts?.open} />;\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 {row.streaming && row.parts.length === 0 && <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\" />}>{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 {settled && row.text && context.replyActions?.({ key: row.key, text: row.text, timestamp: row.timestamp }, host)}\n {settled && row.text && !context.replyActions && <div className=\"mt-1 -ml-2 flex opacity-0 transition-opacity group-focus-within/message:opacity-100 group-hover/message:opacity-100 pointer-coarse:opacity-100 max-sm:opacity-100 motion-reduce:transition-none\">\n <CopyButton text={row.text} label={labels.copyResponse} {...(context.onCopy ? { onCopy: context.onCopy } : {})} className=\"text-muted-foreground\" />\n {context.onFork && row.entryId && <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.fork} title={labels.forkHint} data-testid=\"fork-reply\"\n className=\"text-muted-foreground\" onClick={() => context.onFork!(row.entryId!)}><icons.fork className=\"size-3.5\" aria-hidden=\"true\" /></Button>}\n {host}</div>}\n {settled && !row.active && row.text && context.replyFooter?.(replyRef(row))}\n </article>;\n }\n }\n}\n",
|
|
128
|
+
"content": "'use client';\n\nimport { useEffect, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ImageContent, ToolResultMessage, UserMessage } from '@earendil-works/pi-ai';\nimport { AlertCircleIcon, CircleSlashIcon, FileIcon, FileSpreadsheetIcon, FileTextIcon, ImageIcon, PresentationIcon } from 'lucide-react';\nimport { CopyButton } from './code-block';\nimport { Markdown } from './markdown';\nimport type { CommandMentions } from './markdown';\nimport { ArtifactCard } from './artifact-card';\nimport type { ArtifactsConfig } from './artifact';\nimport { ApprovalCard } from './approval-card';\nimport { QuestionCard } from './question-card';\nimport type { AnswerOutcome } from './question-card';\nimport { ActivityBlock } from './activity';\nimport type { ActivityState } from './activity';\nimport { Shimmer } from './shimmer';\nimport { ToolCard } from './tool';\nimport { thumbnail } from './composer';\nimport { displayName, fileKind, isUploadPath, pieces, visibleParts } from './config';\nimport type { FileKind, Piece, PreviewRead } from './config';\nimport { isFileBlock, segments } from './rows';\nimport type { DeriveOptions, Row } from './rows';\nimport { Button } from '@/components/ui/button';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { useChatText } from './labels';\nimport type { ReplyRef } from './config';\nimport { Message, MessageContent, MessageFooter } from '@/components/ui/message';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { Attachment, AttachmentContent, AttachmentDescription, AttachmentMedia, AttachmentTitle, AttachmentTrigger } from '@/components/ui/attachment';\nimport { Badge } from '@/components/ui/badge';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';\n\nexport interface RowContext {\n readonly developer: boolean;\n readonly groupTool: DeriveOptions['groupTool'];\n readonly commandMentions: CommandMentions | undefined;\n readonly onOpenImage: ((image: ImageContent) => void) | undefined;\n readonly onCopy: ((text: string) => Promise<void>) | undefined;\n /** Which tokens of a sent message get a styled treatment (`@path` mentions, a leading `/skill`). */\n readonly pieces: { readonly mentions: boolean; readonly uploads?: boolean; readonly skills: readonly string[]; readonly openMention?: ((path: string) => void) | undefined; readonly renderMention?: ((path: string) => ReactNode | undefined) | undefined;\n readonly preview?: PreviewRead | undefined } | undefined;\n readonly fileChanges?: boolean | undefined;\n /** Opens a file card through the host's workspace opener. */\n readonly openFile?: ((path: string) => void) | undefined;\n readonly answer: ((questionId: string, answer: string) => Promise<AnswerOutcome>) | undefined;\n readonly artifacts: { readonly open: ArtifactsConfig['open']; readonly isOpen?: ArtifactsConfig['isOpen'] | undefined } | 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 /** A line under a finished reply (not while it streams, runs a tool or waits to retry), after its actions (a host shows which model answered and how long it took). */\n readonly replyFooter?: ((reply: ReplyRef) => ReactNode) | undefined;\n /** Fork the conversation after a settled reply (its last native entry): adds a Fork button next to Copy. */\n readonly onFork?: ((entryId: string) => void) | undefined;\n}\n\nconst replyRef = (row: Extract<Row, { type: 'assistant' }>): ReplyRef => ({ key: row.key, text: row.text, entryId: row.entryId, timestamp: row.timestamp, model: row.model, durationMs: row.durationMs });\n\nfunction Image({ image, onOpenImage }: { readonly image: ImageContent; readonly onOpenImage: RowContext['onOpenImage'] }) {\n const { labels } = useChatText();\n const src = thumbnail(image.mimeType, image.data);\n return <Attachment orientation=\"vertical\">\n <AttachmentMedia variant={src ? 'image' : 'icon'}>{src ? <img src={src} alt={labels.imageAttachment} /> : <ImageIcon aria-hidden=\"true\" />}</AttachmentMedia>\n <AttachmentContent><AttachmentTitle>{labels.imageAttachment}</AttachmentTitle></AttachmentContent>\n <AttachmentTrigger data-testid=\"message-image\" disabled={!onOpenImage} aria-label={labels.openImage} onClick={() => onOpenImage?.(image)} />\n </Attachment>;\n}\n\n/** A `@path` mention in a sent message: the host's own look when its `render` returns one, else the default chip (a button when it can be opened). */\nfunction UserMention({ path, text, open, render }: { readonly path: string; readonly text: string; readonly open: ((path: string) => void) | undefined; readonly render: ((path: string) => ReactNode | undefined) | undefined }) {\n const custom = render?.(path);\n if (custom !== undefined) return <>{custom}</>;\n return open\n ? <Button variant=\"secondary\" size=\"sm\" data-testid=\"message-mention\" data-path={path} aria-label={`Open ${path}`} onClick={() => open(path)}>{text}</Button>\n : <Badge variant=\"secondary\" data-testid=\"message-mention\" data-path={path}>{text}</Badge>;\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 'slides': return <PresentationIcon aria-hidden=\"true\" />;\n case 'document': return <FileTextIcon aria-hidden=\"true\" />;\n default: return <FileIcon aria-hidden=\"true\" />;\n }\n}\n\n/** A file a person sent (an `uploads/` mention): its icon or thumbnail, name and type. A button when it can be opened. */\nfunction FileCard({ path, open, preview, change }: { readonly path: string; readonly open: ((path: string) => void) | undefined; readonly preview: PreviewRead | undefined; readonly change?: 'created' | 'edited' }) {\n const { labels } = useChatText();\n const kind = fileKind(path), name = change ? path.split('/').pop() || path : displayName(path);\n const [src, setSrc] = useState<string | undefined>();\n // A thumbnail belongs to the provider that read it: a new provider, or a read that is refused or unavailable, shows the icon.\n useEffect(() => {\n setSrc(undefined);\n if (kind !== 'image' || !preview) return;\n const abort = new AbortController();\n preview(path, abort.signal).then(\n read => { if (!abort.signal.aborted) setSrc(read ? thumbnail(read.mimeType, read.data) : undefined); },\n () => { if (!abort.signal.aborted) setSrc(undefined); });\n return () => abort.abort();\n }, [kind, path, preview]);\n return <Attachment className=\"max-w-64\" {...(!open ? { 'data-testid': 'message-mention', 'data-path': path } : {})}>\n <AttachmentMedia variant={src ? 'image' : 'icon'}>{src ? <img src={src} alt=\"\" /> : <FileGlyph kind={kind} />}</AttachmentMedia>\n <AttachmentContent>\n <AttachmentTitle>{name}</AttachmentTitle>\n <AttachmentDescription>{change ? labels.fileChange[change] : labels.fileKind[kind]}</AttachmentDescription>\n </AttachmentContent>\n {open && <AttachmentTrigger data-testid=\"message-mention\" data-path={path} aria-label={labels.openFile(name)} onClick={() => open(path)} />}\n </Attachment>;\n}\n\n/** The file cards of a message, above its text bubble. */\nfunction FileCards({ paths, open, preview }: { readonly paths: readonly string[]; readonly open: ((path: string) => void) | undefined; readonly preview: PreviewRead | undefined }) {\n return <ul data-testid=\"message-files\" className=\"m-0 flex max-w-[85%] list-none flex-wrap justify-end gap-2 p-0\">\n {paths.map(path => <li key={path} data-testid=\"message-file\"><FileCard path={path} open={open} preview={preview} /></li>)}\n </ul>;\n}\n\n/**\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 */\nfunction UserText({ text, context }: { readonly text: string; readonly context: RowContext }) {\n const open = context.pieces?.openMention;\n const render = context.pieces?.renderMention;\n if (!context.pieces) return <Bubble variant=\"muted\" align=\"end\"><BubbleContent data-testid=\"user-text\"><span className=\"whitespace-pre-wrap\">{text}</span></BubbleContent></Bubble>;\n const all = pieces(text, context.pieces);\n const isFile = (piece: Piece) => piece.kind === 'mention' && isUploadPath(piece.value!);\n const files = all.filter(isFile).map(piece => piece.value!);\n const shown = files.length ? visibleParts(all, isFile) : all;\n const empty = shown.every(piece => piece.kind === undefined && piece.text === '');\n return <>\n {files.length > 0 && <FileCards paths={files} open={context.openFile ?? open} preview={context.pieces.preview} />}\n {!(files.length > 0 && empty) && <Bubble variant=\"muted\" align=\"end\"><BubbleContent data-testid=\"user-text\"><span className=\"whitespace-pre-wrap\">{shown.map((piece, at) => piece.kind === 'mention'\n ? <UserMention key={at} path={piece.value!} text={piece.text} open={open} render={render} />\n : piece.kind === 'skill' ? <Badge key={at} variant=\"secondary\" data-testid=\"message-skill\" data-skill={piece.value}>{piece.text}</Badge>\n : piece.text)}</span></BubbleContent></Bubble>}\n </>;\n}\n\nfunction UserBubble({ message, context }: { readonly message: UserMessage; readonly context: RowContext }) {\n const parts = typeof message.content === 'string' ? [{ type: 'text' as const, text: message.content }] : message.content;\n return <Message align=\"end\"><MessageContent>\n {parts.map((part, index) => part.type === 'text' ? (isFileBlock(part.text) ? null : <UserText key={index} text={part.text} context={context} />)\n : <Image key={index} image={part} onOpenImage={context.onOpenImage} />)}\n </MessageContent></Message>;\n}\n\nfunction Notice({ tone, icon, children, testid }: { readonly tone: 'error' | 'muted'; readonly icon: ReactNode; readonly children: ReactNode; readonly testid: string }) {\n return <Alert variant={tone === 'error' ? 'destructive' : 'default'} role={tone === 'error' ? 'alert' : 'status'} data-testid={testid}>\n {icon}<AlertDescription>{children}</AlertDescription>\n </Alert>;\n}\n\nfunction resultText(message: ToolResultMessage): string { return message.content.map(part => part.type === 'text' ? part.text : '').join('\\n'); }\n\nexport function RowView({ row, context: liveContext }: { readonly row: Row; readonly context: RowContext }) {\n const context: RowContext = row.historical ? { ...liveContext, answer: undefined, onFork: undefined, messageActions: undefined, replyActions: undefined, commandMentions: undefined } : liveContext;\n const { labels, icons } = useChatText();\n switch (row.type) {\n case 'user': return <article data-row-id={row.key} data-role=\"user\" aria-live={row.historical ? 'off' : undefined}><UserBubble message={row.message} context={context} /></article>;\n case 'card': return <article data-row-id={row.key} data-role=\"event\">{row.card.content}</article>;\n case 'event': return <article data-row-id={row.key} data-role=\"event\" className=\"text-center text-xs text-muted-foreground\">{labels.event(row.label)}</article>;\n case 'system': return <article data-row-id={row.key} data-role=\"system\"><Collapsible>\n <CollapsibleTrigger render={<Button variant=\"ghost\" />}>{labels.systemContext}</CollapsibleTrigger>\n <CollapsibleContent><pre className=\"whitespace-pre-wrap\">{row.text}</pre></CollapsibleContent>\n </Collapsible></article>;\n case 'orphan-result': return <article data-row-id={row.key} data-role=\"toolResult\"><Notice tone=\"error\" testid=\"tool-error\" icon={<AlertCircleIcon className=\"mt-0.5 size-4 shrink-0\" aria-hidden=\"true\" />}>\n <strong className=\"font-semibold\">{labels.toolFailed(row.message.toolName)}</strong><span className=\"mt-1 block font-mono text-xs whitespace-pre-wrap\">{resultText(row.message)}</span></Notice></article>;\n case 'assistant': {\n const settled = !row.streaming;\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 active = row.active === true || row.streaming;\n const lastText = [...row.parts].reverse().find(part => part.kind === 'text');\n const host = settled && row.text && context.messageActions\n ? <BlockActions iconOnly actions={context.messageActions(replyRef(row))} testId=\"reply-action\" menuLabel={labels.moreActions} />\n : null;\n return <article data-row-id={row.key} data-role=\"assistant\" aria-live={row.historical ? 'off' : undefined} data-streaming={row.streaming ? 'true' : undefined} className=\"group/message\">\n <Message><MessageContent>\n <div className=\"flex flex-col gap-3\">\n {chunks.map((chunk, at) => {\n if (chunk.kind === 'activity') {\n const failed = chunk.steps.some(step => step.kind === 'tool' && step.entry.status === 'failed');\n const unfinished = chunk.steps.some(step => step.kind === 'tool' && step.entry.status === 'unfinished');\n const state: ActivityState = failed || row.stopReason === 'error' ? 'failed' : row.stopReason === 'aborted' || (!active && unfinished) ? 'stopped' : active && at === chunks.length - 1 ? 'running' : 'done';\n return <ActivityBlock key={chunk.key} steps={chunk.steps} state={state} streaming={row.streaming} developer={context.developer} {...(context.onOpenImage ? { onOpenImage: context.onOpenImage } : {})} />;\n }\n const part = chunk.part;\n if (part.kind === 'text') return <Bubble key={part.key} variant=\"ghost\"><BubbleContent><Markdown text={part.text} streaming={row.streaming && part === lastText && row.parts[row.parts.length - 1] === part}\n {...(settled && context.commandMentions ? { commandMentions: context.commandMentions } : {})} {...(context.onCopy ? { onCopy: context.onCopy } : {})} /></BubbleContent></Bubble>;\n if (part.kind === 'thinking') return null; // reasoning is a step of an activity block\n if (part.kind === 'approval') return <ApprovalCard key={part.key} call={part.call} result={part.result} questionId={part.questionId} answer={context.answer} summary={part.summary} decision={part.decision} live={part.live} />;\n if (part.kind === 'question') return <QuestionCard key={part.key} call={part.call} result={part.result} questionId={part.questionId} answer={context.answer} live={part.live} />;\n if (part.kind === 'artifact' && part.artifact && files.has(part.artifact.target.resource.path.replace(/^(\\.\\/)+/, ''))) return null;\n if (part.kind === 'artifact') return <ArtifactCard key={part.key} artifact={part.artifact} title={part.title} {...(part.state === 'ready' ? {} : { pending: part.state })}\n open={part.artifact !== undefined && (context.artifacts?.isOpen?.(part.artifact) ?? false)} onOpen={context.artifacts?.open} />;\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 {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 && <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\" />}>{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 {settled && row.text && context.replyActions?.({ key: row.key, text: row.text, timestamp: row.timestamp }, host)}\n {settled && row.text && !context.replyActions && <div className=\"mt-1 -ml-2 flex opacity-0 transition-opacity group-focus-within/message:opacity-100 group-hover/message:opacity-100 pointer-coarse:opacity-100 max-sm:opacity-100 motion-reduce:transition-none\">\n <CopyButton iconOnly text={row.text} label={labels.copyResponse} {...(context.onCopy ? { onCopy: context.onCopy } : {})} />\n {context.onFork && row.entryId && <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.fork} title={labels.forkHint} data-testid=\"fork-reply\"\n onClick={() => context.onFork!(row.entryId!)}><icons.fork data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {host}</div>}\n {settled && !row.active && row.text && context.replyFooter && <MessageFooter>{context.replyFooter(replyRef(row))}</MessageFooter>}\n </MessageContent></Message>\n </article>;\n }\n }\n}\n",
|
|
94
129
|
"type": "registry:component",
|
|
95
130
|
"target": "components/pi-chat/message.tsx"
|
|
96
131
|
},
|
|
97
132
|
{
|
|
98
133
|
"path": "registry/pi-chat/notice.tsx",
|
|
99
|
-
"content": "'use client';\n\nimport type { ReactNode } from 'react';\nimport type { NativeChatSnapshot } from '@hachej/boring-ui-kit/native-chat';\nimport { Button } from '
|
|
134
|
+
"content": "'use client';\n\nimport type { ReactNode } from 'react';\nimport type { NativeChatSnapshot } from '@hachej/boring-ui-kit/native-chat';\nimport { Button } from '@/components/ui/button';\nimport { Alert } from '@/components/ui/alert';\nimport { useChatText } from './labels';\n\nexport function Notice({ tone = 'error', children, testid }: { readonly tone?: 'error' | 'info'; readonly children: ReactNode; readonly testid?: string }) {\n return <Alert variant={tone === 'error' ? 'destructive' : 'default'} role={tone === 'error' ? 'alert' : 'status'} {...(testid ? { 'data-testid': testid } : {})}\n className=\"flex flex-wrap items-center gap-2\">{children}</Alert>;\n}\n\n/** What went wrong with sending or stopping, and the recovery for an unknown submission. Shared by every chat surface. */\nexport function ChatNotices({ state, error, onReconcile, onRetry }: {\n readonly state: Pick<NativeChatSnapshot, 'send' | 'stop'>;\n readonly error: string | null;\n readonly onReconcile: () => void;\n readonly onRetry: () => void;\n}) {\n const { labels } = useChatText();\n return <>\n {error && <Notice testid=\"chat-error\">{error}</Notice>}\n {state.send.kind === 'blocked' && <Notice testid=\"send-blocked\">{state.send.reason}</Notice>}\n {state.send.kind === 'unknown' && <Notice testid=\"send-unknown\"><span className=\"flex-1\">{labels.sendUnknown}</span>\n <Button size=\"sm\" variant=\"outline\" onClick={onReconcile}>{labels.checkSubmission}</Button>\n <Button size=\"sm\" variant=\"outline\" onClick={onRetry}>{labels.retrySame}</Button></Notice>}\n {state.stop === 'requested' && <Notice tone=\"info\" testid=\"stop-requested\">{labels.stopRequested}</Notice>}\n {state.stop === 'unconfirmed' && <Notice testid=\"stop-unconfirmed\">{labels.stopUnconfirmed}</Notice>}\n </>;\n}\n",
|
|
100
135
|
"type": "registry:component",
|
|
101
136
|
"target": "components/pi-chat/notice.tsx"
|
|
102
137
|
},
|
|
103
138
|
{
|
|
104
139
|
"path": "registry/pi-chat/pi-chat.tsx",
|
|
105
|
-
"content": "'use client';\n\nimport { useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { Loader2Icon } from 'lucide-react';\nimport { Button } from '../button/button';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { Composer } from './composer';\nimport type { ComposerFeedback } from './composer';\nimport type { ConversationsConfig } from './config';\nimport { EmptyState } from './empty-state';\nimport type { Suggestion } from './empty-state';\nimport { ConversationHistory, HistoryPanel } from './history';\nimport { RowView } from './message';\nimport { MessageQueue } from './queue';\nimport { ChatNotices, Notice } from './notice';\nimport { Shimmer } from './shimmer';\nimport { useChatSession, useTranscript } from './session';\nimport { useChatDraft } from './use-chat-draft';\nimport type { ChatFeatureProps, FilesHandler } from './session';\nimport { ChatTextProvider, useChatText, useMergedText } from './labels';\nimport { chatTextFor } from './locale';\nimport type { ChatLocale } from './locale';\nimport type { ChatIcons, ChatLabels } from './labels';\nimport { cn } from '../utils/utils';\n\nexport type { PiChatActions } from './session';\nexport type { ChatCard, Mode } from './rows';\nexport type { AnswerOutcome } from './question-card';\nexport { artifactKey, collectArtifacts, detectArtifact, parseArtifact } from './artifact';\nexport type { ArtifactDescriptor, ArtifactsConfig, ArtifactTarget, ArtifactType } from './artifact';\nexport type { CommandMention, CommandMentions } from './markdown';\nexport type { Suggestion } from './empty-state';\nexport { ConversationList, relativeTime } from './history';\nexport { defaultChatIcons, defaultChatLabels } from './labels';\nexport type { ChatIcons, ChatLabels } from './labels';\nexport { frenchChatLabels } from './labels-fr';\nexport { chatTextFor } from './locale';\nexport type { ChatLocale } from './locale';\nexport { ActionMenu, BlockActions } from '../button/actions';\nexport { useChatDraft } from './use-chat-draft';\nexport type { BlockAction, BlockIcon } from '../button/actions';\nexport type { AttachmentsConfig, ConversationItem, ConversationsConfig, EffortConfig, FileKind, MentionResult, MentionsConfig, ModelConfig, ModelRef, PasteToFile, PreviewRead, ReplyRef, SlashApi, SlashCommand, SlashConfig, SlashSkill, UploadResult } from './config';\n\nexport interface PiChatProps extends ChatFeatureProps {\n readonly controller: NativeChatController;\n /** Every word the chat shows (title, placeholder, buttons, notices, empty states): a partial object over `defaultChatLabels`. */\n /** The chat's language: `en` (default) or `fr`. `labels` override any of its words. */\n readonly locale?: ChatLocale | undefined;\n readonly labels?: Partial<ChatLabels> | undefined;\n /** The chat's icons (send, stop, history, attach, …): any component taking `className`, over `defaultChatIcons`. */\n readonly icons?: Partial<ChatIcons> | undefined;\n /** The agent's avatar (an image or an icon) before the title in the header, and in the empty state instead of the agent icon. */\n readonly avatar?: ReactNode;\n /** Host actions in the header beside History: `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids `chat-action-<id>`, the menu `chat-action-more`. */\n readonly headerActions?: readonly BlockAction[] | undefined;\n readonly className?: string;\n readonly decisions?: ReactNode;\n readonly controls?: ReactNode;\n /** Host controls at the end of the composer bar (for example a microphone), like AmbientChat's tools slot. */\n readonly tools?: ReactNode;\n /** Rendered before the title in the header (for example a menu button on narrow screens). */\n readonly headerStart?: ReactNode;\n readonly onFiles?: FilesHandler;\n /** Shown instead of the default empty state before the first message. */\n readonly emptyState?: ReactNode;\n readonly suggestions?: readonly Suggestion[];\n /** Past conversations. The header's History button then opens a searchable list with the open one marked and a New action. Omit it and History pages through the earlier records of this conversation. */\n readonly conversations?: ConversationsConfig;\n /**\n * `false`: the host shows `conversations` itself (for example the `pi-app` sessions pane), so History pages through the earlier records of this\n * conversation while replies keep their Fork button (`conversations.fork`). Default `true`: History opens the list.\n */\n readonly historyList?: boolean;\n /** Show the header History button. Default true; paging remains available through the controller. */\n readonly showHistory?: boolean;\n /** Show the header connection badge. Default true; connection state still controls actions. */\n readonly showConnectionStatus?: boolean;\n /** The optional annotate toggle and notes chip in the composer (the registry `feedback` item's `useComposerFeedback`). Omit it and nothing changes. */\n readonly feedback?: ComposerFeedback;\n /** Keeps the composer's half-typed message in localStorage under this key (a conversation id), across reloads and tabs; see `useChatDraft`. Omit it and nothing is stored. */\n readonly draftKey?: string | undefined;\n}\n\nconst PAGE = 60, MORE = 40;\n\n/** Borrows a concrete native controller; unmount releases presentation and uploads only. */\nexport function PiChat(props: PiChatProps) {\n const active = useRef(props.controller); active.current = props.controller;\n const [mount, setMount] = useState({ controller: props.controller, sequence: 0 });\n if (mount.controller !== props.controller) setMount({ controller: props.controller, sequence: mount.sequence + 1 });\n const text = useMergedText(props.labels, props.icons, chatTextFor(props.locale));\n return <ChatTextProvider value={text}><PiChatSession key={mount.sequence} {...props} activeController={active} /></ChatTextProvider>;\n}\n\nfunction ConnectionBadge({ kind }: { readonly kind: string }) {\n const { labels } = useChatText();\n const live = kind === 'connected';\n return <span data-testid=\"connection\" data-state={kind} role=\"status\" className=\"inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-0.5 text-xs text-muted-foreground\">\n <span aria-hidden=\"true\" className={cn('size-1.5 rounded-full', live ? 'bg-(--chat-success)' : 'animate-pulse bg-(--chat-warning) motion-reduce:animate-none')} />\n {live ? labels.live : kind === 'connecting' || kind === 'idle' ? labels.connecting : labels.reconnecting}\n </span>;\n}\n\nfunction PiChatSession({ controller, mode = 'expert', className, actions, avatar, headerActions, decisions, controls, tools, headerStart, renderEntry, renderTool, groupTool, commandMentions,\n onOpenImage, onCopy, onComposerKeyDown, onFiles, fileAccept = 'image/*', emptyState, suggestions, messageActions, replyFooter, slash, mentions, attachments, model, effort, artifacts, conversations, historyList = true, showHistory = true, showConnectionStatus = true, feedback, draftKey, onOpenFile, pasteToFile, activeController: active }: PiChatProps & { readonly activeController: { readonly current: NativeChatController } }) {\n const { labels, icons } = useChatText();\n useChatDraft(controller, draftKey);\n const { title } = labels;\n const [browsingHistory, setBrowsingHistory] = useState(false);\n const [pickingConversation, setPickingConversation] = useState(false);\n const scrollToBottom = useRef<() => void>(() => {});\n // A reply's Fork button calls the host's latest `fork`; the row context changes only when forking is switched on or off.\n const fork = useRef(conversations?.fork); fork.current = conversations?.fork;\n const canFork = Boolean(conversations?.fork);\n const rowExtras = useMemo(() => canFork ? { onFork: (entryId: string) => { void fork.current?.(entryId).catch(() => {}); } } : undefined, [canFork]);\n const session = useChatSession({ controller, activeController: active, mode, actions, renderEntry, renderTool, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, onFiles, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => scrollToBottom.current(), feedback, rowExtras, onOpenFile, pasteToFile });\n const { state, derived, queued, queueActions, working, waitingForAnswer, connected, error, act, textarea, rowContext, composer, empty, loading, developer } = session;\n const { rows, pinned, retry } = derived;\n const transcript = useTranscript(rows, PAGE, MORE);\n const { stick, hidden } = transcript;\n scrollToBottom.current = () => { void stick.scrollToBottom('instant'); };\n const visible = transcript.visible(rows);\n const { barStart, barNote } = session.pickers();\n const connectionKind = state.connection.kind;\n\n return <section data-boring=\"pi-chat\" aria-label={title} className={cn('pi-chat relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-background text-foreground', className)}>\n <header className=\"flex shrink-0 items-center gap-2 border-b border-border px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5\">\n {headerStart}\n {avatar && <span data-testid=\"chat-avatar\" className=\"flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full [&>img]:size-full [&>img]:object-cover [&>svg]:size-4\">{avatar}</span>}\n <h2 className=\"m-0 min-w-0 flex-1 truncate font-(family-name:--chat-display) text-sm font-semibold tracking-tight\">{title}</h2>\n {showHistory && (conversations && historyList\n ? <Button size=\"sm\" data-testid=\"history-open\" className=\"text-muted-foreground\" aria-haspopup=\"dialog\" aria-expanded={pickingConversation} disabled={state.disposed}\n onClick={() => setPickingConversation(open => !open)}><icons.history className=\"size-3.5\" aria-hidden=\"true\" />{labels.history}</Button>\n : state.history.kind !== 'disabled' && !browsingHistory && <Button size=\"sm\" data-testid=\"history-open\" className=\"text-muted-foreground\" disabled={state.disposed || !connected}\n onClick={() => { setBrowsingHistory(true); act(controller.loadEarlier); }}><icons.history className=\"size-3.5\" aria-hidden=\"true\" />{labels.history}</Button>)}\n <BlockActions actions={headerActions} testId=\"chat-action\" menuLabel={labels.moreActions} />\n {showConnectionStatus && <ConnectionBadge kind={connectionKind} />}\n {controls}\n </header>\n\n {decisions && <div data-boring=\"required-decisions\" className=\"shrink-0 border-b border-border px-4 py-2\">{decisions}</div>}\n {pinned.length > 0 && <aside aria-label={labels.requiredActions} className=\"shrink-0 space-y-2 border-b border-border px-4 py-2\">{pinned.map(card => <div key={card.key}>{card.content}</div>)}</aside>}\n {(connectionKind === 'error' || connectionKind === 'closed') && <div className=\"shrink-0 px-4 pt-2\">\n <Notice tone={connectionKind === 'error' ? 'error' : 'info'} testid=\"connection-notice\">\n <span className=\"flex-1\">{connectionKind === 'error' ? labels.loadFailed : labels.disconnected}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}><icons.reconnect className=\"size-3.5\" aria-hidden=\"true\" />{labels.reconnect}</Button>}\n </Notice></div>}\n {pickingConversation && conversations && historyList && <ConversationHistory conversations={conversations} onClose={() => setPickingConversation(false)}\n onBrowseEarlier={state.history.kind !== 'disabled' && connected && Boolean(state.view?.entries.length) ? () => { setBrowsingHistory(true); act(controller.loadEarlier); } : undefined} />}\n {browsingHistory && state.history.kind !== 'disabled' && <HistoryPanel history={state.history} developer={developer} replyFooter={replyFooter} connected={!state.disposed && connected}\n onLoad={() => act(controller.loadEarlier)} onClose={() => { setBrowsingHistory(false); if (!state.disposed) controller.clearHistory(); }} />}\n\n <div className=\"relative min-h-0 flex-1\">\n <div ref={stick.scrollRef} data-testid=\"transcript-scroll\" className=\"h-full overflow-x-hidden overflow-y-auto overscroll-contain\">\n <div ref={stick.contentRef} role=\"log\" aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\" className=\"mx-auto flex min-h-full w-full max-w-3xl flex-col gap-6 px-3 pt-6 pb-8 sm:px-4\">\n {hidden > 0 && <div className=\"flex justify-center\"><Button size=\"sm\" variant=\"outline\" data-testid=\"show-earlier\" className=\"text-muted-foreground\" onClick={transcript.reveal}>{labels.showEarlier}</Button></div>}\n {visible.map(row => <RowView key={row.key} row={row} context={rowContext} />)}\n </div>\n </div>\n {(empty || loading) && <div className=\"pointer-events-none absolute inset-0\">\n <div className=\"pointer-events-auto h-full\">\n {loading ? <p role=\"status\" data-testid=\"loading\" className=\"flex h-full items-center justify-center gap-2 text-sm text-muted-foreground\"><Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{labels.loading}</p>\n : emptyState ?? <EmptyState title={title} description={labels.emptyDescription} {...(avatar ? { avatar } : {})} {...(suggestions ? { suggestions } : {})} onSelect={text => { controller.setText(text); textarea.current?.focus(); }} />}\n </div></div>}\n {!stick.isAtBottom && <Button variant=\"outline\" size=\"icon\" data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={() => { transcript.reset(); void stick.scrollToBottom(); }}\n className=\"absolute bottom-3 left-1/2 -translate-x-1/2 bg-background/95 shadow-md backdrop-blur\"><icons.jumpToLatest className=\"size-4\" aria-hidden=\"true\" /></Button>}\n </div>\n\n <footer className=\"shrink-0 px-3 pt-1 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-4\">\n <div className=\"mx-auto w-full max-w-3xl space-y-2\">\n <div aria-live=\"polite\" className=\"space-y-2 empty:hidden\">\n {working && <p role=\"status\" data-testid=\"agent-status\" className=\"m-0 flex items-center gap-2 px-1 text-xs text-muted-foreground\"><span aria-hidden=\"true\" className=\"size-1.5 animate-pulse rounded-full bg-(--chat-success) motion-reduce:animate-none\" />\n <Shimmer active={!waitingForAnswer}>{waitingForAnswer ? labels.waitingForAnswer : retry ? labels.retryPending : labels.agentWorking}</Shimmer></p>}\n {derived.compacting && <p role=\"status\" className=\"m-0 px-1 text-xs text-muted-foreground\">{labels.compacting}</p>}\n <ChatNotices state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} />\n </div>\n <div>\n <MessageQueue items={queued} sending={state.outbox} withdraw={actions?.withdraw} actions={queueActions} />\n <Composer {...composer} barStart={barStart} barNote={barNote} barEnd={tools} />\n </div>\n </div>\n </footer>\n </section>;\n}\n",
|
|
140
|
+
"content": "'use client';\n\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { Loader2Icon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Avatar, AvatarFallback } from '@/components/ui/avatar';\nimport { Badge } from '@/components/ui/badge';\nimport { Input } from '@/components/ui/input';\nimport { Separator } from '@/components/ui/separator';\nimport { MessageScroller, MessageScrollerProvider, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem, MessageScrollerButton } from '@/components/ui/message-scroller';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { Composer } from './composer';\nimport type { ComposerFeedback, ComposerProps } from './composer';\nimport { ConversationFiles } from './conversation-files';\nimport type { ConversationsConfig } from './config';\nimport { EmptyState } from './empty-state';\nimport type { Suggestion } from './empty-state';\nimport { ConversationHistory, TranscriptHistory } from './history';\nimport { RowView } from './message';\nimport { MessageQueue } from './queue';\nimport { ChatNotices, Notice } from './notice';\nimport { Shimmer } from './shimmer';\nimport { useChatSession, useTranscript } from './session';\nimport { useChatDraft } from './use-chat-draft';\nimport { useChatPlacement } from './use-remote-chat';\nimport type { ChatPlacement } from './use-remote-chat';\nimport type { ChatFeatureProps, FilesHandler } from './session';\nimport { ChatTextProvider, useChatText, useMergedText } from './labels';\nimport { chatTextFor } from './locale';\nimport type { ChatLocale } from './locale';\nimport type { ChatIcons, ChatLabels } from './labels';\nimport { cn } from '../utils/utils';\n\nexport type { PiChatActions } from './session';\nexport type { ChatCard, Mode } from './rows';\nexport type { AnswerOutcome } from './question-card';\nexport { artifactKey, collectArtifacts, detectArtifact, parseArtifact } from './artifact';\nexport type { ArtifactDescriptor, ArtifactsConfig, ArtifactTarget, ArtifactType } from './artifact';\nexport type { CommandMention, CommandMentions } from './markdown';\nexport type { Suggestion } from './empty-state';\nexport { ConversationList, relativeTime } from './history';\nexport { defaultChatIcons, defaultChatLabels } from './labels';\nexport type { ChatIcons, ChatLabels } from './labels';\nexport { frenchChatLabels } from './labels-fr';\nexport { chatTextFor } from './locale';\nexport type { ChatLocale } from './locale';\nexport { BlockActions } from '../button/actions';\nexport { useChatDraft } from './use-chat-draft';\nexport type { BlockAction, BlockIcon } from '../button/actions';\nexport type { AttachmentsConfig, ConversationItem, ConversationsConfig, EffortConfig, FileKind, MentionResult, MentionsConfig, ModelConfig, ModelRef, PasteToFile, PreviewRead, ReplyRef, SlashApi, SlashCommand, SlashConfig, SlashSkill, UploadResult } from './config';\n\nexport interface PiChatProps extends ChatFeatureProps {\n readonly controller: NativeChatController;\n /** Where this chat is shown, reported with each submit (advisory, never authority). `docked` by default; a host that floats or tucks the same chat in a drawer says `floating`. */\n readonly placement?: ChatPlacement | undefined;\n /** Every word the chat shows (title, placeholder, buttons, notices, empty states): a partial object over `defaultChatLabels`. */\n /** The chat's language: `en` (default) or `fr`. `labels` override any of its words. */\n readonly locale?: ChatLocale | undefined;\n readonly labels?: Partial<ChatLabels> | undefined;\n /** The chat's icons (send, stop, history, attach, …): any component taking `className`, over `defaultChatIcons`. */\n readonly icons?: Partial<ChatIcons> | undefined;\n /** The agent's avatar (an image or an icon) before the title in the header, and in the empty state instead of the agent icon. */\n readonly avatar?: ReactNode;\n /** Host actions in the header beside History: `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids `chat-action-<id>`, the menu `chat-action-more`. */\n readonly headerActions?: readonly BlockAction[] | undefined;\n /** `false` lets the host supply one shared header around chat and viewers. Default true. */\n readonly header?: boolean;\n readonly className?: string;\n readonly decisions?: ReactNode;\n readonly controls?: ReactNode;\n /** Host controls at the end of the composer bar (for example a microphone), like AmbientChat's tools slot. */\n readonly tools?: ReactNode;\n /** Rendered before the title in the header (for example a menu button on narrow screens). */\n readonly headerStart?: ReactNode;\n readonly onFiles?: FilesHandler;\n /** Shown instead of the default empty state before the first message. */\n readonly emptyState?: ReactNode;\n readonly suggestions?: readonly Suggestion[];\n /** Past conversations. The header's History button then opens a searchable list with the open one marked and a New action. Omit it and History pages through the earlier records of this conversation. */\n readonly conversations?: ConversationsConfig;\n /**\n * `false`: the host shows `conversations` itself (for example the `pi-app` sessions pane), so History pages through the earlier records of this\n * conversation while replies keep their Fork button (`conversations.fork`). Default `true`: History opens the list.\n */\n readonly historyList?: boolean;\n /** Show the header History button. Default true; paging remains available through the controller. */\n readonly showHistory?: boolean;\n /** Empty desktop composition. `centered` puts the composer beneath the greeting; `docked` keeps the former layout. Phones always dock it. */\n readonly emptyLayout?: 'centered' | 'docked';\n /** Two-row reference composer by default. Explicitly select `pill`, `stacked` or `inline` for the earlier layouts. */\n readonly composerLayout?: ComposerProps['layout'];\n /** A conversation title, separate from the agent name, with optional click-to-rename. */\n readonly conversationTitle?: string | undefined;\n readonly onRenameTitle?: ((title: string) => void | Promise<void>) | undefined;\n /** Show the header connection badge. Default false; connection state still controls actions and failures stay visible. */\n readonly showConnectionStatus?: boolean;\n /** The optional annotate toggle and notes chip in the composer (the registry `feedback` item's `useComposerFeedback`). Omit it and nothing changes. */\n readonly feedback?: ComposerFeedback;\n /** Keeps the composer's half-typed message in localStorage under this key (a conversation id), across reloads and tabs; see `useChatDraft`. Omit it and nothing is stored. */\n readonly draftKey?: string | undefined;\n}\n\nconst PAGE = 60, MORE = 40;\n\n/** Borrows a concrete native controller; unmount releases presentation and uploads only. */\nexport function PiChat(props: PiChatProps) {\n const active = useRef(props.controller); active.current = props.controller;\n const [mount, setMount] = useState({ controller: props.controller, sequence: 0 });\n if (mount.controller !== props.controller) setMount({ controller: props.controller, sequence: mount.sequence + 1 });\n const text = useMergedText(props.labels, props.icons, chatTextFor(props.locale));\n return <ChatTextProvider value={text}><MessageScrollerProvider key={mount.sequence} autoScroll><PiChatSession {...props} activeController={active} /></MessageScrollerProvider></ChatTextProvider>;\n}\n\nfunction ConnectionBadge({ kind }: { readonly kind: string }) {\n const { labels } = useChatText();\n const live = kind === 'connected';\n return <Badge variant={live ? 'outline' : 'secondary'} data-testid=\"connection\" data-state={kind} role=\"status\">\n {live ? labels.live : kind === 'connecting' || kind === 'idle' ? labels.connecting : labels.reconnecting}\n </Badge>;\n}\n\nfunction PiChatSession({ controller, placement = 'docked', mode = 'expert', className, actions, avatar, headerActions, header = true, decisions, controls, tools, composerChips, headerStart, renderEntry, renderTool, groupTool, commandMentions,\n onOpenImage, onCopy, onComposerKeyDown, onFiles, fileAccept = 'image/*', emptyState, suggestions, messageActions, replyFooter, slash, mentions, attachments, model, effort, artifacts, conversations, historyList = true, showHistory = true, showConnectionStatus = false, emptyLayout = 'centered', composerLayout = 'card', conversationTitle, onRenameTitle, feedback, draftKey, onOpenFile, fileChanges, pasteToFile, activeController: active }: PiChatProps & { readonly activeController: { readonly current: NativeChatController } }) {\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 // A reply's Fork button calls the host's latest `fork`; the row context changes only when forking is switched on or off.\n const fork = useRef(conversations?.fork); fork.current = conversations?.fork;\n const canFork = Boolean(conversations?.fork);\n const rowExtras = useMemo(() => canFork ? { onFork: (entryId: string) => { void fork.current?.(entryId).catch(() => {}); } } : undefined, [canFork]);\n const session = useChatSession({ controller, activeController: active, mode, actions, renderEntry, renderTool, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, onFiles, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => scrollToBottom.current(), feedback, rowExtras, onOpenFile, fileChanges, pasteToFile, composerChips });\n const { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, error, act, textarea, rowContext, composer, files, loading } = session;\n const empty = session.empty;\n const { pinned, retry } = derived;\n const transcript = useTranscript(rows, PAGE, MORE, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier });\n const { stick, hidden } = transcript;\n scrollToBottom.current = () => { transcript.reset(); void stick.scrollToBottom('instant'); };\n const visible = transcript.visible(rows);\n const { barStart, barNote } = session.pickers();\n const connectionKind = state.connection.kind;\n const [renamingTitle, setRenamingTitle] = useState(false);\n const [titleError, setTitleError] = useState(false);\n const page = useRef<HTMLElement>(null);\n useEffect(() => {\n const viewport = globalThis.visualViewport;\n if (!viewport) return;\n const element = page.current;\n if (!element) return;\n const measure = () => element.style.setProperty('--composer-keyboard-inset', `${Math.max(0, element.getBoundingClientRect().bottom - viewport.height - viewport.offsetTop)}px`);\n const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure);\n observer?.observe(element);\n measure(); viewport.addEventListener('resize', measure); viewport.addEventListener('scroll', measure);\n return () => { observer?.disconnect(); viewport.removeEventListener('resize', measure); viewport.removeEventListener('scroll', measure); };\n }, []);\n\n return <section ref={page} data-boring=\"pi-chat\" data-connection={connectionKind} data-empty={empty && !loading ? emptyLayout : undefined} aria-label={title} className={cn('pi-chat relative box-border flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-background text-foreground max-[641px]:pb-(--composer-keyboard-inset)', empty && emptyLayout === 'centered' && 'min-[641px]:justify-center', className)}>\n {header && <header className={cn(empty && emptyLayout === 'centered' && 'min-[641px]:absolute min-[641px]:inset-x-0 min-[641px]:top-0', 'flex h-13 shrink-0 items-center gap-2 px-3 sm:gap-3 sm:px-4')}>\n {headerStart}\n {avatar && <Avatar data-testid=\"chat-avatar\" className=\"overflow-hidden\"><AvatarFallback className=\"*:max-h-full *:max-w-full\">{avatar}</AvatarFallback></Avatar>}\n {renamingTitle && onRenameTitle ? <form className=\"min-w-0 flex-1\" onSubmit={event => { event.preventDefault(); const value = new FormData(event.currentTarget).get('title')?.toString().trim(); if (value) { setTitleError(false); void Promise.resolve().then(() => onRenameTitle(value)).catch(() => setTitleError(true)); } setRenamingTitle(false); }}>\n <Input name=\"title\" aria-label={labels.conversationName} maxLength={200} autoFocus defaultValue={conversationTitle ?? title} onBlur={() => setRenamingTitle(false)} onKeyDown={event => { if (event.key === 'Escape') setRenamingTitle(false); }} className=\"w-full min-w-0\" />\n </form> : <h2 className=\"m-0 min-w-0 flex-1 truncate font-(family-name:--chat-display) text-sm font-medium tracking-tight max-[641px]:text-center\">{onRenameTitle ? <Button variant=\"ghost\" className=\"max-w-full\" onClick={() => setRenamingTitle(true)}><span className=\"truncate\">{conversationTitle ?? title}</span></Button> : conversationTitle ?? title}</h2>}\n {showHistory && conversations && historyList && <Button variant=\"ghost\" size=\"sm\" data-testid=\"history-open\" aria-haspopup=\"dialog\" aria-expanded={pickingConversation} disabled={state.disposed}\n onClick={() => setPickingConversation(open => !open)}><icons.history data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.history}</Button>}\n <BlockActions actions={headerActions} testId=\"chat-action\" menuLabel={labels.moreActions} />\n {showConnectionStatus && <ConnectionBadge kind={connectionKind} />}\n {controls}\n </header>}\n\n {titleError && <p role=\"alert\" className=\"m-0 shrink-0 px-4 py-2 text-sm text-destructive\">{labels.actionFailed}</p>}\n {decisions && <><div data-boring=\"required-decisions\" className=\"shrink-0 px-4 py-2\">{decisions}</div><Separator /></>}\n {pinned.length > 0 && <><aside aria-label={labels.requiredActions} className=\"shrink-0 space-y-2 px-4 py-2\">{pinned.map(card => <div key={card.key}>{card.content}</div>)}</aside><Separator /></>}\n {(connectionKind === 'error' || connectionKind === 'closed') && <div className=\"shrink-0 px-4 pt-2\">\n <Notice tone={connectionKind === 'error' ? 'error' : 'info'} testid=\"connection-notice\">\n <span className=\"flex-1\">{connectionKind === 'error' ? labels.loadFailed : labels.disconnected}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}><icons.reconnect data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.reconnect}</Button>}\n </Notice></div>}\n {pickingConversation && conversations && historyList && <ConversationHistory conversations={conversations} onClose={() => setPickingConversation(false)} />}\n\n <div data-testid=\"conversation-content\" className={cn('relative min-h-0 flex-1', empty && emptyLayout === 'centered' && 'min-[641px]:min-h-60 min-[641px]:flex-none')}>\n <MessageScroller>\n <MessageScrollerViewport ref={stick.scrollRef} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"scrollbar-thumb-border scrollbar-track-transparent\">\n {!empty && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div className=\"mx-auto w-full max-w-[720px] px-4 pt-6 pb-8 sm:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\">\n {visible.map(row => <MessageScrollerItem key={row.key} messageId={row.key} className=\"[content-visibility:visible]\"><RowView row={row} context={rowContext} /></MessageScrollerItem>)}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {!empty && !stick.isAtBottom && <MessageScrollerButton data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={transcript.reset}><icons.jumpToLatest aria-hidden=\"true\" /></MessageScrollerButton>}\n </MessageScroller>\n {(empty || loading) && <div className=\"pointer-events-none absolute inset-0\">\n <div className=\"pointer-events-auto h-full\">\n {loading ? <p role=\"status\" data-testid=\"loading\" className=\"flex h-full items-center justify-center gap-2 text-sm text-muted-foreground\"><Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{labels.loading}</p>\n : emptyState ?? <EmptyState title={title} description={labels.emptyDescription} {...(avatar ? { avatar } : {})} {...(suggestions ? { suggestions } : {})} onSelect={text => { controller.setText(text); textarea.current?.focus(); }} />}\n </div></div>}\n </div>\n\n <footer data-testid=\"composer-dock\" className=\"shrink-0 px-4 pt-2 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-6\">\n <div className=\"mx-auto w-full max-w-[720px] space-y-2\">\n <div aria-live=\"polite\" className=\"space-y-2 empty:hidden\">\n {working && <p role=\"status\" data-testid=\"agent-status\" className=\"m-0 flex items-center gap-2 px-1 text-xs text-muted-foreground\">\n <Shimmer active={!waitingForAnswer}>{waitingForAnswer ? labels.waitingForAnswer : retry ? labels.retryPending : labels.agentWorking}</Shimmer></p>}\n {derived.compacting && <p role=\"status\" className=\"m-0 px-1 text-xs text-muted-foreground\">{labels.compacting}</p>}\n <ChatNotices state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} />\n </div>\n <div>\n <ConversationFiles files={files} />\n <MessageQueue items={queued} sending={state.outbox} withdraw={actions?.withdraw} actions={queueActions} />\n <Composer {...composer} layout={composerLayout} placeholder={labels.placeholder === 'Message the agent…' ? `Message ${title}…` : labels.placeholder} barStart={barStart} barNote={barNote} barEnd={tools} />\n </div>\n </div>\n </footer>\n </section>;\n}\n",
|
|
106
141
|
"type": "registry:component",
|
|
107
142
|
"target": "components/pi-chat/pi-chat.tsx"
|
|
108
143
|
},
|
|
@@ -114,55 +149,55 @@
|
|
|
114
149
|
},
|
|
115
150
|
{
|
|
116
151
|
"path": "registry/pi-chat/pickers.tsx",
|
|
117
|
-
"content": "'use client';\n\nimport { useEffect,
|
|
152
|
+
"content": "'use client';\n\nimport { useEffect, useState } from 'react';\nimport { ChevronDownIcon, Loader2Icon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport type { EffortConfig, ModelConfig, ModelRef } from './config';\nimport { useChatText } from './labels';\n\ninterface Choice { readonly section: 'model' | 'effort'; readonly value: string; readonly label: string; readonly detail?: string }\n\nconst key = (model: ModelRef) => `${model.provider}/${model.modelId}`;\n\n/**\n * One pill for the model and the thinking level (\"GPT-5 mini · Medium\") that opens the host's shadcn dropdown menu with a radio\n * group for each. Only the configured parts show; with neither there is no pill.\n */\nexport function ModelEffortPicker({ model, effort, currentModel, currentEffort, disabled, busy, onModel, onEffort }: {\n readonly model: ModelConfig | undefined; readonly effort: EffortConfig | undefined;\n readonly currentModel: ModelRef | undefined; readonly currentEffort: string | undefined;\n readonly disabled: boolean; readonly busy: 'model' | 'effort' | null;\n readonly onModel: (model: ModelRef) => void; readonly onEffort: (level: string) => void;\n}) {\n const { labels } = useChatText();\n const name = labels.effortName;\n const [open, setOpen] = useState(false);\n useEffect(() => { if (disabled) setOpen(false); }, [disabled]);\n const models: Choice[] = (model?.options ?? []).map(option => ({ section: 'model', value: key(option), label: option.label ?? option.modelId, detail: option.provider }));\n // A model or level outside the offered list is still shown, so the label never lies about what runs.\n if (model && currentModel && !models.some(choice => choice.value === key(currentModel))) models.unshift({ section: 'model', value: key(currentModel), label: currentModel.modelId, detail: currentModel.provider });\n const levels: Choice[] = (effort?.options ?? []).map(level => ({ section: 'effort', value: level, label: name(level), ...(labels.effortDetail(level) ? { detail: labels.effortDetail(level)! } : {}) }));\n if (effort && currentEffort && !levels.some(choice => choice.value === currentEffort)) levels.unshift({ section: 'effort', value: currentEffort, label: name(currentEffort) });\n if (!models.length && !levels.length) return null;\n const modelValue = currentModel ? key(currentModel) : '';\n\n const pickModel = (value: string) => {\n const option = model?.options.find(item => key(item) === value);\n if (option && value !== modelValue) onModel({ provider: option.provider, modelId: option.modelId });\n };\n const modelLabel = model ? models.find(choice => choice.value === modelValue)?.label || currentModel?.modelId || labels.model : undefined;\n const effortLabel = effort ? currentEffort ? name(currentEffort) : labels.effortDefault : undefined;\n const summary = [modelLabel, effortLabel].filter(Boolean).join(' · ');\n const description = labels.modelSummary(modelLabel, effortLabel);\n const section = (title: string, items: readonly Choice[], value: string, pick: (value: string) => void) => items.length > 0 && <DropdownMenuGroup>\n <DropdownMenuLabel>{title}</DropdownMenuLabel>\n <DropdownMenuRadioGroup value={value} onValueChange={next => pick(String(next))}>\n {items.map(choice => <DropdownMenuRadioItem key={choice.value} value={choice.value} data-testid={`composer-${choice.section}-option`} data-value={choice.value} closeOnClick className=\"max-sm:min-h-11 pointer-coarse:min-h-11\">\n <span className=\"flex min-w-0 flex-1 flex-col\"><span className=\"truncate\">{choice.label}</span>\n {choice.detail && <span className=\"truncate text-xs text-muted-foreground\">{choice.detail}</span>}</span>\n </DropdownMenuRadioItem>)}\n </DropdownMenuRadioGroup>\n </DropdownMenuGroup>;\n return <DropdownMenu open={open} onOpenChange={setOpen}>\n <div className=\"min-w-0 max-w-[65%] shrink @max-[480px]/composer:max-w-20\">\n <DropdownMenuTrigger render={<Button variant=\"ghost\" />} data-testid=\"composer-model\" data-value={modelValue} data-effort={currentEffort ?? ''} disabled={disabled} aria-label={description} title={description} className=\"min-w-0 max-w-full\">\n {busy && <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />}\n <span data-testid=\"composer-model-label\" className=\"min-w-0 truncate\">{summary}</span>\n <ChevronDownIcon data-icon=\"inline-end\" aria-hidden=\"true\" />\n </DropdownMenuTrigger>\n </div>\n <DropdownMenuContent side=\"top\" align=\"start\" aria-label={labels.modelAndEffort} data-testid=\"composer-model-menu\" className=\"w-56 max-w-[calc(100vw-1rem)]\">\n {model && section(labels.model, models, modelValue, pickModel)}\n {model && effort && models.length > 0 && levels.length > 0 && <DropdownMenuSeparator />}\n {effort && section(labels.effort, levels, currentEffort ?? '', value => { if (value !== currentEffort) onEffort(value); })}\n </DropdownMenuContent>\n </DropdownMenu>;\n}\n",
|
|
118
153
|
"type": "registry:component",
|
|
119
154
|
"target": "components/pi-chat/pickers.tsx"
|
|
120
155
|
},
|
|
121
156
|
{
|
|
122
157
|
"path": "registry/pi-chat/question-card.tsx",
|
|
123
|
-
"content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport type { FormEvent } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { CheckIcon, MessageCircleQuestionIcon, SendIcon } from 'lucide-react';\nimport { Button } from '
|
|
158
|
+
"content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport type { FormEvent } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { CheckIcon, MessageCircleQuestionIcon, SendIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Item, ItemContent, ItemDescription, ItemFooter, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { useChatText } from './labels';\n\n/** What the host answers when asked to resolve a pending question. */\nexport type AnswerOutcome = { readonly kind: 'answered' } | { readonly kind: 'denied' | 'conflict' | 'unknown-question'; readonly reason?: string };\nexport const ASK_USER_TOOL = 'ask_user';\n\ninterface Question { readonly question: string; readonly options: readonly string[]; readonly allowFreeText: boolean }\n\n/** Read `{ question, options?, allowFreeText? }` defensively: model arguments are untrusted input. */\nexport function parseQuestion(call: ToolCall): Question {\n const args = call.arguments as Record<string, unknown>;\n const options = Array.isArray(args['options']) ? args['options'].filter((item): item is string => typeof item === 'string' && item.length > 0).slice(0, 6) : [];\n return { question: typeof args['question'] === 'string' ? args['question'] : '', options, allowFreeText: args['allowFreeText'] === true };\n}\n\n/** The answered text from a result such as `{\"kind\":\"answered\",\"answer\":\"...\"}`; undefined when the call did not produce an answer. */\nexport function parseAnswer(result: ToolResultMessage | undefined): { readonly answer: string } | { readonly failure: string } | undefined {\n if (!result) return undefined;\n const text = result.content.map(part => part.type === 'text' ? part.text : '').join('');\n if (!result.isError) {\n try {\n const value: unknown = JSON.parse(text);\n if (value !== null && typeof value === 'object' && (value as { kind?: unknown }).kind === 'answered' && typeof (value as { answer?: unknown }).answer === 'string') return { answer: (value as { answer: string }).answer };\n } catch { /* fall through to the failure text */ }\n }\n return { failure: text || 'The question was not answered.' };\n}\n\n\n/** Human-in-the-loop question: writing (still generated, not answerable), pending (options and optional free text), then answered with the chosen answer. */\nexport function QuestionCard({ call, result, answer, questionId, live = false }: {\n readonly call: ToolCall;\n readonly result: ToolResultMessage | undefined;\n readonly questionId: string | undefined;\n readonly answer?: ((questionId: string, answer: string) => Promise<AnswerOutcome>) | undefined;\n /** The call is still being generated by the model: it cannot be answered yet. */\n readonly live?: boolean;\n}) {\n const { labels } = useChatText();\n const question = parseQuestion(call);\n const settled = parseAnswer(result);\n const [sent, setSent] = useState<string | undefined>();\n const [sending, setSending] = useState<string | undefined>();\n const [refusal, setRefusal] = useState<string | undefined>();\n const [text, setText] = useState('');\n const mounted = useRef(true);\n useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\n const current = settled && 'answer' in settled ? settled.answer : sent;\n // `pending` means the question waits on the person; `writing` means the model is still generating it (options may be partial).\n const state = settled ? ('failure' in settled ? 'failed' : 'answered') : sent !== undefined ? 'sent' : live ? 'writing' : 'pending';\n const open = state === 'pending' || state === 'writing';\n\n async function submit(value: string) {\n const trimmed = value.trim();\n if (!answer || !questionId || live || !trimmed || sending !== undefined) return;\n setRefusal(undefined); setSending(trimmed);\n try {\n const outcome = await answer(questionId, trimmed);\n if (outcome.kind === 'answered') setSent(trimmed);\n else setRefusal(outcome.reason || labels.answerRefused(outcome.kind));\n } catch (cause) { setRefusal(cause instanceof Error ? cause.message : labels.answerNotSent); }\n finally { if (mounted.current) setSending(undefined); }\n }\n const onSubmit = (event: FormEvent) => { event.preventDefault(); void submit(text); };\n const busy = sending !== undefined || live;\n\n const unavailable = !answer || !questionId || busy;\n\n return <Item variant=\"outline\" size=\"sm\" render={<section />} data-testid=\"question-card\" data-state={state} data-call-id={call.id} aria-label={labels.question} className=\"my-2 text-start\">\n <ItemMedia variant=\"icon\">\n {state === 'answered' || state === 'sent' ? <CheckIcon aria-hidden=\"true\" /> : <MessageCircleQuestionIcon aria-hidden=\"true\" />}\n </ItemMedia>\n <ItemContent className=\"min-w-0 basis-0\">\n <ItemDescription className=\"line-clamp-none\"><Badge variant={open ? 'outline' : 'secondary'}>{open ? labels.questionForYou : state === 'failed' ? labels.notAnswered : labels.answered}</Badge></ItemDescription>\n <ItemTitle data-testid=\"question-text\" className=\"line-clamp-none [overflow-wrap:anywhere]\">{question.question || labels.askedQuestion}</ItemTitle>\n {(state === 'answered' || state === 'sent') && current !== undefined && <ItemDescription className=\"line-clamp-none\">{labels.yourAnswer} <span data-testid=\"question-answer\" className=\"font-medium text-foreground [overflow-wrap:anywhere]\">{current}</span>\n {state === 'sent' && <span className=\"ms-2 text-xs\">{labels.waitingForAgent}</span>}</ItemDescription>}\n {state === 'failed' && settled && 'failure' in settled && <p role=\"alert\" className=\"m-0 text-sm text-destructive\">{settled.failure}</p>}\n </ItemContent>\n {open && <ItemFooter className=\"flex-col items-stretch\">\n {question.options.length > 0 && <div role=\"group\" aria-label={labels.answerOptions} className=\"flex flex-col items-stretch gap-2\">\n {question.options.map(option => <Button key={option} variant=\"outline\" data-testid=\"question-option\" data-option={option} disabled={unavailable}\n onClick={() => { void submit(option); }} className=\"h-auto min-h-11 w-full justify-start whitespace-normal py-2 text-start\">{option}</Button>)}\n </div>}\n {question.allowFreeText && <form onSubmit={onSubmit} className=\"flex gap-2\">\n <Input data-testid=\"question-input\" value={text} maxLength={2000} disabled={unavailable} placeholder={question.options.length ? labels.ownAnswer : labels.typeAnswer}\n aria-label={labels.yourAnswerInput} onChange={event => setText(event.currentTarget.value)} className=\"min-w-0 flex-1\" />\n <Button type=\"submit\" variant=\"default\" data-testid=\"question-submit\" disabled={unavailable || !text.trim()}><SendIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.answer}</Button>\n </form>}\n {live && <ItemDescription>{labels.stillWritingQuestion}</ItemDescription>}\n {!answer && !live && <ItemDescription>{labels.answeringUnavailable}</ItemDescription>}\n {refusal && <p role=\"alert\" data-testid=\"question-refusal\" className=\"m-0 text-sm text-destructive\">{refusal}</p>}\n </ItemFooter>}\n </Item>;\n}\n",
|
|
124
159
|
"type": "registry:component",
|
|
125
160
|
"target": "components/pi-chat/question-card.tsx"
|
|
126
161
|
},
|
|
127
162
|
{
|
|
128
163
|
"path": "registry/pi-chat/queue.tsx",
|
|
129
|
-
"content": "'use client';\n\nimport {
|
|
164
|
+
"content": "'use client';\n\nimport { useState } from 'react';\nimport type { ChatDraft } from '@hachej/boring-ui-kit/native-chat';\nimport { CornerDownRightIcon, EllipsisIcon, ListEndIcon, PencilIcon, RouteIcon, Trash2Icon } from 'lucide-react';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { Card, CardContent } from '@/components/ui/card';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Spinner } from '@/components/ui/spinner';\nimport { TraceIcon, TraceLine, TraceTitle } from '@/components/ui/trace';\nimport { Button } from '@/components/ui/button';\nimport { isFileBlock, type QueuedMessage } from './rows';\nimport { defaultChatLabels, useChatText } from './labels';\n\nexport type { QueuedMessage } from './rows';\n\n/** Display text of a queued submission: the text parts of its content, plus a count of images. */\nexport function queuedText(item: QueuedMessage, imagesLabel: (count: number) => string = defaultChatLabels.images): string {\n const content = item.content as unknown;\n if (typeof content === 'string') return content;\n if (!Array.isArray(content)) return '';\n const parts = content as readonly { type?: unknown; text?: unknown }[];\n const text = parts.flatMap(part => part?.type === 'text' && typeof part.text === 'string' && !isFileBlock(part.text) ? [part.text] : []).join('\\n');\n const images = parts.filter(part => part?.type === 'image').length;\n return images ? `${text}${text ? '\\n' : ''}${imagesLabel(images)}` : text;\n}\n\n/** What a queued message can be taken back for. Both take the message out of the queue first and throw when it is too late. */\nexport interface QueueActions {\n /** Take it out of the queue and put its text (and images) back in the composer. */\n readonly edit?: ((item: QueuedMessage) => Promise<void>) | undefined;\n /** Take it out of the queue and send it into the running turn now. */\n readonly steer?: ((item: QueuedMessage) => Promise<void>) | undefined;\n}\n\n/** The \"…\" button of one queued message: a small menu of the secondary actions (Edit). */\nfunction QueueEditAction({ disabled, onEdit }: { readonly disabled: boolean; readonly onEdit: () => void }) {\n const { labels } = useChatText();\n return <DropdownMenu>\n <DropdownMenuTrigger render={<Button variant=\"ghost\" size=\"icon\" />} data-testid=\"queue-more\" aria-label={labels.moreActions} title={labels.more} disabled={disabled}>\n <EllipsisIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n </DropdownMenuTrigger>\n <DropdownMenuContent side=\"top\" align=\"end\" data-testid=\"queue-menu\">\n <DropdownMenuItem data-testid=\"queue-edit\" onClick={onEdit}><PencilIcon aria-hidden=\"true\" />{labels.edit}</DropdownMenuItem>\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><CardContent><ul className=\"m-0 flex list-none flex-col gap-2 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=\"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=\"sm\" data-testid=\"queue-steer\" title={labels.steerHint} disabled={working} onClick={() => { void run(item, () => actions.steer!(item)); }}>\n <CornerDownRightIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.steer}</Button>}\n {steering && <span className=\"shrink-0 px-1 text-xs text-muted-foreground\">{labels.steering}</span>}\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=\"flex-1\">\n <TraceIcon><Spinner aria-hidden=\"true\" /></TraceIcon>\n <TraceTitle data-testid=\"queue-text\">{draft.text}{draft.attachments.length ? `${draft.text ? '\\n' : ''}${labels.images(draft.attachments.length)}` : ''}</TraceTitle>\n </TraceLine>\n <span className=\"shrink-0 px-1 text-xs text-muted-foreground\">{labels.sending}</span>\n </li>)}\n </ul></CardContent></Card>\n {failed && <Alert variant=\"destructive\" data-testid=\"queue-error\"><AlertDescription>{failed}</AlertDescription></Alert>}\n </section>;\n}\n",
|
|
130
165
|
"type": "registry:component",
|
|
131
166
|
"target": "components/pi-chat/queue.tsx"
|
|
132
167
|
},
|
|
133
168
|
{
|
|
134
169
|
"path": "registry/pi-chat/rows.ts",
|
|
135
|
-
"content": "import type { ReactNode } from 'react';\nimport type { ConversationView, EntryRecord, InboxItem, JsonObject } from '@earendil-works/pi-durable';\nimport type { JsonValue } from '@earendil-works/chord';\nimport type { AssistantMessage, Message, ToolCall, ToolResultMessage, UserMessage } from '@earendil-works/pi-ai';\nimport type { ToolEntry, ToolStatus } from './tool';\nimport { ASK_USER_TOOL } from './question-card';\nimport { PRESENT_TOOL, artifactKey, detectArtifact, pendingId, pendingTitle } from './artifact';\nimport type { ArtifactDescriptor, ArtifactsConfig } from './artifact';\n\n/** The host adds file content for `@path` mentions as parts that start like this; the message already shows the mention itself. */\nexport const isFileBlock = (text: string): boolean => text.startsWith('<file path=\"');\n\n/** A submission waiting in the native inbox (steer or follow-up); `queuedMessages` reads them from the view, the queue renders them. */\nexport type QueuedMessage = Extract<InboxItem, { readonly mode: 'steer' | 'followUp' }>;\n\n/*\n * The one message model is the native Pi view: entries (with their model messages) plus the live `pi.live` document.\n * This module turns a view into display rows with these semantics: tool calls are matched with tool results, expert mode hides successful tool details, the live\n * generation is appended as a streaming row whose key matches the row its committed message will take.\n */\n/** `required` pins the card above the transcript. `inline` (tool cards) keeps it in place in expert mode too, like the\n * question and artifact cards; other custom tool cards show only in developer mode or on failure. */\nexport interface ChatCard { readonly content: ReactNode; readonly required?: boolean; readonly inline?: boolean }\nexport type Mode = 'expert' | 'developer';\nexport type StreamPart = { readonly type: 'text'; readonly text: string } | { readonly type: 'thinking'; readonly thinking: string } | ToolCall;\n\nexport type Part =\n | { readonly kind: 'text'; readonly key: string; readonly text: string }\n | { readonly kind: 'thinking'; readonly key: string; readonly text: string; readonly streaming: boolean }\n | { readonly kind: 'question'; readonly key: string; readonly call: ToolCall; readonly result: ToolResultMessage | undefined; readonly live: boolean; readonly questionId: string | undefined }\n | { readonly kind: 'approval'; readonly key: string; readonly call: ToolCall; readonly result: ToolResultMessage | undefined; readonly live: boolean; /** The bound question ID the answer must use (as for `question`). */ readonly questionId: string | undefined; /** The one-line summary `requireApproval` published for this call. */ readonly summary: string; /** The decision it recorded, once made. */ readonly decision?: ApprovalDecision; /** The call's own step, shown in the activity block once it has a result. */ readonly entry?: ToolEntry }\n | { readonly kind: 'artifact'; readonly key: string; readonly call: ToolCall; readonly state: 'presenting' | 'ready'; readonly artifact: ArtifactDescriptor | undefined; readonly title: string | undefined; /** Set when the host's `detect` found the descriptor: the call stays a step of the activity block too. */ readonly entry?: ToolEntry }\n | { readonly kind: 'tool'; readonly key: string; readonly entry: ToolEntry };\n\nexport type Row =\n | { readonly key: string; readonly type: 'user'; readonly message: UserMessage }\n | { readonly key: string; readonly type: 'assistant'; readonly parts: readonly Part[]; readonly streaming: boolean; /** The turn is still running (the last row while a run is active). */ readonly active?: boolean; readonly stopReason?: AssistantMessage['stopReason']; readonly errorMessage?: string; readonly text: string; /** When the reply was written (the last round of a merged turn), in ms. Absent while it streams. */ readonly timestamp?: number; /** The model that wrote the last round of the reply. */ readonly model?: { readonly provider: string; readonly modelId: string }; /** How long the last round took to answer, in ms (Pi's `durationMs`). */ readonly durationMs?: number; /** The native entry of its last committed round: where a fork of the conversation after this reply starts. */ readonly entryId?: string }\n | { readonly key: string; readonly type: 'orphan-result'; readonly message: ToolResultMessage }\n | { readonly key: string; readonly type: 'system'; readonly text: string }\n | { readonly key: string; readonly type: 'event'; readonly label: string }\n | { readonly key: string; readonly type: 'card'; readonly card: ChatCard };\n\nexport function object(value: JsonValue | undefined): JsonObject | undefined {\n return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;\n}\nexport function streamParts(value: JsonValue | undefined): StreamPart[] {\n if (!Array.isArray(value)) return [];\n return value.flatMap((item): StreamPart[] => {\n const part = object(item);\n if (part?.['type'] === 'text' && typeof part['text'] === 'string') return [{ type: 'text', text: part['text'] }];\n if (part?.['type'] === 'thinking' && typeof part['thinking'] === 'string') return [{ type: 'thinking', thinking: part['thinking'] }];\n const args = object(part?.['arguments']);\n if (part?.['type'] === 'toolCall' && typeof part['id'] === 'string' && typeof part['name'] === 'string' && args) return [{ type: 'toolCall', id: part['id'], name: part['name'], arguments: args }];\n return [];\n });\n}\n\nexport function queuedMessages(view: ConversationView | undefined): QueuedMessage[] {\n const items = object(view?.docs['pi.inbox'])?.['items'];\n if (!Array.isArray(items)) return [];\n return items.filter((item): item is QueuedMessage => {\n const value = object(item as JsonValue);\n return value !== undefined && (value['mode'] === 'steer' || value['mode'] === 'followUp') && value['id'] !== undefined;\n });\n}\n\nexport interface Derived {\n readonly rows: readonly Row[];\n /** Required entry and tool cards: pinned above the transcript. */\n readonly pinned: readonly { readonly key: string; readonly content: ReactNode }[];\n readonly run: JsonObject | undefined;\n readonly retry: JsonObject | undefined;\n readonly compacting: boolean;\n}\nexport interface DeriveOptions {\n readonly mode: Mode;\n readonly renderEntry: ((entry: EntryRecord) => ChatCard | undefined) | undefined;\n readonly renderTool: ((call: ToolCall, result: ToolResultMessage | undefined) => ChatCard | undefined) | undefined;\n readonly groupTool: ((call: ToolCall, result: ToolResultMessage) => boolean) | undefined;\n /** Turn artifact tool results into cards (`detect` recognises descriptors the tool result does not carry itself). */\n readonly artifacts?: { readonly detect?: ArtifactsConfig['detect'] | undefined } | undefined;\n}\n\n/** Key of `{ summary, decision? }` in a gated call's details: `APPROVAL_DETAILS` of `@hachej/boring-agent/approval` (a contract test keeps them equal). */\nexport const APPROVAL_DETAILS = 'boring.approval';\nexport type ApprovalDecision = 'approved' | 'denied' | 'cancelled';\n/** The approval record a gated call publishes: its summary and, once made, the decision (`approved`, `denied` or `cancelled`). */\nconst approvalRecord = (details: unknown): { summary: string; decision?: ApprovalDecision } | undefined => {\n const record = object(object(details as JsonValue | undefined)?.[APPROVAL_DETAILS]);\n const summary = record?.['summary'], decision = record?.['decision'];\n if (typeof summary !== 'string' || !summary.trim()) return undefined;\n return { summary: summary.trim(), ...(decision === 'approved' || decision === 'denied' || decision === 'cancelled' ? { decision } : {}) };\n};\n\nconst textOf = (message: Message): string => message.role === 'assistant' ? message.content.flatMap(part => part.type === 'text' ? [part.text] : []).join('\\n') : '';\n\nexport function derive(view: ConversationView | undefined, options: DeriveOptions): Derived {\n const { mode, renderEntry, renderTool } = options;\n const developer = mode === 'developer';\n const live = object(view?.docs['pi.live']);\n const run = object(live?.['run']), generation = object(live?.['generation']);\n const liveMessage = object(generation?.['message']), retry = object(generation?.['retry']);\n const taskId = run?.['taskId'];\n\n // Pass 1: match each result to its preceding invocation. Model call IDs may be reused.\n type Item = { readonly key: string; readonly entry: EntryRecord; readonly message?: Message; readonly card?: ChatCard };\n const items: Item[] = [], results = new Map<ToolCall, ToolResultMessage>(), counts = new Map<number, number>(), questionIds = new Map<ToolCall, string>();\n const pendingCalls = new Map<string, ToolCall>();\n for (const entry of view?.entries ?? []) {\n const custom = renderEntry?.(entry);\n const card = custom && (developer || custom.required) ? custom : undefined;\n if (card) items.push({ key: `entry:${entry.id}`, entry, card });\n if (!entry.model?.length) { if (!card && developer) items.push({ key: `entry:${entry.id}`, entry }); continue; }\n for (const [index, message] of entry.model.entries()) {\n if (message.role === 'assistant') for (const part of message.content) if (part.type === 'toolCall') { pendingCalls.set(part.id, part); questionIds.set(part, JSON.stringify([entry.id, part.id])); }\n if (message.role === 'toolResult') {\n const call = pendingCalls.get(message.toolCallId);\n if (call) { results.set(call, message); pendingCalls.delete(message.toolCallId); }\n }\n let key = `entry:${entry.id}:${index}`;\n if (message.role === 'assistant' && entry.byTaskId !== undefined) {\n const ordinal = counts.get(entry.byTaskId) ?? 0; counts.set(entry.byTaskId, ordinal + 1);\n key = `assistant:${entry.byTaskId}:${ordinal}`;\n }\n if (!card) items.push({ key, entry, message });\n }\n }\n const matched = new Set(results.values());\n\n const pinned: { key: string; content: ReactNode }[] = [];\n for (const item of items) if (item.card?.required) pinned.push({ key: item.key, content: item.card.content });\n\n const status = (result: ToolResultMessage | undefined): ToolStatus => result ? (result.isError ? 'failed' : 'completed') : run ? 'running' : 'unfinished';\n function toolPart(call: ToolCall, key: string, committed: boolean): Part | undefined {\n const result = results.get(call);\n if (call.name === ASK_USER_TOOL) return { kind: 'question', key, call, result, live: !committed, questionId: questionIds.get(call) };\n // A call gated by `requireApproval` identifies itself: it publishes its summary under APPROVAL_DETAILS before it asks.\n const record = approvalRecord(result ? result.details : (Array.isArray(live?.['tools']) ? live['tools'] : []).map(object).find(slot => slot?.['callId'] === call.id)?.['details']);\n if (record) return { kind: 'approval', key, call, result, live: !committed, questionId: questionIds.get(call), summary: record.summary, ...(record.decision ? { decision: record.decision } : {}), ...(result ? { entry: { key: `${key}:step`, call, result, status: status(result) } } : {}) };\n if (options.artifacts) {\n // A finished call that carries a descriptor becomes a card; a running `present` shows an \"opening\" card. A failed call stays an ordinary (failed) tool step.\n const artifact = result ? detectArtifact(call, result, options.artifacts.detect) : undefined;\n if (artifact) {\n // A tool whose own result carries the descriptor is replaced by its card; a host-detected card is shown in addition to the step.\n const own = result && detectArtifact(call, result);\n return { kind: 'artifact', key, call, state: 'ready', artifact, title: artifact.title, ...(own ? {} : { entry: { key, call, result, status: status(result) } }) };\n }\n if (!result && run && call.name === PRESENT_TOOL) {\n return { kind: 'artifact', key, call, state: 'presenting', artifact: undefined, title: pendingTitle(call) };\n }\n }\n const custom = committed ? renderTool?.(call, result) : undefined;\n if (custom?.required) { pinned.push({ key, content: custom.content }); return undefined; }\n if (custom && (developer || result?.isError || custom.inline)) return { kind: 'tool', key, entry: { key, call, result, status: status(result), custom: custom.content } };\n // A host that folds only some calls (`groupTool`) keeps the rest as cards, which expert mode hides unless they failed.\n if (!developer && !result?.isError && options.groupTool && !(result && safe(options.groupTool, { key, call, result, status: status(result) }))) return undefined;\n return { kind: 'tool', key, entry: { key, call, result, status: status(result) } };\n }\n function partsOf(content: readonly StreamPart[], rowKey: string, streaming: boolean, committed: boolean): Part[] {\n const parts: Part[] = [];\n for (const [index, part] of content.entries()) {\n if (part.type === 'text') { if (part.text.length) parts.push({ kind: 'text', key: `${rowKey}:text:${index}`, text: part.text }); }\n else if (part.type === 'thinking') { if (developer && part.thinking.length) parts.push({ kind: 'thinking', key: `${rowKey}:thinking:${index}`, text: part.thinking, streaming: streaming && index === content.length - 1 }); }\n else { const made = toolPart(part, `${rowKey}:tool:${index}`, committed); if ((made?.kind === 'artifact' || made?.kind === 'approval') && made.entry) parts.push({ kind: 'tool', key: `${made.key}:step`, entry: made.entry }); if (made) parts.push(made); }\n }\n return parts;\n }\n\n // Pass 2: rows.\n const rows: Row[] = [];\n for (const item of items) {\n if (item.card) { if (!item.card.required) rows.push({ key: item.key, type: 'card', card: item.card }); continue; }\n const message = item.message;\n if (!message) { if (developer) rows.push({ key: item.key, type: 'event', label: item.entry.kind }); continue; }\n if (message.role === 'system') {\n if (developer) rows.push({ key: item.key, type: 'system', text: typeof message.content === 'string' ? message.content : message.content.map(part => part.text).join('\\n') });\n } else if (message.role === 'toolResult') {\n // A result with its call is shown on the call's card. Only an unmatched failure gets its own row.\n if (message.isError && !matched.has(message)) rows.push({ key: item.key, type: 'orphan-result', message });\n } else if (message.role === 'user') rows.push({ key: item.key, type: 'user', message });\n else {\n const parts = partsOf(message.content, item.key, false, true);\n const interrupted = message.stopReason === 'error' || message.stopReason === 'aborted';\n if (parts.length || interrupted) rows.push({ key: item.key, type: 'assistant', parts, streaming: false, stopReason: message.stopReason, ...(message.errorMessage ? { errorMessage: message.errorMessage } : {}), text: textOf(message), ...replyMeta(message), entryId: String(item.entry.id) });\n else {\n // A final round with nothing to show adds no content, but it is still the round that ended the reply: its model, time and entry replace the earlier round's.\n const previous = rows[rows.length - 1];\n if (previous?.type === 'assistant' && !previous.stopReason?.match(/^(error|aborted)$/)) {\n const { timestamp: _t, model: _m, durationMs: _d, ...rest } = previous;\n rows[rows.length - 1] = { ...rest, ...replyMeta(message), entryId: String(item.entry.id) };\n }\n }\n }\n }\n if (typeof taskId === 'number' && liveMessage) {\n const key = `assistant:${taskId}:${counts.get(taskId) ?? 0}`, content = streamParts(liveMessage['content']);\n rows.push({ key, type: 'assistant', parts: partsOf(content, key, true, false), streaming: true, text: '' });\n }\n const merged = mergeTurns(rows).map(row => row.type === 'assistant' && options.artifacts ? { ...row, parts: oneCardPerArtifact(row.parts) } : row);\n const last = merged[merged.length - 1];\n if (run && last?.type === 'assistant') merged[merged.length - 1] = { ...last, active: true };\n return { rows: merged, pinned, run, retry, compacting: Array.isArray(live?.['compactions']) && live['compactions'].length > 0 };\n}\n\n/** What a reply reports about the model round that wrote it, read from Pi's own message (each field only when it is there). */\nexport function replyMeta(message: AssistantMessage): { readonly timestamp?: number; readonly model?: { readonly provider: string; readonly modelId: string }; readonly durationMs?: number } {\n return {\n ...(typeof message.timestamp === 'number' ? { timestamp: message.timestamp } : {}),\n ...(typeof message.provider === 'string' && typeof message.model === 'string' ? { model: { provider: message.provider, modelId: message.model } } : {}),\n ...(typeof message.durationMs === 'number' ? { durationMs: message.durationMs } : {}),\n };\n}\n\n/**\n * Pi commits one assistant message per model round, so a turn with tool use is several messages in a row.\n * They read as one answer: merge consecutive assistant rows (unless one ended in an error or stop) so tool calls\n * group across rounds and the turn has a single copy action. The first row's key is kept, so identity is stable.\n */\nfunction mergeTurns(rows: readonly Row[]): Row[] {\n const out: Row[] = [];\n for (const row of rows) {\n const last = out[out.length - 1];\n if (last?.type === 'assistant' && row.type === 'assistant' && !last.stopReason?.match(/^(error|aborted)$/)) {\n out[out.length - 1] = { key: last.key, type: 'assistant', parts: [...last.parts, ...row.parts], streaming: row.streaming,\n ...(row.stopReason ? { stopReason: row.stopReason } : {}), ...(row.errorMessage ? { errorMessage: row.errorMessage } : {}),\n text: [last.text, row.text].filter(Boolean).join('\\n\\n'), ...(row.timestamp !== undefined ? { timestamp: row.timestamp } : {}), ...(row.model ? { model: row.model } : {}), ...(row.durationMs !== undefined ? { durationMs: row.durationMs } : {}),\n ...(row.entryId !== undefined ? { entryId: row.entryId } : last.entryId !== undefined ? { entryId: last.entryId } : {}) };\n } else out.push(row);\n }\n return out;\n}\n\n/**\n * A turn that revises an artifact several times shows one card for it: the last version, at the place of the last step.\n * A running `present` of a file already shown takes over the title of its card.\n */\nfunction oneCardPerArtifact(parts: readonly Part[]): Part[] {\n const idOf = (part: Part) => part.kind === 'artifact' ? part.artifact ? artifactKey(part.artifact) : (part.state === 'presenting' ? pendingId(part.call) : undefined) : undefined;\n const last = new Map<string, number>();\n for (const [index, part] of parts.entries()) { const id = idOf(part); if (id !== undefined) last.set(id, index); }\n const out: Part[] = [];\n for (const [index, part] of parts.entries()) {\n const id = idOf(part);\n if (id === undefined || last.get(id) === index) {\n if (part.kind === 'artifact' && part.artifact === undefined && part.title === undefined && id !== undefined) {\n const earlier = parts.find(other => other.kind === 'artifact' && other.artifact !== undefined && artifactKey(other.artifact) === id);\n if (earlier?.kind === 'artifact') { out.push({ ...part, title: earlier.title }); continue; }\n }\n out.push(part);\n }\n }\n return out;\n}\n\n/** One step of an activity block: a default tool call or a stretch of reasoning. */\nexport type Step = { readonly kind: 'tool'; readonly key: string; readonly entry: ToolEntry }\n | { readonly kind: 'thinking'; readonly key: string; readonly text: string; readonly streaming: boolean };\n/** An assistant turn is text parts and activity blocks: one block per uninterrupted run of steps. */\nexport type Segment = { readonly kind: 'part'; readonly part: Part } | { readonly kind: 'activity'; readonly key: string; readonly steps: readonly Step[] };\nexport function segments(parts: readonly Part[], groupTool: DeriveOptions['groupTool']): Segment[] {\n const out: Segment[] = [];\n let run: Step[] = [];\n const flush = () => {\n const first = run[0];\n if (first) out.push({ kind: 'activity', key: `activity:${first.key}`, steps: run });\n run = [];\n };\n for (const part of parts) {\n if (part.kind === 'thinking') run.push({ kind: 'thinking', key: part.key, text: part.text, streaming: part.streaming });\n else if (part.kind === 'tool' && !part.entry.custom && (!groupTool || safe(groupTool, part.entry))) run.push({ kind: 'tool', key: part.key, entry: part.entry });\n else { flush(); out.push({ kind: 'part', part }); }\n }\n flush();\n return out;\n}\nfunction safe(groupTool: NonNullable<DeriveOptions['groupTool']>, entry: ToolEntry): boolean {\n try { return entry.result !== undefined && !entry.result.isError && groupTool(entry.call, entry.result) === true; } catch { return false; }\n}\n",
|
|
170
|
+
"content": "import type { ReactNode } from 'react';\nimport type { ConversationView, EntryRecord, InboxItem, JsonObject } from '@earendil-works/pi-durable';\nimport type { JsonValue } from '@earendil-works/chord';\nimport type { AssistantMessage, Message, ToolCall, ToolResultMessage, UserMessage } from '@earendil-works/pi-ai';\nimport type { ToolEntry, ToolStatus } from './tool';\nimport { ASK_USER_TOOL } from './question-card';\nimport { PRESENT_TOOL, artifactKey, detectArtifact, pendingId, pendingTitle } from './artifact';\nimport type { ArtifactDescriptor, ArtifactsConfig } from './artifact';\n\n/** The host adds file content for `@path` mentions as parts that start like this; the message already shows the mention itself. */\nexport const isFileBlock = (text: string): boolean => text.startsWith('<file path=\"');\n\n/** A submission waiting in the native inbox (steer or follow-up); `queuedMessages` reads them from the view, the queue renders them. */\nexport type QueuedMessage = Extract<InboxItem, { readonly mode: 'steer' | 'followUp' }>;\n\n/*\n * The one message model is the native Pi view: entries (with their model messages) plus the live `pi.live` document.\n * This module turns a view into display rows with these semantics: tool calls are matched with tool results, expert mode hides successful tool details, the live\n * generation is appended as a streaming row whose key matches the row its committed message will take.\n */\n/** `required` pins the card above the transcript. `inline` (tool cards) keeps it in place in expert mode too, like the\n * question and artifact cards; other custom tool cards show only in developer mode or on failure. */\nexport interface ChatCard { readonly content: ReactNode; readonly required?: boolean; readonly inline?: boolean }\nexport type Mode = 'expert' | 'developer';\nexport type StreamPart = { readonly type: 'text'; readonly text: string } | { readonly type: 'thinking'; readonly thinking: string } | ToolCall;\n\nexport type Part =\n | { readonly kind: 'text'; readonly key: string; readonly text: string }\n | { readonly kind: 'thinking'; readonly key: string; readonly text: string; readonly streaming: boolean }\n | { readonly kind: 'question'; readonly key: string; readonly call: ToolCall; readonly result: ToolResultMessage | undefined; readonly live: boolean; readonly questionId: string | undefined }\n | { readonly kind: 'approval'; readonly key: string; readonly call: ToolCall; readonly result: ToolResultMessage | undefined; readonly live: boolean; /** The bound question ID the answer must use (as for `question`). */ readonly questionId: string | undefined; /** The one-line summary `requireApproval` published for this call. */ readonly summary: string; /** The decision it recorded, once made. */ readonly decision?: ApprovalDecision; /** The call's own step, shown in the activity block once it has a result. */ readonly entry?: ToolEntry }\n | { readonly kind: 'artifact'; readonly key: string; readonly call: ToolCall; readonly state: 'presenting' | 'ready'; readonly artifact: ArtifactDescriptor | undefined; readonly title: string | undefined; /** Set when the host's `detect` found the descriptor: the call stays a step of the activity block too. */ readonly entry?: ToolEntry }\n | { readonly kind: 'tool'; readonly key: string; readonly entry: ToolEntry };\n\nexport type Row = (\n | { readonly key: string; readonly type: 'user'; readonly message: UserMessage }\n | { readonly key: string; readonly type: 'assistant'; readonly parts: readonly Part[]; readonly streaming: boolean; /** The turn is still running (the last row while a run is active). */ readonly active?: boolean; readonly stopReason?: AssistantMessage['stopReason']; readonly errorMessage?: string; readonly text: string; /** When the reply was written (the last round of a merged turn), in ms. Absent while it streams. */ readonly timestamp?: number; /** The model that wrote the last round of the reply. */ readonly model?: { readonly provider: string; readonly modelId: string }; /** How long the last round took to answer, in ms (Pi's `durationMs`). */ readonly durationMs?: number; /** The native entry of its last committed round: where a fork of the conversation after this reply starts. */ readonly entryId?: string }\n | { readonly key: string; readonly type: 'orphan-result'; readonly message: ToolResultMessage }\n | { readonly key: string; readonly type: 'system'; readonly text: string }\n | { readonly key: string; readonly type: 'event'; readonly label: string }\n | { readonly key: string; readonly type: 'card'; readonly card: ChatCard }) & { readonly historical?: boolean };\n\nexport function object(value: JsonValue | undefined): JsonObject | undefined {\n return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;\n}\nexport function streamParts(value: JsonValue | undefined): StreamPart[] {\n if (!Array.isArray(value)) return [];\n return value.flatMap((item): StreamPart[] => {\n const part = object(item);\n if (part?.['type'] === 'text' && typeof part['text'] === 'string') return [{ type: 'text', text: part['text'] }];\n if (part?.['type'] === 'thinking' && typeof part['thinking'] === 'string') return [{ type: 'thinking', thinking: part['thinking'] }];\n const args = object(part?.['arguments']);\n if (part?.['type'] === 'toolCall' && typeof part['id'] === 'string' && typeof part['name'] === 'string' && args) return [{ type: 'toolCall', id: part['id'], name: part['name'], arguments: args }];\n return [];\n });\n}\n\nexport function queuedMessages(view: ConversationView | undefined): QueuedMessage[] {\n const items = object(view?.docs['pi.inbox'])?.['items'];\n if (!Array.isArray(items)) return [];\n return items.filter((item): item is QueuedMessage => {\n const value = object(item as JsonValue);\n return value !== undefined && (value['mode'] === 'steer' || value['mode'] === 'followUp') && value['id'] !== undefined;\n });\n}\n\nexport interface Derived {\n readonly rows: readonly Row[];\n /** Required entry and tool cards: pinned above the transcript. */\n readonly pinned: readonly { readonly key: string; readonly content: ReactNode }[];\n readonly run: JsonObject | undefined;\n readonly retry: JsonObject | undefined;\n readonly compacting: boolean;\n}\nexport interface DeriveOptions {\n readonly mode: Mode;\n readonly renderEntry: ((entry: EntryRecord) => ChatCard | undefined) | undefined;\n readonly renderTool: ((call: ToolCall, result: ToolResultMessage | undefined) => ChatCard | undefined) | undefined;\n readonly groupTool: ((call: ToolCall, result: ToolResultMessage) => boolean) | undefined;\n /** Turn artifact tool results into cards (`detect` recognises descriptors the tool result does not carry itself). */\n readonly artifacts?: { readonly detect?: ArtifactsConfig['detect'] | undefined } | undefined;\n}\n\n/** Key of `{ summary, decision? }` in a gated call's details: `APPROVAL_DETAILS` of `@hachej/boring-agent/approval` (a contract test keeps them equal). */\nexport const APPROVAL_DETAILS = 'boring.approval';\nexport type ApprovalDecision = 'approved' | 'denied' | 'cancelled';\n/** The approval record a gated call publishes: its summary and, once made, the decision (`approved`, `denied` or `cancelled`). */\nconst approvalRecord = (details: unknown): { summary: string; decision?: ApprovalDecision } | undefined => {\n const record = object(object(details as JsonValue | undefined)?.[APPROVAL_DETAILS]);\n const summary = record?.['summary'], decision = record?.['decision'];\n if (typeof summary !== 'string' || !summary.trim()) return undefined;\n return { summary: summary.trim(), ...(decision === 'approved' || decision === 'denied' || decision === 'cancelled' ? { decision } : {}) };\n};\n\nconst textOf = (message: Message): string => message.role === 'assistant' ? message.content.flatMap(part => part.type === 'text' ? [part.text] : []).join('\\n') : '';\n\nexport function derive(view: ConversationView | undefined, options: DeriveOptions): Derived {\n const { mode, renderEntry, renderTool } = options;\n const developer = mode === 'developer';\n const live = object(view?.docs['pi.live']);\n const run = object(live?.['run']), generation = object(live?.['generation']);\n const liveMessage = object(generation?.['message']), retry = object(generation?.['retry']);\n const taskId = run?.['taskId'];\n\n // Pass 1: match each result to its preceding invocation. Model call IDs may be reused.\n type Item = { readonly key: string; readonly entry: EntryRecord; readonly message?: Message; readonly card?: ChatCard };\n const items: Item[] = [], results = new Map<ToolCall, ToolResultMessage>(), counts = new Map<number, number>(), questionIds = new Map<ToolCall, string>();\n const pendingCalls = new Map<string, ToolCall>();\n for (const entry of view?.entries ?? []) {\n const custom = renderEntry?.(entry);\n const card = custom && (developer || custom.required) ? custom : undefined;\n if (card) items.push({ key: `entry:${entry.id}`, entry, card });\n if (!entry.model?.length) { if (!card && developer) items.push({ key: `entry:${entry.id}`, entry }); continue; }\n for (const [index, message] of entry.model.entries()) {\n if (message.role === 'assistant') for (const part of message.content) if (part.type === 'toolCall') { pendingCalls.set(part.id, part); questionIds.set(part, JSON.stringify([entry.id, part.id])); }\n if (message.role === 'toolResult') {\n const call = pendingCalls.get(message.toolCallId);\n if (call) { results.set(call, message); pendingCalls.delete(message.toolCallId); }\n }\n let key = `entry:${entry.id}:${index}`;\n if (message.role === 'assistant' && entry.byTaskId !== undefined) {\n const ordinal = counts.get(entry.byTaskId) ?? 0; counts.set(entry.byTaskId, ordinal + 1);\n key = `assistant:${entry.byTaskId}:${ordinal}`;\n }\n if (!card) items.push({ key, entry, message });\n }\n }\n const matched = new Set(results.values());\n\n const pinned: { key: string; content: ReactNode }[] = [];\n for (const item of items) if (item.card?.required) pinned.push({ key: item.key, content: item.card.content });\n\n const status = (result: ToolResultMessage | undefined): ToolStatus => result ? (result.isError ? 'failed' : 'completed') : run ? 'running' : 'unfinished';\n function toolPart(call: ToolCall, key: string, committed: boolean): Part | undefined {\n const result = results.get(call);\n if (call.name === ASK_USER_TOOL) return { kind: 'question', key, call, result, live: !committed, questionId: questionIds.get(call) };\n // A call gated by `requireApproval` identifies itself: it publishes its summary under APPROVAL_DETAILS before it asks.\n const record = approvalRecord(result ? result.details : (Array.isArray(live?.['tools']) ? live['tools'] : []).map(object).find(slot => slot?.['callId'] === call.id)?.['details']);\n if (record) return { kind: 'approval', key, call, result, live: !committed, questionId: questionIds.get(call), summary: record.summary, ...(record.decision ? { decision: record.decision } : {}), ...(result ? { entry: { key: `${key}:step`, call, result, status: status(result) } } : {}) };\n if (options.artifacts) {\n // A finished call that carries a descriptor becomes a card; a running `present` shows an \"opening\" card. A failed call stays an ordinary (failed) tool step.\n const artifact = result ? detectArtifact(call, result, options.artifacts.detect) : undefined;\n if (artifact) {\n // A tool whose own result carries the descriptor is replaced by its card; a host-detected card is shown in addition to the step.\n const own = result && detectArtifact(call, result);\n return { kind: 'artifact', key, call, state: 'ready', artifact, title: artifact.title, ...(own ? {} : { entry: { key, call, result, status: status(result) } }) };\n }\n if (!result && run && call.name === PRESENT_TOOL) {\n return { kind: 'artifact', key, call, state: 'presenting', artifact: undefined, title: pendingTitle(call) };\n }\n }\n const custom = committed ? renderTool?.(call, result) : undefined;\n if (custom?.required) { pinned.push({ key, content: custom.content }); return undefined; }\n if (custom && (developer || result?.isError || custom.inline)) return { kind: 'tool', key, entry: { key, call, result, status: status(result), custom: custom.content } };\n // A host that folds only some calls (`groupTool`) keeps the rest as cards, which expert mode hides unless they failed.\n if (!developer && !result?.isError && options.groupTool && !(result && safe(options.groupTool, { key, call, result, status: status(result) }))) return undefined;\n return { kind: 'tool', key, entry: { key, call, result, status: status(result) } };\n }\n function partsOf(content: readonly StreamPart[], rowKey: string, streaming: boolean, committed: boolean): Part[] {\n const parts: Part[] = [];\n for (const [index, part] of content.entries()) {\n if (part.type === 'text') { if (part.text.length) parts.push({ kind: 'text', key: `${rowKey}:text:${index}`, text: part.text }); }\n else if (part.type === 'thinking') { if (developer && part.thinking.length) parts.push({ kind: 'thinking', key: `${rowKey}:thinking:${index}`, text: part.thinking, streaming: streaming && index === content.length - 1 }); }\n else { const made = toolPart(part, `${rowKey}:tool:${index}`, committed); if ((made?.kind === 'artifact' || made?.kind === 'approval') && made.entry) parts.push({ kind: 'tool', key: `${made.key}:step`, entry: made.entry }); if (made) parts.push(made); }\n }\n return parts;\n }\n\n // Pass 2: rows.\n const rows: Row[] = [];\n for (const item of items) {\n if (item.card) { if (!item.card.required) rows.push({ key: item.key, type: 'card', card: item.card }); continue; }\n const message = item.message;\n if (!message) { if (developer) rows.push({ key: item.key, type: 'event', label: item.entry.kind }); continue; }\n if (message.role === 'system') {\n if (developer) rows.push({ key: item.key, type: 'system', text: typeof message.content === 'string' ? message.content : message.content.map(part => part.text).join('\\n') });\n } else if (message.role === 'toolResult') {\n // A result with its call is shown on the call's card. Only an unmatched failure gets its own row.\n if (message.isError && !matched.has(message)) rows.push({ key: item.key, type: 'orphan-result', message });\n } else if (message.role === 'user') rows.push({ key: item.key, type: 'user', message });\n else {\n const parts = partsOf(message.content, item.key, false, true);\n const interrupted = message.stopReason === 'error' || message.stopReason === 'aborted';\n if (parts.length || interrupted) rows.push({ key: item.key, type: 'assistant', parts, streaming: false, stopReason: message.stopReason, ...(message.errorMessage ? { errorMessage: message.errorMessage } : {}), text: textOf(message), ...replyMeta(message), entryId: String(item.entry.id) });\n else {\n // A final round with nothing to show adds no content, but it is still the round that ended the reply: its model, time and entry replace the earlier round's.\n const previous = rows[rows.length - 1];\n if (previous?.type === 'assistant' && !previous.stopReason?.match(/^(error|aborted)$/)) {\n const { timestamp: _t, model: _m, durationMs: _d, ...rest } = previous;\n rows[rows.length - 1] = { ...rest, ...replyMeta(message), entryId: String(item.entry.id) };\n }\n }\n }\n }\n if (typeof taskId === 'number' && liveMessage) {\n const key = `assistant:${taskId}:${counts.get(taskId) ?? 0}`, content = streamParts(liveMessage['content']);\n rows.push({ key, type: 'assistant', parts: partsOf(content, key, true, false), streaming: true, text: '' });\n }\n const merged = mergeTurns(rows).map(row => row.type === 'assistant' && options.artifacts ? { ...row, parts: oneCardPerArtifact(row.parts) } : row);\n const last = merged[merged.length - 1];\n if (run && last?.type === 'assistant') merged[merged.length - 1] = { ...last, active: true };\n return { rows: merged, pinned, run, retry, compacting: Array.isArray(live?.['compactions']) && live['compactions'].length > 0 };\n}\n\n/** What a reply reports about the model round that wrote it, read from Pi's own message (each field only when it is there). */\nexport function replyMeta(message: AssistantMessage): { readonly timestamp?: number; readonly model?: { readonly provider: string; readonly modelId: string }; readonly durationMs?: number } {\n return {\n ...(typeof message.timestamp === 'number' ? { timestamp: message.timestamp } : {}),\n ...(typeof message.provider === 'string' && typeof message.model === 'string' ? { model: { provider: message.provider, modelId: message.model } } : {}),\n ...(typeof message.durationMs === 'number' ? { durationMs: message.durationMs } : {}),\n };\n}\n\n/**\n * Pi commits one assistant message per model round, so a turn with tool use is several messages in a row.\n * They read as one answer: merge consecutive assistant rows (unless one ended in an error or stop) so tool calls\n * group across rounds and the turn has a single copy action. The first row's key is kept, so identity is stable.\n */\nfunction mergeTurns(rows: readonly Row[]): Row[] {\n const out: Row[] = [];\n for (const row of rows) {\n const last = out[out.length - 1];\n if (last?.type === 'assistant' && row.type === 'assistant' && !last.stopReason?.match(/^(error|aborted)$/)) {\n out[out.length - 1] = { key: last.key, type: 'assistant', parts: [...last.parts, ...row.parts], streaming: row.streaming,\n ...(row.stopReason ? { stopReason: row.stopReason } : {}), ...(row.errorMessage ? { errorMessage: row.errorMessage } : {}),\n text: [last.text, row.text].filter(Boolean).join('\\n\\n'), ...(row.timestamp !== undefined ? { timestamp: row.timestamp } : {}), ...(row.model ? { model: row.model } : {}), ...(row.durationMs !== undefined ? { durationMs: row.durationMs } : {}),\n ...(row.entryId !== undefined ? { entryId: row.entryId } : last.entryId !== undefined ? { entryId: last.entryId } : {}) };\n } else out.push(row);\n }\n return out;\n}\n\n/**\n * A turn that revises an artifact several times shows one card for it: the last version, at the place of the last step.\n * A running `present` of a file already shown takes over the title of its card.\n */\nfunction oneCardPerArtifact(parts: readonly Part[]): Part[] {\n const idOf = (part: Part) => part.kind === 'artifact' ? part.artifact ? artifactKey(part.artifact) : (part.state === 'presenting' ? pendingId(part.call) : undefined) : undefined;\n const last = new Map<string, number>();\n for (const [index, part] of parts.entries()) { const id = idOf(part); if (id !== undefined) last.set(id, index); }\n const out: Part[] = [];\n for (const [index, part] of parts.entries()) {\n const id = idOf(part);\n if (id === undefined || last.get(id) === index) {\n if (part.kind === 'artifact' && part.artifact === undefined && part.title === undefined && id !== undefined) {\n const earlier = parts.find(other => other.kind === 'artifact' && other.artifact !== undefined && artifactKey(other.artifact) === id);\n if (earlier?.kind === 'artifact') { out.push({ ...part, title: earlier.title }); continue; }\n }\n out.push(part);\n }\n }\n return out;\n}\n\n/** One step of an activity block: a default tool call or a stretch of reasoning. */\nexport type Step = { readonly kind: 'tool'; readonly key: string; readonly entry: ToolEntry }\n | { readonly kind: 'thinking'; readonly key: string; readonly text: string; readonly streaming: boolean };\n/** An assistant turn is text parts and activity blocks: one block per uninterrupted run of steps. */\nexport type Segment = { readonly kind: 'part'; readonly part: Part } | { readonly kind: 'activity'; readonly key: string; readonly steps: readonly Step[] };\nexport function segments(parts: readonly Part[], groupTool: DeriveOptions['groupTool']): Segment[] {\n const out: Segment[] = [];\n let run: Step[] = [];\n const flush = () => {\n const first = run[0];\n if (first) out.push({ kind: 'activity', key: `activity:${first.key}`, steps: run });\n run = [];\n };\n for (const part of parts) {\n if (part.kind === 'thinking') run.push({ kind: 'thinking', key: part.key, text: part.text, streaming: part.streaming });\n else if (part.kind === 'tool' && !part.entry.custom && (!groupTool || safe(groupTool, part.entry))) run.push({ kind: 'tool', key: part.key, entry: part.entry });\n else { flush(); out.push({ kind: 'part', part }); }\n }\n flush();\n return out;\n}\nfunction safe(groupTool: NonNullable<DeriveOptions['groupTool']>, entry: ToolEntry): boolean {\n try { return entry.result !== undefined && !entry.result.isError && groupTool(entry.call, entry.result) === true; } catch { return false; }\n}\n",
|
|
136
171
|
"type": "registry:lib",
|
|
137
172
|
"target": "components/pi-chat/rows.ts"
|
|
138
173
|
},
|
|
139
174
|
{
|
|
140
175
|
"path": "registry/pi-chat/session.tsx",
|
|
141
|
-
"content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { KeyboardEvent, ReactNode } from 'react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport type { ImageContent, ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { ChatAttachment, NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { useStickToBottom } from 'use-stick-to-bottom';\nimport type { ComposerFeedback, PendingUpload } from './composer';\nimport type { AttachmentsConfig, EffortConfig, MentionsConfig, ModelConfig, ModelRef, PastedText, PasteToFile, SlashConfig } from './config';\nimport { looksLikeMarkdown, mentionToken } from './config';\nimport { ModelEffortPicker } from './pickers';\nimport type { CommandMentions } from './markdown';\nimport type { RowContext } from './message';\nimport type { QueueActions, QueuedMessage } from './queue';\nimport type { AnswerOutcome } from './question-card';\nimport type { ArtifactsConfig } from './artifact';\nimport { derive, isFileBlock, object, queuedMessages } from './rows';\nimport type { ChatCard, Mode } from './rows';\nimport { useChatText } from './labels';\nimport type { BlockAction } from '../button/actions';\nimport type { ReplyRef } from './config';\n\n/** What the host lets the person do beyond typing. Pass `remote.answer` and `remote.withdraw` from `createRemoteChat()`. */\nexport interface PiChatActions {\n /** Resolve a pending `ask_user` question. Without it, question cards are read-only. */\n readonly answer?: (questionId: string, answer: string) => Promise<AnswerOutcome>;\n /** Withdraw a queued message before Pi places it. Without it, queued messages cannot be cancelled. */\n readonly withdraw?: (id: QueuedMessage['id']) => Promise<unknown>;\n}\n\n/** The props `PiChat` and `AmbientChat` both take and hand to `useChatSession`. */\nexport interface ChatFeatureProps {\n /** `expert` hides successful tool details and reasoning; `developer` shows everything. */\n readonly mode?: Mode;\n readonly actions?: PiChatActions;\n readonly renderEntry?: (entry: EntryRecord) => ChatCard | undefined;\n readonly renderTool?: (call: ToolCall, result: ToolResultMessage | undefined) => ChatCard | undefined;\n readonly groupTool?: (call: ToolCall, result: ToolResultMessage) => boolean;\n readonly commandMentions?: CommandMentions;\n readonly onOpenImage?: (image: ImageContent) => void;\n readonly onCopy?: (text: string) => Promise<void>;\n readonly onComposerKeyDown?: (event: KeyboardEvent<HTMLTextAreaElement>) => void;\n readonly fileAccept?: string;\n /** `/` menu of host commands and skills. Omit it and `/` is just text. */\n readonly slash?: SlashConfig;\n /** Opens an `uploads/` file from its card in a sent message. Without it the card is not clickable. */\n readonly onOpenFile?: (path: string) => void;\n /** Pastes above these limits become a file through `attachments` (on by default, 2000 characters or 40 lines). Needs `attachments`. */\n readonly pasteToFile?: PasteToFile;\n /** `@` workspace file mentions: a picker, composer chips and styled mentions in sent messages. */\n readonly mentions?: MentionsConfig;\n /** Attach button, paste and drop through the host's upload. Takes the place of `onFiles` and `fileAccept`. */\n readonly attachments?: AttachmentsConfig;\n /** Model picker in the composer bar. The current model comes from the view's `pi.agent` document. */\n readonly model?: ModelConfig;\n /** Thinking level picker in the composer bar. The current level comes from the view's `pi.agent` document. */\n readonly effort?: EffortConfig;\n /** Artifact cards: tool results that carry a descriptor become a card that opens the artifact in the host's panel. Omit it and nothing changes. */\n readonly artifacts?: ArtifactsConfig;\n /**\n * Host actions under each settled reply, beside Copy (and Fork): `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids\n * `reply-action-<id>`, the menu `reply-action-more`.\n */\n readonly messageActions?: ((reply: ReplyRef) => readonly BlockAction[]) | undefined;\n /** A line under each settled reply (for example the model that answered and the time it took), for the host to show to some people only. */\n readonly replyFooter?: ((reply: ReplyRef) => ReactNode) | undefined;\n}\n\n\nexport type FilesHandler = (files: readonly File[], target: Pick<ReturnType<NativeChatController['getSnapshot']>, 'identity' | 'conversationId'> & { readonly signal: AbortSignal }) => Promise<readonly ChatAttachment[]>;\n\n/** The chat behaviour every surface over a `NativeChatController` shares (`PiChat`, `AmbientChat`): sending, queue actions, uploads, pickers, rows. */\nexport interface ChatSessionOptions extends Readonly<{ [Key in keyof ChatFeatureProps]: ChatFeatureProps[Key] | undefined }> {\n readonly controller: NativeChatController;\n /** The controller the host currently passes; a surface that outlives its controller stops acting on the old one. */\n readonly activeController: { readonly current: NativeChatController };\n readonly mode: Mode;\n readonly fileAccept: string;\n readonly onFiles?: FilesHandler | undefined;\n /** Runs after a message was accepted for sending (for example to scroll to the bottom). */\n readonly afterSend?: (() => void) | undefined;\n /** Extra attributes of the row context a surface needs (for example per-reply actions). */\n readonly rowExtras?: Partial<RowContext> | undefined;\n /** The optional annotate toggle: when notes are pending (`count` > 0), Send attaches them to the message first. */\n readonly feedback?: ComposerFeedback | undefined;\n}\n\nexport function useChatSession(options: ChatSessionOptions) {\n const { controller, activeController: active, mode, actions, renderEntry, renderTool, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, onFiles, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend, rowExtras, feedback, onOpenFile, pasteToFile } = options;\n const { labels } = useChatText();\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const [error, setError] = useState<string | null>(null);\n const [uploading, setUploading] = useState(0);\n const [picked, setPicked] = useState<readonly string[]>([]);\n const [pending, setPending] = useState<readonly PendingUpload[]>([]);\n const [changing, setChanging] = useState<'model' | 'effort' | null>(null);\n const [changeError, setChangeError] = useState<string | null>(null);\n const [optimistic, setOptimistic] = useState<{ readonly model?: ModelRef; readonly effort?: string }>({});\n const uploadIds = useRef(0);\n const uploads = useRef(new Set<AbortController>()), mounted = useRef(false);\n const textarea = useRef<HTMLTextAreaElement | null>(null);\n const current = () => mounted.current && active.current === controller && !controller.getSnapshot().disposed;\n useEffect(() => {\n mounted.current = true;\n // A controller disposed before mount throws synchronously; the chat then renders its disposed state.\n if (!controller.getSnapshot().disposed) void controller.connect().catch(cause => { if (current()) setError(cause instanceof Error ? cause.message : labels.connectionFailed); });\n return () => { mounted.current = false; for (const upload of uploads.current) upload.abort(); uploads.current.clear(); };\n }, [controller]);\n\n const developer = mode === 'developer';\n // Only the recogniser takes part in deriving rows; which card is open changes without rebuilding them.\n const artifactDetect = useMemo(() => artifacts ? { detect: artifacts.detect } : undefined, [Boolean(artifacts), artifacts?.detect]); // eslint-disable-line react-hooks/exhaustive-deps\n const derived = useMemo(() => derive(state.view, { mode, renderEntry, renderTool, groupTool, artifacts: artifactDetect }), [state.view, mode, renderEntry, renderTool, groupTool, artifactDetect]);\n const queued = useMemo(() => queuedMessages(state.view), [state.view]);\n const { rows, run } = derived;\n\n const report = (cause: unknown) => { if (current()) setError(cause instanceof Error ? cause.message : labels.actionFailedShort); };\n const act = (action: () => Promise<unknown>) => {\n setError(null);\n try { void action().catch(report); } catch (cause) { report(cause); }\n };\n // A send being confirmed does not block the next one: the controller empties the composer at once and queues the next message behind it.\n // Only a submission whose outcome is unknown blocks, until it is checked or retried.\n const unconfirmed = state.send.kind === 'unknown';\n const connected = state.connection.kind === 'connected';\n const sendBlocked = uploading > 0 || state.disposed || !connected || unconfirmed;\n /** Puts a message back into the composer ahead of whatever is there now (nothing is dropped). */\n const putBack = (text: string, images: readonly ChatAttachment[]) => {\n if (controller.getSnapshot().disposed) return;\n const draft = controller.getSnapshot().draft;\n controller.setText([text, draft.text].filter(value => value.trim()).join('\\n'));\n if (images.length) controller.setAttachments([...images, ...draft.attachments.filter(item => !images.some(image => image.id === item.id))]);\n };\n const working = Boolean(run);\n const waitingForAnswer = rows.some(row => row.type === 'assistant' && row.parts.some(part => (part.kind === 'question' || part.kind === 'approval') && !part.live && !part.result));\n const send = () => {\n if (sendBlocked || !current()) return;\n const attach = feedback && feedback.count > 0 ? feedback.attach : undefined;\n if (attach) {\n // The pending feedback goes with this message: the host attaches it to the text, then the native send reads the draft.\n act(async () => {\n // Like any send, the composer empties at once; the person's own text comes back if the feedback or the message is refused.\n const typed = controller.getSnapshot().draft;\n controller.setText(''); controller.setAttachments([]);\n let attached: Awaited<ReturnType<typeof attach>>;\n try { attached = await attach(typed.text); }\n catch (cause) { putBack(typed.text, typed.attachments); throw cause; }\n if (attached.kind === 'refused') { putBack(typed.text, typed.attachments); throw new Error(attached.reason); }\n if (!current()) { putBack(typed.text, typed.attachments); return; }\n const submission = await controller.send('followUp', { text: attached.text, attachments: typed.attachments, restore: { text: typed.text, attachments: typed.attachments } });\n if (submission) feedback?.sent();\n });\n afterSend?.();\n textarea.current?.focus();\n return;\n }\n act(() => controller.send('followUp'));\n afterSend?.();\n textarea.current?.focus();\n };\n // A queued message is taken back (withdrawn) before it is edited or steered; if Pi already placed it, that is reported and nothing is lost.\n async function takeBack(item: QueuedMessage) {\n if (!actions?.withdraw) throw new Error(labels.queueLocked);\n const outcome = await actions.withdraw(item.id);\n if (outcome === 'already_placed' || outcome === 'settled') throw new Error(labels.tooLate);\n }\n const queuedContent = (item: QueuedMessage) => {\n const parts = Array.isArray(item.content) ? item.content as readonly { type?: unknown; text?: unknown; data?: unknown; mimeType?: unknown }[] : [];\n const text = typeof item.content === 'string' ? item.content : parts.flatMap(part => part?.type === 'text' && typeof part.text === 'string' && !isFileBlock(part.text) ? [part.text] : []).join('\\n');\n const images: ChatAttachment[] = parts.flatMap((part, index) => part?.type === 'image' && typeof part.data === 'string' && typeof part.mimeType === 'string'\n ? [{ id: `queued-${String(item.id)}-${index}`, name: `image-${index + 1}`, content: { type: 'image' as const, data: part.data, mimeType: part.mimeType } }] : []);\n return { text, images };\n };\n const queueActions: QueueActions | undefined = actions?.withdraw ? {\n edit: async item => {\n await takeBack(item);\n const { text, images } = queuedContent(item), draft = controller.getSnapshot().draft;\n controller.setText(draft.text.trim() ? `${text}\\n${draft.text}` : text);\n if (images.length) controller.setAttachments([...draft.attachments, ...images]);\n textarea.current?.focus();\n },\n steer: async item => {\n if (sendBlocked) throw new Error(labels.waitToSteer);\n await takeBack(item);\n const { text, images } = queuedContent(item);\n // Sent as explicit content, so the person's own draft is never touched; a refused steer comes back into the composer (the controller\n // puts it back), an unknown one is held for checking.\n try {\n const submission = await controller.send('steer', { text, attachments: images });\n if (!submission && current() && controller.getSnapshot().send.kind === 'blocked') setError(labels.steerRefused);\n } catch (cause) { putBack(text, images); if (current()) setError(cause instanceof Error ? cause.message : labels.steerFailed); }\n },\n } : undefined;\n const addMentions = (paths: readonly string[]) => {\n const text = controller.getSnapshot().draft.text;\n controller.setText(`${text}${text && !/\\s$/.test(text) ? ' ' : ''}${paths.map(mentionToken).join(' ')} `);\n setPicked(value => [...new Set([...value, ...paths])]);\n };\n /** Uploads files; `restore` (a pasted text) is what their pending cards undo: it abandons the upload and puts the text back. */\n async function upload(files: readonly File[], paste?: { readonly restore: () => void; readonly after: string }) {\n if ((!onFiles && !attachments) || !files.length || !current()) return;\n const abort = new AbortController(); uploads.current.add(abort); setUploading(value => value + 1);\n const ids = files.map(() => `upload-${++uploadIds.current}`);\n const settle = (failure?: string) => setPending(items => failure === undefined ? items.filter(item => !ids.includes(item.id))\n : items.map(item => ids.includes(item.id) ? { ...item, state: 'failed', error: failure } : item));\n const cancel = paste && (() => { abort.abort(); setPending(items => items.filter(item => !ids.includes(item.id))); paste.restore(); });\n setPending(items => [...items, ...files.map((file, index): PendingUpload => ({ id: ids[index]!, name: file.name, state: 'uploading',\n ...(cancel && paste ? { cancel, restoreWhile: paste.after } : {}) }))]);\n try {\n let added: readonly ChatAttachment[] = [];\n let paths: string[] = [];\n if (attachments) {\n for (const result of await attachments.upload([...files], abort.signal)) {\n if (result.image) added = [...added, { id: `image-${Date.now()}-${++uploadIds.current}`, name: result.name, content: { type: 'image', data: result.image.data, mimeType: result.image.mimeType } }];\n else if (result.path) paths = [...paths, result.path];\n }\n } else added = await onFiles!(files, { identity: state.identity, conversationId: state.conversationId, signal: abort.signal });\n if (current() && !abort.signal.aborted) {\n if (added.length) controller.setAttachments([...controller.getSnapshot().draft.attachments, ...added]);\n if (paths.length) addMentions(paths);\n settle();\n }\n } catch (cause) { if (!abort.signal.aborted) { settle(cause instanceof Error ? cause.message : labels.uploadFailedShort); if (!attachments) report(cause); } }\n finally { uploads.current.delete(abort); if (current()) setUploading(value => value - 1); }\n }\n /** A paste the composer took: saved as a file under `uploads/` (Send then references it). Cancelling puts the text back where it was pasted. */\n const pasteText = (paste: PastedText) => {\n if (!attachments || !current()) return;\n const at = new Date(), two = (value: number) => String(value).padStart(2, '0');\n const stamp = `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}-${two(at.getMinutes())}-${two(at.getSeconds())}`;\n const markdown = looksLikeMarkdown(paste.text);\n const file = new File([paste.text], `${labels.pastedFileName(stamp)}${markdown ? '.md' : '.txt'}`, { type: markdown ? 'text/markdown' : 'text/plain' });\n // The pasted text replaces its selection when the paste is accepted, so the draft shows what is left. Restoring inserts the text at\n // the same place and deletes nothing: whatever was typed since stays.\n const draft = controller.getSnapshot().draft.text;\n const start = Math.min(paste.start, draft.length), end = Math.min(Math.max(paste.end, start), draft.length);\n const after = `${draft.slice(0, start)}${draft.slice(end)}`;\n controller.setText(after);\n const restore = () => {\n if (!current()) return;\n const now = controller.getSnapshot().draft.text;\n const at = Math.min(start, now.length);\n controller.setText(`${now.slice(0, at)}${paste.text}${now.slice(at)}`);\n textarea.current?.focus();\n };\n void upload([file], { restore, after });\n };\n const agentDoc = object(state.view?.docs['pi.agent']);\n const currentModel = object(agentDoc?.['model']);\n const viewModel: ModelRef | undefined = typeof currentModel?.['provider'] === 'string' && typeof currentModel['modelId'] === 'string' ? { provider: currentModel['provider'], modelId: currentModel['modelId'] } : undefined;\n const viewEffort = typeof agentDoc?.['thinkingLevel'] === 'string' ? agentDoc['thinkingLevel'] : undefined;\n // Show the accepted choice until the watch delivers it, so the label does not flicker back.\n useEffect(() => {\n const same = optimistic.model && viewModel && optimistic.model.provider === viewModel.provider && optimistic.model.modelId === viewModel.modelId;\n if (same || (optimistic.effort !== undefined && optimistic.effort === viewEffort)) setOptimistic(value => ({ ...(same ? {} : value.model ? { model: value.model } : {}), ...(optimistic.effort === viewEffort ? {} : value.effort ? { effort: value.effort } : {}) }));\n }, [optimistic, viewModel?.provider, viewModel?.modelId, viewEffort]);\n useEffect(() => { if (!state.draft.text) setPicked(value => value.length ? [] : value); }, [state.draft.text]);\n async function change(kind: 'model' | 'effort', apply: () => Promise<void>, accepted: () => void, rejected: () => void) {\n if (changing || !current()) return;\n setChanging(kind); setChangeError(null);\n try { await apply(); if (current()) accepted(); }\n catch (cause) { if (current()) { rejected(); setChangeError(cause instanceof Error && cause.message ? cause.message : labels.changeRefused); } }\n finally { if (current()) setChanging(null); }\n }\n const activateMention = (command: { readonly name: string; readonly behavior: 'execute' | 'insert' }) => {\n if (!current() || controller.getSnapshot().connection.kind !== 'connected') return;\n const catalog = commandMentions;\n const selected = catalog?.commands.find(item => item.name === command.name && item.behavior === command.behavior);\n if (catalog && selected) act(async () => catalog.onActivate({ name: selected.name, behavior: selected.behavior }));\n };\n const rowContext = useMemo<RowContext>(() => ({\n developer, groupTool, onOpenImage, onCopy, openFile: onOpenFile,\n commandMentions: commandMentions ? { commands: commandMentions.commands, onActivate: activateMention } : undefined,\n answer: actions?.answer,\n artifacts: artifacts ? { open: artifacts.open, isOpen: artifacts.isOpen } : undefined,\n messageActions,\n replyFooter,\n pieces: slash?.skills?.length || mentions || attachments ? { mentions: Boolean(mentions), uploads: Boolean(attachments), skills: (slash?.skills ?? []).map(skill => skill.name), openMention: mentions?.open, renderMention: mentions?.render, preview: mentions?.preview } : undefined,\n ...rowExtras,\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }), [developer, groupTool, onOpenImage, onCopy, onOpenFile, commandMentions, actions?.answer, slash?.skills, mentions, mentions?.open, mentions?.render, mentions?.preview, attachments, artifacts?.open, artifacts?.isOpen, messageActions, replyFooter, rowExtras]);\n\n /** The model-and-effort pill and the note that reports a refused change. */\n const pickers = () => {\n const disabled = changing !== null || state.disposed || !connected;\n const barStart = model || effort ? <ModelEffortPicker model={model} effort={effort} currentModel={optimistic.model ?? viewModel} currentEffort={optimistic.effort ?? viewEffort} disabled={disabled} busy={changing}\n onModel={next => void change('model', () => model!.change(next), () => setOptimistic(value => ({ ...value, model: next })), () => {})}\n onEffort={next => void change('effort', () => effort!.change(next), () => setOptimistic(value => ({ ...value, effort: next })), () => {})} /> : undefined;\n const barNote = changeError ? <button type=\"button\" role=\"alert\" data-testid=\"configure-error\" title={`${changeError} (${labels.dismissHint})`} onClick={() => setChangeError(null)}\n className=\"min-w-0 max-w-[16rem] cursor-pointer truncate rounded-md bg-destructive/10 px-2 py-1 text-xs text-destructive outline-none focus-visible:ring-2 focus-visible:ring-ring/60\">{changeError}</button> : undefined;\n return { barStart, barNote };\n };\n\n const empty = connected && !state.view?.entries.length && !rows.length;\n const loading = (state.connection.kind === 'idle' || state.connection.kind === 'connecting') && !state.view;\n\n /** Everything `Composer` needs from the session; a surface adds its layout and slots. */\n const composer = {\n text: state.draft.text, onText: (text: string) => controller.setText(text), onKeyDown: onComposerKeyDown, textareaRef: textarea,\n attachments: state.draft.attachments.map(item => ({ id: item.id, name: item.name, mimeType: item.content.mimeType, data: item.content.data })),\n onRemoveAttachment: (id: string) => controller.setAttachments(controller.getSnapshot().draft.attachments.filter(item => item.id !== id)),\n working, sendBlocked, disabled: state.disposed, stopRequested: state.stop === 'requested',\n onSend: send, onStop: () => act(controller.stop),\n uploading: uploading > 0, canAttach: Boolean(onFiles || attachments), fileAccept: attachments ? attachments.accept ?? '' : fileAccept, onPickFiles: (files: readonly File[]) => { void upload(files); },\n slash, onSlashError: report, mentions, mentionPaths: picked, onMentionPicked: (path: string) => setPicked(value => value.includes(path) ? value : [...value, path]),\n uploads: pending, onDismissUpload: (id: string) => setPending(items => items.filter(item => item.id !== id)),\n onPasteText: attachments ? pasteText : undefined, pasteToFile,\n feedback,\n };\n\n return { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, sendBlocked, error, setError, report, act, current, textarea, rowContext, composer, pickers, empty, loading, developer };\n}\n\nexport type ChatSession = ReturnType<typeof useChatSession>;\n\n/** Scroll-to-bottom transcript with older rows revealed in pages as the person scrolls up. */\nexport function useTranscript(rows: readonly unknown[], page = 60, more = 40) {\n const [limit, setLimit] = useState(page);\n const anchor = useRef<{ readonly height: number; readonly top: number } | undefined>(undefined);\n // Growth re-pins at once: a spring trails the rows that grow after insert (streamed text, late images) and leaves the newest one under the composer.\n const library = useStickToBottom({ initial: 'instant', resize: 'instant' });\n // The scroller can mount later (AmbientChat starts collapsed) and unmount on collapse, so the element is held in state.\n const [scroller, setScroller] = useState<HTMLElement | null>(null);\n const scrollRef = useCallback((element: HTMLElement | null) => { library.scrollRef(element); setScroller(element); }, [library.scrollRef]);\n const stick = { ...library, scrollRef };\n // The library observes only the content. A shorter viewport (phone keyboard, a taller composer) leaves the newest row below the fold,\n // so re-pin when the viewport shrinks while the reader is at the bottom.\n useEffect(() => {\n if (!scroller || typeof ResizeObserver === 'undefined') return;\n let height = scroller.clientHeight;\n const observer = new ResizeObserver(() => {\n const shrank = scroller.clientHeight < height;\n height = scroller.clientHeight;\n if (shrank && library.state.isAtBottom) void library.scrollToBottom('instant');\n });\n observer.observe(scroller);\n return () => observer.disconnect();\n }, [scroller, library.state, library.scrollToBottom]);\n // The library ignores scroll events while it is absorbing a growth (a resize difference), so a scroll-up in that window would be\n // snapped back by its pending frame. Release the bottom lock as soon as the reader moves up. The library's own programmatic\n // writes are marked in ignoreScrollToTop, which its listener clears: this listener is a capture listener so it reads the mark first.\n useEffect(() => {\n if (!scroller) return;\n let last = scroller.scrollTop;\n const onScroll = () => {\n const top = scroller.scrollTop;\n const programmatic = library.state.ignoreScrollToTop === top;\n if (!programmatic && top < last && !library.state.isNearBottom && library.state.isAtBottom) library.stopScroll();\n last = top;\n };\n scroller.addEventListener('scroll', onScroll, { passive: true, capture: true });\n return () => scroller.removeEventListener('scroll', onScroll, { capture: true });\n }, [scroller, library.state, library.stopScroll]);\n const hidden = Math.max(0, rows.length - limit);\n // Reveal older rows when the person scrolls near the top, keeping the same rows in view.\n const reveal = useCallback(() => {\n if (scroller) anchor.current = { height: scroller.scrollHeight, top: scroller.scrollTop };\n setLimit(value => value + more);\n }, [scroller, more]);\n useLayoutEffect(() => {\n const saved = anchor.current;\n if (scroller && saved) { scroller.scrollTop = saved.top + scroller.scrollHeight - saved.height; anchor.current = undefined; }\n }, [limit, scroller]);\n useEffect(() => {\n if (!scroller) return;\n const onScroll = () => { if (scroller.scrollTop < 240 && scroller.scrollHeight > scroller.clientHeight && hidden > 0 && !anchor.current) reveal(); };\n scroller.addEventListener('scroll', onScroll, { passive: true });\n return () => scroller.removeEventListener('scroll', onScroll);\n }, [hidden, reveal, scroller]);\n return { stick, hidden, reveal, visible: <T,>(items: readonly T[]): readonly T[] => hidden ? items.slice(hidden) : items, reset: () => setLimit(page) };\n}\n",
|
|
176
|
+
"content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { KeyboardEvent, ReactNode } from 'react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport type { ImageContent, ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { ChatAttachment, ChatHistoryState, NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { useMessageScroller, useMessageScrollerScrollable } from '@/components/ui/message-scroller';\nimport { Button } from '@/components/ui/button';\nimport type { ComposerFeedback, ComposerProps, PendingUpload } from './composer';\nimport type { ConversationFile } from './conversation-files';\nimport type { AttachmentsConfig, EffortConfig, MentionsConfig, ModelConfig, ModelRef, PastedText, PasteToFile, SlashConfig } from './config';\nimport { displayName, looksLikeMarkdown, mentionToken, pieces } from './config';\nimport { ModelEffortPicker } from './pickers';\nimport type { CommandMentions } from './markdown';\nimport type { RowContext } from './message';\nimport type { QueueActions, QueuedMessage } from './queue';\nimport type { AnswerOutcome } from './question-card';\nimport type { ArtifactsConfig } from './artifact';\nimport { derive, isFileBlock, object, queuedMessages } from './rows';\nimport type { ChatCard, Mode, Row } from './rows';\nimport { useChatText } from './labels';\nimport type { BlockAction } from '../button/actions';\nimport type { ReplyRef } from './config';\n\n/** What the host lets the person do beyond typing. Pass `remote.answer` and `remote.withdraw` from `createRemoteChat()`. */\nexport interface PiChatActions {\n /** Resolve a pending `ask_user` question. Without it, question cards are read-only. */\n readonly answer?: (questionId: string, answer: string) => Promise<AnswerOutcome>;\n /** Withdraw a queued message before Pi places it. Without it, queued messages cannot be cancelled. */\n readonly withdraw?: (id: QueuedMessage['id']) => Promise<unknown>;\n}\n\n/** The props `PiChat` and `AmbientChat` both take and hand to `useChatSession`. */\nexport interface ChatFeatureProps {\n /** `expert` hides successful tool details and reasoning; `developer` shows everything. */\n readonly mode?: Mode;\n readonly actions?: PiChatActions;\n readonly renderEntry?: (entry: EntryRecord) => ChatCard | undefined;\n readonly renderTool?: (call: ToolCall, result: ToolResultMessage | undefined) => ChatCard | undefined;\n readonly groupTool?: (call: ToolCall, result: ToolResultMessage) => boolean;\n readonly commandMentions?: CommandMentions;\n readonly onOpenImage?: (image: ImageContent) => void;\n readonly onCopy?: (text: string) => Promise<void>;\n readonly onComposerKeyDown?: (event: KeyboardEvent<HTMLTextAreaElement>) => void;\n readonly fileAccept?: string;\n /** Optional pills at the left of the composer's controls row. */\n readonly composerChips?: ComposerProps['chips'];\n /** `/` menu of host commands and skills. Omit it and `/` is just text. */\n readonly slash?: SlashConfig;\n /** Show successful native write/edit paths as file cards under each reply. Off by default: only hosts with workspace file tools should opt in. */\n readonly fileChanges?: boolean;\n /** Opens a file from an upload or write/edit card. Falls back to mentions.open. */\n readonly onOpenFile?: (path: string) => void;\n /** Pastes above these limits become a file through `attachments` (on by default, 2000 characters or 40 lines). Needs `attachments`. */\n readonly pasteToFile?: PasteToFile;\n /** `@` workspace file mentions: a picker, composer chips and styled mentions in sent messages. */\n readonly mentions?: MentionsConfig;\n /** Attach button, paste and drop through the host's upload. Takes the place of `onFiles` and `fileAccept`. */\n readonly attachments?: AttachmentsConfig;\n /** Model picker in the composer bar. The current model comes from the view's `pi.agent` document. */\n readonly model?: ModelConfig;\n /** Thinking level picker in the composer bar. The current level comes from the view's `pi.agent` document. */\n readonly effort?: EffortConfig;\n /** Artifact cards: tool results that carry a descriptor become a card that opens the artifact in the host's panel. Omit it and nothing changes. */\n readonly artifacts?: ArtifactsConfig;\n /**\n * Host actions under each settled reply, beside Copy (and Fork): `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids\n * `reply-action-<id>`, the menu `reply-action-more`.\n */\n readonly messageActions?: ((reply: ReplyRef) => readonly BlockAction[]) | undefined;\n /** A line under each settled reply (for example the model that answered and the time it took), for the host to show to some people only. */\n readonly replyFooter?: ((reply: ReplyRef) => ReactNode) | undefined;\n}\n\n\nexport type FilesHandler = (files: readonly File[], target: Pick<ReturnType<NativeChatController['getSnapshot']>, 'identity' | 'conversationId'> & { readonly signal: AbortSignal }) => Promise<readonly ChatAttachment[]>;\n\n/** The chat behaviour every surface over a `NativeChatController` shares (`PiChat`, `AmbientChat`): sending, queue actions, uploads, pickers, rows. */\nexport interface ChatSessionOptions extends Readonly<{ [Key in keyof ChatFeatureProps]: ChatFeatureProps[Key] | undefined }> {\n readonly controller: NativeChatController;\n /** The controller the host currently passes; a surface that outlives its controller stops acting on the old one. */\n readonly activeController: { readonly current: NativeChatController };\n readonly mode: Mode;\n readonly fileAccept: string;\n readonly onFiles?: FilesHandler | undefined;\n /** Runs after a message was accepted for sending (for example to scroll to the bottom). */\n readonly afterSend?: (() => void) | undefined;\n /** Extra attributes of the row context a surface needs (for example per-reply actions). */\n readonly rowExtras?: Partial<RowContext> | undefined;\n /** The optional annotate toggle: when notes are pending (`count` > 0), Send attaches them to the message first. */\n readonly feedback?: ComposerFeedback | undefined;\n}\n\nexport function useChatSession(options: ChatSessionOptions) {\n const { controller, activeController: active, mode, actions, renderEntry, renderTool, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, onFiles, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend, rowExtras, feedback, onOpenFile, fileChanges, pasteToFile, composerChips } = options;\n const { labels } = useChatText();\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const [error, setError] = useState<string | null>(null);\n const [uploading, setUploading] = useState(0);\n const [picked, setPicked] = useState<readonly string[]>([]);\n const [pending, setPending] = useState<readonly PendingUpload[]>([]);\n const [changing, setChanging] = useState<'model' | 'effort' | null>(null);\n const [changeError, setChangeError] = useState<string | null>(null);\n const [optimistic, setOptimistic] = useState<{ readonly model?: ModelRef; readonly effort?: string }>({});\n const uploadIds = useRef(0);\n const uploads = useRef(new Set<AbortController>()), mounted = useRef(false);\n const textarea = useRef<HTMLTextAreaElement | null>(null);\n const current = () => mounted.current && active.current === controller && !controller.getSnapshot().disposed;\n useEffect(() => {\n mounted.current = true;\n // A controller disposed before mount throws synchronously; the chat then renders its disposed state.\n if (!controller.getSnapshot().disposed) void controller.connect().catch(cause => { if (current()) setError(cause instanceof Error ? cause.message : labels.connectionFailed); });\n return () => { mounted.current = false; for (const upload of uploads.current) upload.abort(); uploads.current.clear(); };\n }, [controller]);\n\n const developer = mode === 'developer';\n // Only the recogniser takes part in deriving rows; which card is open changes without rebuilding them.\n const artifactDetect = useMemo(() => artifacts ? { detect: artifacts.detect } : undefined, [Boolean(artifacts), artifacts?.detect]); // eslint-disable-line react-hooks/exhaustive-deps\n const derived = useMemo(() => derive(state.view, { mode, renderEntry, renderTool, groupTool, artifacts: artifactDetect }), [state.view, mode, renderEntry, renderTool, groupTool, artifactDetect]);\n const queued = useMemo(() => queuedMessages(state.view), [state.view]);\n const { run } = derived;\n const earlier = useMemo<readonly Row[]>(() => {\n if (!state.view || !('entries' in state.history)) return [];\n const activeIds = new Set(state.view.entries.map(entry => entry.id));\n const entries = state.history.entries.filter(entry => !activeIds.has(entry.id));\n return derive({ ...state.view, entries, docs: {} }, { mode, renderEntry: undefined, renderTool: undefined, groupTool: undefined, artifacts: artifactDetect }).rows\n .map(row => ({ ...row, key: `history:${row.key}`, historical: true }));\n }, [state.view, state.history, mode, artifactDetect]);\n const rows = useMemo(() => [...earlier, ...derived.rows], [earlier, derived.rows]);\n const files = useMemo(() => {\n const found = new Map<string, ConversationFile>();\n const open = onOpenFile ?? mentions?.open;\n const add = (path: string) => {\n const normalized = path.replace(/^\\.\\//, '');\n const id = `file:${normalized}`;\n if (!found.has(id)) found.set(id, { id, name: displayName(normalized), open: open ? () => open(normalized) : undefined });\n };\n for (const path of picked) add(path);\n for (const item of state.draft.attachments) found.set(`draft:${item.id}`, { id: `draft:${item.id}`, name: item.name, open: onOpenImage ? () => onOpenImage(item.content) : undefined });\n for (const row of derived.rows) {\n if (row.type === 'user') {\n const content = typeof row.message.content === 'string' ? [{ type: 'text' as const, text: row.message.content }] : row.message.content;\n for (const [index, part] of content.entries()) {\n if (part.type === 'text' && !isFileBlock(part.text)) for (const piece of pieces(part.text, { mentions: true, skills: [] })) { if (piece.kind === 'mention' && piece.value) add(piece.value); }\n if (part.type === 'image') { const id = `image:${row.key}:${index}`; found.set(id, { id, name: labels.imageAttachment, open: onOpenImage ? () => onOpenImage(part) : undefined }); }\n }\n }\n if (row.type !== 'assistant') continue;\n for (const part of row.parts) {\n if (part.kind === 'artifact' && part.artifact) {\n const artifact = part.artifact, id = `file:${artifact.target.resource.path.replace(/^\\.\\//, '')}`;\n found.set(id, { id, name: displayName(artifact.target.resource.path), open: artifacts ? () => artifacts.open(artifact) : undefined });\n }\n if (part.kind === 'tool' && part.entry.result && !part.entry.result.isError && ['read', 'write', 'edit'].includes(part.entry.call.name)) {\n const path = part.entry.call.arguments['path'];\n if (typeof path === 'string') add(path);\n }\n }\n }\n return [...found.values()];\n }, [rows, picked, state.draft.attachments, artifacts, onOpenFile, mentions?.open, onOpenImage, labels.imageAttachment]);\n\n const report = (cause: unknown) => { if (current()) setError(cause instanceof Error ? cause.message : labels.actionFailedShort); };\n const act = (action: () => Promise<unknown>) => {\n setError(null);\n try { void action().catch(report); } catch (cause) { report(cause); }\n };\n // A send being confirmed does not block the next one: the controller empties the composer at once and queues the next message behind it.\n // Only a submission whose outcome is unknown blocks, until it is checked or retried.\n const unconfirmed = state.send.kind === 'unknown';\n const connected = state.connection.kind === 'connected';\n const sendBlocked = uploading > 0 || state.disposed || !connected || unconfirmed;\n /** Puts a message back into the composer ahead of whatever is there now (nothing is dropped). */\n const putBack = (text: string, images: readonly ChatAttachment[]) => {\n if (controller.getSnapshot().disposed) return;\n const draft = controller.getSnapshot().draft;\n controller.setText([text, draft.text].filter(value => value.trim()).join('\\n'));\n if (images.length) controller.setAttachments([...images, ...draft.attachments.filter(item => !images.some(image => image.id === item.id))]);\n };\n const working = Boolean(run);\n const waitingForAnswer = derived.rows.some(row => row.type === 'assistant' && row.parts.some(part => (part.kind === 'question' || part.kind === 'approval') && !part.live && !part.result));\n const send = () => {\n if (sendBlocked || !current()) return;\n const attach = feedback && feedback.count > 0 ? feedback.attach : undefined;\n if (attach) {\n // The pending feedback goes with this message: the host attaches it to the text, then the native send reads the draft.\n act(async () => {\n // Like any send, the composer empties at once; the person's own text comes back if the feedback or the message is refused.\n const typed = controller.getSnapshot().draft;\n controller.setText(''); controller.setAttachments([]);\n let attached: Awaited<ReturnType<typeof attach>>;\n try { attached = await attach(typed.text); }\n catch (cause) { putBack(typed.text, typed.attachments); throw cause; }\n if (attached.kind === 'refused') { putBack(typed.text, typed.attachments); throw new Error(attached.reason); }\n if (!current()) { putBack(typed.text, typed.attachments); return; }\n const submission = await controller.send('followUp', { text: attached.text, attachments: typed.attachments, restore: { text: typed.text, attachments: typed.attachments } });\n if (submission) feedback?.sent();\n });\n afterSend?.();\n textarea.current?.focus();\n return;\n }\n act(() => controller.send('followUp'));\n afterSend?.();\n textarea.current?.focus();\n };\n // A queued message is taken back (withdrawn) before it is edited or steered; if Pi already placed it, that is reported and nothing is lost.\n async function takeBack(item: QueuedMessage) {\n if (!actions?.withdraw) throw new Error(labels.queueLocked);\n const outcome = await actions.withdraw(item.id);\n if (outcome === 'already_placed' || outcome === 'settled') throw new Error(labels.tooLate);\n }\n const queuedContent = (item: QueuedMessage) => {\n const parts = Array.isArray(item.content) ? item.content as readonly { type?: unknown; text?: unknown; data?: unknown; mimeType?: unknown }[] : [];\n const text = typeof item.content === 'string' ? item.content : parts.flatMap(part => part?.type === 'text' && typeof part.text === 'string' && !isFileBlock(part.text) ? [part.text] : []).join('\\n');\n const images: ChatAttachment[] = parts.flatMap((part, index) => part?.type === 'image' && typeof part.data === 'string' && typeof part.mimeType === 'string'\n ? [{ id: `queued-${String(item.id)}-${index}`, name: `image-${index + 1}`, content: { type: 'image' as const, data: part.data, mimeType: part.mimeType } }] : []);\n return { text, images };\n };\n const queueActions: QueueActions | undefined = actions?.withdraw ? {\n edit: async item => {\n await takeBack(item);\n const { text, images } = queuedContent(item), draft = controller.getSnapshot().draft;\n controller.setText(draft.text.trim() ? `${text}\\n${draft.text}` : text);\n if (images.length) controller.setAttachments([...draft.attachments, ...images]);\n textarea.current?.focus();\n },\n steer: async item => {\n if (sendBlocked) throw new Error(labels.waitToSteer);\n await takeBack(item);\n const { text, images } = queuedContent(item);\n // Sent as explicit content, so the person's own draft is never touched; a refused steer comes back into the composer (the controller\n // puts it back), an unknown one is held for checking.\n try {\n const submission = await controller.send('steer', { text, attachments: images });\n if (!submission && current() && controller.getSnapshot().send.kind === 'blocked') setError(labels.steerRefused);\n } catch (cause) { putBack(text, images); if (current()) setError(cause instanceof Error ? cause.message : labels.steerFailed); }\n },\n } : undefined;\n const addMentions = (paths: readonly string[]) => {\n const text = controller.getSnapshot().draft.text;\n controller.setText(`${text}${text && !/\\s$/.test(text) ? ' ' : ''}${paths.map(mentionToken).join(' ')} `);\n setPicked(value => [...new Set([...value, ...paths])]);\n };\n /** Uploads files; `restore` (a pasted text) is what their pending cards undo: it abandons the upload and puts the text back. */\n async function upload(files: readonly File[], paste?: { readonly restore: () => void; readonly after: string }) {\n if ((!onFiles && !attachments) || !files.length || !current()) return;\n const abort = new AbortController(); uploads.current.add(abort); setUploading(value => value + 1);\n const ids = files.map(() => `upload-${++uploadIds.current}`);\n const settle = (failure?: string) => setPending(items => failure === undefined ? items.filter(item => !ids.includes(item.id))\n : items.map(item => ids.includes(item.id) ? { ...item, state: 'failed', error: failure } : item));\n const cancel = paste && (() => { abort.abort(); setPending(items => items.filter(item => !ids.includes(item.id))); paste.restore(); });\n setPending(items => [...items, ...files.map((file, index): PendingUpload => ({ id: ids[index]!, name: file.name, state: 'uploading',\n ...(cancel && paste ? { cancel, restoreWhile: paste.after } : {}) }))]);\n try {\n let added: readonly ChatAttachment[] = [];\n let paths: string[] = [];\n if (attachments) {\n for (const result of await attachments.upload([...files], abort.signal)) {\n if (result.image) added = [...added, { id: `image-${Date.now()}-${++uploadIds.current}`, name: result.name, content: { type: 'image', data: result.image.data, mimeType: result.image.mimeType } }];\n else if (result.path) paths = [...paths, result.path];\n }\n } else added = await onFiles!(files, { identity: state.identity, conversationId: state.conversationId, signal: abort.signal });\n if (current() && !abort.signal.aborted) {\n if (added.length) controller.setAttachments([...controller.getSnapshot().draft.attachments, ...added]);\n if (paths.length) addMentions(paths);\n settle();\n }\n } catch (cause) { if (!abort.signal.aborted) { settle(cause instanceof Error ? cause.message : labels.uploadFailedShort); if (!attachments) report(cause); } }\n finally { uploads.current.delete(abort); if (current()) setUploading(value => value - 1); }\n }\n /** A paste the composer took: saved as a file under `uploads/` (Send then references it). Cancelling puts the text back where it was pasted. */\n const pasteText = (paste: PastedText) => {\n if (!attachments || !current()) return;\n const at = new Date(), two = (value: number) => String(value).padStart(2, '0');\n const stamp = `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}-${two(at.getMinutes())}-${two(at.getSeconds())}`;\n const markdown = looksLikeMarkdown(paste.text);\n const file = new File([paste.text], `${labels.pastedFileName(stamp)}${markdown ? '.md' : '.txt'}`, { type: markdown ? 'text/markdown' : 'text/plain' });\n // The pasted text replaces its selection when the paste is accepted, so the draft shows what is left. Restoring inserts the text at\n // the same place and deletes nothing: whatever was typed since stays.\n const draft = controller.getSnapshot().draft.text;\n const start = Math.min(paste.start, draft.length), end = Math.min(Math.max(paste.end, start), draft.length);\n const after = `${draft.slice(0, start)}${draft.slice(end)}`;\n controller.setText(after);\n const restore = () => {\n if (!current()) return;\n const now = controller.getSnapshot().draft.text;\n const at = Math.min(start, now.length);\n controller.setText(`${now.slice(0, at)}${paste.text}${now.slice(at)}`);\n textarea.current?.focus();\n };\n void upload([file], { restore, after });\n };\n const agentDoc = object(state.view?.docs['pi.agent']);\n const currentModel = object(agentDoc?.['model']);\n const viewModel: ModelRef | undefined = typeof currentModel?.['provider'] === 'string' && typeof currentModel['modelId'] === 'string' ? { provider: currentModel['provider'], modelId: currentModel['modelId'] } : undefined;\n const viewEffort = typeof agentDoc?.['thinkingLevel'] === 'string' ? agentDoc['thinkingLevel'] : undefined;\n // Show the accepted choice until the watch delivers it, so the label does not flicker back.\n useEffect(() => {\n const same = optimistic.model && viewModel && optimistic.model.provider === viewModel.provider && optimistic.model.modelId === viewModel.modelId;\n if (same || (optimistic.effort !== undefined && optimistic.effort === viewEffort)) setOptimistic(value => ({ ...(same ? {} : value.model ? { model: value.model } : {}), ...(optimistic.effort === viewEffort ? {} : value.effort ? { effort: value.effort } : {}) }));\n }, [optimistic, viewModel?.provider, viewModel?.modelId, viewEffort]);\n useEffect(() => { if (!state.draft.text) setPicked(value => value.length ? [] : value); }, [state.draft.text]);\n async function change(kind: 'model' | 'effort', apply: () => Promise<void>, accepted: () => void, rejected: () => void) {\n if (changing || !current()) return;\n setChanging(kind); setChangeError(null);\n try { await apply(); if (current()) accepted(); }\n catch (cause) { if (current()) { rejected(); setChangeError(cause instanceof Error && cause.message ? cause.message : labels.changeRefused); } }\n finally { if (current()) setChanging(null); }\n }\n const activateMention = (command: { readonly name: string; readonly behavior: 'execute' | 'insert' }) => {\n if (!current() || controller.getSnapshot().connection.kind !== 'connected') return;\n const catalog = commandMentions;\n const selected = catalog?.commands.find(item => item.name === command.name && item.behavior === command.behavior);\n if (catalog && selected) act(async () => catalog.onActivate({ name: selected.name, behavior: selected.behavior }));\n };\n const rowContext = useMemo<RowContext>(() => ({\n developer, groupTool, onOpenImage, onCopy, openFile: onOpenFile ?? mentions?.open, fileChanges,\n commandMentions: commandMentions ? { commands: commandMentions.commands, onActivate: activateMention } : undefined,\n answer: actions?.answer,\n artifacts: artifacts ? { open: artifacts.open, isOpen: artifacts.isOpen } : undefined,\n messageActions,\n replyFooter,\n pieces: slash?.skills?.length || mentions || attachments ? { mentions: Boolean(mentions), uploads: Boolean(attachments), skills: (slash?.skills ?? []).map(skill => skill.name), openMention: mentions?.open, renderMention: mentions?.render, preview: mentions?.preview } : undefined,\n ...rowExtras,\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }), [developer, groupTool, onOpenImage, onCopy, onOpenFile, fileChanges, commandMentions, actions?.answer, slash?.skills, mentions, mentions?.open, mentions?.render, mentions?.preview, attachments, artifacts?.open, artifacts?.isOpen, messageActions, replyFooter, rowExtras]);\n\n /** The model-and-effort pill and the note that reports a refused change. */\n const pickers = () => {\n const disabled = changing !== null || state.disposed || !connected;\n const barStart = model || effort ? <ModelEffortPicker model={model} effort={effort} currentModel={optimistic.model ?? viewModel} currentEffort={optimistic.effort ?? viewEffort} disabled={disabled} busy={changing}\n onModel={next => void change('model', () => model!.change(next), () => setOptimistic(value => ({ ...value, model: next })), () => {})}\n onEffort={next => void change('effort', () => effort!.change(next), () => setOptimistic(value => ({ ...value, effort: next })), () => {})} /> : undefined;\n const barNote = changeError ? <Button variant=\"ghost\" size=\"sm\" role=\"alert\" data-testid=\"configure-error\" title={`${changeError} (${labels.dismissHint})`} onClick={() => setChangeError(null)}\n className=\"min-w-0 max-w-[16rem]\"><span className=\"truncate\">{changeError}</span></Button> : undefined;\n return { barStart, barNote };\n };\n\n const empty = connected && !state.view?.entries.length && !rows.length;\n const loading = (state.connection.kind === 'idle' || state.connection.kind === 'connecting') && !state.view;\n\n /** Everything `Composer` needs from the session; a surface adds its layout and slots. */\n const composer = {\n text: state.draft.text, onText: (text: string) => controller.setText(text), onKeyDown: onComposerKeyDown, textareaRef: textarea,\n attachments: state.draft.attachments.map(item => ({ id: item.id, name: item.name, mimeType: item.content.mimeType, data: item.content.data })),\n onRemoveAttachment: (id: string) => controller.setAttachments(controller.getSnapshot().draft.attachments.filter(item => item.id !== id)),\n working, sendBlocked, disabled: state.disposed, stopRequested: state.stop === 'requested',\n onSend: send, onStop: () => act(controller.stop),\n uploading: uploading > 0, canAttach: Boolean(onFiles || attachments), fileAccept: attachments ? attachments.accept ?? '' : fileAccept, onPickFiles: (files: readonly File[]) => { void upload(files); },\n slash, onSlashError: report, mentions, mentionPaths: picked, onMentionPicked: (path: string) => setPicked(value => value.includes(path) ? value : [...value, path]),\n uploads: pending, onDismissUpload: (id: string) => setPending(items => items.filter(item => item.id !== id)),\n onPasteText: attachments ? pasteText : undefined, pasteToFile,\n feedback, chips: composerChips,\n };\n\n return { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, sendBlocked, error, setError, report, act, current, textarea, rowContext, composer, files, pickers, empty, loading, developer };\n}\n\nexport type ChatSession = ReturnType<typeof useChatSession>;\n\n/** One scroll root for current and archived rows. Native paging stays in the borrowed controller. */\nexport function useTranscript(rows: readonly Row[], page = 60, more = 40, history?: { readonly state: ChatHistoryState; readonly connected: boolean; readonly load: () => Promise<void> }) {\n const [limit, setLimit] = useState(page);\n const [sentinel, setSentinel] = useState<HTMLDivElement | null>(null);\n const [loaded, setLoaded] = useState(0);\n const pending = useRef(false);\n const anchor = useRef<{ readonly length: number; readonly limit: number } | undefined>(undefined);\n const { scrollToEnd } = useMessageScroller();\n const scrollable = useMessageScrollerScrollable();\n const [scroller, setScroller] = useState<HTMLDivElement | null>(null);\n const scrollToBottom = useCallback((behavior?: 'instant') => scrollToEnd({ behavior: behavior === 'instant' ? 'instant' : 'smooth' }), [scrollToEnd]);\n const stick = { scrollRef: setScroller, scrollToBottom, isAtBottom: !scrollable.end };\n const hidden = Math.max(0, rows.length - limit);\n const reveal = useCallback(() => {\n if (pending.current || anchor.current) return;\n const state = history?.state;\n if (hidden === 0 && (!history?.connected || !state || state.kind === 'disabled' || state.kind === 'loading' || state.kind === 'ready' && !state.hasMore)) return;\n anchor.current = { length: rows.length, limit };\n if (hidden > 0) setLimit(value => value + more);\n else if (history) {\n pending.current = true;\n void Promise.resolve().then(history.load).catch(() => { anchor.current = undefined; }).finally(() => { pending.current = false; });\n }\n }, [hidden, history?.state, history?.connected, history?.load, rows.length, limit, more]);\n useLayoutEffect(() => {\n const saved = anchor.current;\n if (!scroller || !saved || history?.state.kind === 'loading') return;\n const added = Math.max(0, rows.length - saved.length);\n if (limit < saved.limit + added) { setLimit(saved.limit + added); return; }\n anchor.current = undefined;\n setLoaded(count => count + Math.max(0, Math.min(rows.length, limit) - Math.min(saved.length, saved.limit)));\n }, [rows, limit, scroller, history?.state]);\n useEffect(() => {\n if (!scroller || !sentinel || typeof IntersectionObserver === 'undefined' || history?.state.kind === 'loading' || history?.state.kind === 'error') return;\n const observer = new IntersectionObserver(entries => {\n if (entries.some(entry => entry.isIntersecting) && (scroller.scrollHeight <= scroller.clientHeight || scrollable.start)) reveal();\n }, { root: scroller, rootMargin: '240px 0px 0px' });\n observer.observe(sentinel);\n return () => observer.disconnect();\n }, [scroller, sentinel, reveal, scrollable.start, history?.state]);\n return { stick, hidden, reveal, sentinelRef: setSentinel, loaded, visible: <T,>(items: readonly T[]): readonly T[] => hidden ? items.slice(hidden) : items, reset: () => { anchor.current = undefined; setLimit(page); } };\n}\n",
|
|
142
177
|
"type": "registry:component",
|
|
143
178
|
"target": "components/pi-chat/session.tsx"
|
|
144
179
|
},
|
|
145
180
|
{
|
|
146
181
|
"path": "registry/pi-chat/shimmer.tsx",
|
|
147
|
-
"content": "import type { ElementType, ReactNode } from 'react';\nimport { cn } from '../utils/utils';\n\n/**
|
|
182
|
+
"content": "import type { ElementType, ReactNode } from 'react';\nimport { Spinner } from '@/components/ui/spinner';\nimport { cn } from '../utils/utils';\n\n/** Working text retains its public adapter; motion and appearance belong to the host's Spinner. */\nexport function Shimmer({ children, active = true, as: Tag = 'span', className }: { readonly children: ReactNode; readonly active?: boolean; readonly as?: ElementType; readonly className?: string }) {\n return <Tag className={cn('inline-flex items-center gap-2', className)}>{active && <Spinner aria-hidden=\"true\" />}{children}</Tag>;\n}\n",
|
|
148
183
|
"type": "registry:component",
|
|
149
184
|
"target": "components/pi-chat/shimmer.tsx"
|
|
150
185
|
},
|
|
151
186
|
{
|
|
152
187
|
"path": "registry/pi-chat/slash-menu.tsx",
|
|
153
|
-
"content": "'use client';\n\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport { usePickerKeyboard } from './picker-keyboard';\nimport {
|
|
188
|
+
"content": "'use client';\n\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport { SearchIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group';\nimport { Item, ItemContent, ItemDescription, ItemTitle } from '@/components/ui/item';\nimport { usePickerKeyboard } from './picker-keyboard';\nimport { ComposerSuggestions } from './mention-menu';\nimport { slashGroup } from './config';\nimport type { SlashItem } from './config';\nimport { useChatText } from './labels';\n\nconst ALL = '__all__';\n\n/**\n * The `/` picker, ported from v2's `SlashCommandPicker`: a search box seeded from what was typed after `/`, filter chips\n * (All, built-in, skills, plugin names), then rows with a name, badges and a one line description.\n */\nexport function SlashPicker({ query, items, onSelect, onDismiss }: {\n readonly query: string;\n readonly items: readonly SlashItem[];\n readonly onSelect: (item: SlashItem) => void;\n readonly onDismiss: () => void;\n}) {\n const { labels } = useChatText();\n const [group, setGroup] = useState(ALL);\n const [search, setSearch] = useState(query);\n const [active, setActive] = useState(0);\n const list = useRef<HTMLUListElement>(null);\n // Follow the composer text.\n useEffect(() => { setSearch(query); setActive(0); }, [query]);\n const groups = useMemo(() => [...new Set(items.map(slashGroup))].sort((a, b) => a.localeCompare(b)), [items]);\n const filtered = useMemo(() => {\n const needle = search.trim().toLowerCase();\n return items.filter(item => (group === ALL || slashGroup(item) === group)\n && (!needle || item.name.toLowerCase().includes(needle) || item.description.toLowerCase().includes(needle)));\n }, [items, search, group]);\n useEffect(() => { setActive(index => filtered.length === 0 ? 0 : Math.min(index, filtered.length - 1)); }, [filtered.length]);\n usePickerKeyboard({ count: filtered.length, activeIdx: active, setActiveIdx: setActive, listRef: list, onSelect: index => { const item = filtered[index]; if (item) onSelect(item); }, onDismiss });\n\n const optionId = (index: number) => `pi-chat-slash-${index}`;\n return <ComposerSuggestions testId=\"slash-menu\" onDismiss={onDismiss}>\n <InputGroup>\n <InputGroupAddon><SearchIcon aria-hidden=\"true\" /></InputGroupAddon>\n <InputGroupInput className=\"max-sm:min-h-11 pointer-coarse:min-h-11\" aria-label={labels.searchCommands} data-testid=\"slash-search\" type=\"text\" autoComplete=\"off\" spellCheck={false} value={search}\n onChange={event => { setSearch(event.currentTarget.value); setActive(0); }} placeholder={labels.searchCommands} />\n </InputGroup>\n <div role=\"tablist\" aria-label={labels.filterBySource} className=\"flex flex-wrap gap-1\">\n {[ALL, ...groups].map(name => <Button key={name} size=\"xs\" variant={group === name ? 'secondary' : 'ghost'} role=\"tab\" aria-selected={group === name} data-testid=\"slash-chip\" data-group={name === ALL ? 'all' : name}\n onMouseDown={event => { event.preventDefault(); setGroup(name); setActive(0); }}>{name === ALL ? labels.allSources : labels.slashGroup(name)}</Button>)}\n </div>\n {filtered.length === 0\n ? <div data-testid=\"slash-empty\" className=\"text-xs text-muted-foreground\">{labels.noCommands}</div>\n : <ul ref={list} role=\"listbox\" aria-label={labels.commands} className=\"m-0 flex max-h-[min(20rem,45dvh)] list-none flex-col overflow-y-auto p-0\">\n {filtered.map((item, index) => <Item key={`${item.source}:${item.name}`} render={<li />} size=\"xs\" variant={index === active ? 'muted' : 'default'} id={optionId(index)} role=\"option\" aria-selected={index === active} title={item.description || undefined}\n data-testid=\"slash-item\" data-name={item.name} data-source={item.source} data-active={index === active ? 'true' : undefined}\n onMouseEnter={() => setActive(index)} onMouseDown={event => { event.preventDefault(); onSelect(item); }}\n className=\"cursor-pointer flex-nowrap max-sm:min-h-11 pointer-coarse:min-h-11\">\n <ItemContent className=\"min-w-0\">\n <ItemTitle>/{item.name}\n {item.source === 'skill' && <Badge variant=\"secondary\" data-testid=\"slash-badge-skill\">{labels.skillBadge}</Badge>}\n {item.sourcePlugin && <Badge variant=\"outline\" data-testid=\"slash-badge-source\">{item.sourcePlugin}</Badge>}\n </ItemTitle>\n {item.description && <ItemDescription className=\"m-0 truncate\">{item.description}</ItemDescription>}\n </ItemContent>\n </Item>)}\n </ul>}\n </ComposerSuggestions>;\n}\n",
|
|
154
189
|
"type": "registry:component",
|
|
155
190
|
"target": "components/pi-chat/slash-menu.tsx"
|
|
156
191
|
},
|
|
157
192
|
{
|
|
158
193
|
"path": "registry/pi-chat/tool.tsx",
|
|
159
|
-
"content": "'use client';\n\nimport { useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport {
|
|
194
|
+
"content": "'use client';\n\nimport { useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { WrenchIcon } from 'lucide-react';\nimport { TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceDescription, TraceIcon, TraceLine, TraceTitle } from '@/components/ui/trace';\nimport { Card, CardContent } from '@/components/ui/card';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Button } from '@/components/ui/button';\nimport { defaultChatLabels, useChatText } from './labels';\nimport type { ChatLabels } from './labels';\n\nexport type ToolStatus = 'running' | 'completed' | 'failed' | 'unfinished';\nexport interface ToolEntry { readonly key: string; readonly call: ToolCall; readonly result: ToolResultMessage | undefined; readonly status: ToolStatus; readonly custom?: ReactNode }\n\nconst STATUS_LABEL = { running: 'toolRunning', completed: 'toolCompleted', failed: 'toolFailedStatus', unfinished: 'toolUnfinished' } as const satisfies Record<ToolStatus, keyof ChatLabels>;\nconst PREVIEW_KEYS = ['path', 'file_path', 'filePath', 'command', 'query', 'pattern', 'name', 'url', 'id'];\n\n/** A short hint of what the call acts on, shown next to its name. */\nexport function preview(args: ToolCall['arguments']): string {\n const values = args as Record<string, unknown>;\n for (const key of PREVIEW_KEYS) { const value = values[key]; if (typeof value === 'string' && value) return value.replace(/\\s+/g, ' ').slice(0, 90); }\n const first = Object.values(values).find(value => typeof value === 'string' && value);\n return typeof first === 'string' ? first.replace(/\\s+/g, ' ').slice(0, 90) : '';\n}\n\nfunction Section({ title, children }: { readonly title: string; readonly children: ReactNode }) {\n return <div className=\"space-y-1.5\"><h4 className=\"m-0 text-xs font-medium tracking-wider text-muted-foreground uppercase\">{title}</h4>{children}</div>;\n}\n\n/** A short live label for a call: \"Reading notes/consultation.md\", \"Running bash\", \"Running working_git\" (`labels.toolActivity`). */\nexport function liveLabel(call: ToolCall, labels: Pick<ChatLabels, 'toolActivity'> = defaultChatLabels): string {\n return labels.toolActivity(call.name, preview(call.arguments));\n}\n\n/** One Trace step. Failures open by default; quiet successful steps have no disclosure. */\nexport function ToolCard({ entry, onOpenImage, details = true }: { readonly entry: ToolEntry; readonly details?: boolean; readonly onOpenImage?: (image: Extract<ToolResultMessage['content'][number], { type: 'image' }>) => void }) {\n const { labels } = useChatText();\n const { call, result, status } = entry;\n const [chosen, setChosen] = useState<boolean | undefined>();\n const open = details && (chosen ?? status === 'failed');\n const hint = preview(call.arguments);\n const line = <TraceLine {...(status === 'running' ? { status: 'running' } : status === 'failed' ? { status: 'error' } : {})}>\n <TraceIcon><WrenchIcon /></TraceIcon>\n <TraceTitle><span data-testid=\"tool-name\">{call.name}</span>{hint && ` · ${hint}`}</TraceTitle>\n <TraceDescription data-testid=\"tool-status\">{labels[STATUS_LABEL[status]]}</TraceDescription>\n </TraceLine>;\n return <TraceCollapsible data-testid=\"tool-card\" data-boring=\"tool-card\" data-status={status} data-tool={call.name} data-call-id={call.id} open={open} onOpenChange={setChosen}>\n {details ? <TraceCollapsibleTrigger className=\"min-h-8 max-sm:min-h-11 pointer-coarse:min-h-11\">{line}</TraceCollapsibleTrigger> : line}\n {details && <TraceCollapsibleContent><div data-testid=\"tool-details\" className=\"flex flex-col gap-3\">\n <Section title={labels.arguments}><Card><CardContent><pre className=\"m-0 max-h-60 overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere]\">{JSON.stringify(call.arguments, null, 2)}</pre></CardContent></Card></Section>\n {result && <Section title={result.isError ? labels.error : labels.result}>\n <Alert variant={result.isError ? 'destructive' : 'default'} role={result.isError ? 'alert' : 'status'}><AlertDescription>\n {result.content.map((part, index) => part.type === 'text'\n ? <pre key={index} className=\"m-0 max-h-72 overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere]\">{part.text}</pre>\n : <Button key={index} variant=\"outline\" size=\"sm\" 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",
|
|
160
195
|
"type": "registry:component",
|
|
161
196
|
"target": "components/pi-chat/tool.tsx"
|
|
162
197
|
},
|
|
163
198
|
{
|
|
164
199
|
"path": "registry/pi-chat/use-remote-chat.ts",
|
|
165
|
-
"content": "import { useEffect, useLayoutEffect, useMemo, useState } from 'react';\nimport { createNativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport type { ChatIdentity, NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { createRemoteChat } from '@hachej/boring-ui-kit/remote-chat';\nimport type { RemoteChat } from '@hachej/boring-ui-kit/remote-chat';\nimport type { PiChatActions } from './
|
|
200
|
+
"content": "import { useEffect, useLayoutEffect, useMemo, useState } from 'react';\nimport { createNativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport type { ChatIdentity, NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { createRemoteChat } from '@hachej/boring-ui-kit/remote-chat';\nimport type { RemoteChat } from '@hachej/boring-ui-kit/remote-chat';\nimport type { PiChatActions } from './session';\n\n// ---- Placement: where the chat is shown, reported by the component that shows it and sent with each submit (see `useRemoteChat`).\n// `PiChat` reports `docked`, `AmbientChat` (also a docked chat floated by its divider) reports `floating`. Advisory for the model, never\n// authority. See docs/architecture/PAGE-TOOLS.md (PLACE-1..3).\nexport type ChatPlacement = 'docked' | 'floating';\n\n// Keyed by controller (a page can hold several chats; floating or docking keeps the controller, PLACE-3). Each mounted presenter has its own\n// registration, so unmounting one never clears another's; the latest surviving registration wins.\nconst placements = new WeakMap<NativeChatController, { readonly placement: ChatPlacement }[]>();\n\n/** Reports the placement of the chat showing `controller` while the caller is mounted. */\nexport function useChatPlacement(controller: NativeChatController, placement: ChatPlacement): void {\n useLayoutEffect(() => {\n const entry = { placement };\n const list = placements.get(controller) ?? [];\n placements.set(controller, [...list, entry]);\n return () => {\n const rest = (placements.get(controller) ?? []).filter(item => item !== entry);\n if (rest.length) placements.set(controller, rest); else placements.delete(controller);\n };\n }, [controller, placement]);\n}\n\nexport function readChatPlacement(controller: NativeChatController | undefined): ChatPlacement | undefined {\n return controller ? placements.get(controller)?.at(-1)?.placement : undefined;\n}\n\nexport type RemoteChatState =\n | { readonly status: 'connecting'; readonly conversationId: string | undefined }\n | { readonly status: 'offline'; readonly conversationId: string | undefined; readonly error: string }\n | { readonly status: 'ready'; readonly conversationId: string; readonly controller: NativeChatController; readonly actions: PiChatActions; readonly configure: RemoteChat['configure'] };\n\n/**\n * One native chat controller per selected conversation over the host's chat transport (`createChatTransportHandler` of\n * `@hachej/boring-agent/chat-transport`). `endpoint(id)` names the transport URL of a conversation; `fetch` is the host's authenticated fetch.\n * The remote chat reopens a dropped stream itself (the controller reads `reconnecting` meanwhile); it never means the task stopped.\n * A failed connect retries every second. The previous conversation's controller is never handed out: it is disposed on change.\n */\nexport function useRemoteChat({ conversationId, endpoint, fetch, identity, requestContext }: {\n readonly conversationId: string | undefined;\n readonly endpoint: (conversationId: string) => string | URL;\n readonly fetch: (request: Request) => Promise<Response>;\n readonly identity: ChatIdentity | undefined;\n /**\n * The current screen context, read at each submit (for example `readAgentContext` of the app-shell item). The submit's `context` is this\n * value as it is (so a host reading `name` and `params` keeps working) plus `placement`: `docked` or `floating`, reported by the chat.\n */\n readonly requestContext?: () => Readonly<Record<string, unknown>> | undefined;\n}): RemoteChatState {\n const url = conversationId === undefined ? undefined : String(endpoint(conversationId));\n const key = JSON.stringify([conversationId, url, identity?.runtimeId, identity?.scopeId, identity?.principalId]);\n const binding = useMemo(() => ({ key, fetch, requestContext }), [key]); // eslint-disable-line react-hooks/exhaustive-deps\n useLayoutEffect(() => { binding.fetch = fetch; binding.requestContext = requestContext; }, [binding, fetch, requestContext]);\n const [stored, setStored] = useState<{ readonly binding: typeof binding; readonly chat: RemoteChatState }>();\n useEffect(() => {\n if (conversationId === undefined || !identity || url === undefined) return;\n const setChat = (chat: RemoteChatState) => setStored({ binding, chat });\n let disposed = false, controller: NativeChatController | undefined, remote: RemoteChat | undefined;\n const context = () => {\n const placement = readChatPlacement(controller), screen = binding.requestContext?.();\n return placement || screen ? { ...screen, ...(placement && { placement }) } : undefined;\n };\n setChat({ status: 'connecting', conversationId });\n void (async () => {\n while (!disposed) {\n try {\n remote = await createRemoteChat({ endpoint: url, fetch: request => binding.fetch(request), requestContext: context });\n if (disposed) return void remote.close();\n controller = createNativeChatController({ identity, ...remote });\n await controller.connect();\n if (disposed) return void controller.dispose();\n // Human-in-the-loop answers and queue withdrawal are optional on the transport: pass only what it offers.\n const actions: { answer?: RemoteChat['answer']; withdraw?: RemoteChat['withdraw'] } = {};\n if (typeof remote.answer === 'function') actions.answer = remote.answer;\n if (typeof remote.withdraw === 'function') actions.withdraw = remote.withdraw;\n setChat({ status: 'ready', controller, conversationId, actions, configure: remote.configure });\n return;\n } catch (error) {\n controller?.dispose(); controller = undefined; void remote?.close(); remote = undefined;\n if (disposed) return;\n setChat({ status: 'offline', conversationId, error: String((error as Error)?.message ?? error) });\n await new Promise(resolve => setTimeout(resolve, 1000));\n }\n }\n })();\n return () => { disposed = true; controller?.dispose(); void remote?.close(); };\n }, [binding]); // eslint-disable-line react-hooks/exhaustive-deps\n return identity && stored?.binding === binding ? stored.chat : { status: 'connecting', conversationId };\n}\n",
|
|
166
201
|
"type": "registry:hook",
|
|
167
202
|
"target": "components/pi-chat/use-remote-chat.ts"
|
|
168
203
|
},
|
|
@@ -174,13 +209,13 @@
|
|
|
174
209
|
},
|
|
175
210
|
{
|
|
176
211
|
"path": "registry/pi-chat/labels.ts",
|
|
177
|
-
"content": "/*\n * Every word the chat shows, and the icons it draws, as plain data. A host passes a partial `labels` (and `icons`) object to `PiChat`\n * (or `AmbientChat`, `AgentWorkspace`): what it leaves out keeps the default below. Strings that need a value are functions. No i18n\n * framework: an app that translates passes its own object per language.\n */\nimport { createContext, createElement, useContext, useMemo } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowDownIcon, ArrowUpIcon, GitForkIcon, HistoryIcon, ListEndIcon, MessagesSquareIcon, PaperclipIcon, PlusIcon, RefreshCwIcon, SquareIcon } from 'lucide-react';\nimport type { BlockIcon } from '../button/actions';\nimport { withDefaults } from '../utils/utils';\n\nconst TOOL_VERBS: Readonly<Record<string, string>> = { read: 'Reading', write: 'Writing', edit: 'Editing', ls: 'Listing', list_files: 'Listing', find: 'Searching', grep: 'Searching', load_skill: 'Loading skill', bash: 'Running bash' };\nconst TOOL_NOUNS: Readonly<Record<string, string>> = { bash: 'command', read: 'read', write: 'write', edit: 'edit', find: 'find', grep: 'search', ls: 'list' };\n\nconst EFFORT_NAMES: Readonly<Record<string, string>> = { off: 'Off', minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', max: 'Max' };\nconst EFFORT_DETAILS: Readonly<Record<string, string>> = { off: 'No extra reasoning', minimal: 'Barely any reasoning', low: 'Light reasoning', medium: 'Balanced reasoning', high: 'Deep reasoning', xhigh: 'Deeper reasoning', max: 'Maximum reasoning' };\n\nexport const defaultChatLabels = {\n // Header and transcript\n /** The chat's name: the header title, the region's name and the empty state's title. */\n title: 'Conversation',\n history: 'History',\n live: 'Live',\n connecting: 'Connecting…',\n reconnecting: 'Reconnecting…',\n loadFailed: 'Conversation could not be loaded.',\n disconnected: 'Disconnected. Last observed messages remain visible.',\n reconnect: 'Reconnect',\n requiredActions: 'Required conversation actions',\n messages: 'Messages',\n showEarlier: 'Show earlier messages',\n loading: 'Loading conversation…',\n emptyDescription: 'Start a conversation.',\n jumpToLatest: 'Jump to latest',\n agentWorking: 'Agent is working',\n waitingForAnswer: 'Waiting for your answer',\n retryPending: 'Native retry pending',\n compacting: 'Native context compaction in progress',\n /** The \"…\" menu of host actions (header, replies). */\n moreActions: 'More actions',\n // Notices\n sendUnknown: 'Submission acknowledgement is unknown. The message is kept until you check or retry it.',\n checkSubmission: 'Check original submission',\n retrySame: 'Retry same request',\n stopRequested: 'Stop requested. Waiting for native confirmation.',\n stopUnconfirmed: 'Stop is unconfirmed. Work may still be running.',\n steerRefused: 'The message could not be steered; it is back in the message box.',\n tooLate: 'Too late: that message has already started.',\n tooLateToRemove: 'Too late to remove: that message has already started.',\n queueFailed: 'The message could not be changed.',\n queueLocked: 'Queued messages cannot be changed here.',\n waitToSteer: 'Wait for the current send to finish, then steer.',\n steerFailed: 'The message could not be steered.',\n connectionFailed: 'Connection failed',\n actionFailedShort: 'Action failed',\n uploadFailedShort: 'Upload failed',\n changeRefused: 'The change was refused',\n dismissHint: 'click to dismiss',\n // Composer\n placeholder: 'Message the agent…',\n placeholderWorking: 'Queue a message…',\n messageInput: 'Message',\n send: 'Send',\n sendHint: 'Send (Enter)',\n stop: 'Stop',\n queue: 'Queue',\n add: 'Add',\n attachFiles: 'Attach files',\n commands: 'Commands',\n annotate: 'Annotate',\n annotateHint: 'Annotate: click what you want to comment on (Esc to stop)',\n annotateNotes: (count: number) => `${count} ${count === 1 ? 'note' : 'notes'}`,\n annotateClear: 'Remove the notes',\n remove: (name: string) => `Remove ${name}`,\n dismiss: (name: string) => `Dismiss ${name}`,\n uploading: (name: string) => `Uploading ${name}…`,\n uploadFailed: (name: string, error: string | undefined) => `${name}: ${error ?? 'upload failed'}`,\n preparingAttachments: 'Preparing attachments…',\n preparingAttachmentsShort: 'Preparing attachments',\n mentionedFiles: 'Mentioned files',\n uploads: 'Uploads',\n attachments: 'Attachments',\n /** The type line of a file card in a sent message. */\n fileKind: { document: 'Document', image: 'Image', pdf: 'PDF', sheet: 'Spreadsheet', slides: 'Presentation', file: 'File' },\n openFile: (name: string) => `Open ${name}`,\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',\n model: 'Model',\n effort: 'Effort',\n effortDefault: 'Default',\n modelAndEffort: 'Model and effort',\n modelSummary: (model: string | undefined, effort: string | undefined) => [model && `Model: ${model}`, effort && `Effort: ${effort}`].filter(Boolean).join(', '),\n /** A thinking level's name and short description in the picker. */\n effortName: (level: string) => EFFORT_NAMES[level] ?? level,\n effortDetail: (level: string): string | undefined => EFFORT_DETAILS[level],\n // Voice input (the microphone button beside the composer)\n voiceRecord: 'Record a message',\n voiceStop: 'Stop recording',\n voiceCancel: 'Cancel recording',\n voiceTranscribing: 'Transcribing…',\n voiceNothingHeard: 'Nothing was heard. Try again.',\n voiceTranscribeFailed: 'The recording could not be transcribed. Try again.',\n voiceDenied: 'Microphone blocked. Allow it for this site in the browser, then press record again.',\n voiceNoMicrophone: 'No microphone found. Connect one, then press record again.',\n voiceBusy: 'The microphone is busy or could not be opened. Close other apps using it and try again.',\n voiceInsecure: 'Recording needs a secure page (https or localhost).',\n voiceUnsupported: 'This browser cannot record audio here.',\n voiceStartFailed: 'The microphone could not be started.',\n // Queue\n queuedMessages: 'Queued messages',\n steer: 'Steer',\n steerHint: 'Send this into the running turn now',\n steering: 'Steering',\n queued: 'Queued',\n removeQueued: 'Remove queued message',\n removeHint: 'Remove',\n more: 'More',\n edit: 'Edit',\n sending: 'Sending',\n images: (count: number) => `[${count} image${count === 1 ? '' : 's'}]`,\n // Conversation list and history\n historyDialog: 'Conversation history',\n newConversation: 'New',\n untitled: 'New conversation',\n closeHistory: 'Close history',\n close: 'Close',\n earlierInConversation: 'Earlier messages in this conversation',\n searchConversations: 'Search conversations',\n archived: 'Archived',\n current: 'Current',\n noMatch: (query: string) => `No conversation matches “${query}”.`,\n noArchived: 'No archived conversations.',\n noConversations: 'No conversations yet.',\n loadingConversations: 'Loading conversations…',\n actionFailed: 'That did not work. Try again.',\n rename: 'Rename',\n renameItem: (title: string) => `Rename ${title}`,\n conversationName: 'Conversation name',\n save: 'Save',\n archive: 'Archive',\n restore: 'Restore',\n archiveItem: (title: string, archived: boolean) => `${archived ? 'Restore' : 'Archive'} ${title}`,\n delete: 'Delete',\n deleteItem: (title: string) => `Delete ${title}`,\n keep: 'Keep conversation',\n today: 'Today',\n yesterday: 'Yesterday',\n previousWeek: 'Previous 7 days',\n older: 'Older',\n conversations: 'Conversations',\n earlierMessages: 'Earlier messages',\n readOnly: '(read-only)',\n retry: 'Retry',\n loadingShort: 'Loading…',\n loadEarlier: 'Load earlier',\n returnToConversation: 'Return to active conversation',\n historyFailed: 'Conversation history could not be loaded. The previous page remains visible.',\n loadPage: 'Load a page to browse earlier messages.',\n noRecords: 'No records in this history page.',\n beginning: 'Beginning of conversation.',\n justNow: 'now',\n roleSystem: 'System',\n roleToolResult: (tool: string, failed: boolean) => `Tool result: ${tool}${failed ? ' (failed)' : ''}`,\n roleUser: 'You',\n roleAssistant: (outcome: 'ok' | 'interrupted' | 'failed'): string => outcome === 'interrupted' ? 'Assistant (interrupted)' : outcome === 'failed' ? 'Assistant (failed)' : 'Assistant',\n roleEvent: 'Event',\n contextBoundary: 'Context boundary',\n // Messages\n copy: 'Copy',\n copied: 'Copied',\n copyFailed: 'Copy failed',\n failed: 'Failed',\n copyResponse: 'Copy response',\n copyCode: 'Copy code',\n fork: 'Fork from here',\n forkHint: 'Fork from here: a new conversation with the messages up to this reply',\n responseFailed: 'The response failed',\n interrupted: 'Response interrupted',\n thinking: 'Thinking…',\n openImage: 'Open image attachment',\n imageAttachment: 'Image attachment',\n systemContext: 'System context',\n toolFailed: (tool: string) => `${tool} failed`,\n event: (label: string) => `Event: ${label}`,\n // Activity and tools\n reasoning: 'Reasoning',\n thinkingStep: 'Thinking',\n steps: (count: number) => `${count} ${count === 1 ? 'step' : 'steps'}`,\n toolRunning: 'Running',\n toolCompleted: 'Completed',\n toolFailedStatus: 'Failed',\n toolUnfinished: 'No result',\n arguments: 'Arguments',\n result: 'Result',\n error: 'Error',\n openImageResult: 'Open image result',\n emptyResult: 'Empty result',\n /** The live line of a running tool call; `hint` is what it acts on (a path, a command), possibly empty. */\n toolActivity: (tool: string, hint: string) => {\n const verb = TOOL_VERBS[tool] ?? `Running ${tool}`;\n return hint ? `${verb}${tool === 'bash' ? ':' : ''} ${hint}` : verb;\n },\n /** How a tool is named in a finished activity summary (\"Used read ×3 · command\"). */\n toolNoun: (tool: string) => TOOL_NOUNS[tool] ?? tool,\n // Approval and question cards\n approvalNeeded: 'Approval needed',\n needsApproval: 'Needs your approval',\n approve: 'Approve',\n deny: 'Deny',\n approved: 'Approved',\n denied: 'Denied',\n notDecided: 'Not decided: the run was stopped',\n wantsToRun: 'The agent wants to run',\n decision: 'Decision',\n stillWritingCall: 'The agent is still writing this call…',\n decidingUnavailable: 'Deciding is not available in this view.',\n decisionSent: (approved: boolean) => `${approved ? 'Approved.' : 'Denied.'} Waiting for the agent…`,\n decisionRefused: (kind: 'denied' | 'conflict' | 'unknown-question'): string => kind === 'denied' ? 'The host did not accept this answer.' : kind === 'conflict' ? 'This call was already decided.' : 'This call is no longer waiting for a decision.',\n decisionNotSent: 'The decision could not be sent.',\n waitingForAgent: 'Waiting for the agent…',\n question: 'Question',\n questionForYou: 'Question for you',\n notAnswered: 'Not answered',\n answered: 'Answered',\n askedQuestion: 'The agent asked a question.',\n answerOptions: 'Answer options',\n ownAnswer: 'Or type your own answer',\n typeAnswer: 'Type your answer',\n yourAnswerInput: 'Your answer',\n answer: 'Answer',\n stillWritingQuestion: 'The agent is still writing this question…',\n answeringUnavailable: 'Answering is not available in this view.',\n answerRefused: (kind: 'denied' | 'conflict' | 'unknown-question'): string => kind === 'denied' ? 'The host did not accept this answer.' : kind === 'conflict' ? 'This question was already answered.' : 'This question is no longer waiting for an answer.',\n answerNotSent: 'The answer could not be sent.',\n yourAnswer: 'Your answer:',\n // Artifact cards\n artifact: 'Artifact',\n opening: 'Opening…',\n viewing: 'Viewing',\n version: (ordinal: number) => `Version ${ordinal}`,\n runCommand: (name: string, execute: boolean) => `${execute ? 'Run' : 'Insert'} /${name} command`,\n taskStatus: 'Task status',\n activityFailed: (count: number, tools: string) => `${count > 1 ? `${count} steps failed` : 'Step failed'}: ${tools}`,\n activityUsed: (summary: string, stopped: boolean) => `${stopped ? 'Stopped · ' : 'Used '}${summary}`,\n activityThought: 'Thought it through',\n activityMany: (steps: number) => `Worked through ${steps} steps`,\n open: 'Open',\n openArtifact: (title: string, version: number | undefined) => version === undefined ? `Open ${title}` : `Open ${title}, version ${version}`,\n};\n\nexport type ChatLabels = typeof defaultChatLabels;\n\n/** The chat's icons. Any component that takes `className` (every lucide icon does). */\nexport const defaultChatIcons = {\n /** The agent's glyph in the empty state (the header shows the host's `avatar`, if any). */\n agent: MessagesSquareIcon as BlockIcon,\n send: ArrowUpIcon as BlockIcon,\n stop: SquareIcon as BlockIcon,\n history: HistoryIcon as BlockIcon,\n newChat: PlusIcon as BlockIcon,\n add: PlusIcon as BlockIcon,\n attach: PaperclipIcon as BlockIcon,\n queue: ListEndIcon as BlockIcon,\n jumpToLatest: ArrowDownIcon as BlockIcon,\n reconnect: RefreshCwIcon as BlockIcon,\n fork: GitForkIcon as BlockIcon,\n};\n\nexport type ChatIcons = typeof defaultChatIcons;\n\n/** What the chat's pieces read: the merged labels and icons. */\nexport interface ChatText { readonly labels: ChatLabels; readonly icons: ChatIcons }\n/** The defaults, as `useMergedText`'s base. */\nexport const defaultChatText: ChatText = { labels: defaultChatLabels, icons: defaultChatIcons };\nconst DEFAULT_TEXT = defaultChatText;\nconst ChatTextContext = createContext<ChatText>(DEFAULT_TEXT);\n\n/** The labels and icons of the surrounding chat (the defaults outside one). */\nexport const useChatText = (): ChatText => useContext(ChatTextContext);\n\n/** Merges the host's partial labels and icons over the defaults (or over `base`), keeping one object while they do not change. */\nexport function useMergedText<L extends ChatLabels = ChatLabels, I extends ChatIcons = ChatIcons>(labels: Partial<L> | undefined, icons: Partial<I> | undefined,\n base: { readonly labels: L; readonly icons: I } = DEFAULT_TEXT as { readonly labels: L; readonly icons: I }): { readonly labels: L; readonly icons: I } {\n return useMemo(() => ({ labels: withDefaults(base.labels, labels), icons: withDefaults(base.icons, icons) }), [labels, icons, base]);\n}\n\n/** Provides the chat's labels and icons to everything inside (composer, queue, rows, history). */\nexport function ChatTextProvider({ value, children }: { readonly value: ChatText; readonly children: ReactNode }) {\n return createElement(ChatTextContext.Provider, { value }, children);\n}\n",
|
|
212
|
+
"content": "/*\n * Every word the chat shows, and the icons it draws, as plain data. A host passes a partial `labels` (and `icons`) object to `PiChat`\n * (or `AmbientChat`, `AgentWorkspace`): what it leaves out keeps the default below. Strings that need a value are functions. No i18n\n * framework: an app that translates passes its own object per language.\n */\nimport { createContext, createElement, useContext, useMemo } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowDownIcon, ArrowUpIcon, GitForkIcon, HistoryIcon, ListEndIcon, MessagesSquareIcon, PaperclipIcon, PlusIcon, RefreshCwIcon, SquareIcon } from 'lucide-react';\nimport type { BlockIcon } from '../button/actions';\nimport { withDefaults } from '../utils/utils';\n\nconst TOOL_VERBS: Readonly<Record<string, string>> = { read: 'Reading', write: 'Writing', edit: 'Editing', ls: 'Listing', list_files: 'Listing', find: 'Searching', grep: 'Searching', load_skill: 'Loading skill', bash: 'Running bash' };\nconst TOOL_NOUNS: Readonly<Record<string, string>> = { bash: 'command', read: 'read', write: 'write', edit: 'edit', find: 'find', grep: 'search', ls: 'list' };\n\nconst EFFORT_NAMES: Readonly<Record<string, string>> = { off: 'Off', minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', max: 'Max' };\nconst EFFORT_DETAILS: Readonly<Record<string, string>> = { off: 'No extra reasoning', minimal: 'Barely any reasoning', low: 'Light reasoning', medium: 'Balanced reasoning', high: 'Deep reasoning', xhigh: 'Deeper reasoning', max: 'Maximum reasoning' };\n\nexport const defaultChatLabels = {\n // Header and transcript\n /** The chat's name: the header title, the region's name and the empty state's title. */\n title: 'Conversation',\n history: 'History',\n 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 \"…\" menu of host actions (header, replies). */\n moreActions: 'More actions',\n // Notices\n sendUnknown: 'Submission acknowledgement is unknown. The message is kept until you check or retry it.',\n checkSubmission: 'Check original submission',\n retrySame: 'Retry same request',\n stopRequested: 'Stop requested. Waiting for native confirmation.',\n stopUnconfirmed: 'Stop is unconfirmed. Work may still be running.',\n steerRefused: 'The message could not be steered; it is back in the message box.',\n tooLate: 'Too late: that message has already started.',\n tooLateToRemove: 'Too late to remove: that message has already started.',\n queueFailed: 'The message could not be changed.',\n queueLocked: 'Queued messages cannot be changed here.',\n waitToSteer: 'Wait for the current send to finish, then steer.',\n steerFailed: 'The message could not be steered.',\n connectionFailed: 'Connection failed',\n actionFailedShort: 'Action failed',\n uploadFailedShort: 'Upload failed',\n changeRefused: 'The change was refused',\n dismissHint: 'click to dismiss',\n // Composer\n placeholder: 'Message the agent…',\n placeholderWorking: 'Queue a message…',\n messageInput: 'Message',\n send: 'Send',\n sendHint: 'Send (Enter)',\n stop: 'Stop',\n queue: 'Queue',\n add: 'Add',\n attachFiles: 'Attach files',\n commands: 'Commands',\n annotate: 'Annotate',\n annotateHint: 'Annotate: click what you want to comment on (Esc to stop)',\n annotateNotes: (count: number) => `${count} ${count === 1 ? 'note' : 'notes'}`,\n annotateClear: 'Remove the notes',\n remove: (name: string) => `Remove ${name}`,\n dismiss: (name: string) => `Dismiss ${name}`,\n uploading: (name: string) => `Uploading ${name}…`,\n uploadFailed: (name: string, error: string | undefined) => `${name}: ${error ?? 'upload failed'}`,\n preparingAttachments: 'Preparing attachments…',\n preparingAttachmentsShort: 'Preparing attachments',\n mentionedFiles: 'Mentioned files',\n uploads: 'Uploads',\n attachments: 'Attachments',\n /** The type line of a file card in a sent message. */\n fileKind: { document: 'Document', image: 'Image', pdf: 'PDF', sheet: 'Spreadsheet', slides: 'Presentation', 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',\n model: 'Model',\n effort: 'Effort',\n effortDefault: 'Default',\n modelAndEffort: 'Model and effort',\n modelSummary: (model: string | undefined, effort: string | undefined) => [model && `Model: ${model}`, effort && `Effort: ${effort}`].filter(Boolean).join(', '),\n /** A thinking level's name and short description in the picker. */\n effortName: (level: string) => EFFORT_NAMES[level] ?? level,\n effortDetail: (level: string): string | undefined => EFFORT_DETAILS[level],\n // Voice input (the microphone button beside the composer)\n voiceRecord: 'Record a message',\n voiceStop: 'Stop recording',\n voiceCancel: 'Cancel recording',\n voiceTranscribing: 'Transcribing…',\n voiceNothingHeard: 'Nothing was heard. Try again.',\n voiceTranscribeFailed: 'The recording could not be transcribed. Try again.',\n voiceDenied: 'Microphone blocked. Allow it for this site in the browser, then press record again.',\n voiceNoMicrophone: 'No microphone found. Connect one, then press record again.',\n voiceBusy: 'The microphone is busy or could not be opened. Close other apps using it and try again.',\n voiceInsecure: 'Recording needs a secure page (https or localhost).',\n voiceUnsupported: 'This browser cannot record audio here.',\n voiceStartFailed: 'The microphone could not be started.',\n // Queue\n queuedMessages: 'Queued messages',\n steer: 'Steer',\n steerHint: 'Send this into the running turn now',\n steering: 'Steering',\n queued: 'Queued',\n removeQueued: 'Remove queued message',\n removeHint: 'Remove',\n more: 'More',\n edit: 'Edit',\n sending: 'Sending',\n images: (count: number) => `[${count} image${count === 1 ? '' : 's'}]`,\n // Conversation list and history\n historyDialog: 'Conversation history',\n newConversation: 'New',\n untitled: 'New conversation',\n closeHistory: 'Close history',\n close: 'Close',\n earlierInConversation: 'Earlier messages in this conversation',\n searchConversations: 'Search conversations',\n archived: 'Archived',\n current: 'Current',\n noMatch: (query: string) => `No conversation matches “${query}”.`,\n noArchived: 'No archived conversations.',\n noConversations: 'No conversations yet.',\n loadingConversations: 'Loading conversations…',\n actionFailed: 'That did not work. Try again.',\n rename: 'Rename',\n renameItem: (title: string) => `Rename ${title}`,\n conversationName: 'Conversation name',\n save: 'Save',\n archive: 'Archive',\n restore: 'Restore',\n archiveItem: (title: string, archived: boolean) => `${archived ? 'Restore' : 'Archive'} ${title}`,\n delete: 'Delete',\n deleteItem: (title: string) => `Delete ${title}`,\n keep: 'Keep conversation',\n today: 'Today',\n yesterday: 'Yesterday',\n previousWeek: 'Previous 7 days',\n older: 'Older',\n conversations: 'Conversations',\n earlierMessages: 'Earlier messages',\n readOnly: '(read-only)',\n retry: 'Retry',\n loadingShort: 'Loading…',\n loadEarlier: 'Load earlier',\n 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 thinking: 'Thinking…',\n openImage: 'Open image attachment',\n imageAttachment: 'Image attachment',\n systemContext: 'System context',\n toolFailed: (tool: string) => `${tool} failed`,\n event: (label: string) => `Event: ${label}`,\n // Activity and tools\n reasoning: 'Reasoning',\n thinkingStep: 'Thinking',\n steps: (count: number) => `${count} ${count === 1 ? 'step' : 'steps'}`,\n toolRunning: 'Running',\n toolCompleted: 'Completed',\n toolFailedStatus: 'Failed',\n toolUnfinished: 'No result',\n arguments: 'Arguments',\n result: 'Result',\n error: 'Error',\n openImageResult: 'Open image result',\n emptyResult: 'Empty result',\n /** The live line of a running tool call; `hint` is what it acts on (a path, a command), possibly empty. */\n toolActivity: (tool: string, hint: string) => {\n const verb = TOOL_VERBS[tool] ?? `Running ${tool}`;\n return hint ? `${verb}${tool === 'bash' ? ':' : ''} ${hint}` : verb;\n },\n /** How a tool is named in a finished activity summary (\"Used read ×3 · command\"). */\n toolNoun: (tool: string) => TOOL_NOUNS[tool] ?? tool,\n // Approval and question cards\n approvalNeeded: 'Approval needed',\n needsApproval: 'Needs your approval',\n approve: 'Approve',\n deny: 'Deny',\n approved: 'Approved',\n denied: 'Denied',\n notDecided: 'Not decided: the run was stopped',\n wantsToRun: 'The agent wants to run',\n decision: 'Decision',\n stillWritingCall: 'The agent is still writing this call…',\n decidingUnavailable: 'Deciding is not available in this view.',\n decisionSent: (approved: boolean) => `${approved ? 'Approved.' : 'Denied.'} Waiting for the agent…`,\n decisionRefused: (kind: 'denied' | 'conflict' | 'unknown-question'): string => kind === 'denied' ? 'The host did not accept this answer.' : kind === 'conflict' ? 'This call was already decided.' : 'This call is no longer waiting for a decision.',\n decisionNotSent: 'The decision could not be sent.',\n waitingForAgent: 'Waiting for the agent…',\n question: 'Question',\n questionForYou: 'Question for you',\n notAnswered: 'Not answered',\n answered: 'Answered',\n askedQuestion: 'The agent asked a question.',\n answerOptions: 'Answer options',\n ownAnswer: 'Or type your own answer',\n typeAnswer: 'Type your answer',\n yourAnswerInput: 'Your answer',\n answer: 'Answer',\n stillWritingQuestion: 'The agent is still writing this question…',\n answeringUnavailable: 'Answering is not available in this view.',\n answerRefused: (kind: 'denied' | 'conflict' | 'unknown-question'): string => kind === 'denied' ? 'The host did not accept this answer.' : kind === 'conflict' ? 'This question was already answered.' : 'This question is no longer waiting for an answer.',\n answerNotSent: 'The answer could not be sent.',\n yourAnswer: 'Your answer:',\n // Artifact cards\n artifact: 'Artifact',\n opening: 'Opening…',\n viewing: 'Viewing',\n version: (ordinal: number) => `Version ${ordinal}`,\n runCommand: (name: string, execute: boolean) => `${execute ? 'Run' : 'Insert'} /${name} command`,\n taskStatus: 'Task status',\n activityFailed: (count: number, tools: string) => `${count > 1 ? `${count} steps failed` : 'Step failed'}: ${tools}`,\n activityUsed: (summary: string, stopped: boolean) => `${stopped ? 'Stopped · ' : 'Used '}${summary}`,\n activityThought: 'Thought it through',\n activityMany: (steps: number) => `Worked through ${steps} steps`,\n open: 'Open',\n openArtifact: (title: string, version: number | undefined) => version === undefined ? `Open ${title}` : `Open ${title}, version ${version}`,\n};\n\nexport type ChatLabels = typeof defaultChatLabels;\n\n/** The chat's icons. Any component that takes `className` (every lucide icon does). */\nexport const defaultChatIcons = {\n /** The agent's glyph in the empty state (the header shows the host's `avatar`, if any). */\n agent: MessagesSquareIcon as BlockIcon,\n send: ArrowUpIcon as BlockIcon,\n stop: SquareIcon as BlockIcon,\n history: HistoryIcon as BlockIcon,\n newChat: PlusIcon as BlockIcon,\n add: PlusIcon as BlockIcon,\n attach: PaperclipIcon as BlockIcon,\n queue: ListEndIcon as BlockIcon,\n jumpToLatest: ArrowDownIcon as BlockIcon,\n reconnect: RefreshCwIcon as BlockIcon,\n fork: GitForkIcon as BlockIcon,\n};\n\nexport type ChatIcons = typeof defaultChatIcons;\n\n/** What the chat's pieces read: the merged labels and icons. */\nexport interface ChatText { readonly labels: ChatLabels; readonly icons: ChatIcons }\n/** The defaults, as `useMergedText`'s base. */\nexport const defaultChatText: ChatText = { labels: defaultChatLabels, icons: defaultChatIcons };\nconst DEFAULT_TEXT = defaultChatText;\nconst ChatTextContext = createContext<ChatText>(DEFAULT_TEXT);\n\n/** The labels and icons of the surrounding chat (the defaults outside one). */\nexport const useChatText = (): ChatText => useContext(ChatTextContext);\n\n/** Merges the host's partial labels and icons over the defaults (or over `base`), keeping one object while they do not change. */\nexport function useMergedText<L extends ChatLabels = ChatLabels, I extends ChatIcons = ChatIcons>(labels: Partial<L> | undefined, icons: Partial<I> | undefined,\n base: { readonly labels: L; readonly icons: I } = DEFAULT_TEXT as { readonly labels: L; readonly icons: I }): { readonly labels: L; readonly icons: I } {\n return useMemo(() => ({ labels: withDefaults(base.labels, labels), icons: withDefaults(base.icons, icons) }), [labels, icons, base]);\n}\n\n/** Provides the chat's labels and icons to everything inside (composer, queue, rows, history). */\nexport function ChatTextProvider({ value, children }: { readonly value: ChatText; readonly children: ReactNode }) {\n return createElement(ChatTextContext.Provider, { value }, children);\n}\n",
|
|
178
213
|
"type": "registry:lib",
|
|
179
214
|
"target": "components/pi-chat/labels.ts"
|
|
180
215
|
},
|
|
181
216
|
{
|
|
182
217
|
"path": "registry/pi-chat/labels-fr.ts",
|
|
183
|
-
"content": "/* The chat's words in French, the `locale=\"fr\"` base of `PiChat`, `AmbientChat` and `AgentWorkspace`. Typed as `ChatLabels`, so a key added in English must be added here too. */\nimport type { ChatLabels } from './labels';\n\nconst TOOL_VERBS: Readonly<Record<string, string>> = { read: 'Lecture', write: 'Écriture', edit: 'Modification', ls: 'Liste', list_files: 'Liste', find: 'Recherche', grep: 'Recherche', load_skill: 'Chargement de la compétence', bash: 'Exécution bash' };\nconst TOOL_NOUNS: Readonly<Record<string, string>> = { bash: 'commande', read: 'lecture', write: 'écriture', edit: 'modification', find: 'recherche', grep: 'recherche', ls: 'liste' };\nconst EFFORT_NAMES: Readonly<Record<string, string>> = { off: 'Désactivé', minimal: 'Minimal', low: 'Faible', medium: 'Moyen', high: 'Élevé', xhigh: 'Très élevé', max: 'Maximum' };\nconst EFFORT_DETAILS: Readonly<Record<string, string>> = { off: 'Sans réflexion supplémentaire', minimal: 'Presque aucune réflexion', low: 'Réflexion légère', medium: 'Réflexion équilibrée', high: 'Réflexion approfondie', xhigh: 'Réflexion très approfondie', max: 'Réflexion maximale' };\n\nexport const frenchChatLabels: ChatLabels = {\n title: 'Conversation',\n history: 'Historique',\n live: 'En direct',\n connecting: 'Connexion…',\n reconnecting: 'Reconnexion…',\n loadFailed: 'La conversation n’a pas pu être chargée.',\n disconnected: 'Déconnecté. Les derniers messages reçus restent affichés.',\n reconnect: 'Se reconnecter',\n requiredActions: 'Actions requises dans la conversation',\n messages: 'Messages',\n showEarlier: 'Afficher les messages précédents',\n loading: 'Chargement de la conversation…',\n emptyDescription: 'Commencez une conversation.',\n jumpToLatest: 'Aller au dernier message',\n agentWorking: 'L’agent travaille',\n waitingForAnswer: 'En attente de votre réponse',\n retryPending: 'Nouvelle tentative en attente',\n compacting: 'Résumé du contexte en cours',\n moreActions: 'Plus d’actions',\n sendUnknown: 'L’envoi n’est pas confirmé. Le message est conservé jusqu’à ce que vous le vérifiiez ou le renvoyiez.',\n checkSubmission: 'Vérifier l’envoi',\n retrySame: 'Renvoyer la même demande',\n stopRequested: 'Arrêt demandé. En attente de confirmation.',\n stopUnconfirmed: 'Arrêt non confirmé. Le travail continue peut-être.',\n steerRefused: 'Le message n’a pas pu être ajouté en cours de réponse ; il est revenu dans la zone de saisie.',\n tooLate: 'Trop tard : ce message a déjà commencé.',\n tooLateToRemove: 'Trop tard pour le retirer : ce message a déjà commencé.',\n queueFailed: 'Le message n’a pas pu être modifié.',\n queueLocked: 'Les messages en attente ne peuvent pas être modifiés ici.',\n waitToSteer: 'Attendez la fin de l’envoi en cours, puis réessayez.',\n steerFailed: 'Le message n’a pas pu être ajouté en cours de réponse.',\n connectionFailed: 'Échec de la connexion',\n actionFailedShort: 'Échec de l’action',\n uploadFailedShort: 'Échec de l’envoi',\n changeRefused: 'La modification a été refusée',\n dismissHint: 'cliquez pour fermer',\n placeholder: 'Écrivez à l’agent…',\n placeholderWorking: 'Mettre un message en attente…',\n messageInput: 'Message',\n send: 'Envoyer',\n sendHint: 'Envoyer (Entrée)',\n stop: 'Arrêter',\n queue: 'Mettre en attente',\n add: 'Ajouter',\n attachFiles: 'Joindre des fichiers',\n // Voice input (the microphone button beside the composer)\n voiceRecord: 'Enregistrer un message',\n voiceStop: 'Arrêter l’enregistrement',\n voiceCancel: 'Annuler l’enregistrement',\n voiceTranscribing: 'Transcription…',\n voiceNothingHeard: 'Rien n’a été entendu. Réessayez.',\n voiceTranscribeFailed: 'L’enregistrement n’a pas pu être transcrit. Réessayez.',\n voiceDenied: 'Microphone bloqué. Autorisez-le pour ce site dans le navigateur, puis appuyez de nouveau sur enregistrer.',\n voiceNoMicrophone: 'Aucun microphone trouvé. Branchez-en un, puis appuyez de nouveau sur enregistrer.',\n voiceBusy: 'Le microphone est occupé ou n’a pas pu être ouvert. Fermez les autres applications qui l’utilisent et réessayez.',\n voiceInsecure: 'L’enregistrement exige une page sécurisée (https ou localhost).',\n voiceUnsupported: 'Ce navigateur ne peut pas enregistrer le son ici.',\n voiceStartFailed: 'Le microphone n’a pas pu être démarré.',\n commands: 'Commandes',\n annotate: 'Annoter',\n annotateHint: 'Annoter : cliquez sur ce que vous voulez commenter (Échap pour arrêter)',\n annotateNotes: count => `${count} ${count === 1 ? 'note' : 'notes'}`,\n annotateClear: 'Retirer les notes',\n remove: name => `Retirer ${name}`,\n dismiss: name => `Fermer ${name}`,\n uploading: name => `Envoi de ${name}…`,\n uploadFailed: (name, error) => `${name} : ${error ?? 'échec de l’envoi'}`,\n preparingAttachments: 'Préparation des pièces jointes…',\n preparingAttachmentsShort: 'Préparation des pièces jointes',\n mentionedFiles: 'Fichiers mentionnés',\n uploads: 'Envois',\n attachments: 'Pièces jointes',\n fileKind: { document: 'Document', image: 'Image', pdf: 'PDF', sheet: 'Tableur', slides: 'Présentation', file: 'Fichier' },\n openFile: name => `Ouvrir ${name}`,\n pastedFileName: stamp => `Texte collé ${stamp}`,\n restorePaste: 'Remettre le texte collé dans le message',\n searchCommands: 'Rechercher une commande',\n filterBySource: 'Filtrer par source',\n allSources: 'Toutes',\n noCommands: 'Aucune commande ne correspond.',\n slashGroup: group => group === 'skills' ? 'compétences' : group === 'built-in' ? 'intégrées' : group,\n skillBadge: 'compétence',\n workspaceFiles: 'Fichiers de l’espace',\n searchFailed: 'La recherche a échoué',\n model: 'Modèle',\n effort: 'Effort',\n effortDefault: 'Par défaut',\n modelAndEffort: 'Modèle et effort',\n modelSummary: (model, effort) => [model && `Modèle : ${model}`, effort && `Effort : ${effort}`].filter(Boolean).join(', '),\n effortName: level => EFFORT_NAMES[level] ?? level,\n effortDetail: level => EFFORT_DETAILS[level],\n queuedMessages: 'Messages en attente',\n steer: 'Envoyer maintenant',\n steerHint: 'Ajouter ce message à la réponse en cours',\n steering: 'Envoi en cours de réponse',\n queued: 'En attente',\n removeQueued: 'Retirer le message en attente',\n removeHint: 'Retirer',\n more: 'Plus',\n edit: 'Modifier',\n sending: 'Envoi',\n images: count => `[${count} image${count === 1 ? '' : 's'}]`,\n historyDialog: 'Historique des conversations',\n newConversation: 'Nouvelle',\n untitled: 'Nouvelle conversation',\n closeHistory: 'Fermer l’historique',\n close: 'Fermer',\n earlierInConversation: 'Messages précédents de cette conversation',\n searchConversations: 'Rechercher une conversation',\n archived: 'Archivées',\n current: 'En cours',\n noMatch: query => `Aucune conversation ne correspond à « ${query} ».`,\n noArchived: 'Aucune conversation archivée.',\n noConversations: 'Aucune conversation pour le moment.',\n loadingConversations: 'Chargement des conversations…',\n actionFailed: 'Cela n’a pas fonctionné. Réessayez.',\n rename: 'Renommer',\n renameItem: title => `Renommer ${title}`,\n conversationName: 'Nom de la conversation',\n save: 'Enregistrer',\n archive: 'Archiver',\n restore: 'Restaurer',\n archiveItem: (title, archived) => `${archived ? 'Restaurer' : 'Archiver'} ${title}`,\n delete: 'Supprimer',\n deleteItem: title => `Supprimer ${title}`,\n keep: 'Garder la conversation',\n today: 'Aujourd’hui',\n yesterday: 'Hier',\n previousWeek: 'Les 7 derniers jours',\n older: 'Plus anciennes',\n conversations: 'Conversations',\n earlierMessages: 'Messages précédents',\n readOnly: '(lecture seule)',\n retry: 'Réessayer',\n loadingShort: 'Chargement…',\n loadEarlier: 'Charger les précédents',\n returnToConversation: 'Revenir à la conversation en cours',\n historyFailed: 'L’historique n’a pas pu être chargé. La page précédente reste affichée.',\n loadPage: 'Chargez une page pour parcourir les messages précédents.',\n noRecords: 'Aucun élément sur cette page d’historique.',\n beginning: 'Début de la conversation.',\n justNow: 'à l’instant',\n roleSystem: 'Système',\n roleToolResult: (tool, failed) => `Résultat de l’outil : ${tool}${failed ? ' (échec)' : ''}`,\n roleUser: 'Vous',\n roleAssistant: outcome => outcome === 'interrupted' ? 'Assistant (interrompu)' : outcome === 'failed' ? 'Assistant (échec)' : 'Assistant',\n roleEvent: 'Événement',\n contextBoundary: 'Limite du contexte',\n copy: 'Copier',\n copied: 'Copié',\n copyFailed: 'Échec de la copie',\n failed: 'Échec',\n copyResponse: 'Copier la réponse',\n copyCode: 'Copier le code',\n fork: 'Repartir d’ici',\n forkHint: 'Repartir d’ici : une nouvelle conversation avec les messages jusqu’à cette réponse',\n responseFailed: 'La réponse a échoué',\n interrupted: 'Réponse interrompue',\n thinking: 'Réflexion…',\n openImage: 'Ouvrir l’image jointe',\n imageAttachment: 'Image jointe',\n systemContext: 'Contexte système',\n toolFailed: tool => `Échec de ${tool}`,\n event: label => `Événement : ${label}`,\n reasoning: 'Réflexion',\n thinkingStep: 'Réflexion',\n steps: count => `${count} ${count === 1 ? 'étape' : 'étapes'}`,\n toolRunning: 'En cours',\n toolCompleted: 'Terminé',\n toolFailedStatus: 'Échec',\n toolUnfinished: 'Sans résultat',\n arguments: 'Arguments',\n result: 'Résultat',\n error: 'Erreur',\n openImageResult: 'Ouvrir l’image du résultat',\n emptyResult: 'Résultat vide',\n toolActivity: (tool, hint) => {\n const verb = TOOL_VERBS[tool] ?? `Exécution de ${tool}`;\n return hint ? `${verb}${tool === 'bash' ? ' :' : ''} ${hint}` : verb;\n },\n toolNoun: tool => TOOL_NOUNS[tool] ?? tool,\n approvalNeeded: 'Approbation requise',\n needsApproval: 'Votre approbation est nécessaire',\n approve: 'Approuver',\n deny: 'Refuser',\n approved: 'Approuvé',\n denied: 'Refusé',\n notDecided: 'Non décidé : l’exécution a été arrêtée',\n wantsToRun: 'L’agent souhaite exécuter',\n decision: 'Décision',\n stillWritingCall: 'L’agent est encore en train d’écrire cet appel…',\n decidingUnavailable: 'La décision n’est pas disponible dans cette vue.',\n decisionSent: approved => `${approved ? 'Approuvé.' : 'Refusé.'} En attente de l’agent…`,\n decisionRefused: kind => kind === 'denied' ? 'Cette réponse n’a pas été acceptée.' : kind === 'conflict' ? 'Cet appel a déjà été décidé.' : 'Cet appel n’attend plus de décision.',\n decisionNotSent: 'La décision n’a pas pu être envoyée.',\n waitingForAgent: 'En attente de l’agent…',\n question: 'Question',\n questionForYou: 'Une question pour vous',\n notAnswered: 'Sans réponse',\n answered: 'Répondu',\n askedQuestion: 'L’agent a posé une question.',\n answerOptions: 'Réponses possibles',\n ownAnswer: 'Ou écrivez votre propre réponse',\n typeAnswer: 'Écrivez votre réponse',\n yourAnswerInput: 'Votre réponse',\n answer: 'Répondre',\n stillWritingQuestion: 'L’agent est encore en train d’écrire cette question…',\n answeringUnavailable: 'La réponse n’est pas disponible dans cette vue.',\n answerRefused: kind => kind === 'denied' ? 'Cette réponse n’a pas été acceptée.' : kind === 'conflict' ? 'Cette question a déjà reçu une réponse.' : 'Cette question n’attend plus de réponse.',\n answerNotSent: 'La réponse n’a pas pu être envoyée.',\n yourAnswer: 'Votre réponse :',\n artifact: 'Document',\n opening: 'Ouverture…',\n viewing: 'Affiché',\n version: ordinal => `Version ${ordinal}`,\n runCommand: (name, execute) => `${execute ? 'Exécuter' : 'Insérer'} la commande /${name}`,\n taskStatus: 'État de la tâche',\n activityFailed: (count, tools) => `${count > 1 ? `${count} étapes ont échoué` : 'Une étape a échoué'} : ${tools}`,\n activityUsed: (summary, stopped) => `${stopped ? 'Arrêté · ' : 'Utilisé : '}${summary}`,\n activityThought: 'Réflexion terminée',\n activityMany: steps => `${steps} étapes réalisées`,\n open: 'Ouvrir',\n openArtifact: (title, version) => version === undefined ? `Ouvrir ${title}` : `Ouvrir ${title}, version ${version}`,\n};\n",
|
|
218
|
+
"content": "/* The chat's words in French, the `locale=\"fr\"` base of `PiChat`, `AmbientChat` and `AgentWorkspace`. Typed as `ChatLabels`, so a key added in English must be added here too. */\nimport type { ChatLabels } from './labels';\n\nconst TOOL_VERBS: Readonly<Record<string, string>> = { read: 'Lecture', write: 'Écriture', edit: 'Modification', ls: 'Liste', list_files: 'Liste', find: 'Recherche', grep: 'Recherche', load_skill: 'Chargement de la compétence', bash: 'Exécution bash' };\nconst TOOL_NOUNS: Readonly<Record<string, string>> = { bash: 'commande', read: 'lecture', write: 'écriture', edit: 'modification', find: 'recherche', grep: 'recherche', ls: 'liste' };\nconst EFFORT_NAMES: Readonly<Record<string, string>> = { off: 'Désactivé', minimal: 'Minimal', low: 'Faible', medium: 'Moyen', high: 'Élevé', xhigh: 'Très élevé', max: 'Maximum' };\nconst EFFORT_DETAILS: Readonly<Record<string, string>> = { off: 'Sans réflexion supplémentaire', minimal: 'Presque aucune réflexion', low: 'Réflexion légère', medium: 'Réflexion équilibrée', high: 'Réflexion approfondie', xhigh: 'Réflexion très approfondie', max: 'Réflexion maximale' };\n\nexport const frenchChatLabels: ChatLabels = {\n title: 'Conversation',\n history: 'Historique',\n artifacts: 'Fichiers',\n search: 'Rechercher',\n live: 'En direct',\n connecting: 'Connexion…',\n reconnecting: 'Reconnexion…',\n loadFailed: 'La conversation n’a pas pu être chargée.',\n disconnected: 'Déconnecté. Les derniers messages reçus restent affichés.',\n reconnect: 'Se reconnecter',\n requiredActions: 'Actions requises dans la conversation',\n messages: 'Messages',\n showEarlier: 'Afficher les messages précédents',\n loading: 'Chargement de la conversation…',\n emptyDescription: 'Commencez une conversation.',\n jumpToLatest: 'Aller au dernier message',\n agentWorking: 'L’agent travaille',\n waitingForAnswer: 'En attente de votre réponse',\n retryPending: 'Nouvelle tentative en attente',\n compacting: 'Résumé du contexte en cours',\n moreActions: 'Plus d’actions',\n sendUnknown: 'L’envoi n’est pas confirmé. Le message est conservé jusqu’à ce que vous le vérifiiez ou le renvoyiez.',\n checkSubmission: 'Vérifier l’envoi',\n retrySame: 'Renvoyer la même demande',\n stopRequested: 'Arrêt demandé. En attente de confirmation.',\n stopUnconfirmed: 'Arrêt non confirmé. Le travail continue peut-être.',\n steerRefused: 'Le message n’a pas pu être ajouté en cours de réponse ; il est revenu dans la zone de saisie.',\n tooLate: 'Trop tard : ce message a déjà commencé.',\n tooLateToRemove: 'Trop tard pour le retirer : ce message a déjà commencé.',\n queueFailed: 'Le message n’a pas pu être modifié.',\n queueLocked: 'Les messages en attente ne peuvent pas être modifiés ici.',\n waitToSteer: 'Attendez la fin de l’envoi en cours, puis réessayez.',\n steerFailed: 'Le message n’a pas pu être ajouté en cours de réponse.',\n connectionFailed: 'Échec de la connexion',\n actionFailedShort: 'Échec de l’action',\n uploadFailedShort: 'Échec de l’envoi',\n changeRefused: 'La modification a été refusée',\n dismissHint: 'cliquez pour fermer',\n placeholder: 'Écrivez à l’agent…',\n placeholderWorking: 'Mettre un message en attente…',\n messageInput: 'Message',\n send: 'Envoyer',\n sendHint: 'Envoyer (Entrée)',\n stop: 'Arrêter',\n queue: 'Mettre en attente',\n add: 'Ajouter',\n attachFiles: 'Joindre des fichiers',\n // Voice input (the microphone button beside the composer)\n voiceRecord: 'Enregistrer un message',\n voiceStop: 'Arrêter l’enregistrement',\n voiceCancel: 'Annuler l’enregistrement',\n voiceTranscribing: 'Transcription…',\n voiceNothingHeard: 'Rien n’a été entendu. Réessayez.',\n voiceTranscribeFailed: 'L’enregistrement n’a pas pu être transcrit. Réessayez.',\n voiceDenied: 'Microphone bloqué. Autorisez-le pour ce site dans le navigateur, puis appuyez de nouveau sur enregistrer.',\n voiceNoMicrophone: 'Aucun microphone trouvé. Branchez-en un, puis appuyez de nouveau sur enregistrer.',\n voiceBusy: 'Le microphone est occupé ou n’a pas pu être ouvert. Fermez les autres applications qui l’utilisent et réessayez.',\n voiceInsecure: 'L’enregistrement exige une page sécurisée (https ou localhost).',\n voiceUnsupported: 'Ce navigateur ne peut pas enregistrer le son ici.',\n voiceStartFailed: 'Le microphone n’a pas pu être démarré.',\n commands: 'Commandes',\n annotate: 'Annoter',\n annotateHint: 'Annoter : cliquez sur ce que vous voulez commenter (Échap pour arrêter)',\n annotateNotes: count => `${count} ${count === 1 ? 'note' : 'notes'}`,\n annotateClear: 'Retirer les notes',\n remove: name => `Retirer ${name}`,\n dismiss: name => `Fermer ${name}`,\n uploading: name => `Envoi de ${name}…`,\n uploadFailed: (name, error) => `${name} : ${error ?? 'échec de l’envoi'}`,\n preparingAttachments: 'Préparation des pièces jointes…',\n preparingAttachmentsShort: 'Préparation des pièces jointes',\n mentionedFiles: 'Fichiers mentionnés',\n uploads: 'Envois',\n attachments: 'Pièces jointes',\n fileKind: { document: 'Document', image: 'Image', pdf: 'PDF', sheet: 'Tableur', slides: 'Présentation', file: 'Fichier' },\n openFile: name => `Ouvrir ${name}`,\n fileChange: { created: 'Créé', edited: 'Modifié' },\n pastedFileName: stamp => `Texte collé ${stamp}`,\n restorePaste: 'Remettre le texte collé dans le message',\n searchCommands: 'Rechercher une commande',\n filterBySource: 'Filtrer par source',\n allSources: 'Toutes',\n noCommands: 'Aucune commande ne correspond.',\n slashGroup: group => group === 'skills' ? 'compétences' : group === 'built-in' ? 'intégrées' : group,\n skillBadge: 'compétence',\n workspaceFiles: 'Fichiers de l’espace',\n searchFailed: 'La recherche a échoué',\n model: 'Modèle',\n effort: 'Effort',\n effortDefault: 'Par défaut',\n modelAndEffort: 'Modèle et effort',\n modelSummary: (model, effort) => [model && `Modèle : ${model}`, effort && `Effort : ${effort}`].filter(Boolean).join(', '),\n effortName: level => EFFORT_NAMES[level] ?? level,\n effortDetail: level => EFFORT_DETAILS[level],\n queuedMessages: 'Messages en attente',\n steer: 'Envoyer maintenant',\n steerHint: 'Ajouter ce message à la réponse en cours',\n steering: 'Envoi en cours de réponse',\n queued: 'En attente',\n removeQueued: 'Retirer le message en attente',\n removeHint: 'Retirer',\n more: 'Plus',\n edit: 'Modifier',\n sending: 'Envoi',\n images: count => `[${count} image${count === 1 ? '' : 's'}]`,\n historyDialog: 'Historique des conversations',\n newConversation: 'Nouvelle',\n untitled: 'Nouvelle conversation',\n closeHistory: 'Fermer l’historique',\n close: 'Fermer',\n earlierInConversation: 'Messages précédents de cette conversation',\n searchConversations: 'Rechercher une conversation',\n archived: 'Archivées',\n current: 'En cours',\n noMatch: query => `Aucune conversation ne correspond à « ${query} ».`,\n noArchived: 'Aucune conversation archivée.',\n noConversations: 'Aucune conversation pour le moment.',\n loadingConversations: 'Chargement des conversations…',\n actionFailed: 'Cela n’a pas fonctionné. Réessayez.',\n rename: 'Renommer',\n renameItem: title => `Renommer ${title}`,\n conversationName: 'Nom de la conversation',\n save: 'Enregistrer',\n archive: 'Archiver',\n restore: 'Restaurer',\n archiveItem: (title, archived) => `${archived ? 'Restaurer' : 'Archiver'} ${title}`,\n delete: 'Supprimer',\n deleteItem: title => `Supprimer ${title}`,\n keep: 'Garder la conversation',\n today: 'Aujourd’hui',\n yesterday: 'Hier',\n previousWeek: 'Les 7 derniers jours',\n older: 'Plus anciennes',\n conversations: 'Conversations',\n earlierMessages: 'Messages précédents',\n readOnly: '(lecture seule)',\n retry: 'Réessayer',\n loadingShort: 'Chargement…',\n loadEarlier: 'Charger les précédents',\n loadEarlierMessages: 'Charger les messages précédents',\n earlierMessagesLoaded: count => `${count} messages précédents chargés`,\n returnToConversation: 'Revenir à la conversation en cours',\n historyFailed: 'L’historique n’a pas pu être chargé. La page précédente reste affichée.',\n loadPage: 'Chargez une page pour parcourir les messages précédents.',\n noRecords: 'Aucun élément sur cette page d’historique.',\n beginning: 'Début de la conversation.',\n justNow: 'à l’instant',\n roleSystem: 'Système',\n roleToolResult: (tool, failed) => `Résultat de l’outil : ${tool}${failed ? ' (échec)' : ''}`,\n roleUser: 'Vous',\n roleAssistant: outcome => outcome === 'interrupted' ? 'Assistant (interrompu)' : outcome === 'failed' ? 'Assistant (échec)' : 'Assistant',\n roleEvent: 'Événement',\n contextBoundary: 'Limite du contexte',\n copy: 'Copier',\n copied: 'Copié',\n copyFailed: 'Échec de la copie',\n failed: 'Échec',\n copyResponse: 'Copier la réponse',\n copyCode: 'Copier le code',\n fork: 'Repartir d’ici',\n forkHint: 'Repartir d’ici : une nouvelle conversation avec les messages jusqu’à cette réponse',\n responseFailed: 'La réponse a échoué',\n interrupted: 'Réponse interrompue',\n thinking: 'Réflexion…',\n openImage: 'Ouvrir l’image jointe',\n imageAttachment: 'Image jointe',\n systemContext: 'Contexte système',\n toolFailed: tool => `Échec de ${tool}`,\n event: label => `Événement : ${label}`,\n reasoning: 'Réflexion',\n thinkingStep: 'Réflexion',\n steps: count => `${count} ${count === 1 ? 'étape' : 'étapes'}`,\n toolRunning: 'En cours',\n toolCompleted: 'Terminé',\n toolFailedStatus: 'Échec',\n toolUnfinished: 'Sans résultat',\n arguments: 'Arguments',\n result: 'Résultat',\n error: 'Erreur',\n openImageResult: 'Ouvrir l’image du résultat',\n emptyResult: 'Résultat vide',\n toolActivity: (tool, hint) => {\n const verb = TOOL_VERBS[tool] ?? `Exécution de ${tool}`;\n return hint ? `${verb}${tool === 'bash' ? ' :' : ''} ${hint}` : verb;\n },\n toolNoun: tool => TOOL_NOUNS[tool] ?? tool,\n approvalNeeded: 'Approbation requise',\n needsApproval: 'Votre approbation est nécessaire',\n approve: 'Approuver',\n deny: 'Refuser',\n approved: 'Approuvé',\n denied: 'Refusé',\n notDecided: 'Non décidé : l’exécution a été arrêtée',\n wantsToRun: 'L’agent souhaite exécuter',\n decision: 'Décision',\n stillWritingCall: 'L’agent est encore en train d’écrire cet appel…',\n decidingUnavailable: 'La décision n’est pas disponible dans cette vue.',\n decisionSent: approved => `${approved ? 'Approuvé.' : 'Refusé.'} En attente de l’agent…`,\n decisionRefused: kind => kind === 'denied' ? 'Cette réponse n’a pas été acceptée.' : kind === 'conflict' ? 'Cet appel a déjà été décidé.' : 'Cet appel n’attend plus de décision.',\n decisionNotSent: 'La décision n’a pas pu être envoyée.',\n waitingForAgent: 'En attente de l’agent…',\n question: 'Question',\n questionForYou: 'Une question pour vous',\n notAnswered: 'Sans réponse',\n answered: 'Répondu',\n askedQuestion: 'L’agent a posé une question.',\n answerOptions: 'Réponses possibles',\n ownAnswer: 'Ou écrivez votre propre réponse',\n typeAnswer: 'Écrivez votre réponse',\n yourAnswerInput: 'Votre réponse',\n answer: 'Répondre',\n stillWritingQuestion: 'L’agent est encore en train d’écrire cette question…',\n answeringUnavailable: 'La réponse n’est pas disponible dans cette vue.',\n answerRefused: kind => kind === 'denied' ? 'Cette réponse n’a pas été acceptée.' : kind === 'conflict' ? 'Cette question a déjà reçu une réponse.' : 'Cette question n’attend plus de réponse.',\n answerNotSent: 'La réponse n’a pas pu être envoyée.',\n yourAnswer: 'Votre réponse :',\n artifact: 'Document',\n opening: 'Ouverture…',\n viewing: 'Affiché',\n version: ordinal => `Version ${ordinal}`,\n runCommand: (name, execute) => `${execute ? 'Exécuter' : 'Insérer'} la commande /${name}`,\n taskStatus: 'État de la tâche',\n activityFailed: (count, tools) => `${count > 1 ? `${count} étapes ont échoué` : 'Une étape a échoué'} : ${tools}`,\n activityUsed: (summary, stopped) => `${stopped ? 'Arrêté · ' : 'Utilisé : '}${summary}`,\n activityThought: 'Réflexion terminée',\n activityMany: steps => `${steps} étapes réalisées`,\n open: 'Ouvrir',\n openArtifact: (title, version) => version === undefined ? `Ouvrir ${title}` : `Ouvrir ${title}, version ${version}`,\n};\n",
|
|
184
219
|
"type": "registry:lib",
|
|
185
220
|
"target": "components/pi-chat/labels-fr.ts"
|
|
186
221
|
},
|
|
@@ -196,7 +231,10 @@
|
|
|
196
231
|
".pi-chat, .pi-chat *, .pi-chat *::before, .pi-chat *::after": {
|
|
197
232
|
"box-sizing": "border-box"
|
|
198
233
|
},
|
|
199
|
-
".pi-chat :where(
|
|
234
|
+
".pi-chat :where(*), .pi-chat :where(*)::before, .pi-chat :where(*)::after": {
|
|
235
|
+
"border-color": "var(--border)"
|
|
236
|
+
},
|
|
237
|
+
".pi-chat :where(button, input, textarea, select)": {
|
|
200
238
|
"font": "inherit",
|
|
201
239
|
"color": "inherit",
|
|
202
240
|
"letter-spacing": "inherit"
|
|
@@ -211,6 +249,22 @@
|
|
|
211
249
|
},
|
|
212
250
|
".pi-chat [hidden]": {
|
|
213
251
|
"display": "none"
|
|
252
|
+
},
|
|
253
|
+
".pi-chat :where([data-slot=\"empty\"])": {
|
|
254
|
+
"border-width": "0"
|
|
255
|
+
},
|
|
256
|
+
".pi-chat :where([data-slot=\"sidebar-menu\"], [data-slot=\"sidebar-menu-sub\"])": {
|
|
257
|
+
"margin": "0",
|
|
258
|
+
"padding": "0",
|
|
259
|
+
"list-style": "none"
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
"@layer utilities": {
|
|
263
|
+
"@media (max-width: 640px), (pointer: coarse)": {
|
|
264
|
+
".pi-chat :is([data-slot=\"button\"], [data-slot=\"toggle\"], [class~=\"group/button\"], [class~=\"group/toggle\"])": {
|
|
265
|
+
"min-height": "2.75rem",
|
|
266
|
+
"min-width": "2.75rem"
|
|
267
|
+
}
|
|
214
268
|
}
|
|
215
269
|
},
|
|
216
270
|
"@layer components": {
|
|
@@ -219,49 +273,14 @@
|
|
|
219
273
|
"--chat-warning": "var(--warning, var(--muted-foreground))",
|
|
220
274
|
"--chat-warning-soft": "var(--warning-soft, color-mix(in oklab, var(--chat-warning) 15%, var(--background)))",
|
|
221
275
|
"--chat-warning-soft-foreground": "var(--warning-soft-foreground, color-mix(in oklab, var(--chat-warning) 75%, var(--foreground)))",
|
|
222
|
-
"--chat-display": "var(--font-display, var(--font-sans))"
|
|
276
|
+
"--chat-display": "var(--font-display, var(--font-sans))",
|
|
277
|
+
"--chat-composer-radius": "26px",
|
|
278
|
+
"--chat-send-radius": "9999px",
|
|
279
|
+
"--chat-sheet-duration": "200ms"
|
|
223
280
|
},
|
|
224
281
|
".pi-chat:is(.dark, .dark *)": {
|
|
225
282
|
"--chat-warning-soft": "var(--warning-soft, color-mix(in oklab, var(--chat-warning) 26%, var(--background)))",
|
|
226
283
|
"--chat-warning-soft-foreground": "var(--warning-soft-foreground, color-mix(in oklab, var(--chat-warning) 55%, var(--foreground)))"
|
|
227
|
-
},
|
|
228
|
-
".pi-chat-shimmer": {
|
|
229
|
-
"background-image": "linear-gradient(100deg, var(--muted-foreground) 35%, var(--foreground) 50%, var(--muted-foreground) 65%)",
|
|
230
|
-
"background-size": "250% 100%",
|
|
231
|
-
"background-clip": "text",
|
|
232
|
-
"-webkit-background-clip": "text",
|
|
233
|
-
"color": "transparent",
|
|
234
|
-
"animation": "pi-chat-shimmer 2.2s linear infinite"
|
|
235
|
-
},
|
|
236
|
-
".pi-chat [data-testid=\"message-text\"][data-streaming=\"true\"] > :last-child:not([data-testid=\"code-block\"])::after": {
|
|
237
|
-
"content": "\"\"",
|
|
238
|
-
"display": "inline-block",
|
|
239
|
-
"width": "0.45em",
|
|
240
|
-
"height": "1.05em",
|
|
241
|
-
"margin-inline-start": "0.2em",
|
|
242
|
-
"vertical-align": "-0.15em",
|
|
243
|
-
"border-radius": "2px",
|
|
244
|
-
"background": "color-mix(in oklab, var(--foreground) 60%, transparent)",
|
|
245
|
-
"animation": "pi-chat-caret 1s steps(2, jump-none) infinite"
|
|
246
|
-
}
|
|
247
|
-
},
|
|
248
|
-
"@keyframes pi-chat-shimmer": {
|
|
249
|
-
"from": {
|
|
250
|
-
"background-position": "100% 0"
|
|
251
|
-
},
|
|
252
|
-
"to": {
|
|
253
|
-
"background-position": "0 0"
|
|
254
|
-
}
|
|
255
|
-
},
|
|
256
|
-
"@keyframes pi-chat-caret": {
|
|
257
|
-
"0%": {
|
|
258
|
-
"opacity": "1"
|
|
259
|
-
},
|
|
260
|
-
"50%": {
|
|
261
|
-
"opacity": "0.15"
|
|
262
|
-
},
|
|
263
|
-
"100%": {
|
|
264
|
-
"opacity": "1"
|
|
265
284
|
}
|
|
266
285
|
}
|
|
267
286
|
},
|