@hachej/boring-ui-kit 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/remote-chat.d.ts +5 -0
- package/dist/remote-chat.js +2 -1
- package/package.json +2 -2
- package/r/app-shell.json +122 -0
- package/r/button.json +2 -2
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +51 -8
- package/r/file-tree.json +3 -3
- package/r/html-viewer.json +2 -2
- package/r/markdown-editor.json +2 -2
- package/r/pi-ambient.json +6 -6
- package/r/pi-app.json +15 -9
- package/r/pi-chat.json +17 -11
- package/r/pi-workspace.json +2 -2
- package/r/provider-setup.json +2 -2
- package/r/registry.json +205 -44
- package/r/task-list-viewer.json +2 -2
- package/r/utils.json +1 -1
- package/r/viewers.json +5 -5
package/r/pi-app.json
CHANGED
|
@@ -4,24 +4,24 @@
|
|
|
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 viewers (artifact panel with versions, file viewer, auto-open of new artifacts) in the ArtifactWorkspace panel, a full-screen sheet on a phone. 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.2.
|
|
8
|
-
"@hachej/boring-files@^0.2.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.2.5",
|
|
8
|
+
"@hachej/boring-files@^0.2.5",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"lucide-react@^1.51.0",
|
|
12
12
|
"radix-ui@^1.8.0"
|
|
13
13
|
],
|
|
14
14
|
"registryDependencies": [
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
17
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
18
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
19
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.5/r/pi-chat.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.5/r/pi-workspace.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.5/r/viewers.json",
|
|
18
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.5/r/utils.json",
|
|
19
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.5/r/button.json"
|
|
20
20
|
],
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": "registry/pi-app/agent-workspace.tsx",
|
|
24
|
-
"content": "'use client';\n\nimport '@hachej/boring-ui-kit/file-tree.css';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ResourceLocator } from '@hachej/boring-files';\nimport { createResourceClient } from '@hachej/boring-files/remote';\nimport { workspacePath } from '@hachej/boring-files/file-types';\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 '@hachej/boring-ui-kit/file-tree-view';\nimport { frenchAppLabels } from './app-labels-fr';\nimport { chatTextFor } from '../pi-chat/locale';\nimport type { ChatLocale } from '../pi-chat/locale';\nimport type { FileTreeViewProps } from '@hachej/boring-ui-kit/file-tree-view';\nimport type { ResourceIdentity } from '@hachej/boring-files/remote';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { PiChat, artifactKey } from '../pi-chat/pi-chat';\nimport { createPresentAcks, pendingPresents } from '../pi-chat/artifact';\nimport type { PresentAcks } from '../pi-chat/artifact';\nimport type { ArtifactDescriptor, ArtifactsConfig, 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 '../viewers/viewer-frame';\nimport type { ViewerShare } from '../viewers/viewer-frame';\nimport type { InteractiveHtml } from '../viewers/interactive-html';\nimport { Button } from '../button/button';\nimport type { BlockAction } from '../button/actions';\nimport { ChatTextProvider, useMergedText } from '../pi-chat/labels';\nimport { cn, 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 { uploadRevisionAttachments } from './revision-files';\nimport { SessionsPane, SessionsToggle } from './sessions';\nimport type { SessionsView, WorkspaceAgents } from './sessions';\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 type { SessionsView, WorkspaceAgent, WorkspaceAgents } from './sessions';\nexport { useConversations } from './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 = () => () => {};\n\n/** Where the viewers read and write: the host's resource handler (`createResourceHandler` of `@hachej/boring-files/remote`) and its file history. */\nexport interface WorkspaceResources {\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 * Where the Library of a `revisionProvider` opens. `side` (default): 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?: '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'> | 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: NativeChatController | 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 /** Recognise artifacts in tool results that carry no descriptor (`ArtifactsConfig.detect`). */\n readonly detect?: ArtifactsConfig['detect'] | 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 session-scoped layout keys (panel width, floating chat, collapsed sessions). */\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?: never }\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 = 288, LIBRARY_WIDTH = 320, CHAT_MIN = 340, 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({ controller, conversationId, chat = {}, labels, icons, panelActions, connecting, conversations, resources, revisionProvider, libraryPlacement = 'side', locale, fileTree: treeOptions, uploads, agents, detect, viewers, interactive, share, opened: controlled,\n defaultOpened = null, onOpenedChange, onUnsavedChange, panels, autoOpen = true, fileBack, floatingChat, chatTop, controls, storageKey = 'boring.agent-workspace', sheetBelow = 768, drawerBelow = 768, floatBelow, className }: AgentWorkspaceProps) {\n const [own, setOwn] = useState<OpenedView | null>(defaultOpened);\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 /** Opens `next` and reports whether it did: leaving a file with unsaved changes asks first, and the person may stay. */\n const setOpened = useCallback((next: OpenedView | null): boolean => {\n const current = currentView.current;\n if (isFile(current) && isFile(next) && current.path === next.path) return true;\n if (unsaved.current && !globalThis.confirm?.('This file has unsaved or unconfirmed changes. Discard the local draft and leave this file?')) return false;\n noteUnsaved(false);\n if (!isControlled) setOwn(next); change.current?.(next);\n return true;\n }, [isControlled, noteUnsaved]);\n const [fullscreen, setFullscreen] = useState(false);\n const [centerMode, setCenterMode] = useState<'chat' | 'library'>('chat');\n const libraryInPane = Boolean(revisionProvider) && libraryPlacement === 'pane';\n const librarySide = Boolean(revisionProvider) && libraryPlacement === 'side';\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 libraryOpen = Boolean(revisionProvider) && libraryPlacement === 'center' && centerMode === 'library';\n const openLibrary = useCallback(() => { setCenterMode('library'); setFullscreen(false); }, []);\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 const fetcher = useRef(resources?.fetch); fetcher.current = resources?.fetch;\n const endpoint = resources ? String(resources.endpoint) : undefined;\n const historyEndpoint = resources?.history === undefined ? undefined : String(resources.history);\n const identity = revisionProvider?.identity ?? resources!.identity;\n const identityKey = JSON.stringify(identity);\n const client = useMemo(() => revisionProvider ?? createResourceClient({ identity, endpoint: new URL(endpoint!, globalThis.location?.href), publication: true, reconciliation: true,\n fetch: request => fetcher.current!(request) }), [revisionProvider, endpoint, identityKey]); // eslint-disable-line react-hooks/exhaustive-deps\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 // ---- 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-chat/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 const options: ViewerOptions = useMemo(() => ({ client, identity, history, viewers, interactive, share, onShown }), [client, identityKey, history, viewers, interactive, share, onShown]); // eslint-disable-line react-hooks/exhaustive-deps\n const locate = revisionProvider?.locate ?? resources?.locate ?? defaultLocate;\n const [fileBinding, setFileBinding] = useState<{ readonly provider: RevisionProvider; readonly tree: FileTreeController }>();\n useEffect(() => {\n if (!revisionProvider) return;\n const tree = createFileTreeController({ revisionProvider });\n setFileBinding({ provider: revisionProvider, tree });\n void tree.refresh();\n return () => tree.dispose();\n }, [revisionProvider]);\n const fileTree = fileBinding?.provider === revisionProvider ? fileBinding?.tree : undefined;\n // The workspace's change feed: every file view below reloads when its seq moves (agent writes, saves, shell changes).\n const feed = useChangeFeed(revisionProvider);\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 (!fileTree || (Object.is(refreshedKey.current, refreshKey) && refreshedSeq.current === feed.seq)) return;\n refreshedKey.current = refreshKey;\n refreshedSeq.current = feed.seq;\n const { query, expanded } = fileTree.getSnapshot();\n if (query) void fileTree.search(query);\n else for (const directory of ['', ...expanded]) void fileTree.refresh(directory);\n }, [fileTree, refreshKey, feed.seq]);\n const openFile = useCallback((path: string) => { setOpened({ kind: 'file', path }); }, [setOpened]);\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 } : 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 attachments = useMemo(() => fileTree ? {\n ...(uploadAccept !== undefined ? { accept: uploadAccept } : {}),\n upload: (files: File[], signal: AbortSignal) => {\n setAttachmentFailure(undefined);\n return uploadRevisionAttachments(fileTree, files, signal, (name, reason) => {\n if (fileTree.getSnapshot().lifecycle === 'active') setAttachmentFailure(current => ({ tree: fileTree,\n reasons: [...(current?.tree === fileTree ? current.reasons : []), `${name}: ${reason}`] }));\n }, { inlineImages, ...(maxBytes !== undefined ? { maxBytes, tooLarge } : {}) });\n },\n } : undefined, [fileTree, uploadAccept, inlineImages, maxBytes, tooLarge]);\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);\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 = () => { if (!setOpened(null)) return; if (conversationId !== undefined) closedInTurn.current.set(conversationId, turn); setFullscreen(false); };\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 => setOpened({ kind: 'file', path });\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 }), [conversationId, active, versions, detect, 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 [collapsed, setCollapsed] = useState(() => readFlag(`${storageKey}.sessions-hidden`));\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 { writeFlag(`${storageKey}.sessions-hidden`, false); setCollapsed(false); }\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) => fileTree\n ? <FileTreeView key={JSON.stringify([revisionProvider?.workspace, revisionProvider?.identity])} {...(locale ? { locale } : {})} {...viewOptions} controller={fileTree} historyKey={feed.seq} onOpen={onOpen} {...(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 const hasNavigation = Boolean(conversations || revisionProvider);\n const docked = hasNavigation && width > 0 && !narrow && !collapsed;\n const toggle = hasNavigation && <SessionsToggle navigation={Boolean(revisionProvider)} open={narrow ? drawer : !collapsed} drawer={narrow}\n onToggle={() => { if (narrow) setDrawer(value => !value); else setCollapsed(value => { writeFlag(`${storageKey}.sessions-hidden`, !value); return !value; }); }} />;\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} size=\"icon-sm\" aria-hidden=\"true\" tabIndex={-1} className=\"invisible -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 header = typeof controls === 'function' ? controls({ panelOpen }) : controls ?? chat.controls;\n const chatProps: PiChatProps | undefined = controller && { ...(mentionsForChat ? { mentions: mentionsForChat } : {}), ...(attachments ? { attachments } : {}), ...(locale ? { locale } : {}), ...chat, controller, artifacts, ...(conversations ? { conversations, historyList: false } : {}),\n headerStart: <>{toggle}{chat.headerStart}</>, ...(header === undefined ? {} : { controls: header }) };\n const docked_chat = <div data-testid=\"workspace-center\" className=\"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden\">\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 border-b border-border px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5\">{toggleSpace}</div>}{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 ...(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 return <AppTextProvider value={text}><ChatTextProvider value={chatText}><div ref={root} data-boring=\"agent-workspace\" data-sessions={!hasNavigation ? undefined : narrow ? (drawer ? 'drawer' : 'closed') : docked ? 'docked' : 'hidden'}\n className={cn('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)) && <SessionsPane conversations={conversations} drawer={narrow} onClose={() => setDrawer(false)} onChat={() => setCenterMode('chat')}\n agents={agents} view={paneView} onViewChange={changePaneView}\n {...(librarySide ? { library: { selected: sideOpen, onSelect: toggleSide, side: true } } : revisionProvider && !libraryInPane ? { library: { selected: libraryOpen, onSelect: openLibrary } } : {})}\n {...(libraryInPane ? { libraryContent: (onPicked: () => void) => libraryTree(path => { openFile(path); onPicked(); }) } : {})} />}\n <DockMain><ArtifactWorkspace open={panelOpen} onClose={close} panelLabel={text.labels.artifactPanel} labels={{ resize: text.labels.resizePanel, floatHint: text.labels.floatHint }} fullscreen={fullscreen} onFullscreenChange={setFullscreen} storageKey={`${storageKey}.panel-width`}\n sheetBelow={docked ? Math.max(0, 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 ? null : <section data-testid=\"workspace-library-view\" aria-label={text.labels.library} className=\"flex min-h-0 flex-1 flex-col\">\n <header className=\"flex shrink-0 items-center gap-2 border-b border-border px-3 py-2\">{toggle}<h2 className=\"m-0 flex-1 text-sm font-semibold\">{text.labels.library}</h2>\n <Button variant=\"ghost\" size=\"sm\" onClick={() => setCenterMode('chat')}>{text.labels.backToChat}</Button></header>\n <div className=\"min-h-0 flex-1 overflow-auto\">{libraryTree(openFile)}</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 {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])} 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 }} className=\"border-l border-border\">\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 <header className=\"flex shrink-0 items-center gap-2 border-b border-border px-3 py-2\"><h2 className=\"m-0 min-w-0 flex-1 truncate text-sm font-semibold\">{text.labels.library}</h2>\n <Button size=\"icon-sm\" variant=\"ghost\" aria-label={text.labels.close} title={text.labels.close} data-testid=\"workspace-library-close\" onClick={() => setSideOpen(false)}><text.icons.closeSessions className=\"size-4\" aria-hidden=\"true\" /></Button></header>\n <div className=\"min-h-0 flex-1 overflow-auto\">{libraryTree(path => { if (setOpened({ kind: 'file', path }) && narrow) setSideOpen(false); })}</div>\n </section>}\n </Dock>}\n </DockLayout>\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 type=\"button\" className=\"underline\" 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 * The seq of the provider's latest change event (`RevisionProvider.changes`), coalesced over `settleMs` so a burst (a shell writing\n * many files) reloads once. `live` is false without a feed or after the feed was refused; views then keep their own refresh.\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 useEffect(() => {\n if (!provider?.changes) { setState({ provider, seq: undefined, live: false }); return; }\n const stop = new AbortController();\n let timer: ReturnType<typeof setTimeout> | undefined, latest: number | undefined;\n setState({ provider, seq: undefined, live: true });\n void (async () => {\n try {\n for await (const event of provider.changes!({ signal: stop.signal })) {\n latest = event.seq;\n timer ??= setTimeout(() => { timer = undefined; if (!stop.signal.aborted) setState({ provider, seq: latest, live: true }); }, 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({ provider, seq: latest, live: false });\n })();\n return () => { stop.abort(); clearTimeout(timer); };\n }, [provider, settleMs]);\n return state.provider === provider ? state : { seq: undefined, live: false };\n}\n",
|
|
24
|
+
"content": "'use client';\n\nimport '@hachej/boring-ui-kit/file-tree.css';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ResourceLocator } from '@hachej/boring-files';\nimport { createResourceClient } from '@hachej/boring-files/remote';\nimport { workspacePath } from '@hachej/boring-files/file-types';\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 '@hachej/boring-ui-kit/file-tree-view';\nimport { frenchAppLabels } from './app-labels-fr';\nimport { chatTextFor } from '../pi-chat/locale';\nimport type { ChatLocale } from '../pi-chat/locale';\nimport type { FileTreeViewProps } from '@hachej/boring-ui-kit/file-tree-view';\nimport type { ResourceIdentity } from '@hachej/boring-files/remote';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { PiChat, artifactKey } from '../pi-chat/pi-chat';\nimport { createPresentAcks, pendingPresents } from '../pi-chat/artifact';\nimport type { PresentAcks } from '../pi-chat/artifact';\nimport type { ArtifactDescriptor, ArtifactsConfig, 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 '../viewers/viewer-frame';\nimport type { ViewerShare } from '../viewers/viewer-frame';\nimport type { InteractiveHtml } from '../viewers/interactive-html';\nimport { Button } from '../button/button';\nimport type { BlockAction } from '../button/actions';\nimport { ChatTextProvider, useMergedText } from '../pi-chat/labels';\nimport { cn, 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 { useRevisionAttachments } from './use-revision-attachments';\nimport { SessionsPane, SessionsToggle } from './sessions';\nimport type { SessionsView, WorkspaceAgents } from './sessions';\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 type { SessionsView, WorkspaceAgent, WorkspaceAgents } from './sessions';\nexport { useConversations } from './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 = () => () => {};\n\n/** Where the viewers read and write: the host's resource handler (`createResourceHandler` of `@hachej/boring-files/remote`) and its file history. */\nexport interface WorkspaceResources {\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 * Where the Library of a `revisionProvider` opens. `side` (default): 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?: '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'> | 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: NativeChatController | 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 /** Recognise artifacts in tool results that carry no descriptor (`ArtifactsConfig.detect`). */\n readonly detect?: ArtifactsConfig['detect'] | 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 session-scoped layout keys (panel width, floating chat, collapsed sessions). */\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?: never }\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 = 288, LIBRARY_WIDTH = 320, CHAT_MIN = 340, 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({ controller, conversationId, chat = {}, labels, icons, panelActions, connecting, conversations, resources, revisionProvider, libraryPlacement = 'side', locale, fileTree: treeOptions, uploads, agents, detect, viewers, interactive, share, opened: controlled,\n defaultOpened = null, onOpenedChange, onUnsavedChange, panels, autoOpen = true, fileBack, floatingChat, chatTop, controls, storageKey = 'boring.agent-workspace', sheetBelow = 768, drawerBelow = 768, floatBelow, className }: AgentWorkspaceProps) {\n const [own, setOwn] = useState<OpenedView | null>(defaultOpened);\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 /** Opens `next` and reports whether it did: leaving a file with unsaved changes asks first, and the person may stay. */\n const setOpened = useCallback((next: OpenedView | null): boolean => {\n const current = currentView.current;\n if (isFile(current) && isFile(next) && current.path === next.path) return true;\n if (unsaved.current && !globalThis.confirm?.('This file has unsaved or unconfirmed changes. Discard the local draft and leave this file?')) return false;\n noteUnsaved(false);\n if (!isControlled) setOwn(next); change.current?.(next);\n return true;\n }, [isControlled, noteUnsaved]);\n const [fullscreen, setFullscreen] = useState(false);\n const [centerMode, setCenterMode] = useState<'chat' | 'library'>('chat');\n const libraryInPane = Boolean(revisionProvider) && libraryPlacement === 'pane';\n const librarySide = Boolean(revisionProvider) && libraryPlacement === 'side';\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 libraryOpen = Boolean(revisionProvider) && libraryPlacement === 'center' && centerMode === 'library';\n const openLibrary = useCallback(() => { setCenterMode('library'); setFullscreen(false); }, []);\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 const fetcher = useRef(resources?.fetch); fetcher.current = resources?.fetch;\n const endpoint = resources ? String(resources.endpoint) : undefined;\n const historyEndpoint = resources?.history === undefined ? undefined : String(resources.history);\n const identity = revisionProvider?.identity ?? resources!.identity;\n const identityKey = JSON.stringify(identity);\n const client = useMemo(() => revisionProvider ?? createResourceClient({ identity, endpoint: new URL(endpoint!, globalThis.location?.href), publication: true, reconciliation: true,\n fetch: request => fetcher.current!(request) }), [revisionProvider, endpoint, identityKey]); // eslint-disable-line react-hooks/exhaustive-deps\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 // ---- 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-chat/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 const options: ViewerOptions = useMemo(() => ({ client, identity, history, viewers, interactive, share, onShown }), [client, identityKey, history, viewers, interactive, share, onShown]); // eslint-disable-line react-hooks/exhaustive-deps\n const locate = revisionProvider?.locate ?? resources?.locate ?? defaultLocate;\n const [fileBinding, setFileBinding] = useState<{ readonly provider: RevisionProvider; readonly tree: FileTreeController }>();\n useEffect(() => {\n if (!revisionProvider) return;\n const tree = createFileTreeController({ revisionProvider });\n setFileBinding({ provider: revisionProvider, tree });\n void tree.refresh();\n return () => tree.dispose();\n }, [revisionProvider]);\n const fileTree = fileBinding?.provider === revisionProvider ? fileBinding?.tree : undefined;\n // The workspace's change feed: every file view below reloads when its seq moves (agent writes, saves, shell changes).\n const feed = useChangeFeed(revisionProvider);\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 (!fileTree || (Object.is(refreshedKey.current, refreshKey) && refreshedSeq.current === feed.seq)) return;\n refreshedKey.current = refreshKey;\n refreshedSeq.current = feed.seq;\n const { query, expanded } = fileTree.getSnapshot();\n if (query) void fileTree.search(query);\n else for (const directory of ['', ...expanded]) void fileTree.refresh(directory);\n }, [fileTree, refreshKey, feed.seq]);\n const openFile = useCallback((path: string) => { setOpened({ kind: 'file', path }); }, [setOpened]);\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 } : 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);\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 = () => { if (!setOpened(null)) return; if (conversationId !== undefined) closedInTurn.current.set(conversationId, turn); setFullscreen(false); };\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 => setOpened({ kind: 'file', path });\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 }), [conversationId, active, versions, detect, 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 [collapsed, setCollapsed] = useState(() => readFlag(`${storageKey}.sessions-hidden`));\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 { writeFlag(`${storageKey}.sessions-hidden`, false); setCollapsed(false); }\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) => fileTree\n ? <FileTreeView key={JSON.stringify([revisionProvider?.workspace, revisionProvider?.identity])} {...(locale ? { locale } : {})} {...viewOptions} controller={fileTree} historyKey={feed.seq} onOpen={onOpen} {...(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 const hasNavigation = Boolean(conversations || revisionProvider);\n const docked = hasNavigation && width > 0 && !narrow && !collapsed;\n const toggle = hasNavigation && <SessionsToggle navigation={Boolean(revisionProvider)} open={narrow ? drawer : !collapsed} drawer={narrow}\n onToggle={() => { if (narrow) setDrawer(value => !value); else setCollapsed(value => { writeFlag(`${storageKey}.sessions-hidden`, !value); return !value; }); }} />;\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} size=\"icon-sm\" aria-hidden=\"true\" tabIndex={-1} className=\"invisible -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 header = typeof controls === 'function' ? controls({ panelOpen }) : controls ?? chat.controls;\n const chatProps: PiChatProps | undefined = controller && { ...(mentionsForChat ? { mentions: mentionsForChat } : {}), ...(attachments ? { attachments } : {}), ...(locale ? { locale } : {}), ...chat, controller, artifacts, ...(conversations ? { conversations, historyList: false } : {}),\n headerStart: <>{toggle}{chat.headerStart}</>, ...(header === undefined ? {} : { controls: header }) };\n const docked_chat = <div data-testid=\"workspace-center\" className=\"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden\">\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 border-b border-border px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5\">{toggleSpace}</div>}{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 ...(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 return <AppTextProvider value={text}><ChatTextProvider value={chatText}><div ref={root} data-boring=\"agent-workspace\" data-sessions={!hasNavigation ? undefined : narrow ? (drawer ? 'drawer' : 'closed') : docked ? 'docked' : 'hidden'}\n className={cn('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)) && <SessionsPane conversations={conversations} drawer={narrow} onClose={() => setDrawer(false)} onChat={() => setCenterMode('chat')}\n agents={agents} view={paneView} onViewChange={changePaneView}\n {...(librarySide ? { library: { selected: sideOpen, onSelect: toggleSide, side: true } } : revisionProvider && !libraryInPane ? { library: { selected: libraryOpen, onSelect: openLibrary } } : {})}\n {...(libraryInPane ? { libraryContent: (onPicked: () => void) => libraryTree(path => { openFile(path); onPicked(); }) } : {})} />}\n <DockMain><ArtifactWorkspace open={panelOpen} onClose={close} panelLabel={text.labels.artifactPanel} labels={{ resize: text.labels.resizePanel, floatHint: text.labels.floatHint }} fullscreen={fullscreen} onFullscreenChange={setFullscreen} storageKey={`${storageKey}.panel-width`}\n sheetBelow={docked ? Math.max(0, 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 ? null : <section data-testid=\"workspace-library-view\" aria-label={text.labels.library} className=\"flex min-h-0 flex-1 flex-col\">\n <header className=\"flex shrink-0 items-center gap-2 border-b border-border px-3 py-2\">{toggle}<h2 className=\"m-0 flex-1 text-sm font-semibold\">{text.labels.library}</h2>\n <Button variant=\"ghost\" size=\"sm\" onClick={() => setCenterMode('chat')}>{text.labels.backToChat}</Button></header>\n <div className=\"min-h-0 flex-1 overflow-auto\">{libraryTree(openFile)}</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 {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])} 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 }} className=\"border-l border-border\">\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 <header className=\"flex shrink-0 items-center gap-2 border-b border-border px-3 py-2\"><h2 className=\"m-0 min-w-0 flex-1 truncate text-sm font-semibold\">{text.labels.library}</h2>\n <Button size=\"icon-sm\" variant=\"ghost\" aria-label={text.labels.close} title={text.labels.close} data-testid=\"workspace-library-close\" onClick={() => setSideOpen(false)}><text.icons.closeSessions className=\"size-4\" aria-hidden=\"true\" /></Button></header>\n <div className=\"min-h-0 flex-1 overflow-auto\">{libraryTree(path => { if (setOpened({ kind: 'file', path }) && narrow) setSideOpen(false); })}</div>\n </section>}\n </Dock>}\n </DockLayout>\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 type=\"button\" className=\"underline\" 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 * The seq of the provider's latest change event (`RevisionProvider.changes`), coalesced over `settleMs` so a burst (a shell writing\n * many files) reloads once. `live` is false without a feed or after the feed was refused; views then keep their own refresh.\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 useEffect(() => {\n if (!provider?.changes) { setState({ provider, seq: undefined, live: false }); return; }\n const stop = new AbortController();\n let timer: ReturnType<typeof setTimeout> | undefined, latest: number | undefined;\n setState({ provider, seq: undefined, live: true });\n void (async () => {\n try {\n for await (const event of provider.changes!({ signal: stop.signal })) {\n latest = event.seq;\n timer ??= setTimeout(() => { timer = undefined; if (!stop.signal.aborted) setState({ provider, seq: latest, live: true }); }, 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({ provider, seq: latest, live: false });\n })();\n return () => { stop.abort(); clearTimeout(timer); };\n }, [provider, settleMs]);\n return state.provider === provider ? state : { seq: undefined, live: false };\n}\n",
|
|
25
25
|
"type": "registry:component",
|
|
26
26
|
"target": "components/pi-app/agent-workspace.tsx"
|
|
27
27
|
},
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
},
|
|
82
82
|
{
|
|
83
83
|
"path": "registry/pi-app/revision-files.ts",
|
|
84
|
-
"content": "import { uploadPath } from '@hachej/boring-files/file-types';\nimport { randomUUID } from '@hachej/boring-files/platform';\nimport type { FileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport type { UploadResult } from '../pi-chat/config';\n\nfunction imageData(bytes: Uint8Array): string {\n let binary = '';\n for (let offset = 0; offset < bytes.length; offset += 32768) binary += String.fromCharCode(...bytes.subarray(offset, offset + 32768));\n return btoa(binary);\n}\n\n/**\n * What a chat upload may do. `inlineImages` also sends a png, jpeg, gif or webp as a native image in the message (off by default: the\n * file is saved and the message carries only its `@path`, which the agent reads with a tool, so no base64 rides along in every later\n * turn). `maxBytes` refuses a larger file before it is read or uploaded, with `tooLarge` as the reason shown beside the other failures.\n */\nexport interface AttachmentPolicy { readonly inlineImages?: boolean | undefined; readonly maxBytes?: number | undefined; readonly tooLarge?: string | undefined }\n\n/**\n * Where a chat attachment is saved: its name with a short random suffix, so a second `image.png` (every pasted screenshot has that\n * name) or the same file attached again is a new file beside the first instead of a refused overwrite. The name has no whitespace,\n * because a chat mention ends at a space: each run of whitespace becomes `_`.\n */\nexport function attachmentPath(name: string): string {\n return uploadPath(name, randomUUID().slice(0, 8));\n}\n\n/** Successful files remain attached when another file is refused; every outcome remains visible in the shared file tree. */\nexport async function uploadRevisionAttachments(controller: FileTreeController, files: readonly File[], signal: AbortSignal, onPreparationFailure?: (name: string, reason: string) => void, policy: AttachmentPolicy = {}): Promise<readonly UploadResult[]> {\n const uploaded: UploadResult[] = [];\n let reason = 'No files were saved. Open Files to review the uploads.';\n for (const file of files) {\n if (signal.aborted) break;\n if (policy.maxBytes !== undefined && file.size > policy.maxBytes) { reason = policy.tooLarge ?? 'The file is too large to attach.'; onPreparationFailure?.(file.name, reason); continue; }\n try {\n const path = attachmentPath(file.name), bytes = new Uint8Array(await file.arrayBuffer());\n const upload = await controller.upload({ path, bytes, mediaType: file.type || 'application/octet-stream', signal });\n if (upload.state.kind === 'settled' && upload.state.result.kind === 'committed') {\n uploaded.push({ path, name: file.name, ...(policy.inlineImages && /^image\\/(png|jpeg|gif|webp)$/.test(file.type) ? { image: { data: imageData(bytes), mimeType: file.type } } : {}) });\n } else if (upload.state.kind === 'settled' && 'reason' in upload.state.result) reason = upload.state.result.reason;\n } catch (error) { reason = error instanceof Error ? error.message : 'Upload failed'; onPreparationFailure?.(file.name, reason); }\n }\n if (!uploaded.length) throw new Error(reason);\n return uploaded;\n}\n",
|
|
84
|
+
"content": "import { uploadPath } from '@hachej/boring-files/file-types';\nimport { randomUUID } from '@hachej/boring-files/platform';\nimport type { FileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport type { UploadResult } from '../pi-chat/config';\n\nfunction imageData(bytes: Uint8Array): string {\n let binary = '';\n for (let offset = 0; offset < bytes.length; offset += 32768) binary += String.fromCharCode(...bytes.subarray(offset, offset + 32768));\n return btoa(binary);\n}\n\n/**\n * What a chat upload may do. `inlineImages` also sends a png, jpeg, gif or webp as a native image in the message (off by default: the\n * file is saved and the message carries only its `@path`, which the agent reads with a tool, so no base64 rides along in every later\n * turn). `maxBytes` refuses a larger file before it is read or uploaded, with `tooLarge` as the reason shown beside the other failures.\n * `accept` is the picker's MIME/extension list: a file of another type is refused too (a drop or a paste does not pass the picker).\n */\nexport interface AttachmentPolicy { readonly inlineImages?: boolean | undefined; readonly maxBytes?: number | undefined; readonly tooLarge?: string | undefined; readonly accept?: string | undefined }\n\n/** Whether a file passes a picker's `accept` list: `image/*`, an exact MIME type or an extension such as `.pdf`. An empty list accepts any file. */\nexport function acceptsFile(file: { readonly name: string; readonly type: string }, accept: string): boolean {\n const name = file.name.toLowerCase(), type = file.type.toLowerCase();\n const tokens = accept.split(',').map(token => token.trim().toLowerCase()).filter(Boolean);\n return tokens.length === 0 || tokens.some(token => token.startsWith('.') ? name.endsWith(token) : token.endsWith('/*') ? type.startsWith(token.slice(0, -1)) : type === token);\n}\n\n/**\n * Where a chat attachment is saved: its name with a short random suffix, so a second `image.png` (every pasted screenshot has that\n * name) or the same file attached again is a new file beside the first instead of a refused overwrite. The name has no whitespace,\n * because a chat mention ends at a space: each run of whitespace becomes `_`.\n */\nexport function attachmentPath(name: string): string {\n return uploadPath(name, randomUUID().slice(0, 8));\n}\n\n/** Successful files remain attached when another file is refused; every outcome remains visible in the shared file tree. */\nexport async function uploadRevisionAttachments(controller: FileTreeController, files: readonly File[], signal: AbortSignal, onPreparationFailure?: (name: string, reason: string) => void, policy: AttachmentPolicy = {}): Promise<readonly UploadResult[]> {\n const uploaded: UploadResult[] = [];\n let reason = 'No files were saved. Open Files to review the uploads.';\n for (const file of files) {\n if (signal.aborted) break;\n if (policy.maxBytes !== undefined && file.size > policy.maxBytes) { reason = policy.tooLarge ?? 'The file is too large to attach.'; onPreparationFailure?.(file.name, reason); continue; }\n if (policy.accept && !acceptsFile(file, policy.accept)) { reason = 'This file type is not accepted.'; onPreparationFailure?.(file.name, reason); continue; }\n try {\n const path = attachmentPath(file.name), bytes = new Uint8Array(await file.arrayBuffer());\n const upload = await controller.upload({ path, bytes, mediaType: file.type || 'application/octet-stream', signal });\n if (upload.state.kind === 'settled' && upload.state.result.kind === 'committed') {\n uploaded.push({ path, name: file.name, ...(policy.inlineImages && /^image\\/(png|jpeg|gif|webp)$/.test(file.type) ? { image: { data: imageData(bytes), mimeType: file.type } } : {}) });\n } else if (upload.state.kind === 'settled' && 'reason' in upload.state.result) reason = upload.state.result.reason;\n } catch (error) { reason = error instanceof Error ? error.message : 'Upload failed'; onPreparationFailure?.(file.name, reason); }\n }\n if (!uploaded.length) throw new Error(reason);\n return uploaded;\n}\n",
|
|
85
85
|
"type": "registry:lib",
|
|
86
86
|
"target": "components/pi-app/revision-files.ts"
|
|
87
87
|
},
|
|
@@ -90,6 +90,12 @@
|
|
|
90
90
|
"content": "/* The pi-app block's words in French with its viewers', the `locale=\"fr\"` base of `AgentWorkspace`. Typed as `AppLabels`, so a key added in English must be added here too. */\nimport type { AppLabels } from './app-labels';\nimport { frenchViewerLabels } from '../viewers/viewer-labels-fr';\n\nexport const frenchAppLabels: AppLabels = {\n ...frenchViewerLabels,\n sessionsTitle: 'Conversations',\n attachmentTooLarge: (limit: string) => `Plus grand que ${limit}. Non enregistré.`,\n workspaceNavigation: 'Navigation',\n openNavigation: 'Ouvrir la navigation',\n closeNavigation: 'Fermer la navigation',\n hideNavigation: 'Masquer la navigation',\n showNavigation: 'Afficher la navigation',\n chat: 'Discussion',\n library: 'Bibliothèque',\n agent: 'Agent',\n backToChat: 'Retour à la discussion',\n newChat: 'Nouvelle',\n closeSessions: 'Fermer les conversations',\n openSessions: 'Ouvrir les conversations',\n hideSessions: 'Masquer les conversations',\n showSessions: 'Afficher les conversations',\n floatChat: 'Détacher la discussion',\n floatHint: 'Relâchez pour détacher la discussion',\n resizePanel: 'Redimensionner le panneau',\n artifactPanel: 'Document',\n back: 'Retour',\n savedAt: when => `Enregistré ${when}`,\n latestSavedAt: when => `Dernière version, enregistrée ${when}`,\n olderVersion: 'Version précédente',\n earlierVersion: 'Version antérieure',\n artifactNotText: 'Ce document ne peut pas être affiché comme du texte.',\n versionUnavailable: 'Cette version n’est pas disponible.',\n noCanvasViewer: 'Cet espace n’a pas d’afficheur de canevas (à fournir dans `viewers.canvas`).',\n fileMissing: 'Ce fichier n’existe plus.',\n fileNotText: 'Ce fichier ne peut pas être affiché comme du texte.',\n binaryFile: size => `Fichier binaire (${size}) ; il ne peut pas être affiché comme du texte.`,\n fileViewer: kind => `Afficheur ${kind}`,\n newVersion: 'Une nouvelle version est disponible.',\n};\n",
|
|
91
91
|
"type": "registry:lib",
|
|
92
92
|
"target": "components/pi-app/app-labels-fr.ts"
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"path": "registry/pi-app/use-revision-attachments.ts",
|
|
96
|
+
"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",
|
|
97
|
+
"type": "registry:hook",
|
|
98
|
+
"target": "components/pi-app/use-revision-attachments.ts"
|
|
93
99
|
}
|
|
94
100
|
],
|
|
95
101
|
"type": "registry:block"
|