@hachej/boring-ui-kit 0.4.25 → 0.4.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +40 -3
  2. package/dist/chat-artifacts.d.ts +157 -0
  3. package/dist/chat-artifacts.js +274 -0
  4. package/dist/chat-dictation.d.ts +27 -0
  5. package/dist/chat-dictation.js +71 -0
  6. package/dist/chat-react-controller.d.ts +25 -0
  7. package/dist/chat-react-controller.js +72 -0
  8. package/dist/chat-react-conversations.d.ts +40 -0
  9. package/dist/chat-react-conversations.js +68 -0
  10. package/dist/chat-react-draft.d.ts +11 -0
  11. package/dist/chat-react-draft.js +91 -0
  12. package/dist/chat-react-remote.d.ts +33 -0
  13. package/dist/chat-react-remote.js +72 -0
  14. package/dist/chat-react-session.d.ts +170 -0
  15. package/dist/chat-react-session.js +367 -0
  16. package/dist/chat-react-transcript.d.ts +39 -0
  17. package/dist/chat-react-transcript.js +123 -0
  18. package/dist/chat-react.d.ts +62 -0
  19. package/dist/chat-react.js +95 -0
  20. package/dist/chat-text.d.ts +131 -0
  21. package/dist/chat-text.js +220 -0
  22. package/dist/chat-view.d.ts +2 -17
  23. package/dist/chat-view.js +1 -0
  24. package/dist/chat-voice-message.d.ts +65 -0
  25. package/dist/chat-voice-message.js +235 -0
  26. package/dist/chat-voice.d.ts +7 -0
  27. package/dist/chat-voice.js +4 -0
  28. package/dist/experience-catalog.d.ts +2 -2
  29. package/dist/experience-region-tree.d.ts +1 -1
  30. package/dist/markdown-editor-labels.d.ts +83 -0
  31. package/dist/markdown-editor-labels.js +75 -0
  32. package/dist/markdown-editor.d.ts +7 -1
  33. package/dist/markdown-editor.js +28 -18
  34. package/dist/markdown-extensions.d.ts +4 -2
  35. package/dist/markdown-extensions.js +2 -2
  36. package/dist/markdown-preserved.d.ts +5 -1
  37. package/dist/markdown-preserved.js +15 -4
  38. package/dist/markdown-proposal-diff.d.ts +3 -1
  39. package/dist/markdown-proposal-diff.js +6 -5
  40. package/dist/run-notifications.d.ts +42 -0
  41. package/dist/run-notifications.js +67 -0
  42. package/dist/shared-chat/index.d.ts +1 -1
  43. package/dist/shared-chat/index.js +5 -4
  44. package/dist/text-draft-controls.d.ts +5 -1
  45. package/dist/text-draft-controls.js +6 -5
  46. package/dist/text-draft-labels.d.ts +26 -0
  47. package/dist/text-draft-labels.js +47 -0
  48. package/dist/voice-attempt.d.ts +33 -0
  49. package/dist/voice-attempt.js +122 -0
  50. package/dist/voice-capture.d.ts +12 -0
  51. package/dist/voice-capture.js +17 -2
  52. package/package.json +14 -2
  53. package/r/agent-target.json +5 -9
  54. package/r/app-shell.json +19 -28
  55. package/r/board-viewer.json +8 -11
  56. package/r/button.json +2 -9
  57. package/r/diagram-viewer.json +0 -5
  58. package/r/dock.json +2 -2
  59. package/r/documents.json +13 -18
  60. package/r/feedback-preview.json +1 -7
  61. package/r/feedback.json +12 -19
  62. package/r/file-tree.json +4 -14
  63. package/r/highlights.json +4 -9
  64. package/r/html-viewer.json +3 -3
  65. package/r/looks-viewer.json +3 -6
  66. package/r/maquette-viewer.json +2 -20
  67. package/r/markdown-editor.json +4 -7
  68. package/r/mockup-viewer.json +2 -5
  69. package/r/pi-ambient.json +11 -19
  70. package/r/pi-app-kinds.json +10 -10
  71. package/r/pi-app.json +17 -27
  72. package/r/pi-artifacts.json +3 -4
  73. package/r/pi-chat-approval.json +2 -5
  74. package/r/pi-chat-attachments.json +2 -4
  75. package/r/pi-chat-markdown.json +3 -5
  76. package/r/pi-chat-mentions.json +2 -3
  77. package/r/pi-chat-pickers.json +2 -4
  78. package/r/pi-chat-plugins.json +11 -11
  79. package/r/pi-chat-question.json +1 -7
  80. package/r/pi-chat-slash.json +3 -6
  81. package/r/pi-chat-subagent.json +3 -8
  82. package/r/pi-chat-voice.json +7 -10
  83. package/r/pi-chat.json +25 -53
  84. package/r/pi-workspace.json +4 -7
  85. package/r/placed-agent.json +4 -5
  86. package/r/provider-setup.json +2 -8
  87. package/r/record-viewer.json +0 -9
  88. package/r/registry.json +182 -403
  89. package/r/requests-board.json +2 -10
  90. package/r/shared-chat.json +10 -24
  91. package/r/task-list-viewer.json +3 -9
  92. package/r/utils.json +14 -2
  93. package/r/version-history.json +3 -5
  94. package/r/viewer-code.json +2 -3
  95. package/r/viewer-frame.json +9 -17
  96. package/r/viewer-html.json +4 -5
  97. package/r/viewer-image.json +3 -4
  98. package/r/viewer-markdown.json +6 -8
  99. package/r/viewer-pdf.json +5 -7
  100. package/r/viewers.json +6 -6
  101. package/r/windows.json +3 -8
package/r/pi-app.json CHANGED
@@ -4,36 +4,26 @@
4
4
  "title": "Pi agent app",
5
5
  "description": "AgentWorkspace: a whole agent page in one component. Left: the sessions pane (search, New, rename, archive, delete; collapsible, a drawer on a narrow screen). Center: PiChat. Right: the artifact panel (versions, file viewer, auto-open of new artifacts) in the ArtifactWorkspace panel, a full-screen sheet on a phone; it draws files with the kinds the app passes in artifactKinds (defaultKinds of the pi-app-kinds item is the default list). layout=\"embedded\" drops the left column for one compact top bar (title, History, Library, New) that joins an app-shell screen header. Props are data and callbacks; hooks for the conversations handler (useConversations), the chat transport (useRemoteChat) and saved resources (useSaved).",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.25",
8
- "@hachej/boring-files@^0.4.25",
7
+ "@hachej/boring-ui-kit@^0.4.27",
8
+ "@hachej/boring-files@^0.4.27",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "lucide-react@^1.51.0"
12
12
  ],
13
13
  "registryDependencies": [
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/theme.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat-plugins.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-artifacts.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-workspace.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/viewer-frame.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/utils.json",
21
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/button.json",
22
- "drawer",
23
- "dialog",
24
- "button",
25
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/file-tree.json",
26
- "dropdown-menu",
27
- "scroll-area",
28
- "separator",
29
- "table",
30
- "popover",
31
- "tooltip"
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat-plugins.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-artifacts.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-workspace.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/viewer-frame.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/utils.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/button.json",
21
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/file-tree.json"
32
22
  ],
33
23
  "files": [
34
24
  {
35
25
  "path": "registry/pi-app/agent-workspace.tsx",
36
- "content": "'use client';\n\nimport '@hachej/boring-ui-kit/file-tree.css';\n\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ComponentProps, ReactNode } from 'react';\nimport type { ResourceClient, ResourceLocator } from '@hachej/boring-files';\nimport { createResourceClient } from '@hachej/boring-files/remote';\nimport { connectRevisionProvider } from '@hachej/boring-files/revision';\nimport { workspacePath } from '@hachej/boring-files/file-types';\nimport { checkBlobBase, parseBlobPointer, withBlobPointers } from '@hachej/boring-files/blob';\nimport type { BlobPointer } from '@hachej/boring-files/blob';\nimport type { FileEntry, RevisionProvider } from '@hachej/boring-files/revision';\nimport { createFileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport { randomUUID } from '@hachej/boring-files/platform';\nimport type { FileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport { FileTreeView } from '../file-tree/file-tree';\nimport { frenchAppLabels } from './app-labels-fr';\nimport { chatTextFor } from '../pi-chat/locale';\nimport type { ChatLocale } from '../pi-chat/locale';\nimport type { FileTreeViewProps } from '../file-tree/file-tree';\nimport type { ResourceIdentity } from '@hachej/boring-files/remote';\nimport type { ChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { PiChat } from '../pi-chat/pi-chat';\nimport { withChatPlugins } from '../pi-chat-plugins/chat-plugins';\nimport { artifactKey, createPresentAcks, pendingPresents } from '../pi-artifacts/artifact';\nimport type { ArtifactDescriptor, ArtifactsConfig, PresentAcks } from '../pi-artifacts/artifact';\nimport type { ConversationsConfig, PiChatProps } from '../pi-chat/pi-chat';\nimport { Dock, DockLayout, DockMain } from '../pi-workspace/dock';\nimport { ArtifactWorkspace } from '../pi-workspace/workspace';\nimport type { WorkspacePanelApi } from '../pi-workspace/workspace';\nimport { ViewerWindowProvider } from '../viewer-frame/viewer-frame';\nimport type { ViewerShare } from '../viewer-frame/viewer-frame';\nimport type { InteractiveHtml } from '../viewer-frame/interactive-html';\nimport { noKinds } from './viewer-kinds';\nimport type { PanelKind } from './viewer-kinds';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { ChatTextProvider, useMergedText } from '../pi-chat/labels';\nimport { cn, PHONE_BELOW, withDefaults } from '../utils/utils';\nimport { AppTextProvider, defaultAppIcons, defaultAppLabels } from './app-labels';\nimport type { AppIcons, AppLabels } from './app-labels';\nimport { ArtifactPanel, useArtifactVersions, useTurn } from './artifact-panel';\nimport type { CustomViewers, SavedRevision, ViewerOptions } from './artifact-panel';\nimport { FileViewer } from './file-viewer';\nimport { ImageViewer } from '../pi-chat/image-viewer';\nimport { thumbnail } from '../pi-chat/composer';\nimport { useRevisionAttachments } from './use-revision-attachments';\nimport { imageData } from './revision-files';\nimport { SessionsPane, SessionsToggle } from './sessions';\nimport { focusHistoryRow, historyColumnFits, useHistoryOpen } from './history-column';\nimport type { HistoryPlacement } from './history-column';\nimport type { SessionsView, WorkspaceAgents } from './sessions';\nimport { WorkspaceBar } from './workspace-bar';\nimport type { WorkspaceBarPanel } from './workspace-bar';\n\nexport { ArtifactPanel, useArtifactVersions, useTurn } from './artifact-panel';\nexport type { CustomViewerProps, CustomViewers, SavedRevision, ViewerOptions } from './artifact-panel';\nexport { FileViewer } from './file-viewer';\nexport { SessionsPane, SessionsToggle } from './sessions';\nexport { HistoryColumn, HistoryColumnToggle, historyColumnFits, useHistoryColumn } from './history-column';\nexport type { HistoryPlacement } from './history-column';\nexport { WorkspaceBar } from './workspace-bar';\nexport type { WorkspaceBarPanel, WorkspaceBarProps } from './workspace-bar';\nexport { ConversationList, ConversationHistory } from '../pi-chat/history';\nexport { FileTree } from '../file-tree/file-tree';\nexport type { SessionsView, WorkspaceAgent, WorkspaceAgents } from './sessions';\nexport { useConversations } from '../pi-chat/use-conversations';\nexport { useRemoteChat } from '../pi-chat/use-remote-chat';\nexport type { RemoteChatState } from '../pi-chat/use-remote-chat';\nexport { savedLabel, useSaved } from './use-saved';\nexport { viewerKindOf as kindOf, mediaTypeOf } from '@hachej/boring-files/file-types';\nexport { defaultAppIcons, defaultAppLabels } from './app-labels';\nexport { frenchAppLabels } from './app-labels-fr';\nexport type { AppIcons, AppLabels } from './app-labels';\nexport type { ViewerKind as FileKind } from '@hachej/boring-files/file-types';\n\n/** An artifact version in the panel: `follow` shows the latest saved revision, otherwise `descriptor.revision` is pinned and read-only. */\nexport interface ArtifactView { readonly kind: 'artifact'; readonly conversation: string; readonly descriptor: ArtifactDescriptor; readonly follow: boolean }\n/** A file in the panel, by the host's path (see `resources.locate`). */\nexport interface FileView { readonly kind: 'file'; readonly path: string }\n/** Anything else the host renders in the panel through `panels[kind]` (for example a file list or a tool view). */\nexport interface HostView { readonly kind: string; readonly [field: string]: unknown }\n/** What the right-hand panel shows, or `null` when it is closed. */\nexport type OpenedView = ArtifactView | FileView | HostView;\n\nconst isArtifact = (view: OpenedView | null): view is ArtifactView => view?.kind === 'artifact';\nconst isFile = (view: OpenedView | null): view is FileView => view?.kind === 'file';\n/** Whether the panel shows the file at `path` (a file view, or the artifact view of it). */\nconst showsPath = (view: OpenedView | null, path: string): boolean => isFile(view) ? view.path === path : isArtifact(view) ? view.descriptor.target.resource.path === path : false;\nconst subscribeNothing = () => () => {};\ntype ImageContent = Parameters<NonNullable<PiChatProps['onOpenImage']>>[0];\n\n/**\n * Large files (blob pointers): the workspace holds a small pointer file (`<name>.blob.json`, `@hachej/boring-files/blob`) for each\n * large file, and the host serves its bytes at `GET <blobBase><encodeURIComponent(id)>`. The Library lists a pointer under the file's\n * name, size and type, and the viewers show it from that URL (an image inline, a PDF in the PDF viewer, anything else as a download).\n */\nexport interface WorkspaceBlobs {\n /** Where the host serves blob bytes: a same-origin path starting with `/` (default `/_files/`). Anything else is refused (a `TypeError`). */\n readonly blobBase?: string | undefined;\n /**\n * The host's large-file upload, used by Library uploads above `uploads.maxBytes`: it stores the bytes and writes the pointer file\n * itself, then resolves with the pointer it wrote (checked with `parseBlobPointer`); the Library reloads its listing and writes\n * nothing. `onProgress` drives the row's progress bar, `signal` its cancel button; a refusal rejects with `{ error: { code, message } }`.\n * Without it a file above the cap is refused as today.\n */\n readonly uploadBlob?: ((file: File, options: { readonly signal: AbortSignal; readonly onProgress: (progress: { readonly loaded: number; readonly total: number }) => void }) => Promise<BlobPointer>) | undefined;\n}\n\n/**\n * Where the viewers read and write: the host's resource handler (`createResourceHandler` of `@hachej/boring-files/remote`) and its file history.\n * When the endpoint also answers the revision protocol's discovery (`createRevisionHandler` serves both), the page shows a read-only\n * Library of its files (open, and version history with `history`); a plain resource handler has no listing, so there is no Library.\n */\nexport interface WorkspaceResources extends WorkspaceBlobs {\n /** The resource handler endpoint. */\n readonly endpoint: string | URL;\n /** The host's authenticated fetch, also used for `history`. */\n readonly fetch: (request: Request) => Promise<Response>;\n /** The person's identity as the resource handler authenticates it (comparison metadata, never a grant). */\n readonly identity: ResourceIdentity;\n /** Optional `GET <history>?path=<resource path>` returning `{ saves: [{ revision, savedAt }] }` newest first (`files.saves(path)`): the version menu. */\n readonly history?: string | URL | undefined;\n /** The resource of a file path in a `FileView`. Default: the published `workspace` provider at the path without a leading slash. */\n readonly locate?: ((path: string) => ResourceLocator) | undefined;\n}\n\ninterface AgentWorkspaceBaseProps {\n /**\n * `default`: the sessions pane on the left, the chat in the center and the artifact panel on the right. `embedded`: for a page set\n * inside a host's screen (an app-shell screen, a hub page): no left column at any width, and one compact top bar with the\n * conversation title and History, Library and New as icon buttons (a popover on a wide workspace, a sheet on a phone) followed by\n * `controls`. Inside an app-shell screen the bar's buttons go into the shell's header, so the page has one row.\n */\n readonly layout?: 'default' | 'embedded' | undefined;\n /**\n * The side panel's width when it first opens, before the person drags the divider (then the dragged width is kept for the session):\n * pixels, or a fraction of the workspace's width when at most 1. Without it the panel leaves the chat 420px.\n */\n readonly panelWidth?: number | undefined;\n /** `embedded` only: the host's own panels in the bar, after History and Library (a popover on a wide workspace, a sheet on a phone). */\n readonly barPanels?: readonly WorkspaceBarPanel[] | undefined;\n /**\n * `embedded` only: a fixed leading title for the bar, normally the app's name. It stays whatever the conversation is called; the\n * conversation title then follows it as a muted second element (on a phone it is left to History). Without it the bar shows the\n * conversation title alone, as before.\n */\n readonly title?: string | undefined;\n /**\n * Where the Library of a `revisionProvider` opens. `panel` (default): the artifact column, or a bottom drawer on phones.\n * `side`: a column to the right of the viewer (a `Dock`, resizable), opened\n * from the Library button in the viewer's top bar or in the left pane, alone at the right edge when no file is open; on a phone a\n * full-screen sheet that choosing a file closes. `center`: Chat and Library links in the left pane, the Library replacing\n * the chat in the center (the chat stays mounted). `pane`: the Library is a tab beside the conversations in the left pane, or the\n * whole pane on a single-session page without `conversations`; files open in the right panel as usual.\n */\n readonly libraryPlacement?: 'panel' | 'side' | 'center' | 'pane' | undefined;\n /** The page's language: `en` (default) or `fr`, for the chat, the panes, the viewers and the Library. `labels` and `chat.labels` override any word. */\n readonly locale?: ChatLocale | undefined;\n /**\n * The Library tree's options: its words (`labels`, over `defaultFileTreeLabels`), whether it offers Upload and History, and a\n * `refreshKey` that reloads it when it changes. Deprecated: with a `revisionProvider` that has a change feed (`connectRevisionProvider`)\n * the tree, the Library, the history panel and the @ search reload from the workspace's own change events; `refreshKey` remains\n * for a provider without one.\n */\n readonly fileTree?: Pick<FileTreeViewProps, 'labels' | 'upload' | 'history' | 'refreshKey' | 'capabilities'> | undefined;\n /**\n * How the chat's attachments are saved under `uploads/` (they exist only with a Library, `revisionProvider` or `resources`). Every file\n * is reference-only by default: the message carries its `@path` and the agent reads it with its file tools. See `WorkspaceUploads`.\n */\n readonly uploads?: WorkspaceUploads | undefined;\n /**\n * The page's agents. With two or more, a switcher at the top of the left pane chooses one; the host scopes `conversations` and the\n * controller to `activeId`. One agent (or none given) shows no switcher.\n */\n readonly agents?: WorkspaceAgents | undefined;\n /** The open conversation's controller (`useRemoteChat`); `undefined` while connecting, when `connecting` is shown instead. */\n readonly controller: ChatController | undefined;\n /** The open conversation: auto-opened artifacts and the artifact view belong to it. */\n readonly conversationId: string | undefined;\n /** Everything else `PiChat` takes (labels, icons, headerActions, messageActions, mode, actions, slash, mentions, attachments, model, effort, emptyState, ...). */\n readonly chat?: Omit<PiChatProps, 'controller' | 'artifacts' | 'conversations' | 'historyList'>;\n /**\n * The block's words (sessions pane, artifact panel, file viewer and the viewers' bars), over `defaultAppLabels`; for example\n * `{ sessionsTitle: 'Projects', newChat: 'Start' }`. The chat's own go in `chat.labels`.\n */\n readonly labels?: Partial<AppLabels> | undefined;\n /** The block's icons (sessions toggle, New, Float chat, the viewer bar's), over `defaultAppIcons`. The chat's go in `chat.icons`. */\n readonly icons?: Partial<AppIcons> | undefined;\n /**\n * Host actions on the artifact panel's bar (every viewer it shows), after the built-in Float chat: `header` ones as buttons beside Share,\n * `menu` ones in its \"…\" menu. A function receives what is open. Test ids `<viewer testId>-<id>` (`artifact-<id>` for an artifact).\n */\n readonly panelActions?: readonly BlockAction[] | ((view: OpenedView) => readonly BlockAction[]) | undefined;\n readonly connecting?: ReactNode;\n /** The sessions pane (`useConversations`). Omit it for a page without one. Replies keep their Fork button through `conversations.fork`. */\n readonly conversations?: ConversationsConfig | undefined;\n /** Place the sessions pieces in host navigation. Default: SessionsPane. False leaves navigation entirely to the host; conversations still powers chat Fork. */\n readonly sessions?: false | ((props: ComponentProps<typeof SessionsPane>) => ReactNode) | undefined;\n /**\n * `column` (default): the conversations are the pane's side column, open or closed as this viewer left it; while an open panel leaves\n * the chat too narrow beside it, the column steps aside and the chat's History opens the popover. `popover`: History is always the popover.\n */\n readonly history?: HistoryPlacement | undefined;\n /** Recognise artifacts in tool results that carry no descriptor (`ArtifactsConfig.detect`). */\n readonly detect?: ArtifactsConfig['detect'] | undefined;\n /**\n * Every artifact kind the chat's cards, the artifact panel and the file viewer resolve through, in order (the first match wins): an\n * `ArtifactKind` (match, label, icon, and a `renderer` or a lazily loaded `load`) or a document kind. The workspace knows no kind of its\n * own. The pi-app-kinds item's `defaultKinds({ locale, comments })` is the default list (maquette, diagram, mock-up, records, then\n * Markdown, HTML, SVG, CSV, image and PDF): pass it, put the app's own kinds before it (`[...mine, ...defaultKinds()]`) so they win, or\n * pass a list of your own. Build it once (`useMemo`): a new list re-resolves every open view.\n */\n readonly artifactKinds?: readonly PanelKind[] | undefined;\n /** Host viewers by artifact type or file kind, for example `{ canvas: props => <MyCanvas {...props} /> }`. */\n readonly viewers?: CustomViewers | undefined;\n /** Run HTML pages in the viewers' sandboxed preview, loading scripts only from these origins. Off without it. */\n readonly interactive?: InteractiveHtml | undefined;\n /** The viewer bar's Share action; it receives `target` `{ artifact, version }` or `{ file }`. */\n readonly share?: ViewerShare | undefined;\n /** Controlled panel content (with `onOpenedChange`); uncontrolled from `defaultOpened` otherwise. */\n readonly opened?: OpenedView | null | undefined;\n readonly defaultOpened?: OpenedView | null | undefined;\n readonly onOpenedChange?: ((next: OpenedView | null) => void) | undefined;\n /**\n * Whether the open file has unsaved or unconfirmed changes. The workspace asks before leaving such a file itself; a host that\n * switches the workspace or identity (a new `revisionProvider`) or changes a controlled `opened` should ask first too, since the\n * viewer of the previous file is replaced and its local draft discarded.\n */\n readonly onUnsavedChange?: ((unsaved: boolean) => void) | undefined;\n /** Renderers for host views, by `kind`. */\n readonly panels?: Readonly<Record<string, (view: HostView, panel: WorkspacePanelApi) => ReactNode>> | undefined;\n /** Open the panel on each artifact (or new version) the agent makes, unless the person closed it during this turn. Not on a phone. Default true. */\n readonly autoOpen?: boolean;\n /** A back button in a file's viewer bar (for example to the host's file list). */\n readonly fileBack?: { readonly label: string; readonly onBack: () => void } | undefined;\n /** With `floatBelow`: the floating chat over the same session (for example `AmbientChat` of `pi-ambient` with `onDock={dock}`). */\n readonly floatingChat?: ((props: PiChatProps, dock: () => void) => ReactNode) | undefined;\n /** Shown above the chat (notices). */\n readonly chatTop?: ReactNode;\n /** The chat header's controls; a function receives whether the panel is open. */\n readonly controls?: ReactNode | ((state: { readonly panelOpen: boolean }) => ReactNode);\n /** Prefix of the layout keys (panel width and floating chat for the session; whether the sessions column is open, per viewer). */\n readonly storageKey?: string;\n /** Below this width of the workspace the artifact panel is a full-screen sheet. */\n readonly sheetBelow?: number;\n /** Below this width of the workspace the sessions pane is a drawer. */\n readonly drawerBelow?: number;\n readonly floatBelow?: number | undefined;\n readonly className?: string;\n}\n\n/** The chat's attachment rules. `inlineImages` (default false) also sends a png, jpeg, gif or webp as a native image in the message: every later turn then carries its base64, so it is off by default. `maxBytes` refuses a larger file before it is uploaded (no limit by default), and the chat shows why. `accept` is the file picker's `accept` (any file by default). */\nexport interface WorkspaceUploads {\n readonly inlineImages?: boolean | undefined;\n readonly maxBytes?: number | undefined;\n readonly accept?: string | undefined;\n}\n\n/** A byte count for a refusal message: `2.5 MB`, `300 KB`, or `12 bytes`. */\nconst sizeLabel = (bytes: number) => bytes >= 1_000_000 ? `${Number((bytes / 1_000_000).toFixed(1))} MB` : bytes >= 1_000 ? `${Math.round(bytes / 1_000)} KB` : `${bytes} bytes`;\n\nexport type AgentWorkspaceProps = AgentWorkspaceBaseProps & (\n | { readonly revisionProvider: RevisionProvider; readonly resources?: WorkspaceBlobs | undefined }\n | { readonly resources: WorkspaceResources; readonly revisionProvider?: never }\n);\n\n// The Library column is docked only while the chat and the viewer (their `ArtifactWorkspace` minimums) keep their room beside it; otherwise it is Dock's sheet.\nconst SESSIONS_WIDTH = 260, LIBRARY_WIDTH = 320, CHAT_MIN = 360, VIEWER_MIN = 360;\nconst readFlag = (key: string): boolean => { try { return sessionStorage.getItem(key) === '1'; } catch { return false; } };\nconst writeFlag = (key: string, on: boolean) => { try { if (on) sessionStorage.setItem(key, '1'); else sessionStorage.removeItem(key); } catch { /* the layout is a convenience */ } };\nconst defaultLocate = (path: string): ResourceLocator => ({ resource: { providerId: 'workspace', path: workspacePath(path) }, view: { kind: 'published' } });\n\n/**\n * A complete agent page in one component: the sessions pane on the left (search, New, rename, archive, delete; collapsible, a drawer on\n * a narrow screen), the chat in the center (`PiChat`) and the artifact viewers on the right (`ArtifactWorkspace` with the artifact panel and\n * its versions, the file viewer and the host's own views). Agent artifacts open the panel as they appear. Every prop is data or a callback:\n * the host owns the routes, authentication, the controller and what is open (when controlled).\n */\nexport function AgentWorkspace({ layout = 'default', barPanels, panelWidth, title, controller, conversationId, chat = {}, labels, icons, panelActions, connecting, conversations, sessions, history: historyPlacement = 'column', resources, revisionProvider, libraryPlacement = 'panel', locale, fileTree: treeOptions, uploads, agents, detect, artifactKinds, viewers, interactive, share, opened: controlled,\n defaultOpened = null, onOpenedChange, onUnsavedChange, panels, autoOpen = true, fileBack, floatingChat, chatTop, controls, storageKey = 'boring.agent-workspace', sheetBelow = PHONE_BELOW, drawerBelow = PHONE_BELOW, floatBelow, className }: AgentWorkspaceProps) {\n const [own, setOwn] = useState<OpenedView | null>(defaultOpened);\n const [panelLibrary, setPanelLibrary] = useState(false);\n const opened = controlled !== undefined ? controlled : own;\n const change = useRef(onOpenedChange); change.current = onOpenedChange;\n const isControlled = controlled !== undefined;\n const unsaved = useRef(false);\n const currentView = useRef(opened); currentView.current = opened;\n const unsavedChange = useRef(onUnsavedChange); unsavedChange.current = onUnsavedChange;\n const noteUnsaved = useCallback((value: boolean) => { if (unsaved.current !== value) { unsaved.current = value; unsavedChange.current?.(value); } }, []);\n const beforeLeave = useRef<(() => Promise<boolean>) | null>(null);\n const registerBeforeLeave = useCallback((flush: (() => Promise<boolean>) | null) => { beforeLeave.current = flush; }, []);\n const leaving = useRef(false);\n const guard = useCallback(async () => {\n if (leaving.current) return false;\n if (!unsaved.current) return true;\n const current = currentView.current;\n leaving.current = true;\n try {\n const saved = await beforeLeave.current?.();\n if (currentView.current !== current) return false;\n return saved === true || !unsaved.current || Boolean(globalThis.confirm?.('This file has unsaved or unconfirmed changes. Discard the local draft and leave this file?'));\n } finally { leaving.current = false; }\n }, []);\n const setOpened = useCallback(async (next: OpenedView | null): Promise<boolean> => {\n const current = currentView.current;\n if (isFile(current) && isFile(next) && current.path === next.path) { setPanelLibrary(false); return true; }\n if (!await guard()) return false;\n noteUnsaved(false);\n setPanelLibrary(false);\n if (!isControlled) setOwn(next); change.current?.(next);\n return true;\n }, [guard, isControlled, noteUnsaved]);\n const [fullscreen, setFullscreen] = useState(false);\n const [centerMode, setCenterMode] = useState<'chat' | 'library'>('chat');\n const [sideOpen, setSideOpenState] = useState(false);\n // The Library's real docked width, reported by its Dock: the room it needs beside the chat and the viewer.\n const [libraryWidth, setLibraryWidth] = useState(LIBRARY_WIDTH);\n // Opening the column leaves a full-screen viewer, whose overlay would otherwise cover it.\n const setSideOpen = useCallback((next: boolean) => { setSideOpenState(next); if (next) setFullscreen(false); }, []);\n const openLibrary = useCallback(async () => {\n if (libraryPlacement === 'panel') {\n if (!await guard()) return;\n noteUnsaved(false); setPanelLibrary(value => !value);\n } else setCenterMode('library');\n setFullscreen(false);\n }, [guard, libraryPlacement, noteUnsaved]);\n const text = useMemo(() => ({ labels: withDefaults(locale === 'fr' ? frenchAppLabels : defaultAppLabels, labels), icons: withDefaults(defaultAppIcons, icons) }), [labels, icons, locale]);\n // The sessions pane shows the chat's conversation list, so it reads the chat's labels too.\n const chatText = useMergedText(chat.labels, chat.icons, chatTextFor(locale));\n\n // With a revision provider, `resources` carries only the blob options; without one it is the resource handler too.\n const remote = revisionProvider ? undefined : resources as WorkspaceResources;\n const fetcher = useRef(remote?.fetch); fetcher.current = remote?.fetch;\n const endpoint = remote ? String(remote.endpoint) : undefined;\n const historyEndpoint = remote?.history === undefined ? undefined : String(remote.history);\n const identity = revisionProvider?.identity ?? remote!.identity;\n const blobBase = checkBlobBase(resources?.blobBase);\n const hostUploadBlob = resources?.uploadBlob;\n const appLabels = text.labels;\n // The host's pointer is checked with the same validator the host uses; an invalid one is a refusal the Library shows.\n const uploadBlob = useMemo(() => hostUploadBlob && (async (file: File, options: Parameters<NonNullable<WorkspaceBlobs['uploadBlob']>>[1]) => {\n const pointer = parseBlobPointer(JSON.stringify(await hostUploadBlob(file, options) ?? null));\n if (pointer === undefined || 'kind' in pointer) throw { error: { code: 'invalid_pointer', message: appLabels.invalidBlobPointer(file.name) } };\n return pointer;\n }), [hostUploadBlob, appLabels]);\n const identityKey = JSON.stringify(identity);\n const history = useMemo(() => revisionProvider ? revisionProvider.history : historyEndpoint === undefined ? undefined : async (path: string): Promise<readonly SavedRevision[]> => {\n const url = new URL(historyEndpoint, globalThis.location?.href);\n url.searchParams.set('path', path);\n const response = await fetcher.current!(new Request(url));\n if (!response.ok) throw new Error(`History: ${response.status}`);\n return (await response.json() as { saves: readonly SavedRevision[] }).saves;\n }, [revisionProvider, historyEndpoint]);\n // Resources only: a Library over the endpoint's own listing, when it answers the revision protocol's discovery for the same\n // person (`createRevisionHandler` serves the resource calls and the listing on one route). A plain resource handler refuses: no Library.\n const [listing, setListing] = useState<{ readonly key: string; readonly provider: RevisionProvider }>();\n const listingKey = revisionProvider || endpoint === undefined ? undefined : JSON.stringify([endpoint, identity]);\n useEffect(() => {\n if (listingKey === undefined) return;\n const abort = new AbortController();\n connectRevisionProvider({ endpoint: new URL(endpoint!, globalThis.location?.href), fetch: request => fetcher.current!(request), signal: abort.signal }).then(provider => {\n const { principalId, scopeId, initiatorId } = provider.identity;\n if (!abort.signal.aborted && principalId === identity.principalId && scopeId === identity.scopeId && initiatorId === identity.initiatorId) setListing({ key: listingKey, provider });\n }, () => { /* no listing on this endpoint */ });\n return () => abort.abort();\n }, [listingKey]); // eslint-disable-line react-hooks/exhaustive-deps\n // The Library's binding: the host's revision provider, or the listing above with the versions of `resources.history`.\n const libraryProvider = useMemo(() => revisionProvider ?? (listing && listing.key === listingKey ? { ...listing.provider, history: history ?? listing.provider.history } : undefined),\n [revisionProvider, listing, listingKey, history]);\n // The viewers' client: the Library's binding when there is one (its calls carry the binding the revision protocol checks), else the plain resource client.\n // With `resources`, the binding is discovered after the first render on the same endpoint for the same person: it takes over the calls of\n // one stable client, so a viewer already open keeps its bytes and its editor instead of reloading when the binding arrives.\n const discovered = useRef(libraryProvider);\n discovered.current = libraryProvider;\n const client = useMemo((): ResourceClient => {\n if (revisionProvider) return revisionProvider;\n const plain = createResourceClient({ identity, endpoint: new URL(endpoint!, globalThis.location?.href), publication: true, reconciliation: true, fetch: request => fetcher.current!(request) });\n const current = (): ResourceClient => discovered.current ?? plain;\n return {\n read: (request, signal) => current().read(request, signal),\n publish: (request, signal) => current().publish!(request, signal),\n lookup: (operationId, signal) => current().lookup!(operationId, signal),\n rename: (request, signal) => current().rename!(request, signal),\n delete: (request, signal) => current().delete!(request, signal),\n };\n }, [revisionProvider, endpoint, identityKey]); // eslint-disable-line react-hooks/exhaustive-deps\n const [imagePreview, setImagePreview] = useState<{ readonly images: readonly ImageContent[]; readonly index: number; readonly controller: typeof controller; readonly client: typeof client; readonly conversationId: typeof conversationId }>();\n const openImage = useCallback((image: ImageContent, images: readonly ImageContent[] = [image]) => {\n const index = images.indexOf(image);\n if (index >= 0 && images.every(item => thumbnail(item.mimeType, item.data))) setImagePreview({ images: images.slice(), index, controller, client, conversationId });\n }, [controller, client, conversationId]);\n const preview = imagePreview && !chat.onOpenImage && imagePreview.controller === controller && imagePreview.client === client && imagePreview.conversationId === conversationId ? imagePreview : undefined;\n useEffect(() => { setImagePreview(undefined); }, [controller, client, conversationId, chat.onOpenImage]);\n // ---- Presented files: the page confirms a `present` call only once its panel has mounted the revision (`onShown`, from the viewers).\n // The bookkeeping is `createPresentAcks` (pi-artifacts/artifact.ts); the answer goes through the chat's own `answer` (the transport's\n // `?op=answer`) and the server checks the path and revision.\n const answerPresent = useRef(chat.actions?.answer); answerPresent.current = chat.actions?.answer;\n const openForPresent = useRef<(path: string) => boolean>(() => false);\n const presentAcks = useRef<PresentAcks | undefined>(undefined);\n if (!presentAcks.current) presentAcks.current = createPresentAcks({ answer: (id, body) => answerPresent.current?.(id, body),\n open: path => openForPresent.current(path), instanceId: randomUUID() });\n const onShown = useCallback((view: { readonly path: string; readonly revision: string; readonly hidden?: true }) => {\n if (view.hidden) presentAcks.current?.hidden(view); else presentAcks.current?.shown(view);\n }, []);\n // A kind that could not show the file (a dashboard whose data failed): the waiting `present` is refused with its reason.\n const onFailed = useCallback((view: { readonly path: string; readonly reason: string }) => { presentAcks.current?.failed(view); }, []);\n const kinds = artifactKinds ?? noKinds;\n const options: ViewerOptions = useMemo(() => ({ client, identity, history, viewers, kinds, interactive, share, onShown, onFailed, onUnsavedChange: noteUnsaved, onBeforeLeaveChange: registerBeforeLeave, blobBase }), [client, identityKey, history, viewers, kinds, interactive, share, onShown, onFailed, noteUnsaved, registerBeforeLeave, blobBase]); // eslint-disable-line react-hooks/exhaustive-deps\n const locate = revisionProvider?.locate ?? remote?.locate ?? defaultLocate;\n const readOnlyLibrary = !revisionProvider;\n const [fileBinding, setFileBinding] = useState<{ readonly provider: RevisionProvider; readonly tree: FileTreeController }>();\n useEffect(() => {\n if (!libraryProvider) return;\n // The Library reads its listings through `withBlobPointers`: each pointer file is resolved there, once, to the file it stands for.\n const tree = createFileTreeController({ revisionProvider: withBlobPointers(libraryProvider, blobBase) });\n setFileBinding({ provider: libraryProvider, tree });\n void tree.refresh();\n return () => tree.dispose();\n }, [libraryProvider, blobBase]);\n const libraryController = fileBinding?.provider === libraryProvider ? fileBinding?.tree : undefined;\n // Uploads and the upload notice belong to a revision provider only; the resources Library is read-only.\n const fileTree = revisionProvider ? libraryController : undefined;\n const embedded = layout === 'embedded';\n // Where the Library opens (`libraryPlacement`); embedded, it is the bar's Library button instead.\n const placed = Boolean(libraryProvider) && !embedded;\n const libraryInPane = placed && libraryPlacement === 'pane';\n const librarySide = placed && libraryPlacement === 'side';\n const libraryOpen = placed && libraryPlacement === 'center' && centerMode === 'library';\n // The workspace's change feed: every file view below reloads when its seq moves (agent writes, saves, shell changes).\n const feed = useChangeFeed(libraryProvider);\n // The shared tree controller reloads, shown or not: the Library may be on its other tab when a file arrives, and mounting the tree\n // later must not show the listing from before. `fileTree.refreshKey` (deprecated) does the same for a provider without a feed.\n const { refreshKey, ...viewOptions } = treeOptions ?? {};\n const refreshedKey = useRef(refreshKey);\n const refreshedSeq = useRef(feed.seq);\n useEffect(() => {\n if (!libraryController || (Object.is(refreshedKey.current, refreshKey) && refreshedSeq.current === feed.seq)) return;\n refreshedKey.current = refreshKey;\n refreshedSeq.current = feed.seq;\n const { query, expanded } = libraryController.getSnapshot();\n if (query) void libraryController.search(query);\n else for (const directory of ['', ...expanded]) void libraryController.refresh(directory);\n }, [libraryController, refreshKey, feed.seq]);\n const openFile = useCallback((path: string) => { setOpened({ kind: 'file', path }); }, [setOpened]);\n // The file on show was renamed, moved or deleted from the Library: follow it to its new path, or close it (its old path is gone).\n const followFile = (from: string, to: string | null) => {\n const current = currentView.current;\n if (!isFile(current) || current.path !== from) return;\n const next = to === null ? null : { kind: 'file' as const, path: to };\n noteUnsaved(false);\n if (!isControlled) setOwn(next); change.current?.(next);\n };\n // @ search: results are reused while the feed reports nothing new (typing back and forth), and dropped at the next change event.\n // One cache per provider instance (one workspace binding), so another binding never sees these results.\n const mentionResults = useMemo(() => ({ seq: undefined as number | undefined, results: new Map<string, readonly FileEntry[]>() }), [revisionProvider]);\n const feedState = useRef(feed); feedState.current = feed;\n const mentions = useMemo(() => revisionProvider ? {\n search: async (query: string, signal: AbortSignal) => {\n const { live, seq } = feedState.current;\n const cache = mentionResults;\n if (cache.seq !== seq || !live) { cache.seq = seq; cache.results = new Map(); }\n const known = live ? cache.results.get(query) : undefined;\n if (known) return known;\n const results = cache.results;\n const entries = (await revisionProvider.search({ query, limit: 8 }, signal)).entries;\n // Kept only when no change arrived meanwhile.\n if (live && cache.results === results && cache.seq === feedState.current.seq) results.set(query, entries);\n return entries;\n },\n open: openFile,\n // Thumbnails and voice playback read the original upload and MIME through the same provider as the file viewer.\n preview: async (path: string, signal: AbortSignal) => {\n const read = await revisionProvider.read({ target: revisionProvider.locate(path), revision: { kind: 'latest' } }, signal);\n return read.kind === 'available' ? { mimeType: read.snapshot.mediaType, data: imageData(read.snapshot.bytes) } : undefined;\n },\n } : undefined, [revisionProvider, openFile, mentionResults]);\n // The search function changes only with the binding; the feed's seq rides along as the picker's generation.\n const mentionsForChat = useMemo(() => mentions && { ...mentions, generation: feed.seq }, [mentions, feed.seq]);\n const [attachmentFailure, setAttachmentFailure] = useState<{ readonly tree: FileTreeController; readonly reasons: readonly string[] }>();\n // Primitives only in the memo: a host's inline `labels` object would otherwise rebuild the chat's attachments on every render.\n const uploadAccept = uploads?.accept, inlineImages = uploads?.inlineImages === true, maxBytes = uploads?.maxBytes;\n const tooLarge = maxBytes !== undefined ? text.labels.attachmentTooLarge(sizeLabel(maxBytes)) : undefined;\n const revisionAttachments = useRevisionAttachments({ fileTree, accept: uploadAccept, inlineImages, maxBytes, tooLarge, onPreparationFailure: (name, reason) => {\n if (fileTree && fileTree.getSnapshot().lifecycle === 'active') setAttachmentFailure(current => ({ tree: fileTree,\n reasons: [...(current?.tree === fileTree ? current.reasons : []), `${name}: ${reason}`] }));\n } });\n const attachments = useMemo(() => revisionAttachments && { ...revisionAttachments, upload: (files: File[], signal: AbortSignal) => {\n setAttachmentFailure(undefined);\n return revisionAttachments.upload(files, signal);\n } }, [revisionAttachments]);\n\n // ---- Artifacts: cards open the panel; the agent's new ones open it too.\n const versions = useArtifactVersions(controller, detect);\n const turn = useTurn(controller);\n const active = isArtifact(opened) && opened.conversation === conversationId ? opened : null;\n const file = isFile(opened) ? opened : null;\n const host = opened && !isArtifact(opened) && !isFile(opened) && panels?.[opened.kind] ? opened : null;\n const panelOpen = Boolean(active || file || host || panelLibrary);\n const seen = useRef(new Map<string, Set<string>>());\n const closedInTurn = useRef(new Map<string, number>());\n // Kept editing after the unsaved-changes question: the panel stays as it was, and the next artifact may still open it.\n const close = async () => { if (panelLibrary) { setPanelLibrary(false); return true; } if (!await setOpened(null)) return false; if (conversationId !== undefined) closedInTurn.current.set(conversationId, turn); setFullscreen(false); return true; };\n useEffect(() => {\n if (!controller || conversationId === undefined) return;\n const keys = versions.map(version => `${artifactKey(version)}:${version.revision}`);\n const known = seen.current.get(conversationId);\n // What the conversation already held when it loaded does not count.\n if (!known) { seen.current.set(conversationId, new Set(keys)); return; }\n const fresh = versions.filter(version => !known.has(`${artifactKey(version)}:${version.revision}`));\n for (const key of keys) known.add(key);\n if (unsaved.current || !autoOpen || !fresh.length || closedInTurn.current.get(conversationId) === turn || globalThis.matchMedia?.(`(max-width: ${sheetBelow - 1}px)`).matches) return;\n setOpened({ kind: 'artifact', conversation: conversationId, descriptor: fresh.at(-1)!, follow: true });\n }, [versions, controller, conversationId, turn]); // eslint-disable-line react-hooks/exhaustive-deps\n // A `present` call waiting for the page: decided by `createPresentAcks` (refused when the person closed the panel this turn, has unsaved\n // edits or is on a phone; otherwise its file is opened unless `autoOpen` is off, and the acknowledgement follows the mount).\n openForPresent.current = path => { if (unsaved.current) return false; void setOpened({ kind: 'file', path }); return true; };\n const liveView = useSyncExternalStore(controller ? controller.subscribe : subscribeNothing, () => controller?.getSnapshot().view, () => undefined);\n const presentCalls = useMemo(() => pendingPresents(liveView), [liveView]);\n useEffect(() => {\n if (!answerPresent.current || conversationId === undefined) return;\n presentAcks.current?.consider(presentCalls, { closedThisTurn: closedInTurn.current.get(conversationId) === turn, unsaved: unsaved.current,\n narrow: globalThis.matchMedia?.(`(max-width: ${sheetBelow - 1}px)`).matches === true, autoOpen, showing: path => showsPath(currentView.current, path) });\n }, [presentCalls, conversationId, turn, autoOpen, sheetBelow]);\n /** The versions of one file that the conversation presented or saved, newest first. */\n const newestOf = (key: string) => versions.filter(version => artifactKey(version) === key).reverse();\n const artifacts: ArtifactsConfig = useMemo(() => ({\n open: descriptor => { if (conversationId !== undefined) setOpened({ kind: 'artifact', conversation: conversationId, descriptor, follow: descriptor.revision === newestOf(artifactKey(descriptor))[0]?.revision }); },\n isOpen: descriptor => Boolean(active && artifactKey(active.descriptor) === artifactKey(descriptor) && (active.follow ? descriptor.revision === newestOf(artifactKey(descriptor))[0]?.revision : descriptor.revision === active.descriptor.revision)),\n ...(detect ? { detect } : {}),\n kinds,\n }), [conversationId, active, versions, detect, kinds, setOpened]); // eslint-disable-line react-hooks/exhaustive-deps\n\n // ---- Sessions: docked on a wide workspace (collapsible, remembered for the session), a drawer on a narrow one.\n const root = useRef<HTMLDivElement>(null);\n const [width, setWidth] = useState(0);\n useLayoutEffect(() => {\n const element = root.current;\n if (!element) return;\n const measure = () => setWidth(element.getBoundingClientRect().width);\n measure();\n if (typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n const narrow = width > 0 && width < drawerBelow;\n const [paneOpen, setPaneOpen] = useHistoryOpen(`${storageKey}.history-column`, true);\n const paneId = `${storageKey}-sessions`;\n const [drawer, setDrawer] = useState(false);\n const [paneView, setPaneView] = useState<SessionsView>(() => readFlag(`${storageKey}.library`) ? 'library' : 'conversations');\n const changePaneView = useCallback((next: SessionsView) => { writeFlag(`${storageKey}.library`, next === 'library'); setPaneView(next); }, [storageKey]);\n // The upload notice's \"Open Library\": the center Library, or the pane's Library tab with the pane shown.\n const revealLibrary = () => {\n if (librarySide) { setSideOpen(true); return; }\n if (!libraryInPane) { openLibrary(); return; }\n changePaneView('library');\n if (narrow) setDrawer(true); else setPaneOpen(true);\n };\n useEffect(() => { if (!narrow) setDrawer(false); }, [narrow]);\n const toggleSide = useCallback(() => setSideOpen(!sideOpen), [setSideOpen, sideOpen]);\n // The Library tree, one view for the side column, the center and the pane; `onOpen` is what choosing a file does there.\n const libraryTree = (onOpen: (path: string) => void, onClose?: () => void) => libraryController\n ? <FileTreeView variant=\"list\" refresh={libraryPlacement !== 'panel'} title={text.labels.library} {...(onClose ? { onClose } : {})} key={JSON.stringify([libraryProvider?.workspace, libraryProvider?.identity])} {...(locale ? { locale } : {})} {...viewOptions}\n {...(readOnlyLibrary ? { upload: false, history: history !== undefined } : {})} {...(maxBytes !== undefined ? { maxBytes, ...(tooLarge ? { tooLarge } : {}) } : {})} {...(uploadBlob ? { uploadBlob } : {})} controller={libraryController} historyKey={feed.seq} onOpen={onOpen} onRenamed={(from, to) => followFile(from, to)} onDeleted={path => followFile(path, null)} {...(isFile(opened) ? { selectedPath: opened.path } : {})} />\n : <p role=\"status\" className=\"m-0 p-3 text-sm text-muted-foreground\">{treeOptions?.labels?.loading ?? text.labels.loading}</p>;\n // `history=\"popover\"` keeps the conversations out of the pane. With an open panel the column steps aside unless column, panel and chat fit.\n const paneConversations = historyPlacement === 'column' ? conversations : undefined;\n const hasNavigation = !embedded && sessions !== false && Boolean(paneConversations || placed);\n const stepAside = Boolean(paneConversations) && hasNavigation && !narrow && panelOpen && !historyColumnFits(width, VIEWER_MIN);\n const docked = hasNavigation && width > 0 && !narrow && paneOpen && !stepAside;\n const popoverHistory = historyPlacement === 'popover' || stepAside;\n const toggle = hasNavigation && !docked && !drawer && !stepAside && <SessionsToggle navigation={placed} open={narrow ? drawer : paneOpen} drawer={narrow} controls={narrow ? undefined : paneId}\n onToggle={() => { if (narrow) setDrawer(value => !value); else { setPaneOpen(!paneOpen); if (!paneOpen) focusHistoryRow(paneId); } }} />;\n\n // ---- The docked chat's header is replaced on every switch and connect (the `connecting` row, then a new `PiChat`). The toggle is not\n // part of it: it stays mounted in the shell, over an invisible space of its size in whichever header is showing, and follows that\n // header's height. A press that starts before the swap ends on the same button and still clicks (a button that leaves the document\n // between press and release loses the click, even when moved rather than recreated).\n const toggleRow = useRef<HTMLDivElement>(null);\n const followHeader = useRef<ResizeObserver | undefined>(undefined);\n const headerSlot = useCallback((space: HTMLElement | null) => {\n followHeader.current?.disconnect(); followHeader.current = undefined;\n const header = space?.parentElement;\n if (!header || typeof ResizeObserver === 'undefined') return;\n followHeader.current = new ResizeObserver(() => { if (toggleRow.current) toggleRow.current.style.height = `${header.clientHeight}px`; });\n followHeader.current.observe(header);\n }, []);\n const toggleSpace = toggle && <Button ref={headerSlot} variant=\"ghost\" size=\"icon-sm\" aria-hidden=\"true\" tabIndex={-1} className=\"invisible min-[641px]:-ml-1\" />;\n\n // ---- The chat in the center: docked PiChat, or the host's floating surface over the same controller (with the toggle in its header).\n const hostControls = typeof controls === 'function' ? controls({ panelOpen }) : controls ?? chat.controls;\n const header = <>{conversations?.onNew && <Button variant=\"ghost\" size=\"icon-sm\" data-testid=\"workspace-new-chat\" aria-label={text.labels.newChat} title={text.labels.newChat} className=\"min-[641px]:hidden\" onClick={conversations.onNew}><text.icons.newChat data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}{hostControls}</>;\n const conversationTitle = conversations?.items.find(item => item.id === conversationId)?.title;\n // pi-app ships every composer feature item: Markdown, the `/` menu, `@` mentions, the model pill, voice messages, the phone camera.\n const chatProps: PiChatProps | undefined = controller && withChatPlugins({ ...(mentionsForChat ? { mentions: mentionsForChat } : {}), ...(attachments ? { attachments } : {}), ...(locale ? { locale } : {}), ...chat, controller, artifacts, onOpenFile: chat.onOpenFile ?? chat.mentions?.open ?? openFile, onOpenImage: chat.onOpenImage ?? openImage, ...(conversations ? { conversations, historyList: popoverHistory, showHistory: chat.showHistory ?? popoverHistory, conversationTitle, onRenameTitle: conversations.rename && conversationId ? (title: string) => conversations.rename!(conversationId, title) : undefined } : {}),\n headerStart: <>{toggle}{chat.headerStart}</>, ...(header === undefined ? {} : { controls: header }),\n // Embedded: the bar above replaces the chat's header (History, New and the host's controls are in it); conversations stay for Fork.\n ...(embedded ? { header: false, historyList: false, showHistory: false } : {}) });\n const bar = embedded && <WorkspaceBar title={title ?? conversationTitle ?? chatText.labels.title} {...(title && conversationTitle ? { subtitle: conversationTitle } : {})} conversations={conversations} narrow={narrow}\n library={libraryController ? onPicked => libraryTree(path => { openFile(path); onPicked(); }, onPicked) : undefined} panels={barPanels}\n controls={<><BlockActions actions={chat.headerActions} testId=\"chat-action\" menuLabel={chatText.labels.moreActions} />{hostControls}</>} />;\n const docked_chat = <div data-testid=\"workspace-center\" className=\"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden\">\n {bar}\n {attachmentFailure?.tree === fileTree && attachmentFailure?.reasons.map((reason, index) => <p key={index} role=\"alert\" className=\"m-0 px-4 py-2 text-sm\">{reason}</p>)}\n {fileTree && <UploadNotice controller={fileTree} onOpen={revealLibrary} />}\n {chatTop}\n <div className=\"relative flex min-h-0 flex-1 flex-col\">\n {chatProps ? <PiChat key={conversationId} {...chatProps} headerStart={<>{toggleSpace}{chat.headerStart}</>} className={cn('min-h-0 flex-1', chat.className)} />\n // Same geometry as the PiChat header, so nothing moves when the chat arrives.\n : <>{toggle && <><div className=\"flex shrink-0 items-center gap-2 px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5\">{toggleSpace}</div><Separator /></>}{connecting}</>}\n {toggle && <div ref={toggleRow} className=\"pointer-events-none absolute top-0 left-0 z-10 flex items-center px-3 sm:px-4 [&>*]:pointer-events-auto\">{toggle}</div>}\n </div>\n </div>;\n\n const kind = active ? 'artifact' : file ? 'file' : host?.kind;\n const shown = active ?? file ?? host;\n const actionsFor = (floatChat: (() => void) | undefined): readonly BlockAction[] => [\n ...(placed && libraryPlacement === 'panel' ? [{ id: 'library', label: text.labels.library, icon: text.icons.library, placement: 'menu' as const, onSelect: openLibrary }] : []),\n ...(librarySide ? [{ id: 'library', label: text.labels.library, icon: text.icons.library, pressed: sideOpen, onSelect: toggleSide }] : []),\n ...(floatChat ? [{ id: 'float-chat', label: text.labels.floatChat, icon: text.icons.floatChat, placement: 'menu' as const, onSelect: floatChat }] : []),\n ...(shown ? typeof panelActions === 'function' ? panelActions(shown) : panelActions ?? [] : []),\n ];\n const sessionProps: ComponentProps<typeof SessionsPane> = { id: paneId, conversations: paneConversations, drawer: narrow, onClose: () => setDrawer(false), onChat: () => setCenterMode('chat'), onCollapse: () => setPaneOpen(false), title: labels?.sessionsTitle,\n agents, view: paneView, onViewChange: changePaneView,\n ...(librarySide ? { library: { selected: sideOpen, onSelect: toggleSide, side: true } } : placed && !libraryInPane ? { library: { selected: libraryOpen || panelLibrary, onSelect: openLibrary } } : {}),\n ...(libraryInPane ? { libraryContent: (onPicked: () => void) => libraryTree(path => { openFile(path); onPicked(); }) } : {}),\n };\n return <AppTextProvider value={text}><ChatTextProvider value={chatText}><div ref={root} data-boring=\"agent-workspace\" data-layout={layout} data-sessions={!hasNavigation ? undefined : narrow ? (drawer ? 'drawer' : 'closed') : docked ? 'docked' : 'hidden'}\n className={cn('pi-chat relative flex h-full min-h-0 min-w-0 flex-1 overflow-hidden', className)}>\n <DockLayout storageKey={`${storageKey}.dock`} minMain={SESSIONS_WIDTH + 480}>\n {hasNavigation && (docked || (narrow && drawer)) && (sessions ? sessions(sessionProps) : <SessionsPane {...sessionProps} />)}\n <DockMain><ArtifactWorkspace open={panelOpen && !(panelLibrary && narrow)} onClose={close} panelLabel={text.labels.artifactPanel} labels={{ resize: text.labels.resizePanel, floatHint: text.labels.floatHint }} fullscreen={fullscreen} onFullscreenChange={setFullscreen} storageKey={`${storageKey}.panel-width`} {...(panelWidth === undefined ? {} : { defaultWidth: panelWidth })}\n sheetBelow={Math.max(CHAT_MIN + VIEWER_MIN, docked ? sheetBelow - SESSIONS_WIDTH : sheetBelow)} {...(floatBelow === undefined || libraryOpen ? {} : { floatBelow })}\n chat={layout => <WorkspaceCenter libraryOpen={libraryOpen} floating={layout.floating} chat={docked_chat}\n floatingChat={floatingChat && chatProps ? floatingChat(chatProps, layout.dock) : undefined}\n // With the Library in the pane, the center holds only the chat (no second, hidden toggle or tree).\n library={libraryInPane || librarySide || libraryPlacement === 'panel' ? null : <section data-testid=\"workspace-library-view\" aria-label={text.labels.library} className=\"flex min-h-0 flex-1 flex-col\">\n <div className=\"min-h-0 flex-1 overflow-auto\">{libraryTree(openFile, () => setCenterMode('chat'))}</div>\n </section>} />}\n panel={win => <ViewerWindowProvider value={{ fullscreen: win.fullscreen, onFullscreenChange: win.onFullscreenChange, actions: actionsFor(win.floatChat), labels: text.labels, icons: text.icons }}>\n <div data-testid=\"viewer-panel\" data-kind={kind} className=\"flex min-h-0 flex-1 flex-col overflow-hidden [&>*]:min-h-0 [&>*]:flex-1\">\n {panelLibrary ? <section data-testid=\"workspace-library-side\" aria-label={text.labels.library} className=\"flex min-h-0 flex-1 flex-col\"><div className=\"min-h-0 flex-1 overflow-auto\">{libraryTree(openFile, () => setPanelLibrary(false))}</div></section> : active\n ? <ArtifactPanel key={artifactKey(active.descriptor)} active={active} versions={newestOf(artifactKey(active.descriptor))} options={options} onClose={win.close}\n onSelect={value => {\n const known = newestOf(artifactKey(active.descriptor));\n setOpened({ kind: 'artifact', conversation: active.conversation, follow: value === 'latest', descriptor: value === 'latest' ? (known[0] ?? active.descriptor) : { ...active.descriptor, revision: value } });\n }} />\n : file ? <FileViewer key={JSON.stringify([revisionProvider?.workspace, identity, file.path])} onBeforeLeaveChange={registerBeforeLeave} onUnsavedChange={noteUnsaved} path={file.path} locator={locate(file.path)} options={options} onClose={win.close} {...(fileBack ? { onBack: fileBack.onBack, backLabel: fileBack.label } : {})} />\n : host ? panels![host.kind]!(host, win) : null}\n </div></ViewerWindowProvider>} /></DockMain>\n {librarySide && <Dock id=\"library\" side=\"right\" open={sideOpen} onOpenChange={setSideOpen} defaultWidth={LIBRARY_WIDTH} minWidth={240} narrowBelow={Math.max(drawerBelow, (docked ? SESSIONS_WIDTH : 0) + (panelOpen ? VIEWER_MIN + CHAT_MIN : CHAT_MIN) + libraryWidth)} narrow=\"sheet\"\n labels={{ region: text.labels.library, resize: text.labels.resizePanel }} >\n {api => <section data-testid=\"workspace-library-side\" aria-label={text.labels.library} className=\"flex min-h-0 flex-1 flex-col\">\n <WidthProbe width={api.width} onWidth={setLibraryWidth} />\n <div className=\"min-h-0 flex-1 overflow-auto\">{libraryTree(path => { void setOpened({ kind: 'file', path }).then(opened => { if (opened && narrow) setSideOpen(false); }); }, () => setSideOpen(false))}</div>\n </section>}\n </Dock>}\n </DockLayout>\n {preview && <ImageViewer images={preview.images} index={preview.index} labels={chatText.labels} testId=\"conversation-image\"\n onIndexChange={index => setImagePreview({ ...preview, index })} onClose={() => setImagePreview(undefined)} />}\n {panelLibrary && narrow && <Drawer open showSwipeHandle onOpenChange={next => { if (!next) setPanelLibrary(false); }}><DrawerContent aria-label={text.labels.library} className=\"pi-chat min-h-[80dvh] max-h-[80dvh]\"><DrawerTitle className=\"sr-only\">{text.labels.library}</DrawerTitle><div data-testid=\"workspace-library-side\" className=\"min-h-0 flex-1 overflow-auto pb-[env(safe-area-inset-bottom)]\">{libraryTree(openFile, () => setPanelLibrary(false))}</div></DrawerContent></Drawer>}\n </div></ChatTextProvider></AppTextProvider>;\n}\n\nfunction WidthProbe({ width, onWidth }: { readonly width: number; readonly onWidth: (width: number) => void }) {\n useLayoutEffect(() => { onWidth(width); }, [width, onWidth]);\n return null;\n}\n\nfunction UploadNotice({ controller, onOpen }: { readonly controller: FileTreeController; readonly onOpen: () => void }) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const failures = state.uploads.filter(upload => upload.state.kind === 'settled' && upload.state.result.kind !== 'committed');\n return failures.length ? <p role=\"alert\" className=\"m-0 px-4 py-2 text-sm\">{failures.length} upload{failures.length === 1 ? '' : 's'} need attention. <Button variant=\"link\" onClick={onOpen}>Open Library</Button> to review the results and check unconfirmed uploads.</p> : null;\n}\n\n/** Keep the selected chat surface mounted while Library temporarily uses its layout space. */\nfunction WorkspaceCenter({ libraryOpen, floating, chat, floatingChat, library }: {\n readonly libraryOpen: boolean;\n readonly floating: boolean;\n readonly chat: ReactNode;\n readonly floatingChat: ReactNode;\n readonly library: ReactNode;\n}) {\n const lastFloating = useRef(floating);\n useLayoutEffect(() => { if (!libraryOpen) lastFloating.current = floating; }, [libraryOpen, floating]);\n const showFloating = libraryOpen ? lastFloating.current : floating;\n return <div className=\"flex min-h-0 flex-1 flex-col\">\n <div data-testid=\"workspace-chat-surface\" hidden={libraryOpen} inert={libraryOpen} aria-hidden={libraryOpen || undefined} className={cn('min-h-0 flex-1 flex-col', libraryOpen ? 'hidden' : 'flex')}>\n {showFloating && floatingChat ? floatingChat : chat}\n </div>\n <div hidden={!libraryOpen} inert={!libraryOpen} aria-hidden={!libraryOpen || undefined} className={cn('min-h-0 flex-1 flex-col', libraryOpen ? 'flex' : 'hidden')}>{library}</div>\n </div>;\n}\n\n/**\n * A number that moves when the provider's files change (`RevisionProvider.changes`), coalesced over `settleMs` so a burst (a shell\n * writing many files) reloads once. `live` is false without a feed or after the feed was refused; views then keep their own refresh.\n * A hidden tab holds no feed: a browser allows few connections per host, and every tab of a share link holding a stream starves the\n * others (remote chat releases its stream the same way); `live` is false meanwhile. Shown again, it reconnects after the last event it\n * acted on (`since`; 0 when it had none, which the feed answers with a `resnapshot`), so a change made while hidden or while reconnecting\n * arrives on the new feed and moves the number then, after the feed is connected.\n */\nfunction useChangeFeed(provider: RevisionProvider | undefined, settleMs = 150): { readonly seq: number | undefined; readonly live: boolean } {\n const [state, setState] = useState<{ readonly provider: RevisionProvider | undefined; readonly seq: number | undefined; readonly live: boolean }>({ provider, seq: undefined, live: false });\n const shown = usePageShown();\n // The provider connected last, and the protocol seq of the last event the views reloaded for.\n const connected = useRef<{ readonly provider: RevisionProvider; seen: number | undefined } | undefined>(undefined);\n useEffect(() => {\n if (!provider?.changes) { setState({ provider, seq: undefined, live: false }); return; }\n if (!shown) { setState(current => current.provider === provider && current.live ? { ...current, live: false } : current); return; }\n const stop = new AbortController();\n let timer: ReturnType<typeof setTimeout> | undefined, latest: number | undefined;\n // A second connection to the same provider follows a hidden period: it resumes after the last event acted on. A first connection has nothing to catch up.\n const since = connected.current?.provider === provider ? connected.current.seen ?? 0 : undefined;\n const cursor = connected.current?.provider === provider ? connected.current : { provider, seen: undefined };\n connected.current = cursor;\n const move = () => { cursor.seen = latest; setState(current => ({ provider, seq: current.provider === provider ? (current.seq ?? 0) + 1 : 1, live: true })); };\n setState(current => ({ provider, seq: current.provider === provider ? current.seq : undefined, live: true }));\n void (async () => {\n try {\n for await (const event of provider.changes!({ ...(since === undefined ? {} : { since }), signal: stop.signal })) {\n latest = event.seq;\n // An event still settling when the feed closes is not acted on, so the next connection asks for it again.\n timer ??= setTimeout(() => { timer = undefined; if (!stop.signal.aborted) move(); }, settleMs);\n }\n } catch { /* refused: the views fall back to their own refresh */ }\n clearTimeout(timer);\n timer = undefined;\n if (!stop.signal.aborted) setState(current => ({ provider, seq: current.provider === provider ? current.seq : undefined, live: false }));\n })();\n return () => { stop.abort(); clearTimeout(timer); };\n }, [provider, settleMs, shown]);\n return state.provider === provider ? state : { seq: undefined, live: false };\n}\n\n/** Whether the page is shown (`document.visibilityState`); true where there is no document. */\nfunction usePageShown(): boolean {\n const [shown, setShown] = useState(() => typeof document === 'undefined' || document.visibilityState !== 'hidden');\n useEffect(() => {\n if (typeof document === 'undefined') return;\n const update = () => setShown(document.visibilityState !== 'hidden');\n update();\n document.addEventListener('visibilitychange', update);\n return () => document.removeEventListener('visibilitychange', update);\n }, []);\n return shown;\n}\n",
26
+ "content": "'use client';\n\nimport '@hachej/boring-ui-kit/file-tree.css';\n\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ComponentProps, ReactNode } from 'react';\nimport type { ResourceClient, ResourceLocator } from '@hachej/boring-files';\nimport { createResourceClient } from '@hachej/boring-files/remote';\nimport { connectRevisionProvider } from '@hachej/boring-files/revision';\nimport { workspacePath } from '@hachej/boring-files/file-types';\nimport { checkBlobBase, parseBlobPointer, withBlobPointers } from '@hachej/boring-files/blob';\nimport type { BlobPointer } from '@hachej/boring-files/blob';\nimport type { FileEntry, RevisionProvider } from '@hachej/boring-files/revision';\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 { frenchAppLabels } from './app-labels-fr';\nimport { chatTextFor } from '../pi-chat/locale';\nimport type { ChatLocale } from '../pi-chat/locale';\nimport type { FileTreeViewProps } from '../file-tree/file-tree';\nimport type { ResourceIdentity } from '@hachej/boring-files/remote';\nimport type { ChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { useArtifactFollow } from '@hachej/boring-ui-kit/chat-react';\nimport { PiChat } from '../pi-chat/pi-chat';\nimport { withChatPlugins } from '../pi-chat-plugins/chat-plugins';\nimport { artifactKey } from '../pi-artifacts/artifact';\nimport type { ArtifactDescriptor, ArtifactsConfig } from '../pi-artifacts/artifact';\nimport type { ConversationsConfig, PiChatProps } from '../pi-chat/pi-chat';\nimport { Dock, DockLayout, DockMain } from '../pi-workspace/dock';\nimport { ArtifactWorkspace } from '../pi-workspace/workspace';\nimport type { WorkspacePanelApi } from '../pi-workspace/workspace';\nimport { ViewerWindowProvider } from '../viewer-frame/viewer-frame';\nimport type { ViewerShare } from '../viewer-frame/viewer-frame';\nimport type { InteractiveHtml } from '../viewer-frame/interactive-html';\nimport { noKinds } from './viewer-kinds';\nimport type { PanelKind } from './viewer-kinds';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { ChatTextProvider, useMergedText } from '../pi-chat/labels';\nimport { cn, PHONE_BELOW, withDefaults } from '../utils/utils';\nimport { readFlag, writeFlag } from '../utils/persisted';\nimport { AppTextProvider, defaultAppIcons, defaultAppLabels } from './app-labels';\nimport type { AppIcons, AppLabels } from './app-labels';\nimport { ArtifactPanel } from './artifact-panel';\nimport type { CustomViewers, SavedRevision, ViewerOptions } from './artifact-panel';\nimport { FileViewer } from './file-viewer';\nimport { ImageViewer } from '../pi-chat/image-viewer';\nimport { thumbnail } from '../pi-chat/composer';\nimport { useRevisionAttachments } from './use-revision-attachments';\nimport { imageData } from './revision-files';\nimport { SessionsPane, SessionsToggle } from './sessions';\nimport { focusHistoryRow, historyColumnFits, useHistoryOpen } from './history-column';\nimport type { HistoryPlacement } from './history-column';\nimport type { SessionsView, WorkspaceAgents } from './sessions';\nimport { WorkspaceBar } from './workspace-bar';\nimport type { WorkspaceBarPanel } from './workspace-bar';\n\nexport { ArtifactPanel, useArtifactVersions, useTurn } from './artifact-panel';\nexport type { CustomViewerProps, CustomViewers, SavedRevision, ViewerOptions } from './artifact-panel';\nexport { FileViewer } from './file-viewer';\nexport { SessionsPane, SessionsToggle } from './sessions';\nexport { HistoryColumn, HistoryColumnToggle, historyColumnFits, useHistoryColumn } from './history-column';\nexport type { HistoryPlacement } from './history-column';\nexport { WorkspaceBar } from './workspace-bar';\nexport type { WorkspaceBarPanel, WorkspaceBarProps } from './workspace-bar';\nexport { ConversationList, ConversationHistory } from '../pi-chat/history';\nexport { FileTree } from '../file-tree/file-tree';\nexport type { SessionsView, WorkspaceAgent, WorkspaceAgents } from './sessions';\nexport { useConversations } from '../pi-chat/use-conversations';\nexport { useRemoteChat } from '../pi-chat/use-remote-chat';\nexport type { RemoteChatState } from '../pi-chat/use-remote-chat';\nexport { savedLabel, useSaved } from './use-saved';\nexport { viewerKindOf as kindOf, mediaTypeOf } from '@hachej/boring-files/file-types';\nexport { defaultAppIcons, defaultAppLabels } from './app-labels';\nexport { frenchAppLabels } from './app-labels-fr';\nexport type { AppIcons, AppLabels } from './app-labels';\nexport type { ViewerKind as FileKind } from '@hachej/boring-files/file-types';\n\n/** An artifact version in the panel: `follow` shows the latest saved revision, otherwise `descriptor.revision` is pinned and read-only. */\nexport interface ArtifactView { readonly kind: 'artifact'; readonly conversation: string; readonly descriptor: ArtifactDescriptor; readonly follow: boolean }\n/** A file in the panel, by the host's path (see `resources.locate`). */\nexport interface FileView { readonly kind: 'file'; readonly path: string }\n/** Anything else the host renders in the panel through `panels[kind]` (for example a file list or a tool view). */\nexport interface HostView { readonly kind: string; readonly [field: string]: unknown }\n/** What the right-hand panel shows, or `null` when it is closed. */\nexport type OpenedView = ArtifactView | FileView | HostView;\n\nconst isArtifact = (view: OpenedView | null): view is ArtifactView => view?.kind === 'artifact';\nconst isFile = (view: OpenedView | null): view is FileView => view?.kind === 'file';\n/** Whether the panel shows the file at `path` (a file view, or the artifact view of it). */\nconst showsPath = (view: OpenedView | null, path: string): boolean => isFile(view) ? view.path === path : isArtifact(view) ? view.descriptor.target.resource.path === path : false;\ntype ImageContent = Parameters<NonNullable<PiChatProps['onOpenImage']>>[0];\n\n/**\n * Large files (blob pointers): the workspace holds a small pointer file (`<name>.blob.json`, `@hachej/boring-files/blob`) for each\n * large file, and the host serves its bytes at `GET <blobBase><encodeURIComponent(id)>`. The Library lists a pointer under the file's\n * name, size and type, and the viewers show it from that URL (an image inline, a PDF in the PDF viewer, anything else as a download).\n */\nexport interface WorkspaceBlobs {\n /** Where the host serves blob bytes: a same-origin path starting with `/` (default `/_files/`). Anything else is refused (a `TypeError`). */\n readonly blobBase?: string | undefined;\n /**\n * The host's large-file upload, used by Library uploads above `uploads.maxBytes`: it stores the bytes and writes the pointer file\n * itself, then resolves with the pointer it wrote (checked with `parseBlobPointer`); the Library reloads its listing and writes\n * nothing. `onProgress` drives the row's progress bar, `signal` its cancel button; a refusal rejects with `{ error: { code, message } }`.\n * Without it a file above the cap is refused as today.\n */\n readonly uploadBlob?: ((file: File, options: { readonly signal: AbortSignal; readonly onProgress: (progress: { readonly loaded: number; readonly total: number }) => void }) => Promise<BlobPointer>) | undefined;\n}\n\n/**\n * Where the viewers read and write: the host's resource handler (`createResourceHandler` of `@hachej/boring-files/remote`) and its file history.\n * When the endpoint also answers the revision protocol's discovery (`createRevisionHandler` serves both), the page shows a read-only\n * Library of its files (open, and version history with `history`); a plain resource handler has no listing, so there is no Library.\n */\nexport interface WorkspaceResources extends WorkspaceBlobs {\n /** The resource handler endpoint. */\n readonly endpoint: string | URL;\n /** The host's authenticated fetch, also used for `history`. */\n readonly fetch: (request: Request) => Promise<Response>;\n /** The person's identity as the resource handler authenticates it (comparison metadata, never a grant). */\n readonly identity: ResourceIdentity;\n /** Optional `GET <history>?path=<resource path>` returning `{ saves: [{ revision, savedAt }] }` newest first (`files.saves(path)`): the version menu. */\n readonly history?: string | URL | undefined;\n /** The resource of a file path in a `FileView`. Default: the published `workspace` provider at the path without a leading slash. */\n readonly locate?: ((path: string) => ResourceLocator) | undefined;\n}\n\ninterface AgentWorkspaceBaseProps {\n /**\n * `default`: the sessions pane on the left, the chat in the center and the artifact panel on the right. `embedded`: for a page set\n * inside a host's screen (an app-shell screen, a hub page): no left column at any width, and one compact top bar with the\n * conversation title and History, Library and New as icon buttons (a popover on a wide workspace, a sheet on a phone) followed by\n * `controls`. Inside an app-shell screen the bar's buttons go into the shell's header, so the page has one row.\n */\n readonly layout?: 'default' | 'embedded' | undefined;\n /**\n * The side panel's width when it first opens, before the person drags the divider (then the dragged width is kept for the session):\n * pixels, or a fraction of the workspace's width when at most 1. Without it the panel leaves the chat 420px.\n */\n readonly panelWidth?: number | undefined;\n /** `embedded` only: the host's own panels in the bar, after History and Library (a popover on a wide workspace, a sheet on a phone). */\n readonly barPanels?: readonly WorkspaceBarPanel[] | undefined;\n /**\n * `embedded` only: a fixed leading title for the bar, normally the app's name. It stays whatever the conversation is called; the\n * conversation title then follows it as a muted second element (on a phone it is left to History). Without it the bar shows the\n * conversation title alone, as before.\n */\n readonly title?: string | undefined;\n /**\n * Where the Library of a `revisionProvider` opens. `panel` (default): the artifact column, or a bottom drawer on phones.\n * `side`: a column to the right of the viewer (a `Dock`, resizable), opened\n * from the Library button in the viewer's top bar or in the left pane, alone at the right edge when no file is open; on a phone a\n * full-screen sheet that choosing a file closes. `center`: Chat and Library links in the left pane, the Library replacing\n * the chat in the center (the chat stays mounted). `pane`: the Library is a tab beside the conversations in the left pane, or the\n * whole pane on a single-session page without `conversations`; files open in the right panel as usual.\n */\n readonly libraryPlacement?: 'panel' | 'side' | 'center' | 'pane' | undefined;\n /** The page's language: `en` (default) or `fr`, for the chat, the panes, the viewers and the Library. `labels` and `chat.labels` override any word. */\n readonly locale?: ChatLocale | undefined;\n /**\n * The Library tree's options: its words (`labels`, over `defaultFileTreeLabels`), whether it offers Upload and History, and a\n * `refreshKey` that reloads it when it changes. Deprecated: with a `revisionProvider` that has a change feed (`connectRevisionProvider`)\n * the tree, the Library, the history panel and the @ search reload from the workspace's own change events; `refreshKey` remains\n * for a provider without one.\n */\n readonly fileTree?: Pick<FileTreeViewProps, 'labels' | 'upload' | 'history' | 'refreshKey' | 'capabilities'> | undefined;\n /**\n * How the chat's attachments are saved under `uploads/` (they exist only with a Library, `revisionProvider` or `resources`). Every file\n * is reference-only by default: the message carries its `@path` and the agent reads it with its file tools. See `WorkspaceUploads`.\n */\n readonly uploads?: WorkspaceUploads | undefined;\n /**\n * The page's agents. With two or more, a switcher at the top of the left pane chooses one; the host scopes `conversations` and the\n * controller to `activeId`. One agent (or none given) shows no switcher.\n */\n readonly agents?: WorkspaceAgents | undefined;\n /** The open conversation's controller (`useRemoteChat`); `undefined` while connecting, when `connecting` is shown instead. */\n readonly controller: ChatController | undefined;\n /** The open conversation: auto-opened artifacts and the artifact view belong to it. */\n readonly conversationId: string | undefined;\n /** Everything else `PiChat` takes (labels, icons, headerActions, messageActions, mode, actions, slash, mentions, attachments, model, effort, emptyState, ...). */\n readonly chat?: Omit<PiChatProps, 'controller' | 'artifacts' | 'conversations' | 'historyList'>;\n /**\n * The block's words (sessions pane, artifact panel, file viewer and the viewers' bars), over `defaultAppLabels`; for example\n * `{ sessionsTitle: 'Projects', newChat: 'Start' }`. The chat's own go in `chat.labels`.\n */\n readonly labels?: Partial<AppLabels> | undefined;\n /** The block's icons (sessions toggle, New, Float chat, the viewer bar's), over `defaultAppIcons`. The chat's go in `chat.icons`. */\n readonly icons?: Partial<AppIcons> | undefined;\n /**\n * Host actions on the artifact panel's bar (every viewer it shows), after the built-in Float chat: `header` ones as buttons beside Share,\n * `menu` ones in its \"…\" menu. A function receives what is open. Test ids `<viewer testId>-<id>` (`artifact-<id>` for an artifact).\n */\n readonly panelActions?: readonly BlockAction[] | ((view: OpenedView) => readonly BlockAction[]) | undefined;\n readonly connecting?: ReactNode;\n /** The sessions pane (`useConversations`). Omit it for a page without one. Replies keep their Fork button through `conversations.fork`. */\n readonly conversations?: ConversationsConfig | undefined;\n /** Place the sessions pieces in host navigation. Default: SessionsPane. False leaves navigation entirely to the host; conversations still powers chat Fork. */\n readonly sessions?: false | ((props: ComponentProps<typeof SessionsPane>) => ReactNode) | undefined;\n /**\n * `column` (default): the conversations are the pane's side column, open or closed as this viewer left it; while an open panel leaves\n * the chat too narrow beside it, the column steps aside and the chat's History opens the popover. `popover`: History is always the popover.\n */\n readonly history?: HistoryPlacement | undefined;\n /** Recognise artifacts in tool results that carry no descriptor (`ArtifactsConfig.detect`). */\n readonly detect?: ArtifactsConfig['detect'] | undefined;\n /**\n * Every artifact kind the chat's cards, the artifact panel and the file viewer resolve through, in order (the first match wins): an\n * `ArtifactKind` (match, label, icon, and a `renderer` or a lazily loaded `load`) or a document kind. The workspace knows no kind of its\n * own. The pi-app-kinds item's `defaultKinds({ locale, comments })` is the default list (maquette, diagram, mock-up, records, then\n * Markdown, HTML, SVG, CSV, image and PDF): pass it, put the app's own kinds before it (`[...mine, ...defaultKinds()]`) so they win, or\n * pass a list of your own. Build it once (`useMemo`): a new list re-resolves every open view.\n */\n readonly artifactKinds?: readonly PanelKind[] | undefined;\n /** Host viewers by artifact type or file kind, for example `{ canvas: props => <MyCanvas {...props} /> }`. */\n readonly viewers?: CustomViewers | undefined;\n /** Run HTML pages in the viewers' sandboxed preview, loading scripts only from these origins. Off without it. */\n readonly interactive?: InteractiveHtml | undefined;\n /** The viewer bar's Share action; it receives `target` `{ artifact, version }` or `{ file }`. */\n readonly share?: ViewerShare | undefined;\n /** Controlled panel content (with `onOpenedChange`); uncontrolled from `defaultOpened` otherwise. */\n readonly opened?: OpenedView | null | undefined;\n readonly defaultOpened?: OpenedView | null | undefined;\n readonly onOpenedChange?: ((next: OpenedView | null) => void) | undefined;\n /**\n * Whether the open file has unsaved or unconfirmed changes. The workspace asks before leaving such a file itself; a host that\n * switches the workspace or identity (a new `revisionProvider`) or changes a controlled `opened` should ask first too, since the\n * viewer of the previous file is replaced and its local draft discarded.\n */\n readonly onUnsavedChange?: ((unsaved: boolean) => void) | undefined;\n /** Renderers for host views, by `kind`. */\n readonly panels?: Readonly<Record<string, (view: HostView, panel: WorkspacePanelApi) => ReactNode>> | undefined;\n /** Open the panel on each artifact (or new version) the agent makes, unless the person closed it during this turn. Not on a phone. Default true. */\n readonly autoOpen?: boolean;\n /** A back button in a file's viewer bar (for example to the host's file list). */\n readonly fileBack?: { readonly label: string; readonly onBack: () => void } | undefined;\n /** With `floatBelow`: the floating chat over the same session (for example `AmbientChat` of `pi-ambient` with `onDock={dock}`). */\n readonly floatingChat?: ((props: PiChatProps, dock: () => void) => ReactNode) | undefined;\n /** Shown above the chat (notices). */\n readonly chatTop?: ReactNode;\n /** The chat header's controls; a function receives whether the panel is open. */\n readonly controls?: ReactNode | ((state: { readonly panelOpen: boolean }) => ReactNode);\n /** Prefix of the layout keys (panel width and floating chat for the session; whether the sessions column is open, per viewer). */\n readonly storageKey?: string;\n /** Below this width of the workspace the artifact panel is a full-screen sheet. */\n readonly sheetBelow?: number;\n /** Below this width of the workspace the sessions pane is a drawer. */\n readonly drawerBelow?: number;\n readonly floatBelow?: number | undefined;\n readonly className?: string;\n}\n\n/** The chat's attachment rules. `inlineImages` (default false) also sends a png, jpeg, gif or webp as a native image in the message: every later turn then carries its base64, so it is off by default. `maxBytes` refuses a larger file before it is uploaded (no limit by default), and the chat shows why. `accept` is the file picker's `accept` (any file by default). */\nexport interface WorkspaceUploads {\n readonly inlineImages?: boolean | undefined;\n readonly maxBytes?: number | undefined;\n readonly accept?: string | undefined;\n}\n\n/** A byte count for a refusal message: `2.5 MB`, `300 KB`, or `12 bytes`. */\nconst sizeLabel = (bytes: number) => bytes >= 1_000_000 ? `${Number((bytes / 1_000_000).toFixed(1))} MB` : bytes >= 1_000 ? `${Math.round(bytes / 1_000)} KB` : `${bytes} bytes`;\n\nexport type AgentWorkspaceProps = AgentWorkspaceBaseProps & (\n | { readonly revisionProvider: RevisionProvider; readonly resources?: WorkspaceBlobs | undefined }\n | { readonly resources: WorkspaceResources; readonly revisionProvider?: never }\n);\n\n// The Library column is docked only while the chat and the viewer (their `ArtifactWorkspace` minimums) keep their room beside it; otherwise it is Dock's sheet.\nconst SESSIONS_WIDTH = 260, LIBRARY_WIDTH = 320, CHAT_MIN = 360, VIEWER_MIN = 360;\nconst defaultLocate = (path: string): ResourceLocator => ({ resource: { providerId: 'workspace', path: workspacePath(path) }, view: { kind: 'published' } });\n\n/**\n * A complete agent page in one component: the sessions pane on the left (search, New, rename, archive, delete; collapsible, a drawer on\n * a narrow screen), the chat in the center (`PiChat`) and the artifact viewers on the right (`ArtifactWorkspace` with the artifact panel and\n * its versions, the file viewer and the host's own views). Agent artifacts open the panel as they appear. Every prop is data or a callback:\n * the host owns the routes, authentication, the controller and what is open (when controlled).\n */\nexport function AgentWorkspace({ layout = 'default', barPanels, panelWidth, title, controller, conversationId, chat = {}, labels, icons, panelActions, connecting, conversations, sessions, history: historyPlacement = 'column', resources, revisionProvider, libraryPlacement = 'panel', locale, fileTree: treeOptions, uploads, agents, detect, artifactKinds, viewers, interactive, share, opened: controlled,\n defaultOpened = null, onOpenedChange, onUnsavedChange, panels, autoOpen = true, fileBack, floatingChat, chatTop, controls, storageKey = 'boring.agent-workspace', sheetBelow = PHONE_BELOW, drawerBelow = PHONE_BELOW, floatBelow, className }: AgentWorkspaceProps) {\n const [own, setOwn] = useState<OpenedView | null>(defaultOpened);\n const [panelLibrary, setPanelLibrary] = useState(false);\n const opened = controlled !== undefined ? controlled : own;\n // eslint-disable-next-line react-hooks/refs -- latest-value ref assigned in render, read only by event handlers, effects and async callbacks\n const change = useRef(onOpenedChange); change.current = onOpenedChange;\n const isControlled = controlled !== undefined;\n const unsaved = useRef(false);\n // eslint-disable-next-line react-hooks/refs -- latest-value ref assigned in render, read only by event handlers, effects and async callbacks\n const currentView = useRef(opened); currentView.current = opened;\n // eslint-disable-next-line react-hooks/refs -- latest-value ref assigned in render, read only by event handlers, effects and async callbacks\n const unsavedChange = useRef(onUnsavedChange); unsavedChange.current = onUnsavedChange;\n const noteUnsaved = useCallback((value: boolean) => { if (unsaved.current !== value) { unsaved.current = value; unsavedChange.current?.(value); } }, []);\n const beforeLeave = useRef<(() => Promise<boolean>) | null>(null);\n const registerBeforeLeave = useCallback((flush: (() => Promise<boolean>) | null) => { beforeLeave.current = flush; }, []);\n const leaving = useRef(false);\n const guard = useCallback(async () => {\n if (leaving.current) return false;\n if (!unsaved.current) return true;\n const current = currentView.current;\n leaving.current = true;\n try {\n const saved = await beforeLeave.current?.();\n if (currentView.current !== current) return false;\n return saved === true || !unsaved.current || Boolean(globalThis.confirm?.('This file has unsaved or unconfirmed changes. Discard the local draft and leave this file?'));\n } finally { leaving.current = false; }\n }, []);\n const setOpened = useCallback(async (next: OpenedView | null): Promise<boolean> => {\n const current = currentView.current;\n if (isFile(current) && isFile(next) && current.path === next.path) { setPanelLibrary(false); return true; }\n if (!await guard()) return false;\n noteUnsaved(false);\n setPanelLibrary(false);\n if (!isControlled) setOwn(next); change.current?.(next);\n return true;\n }, [guard, isControlled, noteUnsaved]);\n const [fullscreen, setFullscreen] = useState(false);\n const [centerMode, setCenterMode] = useState<'chat' | 'library'>('chat');\n const [sideOpen, setSideOpenState] = useState(false);\n // The Library's real docked width, reported by its Dock: the room it needs beside the chat and the viewer.\n const [libraryWidth, setLibraryWidth] = useState(LIBRARY_WIDTH);\n // Opening the column leaves a full-screen viewer, whose overlay would otherwise cover it.\n const setSideOpen = useCallback((next: boolean) => { setSideOpenState(next); if (next) setFullscreen(false); }, []);\n const openLibrary = useCallback(async () => {\n if (libraryPlacement === 'panel') {\n if (!await guard()) return;\n noteUnsaved(false); setPanelLibrary(value => !value);\n } else setCenterMode('library');\n setFullscreen(false);\n }, [guard, libraryPlacement, noteUnsaved]);\n const text = useMemo(() => ({ labels: withDefaults(locale === 'fr' ? frenchAppLabels : defaultAppLabels, labels), icons: withDefaults(defaultAppIcons, icons) }), [labels, icons, locale]);\n // The sessions pane shows the chat's conversation list, so it reads the chat's labels too.\n const chatText = useMergedText(chat.labels, chat.icons, chatTextFor(locale));\n\n // With a revision provider, `resources` carries only the blob options; without one it is the resource handler too.\n const remote = revisionProvider ? undefined : resources as WorkspaceResources;\n // eslint-disable-next-line react-hooks/refs -- the host's latest fetch, read when a request goes out; clients built once keep calling the current one\n const fetcher = useRef(remote?.fetch); fetcher.current = remote?.fetch;\n const endpoint = remote ? String(remote.endpoint) : undefined;\n const historyEndpoint = remote?.history === undefined ? undefined : String(remote.history);\n const identity = revisionProvider?.identity ?? remote!.identity;\n const blobBase = checkBlobBase(resources?.blobBase);\n const hostUploadBlob = resources?.uploadBlob;\n const appLabels = text.labels;\n // The host's pointer is checked with the same validator the host uses; an invalid one is a refusal the Library shows.\n const uploadBlob = useMemo(() => hostUploadBlob && (async (file: File, options: Parameters<NonNullable<WorkspaceBlobs['uploadBlob']>>[1]) => {\n const pointer = parseBlobPointer(JSON.stringify(await hostUploadBlob(file, options) ?? null));\n if (pointer === undefined || 'kind' in pointer) throw { error: { code: 'invalid_pointer', message: appLabels.invalidBlobPointer(file.name) } };\n return pointer;\n }), [hostUploadBlob, appLabels]);\n const identityKey = JSON.stringify(identity);\n const history = useMemo(() => revisionProvider ? revisionProvider.history : historyEndpoint === undefined ? undefined : async (path: string): Promise<readonly SavedRevision[]> => {\n const url = new URL(historyEndpoint, globalThis.location?.href);\n url.searchParams.set('path', path);\n const response = await fetcher.current!(new Request(url));\n if (!response.ok) throw new Error(`History: ${response.status}`);\n return (await response.json() as { saves: readonly SavedRevision[] }).saves;\n }, [revisionProvider, historyEndpoint]);\n // Resources only: a Library over the endpoint's own listing, when it answers the revision protocol's discovery for the same\n // person (`createRevisionHandler` serves the resource calls and the listing on one route). A plain resource handler refuses: no Library.\n const [listing, setListing] = useState<{ readonly key: string; readonly provider: RevisionProvider }>();\n const listingKey = revisionProvider || endpoint === undefined ? undefined : JSON.stringify([endpoint, identity]);\n useEffect(() => {\n if (listingKey === undefined) return;\n const abort = new AbortController();\n connectRevisionProvider({ endpoint: new URL(endpoint!, globalThis.location?.href), fetch: request => fetcher.current!(request), signal: abort.signal }).then(provider => {\n const { principalId, scopeId, initiatorId } = provider.identity;\n if (!abort.signal.aborted && principalId === identity.principalId && scopeId === identity.scopeId && initiatorId === identity.initiatorId) setListing({ key: listingKey, provider });\n }, () => { /* no listing on this endpoint */ });\n return () => abort.abort();\n }, [listingKey]); // eslint-disable-line react-hooks/exhaustive-deps\n // The Library's binding: the host's revision provider, or the listing above with the versions of `resources.history`.\n // eslint-disable-next-line react-hooks/refs -- the functions built here read refs only when a request, an event or an effect calls them, never during render\n const libraryProvider = useMemo(() => revisionProvider ?? (listing && listing.key === listingKey ? { ...listing.provider, history: history ?? listing.provider.history } : undefined),\n [revisionProvider, listing, listingKey, history]);\n // The viewers' client: the Library's binding when there is one (its calls carry the binding the revision protocol checks), else the plain resource client.\n // With `resources`, the binding is discovered after the first render on the same endpoint for the same person: it takes over the calls of\n // one stable client, so a viewer already open keeps its bytes and its editor instead of reloading when the binding arrives.\n const discovered = useRef(libraryProvider);\n // eslint-disable-next-line react-hooks/refs -- latest-value ref: a viewer may read through the client during its own render or layout effect, before this component's effects run\n discovered.current = libraryProvider;\n const client = useMemo((): ResourceClient => {\n if (revisionProvider) return revisionProvider;\n // eslint-disable-next-line react-hooks/refs -- the client's functions read refs only when a request calls them, never during render\n const plain = createResourceClient({ identity, endpoint: new URL(endpoint!, globalThis.location?.href), publication: true, reconciliation: true, fetch: request => fetcher.current!(request) });\n const current = (): ResourceClient => discovered.current ?? plain;\n return {\n read: (request, signal) => current().read(request, signal),\n publish: (request, signal) => current().publish!(request, signal),\n lookup: (operationId, signal) => current().lookup!(operationId, signal),\n rename: (request, signal) => current().rename!(request, signal),\n delete: (request, signal) => current().delete!(request, signal),\n };\n }, [revisionProvider, endpoint, identityKey]); // eslint-disable-line react-hooks/exhaustive-deps\n const [imagePreview, setImagePreview] = useState<{ readonly images: readonly ImageContent[]; readonly index: number; readonly controller: typeof controller; readonly client: typeof client; readonly conversationId: typeof conversationId }>();\n const openImage = useCallback((image: ImageContent, images: readonly ImageContent[] = [image]) => {\n const index = images.indexOf(image);\n if (index >= 0 && images.every(item => thumbnail(item.mimeType, item.data))) setImagePreview({ images: images.slice(), index, controller, client, conversationId });\n }, [controller, client, conversationId]);\n // eslint-disable-next-line react-hooks/refs -- the functions built here read refs only when a request, an event or an effect calls them, never during render\n const preview = imagePreview && !chat.onOpenImage && imagePreview.controller === controller && imagePreview.client === client && imagePreview.conversationId === conversationId ? imagePreview : undefined;\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a new controller, client, conversation or host image handler drops the preview, once per change\n useEffect(() => { setImagePreview(undefined); }, [controller, client, conversationId, chat.onOpenImage]);\n const active = isArtifact(opened) && opened.conversation === conversationId ? opened : null;\n const follow = useArtifactFollow({ controller, conversationId, detect, active, autoOpen,\n onOpen: selection => { if (conversationId !== undefined) void setOpened({ kind: 'artifact', conversation: conversationId, ...selection }); },\n unsaved: () => unsaved.current,\n narrow: () => globalThis.matchMedia?.(`(width < ${sheetBelow}px)`).matches === true,\n showing: path => showsPath(currentView.current, path),\n openPresent: path => { if (unsaved.current) return false; void setOpened({ kind: 'file', path }); return true; },\n answer: chat.actions?.answer });\n const { onShown, onFailed } = follow;\n const kinds = artifactKinds ?? noKinds;\n const options: ViewerOptions = useMemo(() => ({ client, identity, history, viewers, kinds, interactive, share, onShown, onFailed, onUnsavedChange: noteUnsaved, onBeforeLeaveChange: registerBeforeLeave, blobBase }), [client, identityKey, history, viewers, kinds, interactive, share, onShown, onFailed, noteUnsaved, registerBeforeLeave, blobBase]); // eslint-disable-line react-hooks/exhaustive-deps\n const locate = revisionProvider?.locate ?? remote?.locate ?? defaultLocate;\n const readOnlyLibrary = !revisionProvider;\n const [fileBinding, setFileBinding] = useState<{ readonly provider: RevisionProvider; readonly tree: FileTreeController }>();\n useEffect(() => {\n if (!libraryProvider) return;\n // The Library reads its listings through `withBlobPointers`: each pointer file is resolved there, once, to the file it stands for.\n const tree = createFileTreeController({ revisionProvider: withBlobPointers(libraryProvider, blobBase) });\n // eslint-disable-next-line react-hooks/set-state-in-effect -- this effect owns the disposable tree (created and disposed here, StrictMode-safe); state only publishes it\n setFileBinding({ provider: libraryProvider, tree });\n void tree.refresh();\n return () => tree.dispose();\n }, [libraryProvider, blobBase]);\n // eslint-disable-next-line react-hooks/refs -- the functions built here read refs only when a request, an event or an effect calls them, never during render\n const libraryController = fileBinding?.provider === libraryProvider ? fileBinding?.tree : undefined;\n // Uploads and the upload notice belong to a revision provider only; the resources Library is read-only.\n const fileTree = revisionProvider ? libraryController : undefined;\n const embedded = layout === 'embedded';\n // Where the Library opens (`libraryPlacement`); embedded, it is the bar's Library button instead.\n // eslint-disable-next-line react-hooks/refs -- the functions built here read refs only when a request, an event or an effect calls them, never during render\n const placed = Boolean(libraryProvider) && !embedded;\n const libraryInPane = placed && libraryPlacement === 'pane';\n const librarySide = placed && libraryPlacement === 'side';\n const libraryOpen = placed && libraryPlacement === 'center' && centerMode === 'library';\n // The workspace's change feed: every file view below reloads when its seq moves (agent writes, saves, shell changes).\n const feed = useChangeFeed(libraryProvider);\n // The shared tree controller reloads, shown or not: the Library may be on its other tab when a file arrives, and mounting the tree\n // later must not show the listing from before. `fileTree.refreshKey` (deprecated) does the same for a provider without a feed.\n const { refreshKey, ...viewOptions } = treeOptions ?? {};\n const refreshedKey = useRef(refreshKey);\n const refreshedSeq = useRef(feed.seq);\n useEffect(() => {\n if (!libraryController || (Object.is(refreshedKey.current, refreshKey) && refreshedSeq.current === feed.seq)) return;\n refreshedKey.current = refreshKey;\n refreshedSeq.current = feed.seq;\n const { query, expanded } = libraryController.getSnapshot();\n if (query) void libraryController.search(query);\n else for (const directory of ['', ...expanded]) void libraryController.refresh(directory);\n }, [libraryController, refreshKey, feed.seq]);\n const openFile = useCallback((path: string) => { setOpened({ kind: 'file', path }); }, [setOpened]);\n // The file on show was renamed, moved or deleted from the Library: follow it to its new path, or close it (its old path is gone).\n const followFile = (from: string, to: string | null) => {\n const current = currentView.current;\n if (!isFile(current) || current.path !== from) return;\n const next = to === null ? null : { kind: 'file' as const, path: to };\n noteUnsaved(false);\n if (!isControlled) setOwn(next); change.current?.(next);\n };\n // @ search: results are reused while the feed reports nothing new (typing back and forth), and dropped at the next change event.\n // One cache per provider instance (one workspace binding), so another binding never sees these results.\n // eslint-disable-next-line react-hooks/exhaustive-deps -- one mention cache per provider instance: revisionProvider is the cache's identity, not an input\n const mentionResults = useMemo(() => ({ seq: undefined as number | undefined, results: new Map<string, readonly FileEntry[]>() }), [revisionProvider]);\n // eslint-disable-next-line react-hooks/refs -- latest-value ref assigned in render, read only by event handlers, effects and async callbacks\n const feedState = useRef(feed); feedState.current = feed;\n const mentions = useMemo(() => revisionProvider ? {\n search: async (query: string, signal: AbortSignal) => {\n const { live, seq } = feedState.current;\n const cache = mentionResults;\n // eslint-disable-next-line react-hooks/immutability -- the mention cache is this binding's own mutable holder, reset when the feed reports a change\n if (cache.seq !== seq || !live) { cache.seq = seq; cache.results = new Map(); }\n const known = live ? cache.results.get(query) : undefined;\n if (known) return known;\n const results = cache.results;\n const entries = (await revisionProvider.search({ query, limit: 8 }, signal)).entries;\n // Kept only when no change arrived meanwhile.\n if (live && cache.results === results && cache.seq === feedState.current.seq) results.set(query, entries);\n return entries;\n },\n open: openFile,\n // Thumbnails and voice playback read the original upload and MIME through the same provider as the file viewer.\n preview: async (path: string, signal: AbortSignal) => {\n const read = await revisionProvider.read({ target: revisionProvider.locate(path), revision: { kind: 'latest' } }, signal);\n return read.kind === 'available' ? { mimeType: read.snapshot.mediaType, data: imageData(read.snapshot.bytes) } : undefined;\n },\n } : undefined, [revisionProvider, openFile, mentionResults]);\n // The search function changes only with the binding; the feed's seq rides along as the picker's generation.\n // eslint-disable-next-line react-hooks/refs -- the functions built here read refs only when a request, an event or an effect calls them, never during render\n const mentionsForChat = useMemo(() => mentions && { ...mentions, generation: feed.seq }, [mentions, feed.seq]);\n const [attachmentFailure, setAttachmentFailure] = useState<{ readonly tree: FileTreeController; readonly reasons: readonly string[] }>();\n // Primitives only in the memo: a host's inline `labels` object would otherwise rebuild the chat's attachments on every render.\n const uploadAccept = uploads?.accept, inlineImages = uploads?.inlineImages === true, maxBytes = uploads?.maxBytes;\n const tooLarge = maxBytes !== undefined ? text.labels.attachmentTooLarge(sizeLabel(maxBytes)) : undefined;\n const revisionAttachments = useRevisionAttachments({ fileTree, accept: uploadAccept, inlineImages, maxBytes, tooLarge, onPreparationFailure: (name, reason) => {\n if (fileTree && fileTree.getSnapshot().lifecycle === 'active') setAttachmentFailure(current => ({ tree: fileTree,\n reasons: [...(current?.tree === fileTree ? current.reasons : []), `${name}: ${reason}`] }));\n } });\n const attachments = useMemo(() => revisionAttachments && { ...revisionAttachments, upload: (files: File[], signal: AbortSignal) => {\n setAttachmentFailure(undefined);\n return revisionAttachments.upload(files, signal);\n } }, [revisionAttachments]);\n\n // ---- Artifacts: cards open the panel; the agent's new ones open it too.\n const file = isFile(opened) ? opened : null;\n const host = opened && !isArtifact(opened) && !isFile(opened) && panels?.[opened.kind] ? opened : null;\n const panelOpen = Boolean(active || file || host || panelLibrary);\n // Record a close only after the host's unsaved-changes guard accepted it.\n const close = async () => { if (panelLibrary) { setPanelLibrary(false); return true; } if (!await setOpened(null)) return false; follow.recordClosed(); setFullscreen(false); return true; };\n const newestOf = (key: string) => follow.versions.filter(version => artifactKey(version) === key);\n const artifacts: ArtifactsConfig = useMemo(() => ({\n open: follow.open, isOpen: follow.isOpen, ...(detect ? { detect } : {}), kinds,\n }), [follow.open, follow.isOpen, detect, kinds]);\n\n // ---- Sessions: docked on a wide workspace (collapsible, remembered for the session), a drawer on a narrow one.\n const root = useRef<HTMLDivElement>(null);\n const [width, setWidth] = useState(0);\n useLayoutEffect(() => {\n const element = root.current;\n if (!element) return;\n const measure = () => setWidth(element.getBoundingClientRect().width);\n measure();\n if (typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n const narrow = width > 0 && width < drawerBelow;\n const [paneOpen, setPaneOpen] = useHistoryOpen(`${storageKey}.history-column`, true);\n const paneId = `${storageKey}-sessions`;\n const [drawer, setDrawer] = useState(false);\n const [paneView, setPaneView] = useState<SessionsView>(() => readFlag(`${storageKey}.library`) ? 'library' : 'conversations');\n const changePaneView = useCallback((next: SessionsView) => { writeFlag(`${storageKey}.library`, next === 'library'); setPaneView(next); }, [storageKey]);\n // The upload notice's \"Open Library\": the center Library, or the pane's Library tab with the pane shown.\n const revealLibrary = () => {\n if (librarySide) { setSideOpen(true); return; }\n if (!libraryInPane) { openLibrary(); return; }\n changePaneView('library');\n if (narrow) setDrawer(true); else setPaneOpen(true);\n };\n // eslint-disable-next-line react-hooks/set-state-in-effect -- widening past the drawer breakpoint closes the drawer, once per change\n useEffect(() => { if (!narrow) setDrawer(false); }, [narrow]);\n const toggleSide = useCallback(() => setSideOpen(!sideOpen), [setSideOpen, sideOpen]);\n // The Library tree, one view for the side column, the center and the pane; `onOpen` is what choosing a file does there.\n const libraryTree = (onOpen: (path: string) => void, onClose?: () => void) => libraryController\n ? <FileTreeView variant=\"list\" refresh={libraryPlacement !== 'panel'} title={text.labels.library} {...(onClose ? { onClose } : {})} key={JSON.stringify([libraryProvider?.workspace, libraryProvider?.identity])} {...(locale ? { locale } : {})} {...viewOptions}\n {...(readOnlyLibrary ? { upload: false, history: history !== undefined } : {})} {...(maxBytes !== undefined ? { maxBytes, ...(tooLarge ? { tooLarge } : {}) } : {})} {...(uploadBlob ? { uploadBlob } : {})} controller={libraryController} historyKey={feed.seq} onOpen={onOpen} onRenamed={(from, to) => followFile(from, to)} onDeleted={path => followFile(path, null)} {...(isFile(opened) ? { selectedPath: opened.path } : {})} />\n : <p role=\"status\" className=\"m-0 p-3 text-sm text-muted-foreground\">{treeOptions?.labels?.loading ?? text.labels.loading}</p>;\n // `history=\"popover\"` keeps the conversations out of the pane. With an open panel the column steps aside unless column, panel and chat fit.\n const paneConversations = historyPlacement === 'column' ? conversations : undefined;\n const hasNavigation = !embedded && sessions !== false && Boolean(paneConversations || placed);\n const stepAside = Boolean(paneConversations) && hasNavigation && !narrow && panelOpen && !historyColumnFits(width, VIEWER_MIN);\n const docked = hasNavigation && width > 0 && !narrow && paneOpen && !stepAside;\n const popoverHistory = historyPlacement === 'popover' || stepAside;\n const toggle = hasNavigation && !docked && !drawer && !stepAside && <SessionsToggle navigation={placed} open={narrow ? drawer : paneOpen} drawer={narrow} controls={narrow ? undefined : paneId}\n onToggle={() => { if (narrow) setDrawer(value => !value); else { setPaneOpen(!paneOpen); if (!paneOpen) focusHistoryRow(paneId); } }} />;\n\n // ---- The docked chat's header is replaced on every switch and connect (the `connecting` row, then a new `PiChat`). The toggle is not\n // part of it: it stays mounted in the shell, over an invisible space of its size in whichever header is showing, and follows that\n // header's height. A press that starts before the swap ends on the same button and still clicks (a button that leaves the document\n // between press and release loses the click, even when moved rather than recreated).\n const toggleRow = useRef<HTMLDivElement>(null);\n const followHeader = useRef<ResizeObserver | undefined>(undefined);\n const headerSlot = useCallback((space: HTMLElement | null) => {\n followHeader.current?.disconnect(); followHeader.current = undefined;\n const header = space?.parentElement;\n if (!header || typeof ResizeObserver === 'undefined') return;\n followHeader.current = new ResizeObserver(() => { if (toggleRow.current) toggleRow.current.style.height = `${header.clientHeight}px`; });\n followHeader.current.observe(header);\n }, []);\n const toggleSpace = toggle && <Button ref={headerSlot} variant=\"ghost\" size=\"icon-sm\" aria-hidden=\"true\" tabIndex={-1} className=\"invisible md:-ml-1\" />;\n\n // ---- The chat in the center: docked PiChat, or the host's floating surface over the same controller (with the toggle in its header).\n const hostControls = typeof controls === 'function' ? controls({ panelOpen }) : controls ?? chat.controls;\n const header = <>{conversations?.onNew && <Button variant=\"ghost\" size=\"icon-sm\" data-testid=\"workspace-new-chat\" aria-label={text.labels.newChat} title={text.labels.newChat} className=\"md:hidden\" onClick={conversations.onNew}><text.icons.newChat data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}{hostControls}</>;\n const conversationTitle = conversations?.items.find(item => item.id === conversationId)?.title;\n // pi-app ships every composer feature item: Markdown, the `/` menu, `@` mentions, the model pill, voice messages, the phone camera.\n // eslint-disable-next-line react-hooks/refs -- the functions built here read refs only when a request, an event or an effect calls them, never during render\n const chatProps: PiChatProps | undefined = controller && withChatPlugins({ ...(mentionsForChat ? { mentions: mentionsForChat } : {}), ...(attachments ? { attachments } : {}), ...(locale ? { locale } : {}), ...chat, controller, artifacts, onOpenFile: chat.onOpenFile ?? chat.mentions?.open ?? openFile, onOpenImage: chat.onOpenImage ?? openImage, ...(conversations ? { conversations, historyList: popoverHistory, showHistory: chat.showHistory ?? popoverHistory, conversationTitle, onRenameTitle: conversations.rename && conversationId ? (title: string) => conversations.rename!(conversationId, title) : undefined } : {}),\n headerStart: <>{toggle}{chat.headerStart}</>, ...(header === undefined ? {} : { controls: header }),\n // Embedded: the bar above replaces the chat's header (History, New and the host's controls are in it); conversations stay for Fork.\n ...(embedded ? { header: false, historyList: false, showHistory: false } : {}) });\n const bar = embedded && <WorkspaceBar title={title ?? conversationTitle ?? chatText.labels.title} {...(title && conversationTitle ? { subtitle: conversationTitle } : {})} conversations={conversations} narrow={narrow}\n library={libraryController ? onPicked => libraryTree(path => { openFile(path); onPicked(); }, onPicked) : undefined} panels={barPanels}\n controls={<><BlockActions actions={chat.headerActions} testId=\"chat-action\" menuLabel={chatText.labels.moreActions} />{hostControls}</>} />;\n const docked_chat = <div data-testid=\"workspace-center\" className=\"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden\">\n {bar}\n {attachmentFailure?.tree === fileTree && attachmentFailure?.reasons.map((reason, index) => <p key={index} role=\"alert\" className=\"m-0 px-4 py-2 text-sm\">{reason}</p>)}\n {fileTree && <UploadNotice controller={fileTree} onOpen={revealLibrary} />}\n {chatTop}\n <div className=\"relative flex min-h-0 flex-1 flex-col\">\n {chatProps ? <PiChat key={conversationId} {...chatProps} headerStart={<>{toggleSpace}{chat.headerStart}</>} className={cn('min-h-0 flex-1', chat.className)} />\n // Same geometry as the PiChat header, so nothing moves when the chat arrives.\n : <>{toggle && <><div className=\"flex shrink-0 items-center gap-2 px-3 py-2 md:gap-3 md:px-4 md:py-2.5\">{toggleSpace}</div><Separator /></>}{connecting}</>}\n {toggle && <div ref={toggleRow} className=\"pointer-events-none absolute top-0 left-0 z-10 flex items-center px-3 md:px-4 [&>*]:pointer-events-auto\">{toggle}</div>}\n </div>\n </div>;\n\n const kind = active ? 'artifact' : file ? 'file' : host?.kind;\n const shown = active ?? file ?? host;\n const actionsFor = (floatChat: (() => void) | undefined): readonly BlockAction[] => [\n ...(placed && libraryPlacement === 'panel' ? [{ id: 'library', label: text.labels.library, icon: text.icons.library, placement: 'menu' as const, onSelect: openLibrary }] : []),\n ...(librarySide ? [{ id: 'library', label: text.labels.library, icon: text.icons.library, pressed: sideOpen, onSelect: toggleSide }] : []),\n ...(floatChat ? [{ id: 'float-chat', label: text.labels.floatChat, icon: text.icons.floatChat, placement: 'menu' as const, onSelect: floatChat }] : []),\n ...(shown ? typeof panelActions === 'function' ? panelActions(shown) : panelActions ?? [] : []),\n ];\n const sessionProps: ComponentProps<typeof SessionsPane> = { id: paneId, conversations: paneConversations, drawer: narrow, onClose: () => setDrawer(false), onChat: () => setCenterMode('chat'), onCollapse: () => setPaneOpen(false), title: labels?.sessionsTitle,\n agents, view: paneView, onViewChange: changePaneView,\n ...(librarySide ? { library: { selected: sideOpen, onSelect: toggleSide, side: true } } : placed && !libraryInPane ? { library: { selected: libraryOpen || panelLibrary, onSelect: openLibrary } } : {}),\n ...(libraryInPane ? { libraryContent: (onPicked: () => void) => libraryTree(path => { openFile(path); onPicked(); }) } : {}),\n };\n return <AppTextProvider value={text}><ChatTextProvider value={chatText}><div ref={root} data-boring=\"agent-workspace\" data-layout={layout} data-sessions={!hasNavigation ? undefined : narrow ? (drawer ? 'drawer' : 'closed') : docked ? 'docked' : 'hidden'}\n className={cn('pi-chat relative flex h-full min-h-0 min-w-0 flex-1 overflow-hidden', className)}>\n <DockLayout storageKey={`${storageKey}.dock`} minMain={SESSIONS_WIDTH + 480}>\n {/* eslint-disable-next-line react-hooks/refs -- the functions built here read refs only when a request, an event or an effect calls them, never during render */}\n {hasNavigation && (docked || (narrow && drawer)) && (sessions ? sessions(sessionProps) : <SessionsPane {...sessionProps} />)}\n <DockMain><ArtifactWorkspace open={panelOpen && !(panelLibrary && narrow)} onClose={close} panelLabel={text.labels.artifactPanel} labels={{ resize: text.labels.resizePanel, floatHint: text.labels.floatHint }} fullscreen={fullscreen} onFullscreenChange={setFullscreen} storageKey={`${storageKey}.panel-width`} {...(panelWidth === undefined ? {} : { defaultWidth: panelWidth })}\n sheetBelow={Math.max(CHAT_MIN + VIEWER_MIN, docked ? sheetBelow - SESSIONS_WIDTH : sheetBelow)} {...(floatBelow === undefined || libraryOpen ? {} : { floatBelow })}\n chat={layout => <WorkspaceCenter libraryOpen={libraryOpen} floating={layout.floating} chat={docked_chat}\n floatingChat={floatingChat && chatProps ? floatingChat(chatProps, layout.dock) : undefined}\n // With the Library in the pane, the center holds only the chat (no second, hidden toggle or tree).\n library={libraryInPane || librarySide || libraryPlacement === 'panel' ? null : <section data-testid=\"workspace-library-view\" aria-label={text.labels.library} className=\"flex min-h-0 flex-1 flex-col\">\n <div className=\"min-h-0 flex-1 overflow-auto\">{libraryTree(openFile, () => setCenterMode('chat'))}</div>\n </section>} />}\n panel={win => <ViewerWindowProvider value={{ fullscreen: win.fullscreen, onFullscreenChange: win.onFullscreenChange, actions: actionsFor(win.floatChat), labels: text.labels, icons: text.icons }}>\n <div data-testid=\"viewer-panel\" data-kind={kind} className=\"flex min-h-0 flex-1 flex-col overflow-hidden [&>*]:min-h-0 [&>*]:flex-1\">\n {panelLibrary ? <section data-testid=\"workspace-library-side\" aria-label={text.labels.library} className=\"flex min-h-0 flex-1 flex-col\"><div className=\"min-h-0 flex-1 overflow-auto\">{libraryTree(openFile, () => setPanelLibrary(false))}</div></section> : active\n ? <ArtifactPanel key={artifactKey(active.descriptor)} active={active} versions={newestOf(artifactKey(active.descriptor))} options={options} onClose={win.close}\n onSelect={value => {\n const known = newestOf(artifactKey(active.descriptor));\n setOpened({ kind: 'artifact', conversation: active.conversation, follow: value === 'latest', descriptor: value === 'latest' ? (known[0] ?? active.descriptor) : { ...active.descriptor, revision: value } });\n }} />\n : file ? <FileViewer key={JSON.stringify([revisionProvider?.workspace, identity, file.path])} onBeforeLeaveChange={registerBeforeLeave} onUnsavedChange={noteUnsaved} path={file.path} locator={locate(file.path)} options={options} onClose={win.close} {...(fileBack ? { onBack: fileBack.onBack, backLabel: fileBack.label } : {})} />\n : host ? panels![host.kind]!(host, win) : null}\n </div></ViewerWindowProvider>} /></DockMain>\n {librarySide && <Dock id=\"library\" side=\"right\" open={sideOpen} onOpenChange={setSideOpen} defaultWidth={LIBRARY_WIDTH} minWidth={240} narrowBelow={Math.max(drawerBelow, (docked ? SESSIONS_WIDTH : 0) + (panelOpen ? VIEWER_MIN + CHAT_MIN : CHAT_MIN) + libraryWidth)} narrow=\"sheet\"\n labels={{ region: text.labels.library, resize: text.labels.resizePanel }} >\n {api => <section data-testid=\"workspace-library-side\" aria-label={text.labels.library} className=\"flex min-h-0 flex-1 flex-col\">\n <WidthProbe width={api.width} onWidth={setLibraryWidth} />\n <div className=\"min-h-0 flex-1 overflow-auto\">{libraryTree(path => { void setOpened({ kind: 'file', path }).then(opened => { if (opened && narrow) setSideOpen(false); }); }, () => setSideOpen(false))}</div>\n </section>}\n </Dock>}\n </DockLayout>\n {preview && <ImageViewer images={preview.images} index={preview.index} labels={chatText.labels} testId=\"conversation-image\"\n onIndexChange={index => setImagePreview({ ...preview, index })} onClose={() => setImagePreview(undefined)} />}\n {/* eslint-disable-next-line react-hooks/refs -- the functions built here read refs only when a request, an event or an effect calls them, never during render */}\n {panelLibrary && narrow && <Drawer open showSwipeHandle onOpenChange={next => { if (!next) setPanelLibrary(false); }}><DrawerContent aria-label={text.labels.library} className=\"pi-chat min-h-[80dvh] max-h-[80dvh]\"><DrawerTitle className=\"sr-only\">{text.labels.library}</DrawerTitle><div data-testid=\"workspace-library-side\" className=\"min-h-0 flex-1 overflow-auto pb-[env(safe-area-inset-bottom)]\">{libraryTree(openFile, () => setPanelLibrary(false))}</div></DrawerContent></Drawer>}\n </div></ChatTextProvider></AppTextProvider>;\n}\n\nfunction WidthProbe({ width, onWidth }: { readonly width: number; readonly onWidth: (width: number) => void }) {\n useLayoutEffect(() => { onWidth(width); }, [width, onWidth]);\n return null;\n}\n\nfunction UploadNotice({ controller, onOpen }: { readonly controller: FileTreeController; readonly onOpen: () => void }) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const failures = state.uploads.filter(upload => upload.state.kind === 'settled' && upload.state.result.kind !== 'committed');\n return failures.length ? <p role=\"alert\" className=\"m-0 px-4 py-2 text-sm\">{failures.length} upload{failures.length === 1 ? '' : 's'} need attention. <Button variant=\"link\" onClick={onOpen}>Open Library</Button> to review the results and check unconfirmed uploads.</p> : null;\n}\n\n/** Keep the selected chat surface mounted while Library temporarily uses its layout space. */\nfunction WorkspaceCenter({ libraryOpen, floating, chat, floatingChat, library }: {\n readonly libraryOpen: boolean;\n readonly floating: boolean;\n readonly chat: ReactNode;\n readonly floatingChat: ReactNode;\n readonly library: ReactNode;\n}) {\n const lastFloating = useRef(floating);\n useLayoutEffect(() => { if (!libraryOpen) lastFloating.current = floating; }, [libraryOpen, floating]);\n // eslint-disable-next-line react-hooks/refs -- the floating choice is held while the Library covers the chat; the layout effect above keeps it current\n const showFloating = libraryOpen ? lastFloating.current : floating;\n return <div className=\"flex min-h-0 flex-1 flex-col\">\n <div data-testid=\"workspace-chat-surface\" hidden={libraryOpen} inert={libraryOpen} aria-hidden={libraryOpen || undefined} className={cn('min-h-0 flex-1 flex-col', libraryOpen ? 'hidden' : 'flex')}>\n {showFloating && floatingChat ? floatingChat : chat}\n </div>\n <div hidden={!libraryOpen} inert={!libraryOpen} aria-hidden={!libraryOpen || undefined} className={cn('min-h-0 flex-1 flex-col', libraryOpen ? 'flex' : 'hidden')}>{library}</div>\n </div>;\n}\n\n/**\n * A number that moves when the provider's files change (`RevisionProvider.changes`), coalesced over `settleMs` so a burst (a shell\n * writing many files) reloads once. `live` is false without a feed or after the feed was refused; views then keep their own refresh.\n * A hidden tab holds no feed: a browser allows few connections per host, and every tab of a share link holding a stream starves the\n * others (remote chat releases its stream the same way); `live` is false meanwhile. Shown again, it reconnects after the last event it\n * acted on (`since`; 0 when it had none, which the feed answers with a `resnapshot`), so a change made while hidden or while reconnecting\n * arrives on the new feed and moves the number then, after the feed is connected.\n */\nfunction useChangeFeed(provider: RevisionProvider | undefined, settleMs = 150): { readonly seq: number | undefined; readonly live: boolean } {\n const [state, setState] = useState<{ readonly provider: RevisionProvider | undefined; readonly seq: number | undefined; readonly live: boolean }>({ provider, seq: undefined, live: false });\n const shown = usePageShown();\n // The provider connected last, and the protocol seq of the last event the views reloaded for.\n const connected = useRef<{ readonly provider: RevisionProvider; seen: number | undefined } | undefined>(undefined);\n useEffect(() => {\n // eslint-disable-next-line react-hooks/set-state-in-effect -- the feed's state is reset for the new provider when this effect subscribes to it\n if (!provider?.changes) { setState({ provider, seq: undefined, live: false }); return; }\n if (!shown) { setState(current => current.provider === provider && current.live ? { ...current, live: false } : current); return; }\n const stop = new AbortController();\n let timer: ReturnType<typeof setTimeout> | undefined, latest: number | undefined;\n // A second connection to the same provider follows a hidden period: it resumes after the last event acted on. A first connection has nothing to catch up.\n const since = connected.current?.provider === provider ? connected.current.seen ?? 0 : undefined;\n const cursor = connected.current?.provider === provider ? connected.current : { provider, seen: undefined };\n connected.current = cursor;\n const move = () => { cursor.seen = latest; setState(current => ({ provider, seq: current.provider === provider ? (current.seq ?? 0) + 1 : 1, live: true })); };\n setState(current => ({ provider, seq: current.provider === provider ? current.seq : undefined, live: true }));\n void (async () => {\n try {\n for await (const event of provider.changes!({ ...(since === undefined ? {} : { since }), signal: stop.signal })) {\n latest = event.seq;\n // An event still settling when the feed closes is not acted on, so the next connection asks for it again.\n timer ??= setTimeout(() => { timer = undefined; if (!stop.signal.aborted) move(); }, settleMs);\n }\n } catch { /* refused: the views fall back to their own refresh */ }\n clearTimeout(timer);\n timer = undefined;\n if (!stop.signal.aborted) setState(current => ({ provider, seq: current.provider === provider ? current.seq : undefined, live: false }));\n })();\n return () => { stop.abort(); clearTimeout(timer); };\n }, [provider, settleMs, shown]);\n return state.provider === provider ? state : { seq: undefined, live: false };\n}\n\n/** Whether the page is shown (`document.visibilityState`); true where there is no document. */\nfunction usePageShown(): boolean {\n const [shown, setShown] = useState(() => typeof document === 'undefined' || document.visibilityState !== 'hidden');\n useEffect(() => {\n if (typeof document === 'undefined') return;\n const update = () => setShown(document.visibilityState !== 'hidden');\n update();\n document.addEventListener('visibilitychange', update);\n return () => document.removeEventListener('visibilitychange', update);\n }, []);\n return shown;\n}\n",
37
27
  "type": "registry:component",
38
28
  "target": "components/pi-app/agent-workspace.tsx"
39
29
  },
@@ -51,19 +41,19 @@
51
41
  },
52
42
  {
53
43
  "path": "registry/pi-app/workspace-bar.tsx",
54
- "content": "'use client';\n\n// The compact top bar of `AgentWorkspace layout=\"embedded\"`: the conversation title, then History, Library, the host's own panels and New\n// as icon buttons (each panel opens a popover on a wide workspace and a bottom sheet on a phone) and the host's controls. Inside an app-shell\n// screen the controls go into the shell's header instead (`useScreenHeaderSlot`), so the page keeps one row. Host concern: Pi has no layout.\nimport { useState } from 'react';\nimport type { ReactElement, ReactNode } from 'react';\nimport { createPortal } from 'react-dom';\nimport { XIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { Separator } from '@/components/ui/separator';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { ConversationList } from '../pi-chat/history';\nimport type { ConversationsConfig } from '../pi-chat/config';\nimport type { BlockIcon } from '../button/actions';\nimport { useScreenHeaderSlot } from '../utils/screen-header-slot';\nimport { cn } from '../utils/utils';\nimport { useAppText } from './app-labels';\n\n/** 44px on a phone (`narrow`), the bar's 32px otherwise. */\nconst iconSize = (narrow: boolean) => cn(narrow && 'size-11');\n\n/** A hover and focus tooltip on a wide workspace; a phone has no hover, and focus coming back from a sheet must not raise one. */\nfunction Hinted({ label, trigger, narrow }: { readonly label: string; readonly trigger: ReactElement; readonly narrow: boolean }) {\n if (narrow) return trigger;\n return <Tooltip><TooltipTrigger render={trigger} /><TooltipContent>{label}</TooltipContent></Tooltip>;\n}\n\n/** One icon button that opens `children`: an anchored popover on a wide workspace, a bottom sheet on a phone. */\nfunction BarPanel({ id, label, icon: Icon, narrow, open, onOpenChange, children }: {\n readonly id: string;\n readonly label: string;\n readonly icon: BlockIcon;\n readonly narrow: boolean;\n readonly open: boolean;\n readonly onOpenChange: (open: boolean) => void;\n readonly children: ReactNode;\n}) {\n const button = <Button variant=\"ghost\" size=\"icon\" data-testid={`workspace-bar-${id}`} aria-label={label} className={iconSize(narrow)}><Icon aria-hidden=\"true\" /></Button>;\n if (narrow) return <>\n <Button variant=\"ghost\" size=\"icon\" data-testid={`workspace-bar-${id}`} aria-label={label} aria-haspopup=\"dialog\" aria-expanded={open} className={iconSize(narrow)} onClick={() => onOpenChange(true)}><Icon aria-hidden=\"true\" /></Button>\n <Drawer open={open} onOpenChange={onOpenChange} showSwipeHandle>\n <DrawerContent data-testid={`workspace-${id}`} aria-label={label} className=\"pi-chat min-h-[50dvh] max-h-[85dvh]\">\n <DrawerTitle className=\"sr-only\">{label}</DrawerTitle>\n <div className=\"flex min-h-0 flex-1 flex-col pb-[env(safe-area-inset-bottom)]\">{children}</div>\n </DrawerContent>\n </Drawer>\n </>;\n return <Popover open={open} onOpenChange={onOpenChange}>\n <Hinted label={label} narrow={false} trigger={<PopoverTrigger render={button} />} />\n <PopoverContent data-testid={`workspace-${id}`} aria-label={label} align=\"end\" className=\"pi-chat w-[23rem] max-w-[calc(100vw-1rem)] max-h-[min(34rem,70dvh)]\">{children}</PopoverContent>\n </Popover>;\n}\n\n/** A host panel in the bar, beside History and Library: an icon button that opens `content` (`close` closes the popover or sheet). */\nexport interface WorkspaceBarPanel {\n readonly id: string;\n readonly label: string;\n readonly icon: BlockIcon;\n readonly content: (close: () => void) => ReactNode;\n}\n\nexport interface WorkspaceBarProps {\n readonly title: string;\n /** Muted after a fixed `title`: the conversation's title. Hidden on a phone, where History names the conversations. */\n readonly subtitle?: string | undefined;\n readonly conversations: ConversationsConfig | undefined;\n /** The Library's list (`onPicked` closes the popover or sheet), or undefined without a Library. */\n readonly library: ((onPicked: () => void) => ReactNode) | undefined;\n /** The host's own panels, after Library and before New (test ids `workspace-bar-<id>` and `workspace-<id>`). */\n readonly panels?: readonly WorkspaceBarPanel[] | undefined;\n /** A phone-sized workspace: sheets instead of popovers, 44px buttons. */\n readonly narrow: boolean;\n /** The host's own controls, after New. */\n readonly controls?: ReactNode;\n}\n\n/** The bar, or only its controls in the app shell's header when the page sits in a shell screen (then nothing renders here). */\nexport function WorkspaceBar({ title, subtitle, conversations, library, panels, narrow, controls }: WorkspaceBarProps) {\n const { labels, icons } = useAppText();\n const slot = useScreenHeaderSlot();\n const [shown, setShown] = useState<string | null>(null);\n const toggle = (which: string) => (open: boolean) => setShown(open ? which : current => current === which ? null : current);\n const close = () => setShown(null);\n const tools = <div data-testid=\"workspace-bar-tools\" className=\"flex shrink-0 items-center gap-0.5\">\n {conversations && <BarPanel id=\"history\" label={labels.history} icon={icons.history} narrow={narrow} open={shown === 'history'} onOpenChange={toggle('history')}>\n <header className=\"flex shrink-0 items-center gap-2 pl-1\">\n <h3 className=\"m-0 min-w-0 flex-1 truncate text-sm font-semibold\">{labels.history}</h3>\n <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.close} title={labels.close} className={iconSize(narrow)} onClick={close}><XIcon aria-hidden=\"true\" /></Button>\n </header>\n <Separator />\n <div className=\"flex min-h-0 flex-1 flex-col\"><ConversationList conversations={conversations} onPicked={close} /></div>\n </BarPanel>}\n {library && <BarPanel id=\"library\" label={labels.library} icon={icons.library} narrow={narrow} open={shown === 'library'} onOpenChange={toggle('library')}>\n <div className=\"min-h-0 flex-1 overflow-auto\">{library(close)}</div>\n </BarPanel>}\n {panels?.map(panel => <BarPanel key={panel.id} id={panel.id} label={panel.label} icon={panel.icon} narrow={narrow} open={shown === panel.id} onOpenChange={toggle(panel.id)}>\n {panel.content(close)}\n </BarPanel>)}\n {conversations?.onNew && <Hinted label={labels.newChat} narrow={narrow} trigger={<Button variant=\"ghost\" size=\"icon\" data-testid=\"workspace-bar-new\" aria-label={labels.newChat} className={iconSize(narrow)} onClick={() => conversations.onNew?.()}><icons.newChat aria-hidden=\"true\" /></Button>} />}\n {controls}\n </div>;\n if (slot) return createPortal(tools, slot);\n return <header data-testid=\"workspace-bar\" className=\"flex h-13 shrink-0 items-center gap-1 pr-2 pl-4 max-[640px]:h-14 max-[640px]:pl-3\">\n <div className=\"flex min-w-0 flex-1 items-baseline gap-2\">\n <h2 data-testid=\"workspace-bar-title\" className={cn('m-0 min-w-0 truncate text-sm font-medium tracking-tight', subtitle === undefined && 'flex-1')}>{title}</h2>\n {subtitle !== undefined && <span data-testid=\"workspace-bar-conversation\" className=\"min-w-0 flex-1 truncate text-sm text-muted-foreground max-sm:hidden\">{subtitle}</span>}\n </div>\n {tools}\n </header>;\n}\n",
44
+ "content": "'use client';\n\n// The compact top bar of `AgentWorkspace layout=\"embedded\"`: the conversation title, then History, Library, the host's own panels and New\n// as icon buttons (each panel opens a popover on a wide workspace and a bottom sheet on a phone) and the host's controls. Inside an app-shell\n// screen the controls go into the shell's header instead (`useScreenHeaderSlot`), so the page keeps one row. Host concern: Pi has no layout.\nimport { useState } from 'react';\nimport type { ReactElement, ReactNode } from 'react';\nimport { createPortal } from 'react-dom';\nimport { XIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { Separator } from '@/components/ui/separator';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { ConversationList } from '../pi-chat/history';\nimport type { ConversationsConfig } from '../pi-chat/config';\nimport type { BlockIcon } from '../button/actions';\nimport { useScreenHeaderSlot } from '../utils/screen-header-slot';\nimport { cn } from '../utils/utils';\nimport { useAppText } from './app-labels';\n\n/** 44px on a phone (`narrow`), the bar's 32px otherwise. */\nconst iconSize = (narrow: boolean) => cn(narrow && 'size-11');\n\n/** A hover and focus tooltip on a wide workspace; a phone has no hover, and focus coming back from a sheet must not raise one. */\nfunction Hinted({ label, trigger, narrow }: { readonly label: string; readonly trigger: ReactElement; readonly narrow: boolean }) {\n if (narrow) return trigger;\n return <Tooltip><TooltipTrigger render={trigger} /><TooltipContent>{label}</TooltipContent></Tooltip>;\n}\n\n/** One icon button that opens `children`: an anchored popover on a wide workspace, a bottom sheet on a phone. */\nfunction BarPanel({ id, label, icon: Icon, narrow, open, onOpenChange, children }: {\n readonly id: string;\n readonly label: string;\n readonly icon: BlockIcon;\n readonly narrow: boolean;\n readonly open: boolean;\n readonly onOpenChange: (open: boolean) => void;\n readonly children: ReactNode;\n}) {\n const button = <Button variant=\"ghost\" size=\"icon\" data-testid={`workspace-bar-${id}`} aria-label={label} className={iconSize(narrow)}><Icon aria-hidden=\"true\" /></Button>;\n if (narrow) return <>\n <Button variant=\"ghost\" size=\"icon\" data-testid={`workspace-bar-${id}`} aria-label={label} aria-haspopup=\"dialog\" aria-expanded={open} className={iconSize(narrow)} onClick={() => onOpenChange(true)}><Icon aria-hidden=\"true\" /></Button>\n <Drawer open={open} onOpenChange={onOpenChange} showSwipeHandle>\n <DrawerContent data-testid={`workspace-${id}`} aria-label={label} className=\"pi-chat min-h-[50dvh] max-h-[85dvh]\">\n <DrawerTitle className=\"sr-only\">{label}</DrawerTitle>\n <div className=\"flex min-h-0 flex-1 flex-col pb-[env(safe-area-inset-bottom)]\">{children}</div>\n </DrawerContent>\n </Drawer>\n </>;\n return <Popover open={open} onOpenChange={onOpenChange}>\n <Hinted label={label} narrow={false} trigger={<PopoverTrigger render={button} />} />\n <PopoverContent data-testid={`workspace-${id}`} aria-label={label} align=\"end\" className=\"pi-chat w-[23rem] max-w-[calc(100vw-1rem)] max-h-[min(34rem,70dvh)]\">{children}</PopoverContent>\n </Popover>;\n}\n\n/** A host panel in the bar, beside History and Library: an icon button that opens `content` (`close` closes the popover or sheet). */\nexport interface WorkspaceBarPanel {\n readonly id: string;\n readonly label: string;\n readonly icon: BlockIcon;\n readonly content: (close: () => void) => ReactNode;\n}\n\nexport interface WorkspaceBarProps {\n readonly title: string;\n /** Muted after a fixed `title`: the conversation's title. Hidden on a phone, where History names the conversations. */\n readonly subtitle?: string | undefined;\n readonly conversations: ConversationsConfig | undefined;\n /** The Library's list (`onPicked` closes the popover or sheet), or undefined without a Library. */\n readonly library: ((onPicked: () => void) => ReactNode) | undefined;\n /** The host's own panels, after Library and before New (test ids `workspace-bar-<id>` and `workspace-<id>`). */\n readonly panels?: readonly WorkspaceBarPanel[] | undefined;\n /** A phone-sized workspace: sheets instead of popovers, 44px buttons. */\n readonly narrow: boolean;\n /** The host's own controls, after New. */\n readonly controls?: ReactNode;\n}\n\n/** The bar, or only its controls in the app shell's header when the page sits in a shell screen (then nothing renders here). */\nexport function WorkspaceBar({ title, subtitle, conversations, library, panels, narrow, controls }: WorkspaceBarProps) {\n const { labels, icons } = useAppText();\n const slot = useScreenHeaderSlot();\n const [shown, setShown] = useState<string | null>(null);\n const toggle = (which: string) => (open: boolean) => setShown(open ? which : current => current === which ? null : current);\n const close = () => setShown(null);\n const tools = <div data-testid=\"workspace-bar-tools\" className=\"flex shrink-0 items-center gap-0.5\">\n {conversations && <BarPanel id=\"history\" label={labels.history} icon={icons.history} narrow={narrow} open={shown === 'history'} onOpenChange={toggle('history')}>\n <header className=\"flex shrink-0 items-center gap-2 pl-1\">\n <h3 className=\"m-0 min-w-0 flex-1 truncate text-sm font-semibold\">{labels.history}</h3>\n <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.close} title={labels.close} className={iconSize(narrow)} onClick={close}><XIcon aria-hidden=\"true\" /></Button>\n </header>\n <Separator />\n <div className=\"flex min-h-0 flex-1 flex-col\"><ConversationList conversations={conversations} onPicked={close} /></div>\n </BarPanel>}\n {library && <BarPanel id=\"library\" label={labels.library} icon={icons.library} narrow={narrow} open={shown === 'library'} onOpenChange={toggle('library')}>\n <div className=\"min-h-0 flex-1 overflow-auto\">{library(close)}</div>\n </BarPanel>}\n {panels?.map(panel => <BarPanel key={panel.id} id={panel.id} label={panel.label} icon={panel.icon} narrow={narrow} open={shown === panel.id} onOpenChange={toggle(panel.id)}>\n {panel.content(close)}\n </BarPanel>)}\n {conversations?.onNew && <Hinted label={labels.newChat} narrow={narrow} trigger={<Button variant=\"ghost\" size=\"icon\" data-testid=\"workspace-bar-new\" aria-label={labels.newChat} className={iconSize(narrow)} onClick={() => conversations.onNew?.()}><icons.newChat aria-hidden=\"true\" /></Button>} />}\n {controls}\n </div>;\n if (slot) return createPortal(tools, slot);\n return <header data-testid=\"workspace-bar\" className=\"flex h-13 shrink-0 items-center gap-1 pr-2 pl-4 max-md:h-14 max-md:pl-3\">\n <div className=\"flex min-w-0 flex-1 items-baseline gap-2\">\n <h2 data-testid=\"workspace-bar-title\" className={cn('m-0 min-w-0 truncate text-sm font-medium tracking-tight', subtitle === undefined && 'flex-1')}>{title}</h2>\n {subtitle !== undefined && <span data-testid=\"workspace-bar-conversation\" className=\"min-w-0 flex-1 truncate text-sm text-muted-foreground max-md:hidden\">{subtitle}</span>}\n </div>\n {tools}\n </header>;\n}\n",
55
45
  "type": "registry:component",
56
46
  "target": "components/pi-app/workspace-bar.tsx"
57
47
  },
58
48
  {
59
49
  "path": "registry/pi-app/artifact-panel.tsx",
60
- "content": "'use client';\n\n// The artifact panel: the standard viewer bar (title, type, version switcher, Refresh, Share, copy, download, close) over the viewer for\n// the artifact's type. It follows the latest saved revision while nothing is unsaved (\"Latest\") or pins one older version, read-only. It\n// lists the versions the host's workspace provider retains for the file (`history`) and reads through the host's resource client. It never\n// executes artifact content (HTML runs only in the viewers' sandboxed preview when the host passes `interactive`).\nimport { Component, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { randomUUID } from '@hachej/boring-files/platform';\nimport type { ResourceClient, ResourceSnapshot } from '@hachej/boring-files';\nimport type { ResourceIdentity } from '@hachej/boring-files/remote';\nimport type { SavedRevision as FileRevision } from '@hachej/boring-files/revision';\nimport { createMarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport type { MarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport { createHtmlController } from '@hachej/boring-ui-kit/html';\nimport type { HtmlController } from '@hachej/boring-ui-kit/html';\nimport type { ChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { artifactKey, artifactKind, collectArtifacts, typeLabel } from '../pi-artifacts/artifact';\nimport type { ArtifactDescriptor, ArtifactKindViewProps, ArtifactTarget, ArtifactsConfig } from '../pi-artifacts/artifact';\nimport { CodeBlock } from '../pi-chat/code-block';\nimport { ViewerFrame } from '../viewer-frame/viewer-frame';\nimport type { ViewerFrameProps, ViewerShare } from '../viewer-frame/viewer-frame';\nimport type { ViewerMenuItem } from '../viewer-frame/menu';\nimport { ViewerVersions } from '../viewer-frame/menu';\nimport type { ViewerVersion } from '../viewer-frame/menu';\nimport type { InteractiveHtml } from '../viewer-frame/interactive-html';\nimport { downloadFile } from '../viewer-frame/download';\nimport { copyText } from '../utils/utils';\nimport { Separator } from '@/components/ui/separator';\nimport { Mounted, ViewLoading, drawsText, isEditorKind, isMediaKind, lazyRenderer, lazyView, noKinds } from './viewer-kinds';\nimport type { PanelKind } from './viewer-kinds';\nimport { readText, savedLabel, useSaved } from './use-saved';\nimport { useAppText } from './app-labels';\nimport { useChatText } from '../pi-chat/labels';\n\nconst NOOP = () => () => {};\n/** The page's URL fragment without `#`, followed through `hashchange`: artifact kinds read a place inside the artifact from it. */\nconst subscribeHash = (change: () => void) => { globalThis.addEventListener?.('hashchange', change); return () => globalThis.removeEventListener?.('hashchange', change); };\nconst readHash = () => (globalThis.location?.hash ?? '').replace(/^#/, '');\nconst POLL_MS = 1500;\nconst EXTENSION = /\\.([A-Za-z0-9]{1,8})$/;\n\n/** One retained revision of a file with its save time (`files.saves(path)` of `@hachej/boring-files/workspace`). */\nexport type SavedRevision = FileRevision;\n\n/** What a host viewer for an artifact type or file kind the block does not render itself (for example a tldraw canvas) receives. */\nexport interface CustomViewerProps {\n readonly target: ArtifactTarget;\n readonly title: string;\n /** A pinned revision, read-only; absent: follow the latest. */\n readonly revision?: string | undefined;\n /** Pass these on to the `ViewerFrame` around it: test ids, the version switcher or back button, Close, the share target. */\n readonly frame: Pick<ViewerFrameProps, 'testId' | 'titleTestId' | 'headerStart' | 'controls' | 'onClose' | 'target'>;\n}\n/** Host viewers by artifact type or file kind (`canvas`, or any other). They take precedence over the built-in ones. */\nexport type CustomViewers = Readonly<Record<string, (props: CustomViewerProps) => ReactNode>>;\n\n/** Everything a viewer in the panel reads through: one resource client, the version history, host viewers and policy. */\nexport interface ViewerOptions {\n readonly client: ResourceClient;\n readonly identity: ResourceIdentity;\n readonly history?: ((path: string) => Promise<readonly SavedRevision[]>) | undefined;\n readonly viewers?: CustomViewers | undefined;\n /**\n * Every kind the panel and the file viewer resolve through, in order (the first match wins): its label names the artifact in the bar and\n * its `renderer` or lazily loaded `load` draws it. The panel knows no kind of its own: without this list every artifact shows as text.\n * The pi-app-kinds item's `defaultKinds()` is the default list (maquette, diagram, mock-up and records, then Markdown, HTML, SVG, CSV,\n * image and PDF); a host puts its own kinds before it, or passes a list of its own. A `viewers` entry wins over a document kind\n * (`fallback`), not over a host's kind.\n */\n readonly kinds?: readonly PanelKind[] | undefined;\n /** The host's opt-in to running HTML pages in the sandboxed preview. */\n readonly interactive?: InteractiveHtml | undefined;\n readonly share?: ViewerShare | undefined;\n readonly onUnsavedChange?: ((unsaved: boolean) => void) | undefined;\n readonly onBeforeLeaveChange?: ((flush: (() => Promise<boolean>) | null) => void) | undefined;\n /** Told each revision a built-in viewer has mounted and shows (after it loads, never before): the page's acknowledgement of `present`. */\n readonly onShown?: ((shown: { readonly path: string; readonly revision: string; readonly hidden?: true }) => void) | undefined;\n /** Told when an artifact kind could not show a revision (its `onFailed`, or its renderer threw): the page refuses a `present` of that file. */\n readonly onFailed?: ((failed: { readonly path: string; readonly revision: string; readonly reason: string }) => void) | undefined;\n /** Where the host serves blob bytes (a checked same-origin path, default `/_files/`): a pointer file opens from `<blobBase><id>`. */\n readonly blobBase?: string | undefined;\n}\n\n/** Reports `revision` of `path` once the viewer showing it has mounted (an effect, so never before the panel really shows it), and `hidden` when it stops. */\nexport function useReportShown(onShown: ViewerOptions['onShown'], path: string, revision: string | undefined): void {\n useEffect(() => {\n if (revision === undefined || !onShown) return undefined;\n onShown({ path, revision });\n return () => onShown({ path, revision, hidden: true });\n }, [onShown, path, revision]);\n}\n\n/** Draws a kind's renderer as its own component, so a throw is caught by `KindBoundary` and reported as a failure. */\nfunction KindView({ render, props }: { readonly render: (props: ArtifactKindViewProps) => ReactNode; readonly props: ArtifactKindViewProps }) {\n return <>{render(props)}</>;\n}\n\n/**\n * Catches a kind renderer that throws: shows a short note instead of the view and reports the failure once. A new `revision` clears the\n * note without remounting the renderer: a stateful kind (the board's local frame, its save and conflict state) survives its own saves.\n */\nclass KindBoundary extends Component<{ readonly revision: string; readonly onError: () => void; readonly message: string; readonly children: ReactNode }, { readonly failed: boolean; readonly revision: string }> {\n override state = { failed: false, revision: this.props.revision };\n static getDerivedStateFromError(): { failed: boolean } { return { failed: true }; }\n static getDerivedStateFromProps(props: { readonly revision: string }, state: { readonly failed: boolean; readonly revision: string }): { failed: boolean; revision: string } | null {\n return props.revision === state.revision ? null : { failed: false, revision: props.revision };\n }\n override componentDidCatch(): void { this.props.onError(); }\n override render(): ReactNode {\n return this.state.failed ? <p className=\"m-0 p-4 text-sm text-muted-foreground\" role=\"status\">{this.props.message}</p> : this.props.children;\n }\n}\n\n/** Every artifact version the conversation's tool results produced (the version switcher's source), oldest first. */\nexport function useArtifactVersions(controller: ChatController | undefined, detect?: ArtifactsConfig['detect']): ArtifactDescriptor[] {\n const view = useSyncExternalStore(controller ? controller.subscribe : NOOP, () => controller?.getSnapshot().view, () => undefined);\n return useMemo(() => collectArtifacts(view, detect), [view, detect]);\n}\n\n/** How many messages the person has sent in the conversation: it changes when a new turn starts. */\nexport function useTurn(controller: ChatController | undefined): number {\n const view = useSyncExternalStore(controller ? controller.subscribe : NOOP, () => controller?.getSnapshot().view, () => undefined);\n return useMemo(() => (view?.entries ?? []).reduce((count, entry) => count + (entry.model ?? []).filter(message => message.role === 'user').length, 0), [view]);\n}\n\n/** The revisions the workspace provider retains for a file with their save times, newest first; followed while the panel is open. */\nfunction useHistory(history: ViewerOptions['history'], path: string): readonly SavedRevision[] {\n const [revisions, setRevisions] = useState<readonly SavedRevision[]>([]);\n useEffect(() => {\n if (!history) return;\n let cancelled = false;\n const key = (saves: readonly SavedRevision[]) => saves.map(save => `${save.revision}@${save.savedAt}`).join();\n const load = () => history(path).then(saves => { if (!cancelled) setRevisions(current => key(current) === key(saves) ? current : saves); }).catch(() => {});\n void load();\n const timer = setInterval(load, POLL_MS);\n return () => { cancelled = true; clearInterval(timer); };\n }, [history, path]);\n return revisions;\n}\n\ninterface VersionMenu {\n readonly items: readonly ViewerVersion[];\n readonly current: string;\n /** Position counted from the oldest retained revision: only a test hook. */\n readonly number: number | undefined;\n readonly savedAt: number | undefined;\n}\n\n/** The version menu, newest first: each retained revision is named by when it was saved, and the newest is \"Latest\". The pin is the revision. */\nfunction versionChoices(artifact: ArtifactDescriptor, follow: boolean, revisions: readonly SavedRevision[], earlier: string): VersionMenu {\n const at = follow ? 0 : revisions.findIndex(save => save.revision === artifact.revision);\n const shown = at < 0 ? undefined : revisions[at];\n return { items: revisions.map((save, index) => ({ id: index === 0 ? 'latest' : save.revision, label: savedLabel(save.savedAt, Date.now(), earlier), savedAt: save.savedAt, ...(save.source ? { source: save.source } : {}), latest: index === 0 })),\n current: follow ? 'latest' : artifact.revision, number: shown ? revisions.length - at : undefined, savedAt: shown?.savedAt };\n}\n\ntype DocumentController = MarkdownController | HtmlController;\n\n/** One artifact version's frame and body. Mounted per resource and revision, so switching version starts clean. */\nfunction Version({ artifact, pinned, menu, options, onSelect, onClose, headerStart }: {\n readonly artifact: ArtifactDescriptor; readonly pinned: boolean; readonly menu: VersionMenu; readonly options: ViewerOptions;\n readonly onSelect: (value: string) => void; readonly onClose: () => void; readonly headerStart?: ReactNode;\n}) {\n const { type } = artifact;\n const chatText = useChatText();\n const { client, identity } = options;\n const { labels } = useAppText();\n const path = artifact.target.resource.path;\n const [ready, setReady] = useState<{ revision: string | undefined; text: string }>();\n const [kindMenu, setKindMenu] = useState<readonly ViewerMenuItem[]>([]);\n const [kindControls, setKindControls] = useState<ReactNode>();\n // The revision a lazily loaded view has mounted: the panel acknowledges a `present` only once the view is on screen, not while it loads.\n const [mounted, setMounted] = useState<string>();\n const kinds = options.kinds ?? noKinds;\n // The kinds that draw (an editor, a renderer or a lazily loaded view); a label-only kind names the artifact but leaves the drawing to the\n // next match. Image and PDF kinds read bytes: the file viewer draws them, never the artifact panel.\n const drawingKinds = useMemo(() => kinds.filter(kind => drawsText(kind)), [kinds]);\n // A kind's file may link to another file of the same workspace (a maquette's diagram): read its latest revision as text.\n const read = useCallback(async (other: string) => {\n const result = await client.read({ target: { ...artifact.target, resource: { ...artifact.target.resource, path: other } }, revision: { kind: 'latest' } }).catch(() => undefined);\n return result?.kind === 'available' ? readText(result.snapshot.bytes) : undefined;\n }, [client, artifact.target]);\n const fragment = useSyncExternalStore(subscribeHash, readHash, () => '');\n // A host viewer for the type (`viewers`) wins over a document kind (a `fallback` one), not over any other kind.\n const draws = artifactKind(artifact, drawingKinds);\n const custom = draws && !draws.fallback ? undefined : options.viewers?.[type];\n const drawing = custom ? undefined : draws;\n const editor = isEditorKind(drawing) ? drawing.editor : undefined;\n const create = useMemo(() => {\n const base = (snapshot: ResourceSnapshot) => ({ identity, client, instanceId: randomUUID(), epoch: 'workspace', source: { kind: 'saved' as const, snapshot } });\n if (editor === 'markdown') return (snapshot: ResourceSnapshot): DocumentController => createMarkdownController({ ...base(snapshot), readOnly: pinned });\n if (editor === 'html') return (snapshot: ResourceSnapshot): DocumentController => createHtmlController({ ...base(snapshot), readOnly: true });\n return undefined;\n }, [editor, pinned, client, identity]);\n const autosave = useRef<{ controller: DocumentController; flush: () => Promise<boolean> } | null>(null);\n const saved = useSaved<DocumentController>({ client, target: artifact.target, revision: pinned ? artifact.revision : undefined, create,\n beforeDispose: controller => autosave.current?.controller === controller ? autosave.current.flush() : undefined });\n const controller = saved.controller;\n const registerAutosave = useCallback((flush: (() => Promise<boolean>) | null) => {\n autosave.current = flush && controller ? { controller, flush } : null;\n }, [controller]);\n useLayoutEffect(() => {\n if (!controller) return;\n const notify = () => {\n const state = controller.getSnapshot();\n options.onUnsavedChange?.(state.dirty || state.save.kind === 'pending' || state.save.kind === 'settled' && state.save.result.kind === 'unknown');\n };\n notify(); const stop = controller.subscribe(notify);\n return () => { stop(); options.onUnsavedChange?.(false); };\n }, [controller, options.onUnsavedChange]);\n // The text to copy and download is the buffer when a viewer holds one, so the person's unsaved edits are included.\n const text = useSyncExternalStore(controller ? controller.subscribe : NOOP, () => controller ? controller.getSnapshot().text : saved.text, () => saved.text);\n const extension = EXTENSION.exec(path)?.[1] ?? 'txt';\n const revision = saved.kind === 'open' ? saved.snapshot.ref.revision : pinned ? artifact.revision : undefined;\n // A host viewer and the canvas (which shows only a \"no viewer\" note) never report a mount: a presented file in them stays unconfirmed.\n // A kind that waits for ready (a dashboard loading its data, the sandboxed mock-up) reports only once its renderer called `onReady` for this text.\n // A lazily loaded view (every built-in viewer) reports only once it has loaded and mounted for this revision.\n const waits = !!(drawing && 'waitsForReady' in drawing && drawing.waitsForReady);\n const suspends = !!drawing && (!editor || !!controller);\n const opened = saved.kind === 'open' ? saved.snapshot.ref.revision : undefined;\n useReportShown(options.onShown, path, !custom && type !== 'canvas' && opened !== undefined && (!suspends || mounted === opened) && (!waits || ready !== undefined && ready.revision === revision && ready.text === saved.text) ? opened : undefined);\n const onFailedOption = options.onFailed;\n const reportFailed = useCallback((reason: string) => {\n setReady(undefined);\n if (revision !== undefined) onFailedOption?.({ path, revision, reason });\n }, [onFailedOption, path, revision]);\n // Choosing newest follows live saves; Current marks the displayed revision.\n const versions = menu.items.length > 1 ? <ViewerVersions testId=\"artifact-versions\" current={menu.current} onSelect={onSelect} versions={menu.items} /> : undefined;\n const loading = <ViewLoading label={labels.loading} />;\n const target = { artifact: artifactKey(artifact), version: pinned ? artifact.revision : 'latest' };\n const frame = {\n title: artifact.title, testId: 'artifact', titleTestId: 'artifact-panel-title', controls: versions, onClose, target, ...(headerStart ? { headerStart } : {}), ...(revision === undefined ? {} : { revision }), ...(options.share ? { onShare: options.share } : {}),\n subtitle: <>\n <span title={path}>{path}</span><span aria-hidden=\"true\">·</span>\n <span data-testid=\"artifact-panel-type\">{typeLabel(artifact, kinds, chatText.labels.artifactTypes)}</span>\n <span aria-hidden=\"true\">·</span>\n <span data-testid=\"artifact-panel-version\">{pinned ? (menu.savedAt !== undefined ? labels.savedAt(savedLabel(menu.savedAt, Date.now(), labels.earlierVersion)) : labels.olderVersion) : menu.savedAt ? labels.latestSavedAt(savedLabel(menu.savedAt, Date.now(), labels.earlierVersion)) : labels.latest}</span>\n </>,\n ...(pinned ? { status: { label: labels.readOnly, kind: 'read-only' as const } } : {}),\n };\n const filename = `${artifact.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'artifact'}.${extension}`;\n const body = (children: ReactNode) => <div data-testid=\"artifact-body\" data-state={saved.kind} className=\"flex h-full min-h-0 flex-1 flex-col overflow-hidden\">{children}</div>;\n if (custom) return body(custom({ target: artifact.target, title: artifact.title, ...(pinned ? { revision: artifact.revision } : {}), frame: { testId: 'artifact', titleTestId: 'artifact-panel-title', controls: versions, onClose, target, ...(headerStart ? { headerStart } : {}) } }));\n // Until the version is open the bar offers no Share: the pane that shows it brings its own frame, so this one is replaced (remounted)\n // when it opens, and a Share pressed just then would copy the link with its \"Link copied\" notice lost with the old frame.\n if (saved.kind !== 'open') {\n const { onShare: _, ...waiting } = frame;\n return body(<ViewerFrame {...waiting}><p className=\"m-0 p-4 text-sm text-muted-foreground\" role=\"status\">{saved.kind === 'loading' ? labels.loading : saved.kind === 'invalid' ? labels.artifactNotText : labels.versionUnavailable}</p></ViewerFrame>);\n }\n const document = (children: ReactNode) => <div data-testid=\"document\" data-revision={saved.snapshot.ref.revision} className=\"flex h-full min-h-0 flex-col\">\n {pinned && <><p role=\"note\" data-testid=\"version-view-only\" className=\"m-0 shrink-0 bg-muted/30 px-3 py-2 text-xs text-muted-foreground\">{labels.versionViewOnly}</p><Separator /></>}\n <div className=\"min-h-0 flex-1\">{children}</div>\n </div>;\n if (isEditorKind(drawing) && controller) {\n // The pane brings its own bar; while it loads, the same bar without Share shows a spinner. Its test ids are `artifact-pending-*`, so a\n // test (or a script) waiting for `artifact-more` reaches the pane's own menu, not the one of a bar about to be replaced.\n const { onShare: _, ...waiting } = { ...frame, testId: 'artifact-pending' };\n let pane: ReactNode;\n if (drawing.editor === 'markdown') {\n const MarkdownPane = lazyView(drawing.load);\n pane = <MarkdownPane controller={controller as MarkdownController} initialMode=\"rich\" onAutosaveChange={registerAutosave} onBeforeLeaveChange={options.onBeforeLeaveChange} {...frame} />;\n } else {\n const HtmlPane = lazyView(drawing.load);\n pane = <HtmlPane controller={controller as HtmlController} {...(options.interactive ? { interactive: options.interactive } : {})} {...frame} />;\n }\n return body(document(<Suspense fallback={<ViewerFrame {...waiting}>{loading}</ViewerFrame>}>{pane}<Mounted value={saved.snapshot.ref.revision} onMount={setMounted} /></Suspense>));\n }\n const actions = { onCopy: () => copyText(text ?? ''), onDownload: () => downloadFile(filename, text ?? '', `${artifact.mediaType};charset=utf-8`) };\n if (drawing && !isEditorKind(drawing) && !isMediaKind(drawing)) {\n const render = ('renderer' in drawing ? drawing.renderer : undefined) ?? lazyRenderer(drawing.load!);\n return body(<ViewerFrame {...frame} {...actions} controls={<>{kindControls}{versions}</>} menu={kindMenu}><div data-testid=\"artifact-kind-view\" className=\"min-h-0 flex-1 overflow-auto\">\n <KindBoundary revision={saved.snapshot.ref.revision} message={labels.versionUnavailable} onError={() => reportFailed('the view failed to render')}>\n <Suspense fallback={loading}>\n <KindView render={render} props={{ artifact, text: saved.text, revision: saved.snapshot.ref.revision, pinned, fragment, read, onReady: () => setReady({ revision, text: saved.text }), onFailed: reportFailed, setMenu: setKindMenu, setControls: setKindControls }} />\n <Mounted value={saved.snapshot.ref.revision} onMount={setMounted} />\n </Suspense>\n </KindBoundary>\n </div></ViewerFrame>);\n }\n if (type === 'canvas') return body(<ViewerFrame {...frame}><p className=\"m-0 p-4 text-sm text-muted-foreground\" role=\"status\">{labels.noCanvasViewer}</p></ViewerFrame>);\n return body(<ViewerFrame {...frame} {...actions}><div className=\"min-h-0 flex-1 overflow-auto p-3\"><CodeBlock code={saved.text} language={artifact.language ?? 'text'} /></div></ViewerFrame>);\n}\n\n/**\n * One artifact in the panel. `active.follow` shows the latest saved revision under the newest known title (`versions`: every version of\n * this file the conversation presented or saved, newest first); otherwise the one revision in `active.descriptor` is pinned and read-only.\n * `onSelect` receives `latest` or a revision from the version menu.\n */\nexport function ArtifactPanel({ active, versions, options, onSelect, onClose, headerStart }: {\n readonly active: { readonly descriptor: ArtifactDescriptor; readonly follow: boolean };\n readonly versions: readonly ArtifactDescriptor[];\n readonly options: ViewerOptions;\n readonly onSelect: (value: string) => void;\n readonly onClose: () => void;\n /** A leading control in the bar, such as a back button (a file opened from a list). */\n readonly headerStart?: ReactNode;\n}) {\n const { follow } = active;\n const artifact = follow ? versions[0] ?? active.descriptor : active.descriptor;\n const path = artifact.target.resource.path;\n const revisions = useHistory(options.history, path);\n const { labels } = useAppText();\n const menu = versionChoices(artifact, follow, revisions, labels.earlierVersion);\n return <div data-testid=\"artifact-panel\" data-artifact-id={artifactKey(artifact)} data-artifact-type={artifact.type} data-follow={follow ? 'true' : 'false'} data-artifact-position={menu.number} data-artifact-revision={follow ? undefined : artifact.revision}\n className=\"pi-chat flex h-full min-h-0 flex-col\">\n <Version key={`${path}@${follow ? 'latest' : artifact.revision}`} artifact={artifact} pinned={!follow} menu={menu} options={options} onSelect={onSelect} onClose={onClose} {...(headerStart ? { headerStart } : {})} />\n </div>;\n}\n",
50
+ "content": "'use client';\n\nimport { useChatView } from '@hachej/boring-ui-kit/chat-react';\n\n// The artifact panel: the standard viewer bar (title, type, version switcher, Refresh, Share, copy, download, close) over the viewer for\n// the artifact's type. It follows the latest saved revision while nothing is unsaved (\"Latest\") or pins one older version, read-only. It\n// lists the versions the host's workspace provider retains for the file (`history`) and reads through the host's resource client. It never\n// executes artifact content (HTML runs only in the viewers' sandboxed preview when the host passes `interactive`).\nimport { Component, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { randomUUID } from '@hachej/boring-files/platform';\nimport type { ResourceClient, ResourceSnapshot } from '@hachej/boring-files';\nimport type { ResourceIdentity } from '@hachej/boring-files/remote';\nimport type { SavedRevision as FileRevision } from '@hachej/boring-files/revision';\nimport { createMarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport type { MarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport { createHtmlController } from '@hachej/boring-ui-kit/html';\nimport type { HtmlController } from '@hachej/boring-ui-kit/html';\nimport type { ChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { artifactTurn } from '@hachej/boring-ui-kit/chat-view';\nimport { artifactKey, artifactKind, collectArtifacts, typeLabel } from '../pi-artifacts/artifact';\nimport type { ArtifactDescriptor, ArtifactKindViewProps, ArtifactTarget, ArtifactsConfig } from '../pi-artifacts/artifact';\nimport { CodeBlock } from '../pi-chat/code-block';\nimport { ViewerFrame } from '../viewer-frame/viewer-frame';\nimport type { ViewerFrameProps, ViewerShare } from '../viewer-frame/viewer-frame';\nimport type { ViewerMenuItem } from '../viewer-frame/menu';\nimport { ViewerVersions } from '../viewer-frame/menu';\nimport type { ViewerVersion } from '../viewer-frame/menu';\nimport type { InteractiveHtml } from '../viewer-frame/interactive-html';\nimport { downloadFile } from '../viewer-frame/download';\nimport { copyText } from '../utils/utils';\nimport { Separator } from '@/components/ui/separator';\nimport { LazyPane, Mounted, ViewLoading, drawsText, isEditorKind, isMediaKind, lazyRenderer, noKinds } from './viewer-kinds';\nimport type { PanelKind } from './viewer-kinds';\nimport { readText, savedLabel, useSaved } from './use-saved';\nimport { useAppText } from './app-labels';\nimport { useChatText } from '../pi-chat/labels';\n\nconst NOOP = () => () => {};\n/** The page's URL fragment without `#`, followed through `hashchange`: artifact kinds read a place inside the artifact from it. */\nconst subscribeHash = (change: () => void) => { globalThis.addEventListener?.('hashchange', change); return () => globalThis.removeEventListener?.('hashchange', change); };\nconst readHash = () => (globalThis.location?.hash ?? '').replace(/^#/, '');\nconst POLL_MS = 1500;\nconst EXTENSION = /\\.([A-Za-z0-9]{1,8})$/;\n\n/** One retained revision of a file with its save time (`files.saves(path)` of `@hachej/boring-files/workspace`). */\nexport type SavedRevision = FileRevision;\n\n/** What a host viewer for an artifact type or file kind the block does not render itself (for example a tldraw canvas) receives. */\nexport interface CustomViewerProps {\n readonly target: ArtifactTarget;\n readonly title: string;\n /** A pinned revision, read-only; absent: follow the latest. */\n readonly revision?: string | undefined;\n /** Pass these on to the `ViewerFrame` around it: test ids, the version switcher or back button, Close, the share target. */\n readonly frame: Pick<ViewerFrameProps, 'testId' | 'titleTestId' | 'headerStart' | 'controls' | 'onClose' | 'target'>;\n}\n/** Host viewers by artifact type or file kind (`canvas`, or any other). They take precedence over the built-in ones. */\nexport type CustomViewers = Readonly<Record<string, (props: CustomViewerProps) => ReactNode>>;\n\n/** Everything a viewer in the panel reads through: one resource client, the version history, host viewers and policy. */\nexport interface ViewerOptions {\n readonly client: ResourceClient;\n readonly identity: ResourceIdentity;\n readonly history?: ((path: string) => Promise<readonly SavedRevision[]>) | undefined;\n readonly viewers?: CustomViewers | undefined;\n /**\n * Every kind the panel and the file viewer resolve through, in order (the first match wins): its label names the artifact in the bar and\n * its `renderer` or lazily loaded `load` draws it. The panel knows no kind of its own: without this list every artifact shows as text.\n * The pi-app-kinds item's `defaultKinds()` is the default list (maquette, diagram, mock-up and records, then Markdown, HTML, SVG, CSV,\n * image and PDF); a host puts its own kinds before it, or passes a list of its own. A `viewers` entry wins over a document kind\n * (`fallback`), not over a host's kind.\n */\n readonly kinds?: readonly PanelKind[] | undefined;\n /** The host's opt-in to running HTML pages in the sandboxed preview. */\n readonly interactive?: InteractiveHtml | undefined;\n readonly share?: ViewerShare | undefined;\n readonly onUnsavedChange?: ((unsaved: boolean) => void) | undefined;\n readonly onBeforeLeaveChange?: ((flush: (() => Promise<boolean>) | null) => void) | undefined;\n /** Told each revision a built-in viewer has mounted and shows (after it loads, never before): the page's acknowledgement of `present`. */\n readonly onShown?: ((shown: { readonly path: string; readonly revision: string; readonly hidden?: true }) => void) | undefined;\n /** Told when an artifact kind could not show a revision (its `onFailed`, or its renderer threw): the page refuses a `present` of that file. */\n readonly onFailed?: ((failed: { readonly path: string; readonly revision: string; readonly reason: string }) => void) | undefined;\n /** Where the host serves blob bytes (a checked same-origin path, default `/_files/`): a pointer file opens from `<blobBase><id>`. */\n readonly blobBase?: string | undefined;\n}\n\n/** Reports `revision` of `path` once the viewer showing it has mounted (an effect, so never before the panel really shows it), and `hidden` when it stops. */\nexport function useReportShown(onShown: ViewerOptions['onShown'], path: string, revision: string | undefined): void {\n useEffect(() => {\n if (revision === undefined || !onShown) return undefined;\n onShown({ path, revision });\n return () => onShown({ path, revision, hidden: true });\n }, [onShown, path, revision]);\n}\n\n/** Draws a kind's renderer as its own component, so a throw is caught by `KindBoundary` and reported as a failure. */\nfunction KindView({ render, props }: { readonly render: (props: ArtifactKindViewProps) => ReactNode; readonly props: ArtifactKindViewProps }) {\n return <>{render(props)}</>;\n}\n\n/**\n * Catches a kind renderer that throws: shows a short note instead of the view and reports the failure once. A new `revision` clears the\n * note without remounting the renderer: a stateful kind (the board's local frame, its save and conflict state) survives its own saves.\n */\nclass KindBoundary extends Component<{ readonly revision: string; readonly onError: () => void; readonly message: string; readonly children: ReactNode }, { readonly failed: boolean; readonly revision: string }> {\n override state = { failed: false, revision: this.props.revision };\n static getDerivedStateFromError(): { failed: boolean } { return { failed: true }; }\n static getDerivedStateFromProps(props: { readonly revision: string }, state: { readonly failed: boolean; readonly revision: string }): { failed: boolean; revision: string } | null {\n return props.revision === state.revision ? null : { failed: false, revision: props.revision };\n }\n override componentDidCatch(): void { this.props.onError(); }\n override render(): ReactNode {\n return this.state.failed ? <p className=\"m-0 p-4 text-sm text-muted-foreground\" role=\"status\">{this.props.message}</p> : this.props.children;\n }\n}\n\n/** Every artifact version the conversation's tool results produced (the version switcher's source), oldest first. */\nexport function useArtifactVersions(controller: ChatController | undefined, detect?: ArtifactsConfig['detect']): ArtifactDescriptor[] {\n return useChatView(controller, useCallback(view => collectArtifacts(view, detect), [detect]));\n}\n\n/** How many messages the person has sent in the conversation: it changes when a new turn starts. */\nexport function useTurn(controller: ChatController | undefined): number {\n return useChatView(controller, artifactTurn);\n}\n\n/** The revisions the workspace provider retains for a file with their save times, newest first; followed while the panel is open. */\nfunction useHistory(history: ViewerOptions['history'], path: string): readonly SavedRevision[] {\n const [revisions, setRevisions] = useState<readonly SavedRevision[]>([]);\n useEffect(() => {\n if (!history) return;\n let cancelled = false;\n const key = (saves: readonly SavedRevision[]) => saves.map(save => `${save.revision}@${save.savedAt}`).join();\n const load = () => history(path).then(saves => { if (!cancelled) setRevisions(current => key(current) === key(saves) ? current : saves); }).catch(() => {});\n void load();\n const timer = setInterval(load, POLL_MS);\n return () => { cancelled = true; clearInterval(timer); };\n }, [history, path]);\n return revisions;\n}\n\ninterface VersionMenu {\n readonly items: readonly ViewerVersion[];\n readonly current: string;\n /** Position counted from the oldest retained revision: only a test hook. */\n readonly number: number | undefined;\n readonly savedAt: number | undefined;\n}\n\n/** The version menu, newest first: each retained revision is named by when it was saved, and the newest is \"Latest\". The pin is the revision. */\nfunction versionChoices(artifact: ArtifactDescriptor, follow: boolean, revisions: readonly SavedRevision[], earlier: string): VersionMenu {\n const at = follow ? 0 : revisions.findIndex(save => save.revision === artifact.revision);\n const shown = at < 0 ? undefined : revisions[at];\n return { items: revisions.map((save, index) => ({ id: index === 0 ? 'latest' : save.revision, label: savedLabel(save.savedAt, Date.now(), earlier), savedAt: save.savedAt, ...(save.source ? { source: save.source } : {}), latest: index === 0 })),\n current: follow ? 'latest' : artifact.revision, number: shown ? revisions.length - at : undefined, savedAt: shown?.savedAt };\n}\n\ntype DocumentController = MarkdownController | HtmlController;\n\n/** One artifact version's frame and body. Mounted per resource and revision, so switching version starts clean. */\nfunction Version({ artifact, pinned, menu, options, onSelect, onClose, headerStart }: {\n readonly artifact: ArtifactDescriptor; readonly pinned: boolean; readonly menu: VersionMenu; readonly options: ViewerOptions;\n readonly onSelect: (value: string) => void; readonly onClose: () => void; readonly headerStart?: ReactNode;\n}) {\n const { type } = artifact;\n const chatText = useChatText();\n const { client, identity } = options;\n const { labels } = useAppText();\n const path = artifact.target.resource.path;\n const [ready, setReady] = useState<{ revision: string | undefined; text: string }>();\n const [kindMenu, setKindMenu] = useState<readonly ViewerMenuItem[]>([]);\n const [kindControls, setKindControls] = useState<ReactNode>();\n // The revision a lazily loaded view has mounted: the panel acknowledges a `present` only once the view is on screen, not while it loads.\n const [mounted, setMounted] = useState<string>();\n const kinds = options.kinds ?? noKinds;\n // The kinds that draw (an editor, a renderer or a lazily loaded view); a label-only kind names the artifact but leaves the drawing to the\n // next match. Image and PDF kinds read bytes: the file viewer draws them, never the artifact panel.\n const drawingKinds = useMemo(() => kinds.filter(kind => drawsText(kind)), [kinds]);\n // A kind's file may link to another file of the same workspace (a maquette's diagram): read its latest revision as text.\n const read = useCallback(async (other: string) => {\n const result = await client.read({ target: { ...artifact.target, resource: { ...artifact.target.resource, path: other } }, revision: { kind: 'latest' } }).catch(() => undefined);\n return result?.kind === 'available' ? readText(result.snapshot.bytes) : undefined;\n }, [client, artifact.target]);\n const fragment = useSyncExternalStore(subscribeHash, readHash, () => '');\n // A host viewer for the type (`viewers`) wins over a document kind (a `fallback` one), not over any other kind.\n const draws = artifactKind(artifact, drawingKinds);\n const custom = draws && !draws.fallback ? undefined : options.viewers?.[type];\n const drawing = custom ? undefined : draws;\n const editor = isEditorKind(drawing) ? drawing.editor : undefined;\n // useSaved reads the latest factory when it opens a revision, so it need not be memoized.\n const base = (snapshot: ResourceSnapshot) => ({ identity, client, instanceId: randomUUID(), epoch: 'workspace', source: { kind: 'saved' as const, snapshot } });\n const create = editor === 'markdown' ? (snapshot: ResourceSnapshot): DocumentController => createMarkdownController({ ...base(snapshot), readOnly: pinned })\n : editor === 'html' ? (snapshot: ResourceSnapshot): DocumentController => createHtmlController({ ...base(snapshot), readOnly: true }) : undefined;\n const autosave = useRef<{ controller: DocumentController; flush: () => Promise<boolean> } | null>(null);\n const saved = useSaved<DocumentController>({ client, target: artifact.target, revision: pinned ? artifact.revision : undefined, create,\n beforeDispose: controller => autosave.current?.controller === controller ? autosave.current.flush() : undefined });\n const controller = saved.controller;\n const registerAutosave = useCallback((flush: (() => Promise<boolean>) | null) => {\n autosave.current = flush && controller ? { controller, flush } : null;\n }, [controller]);\n const onUnsavedChange = options.onUnsavedChange;\n useLayoutEffect(() => {\n if (!controller) return;\n const notify = () => {\n const state = controller.getSnapshot();\n onUnsavedChange?.(state.dirty || state.save.kind === 'pending' || state.save.kind === 'settled' && state.save.result.kind === 'unknown');\n };\n notify(); const stop = controller.subscribe(notify);\n return () => { stop(); onUnsavedChange?.(false); };\n }, [controller, onUnsavedChange]);\n // The text to copy and download is the buffer when a viewer holds one, so the person's unsaved edits are included.\n const text = useSyncExternalStore(controller ? controller.subscribe : NOOP, () => controller ? controller.getSnapshot().text : saved.text, () => saved.text);\n const extension = EXTENSION.exec(path)?.[1] ?? 'txt';\n const revision = saved.kind === 'open' ? saved.snapshot.ref.revision : pinned ? artifact.revision : undefined;\n // A host viewer and the canvas (which shows only a \"no viewer\" note) never report a mount: a presented file in them stays unconfirmed.\n // A kind that waits for ready (a dashboard loading its data, the sandboxed mock-up) reports only once its renderer called `onReady` for this text.\n // A lazily loaded view (every built-in viewer) reports only once it has loaded and mounted for this revision.\n const waits = !!(drawing && 'waitsForReady' in drawing && drawing.waitsForReady);\n const suspends = !!drawing && (!editor || !!controller);\n const opened = saved.kind === 'open' ? saved.snapshot.ref.revision : undefined;\n useReportShown(options.onShown, path, !custom && type !== 'canvas' && opened !== undefined && (!suspends || mounted === opened) && (!waits || ready !== undefined && ready.revision === revision && ready.text === saved.text) ? opened : undefined);\n const onFailedOption = options.onFailed;\n const reportFailed = useCallback((reason: string) => {\n setReady(undefined);\n if (revision !== undefined) onFailedOption?.({ path, revision, reason });\n }, [onFailedOption, path, revision]);\n // Choosing newest follows live saves; Current marks the displayed revision.\n const versions = menu.items.length > 1 ? <ViewerVersions testId=\"artifact-versions\" current={menu.current} onSelect={onSelect} versions={menu.items} /> : undefined;\n const loading = <ViewLoading label={labels.loading} />;\n const target = { artifact: artifactKey(artifact), version: pinned ? artifact.revision : 'latest' };\n const frame = {\n title: artifact.title, testId: 'artifact', titleTestId: 'artifact-panel-title', controls: versions, onClose, target, ...(headerStart ? { headerStart } : {}), ...(revision === undefined ? {} : { revision }), ...(options.share ? { onShare: options.share } : {}),\n subtitle: <>\n <span title={path}>{path}</span><span aria-hidden=\"true\">·</span>\n <span data-testid=\"artifact-panel-type\">{typeLabel(artifact, kinds, chatText.labels.artifactTypes)}</span>\n <span aria-hidden=\"true\">·</span>\n <span data-testid=\"artifact-panel-version\">{pinned ? (menu.savedAt !== undefined ? labels.savedAt(savedLabel(menu.savedAt, undefined, labels.earlierVersion)) : labels.olderVersion) : menu.savedAt ? labels.latestSavedAt(savedLabel(menu.savedAt, undefined, labels.earlierVersion)) : labels.latest}</span>\n </>,\n ...(pinned ? { status: { label: labels.readOnly, kind: 'read-only' as const } } : {}),\n };\n const filename = `${artifact.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'artifact'}.${extension}`;\n const body = (children: ReactNode) => <div data-testid=\"artifact-body\" data-state={saved.kind} className=\"flex h-full min-h-0 flex-1 flex-col overflow-hidden\">{children}</div>;\n if (custom) return body(custom({ target: artifact.target, title: artifact.title, ...(pinned ? { revision: artifact.revision } : {}), frame: { testId: 'artifact', titleTestId: 'artifact-panel-title', controls: versions, onClose, target, ...(headerStart ? { headerStart } : {}) } }));\n // Until the version is open the bar offers no Share: the pane that shows it brings its own frame, so this one is replaced (remounted)\n // when it opens, and a Share pressed just then would copy the link with its \"Link copied\" notice lost with the old frame.\n if (saved.kind !== 'open') {\n const { onShare: _, ...waiting } = frame;\n return body(<ViewerFrame {...waiting}><p className=\"m-0 p-4 text-sm text-muted-foreground\" role=\"status\">{saved.kind === 'loading' ? labels.loading : saved.kind === 'invalid' ? labels.artifactNotText : labels.versionUnavailable}</p></ViewerFrame>);\n }\n const document = (children: ReactNode) => <div data-testid=\"document\" data-revision={saved.snapshot.ref.revision} className=\"flex h-full min-h-0 flex-col\">\n {pinned && <><p role=\"note\" data-testid=\"version-view-only\" className=\"m-0 shrink-0 bg-muted/30 px-3 py-2 text-xs text-muted-foreground\">{labels.versionViewOnly}</p><Separator /></>}\n <div className=\"min-h-0 flex-1\">{children}</div>\n </div>;\n if (isEditorKind(drawing) && controller) {\n // The pane brings its own bar; while it loads, the same bar without Share shows a spinner. Its test ids are `artifact-pending-*`, so a\n // test (or a script) waiting for `artifact-more` reaches the pane's own menu, not the one of a bar about to be replaced.\n const { onShare: _, ...waiting } = { ...frame, testId: 'artifact-pending' };\n let pane: ReactNode;\n if (drawing.editor === 'markdown') {\n pane = <LazyPane load={drawing.load} controller={controller as MarkdownController} initialMode=\"rich\" onAutosaveChange={registerAutosave} onBeforeLeaveChange={options.onBeforeLeaveChange} {...frame} />;\n } else {\n pane = <LazyPane load={drawing.load} controller={controller as HtmlController} {...(options.interactive ? { interactive: options.interactive } : {})} {...frame} />;\n }\n return body(document(<Suspense fallback={<ViewerFrame {...waiting}>{loading}</ViewerFrame>}>{pane}<Mounted value={saved.snapshot.ref.revision} onMount={setMounted} /></Suspense>));\n }\n const actions = { onCopy: () => copyText(text ?? ''), onDownload: () => downloadFile(filename, text ?? '', `${artifact.mediaType};charset=utf-8`) };\n if (drawing && !isEditorKind(drawing) && !isMediaKind(drawing)) {\n const render = ('renderer' in drawing ? drawing.renderer : undefined) ?? lazyRenderer(drawing.load!);\n return body(<ViewerFrame {...frame} {...actions} controls={<>{kindControls}{versions}</>} menu={kindMenu}><div data-testid=\"artifact-kind-view\" className=\"min-h-0 flex-1 overflow-auto\">\n <KindBoundary revision={saved.snapshot.ref.revision} message={labels.versionUnavailable} onError={() => reportFailed('the view failed to render')}>\n <Suspense fallback={loading}>\n <KindView render={render} props={{ artifact, text: saved.text, revision: saved.snapshot.ref.revision, pinned, fragment, read, onReady: () => setReady({ revision, text: saved.text }), onFailed: reportFailed, setMenu: setKindMenu, setControls: setKindControls }} />\n <Mounted value={saved.snapshot.ref.revision} onMount={setMounted} />\n </Suspense>\n </KindBoundary>\n </div></ViewerFrame>);\n }\n if (type === 'canvas') return body(<ViewerFrame {...frame}><p className=\"m-0 p-4 text-sm text-muted-foreground\" role=\"status\">{labels.noCanvasViewer}</p></ViewerFrame>);\n return body(<ViewerFrame {...frame} {...actions}><div className=\"min-h-0 flex-1 overflow-auto p-3\"><CodeBlock code={saved.text} language={artifact.language ?? 'text'} /></div></ViewerFrame>);\n}\n\n/**\n * One artifact in the panel. `active.follow` shows the latest saved revision under the newest known title (`versions`: every version of\n * this file the conversation presented or saved, newest first); otherwise the one revision in `active.descriptor` is pinned and read-only.\n * `onSelect` receives `latest` or a revision from the version menu.\n */\nexport function ArtifactPanel({ active, versions, options, onSelect, onClose, headerStart }: {\n readonly active: { readonly descriptor: ArtifactDescriptor; readonly follow: boolean };\n readonly versions: readonly ArtifactDescriptor[];\n readonly options: ViewerOptions;\n readonly onSelect: (value: string) => void;\n readonly onClose: () => void;\n /** A leading control in the bar, such as a back button (a file opened from a list). */\n readonly headerStart?: ReactNode;\n}) {\n const { follow } = active;\n const artifact = follow ? versions[0] ?? active.descriptor : active.descriptor;\n const path = artifact.target.resource.path;\n const revisions = useHistory(options.history, path);\n const { labels } = useAppText();\n const menu = versionChoices(artifact, follow, revisions, labels.earlierVersion);\n return <div data-testid=\"artifact-panel\" data-artifact-id={artifactKey(artifact)} data-artifact-type={artifact.type} data-follow={follow ? 'true' : 'false'} data-artifact-position={menu.number} data-artifact-revision={follow ? undefined : artifact.revision}\n className=\"pi-chat flex h-full min-h-0 flex-col\">\n <Version key={`${path}@${follow ? 'latest' : artifact.revision}`} artifact={artifact} pinned={!follow} menu={menu} options={options} onSelect={onSelect} onClose={onClose} {...(headerStart ? { headerStart } : {})} />\n </div>;\n}\n",
61
51
  "type": "registry:component",
62
52
  "target": "components/pi-app/artifact-panel.tsx"
63
53
  },
64
54
  {
65
55
  "path": "registry/pi-app/file-viewer.tsx",
66
- "content": "'use client';\n\n// Opens one workspace file in the viewer its kind names (`ViewerOptions.kinds`), always in the standard viewer frame (viewer-frame item):\n// Markdown and HTML in their editors (revisioned through the host's resource client, so saves go back to the file and an edit made behind\n// the editor's back is a conflict), images and PDFs read-only from the file's bytes, a drawing kind in the artifact panel, anything else as\n// text. Each viewer loads the first time its kind opens. A host viewer (`viewers[kind]`) wins over a document kind.\nimport { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { DownloadIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { randomUUID } from '@hachej/boring-files/platform';\nimport type { ResourceLocator, ResourceSnapshot } from '@hachej/boring-files';\nimport { createMarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport type { MarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport { createHtmlController } from '@hachej/boring-ui-kit/html';\nimport type { HtmlController } from '@hachej/boring-ui-kit/html';\nimport { ViewerFrame } from '../viewer-frame/viewer-frame';\nimport { downloadFile } from '../viewer-frame/download';\nimport { formatBytes } from '../viewer-frame/media';\nimport { copyText } from '../utils/utils';\nimport { mediaTypeOf, viewerKindOf } from '@hachej/boring-files/file-types';\nimport { isBlobPointerPath, resolveBlobPointer } from '@hachej/boring-files/blob';\nimport type { BlobItem } from '@hachej/boring-files/blob';\nimport type { ViewerKind as FileKind } from '@hachej/boring-files/file-types';\nimport { KIND_LABEL, decodeText } from './file-kinds';\nimport { ArtifactPanel, useReportShown } from './artifact-panel';\nimport { artifactKind } from '../pi-artifacts/artifact';\nimport type { ArtifactDescriptor, ArtifactType } from '../pi-artifacts/artifact';\nimport { Mounted, ViewLoading, drawsText, isEditorKind, isMediaKind, lazyView, noKinds } from './viewer-kinds';\nimport type { EditorKind, MediaKind, PanelKind } from './viewer-kinds';\nimport type { ViewerOptions } from './artifact-panel';\nimport { useSaved } from './use-saved';\nimport { useAppText } from './app-labels';\n\nconst POLL_MS = 1500;\nconst nameOf = (path: string) => path.split('/').pop() || path;\n\ninterface Common {\n readonly path: string;\n readonly kind: FileKind;\n readonly locator: ResourceLocator;\n readonly options: ViewerOptions;\n readonly onClose: () => void;\n readonly back: ReactNode;\n readonly onUnsavedChange?: ((unsaved: boolean) => void) | undefined;\n readonly onBeforeLeaveChange?: ((flush: (() => Promise<boolean>) | null) => void) | undefined;\n}\n\nconst FILE_TYPE: Partial<Record<FileKind, ArtifactType>> = { markdown: 'markdown', html: 'html' };\n/** A file as the artifact its kind sees: the same descriptor a `present` of it would carry, so one kind match serves every way of opening a file. */\nconst fileArtifact = (path: string, locator: ResourceLocator): ArtifactDescriptor => ({ schema: 'boring.artifact', version: 1, title: nameOf(path), type: FILE_TYPE[viewerKindOf(path)] ?? 'code', mediaType: mediaTypeOf(path), target: locator, revision: 'latest' });\n/** The image or PDF kind of a media type (a file's, or a large file's behind a pointer). */\nconst mediaKindOf = (kinds: readonly PanelKind[], artifact: ArtifactDescriptor): MediaKind | undefined => artifactKind(artifact, kinds.filter(isMediaKind));\n\nconst subtitleOf = (locator: ResourceLocator) => <span data-testid=\"file-path\" className=\"truncate\">{locator.resource.path}</span>;\n\n/** Markdown or HTML: a revisioned controller over the file. A new revision remounts the pane; it starts in the mode the person chose. */\nfunction FileDocument({ path, editor, locator, options, onClose, back, onUnsavedChange, onBeforeLeaveChange }: Common & { readonly editor: EditorKind }) {\n const { client, identity } = options;\n const markdown = editor.editor === 'markdown';\n const create = useMemo(() => (snapshot: ResourceSnapshot): MarkdownController | HtmlController => (markdown ? createMarkdownController : createHtmlController)({\n identity, client, instanceId: randomUUID(), epoch: 'workspace', source: { kind: 'saved', snapshot } }), [markdown, client, identity]);\n const autosave = useRef<{ controller: MarkdownController | HtmlController; flush: () => Promise<boolean> } | null>(null);\n const saved = useSaved({ client, target: locator, create, beforeDispose: controller => autosave.current?.controller === controller ? autosave.current.flush() : undefined });\n const registerAutosave = useCallback((flush: (() => Promise<boolean>) | null) => {\n autosave.current = flush && saved.controller ? { controller: saved.controller, flush } : null;\n }, [saved.controller]);\n useLayoutEffect(() => {\n const controller = saved.controller;\n if (!controller) return;\n const notify = () => {\n const snapshot = controller.getSnapshot();\n onUnsavedChange?.(snapshot.dirty || snapshot.save.kind === 'pending'\n || (snapshot.save.kind === 'settled' && snapshot.save.result.kind === 'unknown'));\n };\n notify();\n const unsubscribe = controller.subscribe(notify);\n return () => { unsubscribe(); onUnsavedChange?.(false); };\n }, [saved.controller, onUnsavedChange]);\n const { labels } = useAppText();\n const [mode, setMode] = useState<string>(markdown ? 'rich' : 'preview');\n const [formattingToolbar, setFormattingToolbar] = useState<'selection' | 'pinned'>('selection');\n // The editor loads the first time a document opens: the file counts as shown once it has mounted.\n const [mounted, setMounted] = useState<string>();\n const opened = saved.kind === 'open' && saved.controller ? saved.snapshot.ref.revision : undefined;\n useReportShown(options.onShown, path, opened !== undefined && mounted === opened ? opened : undefined);\n const common = { title: nameOf(path), subtitle: subtitleOf(locator), target: { file: path }, onClose, titleTestId: 'file-title', headerStart: back, ...(options.share ? { onShare: options.share } : {}) };\n // Until the document is open the bar offers no Share, Copy or menu actions of its own: they would act on a document that is not shown.\n if (saved.kind !== 'open' || !saved.controller) {\n return <ViewerFrame title={common.title} subtitle={common.subtitle} onClose={onClose} headerStart={back}>\n <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{saved.kind === 'loading' ? labels.loading : saved.kind === 'missing' ? labels.fileMissing : labels.fileNotText}</p>\n </ViewerFrame>;\n }\n const revision = saved.snapshot.ref.revision;\n // The preview selector the journeys use keeps working: the wrapper holds the document's text.\n let pane: ReactNode;\n if (editor.editor === 'markdown') {\n const MarkdownPane = lazyView(editor.load);\n pane = <MarkdownPane key={revision} controller={saved.controller as MarkdownController} revision={revision} initialMode={mode as 'rich'} onModeChange={setMode} formattingToolbar={formattingToolbar} onFormattingToolbarChange={setFormattingToolbar} onAutosaveChange={registerAutosave} onBeforeLeaveChange={onBeforeLeaveChange} {...common} />;\n } else {\n const HtmlPane = lazyView(editor.load);\n pane = <HtmlPane key={revision} controller={saved.controller as HtmlController} revision={revision} initialMode={mode as 'preview'} onModeChange={setMode} {...(options.interactive ? { interactive: options.interactive } : {})} {...common} />;\n }\n return <div data-testid=\"file-preview\" className=\"h-full min-h-0\" data-revision={revision}>\n <Suspense fallback={<ViewerFrame testId=\"viewer-pending\" title={common.title} subtitle={common.subtitle} onClose={onClose} headerStart={back}><ViewLoading label={labels.loading} /></ViewerFrame>}>\n {pane}<Mounted value={revision} onMount={setMounted} />\n </Suspense>\n </div>;\n}\n\n/** Native controls only. A failed codec leaves a download instead of a broken video box. */\nfunction VideoPreview({ item }: { readonly item: BlobItem }) {\n const [failed, setFailed] = useState(false);\n const { labels } = useAppText();\n return <div className=\"flex min-h-0 min-w-0 flex-1 items-center justify-center\">\n {failed ? <div className=\"flex flex-col items-center gap-3 p-4\">\n <p role=\"status\" data-testid=\"video-unplayable\" className=\"text-muted-foreground\">{labels.videoUnreadable}</p>\n <Button nativeButton={false} render={<a href={item.url} download={item.name} />} data-testid=\"video-download\">{labels.download}</Button>\n </div> : <video src={item.url} aria-label={item.name} controls playsInline preload=\"metadata\" onError={() => setFailed(true)} className=\"h-full max-h-full w-full min-w-0 object-contain\" />}\n </div>;\n}\n\n/**\n * A large file behind a pointer, from the host's URL: images, PDFs and videos inline, anything else as a download.\n * The viewers get a URL; none reads the media bytes into the page.\n */\nfunction BlobFile({ item, subtitle, media, ...common }: { readonly item: BlobItem; readonly subtitle: ReactNode; readonly media: MediaKind | undefined; readonly onClose: () => void; readonly onRefresh: () => unknown; readonly target: unknown; readonly titleTestId: string; readonly headerStart: ReactNode; readonly onShare?: NonNullable<ViewerOptions['share']> }) {\n const { labels } = useAppText();\n const details = <>{subtitle}<span aria-hidden=\"true\">·</span><span>{formatBytes(item.size)}</span></>;\n if (media?.media === 'image') { const ImagePane = lazyView(media.load); return <ImagePane name={item.name} mediaType={item.type} url={item.url} subtitle={details} {...common} />; }\n if (media?.media === 'pdf') { const PdfPane = lazyView(media.load); return <PdfPane name={item.name} url={item.url} subtitle={subtitle} {...common} />; }\n if (item.type.startsWith('video/')) return <ViewerFrame title={item.name} subtitle={details} {...common} onDownload={() => {\n const link = Object.assign(document.createElement('a'), { href: item.url, download: item.name });\n document.body.append(link); link.click(); link.remove();\n }}><VideoPreview key={item.url} item={item} /></ViewerFrame>;\n return <ViewerFrame title={item.name} subtitle={details} {...common}>\n <div data-testid=\"blob-file\" data-blob-type={item.type} className=\"m-auto flex flex-col items-center gap-3 p-6 text-center\">\n <p className=\"m-0 text-sm text-muted-foreground\">{item.type}</p>\n <Button nativeButton={false} render={<a href={item.url} download={item.name} />} data-testid=\"blob-download\"><DownloadIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.download}</Button>\n </div>\n </ViewerFrame>;\n}\n\ntype Loaded = { readonly kind: 'loading' | 'missing' } | { readonly kind: 'ready'; readonly bytes: Uint8Array; readonly revision: string; readonly text: string | undefined };\n\n/** Image, PDF and text files: read through the resource client, refreshed on demand (and followed while open, for text). */\nfunction FileBytes({ path, kind, locator, options, onClose, back, media }: Common & { readonly media: MediaKind | undefined }) {\n const { labels } = useAppText();\n // An image or PDF pane loads the first time one opens: the file counts as shown once it has mounted.\n const [mounted, setMounted] = useState<string>();\n const targetKey = JSON.stringify(locator);\n const binding = useMemo(() => ({ client: options.client, locator, path }), [options.client, targetKey, path]); // eslint-disable-line react-hooks/exhaustive-deps\n const [stored, setStored] = useState<{ readonly binding: typeof binding; readonly value: Loaded }>();\n const file: Loaded = stored?.binding === binding ? stored.value : { kind: 'loading' };\n const running = useRef<AbortController | undefined>(undefined);\n const load = useCallback(async () => {\n running.current?.abort();\n const abort = new AbortController(); running.current = abort;\n try {\n const read = await binding.client.read({ target: binding.locator, revision: { kind: 'latest' } }, abort.signal);\n if (abort.signal.aborted || running.current !== abort) return;\n if (read.kind !== 'available') {\n setStored(current => current?.binding === binding && current.value.kind === 'ready' ? current : { binding, value: { kind: 'missing' } });\n return;\n }\n const { bytes, ref } = read.snapshot;\n setStored({ binding, value: { kind: 'ready', bytes, revision: ref.revision, text: decodeText(binding.path, bytes) } });\n } catch {\n if (!abort.signal.aborted && running.current === abort) setStored(current => current?.binding === binding && current.value.kind === 'ready' ? current : { binding, value: { kind: 'missing' } });\n }\n }, [binding]);\n useEffect(() => {\n void load();\n const timer = kind === 'text' ? setInterval(() => { void load(); }, POLL_MS) : undefined;\n return () => { running.current?.abort(); clearInterval(timer); };\n }, [load, kind]);\n // A pointer file is resolved here, where it is read, once per read: the viewers below receive the item, never the pointer.\n const blob = useMemo(() => file.kind === 'ready' && isBlobPointerPath(path) ? resolveBlobPointer(file.bytes, options.blobBase) : undefined, [file, path, options.blobBase]);\n // A lazily loaded pane (an image, a PDF, a large file's viewer) counts as shown once it has mounted; the text view at once.\n const lazyPane = blob?.kind === 'blob' || !blob && !!media;\n useReportShown(options.onShown, path, file.kind === 'ready' && (!lazyPane || mounted === file.revision) ? file.revision : undefined);\n const common = { subtitle: subtitleOf(locator), target: { file: path }, onClose, titleTestId: 'file-title', headerStart: back, ...(options.share ? { onShare: options.share } : {}) };\n // Until the file is read the bar offers no Share: the image and PDF panes bring their own frame, so this one is replaced (remounted)\n // when the bytes arrive, and a Share pressed just then would copy the link with its \"Link copied\" notice lost with the old frame.\n if (file.kind !== 'ready') {\n return <ViewerFrame title={nameOf(path)} subtitle={common.subtitle} onClose={onClose} titleTestId=\"file-title\" headerStart={back}>\n <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{file.kind === 'loading' ? labels.loading : labels.fileMissing}</p>\n </ViewerFrame>;\n }\n // The bar while a view loads: `viewer-pending-*` test ids, so `viewer-more` is the view's own menu.\n const loading = <ViewerFrame testId=\"viewer-pending\" title={nameOf(path)} subtitle={common.subtitle} onClose={onClose} titleTestId=\"file-title\" headerStart={back}><ViewLoading label={labels.loading} /></ViewerFrame>;\n const shown = <Mounted value={file.revision} onMount={setMounted} />;\n if (blob?.kind === 'blob') {\n const blobMedia = mediaKindOf(options.kinds ?? noKinds, { ...fileArtifact(path, locator), mediaType: blob.item.type });\n return <Suspense fallback={loading}><BlobFile item={blob.item} media={blobMedia} onRefresh={load} {...common} />{shown}</Suspense>;\n }\n if (media?.media === 'image') { const ImagePane = lazyView(media.load); return <Suspense fallback={loading}><ImagePane name={nameOf(path)} mediaType={mediaTypeOf(path)} bytes={file.bytes} onRefresh={load} {...common} />{shown}</Suspense>; }\n if (media?.media === 'pdf') { const PdfPane = lazyView(media.load); return <Suspense fallback={loading}><PdfPane name={nameOf(path)} bytes={file.bytes} onRefresh={load} {...common} />{shown}</Suspense>; }\n const { text } = file, size = file.bytes.byteLength;\n return <ViewerFrame title={nameOf(path)} {...common} subtitle={<>{subtitleOf(locator)}<span aria-hidden=\"true\">·</span><span>{formatBytes(size)}</span></>}\n onRefresh={load} {...(text === undefined ? {} : { onCopy: () => copyText(text), onDownload: () => downloadFile(nameOf(path), text, 'text/plain;charset=utf-8') })}>\n {blob?.kind === 'unsupported-version' && <p role=\"note\" data-testid=\"blob-newer-format\" className=\"m-0 px-3 pt-3 text-sm text-muted-foreground\">{labels.newerBlobFormat}</p>}\n {text === undefined\n ? <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.binaryFile(formatBytes(size))}</p>\n : <div className=\"min-h-0 flex-1 overflow-auto p-3\"><pre data-testid=\"file-preview\" className=\"m-0 font-mono text-sm leading-relaxed break-words whitespace-pre-wrap\">{text}</pre></div>}\n </ViewerFrame>;\n}\n\n/**\n * One file in the panel. `path` is the host's name for it (shown in the share target and `data-path`); `locator` is the resource the\n * client reads. Remount per file with a `key`. `onBack` adds a back button to the bar (for example to the host's file list).\n */\nexport function FileViewer({ path, locator, options, onClose, onBack, backLabel, onUnsavedChange, onBeforeLeaveChange }: {\n readonly path: string;\n readonly locator: ResourceLocator;\n readonly options: ViewerOptions;\n readonly onClose: () => void;\n readonly onBack?: (() => void) | undefined;\n readonly backLabel?: string | undefined;\n /** Built-in editable viewers report unsaved and unconfirmed changes for navigation guards. */\n readonly onUnsavedChange?: ((unsaved: boolean) => void) | undefined;\n /** The Markdown autosave barrier, for flush-before-navigation guards. Null for explicit-save viewers. */\n readonly onBeforeLeaveChange?: ((flush: (() => Promise<boolean>) | null) => void) | undefined;\n}) {\n const kind = viewerKindOf(path);\n const { labels, icons } = useAppText();\n const back = onBack ? <Button variant=\"ghost\" size=\"sm\" data-testid=\"file-back\" aria-label={backLabel ?? labels.back} title={backLabel ?? labels.back} onClick={onBack} className=\"shrink-0\"><icons.back data-icon=\"inline-start\" aria-hidden=\"true\" /><span className=\"max-sm:sr-only\">{backLabel ?? labels.back}</span></Button> : undefined;\n const common: Common = { path, kind, locator, options, onClose, back, onUnsavedChange, onBeforeLeaveChange };\n const locatorKey = JSON.stringify(locator);\n const artifact = useMemo(() => fileArtifact(path, locator), [path, locatorKey]); // eslint-disable-line react-hooks/exhaustive-deps\n // One resolution through the kinds, whoever opened the file: a host viewer for the file kind wins over a document kind (a `fallback`\n // one), not over any other kind. A drawing kind (a maquette, a diagram, an app's) shows the file in the artifact panel.\n const kinds = options.kinds ?? noKinds;\n const found = artifactKind(artifact, kinds.filter(entry => drawsText(entry) || isMediaKind(entry)));\n const custom = found && !found.fallback ? undefined : options.viewers?.[kind];\n const resolved = custom ? undefined : found;\n const drawn = resolved && !isEditorKind(resolved) && !isMediaKind(resolved) ? artifact : undefined;\n if (drawn) return <div data-testid=\"file-viewer\" data-kind=\"artifact\" data-path={path} aria-label={labels.fileViewer(drawn.title)} className=\"flex h-full min-h-0 flex-col overflow-hidden [&>*]:min-h-0 [&>*]:flex-1\">\n <ArtifactPanel active={{ descriptor: drawn, follow: true }} versions={[]} options={options} onSelect={() => {}} onClose={onClose} headerStart={back} />\n </div>;\n return <div data-testid=\"file-viewer\" data-kind={kind} data-path={path} aria-label={labels.fileViewer(KIND_LABEL[kind])} className=\"flex h-full min-h-0 flex-col overflow-hidden [&>*]:min-h-0 [&>*]:flex-1\">\n {custom ? custom({ target: locator, title: nameOf(path), frame: { titleTestId: 'file-title', onClose, target: { file: path }, headerStart: back } })\n : isEditorKind(resolved) ? <FileDocument {...common} editor={resolved} />\n // The resource client supplies bytes, not a streaming URL. Unlike images, videos must not be buffered to make an object URL.\n : kind === 'video' ? <ViewerFrame title={nameOf(path)} subtitle={subtitleOf(locator)} onClose={onClose} headerStart={back}><p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.fileNotText}</p></ViewerFrame>\n : kind === 'canvas' ? <ViewerFrame title={nameOf(path)} subtitle={subtitleOf(locator)} onClose={onClose} headerStart={back}><p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.noCanvasViewer}</p></ViewerFrame>\n : <FileBytes {...common} media={isMediaKind(resolved) ? resolved : undefined} />}\n </div>;\n}\n",
56
+ "content": "'use client';\n\n// Opens one workspace file in the viewer its kind names (`ViewerOptions.kinds`), always in the standard viewer frame (viewer-frame item):\n// Markdown and HTML in their editors (revisioned through the host's resource client, so saves go back to the file and an edit made behind\n// the editor's back is a conflict), images and PDFs read-only from the file's bytes, a drawing kind in the artifact panel, anything else as\n// text. Each viewer loads the first time its kind opens. A host viewer (`viewers[kind]`) wins over a document kind.\nimport { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { DownloadIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { randomUUID } from '@hachej/boring-files/platform';\nimport type { ResourceLocator, ResourceSnapshot } from '@hachej/boring-files';\nimport { createMarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport type { MarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport { createHtmlController } from '@hachej/boring-ui-kit/html';\nimport type { HtmlController } from '@hachej/boring-ui-kit/html';\nimport { ViewerFrame } from '../viewer-frame/viewer-frame';\nimport { downloadFile } from '../viewer-frame/download';\nimport { formatBytes } from '../viewer-frame/media';\nimport { copyText } from '../utils/utils';\nimport { mediaTypeOf, viewerKindOf } from '@hachej/boring-files/file-types';\nimport { isBlobPointerPath, resolveBlobPointer } from '@hachej/boring-files/blob';\nimport type { BlobItem } from '@hachej/boring-files/blob';\nimport type { ViewerKind as FileKind } from '@hachej/boring-files/file-types';\nimport { KIND_LABEL, decodeText } from './file-kinds';\nimport { ArtifactPanel, useReportShown } from './artifact-panel';\nimport { artifactKind } from '../pi-artifacts/artifact';\nimport type { ArtifactDescriptor, ArtifactType } from '../pi-artifacts/artifact';\nimport { LazyPane, Mounted, ViewLoading, drawsText, isEditorKind, isMediaKind, noKinds } from './viewer-kinds';\nimport type { EditorKind, MediaKind, PanelKind } from './viewer-kinds';\nimport type { ViewerOptions } from './artifact-panel';\nimport { useSaved } from './use-saved';\nimport { useAppText } from './app-labels';\n\nconst POLL_MS = 1500;\nconst nameOf = (path: string) => path.split('/').pop() || path;\n\ninterface Common {\n readonly path: string;\n readonly kind: FileKind;\n readonly locator: ResourceLocator;\n readonly options: ViewerOptions;\n readonly onClose: () => void;\n readonly back: ReactNode;\n readonly onUnsavedChange?: ((unsaved: boolean) => void) | undefined;\n readonly onBeforeLeaveChange?: ((flush: (() => Promise<boolean>) | null) => void) | undefined;\n}\n\nconst FILE_TYPE: Partial<Record<FileKind, ArtifactType>> = { markdown: 'markdown', html: 'html' };\n/** A file as the artifact its kind sees: the same descriptor a `present` of it would carry, so one kind match serves every way of opening a file. */\nconst fileArtifact = (path: string, locator: ResourceLocator): ArtifactDescriptor => ({ schema: 'boring.artifact', version: 1, title: nameOf(path), type: FILE_TYPE[viewerKindOf(path)] ?? 'code', mediaType: mediaTypeOf(path), target: locator, revision: 'latest' });\n/** The image or PDF kind of a media type (a file's, or a large file's behind a pointer). */\nconst mediaKindOf = (kinds: readonly PanelKind[], artifact: ArtifactDescriptor): MediaKind | undefined => artifactKind(artifact, kinds.filter(isMediaKind));\n\nconst subtitleOf = (locator: ResourceLocator) => <span data-testid=\"file-path\" className=\"truncate\">{locator.resource.path}</span>;\n\n/** Markdown or HTML: a revisioned controller over the file. A new revision remounts the pane; it starts in the mode the person chose. */\nfunction FileDocument({ path, editor, locator, options, onClose, back, onUnsavedChange, onBeforeLeaveChange }: Common & { readonly editor: EditorKind }) {\n const { client, identity } = options;\n const markdown = editor.editor === 'markdown';\n const create = useMemo(() => (snapshot: ResourceSnapshot): MarkdownController | HtmlController => (markdown ? createMarkdownController : createHtmlController)({\n identity, client, instanceId: randomUUID(), epoch: 'workspace', source: { kind: 'saved', snapshot } }), [markdown, client, identity]);\n const autosave = useRef<{ controller: MarkdownController | HtmlController; flush: () => Promise<boolean> } | null>(null);\n const saved = useSaved({ client, target: locator, create, beforeDispose: controller => autosave.current?.controller === controller ? autosave.current.flush() : undefined });\n const registerAutosave = useCallback((flush: (() => Promise<boolean>) | null) => {\n autosave.current = flush && saved.controller ? { controller: saved.controller, flush } : null;\n }, [saved.controller]);\n useLayoutEffect(() => {\n const controller = saved.controller;\n if (!controller) return;\n const notify = () => {\n const snapshot = controller.getSnapshot();\n onUnsavedChange?.(snapshot.dirty || snapshot.save.kind === 'pending'\n || (snapshot.save.kind === 'settled' && snapshot.save.result.kind === 'unknown'));\n };\n notify();\n const unsubscribe = controller.subscribe(notify);\n return () => { unsubscribe(); onUnsavedChange?.(false); };\n }, [saved.controller, onUnsavedChange]);\n const { labels } = useAppText();\n const [mode, setMode] = useState<string>(markdown ? 'rich' : 'preview');\n const [formattingToolbar, setFormattingToolbar] = useState<'selection' | 'pinned'>('selection');\n // The editor loads the first time a document opens: the file counts as shown once it has mounted.\n const [mounted, setMounted] = useState<string>();\n const opened = saved.kind === 'open' && saved.controller ? saved.snapshot.ref.revision : undefined;\n useReportShown(options.onShown, path, opened !== undefined && mounted === opened ? opened : undefined);\n const common = { title: nameOf(path), subtitle: subtitleOf(locator), target: { file: path }, onClose, titleTestId: 'file-title', headerStart: back, ...(options.share ? { onShare: options.share } : {}) };\n // Until the document is open the bar offers no Share, Copy or menu actions of its own: they would act on a document that is not shown.\n if (saved.kind !== 'open' || !saved.controller) {\n return <ViewerFrame title={common.title} subtitle={common.subtitle} onClose={onClose} headerStart={back}>\n <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{saved.kind === 'loading' ? labels.loading : saved.kind === 'missing' ? labels.fileMissing : labels.fileNotText}</p>\n </ViewerFrame>;\n }\n const revision = saved.snapshot.ref.revision;\n // The preview selector the journeys use keeps working: the wrapper holds the document's text.\n let pane: ReactNode;\n if (editor.editor === 'markdown') {\n pane = <LazyPane load={editor.load} key={revision} controller={saved.controller as MarkdownController} revision={revision} initialMode={mode as 'rich'} onModeChange={setMode} formattingToolbar={formattingToolbar} onFormattingToolbarChange={setFormattingToolbar} onAutosaveChange={registerAutosave} onBeforeLeaveChange={onBeforeLeaveChange} {...common} />;\n } else {\n pane = <LazyPane load={editor.load} key={revision} controller={saved.controller as HtmlController} revision={revision} initialMode={mode as 'preview'} onModeChange={setMode} {...(options.interactive ? { interactive: options.interactive } : {})} {...common} />;\n }\n return <div data-testid=\"file-preview\" className=\"h-full min-h-0\" data-revision={revision}>\n <Suspense fallback={<ViewerFrame testId=\"viewer-pending\" title={common.title} subtitle={common.subtitle} onClose={onClose} headerStart={back}><ViewLoading label={labels.loading} /></ViewerFrame>}>\n {pane}<Mounted value={revision} onMount={setMounted} />\n </Suspense>\n </div>;\n}\n\n/** Native controls only. A failed codec leaves a download instead of a broken video box. */\nfunction VideoPreview({ item }: { readonly item: BlobItem }) {\n const [failed, setFailed] = useState(false);\n const { labels } = useAppText();\n return <div className=\"flex min-h-0 min-w-0 flex-1 items-center justify-center\">\n {failed ? <div className=\"flex flex-col items-center gap-3 p-4\">\n <p role=\"status\" data-testid=\"video-unplayable\" className=\"text-muted-foreground\">{labels.videoUnreadable}</p>\n <Button nativeButton={false} render={<a href={item.url} download={item.name} />} data-testid=\"video-download\">{labels.download}</Button>\n </div> : <video src={item.url} aria-label={item.name} controls playsInline preload=\"metadata\" onError={() => setFailed(true)} className=\"h-full max-h-full w-full min-w-0 object-contain\" />}\n </div>;\n}\n\n/**\n * A large file behind a pointer, from the host's URL: images, PDFs and videos inline, anything else as a download.\n * The viewers get a URL; none reads the media bytes into the page.\n */\nfunction BlobFile({ item, subtitle, media, ...common }: { readonly item: BlobItem; readonly subtitle: ReactNode; readonly media: MediaKind | undefined; readonly onClose: () => void; readonly onRefresh: () => unknown; readonly target: unknown; readonly titleTestId: string; readonly headerStart: ReactNode; readonly onShare?: NonNullable<ViewerOptions['share']> }) {\n const { labels } = useAppText();\n const details = <>{subtitle}<span aria-hidden=\"true\">·</span><span>{formatBytes(item.size)}</span></>;\n if (media?.media === 'image') return <LazyPane load={media.load} name={item.name} mediaType={item.type} url={item.url} subtitle={details} {...common} />;\n if (media?.media === 'pdf') return <LazyPane load={media.load} name={item.name} url={item.url} subtitle={subtitle} {...common} />;\n if (item.type.startsWith('video/')) return <ViewerFrame title={item.name} subtitle={details} {...common} onDownload={() => {\n const link = Object.assign(document.createElement('a'), { href: item.url, download: item.name });\n document.body.append(link); link.click(); link.remove();\n }}><VideoPreview key={item.url} item={item} /></ViewerFrame>;\n return <ViewerFrame title={item.name} subtitle={details} {...common}>\n <div data-testid=\"blob-file\" data-blob-type={item.type} className=\"m-auto flex flex-col items-center gap-3 p-6 text-center\">\n <p className=\"m-0 text-sm text-muted-foreground\">{item.type}</p>\n <Button nativeButton={false} render={<a href={item.url} download={item.name} />} data-testid=\"blob-download\"><DownloadIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.download}</Button>\n </div>\n </ViewerFrame>;\n}\n\ntype Loaded = { readonly kind: 'loading' | 'missing' } | { readonly kind: 'ready'; readonly bytes: Uint8Array; readonly revision: string; readonly text: string | undefined };\nconst LOADING: Loaded = { kind: 'loading' };\n\n/** Image, PDF and text files: read through the resource client, refreshed on demand (and followed while open, for text). */\nfunction FileBytes({ path, kind, locator, options, onClose, back, media }: Common & { readonly media: MediaKind | undefined }) {\n const { labels } = useAppText();\n // An image or PDF pane loads the first time one opens: the file counts as shown once it has mounted.\n const [mounted, setMounted] = useState<string>();\n const targetKey = JSON.stringify(locator);\n const binding = useMemo(() => ({ client: options.client, locator, path }), [options.client, targetKey, path]); // eslint-disable-line react-hooks/exhaustive-deps\n const [stored, setStored] = useState<{ readonly binding: typeof binding; readonly value: Loaded }>();\n const file: Loaded = stored?.binding === binding ? stored.value : LOADING;\n const running = useRef<AbortController | undefined>(undefined);\n const load = useCallback(async () => {\n running.current?.abort();\n const abort = new AbortController(); running.current = abort;\n try {\n const read = await binding.client.read({ target: binding.locator, revision: { kind: 'latest' } }, abort.signal);\n if (abort.signal.aborted || running.current !== abort) return;\n if (read.kind !== 'available') {\n setStored(current => current?.binding === binding && current.value.kind === 'ready' ? current : { binding, value: { kind: 'missing' } });\n return;\n }\n const { bytes, ref } = read.snapshot;\n setStored({ binding, value: { kind: 'ready', bytes, revision: ref.revision, text: decodeText(binding.path, bytes) } });\n } catch {\n if (!abort.signal.aborted && running.current === abort) setStored(current => current?.binding === binding && current.value.kind === 'ready' ? current : { binding, value: { kind: 'missing' } });\n }\n }, [binding]);\n useEffect(() => {\n // eslint-disable-next-line react-hooks/set-state-in-effect -- load is async: it sets state when the read settles, not during the effect\n void load();\n const timer = kind === 'text' ? setInterval(() => { void load(); }, POLL_MS) : undefined;\n return () => { running.current?.abort(); clearInterval(timer); };\n }, [load, kind]);\n // A pointer file is resolved here, where it is read, once per read: the viewers below receive the item, never the pointer.\n const blob = useMemo(() => file.kind === 'ready' && isBlobPointerPath(path) ? resolveBlobPointer(file.bytes, options.blobBase) : undefined, [file, path, options.blobBase]);\n // A lazily loaded pane (an image, a PDF, a large file's viewer) counts as shown once it has mounted; the text view at once.\n const lazyPane = blob?.kind === 'blob' || !blob && !!media;\n useReportShown(options.onShown, path, file.kind === 'ready' && (!lazyPane || mounted === file.revision) ? file.revision : undefined);\n const common = { subtitle: subtitleOf(locator), target: { file: path }, onClose, titleTestId: 'file-title', headerStart: back, ...(options.share ? { onShare: options.share } : {}) };\n // Until the file is read the bar offers no Share: the image and PDF panes bring their own frame, so this one is replaced (remounted)\n // when the bytes arrive, and a Share pressed just then would copy the link with its \"Link copied\" notice lost with the old frame.\n if (file.kind !== 'ready') {\n return <ViewerFrame title={nameOf(path)} subtitle={common.subtitle} onClose={onClose} titleTestId=\"file-title\" headerStart={back}>\n <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{file.kind === 'loading' ? labels.loading : labels.fileMissing}</p>\n </ViewerFrame>;\n }\n // The bar while a view loads: `viewer-pending-*` test ids, so `viewer-more` is the view's own menu.\n const loading = <ViewerFrame testId=\"viewer-pending\" title={nameOf(path)} subtitle={common.subtitle} onClose={onClose} titleTestId=\"file-title\" headerStart={back}><ViewLoading label={labels.loading} /></ViewerFrame>;\n const shown = <Mounted value={file.revision} onMount={setMounted} />;\n if (blob?.kind === 'blob') {\n const blobMedia = mediaKindOf(options.kinds ?? noKinds, { ...fileArtifact(path, locator), mediaType: blob.item.type });\n return <Suspense fallback={loading}><BlobFile item={blob.item} media={blobMedia} onRefresh={load} {...common} />{shown}</Suspense>;\n }\n if (media?.media === 'image') return <Suspense fallback={loading}><LazyPane load={media.load} name={nameOf(path)} mediaType={mediaTypeOf(path)} bytes={file.bytes} onRefresh={load} {...common} />{shown}</Suspense>;\n if (media?.media === 'pdf') return <Suspense fallback={loading}><LazyPane load={media.load} name={nameOf(path)} bytes={file.bytes} onRefresh={load} {...common} />{shown}</Suspense>;\n const { text } = file, size = file.bytes.byteLength;\n return <ViewerFrame title={nameOf(path)} {...common} subtitle={<>{subtitleOf(locator)}<span aria-hidden=\"true\">·</span><span>{formatBytes(size)}</span></>}\n onRefresh={load} {...(text === undefined ? {} : { onCopy: () => copyText(text), onDownload: () => downloadFile(nameOf(path), text, 'text/plain;charset=utf-8') })}>\n {blob?.kind === 'unsupported-version' && <p role=\"note\" data-testid=\"blob-newer-format\" className=\"m-0 px-3 pt-3 text-sm text-muted-foreground\">{labels.newerBlobFormat}</p>}\n {text === undefined\n ? <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.binaryFile(formatBytes(size))}</p>\n : <div className=\"min-h-0 flex-1 overflow-auto p-3\"><pre data-testid=\"file-preview\" className=\"m-0 font-mono text-sm leading-relaxed break-words whitespace-pre-wrap\">{text}</pre></div>}\n </ViewerFrame>;\n}\n\n/**\n * One file in the panel. `path` is the host's name for it (shown in the share target and `data-path`); `locator` is the resource the\n * client reads. Remount per file with a `key`. `onBack` adds a back button to the bar (for example to the host's file list).\n */\nexport function FileViewer({ path, locator, options, onClose, onBack, backLabel, onUnsavedChange, onBeforeLeaveChange }: {\n readonly path: string;\n readonly locator: ResourceLocator;\n readonly options: ViewerOptions;\n readonly onClose: () => void;\n readonly onBack?: (() => void) | undefined;\n readonly backLabel?: string | undefined;\n /** Built-in editable viewers report unsaved and unconfirmed changes for navigation guards. */\n readonly onUnsavedChange?: ((unsaved: boolean) => void) | undefined;\n /** The Markdown autosave barrier, for flush-before-navigation guards. Null for explicit-save viewers. */\n readonly onBeforeLeaveChange?: ((flush: (() => Promise<boolean>) | null) => void) | undefined;\n}) {\n const kind = viewerKindOf(path);\n const { labels, icons } = useAppText();\n const back = onBack ? <Button variant=\"ghost\" size=\"sm\" data-testid=\"file-back\" aria-label={backLabel ?? labels.back} title={backLabel ?? labels.back} onClick={onBack} className=\"shrink-0\"><icons.back data-icon=\"inline-start\" aria-hidden=\"true\" /><span className=\"max-md:sr-only\">{backLabel ?? labels.back}</span></Button> : undefined;\n const common: Common = { path, kind, locator, options, onClose, back, onUnsavedChange, onBeforeLeaveChange };\n const locatorKey = JSON.stringify(locator);\n const artifact = useMemo(() => fileArtifact(path, locator), [path, locatorKey]); // eslint-disable-line react-hooks/exhaustive-deps\n // One resolution through the kinds, whoever opened the file: a host viewer for the file kind wins over a document kind (a `fallback`\n // one), not over any other kind. A drawing kind (a maquette, a diagram, an app's) shows the file in the artifact panel.\n const kinds = options.kinds ?? noKinds;\n const found = artifactKind(artifact, kinds.filter(entry => drawsText(entry) || isMediaKind(entry)));\n const custom = found && !found.fallback ? undefined : options.viewers?.[kind];\n const resolved = custom ? undefined : found;\n const drawn = resolved && !isEditorKind(resolved) && !isMediaKind(resolved) ? artifact : undefined;\n if (drawn) return <div data-testid=\"file-viewer\" data-kind=\"artifact\" data-path={path} aria-label={labels.fileViewer(drawn.title)} className=\"flex h-full min-h-0 flex-col overflow-hidden [&>*]:min-h-0 [&>*]:flex-1\">\n <ArtifactPanel active={{ descriptor: drawn, follow: true }} versions={[]} options={options} onSelect={() => {}} onClose={onClose} headerStart={back} />\n </div>;\n return <div data-testid=\"file-viewer\" data-kind={kind} data-path={path} aria-label={labels.fileViewer(KIND_LABEL[kind])} className=\"flex h-full min-h-0 flex-col overflow-hidden [&>*]:min-h-0 [&>*]:flex-1\">\n {custom ? custom({ target: locator, title: nameOf(path), frame: { titleTestId: 'file-title', onClose, target: { file: path }, headerStart: back } })\n : isEditorKind(resolved) ? <FileDocument {...common} editor={resolved} />\n // The resource client supplies bytes, not a streaming URL. Unlike images, videos must not be buffered to make an object URL.\n : kind === 'video' ? <ViewerFrame title={nameOf(path)} subtitle={subtitleOf(locator)} onClose={onClose} headerStart={back}><p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.fileNotText}</p></ViewerFrame>\n : kind === 'canvas' ? <ViewerFrame title={nameOf(path)} subtitle={subtitleOf(locator)} onClose={onClose} headerStart={back}><p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.noCanvasViewer}</p></ViewerFrame>\n : <FileBytes {...common} media={isMediaKind(resolved) ? resolved : undefined} />}\n </div>;\n}\n",
67
57
  "type": "registry:component",
68
58
  "target": "components/pi-app/file-viewer.tsx"
69
59
  },
@@ -75,7 +65,7 @@
75
65
  },
76
66
  {
77
67
  "path": "registry/pi-app/use-saved.ts",
78
- "content": "import { useEffect, useLayoutEffect, useMemo, useState } from 'react';\nimport type { ResourceClient, ResourceLocator, ResourceSnapshot } from '@hachej/boring-files';\n\nconst POLL_MS = 1500;\nexport const readText = (bytes: Uint8Array): string | undefined => { try { return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); } catch { return undefined; } };\n\n/** What a viewer controller exposes to `useSaved`: its buffer state (never replaced while dirty or saving) and teardown. */\nexport interface SavedController {\n readonly getSnapshot: () => { readonly dirty?: boolean; readonly save?: { readonly kind: string }; readonly base?: { readonly kind: string; readonly target?: object } };\n readonly dispose: () => void;\n}\n\nexport type SavedState<C extends SavedController> =\n | { readonly kind: 'loading' | 'missing' | 'denied' | 'unavailable' | 'invalid'; readonly text?: undefined; readonly controller?: undefined }\n | { readonly kind: 'open'; readonly snapshot: ResourceSnapshot; readonly text: string; readonly controller: C | undefined };\n\n/**\n * The saved text of one resource: the latest revision (polled while the local buffer is clean, never replacing unsaved\n * edits) or one exact revision read once. `create` may return a viewer controller for each saved snapshot; it is disposed when\n * a newer revision replaces it and on unmount.\n */\nexport function useSaved<C extends SavedController = SavedController>({ client, target, revision, create, beforeDispose }: {\n readonly client: ResourceClient;\n readonly target: ResourceLocator;\n readonly revision?: string | undefined;\n readonly create?: ((snapshot: ResourceSnapshot) => C) | undefined;\n /** Owner teardown may wait for an editor's final conditional flush. Unsubscribe remains synchronous. */\n readonly beforeDispose?: ((controller: C) => Promise<unknown> | undefined) | undefined;\n}): SavedState<C> {\n const key = JSON.stringify([target.resource.providerId, target.resource.path, target.view.kind, target.view.kind === 'working' ? target.view.viewId : null, revision]);\n const binding = useMemo(() => ({ client, key, create, beforeDispose }), [client, key]); // eslint-disable-line react-hooks/exhaustive-deps\n useLayoutEffect(() => { binding.create = create; binding.beforeDispose = beforeDispose; }, [binding, create, beforeDispose]);\n const [stored, setStored] = useState<{ readonly binding: typeof binding; readonly state: SavedState<C> }>();\n useEffect(() => {\n let cancelled = false, reading = false;\n let state: SavedState<C> = { kind: 'loading' };\n const update = (next: SavedState<C>) => { state = next; setStored({ binding, state }); };\n update(state);\n const release = (controller: C | undefined) => {\n if (!controller) return;\n const flushing = binding.beforeDispose?.(controller);\n if (flushing) void flushing.finally(() => controller.dispose()).catch(() => {});\n else controller.dispose();\n };\n const follow = async () => {\n if (cancelled || reading) return;\n const now = state;\n const before = now.kind === 'open' ? now.controller?.getSnapshot() : undefined;\n if (before && (before.dirty || before.save?.kind === 'pending')) return;\n reading = true;\n let read;\n try { read = await client.read({ target, revision: revision ? { kind: 'exact', value: revision } : { kind: 'latest' } }).catch(() => undefined); }\n finally { reading = false; }\n if (cancelled || !read || state !== now) return;\n const local = now.kind === 'open' ? now.controller?.getSnapshot() : undefined;\n if (local !== before) return;\n if (read.kind !== 'available') { if (now.kind !== 'open') update({ kind: read.kind }); return; }\n if (now.kind === 'open') {\n if (now.snapshot.ref.revision === read.snapshot.ref.revision) return;\n if (local?.base?.kind === 'revision' && (local.base.target as { readonly revision?: string } | undefined)?.revision === read.snapshot.ref.revision) return;\n release(now.controller);\n }\n const text = readText(read.snapshot.bytes);\n if (text === undefined) { update({ kind: 'invalid' }); return; }\n let controller: C | undefined;\n try { controller = binding.create?.(read.snapshot); } catch { update({ kind: 'invalid' }); return; }\n update({ kind: 'open', snapshot: read.snapshot, text, controller });\n };\n void follow();\n const timer = revision ? undefined : setInterval(() => { void follow(); }, POLL_MS);\n return () => { cancelled = true; clearInterval(timer); release(state.controller); };\n }, [binding]); // eslint-disable-line react-hooks/exhaustive-deps\n return stored?.binding === binding ? stored.state : { kind: 'loading' };\n}\n\n/** A save time for a person: the clock time (with seconds, so two quick saves differ) today, the date as well on another day. */\nexport function savedLabel(savedAt: number | undefined, now = Date.now(), earlier = 'Earlier version'): string {\n if (!savedAt) return earlier;\n const day = (value: number) => new Date(value).toDateString();\n return new Date(savedAt).toLocaleString([], day(savedAt) === day(now) ? { timeStyle: 'medium' } : { dateStyle: 'medium', timeStyle: 'medium' });\n}\n",
68
+ "content": "import { useEffect, useLayoutEffect, useMemo, useState } from 'react';\nimport type { ResourceClient, ResourceLocator, ResourceSnapshot } from '@hachej/boring-files';\n\nconst POLL_MS = 1500;\nexport const readText = (bytes: Uint8Array): string | undefined => { try { return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); } catch { return undefined; } };\n\n/** What a viewer controller exposes to `useSaved`: its buffer state (never replaced while dirty or saving) and teardown. */\nexport interface SavedController {\n readonly getSnapshot: () => { readonly dirty?: boolean; readonly save?: { readonly kind: string }; readonly base?: { readonly kind: string; readonly target?: object } };\n readonly dispose: () => void;\n}\n\nexport type SavedState<C extends SavedController> =\n | { readonly kind: 'loading' | 'missing' | 'denied' | 'unavailable' | 'invalid'; readonly text?: undefined; readonly controller?: undefined }\n | { readonly kind: 'open'; readonly snapshot: ResourceSnapshot; readonly text: string; readonly controller: C | undefined };\n\n/**\n * The saved text of one resource: the latest revision (polled while the local buffer is clean, never replacing unsaved\n * edits) or one exact revision read once. `create` may return a viewer controller for each saved snapshot; it is disposed when\n * a newer revision replaces it and on unmount.\n */\nexport function useSaved<C extends SavedController = SavedController>({ client, target, revision, create, beforeDispose }: {\n readonly client: ResourceClient;\n readonly target: ResourceLocator;\n readonly revision?: string | undefined;\n readonly create?: ((snapshot: ResourceSnapshot) => C) | undefined;\n /** Owner teardown may wait for an editor's final conditional flush. Unsubscribe remains synchronous. */\n readonly beforeDispose?: ((controller: C) => Promise<unknown> | undefined) | undefined;\n}): SavedState<C> {\n const key = JSON.stringify([target.resource.providerId, target.resource.path, target.view.kind, target.view.kind === 'working' ? target.view.viewId : null, revision]);\n const binding = useMemo(() => ({ client, key, create, beforeDispose }), [client, key]); // eslint-disable-line react-hooks/exhaustive-deps\n // eslint-disable-next-line react-hooks/immutability -- the binding is this hook's own mutable holder (one per client and key); the open revision reads the latest callbacks from it\n useLayoutEffect(() => { binding.create = create; binding.beforeDispose = beforeDispose; }, [binding, create, beforeDispose]);\n const [stored, setStored] = useState<{ readonly binding: typeof binding; readonly state: SavedState<C> }>();\n useEffect(() => {\n let cancelled = false, reading = false;\n let state: SavedState<C> = { kind: 'loading' };\n const update = (next: SavedState<C>) => { state = next; setStored({ binding, state }); };\n update(state);\n const release = (controller: C | undefined) => {\n if (!controller) return;\n const flushing = binding.beforeDispose?.(controller);\n if (flushing) void flushing.finally(() => controller.dispose()).catch(() => {});\n else controller.dispose();\n };\n const follow = async () => {\n if (cancelled || reading) return;\n const now = state;\n const before = now.kind === 'open' ? now.controller?.getSnapshot() : undefined;\n if (before && (before.dirty || before.save?.kind === 'pending')) return;\n reading = true;\n let read;\n try { read = await client.read({ target, revision: revision ? { kind: 'exact', value: revision } : { kind: 'latest' } }).catch(() => undefined); }\n finally { reading = false; }\n if (cancelled || !read || state !== now) return;\n const local = now.kind === 'open' ? now.controller?.getSnapshot() : undefined;\n if (local !== before) return;\n if (read.kind !== 'available') { if (now.kind !== 'open') update({ kind: read.kind }); return; }\n if (now.kind === 'open') {\n if (now.snapshot.ref.revision === read.snapshot.ref.revision) return;\n if (local?.base?.kind === 'revision' && (local.base.target as { readonly revision?: string } | undefined)?.revision === read.snapshot.ref.revision) return;\n release(now.controller);\n }\n const text = readText(read.snapshot.bytes);\n if (text === undefined) { update({ kind: 'invalid' }); return; }\n let controller: C | undefined;\n try { controller = binding.create?.(read.snapshot); } catch { update({ kind: 'invalid' }); return; }\n update({ kind: 'open', snapshot: read.snapshot, text, controller });\n };\n void follow();\n const timer = revision ? undefined : setInterval(() => { void follow(); }, POLL_MS);\n return () => { cancelled = true; clearInterval(timer); release(state.controller); };\n }, [binding]); // eslint-disable-line react-hooks/exhaustive-deps\n return stored?.binding === binding ? stored.state : { kind: 'loading' };\n}\n\n/** A save time for a person: the clock time (with seconds, so two quick saves differ) today, the date as well on another day. */\nexport function savedLabel(savedAt: number | undefined, now = Date.now(), earlier = 'Earlier version'): string {\n if (!savedAt) return earlier;\n const day = (value: number) => new Date(value).toDateString();\n return new Date(savedAt).toLocaleString([], day(savedAt) === day(now) ? { timeStyle: 'medium' } : { dateStyle: 'medium', timeStyle: 'medium' });\n}\n",
79
69
  "type": "registry:hook",
80
70
  "target": "components/pi-app/use-saved.ts"
81
71
  },
@@ -111,13 +101,13 @@
111
101
  },
112
102
  {
113
103
  "path": "registry/pi-app/use-revision-attachments.ts",
114
- "content": "'use client';\n\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport type { RevisionProvider } from '@hachej/boring-files/revision';\nimport { createFileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport type { FileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport type { AttachmentsConfig } from '../pi-chat/config';\nimport { uploadRevisionAttachments } from './revision-files';\n\nexport interface RevisionAttachmentsOptions {\n /** The workspace the files are saved in, under `uploads/`. Absent: no attachments. */\n readonly revisionProvider?: RevisionProvider | undefined;\n /**\n * The tree controller to save through. `AgentWorkspace` passes its Library's, so the page keeps one per workspace. Absent: the hook makes\n * one from `revisionProvider` and disposes it with the binding.\n */\n readonly fileTree?: FileTreeController | undefined;\n /** The picker's MIME/extension list (`image/*,.pdf`). Other types are refused. Any file when omitted. */\n readonly accept?: string | undefined;\n /** The host's size limit in bytes: a larger file is refused before it is read, with `tooLarge` as its reason. No limit when omitted. */\n readonly maxBytes?: number | undefined;\n readonly tooLarge?: string | undefined;\n /** Send a png, jpeg, gif or webp as a native image in the message too (off by default: the message carries only the `@path`). */\n readonly inlineImages?: boolean | undefined;\n /** A file that could not be saved, with its reason. Partial failures are reported here; an upload that saves nothing rejects with the reason. */\n readonly onPreparationFailure?: ((name: string, reason: string) => void) | undefined;\n}\n\n/**\n * The `attachments` config `PiChat` takes, saved through a revision provider: each file is written under `uploads/` with a short random suffix\n * (`uploadRevisionAttachments`), refused when it is over `maxBytes` or not in `accept`. Works for a bare `PiChat` as well as `AgentWorkspace`,\n * which passes its own `fileTree`. Returns `undefined` until there is a tree to save through.\n */\nexport function useRevisionAttachments({ revisionProvider, fileTree, accept, maxBytes, tooLarge, inlineImages = false, onPreparationFailure }: RevisionAttachmentsOptions): AttachmentsConfig | undefined {\n const [binding, setBinding] = useState<{ readonly provider: RevisionProvider; readonly tree: FileTreeController }>();\n useEffect(() => {\n if (fileTree || !revisionProvider) return;\n const tree = createFileTreeController({ revisionProvider });\n setBinding({ provider: revisionProvider, tree });\n return () => tree.dispose();\n }, [fileTree, revisionProvider]);\n const tree = fileTree ?? (binding?.provider === revisionProvider ? binding?.tree : undefined);\n // The failure callback is read when an upload starts, so a new callback does not rebuild the config, and a late failure of an upload\n // reports to the workspace it was started in (not the one selected since).\n const failure = useRef(onPreparationFailure); failure.current = onPreparationFailure;\n return useMemo<AttachmentsConfig | undefined>(() => tree ? {\n ...(accept !== undefined ? { accept } : {}),\n upload: (files: File[], signal: AbortSignal) => {\n const report = failure.current;\n return uploadRevisionAttachments(tree, files, signal, (name, reason) => report?.(name, reason),\n { inlineImages, ...(maxBytes !== undefined ? { maxBytes, tooLarge } : {}), ...(accept ? { accept } : {}) });\n },\n } : undefined, [tree, accept, inlineImages, maxBytes, tooLarge]);\n}\n",
104
+ "content": "'use client';\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { RevisionProvider } from '@hachej/boring-files/revision';\nimport { createFileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport type { FileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport type { AttachmentsConfig } from '../pi-chat/config';\nimport { uploadRevisionAttachments } from './revision-files';\n\nexport interface RevisionAttachmentsOptions {\n /** The workspace the files are saved in, under `uploads/`. Absent: no attachments. */\n readonly revisionProvider?: RevisionProvider | undefined;\n /**\n * The tree controller to save through. `AgentWorkspace` passes its Library's, so the page keeps one per workspace. Absent: the hook makes\n * one from `revisionProvider` and disposes it with the binding.\n */\n readonly fileTree?: FileTreeController | undefined;\n /** The picker's MIME/extension list (`image/*,.pdf`). Other types are refused. Any file when omitted. */\n readonly accept?: string | undefined;\n /** The host's size limit in bytes: a larger file is refused before it is read, with `tooLarge` as its reason. No limit when omitted. */\n readonly maxBytes?: number | undefined;\n readonly tooLarge?: string | undefined;\n /** Send a png, jpeg, gif or webp as a native image in the message too (off by default: the message carries only the `@path`). */\n readonly inlineImages?: boolean | undefined;\n /** A file that could not be saved, with its reason. Partial failures are reported here; an upload that saves nothing rejects with the reason. */\n readonly onPreparationFailure?: ((name: string, reason: string) => void) | undefined;\n}\n\n/**\n * The `attachments` config `PiChat` takes, saved through a revision provider: each file is written under `uploads/` with a short random suffix\n * (`uploadRevisionAttachments`), refused when it is over `maxBytes` or not in `accept`. Works for a bare `PiChat` as well as `AgentWorkspace`,\n * which passes its own `fileTree`. Returns `undefined` until there is a tree to save through.\n */\nexport function useRevisionAttachments({ revisionProvider, fileTree, accept, maxBytes, tooLarge, inlineImages = false, onPreparationFailure }: RevisionAttachmentsOptions): AttachmentsConfig | undefined {\n const [binding, setBinding] = useState<{ readonly provider: RevisionProvider; readonly tree: FileTreeController }>();\n useEffect(() => {\n if (fileTree || !revisionProvider) return;\n const tree = createFileTreeController({ revisionProvider });\n // eslint-disable-next-line react-hooks/set-state-in-effect -- this effect owns the disposable tree (created and disposed here, StrictMode-safe); state only publishes it\n setBinding({ provider: revisionProvider, tree });\n return () => tree.dispose();\n }, [fileTree, revisionProvider]);\n const tree = fileTree ?? (binding?.provider === revisionProvider ? binding?.tree : undefined);\n // The failure callback is read when an upload starts, so a new callback does not rebuild the config, and a late failure of an upload\n // reports to the workspace it was started in (not the one selected since).\n const failure = useRef(onPreparationFailure);\n useLayoutEffect(() => { failure.current = onPreparationFailure; });\n return useMemo<AttachmentsConfig | undefined>(() => tree ? {\n ...(accept !== undefined ? { accept } : {}),\n upload: (files: File[], signal: AbortSignal) => {\n const report = failure.current;\n return uploadRevisionAttachments(tree, files, signal, (name, reason) => report?.(name, reason),\n { inlineImages, ...(maxBytes !== undefined ? { maxBytes, tooLarge } : {}), ...(accept ? { accept } : {}) });\n },\n } : undefined, [tree, accept, inlineImages, maxBytes, tooLarge]);\n}\n",
115
105
  "type": "registry:hook",
116
106
  "target": "components/pi-app/use-revision-attachments.ts"
117
107
  },
118
108
  {
119
109
  "path": "registry/pi-app/viewer-kinds.tsx",
120
- "content": "'use client';\n\n// What a kind is to the artifact panel and the file viewer (D-257): an app's `ArtifactKind`, or a document kind (Markdown and HTML edited\n// over a revisioned controller; image and PDF read as bytes). These are contracts only: the kinds themselves come from the host, through\n// `AgentWorkspace`'s `artifactKinds` or `ViewerOptions.kinds`. The pi-app-kinds item exports the default list (`defaultKinds`), whose\n// kinds (the viewer items' `markdownKind`, `imageKind`...) satisfy these types structurally; none of the viewers is in this item.\nimport { lazy, useEffect } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { LoaderCircleIcon } from 'lucide-react';\nimport type { MarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport type { HtmlController } from '@hachej/boring-ui-kit/html';\nimport type { MarkdownEditorProps } from '@hachej/boring-ui-kit/markdown-editor';\nimport type { ArtifactKind, ArtifactKindViewProps } from '../pi-artifacts/artifact';\nimport type { ViewerShare } from '../viewer-frame/viewer-frame';\nimport type { InteractiveHtml } from '../viewer-frame/interactive-html';\nimport type { MediaSource } from '../viewer-frame/media';\n\n/** The viewer bar's props the panel and the file viewer give every pane a document or media kind loads. */\nexport interface PaneBarProps {\n readonly title?: string;\n readonly subtitle?: ReactNode;\n readonly target?: unknown;\n readonly revision?: string;\n readonly headerStart?: ReactNode;\n readonly controls?: ReactNode;\n readonly onRefresh?: () => unknown;\n readonly onShare?: ViewerShare;\n readonly onClose?: () => unknown;\n readonly testId?: string;\n readonly titleTestId?: string;\n}\n\n/** What a Markdown kind's pane receives (the viewer-markdown item's `MarkdownPane` takes these and more). */\nexport interface MarkdownPaneInput extends PaneBarProps {\n readonly controller: MarkdownController;\n readonly initialMode?: 'rich' | 'source';\n readonly onModeChange?: (mode: 'rich' | 'source') => void;\n readonly formattingToolbar?: MarkdownEditorProps['formattingToolbar'];\n readonly onFormattingToolbarChange?: (toolbar: 'selection' | 'pinned') => void;\n readonly onAutosaveChange?: MarkdownEditorProps['onAutosaveChange'];\n readonly onBeforeLeaveChange?: MarkdownEditorProps['onAutosaveChange'];\n}\n\n/** What an HTML kind's pane receives (the viewer-html item's `HtmlPane`). */\nexport interface HtmlPaneInput extends PaneBarProps {\n readonly controller: HtmlController;\n readonly initialMode?: 'preview' | 'source';\n readonly onModeChange?: (mode: 'preview' | 'source') => void;\n readonly interactive?: InteractiveHtml;\n}\n\n/** What an image kind's pane receives (the viewer-image item's `ImagePane`): the bytes, a blob or the host's URL of a large file. */\nexport interface ImagePaneInput extends PaneBarProps, MediaSource { readonly name: string; readonly mediaType: string }\n\n/** What a PDF kind's pane receives (the viewer-pdf item's `PdfPane`). */\nexport interface PdfPaneInput extends PaneBarProps, MediaSource { readonly name: string }\n\n/** What a code kind's view receives (the viewer-code item's SVG and CSV views): of the panel's kind props, these. */\nexport interface CodeViewInput {\n readonly text: string;\n readonly artifact: { readonly title: string };\n readonly setControls?: ((controls: ReactNode) => void) | undefined;\n}\n\ntype KindHead = Pick<ArtifactKind, 'match' | 'label' | 'icon' | 'fallback'>;\ntype Loads<Props> = { readonly load: () => Promise<{ readonly default: ComponentType<Props> }> };\n\n/** A kind drawn by a view of the panel's text that reads fewer props than an `ArtifactKind` view (SVG, CSV). */\nexport type CodeKind = KindHead & Loads<CodeViewInput>;\n/** A document kind edited over a revisioned controller (Markdown, HTML): the panel and the file viewer create the controller. */\nexport type EditorKind = KindHead & ((Loads<MarkdownPaneInput> & { readonly editor: 'markdown' }) | (Loads<HtmlPaneInput> & { readonly editor: 'html' }));\n/** A kind that reads a file's bytes rather than its text (image, PDF): the file viewer draws it. */\nexport type MediaKind = KindHead & ((Loads<ImagePaneInput> & { readonly media: 'image' }) | (Loads<PdfPaneInput> & { readonly media: 'pdf' }));\n/** Any kind the panel and the file viewer take: an app's `ArtifactKind`, a code kind, an editor kind or a media kind. */\nexport type PanelKind = ArtifactKind | CodeKind | EditorKind | MediaKind;\n\n/** What the panel and the file viewer resolve through when the host passes no kinds: none, so every file shows as text. */\nexport const noKinds: readonly PanelKind[] = [];\n\nexport const isEditorKind = (kind: PanelKind | undefined): kind is EditorKind => !!kind && 'editor' in kind;\nexport const isMediaKind = (kind: PanelKind | undefined): kind is MediaKind => !!kind && 'media' in kind;\n\n/** A kind that draws an artifact's text in the panel: an editor kind, or one with a renderer or a lazily loaded view (not image or PDF). */\nexport const drawsText = (kind: PanelKind): boolean => !isMediaKind(kind) && (isEditorKind(kind) || ('renderer' in kind && !!kind.renderer) || !!kind.load);\n\n// One lazy component per loader, for the life of the page: a kind recreated on each render (a fresh comments object) keeps its loader\n// identity when it is a module constant, and a loader seen again never re-suspends.\nconst views = new WeakMap<object, unknown>();\n\n/** The `React.lazy` component of a kind's `load`, created once per loader. */\nexport function lazyView<Props>(load: () => Promise<{ readonly default: ComponentType<Props> }>): ComponentType<Props> {\n let view = views.get(load) as ComponentType<Props> | undefined;\n if (!view) { view = lazy(load) as unknown as ComponentType<Props>; views.set(load, view); }\n return view;\n}\n\nconst renderers = new WeakMap<object, (props: ArtifactKindViewProps) => ReactNode>();\n\n/** A kind's `load` as a renderer (one per loader): the panel draws it like a kind's own `renderer`, inside its `Suspense`. */\n// A view takes the panel's props or fewer of them (the code views read only `text`, `artifact` and `setControls`).\nexport function lazyRenderer(load: () => Promise<{ readonly default: unknown }>): (props: ArtifactKindViewProps) => ReactNode {\n let render = renderers.get(load);\n if (!render) { const View = lazyView(load as () => Promise<{ readonly default: ComponentType<ArtifactKindViewProps> }>); render = props => <View {...props} />; renderers.set(load, render); }\n return render;\n}\n\n/** The spinner a viewer bar shows while its view loads. */\nexport function ViewLoading({ label }: { readonly label: string }) {\n return <p data-testid=\"viewer-loading\" role=\"status\" aria-label={label} className=\"m-0 flex flex-1 items-center justify-center p-6 text-muted-foreground\">\n <LoaderCircleIcon className=\"size-5 animate-spin\" aria-hidden=\"true\" />\n </p>;\n}\n\n/** Calls `onMount(value)` once it commits: inside a `Suspense`, that is only after the lazy view beside it has loaded and mounted. */\nexport function Mounted({ value, onMount }: { readonly value: string; readonly onMount: (value: string) => void }) {\n useEffect(() => { onMount(value); }, [value, onMount]);\n return null;\n}\n",
110
+ "content": "'use client';\n\n// What a kind is to the artifact panel and the file viewer (D-257): an app's `ArtifactKind`, or a document kind (Markdown and HTML edited\n// over a revisioned controller; image and PDF read as bytes). These are contracts only: the kinds themselves come from the host, through\n// `AgentWorkspace`'s `artifactKinds` or `ViewerOptions.kinds`. The pi-app-kinds item exports the default list (`defaultKinds`), whose\n// kinds (the viewer items' `markdownKind`, `imageKind`...) satisfy these types structurally; none of the viewers is in this item.\nimport { lazy, useEffect } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { LoaderCircleIcon } from 'lucide-react';\nimport type { MarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport type { HtmlController } from '@hachej/boring-ui-kit/html';\nimport type { MarkdownEditorProps } from '@hachej/boring-ui-kit/markdown-editor';\nimport type { ArtifactKind, ArtifactKindViewProps } from '../pi-artifacts/artifact';\nimport type { ViewerShare } from '../viewer-frame/viewer-frame';\nimport type { InteractiveHtml } from '../viewer-frame/interactive-html';\nimport type { MediaSource } from '../viewer-frame/media';\n\n/** The viewer bar's props the panel and the file viewer give every pane a document or media kind loads. */\nexport interface PaneBarProps {\n readonly title?: string;\n readonly subtitle?: ReactNode;\n readonly target?: unknown;\n readonly revision?: string;\n readonly headerStart?: ReactNode;\n readonly controls?: ReactNode;\n readonly onRefresh?: () => unknown;\n readonly onShare?: ViewerShare;\n readonly onClose?: () => unknown;\n readonly testId?: string;\n readonly titleTestId?: string;\n}\n\n/** What a Markdown kind's pane receives (the viewer-markdown item's `MarkdownPane` takes these and more). */\nexport interface MarkdownPaneInput extends PaneBarProps {\n readonly controller: MarkdownController;\n readonly initialMode?: 'rich' | 'source';\n readonly onModeChange?: (mode: 'rich' | 'source') => void;\n readonly formattingToolbar?: MarkdownEditorProps['formattingToolbar'];\n readonly onFormattingToolbarChange?: (toolbar: 'selection' | 'pinned') => void;\n readonly onAutosaveChange?: MarkdownEditorProps['onAutosaveChange'];\n readonly onBeforeLeaveChange?: MarkdownEditorProps['onAutosaveChange'];\n}\n\n/** What an HTML kind's pane receives (the viewer-html item's `HtmlPane`). */\nexport interface HtmlPaneInput extends PaneBarProps {\n readonly controller: HtmlController;\n readonly initialMode?: 'preview' | 'source';\n readonly onModeChange?: (mode: 'preview' | 'source') => void;\n readonly interactive?: InteractiveHtml;\n}\n\n/** What an image kind's pane receives (the viewer-image item's `ImagePane`): the bytes, a blob or the host's URL of a large file. */\nexport interface ImagePaneInput extends PaneBarProps, MediaSource { readonly name: string; readonly mediaType: string }\n\n/** What a PDF kind's pane receives (the viewer-pdf item's `PdfPane`). */\nexport interface PdfPaneInput extends PaneBarProps, MediaSource { readonly name: string }\n\n/** What a code kind's view receives (the viewer-code item's SVG and CSV views): of the panel's kind props, these. */\nexport interface CodeViewInput {\n readonly text: string;\n readonly artifact: { readonly title: string };\n readonly setControls?: ((controls: ReactNode) => void) | undefined;\n}\n\ntype KindHead = Pick<ArtifactKind, 'match' | 'label' | 'icon' | 'fallback'>;\ntype Loads<Props> = { readonly load: () => Promise<{ readonly default: ComponentType<Props> }> };\n\n/** A kind drawn by a view of the panel's text that reads fewer props than an `ArtifactKind` view (SVG, CSV). */\nexport type CodeKind = KindHead & Loads<CodeViewInput>;\n/** A document kind edited over a revisioned controller (Markdown, HTML): the panel and the file viewer create the controller. */\nexport type EditorKind = KindHead & ((Loads<MarkdownPaneInput> & { readonly editor: 'markdown' }) | (Loads<HtmlPaneInput> & { readonly editor: 'html' }));\n/** A kind that reads a file's bytes rather than its text (image, PDF): the file viewer draws it. */\nexport type MediaKind = KindHead & ((Loads<ImagePaneInput> & { readonly media: 'image' }) | (Loads<PdfPaneInput> & { readonly media: 'pdf' }));\n/** Any kind the panel and the file viewer take: an app's `ArtifactKind`, a code kind, an editor kind or a media kind. */\nexport type PanelKind = ArtifactKind | CodeKind | EditorKind | MediaKind;\n\n/** What the panel and the file viewer resolve through when the host passes no kinds: none, so every file shows as text. */\nexport const noKinds: readonly PanelKind[] = [];\n\nexport const isEditorKind = (kind: PanelKind | undefined): kind is EditorKind => !!kind && 'editor' in kind;\nexport const isMediaKind = (kind: PanelKind | undefined): kind is MediaKind => !!kind && 'media' in kind;\n\n/** A kind that draws an artifact's text in the panel: an editor kind, or one with a renderer or a lazily loaded view (not image or PDF). */\nexport const drawsText = (kind: PanelKind): boolean => !isMediaKind(kind) && (isEditorKind(kind) || ('renderer' in kind && !!kind.renderer) || !!kind.load);\n\n// One lazy component per loader, for the life of the page: a kind recreated on each render (a fresh comments object) keeps its loader\n// identity when it is a module constant, and a loader seen again never re-suspends.\nconst views = new WeakMap<object, unknown>();\n\n/** The `React.lazy` component of a kind's `load`, created once per loader. */\nexport function lazyView<Props>(load: () => Promise<{ readonly default: ComponentType<Props> }>): ComponentType<Props> {\n let view = views.get(load) as ComponentType<Props> | undefined;\n if (!view) { view = lazy(load) as unknown as ComponentType<Props>; views.set(load, view); }\n return view;\n}\n\n/** A kind's lazy view as an element (`lazyView` gives one component per loader, so its identity holds across renders). */\nconst lazyElement = <Props extends object>(load: () => Promise<{ readonly default: ComponentType<Props> }>, props: Props): ReactNode => { const View = lazyView(load); return <View {...props} />; };\n/** Draws a kind's `load` with the given props; a host can key it like any element. */\nexport function LazyPane<Props extends object>({ load, ...props }: { readonly load: () => Promise<{ readonly default: ComponentType<Props> }> } & Props) { return lazyElement(load, props as unknown as Props); }\n\nconst renderers = new WeakMap<object, (props: ArtifactKindViewProps) => ReactNode>();\n\n/** A kind's `load` as a renderer (one per loader): the panel draws it like a kind's own `renderer`, inside its `Suspense`. */\n// A view takes the panel's props or fewer of them (the code views read only `text`, `artifact` and `setControls`).\nexport function lazyRenderer(load: () => Promise<{ readonly default: unknown }>): (props: ArtifactKindViewProps) => ReactNode {\n let render = renderers.get(load);\n if (!render) { const View = lazyView(load as () => Promise<{ readonly default: ComponentType<ArtifactKindViewProps> }>); render = props => <View {...props} />; renderers.set(load, render); }\n return render;\n}\n\n/** The spinner a viewer bar shows while its view loads. */\nexport function ViewLoading({ label }: { readonly label: string }) {\n return <p data-testid=\"viewer-loading\" role=\"status\" aria-label={label} className=\"m-0 flex flex-1 items-center justify-center p-6 text-muted-foreground\">\n <LoaderCircleIcon className=\"size-5 animate-spin\" aria-hidden=\"true\" />\n </p>;\n}\n\n/** Calls `onMount(value)` once it commits: inside a `Suspense`, that is only after the lazy view beside it has loaded and mounted. */\nexport function Mounted({ value, onMount }: { readonly value: string; readonly onMount: (value: string) => void }) {\n useEffect(() => { onMount(value); }, [value, onMount]);\n return null;\n}\n",
121
111
  "type": "registry:component",
122
112
  "target": "components/pi-app/viewer-kinds.tsx"
123
113
  }