@hachej/boring-ui-kit 0.4.59 → 0.4.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +2 -2
  2. package/r/agent-target.json +4 -4
  3. package/r/app-shell.json +10 -10
  4. package/r/board-viewer.json +7 -7
  5. package/r/button.json +2 -2
  6. package/r/data-table.json +1 -1
  7. package/r/diagram-viewer.json +1 -1
  8. package/r/dock.json +1 -1
  9. package/r/documents.json +12 -12
  10. package/r/drawing-viewer.json +2 -2
  11. package/r/empty-state.json +2 -2
  12. package/r/feedback.json +7 -7
  13. package/r/file-tree.json +4 -4
  14. package/r/highlights.json +3 -3
  15. package/r/html-viewer.json +3 -3
  16. package/r/inline-question.json +22 -0
  17. package/r/kpi-stat.json +2 -2
  18. package/r/list-row.json +1 -1
  19. package/r/looks-viewer.json +3 -3
  20. package/r/maquette-viewer.json +3 -3
  21. package/r/markdown-editor.json +4 -4
  22. package/r/mockup-viewer.json +2 -2
  23. package/r/page-header.json +2 -2
  24. package/r/pi-ambient.json +9 -9
  25. package/r/pi-app-kinds.json +10 -10
  26. package/r/pi-app.json +11 -11
  27. package/r/pi-artifacts.json +3 -3
  28. package/r/pi-chat-approval.json +4 -4
  29. package/r/pi-chat-attachments.json +2 -2
  30. package/r/pi-chat-markdown.json +2 -2
  31. package/r/pi-chat-mentions.json +2 -2
  32. package/r/pi-chat-pickers.json +2 -2
  33. package/r/pi-chat-plugins.json +11 -11
  34. package/r/pi-chat-question.json +4 -3
  35. package/r/pi-chat-slash.json +3 -3
  36. package/r/pi-chat-subagent.json +2 -2
  37. package/r/pi-chat-voice.json +4 -4
  38. package/r/pi-chat.json +8 -8
  39. package/r/pi-workspace.json +3 -3
  40. package/r/placed-agent.json +5 -5
  41. package/r/provider-setup.json +2 -2
  42. package/r/record-viewer.json +1 -1
  43. package/r/registry.json +289 -205
  44. package/r/shared-chat.json +7 -7
  45. package/r/space-card.json +22 -0
  46. package/r/spaces.json +2 -2
  47. package/r/status-narrative.json +22 -0
  48. package/r/task-list-viewer.json +2 -2
  49. package/r/theme.json +3 -0
  50. package/r/version-history.json +2 -2
  51. package/r/viewer-code.json +3 -3
  52. package/r/viewer-frame.json +5 -5
  53. package/r/viewer-html.json +5 -5
  54. package/r/viewer-image.json +3 -3
  55. package/r/viewer-markdown.json +6 -6
  56. package/r/viewer-pdf.json +4 -4
  57. package/r/viewers.json +6 -6
  58. package/r/windows.json +2 -2
  59. package/r/wiring-chips.json +22 -0
@@ -4,18 +4,18 @@
4
4
  "title": "Pi chat approval card",
5
5
  "description": "The Approve / Deny card for host tools wrapped with `requireApproval`. Plug it in with `cards={{ approval: ApprovalCard }}`; without it pi-chat shows a \"waiting for your answer\" notice with no answer UI.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.59",
7
+ "@hachej/boring-ui-kit@^0.4.61",
8
8
  "react@^19.0.0",
9
9
  "@earendil-works/pi-ai@~1.4.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/_shared.json"
14
14
  ],
15
15
  "files": [
16
16
  {
17
17
  "path": "registry/pi-chat-approval/approval-card.tsx",
18
- "content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { CheckIcon, ShieldAlertIcon, XIcon } from '../_shared/icons';\nimport { waits, type ApprovalDecision } from '@hachej/boring-ui-kit/chat-view';\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 { ApprovalCardProps } from '../pi-chat/config';\nimport { useChatText } from '../pi-chat/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 decision, once there is one. The wrapper records it in the call's details as soon as it is made, before the call finishes; a call\n * that settled without one was never decided (the run was stopped while it waited), so it is not shown as approved.\n */\nfunction decisionOf(result: ToolResultMessage | undefined, decision: ApprovalDecision | undefined): ApprovalDecision | undefined {\n if (decision) return decision;\n if (!result) return undefined;\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\nexport type { ApprovalCardProps };\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 }: ApprovalCardProps) {\n const { labels } = useChatText();\n const [sent, setSent] = useState<string | undefined>();\n const [sending, setSending] = useState<string | undefined>();\n const [refusal, setRefusal] = useState<string | undefined>();\n const mounted = useRef(true);\n useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\n // `pending` is the kit's waiting rule (`waits`, as the chat's status line uses); `writing` means the model is still generating the call.\n const waiting = waits({ kind: 'approval', key: call.id, call, result, live, questionId, summary: summary ?? '', ...(decision ? { decision } : {}) });\n const state = decisionOf(result, decision) ?? (sent === APPROVE ? 'approved-sent' : sent === DENY ? 'denied-sent' : waiting ? 'pending' : 'writing');\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 {/* Decided, the gated call not finished yet: whoever decided, the run carries on by itself. */}\n {(approved || denied) && !result && <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",
18
+ "content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { CheckIcon, ShieldAlertIcon, XIcon } from '../_shared/icons';\nimport { waits, type ApprovalDecision } from '@hachej/boring-ui-kit/chat-view';\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 { ApprovalCardProps } from '../pi-chat/config';\nimport { useChatText } from '../pi-chat/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 decision, once there is one. The wrapper records it in the call's details as soon as it is made, before the call finishes; a call\n * that settled without one was never decided (the run was stopped while it waited), so it is not shown as approved.\n */\nfunction decisionOf(result: ToolResultMessage | undefined, decision: ApprovalDecision | undefined): ApprovalDecision | undefined {\n if (decision) return decision;\n if (!result) return undefined;\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\nexport type { ApprovalCardProps };\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 }: ApprovalCardProps) {\n const { labels } = useChatText();\n const [sent, setSent] = useState<string | undefined>();\n const [sending, setSending] = useState<string | undefined>();\n const [refusal, setRefusal] = useState<string | undefined>();\n const mounted = useRef(true);\n useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\n // `pending` is the kit's waiting rule (`waits`, as the chat's status line uses); `writing` means the model is still generating the call.\n const waiting = waits({ kind: 'approval', key: call.id, call, result, live, questionId, summary: summary ?? '', ...(decision ? { decision } : {}) });\n const state = decisionOf(result, decision) ?? (sent === APPROVE ? 'approved-sent' : sent === DENY ? 'denied-sent' : waiting ? 'pending' : 'writing');\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=\"muted\" 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 {/* Decided, the gated call not finished yet: whoever decided, the run carries on by itself. */}\n {(approved || denied) && !result && <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",
19
19
  "type": "registry:component",
20
20
  "target": "components/pi-chat-approval/approval-card.tsx"
21
21
  }
@@ -8,8 +8,8 @@
8
8
  "react-dom@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/_shared.json"
13
13
  ],
14
14
  "files": [
15
15
  {
@@ -4,13 +4,13 @@
4
4
  "title": "Pi chat Markdown",
5
5
  "description": "Markdown for the chat's assistant replies: safe token-by-token rendering (raw HTML shown as text, no images fetched, http(s) links only), code blocks, tables, task lists and /command buttons. Plug it in with `renderText={markdownText}`; without it pi-chat shows replies as plain text.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.59",
7
+ "@hachej/boring-ui-kit@^0.4.61",
8
8
  "react@^19.0.0",
9
9
  "marked@^17.0.6",
10
10
  "entities@^9.0.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat.json"
14
14
  ],
15
15
  "files": [
16
16
  {
@@ -7,8 +7,8 @@
7
7
  "react@^19.0.0"
8
8
  ],
9
9
  "registryDependencies": [
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/_shared.json"
12
12
  ],
13
13
  "files": [
14
14
  {
@@ -7,8 +7,8 @@
7
7
  "react@^19.0.0"
8
8
  ],
9
9
  "registryDependencies": [
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/_shared.json"
12
12
  ],
13
13
  "files": [
14
14
  {
@@ -7,17 +7,17 @@
7
7
  "react@^19.0.0"
8
8
  ],
9
9
  "registryDependencies": [
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-markdown.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-slash.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-mentions.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-pickers.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-voice.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-attachments.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-question.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-approval.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-subagent.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-artifacts.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat-markdown.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat-slash.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat-mentions.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat-pickers.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat-voice.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat-attachments.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat-question.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat-approval.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat-subagent.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-artifacts.json"
21
21
  ],
22
22
  "files": [
23
23
  {
@@ -8,13 +8,14 @@
8
8
  "@earendil-works/pi-ai@~1.4.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/inline-question.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/_shared.json"
13
14
  ],
14
15
  "files": [
15
16
  {
16
17
  "path": "registry/pi-chat-question/question-card.tsx",
17
- "content": "'use client';\n\nimport { useEffect, useId, useRef, useState } from 'react';\nimport type { FormEvent } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { CheckIcon, CircleSlashIcon, SendIcon } from '../_shared/icons';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Spinner } from '@/components/ui/spinner';\nimport { useChatText } from '../pi-chat/labels';\nimport { ASK_USER_TOOL } from '../pi-chat/config';\nimport type { AnswerOutcome, QuestionCardProps } from '../pi-chat/config';\n\n// The core owns the tool name and the props: it places the question row and shows its waiting notice without this item.\nexport { ASK_USER_TOOL };\nexport type { AnswerOutcome, QuestionCardProps };\n\n/** One question of a form: its text and its options (chips); every question of a form also takes free text. */\nexport interface FormQuestion { readonly question: string; readonly options: readonly string[] }\ninterface Question {\n readonly question: string; readonly options: readonly string[]; readonly allowFreeText: boolean;\n /** Several questions in one form (`questions`), answered together as one text. Empty for a single question. */\n readonly items: readonly FormQuestion[];\n}\n\nconst strings = (value: unknown, max: number) => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string' && item.length > 0).slice(0, max) : [];\n\n/**\n * Read `{ question, options?, allowFreeText?, questions? }` defensively: model arguments are untrusted input. Free text is allowed\n * unless `allowFreeText` is `false` (and always when there are no options). `questions` (up to 8) makes a form.\n */\nexport function parseQuestion(call: ToolCall): Question {\n const args = call.arguments as Record<string, unknown>;\n const options = strings(args['options'], 6);\n const items = Array.isArray(args['questions']) ? args['questions'].flatMap((item: unknown) => {\n const value = item !== null && typeof item === 'object' ? item as Record<string, unknown> : {};\n return typeof value['question'] === 'string' && value['question'] ? [{ question: value['question'], options: strings(value['options'], 6) }] : [];\n }).slice(0, 8) : [];\n return { question: typeof args['question'] === 'string' ? args['question'] : '', options, allowFreeText: args['allowFreeText'] !== false || options.length === 0, items };\n}\n\n/** The answers of a form as the one text the agent reads: each question, then its answer, or `skipped` when left blank. */\nexport function formAnswer(items: readonly FormQuestion[], values: readonly string[], skipped: string): string {\n return items.map((item, i) => `${i + 1}. ${item.question}\\n→ ${values[i]?.trim() || skipped}`).join('\\n\\n');\n}\n\n/** The answers of a `formAnswer` text, one per question; undefined when the text does not have that shape (then it is shown whole). */\nfunction splitFormAnswer(items: readonly FormQuestion[], text: string): readonly string[] | undefined {\n const values: string[] = [];\n let at = 0;\n for (let i = 0; i < items.length; i++) {\n const head = `${i + 1}. ${items[i]!.question}\\n→ `;\n if (!text.startsWith(head, at)) return undefined;\n at += head.length;\n const end = i + 1 < items.length ? text.indexOf(`\\n\\n${i + 2}. ${items[i + 1]!.question}\\n→ `, at) : text.length;\n if (end < 0) return undefined;\n values.push(text.slice(at, end));\n at = end + 2;\n }\n return values;\n}\n\n/** The answered text from a result such as `{\"kind\":\"answered\",\"answer\":\"...\"}`; undefined when the call did not produce an answer. */\nexport function parseAnswer(result: ToolResultMessage | undefined): { readonly answer: string } | { readonly failure: string } | undefined {\n if (!result) return undefined;\n // Pi appends its diagnostics to a result as a `<harness>` block of their own, for the model: never for the person.\n const text = result.content.map(part => part.type === 'text' && !/^<harness>[\\s\\S]*<\\/harness>$/.test(part.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 };\n}\n\n\n/**\n * Human-in-the-loop question: writing (still generated, not answerable), pending (options and free text), then answered with the chosen\n * answer. With `questions` it is one form: each question has its option chips and a free-text field, and Answer sends one text\n * (`formAnswer`). It needs only a tool call and an `answer` callback, so a host can show it outside the chat too (for example in a\n * popover answering a request of its own); wrap it in `ChatTextProvider` for the chat's words there.\n */\nexport function QuestionCard({ call, result, answer, questionId, live = false }: QuestionCardProps) {\n const { labels } = useChatText();\n const question = parseQuestion(call);\n const settled = parseAnswer(result);\n const [sent, setSent] = useState<string | undefined>();\n const [sending, setSending] = useState<string | undefined>();\n const [refusal, setRefusal] = useState<string | undefined>();\n const [text, setText] = useState('');\n const [values, setValues] = useState<readonly string[]>([]);\n const formId = useId();\n const setValue = (i: number, value: string) => setValues(previous => question.items.map((_, j) => j === i ? value : previous[j] ?? ''));\n const mounted = useRef(true);\n useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\n const current = settled && 'answer' in settled ? settled.answer : sent;\n // `pending` means the question waits on the person; `writing` means the model is still generating it (options may be partial).\n const state = settled ? ('failure' in settled ? 'failed' : 'answered') : sent !== undefined ? 'sent' : live ? 'writing' : 'pending';\n const open = state === 'pending' || state === 'writing';\n\n async function submit(value: string) {\n const trimmed = value.trim();\n if (!answer || !questionId || live || !trimmed || sending !== undefined) return;\n setRefusal(undefined); setSending(trimmed);\n try {\n const outcome = await answer(questionId, trimmed);\n if (outcome.kind === 'answered') setSent(trimmed);\n else setRefusal(outcome.reason || labels.answerRefused(outcome.kind));\n } catch (cause) { setRefusal(cause instanceof Error ? cause.message : labels.answerNotSent); }\n finally { if (mounted.current) setSending(undefined); }\n }\n const onSubmit = (event: FormEvent) => { event.preventDefault(); void submit(text); };\n const onSubmitForm = (event: FormEvent) => { event.preventDefault(); if (values.some(value => value.trim())) void submit(formAnswer(question.items, values, labels.skipped)); };\n const busy = sending !== undefined || live;\n\n const unavailable = !answer || !questionId || busy;\n // `m-0`: a host without a global preflight keeps the paragraph margin of ItemDescription.\n const wrap = 'm-0 line-clamp-none [overflow-wrap:anywhere]';\n\n if (!open) {\n const values = question.items.length > 0 && current !== undefined ? splitFormAnswer(question.items, current) : undefined;\n return <Item role=\"group\" data-testid=\"question-card\" data-state={state} data-call-id={call.id} aria-label={labels.question}>\n <ItemMedia variant=\"icon\">\n {state === 'answered' ? <CheckIcon role=\"img\" aria-label={labels.answered} /> : state === 'sent' ? <Spinner aria-label={labels.waitingForAgent} /> : <CircleSlashIcon role=\"img\" aria-label={labels.notAnswered} />}\n </ItemMedia>\n <ItemContent className=\"min-w-0\">\n {state === 'failed' && <ItemDescription data-testid=\"question-text\" className={wrap}>{question.question || labels.askedQuestion}</ItemDescription>}\n {values ? <div className=\"flex min-w-0 flex-col gap-2\">{question.items.map((item, i) => <div key={i} data-testid=\"question-answer-item\" className=\"flex min-w-0 flex-col gap-1\">\n <ItemDescription className={wrap}>{item.question}</ItemDescription>\n {values[i] === labels.skipped\n ? <ItemDescription data-testid=\"question-answer\" data-skipped=\"true\" className={wrap}>{values[i]}</ItemDescription>\n : <ItemTitle data-testid=\"question-answer\" className={wrap}>{values[i]}</ItemTitle>}\n </div>)}</div>\n : current !== undefined && <ItemTitle className={wrap}><span className=\"sr-only\">{labels.yourAnswer} </span><span data-testid=\"question-answer\" className=\"whitespace-pre-line\">{current}</span></ItemTitle>}\n {state === 'failed' && settled && 'failure' in settled && <ItemDescription role=\"alert\" className={wrap}>{settled.failure || labels.questionNotAnswered}</ItemDescription>}\n </ItemContent>\n </Item>;\n }\n\n const option = (value: string, selected: boolean, onClick: () => void) => <Button key={value} variant={selected ? 'secondary' : 'outline'} data-testid=\"question-option\" data-option={value}\n aria-pressed={question.items.length > 0 ? selected : undefined} disabled={unavailable} onClick={onClick} className=\"h-auto min-h-8 w-full justify-start whitespace-normal text-start\">\n {selected && <CheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}{value}\n </Button>;\n\n return <Card role=\"group\" data-testid=\"question-card\" data-state={state} data-call-id={call.id} aria-label={labels.question}>\n <CardHeader>\n <CardDescription>{labels.questionForYou}</CardDescription>\n <CardTitle data-testid=\"question-text\" className=\"[overflow-wrap:anywhere]\">{question.question || labels.askedQuestion}</CardTitle>\n </CardHeader>\n <CardContent className=\"flex flex-col gap-4\">\n {question.items.length > 0 ? <form data-testid=\"question-form\" onSubmit={onSubmitForm} className=\"flex flex-col gap-4\">\n {question.items.map((item, i) => <div key={i} role=\"group\" aria-labelledby={`${formId}-${i}`} data-testid=\"question-item\" className=\"flex min-w-0 flex-col gap-2\">\n <p id={`${formId}-${i}`} className=\"m-0 [overflow-wrap:anywhere]\">{i + 1}. {item.question}</p>\n {item.options.length > 0 && <div role=\"group\" aria-label={labels.answerOptions} className=\"flex flex-col gap-2\">\n {item.options.map(value => option(value, values[i] === value, () => setValue(i, values[i] === value ? '' : value)))}\n </div>}\n <Input data-testid=\"question-input\" value={item.options.includes(values[i] ?? '') ? '' : values[i] ?? ''} maxLength={2000} disabled={unavailable}\n placeholder={item.options.length ? labels.ownAnswer : labels.typeAnswer} aria-label={`${labels.yourAnswerInput} ${i + 1}`} onChange={event => setValue(i, event.currentTarget.value)} />\n </div>)}\n <div><Button type=\"submit\" data-testid=\"question-submit\" disabled={unavailable || !values.some(value => value.trim())}><SendIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.answer}</Button></div>\n </form> : <>\n {question.options.length > 0 && <div role=\"group\" aria-label={labels.answerOptions} className=\"flex flex-col gap-2\">\n {question.options.map(value => option(value, false, () => { void submit(value); }))}\n </div>}\n {question.allowFreeText && <form onSubmit={onSubmit} className=\"flex gap-2\">\n <Input data-testid=\"question-input\" value={text} maxLength={2000} disabled={unavailable} placeholder={question.options.length ? labels.ownAnswer : labels.typeAnswer}\n aria-label={labels.yourAnswerInput} onChange={event => setText(event.currentTarget.value)} />\n <Button type=\"submit\" data-testid=\"question-submit\" disabled={unavailable || !text.trim()}><SendIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.answer}</Button>\n </form>}\n </>}\n {live && <CardDescription>{labels.stillWritingQuestion}</CardDescription>}\n {!answer && !live && <CardDescription>{labels.answeringUnavailable}</CardDescription>}\n {refusal && <Alert variant=\"destructive\" data-testid=\"question-refusal\"><AlertDescription>{refusal}</AlertDescription></Alert>}\n </CardContent>\n </Card>;\n}\n",
18
+ "content": "'use client';\n\nimport { useEffect, useId, useRef, useState } from 'react';\nimport type { FormEvent } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { CheckIcon, CircleSlashIcon, SendIcon } from '../_shared/icons';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Spinner } from '@/components/ui/spinner';\nimport { InlineQuestion } from '../inline-question/inline-question';\nimport { useChatText } from '../pi-chat/labels';\nimport { ASK_USER_TOOL } from '../pi-chat/config';\nimport type { AnswerOutcome, QuestionCardProps } from '../pi-chat/config';\n\n// The core owns the tool name and the props: it places the question row and shows its waiting notice without this item.\nexport { ASK_USER_TOOL };\nexport type { AnswerOutcome, QuestionCardProps };\n\n/** One question of a form: its text and its options (chips); every question of a form also takes free text. */\nexport interface FormQuestion { readonly question: string; readonly options: readonly string[] }\ninterface Question {\n readonly question: string; readonly options: readonly string[]; readonly allowFreeText: boolean;\n /** Several questions in one form (`questions`), answered together as one text. Empty for a single question. */\n readonly items: readonly FormQuestion[];\n}\n\nconst strings = (value: unknown, max: number) => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string' && item.length > 0).slice(0, max) : [];\n\n/**\n * Read `{ question, options?, allowFreeText?, questions? }` defensively: model arguments are untrusted input. Free text is allowed\n * unless `allowFreeText` is `false` (and always when there are no options). `questions` (up to 8) makes a form.\n */\nexport function parseQuestion(call: ToolCall): Question {\n const args = call.arguments as Record<string, unknown>;\n const options = strings(args['options'], 6);\n const items = Array.isArray(args['questions']) ? args['questions'].flatMap((item: unknown) => {\n const value = item !== null && typeof item === 'object' ? item as Record<string, unknown> : {};\n return typeof value['question'] === 'string' && value['question'] ? [{ question: value['question'], options: strings(value['options'], 6) }] : [];\n }).slice(0, 8) : [];\n return { question: typeof args['question'] === 'string' ? args['question'] : '', options, allowFreeText: args['allowFreeText'] !== false || options.length === 0, items };\n}\n\n/** The answers of a form as the one text the agent reads: each question, then its answer, or `skipped` when left blank. */\nexport function formAnswer(items: readonly FormQuestion[], values: readonly string[], skipped: string): string {\n return items.map((item, i) => `${i + 1}. ${item.question}\\n→ ${values[i]?.trim() || skipped}`).join('\\n\\n');\n}\n\n/** The answers of a `formAnswer` text, one per question; undefined when the text does not have that shape (then it is shown whole). */\nfunction splitFormAnswer(items: readonly FormQuestion[], text: string): readonly string[] | undefined {\n const values: string[] = [];\n let at = 0;\n for (let i = 0; i < items.length; i++) {\n const head = `${i + 1}. ${items[i]!.question}\\n→ `;\n if (!text.startsWith(head, at)) return undefined;\n at += head.length;\n const end = i + 1 < items.length ? text.indexOf(`\\n\\n${i + 2}. ${items[i + 1]!.question}\\n→ `, at) : text.length;\n if (end < 0) return undefined;\n values.push(text.slice(at, end));\n at = end + 2;\n }\n return values;\n}\n\n/** The answered text from a result such as `{\"kind\":\"answered\",\"answer\":\"...\"}`; undefined when the call did not produce an answer. */\nexport function parseAnswer(result: ToolResultMessage | undefined): { readonly answer: string } | { readonly failure: string } | undefined {\n if (!result) return undefined;\n // Pi appends its diagnostics to a result as a `<harness>` block of their own, for the model: never for the person.\n const text = result.content.map(part => part.type === 'text' && !/^<harness>[\\s\\S]*<\\/harness>$/.test(part.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 };\n}\n\n\n/**\n * Human-in-the-loop question: writing (still generated, not answerable), pending (options and free text), then answered with the chosen\n * answer. With `questions` it is one form: each question has its option chips and a free-text field, and Answer sends one text\n * (`formAnswer`). It needs only a tool call and an `answer` callback, so a host can show it outside the chat too (for example in a\n * popover answering a request of its own); wrap it in `ChatTextProvider` for the chat's words there.\n */\nexport function QuestionCard({ call, result, answer, questionId, live = false }: QuestionCardProps) {\n const { labels } = useChatText();\n const question = parseQuestion(call);\n const settled = parseAnswer(result);\n const [sent, setSent] = useState<string | undefined>();\n const [sending, setSending] = useState<string | undefined>();\n const [refusal, setRefusal] = useState<string | undefined>();\n const [values, setValues] = useState<readonly string[]>([]);\n const formId = useId();\n const setValue = (i: number, value: string) => setValues(previous => question.items.map((_, j) => j === i ? value : previous[j] ?? ''));\n const mounted = useRef(true);\n useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\n const current = settled && 'answer' in settled ? settled.answer : sent;\n // `pending` means the question waits on the person; `writing` means the model is still generating it (options may be partial).\n const state = settled ? ('failure' in settled ? 'failed' : 'answered') : sent !== undefined ? 'sent' : live ? 'writing' : 'pending';\n const open = state === 'pending' || state === 'writing';\n\n async function submit(value: string) {\n const trimmed = value.trim();\n if (!answer || !questionId || live || !trimmed || sending !== undefined) return;\n setRefusal(undefined); setSending(trimmed);\n try {\n const outcome = await answer(questionId, trimmed);\n if (outcome.kind === 'answered') setSent(trimmed);\n else setRefusal(outcome.reason || labels.answerRefused(outcome.kind));\n } catch (cause) { setRefusal(cause instanceof Error ? cause.message : labels.answerNotSent); }\n finally { if (mounted.current) setSending(undefined); }\n }\n const onSubmitForm = (event: FormEvent) => { event.preventDefault(); if (values.some(value => value.trim())) void submit(formAnswer(question.items, values, labels.skipped)); };\n const busy = sending !== undefined || live;\n\n const unavailable = !answer || !questionId || busy;\n // `m-0`: a host without a global preflight keeps the paragraph margin of ItemDescription.\n const wrap = 'm-0 line-clamp-none [overflow-wrap:anywhere]';\n\n if (!open) {\n const values = question.items.length > 0 && current !== undefined ? splitFormAnswer(question.items, current) : undefined;\n return <Item role=\"group\" data-testid=\"question-card\" data-state={state} data-call-id={call.id} aria-label={labels.question}>\n <ItemMedia variant=\"icon\">\n {state === 'answered' ? <CheckIcon role=\"img\" aria-label={labels.answered} /> : state === 'sent' ? <Spinner aria-label={labels.waitingForAgent} /> : <CircleSlashIcon role=\"img\" aria-label={labels.notAnswered} />}\n </ItemMedia>\n <ItemContent className=\"min-w-0\">\n {state === 'failed' && <ItemDescription data-testid=\"question-text\" className={wrap}>{question.question || labels.askedQuestion}</ItemDescription>}\n {values ? <div className=\"flex min-w-0 flex-col gap-2\">{question.items.map((item, i) => <div key={i} data-testid=\"question-answer-item\" className=\"flex min-w-0 flex-col gap-1\">\n <ItemDescription className={wrap}>{item.question}</ItemDescription>\n {values[i] === labels.skipped\n ? <ItemDescription data-testid=\"question-answer\" data-skipped=\"true\" className={wrap}>{values[i]}</ItemDescription>\n : <ItemTitle data-testid=\"question-answer\" className={wrap}>{values[i]}</ItemTitle>}\n </div>)}</div>\n : current !== undefined && <ItemTitle className={wrap}><span className=\"sr-only\">{labels.yourAnswer} </span><span data-testid=\"question-answer\" className=\"whitespace-pre-line\">{current}</span></ItemTitle>}\n {state === 'failed' && settled && 'failure' in settled && <ItemDescription role=\"alert\" className={wrap}>{settled.failure || labels.questionNotAnswered}</ItemDescription>}\n </ItemContent>\n </Item>;\n }\n\n const option = (value: string, selected: boolean, onClick: () => void) => <Button key={value} variant={selected ? 'secondary' : 'outline'} data-testid=\"question-option\" data-option={value}\n aria-pressed={question.items.length > 0 ? selected : undefined} disabled={unavailable} onClick={onClick} className=\"h-auto min-h-8 w-full justify-start whitespace-normal text-start\">\n {selected && <CheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}{value}\n </Button>;\n\n // One waiting question is the shared InlineQuestion (one-tap choices, free text); the card keeps its test ids and state. A form (`questions`) stays below.\n const note = 'm-0 text-sm opacity-80';\n const hasNotice = live || !answer || !!refusal;\n const notice = hasNotice ? <>\n {live && <p className={note}>{labels.stillWritingQuestion}</p>}\n {!answer && !live && <p className={note}>{labels.answeringUnavailable}</p>}\n {refusal && <Alert variant=\"destructive\" data-testid=\"question-refusal\"><AlertDescription>{refusal}</AlertDescription></Alert>}\n </> : undefined;\n if (question.items.length === 0) return <div data-testid=\"question-card\" data-state={state} data-call-id={call.id} className=\"my-2 text-start\">\n <InlineQuestion className=\"bg-(--chat-warning-soft) text-(--chat-warning-soft-foreground)\" id={questionId ?? call.id} kind=\"question\" text={question.question || labels.askedQuestion} options={question.options} maxOptions={6} freeText={question.allowFreeText}\n disabled={unavailable} notice={notice} onAnswer={(_, value) => { void submit(value); }}\n labels={{ question: labels.questionForYou, send: labels.answer, placeholder: labels.typeAnswer, placeholderMore: labels.ownAnswer }} />\n </div>;\n // A form of several questions: the same flat warning tint as InlineQuestion.\n return <div role=\"group\" data-testid=\"question-card\" data-state={state} data-call-id={call.id} aria-label={labels.question} className=\"my-2 flex min-w-0 flex-col gap-3 rounded-(--radius) bg-(--chat-warning-soft) p-3 text-start text-(--chat-warning-soft-foreground)\">\n <div className=\"flex min-w-0 flex-col gap-1\">\n <p className=\"m-0 font-mono text-xs\">{labels.questionForYou}</p>\n <p data-testid=\"question-text\" className=\"m-0 text-sm font-medium [overflow-wrap:anywhere]\">{question.question || labels.askedQuestion}</p>\n </div>\n <form data-testid=\"question-form\" onSubmit={onSubmitForm} className=\"flex flex-col gap-4\">\n {question.items.map((item, i) => <div key={i} role=\"group\" aria-labelledby={`${formId}-${i}`} data-testid=\"question-item\" className=\"flex min-w-0 flex-col gap-2\">\n <p id={`${formId}-${i}`} className=\"m-0 [overflow-wrap:anywhere]\">{i + 1}. {item.question}</p>\n {item.options.length > 0 && <div role=\"group\" aria-label={labels.answerOptions} className=\"flex flex-col gap-2\">\n {item.options.map(value => option(value, values[i] === value, () => setValue(i, values[i] === value ? '' : value)))}\n </div>}\n <Input data-testid=\"question-input\" value={item.options.includes(values[i] ?? '') ? '' : values[i] ?? ''} maxLength={2000} disabled={unavailable}\n placeholder={item.options.length ? labels.ownAnswer : labels.typeAnswer} aria-label={`${labels.yourAnswerInput} ${i + 1}`} onChange={event => setValue(i, event.currentTarget.value)} />\n </div>)}\n <div><Button type=\"submit\" data-testid=\"question-submit\" disabled={unavailable || !values.some(value => value.trim())}><SendIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.answer}</Button></div>\n </form>\n {notice}\n </div>;\n}\n",
18
19
  "type": "registry:component",
19
20
  "target": "components/pi-chat-question/question-card.tsx"
20
21
  }
@@ -4,12 +4,12 @@
4
4
  "title": "Pi chat slash menu",
5
5
  "description": "The composer's `/` menu of commands and skills (and the Commands entry of the + menu). Plug it in with `slash={{ ...config, Picker: SlashPicker }}`; without it pi-chat has no `/` menu.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.59",
7
+ "@hachej/boring-ui-kit@^0.4.61",
8
8
  "react@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/_shared.json"
13
13
  ],
14
14
  "files": [
15
15
  {
@@ -8,8 +8,8 @@
8
8
  "@earendil-works/pi-ai@~1.4.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/_shared.json"
13
13
  ],
14
14
  "files": [
15
15
  {
@@ -4,13 +4,13 @@
4
4
  "title": "Pi chat voice messages",
5
5
  "description": "Tap-to-record voice messages: a compatibility export of the kit's chat-react/voice recorder hook, the composer's record button and the pending voice bubble, plus useHoldToRecord. Plug it in with `voiceMessage={{ ...config, ...voiceMessagePlugin }}`; without it pi-chat has no voice button.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.59",
7
+ "@hachej/boring-ui-kit@^0.4.61",
8
8
  "react@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/utils.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/utils.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/_shared.json"
14
14
  ],
15
15
  "files": [
16
16
  {
package/r/pi-chat.json CHANGED
@@ -4,8 +4,8 @@
4
4
  "title": "Pi chat",
5
5
  "description": "A complete Tailwind chat over the borrowed native chat controller: composer, messages, one collapsible activity block per turn (tool calls and reasoning), message queue, history and feedback cards for the opt-in feedback tool (lists, and Show offers that the host page resolves). Chat only: no layout. Owned, editable source; no AI SDK. The question, approval, sub-agent and artifact cards are separate items (pi-chat-question, pi-chat-approval, pi-chat-subagent, pi-artifacts); without them a question or an approval shows a \"waiting for your answer\" notice, a sub-agent call is an ordinary tool step and no artifact is offered. The optional pi-ambient and pi-workspace blocks build on it.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.59",
8
- "@hachej/boring-files@^0.4.59",
7
+ "@hachej/boring-ui-kit@^0.4.61",
8
+ "@hachej/boring-files@^0.4.61",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "@base-ui/react@1.9.0",
@@ -17,9 +17,9 @@
17
17
  "zod@^4.6.5"
18
18
  ],
19
19
  "registryDependencies": [
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/utils.json",
21
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/button.json",
22
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/utils.json",
21
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/button.json",
22
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/_shared.json"
23
23
  ],
24
24
  "files": [
25
25
  {
@@ -126,7 +126,7 @@
126
126
  },
127
127
  {
128
128
  "path": "registry/pi-chat/tool.tsx",
129
- "content": "'use client';\n\nimport { useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { ToolStatus, ToolEntry as ViewToolEntry } from '@hachej/boring-ui-kit/chat-view';\nimport { WrenchIcon } from '../_shared/icons';\nimport { TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceDescription, TraceIcon, TraceLine, TraceTitle } from '@/components/ui/trace';\nimport { Card, CardContent } from '@/components/ui/card';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Button } from '@/components/ui/button';\nimport { defaultChatLabels, useChatText } from './labels';\nimport type { ChatLabels } from './labels';\n\nexport type { ToolStatus } from '@hachej/boring-ui-kit/chat-view';\nexport type ToolEntry = ViewToolEntry<ReactNode>;\n\nconst STATUS_LABEL = { running: 'toolRunning', completed: 'toolCompleted', failed: 'toolFailedStatus', unfinished: 'toolUnfinished' } as const satisfies Record<ToolStatus, keyof ChatLabels>;\nconst PREVIEW_KEYS = ['path', 'file_path', 'filePath', 'command', 'query', 'pattern', 'name', 'url', 'id'];\n\n/** A short hint of what the call acts on, shown next to its name. */\nexport function preview(args: ToolCall['arguments']): string {\n const values = args as Record<string, unknown>;\n for (const key of PREVIEW_KEYS) { const value = values[key]; if (typeof value === 'string' && value) return value.replace(/\\s+/g, ' ').slice(0, 90); }\n const first = Object.values(values).find(value => typeof value === 'string' && value);\n return typeof first === 'string' ? first.replace(/\\s+/g, ' ').slice(0, 90) : '';\n}\n\nfunction Section({ title, children }: { readonly title: string; readonly children: ReactNode }) {\n return <div className=\"space-y-1.5\"><h4 className=\"m-0 text-xs font-medium tracking-wider text-muted-foreground uppercase\">{title}</h4>{children}</div>;\n}\n\n/** A short live label for a call: \"Reading notes/consultation.md\", \"Running bash\", \"Running working_git\" (`labels.toolActivity`). */\nexport function liveLabel(call: ToolCall, labels: Pick<ChatLabels, 'toolActivity'> = defaultChatLabels): string {\n return labels.toolActivity(call.name, preview(call.arguments));\n}\n\n/** One Trace step. Failures open by default; quiet successful steps have no disclosure. */\nexport function ToolCard({ entry, onOpenImage, details = true }: { readonly entry: ToolEntry; readonly details?: boolean; readonly onOpenImage?: (image: Extract<ToolResultMessage['content'][number], { type: 'image' }>) => void }) {\n const { labels } = useChatText();\n const [chosen, setChosen] = useState<boolean | undefined>();\n const { call, result, status } = entry;\n const open = details && (chosen ?? status === 'failed');\n const hint = preview(call.arguments);\n const line = <TraceLine {...(status === 'running' ? { status: 'running' } : status === 'failed' ? { status: 'error' } : {})}>\n <TraceIcon><WrenchIcon /></TraceIcon>\n <TraceTitle><span data-testid=\"tool-name\">{call.name}</span>{hint && ` · ${hint}`}</TraceTitle>\n <TraceDescription data-testid=\"tool-status\">{labels[STATUS_LABEL[status]]}</TraceDescription>\n </TraceLine>;\n return <TraceCollapsible data-testid=\"tool-card\" data-boring=\"tool-card\" data-status={status} data-tool={call.name} data-call-id={call.id} open={open} onOpenChange={setChosen}>\n {details ? <TraceCollapsibleTrigger className=\"min-h-8 max-md:min-h-11 pointer-coarse:min-h-11\">{line}</TraceCollapsibleTrigger> : line}\n {details && <TraceCollapsibleContent><div data-testid=\"tool-details\" className=\"flex flex-col gap-3\">\n <Section title={labels.arguments}><Card><CardContent><pre className=\"m-0 max-h-60 overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere]\">{JSON.stringify(call.arguments, null, 2)}</pre></CardContent></Card></Section>\n {result && <Section title={result.isError ? labels.error : labels.result}>\n <Alert variant={result.isError ? 'destructive' : 'default'} role={result.isError ? 'alert' : 'status'}><AlertDescription>\n {result.content.map((part, index) => part.type === 'text'\n ? <pre key={index} className=\"m-0 max-h-72 overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere]\">{part.text}</pre>\n : <Button key={index} variant=\"outline\" size=\"sm\" className=\"max-md:min-h-12! pointer-coarse:min-h-12!\" disabled={!onOpenImage} onClick={() => onOpenImage?.(part)}>{labels.openImageResult}</Button>)}\n {result.content.length === 0 && <span>{labels.emptyResult}</span>}\n </AlertDescription></Alert></Section>}\n </div></TraceCollapsibleContent>}\n </TraceCollapsible>;\n}\n\n",
129
+ "content": "'use client';\n\nimport { useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { ToolStatus, ToolEntry as ViewToolEntry } from '@hachej/boring-ui-kit/chat-view';\nimport { WrenchIcon } from '../_shared/icons';\nimport { TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceDescription, TraceIcon, TraceLine, TraceTitle } from '@/components/ui/trace';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Button } from '@/components/ui/button';\nimport { defaultChatLabels, useChatText } from './labels';\nimport type { ChatLabels } from './labels';\n\nexport type { ToolStatus } from '@hachej/boring-ui-kit/chat-view';\nexport type ToolEntry = ViewToolEntry<ReactNode>;\n\nconst STATUS_LABEL = { running: 'toolRunning', completed: 'toolCompleted', failed: 'toolFailedStatus', unfinished: 'toolUnfinished' } as const satisfies Record<ToolStatus, keyof ChatLabels>;\nconst PREVIEW_KEYS = ['path', 'file_path', 'filePath', 'command', 'query', 'pattern', 'name', 'url', 'id'];\n\n/** A short hint of what the call acts on, shown next to its name. */\nexport function preview(args: ToolCall['arguments']): string {\n const values = args as Record<string, unknown>;\n for (const key of PREVIEW_KEYS) { const value = values[key]; if (typeof value === 'string' && value) return value.replace(/\\s+/g, ' ').slice(0, 90); }\n const first = Object.values(values).find(value => typeof value === 'string' && value);\n return typeof first === 'string' ? first.replace(/\\s+/g, ' ').slice(0, 90) : '';\n}\n\nfunction Section({ title, children }: { readonly title: string; readonly children: ReactNode }) {\n return <div className=\"flex flex-col gap-1.5\"><h4 className=\"m-0 text-xs font-medium tracking-wider text-muted-foreground uppercase\">{title}</h4>{children}</div>;\n}\n\n/** A short live label for a call: \"Reading notes/consultation.md\", \"Running bash\", \"Running working_git\" (`labels.toolActivity`). */\nexport function liveLabel(call: ToolCall, labels: Pick<ChatLabels, 'toolActivity'> = defaultChatLabels): string {\n return labels.toolActivity(call.name, preview(call.arguments));\n}\n\n/** One Trace step. Failures open by default; quiet successful steps have no disclosure. */\nexport function ToolCard({ entry, onOpenImage, details = true }: { readonly entry: ToolEntry; readonly details?: boolean; readonly onOpenImage?: (image: Extract<ToolResultMessage['content'][number], { type: 'image' }>) => void }) {\n const { labels } = useChatText();\n const [chosen, setChosen] = useState<boolean | undefined>();\n const { call, result, status } = entry;\n const open = details && (chosen ?? status === 'failed');\n const hint = preview(call.arguments);\n const line = <TraceLine {...(status === 'running' ? { status: 'running' } : status === 'failed' ? { status: 'error' } : {})}>\n <TraceIcon><WrenchIcon /></TraceIcon>\n <TraceTitle><span data-testid=\"tool-name\" data-tool-id={call.name}>{labels.toolName(call.name)}</span>{hint && ` · ${hint}`}</TraceTitle>\n <TraceDescription data-testid=\"tool-status\">{labels[STATUS_LABEL[status]]}</TraceDescription>\n </TraceLine>;\n const resultBody = result && <>\n {result.content.map((part, index) => part.type === 'text'\n ? <pre key={index} className=\"m-0 max-h-72 overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere]\">{part.text}</pre>\n : <Button key={index} variant=\"outline\" size=\"sm\" className=\"max-md:min-h-12! pointer-coarse:min-h-12!\" disabled={!onOpenImage} onClick={() => onOpenImage?.(part)}>{labels.openImageResult}</Button>)}\n {result.content.length === 0 && <span>{labels.emptyResult}</span>}\n </>;\n return <TraceCollapsible data-testid=\"tool-card\" data-boring=\"tool-card\" data-status={status} data-tool={call.name} data-call-id={call.id} open={open} onOpenChange={setChosen}>\n {details ? <TraceCollapsibleTrigger className=\"min-h-8 max-md:min-h-11 pointer-coarse:min-h-11\">{line}</TraceCollapsibleTrigger> : line}\n {details && <TraceCollapsibleContent><div data-testid=\"tool-details\" className=\"flex flex-col gap-3\">\n <Section title={labels.arguments}><pre className=\"m-0 max-h-60 overflow-auto bg-muted p-2 whitespace-pre-wrap [overflow-wrap:anywhere]\">{JSON.stringify(call.arguments, null, 2)}</pre></Section>\n {result && <Section title={result.isError ? labels.error : labels.result}>\n {result.isError\n ? <Alert variant=\"destructive\" role=\"alert\"><AlertDescription>{resultBody}</AlertDescription></Alert>\n : <div role=\"status\" className=\"flex flex-col gap-2 bg-muted p-2\">{resultBody}</div>}\n </Section>}\n </div></TraceCollapsibleContent>}\n </TraceCollapsible>;\n}\n\n",
130
130
  "type": "registry:component",
131
131
  "target": "components/pi-chat/tool.tsx"
132
132
  },
@@ -144,13 +144,13 @@
144
144
  },
145
145
  {
146
146
  "path": "registry/pi-chat/labels.ts",
147
- "content": "/*\n * Every word the chat shows, and the icons it draws, as plain data. A host passes a partial `labels` (and `icons`) object to `PiChat`\n * (or `AmbientChat`, `AgentWorkspace`): what it leaves out keeps the default below. Strings that need a value are functions. No i18n\n * framework: an app that translates passes its own object per language.\n */\nimport { createContext, createElement, useContext, useMemo, useRef } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowDownIcon, ArrowUpIcon, CameraIcon, GitForkIcon, HistoryIcon, ListEndIcon, MessagesSquareIcon, PaperclipIcon, PlusIcon, RefreshCwIcon, SquareIcon } from '../_shared/icons';\nimport type { BlockIcon } from '../button/actions';\nimport { withDefaults } from '../utils/utils';\n\n// An Intl.DateTimeFormat costs far more than formatting with one; every bubble's clock reuses the same few.\nconst formats = new Map<string, Intl.DateTimeFormat>();\nfunction intlFormat(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {\n const key = `${locale}|${JSON.stringify(options)}`;\n let found = formats.get(key);\n if (!found) { found = new Intl.DateTimeFormat(locale, options); formats.set(key, found); }\n return found;\n}\n/** « Tue 22 Oct, 14:02 ». */\nexport function formatTimestamp(at: string, locale: string) {\n const date = new Date(at);\n if (!Number.isFinite(date.getTime())) return at;\n return `${intlFormat(locale, { weekday: 'short', day: 'numeric', month: 'short' }).format(date)}, ${intlFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(date)}`;\n}\n/** The time inside a bubble (« 14:02 », « 2:02 PM »). */\nexport function formatClock(at: string, locale: string) {\n const date = new Date(at);\n return Number.isFinite(date.getTime()) ? intlFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(date) : '';\n}\n\nconst TOOL_VERBS: Readonly<Record<string, string>> = { read: 'Reading', write: 'Writing', edit: 'Editing', ls: 'Listing', list_files: 'Listing', find: 'Searching', grep: 'Searching', load_skill: 'Loading skill', bash: 'Running bash' };\nconst TOOL_NOUNS: Readonly<Record<string, string>> = { bash: 'command', read: 'read', write: 'write', edit: 'edit', find: 'find', grep: 'search', ls: 'list' };\n\nconst EFFORT_NAMES: Readonly<Record<string, string>> = { off: 'Off', minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', max: 'Max' };\nconst EFFORT_DETAILS: Readonly<Record<string, string>> = { off: 'No extra reasoning', minimal: 'Barely any reasoning', low: 'Light reasoning', medium: 'Balanced reasoning', high: 'Deep reasoning', xhigh: 'Deeper reasoning', max: 'Maximum reasoning' };\n\nexport const defaultChatLabels = {\n // Header and transcript\n /** The chat's name: the header title, the region's name and the empty state's title. */\n title: 'Conversation',\n history: 'History',\n artifacts: 'Artifacts',\n search: 'Search',\n live: 'Live',\n connecting: 'Connecting…',\n reconnecting: 'Reconnecting…',\n loadFailed: 'Conversation could not be loaded.',\n disconnected: 'Disconnected. Last observed messages remain visible.',\n reconnect: 'Reconnect',\n requiredActions: 'Required conversation actions',\n messages: 'Messages',\n showEarlier: 'Show earlier messages',\n loading: 'Loading conversation…',\n emptyDescription: 'Start a conversation.',\n jumpToLatest: 'Jump to latest',\n agentWorking: 'Agent is working',\n waitingForAnswer: 'Waiting for your answer',\n retryPending: 'Native retry pending',\n compacting: 'Native context compaction in progress',\n /** The one status of `presentation=\"natural\"` while the agent works (any tool, retry or compaction), instead of the activity wording above. */\n preparing: 'Preparing…',\n /** In `presentation=\"natural\"`, replaces the controller's own reason when a send is blocked. */\n sendUnavailable: 'Sending is not available right now.',\n /** The \"…\" menu of host actions (header, replies). */\n moreActions: 'More actions',\n // Notices\n sendUnknown: 'Submission acknowledgement is unknown. The message is kept until you check or retry it.',\n checkSubmission: 'Check original submission',\n retrySame: 'Retry same request',\n stopRequested: 'Stop requested. Waiting for native confirmation.',\n stopUnconfirmed: 'Stop is unconfirmed. Work may still be running.',\n newChatFailed: \"The new conversation wasn't made. You are still in this one.\",\n newChatFailedTyped: \"The new conversation wasn't made, and what you typed wasn't kept. You are still in this one.\",\n steerRefused: 'The message could not be steered; it is back in the message box.',\n tooLate: 'Too late: that message has already started.',\n tooLateToRemove: 'Too late to remove: that message has already started.',\n queueFailed: 'The message could not be changed.',\n queueLocked: 'Queued messages cannot be changed here.',\n waitToSteer: 'Wait for the current send to finish, then steer.',\n steerFailed: 'The message could not be steered.',\n connectionFailed: 'Connection failed',\n actionFailedShort: 'Action failed',\n uploadFailedShort: 'Upload failed',\n changeRefused: 'The change was refused',\n dismissHint: 'click to dismiss',\n // Composer\n placeholder: 'Message the agent…',\n placeholderWorking: 'Queue a message…',\n messageInput: 'Message',\n send: 'Send',\n sendHint: 'Send (Enter)',\n stop: 'Stop',\n queue: 'Queue',\n add: 'Add',\n attachFiles: 'Attach files',\n takePhoto: 'Take a photo',\n cameraShoot: 'Take the photo',\n cameraClose: 'Close the camera',\n cameraUnavailable: 'The camera cannot open on this page.',\n cameraFallback: \"Use the phone's camera\",\n commands: 'Commands',\n annotate: 'Annotate',\n annotateHint: 'Annotate: click what you want to comment on (Esc to stop)',\n annotateNotes: (count: number) => `${count} ${count === 1 ? 'note' : 'notes'}`,\n annotateClear: 'Remove the notes',\n feedbackSent: (count: number) => `${count} ${count === 1 ? 'comment' : 'comments'} on the page`,\n remove: (name: string) => `Remove ${name}`,\n dismiss: (name: string) => `Dismiss ${name}`,\n uploading: (name: string) => `Uploading ${name}…`,\n uploadFailed: (name: string, error: string | undefined) => `${name}: ${error ?? 'upload failed'}`,\n preparingAttachments: 'Preparing attachments…',\n preparingAttachmentsShort: 'Preparing attachments',\n mentionedFiles: 'Mentioned files',\n uploads: 'Uploads',\n attachments: 'Attachments',\n /** The type line of a file card in a sent message. */\n fileKind: { document: 'Document', image: 'Image', pdf: 'PDF', sheet: 'Spreadsheet', slides: 'Presentation', audio: 'Audio', file: 'File' },\n openFile: (name: string) => `Open ${name}`,\n fileChange: { created: 'Created', edited: 'Edited' },\n /** The name of a pasted text file: the words and the time it was pasted. */\n pastedFileName: (stamp: string) => `Pasted text ${stamp}`,\n restorePaste: 'Put the pasted text back in the message',\n searchCommands: 'Search commands',\n filterBySource: 'Filter by source',\n allSources: 'All',\n noCommands: 'No matching commands.',\n /** A `/` menu filter chip: `skills`, `built-in` or a plugin's name. */\n slashGroup: (group: string) => group,\n skillBadge: 'skill',\n workspaceFiles: 'Workspace files',\n searchFailed: 'Search failed. Try again.',\n model: 'Model',\n effort: 'Effort',\n effortDefault: 'Default',\n modelAndEffort: 'Model and effort',\n modelSummary: (model: string | undefined, effort: string | undefined) => [model && `Model: ${model}`, effort && `Effort: ${effort}`].filter(Boolean).join(', '),\n /** A thinking level's name and short description in the picker. */\n effortName: (level: string) => EFFORT_NAMES[level] ?? level,\n effortDetail: (level: string): string | undefined => EFFORT_DETAILS[level],\n // Voice input (the microphone button beside the composer)\n voiceRecord: 'Record a message',\n voiceHold: 'Hold to record, release to send',\n voicePauseRecording: 'Pause recording',\n voiceResumeRecording: 'Resume recording',\n voicePaused: 'Recording paused',\n voiceRecording: 'Recording',\n voicePreparing: 'Preparing voice message…',\n voiceAudioOnly: 'Send audio only',\n voiceDiscard: 'Discard',\n voicePlay: 'Play voice message',\n voicePause: 'Pause voice message',\n voicePlayFailed: 'The audio could not be played. Try again.',\n voiceBlocked: 'Not sent. Review the chat notice before trying again.',\n voiceUnconfirmed: 'Message admission is unconfirmed. Use the chat notice to reconcile.',\n voiceStop: 'Stop recording',\n voiceCancel: 'Cancel recording',\n voiceTranscribing: 'Transcribing…',\n voiceNothingHeard: 'Nothing was heard. Try again.',\n voiceTranscribeFailed: 'The recording could not be transcribed. Try again.',\n voiceTranscriptUnavailable: 'Voice message — transcription unavailable.',\n voiceDenied: 'Microphone blocked. Allow it for this site in the browser, then press record again.',\n voiceNoMicrophone: 'No microphone found. Connect one, then press record again.',\n voiceBusy: 'The microphone is busy or could not be opened. Close other apps using it and try again.',\n voiceInsecure: 'Recording needs a secure page (https or localhost).',\n voiceUnsupported: 'This browser cannot record audio here.',\n voiceStartFailed: 'The microphone could not be started.',\n // Queue\n queuedMessages: 'Queued messages',\n steer: 'Steer',\n steerHint: 'Send this into the running turn now',\n steering: 'Steering',\n queued: 'Queued',\n removeQueued: 'Remove queued message',\n removeHint: 'Remove',\n more: 'More',\n edit: 'Edit',\n sending: 'Sending',\n /** Under a message whose send was not confirmed; the notice below offers Check and Retry. */\n notSent: 'Not sent',\n /** The time inside a bubble in the `messaging` style (« 14:02 »), and its full date as the tooltip. */\n messageClock: (at: string) => formatClock(at, 'en'),\n messageTimestamp: (at: string) => formatTimestamp(at, 'en'),\n images: (count: number) => `[${count} image${count === 1 ? '' : 's'}]`,\n // Conversation list and history\n historyDialog: 'Conversation history',\n newConversation: 'New',\n untitled: 'New conversation',\n closeHistory: 'Close history',\n close: 'Close',\n earlierInConversation: 'Earlier messages in this conversation',\n searchConversations: 'Search conversations',\n archived: 'Archived',\n current: 'Current',\n noMatch: (query: string) => `No conversation matches “${query}”.`,\n noArchived: 'No archived conversations.',\n noConversations: 'No conversations yet.',\n loadingConversations: 'Loading conversations…',\n actionFailed: 'That did not work. Try again.',\n rename: 'Rename',\n renameItem: (title: string) => `Rename ${title}`,\n conversationName: 'Conversation name',\n save: 'Save',\n archive: 'Archive',\n restore: 'Restore',\n archiveItem: (title: string, archived: boolean) => `${archived ? 'Restore' : 'Archive'} ${title}`,\n delete: 'Delete',\n deleteItem: (title: string) => `Delete ${title}`,\n keep: 'Keep conversation',\n today: 'Today',\n yesterday: 'Yesterday',\n previousWeek: 'Previous 7 days',\n older: 'Older',\n conversations: 'Conversations',\n earlierMessages: 'Earlier messages',\n readOnly: '(read-only)',\n retry: 'Retry',\n loadingShort: 'Loading…',\n loadEarlier: 'Load earlier',\n loadEarlierMessages: 'Load earlier messages',\n earlierMessagesLoaded: (count: number) => `${count} earlier messages loaded`,\n returnToConversation: 'Return to active conversation',\n historyFailed: 'Conversation history could not be loaded. The previous page remains visible.',\n loadPage: 'Load a page to browse earlier messages.',\n noRecords: 'No records in this history page.',\n beginning: 'Start of conversation',\n justNow: 'now',\n roleSystem: 'System',\n roleToolResult: (tool: string, failed: boolean) => `Tool result: ${tool}${failed ? ' (failed)' : ''}`,\n roleUser: 'You',\n roleAssistant: (outcome: 'ok' | 'interrupted' | 'failed'): string => outcome === 'interrupted' ? 'Assistant (interrupted)' : outcome === 'failed' ? 'Assistant (failed)' : 'Assistant',\n roleEvent: 'Event',\n contextBoundary: 'Context boundary',\n // Messages\n copy: 'Copy',\n copied: 'Copied',\n copyFailed: 'Copy failed',\n failed: 'Failed',\n copyResponse: 'Copy response',\n copyCode: 'Copy code',\n fork: 'Fork from here',\n forkHint: 'Fork from here: a new conversation with the messages up to this reply',\n responseFailed: 'The response failed',\n interrupted: 'Response interrupted',\n answeredBy: (model: string) => `answered by ${model}`,\n answeredFallback: (reason: string) => `fallback: ${reason}`,\n thinking: 'Thinking…',\n openImage: 'Open image attachment',\n imageAttachment: 'Image attachment',\n imagePosition: (index: number, count: number) => `Image ${index} of ${count}`,\n previousImage: 'Previous image',\n nextImage: 'Next image',\n systemContext: 'System context',\n toolFailed: (tool: string) => `${tool} failed`,\n event: (label: string) => `Event: ${label}`,\n // Activity and tools\n reasoning: 'Reasoning',\n thinkingStep: 'Thinking',\n steps: (count: number) => `${count} ${count === 1 ? 'step' : 'steps'}`,\n toolRunning: 'Running',\n toolCompleted: 'Completed',\n toolFailedStatus: 'Failed',\n toolUnfinished: 'No result',\n arguments: 'Arguments',\n result: 'Result',\n error: 'Error',\n openImageResult: 'Open image result',\n emptyResult: 'Empty result',\n /** The live line of a running tool call; `hint` is what it acts on (a path, a command), possibly empty. */\n toolActivity: (tool: string, hint: string) => {\n const verb = TOOL_VERBS[tool] ?? `Running ${tool}`;\n return hint ? `${verb}${tool === 'bash' ? ':' : ''} ${hint}` : verb;\n },\n /** How a tool is named in a finished activity summary (\"Used read ×3 · command\"). */\n toolNoun: (tool: string) => TOOL_NOUNS[tool] ?? tool,\n // Approval and question cards\n approvalNeeded: 'Approval needed',\n needsApproval: 'Needs your approval',\n approve: 'Approve',\n deny: 'Deny',\n approved: 'Approved',\n denied: 'Denied',\n notDecided: 'Not decided: the run was stopped',\n wantsToRun: 'The agent wants to run',\n decision: 'Decision',\n stillWritingCall: 'The agent is still writing this call…',\n decidingUnavailable: 'Deciding is not available in this view.',\n decisionSent: (approved: boolean) => `${approved ? 'Approved.' : 'Denied.'} Waiting for the agent…`,\n decisionRefused: (kind: 'denied' | 'conflict' | 'unknown-question'): string => kind === 'denied' ? 'The host did not accept this answer.' : kind === 'conflict' ? 'This call was already decided.' : 'This call is no longer waiting for a decision.',\n decisionNotSent: 'The decision could not be sent.',\n waitingForAgent: 'Waiting for the agent…',\n question: 'Question',\n questionForYou: 'Question for you',\n notAnswered: 'Not answered',\n answered: 'Answered',\n askedQuestion: 'The agent asked a question.',\n answerOptions: 'Answer options',\n ownAnswer: 'Or type your own answer',\n typeAnswer: 'Type your answer',\n yourAnswerInput: 'Your answer',\n /** A question of a form left blank, in the answer sent to the agent. */\n skipped: '(no answer)',\n answer: 'Answer',\n stillWritingQuestion: 'The agent is still writing this question…',\n answeringUnavailable: 'Answering is not available in this view.',\n answerRefused: (kind: 'denied' | 'conflict' | 'unknown-question'): string => kind === 'denied' ? 'The host did not accept this answer.' : kind === 'conflict' ? 'This question was already answered.' : 'This question is no longer waiting for an answer.',\n answerNotSent: 'The answer could not be sent.',\n questionNotAnswered: 'The question was not answered.',\n yourAnswer: 'Your answer:',\n // Run card (a headless run: `runs/<id>`)\n run: 'Run',\n runStatus: (status: 'queued' | 'running' | 'completed' | 'failed'): string => ({ queued: 'Queued', running: 'Running', completed: 'Completed', failed: 'Failed' })[status],\n runElapsed: (time: string) => `Running for ${time}`,\n runDuration: (time: string) => `Took ${time}`,\n runRefresh: 'Refresh',\n runNotLoaded: 'The run could not be loaded.',\n // Sub-agent card (a `delegate` call)\n subagentStatus: (status: 'running' | 'done' | 'failed' | 'cancelled'): string => ({ running: 'Running', done: 'Done', failed: 'Failed', cancelled: 'Stopped' })[status],\n subagentTime: 'Time',\n subagentToolCalls: (count: number) => `${count} tool ${count === 1 ? 'call' : 'calls'}`,\n subagentTokens: (input: number, output: number) => `${input} tokens in, ${output} tokens out`,\n subagentConversation: (id: number) => `Open conversation ${id}`,\n // Artifact cards\n artifact: 'Artifact',\n artifactTypes: { markdown: 'Document', html: 'HTML page', svg: 'SVG image', code: 'Code', canvas: 'Canvas' },\n opening: 'Opening…',\n viewing: 'Viewing',\n version: (ordinal: number) => `Version ${ordinal}`,\n runCommand: (name: string, execute: boolean) => `${execute ? 'Run' : 'Insert'} /${name} command`,\n taskStatus: 'Task status',\n activityFailed: (count: number, tools: string) => `${count > 1 ? `${count} steps failed` : 'Step failed'}: ${tools}`,\n activityUsed: (summary: string, stopped: boolean) => `${stopped ? 'Stopped · ' : 'Used '}${summary}`,\n activityThought: 'Thought it through',\n activityMany: (steps: number) => `Worked through ${steps} steps`,\n open: 'Open',\n openArtifact: (title: string, version: number | undefined) => version === undefined ? `Open ${title}` : `Open ${title}, version ${version}`,\n};\n\nexport type ChatLabels = typeof defaultChatLabels;\n\n/** The chat's icons. Any component that takes `className` (every lucide icon does). */\nexport const defaultChatIcons = {\n /** The agent's glyph in the empty state (the header shows the host's `avatar`, if any). */\n agent: MessagesSquareIcon as BlockIcon,\n send: ArrowUpIcon as BlockIcon,\n stop: SquareIcon as BlockIcon,\n history: HistoryIcon as BlockIcon,\n newChat: PlusIcon as BlockIcon,\n add: PlusIcon as BlockIcon,\n attach: PaperclipIcon as BlockIcon,\n camera: CameraIcon as BlockIcon,\n queue: ListEndIcon as BlockIcon,\n jumpToLatest: ArrowDownIcon as BlockIcon,\n reconnect: RefreshCwIcon as BlockIcon,\n fork: GitForkIcon as BlockIcon,\n};\n\nexport type ChatIcons = typeof defaultChatIcons;\n\n/** What the chat's pieces read: the merged labels and icons. */\nexport interface ChatText { readonly labels: ChatLabels; readonly icons: ChatIcons }\n/** The defaults, as `useMergedText`'s base. */\nexport const defaultChatText: ChatText = { labels: defaultChatLabels, icons: defaultChatIcons };\nconst DEFAULT_TEXT = defaultChatText;\nconst ChatTextContext = createContext<ChatText>(DEFAULT_TEXT);\n\n/** The labels and icons of the surrounding chat (the defaults outside one). */\nexport const useChatText = (): ChatText => useContext(ChatTextContext);\n\n/** Merges the host's partial labels and icons over the defaults (or over `base`), keeping one object while they do not change. */\nexport function useMergedText<L extends ChatLabels = ChatLabels, I extends ChatIcons = ChatIcons>(labels: Partial<L> | undefined, icons: Partial<I> | undefined,\n base: { readonly labels: L; readonly icons: I } = DEFAULT_TEXT as { readonly labels: L; readonly icons: I }): { readonly labels: L; readonly icons: I } {\n const stableLabels = useShallowStable(labels), stableIcons = useShallowStable(icons);\n return useMemo(() => ({ labels: withDefaults(base.labels, stableLabels), icons: withDefaults(base.icons, stableIcons) }), [stableLabels, stableIcons, base]);\n}\n\nconst shallowEqual = (a: unknown, b: unknown): boolean => Object.is(a, b) || (!!a && !!b && typeof a === 'object' && typeof b === 'object' && Array.isArray(a) === Array.isArray(b)\n && Object.keys(a).length === Object.keys(b).length && Object.keys(a).every(key => Object.is((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])));\n/** The previous value while the new one is equal key by key (functions by identity): inline `labels={{ title }}` keeps one text context. */\nexport function useShallowStable<T>(value: T): T {\n const previous = useRef(value);\n // eslint-disable-next-line react-hooks/refs -- a render-time identity cache: it only swaps in a value equal key by key, so any render reads the same result\n if (!shallowEqual(previous.current, value)) previous.current = value;\n // eslint-disable-next-line react-hooks/refs -- returns the cached identity (see above); never setState in render\n return previous.current;\n}\n\n/** Provides the chat's labels and icons to everything inside (composer, queue, rows, history). */\nexport function ChatTextProvider({ value, children }: { readonly value: ChatText; readonly children: ReactNode }) {\n return createElement(ChatTextContext.Provider, { value }, children);\n}\n",
147
+ "content": "/*\n * Every word the chat shows, and the icons it draws, as plain data. A host passes a partial `labels` (and `icons`) object to `PiChat`\n * (or `AmbientChat`, `AgentWorkspace`): what it leaves out keeps the default below. Strings that need a value are functions. No i18n\n * framework: an app that translates passes its own object per language.\n */\nimport { createContext, createElement, useContext, useMemo, useRef } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowDownIcon, ArrowUpIcon, CameraIcon, GitForkIcon, HistoryIcon, ListEndIcon, MessagesSquareIcon, PaperclipIcon, PlusIcon, RefreshCwIcon, SquareIcon } from '../_shared/icons';\nimport type { BlockIcon } from '../button/actions';\nimport { withDefaults } from '../utils/utils';\n\n// An Intl.DateTimeFormat costs far more than formatting with one; every bubble's clock reuses the same few.\nconst formats = new Map<string, Intl.DateTimeFormat>();\nfunction intlFormat(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {\n const key = `${locale}|${JSON.stringify(options)}`;\n let found = formats.get(key);\n if (!found) { found = new Intl.DateTimeFormat(locale, options); formats.set(key, found); }\n return found;\n}\n/** « Tue 22 Oct, 14:02 ». */\nexport function formatTimestamp(at: string, locale: string) {\n const date = new Date(at);\n if (!Number.isFinite(date.getTime())) return at;\n return `${intlFormat(locale, { weekday: 'short', day: 'numeric', month: 'short' }).format(date)}, ${intlFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(date)}`;\n}\n/** The time inside a bubble (« 14:02 », « 2:02 PM »). */\nexport function formatClock(at: string, locale: string) {\n const date = new Date(at);\n return Number.isFinite(date.getTime()) ? intlFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(date) : '';\n}\n\nconst TOOL_VERBS: Readonly<Record<string, string>> = { read: 'Reading', write: 'Writing', edit: 'Editing', ls: 'Listing', list_files: 'Listing', find: 'Searching', grep: 'Searching', load_skill: 'Loading skill', bash: 'Running bash' };\nconst TOOL_NOUNS: Readonly<Record<string, string>> = { bash: 'command', read: 'read', write: 'write', edit: 'edit', find: 'find', grep: 'search', ls: 'list' };\n/** A built-in tool's name as a person reads it. Any other tool falls back to `humanToolName`. */\nconst TOOL_NAMES: Readonly<Record<string, string>> = { bash: 'Shell', read: 'Read', write: 'Write', edit: 'Edit', ls: 'List', list_files: 'List files', find: 'Find', grep: 'Search', load_skill: 'Load skill' };\n\n/** A tool id as a person reads it: `piece_save` is « Piece save », `web-fetch` is « Web fetch ». Never a raw id on screen. */\nexport function humanToolName(id: string): string {\n const words = id.replace(/[_\\-.]+/g, ' ').trim().toLowerCase();\n return words ? words.charAt(0).toUpperCase() + words.slice(1) : id;\n}\n/** The name of a tool: the built-in name, or the humanised id. `labels.toolName` returns this by default. */\nexport const defaultToolName = (tool: string): string => TOOL_NAMES[tool] ?? humanToolName(tool);\n\nconst EFFORT_NAMES: Readonly<Record<string, string>> = { off: 'Off', minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', max: 'Max' };\nconst EFFORT_DETAILS: Readonly<Record<string, string>> = { off: 'No extra reasoning', minimal: 'Barely any reasoning', low: 'Light reasoning', medium: 'Balanced reasoning', high: 'Deep reasoning', xhigh: 'Deeper reasoning', max: 'Maximum reasoning' };\n\nexport const defaultChatLabels = {\n // Header and transcript\n /** The chat's name: the header title, the region's name and the empty state's title. */\n title: 'Conversation',\n history: 'History',\n artifacts: 'Artifacts',\n search: 'Search',\n live: 'Live',\n connecting: 'Connecting…',\n reconnecting: 'Reconnecting…',\n loadFailed: 'Conversation could not be loaded.',\n disconnected: 'Disconnected. Last observed messages remain visible.',\n reconnect: 'Reconnect',\n requiredActions: 'Required conversation actions',\n messages: 'Messages',\n showEarlier: 'Show earlier messages',\n loading: 'Loading conversation…',\n emptyDescription: 'Start a conversation.',\n jumpToLatest: 'Jump to latest',\n agentWorking: 'Agent is working',\n waitingForAnswer: 'Waiting for your answer',\n retryPending: 'Native retry pending',\n compacting: 'Native context compaction in progress',\n /** The one status of `presentation=\"natural\"` while the agent works (any tool, retry or compaction), instead of the activity wording above. */\n preparing: 'Preparing…',\n /** In `presentation=\"natural\"`, replaces the controller's own reason when a send is blocked. */\n sendUnavailable: 'Sending is not available right now.',\n /** The \"…\" menu of host actions (header, replies). */\n moreActions: 'More actions',\n // Notices\n sendUnknown: 'Submission acknowledgement is unknown. The message is kept until you check or retry it.',\n checkSubmission: 'Check original submission',\n retrySame: 'Retry same request',\n stopRequested: 'Stop requested. Waiting for native confirmation.',\n stopUnconfirmed: 'Stop is unconfirmed. Work may still be running.',\n newChatFailed: \"The new conversation wasn't made. You are still in this one.\",\n newChatFailedTyped: \"The new conversation wasn't made, and what you typed wasn't kept. You are still in this one.\",\n steerRefused: 'The message could not be steered; it is back in the message box.',\n tooLate: 'Too late: that message has already started.',\n tooLateToRemove: 'Too late to remove: that message has already started.',\n queueFailed: 'The message could not be changed.',\n queueLocked: 'Queued messages cannot be changed here.',\n waitToSteer: 'Wait for the current send to finish, then steer.',\n steerFailed: 'The message could not be steered.',\n connectionFailed: 'Connection failed',\n actionFailedShort: 'Action failed',\n uploadFailedShort: 'Upload failed',\n changeRefused: 'The change was refused',\n dismissHint: 'click to dismiss',\n // Composer\n placeholder: 'Message the agent…',\n placeholderWorking: 'Queue a message…',\n messageInput: 'Message',\n send: 'Send',\n sendHint: 'Send (Enter)',\n stop: 'Stop',\n queue: 'Queue',\n add: 'Add',\n attachFiles: 'Attach files',\n takePhoto: 'Take a photo',\n cameraShoot: 'Take the photo',\n cameraClose: 'Close the camera',\n cameraUnavailable: 'The camera cannot open on this page.',\n cameraFallback: \"Use the phone's camera\",\n commands: 'Commands',\n annotate: 'Annotate',\n annotateHint: 'Annotate: click what you want to comment on (Esc to stop)',\n annotateNotes: (count: number) => `${count} ${count === 1 ? 'note' : 'notes'}`,\n annotateClear: 'Remove the notes',\n feedbackSent: (count: number) => `${count} ${count === 1 ? 'comment' : 'comments'} on the page`,\n remove: (name: string) => `Remove ${name}`,\n dismiss: (name: string) => `Dismiss ${name}`,\n uploading: (name: string) => `Uploading ${name}…`,\n uploadFailed: (name: string, error: string | undefined) => `${name}: ${error ?? 'upload failed'}`,\n preparingAttachments: 'Preparing attachments…',\n preparingAttachmentsShort: 'Preparing attachments',\n mentionedFiles: 'Mentioned files',\n uploads: 'Uploads',\n attachments: 'Attachments',\n /** The type line of a file card in a sent message. */\n fileKind: { document: 'Document', image: 'Image', pdf: 'PDF', sheet: 'Spreadsheet', slides: 'Presentation', audio: 'Audio', file: 'File' },\n openFile: (name: string) => `Open ${name}`,\n fileChange: { created: 'Created', edited: 'Edited' },\n /** The name of a pasted text file: the words and the time it was pasted. */\n pastedFileName: (stamp: string) => `Pasted text ${stamp}`,\n restorePaste: 'Put the pasted text back in the message',\n searchCommands: 'Search commands',\n filterBySource: 'Filter by source',\n allSources: 'All',\n noCommands: 'No matching commands.',\n /** A `/` menu filter chip: `skills`, `built-in` or a plugin's name. */\n slashGroup: (group: string) => group,\n skillBadge: 'skill',\n workspaceFiles: 'Workspace files',\n searchFailed: 'Search failed. Try again.',\n model: 'Model',\n effort: 'Effort',\n effortDefault: 'Default',\n modelAndEffort: 'Model and effort',\n modelSummary: (model: string | undefined, effort: string | undefined) => [model && `Model: ${model}`, effort && `Effort: ${effort}`].filter(Boolean).join(', '),\n /** A thinking level's name and short description in the picker. */\n effortName: (level: string) => EFFORT_NAMES[level] ?? level,\n effortDetail: (level: string): string | undefined => EFFORT_DETAILS[level],\n // Voice input (the microphone button beside the composer)\n voiceRecord: 'Record a message',\n voiceHold: 'Hold to record, release to send',\n voicePauseRecording: 'Pause recording',\n voiceResumeRecording: 'Resume recording',\n voicePaused: 'Recording paused',\n voiceRecording: 'Recording',\n voicePreparing: 'Preparing voice message…',\n voiceAudioOnly: 'Send audio only',\n voiceDiscard: 'Discard',\n voicePlay: 'Play voice message',\n voicePause: 'Pause voice message',\n voicePlayFailed: 'The audio could not be played. Try again.',\n voiceBlocked: 'Not sent. Review the chat notice before trying again.',\n voiceUnconfirmed: 'Message admission is unconfirmed. Use the chat notice to reconcile.',\n voiceStop: 'Stop recording',\n voiceCancel: 'Cancel recording',\n voiceTranscribing: 'Transcribing…',\n voiceNothingHeard: 'Nothing was heard. Try again.',\n voiceTranscribeFailed: 'The recording could not be transcribed. Try again.',\n voiceTranscriptUnavailable: 'Voice message — transcription unavailable.',\n voiceDenied: 'Microphone blocked. Allow it for this site in the browser, then press record again.',\n voiceNoMicrophone: 'No microphone found. Connect one, then press record again.',\n voiceBusy: 'The microphone is busy or could not be opened. Close other apps using it and try again.',\n voiceInsecure: 'Recording needs a secure page (https or localhost).',\n voiceUnsupported: 'This browser cannot record audio here.',\n voiceStartFailed: 'The microphone could not be started.',\n // Queue\n queuedMessages: 'Queued messages',\n steer: 'Steer',\n steerHint: 'Send this into the running turn now',\n steering: 'Steering',\n queued: 'Queued',\n removeQueued: 'Remove queued message',\n removeHint: 'Remove',\n more: 'More',\n edit: 'Edit',\n sending: 'Sending',\n /** Under a message whose send was not confirmed; the notice below offers Check and Retry. */\n notSent: 'Not sent',\n /** The time inside a bubble in the `messaging` style (« 14:02 »), and its full date as the tooltip. */\n messageClock: (at: string) => formatClock(at, 'en'),\n messageTimestamp: (at: string) => formatTimestamp(at, 'en'),\n images: (count: number) => `[${count} image${count === 1 ? '' : 's'}]`,\n // Conversation list and history\n historyDialog: 'Conversation history',\n newConversation: 'New',\n untitled: 'New conversation',\n closeHistory: 'Close history',\n close: 'Close',\n earlierInConversation: 'Earlier messages in this conversation',\n searchConversations: 'Search conversations',\n archived: 'Archived',\n current: 'Current',\n noMatch: (query: string) => `No conversation matches “${query}”.`,\n noArchived: 'No archived conversations.',\n noConversations: 'No conversations yet.',\n loadingConversations: 'Loading conversations…',\n actionFailed: 'That did not work. Try again.',\n rename: 'Rename',\n renameItem: (title: string) => `Rename ${title}`,\n conversationName: 'Conversation name',\n save: 'Save',\n archive: 'Archive',\n restore: 'Restore',\n archiveItem: (title: string, archived: boolean) => `${archived ? 'Restore' : 'Archive'} ${title}`,\n delete: 'Delete',\n deleteItem: (title: string) => `Delete ${title}`,\n keep: 'Keep conversation',\n today: 'Today',\n yesterday: 'Yesterday',\n previousWeek: 'Previous 7 days',\n older: 'Older',\n conversations: 'Conversations',\n earlierMessages: 'Earlier messages',\n readOnly: '(read-only)',\n retry: 'Retry',\n loadingShort: 'Loading…',\n loadEarlier: 'Load earlier',\n loadEarlierMessages: 'Load earlier messages',\n earlierMessagesLoaded: (count: number) => `${count} earlier messages loaded`,\n returnToConversation: 'Return to active conversation',\n historyFailed: 'Conversation history could not be loaded. The previous page remains visible.',\n loadPage: 'Load a page to browse earlier messages.',\n noRecords: 'No records in this history page.',\n beginning: 'Start of conversation',\n justNow: 'now',\n roleSystem: 'System',\n roleToolResult: (tool: string, failed: boolean) => `Tool result: ${tool}${failed ? ' (failed)' : ''}`,\n roleUser: 'You',\n roleAssistant: (outcome: 'ok' | 'interrupted' | 'failed'): string => outcome === 'interrupted' ? 'Assistant (interrupted)' : outcome === 'failed' ? 'Assistant (failed)' : 'Assistant',\n roleEvent: 'Event',\n contextBoundary: 'Context boundary',\n // Messages\n copy: 'Copy',\n copied: 'Copied',\n copyFailed: 'Copy failed',\n failed: 'Failed',\n copyResponse: 'Copy response',\n copyCode: 'Copy code',\n fork: 'Fork from here',\n forkHint: 'Fork from here: a new conversation with the messages up to this reply',\n responseFailed: 'The response failed',\n interrupted: 'Response interrupted',\n answeredBy: (model: string) => `answered by ${model}`,\n answeredFallback: (reason: string) => `fallback: ${reason}`,\n thinking: 'Thinking…',\n openImage: 'Open image attachment',\n imageAttachment: 'Image attachment',\n imagePosition: (index: number, count: number) => `Image ${index} of ${count}`,\n previousImage: 'Previous image',\n nextImage: 'Next image',\n systemContext: 'System context',\n toolFailed: (tool: string) => `${defaultToolName(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 ${humanToolName(tool).toLowerCase()}`;\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] ?? humanToolName(tool).toLowerCase(),\n /** The name a tool call shows (the card's title). Override it per tool id; anything left out is `humanToolName`. */\n toolName: (tool: string) => defaultToolName(tool),\n // Approval and question cards\n approvalNeeded: 'Approval needed',\n needsApproval: 'Needs your approval',\n approve: 'Approve',\n deny: 'Deny',\n approved: 'Approved',\n denied: 'Denied',\n notDecided: 'Not decided: the run was stopped',\n wantsToRun: 'The agent wants to run',\n decision: 'Decision',\n stillWritingCall: 'The agent is still writing this call…',\n decidingUnavailable: 'Deciding is not available in this view.',\n decisionSent: (approved: boolean) => `${approved ? 'Approved.' : 'Denied.'} Waiting for the agent…`,\n decisionRefused: (kind: 'denied' | 'conflict' | 'unknown-question'): string => kind === 'denied' ? 'The host did not accept this answer.' : kind === 'conflict' ? 'This call was already decided.' : 'This call is no longer waiting for a decision.',\n decisionNotSent: 'The decision could not be sent.',\n waitingForAgent: 'Waiting for the agent…',\n question: 'Question',\n questionForYou: 'Question for you',\n notAnswered: 'Not answered',\n answered: 'Answered',\n askedQuestion: 'The agent asked a question.',\n answerOptions: 'Answer options',\n ownAnswer: 'Or type your own answer',\n typeAnswer: 'Type your answer',\n yourAnswerInput: 'Your answer',\n /** A question of a form left blank, in the answer sent to the agent. */\n skipped: '(no answer)',\n answer: 'Answer',\n stillWritingQuestion: 'The agent is still writing this question…',\n answeringUnavailable: 'Answering is not available in this view.',\n answerRefused: (kind: 'denied' | 'conflict' | 'unknown-question'): string => kind === 'denied' ? 'The host did not accept this answer.' : kind === 'conflict' ? 'This question was already answered.' : 'This question is no longer waiting for an answer.',\n answerNotSent: 'The answer could not be sent.',\n questionNotAnswered: 'The question was not answered.',\n yourAnswer: 'Your answer:',\n // Run card (a headless run: `runs/<id>`)\n run: 'Run',\n runStatus: (status: 'queued' | 'running' | 'completed' | 'failed'): string => ({ queued: 'Queued', running: 'Running', completed: 'Completed', failed: 'Failed' })[status],\n runElapsed: (time: string) => `Running for ${time}`,\n runDuration: (time: string) => `Took ${time}`,\n runRefresh: 'Refresh',\n runNotLoaded: 'The run could not be loaded.',\n // Sub-agent card (a `delegate` call)\n subagentStatus: (status: 'running' | 'done' | 'failed' | 'cancelled'): string => ({ running: 'Running', done: 'Done', failed: 'Failed', cancelled: 'Stopped' })[status],\n subagentTime: 'Time',\n subagentToolCalls: (count: number) => `${count} tool ${count === 1 ? 'call' : 'calls'}`,\n subagentTokens: (input: number, output: number) => `${input} tokens in, ${output} tokens out`,\n subagentConversation: (id: number) => `Open conversation ${id}`,\n // Artifact cards\n artifact: 'Artifact',\n artifactTypes: { markdown: 'Document', html: 'HTML page', svg: 'SVG image', code: 'Code', canvas: 'Canvas' },\n opening: 'Opening…',\n viewing: 'Viewing',\n version: (ordinal: number) => `Version ${ordinal}`,\n runCommand: (name: string, execute: boolean) => `${execute ? 'Run' : 'Insert'} /${name} command`,\n taskStatus: 'Task status',\n activityFailed: (count: number, tools: string) => `${count > 1 ? `${count} steps failed` : 'Step failed'}: ${tools}`,\n activityUsed: (summary: string, stopped: boolean) => `${stopped ? 'Stopped · ' : 'Used '}${summary}`,\n activityThought: 'Thought it through',\n activityMany: (steps: number) => `Worked through ${steps} steps`,\n open: 'Open',\n openArtifact: (title: string, version: number | undefined) => version === undefined ? `Open ${title}` : `Open ${title}, version ${version}`,\n};\n\nexport type ChatLabels = typeof defaultChatLabels;\n\n/** The chat's icons. Any component that takes `className` (every lucide icon does). */\nexport const defaultChatIcons = {\n /** The agent's glyph in the empty state (the header shows the host's `avatar`, if any). */\n agent: MessagesSquareIcon as BlockIcon,\n send: ArrowUpIcon as BlockIcon,\n stop: SquareIcon as BlockIcon,\n history: HistoryIcon as BlockIcon,\n newChat: PlusIcon as BlockIcon,\n add: PlusIcon as BlockIcon,\n attach: PaperclipIcon as BlockIcon,\n camera: CameraIcon as BlockIcon,\n queue: ListEndIcon as BlockIcon,\n jumpToLatest: ArrowDownIcon as BlockIcon,\n reconnect: RefreshCwIcon as BlockIcon,\n fork: GitForkIcon as BlockIcon,\n};\n\nexport type ChatIcons = typeof defaultChatIcons;\n\n/** What the chat's pieces read: the merged labels and icons. */\nexport interface ChatText { readonly labels: ChatLabels; readonly icons: ChatIcons }\n/** The defaults, as `useMergedText`'s base. */\nexport const defaultChatText: ChatText = { labels: defaultChatLabels, icons: defaultChatIcons };\nconst DEFAULT_TEXT = defaultChatText;\nconst ChatTextContext = createContext<ChatText>(DEFAULT_TEXT);\n\n/** The labels and icons of the surrounding chat (the defaults outside one). */\nexport const useChatText = (): ChatText => useContext(ChatTextContext);\n\n/** Merges the host's partial labels and icons over the defaults (or over `base`), keeping one object while they do not change. */\nexport function useMergedText<L extends ChatLabels = ChatLabels, I extends ChatIcons = ChatIcons>(labels: Partial<L> | undefined, icons: Partial<I> | undefined,\n base: { readonly labels: L; readonly icons: I } = DEFAULT_TEXT as { readonly labels: L; readonly icons: I }): { readonly labels: L; readonly icons: I } {\n const stableLabels = useShallowStable(labels), stableIcons = useShallowStable(icons);\n return useMemo(() => ({ labels: withDefaults(base.labels, stableLabels), icons: withDefaults(base.icons, stableIcons) }), [stableLabels, stableIcons, base]);\n}\n\nconst shallowEqual = (a: unknown, b: unknown): boolean => Object.is(a, b) || (!!a && !!b && typeof a === 'object' && typeof b === 'object' && Array.isArray(a) === Array.isArray(b)\n && Object.keys(a).length === Object.keys(b).length && Object.keys(a).every(key => Object.is((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])));\n/** The previous value while the new one is equal key by key (functions by identity): inline `labels={{ title }}` keeps one text context. */\nexport function useShallowStable<T>(value: T): T {\n const previous = useRef(value);\n // eslint-disable-next-line react-hooks/refs -- a render-time identity cache: it only swaps in a value equal key by key, so any render reads the same result\n if (!shallowEqual(previous.current, value)) previous.current = value;\n // eslint-disable-next-line react-hooks/refs -- returns the cached identity (see above); never setState in render\n return previous.current;\n}\n\n/** Provides the chat's labels and icons to everything inside (composer, queue, rows, history). */\nexport function ChatTextProvider({ value, children }: { readonly value: ChatText; readonly children: ReactNode }) {\n return createElement(ChatTextContext.Provider, { value }, children);\n}\n",
148
148
  "type": "registry:lib",
149
149
  "target": "components/pi-chat/labels.ts"
150
150
  },
151
151
  {
152
152
  "path": "registry/pi-chat/labels-fr.ts",
153
- "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 { formatClock, formatTimestamp } from './labels';\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 preparing: 'En préparation…',\n sendUnavailable: 'L’envoi n’est pas disponible pour le moment.',\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 newChatFailed: \"La nouvelle conversation n’a pas été créée. Vous êtes toujours dans celle-ci.\",\n newChatFailedTyped: \"La nouvelle conversation n’a pas été créée et ce que vous avez tapé n’a pas été gardé. Vous êtes toujours dans celle-ci.\",\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 takePhoto: 'Prendre une photo',\n cameraShoot: 'Prendre la photo',\n cameraClose: 'Fermer l’appareil photo',\n cameraUnavailable: 'L’appareil photo ne peut pas s’ouvrir sur cette page.',\n cameraFallback: 'Utiliser l’appareil photo du téléphone',\n // Voice input (the microphone button beside the composer)\n voiceRecord: 'Enregistrer un message',\n voiceHold: 'Maintenir pour enregistrer, relâcher pour envoyer',\n voicePauseRecording: 'Mettre l’enregistrement en pause',\n voiceResumeRecording: 'Reprendre l’enregistrement',\n voicePaused: 'Enregistrement en pause',\n voiceRecording: 'Enregistrement',\n voicePreparing: 'Préparation du message vocal…',\n voiceAudioOnly: 'Envoyer l’audio seul',\n voiceDiscard: 'Abandonner',\n voicePlay: 'Lire',\n voicePause: 'Mettre en pause',\n voicePlayFailed: 'La lecture de l’audio a échoué. Réessayez.',\n voiceBlocked: 'Non envoyé. Vérifiez l’avis de la conversation avant de réessayer.',\n voiceUnconfirmed: 'L’envoi n’est pas confirmé. Vérifiez l’avis de la conversation.',\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 voiceTranscriptUnavailable: 'Message vocal — transcription indisponible.',\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 feedbackSent: count => `${count} ${count === 1 ? 'commentaire' : 'commentaires'} sur la page`,\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', audio: 'Audio', 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é. Réessayez.',\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 notSent: 'Non envoyé',\n messageClock: at => formatClock(at, 'fr'),\n messageTimestamp: at => formatTimestamp(at, 'fr'),\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 answeredBy: model => `répondu par ${model}`,\n answeredFallback: reason => `repli : ${reason}`,\n thinking: 'Réflexion…',\n openImage: 'Ouvrir l’image jointe',\n imageAttachment: 'Image jointe',\n imagePosition: (index, count) => `Image ${index} sur ${count}`,\n previousImage: 'Image précédente',\n nextImage: 'Image suivante',\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 run: 'Exécution',\n runStatus: status => ({ queued: 'En attente', running: 'En cours', completed: 'Terminée', failed: 'Échouée' })[status],\n runElapsed: time => `En cours depuis ${time}`,\n runDuration: time => `Durée : ${time}`,\n runRefresh: 'Actualiser',\n runNotLoaded: 'L’exécution n’a pas pu être chargée.',\n subagentStatus: status => ({ running: 'En cours', done: 'Terminé', failed: 'Échoué', cancelled: 'Arrêté' })[status],\n subagentTime: 'Durée',\n subagentToolCalls: count => `${count} ${count === 1 ? 'appel' : 'appels'} d’outil`,\n subagentTokens: (input, output) => `${input} jetons en entrée, ${output} jetons en sortie`,\n subagentConversation: id => `Ouvrir la conversation ${id}`,\n skipped: '(pas de 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 questionNotAnswered: 'La question n’a pas reçu de réponse.',\n yourAnswer: 'Votre réponse :',\n artifact: 'Document',\n artifactTypes: { markdown: 'Document', html: 'Page HTML', svg: 'Image SVG', code: 'Code', canvas: 'Canevas' },\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",
153
+ "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 { formatClock, formatTimestamp, humanToolName } from './labels';\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 TOOL_NAMES: Readonly<Record<string, string>> = { bash: 'Commande shell', read: 'Lecture', write: 'Écriture', edit: 'Modification', ls: 'Liste', list_files: 'Liste des fichiers', find: 'Recherche', grep: 'Recherche', load_skill: 'Chargement de la compétence' };\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 preparing: 'En préparation…',\n sendUnavailable: 'L’envoi n’est pas disponible pour le moment.',\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 newChatFailed: \"La nouvelle conversation n’a pas été créée. Vous êtes toujours dans celle-ci.\",\n newChatFailedTyped: \"La nouvelle conversation n’a pas été créée et ce que vous avez tapé n’a pas été gardé. Vous êtes toujours dans celle-ci.\",\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 takePhoto: 'Prendre une photo',\n cameraShoot: 'Prendre la photo',\n cameraClose: 'Fermer l’appareil photo',\n cameraUnavailable: 'L’appareil photo ne peut pas s’ouvrir sur cette page.',\n cameraFallback: 'Utiliser l’appareil photo du téléphone',\n // Voice input (the microphone button beside the composer)\n voiceRecord: 'Enregistrer un message',\n voiceHold: 'Maintenir pour enregistrer, relâcher pour envoyer',\n voicePauseRecording: 'Mettre l’enregistrement en pause',\n voiceResumeRecording: 'Reprendre l’enregistrement',\n voicePaused: 'Enregistrement en pause',\n voiceRecording: 'Enregistrement',\n voicePreparing: 'Préparation du message vocal…',\n voiceAudioOnly: 'Envoyer l’audio seul',\n voiceDiscard: 'Abandonner',\n voicePlay: 'Lire',\n voicePause: 'Mettre en pause',\n voicePlayFailed: 'La lecture de l’audio a échoué. Réessayez.',\n voiceBlocked: 'Non envoyé. Vérifiez l’avis de la conversation avant de réessayer.',\n voiceUnconfirmed: 'L’envoi n’est pas confirmé. Vérifiez l’avis de la conversation.',\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 voiceTranscriptUnavailable: 'Message vocal — transcription indisponible.',\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 feedbackSent: count => `${count} ${count === 1 ? 'commentaire' : 'commentaires'} sur la page`,\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', audio: 'Audio', 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é. Réessayez.',\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 notSent: 'Non envoyé',\n messageClock: at => formatClock(at, 'fr'),\n messageTimestamp: at => formatTimestamp(at, 'fr'),\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 answeredBy: model => `répondu par ${model}`,\n answeredFallback: reason => `repli : ${reason}`,\n thinking: 'Réflexion…',\n openImage: 'Ouvrir l’image jointe',\n imageAttachment: 'Image jointe',\n imagePosition: (index, count) => `Image ${index} sur ${count}`,\n previousImage: 'Image précédente',\n nextImage: 'Image suivante',\n systemContext: 'Contexte système',\n toolFailed: tool => `Échec de ${TOOL_NAMES[tool] ?? humanToolName(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 ${humanToolName(tool).toLowerCase()}`;\n return hint ? `${verb}${tool === 'bash' ? ' :' : ''} ${hint}` : verb;\n },\n toolNoun: tool => TOOL_NOUNS[tool] ?? humanToolName(tool).toLowerCase(),\n toolName: tool => TOOL_NAMES[tool] ?? humanToolName(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 run: 'Exécution',\n runStatus: status => ({ queued: 'En attente', running: 'En cours', completed: 'Terminée', failed: 'Échouée' })[status],\n runElapsed: time => `En cours depuis ${time}`,\n runDuration: time => `Durée : ${time}`,\n runRefresh: 'Actualiser',\n runNotLoaded: 'L’exécution n’a pas pu être chargée.',\n subagentStatus: status => ({ running: 'En cours', done: 'Terminé', failed: 'Échoué', cancelled: 'Arrêté' })[status],\n subagentTime: 'Durée',\n subagentToolCalls: count => `${count} ${count === 1 ? 'appel' : 'appels'} d’outil`,\n subagentTokens: (input, output) => `${input} jetons en entrée, ${output} jetons en sortie`,\n subagentConversation: id => `Ouvrir la conversation ${id}`,\n skipped: '(pas de 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 questionNotAnswered: 'La question n’a pas reçu de réponse.',\n yourAnswer: 'Votre réponse :',\n artifact: 'Document',\n artifactTypes: { markdown: 'Document', html: 'Page HTML', svg: 'Image SVG', code: 'Code', canvas: 'Canevas' },\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",
154
154
  "type": "registry:lib",
155
155
  "target": "components/pi-chat/labels-fr.ts"
156
156
  },
@@ -8,9 +8,9 @@
8
8
  "react-dom@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/utils.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/dock.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/utils.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/dock.json"
14
14
  ],
15
15
  "files": [
16
16
  {
@@ -8,11 +8,11 @@
8
8
  "react-dom@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-app.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-ambient.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/app-shell.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/utils.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-app.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/pi-ambient.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/app-shell.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/utils.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.61/r/_shared.json"
16
16
  ],
17
17
  "files": [
18
18
  {