@hachej/boring-ui-kit 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/boring-ui-blocks.mjs +286 -0
- package/dist/markdown-editor.js +2 -4
- package/dist/remote-chat.d.ts +18 -0
- package/dist/remote-chat.js +144 -6
- package/package.json +6 -2
- package/r/app-shell.json +10 -10
- package/r/button.json +1 -1
- package/r/dock.json +1 -1
- package/r/documents.json +10 -10
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +5 -5
- package/r/file-tree.json +5 -5
- package/r/html-viewer.json +3 -3
- package/r/maquette-viewer.json +90 -0
- package/r/markdown-editor.json +3 -3
- package/r/pi-ambient.json +7 -7
- package/r/pi-app.json +23 -19
- package/r/pi-chat.json +25 -13
- package/r/pi-workspace.json +4 -4
- package/r/provider-setup.json +2 -2
- package/r/registry.json +185 -81
- package/r/task-list-viewer.json +2 -2
- package/r/utils.json +9 -2
- package/r/version-history.json +3 -3
- package/r/viewers.json +11 -11
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "maquette-viewer",
|
|
4
|
+
"title": "Maquette viewer",
|
|
5
|
+
"description": "MaquetteViewer and maquetteKind: a PM agent's *.maquette.json mock-up (format v1, versioned) one screen at a time, drawn from the host's shadcn components, under one thin icon bar: Back, previous and next (also arrow keys and swipe), and a menu with guided tours and How it works (the workflow diagram in a dialog). Optional phone or desktop frame and #screen=<id> deep links. The pi-app artifact panel shows .maquette.json files with it by default; app artifactKinds win.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"react@^19.0.0",
|
|
8
|
+
"lucide-react@^1.51.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/theme.json",
|
|
12
|
+
"alert",
|
|
13
|
+
"badge",
|
|
14
|
+
"bubble",
|
|
15
|
+
"button",
|
|
16
|
+
"card",
|
|
17
|
+
"empty",
|
|
18
|
+
"input",
|
|
19
|
+
"item",
|
|
20
|
+
"label",
|
|
21
|
+
"native-select",
|
|
22
|
+
"separator",
|
|
23
|
+
"table",
|
|
24
|
+
"dialog",
|
|
25
|
+
"dropdown-menu",
|
|
26
|
+
"popover"
|
|
27
|
+
],
|
|
28
|
+
"files": [
|
|
29
|
+
{
|
|
30
|
+
"path": "registry/maquette-viewer/maquette.ts",
|
|
31
|
+
"content": "/*\n * The maquette format: a lo-fi mock-up a PM agent writes as `<name>.maquette.json` (screens built from a small catalogue of blocks, goto\n * links between them, an optional workflow and its cases). Plain data drawn by `MaquetteViewer`: nothing in it is built or runs.\n * The format is documented in registry/README.md (\"Maquette viewer\"). Model output is untrusted: `parseMaquette` reads it defensively,\n * keeps what it understands, bounds every list and string, and never throws.\n */\n\n/** The format version this viewer writes and reads. A file without `version` is read as version 1. */\nexport const MAQUETTE_FORMAT_VERSION = 1;\n\n/** A block as written in format v1. `type` picks the drawing; the other fields are read by the types that use them. */\nexport interface MaquetteBlockV1 {\n readonly type: string;\n /** The block's words (`text` and `title` are accepted as aliases). */\n readonly label?: string;\n readonly text?: string;\n readonly title?: string;\n /** The screen id a click on this block opens (buttons, links, list items, placeholders). */\n readonly goto?: string;\n /** A primary button. */\n readonly primary?: boolean;\n /** Nested blocks of `card`, `row`, `stack` (`blocks` is accepted as an alias). */\n readonly children?: readonly MaquetteBlockV1[];\n readonly blocks?: readonly MaquetteBlockV1[];\n /** `list` rows, or `select` options. */\n readonly items?: readonly string[];\n readonly options?: readonly string[];\n /** `table` header and rows. */\n readonly columns?: readonly string[];\n readonly rows?: readonly (readonly string[])[];\n /** `chat` messages; `from: \"user\"` (or `person`, `me`, `moi`) is the person's side. */\n readonly messages?: readonly { readonly from?: string; readonly text?: string }[];\n /** `document` lines. */\n readonly lines?: readonly string[];\n /** `input`, `select` and `chat` hint and value. */\n readonly placeholder?: string;\n readonly value?: string;\n /** A placeholder's note under its name. */\n readonly note?: string;\n}\n\n/** A maquette file as written in format v1. */\nexport interface MaquetteFileV1 {\n /** Absent means 1. */\n readonly version?: 1;\n readonly title?: string;\n /** The screen id play mode opens first; the first screen otherwise. */\n readonly start?: string;\n /** The frame screens are drawn in: a light phone or desktop frame. Absent: the panel's width. */\n readonly device?: 'phone' | 'desktop';\n /** Screens by id, in the order the stepper walks them. `note` says why the screen exists and what the person does there. */\n readonly screens: Readonly<Record<string, { readonly title?: string; readonly note?: string; readonly blocks: readonly MaquetteBlockV1[] }>>;\n /** The app's navigation bar, shown above every screen. */\n readonly nav?: readonly { readonly label: string; readonly goto: string }[];\n /** The workflow understood: states and the transitions between them (`on` is accepted as an alias of `label`). */\n readonly workflow?: {\n readonly states: readonly ({ readonly id: string; readonly label?: string } | string)[];\n readonly transitions: readonly { readonly from: string; readonly to: string; readonly label?: string; readonly on?: string; readonly by?: string }[];\n };\n /**\n * Cases. `steps` makes a guided tour: its screens in order, each with a caption (\"Camille receives the invoice and approves it\").\n * `path` (workflow state ids) highlights the case in the workflow diagram. A case may carry both.\n */\n readonly cases?: readonly { readonly name: string; readonly steps?: readonly { readonly screen: string; readonly caption?: string }[]; readonly path?: readonly string[] }[];\n}\n\n/** A block as the viewer draws it: every field read, bounded and defaulted. */\nexport interface MaquetteBlock {\n readonly type: string;\n readonly label: string;\n readonly goto: string | undefined;\n readonly primary: boolean;\n readonly children: readonly MaquetteBlock[];\n readonly items: readonly string[];\n readonly columns: readonly string[];\n readonly rows: readonly (readonly string[])[];\n readonly messages: readonly { readonly mine: boolean; readonly text: string }[];\n readonly lines: readonly string[];\n readonly placeholder: string;\n readonly value: string;\n readonly note: string;\n}\nexport interface MaquetteScreen { readonly id: string; readonly title: string; readonly note: string; readonly blocks: readonly MaquetteBlock[] }\nexport interface MaquetteState { readonly id: string; readonly label: string }\nexport interface MaquetteTransition { readonly from: string; readonly to: string; readonly label: string; readonly by: string }\nexport interface MaquetteWorkflow { readonly states: readonly MaquetteState[]; readonly transitions: readonly MaquetteTransition[] }\nexport interface MaquetteCase { readonly name: string; readonly steps: readonly { readonly screen: string; readonly caption: string }[]; readonly path: readonly string[] }\n/** A maquette ready to draw. */\nexport interface Maquette {\n readonly title: string;\n readonly start: string;\n readonly device: 'phone' | 'desktop' | undefined;\n readonly screens: readonly MaquetteScreen[];\n readonly nav: readonly { readonly label: string; readonly goto: string }[];\n readonly workflow: MaquetteWorkflow | null;\n readonly cases: readonly MaquetteCase[];\n}\n\n/**\n * The outcome of reading a file. `version` is the file's declared version (1 when absent); a version above `MAQUETTE_FORMAT_VERSION`\n * is read as v1, best effort. `invalid` is not JSON (`json`) or has no screen to show (`shape`).\n */\nexport type MaquetteParse =\n | { readonly kind: 'ok'; readonly maquette: Maquette; readonly version: number }\n | { readonly kind: 'invalid'; readonly reason: 'json' | 'shape'; readonly version: number | undefined };\n\nconst MINE = new Set(['user', 'person', 'me', 'moi']);\nconst str = (value: unknown, max = 400): string => typeof value === 'string' ? value.slice(0, max) : typeof value === 'number' && Number.isFinite(value) ? String(value) : '';\nconst arr = (value: unknown, max = 60): readonly unknown[] => Array.isArray(value) ? value.slice(0, max) : [];\nconst obj = (value: unknown): Record<string, unknown> => value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};\nconst strings = (value: unknown, max: number, length = 120): string[] => arr(value, max).map(item => str(item, length));\n\nfunction block(value: unknown, depth: number): MaquetteBlock | null {\n const raw = obj(value);\n const type = str(raw['type'], 40).trim().toLowerCase();\n if (!type || depth > 6) return null;\n const goto = str(raw['goto'], 60);\n return {\n type, label: str(raw['label'] ?? raw['text'] ?? raw['title']), goto: goto || undefined, primary: raw['primary'] === true,\n children: arr(raw['children'] ?? raw['blocks'], 40).map(child => block(child, depth + 1)).filter((child): child is MaquetteBlock => child !== null),\n items: strings(raw['items'] ?? raw['options'], 12),\n columns: strings(raw['columns'], 8, 40),\n rows: arr(raw['rows'], 12).map(row => strings(row, 8, 80)),\n messages: arr(raw['messages'], 10).map(item => { const message = obj(item); return { mine: MINE.has(str(message['from'], 20).toLowerCase()), text: str(message['text'], 600) }; }).filter(message => message.text),\n lines: strings(raw['lines'], 16, 300),\n placeholder: str(raw['placeholder'], 120), value: str(raw['value'], 120), note: str(raw['note'], 200),\n };\n}\n\n/** Reads a maquette (its JSON text or an already parsed value). Never throws. */\nexport function parseMaquette(input: unknown): MaquetteParse {\n let raw: unknown = input;\n if (typeof raw === 'string') {\n try { raw = JSON.parse(raw); } catch { return { kind: 'invalid', reason: 'json', version: undefined }; }\n }\n const spec = obj(raw);\n const declared = spec['version'];\n const version = declared === undefined ? 1 : typeof declared === 'number' && Number.isSafeInteger(declared) && declared >= 1 ? declared : undefined;\n if (version === undefined) return { kind: 'invalid', reason: 'shape', version: undefined };\n const screens = Object.entries(obj(spec['screens'])).slice(0, 30).map(([id, value]): MaquetteScreen => {\n const screen = obj(value);\n return { id: id.slice(0, 60), title: str(screen['title'], 120) || id.slice(0, 60), note: str(screen['note'], 300),\n blocks: arr(screen['blocks'] ?? screen['children']).map(item => block(item, 0)).filter((item): item is MaquetteBlock => item !== null) };\n });\n if (!screens.length) return { kind: 'invalid', reason: 'shape', version };\n const ids = new Set(screens.map(screen => screen.id));\n const flow = obj(spec['workflow']);\n const states = arr(flow['states'], 40).map((value): MaquetteState | null => {\n const state = obj(value), id = str(state['id'], 60) || str(value, 60);\n return id ? { id, label: str(state['label'], 80) || id } : null;\n }).filter((state): state is MaquetteState => state !== null);\n const known = new Set(states.map(state => state.id));\n const transitions = arr(flow['transitions'], 80).map((value): MaquetteTransition => {\n const transition = obj(value);\n return { from: str(transition['from'], 60), to: str(transition['to'], 60), label: str(transition['label'] ?? transition['on'], 80), by: str(transition['by'], 40) };\n }).filter(transition => known.has(transition.from) && known.has(transition.to));\n const start = str(spec['start'], 60), device = spec['device'];\n return { kind: 'ok', version, maquette: {\n title: str(spec['title'], 120), start: ids.has(start) ? start : screens[0]!.id, device: device === 'phone' || device === 'desktop' ? device : undefined, screens,\n nav: arr(spec['nav'], 8).map(value => { const item = obj(value); return { label: str(item['label'], 40), goto: str(item['goto'], 60) }; }).filter(item => item.label && ids.has(item.goto)),\n workflow: states.length ? { states, transitions } : null,\n cases: arr(spec['cases'], 10).map((value): MaquetteCase => {\n const item = obj(value);\n const steps = arr(item['steps'], 20).map(step => { const raw = obj(step); return { screen: str(raw['screen'], 60), caption: str(raw['caption'], 300) }; }).filter(step => ids.has(step.screen));\n return { name: str(item['name'], 80), steps, path: strings(item['path'], 20, 60).filter(id => known.has(id)) };\n }).filter(item => item.name && (item.steps.length > 0 || item.path.length > 0)),\n } };\n}\n\n/** Every screen id a block tree links to, in order, without repeats. */\nexport function linksOf(blocks: readonly MaquetteBlock[]): string[] {\n const found = new Set<string>();\n const walk = (list: readonly MaquetteBlock[]) => { for (const item of list) { if (item.goto) found.add(item.goto); walk(item.children); } };\n walk(blocks);\n return [...found];\n}\n\n/** True for a path the built-in maquette kind draws: a `.maquette.json` file. */\nexport const isMaquettePath = (path: string): boolean => /\\.maquette\\.json$/i.test(path);\n\n/** The screen id a URL fragment names (`#screen=<id>`, alone or among other `&`-separated keys), or undefined. */\nexport function screenFromFragment(fragment: string | undefined): string | undefined {\n if (!fragment) return undefined;\n try { return new URLSearchParams(fragment.replace(/^#/, '')).get('screen') ?? undefined; } catch { return undefined; }\n}\n",
|
|
32
|
+
"type": "registry:lib",
|
|
33
|
+
"target": "components/maquette-viewer/maquette.ts"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"path": "registry/maquette-viewer/maquette-labels.ts",
|
|
37
|
+
"content": "/* The maquette viewer's words in English and French. `locale` picks the base set; a host's `labels` still override any of them. */\n\nexport type MaquetteLocale = 'en' | 'fr';\n\nexport const defaultMaquetteLabels = {\n /** The kind's name on the artifact card and in the panel bar. */\n kind: 'Mock-up',\n screens: 'Screens',\n previous: 'Previous screen',\n next: 'Next screen',\n back: 'Back',\n more: 'More',\n appNavigation: 'App navigation',\n howItWorks: 'How it works',\n endTour: 'End tour',\n aboutScreen: 'About this screen',\n opens: (title: string) => `Opens “${title}”`,\n tourStep: (index: number, count: number) => `Step ${index} of ${count}`,\n transitionBy: (by: string) => `by ${by}`,\n unknownBlock: 'Not drawn in this mock-up',\n showing: (title: string) => `Showing ${title}`,\n newerTitle: 'Made with a newer version',\n newer: (version: number) => `This mock-up uses format version ${version}. This viewer reads version 1: parts it does not know may be missing.`,\n errorTitle: 'This mock-up cannot be shown',\n invalidJson: 'The file is not valid JSON. Ask the PM to present it again.',\n invalidShape: 'The file has no screen to show (a mock-up needs \"screens\": { \"<id>\": { \"title\", \"blocks\" } }).',\n};\nexport type MaquetteLabels = typeof defaultMaquetteLabels;\n\n/** Typed as `MaquetteLabels`, so a key added in English must be added here too. */\nexport const frenchMaquetteLabels: MaquetteLabels = {\n kind: 'Maquette',\n screens: 'Écrans',\n previous: 'Écran précédent',\n next: 'Écran suivant',\n back: 'Retour',\n more: 'Plus',\n appNavigation: 'Navigation de l’application',\n howItWorks: 'Comment ça marche',\n endTour: 'Terminer la visite',\n aboutScreen: 'À propos de cet écran',\n opens: title => `Ouvre « ${title} »`,\n tourStep: (index, count) => `Étape ${index} sur ${count}`,\n transitionBy: by => `par ${by}`,\n unknownBlock: 'Pas dessiné dans cette maquette',\n showing: title => `Écran affiché : ${title}`,\n newerTitle: 'Faite avec une version plus récente',\n newer: version => `Cette maquette utilise la version ${version} du format. Cet afficheur lit la version 1 : ce qu’il ne connaît pas peut manquer.`,\n errorTitle: 'Cette maquette ne peut pas être affichée',\n invalidJson: 'Le fichier n’est pas du JSON valide. Demandez au PM de la présenter à nouveau.',\n invalidShape: 'Le fichier n’a aucun écran à montrer (une maquette a besoin de « screens » : { \"<id>\": { \"title\", \"blocks\" } }).',\n};\n\n/** The viewer's words for `locale` (English by default) with the host's overrides. */\nexport const maquetteLabelsFor = (locale: MaquetteLocale | undefined, labels?: Partial<MaquetteLabels>): MaquetteLabels =>\n ({ ...(locale === 'fr' ? frenchMaquetteLabels : defaultMaquetteLabels), ...labels });\n",
|
|
38
|
+
"type": "registry:lib",
|
|
39
|
+
"target": "components/maquette-viewer/maquette-labels.ts"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"path": "registry/maquette-viewer/maquette-blocks.tsx",
|
|
43
|
+
"content": "'use client';\n\n// One maquette screen drawn from the host's own shadcn components: each catalogue type is a real Button, Input, NativeSelect, Badge, Table,\n// Item, Card or Bubble, so a mock-up looks like the app it announces. A type the catalogue does not know becomes a named placeholder\n// (a grey dashed Empty). Blocks with a goto open their screen; the item's CSS gives them a soft ring on hover and focus.\nimport type { ReactNode, Ref } from 'react';\nimport { BoxIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty';\nimport { Input } from '@/components/ui/input';\nimport { Item, ItemContent, ItemGroup, ItemTitle } from '@/components/ui/item';\nimport { Label } from '@/components/ui/label';\nimport { NativeSelect, NativeSelectOption } from '@/components/ui/native-select';\nimport { Separator } from '@/components/ui/separator';\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';\nimport type { Maquette, MaquetteBlock, MaquetteScreen } from './maquette';\nimport type { MaquetteLabels } from './maquette-labels';\n\n/** Opens a screen by id. */\nexport type MaquetteGo = (screen: string) => void;\n/** How a block reaches other screens: `go` opens one, `hint` names where a goto leads (the button's tooltip). */\nexport interface MaquetteLinks { readonly go: MaquetteGo; readonly hint: (screen: string) => string }\n\nconst SKELETON = [92, 100, 64];\n\nfunction Placeholder({ block, links, labels }: { readonly block: MaquetteBlock; readonly links: MaquetteLinks; readonly labels: MaquetteLabels }) {\n return <div data-testid=\"maquette-placeholder\" data-type={block.type}>\n <Empty data-maquette=\"placeholder\">\n <EmptyHeader>\n <EmptyMedia variant=\"icon\"><BoxIcon aria-hidden=\"true\" /></EmptyMedia>\n <EmptyTitle>{block.label || block.type}</EmptyTitle>\n <EmptyDescription>{block.note || labels.unknownBlock}</EmptyDescription>\n </EmptyHeader>\n {block.goto && <EmptyContent><GotoButton block={block} links={links} label={block.label || block.type} /></EmptyContent>}\n </Empty>\n </div>;\n}\n\n// A block with a goto opens its screen (its tooltip names it); one without is drawn but does nothing.\nfunction GotoButton({ block, links, label }: { readonly block: MaquetteBlock; readonly links: MaquetteLinks; readonly label: string }) {\n const target = block.goto;\n return <Button type=\"button\" variant={block.type === 'link' ? 'link' : block.primary ? 'default' : 'outline'} className=\"w-fit max-w-full pointer-coarse:min-h-11\"\n data-testid={target ? 'maquette-goto' : 'maquette-button'} data-goto={target} title={target ? links.hint(target) : undefined} onClick={() => { if (target) links.go(target); }}>\n <span className=\"truncate\">{label || '…'}</span>\n </Button>;\n}\n\n/** One block and its children. */\nexport function MaquetteBlockView({ block, links, labels }: { readonly block: MaquetteBlock; readonly links: MaquetteLinks; readonly labels: MaquetteLabels }): ReactNode {\n const children = block.children.map((child, index) => <MaquetteBlockView key={index} block={child} links={links} labels={labels} />);\n switch (block.type) {\n case 'heading': return <h4 className=\"m-0 text-base font-semibold text-balance\">{block.label}</h4>;\n case 'text': return <p className=\"m-0 text-sm text-pretty text-muted-foreground\">{block.label}</p>;\n case 'button': case 'link': return <GotoButton block={block} links={links} label={block.label} />;\n case 'input': return <Label className=\"flex-col items-stretch gap-1.5\">{block.label}\n <Input className=\"pointer-coarse:min-h-11\" placeholder={block.placeholder} defaultValue={block.value} /></Label>;\n case 'select': return <Label className=\"flex-col items-stretch gap-1.5\">{block.label}\n <NativeSelect className=\"w-full pointer-coarse:[&_select]:min-h-11\" defaultValue={block.value}>\n {(block.items.length ? block.items : [block.value || block.placeholder || '…']).map((option, index) => <NativeSelectOption key={index} value={option}>{option}</NativeSelectOption>)}\n </NativeSelect></Label>;\n case 'badge': return <Badge variant=\"secondary\">{block.label}</Badge>;\n case 'list': return <ItemGroup className=\"gap-2\">\n {block.label && <p className=\"m-0 text-sm font-medium\">{block.label}</p>}\n {block.items.map((item, index) => <Item key={index} variant=\"outline\" size=\"sm\"><ItemContent><ItemTitle>{item}</ItemTitle></ItemContent></Item>)}\n </ItemGroup>;\n case 'table': return <div className=\"min-w-0 overflow-x-auto\"><Table>\n <TableHeader><TableRow>{block.columns.map((column, index) => <TableHead key={index}>{column}</TableHead>)}</TableRow></TableHeader>\n <TableBody>{block.rows.map((row, index) => <TableRow key={index}>{row.map((cell, column) => <TableCell key={column}>{cell}</TableCell>)}</TableRow>)}</TableBody>\n </Table></div>;\n case 'card': return <Card size=\"sm\">\n {block.label && <CardHeader><CardTitle>{block.label}</CardTitle></CardHeader>}\n {children.length > 0 && <CardContent className=\"flex flex-col gap-3\">{children}</CardContent>}\n </Card>;\n case 'row': return <div className=\"flex flex-wrap items-start gap-3 *:min-w-40 *:flex-1\">{children}</div>;\n case 'stack': return <div className=\"flex flex-col gap-3\">{children}</div>;\n case 'chat': return <Card size=\"sm\">\n {block.label && <CardHeader><CardTitle>{block.label}</CardTitle></CardHeader>}\n <CardContent className=\"flex flex-col gap-2\">\n {block.messages.map((message, index) => <Bubble key={index} align={message.mine ? 'end' : 'start'} variant={message.mine ? 'default' : 'muted'}><BubbleContent>{message.text}</BubbleContent></Bubble>)}\n <Input className=\"pointer-coarse:min-h-11\" placeholder={block.placeholder || '…'} aria-label={block.placeholder || block.label || 'chat'} />\n {children}\n </CardContent>\n </Card>;\n case 'document': return <Card size=\"sm\">\n {block.label && <CardHeader><CardTitle>{block.label}</CardTitle></CardHeader>}\n <CardContent className=\"flex flex-col gap-2\">\n {block.lines.length ? block.lines.map((line, index) => <p key={index} className=\"m-0 text-sm text-muted-foreground\">{line}</p>)\n : SKELETON.map(width => <span key={width} aria-hidden=\"true\" className=\"block h-2 bg-muted\" style={{ width: `${width}%` }} />)}\n {children}\n </CardContent>\n </Card>;\n case 'placeholder': default: return <Placeholder block={block} links={links} labels={labels} />;\n }\n}\n\n/** A screen as the app would show it: the app's navigation bar (when the maquette has one), then its blocks. Its name is for screen readers and focus. */\nexport function MaquetteScreenView({ maquette, screen, links, labels, headingRef }: {\n readonly maquette: Maquette; readonly screen: MaquetteScreen; readonly links: MaquetteLinks; readonly labels: MaquetteLabels;\n readonly headingRef?: Ref<HTMLHeadingElement> | undefined;\n}) {\n return <div data-testid=\"maquette-screen\" data-screen={screen.id} className=\"flex min-w-0 flex-col gap-4\">\n {maquette.nav.length > 0 && <>\n <nav aria-label={labels.appNavigation} className=\"flex flex-wrap gap-1\">\n {maquette.nav.map(item => <Button key={item.goto} type=\"button\" size=\"sm\" variant={item.goto === screen.id ? 'secondary' : 'ghost'} className=\"pointer-coarse:min-h-11\"\n aria-current={item.goto === screen.id ? 'page' : undefined} data-testid=\"maquette-nav\" title={links.hint(item.goto)} onClick={() => links.go(item.goto)}>{item.label}</Button>)}\n </nav>\n <Separator />\n </>}\n <h3 ref={headingRef} tabIndex={-1} data-testid=\"maquette-screen-title\" className=\"sr-only\">{screen.title}</h3>\n {screen.blocks.map((block, index) => <MaquetteBlockView key={index} block={block} links={links} labels={labels} />)}\n </div>;\n}\n",
|
|
44
|
+
"type": "registry:component",
|
|
45
|
+
"target": "components/maquette-viewer/maquette-blocks.tsx"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"path": "registry/maquette-viewer/maquette-diagram.tsx",
|
|
49
|
+
"content": "'use client';\n\n// The workflow diagram: states in columns by distance from the first state, one arrow per transition with a numbered badge, and a legend\n// under it that says what happens and who acts (labels of any length). Columns run left to right while they fit the box; in a narrow box\n// (a phone) the steps run top to bottom instead. A diagram still wider than its box scrolls inside it, never the page.\nimport { useEffect, useId, useRef, useState } from 'react';\nimport { Badge } from '@/components/ui/badge';\nimport type { MaquetteWorkflow } from './maquette';\nimport type { MaquetteLabels } from './maquette-labels';\n\nconst H = 44, W_WIDE = 148, GX = 72, GY = 36, VX = 16, VY = 56, LOOP = 36;\n\ntype Point = { readonly x: number; readonly y: number };\n\n/** Each state's step: the shortest number of transitions from the first state; states it never reaches come after, one step each. */\nfunction steps(workflow: MaquetteWorkflow): Map<number, string[]> {\n const { states, transitions } = workflow;\n const depth = new Map<string, number>([[states[0]!.id, 0]]);\n for (let changed = true, guard = 0; changed && guard < 50; guard++) {\n changed = false;\n for (const t of transitions) if (depth.has(t.from) && !depth.has(t.to)) { depth.set(t.to, depth.get(t.from)! + 1); changed = true; }\n }\n let extra = Math.max(0, ...depth.values()) + 1;\n for (const s of states) if (!depth.has(s.id)) depth.set(s.id, extra++);\n const columns = new Map<number, string[]>();\n for (const s of states) { const d = depth.get(s.id)!; columns.set(d, [...(columns.get(d) ?? []), s.id]); }\n return columns;\n}\n\n/** The SVG path of a transition and where its number sits. */\nfunction arrow(a: Point, b: Point, W: number, vertical: boolean): { d: string; x: number; y: number } {\n if (!vertical) {\n // Next step: right edge to left edge. Same column, below: straight down. Otherwise (back, or skipping a step): a loop under both boxes.\n const kind = b.x > a.x && b.x - a.x <= W + GX ? 'forward' : b.x === a.x && b.y > a.y ? 'down' : 'back';\n const x1 = kind === 'forward' ? a.x + W : a.x + W / 2, y1 = kind === 'forward' ? a.y + H / 2 : a.y + H;\n const x2 = kind === 'forward' ? b.x : b.x + W / 2, y2 = kind === 'forward' ? b.y + H / 2 : kind === 'down' ? b.y : b.y + H;\n const d = kind === 'forward' ? `M${x1} ${y1} C${x1 + GX / 2} ${y1} ${x2 - GX / 2} ${y2} ${x2} ${y2}` : kind === 'down' ? `M${x1} ${y1} L${x2} ${y2}` : `M${x1} ${y1} C${x1} ${y1 + 30} ${x2} ${y2 + 30} ${x2} ${y2}`;\n return { d, x: kind === 'down' ? x1 + 12 : (x1 + x2) / 2, y: kind === 'back' ? Math.max(y1, y2) + 22 : (y1 + y2) / 2 };\n }\n // Next step: bottom edge to top edge. Same row: side to side. Otherwise (back, or skipping a step): a loop on the right.\n const kind = b.y > a.y && b.y - a.y <= H + VY ? 'forward' : b.y === a.y ? 'across' : 'back';\n const right = b.x > a.x;\n const x1 = kind === 'forward' ? a.x + W / 2 : kind === 'across' ? (right ? a.x + W : a.x) : a.x + W, y1 = kind === 'forward' ? a.y + H : a.y + H / 2;\n const x2 = kind === 'forward' ? b.x + W / 2 : kind === 'across' ? (right ? b.x : b.x + W) : b.x + W, y2 = kind === 'forward' ? b.y : b.y + H / 2;\n const out = Math.max(x1, x2) + LOOP - 8;\n const d = kind === 'forward' ? `M${x1} ${y1} C${x1} ${y1 + VY / 2} ${x2} ${y2 - VY / 2} ${x2} ${y2}` : kind === 'across' ? `M${x1} ${y1} L${x2} ${y2}` : `M${x1} ${y1} C${out} ${y1} ${out} ${y2} ${x2} ${y2}`;\n return { d, x: kind === 'back' ? out - 6 : (x1 + x2) / 2, y: (y1 + y2) / 2 };\n}\n\n/** The diagram and its legend. `highlight`: the states of the case on display, drawn stronger with the transitions between them. */\nexport function MaquetteDiagram({ workflow, highlight, labels }: { readonly workflow: MaquetteWorkflow; readonly highlight: readonly string[]; readonly labels: MaquetteLabels }) {\n const { states, transitions } = workflow;\n const box = useRef<HTMLDivElement>(null);\n const [room, setRoom] = useState(0);\n const marker = `maquette-arrow-${useId().replace(/:/g, '')}`;\n useEffect(() => {\n const element = box.current;\n if (!element) return undefined;\n const measure = () => setRoom(element.clientWidth);\n measure();\n if (typeof ResizeObserver === 'undefined') return undefined;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n const columns = steps(workflow);\n const count = Math.max(...columns.keys()) + 1, widest = Math.max(...[...columns.values()].map(ids => ids.length));\n const vertical = room > 0 && 16 + count * W_WIDE + (count - 1) * GX > room;\n const W = vertical ? Math.max(104, Math.min(W_WIDE, Math.floor((room - 16 - LOOP - (widest - 1) * VX) / widest))) : W_WIDE;\n const pos = new Map<string, Point>();\n for (const [d, ids] of columns) ids.forEach((id, i) => pos.set(id, vertical ? { x: 8 + i * (W + VX), y: 8 + d * (H + VY) } : { x: 8 + d * (W + GX), y: 8 + i * (H + GY) }));\n const points = [...pos.values()];\n const width = (vertical ? LOOP : 16) + Math.max(...points.map(p => p.x)) + W + 8, height = (vertical ? 16 : 40) + Math.max(...points.map(p => p.y)) + H;\n const on = new Set(highlight);\n const label = (id: string) => states.find(s => s.id === id)?.label ?? id;\n const fit = Math.floor((W - 16) / 7);\n return <div className=\"flex min-w-0 flex-col gap-4\">\n <div ref={box} data-testid=\"maquette-diagram\" data-layout={vertical ? 'vertical' : 'horizontal'} className=\"min-w-0 overflow-x-auto\">\n <svg role=\"img\" aria-label={states.map(s => s.label).join(' → ')} width={width} height={height} viewBox={`0 0 ${width} ${height}`} className=\"block\">\n <defs><marker id={marker} viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"6\" markerHeight=\"6\" orient=\"auto-start-reverse\"><path d=\"M0 0L10 5L0 10z\" className=\"fill-muted-foreground\" /></marker></defs>\n {transitions.map((t, i) => {\n const path = arrow(pos.get(t.from)!, pos.get(t.to)!, W, vertical);\n const lit = on.has(t.from) && on.has(t.to);\n return <g key={i} data-lit={lit || undefined}>\n <path d={path.d} fill=\"none\" className={lit ? 'stroke-primary' : 'stroke-muted-foreground/60'} strokeWidth={lit ? 2 : 1.25} markerEnd={`url(#${marker})`} />\n <circle cx={path.x} cy={path.y} r=\"9\" className={lit ? 'fill-primary stroke-primary' : 'fill-background stroke-border'} />\n <text x={path.x} y={path.y + 3.5} textAnchor=\"middle\" fontSize=\"10\" className={lit ? 'fill-primary-foreground' : 'fill-foreground'}>{i + 1}</text>\n </g>;\n })}\n {states.map(s => {\n const p = pos.get(s.id)!, lit = on.has(s.id);\n return <g key={s.id} data-state={s.id} data-lit={lit || undefined}>\n <rect x={p.x} y={p.y} width={W} height={H} rx=\"10\" className={lit ? 'fill-primary/10 stroke-primary' : 'fill-card stroke-border'} strokeWidth={lit ? 2 : 1} />\n <text x={p.x + W / 2} y={p.y + H / 2 + 4} textAnchor=\"middle\" fontSize=\"12\" className=\"fill-foreground\">{s.label.length > fit ? `${s.label.slice(0, fit - 1)}…` : s.label}<title>{s.label}</title></text>\n </g>;\n })}\n </svg>\n </div>\n {transitions.length > 0 && <ol data-testid=\"maquette-legend\" className=\"m-0 list-none p-0 grid gap-x-6 gap-y-2 text-sm sm:grid-cols-2\">\n {transitions.map((t, i) => <li key={i} className=\"flex min-w-0 items-start gap-2\">\n <Badge variant={on.has(t.from) && on.has(t.to) ? 'default' : 'outline'} className=\"tabular-nums\">{i + 1}</Badge>\n <span className=\"min-w-0 text-pretty\"><span className=\"font-medium\">{label(t.from)} → {label(t.to)}</span>{t.label && <>: {t.label}</>}\n {t.by && <span className=\"text-muted-foreground\"> ({labels.transitionBy(t.by)})</span>}</span>\n </li>)}\n </ol>}\n </div>;\n}\n",
|
|
50
|
+
"type": "registry:component",
|
|
51
|
+
"target": "components/maquette-viewer/maquette-diagram.tsx"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"path": "registry/maquette-viewer/maquette-viewer.tsx",
|
|
55
|
+
"content": "'use client';\n\n// The maquette viewer: a PM agent's `*.maquette.json` mock-up, one screen at a time. The screen is the content; the chrome is one thin bar\n// of icons: Back, ‹ 2 / 5 › (also arrow keys and swipe) and a menu holding the guided tours and \"How it works\" (the workflow, in a dialog).\n// During a tour the bar shows the step dots and ×, with the step's caption under it. `maquetteKind` is the kind the pi-app artifact panel\n// uses by default for `.maquette.json` files; an app's own kind in `artifactKinds` wins. It draws data only: nothing in the file runs.\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport type { ComponentType, KeyboardEvent, ReactNode, TouchEvent } from 'react';\nimport { ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon, CompassIcon, EllipsisIcon, InfoIcon, LayoutTemplateIcon, TriangleAlertIcon, WorkflowIcon, XIcon } from 'lucide-react';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent } from '@/components/ui/card';\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { MAQUETTE_FORMAT_VERSION, isMaquettePath, parseMaquette, screenFromFragment } from './maquette';\nimport type { Maquette, MaquetteCase, MaquetteScreen } from './maquette';\nimport { maquetteLabelsFor } from './maquette-labels';\nimport type { MaquetteLabels, MaquetteLocale } from './maquette-labels';\nimport { MaquetteScreenView } from './maquette-blocks';\nimport type { MaquetteLinks } from './maquette-blocks';\nimport { MaquetteDiagram } from './maquette-diagram';\n\nexport { MAQUETTE_FORMAT_VERSION, isMaquettePath, linksOf, parseMaquette, screenFromFragment } from './maquette';\nexport type { Maquette, MaquetteBlock, MaquetteBlockV1, MaquetteCase, MaquetteFileV1, MaquetteParse, MaquetteScreen, MaquetteState, MaquetteTransition, MaquetteWorkflow } from './maquette';\nexport { defaultMaquetteLabels, frenchMaquetteLabels, maquetteLabelsFor } from './maquette-labels';\nexport type { MaquetteLabels, MaquetteLocale } from './maquette-labels';\n\nexport interface MaquetteViewerProps {\n /** The file's text (JSON). */\n readonly text: string;\n /** The page's URL fragment: `screen=<id>` opens that screen (and follows later changes). */\n readonly fragment?: string | undefined;\n /** The base words: English by default. */\n readonly locale?: MaquetteLocale | undefined;\n /** Any word over the locale's. */\n readonly labels?: Partial<MaquetteLabels> | undefined;\n}\n\n/** Where the person is: the screens visited (the last is shown) and the guided tour under way. */\ninterface Walk { readonly history: readonly string[]; readonly tour: { readonly name: string; readonly index: number } | null }\n\nconst TYPING = 'input, select, textarea, [contenteditable=\"true\"]';\nconst ICON = 'pointer-coarse:min-h-11 pointer-coarse:min-w-11';\n\n/** One dot per tour step, the current one in the primary colour. */\nfunction Dots({ count, index, label }: { readonly count: number; readonly index: number; readonly label: string }) {\n return <svg role=\"img\" aria-label={label} data-testid=\"maquette-dots\" width={count * 12} height=\"8\" viewBox={`0 0 ${count * 12} 8`} className=\"shrink-0\">\n {Array.from({ length: count }, (_, i) => <circle key={i} cx={i * 12 + 6} cy=\"4\" r=\"3\" className={i === index ? 'fill-primary' : 'fill-muted-foreground/30'} />)}\n </svg>;\n}\n\n/** The workflow diagram, its legend and the cases' `path` highlights, in a dialog opened from the menu. */\nfunction HowItWorks({ maquette, labels, open, onOpenChange }: { readonly maquette: Maquette; readonly labels: MaquetteLabels; readonly open: boolean; readonly onOpenChange: (open: boolean) => void }) {\n const [shown, setShown] = useState<string | null>(null);\n const highlighted = maquette.cases.filter(item => item.path.length > 0);\n if (!maquette.workflow) return null;\n return <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent data-testid=\"maquette-how\" className=\"max-h-[85dvh] overflow-y-auto\">\n <DialogHeader><DialogTitle>{labels.howItWorks}</DialogTitle></DialogHeader>\n <MaquetteDiagram workflow={maquette.workflow} highlight={highlighted.find(item => item.name === shown)?.path ?? []} labels={labels} />\n {highlighted.length > 0 && <div role=\"group\" className=\"flex flex-wrap gap-2\">\n {highlighted.map(item => <Button key={item.name} type=\"button\" size=\"sm\" variant={shown === item.name ? 'secondary' : 'outline'} aria-pressed={shown === item.name}\n data-testid=\"maquette-case\" className=\"pointer-coarse:min-h-11\" onClick={() => setShown(current => current === item.name ? null : item.name)}>{item.name}</Button>)}\n </div>}\n </DialogContent>\n </Dialog>;\n}\n\nfunction Walkthrough({ maquette, labels, fragment }: { readonly maquette: Maquette; readonly labels: MaquetteLabels; readonly fragment: string | undefined }) {\n const exists = (id: string | undefined): id is string => !!id && maquette.screens.some(screen => screen.id === id);\n const linked = screenFromFragment(fragment);\n const [walk, setWalk] = useState<Walk>(() => ({ history: [exists(linked) ? linked : maquette.start], tour: null }));\n const [how, setHow] = useState(false);\n const seen = useRef(new Set<string>());\n const heading = useRef<HTMLHeadingElement>(null);\n const moved = useRef(false);\n const touch = useRef<{ x: number; y: number } | null>(null);\n const screens = maquette.screens;\n const current: MaquetteScreen = screens.find(screen => screen.id === walk.history.at(-1)) ?? screens[0]!;\n const position = screens.indexOf(current);\n const tour: MaquetteCase | undefined = walk.tour ? maquette.cases.find(item => item.name === walk.tour!.name) : undefined;\n const tours = maquette.cases.filter(item => item.steps.length > 0);\n const titleOf = (id: string) => screens.find(screen => screen.id === id)?.title ?? id;\n // Goto blocks pulse once, the first time their screen is shown.\n const firstView = !seen.current.has(current.id);\n useEffect(() => { seen.current.add(current.id); }, [current.id]);\n\n const move = (next: Walk) => { moved.current = true; setWalk(next); };\n const go = (id: string) => { if (exists(id) && id !== current.id) move({ history: [...walk.history, id], tour: null }); };\n const step = (delta: number) => {\n if (tour && walk.tour) {\n const index = walk.tour.index + delta, target = tour.steps[index];\n if (target) move({ history: [...walk.history, target.screen], tour: { name: tour.name, index } });\n return;\n }\n const target = screens[position + delta];\n if (target) move({ history: [...walk.history, target.id], tour: null });\n };\n const back = () => { if (walk.history.length > 1) move({ history: walk.history.slice(0, -1), tour: null }); };\n const startTour = (item: MaquetteCase) => move({ history: [...walk.history, item.steps[0]!.screen], tour: { name: item.name, index: 0 } });\n // A later `#screen=<id>` (the PM's \"see screen 2\" link) opens that screen.\n useEffect(() => { if (exists(linked) && linked !== walk.history.at(-1)) setWalk(previous => ({ history: [...previous.history, linked], tour: null })); }, [linked]); // eslint-disable-line react-hooks/exhaustive-deps\n // After a move, keyboard focus starts on the new screen, so Tab continues inside the screen just opened.\n useEffect(() => { if (moved.current) { moved.current = false; heading.current?.focus(); } }, [walk]);\n\n const index = tour && walk.tour ? walk.tour.index : position, count = tour ? tour.steps.length : screens.length;\n const links: MaquetteLinks = { go, hint: id => labels.opens(titleOf(id)) };\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.shiftKey || (event.target as Element).closest?.(TYPING)) return;\n if (event.altKey) { if (event.key === 'ArrowLeft') { event.preventDefault(); back(); } return; }\n if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); step(event.key === 'ArrowLeft' ? -1 : 1); }\n };\n const onTouchStart = (event: TouchEvent) => { const point = event.touches[0]; touch.current = point ? { x: point.clientX, y: point.clientY } : null; };\n const onTouchEnd = (event: TouchEvent) => {\n const start = touch.current, point = event.changedTouches[0];\n touch.current = null;\n if (!start || !point) return;\n const dx = point.clientX - start.x, dy = point.clientY - start.y;\n if (Math.abs(dx) > 56 && Math.abs(dx) > Math.abs(dy) * 1.5) step(dx < 0 ? 1 : -1);\n };\n const caption = tour && walk.tour ? tour.steps[walk.tour.index]?.caption : undefined;\n\n return <div data-testid=\"maquette-walk\" data-screen={current.id} data-tour={tour?.name} className=\"flex min-w-0 flex-col gap-2\" onKeyDown={onKeyDown}>\n <nav aria-label={labels.screens} data-testid=\"maquette-stepper\" className=\"flex items-center gap-1\">\n {walk.history.length > 1 && <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.back} data-testid=\"maquette-back\" onClick={back}><ArrowLeftIcon aria-hidden=\"true\" /></Button>}\n <div className=\"flex flex-1 items-center justify-center gap-1\">\n <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.previous} data-testid=\"maquette-previous\" disabled={index <= 0} onClick={() => step(-1)}><ChevronLeftIcon aria-hidden=\"true\" /></Button>\n {tour ? <Dots count={count} index={index} label={labels.tourStep(index + 1, count)} />\n : <span data-testid=\"maquette-position\" className=\"min-w-10 text-center text-sm text-muted-foreground tabular-nums\">{index + 1} / {count}</span>}\n <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.next} data-testid=\"maquette-next\" disabled={index >= count - 1} onClick={() => step(1)}><ChevronRightIcon aria-hidden=\"true\" /></Button>\n </div>\n {tour ? <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.endTour} data-testid=\"maquette-end-tour\" onClick={() => setWalk(previous => ({ ...previous, tour: null }))}><XIcon aria-hidden=\"true\" /></Button>\n : (tours.length > 0 || maquette.workflow) && <DropdownMenu>\n <DropdownMenuTrigger render={<Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.more} data-testid=\"maquette-menu\" />}><EllipsisIcon aria-hidden=\"true\" /></DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" data-testid=\"maquette-menu-list\">\n {tours.map(item => <DropdownMenuItem key={item.name} className=\"pointer-coarse:min-h-11\" data-testid=\"maquette-tour\" onClick={() => startTour(item)}><CompassIcon aria-hidden=\"true\" />{item.name}</DropdownMenuItem>)}\n {tours.length > 0 && maquette.workflow && <DropdownMenuSeparator />}\n {maquette.workflow && <DropdownMenuItem className=\"pointer-coarse:min-h-11\" data-testid=\"maquette-how-open\" onClick={() => setHow(true)}><WorkflowIcon aria-hidden=\"true\" />{labels.howItWorks}</DropdownMenuItem>}\n </DropdownMenuContent>\n </DropdownMenu>}\n </nav>\n {caption && <p data-testid=\"maquette-caption\" className=\"m-0 line-clamp-2 px-1 text-sm text-pretty text-muted-foreground\">{caption}</p>}\n\n <div className={maquette.device === 'phone' ? 'mx-auto flex w-full max-w-sm flex-col gap-2' : 'flex min-w-0 flex-col gap-2'}>\n <div className=\"min-w-0\" data-hint={firstView ? 'pulse' : undefined} onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>\n <Card data-testid=\"maquette-frame\" data-device={maquette.device ?? 'panel'}><CardContent>\n <MaquetteScreenView key={current.id} maquette={maquette} screen={current} links={links} labels={labels} headingRef={heading} />\n </CardContent></Card>\n </div>\n {current.note && <div className=\"flex min-w-0 items-center gap-1 px-1\">\n <p data-testid=\"maquette-note\" title={current.note} className=\"m-0 min-w-0 flex-1 truncate text-xs text-muted-foreground\">{current.note}</p>\n <Popover>\n <PopoverTrigger render={<Button type=\"button\" variant=\"ghost\" size=\"icon-xs\" className={ICON} aria-label={labels.aboutScreen} data-testid=\"maquette-note-more\" />}><InfoIcon aria-hidden=\"true\" /></PopoverTrigger>\n <PopoverContent align=\"end\" data-testid=\"maquette-note-full\">{current.note}</PopoverContent>\n </Popover>\n </div>}\n </div>\n <p aria-live=\"polite\" className=\"m-0 sr-only\">{labels.showing(current.title)}</p>\n <HowItWorks maquette={maquette} labels={labels} open={how} onOpenChange={setHow} />\n </div>;\n}\n\n/** A maquette file drawn one screen at a time; a newer format version shows a notice, an unreadable file an inline error. */\nexport function MaquetteViewer({ text, fragment, locale, labels: given }: MaquetteViewerProps) {\n const labels = useMemo(() => maquetteLabelsFor(locale, given), [locale, given]);\n const parsed = useMemo(() => parseMaquette(text), [text]);\n const newer = parsed.version !== undefined && parsed.version > MAQUETTE_FORMAT_VERSION;\n const notice = newer && <Alert data-testid=\"maquette-newer\"><InfoIcon aria-hidden=\"true\" /><AlertTitle>{labels.newerTitle}</AlertTitle><AlertDescription>{labels.newer(parsed.version!)}</AlertDescription></Alert>;\n return <section data-boring=\"maquette-viewer\" data-state={parsed.kind} aria-label={parsed.kind === 'ok' ? parsed.maquette.title || labels.kind : labels.kind}\n className=\"mx-auto flex w-full max-w-5xl min-w-0 flex-col gap-2 px-3 pt-1 pb-4 sm:px-6\">\n {notice}\n {parsed.kind === 'ok' ? <Walkthrough maquette={parsed.maquette} labels={labels} fragment={fragment} />\n : <Alert variant=\"destructive\" data-testid=\"maquette-error\" data-reason={parsed.reason}><TriangleAlertIcon aria-hidden=\"true\" /><AlertTitle>{labels.errorTitle}</AlertTitle>\n <AlertDescription>{parsed.reason === 'json' ? labels.invalidJson : labels.invalidShape}</AlertDescription></Alert>}\n </section>;\n}\n\n/**\n * The artifact kind of a maquette file (structurally an `ArtifactKind` of pi-chat): it matches `*.maquette.json`, names it, gives its icon\n * and draws it with `MaquetteViewer`. Pass it, or your own, in `artifactKinds`; kinds an app passes are consulted before the built-in one.\n */\nexport interface MaquetteKind {\n readonly match: (target: { readonly resource: { readonly path: string } }) => boolean;\n readonly label: string;\n readonly icon: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }>;\n readonly renderer: (props: { readonly text: string; readonly fragment?: string | undefined }) => ReactNode;\n}\n\n/** A maquette kind in `locale` (\"Mock-up\" in English, \"Maquette\" in French) with any word overridden. */\nexport function createMaquetteKind({ locale, labels }: { readonly locale?: MaquetteLocale | undefined; readonly labels?: Partial<MaquetteLabels> | undefined } = {}): MaquetteKind {\n return {\n match: target => isMaquettePath(target.resource.path),\n label: maquetteLabelsFor(locale, labels).kind,\n icon: LayoutTemplateIcon,\n renderer: ({ text, fragment }) => <MaquetteViewer text={text} fragment={fragment} locale={locale} labels={labels} />,\n };\n}\n\n/** The built-in maquette kind, in English. */\nexport const maquetteKind: MaquetteKind = createMaquetteKind();\n",
|
|
56
|
+
"type": "registry:component",
|
|
57
|
+
"target": "components/maquette-viewer/maquette-viewer.tsx"
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
"css": {
|
|
61
|
+
"@layer components": {
|
|
62
|
+
"[data-boring=\"maquette-viewer\"] [data-maquette=\"placeholder\"]": {
|
|
63
|
+
"border-width": "var(--default-border-width, 1px)",
|
|
64
|
+
"border-style": "dashed",
|
|
65
|
+
"border-color": "var(--border)",
|
|
66
|
+
"background-color": "var(--muted)"
|
|
67
|
+
},
|
|
68
|
+
"[data-boring=\"maquette-viewer\"] [data-hint=\"pulse\"] [data-goto]": {
|
|
69
|
+
"animation": "var(--animate-pulse)",
|
|
70
|
+
"animation-iteration-count": "1"
|
|
71
|
+
},
|
|
72
|
+
"[data-boring=\"maquette-viewer\"] [data-goto]:focus-visible": {
|
|
73
|
+
"outline": "2px solid color-mix(in oklch, var(--ring) 55%, transparent)",
|
|
74
|
+
"outline-offset": "2px"
|
|
75
|
+
},
|
|
76
|
+
"@media (hover: hover) and (pointer: fine)": {
|
|
77
|
+
"[data-boring=\"maquette-viewer\"] [data-goto]:hover": {
|
|
78
|
+
"outline": "2px solid color-mix(in oklch, var(--ring) 55%, transparent)",
|
|
79
|
+
"outline-offset": "2px"
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"@media (prefers-reduced-motion: reduce)": {
|
|
83
|
+
"[data-boring=\"maquette-viewer\"] [data-hint=\"pulse\"] [data-goto]": {
|
|
84
|
+
"animation": "none"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
"type": "registry:component"
|
|
90
|
+
}
|
package/r/markdown-editor.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"title": "Markdown editor",
|
|
5
5
|
"description": "A host-owned styled wrapper over the concrete borrowed Markdown controller.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.2",
|
|
8
|
+
"@hachej/boring-files@^0.4.2",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"marked@^17.0.6",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"@tiptap/extension-floating-menu@^3.31.4"
|
|
24
24
|
],
|
|
25
25
|
"registryDependencies": [
|
|
26
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
26
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/button.json"
|
|
27
27
|
],
|
|
28
28
|
"files": [
|
|
29
29
|
{
|
package/r/pi-ambient.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"title": "Pi ambient agent",
|
|
5
5
|
"description": "AmbientChat, a compact floating agent bar that grows into a chat window over an existing page, and AgentNotifications, toasts for finished, failed and waiting tasks (with the one guarded browser Notification helper). A block over the pi-chat item; its in-window artifact panel uses the pi-workspace layout.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.2",
|
|
8
8
|
"react@^19.0.0",
|
|
9
9
|
"react-dom@^19.0.0",
|
|
10
10
|
"@earendil-works/pi-durable@~1.2.0",
|
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
"lucide-react@^1.51.0"
|
|
13
13
|
],
|
|
14
14
|
"registryDependencies": [
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
17
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
18
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
19
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/theme.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/pi-chat.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/pi-workspace.json",
|
|
18
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/utils.json",
|
|
19
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/button.json",
|
|
20
20
|
"message-scroller",
|
|
21
21
|
"button",
|
|
22
22
|
"toggle",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"files": [
|
|
29
29
|
{
|
|
30
30
|
"path": "registry/pi-ambient/ambient.tsx",
|
|
31
|
-
"content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport type { ImageContent, ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { BellIcon, ChevronDownIcon, CircleAlertIcon, CircleCheckIcon, ExternalLinkIcon, GripVerticalIcon, Loader2Icon, MessageCircleQuestionIcon, MinusIcon, PanelLeftIcon, ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { MessageScroller, MessageScrollerProvider, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem, MessageScrollerButton } from '@/components/ui/message-scroller';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Avatar, AvatarFallback } from '@/components/ui/avatar';\nimport { Badge } from '@/components/ui/badge';\nimport { Card } from '@/components/ui/card';\nimport { Separator } from '@/components/ui/separator';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction, BlockIcon } from '../button/actions';\nimport { useChatPlacement } from '../pi-chat/use-remote-chat';\nimport { CopyButton } from '../pi-chat/code-block';\nimport { Composer } from '../pi-chat/composer';\nimport type { ComposerFeedback, ComposerProps } from '../pi-chat/composer';\nimport { ConversationFiles } from '../pi-chat/conversation-files';\nimport type { AttachmentsConfig, ConversationsConfig, EffortConfig, MentionsConfig, ModelConfig, SlashConfig } from '../pi-chat/config';\nimport { notifyPermission, requestNotifyPermission } from './browser-notify';\nimport type { NotifyPermission } from './browser-notify';\nimport { ConversationHistory, TranscriptHistory } from '../pi-chat/history';\nimport type { CommandMentions } from '../pi-chat/markdown';\nimport { RowView } from '../pi-chat/message';\nimport type { RowContext } from '../pi-chat/message';\nimport { ChatNotices, Notice } from '../pi-chat/notice';\nimport { AgentNotifications, KindIcon, createNotificationStore, defaultNotificationLabels, finishNotice, pendingQuestion, useNotifications } from './notifications';\nimport type { AgentNotification, NotificationLabels, NotificationStore } from './notifications';\nimport { ChatTextProvider, defaultChatIcons, defaultChatLabels, useMergedText } from '../pi-chat/labels';\nimport type { ChatIcons, ChatLabels } from '../pi-chat/labels';\nimport { MessageQueue } from '../pi-chat/queue';\nimport { artifactKey, collectArtifacts } from '../pi-chat/artifact';\nimport type { ArtifactDescriptor, ArtifactsConfig } from '../pi-chat/artifact';\nimport type { ChatCard, Mode } from '../pi-chat/rows';\nimport { useChatSession, useTranscript } from '../pi-chat/session';\nimport type { ChatFeatureProps, ChatSession } from '../pi-chat/session';\nimport { ArtifactWorkspace } from '../pi-workspace/workspace';\nimport type { WorkspacePanelApi } from '../pi-workspace/workspace';\nimport { cn } from '../utils/utils';\n\nexport { AgentNotifications, createNotificationStore, defaultNotificationLabels, summaryOf, useNotifications, watchConversation } from './notifications';\nexport type { AgentNotification, AgentNotificationsProps, NewNotification, NotificationKind, NotificationLabels, NotificationStore } from './notifications';\n\nconst elapsedText = (seconds: number) => seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`;\n\n/** The floating window's own words, over the chat's (`defaultChatLabels`) and the toasts' (`defaultNotificationLabels`). */\nexport const defaultAmbientLabels = {\n ...defaultChatLabels,\n ...defaultNotificationLabels,\n /** Shown while the conversation has no title of its own (the active item of `conversations` provides one). */\n title: 'Agent',\n /** The input's placeholder while idle. */\n placeholder: 'Do anything',\n /** The input's placeholder while the agent works. */\n working: (seconds: number) => `Working for ${elapsedText(seconds)}`,\n region: (title: string) => `Agent: ${title}`,\n openAgent: (title: string, unread: boolean) => `Open agent: ${title}${unread ? ' (new activity)' : ''}`,\n openChat: (title: string) => `Open chat: ${title}`,\n minimize: 'Minimise',\n switchConversation: 'Switch conversation',\n statusWorking: 'Working',\n enableNotifications: 'Notify me when the page is in the background',\n dock: 'Dock chat',\n dockHint: 'Dock chat beside the panel',\n move: 'Move the agent window (arrow keys, Home resets)',\n moveHint: 'Drag to move',\n goodResponse: 'Good response',\n badResponse: 'Bad response',\n openFull: 'Open in full chat',\n empty: 'Ask the agent to do something.',\n loadFailedShort: 'Could not load the conversation.',\n disconnectedShort: 'Disconnected.',\n artifactPanel: 'Artifact panel',\n};\nexport type AmbientLabels = ChatLabels & NotificationLabels & typeof defaultAmbientLabels;\n\n/** The floating window's icons, over the chat's. */\nexport const defaultAmbientIcons = {\n ...defaultChatIcons,\n minimize: MinusIcon as BlockIcon,\n dock: PanelLeftIcon as BlockIcon,\n grip: GripVerticalIcon as BlockIcon,\n switchConversation: ChevronDownIcon as BlockIcon,\n notify: BellIcon as BlockIcon,\n openFull: ExternalLinkIcon as BlockIcon,\n goodResponse: ThumbsUpIcon as BlockIcon,\n badResponse: ThumbsDownIcon as BlockIcon,\n};\nexport type AmbientIcons = ChatIcons & typeof defaultAmbientIcons;\nconst AMBIENT_TEXT = { labels: defaultAmbientLabels as AmbientLabels, icons: defaultAmbientIcons as AmbientIcons };\n\n/** `bar` is the compact bar, `expanded` the same window grown into a chat, `minimized` a small pill. */\nexport type AmbientState = 'bar' | 'expanded' | 'minimized';\n\n/** What the artifact panel inside the window gets besides the full-screen and close controls: the versions of the open artifact and how to switch. */\nexport interface AmbientArtifactPanelApi extends WorkspacePanelApi {\n /** Every known version of the open artifact in this conversation, newest first. */\n readonly versions: readonly ArtifactDescriptor[];\n /** True while the panel follows the newest version; false when one older version is pinned. */\n readonly follow: boolean;\n /** `'latest'` follows the newest version; a descriptor from `versions` pins that one. */\n readonly select: (version: ArtifactDescriptor | 'latest') => void;\n}\n\nexport interface AmbientChatProps extends ChatFeatureProps {\n readonly controller: NativeChatController;\n /** Embed the expanded conversation in a host workspace. Switching this flag changes layout without remounting the transcript or composer. The host supplies the header. */\n readonly embedded?: boolean;\n /**\n * Every word the window shows, over `defaultAmbientLabels` (the chat's labels, the toasts' and its own): `title` while the conversation has\n * none of its own, `placeholder` while idle, `working(seconds)` while the agent works.\n */\n readonly labels?: Partial<AmbientLabels> | undefined;\n /** The window's icons (minimise, dock, grip, …) and the chat's, over `defaultAmbientIcons`. */\n readonly icons?: Partial<AmbientIcons> | undefined;\n /** The agent's avatar before the title in the bar. */\n readonly avatar?: ReactNode;\n /** Host actions in the bar, before Dock: `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids `ambient-action-<id>`, the menu `ambient-action-more`. */\n readonly headerActions?: readonly BlockAction[] | undefined;\n /** Host-owned header controls, such as icon-anchored popovers, before the actions and Dock button. */\n readonly headerTools?: ReactNode;\n /** `contrast` is a dark window on any page: the root carries `.dark`, so it shows the host's own dark tokens; `surface` follows the host's theme. */\n readonly variant?: 'contrast' | 'surface';\n /** Controlled window state; otherwise `defaultState` starts it and the window keeps it. */\n readonly state?: AmbientState;\n readonly defaultState?: AmbientState;\n readonly onStateChange?: (state: AmbientState) => void;\n /** Where the bar was dragged is remembered under this key. */\n readonly storageKey?: string;\n /** `session` (default) remembers the position for this browser tab's session; `local` keeps it in `localStorage` across sessions. */\n readonly storage?: 'session' | 'local';\n /** Host controls in the composer row, between the effort picker and Send (for example a voice button the host guards itself). Absent: nothing is shown. */\n readonly tools?: ReactNode;\n /** Two-row reference composer by default; older layouts require an explicit choice. */\n readonly composerLayout?: ComposerProps['layout'];\n /** Hand the conversation to a full `PiChat` or `ArtifactWorkspace`. Adds an \"Open in full chat\" action to replies. */\n readonly onOpenFull?: (conversationId: string) => void;\n /** Adds thumbs up and down to replies. `value` is `null` when the person takes the rating back. */\n readonly onFeedback?: (reply: { readonly key: string; readonly text: string }, value: 'up' | 'down' | null) => void;\n /** Toasts for finished, failed and waiting tasks. Pass a store to feed it for background conversations too; `false` turns them off. */\n readonly notifications?: NotificationStore | false;\n readonly autoDismissMs?: number;\n readonly maxToasts?: number;\n /** System notifications while the page is hidden. A bell in the header asks for permission when the person chooses to; nothing is asked on load. */\n readonly systemNotifications?: boolean;\n /**\n * The viewer shown inside the window when an artifact card is opened: the window widens into chat on the left and this panel on the right\n * (`ArtifactWorkspace`: draggable divider, full screen, a full-screen sheet on a phone). Build it from the viewer registry (`ViewerFrame`, ...),\n * wrapped in `ViewerWindowProvider` with `api.fullscreen` / `api.onFullscreenChange` so its bar shows the full screen button.\n */\n readonly artifactPanel?: (artifact: ArtifactDescriptor, api: AmbientArtifactPanelApi) => ReactNode;\n /** `window` (default when `artifactPanel` is given) opens artifacts inside the window; `host` calls `artifacts.open` instead, for hosts that show them in their own viewer. */\n readonly artifactTarget?: 'window' | 'host';\n /** The title's ⌄ opens this list to switch conversations or tasks. */\n readonly conversations?: ConversationsConfig;\n /** Adds a \"Dock chat\" action to the header, for a host that floats this window out of a docked chat (`ArtifactWorkspace` with `floatBelow`). It shares the controller with the docked `PiChat`, so nothing is lost. */\n readonly onDock?: () => void;\n /** The optional annotate toggle and notes chip in the composer (the registry `feedback` item's `useComposerFeedback`). Omit it and nothing changes. */\n readonly feedback?: ComposerFeedback;\n readonly className?: string;\n}\n\ninterface Position { readonly right: number; readonly bottom: number }\nconst MARGIN = 8, DEFAULT_POSITION: Position = { right: 16, bottom: 16 };\n\nfunction storageFor(persist: 'session' | 'local'): Storage | undefined { try { return persist === 'local' ? globalThis.localStorage : globalThis.sessionStorage; } catch { return undefined; } }\nfunction readPosition(key: string, persist: 'session' | 'local'): Position | undefined {\n try {\n const value: unknown = JSON.parse(storageFor(persist)?.getItem(key) ?? 'null');\n const { right, bottom } = (value ?? {}) as { right?: unknown; bottom?: unknown };\n return typeof right === 'number' && typeof bottom === 'number' && Number.isFinite(right) && Number.isFinite(bottom) ? { right, bottom } : undefined;\n } catch { return undefined; }\n}\nfunction writePosition(key: string, persist: 'session' | 'local', position: Position) {\n try { storageFor(persist)?.setItem(key, JSON.stringify(position)); } catch { /* the position is a convenience */ }\n}\nconst clamp = (position: Position, size: { w: number; h: number }, viewport: { w: number; h: number }): Position => ({\n right: Math.round(Math.min(Math.max(MARGIN, position.right), Math.max(MARGIN, viewport.w - size.w - MARGIN))),\n bottom: Math.round(Math.min(Math.max(MARGIN, position.bottom), Math.max(MARGIN, viewport.h - size.h - MARGIN))),\n});\n\n// The layout viewport that fixed positioning uses (without scrollbars), not the window's outer size.\nconst viewportSize = () => { const root = globalThis.document?.documentElement; return { w: root?.clientWidth || globalThis.innerWidth || 1024, h: root?.clientHeight || globalThis.innerHeight || 768 }; };\nfunction usePhone(): boolean {\n const query = '(max-width: 639px)';\n const [phone, setPhone] = useState(() => globalThis.matchMedia?.(query).matches === true);\n useEffect(() => {\n const list = globalThis.matchMedia?.(query);\n if (!list) return;\n const update = () => setPhone(list.matches);\n update();\n list.addEventListener('change', update);\n return () => list.removeEventListener('change', update);\n }, []);\n return phone;\n}\n\n/** Whole seconds since `active` became true; 0 while inactive. */\nfunction useElapsed(active: boolean): number {\n const [seconds, setSeconds] = useState(0);\n useEffect(() => {\n if (!active) { setSeconds(0); return; }\n const started = Date.now();\n setSeconds(0);\n const timer = setInterval(() => setSeconds(Math.floor((Date.now() - started) / 1000)), 1000);\n return () => clearInterval(timer);\n }, [active]);\n return seconds;\n}\n\n/** Pushes a toast when a run finishes or fails and when a question starts waiting; takes it back when the question is answered or a new run starts. */\nfunction useRunFeed({ enabled, store, conversationId, title, session, suppress, onOutcome, waiting }: {\n readonly enabled: boolean; readonly store: NotificationStore; readonly conversationId: string; readonly title: string; readonly session: ChatSession; readonly waiting: string;\n readonly suppress: () => boolean; readonly onOutcome: (outcome: 'done' | 'error' | null) => void;\n}) {\n const { working, waitingForAnswer, rows, connected } = session;\n const before = useRef({ working, waiting: waitingForAnswer });\n const latest = useRef({ rows, title, suppress, onOutcome, connected, waiting });\n latest.current = { rows, title, suppress, onOutcome, connected, waiting };\n useEffect(() => {\n const was = before.current;\n before.current = { working, waiting: waitingForAnswer };\n const now = latest.current;\n if (working && !was.working) { now.onOutcome(null); store.dismiss(`${conversationId}:done`); }\n if (!enabled) return;\n if (waitingForAnswer && !was.waiting && !now.suppress()) store.push({ id: `${conversationId}:input`, kind: 'input', title: now.title, summary: pendingQuestion(now.rows) ?? now.waiting, conversationId });\n if (!waitingForAnswer && was.waiting) store.dismiss(`${conversationId}:input`);\n if (was.working && !working && now.connected) {\n const finished = finishNotice(now.rows);\n if (!finished) return;\n now.onOutcome(finished.kind);\n if (!now.suppress()) store.push({ id: `${conversationId}:done`, kind: finished.kind, title: now.title, summary: finished.summary, conversationId });\n }\n }, [working, waitingForAnswer, enabled, store, conversationId]);\n}\n\n/** A compact floating agent bar over an existing page; it grows into a chat window in place. Borrows a concrete native controller like `PiChat`. */\nexport function AmbientChat(props: AmbientChatProps) {\n const active = useRef(props.controller); active.current = props.controller;\n const [mount, setMount] = useState({ controller: props.controller, sequence: 0 });\n if (mount.controller !== props.controller) setMount({ controller: props.controller, sequence: mount.sequence + 1 });\n // The window state and the toasts belong to the bar, not to one conversation: they survive switching conversations.\n const [local, setLocal] = useState<AmbientState>(props.defaultState ?? 'bar');\n if (props.embedded && local !== 'expanded') setLocal('expanded');\n const internalStore = useMemo(createNotificationStore, []);\n const text = useMergedText<AmbientLabels, AmbientIcons>(props.labels, props.icons, AMBIENT_TEXT);\n return <ChatTextProvider value={text}><MessageScrollerProvider key={mount.sequence} autoScroll><AmbientSession {...props} labels={text.labels} icons={text.icons} activeController={active} windowState={props.embedded ? 'expanded' : props.state ?? local} store={props.notifications || internalStore}\n onWindowState={next => { if (props.state === undefined) setLocal(next); props.onStateChange?.(next); }} /></MessageScrollerProvider></ChatTextProvider>;\n}\n\ntype SessionProps = Omit<AmbientChatProps, 'labels' | 'icons'> & { readonly labels: AmbientLabels; readonly icons: AmbientIcons; readonly activeController: { readonly current: NativeChatController }; readonly windowState: AmbientState; readonly store: NotificationStore; readonly onWindowState: (state: AmbientState) => void };\n\nfunction AmbientSession({ controller, embedded = false, labels, icons, avatar, headerActions, headerTools, messageActions, variant = 'contrast', mode = 'expert', actions,\n windowState, onWindowState, store, storageKey = 'boring.ambient.position', storage = 'session', tools, composerChips, composerLayout = 'card', onOpenFull, onFeedback, notifications, autoDismissMs, maxToasts, systemNotifications = false,\n renderEntry, renderTool, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, fileAccept = 'image/*', slash, mentions, attachments, onOpenFile, fileChanges, pasteToFile, model, effort, replyFooter, artifacts: hostArtifacts, artifactPanel, artifactTarget, conversations, onDock, feedback: composerFeedback, className,\n activeController }: SessionProps) {\n const { title } = labels;\n useChatPlacement(controller, embedded ? 'docked' : 'floating');\n const stateRef = useRef(windowState); stateRef.current = windowState;\n // The artifact open inside the window, and whether it follows the newest version. It belongs to the expanded window: leaving that state closes it.\n const [opened, setOpened] = useState<{ readonly descriptor: ArtifactDescriptor; readonly follow: boolean } | null>(null);\n const [fullscreen, setFullscreen] = useState(false);\n const setWindowState = (next: AmbientState) => {\n if (next !== 'expanded') { setOpened(null); setFullscreen(false); }\n if (next !== stateRef.current) onWindowState(next);\n };\n const expanded = windowState === 'expanded', minimized = windowState === 'minimized';\n const phone = usePhone();\n const root = useRef<HTMLDivElement>(null), titleButton = useRef<HTMLButtonElement>(null), pill = useRef<HTMLButtonElement>(null);\n const [picking, setPicking] = useState(false);\n const [feedback, setFeedback] = useState<Readonly<Record<string, 'up' | 'down'>>>({});\n const [outcome, setOutcome] = useState<'done' | 'error' | null>(null);\n const [permission, setPermission] = useState<NotifyPermission>('unsupported');\n useEffect(() => { setPermission(notifyPermission()); }, []);\n useEffect(() => { if (!outcome) return; const timer = setTimeout(() => setOutcome(null), 8000); return () => clearTimeout(timer); }, [outcome]);\n\n // Replies get copy, optional feedback and the time; the host may hand the conversation to its full chat.\n const conversationRef = useRef('');\n const replyActions = useMemo<NonNullable<RowContext['replyActions']>>(() => (reply, host) => <div data-testid=\"reply-actions\" className=\"mt-2 -ml-2 flex items-center gap-0.5 opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100 max-sm:opacity-100 pointer-coarse:opacity-100 motion-reduce:transition-none\">\n <CopyButton text={reply.text} label={labels.copyResponse} iconOnly {...(onCopy ? { onCopy } : {})} />\n {onFeedback && <>\n {(['up', 'down'] as const).map(value => <Toggle key={value} data-testid={`feedback-${value}`} pressed={feedback[reply.key] === value} aria-label={value === 'up' ? labels.goodResponse : labels.badResponse}\n title={value === 'up' ? labels.goodResponse : labels.badResponse}\n onPressedChange={pressed => { const next = pressed ? value : null; setFeedback(current => { const { [reply.key]: _removed, ...rest } = current; return next ? { ...rest, [reply.key]: next } : rest; }); onFeedback({ key: reply.key, text: reply.text }, next); }}>\n {value === 'up' ? <icons.goodResponse data-icon=\"inline-start\" aria-hidden=\"true\" /> : <icons.badResponse data-icon=\"inline-start\" aria-hidden=\"true\" />}</Toggle>)}\n </>}\n {onOpenFull && <Button variant=\"ghost\" size=\"icon\" data-testid=\"open-full\" aria-label={labels.openFull} title={labels.openFull} onClick={() => onOpenFull(conversationRef.current)}><icons.openFull data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {host}\n {reply.timestamp !== undefined && <time dateTime={new Date(reply.timestamp).toISOString()} data-testid=\"reply-time\" className=\"ml-1.5 text-xs tabular-nums text-muted-foreground\">{new Date(reply.timestamp).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}</time>}\n </div>, [onCopy, onFeedback, onOpenFull, feedback, labels, icons]);\n const scrollToBottom = useRef<() => void>(() => {});\n const rowExtras = useMemo(() => ({ replyActions }), [replyActions]);\n const inWindow = Boolean(artifactPanel) && artifactTarget !== 'host';\n const detect = hostArtifacts?.detect;\n const versionsRef = useRef<readonly ArtifactDescriptor[]>([]);\n const artifacts = useMemo<ArtifactsConfig | undefined>(() => !inWindow ? hostArtifacts : {\n open: descriptor => { const newest = versionsRef.current.filter(version => artifactKey(version) === artifactKey(descriptor)).reduce((top, version) => Math.max(top, version.ordinal ?? 0), 0); setOpened({ descriptor, follow: (descriptor.ordinal ?? 0) >= newest }); },\n isOpen: descriptor => opened !== null && artifactKey(opened.descriptor) === artifactKey(descriptor) && (opened.follow ? (descriptor.ordinal ?? 0) >= (versionsRef.current.find(version => artifactKey(version) === artifactKey(descriptor))?.ordinal ?? 0) : descriptor.revision === opened.descriptor.revision),\n ...(detect ? { detect } : {}),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [inWindow, hostArtifacts, opened, detect]);\n const session = useChatSession({ controller, activeController, mode, actions, renderEntry, renderTool, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => scrollToBottom.current(), rowExtras, feedback: composerFeedback, onOpenFile, fileChanges, pasteToFile, composerChips });\n const { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, error, act, textarea, rowContext, composer, files, loading } = session;\n const empty = session.empty;\n const transcript = useTranscript(rows, 60, 40, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier });\n const versions = useMemo(() => inWindow ? collectArtifacts(state.view, detect).sort((a, b) => (b.ordinal ?? 0) - (a.ordinal ?? 0)) : [], [inWindow, state.view, detect]);\n versionsRef.current = versions;\n const known = opened ? versions.filter(version => artifactKey(version) === artifactKey(opened.descriptor)) : [];\n const shownArtifact = opened ? (opened.follow ? known[0] ?? opened.descriptor : opened.descriptor) : undefined;\n const { stick, hidden } = transcript;\n scrollToBottom.current = () => { transcript.reset(); void stick.scrollToBottom('instant'); };\n const { barStart, barNote } = session.pickers();\n\n const conversationId = String(conversations?.activeId ?? state.conversationId);\n conversationRef.current = conversationId;\n const activeItem = conversations?.items.find(item => item.id === conversations.activeId);\n const shownTitle = activeItem?.title?.trim() || title;\n const toasts = useNotifications(store);\n useRunFeed({ enabled: notifications !== false, store, conversationId, title: shownTitle, session, onOutcome: setOutcome, waiting: labels.waitingForAnswer,\n suppress: () => stateRef.current === 'expanded' && globalThis.document?.visibilityState !== 'hidden' });\n const unread = !expanded && toasts.length > 0;\n const seconds = useElapsed(working);\n\n // Position: offsets from the bottom right corner, remembered for the session, clamped to the viewport at render so a smaller window restores them.\n const [position, setPosition] = useState<Position>(() => readPosition(storageKey, storage) ?? DEFAULT_POSITION);\n const [viewport, setViewport] = useState(viewportSize);\n const [size, setSize] = useState({ w: 0, h: 0 });\n useEffect(() => {\n const onResize = () => setViewport(viewportSize());\n globalThis.addEventListener('resize', onResize);\n return () => globalThis.removeEventListener('resize', onResize);\n }, []);\n useEffect(() => {\n const element = root.current;\n if (!element || typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(() => setSize({ w: element.offsetWidth, h: element.offsetHeight }));\n observer.observe(element);\n setSize({ w: element.offsetWidth, h: element.offsetHeight });\n return () => observer.disconnect();\n }, [minimized, expanded]);\n // The size and the viewport also follow every render, so the clamp below never works from a stale measurement (the title or status icon can change the pill's width; a scrollbar can appear without a resize event).\n useLayoutEffect(() => {\n const element = root.current;\n const fresh = viewportSize();\n setViewport(current => current.w === fresh.w && current.h === fresh.h ? current : fresh);\n if (element) setSize(current => current.w === element.offsetWidth && current.h === element.offsetHeight ? current : { w: element.offsetWidth, h: element.offsetHeight });\n });\n const placed = clamp(position, size, viewport);\n // Moves and the release are followed on the window, so a drag keeps working when the pointer leaves the handle (and when pointer capture is unavailable).\n // `threshold` is how far the pointer must travel before it is a drag: a click on the pill (threshold > 0) still restores it.\n const dragged = useRef(false);\n const beginDrag = (event: PointerEvent<HTMLElement>, threshold: number) => {\n if (phone || event.button !== 0) return;\n dragged.current = false;\n const origin = { pointer: event.pointerId, x: event.clientX, y: event.clientY, start: placed };\n let last = origin.start, moving = threshold === 0;\n const move = (next: globalThis.PointerEvent) => {\n if (next.pointerId !== origin.pointer) return;\n if (!moving) { if (Math.hypot(next.clientX - origin.x, next.clientY - origin.y) < threshold) return; moving = true; dragged.current = true; }\n last = clamp({ right: origin.start.right - (next.clientX - origin.x), bottom: origin.start.bottom - (next.clientY - origin.y) }, size, viewport);\n setPosition(last);\n };\n const end = (next: globalThis.PointerEvent) => {\n if (next.pointerId !== origin.pointer) return;\n globalThis.removeEventListener('pointermove', move); globalThis.removeEventListener('pointerup', end); globalThis.removeEventListener('pointercancel', end);\n if (moving) writePosition(storageKey, storage, last);\n };\n globalThis.addEventListener('pointermove', move); globalThis.addEventListener('pointerup', end); globalThis.addEventListener('pointercancel', end);\n };\n const onGripDown = (event: PointerEvent<HTMLButtonElement>) => { event.preventDefault(); beginDrag(event, 0); };\n // Arrow keys move the window (Shift for big steps); Home puts it back in the default corner.\n const onGripKey = (event: KeyboardEvent<HTMLElement>) => {\n if (phone) return;\n const step = event.shiftKey ? 64 : 16;\n const delta = event.key === 'ArrowLeft' ? { right: step, bottom: 0 } : event.key === 'ArrowRight' ? { right: -step, bottom: 0 } : event.key === 'ArrowUp' ? { right: 0, bottom: step } : event.key === 'ArrowDown' ? { right: 0, bottom: -step } : undefined;\n if (!delta && event.key !== 'Home') return;\n event.preventDefault();\n const next = delta ? clamp({ right: placed.right + delta.right, bottom: placed.bottom + delta.bottom }, size, viewport) : clamp(DEFAULT_POSITION, size, viewport);\n setPosition(next); writePosition(storageKey, storage, next);\n };\n const grip = (className?: string) => <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-grip\" aria-label={labels.move} title={labels.moveHint} onPointerDown={onGripDown} onKeyDown={onGripKey}\n className={cn('touch-none', className)}><icons.grip data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>;\n\n const expand = () => { setOutcome(null); store.dismissConversation(conversationId); setWindowState('expanded'); };\n useEffect(() => {\n if (expanded && !globalThis.matchMedia?.('(pointer: coarse)').matches) textarea.current?.focus();\n }, [expanded, textarea]);\n // The title is disabled while the window is open, so focus goes back to it once the bar is showing again.\n const refocusTitle = useRef(false);\n useEffect(() => { if (!expanded && refocusTitle.current) { refocusTitle.current = false; titleButton.current?.focus(); } }, [expanded]);\n const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n if (embedded || event.key !== 'Escape' || event.defaultPrevented || !expanded || picking) return;\n event.preventDefault();\n // Escape steps back: full screen, then the artifact, then the window.\n if (fullscreen) return setFullscreen(false);\n if (opened) return setOpened(null);\n refocusTitle.current = true;\n setWindowState('bar');\n };\n const onActivate = useCallback((item: AgentNotification) => {\n if (item.conversationId !== undefined && item.conversationId !== conversationId) conversations?.onSelect(item.conversationId);\n expand();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [conversationId, conversations]);\n\n const connectionKind = state.connection.kind;\n const status = waitingForAnswer ? 'input' : working ? 'working' : outcome;\n const label = waitingForAnswer ? labels.waitingForAnswer : labels.working(seconds);\n const panelOpen = expanded && shownArtifact !== undefined && artifactPanel !== undefined;\n // The floating window is drawn by the host's Card behind it (a sibling layer, so switching placement never remounts the chat);\n // embedded and full-screen phone windows are plain page regions.\n const card = !embedded && !(phone && expanded);\n const surface = embedded ? 'relative flex min-h-0 flex-1 flex-col overflow-clip bg-transparent text-foreground' : cn('relative flex flex-col', card ? 'isolate' : 'h-dvh bg-background pt-[env(safe-area-inset-top)] text-foreground',\n !phone && expanded && (panelOpen ? 'h-full overflow-hidden' : 'max-h-[70dvh]'));\n // With an artifact open the window widens (most of the viewport, at most 72rem) and takes a fixed height so the panel fills it.\n const rootClass = embedded ? cn('pi-chat relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden', className) : cn('pi-chat pi-ambient fixed z-50', variant === 'contrast' && 'dark scheme-dark', phone ? (expanded ? 'inset-0' : 'inset-x-2 bottom-[max(0.5rem,env(safe-area-inset-bottom))]')\n : cn('transition-[width] motion-reduce:transition-none', minimized ? 'w-auto' : panelOpen ? 'h-[min(46rem,calc(100dvh-1rem))] w-[min(72rem,calc(100vw-1rem))]' : expanded ? 'w-[min(35rem,calc(100vw-1rem))]' : 'w-[min(26rem,calc(100vw-1rem))]'), className);\n const rootStyle = phone || embedded ? undefined : { right: placed.right, bottom: placed.bottom };\n\n if (minimized) return <div ref={root} data-boring=\"ambient-chat\" data-state=\"minimized\" data-variant={variant} className={cn(rootClass, phone && 'flex justify-end')} style={rootStyle} onKeyDown={onKeyDown}>\n {notifications !== false && <AgentNotifications store={store} variant={variant} placement=\"inline\" systemNotifications={systemNotifications} onActivate={onActivate} labels={labels}\n {...(autoDismissMs !== undefined ? { autoDismissMs } : {})} {...(maxToasts !== undefined ? { max: maxToasts } : {})} className=\"absolute right-0 bottom-full mb-3 w-[min(26rem,calc(100vw-1rem))]\" />}\n <div data-testid=\"ambient-pill-box\" onPointerDown={event => beginDrag(event, 4)} onKeyDown={onGripKey} onClickCapture={event => { if (dragged.current) { dragged.current = false; event.stopPropagation(); event.preventDefault(); } }}\n className={cn('relative flex max-w-[min(20rem,calc(100vw-1rem))] items-center gap-0.5', !phone && 'cursor-grab touch-none select-none active:cursor-grabbing')}>\n {!phone && grip('ml-1.5')}\n <Button ref={pill} variant=\"outline\" size=\"lg\" data-testid=\"ambient-pill\" onClick={() => setWindowState('bar')} aria-label={labels.openAgent(shownTitle, unread)}\n className=\"relative min-w-0 gap-2.5\">\n {status === 'working' ? <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />\n : status === 'input' ? <MessageCircleQuestionIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n : status === 'done' ? <CircleCheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n : status === 'error' ? <CircleAlertIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : null}\n <span className=\"min-w-0 truncate\">{shownTitle}</span>\n {unread && <Badge data-testid=\"ambient-unread\" aria-hidden=\"true\" className=\"absolute -top-1.5 -right-1.5\" />}\n </Button>\n </div>\n </div>;\n\n const visibleRows = transcript.visible(rows);\n const conversation = <section className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n <header hidden={embedded} data-testid=\"ambient-header\" className={cn(embedded && '!hidden','flex h-12 shrink-0 items-center gap-0.5 px-2 max-sm:h-14 max-sm:px-3')}>\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-minimize\" aria-label={labels.minimize} title={labels.minimize} onClick={() => { setPicking(false); setWindowState(expanded ? 'bar' : 'minimized'); }}><icons.minimize data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n <span data-testid=\"ambient-status\" data-status={status ?? 'idle'} role=\"img\" aria-label={status === 'working' ? labels.statusWorking : status === 'input' ? labels.needsInput : status === 'done' ? labels.finished : status === 'error' ? labels.failedRun : undefined}\n aria-hidden={status ? undefined : 'true'} className={cn('flex shrink-0 items-center justify-center', status ? 'mx-1.5 w-5' : 'w-1.5')}>\n {status === 'working' ? <Loader2Icon className=\"size-4 animate-spin text-muted-foreground motion-reduce:animate-none\" aria-hidden=\"true\" /> : status ? <KindIcon kind={status === 'input' ? 'input' : status} className=\"size-[1.125rem]\" /> : null}\n </span>\n {avatar && <Avatar size=\"sm\" data-testid=\"ambient-avatar\" className=\"overflow-hidden\"><AvatarFallback className=\"*:max-h-full *:max-w-full\">{avatar}</AvatarFallback></Avatar>}\n <Button ref={titleButton} variant=\"ghost\" data-testid=\"ambient-title\" onClick={expand} disabled={expanded} aria-label={expanded ? shownTitle : labels.openChat(shownTitle)}\n className=\"min-w-0 max-w-full shrink\"><span className=\"truncate\">{shownTitle}</span></Button>\n {conversations && <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-switch\" aria-label={labels.switchConversation} aria-haspopup=\"dialog\" aria-expanded={picking} title={labels.switchConversation} onClick={() => setPicking(open => !open)}>\n <icons.switchConversation data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {expanded ? <span className=\"flex-1\" /> : <Button variant=\"ghost\" tabIndex={-1} aria-hidden=\"true\" data-testid=\"ambient-header-fill\" onClick={expand} className=\"min-w-2 flex-1\" />}\n {systemNotifications && permission === 'default' && <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-enable-notifications\" aria-label={labels.enableNotifications} title={labels.enableNotifications}\n onClick={() => { void requestNotifyPermission().then(setPermission); }}><icons.notify data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {headerTools}\n <BlockActions actions={headerActions} testId=\"ambient-action\" menuLabel={labels.moreActions} />\n {onDock && !phone && <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-dock\" aria-label={labels.dock} title={labels.dockHint} onClick={() => { setPicking(false); onDock(); }}><icons.dock data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {!phone && grip()}\n </header>\n\n {expanded && <div className=\"flex min-h-0 flex-1 flex-col\">\n {(connectionKind === 'error' || connectionKind === 'closed') && <div className=\"px-4 pb-2\"><Notice tone={connectionKind === 'error' ? 'error' : 'info'} testid=\"connection-notice\">\n <span className=\"flex-1\">{connectionKind === 'error' ? labels.loadFailed : labels.disconnected}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}><icons.reconnect data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.reconnect}</Button>}</Notice></div>}\n {derived.pinned.length > 0 && <aside aria-label={labels.requiredActions} className=\"shrink-0 space-y-2 px-4 pb-2\">{derived.pinned.map(card => <div key={card.key}>{card.content}</div>)}</aside>}\n <div className=\"relative flex min-h-0 flex-1 flex-col\">\n <MessageScroller>\n <MessageScrollerViewport ref={stick.scrollRef} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"scrollbar-thumb-border scrollbar-track-transparent\">\n {!empty && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div className=\"px-4 pt-4 pb-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\">\n {visibleRows.map(row => <MessageScrollerItem key={row.key} messageId={row.key} className=\"[content-visibility:visible]\"><RowView row={row} context={rowContext} /></MessageScrollerItem>)}\n {loading && <p role=\"status\" data-testid=\"loading\" className=\"m-0 flex items-center gap-2 text-sm text-muted-foreground\"><Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{labels.loading}</p>}\n {empty && !loading && <p data-testid=\"ambient-empty\" className=\"m-0 max-w-sm py-8 text-sm leading-6 text-muted-foreground\">{labels.empty}</p>}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {!stick.isAtBottom && <MessageScrollerButton data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={transcript.reset}><icons.jumpToLatest aria-hidden=\"true\" /></MessageScrollerButton>}\n </MessageScroller>\n </div>\n </div>}\n\n {(expanded || !embedded) && <Separator />}\n <div className={cn('shrink-0', phone && 'pb-[env(safe-area-inset-bottom)]')}>\n <div className=\"space-y-2 px-3 empty:hidden [&:has(*)]:pt-2\">\n <ChatNotices state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} />\n {!expanded && (connectionKind === 'error' || connectionKind === 'closed') && <Notice tone=\"info\" testid=\"connection-notice\">\n <span className=\"flex-1\">{connectionKind === 'error' ? labels.loadFailedShort : labels.disconnectedShort}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}>{labels.reconnect}</Button>}</Notice>}\n </div>\n <div className=\"px-3 pt-2 pb-3\">\n <ConversationFiles files={files} />\n <MessageQueue items={queued} sending={state.outbox} withdraw={actions?.withdraw} actions={queueActions} />\n <Composer {...composer} layout={composerLayout} placeholder={working || waitingForAnswer ? label : labels.placeholder} barStart={barStart} barNote={barNote} barEnd={tools} />\n </div>\n </div>\n </section>;\n return <div ref={root} data-boring={embedded ? 'pi-chat' : 'ambient-chat'} data-state={windowState} data-variant={variant} data-phone={phone ? 'true' : undefined} role=\"region\" aria-label={labels.region(shownTitle)}\n className={rootClass} style={rootStyle} onKeyDown={onKeyDown}>\n {!embedded && notifications !== false && !(phone && expanded) && <AgentNotifications store={store} variant={variant} placement=\"inline\" systemNotifications={systemNotifications} onActivate={onActivate} labels={labels}\n {...(autoDismissMs !== undefined ? { autoDismissMs } : {})} {...(maxToasts !== undefined ? { max: maxToasts } : {})} className=\"absolute right-0 bottom-full mb-3 w-full\" />}\n {picking && conversations && <ConversationHistory conversations={conversations} placement={expanded ? 'below' : 'above'} onClose={() => setPicking(false)} />}\n <div data-testid=\"ambient-surface\" className={surface}>\n {card && <Card aria-hidden=\"true\" data-boring=\"chrome\" className=\"pointer-events-none absolute inset-0 -z-10\" />}\n {artifactPanel ? <ArtifactWorkspace open={panelOpen} onClose={() => { setOpened(null); setFullscreen(false); }} fullscreen={fullscreen} onFullscreenChange={setFullscreen} storageKey=\"boring.ambient.panel-width\" panelLabel={labels.artifactPanel}\n defaultWidth={600} minPanel={320} minChat={300} sheetBelow={720}\n panel={api => shownArtifact ? artifactPanel(shownArtifact, { ...api, versions: known, follow: opened?.follow ?? true,\n select: version => setOpened(current => current ? (version === 'latest' ? { descriptor: known[0] ?? current.descriptor, follow: true } : { descriptor: version, follow: false }) : current) }) : null}\n chat={conversation} /> : conversation}\n </div>\n </div>;\n}\n",
|
|
31
|
+
"content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport type { ImageContent, ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { BellIcon, ChevronDownIcon, CircleAlertIcon, CircleCheckIcon, ExternalLinkIcon, GripVerticalIcon, Loader2Icon, MessageCircleQuestionIcon, MinusIcon, PanelLeftIcon, ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { MessageScroller, MessageScrollerProvider, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem, MessageScrollerButton } from '@/components/ui/message-scroller';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Avatar, AvatarFallback } from '@/components/ui/avatar';\nimport { Badge } from '@/components/ui/badge';\nimport { Card } from '@/components/ui/card';\nimport { Separator } from '@/components/ui/separator';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction, BlockIcon } from '../button/actions';\nimport { useChatPlacement } from '../pi-chat/use-remote-chat';\nimport { CopyButton } from '../pi-chat/code-block';\nimport { Composer } from '../pi-chat/composer';\nimport type { ComposerFeedback, ComposerProps } from '../pi-chat/composer';\nimport { ConversationFiles } from '../pi-chat/conversation-files';\nimport type { AttachmentsConfig, ConversationsConfig, EffortConfig, MentionsConfig, ModelConfig, SlashConfig } from '../pi-chat/config';\nimport { notifyPermission, requestNotifyPermission } from './browser-notify';\nimport type { NotifyPermission } from './browser-notify';\nimport { ConversationHistory, TranscriptHistory } from '../pi-chat/history';\nimport type { CommandMentions } from '../pi-chat/markdown';\nimport { RowView } from '../pi-chat/message';\nimport type { RowContext } from '../pi-chat/message';\nimport { ChatNotices, Notice } from '../pi-chat/notice';\nimport { AgentNotifications, KindIcon, createNotificationStore, defaultNotificationLabels, finishNotice, pendingQuestion, useNotifications } from './notifications';\nimport type { AgentNotification, NotificationLabels, NotificationStore } from './notifications';\nimport { ChatTextProvider, defaultChatIcons, defaultChatLabels, useMergedText } from '../pi-chat/labels';\nimport type { ChatIcons, ChatLabels } from '../pi-chat/labels';\nimport { MessageQueue } from '../pi-chat/queue';\nimport { artifactKey, collectArtifacts } from '../pi-chat/artifact';\nimport type { ArtifactDescriptor, ArtifactsConfig } from '../pi-chat/artifact';\nimport type { ChatCard, Mode } from '../pi-chat/rows';\nimport { useChatSession, useTranscript } from '../pi-chat/session';\nimport type { ChatFeatureProps, ChatSession } from '../pi-chat/session';\nimport { ArtifactWorkspace } from '../pi-workspace/workspace';\nimport type { WorkspacePanelApi } from '../pi-workspace/workspace';\nimport { cn } from '../utils/utils';\n\nexport { AgentNotifications, createNotificationStore, defaultNotificationLabels, summaryOf, useNotifications, watchConversation } from './notifications';\nexport type { AgentNotification, AgentNotificationsProps, NewNotification, NotificationKind, NotificationLabels, NotificationStore } from './notifications';\n\nconst elapsedText = (seconds: number) => seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`;\n\n/** The floating window's own words, over the chat's (`defaultChatLabels`) and the toasts' (`defaultNotificationLabels`). */\nexport const defaultAmbientLabels = {\n ...defaultChatLabels,\n ...defaultNotificationLabels,\n /** Shown while the conversation has no title of its own (the active item of `conversations` provides one). */\n title: 'Agent',\n /** The input's placeholder while idle. */\n placeholder: 'Do anything',\n /** The input's placeholder while the agent works. */\n working: (seconds: number) => `Working for ${elapsedText(seconds)}`,\n region: (title: string) => `Agent: ${title}`,\n openAgent: (title: string, unread: boolean) => `Open agent: ${title}${unread ? ' (new activity)' : ''}`,\n openChat: (title: string) => `Open chat: ${title}`,\n minimize: 'Minimise',\n switchConversation: 'Switch conversation',\n statusWorking: 'Working',\n enableNotifications: 'Notify me when the page is in the background',\n dock: 'Dock chat',\n dockHint: 'Dock chat beside the panel',\n move: 'Move the agent window (arrow keys, Home resets)',\n moveHint: 'Drag to move',\n goodResponse: 'Good response',\n badResponse: 'Bad response',\n openFull: 'Open in full chat',\n empty: 'Ask the agent to do something.',\n loadFailedShort: 'Could not load the conversation.',\n disconnectedShort: 'Disconnected.',\n artifactPanel: 'Artifact panel',\n};\nexport type AmbientLabels = ChatLabels & NotificationLabels & typeof defaultAmbientLabels;\n\n/** The floating window's icons, over the chat's. */\nexport const defaultAmbientIcons = {\n ...defaultChatIcons,\n minimize: MinusIcon as BlockIcon,\n dock: PanelLeftIcon as BlockIcon,\n grip: GripVerticalIcon as BlockIcon,\n switchConversation: ChevronDownIcon as BlockIcon,\n notify: BellIcon as BlockIcon,\n openFull: ExternalLinkIcon as BlockIcon,\n goodResponse: ThumbsUpIcon as BlockIcon,\n badResponse: ThumbsDownIcon as BlockIcon,\n};\nexport type AmbientIcons = ChatIcons & typeof defaultAmbientIcons;\nconst AMBIENT_TEXT = { labels: defaultAmbientLabels as AmbientLabels, icons: defaultAmbientIcons as AmbientIcons };\n\n/** `bar` is the compact bar, `expanded` the same window grown into a chat, `minimized` a small pill. */\nexport type AmbientState = 'bar' | 'expanded' | 'minimized';\n\n/** What the artifact panel inside the window gets besides the full-screen and close controls: the versions of the open artifact and how to switch. */\nexport interface AmbientArtifactPanelApi extends WorkspacePanelApi {\n /** Every known version of the open artifact in this conversation, newest first. */\n readonly versions: readonly ArtifactDescriptor[];\n /** True while the panel follows the newest version; false when one older version is pinned. */\n readonly follow: boolean;\n /** `'latest'` follows the newest version; a descriptor from `versions` pins that one. */\n readonly select: (version: ArtifactDescriptor | 'latest') => void;\n}\n\nexport interface AmbientChatProps extends ChatFeatureProps {\n readonly controller: NativeChatController;\n /** Embed the expanded conversation in a host workspace. Switching this flag changes layout without remounting the transcript or composer. The host supplies the header. */\n readonly embedded?: boolean;\n /**\n * Every word the window shows, over `defaultAmbientLabels` (the chat's labels, the toasts' and its own): `title` while the conversation has\n * none of its own, `placeholder` while idle, `working(seconds)` while the agent works.\n */\n readonly labels?: Partial<AmbientLabels> | undefined;\n /** The window's icons (minimise, dock, grip, …) and the chat's, over `defaultAmbientIcons`. */\n readonly icons?: Partial<AmbientIcons> | undefined;\n /** The agent's avatar before the title in the bar. */\n readonly avatar?: ReactNode;\n /** Host actions in the bar, before Dock: `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids `ambient-action-<id>`, the menu `ambient-action-more`. */\n readonly headerActions?: readonly BlockAction[] | undefined;\n /** Host-owned header controls, such as icon-anchored popovers, before the actions and Dock button. */\n readonly headerTools?: ReactNode;\n /** `contrast` is a dark window on any page: the root carries `.dark`, so it shows the host's own dark tokens; `surface` follows the host's theme. */\n readonly variant?: 'contrast' | 'surface';\n /** Controlled window state; otherwise `defaultState` starts it and the window keeps it. */\n readonly state?: AmbientState;\n readonly defaultState?: AmbientState;\n readonly onStateChange?: (state: AmbientState) => void;\n /** Where the bar was dragged is remembered under this key. */\n readonly storageKey?: string;\n /** `session` (default) remembers the position for this browser tab's session; `local` keeps it in `localStorage` across sessions. */\n readonly storage?: 'session' | 'local';\n /** Host controls in the composer row, between the effort picker and Send (for example a voice button the host guards itself). Absent: nothing is shown. */\n readonly tools?: ReactNode;\n /** Host content under the composer (for example a collapsed list of the person's requests). Absent: nothing is shown. */\n readonly belowComposer?: ReactNode;\n /** Two-row reference composer by default; older layouts require an explicit choice. */\n readonly composerLayout?: ComposerProps['layout'];\n /** Hand the conversation to a full `PiChat` or `ArtifactWorkspace`. Adds an \"Open in full chat\" action to replies. */\n readonly onOpenFull?: (conversationId: string) => void;\n /** Adds thumbs up and down to replies. `value` is `null` when the person takes the rating back. */\n readonly onFeedback?: (reply: { readonly key: string; readonly text: string }, value: 'up' | 'down' | null) => void;\n /** Toasts for finished, failed and waiting tasks. Pass a store to feed it for background conversations too; `false` turns them off. */\n readonly notifications?: NotificationStore | false;\n readonly autoDismissMs?: number;\n readonly maxToasts?: number;\n /** System notifications while the page is hidden. A bell in the header asks for permission when the person chooses to; nothing is asked on load. */\n readonly systemNotifications?: boolean;\n /**\n * The viewer shown inside the window when an artifact card is opened: the window widens into chat on the left and this panel on the right\n * (`ArtifactWorkspace`: draggable divider, full screen, a full-screen sheet on a phone). Build it from the viewer registry (`ViewerFrame`, ...),\n * wrapped in `ViewerWindowProvider` with `api.fullscreen` / `api.onFullscreenChange` so its bar shows the full screen button.\n */\n readonly artifactPanel?: (artifact: ArtifactDescriptor, api: AmbientArtifactPanelApi) => ReactNode;\n /** `window` (default when `artifactPanel` is given) opens artifacts inside the window; `host` calls `artifacts.open` instead, for hosts that show them in their own viewer. */\n readonly artifactTarget?: 'window' | 'host';\n /** The title's ⌄ opens this list to switch conversations or tasks. */\n readonly conversations?: ConversationsConfig;\n /** Adds a \"Dock chat\" action to the header, for a host that floats this window out of a docked chat (`ArtifactWorkspace` with `floatBelow`). It shares the controller with the docked `PiChat`, so nothing is lost. */\n readonly onDock?: () => void;\n /** The optional annotate toggle and notes chip in the composer (the registry `feedback` item's `useComposerFeedback`). Omit it and nothing changes. */\n readonly feedback?: ComposerFeedback;\n readonly className?: string;\n}\n\ninterface Position { readonly right: number; readonly bottom: number }\nconst MARGIN = 8, DEFAULT_POSITION: Position = { right: 16, bottom: 16 };\n\nfunction storageFor(persist: 'session' | 'local'): Storage | undefined { try { return persist === 'local' ? globalThis.localStorage : globalThis.sessionStorage; } catch { return undefined; } }\nfunction readPosition(key: string, persist: 'session' | 'local'): Position | undefined {\n try {\n const value: unknown = JSON.parse(storageFor(persist)?.getItem(key) ?? 'null');\n const { right, bottom } = (value ?? {}) as { right?: unknown; bottom?: unknown };\n return typeof right === 'number' && typeof bottom === 'number' && Number.isFinite(right) && Number.isFinite(bottom) ? { right, bottom } : undefined;\n } catch { return undefined; }\n}\nfunction writePosition(key: string, persist: 'session' | 'local', position: Position) {\n try { storageFor(persist)?.setItem(key, JSON.stringify(position)); } catch { /* the position is a convenience */ }\n}\nconst clamp = (position: Position, size: { w: number; h: number }, viewport: { w: number; h: number }): Position => ({\n right: Math.round(Math.min(Math.max(MARGIN, position.right), Math.max(MARGIN, viewport.w - size.w - MARGIN))),\n bottom: Math.round(Math.min(Math.max(MARGIN, position.bottom), Math.max(MARGIN, viewport.h - size.h - MARGIN))),\n});\n\n// The layout viewport that fixed positioning uses (without scrollbars), not the window's outer size.\nconst viewportSize = () => { const root = globalThis.document?.documentElement; return { w: root?.clientWidth || globalThis.innerWidth || 1024, h: root?.clientHeight || globalThis.innerHeight || 768 }; };\nfunction usePhone(): boolean {\n const query = '(max-width: 639px)';\n const [phone, setPhone] = useState(() => globalThis.matchMedia?.(query).matches === true);\n useEffect(() => {\n const list = globalThis.matchMedia?.(query);\n if (!list) return;\n const update = () => setPhone(list.matches);\n update();\n list.addEventListener('change', update);\n return () => list.removeEventListener('change', update);\n }, []);\n return phone;\n}\n\n/** Whole seconds since `active` became true; 0 while inactive. */\nfunction useElapsed(active: boolean): number {\n const [seconds, setSeconds] = useState(0);\n useEffect(() => {\n if (!active) { setSeconds(0); return; }\n const started = Date.now();\n setSeconds(0);\n const timer = setInterval(() => setSeconds(Math.floor((Date.now() - started) / 1000)), 1000);\n return () => clearInterval(timer);\n }, [active]);\n return seconds;\n}\n\n/** Pushes a toast when a run finishes or fails and when a question starts waiting; takes it back when the question is answered or a new run starts. */\nfunction useRunFeed({ enabled, store, conversationId, title, session, suppress, onOutcome, waiting }: {\n readonly enabled: boolean; readonly store: NotificationStore; readonly conversationId: string; readonly title: string; readonly session: ChatSession; readonly waiting: string;\n readonly suppress: () => boolean; readonly onOutcome: (outcome: 'done' | 'error' | null) => void;\n}) {\n const { working, waitingForAnswer, rows, connected } = session;\n const before = useRef({ working, waiting: waitingForAnswer });\n const latest = useRef({ rows, title, suppress, onOutcome, connected, waiting });\n latest.current = { rows, title, suppress, onOutcome, connected, waiting };\n useEffect(() => {\n const was = before.current;\n before.current = { working, waiting: waitingForAnswer };\n const now = latest.current;\n if (working && !was.working) { now.onOutcome(null); store.dismiss(`${conversationId}:done`); }\n if (!enabled) return;\n if (waitingForAnswer && !was.waiting && !now.suppress()) store.push({ id: `${conversationId}:input`, kind: 'input', title: now.title, summary: pendingQuestion(now.rows) ?? now.waiting, conversationId });\n if (!waitingForAnswer && was.waiting) store.dismiss(`${conversationId}:input`);\n if (was.working && !working && now.connected) {\n const finished = finishNotice(now.rows);\n if (!finished) return;\n now.onOutcome(finished.kind);\n if (!now.suppress()) store.push({ id: `${conversationId}:done`, kind: finished.kind, title: now.title, summary: finished.summary, conversationId });\n }\n }, [working, waitingForAnswer, enabled, store, conversationId]);\n}\n\n/** A compact floating agent bar over an existing page; it grows into a chat window in place. Borrows a concrete native controller like `PiChat`. */\nexport function AmbientChat(props: AmbientChatProps) {\n const active = useRef(props.controller); active.current = props.controller;\n const [mount, setMount] = useState({ controller: props.controller, sequence: 0 });\n if (mount.controller !== props.controller) setMount({ controller: props.controller, sequence: mount.sequence + 1 });\n // The window state and the toasts belong to the bar, not to one conversation: they survive switching conversations.\n const [local, setLocal] = useState<AmbientState>(props.defaultState ?? 'bar');\n if (props.embedded && local !== 'expanded') setLocal('expanded');\n const internalStore = useMemo(createNotificationStore, []);\n const text = useMergedText<AmbientLabels, AmbientIcons>(props.labels, props.icons, AMBIENT_TEXT);\n return <ChatTextProvider value={text}><MessageScrollerProvider key={mount.sequence} autoScroll><AmbientSession {...props} labels={text.labels} icons={text.icons} activeController={active} windowState={props.embedded ? 'expanded' : props.state ?? local} store={props.notifications || internalStore}\n onWindowState={next => { if (props.state === undefined) setLocal(next); props.onStateChange?.(next); }} /></MessageScrollerProvider></ChatTextProvider>;\n}\n\ntype SessionProps = Omit<AmbientChatProps, 'labels' | 'icons'> & { readonly labels: AmbientLabels; readonly icons: AmbientIcons; readonly activeController: { readonly current: NativeChatController }; readonly windowState: AmbientState; readonly store: NotificationStore; readonly onWindowState: (state: AmbientState) => void };\n\nfunction AmbientSession({ controller, embedded = false, labels, icons, avatar, headerActions, headerTools, messageActions, variant = 'contrast', mode = 'expert', actions,\n windowState, onWindowState, store, storageKey = 'boring.ambient.position', storage = 'session', tools, belowComposer, composerChips, composerLayout = 'card', onOpenFull, onFeedback, notifications, autoDismissMs, maxToasts, systemNotifications = false,\n renderEntry, renderTool, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, fileAccept = 'image/*', slash, mentions, attachments, onOpenFile, fileChanges, pasteToFile, model, effort, replyFooter, artifacts: hostArtifacts, artifactPanel, artifactTarget, conversations, onDock, feedback: composerFeedback, className,\n activeController }: SessionProps) {\n const { title } = labels;\n useChatPlacement(controller, embedded ? 'docked' : 'floating');\n const stateRef = useRef(windowState); stateRef.current = windowState;\n // The artifact open inside the window, and whether it follows the newest version. It belongs to the expanded window: leaving that state closes it.\n const [opened, setOpened] = useState<{ readonly descriptor: ArtifactDescriptor; readonly follow: boolean } | null>(null);\n const [fullscreen, setFullscreen] = useState(false);\n const setWindowState = (next: AmbientState) => {\n if (next !== 'expanded') { setOpened(null); setFullscreen(false); }\n if (next !== stateRef.current) onWindowState(next);\n };\n const expanded = windowState === 'expanded', minimized = windowState === 'minimized';\n const phone = usePhone();\n const root = useRef<HTMLDivElement>(null), titleButton = useRef<HTMLButtonElement>(null), pill = useRef<HTMLButtonElement>(null);\n const [picking, setPicking] = useState(false);\n const [feedback, setFeedback] = useState<Readonly<Record<string, 'up' | 'down'>>>({});\n const [outcome, setOutcome] = useState<'done' | 'error' | null>(null);\n const [permission, setPermission] = useState<NotifyPermission>('unsupported');\n useEffect(() => { setPermission(notifyPermission()); }, []);\n useEffect(() => { if (!outcome) return; const timer = setTimeout(() => setOutcome(null), 8000); return () => clearTimeout(timer); }, [outcome]);\n\n // Replies get copy, optional feedback and the time; the host may hand the conversation to its full chat.\n const conversationRef = useRef('');\n const replyActions = useMemo<NonNullable<RowContext['replyActions']>>(() => (reply, host) => <div data-testid=\"reply-actions\" className=\"mt-2 -ml-2 flex items-center gap-0.5 opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100 max-sm:opacity-100 pointer-coarse:opacity-100 motion-reduce:transition-none\">\n <CopyButton text={reply.text} label={labels.copyResponse} iconOnly {...(onCopy ? { onCopy } : {})} />\n {onFeedback && <>\n {(['up', 'down'] as const).map(value => <Toggle key={value} data-testid={`feedback-${value}`} pressed={feedback[reply.key] === value} aria-label={value === 'up' ? labels.goodResponse : labels.badResponse}\n title={value === 'up' ? labels.goodResponse : labels.badResponse}\n onPressedChange={pressed => { const next = pressed ? value : null; setFeedback(current => { const { [reply.key]: _removed, ...rest } = current; return next ? { ...rest, [reply.key]: next } : rest; }); onFeedback({ key: reply.key, text: reply.text }, next); }}>\n {value === 'up' ? <icons.goodResponse data-icon=\"inline-start\" aria-hidden=\"true\" /> : <icons.badResponse data-icon=\"inline-start\" aria-hidden=\"true\" />}</Toggle>)}\n </>}\n {onOpenFull && <Button variant=\"ghost\" size=\"icon\" data-testid=\"open-full\" aria-label={labels.openFull} title={labels.openFull} onClick={() => onOpenFull(conversationRef.current)}><icons.openFull data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {host}\n {reply.timestamp !== undefined && <time dateTime={new Date(reply.timestamp).toISOString()} data-testid=\"reply-time\" className=\"ml-1.5 text-xs tabular-nums text-muted-foreground\">{new Date(reply.timestamp).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}</time>}\n </div>, [onCopy, onFeedback, onOpenFull, feedback, labels, icons]);\n const scrollToBottom = useRef<() => void>(() => {});\n const rowExtras = useMemo(() => ({ replyActions }), [replyActions]);\n const inWindow = Boolean(artifactPanel) && artifactTarget !== 'host';\n const detect = hostArtifacts?.detect;\n const versionsRef = useRef<readonly ArtifactDescriptor[]>([]);\n const artifacts = useMemo<ArtifactsConfig | undefined>(() => !inWindow ? hostArtifacts : {\n open: descriptor => { const newest = versionsRef.current.filter(version => artifactKey(version) === artifactKey(descriptor)).reduce((top, version) => Math.max(top, version.ordinal ?? 0), 0); setOpened({ descriptor, follow: (descriptor.ordinal ?? 0) >= newest }); },\n isOpen: descriptor => opened !== null && artifactKey(opened.descriptor) === artifactKey(descriptor) && (opened.follow ? (descriptor.ordinal ?? 0) >= (versionsRef.current.find(version => artifactKey(version) === artifactKey(descriptor))?.ordinal ?? 0) : descriptor.revision === opened.descriptor.revision),\n ...(detect ? { detect } : {}),\n ...(hostArtifacts?.kinds ? { kinds: hostArtifacts.kinds } : {}),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [inWindow, hostArtifacts, opened, detect]);\n const session = useChatSession({ controller, activeController, mode, actions, renderEntry, renderTool, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => scrollToBottom.current(), rowExtras, feedback: composerFeedback, onOpenFile, fileChanges, pasteToFile, composerChips });\n const { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, error, act, textarea, rowContext, composer, files, loading } = session;\n const empty = session.empty;\n const transcript = useTranscript(rows, 60, 40, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier });\n const versions = useMemo(() => inWindow ? collectArtifacts(state.view, detect).sort((a, b) => (b.ordinal ?? 0) - (a.ordinal ?? 0)) : [], [inWindow, state.view, detect]);\n versionsRef.current = versions;\n const known = opened ? versions.filter(version => artifactKey(version) === artifactKey(opened.descriptor)) : [];\n const shownArtifact = opened ? (opened.follow ? known[0] ?? opened.descriptor : opened.descriptor) : undefined;\n const { stick, hidden } = transcript;\n scrollToBottom.current = () => { transcript.reset(); void stick.scrollToBottom('instant'); };\n const { barStart, barNote } = session.pickers();\n\n const conversationId = String(conversations?.activeId ?? state.conversationId);\n conversationRef.current = conversationId;\n const activeItem = conversations?.items.find(item => item.id === conversations.activeId);\n const shownTitle = activeItem?.title?.trim() || title;\n const toasts = useNotifications(store);\n useRunFeed({ enabled: notifications !== false, store, conversationId, title: shownTitle, session, onOutcome: setOutcome, waiting: labels.waitingForAnswer,\n suppress: () => stateRef.current === 'expanded' && globalThis.document?.visibilityState !== 'hidden' });\n const unread = !expanded && toasts.length > 0;\n const seconds = useElapsed(working);\n\n // Position: offsets from the bottom right corner, remembered for the session, clamped to the viewport at render so a smaller window restores them.\n const [position, setPosition] = useState<Position>(() => readPosition(storageKey, storage) ?? DEFAULT_POSITION);\n const [viewport, setViewport] = useState(viewportSize);\n const [size, setSize] = useState({ w: 0, h: 0 });\n useEffect(() => {\n const onResize = () => setViewport(viewportSize());\n globalThis.addEventListener('resize', onResize);\n return () => globalThis.removeEventListener('resize', onResize);\n }, []);\n useEffect(() => {\n const element = root.current;\n if (!element || typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(() => setSize({ w: element.offsetWidth, h: element.offsetHeight }));\n observer.observe(element);\n setSize({ w: element.offsetWidth, h: element.offsetHeight });\n return () => observer.disconnect();\n }, [minimized, expanded]);\n // The size and the viewport also follow every render, so the clamp below never works from a stale measurement (the title or status icon can change the pill's width; a scrollbar can appear without a resize event).\n useLayoutEffect(() => {\n const element = root.current;\n const fresh = viewportSize();\n setViewport(current => current.w === fresh.w && current.h === fresh.h ? current : fresh);\n if (element) setSize(current => current.w === element.offsetWidth && current.h === element.offsetHeight ? current : { w: element.offsetWidth, h: element.offsetHeight });\n });\n const placed = clamp(position, size, viewport);\n // Moves and the release are followed on the window, so a drag keeps working when the pointer leaves the handle (and when pointer capture is unavailable).\n // `threshold` is how far the pointer must travel before it is a drag: a click on the pill (threshold > 0) still restores it.\n const dragged = useRef(false);\n const beginDrag = (event: PointerEvent<HTMLElement>, threshold: number) => {\n if (phone || event.button !== 0) return;\n dragged.current = false;\n const origin = { pointer: event.pointerId, x: event.clientX, y: event.clientY, start: placed };\n let last = origin.start, moving = threshold === 0;\n const move = (next: globalThis.PointerEvent) => {\n if (next.pointerId !== origin.pointer) return;\n if (!moving) { if (Math.hypot(next.clientX - origin.x, next.clientY - origin.y) < threshold) return; moving = true; dragged.current = true; }\n last = clamp({ right: origin.start.right - (next.clientX - origin.x), bottom: origin.start.bottom - (next.clientY - origin.y) }, size, viewport);\n setPosition(last);\n };\n const end = (next: globalThis.PointerEvent) => {\n if (next.pointerId !== origin.pointer) return;\n globalThis.removeEventListener('pointermove', move); globalThis.removeEventListener('pointerup', end); globalThis.removeEventListener('pointercancel', end);\n if (moving) writePosition(storageKey, storage, last);\n };\n globalThis.addEventListener('pointermove', move); globalThis.addEventListener('pointerup', end); globalThis.addEventListener('pointercancel', end);\n };\n const onGripDown = (event: PointerEvent<HTMLButtonElement>) => { event.preventDefault(); beginDrag(event, 0); };\n // Arrow keys move the window (Shift for big steps); Home puts it back in the default corner.\n const onGripKey = (event: KeyboardEvent<HTMLElement>) => {\n if (phone) return;\n const step = event.shiftKey ? 64 : 16;\n const delta = event.key === 'ArrowLeft' ? { right: step, bottom: 0 } : event.key === 'ArrowRight' ? { right: -step, bottom: 0 } : event.key === 'ArrowUp' ? { right: 0, bottom: step } : event.key === 'ArrowDown' ? { right: 0, bottom: -step } : undefined;\n if (!delta && event.key !== 'Home') return;\n event.preventDefault();\n const next = delta ? clamp({ right: placed.right + delta.right, bottom: placed.bottom + delta.bottom }, size, viewport) : clamp(DEFAULT_POSITION, size, viewport);\n setPosition(next); writePosition(storageKey, storage, next);\n };\n const grip = (className?: string) => <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-grip\" aria-label={labels.move} title={labels.moveHint} onPointerDown={onGripDown} onKeyDown={onGripKey}\n className={cn('touch-none', className)}><icons.grip data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>;\n\n const expand = () => { setOutcome(null); store.dismissConversation(conversationId); setWindowState('expanded'); };\n useEffect(() => {\n if (expanded && !globalThis.matchMedia?.('(pointer: coarse)').matches) textarea.current?.focus();\n }, [expanded, textarea]);\n // The title is disabled while the window is open, so focus goes back to it once the bar is showing again.\n const refocusTitle = useRef(false);\n useEffect(() => { if (!expanded && refocusTitle.current) { refocusTitle.current = false; titleButton.current?.focus(); } }, [expanded]);\n const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n if (embedded || event.key !== 'Escape' || event.defaultPrevented || !expanded || picking) return;\n event.preventDefault();\n // Escape steps back: full screen, then the artifact, then the window.\n if (fullscreen) return setFullscreen(false);\n if (opened) return setOpened(null);\n refocusTitle.current = true;\n setWindowState('bar');\n };\n const onActivate = useCallback((item: AgentNotification) => {\n if (item.conversationId !== undefined && item.conversationId !== conversationId) conversations?.onSelect(item.conversationId);\n expand();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [conversationId, conversations]);\n\n const connectionKind = state.connection.kind;\n const status = waitingForAnswer ? 'input' : working ? 'working' : outcome;\n const label = waitingForAnswer ? labels.waitingForAnswer : labels.working(seconds);\n const panelOpen = expanded && shownArtifact !== undefined && artifactPanel !== undefined;\n // The floating window is drawn by the host's Card behind it (a sibling layer, so switching placement never remounts the chat);\n // embedded and full-screen phone windows are plain page regions.\n const card = !embedded && !(phone && expanded);\n const surface = embedded ? 'relative flex min-h-0 flex-1 flex-col overflow-clip bg-transparent text-foreground' : cn('relative flex flex-col', card ? 'isolate' : 'h-dvh bg-background pt-[env(safe-area-inset-top)] text-foreground',\n !phone && expanded && (panelOpen ? 'h-full overflow-hidden' : 'max-h-[70dvh]'));\n // With an artifact open the window widens (most of the viewport, at most 72rem) and takes a fixed height so the panel fills it.\n const rootClass = embedded ? cn('pi-chat relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden', className) : cn('pi-chat pi-ambient fixed z-50', variant === 'contrast' && 'dark scheme-dark', phone ? (expanded ? 'inset-0' : 'inset-x-2 bottom-[max(0.5rem,env(safe-area-inset-bottom))]')\n : cn('transition-[width] motion-reduce:transition-none', minimized ? 'w-auto' : panelOpen ? 'h-[min(46rem,calc(100dvh-1rem))] w-[min(72rem,calc(100vw-1rem))]' : expanded ? 'w-[min(35rem,calc(100vw-1rem))]' : 'w-[min(26rem,calc(100vw-1rem))]'), className);\n const rootStyle = phone || embedded ? undefined : { right: placed.right, bottom: placed.bottom };\n\n if (minimized) return <div ref={root} data-boring=\"ambient-chat\" data-state=\"minimized\" data-variant={variant} className={cn(rootClass, phone && 'flex justify-end')} style={rootStyle} onKeyDown={onKeyDown}>\n {notifications !== false && <AgentNotifications store={store} variant={variant} placement=\"inline\" systemNotifications={systemNotifications} onActivate={onActivate} labels={labels}\n {...(autoDismissMs !== undefined ? { autoDismissMs } : {})} {...(maxToasts !== undefined ? { max: maxToasts } : {})} className=\"absolute right-0 bottom-full mb-3 w-[min(26rem,calc(100vw-1rem))]\" />}\n <div data-testid=\"ambient-pill-box\" onPointerDown={event => beginDrag(event, 4)} onKeyDown={onGripKey} onClickCapture={event => { if (dragged.current) { dragged.current = false; event.stopPropagation(); event.preventDefault(); } }}\n className={cn('relative flex max-w-[min(20rem,calc(100vw-1rem))] items-center gap-0.5', !phone && 'cursor-grab touch-none select-none active:cursor-grabbing')}>\n {!phone && grip('ml-1.5')}\n <Button ref={pill} variant=\"outline\" size=\"lg\" data-testid=\"ambient-pill\" onClick={() => setWindowState('bar')} aria-label={labels.openAgent(shownTitle, unread)}\n className=\"relative min-w-0 gap-2.5\">\n {status === 'working' ? <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />\n : status === 'input' ? <MessageCircleQuestionIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n : status === 'done' ? <CircleCheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n : status === 'error' ? <CircleAlertIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : null}\n <span className=\"min-w-0 truncate\">{shownTitle}</span>\n {unread && <Badge data-testid=\"ambient-unread\" aria-hidden=\"true\" className=\"absolute -top-1.5 -right-1.5\" />}\n </Button>\n </div>\n </div>;\n\n const visibleRows = transcript.visible(rows);\n const conversation = <section className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n <header hidden={embedded} data-testid=\"ambient-header\" className={cn(embedded && '!hidden','flex h-12 shrink-0 items-center gap-0.5 px-2 max-sm:h-14 max-sm:px-3')}>\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-minimize\" aria-label={labels.minimize} title={labels.minimize} onClick={() => { setPicking(false); setWindowState(expanded ? 'bar' : 'minimized'); }}><icons.minimize data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n <span data-testid=\"ambient-status\" data-status={status ?? 'idle'} role=\"img\" aria-label={status === 'working' ? labels.statusWorking : status === 'input' ? labels.needsInput : status === 'done' ? labels.finished : status === 'error' ? labels.failedRun : undefined}\n aria-hidden={status ? undefined : 'true'} className={cn('flex shrink-0 items-center justify-center', status ? 'mx-1.5 w-5' : 'w-1.5')}>\n {status === 'working' ? <Loader2Icon className=\"size-4 animate-spin text-muted-foreground motion-reduce:animate-none\" aria-hidden=\"true\" /> : status ? <KindIcon kind={status === 'input' ? 'input' : status} className=\"size-[1.125rem]\" /> : null}\n </span>\n {avatar && <Avatar size=\"sm\" data-testid=\"ambient-avatar\" className=\"overflow-hidden\"><AvatarFallback className=\"*:max-h-full *:max-w-full\">{avatar}</AvatarFallback></Avatar>}\n <Button ref={titleButton} variant=\"ghost\" data-testid=\"ambient-title\" onClick={expand} disabled={expanded} aria-label={expanded ? shownTitle : labels.openChat(shownTitle)}\n className=\"min-w-0 max-w-full shrink\"><span className=\"truncate\">{shownTitle}</span></Button>\n {conversations && <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-switch\" aria-label={labels.switchConversation} aria-haspopup=\"dialog\" aria-expanded={picking} title={labels.switchConversation} onClick={() => setPicking(open => !open)}>\n <icons.switchConversation data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {expanded ? <span className=\"flex-1\" /> : <Button variant=\"ghost\" tabIndex={-1} aria-hidden=\"true\" data-testid=\"ambient-header-fill\" onClick={expand} className=\"min-w-2 flex-1\" />}\n {systemNotifications && permission === 'default' && <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-enable-notifications\" aria-label={labels.enableNotifications} title={labels.enableNotifications}\n onClick={() => { void requestNotifyPermission().then(setPermission); }}><icons.notify data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {headerTools}\n <BlockActions actions={headerActions} testId=\"ambient-action\" menuLabel={labels.moreActions} />\n {onDock && !phone && <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-dock\" aria-label={labels.dock} title={labels.dockHint} onClick={() => { setPicking(false); onDock(); }}><icons.dock data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {!phone && grip()}\n </header>\n\n {expanded && <div className=\"flex min-h-0 flex-1 flex-col\">\n {(connectionKind === 'error' || connectionKind === 'closed') && <div className=\"px-4 pb-2\"><Notice tone={connectionKind === 'error' ? 'error' : 'info'} testid=\"connection-notice\">\n <span className=\"flex-1\">{connectionKind === 'error' ? labels.loadFailed : labels.disconnected}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}><icons.reconnect data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.reconnect}</Button>}</Notice></div>}\n {derived.pinned.length > 0 && <aside aria-label={labels.requiredActions} className=\"shrink-0 space-y-2 px-4 pb-2\">{derived.pinned.map(card => <div key={card.key}>{card.content}</div>)}</aside>}\n <div className=\"relative flex min-h-0 flex-1 flex-col\">\n <MessageScroller>\n <MessageScrollerViewport ref={stick.scrollRef} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"scrollbar-thumb-border scrollbar-track-transparent\">\n {!empty && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div className=\"px-4 pt-4 pb-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\">\n {visibleRows.map(row => <MessageScrollerItem key={row.key} messageId={row.key} className=\"[content-visibility:visible]\"><RowView row={row} context={rowContext} /></MessageScrollerItem>)}\n {loading && <p role=\"status\" data-testid=\"loading\" className=\"m-0 flex items-center gap-2 text-sm text-muted-foreground\"><Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{labels.loading}</p>}\n {empty && !loading && <p data-testid=\"ambient-empty\" className=\"m-0 max-w-sm py-8 text-sm leading-6 text-muted-foreground\">{labels.empty}</p>}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {!stick.isAtBottom && <MessageScrollerButton data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={transcript.reset}><icons.jumpToLatest aria-hidden=\"true\" /></MessageScrollerButton>}\n </MessageScroller>\n </div>\n </div>}\n\n {(expanded || !embedded) && <Separator />}\n <div className={cn('shrink-0', phone && 'pb-[env(safe-area-inset-bottom)]')}>\n <div className=\"space-y-2 px-3 empty:hidden [&:has(*)]:pt-2\">\n <ChatNotices state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} />\n {!expanded && (connectionKind === 'error' || connectionKind === 'closed') && <Notice tone=\"info\" testid=\"connection-notice\">\n <span className=\"flex-1\">{connectionKind === 'error' ? labels.loadFailedShort : labels.disconnectedShort}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}>{labels.reconnect}</Button>}</Notice>}\n </div>\n <div className=\"px-3 pt-2 pb-3\">\n <ConversationFiles files={files} />\n <MessageQueue items={queued} sending={state.outbox} withdraw={actions?.withdraw} actions={queueActions} />\n <Composer {...composer} layout={composerLayout} placeholder={working || waitingForAnswer ? label : labels.placeholder} barStart={barStart} barNote={barNote} barEnd={tools} />\n {belowComposer !== undefined && <div data-testid=\"below-composer\" className=\"pt-2\">{belowComposer}</div>}\n </div>\n </div>\n </section>;\n return <div ref={root} data-boring={embedded ? 'pi-chat' : 'ambient-chat'} data-state={windowState} data-variant={variant} data-phone={phone ? 'true' : undefined} role=\"region\" aria-label={labels.region(shownTitle)}\n className={rootClass} style={rootStyle} onKeyDown={onKeyDown}>\n {!embedded && notifications !== false && !(phone && expanded) && <AgentNotifications store={store} variant={variant} placement=\"inline\" systemNotifications={systemNotifications} onActivate={onActivate} labels={labels}\n {...(autoDismissMs !== undefined ? { autoDismissMs } : {})} {...(maxToasts !== undefined ? { max: maxToasts } : {})} className=\"absolute right-0 bottom-full mb-3 w-full\" />}\n {picking && conversations && <ConversationHistory conversations={conversations} placement={expanded ? 'below' : 'above'} onClose={() => setPicking(false)} />}\n <div data-testid=\"ambient-surface\" className={surface}>\n {card && <Card aria-hidden=\"true\" data-boring=\"chrome\" className=\"pointer-events-none absolute inset-0 -z-10\" />}\n {artifactPanel ? <ArtifactWorkspace open={panelOpen} onClose={() => { setOpened(null); setFullscreen(false); }} fullscreen={fullscreen} onFullscreenChange={setFullscreen} storageKey=\"boring.ambient.panel-width\" panelLabel={labels.artifactPanel}\n defaultWidth={600} minPanel={320} minChat={300} sheetBelow={720}\n panel={api => shownArtifact ? artifactPanel(shownArtifact, { ...api, versions: known, follow: opened?.follow ?? true,\n select: version => setOpened(current => current ? (version === 'latest' ? { descriptor: known[0] ?? current.descriptor, follow: true } : { descriptor: version, follow: false }) : current) }) : null}\n chat={conversation} /> : conversation}\n </div>\n </div>;\n}\n",
|
|
32
32
|
"type": "registry:component",
|
|
33
33
|
"target": "components/pi-ambient/ambient.tsx"
|
|
34
34
|
},
|