@hachej/boring-ui-kit 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "documents",
4
+ "title": "Documents: the Library, a file and its revisions",
5
+ "description": "The workspace's Library as the list, the chosen file in its viewer and its retained revisions beside it (an older one opens read-only). DocumentSheet opens one document over the app for a host's « Voir la fiche », with an optional « Toutes les fiches ». Every read goes through one revisionProvider binding.",
6
+ "dependencies": [
7
+ "@hachej/boring-ui-kit@^0.3.2",
8
+ "@hachej/boring-files@^0.3.2",
9
+ "react@^19.0.0",
10
+ "lucide-react@^1.51.0"
11
+ ],
12
+ "registryDependencies": [
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/theme.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/utils.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/button.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/pi-chat.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/pi-app.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/app-shell.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/file-tree.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/viewers.json",
21
+ "drawer"
22
+ ],
23
+ "files": [
24
+ {
25
+ "path": "registry/documents/documents.tsx",
26
+ "content": "'use client';\n\n// The documents screen: the workspace's Library is the list (`ListDetail`: beside the file on a wide screen, one pane at a time on a phone);\n// the chosen file opens in the same viewer the Library uses (editable where it edits); its Revisions sit beside it. Every revision row opens\n// that exact revision read-only, and « Back to editing » returns to the file. The editor stays mounted while a revision is inspected, so its\n// unsaved edits survive; leaving the file with unsaved edits asks first. Every read goes through the one `revisionProvider` the host binds.\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { createFileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport type { FileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport { FileTreeView } from '../file-tree/file-tree';\nimport type { RevisionProvider } from '@hachej/boring-files/revision';\nimport { mediaTypeOf, viewerKindOf } from '@hachej/boring-files/file-types';\nimport type { ViewerKind } from '@hachej/boring-files/file-types';\nimport { ListDetail } from '../app-shell/list-detail';\nimport { ArtifactPanel } from '../pi-app/artifact-panel';\nimport type { ArtifactDescriptor } from '../pi-chat/artifact';\nimport type { CustomViewers, ViewerOptions } from '../pi-app/artifact-panel';\nimport { FileViewer } from '../pi-app/file-viewer';\nimport { ImagePane } from '../viewers/image-pane';\nimport { PdfPane } from '../viewers/pdf-pane';\nimport { ViewerFrame } from '../viewers/viewer-frame';\nimport { useViewerText } from '../viewers/viewer-window';\nimport { cn } from '../utils/utils';\nimport { Revisions } from './revisions';\nimport type { DocumentsLabels } from './documents-labels';\nimport { documentsLabels, DocumentsWords } from './words';\nimport type { DocumentsLocale } from './words';\n\nexport interface DocumentsProps {\n /** The workspace's revision provider (the files guide's `connectRevisionProvider`, or a host's own binding). Borrowed: the host disposes it. */\n readonly provider: RevisionProvider;\n readonly locale?: DocumentsLocale | undefined;\n /** The block's own words, over `documentsLabels` (the viewers' words follow `locale`). */\n readonly labels?: Partial<DocumentsLabels> | undefined;\n /** Host viewers by kind, as for the Library (for example a canvas). */\n readonly viewers?: CustomViewers | undefined;\n readonly share?: ViewerOptions['share'];\n}\n\nconst nameOf = (path: string) => path.split('/').pop() || path;\n\n/** The workspace's change feed as a counter: it moves on every change, so the listings and the revisions are read again. */\nfunction useWorkspaceVersion(provider: RevisionProvider): number {\n const [version, setVersion] = useState(0);\n useEffect(() => {\n if (!provider.changes) return undefined;\n const abort = new AbortController();\n void (async () => {\n try { for await (const change of provider.changes!({ signal: abort.signal })) { void change; setVersion(current => current + 1); } } catch { /* the binding ended the feed: the listings stay as they are */ }\n })();\n return () => abort.abort();\n }, [provider]);\n return version;\n}\n\n/** The Library's tree over the provider: created and disposed with the binding, read again when the workspace changes. */\nfunction useFileTree(provider: RevisionProvider, version: number): FileTreeController | undefined {\n const [tree, setTree] = useState<FileTreeController>();\n useEffect(() => {\n const next = createFileTreeController({ revisionProvider: provider });\n setTree(next);\n void next.refresh();\n return () => next.dispose();\n }, [provider]);\n useEffect(() => {\n if (!tree || version === 0) return;\n const { query, expanded } = tree.getSnapshot();\n if (query) void tree.search(query);\n else for (const directory of ['', ...expanded]) void tree.refresh(directory);\n }, [tree, version]);\n return tree;\n}\n\n/**\n * The provider as it reads one revision: every read of the returned client is the exact `revision`, over the same provider (a reload in a\n * pinned view re-reads that revision, never the latest). It is for read-only views only.\n */\nfunction pinnedProviderOf(provider: RevisionProvider, revision: string): RevisionProvider {\n return { ...provider, read: (request, signal) => provider.read({ target: request.target, revision: { kind: 'exact', value: revision } }, signal) };\n}\n\n/** An image or PDF revision, read exactly and shown in the viewer's read-only pane. */\nfunction BinaryRevision({ provider, path, revision, kind, labels, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly revision: string;\n readonly kind: 'image' | 'pdf';\n readonly labels: DocumentsLabels;\n readonly onClose: () => void;\n}) {\n const [state, setState] = useState<{ readonly kind: 'loading' } | { readonly kind: 'failed' } | { readonly kind: 'ready'; readonly bytes: Uint8Array; readonly mediaType: string }>({ kind: 'loading' });\n const { labels: viewer } = useViewerText();\n useEffect(() => {\n const abort = new AbortController();\n setState({ kind: 'loading' });\n provider.read({ target: provider.locate(path), revision: { kind: 'exact', value: revision } }, abort.signal).then(result => {\n if (!abort.signal.aborted) setState(result.kind === 'available' ? { kind: 'ready', bytes: result.snapshot.bytes, mediaType: result.snapshot.mediaType } : { kind: 'failed' });\n }, () => { if (!abort.signal.aborted) setState({ kind: 'failed' }); });\n return () => abort.abort();\n }, [provider, path, revision]);\n const status = { label: viewer.readOnly, kind: 'read-only' as const };\n if (state.kind !== 'ready') return <ViewerFrame title={nameOf(path)} status={status} onClose={onClose}>\n <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{state.kind === 'loading' ? labels.revisionsLoading : labels.revisionLoadFailed}</p>\n </ViewerFrame>;\n return kind === 'image'\n ? <ImagePane name={nameOf(path)} mediaType={state.mediaType} bytes={state.bytes} status={status} onClose={onClose} />\n : <PdfPane name={nameOf(path)} bytes={state.bytes} status={status} onClose={onClose} />;\n}\n\n/** One revision of the file, read-only, with « Back to editing ». Text, Markdown and HTML use the artifact panel's pinned view. */\nfunction OlderVersion({ provider, path, revision, options, labels, onBack, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly revision: string;\n readonly options: ViewerOptions;\n readonly labels: DocumentsLabels;\n readonly onBack: () => void;\n readonly onClose: () => void;\n}) {\n const kind: ViewerKind = viewerKindOf(path);\n const pinned = useMemo(() => pinnedProviderOf(provider, revision), [provider, revision]);\n const type = kind === 'markdown' ? 'markdown' : kind === 'html' ? 'html' : kind === 'text' ? 'code' : undefined;\n const artifact: ArtifactDescriptor | undefined = type === undefined ? undefined\n : { schema: 'boring.artifact', version: 1, title: nameOf(path), type, mediaType: mediaTypeOf(path), target: provider.locate(path), revision };\n const bar = <div className=\"flex shrink-0 items-center gap-2 border-b border-border px-3 py-2\">\n <span role=\"status\" className=\"min-w-0 flex-1 truncate text-sm font-medium\">{labels.readOnlyRevision}</span>\n <button type=\"button\" data-testid=\"back-to-editing\" onClick={onBack}\n className=\"inline-flex min-h-8 pointer-coarse:min-h-11 pointer-coarse:min-w-11 shrink-0 items-center rounded-lg border border-border bg-background px-3 text-xs pointer-coarse:text-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/60\">{labels.backToEditing}</button>\n </div>;\n let body;\n if (artifact) body = <ArtifactPanel active={{ descriptor: artifact, follow: false }} versions={[]} options={{ ...options, client: pinned }} onSelect={onBack} onClose={onClose} />;\n else if (kind === 'image' || kind === 'pdf') body = <BinaryRevision provider={provider} path={path} revision={revision} kind={kind} labels={labels} onClose={onClose} />;\n else body = <ViewerFrame title={nameOf(path)} onClose={onClose}><p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.unsupportedPreview}</p></ViewerFrame>;\n return <div data-testid=\"revision-view\" data-kind={kind} data-revision={revision} className=\"flex h-full min-h-0 flex-1 flex-col\">\n {bar}\n <div className=\"flex min-h-0 flex-1 flex-col\">{body}</div>\n </div>;\n}\n\nfunction DocumentDetail({ provider, path, version, options, labels, locale, older, onDirty, onOpenRevision, onBack, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly version: number;\n readonly options: ViewerOptions;\n readonly labels: DocumentsLabels;\n readonly locale: DocumentsLocale;\n /** The older revision on show, `undefined` for the editable file. */\n readonly older: string | undefined;\n readonly onDirty: (unsaved: boolean) => void;\n readonly onOpenRevision: (revision: string) => void;\n readonly onBack: () => void;\n readonly onClose: () => void;\n}) {\n return <div data-testid=\"documents-detail\" className=\"flex h-full min-h-0 flex-col md:flex-row [&_button]:pointer-coarse:min-h-11 [&_button]:pointer-coarse:min-w-11\">\n <div className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n {/* The editor stays mounted (hidden) while a revision is on show, so its unsaved edits are kept. */}\n <div className={cn('min-h-0 flex-1 flex-col', older === undefined ? 'flex' : 'hidden')}>\n <FileViewer path={path} locator={provider.locate(path)} options={options} onClose={onClose} onUnsavedChange={onDirty} />\n </div>\n {older !== undefined && <OlderVersion provider={provider} path={path} revision={older} options={options} labels={labels} onBack={onBack} onClose={onClose} />}\n </div>\n <aside className=\"flex max-h-[40%] min-h-0 shrink-0 flex-col overflow-y-auto border-t border-border md:max-h-none md:w-72 md:border-t-0 md:border-l\">\n <Revisions provider={provider} path={path} version={version} selected={older} locale={locale} labels={labels} onOpen={onOpenRevision} />\n </aside>\n </div>;\n}\n\nexport function Documents({ provider, locale, labels: given, viewers, share }: DocumentsProps) {\n const labels = documentsLabels(locale, given);\n const language: DocumentsLocale = locale === 'fr' ? 'fr' : 'en';\n const version = useWorkspaceVersion(provider);\n const tree = useFileTree(provider, version);\n const [path, setPath] = useState<string>();\n // An older revision of the file on show (`undefined`: the editable file).\n const [pinned, setPinned] = useState<{ readonly path: string; readonly revision: string }>();\n // The file's unsaved edits, as its viewer reports them. A change of file or a close that would drop them asks first.\n const dirty = useRef(false);\n const onDirty = useCallback((unsaved: boolean) => { dirty.current = unsaved; }, []);\n const mayLeave = () => !dirty.current || window.confirm(labels.discardChanges);\n const options = useMemo<ViewerOptions>(() => ({ client: provider, identity: provider.identity, ...(viewers ? { viewers } : {}), ...(share ? { share } : {}) }), [provider, viewers, share]);\n const choose = (next: string) => {\n if (next === path || !mayLeave()) return;\n setPath(next);\n setPinned(undefined);\n };\n const closeFile = () => { if (!mayLeave()) return; setPath(undefined); setPinned(undefined); };\n const list = tree ? <div className=\"min-h-0 [&_button]:pointer-coarse:min-h-11 [&_button]:pointer-coarse:min-w-11\">\n <FileTreeView controller={tree} onOpen={choose} selectedPath={path} locale={language} upload={false} history={false} />\n </div> : null;\n const older = path !== undefined && pinned?.path === path ? pinned.revision : undefined;\n // `null` (nothing chosen) is what lets a phone show the list alone: ListDetail stacks the detail only when there is one.\n const detail = path === undefined ? null\n : <DocumentDetail key={path} provider={provider} path={path} version={version} options={options} labels={labels} locale={language} older={older}\n onDirty={onDirty} onOpenRevision={revision => setPinned({ path, revision })} onBack={() => setPinned(undefined)} onClose={closeFile} />;\n return <DocumentsWords locale={locale}>\n <ListDetail list={list} detail={detail} onBack={closeFile} />\n </DocumentsWords>;\n}\n",
27
+ "type": "registry:component",
28
+ "target": "components/documents/documents.tsx"
29
+ },
30
+ {
31
+ "path": "registry/documents/document-sheet.tsx",
32
+ "content": "'use client';\n\n// One document over the app, for « Voir la fiche »: the host sets `path` from its own handler (for example its `onOpenSpec`) and clears it in\n// `onClose`. The sheet is a full-screen view of the shell (`useShellLayer`): it closes the agent layers when it opens, and Back closes it\n// first. The file is the same viewer the Library opens (editable where the viewer edits). Closing a file with unsaved edits asks first.\n// `onShowAll` adds « Toutes les fiches »: the sheet closes, then the host's navigation runs after the sheet's Back entry is gone.\nimport { useCallback, useEffect, useRef } from 'react';\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { afterBack } from '../app-shell/back';\nimport { useShellLayer } from '../app-shell/shell-layer';\nimport { useExclusiveSheet } from '../app-shell/sheet';\nimport { FileViewer } from '../pi-app/file-viewer';\nimport type { CustomViewers, ViewerOptions } from '../pi-app/artifact-panel';\nimport type { RevisionProvider } from '@hachej/boring-files/revision';\nimport { cn } from '../utils/utils';\nimport { documentsLabels, DocumentsWords } from './words';\nimport type { DocumentsLocale } from './words';\nimport type { DocumentsLabels } from './documents-labels';\n\nexport interface DocumentSheetProps {\n readonly provider: RevisionProvider;\n /** The workspace path of the document on show; `undefined` closes the sheet. */\n readonly path: string | undefined;\n /** Called when the sheet closes (its close button, a swipe, Back, « Toutes les fiches »): the host clears `path`. */\n readonly onClose: () => void;\n /** Adds « Toutes les fiches » to the sheet. The sheet closes first, then this runs. */\n readonly onShowAll?: (() => void) | undefined;\n readonly locale?: DocumentsLocale | undefined;\n readonly labels?: Partial<DocumentsLabels> | undefined;\n /** Host viewers by kind, as for the Library (for example a canvas). */\n readonly viewers?: CustomViewers | undefined;\n readonly share?: ViewerOptions['share'];\n readonly className?: string | undefined;\n}\n\n/** The sheet's name in the shell's layers: one sheet of this kind at a time. */\nconst LAYER = 'document';\n\nexport function DocumentSheet({ provider, path, onClose, onShowAll, locale, labels: given, viewers, share, className }: DocumentSheetProps) {\n const shown = path !== undefined;\n const labels = documentsLabels(locale, given);\n // The viewer reports its unsaved edits; a close that would drop them asks first (the Back path too, through the shell layer).\n const dirty = useRef(false);\n const report = useCallback((unsaved: boolean) => { dirty.current = unsaved; }, []);\n const mayLeave = () => !dirty.current || window.confirm(labels.discardChanges);\n const { open, close, isOpen } = useShellLayer(LAYER, { confirmBack: mayLeave });\n // The host's `path` decides whether the sheet is on show; a close from the sheet itself reports to the host.\n useEffect(() => { if (shown) open(); else close(); }, [shown, open, close]);\n useExclusiveSheet(isOpen, close);\n const wasOpen = useRef(false);\n useEffect(() => {\n if (shown && wasOpen.current && !isOpen) onClose();\n wasOpen.current = isOpen;\n }, [isOpen, shown, onClose]);\n const requestClose = () => { if (mayLeave()) close(); };\n const title = path === undefined ? labels.sheetTitle : path.split('/').pop() || path;\n return <DocumentsWords locale={locale}>\n <Drawer open={isOpen} onOpenChange={next => { if (!next) requestClose(); }} showSwipeHandle swipeDirection=\"down\">\n <DrawerContent data-testid=\"document-sheet\" className={cn('app-shell h-[92dvh] max-h-[92dvh] pb-[env(safe-area-inset-bottom)]', className)}>\n {/* The title is for assistive technology: the viewer's own bar shows the file name. */}\n <DrawerTitle className=\"sr-only\">{title}</DrawerTitle>\n {onShowAll && <div className=\"flex shrink-0 justify-end px-3 pt-1 pb-2\">\n <button type=\"button\" data-testid=\"document-sheet-all\" onClick={() => { if (!mayLeave()) return; close(); afterBack(onShowAll); }}\n className=\"inline-flex min-h-8 pointer-coarse:min-h-11 pointer-coarse:min-w-11 shrink-0 items-center rounded-lg border border-border bg-background px-3 text-xs pointer-coarse:text-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/60\">{labels.allDocuments}</button>\n </div>}\n <div className=\"flex min-h-0 flex-1 flex-col overflow-hidden [&_button]:pointer-coarse:min-h-11 [&_button]:pointer-coarse:min-w-11\">\n {path !== undefined && <FileViewer key={path} path={path} locator={provider.locate(path)} onUnsavedChange={report}\n options={{ client: provider, identity: provider.identity, ...(viewers ? { viewers } : {}), ...(share ? { share } : {}) }} onClose={requestClose} />}\n </div>\n </DrawerContent>\n </Drawer>\n </DocumentsWords>;\n}\n",
33
+ "type": "registry:component",
34
+ "target": "components/documents/document-sheet.tsx"
35
+ },
36
+ {
37
+ "path": "registry/documents/revisions.tsx",
38
+ "content": "'use client';\n\n// The revisions of one file: what the workspace's history retains for it (`provider.history`, newest first), each with when it was saved and\n// who wrote it (`source`). Every row opens that exact revision read-only; going back to editing is a separate control in the documents\n// screen. The operation id is not shown: the public history carries none (a follow-up for the files seam).\nimport { useEffect, useState } from 'react';\nimport type { RevisionProvider, SavedRevision } from '@hachej/boring-files/revision';\nimport { cn } from '../utils/utils';\nimport type { DocumentsLabels } from './documents-labels';\n\ninterface Loaded { readonly provider: RevisionProvider; readonly path: string; readonly saves: readonly SavedRevision[] }\n\n/**\n * The retained revisions of `path` read from `provider`, read again when `version` changes. The answer is kept with the provider and the\n * path it was read for: another provider or path gets `undefined` at once, and a late answer for one of them is dropped.\n */\nexport function useRevisions(provider: RevisionProvider, path: string, version: number): readonly SavedRevision[] | undefined {\n const [loaded, setLoaded] = useState<Loaded>();\n useEffect(() => {\n const abort = new AbortController();\n const save = (saves: readonly SavedRevision[]) => { if (!abort.signal.aborted) setLoaded({ provider, path, saves }); };\n provider.history(path, abort.signal).then(save, () => save([]));\n return () => abort.abort();\n }, [provider, path, version]);\n return loaded && loaded.provider === provider && loaded.path === path ? loaded.saves : undefined;\n}\n\n/** When a revision was saved, in the page's language; `savedAt` 0 means the journal did not record a time. */\nexport function whenSaved(savedAt: number, locale: string, unknown: string): string {\n if (!savedAt) return unknown;\n return new Date(savedAt).toLocaleString(locale === 'fr' ? 'fr-FR' : 'en-GB', { dateStyle: 'medium', timeStyle: 'short' });\n}\n\n/** The panel's list: one row per retained revision, newest first. Each row opens its own revision read-only. */\nexport function Revisions({ provider, path, version, selected, locale, labels, onOpen }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n /** Bumped when the workspace's change feed moves, so the list is read again. */\n readonly version: number;\n /** The revision on show in the read-only view, if any. */\n readonly selected: string | undefined;\n readonly locale: string;\n readonly labels: DocumentsLabels;\n readonly onOpen: (revision: string) => void;\n}) {\n const saves = useRevisions(provider, path, version);\n return <section data-testid=\"revisions\" aria-label={labels.revisions} className=\"flex min-h-0 flex-col\">\n <h2 className=\"m-0 px-3 py-2 text-sm font-semibold\">{labels.revisions}</h2>\n {saves === undefined ? <p role=\"status\" className=\"m-0 px-3 pb-3 text-sm text-muted-foreground\">{labels.revisionsLoading}</p>\n : saves.length === 0 ? <p role=\"status\" className=\"m-0 px-3 pb-3 text-sm text-muted-foreground\">{labels.noRevisions}</p>\n : <ol className=\"m-0 min-h-0 list-none overflow-y-auto p-1\">\n {saves.map((save, index) => {\n const current = index === 0;\n const on = selected === save.revision;\n const when = whenSaved(save.savedAt, locale, labels.unknownTime);\n const who = save.source ? labels.savedBy[save.source] : labels.savedBy.observed;\n return <li key={save.revision}>\n <button type=\"button\" data-testid=\"revision-item\" data-revision={save.revision} data-current={current ? 'true' : undefined}\n aria-current={on ? 'true' : undefined} aria-label={labels.openRevision(when)} onClick={() => onOpen(save.revision)}\n className={cn('flex min-h-11 w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left text-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/60',\n on && 'bg-accent')}>\n <span className=\"font-medium\">{current ? labels.latest : when}</span>\n <span className=\"text-xs text-muted-foreground\">{current ? `${when}, ${who}` : who}</span>\n </button>\n </li>;\n })}\n </ol>}\n </section>;\n}\n",
39
+ "type": "registry:component",
40
+ "target": "components/documents/revisions.tsx"
41
+ },
42
+ {
43
+ "path": "registry/documents/words.tsx",
44
+ "content": "'use client';\n\n// The words the documents block shows: its own, and the viewers' (the pi-app labels the file viewer and the artifact panel read, and the\n// viewer window's labels the viewer bars read). One locale for all of them: `fr` takes the French sets, anything else English, and a\n// `labels` object overrides any word of the block's own.\nimport type { ReactNode } from 'react';\nimport { defaultAppIcons, defaultAppLabels, AppTextProvider } from '../pi-app/app-labels';\nimport { frenchAppLabels } from '../pi-app/app-labels-fr';\nimport { defaultViewerIcons, defaultViewerLabels, ViewerWindowProvider } from '../viewers/viewer-window';\nimport { frenchViewerLabels } from '../viewers/viewer-labels-fr';\nimport { defaultDocumentsLabels, frenchDocumentsLabels } from './documents-labels';\nimport type { DocumentsLabels } from './documents-labels';\n\nexport type DocumentsLocale = 'en' | 'fr';\n\n/** The block's own words for `locale`, with the host's overrides. */\nexport function documentsLabels(locale: DocumentsLocale | undefined, given: Partial<DocumentsLabels> | undefined): DocumentsLabels {\n return { ...(locale === 'fr' ? frenchDocumentsLabels : defaultDocumentsLabels), ...given };\n}\n\n/** Provides the app's and the viewers' words for `locale` to everything below (the file viewer, the artifact panel, the viewer bars). */\nexport function DocumentsWords({ locale, children }: { readonly locale: DocumentsLocale | undefined; readonly children: ReactNode }) {\n const fr = locale === 'fr';\n return <AppTextProvider value={{ labels: fr ? frenchAppLabels : defaultAppLabels, icons: defaultAppIcons }}>\n <ViewerWindowProvider value={{ labels: fr ? frenchViewerLabels : defaultViewerLabels, icons: defaultViewerIcons }}>{children}</ViewerWindowProvider>\n </AppTextProvider>;\n}\n",
45
+ "type": "registry:component",
46
+ "target": "components/documents/words.tsx"
47
+ },
48
+ {
49
+ "path": "registry/documents/documents-labels.ts",
50
+ "content": "/*\n * The words of the documents block as plain data: its own (the Library's title, the revisions panel, the sheet) in English and French. The\n * viewers it composes take their words from the pi-app block (`defaultAppLabels` / `frenchAppLabels`), which `Documents` provides.\n */\n/** Who wrote a revision, as the history reports it. */\nexport type SavedByKind = 'publish' | 'agent' | 'observed';\n\nexport const defaultDocumentsLabels = {\n revisions: 'Revisions',\n noRevisions: 'No saved revisions yet.',\n revisionsLoading: 'Loading revisions…',\n latest: 'Latest',\n openRevision: (when: string) => `Open the version saved ${when}`,\n savedBy: { publish: 'saved', agent: 'written by the agent', observed: 'found in the workspace' } as Record<SavedByKind, string>,\n unknownTime: 'time unknown',\n unsupportedPreview: 'Older versions of this kind have no preview here.',\n backToEditing: 'Back to editing',\n discardChanges: 'This document has unsaved changes. Leave without saving them?',\n readOnlyRevision: 'This version is read-only.',\n revisionLoadFailed: 'This version could not be read.',\n allDocuments: 'All documents',\n sheetTitle: 'Document',\n};\n\nexport type DocumentsLabels = typeof defaultDocumentsLabels;\n\nexport const frenchDocumentsLabels: DocumentsLabels = {\n revisions: 'Versions',\n noRevisions: 'Aucune version enregistrée pour l’instant.',\n revisionsLoading: 'Chargement des versions…',\n latest: 'Dernière',\n openRevision: (when: string) => `Ouvrir la version enregistrée ${when}`,\n savedBy: { publish: 'enregistrée', agent: 'écrite par l’agent', observed: 'trouvée dans l’espace de travail' },\n unknownTime: 'date inconnue',\n unsupportedPreview: 'Les anciennes versions de ce type ne s’affichent pas ici.',\n backToEditing: 'Revenir à la modification',\n discardChanges: 'Cette fiche contient des modifications non enregistrées. Quitter sans les enregistrer ?',\n readOnlyRevision: 'Cette version est en lecture seule.',\n revisionLoadFailed: 'Cette version n’a pas pu être lue.',\n allDocuments: 'Toutes les fiches',\n sheetTitle: 'Fiche',\n};\n",
51
+ "type": "registry:lib",
52
+ "target": "components/documents/documents-labels.ts"
53
+ }
54
+ ],
55
+ "type": "registry:block"
56
+ }
@@ -4,7 +4,7 @@
4
4
  "title": "Feedback preview",
5
5
  "description": "PreviewBanner: a live, reversible preview of a change in the page (Approve / Discard) over `@hachej/boring-feedback/preview`, whose subagent calls models through a host gateway. Separate from `feedback` so annotation-only apps do not install the model SDK; install `feedback` first (its styles cover the banner).",
6
6
  "dependencies": [
7
- "@hachej/boring-feedback@^0.3.0",
7
+ "@hachej/boring-feedback@^0.3.2",
8
8
  "@earendil-works/pi-ai@~1.2.0",
9
9
  "react@^19.0.0"
10
10
  ],
package/r/feedback.json CHANGED
@@ -4,16 +4,16 @@
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.3.0",
8
- "@hachej/boring-ui-kit@^0.3.0",
9
- "@hachej/boring-files@^0.3.0",
7
+ "@hachej/boring-feedback@^0.3.2",
8
+ "@hachej/boring-ui-kit@^0.3.2",
9
+ "@hachej/boring-files@^0.3.2",
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.3.0/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/utils.json"
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/utils.json"
17
17
  ],
18
18
  "files": [
19
19
  {
@@ -72,7 +72,7 @@
72
72
  },
73
73
  {
74
74
  "path": "registry/feedback/feedback-labels.ts",
75
- "content": "// The words of the feedback controls that cancel a selection, ask a general question and drive point mode, as data. The defaults are English, like the\n// rest of the copied components; `feedbackLabelsFr` is the French set (pass it as `labels` to AnnotateSheet, FeedbackBar,\n// NoteBubble and PointMode). A host may pass any subset: what it leaves out keeps the default.\nexport interface FeedbackLabels {\n /** The annotate sheet's title for a selected zone, and the bubble's accessible name prefix is `note`. */\n readonly title: string;\n readonly close: string;\n /** The button that drops the selected zone and keeps what was typed. */\n readonly cancelSelection: string;\n /** The entry point (bar button, sheet button, general sheet title): a note with no element attached. */\n readonly askGeneral: string;\n /** The sheet's field label for a selected zone / for a general question. */\n readonly changeField: string;\n readonly generalField: string;\n readonly copyReport: string;\n readonly save: string;\n readonly saveAgain: string;\n readonly saving: string;\n readonly saved: string;\n readonly bubblePlaceholder: string;\n /** The pin's one-line desktop hint: holding Alt passes clicks to the app. */\n readonly altHint: string;\n /** The pin's field label, its Add button, and the mic inside the field (record, stop, transcribing, nothing heard, failed). */\n readonly add: string;\n readonly voiceRecord: string;\n readonly voiceStop: string;\n readonly voiceTranscribing: string;\n readonly voiceNothing: string;\n readonly voiceFailed: string;\n /** Announced when the selection was cancelled, so a screen reader hears that the zone is gone. */\n readonly selectionCancelled: string;\n /** Point mode's bar (phones): the hint, the count of picks, Done, Cancel and the \"use the app\" toggle with its hint. */\n readonly pointHint: string;\n readonly pointCount: (count: number) => string;\n readonly pointDone: string;\n readonly pointCancel: string;\n readonly pointUseApp: string;\n readonly pointUsing: string;\n readonly pointBar: string;\n}\n\nexport const feedbackLabels: FeedbackLabels = {\n title: 'Feedback',\n close: 'Close',\n cancelSelection: 'Cancel selection',\n askGeneral: 'Ask a general question',\n changeField: 'What should change?',\n generalField: 'Your question',\n copyReport: 'Copy report',\n save: 'Save',\n saveAgain: 'Save again',\n saving: 'Saving…',\n saved: 'Saved',\n bubblePlaceholder: \"What's wrong here?\",\n altHint: 'Hold ⌥ to use the app',\n add: 'Add note',\n voiceRecord: 'Speak your note',\n voiceStop: 'Stop recording',\n voiceTranscribing: 'Transcribing…',\n voiceNothing: 'Nothing was heard. Your text is kept.',\n voiceFailed: 'The recording could not be transcribed. Your text is kept.',\n selectionCancelled: 'Selection cancelled.',\n pointHint: 'Tap an element to comment',\n pointCount: count => `${count} ${count === 1 ? 'selection' : 'selections'}`,\n pointDone: 'Done',\n pointCancel: 'Cancel',\n pointUseApp: 'Use the app',\n pointUsing: 'Use the app, then come back to comment',\n pointBar: 'Point mode',\n};\n\nexport const feedbackLabelsFr: FeedbackLabels = {\n title: 'Retour',\n close: 'Fermer',\n cancelSelection: 'Annuler la sélection',\n askGeneral: 'Poser une question générale',\n changeField: 'Que faut-il changer ?',\n generalField: 'Votre question',\n copyReport: 'Copier le rapport',\n save: 'Enregistrer',\n saveAgain: 'Enregistrer à nouveau',\n saving: 'Enregistrement…',\n saved: 'Enregistré',\n bubblePlaceholder: \"Qu'est-ce qui ne va pas ici ?\",\n altHint: 'Maintenez ⌥ pour utiliser l’app',\n add: 'Ajouter la note',\n voiceRecord: 'Dicter votre note',\n voiceStop: 'Arrêter l’enregistrement',\n voiceTranscribing: 'Transcription…',\n voiceNothing: 'Rien n’a été entendu. Votre texte est conservé.',\n voiceFailed: 'L’enregistrement n’a pas pu être transcrit. Votre texte est conservé.',\n selectionCancelled: 'Sélection annulée.',\n pointHint: 'Touchez un élément à commenter',\n pointCount: count => `${count} ${count <= 1 ? 'sélection' : 'sélections'}`,\n pointDone: 'Terminé',\n pointCancel: 'Annuler',\n pointUseApp: 'Utiliser l’app',\n pointUsing: 'Utilisez l’app, puis revenez commenter',\n pointBar: 'Mode pointage',\n};\n\n/** The default labels with the host's overrides on top. */\nexport const withLabels = (labels?: Partial<FeedbackLabels>): FeedbackLabels => (labels ? { ...feedbackLabels, ...labels } : feedbackLabels);\n",
75
+ "content": "// The words of the feedback controls that cancel a selection, ask a general question and drive point mode, as data. The defaults are English, like the\n// rest of the copied components; `feedbackLabelsFr` is the French set (pass it as `labels` to AnnotateSheet, FeedbackBar,\n// NoteBubble and PointMode). A host may pass any subset: what it leaves out keeps the default.\nexport interface FeedbackLabels {\n /** The annotate sheet's title for a selected zone, and the bubble's accessible name prefix is `note`. */\n readonly title: string;\n readonly close: string;\n /** The button that drops the selected zone and keeps what was typed. */\n readonly cancelSelection: string;\n /** The entry point (bar button, sheet button, general sheet title): a note with no element attached. */\n readonly askGeneral: string;\n /** The sheet's field label for a selected zone / for a general question. */\n readonly changeField: string;\n readonly generalField: string;\n readonly copyReport: string;\n readonly save: string;\n readonly saveAgain: string;\n readonly saving: string;\n readonly saved: string;\n readonly bubblePlaceholder: string;\n /** The pin's one-line desktop hint: holding Alt passes clicks to the app. */\n readonly altHint: string;\n /** The pin's field label, its Add button, and the mic inside the field (record, stop, transcribing, nothing heard, failed). */\n readonly add: string;\n readonly voiceRecord: string;\n readonly voiceStop: string;\n readonly voiceTranscribing: string;\n readonly voiceNothing: string;\n readonly voiceFailed: string;\n /** Announced when the selection was cancelled, so a screen reader hears that the zone is gone. */\n readonly selectionCancelled: string;\n /** Point mode's bar (phones): the hint, the count of picks, Done, Cancel and the \"use the app\" toggle with its hint. */\n readonly pointHint: string;\n readonly pointCount: (count: number) => string;\n readonly pointDone: string;\n readonly pointCancel: string;\n readonly pointUseApp: string;\n readonly pointUsing: string;\n readonly pointBar: string;\n}\n\nexport const feedbackLabels: FeedbackLabels = {\n title: 'Feedback',\n close: 'Close',\n cancelSelection: 'Cancel selection',\n askGeneral: 'Ask a general question',\n changeField: 'What should change?',\n generalField: 'Your question',\n copyReport: 'Copy report',\n save: 'Save',\n saveAgain: 'Save again',\n saving: 'Saving…',\n saved: 'Saved',\n bubblePlaceholder: \"What's wrong here?\",\n altHint: 'Hold ⌥ to use the app',\n add: 'Add note',\n voiceRecord: 'Speak your note',\n voiceStop: 'Stop recording',\n voiceTranscribing: 'Transcribing…',\n voiceNothing: 'Nothing was heard. Your text is kept.',\n voiceFailed: 'The recording could not be transcribed. Your text is kept.',\n selectionCancelled: 'Selection cancelled.',\n pointHint: 'Tap an element to comment',\n pointCount: count => `${count} ${count === 1 ? 'selection' : 'selections'}`,\n pointDone: 'Done',\n pointCancel: 'Cancel',\n pointUseApp: 'Use the app',\n pointUsing: 'Use the app, then come back to comment',\n pointBar: 'Point mode',\n};\n\nexport const feedbackLabelsFr: FeedbackLabels = {\n title: 'Retour',\n close: 'Fermer',\n cancelSelection: 'Annuler la sélection',\n askGeneral: 'Poser une question générale',\n changeField: 'Que faut-il changer ?',\n generalField: 'Votre question',\n copyReport: 'Copier le rapport',\n save: 'Enregistrer',\n saveAgain: 'Enregistrer à nouveau',\n saving: 'Enregistrement…',\n saved: 'Enregistré',\n bubblePlaceholder: \"Qu’est-ce qui ne va pas ici ?\",\n altHint: 'Maintenez ⌥ pour utiliser l’app',\n add: 'Ajouter la note',\n voiceRecord: 'Dicter votre note',\n voiceStop: 'Arrêter l’enregistrement',\n voiceTranscribing: 'Transcription…',\n voiceNothing: 'Rien n’a été entendu. Votre texte est conservé.',\n voiceFailed: 'L’enregistrement n’a pas pu être transcrit. Votre texte est conservé.',\n selectionCancelled: 'Sélection annulée.',\n pointHint: 'Touchez un élément à commenter',\n pointCount: count => `${count} ${count <= 1 ? 'sélection' : 'sélections'}`,\n pointDone: 'Terminé',\n pointCancel: 'Annuler',\n pointUseApp: 'Utiliser l’app',\n pointUsing: 'Utilisez l’app, puis revenez commenter',\n pointBar: 'Mode pointage',\n};\n\n/** The default labels with the host's overrides on top. */\nexport const withLabels = (labels?: Partial<FeedbackLabels>): FeedbackLabels => (labels ? { ...feedbackLabels, ...labels } : feedbackLabels);\n",
76
76
  "type": "registry:lib",
77
77
  "target": "components/feedback/feedback-labels.ts"
78
78
  },
package/r/file-tree.json CHANGED
@@ -4,14 +4,14 @@
4
4
  "title": "File tree",
5
5
  "description": "Browse, search, upload and inspect retained versions through one revisionProvider binding.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.3.0",
8
- "@hachej/boring-files@^0.3.0",
7
+ "@hachej/boring-ui-kit@^0.3.2",
8
+ "@hachej/boring-files@^0.3.2",
9
9
  "react@^19.0.0",
10
10
  "radix-ui@^1.8.0",
11
11
  "lucide-react@^1.51.0"
12
12
  ],
13
13
  "registryDependencies": [
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/theme.json"
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/theme.json"
15
15
  ],
16
16
  "files": [
17
17
  {
@@ -4,8 +4,8 @@
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.3.0",
8
- "@hachej/boring-files@^0.3.0",
7
+ "@hachej/boring-ui-kit@^0.3.2",
8
+ "@hachej/boring-files@^0.3.2",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0"
11
11
  ],
@@ -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.3.0",
8
- "@hachej/boring-files@^0.3.0",
7
+ "@hachej/boring-ui-kit@^0.3.2",
8
+ "@hachej/boring-files@^0.3.2",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "marked@^17.0.6",
package/r/pi-ambient.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "title": "Pi ambient agent",
5
5
  "description": "AmbientChat, a compact floating agent bar that grows into a chat window over an existing page, and AgentNotifications, toasts for finished, failed and waiting tasks (with the one guarded browser Notification helper). A block over the pi-chat item; its in-window artifact panel uses the pi-workspace layout.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.3.0",
7
+ "@hachej/boring-ui-kit@^0.3.2",
8
8
  "react@^19.0.0",
9
9
  "react-dom@^19.0.0",
10
10
  "@earendil-works/pi-durable@~1.2.0",
@@ -12,15 +12,16 @@
12
12
  "lucide-react@^1.51.0"
13
13
  ],
14
14
  "registryDependencies": [
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/pi-workspace.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/utils.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/button.json"
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/theme.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/pi-chat.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/pi-workspace.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/utils.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/button.json"
19
20
  ],
20
21
  "files": [
21
22
  {
22
23
  "path": "registry/pi-ambient/ambient.tsx",
23
- "content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport type { ImageContent, ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { BellIcon, ChevronDownIcon, ExternalLinkIcon, GripVerticalIcon, Loader2Icon, MinusIcon, PanelLeftIcon, ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react';\nimport { Button } from '../button/button';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction, BlockIcon } from '../button/actions';\nimport { CopyButton } from '../pi-chat/code-block';\nimport { Composer } from '../pi-chat/composer';\nimport type { ComposerFeedback } from '../pi-chat/composer';\nimport type { AttachmentsConfig, ConversationsConfig, EffortConfig, MentionsConfig, ModelConfig, SlashConfig } from '../pi-chat/config';\nimport { notifyPermission, requestNotifyPermission } from './browser-notify';\nimport type { NotifyPermission } from './browser-notify';\nimport { ConversationHistory } from '../pi-chat/history';\nimport type { CommandMentions } from '../pi-chat/markdown';\nimport { RowView } from '../pi-chat/message';\nimport type { RowContext } from '../pi-chat/message';\nimport { ChatNotices, Notice } from '../pi-chat/notice';\nimport { AgentNotifications, KindIcon, createNotificationStore, defaultNotificationLabels, finishNotice, pendingQuestion, useNotifications } from './notifications';\nimport type { AgentNotification, NotificationLabels, NotificationStore } from './notifications';\nimport { ChatTextProvider, defaultChatIcons, defaultChatLabels, useMergedText } from '../pi-chat/labels';\nimport type { ChatIcons, ChatLabels } from '../pi-chat/labels';\nimport { MessageQueue } from '../pi-chat/queue';\nimport { artifactKey, collectArtifacts } from '../pi-chat/artifact';\nimport type { ArtifactDescriptor, ArtifactsConfig } from '../pi-chat/artifact';\nimport type { ChatCard, Mode } from '../pi-chat/rows';\nimport { useChatSession, useTranscript } from '../pi-chat/session';\nimport type { ChatFeatureProps, ChatSession } from '../pi-chat/session';\nimport { ArtifactWorkspace } from '../pi-workspace/workspace';\nimport type { WorkspacePanelApi } from '../pi-workspace/workspace';\nimport { cn } from '../utils/utils';\n\nexport { AgentNotifications, createNotificationStore, defaultNotificationLabels, summaryOf, useNotifications, watchConversation } from './notifications';\nexport type { AgentNotification, AgentNotificationsProps, NewNotification, NotificationKind, NotificationLabels, NotificationStore } from './notifications';\n\nconst elapsedText = (seconds: number) => seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`;\n\n/** The floating window's own words, over the chat's (`defaultChatLabels`) and the toasts' (`defaultNotificationLabels`). */\nexport const defaultAmbientLabels = {\n ...defaultChatLabels,\n ...defaultNotificationLabels,\n /** Shown while the conversation has no title of its own (the active item of `conversations` provides one). */\n title: 'Agent',\n /** The input's placeholder while idle. */\n placeholder: 'Do anything',\n /** The input's placeholder while the agent works. */\n working: (seconds: number) => `Working for ${elapsedText(seconds)}`,\n region: (title: string) => `Agent: ${title}`,\n openAgent: (title: string, unread: boolean) => `Open agent: ${title}${unread ? ' (new activity)' : ''}`,\n openChat: (title: string) => `Open chat: ${title}`,\n minimize: 'Minimise',\n switchConversation: 'Switch conversation',\n statusWorking: 'Working',\n enableNotifications: 'Notify me when the page is in the background',\n dock: 'Dock chat',\n dockHint: 'Dock chat beside the panel',\n move: 'Move the agent window (arrow keys, Home resets)',\n moveHint: 'Drag to move',\n goodResponse: 'Good response',\n badResponse: 'Bad response',\n openFull: 'Open in full chat',\n empty: 'Ask the agent to do something.',\n loadFailedShort: 'Could not load the conversation.',\n disconnectedShort: 'Disconnected.',\n artifactPanel: 'Artifact panel',\n};\nexport type AmbientLabels = ChatLabels & NotificationLabels & typeof defaultAmbientLabels;\n\n/** The floating window's icons, over the chat's. */\nexport const defaultAmbientIcons = {\n ...defaultChatIcons,\n minimize: MinusIcon as BlockIcon,\n dock: PanelLeftIcon as BlockIcon,\n grip: GripVerticalIcon as BlockIcon,\n switchConversation: ChevronDownIcon as BlockIcon,\n notify: BellIcon as BlockIcon,\n openFull: ExternalLinkIcon as BlockIcon,\n goodResponse: ThumbsUpIcon as BlockIcon,\n badResponse: ThumbsDownIcon as BlockIcon,\n};\nexport type AmbientIcons = ChatIcons & typeof defaultAmbientIcons;\nconst AMBIENT_TEXT = { labels: defaultAmbientLabels as AmbientLabels, icons: defaultAmbientIcons as AmbientIcons };\n\n/** `bar` is the compact bar, `expanded` the same window grown into a chat, `minimized` a small pill. */\nexport type AmbientState = 'bar' | 'expanded' | 'minimized';\n\n/** What the artifact panel inside the window gets besides the full-screen and close controls: the versions of the open artifact and how to switch. */\nexport interface AmbientArtifactPanelApi extends WorkspacePanelApi {\n /** Every known version of the open artifact in this conversation, newest first. */\n readonly versions: readonly ArtifactDescriptor[];\n /** True while the panel follows the newest version; false when one older version is pinned. */\n readonly follow: boolean;\n /** `'latest'` follows the newest version; a descriptor from `versions` pins that one. */\n readonly select: (version: ArtifactDescriptor | 'latest') => void;\n}\n\nexport interface AmbientChatProps extends ChatFeatureProps {\n readonly controller: NativeChatController;\n /**\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 /** `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 /** Hand the conversation to a full `PiChat` or `ArtifactWorkspace`. Adds an \"Open in full chat\" action to replies. */\n readonly onOpenFull?: (conversationId: string) => void;\n /** Adds thumbs up and down to replies. `value` is `null` when the person takes the rating back. */\n readonly onFeedback?: (reply: { readonly key: string; readonly text: string }, value: 'up' | 'down' | null) => void;\n /** Toasts for finished, failed and waiting tasks. Pass a store to feed it for background conversations too; `false` turns them off. */\n readonly notifications?: NotificationStore | false;\n readonly autoDismissMs?: number;\n readonly maxToasts?: number;\n /** System notifications while the page is hidden. A bell in the header asks for permission when the person chooses to; nothing is asked on load. */\n readonly systemNotifications?: boolean;\n /**\n * The viewer shown inside the window when an artifact card is opened: the window widens into chat on the left and this panel on the right\n * (`ArtifactWorkspace`: draggable divider, full screen, a full-screen sheet on a phone). Build it from the viewer registry (`ViewerFrame`, ...),\n * wrapped in `ViewerWindowProvider` with `api.fullscreen` / `api.onFullscreenChange` so its bar shows the full screen button.\n */\n readonly artifactPanel?: (artifact: ArtifactDescriptor, api: AmbientArtifactPanelApi) => ReactNode;\n /** `window` (default when `artifactPanel` is given) opens artifacts inside the window; `host` calls `artifacts.open` instead, for hosts that show them in their own viewer. */\n readonly artifactTarget?: 'window' | 'host';\n /** The title's ⌄ opens this list to switch conversations or tasks. */\n readonly conversations?: ConversationsConfig;\n /** Adds a \"Dock chat\" action to the header, for a host that floats this window out of a docked chat (`ArtifactWorkspace` with `floatBelow`). It shares the controller with the docked `PiChat`, so nothing is lost. */\n readonly onDock?: () => void;\n /** The optional annotate toggle and notes chip in the composer (the registry `feedback` item's `useComposerFeedback`). Omit it and nothing changes. */\n readonly feedback?: ComposerFeedback;\n readonly className?: string;\n}\n\ninterface Position { readonly right: number; readonly bottom: number }\nconst MARGIN = 8, DEFAULT_POSITION: Position = { right: 16, bottom: 16 };\n\nfunction storageFor(persist: 'session' | 'local'): Storage | undefined { try { return persist === 'local' ? globalThis.localStorage : globalThis.sessionStorage; } catch { return undefined; } }\nfunction readPosition(key: string, persist: 'session' | 'local'): Position | undefined {\n try {\n const value: unknown = JSON.parse(storageFor(persist)?.getItem(key) ?? 'null');\n const { right, bottom } = (value ?? {}) as { right?: unknown; bottom?: unknown };\n return typeof right === 'number' && typeof bottom === 'number' && Number.isFinite(right) && Number.isFinite(bottom) ? { right, bottom } : undefined;\n } catch { return undefined; }\n}\nfunction writePosition(key: string, persist: 'session' | 'local', position: Position) {\n try { storageFor(persist)?.setItem(key, JSON.stringify(position)); } catch { /* the position is a convenience */ }\n}\nconst clamp = (position: Position, size: { w: number; h: number }, viewport: { w: number; h: number }): Position => ({\n right: Math.round(Math.min(Math.max(MARGIN, position.right), Math.max(MARGIN, viewport.w - size.w - MARGIN))),\n bottom: Math.round(Math.min(Math.max(MARGIN, position.bottom), Math.max(MARGIN, viewport.h - size.h - MARGIN))),\n});\n\n// The layout viewport that fixed positioning uses (without scrollbars), not the window's outer size.\nconst viewportSize = () => { const root = globalThis.document?.documentElement; return { w: root?.clientWidth || globalThis.innerWidth || 1024, h: root?.clientHeight || globalThis.innerHeight || 768 }; };\nfunction usePhone(): boolean {\n const query = '(max-width: 639px)';\n const [phone, setPhone] = useState(() => globalThis.matchMedia?.(query).matches === true);\n useEffect(() => {\n const list = globalThis.matchMedia?.(query);\n if (!list) return;\n const update = () => setPhone(list.matches);\n update();\n list.addEventListener('change', update);\n return () => list.removeEventListener('change', update);\n }, []);\n return phone;\n}\n\n/** Whole seconds since `active` became true; 0 while inactive. */\nfunction useElapsed(active: boolean): number {\n const [seconds, setSeconds] = useState(0);\n useEffect(() => {\n if (!active) { setSeconds(0); return; }\n const started = Date.now();\n setSeconds(0);\n const timer = setInterval(() => setSeconds(Math.floor((Date.now() - started) / 1000)), 1000);\n return () => clearInterval(timer);\n }, [active]);\n return seconds;\n}\n\n/** Pushes a toast when a run finishes or fails and when a question starts waiting; takes it back when the question is answered or a new run starts. */\nfunction useRunFeed({ enabled, store, conversationId, title, session, suppress, onOutcome, waiting }: {\n readonly enabled: boolean; readonly store: NotificationStore; readonly conversationId: string; readonly title: string; readonly session: ChatSession; readonly waiting: string;\n readonly suppress: () => boolean; readonly onOutcome: (outcome: 'done' | 'error' | null) => void;\n}) {\n const { working, waitingForAnswer, rows, connected } = session;\n const before = useRef({ working, waiting: waitingForAnswer });\n const latest = useRef({ rows, title, suppress, onOutcome, connected, waiting });\n latest.current = { rows, title, suppress, onOutcome, connected, waiting };\n useEffect(() => {\n const was = before.current;\n before.current = { working, waiting: waitingForAnswer };\n const now = latest.current;\n if (working && !was.working) { now.onOutcome(null); store.dismiss(`${conversationId}:done`); }\n if (!enabled) return;\n if (waitingForAnswer && !was.waiting && !now.suppress()) store.push({ id: `${conversationId}:input`, kind: 'input', title: now.title, summary: pendingQuestion(now.rows) ?? now.waiting, conversationId });\n if (!waitingForAnswer && was.waiting) store.dismiss(`${conversationId}:input`);\n if (was.working && !working && now.connected) {\n const finished = finishNotice(now.rows);\n if (!finished) return;\n now.onOutcome(finished.kind);\n if (!now.suppress()) store.push({ id: `${conversationId}:done`, kind: finished.kind, title: now.title, summary: finished.summary, conversationId });\n }\n }, [working, waitingForAnswer, enabled, store, conversationId]);\n}\n\n/** A compact floating agent bar over an existing page; it grows into a chat window in place. Borrows a concrete native controller like `PiChat`. */\nexport function AmbientChat(props: AmbientChatProps) {\n const active = useRef(props.controller); active.current = props.controller;\n const [mount, setMount] = useState({ controller: props.controller, sequence: 0 });\n if (mount.controller !== props.controller) setMount({ controller: props.controller, sequence: mount.sequence + 1 });\n // The window state and the toasts belong to the bar, not to one conversation: they survive switching conversations.\n const [local, setLocal] = useState<AmbientState>(props.defaultState ?? 'bar');\n const internalStore = useMemo(createNotificationStore, []);\n const text = useMergedText<AmbientLabels, AmbientIcons>(props.labels, props.icons, AMBIENT_TEXT);\n return <ChatTextProvider value={text}><AmbientSession key={mount.sequence} {...props} labels={text.labels} icons={text.icons} activeController={active} windowState={props.state ?? local} store={props.notifications || internalStore}\n onWindowState={next => { if (props.state === undefined) setLocal(next); props.onStateChange?.(next); }} /></ChatTextProvider>;\n}\n\nconst iconButton = 'inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60 max-sm:size-11 pointer-coarse:size-11 motion-reduce:transition-none';\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, labels, icons, avatar, headerActions, messageActions, variant = 'contrast', mode = 'expert', actions,\n windowState, onWindowState, store, storageKey = 'boring.ambient.position', storage = 'session', tools, onOpenFull, onFeedback, notifications, autoDismissMs, maxToasts, systemNotifications = false,\n renderEntry, renderTool, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, fileAccept = 'image/*', slash, mentions, attachments, model, effort, replyFooter, artifacts: hostArtifacts, artifactPanel, artifactTarget, conversations, onDock, feedback: composerFeedback, className,\n activeController }: SessionProps) {\n const { title } = labels;\n const stateRef = useRef(windowState); stateRef.current = windowState;\n // The artifact open inside the window, and whether it follows the newest version. It belongs to the expanded window: leaving that state closes it.\n const [opened, setOpened] = useState<{ readonly descriptor: ArtifactDescriptor; readonly follow: boolean } | null>(null);\n const [fullscreen, setFullscreen] = useState(false);\n const setWindowState = (next: AmbientState) => {\n if (next !== 'expanded') { setOpened(null); setFullscreen(false); }\n if (next !== stateRef.current) onWindowState(next);\n };\n const expanded = windowState === 'expanded', minimized = windowState === 'minimized';\n const phone = usePhone();\n const root = useRef<HTMLDivElement>(null), titleButton = useRef<HTMLButtonElement>(null), pill = useRef<HTMLButtonElement>(null);\n const [picking, setPicking] = useState(false);\n const [feedback, setFeedback] = useState<Readonly<Record<string, 'up' | 'down'>>>({});\n const [outcome, setOutcome] = useState<'done' | 'error' | null>(null);\n const [permission, setPermission] = useState<NotifyPermission>('unsupported');\n useEffect(() => { setPermission(notifyPermission()); }, []);\n useEffect(() => { if (!outcome) return; const timer = setTimeout(() => setOutcome(null), 8000); return () => clearTimeout(timer); }, [outcome]);\n\n // Replies get copy, optional feedback and the time; the host may hand the conversation to its full chat.\n const conversationRef = useRef('');\n const replyActions = useMemo<NonNullable<RowContext['replyActions']>>(() => (reply, host) => <div data-testid=\"reply-actions\" className=\"mt-2 -ml-2 flex items-center gap-0.5 text-muted-foreground\">\n <CopyButton text={reply.text} label={labels.copyResponse} iconOnly {...(onCopy ? { onCopy } : {})} className=\"size-8 p-0 text-muted-foreground max-sm:size-11\" />\n {onFeedback && <>\n {(['up', 'down'] as const).map(value => <button key={value} type=\"button\" data-testid={`feedback-${value}`} aria-pressed={feedback[reply.key] === value} aria-label={value === 'up' ? labels.goodResponse : labels.badResponse}\n title={value === 'up' ? labels.goodResponse : labels.badResponse}\n onClick={() => { const next = feedback[reply.key] === value ? null : value; setFeedback(current => { const { [reply.key]: _removed, ...rest } = current; return next ? { ...rest, [reply.key]: next } : rest; }); onFeedback({ key: reply.key, text: reply.text }, next); }}\n className={cn(iconButton, 'aria-pressed:text-foreground')}>{value === 'up' ? <icons.goodResponse className=\"size-3.5\" aria-hidden=\"true\" /> : <icons.badResponse className=\"size-3.5\" aria-hidden=\"true\" />}</button>)}\n </>}\n {onOpenFull && <button type=\"button\" data-testid=\"open-full\" aria-label={labels.openFull} title={labels.openFull} onClick={() => onOpenFull(conversationRef.current)} className={iconButton}><icons.openFull className=\"size-3.5\" 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\">{new Date(reply.timestamp).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}</time>}\n </div>, [onCopy, onFeedback, onOpenFull, feedback, labels, icons]);\n const scrollToBottom = useRef<() => void>(() => {});\n const rowExtras = useMemo(() => ({ replyActions }), [replyActions]);\n const inWindow = Boolean(artifactPanel) && artifactTarget !== 'host';\n const detect = hostArtifacts?.detect;\n const versionsRef = useRef<readonly ArtifactDescriptor[]>([]);\n const artifacts = useMemo<ArtifactsConfig | undefined>(() => !inWindow ? hostArtifacts : {\n open: descriptor => { const newest = versionsRef.current.filter(version => artifactKey(version) === artifactKey(descriptor)).reduce((top, version) => Math.max(top, version.ordinal ?? 0), 0); setOpened({ descriptor, follow: (descriptor.ordinal ?? 0) >= newest }); },\n isOpen: descriptor => opened !== null && artifactKey(opened.descriptor) === artifactKey(descriptor) && (opened.follow ? (descriptor.ordinal ?? 0) >= (versionsRef.current.find(version => artifactKey(version) === artifactKey(descriptor))?.ordinal ?? 0) : descriptor.revision === opened.descriptor.revision),\n ...(detect ? { detect } : {}),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [inWindow, hostArtifacts, opened, detect]);\n const session = useChatSession({ controller, activeController, mode, actions, renderEntry, renderTool, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => scrollToBottom.current(), rowExtras, feedback: composerFeedback });\n const { state, derived, rows, queued, queueActions, working, waitingForAnswer, error, act, textarea, rowContext, composer, empty, loading } = session;\n const transcript = useTranscript(rows);\n const versions = useMemo(() => inWindow ? collectArtifacts(state.view, detect).sort((a, b) => (b.ordinal ?? 0) - (a.ordinal ?? 0)) : [], [inWindow, state.view, detect]);\n versionsRef.current = versions;\n const known = opened ? versions.filter(version => artifactKey(version) === artifactKey(opened.descriptor)) : [];\n const shownArtifact = opened ? (opened.follow ? known[0] ?? opened.descriptor : opened.descriptor) : undefined;\n const { stick, hidden } = transcript;\n scrollToBottom.current = () => { void stick.scrollToBottom('instant'); };\n const { barStart, barNote } = session.pickers();\n\n const conversationId = String(conversations?.activeId ?? state.conversationId);\n conversationRef.current = conversationId;\n const activeItem = conversations?.items.find(item => item.id === conversations.activeId);\n const shownTitle = activeItem?.title?.trim() || title;\n const toasts = useNotifications(store);\n useRunFeed({ enabled: notifications !== false, store, conversationId, title: shownTitle, session, onOutcome: setOutcome, waiting: labels.waitingForAnswer,\n suppress: () => stateRef.current === 'expanded' && globalThis.document?.visibilityState !== 'hidden' });\n const unread = !expanded && toasts.length > 0;\n const seconds = useElapsed(working);\n\n // Position: offsets from the bottom right corner, remembered for the session, clamped to the viewport at render so a smaller window restores them.\n const [position, setPosition] = useState<Position>(() => readPosition(storageKey, storage) ?? DEFAULT_POSITION);\n const [viewport, setViewport] = useState(viewportSize);\n const [size, setSize] = useState({ w: 0, h: 0 });\n useEffect(() => {\n const onResize = () => setViewport(viewportSize());\n globalThis.addEventListener('resize', onResize);\n return () => globalThis.removeEventListener('resize', onResize);\n }, []);\n useEffect(() => {\n const element = root.current;\n if (!element || typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(() => setSize({ w: element.offsetWidth, h: element.offsetHeight }));\n observer.observe(element);\n setSize({ w: element.offsetWidth, h: element.offsetHeight });\n return () => observer.disconnect();\n }, [minimized, expanded]);\n // The size and the viewport also follow every render, so the clamp below never works from a stale measurement (the title or status icon can change the pill's width; a scrollbar can appear without a resize event).\n useLayoutEffect(() => {\n const element = root.current;\n const fresh = viewportSize();\n setViewport(current => current.w === fresh.w && current.h === fresh.h ? current : fresh);\n if (element) setSize(current => current.w === element.offsetWidth && current.h === element.offsetHeight ? current : { w: element.offsetWidth, h: element.offsetHeight });\n });\n const placed = clamp(position, size, viewport);\n // Moves and the release are followed on the window, so a drag keeps working when the pointer leaves the handle (and when pointer capture is unavailable).\n // `threshold` is how far the pointer must travel before it is a drag: a click on the pill (threshold > 0) still restores it.\n const dragged = useRef(false);\n const beginDrag = (event: PointerEvent<HTMLElement>, threshold: number) => {\n if (phone || event.button !== 0) return;\n dragged.current = false;\n const origin = { pointer: event.pointerId, x: event.clientX, y: event.clientY, start: placed };\n let last = origin.start, moving = threshold === 0;\n const move = (next: globalThis.PointerEvent) => {\n if (next.pointerId !== origin.pointer) return;\n if (!moving) { if (Math.hypot(next.clientX - origin.x, next.clientY - origin.y) < threshold) return; moving = true; dragged.current = true; }\n last = clamp({ right: origin.start.right - (next.clientX - origin.x), bottom: origin.start.bottom - (next.clientY - origin.y) }, size, viewport);\n setPosition(last);\n };\n const end = (next: globalThis.PointerEvent) => {\n if (next.pointerId !== origin.pointer) return;\n globalThis.removeEventListener('pointermove', move); globalThis.removeEventListener('pointerup', end); globalThis.removeEventListener('pointercancel', end);\n if (moving) writePosition(storageKey, storage, last);\n };\n globalThis.addEventListener('pointermove', move); globalThis.addEventListener('pointerup', end); globalThis.addEventListener('pointercancel', end);\n };\n const onGripDown = (event: PointerEvent<HTMLButtonElement>) => { event.preventDefault(); beginDrag(event, 0); };\n // Arrow keys move the window (Shift for big steps); Home puts it back in the default corner.\n const onGripKey = (event: KeyboardEvent<HTMLElement>) => {\n if (phone) return;\n const step = event.shiftKey ? 64 : 16;\n const delta = event.key === 'ArrowLeft' ? { right: step, bottom: 0 } : event.key === 'ArrowRight' ? { right: -step, bottom: 0 } : event.key === 'ArrowUp' ? { right: 0, bottom: step } : event.key === 'ArrowDown' ? { right: 0, bottom: -step } : undefined;\n if (!delta && event.key !== 'Home') return;\n event.preventDefault();\n const next = delta ? clamp({ right: placed.right + delta.right, bottom: placed.bottom + delta.bottom }, size, viewport) : clamp(DEFAULT_POSITION, size, viewport);\n setPosition(next); writePosition(storageKey, storage, next);\n };\n const grip = (className?: string) => <button type=\"button\" data-testid=\"ambient-grip\" aria-label={labels.move} title={labels.moveHint} onPointerDown={onGripDown} onKeyDown={onGripKey}\n className={cn(iconButton, 'cursor-grab touch-none active:cursor-grabbing', className)}><icons.grip className=\"size-4\" aria-hidden=\"true\" /></button>;\n\n const expand = () => { setOutcome(null); store.dismissConversation(conversationId); setWindowState('expanded'); };\n useEffect(() => {\n if (expanded && !globalThis.matchMedia?.('(pointer: coarse)').matches) textarea.current?.focus();\n }, [expanded, textarea]);\n // The title is disabled while the window is open, so focus goes back to it once the bar is showing again.\n const refocusTitle = useRef(false);\n useEffect(() => { if (!expanded && refocusTitle.current) { refocusTitle.current = false; titleButton.current?.focus(); } }, [expanded]);\n const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n if (event.key !== 'Escape' || event.defaultPrevented || !expanded || picking) return;\n event.preventDefault();\n // Escape steps back: full screen, then the artifact, then the window.\n if (fullscreen) return setFullscreen(false);\n if (opened) return setOpened(null);\n refocusTitle.current = true;\n setWindowState('bar');\n };\n const onActivate = useCallback((item: AgentNotification) => {\n if (item.conversationId !== undefined && item.conversationId !== conversationId) conversations?.onSelect(item.conversationId);\n expand();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [conversationId, conversations]);\n\n const connectionKind = state.connection.kind;\n const status = waitingForAnswer ? 'input' : working ? 'working' : outcome;\n const label = waitingForAnswer ? labels.waitingForAnswer : labels.working(seconds);\n const panelOpen = expanded && shownArtifact !== undefined && artifactPanel !== undefined;\n const surface = cn('relative flex flex-col border border-border bg-background text-foreground shadow-lg', phone && expanded ? 'h-dvh rounded-none border-0 pt-[env(safe-area-inset-top)]' : 'rounded-3xl',\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 = 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 ? 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 h-11 max-w-[min(20rem,calc(100vw-1rem))] items-center rounded-full border border-border bg-background text-foreground shadow-lg transition-colors hover:bg-popover motion-reduce:transition-none', !phone && 'cursor-grab touch-none select-none active:cursor-grabbing')}>\n {!phone && grip('ml-1.5 size-8 rounded-lg')}\n <button ref={pill} type=\"button\" data-testid=\"ambient-pill\" onClick={() => setWindowState('bar')} aria-label={labels.openAgent(shownTitle, unread)}\n className={cn('relative flex h-full min-w-0 cursor-pointer items-center gap-2.5 rounded-full pr-4 text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/70', phone ? 'pl-3.5' : 'pl-1.5')}>\n {status === 'working' ? <Loader2Icon className=\"size-4 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none\" aria-hidden=\"true\" />\n : status ? <KindIcon kind={status === 'input' ? 'input' : status} className=\"size-4\" /> : null}\n <span className=\"min-w-0 truncate text-sm\">{shownTitle}</span>\n {unread && <span data-testid=\"ambient-unread\" aria-hidden=\"true\" className=\"absolute top-1.5 right-2 size-2.5 rounded-full border-2 border-background bg-primary\" />}\n </button>\n </div>\n </div>;\n\n const visibleRows = transcript.visible(rows);\n return <div ref={root} data-boring=\"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 {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 <ArtifactWorkspace open={panelOpen} onClose={() => { setOpened(null); setFullscreen(false); }} fullscreen={fullscreen} onFullscreenChange={setFullscreen} storageKey=\"boring.ambient.panel-width\" panelLabel={labels.artifactPanel}\n defaultWidth={600} minPanel={320} minChat={300} sheetBelow={720}\n panel={api => shownArtifact && artifactPanel ? artifactPanel(shownArtifact, { ...api, versions: known, follow: opened?.follow ?? true,\n select: version => setOpened(current => current ? (version === 'latest' ? { descriptor: known[0] ?? current.descriptor, follow: true } : { descriptor: version, follow: false }) : current) }) : null}\n chat={<section className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n <header data-testid=\"ambient-header\" className={cn('flex h-12 shrink-0 items-center gap-0.5 px-2 max-sm:h-14', !expanded && 'border-b border-border')}>\n <button type=\"button\" data-testid=\"ambient-minimize\" aria-label={labels.minimize} title={labels.minimize} onClick={() => { setPicking(false); setWindowState(expanded ? 'bar' : 'minimized'); }} className={iconButton}><icons.minimize className=\"size-4\" 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 && <span data-testid=\"ambient-avatar\" className=\"flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full [&>img]:size-full [&>img]:object-cover [&>svg]:size-4\">{avatar}</span>}\n <button ref={titleButton} type=\"button\" data-testid=\"ambient-title\" onClick={expand} disabled={expanded} aria-label={expanded ? shownTitle : labels.openChat(shownTitle)}\n className=\"min-w-0 max-w-full cursor-pointer truncate rounded-md px-1 py-1 text-left font-(family-name:--chat-display) text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:cursor-default max-sm:py-2.5\">{shownTitle}</button>\n {conversations && <button type=\"button\" data-testid=\"ambient-switch\" aria-label={labels.switchConversation} aria-haspopup=\"dialog\" aria-expanded={picking} title={labels.switchConversation} onClick={() => setPicking(open => !open)}\n className={cn(iconButton, 'size-7 max-sm:size-11')}><icons.switchConversation className=\"size-3.5\" aria-hidden=\"true\" /></button>}\n {expanded ? <span className=\"flex-1\" /> : <button type=\"button\" tabIndex={-1} aria-hidden=\"true\" data-testid=\"ambient-header-fill\" onClick={expand} className=\"h-full min-w-2 flex-1 cursor-pointer\" />}\n {systemNotifications && permission === 'default' && <button type=\"button\" data-testid=\"ambient-enable-notifications\" aria-label={labels.enableNotifications} title={labels.enableNotifications}\n onClick={() => { void requestNotifyPermission().then(setPermission); }} className={iconButton}><icons.notify className=\"size-4\" aria-hidden=\"true\" /></button>}\n <BlockActions actions={headerActions} testId=\"ambient-action\" menuLabel={labels.moreActions} className=\"[&_button]:max-sm:size-11 [&_button]:pointer-coarse:size-11\" />\n {onDock && !phone && <button type=\"button\" data-testid=\"ambient-dock\" aria-label={labels.dock} title={labels.dockHint} onClick={() => { setPicking(false); onDock(); }} className={iconButton}><icons.dock className=\"size-4\" aria-hidden=\"true\" /></button>}\n {!phone && grip()}\n </header>\n\n {expanded && <div className=\"flex min-h-0 flex-1 flex-col\">\n {(connectionKind === 'error' || connectionKind === 'closed') && <div className=\"px-4 pb-2\"><Notice tone={connectionKind === 'error' ? 'error' : 'info'} testid=\"connection-notice\">\n <span className=\"flex-1\">{connectionKind === 'error' ? labels.loadFailed : labels.disconnected}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}><icons.reconnect className=\"size-3.5\" aria-hidden=\"true\" />{labels.reconnect}</Button>}</Notice></div>}\n {derived.pinned.length > 0 && <aside aria-label={labels.requiredActions} className=\"shrink-0 space-y-2 px-4 pb-2\">{derived.pinned.map(card => <div key={card.key}>{card.content}</div>)}</aside>}\n <div className=\"relative flex min-h-0 flex-1 flex-col\">\n <div ref={stick.scrollRef} data-testid=\"transcript-scroll\" className=\"min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain\">\n <div ref={stick.contentRef} role=\"log\" aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\" className=\"flex min-h-28 flex-col gap-5 px-4 pt-1 pb-5\">\n {hidden > 0 && <div className=\"flex justify-center\"><Button size=\"sm\" variant=\"outline\" data-testid=\"show-earlier\" className=\"text-muted-foreground\" onClick={transcript.reveal}>{labels.showEarlier}</Button></div>}\n {visibleRows.map(row => <RowView key={row.key} row={row} 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 py-6 text-center text-sm text-muted-foreground\">{labels.empty}</p>}\n </div>\n </div>\n {!stick.isAtBottom && <Button variant=\"outline\" size=\"icon\" data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={() => { transcript.reset(); void stick.scrollToBottom(); }}\n className=\"absolute bottom-2 left-1/2 -translate-x-1/2 bg-background/95 shadow-md backdrop-blur\"><icons.jumpToLatest className=\"size-4\" aria-hidden=\"true\" /></Button>}\n </div>\n </div>}\n\n <div className={cn('shrink-0', expanded && 'border-t border-border', phone && 'pb-[env(safe-area-inset-bottom)]')}>\n <div className=\"space-y-2 px-3 empty:hidden [&:has(*)]:pt-2\">\n <ChatNotices state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} />\n {!expanded && (connectionKind === 'error' || connectionKind === 'closed') && <Notice tone=\"info\" testid=\"connection-notice\">\n <span className=\"flex-1\">{connectionKind === 'error' ? labels.loadFailedShort : labels.disconnectedShort}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}>{labels.reconnect}</Button>}</Notice>}\n </div>\n <MessageQueue items={queued} sending={state.outbox} withdraw={actions?.withdraw} actions={queueActions} />\n <Composer {...composer} layout=\"inline\" placeholder={working || waitingForAnswer ? label : labels.placeholder} barStart={barStart} barNote={barNote} barEnd={tools} />\n </div>\n </section>} />\n </div>\n </div>;\n}\n",
24
+ "content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport type { ImageContent, ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { BellIcon, ChevronDownIcon, ExternalLinkIcon, GripVerticalIcon, Loader2Icon, MinusIcon, PanelLeftIcon, ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react';\nimport { Button } from '../button/button';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction, BlockIcon } from '../button/actions';\nimport { CopyButton } from '../pi-chat/code-block';\nimport { Composer } from '../pi-chat/composer';\nimport type { ComposerFeedback } from '../pi-chat/composer';\nimport type { AttachmentsConfig, ConversationsConfig, EffortConfig, MentionsConfig, ModelConfig, SlashConfig } from '../pi-chat/config';\nimport { notifyPermission, requestNotifyPermission } from './browser-notify';\nimport type { NotifyPermission } from './browser-notify';\nimport { ConversationHistory } from '../pi-chat/history';\nimport type { CommandMentions } from '../pi-chat/markdown';\nimport { RowView } from '../pi-chat/message';\nimport type { RowContext } from '../pi-chat/message';\nimport { ChatNotices, Notice } from '../pi-chat/notice';\nimport { AgentNotifications, KindIcon, createNotificationStore, defaultNotificationLabels, finishNotice, pendingQuestion, useNotifications } from './notifications';\nimport type { AgentNotification, NotificationLabels, NotificationStore } from './notifications';\nimport { ChatTextProvider, defaultChatIcons, defaultChatLabels, useMergedText } from '../pi-chat/labels';\nimport type { ChatIcons, ChatLabels } from '../pi-chat/labels';\nimport { MessageQueue } from '../pi-chat/queue';\nimport { artifactKey, collectArtifacts } from '../pi-chat/artifact';\nimport type { ArtifactDescriptor, ArtifactsConfig } from '../pi-chat/artifact';\nimport type { ChatCard, Mode } from '../pi-chat/rows';\nimport { useChatSession, useTranscript } from '../pi-chat/session';\nimport type { ChatFeatureProps, ChatSession } from '../pi-chat/session';\nimport { ArtifactWorkspace } from '../pi-workspace/workspace';\nimport type { WorkspacePanelApi } from '../pi-workspace/workspace';\nimport { cn } from '../utils/utils';\n\nexport { AgentNotifications, createNotificationStore, defaultNotificationLabels, summaryOf, useNotifications, watchConversation } from './notifications';\nexport type { AgentNotification, AgentNotificationsProps, NewNotification, NotificationKind, NotificationLabels, NotificationStore } from './notifications';\n\nconst elapsedText = (seconds: number) => seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`;\n\n/** The floating window's own words, over the chat's (`defaultChatLabels`) and the toasts' (`defaultNotificationLabels`). */\nexport const defaultAmbientLabels = {\n ...defaultChatLabels,\n ...defaultNotificationLabels,\n /** Shown while the conversation has no title of its own (the active item of `conversations` provides one). */\n title: 'Agent',\n /** The input's placeholder while idle. */\n placeholder: 'Do anything',\n /** The input's placeholder while the agent works. */\n working: (seconds: number) => `Working for ${elapsedText(seconds)}`,\n region: (title: string) => `Agent: ${title}`,\n openAgent: (title: string, unread: boolean) => `Open agent: ${title}${unread ? ' (new activity)' : ''}`,\n openChat: (title: string) => `Open chat: ${title}`,\n minimize: 'Minimise',\n switchConversation: 'Switch conversation',\n statusWorking: 'Working',\n enableNotifications: 'Notify me when the page is in the background',\n dock: 'Dock chat',\n dockHint: 'Dock chat beside the panel',\n move: 'Move the agent window (arrow keys, Home resets)',\n moveHint: 'Drag to move',\n goodResponse: 'Good response',\n badResponse: 'Bad response',\n openFull: 'Open in full chat',\n empty: 'Ask the agent to do something.',\n loadFailedShort: 'Could not load the conversation.',\n disconnectedShort: 'Disconnected.',\n artifactPanel: 'Artifact panel',\n};\nexport type AmbientLabels = ChatLabels & NotificationLabels & typeof defaultAmbientLabels;\n\n/** The floating window's icons, over the chat's. */\nexport const defaultAmbientIcons = {\n ...defaultChatIcons,\n minimize: MinusIcon as BlockIcon,\n dock: PanelLeftIcon as BlockIcon,\n grip: GripVerticalIcon as BlockIcon,\n switchConversation: ChevronDownIcon as BlockIcon,\n notify: BellIcon as BlockIcon,\n openFull: ExternalLinkIcon as BlockIcon,\n goodResponse: ThumbsUpIcon as BlockIcon,\n badResponse: ThumbsDownIcon as BlockIcon,\n};\nexport type AmbientIcons = ChatIcons & typeof defaultAmbientIcons;\nconst AMBIENT_TEXT = { labels: defaultAmbientLabels as AmbientLabels, icons: defaultAmbientIcons as AmbientIcons };\n\n/** `bar` is the compact bar, `expanded` the same window grown into a chat, `minimized` a small pill. */\nexport type AmbientState = 'bar' | 'expanded' | 'minimized';\n\n/** What the artifact panel inside the window gets besides the full-screen and close controls: the versions of the open artifact and how to switch. */\nexport interface AmbientArtifactPanelApi extends WorkspacePanelApi {\n /** Every known version of the open artifact in this conversation, newest first. */\n readonly versions: readonly ArtifactDescriptor[];\n /** True while the panel follows the newest version; false when one older version is pinned. */\n readonly follow: boolean;\n /** `'latest'` follows the newest version; a descriptor from `versions` pins that one. */\n readonly select: (version: ArtifactDescriptor | 'latest') => void;\n}\n\nexport interface AmbientChatProps extends ChatFeatureProps {\n readonly controller: NativeChatController;\n /**\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 /** `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 /** Hand the conversation to a full `PiChat` or `ArtifactWorkspace`. Adds an \"Open in full chat\" action to replies. */\n readonly onOpenFull?: (conversationId: string) => void;\n /** Adds thumbs up and down to replies. `value` is `null` when the person takes the rating back. */\n readonly onFeedback?: (reply: { readonly key: string; readonly text: string }, value: 'up' | 'down' | null) => void;\n /** Toasts for finished, failed and waiting tasks. Pass a store to feed it for background conversations too; `false` turns them off. */\n readonly notifications?: NotificationStore | false;\n readonly autoDismissMs?: number;\n readonly maxToasts?: number;\n /** System notifications while the page is hidden. A bell in the header asks for permission when the person chooses to; nothing is asked on load. */\n readonly systemNotifications?: boolean;\n /**\n * The viewer shown inside the window when an artifact card is opened: the window widens into chat on the left and this panel on the right\n * (`ArtifactWorkspace`: draggable divider, full screen, a full-screen sheet on a phone). Build it from the viewer registry (`ViewerFrame`, ...),\n * wrapped in `ViewerWindowProvider` with `api.fullscreen` / `api.onFullscreenChange` so its bar shows the full screen button.\n */\n readonly artifactPanel?: (artifact: ArtifactDescriptor, api: AmbientArtifactPanelApi) => ReactNode;\n /** `window` (default when `artifactPanel` is given) opens artifacts inside the window; `host` calls `artifacts.open` instead, for hosts that show them in their own viewer. */\n readonly artifactTarget?: 'window' | 'host';\n /** The title's ⌄ opens this list to switch conversations or tasks. */\n readonly conversations?: ConversationsConfig;\n /** Adds a \"Dock chat\" action to the header, for a host that floats this window out of a docked chat (`ArtifactWorkspace` with `floatBelow`). It shares the controller with the docked `PiChat`, so nothing is lost. */\n readonly onDock?: () => void;\n /** The optional annotate toggle and notes chip in the composer (the registry `feedback` item's `useComposerFeedback`). Omit it and nothing changes. */\n readonly feedback?: ComposerFeedback;\n readonly className?: string;\n}\n\ninterface Position { readonly right: number; readonly bottom: number }\nconst MARGIN = 8, DEFAULT_POSITION: Position = { right: 16, bottom: 16 };\n\nfunction storageFor(persist: 'session' | 'local'): Storage | undefined { try { return persist === 'local' ? globalThis.localStorage : globalThis.sessionStorage; } catch { return undefined; } }\nfunction readPosition(key: string, persist: 'session' | 'local'): Position | undefined {\n try {\n const value: unknown = JSON.parse(storageFor(persist)?.getItem(key) ?? 'null');\n const { right, bottom } = (value ?? {}) as { right?: unknown; bottom?: unknown };\n return typeof right === 'number' && typeof bottom === 'number' && Number.isFinite(right) && Number.isFinite(bottom) ? { right, bottom } : undefined;\n } catch { return undefined; }\n}\nfunction writePosition(key: string, persist: 'session' | 'local', position: Position) {\n try { storageFor(persist)?.setItem(key, JSON.stringify(position)); } catch { /* the position is a convenience */ }\n}\nconst clamp = (position: Position, size: { w: number; h: number }, viewport: { w: number; h: number }): Position => ({\n right: Math.round(Math.min(Math.max(MARGIN, position.right), Math.max(MARGIN, viewport.w - size.w - MARGIN))),\n bottom: Math.round(Math.min(Math.max(MARGIN, position.bottom), Math.max(MARGIN, viewport.h - size.h - MARGIN))),\n});\n\n// The layout viewport that fixed positioning uses (without scrollbars), not the window's outer size.\nconst viewportSize = () => { const root = globalThis.document?.documentElement; return { w: root?.clientWidth || globalThis.innerWidth || 1024, h: root?.clientHeight || globalThis.innerHeight || 768 }; };\nfunction usePhone(): boolean {\n const query = '(max-width: 639px)';\n const [phone, setPhone] = useState(() => globalThis.matchMedia?.(query).matches === true);\n useEffect(() => {\n const list = globalThis.matchMedia?.(query);\n if (!list) return;\n const update = () => setPhone(list.matches);\n update();\n list.addEventListener('change', update);\n return () => list.removeEventListener('change', update);\n }, []);\n return phone;\n}\n\n/** Whole seconds since `active` became true; 0 while inactive. */\nfunction useElapsed(active: boolean): number {\n const [seconds, setSeconds] = useState(0);\n useEffect(() => {\n if (!active) { setSeconds(0); return; }\n const started = Date.now();\n setSeconds(0);\n const timer = setInterval(() => setSeconds(Math.floor((Date.now() - started) / 1000)), 1000);\n return () => clearInterval(timer);\n }, [active]);\n return seconds;\n}\n\n/** Pushes a toast when a run finishes or fails and when a question starts waiting; takes it back when the question is answered or a new run starts. */\nfunction useRunFeed({ enabled, store, conversationId, title, session, suppress, onOutcome, waiting }: {\n readonly enabled: boolean; readonly store: NotificationStore; readonly conversationId: string; readonly title: string; readonly session: ChatSession; readonly waiting: string;\n readonly suppress: () => boolean; readonly onOutcome: (outcome: 'done' | 'error' | null) => void;\n}) {\n const { working, waitingForAnswer, rows, connected } = session;\n const before = useRef({ working, waiting: waitingForAnswer });\n const latest = useRef({ rows, title, suppress, onOutcome, connected, waiting });\n latest.current = { rows, title, suppress, onOutcome, connected, waiting };\n useEffect(() => {\n const was = before.current;\n before.current = { working, waiting: waitingForAnswer };\n const now = latest.current;\n if (working && !was.working) { now.onOutcome(null); store.dismiss(`${conversationId}:done`); }\n if (!enabled) return;\n if (waitingForAnswer && !was.waiting && !now.suppress()) store.push({ id: `${conversationId}:input`, kind: 'input', title: now.title, summary: pendingQuestion(now.rows) ?? now.waiting, conversationId });\n if (!waitingForAnswer && was.waiting) store.dismiss(`${conversationId}:input`);\n if (was.working && !working && now.connected) {\n const finished = finishNotice(now.rows);\n if (!finished) return;\n now.onOutcome(finished.kind);\n if (!now.suppress()) store.push({ id: `${conversationId}:done`, kind: finished.kind, title: now.title, summary: finished.summary, conversationId });\n }\n }, [working, waitingForAnswer, enabled, store, conversationId]);\n}\n\n/** A compact floating agent bar over an existing page; it grows into a chat window in place. Borrows a concrete native controller like `PiChat`. */\nexport function AmbientChat(props: AmbientChatProps) {\n const active = useRef(props.controller); active.current = props.controller;\n const [mount, setMount] = useState({ controller: props.controller, sequence: 0 });\n if (mount.controller !== props.controller) setMount({ controller: props.controller, sequence: mount.sequence + 1 });\n // The window state and the toasts belong to the bar, not to one conversation: they survive switching conversations.\n const [local, setLocal] = useState<AmbientState>(props.defaultState ?? 'bar');\n const internalStore = useMemo(createNotificationStore, []);\n const text = useMergedText<AmbientLabels, AmbientIcons>(props.labels, props.icons, AMBIENT_TEXT);\n return <ChatTextProvider value={text}><AmbientSession key={mount.sequence} {...props} labels={text.labels} icons={text.icons} activeController={active} windowState={props.state ?? local} store={props.notifications || internalStore}\n onWindowState={next => { if (props.state === undefined) setLocal(next); props.onStateChange?.(next); }} /></ChatTextProvider>;\n}\n\nconst iconButton = 'inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60 max-sm:size-11 pointer-coarse:size-11 motion-reduce:transition-none';\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, labels, icons, avatar, headerActions, messageActions, variant = 'contrast', mode = 'expert', actions,\n windowState, onWindowState, store, storageKey = 'boring.ambient.position', storage = 'session', tools, onOpenFull, onFeedback, notifications, autoDismissMs, maxToasts, systemNotifications = false,\n renderEntry, renderTool, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, fileAccept = 'image/*', slash, mentions, attachments, onOpenFile, pasteToFile, model, effort, replyFooter, artifacts: hostArtifacts, artifactPanel, artifactTarget, conversations, onDock, feedback: composerFeedback, className,\n activeController }: SessionProps) {\n const { title } = labels;\n const stateRef = useRef(windowState); stateRef.current = windowState;\n // The artifact open inside the window, and whether it follows the newest version. It belongs to the expanded window: leaving that state closes it.\n const [opened, setOpened] = useState<{ readonly descriptor: ArtifactDescriptor; readonly follow: boolean } | null>(null);\n const [fullscreen, setFullscreen] = useState(false);\n const setWindowState = (next: AmbientState) => {\n if (next !== 'expanded') { setOpened(null); setFullscreen(false); }\n if (next !== stateRef.current) onWindowState(next);\n };\n const expanded = windowState === 'expanded', minimized = windowState === 'minimized';\n const phone = usePhone();\n const root = useRef<HTMLDivElement>(null), titleButton = useRef<HTMLButtonElement>(null), pill = useRef<HTMLButtonElement>(null);\n const [picking, setPicking] = useState(false);\n const [feedback, setFeedback] = useState<Readonly<Record<string, 'up' | 'down'>>>({});\n const [outcome, setOutcome] = useState<'done' | 'error' | null>(null);\n const [permission, setPermission] = useState<NotifyPermission>('unsupported');\n useEffect(() => { setPermission(notifyPermission()); }, []);\n useEffect(() => { if (!outcome) return; const timer = setTimeout(() => setOutcome(null), 8000); return () => clearTimeout(timer); }, [outcome]);\n\n // Replies get copy, optional feedback and the time; the host may hand the conversation to its full chat.\n const conversationRef = useRef('');\n const replyActions = useMemo<NonNullable<RowContext['replyActions']>>(() => (reply, host) => <div data-testid=\"reply-actions\" className=\"mt-2 -ml-2 flex items-center gap-0.5 text-muted-foreground\">\n <CopyButton text={reply.text} label={labels.copyResponse} iconOnly {...(onCopy ? { onCopy } : {})} className=\"size-8 p-0 text-muted-foreground max-sm:size-11\" />\n {onFeedback && <>\n {(['up', 'down'] as const).map(value => <button key={value} type=\"button\" data-testid={`feedback-${value}`} aria-pressed={feedback[reply.key] === value} aria-label={value === 'up' ? labels.goodResponse : labels.badResponse}\n title={value === 'up' ? labels.goodResponse : labels.badResponse}\n onClick={() => { const next = feedback[reply.key] === value ? null : value; setFeedback(current => { const { [reply.key]: _removed, ...rest } = current; return next ? { ...rest, [reply.key]: next } : rest; }); onFeedback({ key: reply.key, text: reply.text }, next); }}\n className={cn(iconButton, 'aria-pressed:text-foreground')}>{value === 'up' ? <icons.goodResponse className=\"size-3.5\" aria-hidden=\"true\" /> : <icons.badResponse className=\"size-3.5\" aria-hidden=\"true\" />}</button>)}\n </>}\n {onOpenFull && <button type=\"button\" data-testid=\"open-full\" aria-label={labels.openFull} title={labels.openFull} onClick={() => onOpenFull(conversationRef.current)} className={iconButton}><icons.openFull className=\"size-3.5\" 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\">{new Date(reply.timestamp).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}</time>}\n </div>, [onCopy, onFeedback, onOpenFull, feedback, labels, icons]);\n const scrollToBottom = useRef<() => void>(() => {});\n const rowExtras = useMemo(() => ({ replyActions }), [replyActions]);\n const inWindow = Boolean(artifactPanel) && artifactTarget !== 'host';\n const detect = hostArtifacts?.detect;\n const versionsRef = useRef<readonly ArtifactDescriptor[]>([]);\n const artifacts = useMemo<ArtifactsConfig | undefined>(() => !inWindow ? hostArtifacts : {\n open: descriptor => { const newest = versionsRef.current.filter(version => artifactKey(version) === artifactKey(descriptor)).reduce((top, version) => Math.max(top, version.ordinal ?? 0), 0); setOpened({ descriptor, follow: (descriptor.ordinal ?? 0) >= newest }); },\n isOpen: descriptor => opened !== null && artifactKey(opened.descriptor) === artifactKey(descriptor) && (opened.follow ? (descriptor.ordinal ?? 0) >= (versionsRef.current.find(version => artifactKey(version) === artifactKey(descriptor))?.ordinal ?? 0) : descriptor.revision === opened.descriptor.revision),\n ...(detect ? { detect } : {}),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [inWindow, hostArtifacts, opened, detect]);\n const session = useChatSession({ controller, activeController, mode, actions, renderEntry, renderTool, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => scrollToBottom.current(), rowExtras, feedback: composerFeedback, onOpenFile, pasteToFile });\n const { state, derived, rows, queued, queueActions, working, waitingForAnswer, error, act, textarea, rowContext, composer, empty, loading } = session;\n const transcript = useTranscript(rows);\n const versions = useMemo(() => inWindow ? collectArtifacts(state.view, detect).sort((a, b) => (b.ordinal ?? 0) - (a.ordinal ?? 0)) : [], [inWindow, state.view, detect]);\n versionsRef.current = versions;\n const known = opened ? versions.filter(version => artifactKey(version) === artifactKey(opened.descriptor)) : [];\n const shownArtifact = opened ? (opened.follow ? known[0] ?? opened.descriptor : opened.descriptor) : undefined;\n const { stick, hidden } = transcript;\n scrollToBottom.current = () => { void stick.scrollToBottom('instant'); };\n const { barStart, barNote } = session.pickers();\n\n const conversationId = String(conversations?.activeId ?? state.conversationId);\n conversationRef.current = conversationId;\n const activeItem = conversations?.items.find(item => item.id === conversations.activeId);\n const shownTitle = activeItem?.title?.trim() || title;\n const toasts = useNotifications(store);\n useRunFeed({ enabled: notifications !== false, store, conversationId, title: shownTitle, session, onOutcome: setOutcome, waiting: labels.waitingForAnswer,\n suppress: () => stateRef.current === 'expanded' && globalThis.document?.visibilityState !== 'hidden' });\n const unread = !expanded && toasts.length > 0;\n const seconds = useElapsed(working);\n\n // Position: offsets from the bottom right corner, remembered for the session, clamped to the viewport at render so a smaller window restores them.\n const [position, setPosition] = useState<Position>(() => readPosition(storageKey, storage) ?? DEFAULT_POSITION);\n const [viewport, setViewport] = useState(viewportSize);\n const [size, setSize] = useState({ w: 0, h: 0 });\n useEffect(() => {\n const onResize = () => setViewport(viewportSize());\n globalThis.addEventListener('resize', onResize);\n return () => globalThis.removeEventListener('resize', onResize);\n }, []);\n useEffect(() => {\n const element = root.current;\n if (!element || typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(() => setSize({ w: element.offsetWidth, h: element.offsetHeight }));\n observer.observe(element);\n setSize({ w: element.offsetWidth, h: element.offsetHeight });\n return () => observer.disconnect();\n }, [minimized, expanded]);\n // The size and the viewport also follow every render, so the clamp below never works from a stale measurement (the title or status icon can change the pill's width; a scrollbar can appear without a resize event).\n useLayoutEffect(() => {\n const element = root.current;\n const fresh = viewportSize();\n setViewport(current => current.w === fresh.w && current.h === fresh.h ? current : fresh);\n if (element) setSize(current => current.w === element.offsetWidth && current.h === element.offsetHeight ? current : { w: element.offsetWidth, h: element.offsetHeight });\n });\n const placed = clamp(position, size, viewport);\n // Moves and the release are followed on the window, so a drag keeps working when the pointer leaves the handle (and when pointer capture is unavailable).\n // `threshold` is how far the pointer must travel before it is a drag: a click on the pill (threshold > 0) still restores it.\n const dragged = useRef(false);\n const beginDrag = (event: PointerEvent<HTMLElement>, threshold: number) => {\n if (phone || event.button !== 0) return;\n dragged.current = false;\n const origin = { pointer: event.pointerId, x: event.clientX, y: event.clientY, start: placed };\n let last = origin.start, moving = threshold === 0;\n const move = (next: globalThis.PointerEvent) => {\n if (next.pointerId !== origin.pointer) return;\n if (!moving) { if (Math.hypot(next.clientX - origin.x, next.clientY - origin.y) < threshold) return; moving = true; dragged.current = true; }\n last = clamp({ right: origin.start.right - (next.clientX - origin.x), bottom: origin.start.bottom - (next.clientY - origin.y) }, size, viewport);\n setPosition(last);\n };\n const end = (next: globalThis.PointerEvent) => {\n if (next.pointerId !== origin.pointer) return;\n globalThis.removeEventListener('pointermove', move); globalThis.removeEventListener('pointerup', end); globalThis.removeEventListener('pointercancel', end);\n if (moving) writePosition(storageKey, storage, last);\n };\n globalThis.addEventListener('pointermove', move); globalThis.addEventListener('pointerup', end); globalThis.addEventListener('pointercancel', end);\n };\n const onGripDown = (event: PointerEvent<HTMLButtonElement>) => { event.preventDefault(); beginDrag(event, 0); };\n // Arrow keys move the window (Shift for big steps); Home puts it back in the default corner.\n const onGripKey = (event: KeyboardEvent<HTMLElement>) => {\n if (phone) return;\n const step = event.shiftKey ? 64 : 16;\n const delta = event.key === 'ArrowLeft' ? { right: step, bottom: 0 } : event.key === 'ArrowRight' ? { right: -step, bottom: 0 } : event.key === 'ArrowUp' ? { right: 0, bottom: step } : event.key === 'ArrowDown' ? { right: 0, bottom: -step } : undefined;\n if (!delta && event.key !== 'Home') return;\n event.preventDefault();\n const next = delta ? clamp({ right: placed.right + delta.right, bottom: placed.bottom + delta.bottom }, size, viewport) : clamp(DEFAULT_POSITION, size, viewport);\n setPosition(next); writePosition(storageKey, storage, next);\n };\n const grip = (className?: string) => <button type=\"button\" data-testid=\"ambient-grip\" aria-label={labels.move} title={labels.moveHint} onPointerDown={onGripDown} onKeyDown={onGripKey}\n className={cn(iconButton, 'cursor-grab touch-none active:cursor-grabbing', className)}><icons.grip className=\"size-4\" aria-hidden=\"true\" /></button>;\n\n const expand = () => { setOutcome(null); store.dismissConversation(conversationId); setWindowState('expanded'); };\n useEffect(() => {\n if (expanded && !globalThis.matchMedia?.('(pointer: coarse)').matches) textarea.current?.focus();\n }, [expanded, textarea]);\n // The title is disabled while the window is open, so focus goes back to it once the bar is showing again.\n const refocusTitle = useRef(false);\n useEffect(() => { if (!expanded && refocusTitle.current) { refocusTitle.current = false; titleButton.current?.focus(); } }, [expanded]);\n const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n if (event.key !== 'Escape' || event.defaultPrevented || !expanded || picking) return;\n event.preventDefault();\n // Escape steps back: full screen, then the artifact, then the window.\n if (fullscreen) return setFullscreen(false);\n if (opened) return setOpened(null);\n refocusTitle.current = true;\n setWindowState('bar');\n };\n const onActivate = useCallback((item: AgentNotification) => {\n if (item.conversationId !== undefined && item.conversationId !== conversationId) conversations?.onSelect(item.conversationId);\n expand();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [conversationId, conversations]);\n\n const connectionKind = state.connection.kind;\n const status = waitingForAnswer ? 'input' : working ? 'working' : outcome;\n const label = waitingForAnswer ? labels.waitingForAnswer : labels.working(seconds);\n const panelOpen = expanded && shownArtifact !== undefined && artifactPanel !== undefined;\n const surface = cn('relative flex flex-col border border-border bg-background text-foreground shadow-lg', phone && expanded ? 'h-dvh rounded-none border-0 pt-[env(safe-area-inset-top)]' : 'rounded-3xl',\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 = 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 ? 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 h-11 max-w-[min(20rem,calc(100vw-1rem))] items-center rounded-full border border-border bg-background text-foreground shadow-lg transition-colors hover:bg-popover motion-reduce:transition-none', !phone && 'cursor-grab touch-none select-none active:cursor-grabbing')}>\n {!phone && grip('ml-1.5 size-8 rounded-lg')}\n <button ref={pill} type=\"button\" data-testid=\"ambient-pill\" onClick={() => setWindowState('bar')} aria-label={labels.openAgent(shownTitle, unread)}\n className={cn('relative flex h-full min-w-0 cursor-pointer items-center gap-2.5 rounded-full pr-4 text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/70', phone ? 'pl-3.5' : 'pl-1.5')}>\n {status === 'working' ? <Loader2Icon className=\"size-4 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none\" aria-hidden=\"true\" />\n : status ? <KindIcon kind={status === 'input' ? 'input' : status} className=\"size-4\" /> : null}\n <span className=\"min-w-0 truncate text-sm\">{shownTitle}</span>\n {unread && <span data-testid=\"ambient-unread\" aria-hidden=\"true\" className=\"absolute top-1.5 right-2 size-2.5 rounded-full border-2 border-background bg-primary\" />}\n </button>\n </div>\n </div>;\n\n const visibleRows = transcript.visible(rows);\n return <div ref={root} data-boring=\"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 {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 <ArtifactWorkspace open={panelOpen} onClose={() => { setOpened(null); setFullscreen(false); }} fullscreen={fullscreen} onFullscreenChange={setFullscreen} storageKey=\"boring.ambient.panel-width\" panelLabel={labels.artifactPanel}\n defaultWidth={600} minPanel={320} minChat={300} sheetBelow={720}\n panel={api => shownArtifact && artifactPanel ? artifactPanel(shownArtifact, { ...api, versions: known, follow: opened?.follow ?? true,\n select: version => setOpened(current => current ? (version === 'latest' ? { descriptor: known[0] ?? current.descriptor, follow: true } : { descriptor: version, follow: false }) : current) }) : null}\n chat={<section className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n <header data-testid=\"ambient-header\" className={cn('flex h-12 shrink-0 items-center gap-0.5 px-2 max-sm:h-14', !expanded && 'border-b border-border')}>\n <button type=\"button\" data-testid=\"ambient-minimize\" aria-label={labels.minimize} title={labels.minimize} onClick={() => { setPicking(false); setWindowState(expanded ? 'bar' : 'minimized'); }} className={iconButton}><icons.minimize className=\"size-4\" 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 && <span data-testid=\"ambient-avatar\" className=\"flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full [&>img]:size-full [&>img]:object-cover [&>svg]:size-4\">{avatar}</span>}\n <button ref={titleButton} type=\"button\" data-testid=\"ambient-title\" onClick={expand} disabled={expanded} aria-label={expanded ? shownTitle : labels.openChat(shownTitle)}\n className=\"min-w-0 max-w-full cursor-pointer truncate rounded-md px-1 py-1 text-left font-(family-name:--chat-display) text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:cursor-default max-sm:py-2.5\">{shownTitle}</button>\n {conversations && <button type=\"button\" data-testid=\"ambient-switch\" aria-label={labels.switchConversation} aria-haspopup=\"dialog\" aria-expanded={picking} title={labels.switchConversation} onClick={() => setPicking(open => !open)}\n className={cn(iconButton, 'size-7 max-sm:size-11')}><icons.switchConversation className=\"size-3.5\" aria-hidden=\"true\" /></button>}\n {expanded ? <span className=\"flex-1\" /> : <button type=\"button\" tabIndex={-1} aria-hidden=\"true\" data-testid=\"ambient-header-fill\" onClick={expand} className=\"h-full min-w-2 flex-1 cursor-pointer\" />}\n {systemNotifications && permission === 'default' && <button type=\"button\" data-testid=\"ambient-enable-notifications\" aria-label={labels.enableNotifications} title={labels.enableNotifications}\n onClick={() => { void requestNotifyPermission().then(setPermission); }} className={iconButton}><icons.notify className=\"size-4\" aria-hidden=\"true\" /></button>}\n <BlockActions actions={headerActions} testId=\"ambient-action\" menuLabel={labels.moreActions} className=\"[&_button]:max-sm:size-11 [&_button]:pointer-coarse:size-11\" />\n {onDock && !phone && <button type=\"button\" data-testid=\"ambient-dock\" aria-label={labels.dock} title={labels.dockHint} onClick={() => { setPicking(false); onDock(); }} className={iconButton}><icons.dock className=\"size-4\" aria-hidden=\"true\" /></button>}\n {!phone && grip()}\n </header>\n\n {expanded && <div className=\"flex min-h-0 flex-1 flex-col\">\n {(connectionKind === 'error' || connectionKind === 'closed') && <div className=\"px-4 pb-2\"><Notice tone={connectionKind === 'error' ? 'error' : 'info'} testid=\"connection-notice\">\n <span className=\"flex-1\">{connectionKind === 'error' ? labels.loadFailed : labels.disconnected}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}><icons.reconnect className=\"size-3.5\" aria-hidden=\"true\" />{labels.reconnect}</Button>}</Notice></div>}\n {derived.pinned.length > 0 && <aside aria-label={labels.requiredActions} className=\"shrink-0 space-y-2 px-4 pb-2\">{derived.pinned.map(card => <div key={card.key}>{card.content}</div>)}</aside>}\n <div className=\"relative flex min-h-0 flex-1 flex-col\">\n <div ref={stick.scrollRef} data-testid=\"transcript-scroll\" className=\"min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain\">\n <div ref={stick.contentRef} role=\"log\" aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\" className=\"flex min-h-28 flex-col gap-5 px-4 pt-1 pb-5\">\n {hidden > 0 && <div className=\"flex justify-center\"><Button size=\"sm\" variant=\"outline\" data-testid=\"show-earlier\" className=\"text-muted-foreground\" onClick={transcript.reveal}>{labels.showEarlier}</Button></div>}\n {visibleRows.map(row => <RowView key={row.key} row={row} 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 py-6 text-center text-sm text-muted-foreground\">{labels.empty}</p>}\n </div>\n </div>\n {!stick.isAtBottom && <Button variant=\"outline\" size=\"icon\" data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={() => { transcript.reset(); void stick.scrollToBottom(); }}\n className=\"absolute bottom-2 left-1/2 -translate-x-1/2 bg-background/95 shadow-md backdrop-blur\"><icons.jumpToLatest className=\"size-4\" aria-hidden=\"true\" /></Button>}\n </div>\n </div>}\n\n <div className={cn('shrink-0', expanded && 'border-t border-border', phone && 'pb-[env(safe-area-inset-bottom)]')}>\n <div className=\"space-y-2 px-3 empty:hidden [&:has(*)]:pt-2\">\n <ChatNotices state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} />\n {!expanded && (connectionKind === 'error' || connectionKind === 'closed') && <Notice tone=\"info\" testid=\"connection-notice\">\n <span className=\"flex-1\">{connectionKind === 'error' ? labels.loadFailedShort : labels.disconnectedShort}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}>{labels.reconnect}</Button>}</Notice>}\n </div>\n <MessageQueue items={queued} sending={state.outbox} withdraw={actions?.withdraw} actions={queueActions} />\n <Composer {...composer} layout=\"inline\" placeholder={working || waitingForAnswer ? label : labels.placeholder} barStart={barStart} barNote={barNote} barEnd={tools} />\n </div>\n </section>} />\n </div>\n </div>;\n}\n",
24
25
  "type": "registry:component",
25
26
  "target": "components/pi-ambient/ambient.tsx"
26
27
  },