@hachej/boring-ui-kit 0.4.48 → 0.4.50
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/THIRD_PARTY_NOTICES.md +208 -0
- package/bin/boring-ui-blocks.mjs +33 -1
- package/dist/drawio-viewer.d.ts +4 -0
- package/dist/drawio-viewer.js +6 -0
- package/dist/file-tree-view.d.ts +25 -2
- package/dist/file-tree-view.js +8 -4
- package/dist/vendor/drawio-viewer-static.d.ts +3 -0
- package/dist/vendor/drawio-viewer-static.js +5 -0
- package/package.json +6 -2
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +12 -12
- package/r/board-viewer.json +6 -6
- package/r/button.json +1 -1
- package/r/dock.json +1 -1
- package/r/documents.json +12 -12
- package/r/drawing-viewer.json +57 -0
- package/r/feedback.json +6 -6
- package/r/file-tree.json +5 -5
- package/r/highlights.json +3 -3
- package/r/html-viewer.json +3 -3
- package/r/looks-viewer.json +2 -2
- package/r/maquette-viewer.json +1 -1
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +1 -1
- package/r/pi-ambient.json +9 -9
- package/r/pi-app-kinds.json +10 -10
- package/r/pi-app.json +10 -10
- package/r/pi-artifacts.json +2 -2
- package/r/pi-chat-approval.json +2 -2
- package/r/pi-chat-attachments.json +1 -1
- package/r/pi-chat-markdown.json +2 -2
- package/r/pi-chat-mentions.json +1 -1
- package/r/pi-chat-pickers.json +1 -1
- package/r/pi-chat-plugins.json +11 -11
- package/r/pi-chat-question.json +1 -1
- package/r/pi-chat-slash.json +2 -2
- package/r/pi-chat-subagent.json +1 -1
- package/r/pi-chat-voice.json +3 -3
- package/r/pi-chat.json +9 -9
- package/r/pi-workspace.json +3 -3
- package/r/placed-agent.json +4 -4
- package/r/provider-setup.json +1 -1
- package/r/registry.json +210 -161
- package/r/shared-chat.json +6 -6
- package/r/spaces.json +1 -1
- package/r/task-list-viewer.json +2 -2
- package/r/version-history.json +2 -2
- package/r/viewer-code.json +2 -2
- package/r/viewer-frame.json +4 -4
- package/r/viewer-html.json +4 -4
- package/r/viewer-image.json +2 -2
- package/r/viewer-markdown.json +5 -5
- package/r/viewer-pdf.json +3 -3
- package/r/viewers.json +6 -6
- package/r/windows.json +1 -1
- package/styles/file-tree.css +4 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "drawing-viewer",
|
|
4
|
+
"title": "Drawing viewer",
|
|
5
|
+
"description": "DrawingViewer, drawingKind and describeDrawing: an Excalidraw (.excalidraw) or draw.io (.drawio) file, read-only, drawn by the tool's own renderer on a white page fitted to the shapes: Excalidraw's exportToSvg (its fonts served by the app, never its CDN) and the official diagrams.net viewer, vendored in the kit (@hachej/boring-ui-kit/drawio-viewer), in a sandboxed frame without network. Shown as an image, so nothing in the file runs; an unreadable file shows an inline error. onRead hands the shapes and arrows the renderer read (kind, place, size, label, colours, from and to). Each renderer loads the first time a drawing of its format opens; the kind is not in the pi-app-kinds default list, so apps without drawings install nothing.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.50",
|
|
8
|
+
"react@^19.0.0",
|
|
9
|
+
"lucide-react@^1.51.0",
|
|
10
|
+
"@excalidraw/excalidraw@0.18.1"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "registry/drawing-viewer/drawing.ts",
|
|
15
|
+
"content": "// What a drawing is once read (D-1398): the shapes and arrows the native library loaded, in the drawing's own coordinates. The drawing\n// viewer hands it to `onRead`, so an app can describe a drawing to a model without parsing the file again. This module is cheap: it\n// loads neither Excalidraw nor the draw.io viewer, which the viewer imports only when a drawing of that format opens.\n\n/** The two formats: an Excalidraw scene (JSON) and a draw.io file (XML, plain or compressed). */\nexport type DrawingFormat = 'excalidraw' | 'drawio';\n\n/** A shape: its kind in the native vocabulary (Excalidraw `rectangle`, `ellipse`, `diamond`, `text`, `freedraw`, …; draw.io `rectangle`, `ellipse`, `rhombus`, `swimlane`, …). */\nexport interface DrawingShape {\n readonly id: string;\n readonly kind: string;\n readonly x: number;\n readonly y: number;\n readonly width: number;\n readonly height: number;\n /** Degrees clockwise around the box's centre, when the shape is turned (x, y, width and height are the unturned box). */\n readonly rotation?: number | undefined;\n /** Its text: a container's bound text, or a text element's own; HTML stripped. */\n readonly label?: string | undefined;\n readonly stroke?: string | undefined;\n readonly fill?: string | undefined;\n /** The group or container it sits in, when there is one. */\n readonly parent?: string | undefined;\n}\n\n/** An arrow or a line: the shapes it joins (absent when an end is free) and its points, from start to end, on their outlines. */\nexport interface DrawingArrow {\n readonly id: string;\n readonly kind: 'arrow' | 'line';\n readonly from?: string | undefined;\n readonly to?: string | undefined;\n readonly points: readonly (readonly [number, number])[];\n readonly label?: string | undefined;\n readonly stroke?: string | undefined;\n}\n\nexport interface Drawing {\n readonly format: DrawingFormat;\n /** The bounds of everything drawn, in the drawing's coordinates. */\n readonly bounds: { readonly x: number; readonly y: number; readonly width: number; readonly height: number };\n readonly shapes: readonly DrawingShape[];\n readonly arrows: readonly DrawingArrow[];\n}\n\n/** The format of a file by its name (`.excalidraw`, `.excalidraw.json`, `.drawio`, `.drawio.xml`, `.dio`), else by its text. */\nexport function drawingFormat(path: string | undefined, text?: string): DrawingFormat | undefined {\n const name = (path ?? '').toLowerCase();\n if (/\\.excalidraw(?:\\.json)?$/.test(name)) return 'excalidraw';\n if (/\\.(?:drawio(?:\\.xml)?|dio)$/.test(name)) return 'drawio';\n const start = (text ?? '').trimStart();\n if (start.startsWith('{') && /\"type\"\\s*:\\s*\"excalidraw\"/.test(start.slice(0, 400))) return 'excalidraw';\n if (start.startsWith('<') && /<(?:mxfile|mxGraphModel|diagram)\\b/.test(start.slice(0, 8000))) return 'drawio';\n return undefined;\n}\n\n/** Whether a path names a drawing file. */\nexport const isDrawingPath = (path: string): boolean => drawingFormat(path) !== undefined;\n\nconst round = (value: number) => Math.round(value);\nconst name = (drawing: Drawing, id: string | undefined) => {\n if (!id) return 'nothing';\n const shape = drawing.shapes.find(entry => entry.id === id);\n return shape ? (shape.label ? `\"${shape.label}\"` : `${shape.kind} ${id}`) : id;\n};\n\n/** A plain-text account of a drawing for a model: every shape with its place, size, label and colours, then every arrow with its ends. */\nexport function describeDrawing(drawing: Drawing): string {\n const lines = [`A ${drawing.format === 'drawio' ? 'draw.io' : 'Excalidraw'} drawing of ${drawing.shapes.length} shapes and ${drawing.arrows.length} arrows, ${round(drawing.bounds.width)}×${round(drawing.bounds.height)}.`];\n for (const shape of drawing.shapes) {\n const colours = [shape.stroke && `stroke ${shape.stroke}`, shape.fill && `fill ${shape.fill}`].filter(Boolean).join(', ');\n lines.push(`- ${shape.kind} ${shape.id}${shape.label ? ` \"${shape.label}\"` : ''} at (${round(shape.x)}, ${round(shape.y)}), ${round(shape.width)}×${round(shape.height)}${colours ? `, ${colours}` : ''}${shape.parent ? `, in ${name(drawing, shape.parent)}` : ''}`);\n }\n for (const arrow of drawing.arrows) lines.push(`- ${arrow.kind} ${arrow.id}${arrow.label ? ` \"${arrow.label}\"` : ''} from ${name(drawing, arrow.from)} to ${name(drawing, arrow.to)}`);\n return lines.join('\\n');\n}\n\n/** Why a drawing could not be shown: the file is not one (`unreadable`), it draws nothing (`empty`) or the native renderer failed (`renderer`). */\nexport type DrawingFailure = 'unreadable' | 'empty' | 'renderer';\n\nexport class DrawingError extends Error {\n readonly reason: DrawingFailure;\n constructor(reason: DrawingFailure, message?: string) { super(message ?? reason); this.name = 'DrawingError'; this.reason = reason; }\n}\n\n/** A drawing as the native library drew it: an SVG document (shown as an image, so nothing in it runs) and what it read. */\nexport interface RenderedDrawing {\n readonly svg: string;\n readonly width: number;\n readonly height: number;\n readonly drawing: Drawing;\n /** What the renderer was refused (a draw.io image, stencil or font from the network): those parts are not drawn. */\n readonly blocked: readonly string[];\n}\n\n/** The bounds of boxes, or an empty box at the origin. */\nexport function boundsOf(boxes: readonly { readonly x: number; readonly y: number; readonly width: number; readonly height: number }[]): Drawing['bounds'] {\n if (!boxes.length) return { x: 0, y: 0, width: 0, height: 0 };\n const left = Math.min(...boxes.map(box => box.x)), top = Math.min(...boxes.map(box => box.y));\n const right = Math.max(...boxes.map(box => box.x + box.width)), bottom = Math.max(...boxes.map(box => box.y + box.height));\n return { x: left, y: top, width: right - left, height: bottom - top };\n}\n",
|
|
16
|
+
"type": "registry:lib",
|
|
17
|
+
"target": "components/drawing-viewer/drawing.ts"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"path": "registry/drawing-viewer/drawing-labels.ts",
|
|
21
|
+
"content": "/* The drawing viewer's words in English and French. `locale` picks the base set; a host's `labels` still override any of them. */\nimport type { DrawingFailure, DrawingFormat } from './drawing';\n\nexport type DrawingLocale = 'en' | 'fr';\n\nexport const defaultDrawingLabels = {\n /** The kind's name on the artifact card and in the panel bar. */\n kind: 'Drawing',\n loading: 'Drawing…',\n /** The image's text alternative. */\n image: (format: DrawingFormat, shapes: number, arrows: number) => `${format === 'drawio' ? 'draw.io' : 'Excalidraw'} drawing: ${shapes} shapes, ${arrows} arrows`,\n errorTitle: 'This drawing cannot be shown',\n error: (reason: DrawingFailure) => reason === 'unreadable' ? 'The file is not an Excalidraw (.excalidraw) or draw.io (.drawio) drawing, or it is damaged.'\n : reason === 'empty' ? 'The drawing is empty: there is nothing to draw.' : 'The drawing could not be drawn. Try opening it again.',\n};\nexport type DrawingLabels = typeof defaultDrawingLabels;\n\n/** Typed as `DrawingLabels`, so a key added in English must be added here too. */\nexport const frenchDrawingLabels: DrawingLabels = {\n kind: 'Dessin',\n loading: 'Dessin en cours…',\n image: (format, shapes, arrows) => `Dessin ${format === 'drawio' ? 'draw.io' : 'Excalidraw'} : ${shapes} formes, ${arrows} flèches`,\n errorTitle: 'Ce dessin ne peut pas être affiché',\n error: reason => reason === 'unreadable' ? 'Le fichier n’est pas un dessin Excalidraw (.excalidraw) ou draw.io (.drawio), ou il est abîmé.'\n : reason === 'empty' ? 'Le dessin est vide : il n’y a rien à dessiner.' : 'Le dessin n’a pas pu être tracé. Rouvrez-le.',\n};\n\n/** The viewer's words for `locale` (English by default) with the host's overrides. */\nexport const drawingLabelsFor = (locale: DrawingLocale | undefined, labels?: Partial<DrawingLabels>): DrawingLabels =>\n ({ ...(locale === 'fr' ? frenchDrawingLabels : defaultDrawingLabels), ...labels });\n",
|
|
22
|
+
"type": "registry:lib",
|
|
23
|
+
"target": "components/drawing-viewer/drawing-labels.ts"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"path": "registry/drawing-viewer/excalidraw-render.ts",
|
|
27
|
+
"content": "// An Excalidraw scene drawn by Excalidraw itself (D-1398): `restore` (the library's own reader: defaults, migrations, bound text) then\n// `exportToSvg` on a white page, so the drawing has Excalidraw's hand-drawn strokes, fills and fonts. The library loads with this module,\n// which the viewer imports only when an Excalidraw file opens. Fonts come from the app (`assets`, the folder that serves Excalidraw's\n// `dist/prod/fonts`). Excalidraw falls back to its CDN (esm.sh) for a font it cannot get from there; while an export runs, this module\n// refuses the CDN (a font fetch, and the CDN sources of the font faces it creates), so a drawing never fetches from it; an export that fails is drawn\n// again without fonts. The page's `fetch` and `FontFace` are given back as they were once no drawing is being made.\nimport type { DrawingArrow, DrawingShape, RenderedDrawing } from './drawing';\nimport { DrawingError } from './drawing';\n\ndeclare global { interface Window { EXCALIDRAW_ASSET_PATH?: string | string[] } }\n\n/** The fields of an Excalidraw element this module reads (the library's element type, structurally). */\ninterface Element {\n readonly id: string;\n readonly type: string;\n readonly x: number;\n readonly y: number;\n readonly width: number;\n readonly height: number;\n readonly angle?: number;\n readonly isDeleted?: boolean;\n readonly strokeColor?: string;\n readonly backgroundColor?: string;\n readonly frameId?: string | null;\n readonly containerId?: string | null;\n readonly text?: string;\n readonly originalText?: string;\n readonly points?: readonly (readonly [number, number])[];\n readonly startBinding?: { readonly elementId: string } | null;\n readonly endBinding?: { readonly elementId: string } | null;\n}\n\ntype Point = readonly [number, number];\n\nconst FONT_PROBE = 'fonts/Virgil/Virgil-Regular.woff2';\n/** Where Excalidraw 0.18 falls back for its fonts (`ExcalidrawFontFace.ASSETS_FALLBACK_URL`). */\nconst FONT_CDN = /^https:\\/\\/esm\\.sh\\/@excalidraw\\/excalidraw(?:@|\\/)/;\nconst colour = (value: string | undefined) => value && value !== 'transparent' ? value : undefined;\n\n/** A `FontFace` source list without Excalidraw's CDN entries (`url(https://esm.sh/@excalidraw/…) format('woff2')`). */\nfunction withoutCdnSources(source: string): string {\n const kept = source.split(/,\\s*(?=url\\(|local\\()/).filter(part => !FONT_CDN.test(part.replace(/^url\\(\\s*[\"']?/, '')));\n return kept.length ? kept.join(', ') : 'local(\"Excalidraw font not served\")';\n}\n\nlet running = 0;\nlet restore: (() => void) | undefined;\n/**\n * Runs `work` (Excalidraw's import and export) with its CDN refused, by the two ways Excalidraw reaches it: a `fetch` of a font to inline\n * is refused, and a `FontFace` it creates drops its CDN sources. Every other request and font goes through unchanged. Both are in place\n * only while work runs (counted, for drawings side by side) and are given back after the last one, unless the page replaced them meanwhile.\n */\nasync function withoutFontCdn<T>(work: () => Promise<T>): Promise<T> {\n if (running++ === 0) {\n const pageFetch = globalThis.fetch, PageFontFace = globalThis.FontFace;\n const fetchGuard = typeof pageFetch === 'function' ? (input: RequestInfo | URL, init?: RequestInit) => {\n const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;\n return FONT_CDN.test(url) ? Promise.reject(new TypeError('The drawing viewer does not fetch Excalidraw fonts from its CDN.')) : pageFetch(input, init);\n } : undefined;\n const fontGuard = typeof PageFontFace === 'function' ? new Proxy(PageFontFace, {\n construct: (target, [family, source, descriptors]: ConstructorParameters<typeof FontFace>) =>\n new target(family, typeof source === 'string' ? withoutCdnSources(source) : source, descriptors),\n }) : undefined;\n if (fetchGuard) globalThis.fetch = fetchGuard;\n if (fontGuard) globalThis.FontFace = fontGuard;\n restore = () => {\n if (fetchGuard && globalThis.fetch === fetchGuard) globalThis.fetch = pageFetch;\n if (fontGuard && globalThis.FontFace === fontGuard) globalThis.FontFace = PageFontFace;\n };\n }\n try { return await work(); }\n finally { if (--running === 0) { restore?.(); restore = undefined; } }\n}\n\n/** The base URL Excalidraw resolves its fonts against, as the library normalises it. */\nfunction baseOf(assets: string): URL {\n return new URL(`${assets.replace(/\\/+$/, '')}/`, globalThis.location?.href);\n}\n\n/** Whether the app serves Excalidraw's fonts under `assets` (a font, not an HTML fallback page). */\nasync function fontsServed(assets: string): Promise<boolean> {\n try {\n const response = await fetch(new URL(FONT_PROBE, baseOf(assets)), { cache: 'force-cache' });\n return response.ok && !/html/i.test(response.headers.get('content-type') ?? '');\n } catch { return false; }\n}\n\n/** `point` turned by `angle` (radians, clockwise on screen as Excalidraw draws) around `centre`. */\nfunction turn([x, y]: Point, [cx, cy]: Point, angle: number): Point {\n if (!angle) return [x, y];\n const cos = Math.cos(angle), sin = Math.sin(angle);\n return [cx + (x - cx) * cos - (y - cy) * sin, cy + (x - cx) * sin + (y - cy) * cos];\n}\n\n/** A linear element's points on the page: its local points moved to its place and turned by its angle around their own box's centre. */\nfunction pagePoints(element: Element): Point[] {\n const local = element.points ?? [];\n if (!local.length) return [];\n const xs = local.map(([x]) => x), ys = local.map(([, y]) => y);\n const centre: Point = [element.x + (Math.min(...xs) + Math.max(...xs)) / 2, element.y + (Math.min(...ys) + Math.max(...ys)) / 2];\n return local.map(([x, y]) => turn([element.x + x, element.y + y], centre, element.angle ?? 0));\n}\n\n/** The scene's shapes and arrows from the restored elements: a container's text is its label, deleted elements are left out. */\nexport function readExcalidraw(elements: readonly Element[]): { readonly shapes: DrawingShape[]; readonly arrows: DrawingArrow[] } {\n const live = elements.filter(element => !element.isDeleted);\n const bound = new Map(live.filter(element => element.type === 'text' && element.containerId).map(element => [element.containerId!, element.originalText ?? element.text ?? '']));\n const shapes: DrawingShape[] = [], arrows: DrawingArrow[] = [];\n for (const element of live) {\n if (element.type === 'text' && element.containerId && live.some(other => other.id === element.containerId)) continue;\n const label = element.type === 'text' ? element.originalText ?? element.text : bound.get(element.id);\n if (element.type === 'arrow' || element.type === 'line') {\n arrows.push({ id: element.id, kind: element.type, from: element.startBinding?.elementId, to: element.endBinding?.elementId, points: pagePoints(element), label: label || undefined, stroke: colour(element.strokeColor) });\n continue;\n }\n const rotation = element.angle ? Math.round((element.angle * 180 / Math.PI) * 100) / 100 : undefined;\n shapes.push({ id: element.id, kind: element.type, x: element.x, y: element.y, width: element.width, height: element.height, ...(rotation ? { rotation } : {}), label: label || undefined,\n stroke: colour(element.strokeColor), fill: colour(element.backgroundColor), parent: element.frameId ?? undefined });\n }\n return { shapes, arrows };\n}\n\n/** Draws an `.excalidraw` file's text; throws a `DrawingError` for a file that is not a scene or draws nothing. */\nexport async function renderExcalidraw(text: string, { assets }: { readonly assets: string | false }): Promise<RenderedDrawing> {\n let data: unknown;\n try { data = JSON.parse(text); } catch { throw new DrawingError('unreadable', 'The file is not JSON.'); }\n const scene = data as { readonly type?: unknown; readonly elements?: unknown } | null;\n if (!scene || typeof scene !== 'object' || scene.type !== 'excalidraw' || !Array.isArray(scene.elements)) throw new DrawingError('unreadable', 'The file is not an Excalidraw scene.');\n // Where the fonts are must be known before the library loads: its font faces take their sources when it is first imported.\n const own = globalThis.window?.EXCALIDRAW_ASSET_PATH;\n const base = typeof own === 'string' ? own : assets;\n const fonts = base !== false && await fontsServed(base);\n if (fonts && base !== false && own === undefined) window.EXCALIDRAW_ASSET_PATH = baseOf(base).href;\n return withoutFontCdn(() => draw(data, fonts));\n}\n\nasync function draw(data: unknown, fonts: boolean): Promise<RenderedDrawing> {\n const excalidraw = await import('@excalidraw/excalidraw');\n let restored: ReturnType<typeof excalidraw.restore>;\n try { restored = excalidraw.restore(data as Parameters<typeof excalidraw.restore>[0], null, null); }\n catch (error) { throw new DrawingError('unreadable', String(error)); }\n const elements = restored.elements.filter(element => !element.isDeleted);\n if (!elements.length) throw new DrawingError('empty', 'The scene draws nothing.');\n const exportSvg = (inline: boolean) => excalidraw.exportToSvg({\n elements,\n appState: { ...restored.appState, exportBackground: true, viewBackgroundColor: '#ffffff', exportWithDarkMode: false, exportEmbedScene: false },\n files: restored.files ?? null,\n exportPadding: 16,\n ...(inline ? {} : { skipInliningFonts: true as const }),\n });\n let svg: SVGSVGElement;\n try { svg = await exportSvg(fonts); }\n catch (error) {\n if (!fonts) throw new DrawingError('renderer', String(error));\n svg = await exportSvg(false);\n }\n const read = readExcalidraw(elements as unknown as readonly Element[]);\n // Excalidraw's own bounds of what it drew: each shape's geometry turned by its angle, bound text included.\n const [left, top, right, bottom] = excalidraw.getCommonBounds(elements);\n const width = Number.parseFloat(svg.getAttribute('width') ?? '') || 0, height = Number.parseFloat(svg.getAttribute('height') ?? '') || 0;\n return { svg: new XMLSerializer().serializeToString(svg), width, height, blocked: [],\n drawing: { format: 'excalidraw', bounds: { x: left, y: top, width: right - left, height: bottom - top }, ...read } };\n}\n",
|
|
28
|
+
"type": "registry:lib",
|
|
29
|
+
"target": "components/drawing-viewer/excalidraw-render.ts"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"path": "registry/drawing-viewer/drawio-frame.ts",
|
|
33
|
+
"content": "// The document of the sandboxed frame in which the official diagrams.net viewer draws a draw.io file (D-1398). The frame is an opaque\n// origin (`sandbox=\"allow-scripts\"` only, so it shares nothing with this page) and its Content-Security-Policy allows no network at all and only the\n// scripts carrying this frame's nonce: the boot script below and the viewer it inserts. A label's HTML is sanitised by draw.io and, even if\n// it carried a handler or a script, the policy refuses it; `mxUtils.eval` (function bodies in a file) fails, since the policy allows no eval.\n// The boot script waits for one message from the parent with a port, the viewer's text and the file; it answers once on that port with\n// the SVG draw.io exports (`Graph.getSvg`, white page) and what the viewer's own model read, or with an error.\n\n/** The policy of the frame: no fetch, frame, image or font from anywhere but inline data, and only nonce scripts. */\nexport const drawioPolicy = (nonce: string) =>\n `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'`;\n\n/** Runs in the frame (ES5, before and after the viewer): reads the model the viewer decoded and exports its SVG. */\nconst BOOT = String.raw`(function () {\n var nonce = document.currentScript && document.currentScript.nonce, port = null;\n try { window.localStorage; } catch (error) {\n var memory = function () { var items = {}; return { getItem: function (key) { return Object.prototype.hasOwnProperty.call(items, key) ? items[key] : null; },\n setItem: function (key, value) { items[key] = String(value); }, removeItem: function (key) { delete items[key]; }, clear: function () { items = {}; },\n key: function (index) { return Object.keys(items)[index] || null; }, get length() { return Object.keys(items).length; } }; };\n try { Object.defineProperty(window, 'localStorage', { configurable: true, value: memory() }); Object.defineProperty(window, 'sessionStorage', { configurable: true, value: memory() }); } catch (ignored) {}\n }\n window.mxLoadResources = false; window.mxLoadStylesheets = false; window.mxForceIncludes = false;\n var blocked = [];\n document.addEventListener('securitypolicyviolation', function (event) { if (blocked.length < 50) blocked.push(String(event.effectiveDirective || event.violatedDirective) + ' ' + String(event.blockedURI)); });\n function answer(message) { if (port) { port.postMessage(message); port.close(); port = null; } }\n function fail(reason, error) { answer({ type: 'error', reason: reason, message: String(error && error.message || error || reason) }); }\n function plain(value, html) {\n var text = value == null ? '' : String(value);\n if (html) {\n var parsed = new DOMParser().parseFromString(text.replace(/<br\\s*\\/?>/gi, '\\n').replace(/<\\/(div|p|li|h[1-6])>/gi, '\\n</$1>'), 'text/html');\n var hidden = parsed.querySelectorAll('script, style, template');\n for (var h = 0; h < hidden.length; h++) hidden[h].remove();\n text = parsed.body ? parsed.body.textContent || '' : '';\n }\n return text.replace(/ /g, ' ').replace(/[ \\t]+\\n/g, '\\n').replace(/\\n{2,}/g, '\\n').trim();\n }\n function colour(value) { return value && value !== 'none' ? String(value) : undefined; }\n function read(graph) {\n var model = graph.getModel(), view = graph.view, scale = view.scale, dx = view.translate.x, dy = view.translate.y, shapes = [], arrows = [], labels = Object.create(null);\n var place = function (state) { return { x: state.x / scale - dx, y: state.y / scale - dy, width: state.width / scale, height: state.height / scale }; };\n var label = function (cell) { return plain(graph.convertValueToString(cell), graph.isHtmlLabel(cell)) || undefined; };\n for (var id in model.cells) {\n var cell = model.cells[id], state = view.getState(cell), parent = model.getParent(cell);\n if (!state || !(model.isVertex(cell) || model.isEdge(cell))) continue;\n if (model.isVertex(cell) && model.isEdge(parent)) { var text = label(cell); if (text) labels[parent.id] = labels[parent.id] ? labels[parent.id] + '\\n' + text : text; continue; }\n var style = state.style || {};\n if (model.isEdge(cell)) {\n var source = model.getTerminal(cell, true), target = model.getTerminal(cell, false), points = [];\n for (var i = 0; i < (state.absolutePoints || []).length; i++) { var point = state.absolutePoints[i]; if (point) points.push([point.x / scale - dx, point.y / scale - dy]); }\n // The viewer drops a style value of 'none', so a plain line has neither marker at all.\n var marked = function (marker) { return !!marker && marker !== 'none'; };\n var arrowhead = marked(style.endArrow) || marked(style.startArrow);\n arrows.push({ id: String(cell.id), kind: arrowhead ? 'arrow' : 'line', from: source ? String(source.id) : undefined, to: target ? String(target.id) : undefined, points: points, label: label(cell), stroke: colour(style.strokeColor) });\n } else {\n var box = place(state);\n shapes.push({ id: String(cell.id), kind: String(style.shape || 'rectangle'), x: box.x, y: box.y, width: box.width, height: box.height, label: label(cell),\n stroke: colour(style.strokeColor), fill: colour(style.fillColor), parent: model.isVertex(parent) ? String(parent.id) : undefined });\n }\n }\n for (var a = 0; a < arrows.length; a++) if (labels[arrows[a].id]) arrows[a].label = arrows[a].label ? arrows[a].label + '\\n' + labels[arrows[a].id] : labels[arrows[a].id];\n var bounds = graph.getGraphBounds();\n return { format: 'drawio', bounds: { x: bounds.x / scale - dx, y: bounds.y / scale - dy, width: bounds.width / scale, height: bounds.height / scale }, shapes: shapes, arrows: arrows };\n }\n function draw(xml) {\n var document_ = mxUtils.parseXml(xml), root = document_ && document_.documentElement;\n if (!root || ['mxfile', 'mxGraphModel', 'diagram'].indexOf(root.nodeName) < 0) return fail('unreadable', 'not a draw.io file');\n var node;\n try { node = Editor.extractGraphModel(root); } catch (error) { return fail('unreadable', error); }\n if (!node) return fail('unreadable', 'no diagram in the file');\n var graph = new Graph(document.getElementById('graph'));\n graph.setEnabled(false);\n try { new mxCodec(node.ownerDocument).decode(node, graph.getModel()); } catch (error) { return fail('unreadable', error); }\n var drawing = read(graph);\n if (!drawing.shapes.length && !drawing.arrows.length) return fail('empty', 'the diagram draws nothing');\n var svg = graph.getSvg('#ffffff', 1, 16, false, null, true);\n var width = parseFloat(svg.getAttribute('width')) || drawing.bounds.width + 32, height = parseFloat(svg.getAttribute('height')) || drawing.bounds.height + 32;\n var exported = mxUtils.getXml(svg);\n // Refused requests are reported as tasks after the attempt: give them a moment so the answer names what was not drawn.\n setTimeout(function () { answer({ type: 'drawn', svg: exported, width: width, height: height, drawing: drawing, blocked: blocked.slice() }); }, 250);\n }\n window.addEventListener('message', function (event) {\n if (port || event.source !== window.parent || !event.ports || !event.ports[0] || !event.data || event.data.type !== 'draw') return;\n port = event.ports[0];\n try {\n var viewer = document.createElement('script');\n viewer.nonce = nonce; viewer.textContent = String(event.data.viewer);\n document.head.appendChild(viewer);\n if (typeof Graph === 'undefined' || typeof Editor === 'undefined') return fail('renderer', 'the draw.io viewer did not load');\n } catch (error) { return fail('renderer', error); }\n try { draw(String(event.data.xml)); } catch (error) { fail('renderer', error); }\n });\n})();`;\n\n/** The frame's whole document for `nonce` (fresh per drawing). */\nexport function drawioFrameDocument(nonce: string): string {\n return `<!doctype html><html><head><meta charset=\"utf-8\"><meta http-equiv=\"Content-Security-Policy\" content=\"${drawioPolicy(nonce)}\">`\n + `<style>html,body{margin:0;background:#fff}#graph{position:absolute;left:0;top:0;width:2000px;height:2000px;overflow:hidden}</style>`\n + `<script nonce=\"${nonce}\">${BOOT}</script></head><body><div id=\"graph\"></div></body></html>`;\n}\n",
|
|
34
|
+
"type": "registry:lib",
|
|
35
|
+
"target": "components/drawing-viewer/drawio-frame.ts"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"path": "registry/drawing-viewer/drawio-render.ts",
|
|
39
|
+
"content": "// A draw.io file drawn by the official diagrams.net viewer (D-1398), vendored and pinned in the kit (`@hachej/boring-ui-kit/drawio-viewer`)\n// and run in a hidden sandboxed frame (drawio-frame.ts) that cannot reach the network or this page. The viewer decodes the file (plain or\n// compressed `<diagram>`), draws every shape and style as draw.io does and exports the SVG; the frame is removed once it answers. This\n// module and the viewer's text load only when a draw.io file opens.\nimport type { Drawing, DrawingArrow, DrawingShape, RenderedDrawing } from './drawing';\nimport { DrawingError } from './drawing';\nimport { drawioFrameDocument } from './drawio-frame';\n\n/** How long the viewer may take to answer before the drawing shows an error. */\nconst ANSWER_MS = 30000;\n\nconst finite = (value: unknown) => typeof value === 'number' && Number.isFinite(value) ? value : 0;\nconst text = (value: unknown) => typeof value === 'string' && value ? value : undefined;\nconst list = (value: unknown): readonly Record<string, unknown>[] => Array.isArray(value) ? value.filter(entry => entry && typeof entry === 'object') : [];\n\n/** What the frame read, kept to the fields and types of a `Drawing` (the frame is not trusted to send only those). */\nfunction cleanDrawing(value: unknown): Drawing {\n const raw = (value && typeof value === 'object' ? value : {}) as Record<string, unknown>;\n const bounds = (raw.bounds && typeof raw.bounds === 'object' ? raw.bounds : {}) as Record<string, unknown>;\n const shapes: DrawingShape[] = list(raw.shapes).map(shape => ({ id: String(shape.id), kind: text(shape.kind) ?? 'rectangle', x: finite(shape.x), y: finite(shape.y),\n width: finite(shape.width), height: finite(shape.height), label: text(shape.label), stroke: text(shape.stroke), fill: text(shape.fill), parent: text(shape.parent) }));\n const arrows: DrawingArrow[] = list(raw.arrows).map(arrow => ({ id: String(arrow.id), kind: arrow.kind === 'line' ? 'line' : 'arrow', from: text(arrow.from), to: text(arrow.to),\n points: (Array.isArray(arrow.points) ? arrow.points : []).filter(Array.isArray).map(point => [finite(point[0]), finite(point[1])] as const), label: text(arrow.label), stroke: text(arrow.stroke) }));\n return { format: 'drawio', bounds: { x: finite(bounds.x), y: finite(bounds.y), width: finite(bounds.width), height: finite(bounds.height) }, shapes, arrows };\n}\n\nfunction nonce(): string {\n const bytes = new Uint8Array(16);\n crypto.getRandomValues(bytes);\n return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');\n}\n\n/** Draws a `.drawio` file's text; throws a `DrawingError` for a file that is not one, draws nothing or that the viewer could not draw. */\nexport async function renderDrawio(source: string, { signal }: { readonly signal?: AbortSignal | undefined } = {}): Promise<RenderedDrawing> {\n if (!/<(?:mxfile|mxGraphModel|diagram)\\b/.test(source)) throw new DrawingError('unreadable', 'The file is not a draw.io diagram.');\n const { DRAWIO_VIEWER_SCRIPT } = await import('@hachej/boring-ui-kit/drawio-viewer');\n signal?.throwIfAborted();\n const frame = document.createElement('iframe');\n frame.setAttribute('sandbox', 'allow-scripts');\n frame.setAttribute('aria-hidden', 'true');\n frame.setAttribute('data-boring', 'drawio-frame');\n frame.tabIndex = -1;\n frame.title = 'draw.io';\n frame.style.cssText = 'position:fixed;left:-10000px;top:0;width:1200px;height:900px;border:0;visibility:hidden;pointer-events:none';\n frame.srcdoc = drawioFrameDocument(nonce());\n return new Promise<RenderedDrawing>((resolve, reject) => {\n const channel = new MessageChannel();\n const done = () => { clearTimeout(timer); channel.port1.close(); frame.remove(); signal?.removeEventListener('abort', aborted); };\n const aborted = () => { done(); reject(signal?.reason); };\n const timer = setTimeout(() => { done(); reject(new DrawingError('renderer', 'The draw.io viewer did not answer.')); }, ANSWER_MS);\n channel.port1.onmessage = event => {\n const data = (event.data && typeof event.data === 'object' ? event.data : {}) as Record<string, unknown>;\n done();\n const blocked = (Array.isArray(data.blocked) ? data.blocked : []).filter((entry): entry is string => typeof entry === 'string').slice(0, 50);\n if (data.type === 'drawn' && typeof data.svg === 'string') resolve({ svg: data.svg, width: finite(data.width), height: finite(data.height), drawing: cleanDrawing(data.drawing), blocked });\n else reject(new DrawingError(data.reason === 'unreadable' || data.reason === 'empty' ? data.reason : 'renderer', text(data.message)));\n };\n signal?.addEventListener('abort', aborted, { once: true });\n frame.addEventListener('load', () => frame.contentWindow?.postMessage({ type: 'draw', viewer: DRAWIO_VIEWER_SCRIPT, xml: source }, '*', [channel.port2]), { once: true });\n document.body.append(frame);\n });\n}\n",
|
|
40
|
+
"type": "registry:lib",
|
|
41
|
+
"target": "components/drawing-viewer/drawio-render.ts"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"path": "registry/drawing-viewer/drawing-viewer.tsx",
|
|
45
|
+
"content": "'use client';\n\n// The drawing viewer (D-1398): an Excalidraw or draw.io file, read-only, drawn by the tool's own renderer on a white page fitted to the\n// shapes. Excalidraw draws with its library (`exportToSvg`); draw.io with the official diagrams.net viewer in a sandboxed frame without\n// network. Each renderer loads the first time a drawing of its format opens. The result is shown as an image from a blob URL, so nothing\n// in the file runs on this page, and what the renderer read goes to `onRead` (shapes and arrows, see drawing.ts) for describing it to a model.\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport { TriangleAlertIcon } from 'lucide-react';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport { Spinner } from '@/components/ui/spinner';\nimport { DrawingError, drawingFormat } from './drawing';\nimport type { Drawing, DrawingFailure, DrawingFormat, RenderedDrawing } from './drawing';\nimport { drawingLabelsFor } from './drawing-labels';\nimport type { DrawingLabels, DrawingLocale } from './drawing-labels';\n\nexport { boundsOf, describeDrawing, DrawingError, drawingFormat, isDrawingPath } from './drawing';\nexport type { Drawing, DrawingArrow, DrawingFailure, DrawingFormat, DrawingShape, RenderedDrawing } from './drawing';\nexport { defaultDrawingLabels, drawingLabelsFor, frenchDrawingLabels } from './drawing-labels';\nexport type { DrawingLabels, DrawingLocale } from './drawing-labels';\n\n/** Where the app serves Excalidraw's fonts by default: the package's `dist/prod` folder (its `fonts/` inside) at this path. */\nexport const DEFAULT_EXCALIDRAW_ASSETS = '/excalidraw/';\n\nexport interface DrawingViewerProps {\n /** The file's text. */\n readonly text: string;\n /** The file's path or name: `.excalidraw` or `.drawio` picks the format; without it the text decides. */\n readonly path?: string | undefined;\n /** The format, when the app knows it better than the path. */\n readonly format?: DrawingFormat | undefined;\n /** The drawing's name, for the reader. */\n readonly title?: string | undefined;\n /** Called with what the renderer read once the drawing is drawn, and what it was refused from the network (those parts are not drawn). */\n readonly onRead?: ((drawing: Drawing, details: { readonly blocked: readonly string[] }) => void) | undefined;\n /**\n * The URL of the folder where the app serves `@excalidraw/excalidraw/dist/prod/` (Excalidraw looks for `fonts/` in it), unless the page\n * set `window.EXCALIDRAW_ASSET_PATH` itself. `false`, or a folder that serves no font, draws text without Excalidraw's fonts; the\n * viewer never lets Excalidraw fetch them from its CDN.\n */\n readonly excalidrawAssets?: string | false | undefined;\n /** The base words: English by default. */\n readonly locale?: DrawingLocale | undefined;\n /** Any word over the locale's. */\n readonly labels?: Partial<DrawingLabels> | undefined;\n}\n\ntype State =\n | { readonly kind: 'loading' }\n | { readonly kind: 'drawn'; readonly rendered: RenderedDrawing; readonly url: string }\n | { readonly kind: 'error'; readonly reason: DrawingFailure };\n\nasync function render(format: DrawingFormat, text: string, assets: string | false, signal: AbortSignal): Promise<RenderedDrawing> {\n if (format === 'excalidraw') return (await import('./excalidraw-render')).renderExcalidraw(text, { assets });\n return (await import('./drawio-render')).renderDrawio(text, { signal });\n}\n\n/** One drawing; an unreadable or empty file shows an inline error. */\nexport function DrawingViewer({ text, path, format: given, title, onRead, excalidrawAssets = DEFAULT_EXCALIDRAW_ASSETS, locale, labels: words }: DrawingViewerProps) {\n const labels = useMemo(() => drawingLabelsFor(locale, words), [locale, words]);\n const format = given ?? drawingFormat(path, text);\n // The outcome of the drawing it was rendered for: another text, format or font folder shows loading until its own outcome lands.\n const [outcome, setOutcome] = useState<{ readonly text: string; readonly format: DrawingFormat; readonly assets: string | false; readonly state: State }>();\n const state: State = !format ? { kind: 'error', reason: 'unreadable' }\n : outcome && outcome.text === text && outcome.format === format && outcome.assets === excalidrawAssets ? outcome.state : { kind: 'loading' };\n const read = useRef(onRead);\n useLayoutEffect(() => { read.current = onRead; });\n // A drawn outcome's image lives until another outcome replaces it or the viewer unmounts, so going back to an earlier text never shows a\n // revoked image; that text is drawn again anyway.\n // Every image made is tracked from its creation, so one whose outcome never commits (onRead hid the viewer first) is still released, once.\n const made = useRef(new Set<string>());\n useEffect(() => {\n const url = outcome?.state.kind === 'drawn' ? outcome.state.url : undefined;\n const live = made.current;\n return () => { if (url && live.delete(url)) URL.revokeObjectURL(url); };\n }, [outcome]);\n useEffect(() => { const live = made.current; return () => { for (const url of live) URL.revokeObjectURL(url); live.clear(); }; }, []);\n useEffect(() => {\n if (!format) return;\n const abort = new AbortController();\n render(format, text, excalidrawAssets, abort.signal).then(rendered => {\n if (abort.signal.aborted) return;\n const url = URL.createObjectURL(new Blob([rendered.svg], { type: 'image/svg+xml' }));\n made.current.add(url);\n setOutcome({ text, format, assets: excalidrawAssets, state: { kind: 'drawn', rendered, url } });\n read.current?.(rendered.drawing, { blocked: rendered.blocked });\n }, (error: unknown) => {\n if (abort.signal.aborted) return;\n setOutcome({ text, format, assets: excalidrawAssets, state: { kind: 'error', reason: error instanceof DrawingError ? error.reason : 'renderer' } });\n });\n return () => abort.abort();\n }, [format, text, excalidrawAssets]);\n return <section data-boring=\"drawing-viewer\" data-state={state.kind} data-format={format} data-blocked={state.kind === 'drawn' ? state.rendered.blocked.length : undefined} aria-label={title ?? labels.kind} aria-busy={state.kind === 'loading' || undefined}\n className=\"flex w-full min-w-0 flex-col gap-3 px-3 pt-2 pb-4 sm:px-6\">\n {state.kind === 'loading' && <p role=\"status\" className=\"m-0 flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground\"><Spinner />{labels.loading}</p>}\n {state.kind === 'error' && <Alert variant=\"destructive\" data-testid=\"drawing-error\" data-reason={state.reason}><TriangleAlertIcon aria-hidden=\"true\" />\n <AlertTitle>{labels.errorTitle}</AlertTitle><AlertDescription>{labels.error(state.reason)}</AlertDescription></Alert>}\n {state.kind === 'drawn' && <figure className=\"m-0 min-w-0 overflow-auto bg-muted p-2\">\n <img data-testid=\"drawing-image\" src={state.url} width={Math.round(state.rendered.width) || undefined} height={Math.round(state.rendered.height) || undefined}\n alt={labels.image(state.rendered.drawing.format, state.rendered.drawing.shapes.length, state.rendered.drawing.arrows.length)}\n className=\"mx-auto block h-auto max-w-full\" draggable={false} />\n </figure>}\n </section>;\n}\n",
|
|
46
|
+
"type": "registry:component",
|
|
47
|
+
"target": "components/drawing-viewer/drawing-viewer.tsx"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"path": "registry/drawing-viewer/drawing-kind.tsx",
|
|
51
|
+
"content": "'use client';\n\nimport { lazy } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { PenToolIcon } from 'lucide-react';\nimport { isDrawingPath } from './drawing';\nimport { drawingLabelsFor } from './drawing-labels';\nimport type { DrawingLabels, DrawingLocale } from './drawing-labels';\n\nconst DrawingViewer = lazy(() => import('./drawing-viewer').then(module => ({ default: module.DrawingViewer })));\n\n/**\n * The artifact kind of a drawing file (structurally an `ArtifactKind` of pi-artifacts): it matches `.excalidraw` and `.drawio` files, names\n * them and draws them with `DrawingViewer`, loaded on first open. It is not in the pi-app-kinds default list: an app that shows drawings\n * adds it to its `artifactKinds` (and installs this item), so apps without drawings never install Excalidraw.\n */\nexport interface DrawingKind {\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 /** Takes the artifact panel's view props (`ArtifactKindViewProps` of pi-artifacts): the file's name comes from `artifact.target`. */\n readonly renderer: (props: { readonly text: string; readonly artifact?: { readonly title?: string | undefined; readonly target: { readonly resource: { readonly path: string } } } | undefined }) => ReactNode;\n}\n\n/** A drawing kind in `locale` (\"Drawing\", \"Dessin\") with any word overridden, and where the app serves Excalidraw's fonts. */\nexport function createDrawingKind({ locale, labels, excalidrawAssets }: { readonly locale?: DrawingLocale | undefined; readonly labels?: Partial<DrawingLabels> | undefined; readonly excalidrawAssets?: string | false | undefined } = {}): DrawingKind {\n return {\n match: target => isDrawingPath(target.resource.path),\n label: drawingLabelsFor(locale, labels).kind,\n icon: PenToolIcon,\n renderer: ({ text, artifact }) => <DrawingViewer text={text} path={artifact?.target.resource.path} title={artifact?.title} locale={locale} labels={labels} excalidrawAssets={excalidrawAssets} />,\n };\n}\n\n/** The drawing kind, in English, with Excalidraw's fonts at the default path. */\nexport const drawingKind: DrawingKind = createDrawingKind();\n",
|
|
52
|
+
"type": "registry:component",
|
|
53
|
+
"target": "components/drawing-viewer/drawing-kind.tsx"
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
"type": "registry:component"
|
|
57
|
+
}
|
package/r/feedback.json
CHANGED
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
"title": "Feedback",
|
|
5
5
|
"description": "Feedback from the chat composer: one Feedback button starts feedback mode on the application page (hover, click to pin a numbered note, hold ⌥ to use the app, optional voice), and one chip carries the notes to the next message. FeedbackBar, NoteBubble, FeedbackChip with its review, useComposerFeedback for the pi-chat composer, and PickerOverlay styles. Optional, not mounted by default: PointButton, AnnotateSheet, FeedbackList and FeedbackReport. Picking, masking, anchoring and placement stay in @hachej/boring-feedback/page and /ui, so restyling cannot weaken them.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-feedback@^0.4.
|
|
8
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
9
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-feedback@^0.4.50",
|
|
8
|
+
"@hachej/boring-ui-kit@^0.4.50",
|
|
9
|
+
"@hachej/boring-files@^0.4.50",
|
|
10
10
|
"react@^19.0.0",
|
|
11
11
|
"react-dom@^19.0.0",
|
|
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.
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/pi-chat.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/pi-chat-voice.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/utils.json"
|
|
18
18
|
],
|
|
19
19
|
"files": [
|
|
20
20
|
{
|
package/r/file-tree.json
CHANGED
|
@@ -4,19 +4,19 @@
|
|
|
4
4
|
"title": "File tree",
|
|
5
5
|
"description": "Browse, search, upload, rename, move, delete and inspect retained versions through one revisionProvider binding. Uses the consumer's shadcn Button, dropdown-menu and dialog with shared version-history rows; Base UI style only.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.50",
|
|
8
|
+
"@hachej/boring-files@^0.4.50",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"lucide-react@^1.51.0"
|
|
11
11
|
],
|
|
12
12
|
"registryDependencies": [
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/utils.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/version-history.json"
|
|
15
15
|
],
|
|
16
16
|
"files": [
|
|
17
17
|
{
|
|
18
18
|
"path": "registry/file-tree/file-tree.tsx",
|
|
19
|
-
"content": "'use client';\n\nimport '@hachej/boring-ui-kit/file-tree.css';\nimport { FileTree as PackageFileTree, FileTreeView as PackageFileTreeView } from '@hachej/boring-ui-kit/file-tree-view';\nimport type { FileTreeComponents, FileTreeActionsProps, FileTreeButtonProps, FileTreeDialogProps, FileTreeVersionsProps, FileTreeProps as PackageFileTreeProps, FileTreeViewProps as PackageFileTreeViewProps } from '@hachej/boring-ui-kit/file-tree-view';\nimport { useRef } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';\nimport { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty';\nimport { Input } from '@/components/ui/input';\nimport { Progress } from '@/components/ui/progress';\nimport { Separator } from '@/components/ui/separator';\nimport { SidebarMenu, SidebarMenuItem, SidebarMenuSub } from '@/components/ui/sidebar';\nimport { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { VersionHistoryRows, frenchVersionHistoryLabels } from '../version-history/version-history';\nimport { cn } from '../utils/utils';\n\n// Only touch-target layout is added; appearance remains the consumer's shadcn Button.\nfunction LibraryButton({ className, ...props }: FileTreeButtonProps) {\n return <Button {...props} className={cn('max-sm:min-h-11 max-sm:min-w-11 pointer-coarse:min-h-11 pointer-coarse:min-w-11', className)} />;\n}\n\nfunction FileActions({ label, icon, items }: FileTreeActionsProps) {\n const trigger = useRef<HTMLButtonElement>(null);\n const portal = useRef<HTMLElement | null>(null);\n // Rename, Move to… and Delete move focus themselves (an inline input, a dialog): closing the menu leaves it there.\n const chosen = useRef<FileTreeActionsProps['items'][number] | undefined>(undefined);\n return <DropdownMenu>\n <DropdownMenuTrigger ref={trigger} onClick={() => { chosen.current = undefined; }} render={<LibraryButton type=\"button\" variant=\"ghost\" size=\"icon\" ref={node => { portal.current = node?.ownerDocument.body ?? null; }} />} data-file-more aria-label={label}>{icon}</DropdownMenuTrigger>\n <DropdownMenuPortal container={portal}>\n <DropdownMenuContent data-boring=\"file-actions\" align=\"end\" finalFocus={() => chosen.current?.ownsFocus ? false : trigger.current} className=\"w-max max-w-(--available-width)\">\n {items.map(item => <DropdownMenuItem key={item.id} data-action={item.id} variant={item.destructive ? 'destructive' : 'default'}\n onClick={() => { chosen.current = item; item.onSelect(); }} className=\"max-sm:min-h-11 pointer-coarse:min-h-11\">{item.icon}{item.label}</DropdownMenuItem>)}\n </DropdownMenuContent>\n </DropdownMenuPortal>\n </DropdownMenu>;\n}\n\nfunction FileVersions({ versions, current, onSelect, locale, earlierVersion }: FileTreeVersionsProps) {\n return <VersionHistoryRows versions={versions.map(version => ({ id: version.revision, label: earlierVersion, savedAt: version.savedAt, ...(version.source ? { source: version.source } : {}) }))}\n current={current} onSelect={onSelect} testId=\"file-history-versions\" {...(locale === 'fr' ? { labels: frenchVersionHistoryLabels } : {})} />;\n}\n\nfunction FileTreeModal({ open, onOpenChange, title, description, children, footer }: FileTreeDialogProps) {\n return <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent data-boring=\"file-tree-dialog\" showCloseButton={false}>\n <DialogHeader>\n <DialogTitle>{title}</DialogTitle>\n {description && <DialogDescription>{description}</DialogDescription>}\n </DialogHeader>\n {children}\n <DialogFooter>{footer}</DialogFooter>\n </DialogContent>\n </Dialog>;\n}\n\nfunction FileTreeEmpty({ icon, title, description, className, children }: Parameters<FileTreeComponents['Empty']>[0]) {\n return <Empty className={className}>\n <EmptyHeader>\n {icon && <EmptyMedia variant=\"icon\">{icon}</EmptyMedia>}\n <EmptyTitle>{title}</EmptyTitle>\n {description && <EmptyDescription>{description}</EmptyDescription>}\n </EmptyHeader>\n {children && <EmptyContent>{children}</EmptyContent>}\n </Empty>;\n}\n\nexport const fileTreeComponents = {\n Button: LibraryButton, Actions: FileActions, Versions: FileVersions,\n Menu: SidebarMenu, MenuItem: SidebarMenuItem, MenuSub: SidebarMenuSub, Input, Separator: () => <Separator />,\n Collapsible, CollapsibleTrigger, CollapsibleContent, Empty: FileTreeEmpty, Dialog: FileTreeModal,\n Progress: ({ value, 'aria-label': label }: { readonly value: number | null; readonly 'aria-label': string }) => <Progress value={value} aria-label={label} />,\n} satisfies FileTreeComponents;\nexport type FileTreeProps = Omit<PackageFileTreeProps, 'components'>;\nexport type FileTreeViewProps = Omit<PackageFileTreeViewProps, 'components'>;\n\nexport function FileTree(props: FileTreeProps) {\n return <PackageFileTree {...props} components={fileTreeComponents} />;\n}\nexport function FileTreeView(props: FileTreeViewProps) {\n return <PackageFileTreeView {...props} components={fileTreeComponents} />;\n}\nexport { createFileTreeController } from '@hachej/boring-ui-kit/file-tree';\nexport type { FileTreeController } from '@hachej/boring-ui-kit/file-tree';\n",
|
|
19
|
+
"content": "'use client';\n\nimport '@hachej/boring-ui-kit/file-tree.css';\nimport { FileTree as PackageFileTree, FileTreeView as PackageFileTreeView } from '@hachej/boring-ui-kit/file-tree-view';\nimport type { FileTreeComponents, FileTreeActionsProps, FileTreeBadgeProps, FileTreeButtonProps, FileTreeDialogProps, FileTreeVersionsProps, FileTreeProps as PackageFileTreeProps, FileTreeViewProps as PackageFileTreeViewProps } from '@hachej/boring-ui-kit/file-tree-view';\nimport { useRef } from 'react';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';\nimport { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty';\nimport { Input } from '@/components/ui/input';\nimport { Progress } from '@/components/ui/progress';\nimport { Separator } from '@/components/ui/separator';\nimport { SidebarMenu, SidebarMenuItem, SidebarMenuSub } from '@/components/ui/sidebar';\nimport { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { VersionHistoryRows, frenchVersionHistoryLabels } from '../version-history/version-history';\nimport { cn } from '../utils/utils';\n\n// Only touch-target layout is added; appearance remains the consumer's shadcn Button.\nfunction LibraryButton({ className, ...props }: FileTreeButtonProps) {\n return <Button {...props} className={cn('max-sm:min-h-11 max-sm:min-w-11 pointer-coarse:min-h-11 pointer-coarse:min-w-11', className)} />;\n}\n\nfunction FileActions({ label, icon, items }: FileTreeActionsProps) {\n const trigger = useRef<HTMLButtonElement>(null);\n const portal = useRef<HTMLElement | null>(null);\n // Rename, Move to… and Delete move focus themselves (an inline input, a dialog): closing the menu leaves it there.\n const chosen = useRef<FileTreeActionsProps['items'][number] | undefined>(undefined);\n return <DropdownMenu>\n <DropdownMenuTrigger ref={trigger} onClick={() => { chosen.current = undefined; }} render={<LibraryButton type=\"button\" variant=\"ghost\" size=\"icon\" ref={node => { portal.current = node?.ownerDocument.body ?? null; }} />} data-file-more aria-label={label}>{icon}</DropdownMenuTrigger>\n <DropdownMenuPortal container={portal}>\n <DropdownMenuContent data-boring=\"file-actions\" align=\"end\" finalFocus={() => chosen.current?.ownsFocus ? false : trigger.current} className=\"w-max max-w-(--available-width)\">\n {items.map(item => <DropdownMenuItem key={item.id} data-action={item.id} variant={item.destructive ? 'destructive' : 'default'}\n onClick={() => { chosen.current = item; item.onSelect(); }} className=\"max-sm:min-h-11 pointer-coarse:min-h-11\">{item.icon}{item.label}</DropdownMenuItem>)}\n </DropdownMenuContent>\n </DropdownMenuPortal>\n </DropdownMenu>;\n}\n\nfunction FileVersions({ versions, current, onSelect, locale, earlierVersion }: FileTreeVersionsProps) {\n return <VersionHistoryRows versions={versions.map(version => ({ id: version.revision, label: earlierVersion, savedAt: version.savedAt, ...(version.source ? { source: version.source } : {}) }))}\n current={current} onSelect={onSelect} testId=\"file-history-versions\" {...(locale === 'fr' ? { labels: frenchVersionHistoryLabels } : {})} />;\n}\n\nfunction FileTreeModal({ open, onOpenChange, title, description, children, footer }: FileTreeDialogProps) {\n return <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent data-boring=\"file-tree-dialog\" showCloseButton={false}>\n <DialogHeader>\n <DialogTitle>{title}</DialogTitle>\n {description && <DialogDescription>{description}</DialogDescription>}\n </DialogHeader>\n {children}\n <DialogFooter>{footer}</DialogFooter>\n </DialogContent>\n </Dialog>;\n}\n\nfunction FileTreeEmpty({ icon, title, description, className, children }: Parameters<FileTreeComponents['Empty']>[0]) {\n return <Empty className={className}>\n <EmptyHeader>\n {icon && <EmptyMedia variant=\"icon\">{icon}</EmptyMedia>}\n <EmptyTitle>{title}</EmptyTitle>\n {description && <EmptyDescription>{description}</EmptyDescription>}\n </EmptyHeader>\n {children && <EmptyContent>{children}</EmptyContent>}\n </Empty>;\n}\n\n// The tone picks the shadcn variant; success and warning take the theme's colour when it has one (file-tree.css).\nfunction FileTreeBadge({ tone, ...props }: FileTreeBadgeProps) {\n return <Badge variant={tone === 'danger' ? 'destructive' : tone === 'neutral' ? 'secondary' : 'outline'} {...props} />;\n}\n\nexport const fileTreeComponents = {\n Button: LibraryButton, Actions: FileActions, Versions: FileVersions,\n Menu: SidebarMenu, MenuItem: SidebarMenuItem, MenuSub: SidebarMenuSub, Input, Separator: () => <Separator />,\n Collapsible, CollapsibleTrigger, CollapsibleContent, Empty: FileTreeEmpty, Dialog: FileTreeModal, Badge: FileTreeBadge,\n Progress: ({ value, 'aria-label': label }: { readonly value: number | null; readonly 'aria-label': string }) => <Progress value={value} aria-label={label} />,\n} satisfies FileTreeComponents;\nexport type FileTreeProps = Omit<PackageFileTreeProps, 'components'>;\nexport type FileTreeViewProps = Omit<PackageFileTreeViewProps, 'components'>;\n\nexport function FileTree(props: FileTreeProps) {\n return <PackageFileTree {...props} components={fileTreeComponents} />;\n}\nexport function FileTreeView(props: FileTreeViewProps) {\n return <PackageFileTreeView {...props} components={fileTreeComponents} />;\n}\nexport { createFileTreeController } from '@hachej/boring-ui-kit/file-tree';\nexport type { FileTreeController } from '@hachej/boring-ui-kit/file-tree';\nexport type { FileDecoration, FileDecorationTone } from '@hachej/boring-ui-kit/file-tree-view';\n",
|
|
20
20
|
"type": "registry:component",
|
|
21
21
|
"target": "components/file-tree/file-tree.tsx"
|
|
22
22
|
}
|
package/r/highlights.json
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
"title": "Anchored highlights",
|
|
5
5
|
"description": "Exact Markdown highlights and comments in a conditional workspace sidecar.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.50",
|
|
8
|
+
"@hachej/boring-files@^0.4.50",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0"
|
|
11
11
|
],
|
|
12
12
|
"registryDependencies": [
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/markdown-editor.json"
|
|
14
14
|
],
|
|
15
15
|
"files": [
|
|
16
16
|
{
|
package/r/html-viewer.json
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
"title": "HTML source and passive preview",
|
|
5
5
|
"description": "Exact HTML source editing with a reconstructed passive preview and borrowed controller. Browser security qualification remains separate.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.50",
|
|
8
|
+
"@hachej/boring-files@^0.4.50",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0"
|
|
11
11
|
],
|
|
12
12
|
"registryDependencies": [
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/button.json"
|
|
14
14
|
],
|
|
15
15
|
"files": [
|
|
16
16
|
{
|
package/r/looks-viewer.json
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
"lucide-react@^1.51.0"
|
|
9
9
|
],
|
|
10
10
|
"registryDependencies": [
|
|
11
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/mockup-viewer.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/utils.json"
|
|
13
13
|
],
|
|
14
14
|
"files": [
|
|
15
15
|
{
|
package/r/maquette-viewer.json
CHANGED
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.50",
|
|
8
|
+
"@hachej/boring-files@^0.4.50",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"marked@^17.0.6",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"lucide-react@^1.51.0"
|
|
25
25
|
],
|
|
26
26
|
"registryDependencies": [
|
|
27
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
27
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/button.json"
|
|
28
28
|
],
|
|
29
29
|
"files": [
|
|
30
30
|
{
|
package/r/mockup-viewer.json
CHANGED
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 over the kit's shared chat-react run notifications (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.50",
|
|
8
8
|
"react@^19.0.0",
|
|
9
9
|
"react-dom@^19.0.0",
|
|
10
10
|
"@earendil-works/pi-durable@~1.4.0",
|
|
@@ -12,18 +12,18 @@
|
|
|
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.
|
|
20
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
21
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/pi-chat.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/pi-chat-plugins.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/pi-chat-question.json",
|
|
18
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/pi-artifacts.json",
|
|
19
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/pi-workspace.json",
|
|
20
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/utils.json",
|
|
21
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/button.json"
|
|
22
22
|
],
|
|
23
23
|
"files": [
|
|
24
24
|
{
|
|
25
25
|
"path": "registry/pi-ambient/ambient.tsx",
|
|
26
|
-
"content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\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, 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 { ConversationsConfig } from '../pi-chat/config';\nimport { notifyPermission, requestNotifyPermission } from './browser-notify';\nimport type { NotifyPermission } from './browser-notify';\nimport { ConversationHistory, TranscriptHistory } from '../pi-chat/history';\nimport { withChatPlugins } from '../pi-chat-plugins/chat-plugins';\nimport { TranscriptChunk } from '../pi-chat/message';\nimport type { RowContext } from '../pi-chat/message';\nimport { ChatNotices, ChatConnectionNotice, ChatStatusLine } from '../pi-chat/notice';\nimport { AgentNotifications, KindIcon, createNotificationStore, defaultNotificationLabels, noticeTextOf, useRunNotifications, 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 { useChatView, useArtifactFollow } from '@hachej/boring-ui-kit/chat-react';\nimport type { ArtifactShown, ArtifactFailed } from '@hachej/boring-ui-kit/chat-react';\nimport { createTaskArtifact, pendingPresents, taskSubjectKey } from '../pi-artifacts/artifact';\nimport type { ArtifactDescriptor, PresentedArtifact, ArtifactsConfig } from '../pi-artifacts/artifact';\nimport { artifactCards } from '../pi-artifacts/artifact-card';\nimport { PAGE_FIRST, PAGE_MORE, useChatSession, useRowChunks, useTranscript } from '../pi-chat/session';\nimport type { ChatFeatureProps } from '../pi-chat/session';\nimport { ArtifactWorkspace } from '../pi-workspace/workspace';\nimport type { WorkspacePanelApi } from '../pi-workspace/workspace';\nimport { cn } from '../utils/utils';\nimport { usePhone } from '../utils/use-media';\nimport { readPersistedJson, writePersistedJson } from '../utils/persisted';\nexport { usePhone } from '../utils/use-media';\n\nexport { AgentNotifications, createNotificationStore, defaultNotificationLabels, frenchNotificationLabels, noticeTextOf, 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 /** Forward the viewer's actual loaded revision mount/unmount report. Opening or rendering the panel is not evidence. */\n readonly onShown: (shown: ArtifactShown) => void;\n /** Forward a viewer load/render failure; the waiting present is refused. */\n readonly onFailed: (failed: ArtifactFailed) => void;\n}\n\nexport interface AmbientChatProps extends Omit<ChatFeatureProps, 'artifacts'> {\n readonly controller: NativeChatController;\n /** Host recovery content, shared with PiChat in expanded and compact connection notices. */\n readonly renderConnectionError?: ((error: unknown) => ReactNode) | undefined;\n /** Artifact cards (`ArtifactsConfig` of `pi-artifacts`); the ambient bar plugs in their card itself. */\n readonly artifacts?: ArtifactsConfig | undefined;\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 /** Host-shared composer presentation, also accepted by PiChat. */\n readonly renderComposer?: (props: ComposerProps) => ReactNode;\n readonly sendContext?: import('@hachej/boring-ui-kit/chat-react').ChatSessionFeedback;\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 readPosition(key: string, persist: 'session' | 'local'): Position | undefined {\n try {\n const value: unknown = readPersistedJson(key, persist);\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 writePersistedJson(key, position, persist);\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 }; };\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 // eslint-disable-next-line react-hooks/set-state-in-effect -- the count restarts at 0 whenever `active` flips; the interval below owns it after that\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/** A compact floating agent bar over an existing page; it grows into a chat window in place. Borrows a concrete native controller like `PiChat`. */\n/** The ambient bar comes with every composer feature item (`withChatPlugins`); a component the host passes is kept. */\nexport function AmbientChat(given: AmbientChatProps) {\n const props = withChatPlugins(given);\n // eslint-disable-next-line react-hooks/refs -- the session's async callbacks compare against the controller of the latest render; assigned in render so a child's effects never see the previous one\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', presentation, voiceReplay, targetLabel, actions,\n windowState, onWindowState, store, storageKey = 'boring.ambient.position', storage = 'session', tools, belowComposer, composerChips, composerLayout = 'card', renderComposer, sendContext, onOpenFull, onFeedback, notifications, autoDismissMs, maxToasts, systemNotifications = false,\n renderConnectionError, renderEntry, hideLine, displayLimit, paging, historicalMessageActions, unread: replyUnread, replyError, renderTool, toolRenderers, renderText, cards, 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);\n useLayoutEffect(() => { 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: PresentedArtifact; 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 // eslint-disable-next-line react-hooks/set-state-in-effect -- the browser's permission is read once after mount, so the server render and hydration agree on '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-md: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 follow = useArtifactFollow({ controller, conversationId: String(conversations?.activeId ?? controller.getSnapshot().conversationId), detect,\n active: opened, onOpen: setOpened, autoOpen: false, narrow: () => phone,\n showing: path => !inWindow || (expanded && opened?.descriptor.type !== 'task' && opened?.descriptor.target.resource.path === path),\n showingTask: subject => !inWindow || (expanded && opened?.descriptor.type === 'task' && taskSubjectKey(opened.descriptor.subject) === taskSubjectKey(subject)),\n openPresent: () => false, answer: actions?.answer });\n const { onShown, onFailed, open: openArtifact, isOpen: isArtifactOpen } = follow;\n const pendingTasks = useChatView(controller, pendingPresents);\n const requestedTasks = useRef(new WeakMap<NativeChatController, Set<string>>());\n useEffect(() => {\n if (inWindow || !hostArtifacts?.autoOpenTasks) return;\n let requested = requestedTasks.current.get(controller);\n if (!requested) { requested = new Set(); requestedTasks.current.set(controller, requested); }\n for (const call of pendingTasks) {\n if (!('subject' in call) || requested.has(call.id)) continue;\n requested.add(call.id);\n hostArtifacts.open(createTaskArtifact(call.subject), { onShown, onFailed });\n }\n }, [controller, inWindow, hostArtifacts, pendingTasks, onShown, onFailed]);\n const closeArtifact = () => { follow.recordClosed(); setOpened(null); setFullscreen(false); };\n const artifacts = useMemo<ArtifactsConfig | undefined>(() => !inWindow ? hostArtifacts && { ...hostArtifacts,\n open: artifact => hostArtifacts.open(artifact, { onShown, onFailed }) } : {\n open: artifact => artifact.type === 'task' ? hostArtifacts?.open(artifact, { onShown, onFailed }) : openArtifact(artifact), isOpen: isArtifactOpen,\n ...(detect ? { detect } : {}),\n ...(hostArtifacts?.kinds ? { kinds: hostArtifacts.kinds } : {}),\n recognise: hostArtifacts?.recognise ?? artifactCards.recognise, Card: hostArtifacts?.Card ?? artifactCards.Card,\n }, [inWindow, hostArtifacts, openArtifact, isArtifactOpen, onShown, onFailed, detect]);\n const session = useChatSession({ controller, activeController, mode, presentation, voiceReplay, targetLabel, actions, renderEntry, hideLine, historicalMessageActions, unread: replyUnread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => scrollToBottom.current(), rowExtras, feedback: composerFeedback, sendContext, 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, displayLimit ?? paging?.first ?? PAGE_FIRST, paging?.more ?? PAGE_MORE, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier }, displayLimit === undefined);\n const { stick: { scrollRef, isAtBottom, scrollToBottom: stickToBottom }, hidden } = transcript;\n const chunks = useRowChunks(useMemo(() => hidden ? rows.slice(hidden) : rows, [rows, hidden]));\n const known = follow.known, shownArtifact = follow.shown;\n const { reset } = transcript;\n useLayoutEffect(() => { scrollToBottom.current = () => { reset(); void stickToBottom('instant'); }; });\n const { barStart, barNote } = session.pickers();\n\n const conversationId = String(conversations?.activeId ?? state.conversationId);\n useLayoutEffect(() => { 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 useRunNotifications(controller, store, { enabled: notifications !== false, conversationId, title: shownTitle, onOutcome: setOutcome, text: noticeTextOf(labels),\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 // eslint-disable-next-line react-hooks/exhaustive-deps -- measures after every commit on purpose; both setters return the current value when nothing changed, so it settles\n useLayoutEffect(() => {\n const element = root.current;\n const fresh = viewportSize();\n // eslint-disable-next-line react-hooks/set-state-in-effect -- layout reads (viewport, own size) exist only after commit and must land before paint\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 const latestExpand = useRef(expand);\n useLayoutEffect(() => { latestExpand.current = expand; });\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 closeArtifact();\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 latestExpand.current();\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 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-md:h-14 max-md: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\"><ChatConnectionNotice state={state} renderConnectionError={renderConnectionError} onReconnect={() => act(controller.connect)} /></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={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 {chunks.map(run => <TranscriptChunk key={run[0]!.key} rows={run} context={rowContext} />)}\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 {!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 <ChatStatusLine natural={session.natural} working={working} waitingForAnswer={waitingForAnswer} derived={derived} stop={state.stop} sending={session.sending} />\n <ChatNotices natural={session.natural} state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} />\n {!expanded && <ChatConnectionNotice compact fallback={{ error: labels.loadFailedShort, closed: labels.disconnectedShort }} state={state} renderConnectionError={renderConnectionError} onReconnect={() => act(controller.connect)} />}\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 {(renderComposer ?? (value => <Composer {...value} />))({ ...composer, layout: composerLayout, placeholder: working || waitingForAnswer ? label : labels.placeholder, barStart, 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={closeArtifact} fullscreen={fullscreen} onFullscreenChange={setFullscreen} storageKey=\"boring.ambient.panel-width\" panelLabel={labels.artifactPanel}\n defaultWidth={600} minPanel={320} minChat={300}\n panel={api => shownArtifact && shownArtifact.type !== 'task' ? artifactPanel(shownArtifact, { ...api, versions: known.filter((artifact): artifact is ArtifactDescriptor => artifact.type !== 'task'), follow: opened?.follow ?? true,\n select: follow.select, onShown: follow.onShown, onFailed: follow.onFailed }) : null}\n chat={conversation} /> : conversation}\n </div>\n </div>;\n}\n",
|
|
26
|
+
"content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\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, 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 { ConversationsConfig } from '../pi-chat/config';\nimport { notifyPermission, requestNotifyPermission } from './browser-notify';\nimport type { NotifyPermission } from './browser-notify';\nimport { ConversationHistory, TranscriptHistory } from '../pi-chat/history';\nimport { withChatPlugins } from '../pi-chat-plugins/chat-plugins';\nimport { TranscriptChunk } from '../pi-chat/message';\nimport type { RowContext } from '../pi-chat/message';\nimport { ChatNotices, ChatConnectionNotice, ChatStatusLine } from '../pi-chat/notice';\nimport { AgentNotifications, KindIcon, createNotificationStore, defaultNotificationLabels, noticeTextOf, useRunNotifications, 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 { useChatView, useArtifactFollow } from '@hachej/boring-ui-kit/chat-react';\nimport type { ArtifactShown, ArtifactFailed } from '@hachej/boring-ui-kit/chat-react';\nimport { createTaskArtifact, pendingPresents, taskSubjectKey } from '../pi-artifacts/artifact';\nimport type { ArtifactDescriptor, PresentedArtifact, ArtifactsConfig } from '../pi-artifacts/artifact';\nimport { artifactCards } from '../pi-artifacts/artifact-card';\nimport { PAGE_FIRST, PAGE_MORE, useChatSession, useRowChunks, useTranscript } from '../pi-chat/session';\nimport type { ChatFeatureProps } from '../pi-chat/session';\nimport { ArtifactWorkspace } from '../pi-workspace/workspace';\nimport type { WorkspacePanelApi } from '../pi-workspace/workspace';\nimport { cn } from '../utils/utils';\nimport { usePhone } from '../utils/use-media';\nimport { readPersistedJson, writePersistedJson } from '../utils/persisted';\nexport { usePhone } from '../utils/use-media';\n\nexport { AgentNotifications, createNotificationStore, defaultNotificationLabels, frenchNotificationLabels, noticeTextOf, 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 /** Forward the viewer's actual loaded revision mount/unmount report. Opening or rendering the panel is not evidence. */\n readonly onShown: (shown: ArtifactShown) => void;\n /** Forward a viewer load/render failure; the waiting present is refused. */\n readonly onFailed: (failed: ArtifactFailed) => void;\n}\n\nexport interface AmbientChatProps extends Omit<ChatFeatureProps, 'artifacts'> {\n readonly controller: NativeChatController;\n /** Host recovery content, shared with PiChat in expanded and compact connection notices. */\n readonly renderConnectionError?: ((error: unknown) => ReactNode) | undefined;\n /** Artifact cards (`ArtifactsConfig` of `pi-artifacts`); the ambient bar plugs in their card itself. */\n readonly artifacts?: ArtifactsConfig | undefined;\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 /** Host-shared composer presentation, also accepted by PiChat. */\n readonly renderComposer?: (props: ComposerProps) => ReactNode;\n readonly sendContext?: import('@hachej/boring-ui-kit/chat-react').ChatSessionFeedback;\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 readPosition(key: string, persist: 'session' | 'local'): Position | undefined {\n try {\n const value: unknown = readPersistedJson(key, persist);\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 writePersistedJson(key, position, persist);\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 }; };\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 // eslint-disable-next-line react-hooks/set-state-in-effect -- the count restarts at 0 whenever `active` flips; the interval below owns it after that\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/** A compact floating agent bar over an existing page; it grows into a chat window in place. Borrows a concrete native controller like `PiChat`. */\n/** The ambient bar comes with every composer feature item (`withChatPlugins`); a component the host passes is kept. */\nexport function AmbientChat(given: AmbientChatProps) {\n const props = withChatPlugins(given);\n // eslint-disable-next-line react-hooks/refs -- the session's async callbacks compare against the controller of the latest render; assigned in render so a child's effects never see the previous one\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', presentation, voiceReplay, targetLabel, actions,\n windowState, onWindowState, store, storageKey = 'boring.ambient.position', storage = 'session', tools, belowComposer, composerChips, composerLayout = 'card', renderComposer, sendContext, onOpenFull, onFeedback, notifications, autoDismissMs, maxToasts, systemNotifications = false,\n renderConnectionError, renderEntry, hideLine, displayLimit, paging, historicalMessageActions, unread: replyUnread, replyError, renderTool, toolRenderers, renderText, cards, 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);\n useLayoutEffect(() => { 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: PresentedArtifact; 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 // eslint-disable-next-line react-hooks/set-state-in-effect -- the browser's permission is read once after mount, so the server render and hydration agree on '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-md: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 follow = useArtifactFollow({ controller, conversationId: String(conversations?.activeId ?? controller.getSnapshot().conversationId), detect,\n active: opened, onOpen: setOpened, autoOpen: false, narrow: () => phone,\n showing: path => !inWindow || (expanded && opened?.descriptor.type !== 'task' && opened?.descriptor.target.resource.path === path),\n showingTask: subject => !inWindow || (expanded && opened?.descriptor.type === 'task' && taskSubjectKey(opened.descriptor.subject) === taskSubjectKey(subject)),\n openPresent: () => false, answer: actions?.answer });\n const { onShown, onFailed, open: openArtifact, isOpen: isArtifactOpen } = follow;\n const pendingTasks = useChatView(controller, pendingPresents);\n const requestedTasks = useRef(new WeakMap<NativeChatController, Set<string>>());\n useEffect(() => {\n if (inWindow || !hostArtifacts?.autoOpenTasks) return;\n let requested = requestedTasks.current.get(controller);\n if (!requested) { requested = new Set(); requestedTasks.current.set(controller, requested); }\n for (const call of pendingTasks) {\n if (!('subject' in call) || requested.has(call.id)) continue;\n requested.add(call.id);\n hostArtifacts.open(createTaskArtifact(call.subject), { onShown, onFailed });\n }\n }, [controller, inWindow, hostArtifacts, pendingTasks, onShown, onFailed]);\n const closeArtifact = () => { follow.recordClosed(); setOpened(null); setFullscreen(false); };\n const artifacts = useMemo<ArtifactsConfig | undefined>(() => !inWindow ? hostArtifacts && { ...hostArtifacts,\n open: artifact => hostArtifacts.open(artifact, { onShown, onFailed }) } : {\n open: artifact => artifact.type === 'task' ? hostArtifacts?.open(artifact, { onShown, onFailed }) : openArtifact(artifact), isOpen: isArtifactOpen,\n ...(detect ? { detect } : {}),\n ...(hostArtifacts?.kinds ? { kinds: hostArtifacts.kinds } : {}),\n recognise: hostArtifacts?.recognise ?? artifactCards.recognise, Card: hostArtifacts?.Card ?? artifactCards.Card,\n }, [inWindow, hostArtifacts, openArtifact, isArtifactOpen, onShown, onFailed, detect]);\n const session = useChatSession({ controller, activeController, mode, presentation, voiceReplay, targetLabel, actions, renderEntry, hideLine, historicalMessageActions, unread: replyUnread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => scrollToBottom.current(), rowExtras, feedback: composerFeedback, sendContext, 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, displayLimit ?? paging?.first ?? PAGE_FIRST, paging?.more ?? PAGE_MORE, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier }, displayLimit === undefined);\n const { stick: { scrollRef, isAtBottom, scrollToBottom: stickToBottom }, hidden } = transcript;\n const chunks = useRowChunks(useMemo(() => hidden ? rows.slice(hidden) : rows, [rows, hidden]));\n const known = follow.known, shownArtifact = follow.shown;\n const { reset } = transcript;\n useLayoutEffect(() => { scrollToBottom.current = () => { reset(); void stickToBottom('instant'); }; });\n const { barStart, barNote } = session.pickers();\n\n const conversationId = String(conversations?.activeId ?? state.conversationId);\n useLayoutEffect(() => { 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 useRunNotifications(controller, store, { enabled: notifications !== false, conversationId, title: shownTitle, onOutcome: setOutcome, text: noticeTextOf(labels),\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 // eslint-disable-next-line react-hooks/exhaustive-deps -- measures after every commit on purpose; both setters return the current value when nothing changed, so it settles\n useLayoutEffect(() => {\n const element = root.current;\n const fresh = viewportSize();\n // eslint-disable-next-line react-hooks/set-state-in-effect -- layout reads (viewport, own size) exist only after commit and must land before paint\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 const latestExpand = useRef(expand);\n useLayoutEffect(() => { latestExpand.current = expand; });\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 closeArtifact();\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 latestExpand.current();\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 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-md:h-14 max-md: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\"><ChatConnectionNotice state={state} renderConnectionError={renderConnectionError} onReconnect={() => act(controller.connect)} /></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={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 {chunks.map(run => <TranscriptChunk key={run[0]!.key} rows={run} context={rowContext} />)}\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 {/* Its own row under the messages, never over them (D-1379): the arrow used to cover the reply's last lines. */}\n {!isAtBottom && <div data-testid=\"transcript-navigation\" className=\"flex shrink-0 items-center justify-center py-1\"><MessageScrollerButton className=\"static inset-auto translate-x-0\" data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={transcript.reset}><icons.jumpToLatest aria-hidden=\"true\" /></MessageScrollerButton></div>}\n </MessageScroller>\n </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 <ChatStatusLine natural={session.natural} working={working} waitingForAnswer={waitingForAnswer} derived={derived} stop={state.stop} sending={session.sending} />\n <ChatNotices natural={session.natural} state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} />\n {!expanded && <ChatConnectionNotice compact fallback={{ error: labels.loadFailedShort, closed: labels.disconnectedShort }} state={state} renderConnectionError={renderConnectionError} onReconnect={() => act(controller.connect)} />}\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 {(renderComposer ?? (value => <Composer {...value} />))({ ...composer, layout: composerLayout, placeholder: working || waitingForAnswer ? label : labels.placeholder, barStart, 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={closeArtifact} fullscreen={fullscreen} onFullscreenChange={setFullscreen} storageKey=\"boring.ambient.panel-width\" panelLabel={labels.artifactPanel}\n defaultWidth={600} minPanel={320} minChat={300}\n panel={api => shownArtifact && shownArtifact.type !== 'task' ? artifactPanel(shownArtifact, { ...api, versions: known.filter((artifact): artifact is ArtifactDescriptor => artifact.type !== 'task'), follow: opened?.follow ?? true,\n select: follow.select, onShown: follow.onShown, onFailed: follow.onFailed }) : null}\n chat={conversation} /> : conversation}\n </div>\n </div>;\n}\n",
|
|
27
27
|
"type": "registry:component",
|
|
28
28
|
"target": "components/pi-ambient/ambient.tsx"
|
|
29
29
|
},
|