@hachej/boring-ui-kit 0.3.2 → 0.4.1
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/README.md +46 -18
- package/THIRD_PARTY_NOTICES.md +35 -0
- package/bin/boring-ui-blocks.mjs +286 -0
- package/dist/canvas-editor-native.d.ts +4 -1
- package/dist/canvas-editor-native.js +8 -6
- package/dist/canvas-editor.js +1 -1
- package/dist/canvas-proposals.d.ts +3 -1
- package/dist/canvas-proposals.js +4 -3
- package/dist/experience-document-viewer.d.ts +3 -0
- package/dist/experience-document-viewer.js +6 -5
- package/dist/experience.d.ts +3 -0
- package/dist/experience.js +2 -1
- package/dist/file-tree-view.d.ts +76 -3
- package/dist/file-tree-view.js +172 -39
- package/dist/file-tree.d.ts +3 -0
- package/dist/file-tree.js +17 -2
- package/dist/html-viewer.d.ts +4 -0
- package/dist/html-viewer.js +5 -3
- package/dist/markdown-autosave.d.ts +13 -0
- package/dist/markdown-autosave.js +94 -0
- package/dist/markdown-editor.d.ts +16 -0
- package/dist/markdown-editor.js +81 -17
- package/dist/markdown-extensions.js +9 -1
- package/dist/markdown-proposal-diff.d.ts +2 -1
- package/dist/markdown-proposal-diff.js +3 -2
- package/dist/markdown-safety.js +3 -1
- package/dist/native-chat.js +1 -1
- package/dist/remote-chat.d.ts +18 -0
- package/dist/remote-chat.js +149 -7
- package/dist/text-buffer.js +3 -2
- package/dist/text-draft-controls.d.ts +3 -1
- package/dist/text-draft-controls.js +3 -2
- package/dist/viewer-components.d.ts +14 -0
- package/dist/viewer-components.js +1 -0
- package/package.json +8 -4
- package/r/app-shell.json +39 -20
- package/r/button.json +19 -11
- package/r/dock.json +2 -2
- package/r/documents.json +17 -14
- package/r/feedback-preview.json +8 -2
- package/r/feedback.json +46 -325
- package/r/file-tree.json +14 -6
- package/r/html-viewer.json +8 -42
- package/r/markdown-editor.json +38 -87
- package/r/pi-ambient.json +15 -8
- package/r/pi-app.json +30 -24
- package/r/pi-chat.json +101 -70
- package/r/pi-workspace.json +7 -5
- package/r/provider-setup.json +9 -4
- package/r/registry.json +471 -893
- package/r/task-list-viewer.json +11 -21
- package/r/theme.json +23 -4
- package/r/version-history.json +24 -0
- package/r/viewers.json +139 -320
- package/styles/file-tree.css +41 -42
- package/dist/file-tree-shadcn.d.ts +0 -8
- package/dist/file-tree-shadcn.js +0 -14
package/r/pi-app.json
CHANGED
|
@@ -4,43 +4,55 @@
|
|
|
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.
|
|
8
|
-
"@hachej/boring-files@^0.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.1",
|
|
8
|
+
"@hachej/boring-files@^0.4.1",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
|
-
"lucide-react@^1.51.0"
|
|
12
|
-
"radix-ui@^1.8.0"
|
|
11
|
+
"lucide-react@^1.51.0"
|
|
13
12
|
],
|
|
14
13
|
"registryDependencies": [
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.
|
|
17
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.
|
|
18
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.
|
|
19
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.
|
|
20
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/theme.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/pi-chat.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/pi-workspace.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/viewers.json",
|
|
18
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/utils.json",
|
|
19
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/button.json",
|
|
20
|
+
"drawer",
|
|
21
|
+
"button",
|
|
22
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/file-tree.json",
|
|
23
|
+
"dropdown-menu",
|
|
24
|
+
"scroll-area",
|
|
25
|
+
"separator",
|
|
26
|
+
"table"
|
|
21
27
|
],
|
|
22
28
|
"files": [
|
|
23
29
|
{
|
|
24
30
|
"path": "registry/pi-app/agent-workspace.tsx",
|
|
25
|
-
"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 { mediaTypeOf, 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 { imageData } 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 // A card's thumbnail: the chat's image upload read through this provider, the same read the file viewer makes.\n preview: async (path: string, signal: AbortSignal) => {\n const read = await revisionProvider.read({ target: revisionProvider.locate(path), revision: { kind: 'latest' } }, signal);\n return read.kind === 'available' ? { mimeType: mediaTypeOf(path), data: imageData(read.snapshot.bytes) } : undefined;\n },\n } : undefined, [revisionProvider, openFile, mentionResults]);\n // The search function changes only with the binding; the feed's seq rides along as the picker's generation.\n const mentionsForChat = useMemo(() => mentions && { ...mentions, generation: feed.seq }, [mentions, feed.seq]);\n const [attachmentFailure, setAttachmentFailure] = useState<{ readonly tree: FileTreeController; readonly reasons: readonly string[] }>();\n // Primitives only in the memo: a host's inline `labels` object would otherwise rebuild the chat's attachments on every render.\n const uploadAccept = uploads?.accept, inlineImages = uploads?.inlineImages === true, maxBytes = uploads?.maxBytes;\n const tooLarge = maxBytes !== undefined ? text.labels.attachmentTooLarge(sizeLabel(maxBytes)) : undefined;\n const revisionAttachments = useRevisionAttachments({ fileTree, accept: uploadAccept, inlineImages, maxBytes, tooLarge, onPreparationFailure: (name, reason) => {\n if (fileTree && fileTree.getSnapshot().lifecycle === 'active') setAttachmentFailure(current => ({ tree: fileTree,\n reasons: [...(current?.tree === fileTree ? current.reasons : []), `${name}: ${reason}`] }));\n } });\n const attachments = useMemo(() => revisionAttachments && { ...revisionAttachments, upload: (files: File[], signal: AbortSignal) => {\n setAttachmentFailure(undefined);\n return revisionAttachments.upload(files, signal);\n } }, [revisionAttachments]);\n\n // ---- Artifacts: cards open the panel; the agent's new ones open it too.\n const versions = useArtifactVersions(controller, detect);\n const turn = useTurn(controller);\n const active = isArtifact(opened) && opened.conversation === conversationId ? opened : null;\n const file = isFile(opened) ? opened : null;\n const host = opened && !isArtifact(opened) && !isFile(opened) && panels?.[opened.kind] ? opened : null;\n const panelOpen = Boolean(active || file || host);\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",
|
|
31
|
+
"content": "'use client';\n\nimport '@hachej/boring-ui-kit/file-tree.css';\n\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ComponentProps, ReactNode } from 'react';\nimport type { ResourceLocator } from '@hachej/boring-files';\nimport { createResourceClient } from '@hachej/boring-files/remote';\nimport { mediaTypeOf, 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 '../file-tree/file-tree';\nimport { frenchAppLabels } from './app-labels-fr';\nimport { chatTextFor } from '../pi-chat/locale';\nimport type { ChatLocale } from '../pi-chat/locale';\nimport type { FileTreeViewProps } from '../file-tree/file-tree';\nimport type { ResourceIdentity } from '@hachej/boring-files/remote';\nimport type { 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 '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\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 { imageData } from './revision-files';\nimport { SessionsPane, SessionsToggle } from './sessions';\nimport { focusHistoryRow, historyColumnFits, useHistoryOpen } from './history-column';\nimport type { HistoryPlacement } from './history-column';\nimport type { SessionsView, WorkspaceAgents } from './sessions';\n\nexport { ArtifactPanel, useArtifactVersions, useTurn } from './artifact-panel';\nexport type { CustomViewerProps, CustomViewers, SavedRevision, ViewerOptions } from './artifact-panel';\nexport { FileViewer } from './file-viewer';\nexport { SessionsPane, SessionsToggle } from './sessions';\nexport { HistoryColumn, HistoryColumnToggle, historyColumnFits, useHistoryColumn } from './history-column';\nexport type { HistoryPlacement } from './history-column';\nexport { ConversationList, ConversationHistory } from '../pi-chat/history';\nexport { FileTree } from '../file-tree/file-tree';\nexport type { SessionsView, WorkspaceAgent, WorkspaceAgents } from './sessions';\nexport { useConversations } from '../pi-chat/use-conversations';\nexport { useRemoteChat } from '../pi-chat/use-remote-chat';\nexport type { RemoteChatState } from '../pi-chat/use-remote-chat';\nexport { savedLabel, useSaved } from './use-saved';\nexport { viewerKindOf as kindOf, mediaTypeOf } from '@hachej/boring-files/file-types';\nexport { defaultAppIcons, defaultAppLabels } from './app-labels';\nexport { frenchAppLabels } from './app-labels-fr';\nexport type { AppIcons, AppLabels } from './app-labels';\nexport type { ViewerKind as FileKind } from '@hachej/boring-files/file-types';\n\n/** An artifact version in the panel: `follow` shows the latest saved revision, otherwise `descriptor.revision` is pinned and read-only. */\nexport interface ArtifactView { readonly kind: 'artifact'; readonly conversation: string; readonly descriptor: ArtifactDescriptor; readonly follow: boolean }\n/** A file in the panel, by the host's path (see `resources.locate`). */\nexport interface FileView { readonly kind: 'file'; readonly path: string }\n/** Anything else the host renders in the panel through `panels[kind]` (for example a file list or a tool view). */\nexport interface HostView { readonly kind: string; readonly [field: string]: unknown }\n/** What the right-hand panel shows, or `null` when it is closed. */\nexport type OpenedView = ArtifactView | FileView | HostView;\n\nconst isArtifact = (view: OpenedView | null): view is ArtifactView => view?.kind === 'artifact';\nconst isFile = (view: OpenedView | null): view is FileView => view?.kind === 'file';\n/** Whether the panel shows the file at `path` (a file view, or the artifact view of it). */\nconst showsPath = (view: OpenedView | null, path: string): boolean => isFile(view) ? view.path === path : isArtifact(view) ? view.descriptor.target.resource.path === path : false;\nconst subscribeNothing = () => () => {};\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. `panel` (default): the artifact column, or a bottom drawer on phones.\n * `side`: a column to the right of the viewer (a `Dock`, resizable), opened\n * from the Library button in the viewer's top bar or in the left pane, alone at the right edge when no file is open; on a phone a\n * full-screen sheet that choosing a file closes. `center`: Chat and Library links in the left pane, the Library replacing\n * the chat in the center (the chat stays mounted). `pane`: the Library is a tab beside the conversations in the left pane, or the\n * whole pane on a single-session page without `conversations`; files open in the right panel as usual.\n */\n readonly libraryPlacement?: 'panel' | 'side' | 'center' | 'pane' | undefined;\n /** The page's language: `en` (default) or `fr`, for the chat, the panes, the viewers and the Library. `labels` and `chat.labels` override any word. */\n readonly locale?: ChatLocale | undefined;\n /**\n * The Library tree's options: its words (`labels`, over `defaultFileTreeLabels`), whether it offers Upload and History, and a\n * `refreshKey` that reloads it when it changes. Deprecated: with a `revisionProvider` that has a change feed (`connectRevisionProvider`)\n * the tree, the Library, the history panel and the @ search reload from the workspace's own change events; `refreshKey` remains\n * for a provider without one.\n */\n readonly fileTree?: Pick<FileTreeViewProps, 'labels' | 'upload' | 'history' | 'refreshKey'> | 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 /** Place the sessions pieces in host navigation. Default: SessionsPane. False leaves navigation entirely to the host; conversations still powers chat Fork. */\n readonly sessions?: false | ((props: ComponentProps<typeof SessionsPane>) => ReactNode) | undefined;\n /**\n * `column` (default): the conversations are the pane's side column, open or closed as this viewer left it; while an open panel leaves\n * the chat too narrow beside it, the column steps aside and the chat's History opens the popover. `popover`: History is always the popover.\n */\n readonly history?: HistoryPlacement | undefined;\n /** Recognise artifacts in tool results that carry no descriptor (`ArtifactsConfig.detect`). */\n readonly detect?: ArtifactsConfig['detect'] | undefined;\n /** The app's own artifact kinds (`ArtifactKind`: match, label, icon, renderer): the chat's cards and the artifact panel consult them before the built-in types. */\n readonly artifactKinds?: ArtifactsConfig['kinds'] | undefined;\n /** Host viewers by artifact type or file kind, for example `{ canvas: props => <MyCanvas {...props} /> }`. */\n readonly viewers?: CustomViewers | undefined;\n /** Run HTML pages in the viewers' sandboxed preview, loading scripts only from these origins. Off without it. */\n readonly interactive?: InteractiveHtml | undefined;\n /** The viewer bar's Share action; it receives `target` `{ artifact, version }` or `{ file }`. */\n readonly share?: ViewerShare | undefined;\n /** Controlled panel content (with `onOpenedChange`); uncontrolled from `defaultOpened` otherwise. */\n readonly opened?: OpenedView | null | undefined;\n readonly defaultOpened?: OpenedView | null | undefined;\n readonly onOpenedChange?: ((next: OpenedView | null) => void) | undefined;\n /**\n * Whether the open file has unsaved or unconfirmed changes. The workspace asks before leaving such a file itself; a host that\n * switches the workspace or identity (a new `revisionProvider`) or changes a controlled `opened` should ask first too, since the\n * viewer of the previous file is replaced and its local draft discarded.\n */\n readonly onUnsavedChange?: ((unsaved: boolean) => void) | undefined;\n /** Renderers for host views, by `kind`. */\n readonly panels?: Readonly<Record<string, (view: HostView, panel: WorkspacePanelApi) => ReactNode>> | undefined;\n /** Open the panel on each artifact (or new version) the agent makes, unless the person closed it during this turn. Not on a phone. Default true. */\n readonly autoOpen?: boolean;\n /** A back button in a file's viewer bar (for example to the host's file list). */\n readonly fileBack?: { readonly label: string; readonly onBack: () => void } | undefined;\n /** With `floatBelow`: the floating chat over the same session (for example `AmbientChat` of `pi-ambient` with `onDock={dock}`). */\n readonly floatingChat?: ((props: PiChatProps, dock: () => void) => ReactNode) | undefined;\n /** Shown above the chat (notices). */\n readonly chatTop?: ReactNode;\n /** The chat header's controls; a function receives whether the panel is open. */\n readonly controls?: ReactNode | ((state: { readonly panelOpen: boolean }) => ReactNode);\n /** Prefix of the layout keys (panel width and floating chat for the session; whether the sessions column is open, per viewer). */\n readonly storageKey?: string;\n /** Below this width of the workspace the artifact panel is a full-screen sheet. */\n readonly sheetBelow?: number;\n /** Below this width of the workspace the sessions pane is a drawer. */\n readonly drawerBelow?: number;\n readonly floatBelow?: number | undefined;\n readonly className?: string;\n}\n\n/** The chat's attachment rules. `inlineImages` (default false) also sends a png, jpeg, gif or webp as a native image in the message: every later turn then carries its base64, so it is off by default. `maxBytes` refuses a larger file before it is uploaded (no limit by default), and the chat shows why. `accept` is the file picker's `accept` (any file by default). */\nexport interface WorkspaceUploads {\n readonly inlineImages?: boolean | undefined;\n readonly maxBytes?: number | undefined;\n readonly accept?: string | undefined;\n}\n\n/** A byte count for a refusal message: `2.5 MB`, `300 KB`, or `12 bytes`. */\nconst sizeLabel = (bytes: number) => bytes >= 1_000_000 ? `${Number((bytes / 1_000_000).toFixed(1))} MB` : bytes >= 1_000 ? `${Math.round(bytes / 1_000)} KB` : `${bytes} bytes`;\n\nexport type AgentWorkspaceProps = AgentWorkspaceBaseProps & (\n | { readonly revisionProvider: RevisionProvider; readonly resources?: 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 = 260, LIBRARY_WIDTH = 320, CHAT_MIN = 360, VIEWER_MIN = 360;\nconst readFlag = (key: string): boolean => { try { return sessionStorage.getItem(key) === '1'; } catch { return false; } };\nconst writeFlag = (key: string, on: boolean) => { try { if (on) sessionStorage.setItem(key, '1'); else sessionStorage.removeItem(key); } catch { /* the layout is a convenience */ } };\nconst defaultLocate = (path: string): ResourceLocator => ({ resource: { providerId: 'workspace', path: workspacePath(path) }, view: { kind: 'published' } });\n\n/**\n * A complete agent page in one component: the sessions pane on the left (search, New, rename, archive, delete; collapsible, a drawer on\n * a narrow screen), the chat in the center (`PiChat`) and the artifact viewers on the right (`ArtifactWorkspace` with the artifact panel and\n * its versions, the file viewer and the host's own views). Agent artifacts open the panel as they appear. Every prop is data or a callback:\n * the host owns the routes, authentication, the controller and what is open (when controlled).\n */\nexport function AgentWorkspace({ controller, conversationId, chat = {}, labels, icons, panelActions, connecting, conversations, sessions, history: historyPlacement = 'column', resources, revisionProvider, libraryPlacement = 'panel', locale, fileTree: treeOptions, uploads, agents, detect, artifactKinds, viewers, interactive, share, opened: controlled,\n defaultOpened = null, onOpenedChange, onUnsavedChange, panels, autoOpen = true, fileBack, floatingChat, chatTop, controls, storageKey = 'boring.agent-workspace', sheetBelow = 641, drawerBelow = 641, floatBelow, className }: AgentWorkspaceProps) {\n const [own, setOwn] = useState<OpenedView | null>(defaultOpened);\n const [panelLibrary, setPanelLibrary] = useState(false);\n const opened = controlled !== undefined ? controlled : own;\n const change = useRef(onOpenedChange); change.current = onOpenedChange;\n const isControlled = controlled !== undefined;\n const unsaved = useRef(false);\n const currentView = useRef(opened); currentView.current = opened;\n const unsavedChange = useRef(onUnsavedChange); unsavedChange.current = onUnsavedChange;\n const noteUnsaved = useCallback((value: boolean) => { if (unsaved.current !== value) { unsaved.current = value; unsavedChange.current?.(value); } }, []);\n const beforeLeave = useRef<(() => Promise<boolean>) | null>(null);\n const registerBeforeLeave = useCallback((flush: (() => Promise<boolean>) | null) => { beforeLeave.current = flush; }, []);\n const leaving = useRef(false);\n const guard = useCallback(async () => {\n if (leaving.current) return false;\n if (!unsaved.current) return true;\n const current = currentView.current;\n leaving.current = true;\n try {\n const saved = await beforeLeave.current?.();\n if (currentView.current !== current) return false;\n return saved === true || !unsaved.current || Boolean(globalThis.confirm?.('This file has unsaved or unconfirmed changes. Discard the local draft and leave this file?'));\n } finally { leaving.current = false; }\n }, []);\n const setOpened = useCallback(async (next: OpenedView | null): Promise<boolean> => {\n const current = currentView.current;\n if (isFile(current) && isFile(next) && current.path === next.path) { setPanelLibrary(false); return true; }\n if (!await guard()) return false;\n noteUnsaved(false);\n setPanelLibrary(false);\n if (!isControlled) setOwn(next); change.current?.(next);\n return true;\n }, [guard, isControlled, noteUnsaved]);\n const [fullscreen, setFullscreen] = useState(false);\n const [centerMode, setCenterMode] = useState<'chat' | 'library'>('chat');\n const 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(async () => {\n if (libraryPlacement === 'panel') {\n if (!await guard()) return;\n noteUnsaved(false); setPanelLibrary(value => !value);\n } else setCenterMode('library');\n setFullscreen(false);\n }, [guard, libraryPlacement, noteUnsaved]);\n const text = useMemo(() => ({ labels: withDefaults(locale === 'fr' ? frenchAppLabels : defaultAppLabels, labels), icons: withDefaults(defaultAppIcons, icons) }), [labels, icons, locale]);\n // The sessions pane shows the chat's conversation list, so it reads the chat's labels too.\n const chatText = useMergedText(chat.labels, chat.icons, chatTextFor(locale));\n\n 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, kinds: artifactKinds, interactive, share, onShown, onUnsavedChange: noteUnsaved, onBeforeLeaveChange: registerBeforeLeave }), [client, identityKey, history, viewers, artifactKinds, interactive, share, onShown, noteUnsaved, registerBeforeLeave]); // 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 // A card's thumbnail: the chat's image upload read through this provider, the same read the file viewer makes.\n preview: async (path: string, signal: AbortSignal) => {\n const read = await revisionProvider.read({ target: revisionProvider.locate(path), revision: { kind: 'latest' } }, signal);\n return read.kind === 'available' ? { mimeType: mediaTypeOf(path), data: imageData(read.snapshot.bytes) } : undefined;\n },\n } : undefined, [revisionProvider, openFile, mentionResults]);\n // The search function changes only with the binding; the feed's seq rides along as the picker's generation.\n const mentionsForChat = useMemo(() => mentions && { ...mentions, generation: feed.seq }, [mentions, feed.seq]);\n const [attachmentFailure, setAttachmentFailure] = useState<{ readonly tree: FileTreeController; readonly reasons: readonly string[] }>();\n // Primitives only in the memo: a host's inline `labels` object would otherwise rebuild the chat's attachments on every render.\n const uploadAccept = uploads?.accept, inlineImages = uploads?.inlineImages === true, maxBytes = uploads?.maxBytes;\n const tooLarge = maxBytes !== undefined ? text.labels.attachmentTooLarge(sizeLabel(maxBytes)) : undefined;\n const revisionAttachments = useRevisionAttachments({ fileTree, accept: uploadAccept, inlineImages, maxBytes, tooLarge, onPreparationFailure: (name, reason) => {\n if (fileTree && fileTree.getSnapshot().lifecycle === 'active') setAttachmentFailure(current => ({ tree: fileTree,\n reasons: [...(current?.tree === fileTree ? current.reasons : []), `${name}: ${reason}`] }));\n } });\n const attachments = useMemo(() => revisionAttachments && { ...revisionAttachments, upload: (files: File[], signal: AbortSignal) => {\n setAttachmentFailure(undefined);\n return revisionAttachments.upload(files, signal);\n } }, [revisionAttachments]);\n\n // ---- Artifacts: cards open the panel; the agent's new ones open it too.\n const versions = useArtifactVersions(controller, detect);\n const turn = useTurn(controller);\n const active = isArtifact(opened) && opened.conversation === conversationId ? opened : null;\n const file = isFile(opened) ? opened : null;\n const host = opened && !isArtifact(opened) && !isFile(opened) && panels?.[opened.kind] ? opened : null;\n const panelOpen = Boolean(active || file || host || panelLibrary);\n const seen = useRef(new Map<string, Set<string>>());\n const closedInTurn = useRef(new Map<string, number>());\n // Kept editing after the unsaved-changes question: the panel stays as it was, and the next artifact may still open it.\n const close = async () => { if (panelLibrary) { setPanelLibrary(false); return true; } if (!await setOpened(null)) return false; if (conversationId !== undefined) closedInTurn.current.set(conversationId, turn); setFullscreen(false); return true; };\n useEffect(() => {\n if (!controller || conversationId === undefined) return;\n const keys = versions.map(version => `${artifactKey(version)}:${version.revision}`);\n const known = seen.current.get(conversationId);\n // What the conversation already held when it loaded does not count.\n if (!known) { seen.current.set(conversationId, new Set(keys)); return; }\n const fresh = versions.filter(version => !known.has(`${artifactKey(version)}:${version.revision}`));\n for (const key of keys) known.add(key);\n if (unsaved.current || !autoOpen || !fresh.length || closedInTurn.current.get(conversationId) === turn || globalThis.matchMedia?.(`(max-width: ${sheetBelow - 1}px)`).matches) return;\n setOpened({ kind: 'artifact', conversation: conversationId, descriptor: fresh.at(-1)!, follow: true });\n }, [versions, controller, conversationId, turn]); // eslint-disable-line react-hooks/exhaustive-deps\n // A `present` call waiting for the page: decided by `createPresentAcks` (refused when the person closed the panel this turn, has unsaved\n // edits or is on a phone; otherwise its file is opened unless `autoOpen` is off, and the acknowledgement follows the mount).\n openForPresent.current = path => { if (unsaved.current) return false; void setOpened({ kind: 'file', path }); return true; };\n const liveView = useSyncExternalStore(controller ? controller.subscribe : subscribeNothing, () => controller?.getSnapshot().view, () => undefined);\n const presentCalls = useMemo(() => pendingPresents(liveView), [liveView]);\n useEffect(() => {\n if (!answerPresent.current || conversationId === undefined) return;\n presentAcks.current?.consider(presentCalls, { closedThisTurn: closedInTurn.current.get(conversationId) === turn, unsaved: unsaved.current,\n narrow: globalThis.matchMedia?.(`(max-width: ${sheetBelow - 1}px)`).matches === true, autoOpen, showing: path => showsPath(currentView.current, path) });\n }, [presentCalls, conversationId, turn, autoOpen, sheetBelow]);\n /** The versions of one file that the conversation presented or saved, newest first. */\n const newestOf = (key: string) => versions.filter(version => artifactKey(version) === key).reverse();\n const artifacts: ArtifactsConfig = useMemo(() => ({\n open: descriptor => { if (conversationId !== undefined) setOpened({ kind: 'artifact', conversation: conversationId, descriptor, follow: descriptor.revision === newestOf(artifactKey(descriptor))[0]?.revision }); },\n isOpen: descriptor => Boolean(active && artifactKey(active.descriptor) === artifactKey(descriptor) && (active.follow ? descriptor.revision === newestOf(artifactKey(descriptor))[0]?.revision : descriptor.revision === active.descriptor.revision)),\n ...(detect ? { detect } : {}),\n ...(artifactKinds ? { kinds: artifactKinds } : {}),\n }), [conversationId, active, versions, detect, artifactKinds, setOpened]); // eslint-disable-line react-hooks/exhaustive-deps\n\n // ---- Sessions: docked on a wide workspace (collapsible, remembered for the session), a drawer on a narrow one.\n const root = useRef<HTMLDivElement>(null);\n const [width, setWidth] = useState(0);\n useLayoutEffect(() => {\n const element = root.current;\n if (!element) return;\n const measure = () => setWidth(element.getBoundingClientRect().width);\n measure();\n if (typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n const narrow = width > 0 && width < drawerBelow;\n const [paneOpen, setPaneOpen] = useHistoryOpen(`${storageKey}.history-column`, true);\n const paneId = `${storageKey}-sessions`;\n const [drawer, setDrawer] = useState(false);\n const [paneView, setPaneView] = useState<SessionsView>(() => readFlag(`${storageKey}.library`) ? 'library' : 'conversations');\n const changePaneView = useCallback((next: SessionsView) => { writeFlag(`${storageKey}.library`, next === 'library'); setPaneView(next); }, [storageKey]);\n // The upload notice's \"Open Library\": the center Library, or the pane's Library tab with the pane shown.\n const revealLibrary = () => {\n if (librarySide) { setSideOpen(true); return; }\n if (!libraryInPane) { openLibrary(); return; }\n changePaneView('library');\n if (narrow) setDrawer(true); else setPaneOpen(true);\n };\n useEffect(() => { if (!narrow) setDrawer(false); }, [narrow]);\n const toggleSide = useCallback(() => setSideOpen(!sideOpen), [setSideOpen, sideOpen]);\n // The Library tree, one view for the side column, the center and the pane; `onOpen` is what choosing a file does there.\n const libraryTree = (onOpen: (path: string) => void, onClose?: () => void) => fileTree\n ? <FileTreeView variant=\"list\" refresh={libraryPlacement !== 'panel'} title={text.labels.library} {...(onClose ? { onClose } : {})} 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 // `history=\"popover\"` keeps the conversations out of the pane. With an open panel the column steps aside unless column, panel and chat fit.\n const paneConversations = historyPlacement === 'column' ? conversations : undefined;\n const hasNavigation = sessions !== false && Boolean(paneConversations || revisionProvider);\n const stepAside = Boolean(paneConversations) && hasNavigation && !narrow && panelOpen && !historyColumnFits(width, VIEWER_MIN);\n const docked = hasNavigation && width > 0 && !narrow && paneOpen && !stepAside;\n const popoverHistory = historyPlacement === 'popover' || stepAside;\n const toggle = hasNavigation && !docked && !drawer && !stepAside && <SessionsToggle navigation={Boolean(revisionProvider)} open={narrow ? drawer : paneOpen} drawer={narrow} controls={narrow ? undefined : paneId}\n onToggle={() => { if (narrow) setDrawer(value => !value); else { setPaneOpen(!paneOpen); if (!paneOpen) focusHistoryRow(paneId); } }} />;\n\n // ---- The docked chat's header is replaced on every switch and connect (the `connecting` row, then a new `PiChat`). The toggle is not\n // part of it: it stays mounted in the shell, over an invisible space of its size in whichever header is showing, and follows that\n // header's height. A press that starts before the swap ends on the same button and still clicks (a button that leaves the document\n // between press and release loses the click, even when moved rather than recreated).\n const toggleRow = useRef<HTMLDivElement>(null);\n const followHeader = useRef<ResizeObserver | undefined>(undefined);\n const headerSlot = useCallback((space: HTMLElement | null) => {\n followHeader.current?.disconnect(); followHeader.current = undefined;\n const header = space?.parentElement;\n if (!header || typeof ResizeObserver === 'undefined') return;\n followHeader.current = new ResizeObserver(() => { if (toggleRow.current) toggleRow.current.style.height = `${header.clientHeight}px`; });\n followHeader.current.observe(header);\n }, []);\n const toggleSpace = toggle && <Button ref={headerSlot} variant=\"ghost\" size=\"icon-sm\" aria-hidden=\"true\" tabIndex={-1} className=\"invisible min-[641px]:-ml-1\" />;\n\n // ---- The chat in the center: docked PiChat, or the host's floating surface over the same controller (with the toggle in its header).\n const header = <>{conversations?.onNew && <Button variant=\"ghost\" size=\"icon-sm\" data-testid=\"workspace-new-chat\" aria-label={text.labels.newChat} title={text.labels.newChat} className=\"min-[641px]:hidden\" onClick={conversations.onNew}><text.icons.newChat data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}{typeof controls === 'function' ? controls({ panelOpen }) : controls ?? chat.controls}</>;\n const conversationTitle = conversations?.items.find(item => item.id === conversationId)?.title;\n const chatProps: PiChatProps | undefined = controller && { ...(mentionsForChat ? { mentions: mentionsForChat } : {}), ...(attachments ? { attachments } : {}), ...(locale ? { locale } : {}), ...chat, controller, artifacts, ...(conversations ? { conversations, historyList: popoverHistory, showHistory: chat.showHistory ?? popoverHistory, conversationTitle, onRenameTitle: conversations.rename && conversationId ? (title: string) => conversations.rename!(conversationId, title) : undefined } : {}),\n headerStart: <>{toggle}{chat.headerStart}</>, ...(header === undefined ? {} : { controls: header }) };\n 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 px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5\">{toggleSpace}</div><Separator /></>}{connecting}</>}\n {toggle && <div ref={toggleRow} className=\"pointer-events-none absolute top-0 left-0 z-10 flex items-center px-3 sm:px-4 [&>*]:pointer-events-auto\">{toggle}</div>}\n </div>\n </div>;\n\n const kind = active ? 'artifact' : file ? 'file' : host?.kind;\n const shown = active ?? file ?? host;\n const actionsFor = (floatChat: (() => void) | undefined): readonly BlockAction[] => [\n ...(revisionProvider && libraryPlacement === 'panel' ? [{ id: 'library', label: text.labels.library, icon: text.icons.library, placement: 'menu' as const, onSelect: openLibrary }] : []),\n ...(librarySide ? [{ id: 'library', label: text.labels.library, icon: text.icons.library, pressed: sideOpen, onSelect: toggleSide }] : []),\n ...(floatChat ? [{ id: 'float-chat', label: text.labels.floatChat, icon: text.icons.floatChat, placement: 'menu' as const, onSelect: floatChat }] : []),\n ...(shown ? typeof panelActions === 'function' ? panelActions(shown) : panelActions ?? [] : []),\n ];\n const sessionProps: ComponentProps<typeof SessionsPane> = { id: paneId, conversations: paneConversations, drawer: narrow, onClose: () => setDrawer(false), onChat: () => setCenterMode('chat'), onCollapse: () => setPaneOpen(false), title: labels?.sessionsTitle,\n agents, view: paneView, onViewChange: changePaneView,\n ...(librarySide ? { library: { selected: sideOpen, onSelect: toggleSide, side: true } } : revisionProvider && !libraryInPane ? { library: { selected: libraryOpen || panelLibrary, onSelect: openLibrary } } : {}),\n ...(libraryInPane ? { libraryContent: (onPicked: () => void) => libraryTree(path => { openFile(path); onPicked(); }) } : {}),\n };\n return <AppTextProvider value={text}><ChatTextProvider value={chatText}><div ref={root} data-boring=\"agent-workspace\" data-sessions={!hasNavigation ? undefined : narrow ? (drawer ? 'drawer' : 'closed') : docked ? 'docked' : 'hidden'}\n className={cn('pi-chat relative flex h-full min-h-0 min-w-0 flex-1 overflow-hidden', className)}>\n <DockLayout storageKey={`${storageKey}.dock`} minMain={SESSIONS_WIDTH + 480}>\n {hasNavigation && (docked || (narrow && drawer)) && (sessions ? sessions(sessionProps) : <SessionsPane {...sessionProps} />)}\n <DockMain><ArtifactWorkspace open={panelOpen && !(panelLibrary && narrow)} onClose={close} panelLabel={text.labels.artifactPanel} labels={{ resize: text.labels.resizePanel, floatHint: text.labels.floatHint }} fullscreen={fullscreen} onFullscreenChange={setFullscreen} storageKey={`${storageKey}.panel-width`}\n sheetBelow={Math.max(CHAT_MIN + VIEWER_MIN, docked ? sheetBelow - SESSIONS_WIDTH : sheetBelow)} {...(floatBelow === undefined || libraryOpen ? {} : { floatBelow })}\n chat={layout => <WorkspaceCenter libraryOpen={libraryOpen} floating={layout.floating} chat={docked_chat}\n floatingChat={floatingChat && chatProps ? floatingChat(chatProps, layout.dock) : undefined}\n // With the Library in the pane, the center holds only the chat (no second, hidden toggle or tree).\n library={libraryInPane || librarySide || libraryPlacement === 'panel' ? null : <section data-testid=\"workspace-library-view\" aria-label={text.labels.library} className=\"flex min-h-0 flex-1 flex-col\">\n <div className=\"min-h-0 flex-1 overflow-auto\">{libraryTree(openFile, () => setCenterMode('chat'))}</div>\n </section>} />}\n panel={win => <ViewerWindowProvider value={{ fullscreen: win.fullscreen, onFullscreenChange: win.onFullscreenChange, actions: actionsFor(win.floatChat), labels: text.labels, icons: text.icons }}>\n <div data-testid=\"viewer-panel\" data-kind={kind} className=\"flex min-h-0 flex-1 flex-col overflow-hidden [&>*]:min-h-0 [&>*]:flex-1\">\n {panelLibrary ? <section data-testid=\"workspace-library-side\" aria-label={text.labels.library} className=\"flex min-h-0 flex-1 flex-col\"><div className=\"min-h-0 flex-1 overflow-auto\">{libraryTree(openFile, () => setPanelLibrary(false))}</div></section> : active\n ? <ArtifactPanel key={artifactKey(active.descriptor)} active={active} versions={newestOf(artifactKey(active.descriptor))} options={options} onClose={win.close}\n onSelect={value => {\n const known = newestOf(artifactKey(active.descriptor));\n setOpened({ kind: 'artifact', conversation: active.conversation, follow: value === 'latest', descriptor: value === 'latest' ? (known[0] ?? active.descriptor) : { ...active.descriptor, revision: value } });\n }} />\n : file ? <FileViewer key={JSON.stringify([revisionProvider?.workspace, identity, file.path])} onBeforeLeaveChange={registerBeforeLeave} onUnsavedChange={noteUnsaved} path={file.path} locator={locate(file.path)} options={options} onClose={win.close} {...(fileBack ? { onBack: fileBack.onBack, backLabel: fileBack.label } : {})} />\n : host ? panels![host.kind]!(host, win) : null}\n </div></ViewerWindowProvider>} /></DockMain>\n {librarySide && <Dock id=\"library\" side=\"right\" open={sideOpen} onOpenChange={setSideOpen} defaultWidth={LIBRARY_WIDTH} minWidth={240} narrowBelow={Math.max(drawerBelow, (docked ? SESSIONS_WIDTH : 0) + (panelOpen ? VIEWER_MIN + CHAT_MIN : CHAT_MIN) + libraryWidth)} narrow=\"sheet\"\n labels={{ region: text.labels.library, resize: text.labels.resizePanel }} >\n {api => <section data-testid=\"workspace-library-side\" aria-label={text.labels.library} className=\"flex min-h-0 flex-1 flex-col\">\n <WidthProbe width={api.width} onWidth={setLibraryWidth} />\n <div className=\"min-h-0 flex-1 overflow-auto\">{libraryTree(path => { void setOpened({ kind: 'file', path }).then(opened => { if (opened && narrow) setSideOpen(false); }); }, () => setSideOpen(false))}</div>\n </section>}\n </Dock>}\n </DockLayout>\n {panelLibrary && narrow && <Drawer open showSwipeHandle onOpenChange={next => { if (!next) setPanelLibrary(false); }}><DrawerContent aria-label={text.labels.library} className=\"pi-chat min-h-[80dvh] max-h-[80dvh]\"><DrawerTitle className=\"sr-only\">{text.labels.library}</DrawerTitle><div data-testid=\"workspace-library-side\" className=\"min-h-0 flex-1 overflow-auto pb-[env(safe-area-inset-bottom)]\">{libraryTree(openFile, () => setPanelLibrary(false))}</div></DrawerContent></Drawer>}\n </div></ChatTextProvider></AppTextProvider>;\n}\n\nfunction WidthProbe({ width, onWidth }: { readonly width: number; readonly onWidth: (width: number) => void }) {\n useLayoutEffect(() => { onWidth(width); }, [width, onWidth]);\n return null;\n}\n\nfunction UploadNotice({ controller, onOpen }: { readonly controller: FileTreeController; readonly onOpen: () => void }) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const failures = state.uploads.filter(upload => upload.state.kind === 'settled' && upload.state.result.kind !== 'committed');\n return failures.length ? <p role=\"alert\" className=\"m-0 px-4 py-2 text-sm\">{failures.length} upload{failures.length === 1 ? '' : 's'} need attention. <Button variant=\"link\" onClick={onOpen}>Open Library</Button> to review the results and check unconfirmed uploads.</p> : null;\n}\n\n/** Keep the selected chat surface mounted while Library temporarily uses its layout space. */\nfunction WorkspaceCenter({ libraryOpen, floating, chat, floatingChat, library }: {\n readonly libraryOpen: boolean;\n readonly floating: boolean;\n readonly chat: ReactNode;\n readonly floatingChat: ReactNode;\n readonly library: ReactNode;\n}) {\n const lastFloating = useRef(floating);\n useLayoutEffect(() => { if (!libraryOpen) lastFloating.current = floating; }, [libraryOpen, floating]);\n const showFloating = libraryOpen ? lastFloating.current : floating;\n return <div className=\"flex min-h-0 flex-1 flex-col\">\n <div data-testid=\"workspace-chat-surface\" hidden={libraryOpen} inert={libraryOpen} aria-hidden={libraryOpen || undefined} className={cn('min-h-0 flex-1 flex-col', libraryOpen ? 'hidden' : 'flex')}>\n {showFloating && floatingChat ? floatingChat : chat}\n </div>\n <div hidden={!libraryOpen} inert={!libraryOpen} aria-hidden={!libraryOpen || undefined} className={cn('min-h-0 flex-1 flex-col', libraryOpen ? 'flex' : 'hidden')}>{library}</div>\n </div>;\n}\n\n/**\n * 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",
|
|
26
32
|
"type": "registry:component",
|
|
27
33
|
"target": "components/pi-app/agent-workspace.tsx"
|
|
28
34
|
},
|
|
29
35
|
{
|
|
30
36
|
"path": "registry/pi-app/sessions.tsx",
|
|
31
|
-
"content": "'use client';\n\nimport {
|
|
37
|
+
"content": "'use client';\n\nimport type { ReactNode } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { ScrollArea } from '@/components/ui/scroll-area';\nimport { ChevronsUpDownIcon, MenuIcon } from 'lucide-react';\nimport { useAppText } from './app-labels';\nimport { ConversationList } from '../pi-chat/pi-chat';\nimport type { ConversationsConfig } from '../pi-chat/pi-chat';\nimport { cn } from '../utils/utils';\n\nexport interface WorkspaceAgent { readonly id: string; readonly label: string; readonly description?: string | undefined }\nexport interface WorkspaceAgents { readonly items: readonly WorkspaceAgent[]; readonly activeId: string; readonly onSelect: (id: string) => void }\nexport type SessionsView = 'conversations' | 'library';\n\n/** The same navigation in a desktop column and a focus-trapped, swipe-dismissable phone drawer. */\nexport function SessionsPane({ id, conversations, title: givenTitle, drawer, onClose, onCollapse, library, onChat, libraryContent, view = 'conversations', onViewChange, agents }: {\n /** The pane's element id (the toggle's `aria-controls`). */\n readonly id?: string | undefined;\n readonly conversations?: ConversationsConfig | undefined;\n readonly library?: { readonly selected: boolean; readonly onSelect: () => void; readonly side?: boolean } | undefined;\n readonly onChat?: (() => void) | undefined;\n readonly libraryContent?: ((onPicked: () => void) => ReactNode) | undefined;\n readonly view?: SessionsView | undefined;\n readonly onViewChange?: ((view: SessionsView) => void) | undefined;\n readonly agents?: WorkspaceAgents | undefined;\n readonly title?: string;\n readonly drawer: boolean;\n readonly onClose?: (() => void) | undefined;\n readonly onCollapse?: (() => void) | undefined;\n}) {\n const { labels, icons } = useAppText();\n const title = givenTitle ?? (conversations || !libraryContent ? labels.sessionsTitle : labels.library);\n const picked = () => { onChat?.(); if (drawer) onClose?.(); };\n const tabs = Boolean(libraryContent && conversations);\n const shown = !conversations && libraryContent ? 'library' : tabs ? view : 'conversations';\n const navigation = library && <Button variant=\"ghost\" data-testid=\"workspace-library\" aria-pressed={library.selected} className=\"mx-2 justify-start\" onClick={() => { library.onSelect(); if (drawer) onClose?.(); }}><icons.library aria-hidden=\"true\" />{labels.library}</Button>;\n const pane = <aside id={id} data-testid=\"conversations\" data-drawer={drawer ? 'true' : 'false'} aria-label={title}\n className={cn('pi-chat flex min-h-0 flex-1 flex-col bg-muted/30 text-foreground', drawer ? 'pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]' : 'w-[260px] shrink-0 flex-none')}>\n <header className=\"flex h-13 shrink-0 items-center gap-2 px-4\">\n {agents && agents.items.length > 1 ? <DropdownMenu>\n <DropdownMenuTrigger render={<Button variant=\"ghost\" aria-label={labels.agent} data-testid=\"agent-select\" className=\"min-w-0 flex-1 justify-between\" />}>\n <span className=\"truncate\">{(agents.items.find(agent => agent.id === agents.activeId) ?? agents.items[0])?.label}</span><ChevronsUpDownIcon data-icon=\"inline-end\" aria-hidden=\"true\" />\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"start\" aria-label={labels.agent}>\n <DropdownMenuRadioGroup value={agents.activeId} onValueChange={agents.onSelect}>\n {agents.items.map(agent => <DropdownMenuRadioItem key={agent.id} value={agent.id} title={agent.description} data-testid={`agent-option-${agent.id}`}>{agent.label}</DropdownMenuRadioItem>)}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu> : <h2 className=\"m-0 min-w-0 flex-1 truncate text-sm font-semibold tracking-tight\">{agents?.items[0]?.label ?? title}</h2>}\n {(drawer ? onClose : onCollapse) && <SessionsToggle open drawer={drawer} navigation={Boolean(library || libraryContent)} onToggle={(drawer ? onClose : onCollapse)!} />}\n </header>\n {conversations?.onNew && <Button variant=\"ghost\" data-testid=\"conversation-new\" className=\"mx-2 mb-1 justify-start\" onClick={() => { conversations.onNew?.(); picked(); }}><icons.newChat data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.newChat}</Button>}\n {tabs && <div role=\"tablist\" aria-label={title} className=\"mx-2 flex gap-1\">{(['conversations', 'library'] as const).map(tab => <Button key={tab} role=\"tab\" variant=\"ghost\" aria-selected={shown === tab} data-testid={`sessions-tab-${tab}`} onClick={() => onViewChange?.(tab)}>{tab === 'library' ? labels.library : title}</Button>)}</div>}\n {shown === 'library' && libraryContent ? <ScrollArea data-testid=\"sessions-library\" className=\"min-h-0 flex-1\">{libraryContent(() => { if (drawer) onClose?.(); })}</ScrollArea>\n : conversations ? <ConversationList conversations={conversations} onPicked={picked} navigation={navigation} /> : navigation}\n </aside>;\n if (!drawer) return pane;\n return <Drawer open onOpenChange={next => { if (!next) onClose?.(); }} swipeDirection=\"left\">\n <DrawerContent aria-label={title}>\n <DrawerTitle className=\"sr-only\">{title}</DrawerTitle>{pane}\n </DrawerContent>\n </Drawer>;\n}\n\nexport function SessionsToggle({ open, drawer, onToggle, navigation = false, controls }: { readonly open: boolean; readonly drawer: boolean; readonly onToggle: () => void; readonly navigation?: boolean; readonly controls?: string | undefined }) {\n const { labels, icons } = useAppText();\n const label = navigation ? drawer ? open ? labels.closeNavigation : labels.openNavigation : open ? labels.hideNavigation : labels.showNavigation\n : drawer ? open ? labels.closeSessions : labels.openSessions : open ? labels.hideSessions : labels.showSessions;\n return <Button size=\"icon-sm\" variant=\"ghost\" data-testid=\"sessions-toggle\" aria-label={label} title={label} aria-expanded={open} aria-controls={controls} onClick={onToggle} className=\"shrink-0\">\n {drawer && !open ? <MenuIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <icons.sessionsToggle data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Button>;\n}\n",
|
|
32
38
|
"type": "registry:component",
|
|
33
39
|
"target": "components/pi-app/sessions.tsx"
|
|
34
40
|
},
|
|
41
|
+
{
|
|
42
|
+
"path": "registry/pi-app/history-column.tsx",
|
|
43
|
+
"content": "'use client';\n\n// Conversation history as a side column at the start of an agent pane (page and docked placements, pane at least 720px wide), and a\n// popover or sheet otherwise. Host concern: Pi has no layout. The column is `SessionsPane` over the consumer's sidebar parts; this file\n// decides when it fits and remembers, per viewer, whether it is open. `AgentWorkspace` uses the same rule and memory for its sessions pane.\nimport { useCallback, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { useAppText } from './app-labels';\nimport { SessionsPane } from './sessions';\nimport type { ConversationsConfig } from '../pi-chat/pi-chat';\nimport { cn } from '../utils/utils';\n\n/** `column` (default): History toggles the side column where it fits. `popover`: History always opens the popover or sheet. */\nexport type HistoryPlacement = 'column' | 'popover';\n\n/** The column (SessionsPane's 260px and its separator), the pane it needs with nothing beside the chat, and the chat it must leave usable. */\nexport const HISTORY_COLUMN = { width: 261, minPane: 720, minChat: 360 } as const;\n\n/** Whether the column fits a pane `width` wide; `beside` is what an open artifact takes next to the chat (0 when none is open). */\nexport const historyColumnFits = (width: number, beside = 0): boolean =>\n width >= (beside > 0 ? HISTORY_COLUMN.width + beside + HISTORY_COLUMN.minChat : HISTORY_COLUMN.minPane);\n\n/** The open state of the column, remembered per viewer under `storageKey` (a convenience: storage may be unavailable). */\nexport function useHistoryOpen(storageKey: string, defaultOpen: boolean): readonly [boolean, (open: boolean) => void] {\n const [open, setOpenState] = useState(() => {\n try { const stored = localStorage.getItem(storageKey); return stored === null ? defaultOpen : stored === 'open'; } catch { return defaultOpen; }\n });\n const setOpen = useCallback((next: boolean) => {\n try { localStorage.setItem(storageKey, next ? 'open' : 'closed'); } catch { /* per-viewer convenience */ }\n setOpenState(next);\n }, [storageKey]);\n return [open, setOpen] as const;\n}\n\n/** Opening moves focus to the open conversation's row; closing leaves it on the toggle that was pressed. */\nexport const focusHistoryRow = (id: string) => requestAnimationFrame(() => {\n document.getElementById(id)?.querySelector<HTMLElement>('[data-testid=conversation-row][aria-current=true], [data-testid=conversation-row]')?.focus({ preventScroll: true });\n});\n\nexport interface HistoryColumnToggleState { readonly id: string; readonly open: boolean; readonly onToggle: () => void }\n\nexport interface HistoryColumnOptions {\n /** The column's element id, the toggle's `aria-controls`. */\n readonly id: string;\n /** Where the open state is remembered for this viewer (`localStorage`). */\n readonly storageKey: string;\n readonly conversations: ConversationsConfig | undefined;\n /** False where only the popover or sheet fits (floating panes, phones) and for `history=\"popover\"`. */\n readonly enabled: boolean;\n /** What an open artifact takes beside the chat (0 when none is open): the column steps aside while column, artifact and chat do not fit. */\n readonly beside?: number;\n readonly defaultOpen?: boolean;\n readonly title?: string;\n}\n\n/**\n * The column of one agent pane: put `ref` on the pane (the column and the chat), render `column` at its start and give `toggle` to the\n * History button (`HistoryColumnToggle`). `toggle` is null while the column does not fit: History then opens its popover or sheet.\n */\nexport function useHistoryColumn({ id, storageKey, conversations, enabled, beside = 0, defaultOpen = false, title }: HistoryColumnOptions): {\n readonly ref: (element: HTMLElement | null) => void;\n readonly toggle: HistoryColumnToggleState | null;\n readonly column: ReactNode;\n} {\n const [width, setWidth] = useState(0);\n const observer = useRef<ResizeObserver | null>(null);\n const ref = useCallback((element: HTMLElement | null) => {\n observer.current?.disconnect();\n if (!element) return;\n setWidth(element.getBoundingClientRect().width);\n if (typeof ResizeObserver === 'undefined') return;\n observer.current = new ResizeObserver(([entry]) => { if (entry) setWidth(entry.contentRect.width); });\n observer.current.observe(element);\n }, []);\n const [open, setOpen] = useHistoryOpen(storageKey, defaultOpen);\n const fits = enabled && Boolean(conversations) && historyColumnFits(width, beside);\n const shown = fits && open;\n const toggle = fits ? { id, open: shown, onToggle: () => { setOpen(!open); if (!open) focusHistoryRow(id); } } : null;\n return { ref, toggle, column: shown && <HistoryColumn id={id} conversations={conversations} {...(title ? { title } : {})} /> };\n}\n\n/** The side column: the sessions pane (search, New, the rows and their actions) and a vertical separator. Escape and a pick leave it open. */\nexport function HistoryColumn({ id, conversations, title }: { readonly id: string; readonly conversations: ConversationsConfig | undefined; readonly title?: string }) {\n return <div id={id} data-testid=\"history-column\" className=\"flex min-h-0 shrink-0\">\n <SessionsPane conversations={conversations} drawer={false} {...(title ? { title } : {})} />\n <Separator orientation=\"vertical\" className=\"w-px\" />\n </div>;\n}\n\n/** History as the column's toggle: `aria-expanded`, `aria-controls`, test id `chat-action-history`. */\nexport function HistoryColumnToggle({ toggle, className }: { readonly toggle: HistoryColumnToggleState; readonly className?: string }) {\n const { labels, icons } = useAppText();\n return <Button size=\"icon\" variant=\"ghost\" data-testid=\"chat-action-history\" aria-label={labels.history} title={toggle.open ? labels.hideHistory : labels.showHistory}\n aria-expanded={toggle.open} aria-controls={toggle.id} data-state={toggle.open ? 'open' : 'closed'} onClick={toggle.onToggle} className={cn(className)}>\n <icons.history aria-hidden=\"true\" />\n </Button>;\n}\n",
|
|
44
|
+
"type": "registry:component",
|
|
45
|
+
"target": "components/pi-app/history-column.tsx"
|
|
46
|
+
},
|
|
35
47
|
{
|
|
36
48
|
"path": "registry/pi-app/artifact-panel.tsx",
|
|
37
|
-
"content": "'use client';\n\n// The artifact panel: the standard viewer bar (title, type, version switcher, Refresh, Share, copy, download, close) over the viewer for\n// the artifact's type. It follows the latest saved revision while nothing is unsaved (\"Latest\") or pins one older version, read-only. It\n// lists the versions the host's workspace provider retains for the file (`history`) and reads through the host's resource client. It never\n// executes artifact content (HTML runs only in the viewers' sandboxed preview when the host passes `interactive`).\nimport { useEffect, useMemo, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { CodeXmlIcon, EyeIcon } from 'lucide-react';\nimport { randomUUID } from '@hachej/boring-files/platform';\nimport type { ResourceClient, ResourceSnapshot } from '@hachej/boring-files';\nimport type { ResourceIdentity } from '@hachej/boring-files/remote';\nimport { createMarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport type { MarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport { createHtmlController } from '@hachej/boring-ui-kit/html';\nimport type { HtmlController } from '@hachej/boring-ui-kit/html';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { artifactKey, collectArtifacts } from '../pi-chat/pi-chat';\nimport type { ArtifactDescriptor, ArtifactTarget, ArtifactsConfig } from '../pi-chat/pi-chat';\nimport { CodeBlock } from '../pi-chat/code-block';\nimport { typeLabel } from '../pi-chat/artifact';\nimport { HtmlPane } from '../viewers/html-pane';\nimport { MarkdownPane } from '../viewers/markdown-pane';\nimport { ViewerFrame, ViewerToggle } from '../viewers/viewer-frame';\nimport type { ViewerFrameProps, ViewerShare } from '../viewers/viewer-frame';\nimport { ViewerVersions } from '../viewers/menu';\nimport type { InteractiveHtml } from '../viewers/interactive-html';\nimport { downloadFile } from '../viewers/download';\nimport { copyText } from '../utils/utils';\nimport { savedLabel, useSaved } from './use-saved';\nimport { useAppText } from './app-labels';\n\nconst NOOP = () => () => {};\nconst POLL_MS = 1500;\nconst EXTENSION = /\\.([A-Za-z0-9]{1,8})$/;\n\n/** One retained revision of a file with its save time (`files.saves(path)` of `@hachej/boring-files/workspace`). */\nexport interface SavedRevision { readonly revision: string; readonly savedAt: number }\n\n/** What a host viewer for an artifact type or file kind the block does not render itself (for example a tldraw canvas) receives. */\nexport interface CustomViewerProps {\n readonly target: ArtifactTarget;\n readonly title: string;\n /** A pinned revision, read-only; absent: follow the latest. */\n readonly revision?: string | undefined;\n /** Pass these on to the `ViewerFrame` around it: test ids, the version switcher or back button, Close, the share target. */\n readonly frame: Pick<ViewerFrameProps, 'testId' | 'titleTestId' | 'controls' | 'onClose' | 'target'>;\n}\n/** Host viewers by artifact type or file kind (`canvas`, or any other). They take precedence over the built-in ones. */\nexport type CustomViewers = Readonly<Record<string, (props: CustomViewerProps) => ReactNode>>;\n\n/** Everything a viewer in the panel reads through: one resource client, the version history, host viewers and policy. */\nexport interface ViewerOptions {\n readonly client: ResourceClient;\n readonly identity: ResourceIdentity;\n readonly history?: ((path: string) => Promise<readonly SavedRevision[]>) | undefined;\n readonly viewers?: CustomViewers | undefined;\n /** The host's opt-in to running HTML pages in the sandboxed preview. */\n readonly interactive?: InteractiveHtml | undefined;\n readonly share?: ViewerShare | undefined;\n /** Told each revision a built-in viewer has mounted and shows (after it loads, never before): the page's acknowledgement of `present`. */\n readonly onShown?: ((shown: { readonly path: string; readonly revision: string; readonly hidden?: true }) => void) | undefined;\n}\n\n/** Reports `revision` of `path` once the viewer showing it has mounted (an effect, so never before the panel really shows it), and `hidden` when it stops. */\nexport function useReportShown(onShown: ViewerOptions['onShown'], path: string, revision: string | undefined): void {\n useEffect(() => {\n if (revision === undefined || !onShown) return undefined;\n onShown({ path, revision });\n return () => onShown({ path, revision, hidden: true });\n }, [onShown, path, revision]);\n}\n\n/** Every artifact version the conversation's tool results produced (the version switcher's source), oldest first. */\nexport function useArtifactVersions(controller: NativeChatController | undefined, detect?: ArtifactsConfig['detect']): ArtifactDescriptor[] {\n const view = useSyncExternalStore(controller ? controller.subscribe : NOOP, () => controller?.getSnapshot().view, () => undefined);\n return useMemo(() => collectArtifacts(view, detect), [view, detect]);\n}\n\n/** How many messages the person has sent in the conversation: it changes when a new turn starts. */\nexport function useTurn(controller: NativeChatController | undefined): number {\n const view = useSyncExternalStore(controller ? controller.subscribe : NOOP, () => controller?.getSnapshot().view, () => undefined);\n return useMemo(() => (view?.entries ?? []).reduce((count, entry) => count + (entry.model ?? []).filter(message => message.role === 'user').length, 0), [view]);\n}\n\n/** The revisions the workspace provider retains for a file with their save times, newest first; followed while the panel is open. */\nfunction useHistory(history: ViewerOptions['history'], path: string): readonly SavedRevision[] {\n const [revisions, setRevisions] = useState<readonly SavedRevision[]>([]);\n useEffect(() => {\n if (!history) return;\n let cancelled = false;\n const key = (saves: readonly SavedRevision[]) => saves.map(save => `${save.revision}@${save.savedAt}`).join();\n const load = () => history(path).then(saves => { if (!cancelled) setRevisions(current => key(current) === key(saves) ? current : saves); }).catch(() => {});\n void load();\n const timer = setInterval(load, POLL_MS);\n return () => { cancelled = true; clearInterval(timer); };\n }, [history, path]);\n return revisions;\n}\n\ninterface VersionMenu {\n readonly items: readonly { readonly id: string; readonly label: string; readonly latest: boolean }[];\n readonly current: string;\n /** Position counted from the oldest retained revision: only a test hook. */\n readonly number: number | undefined;\n readonly savedAt: number | undefined;\n}\n\n/** The version menu, newest first: each retained revision is named by when it was saved, and the newest is \"Latest\". The pin is the revision. */\nfunction versionMenu(artifact: ArtifactDescriptor, follow: boolean, revisions: readonly SavedRevision[], earlier: string): VersionMenu {\n const at = follow ? 0 : revisions.findIndex(save => save.revision === artifact.revision);\n const shown = at < 0 ? undefined : revisions[at];\n return { items: revisions.map((save, index) => ({ id: index === 0 ? 'latest' : save.revision, label: savedLabel(save.savedAt, Date.now(), earlier), latest: index === 0 })),\n current: follow ? 'latest' : artifact.revision, number: shown ? revisions.length - at : undefined, savedAt: shown?.savedAt };\n}\n\nfunction SvgView({ text, title, frame }: { readonly text: string; readonly title: string; readonly frame: ViewerFrameProps }) {\n const { labels } = useAppText();\n const [mode, setMode] = useState<'preview' | 'source'>('preview');\n // An SVG is shown only as an image from a blob URL: it never becomes markup in this page, so its scripts cannot run.\n const url = useMemo(() => URL.createObjectURL(new Blob([text], { type: 'image/svg+xml' })), [text]);\n useEffect(() => () => URL.revokeObjectURL(url), [url]);\n return <ViewerFrame {...frame} controls={<>\n <ViewerToggle label={labels.view} value={mode} onChange={setMode} options={[\n { id: 'preview', label: labels.preview, text: labels.preview, icon: <EyeIcon className=\"size-4\" aria-hidden=\"true\" />, testId: 'artifact-mode' },\n { id: 'source', label: labels.source, text: labels.source, icon: <CodeXmlIcon className=\"size-4\" aria-hidden=\"true\" />, testId: 'artifact-mode' }]} />\n {frame.controls}</>}>\n {mode === 'preview'\n ? <div className=\"boring-viewer-checker flex min-h-0 flex-1 items-center justify-center overflow-auto bg-white p-6\"><img data-testid=\"artifact-svg\" src={url} alt={title} className=\"max-h-full max-w-full\" /></div>\n : <div className=\"min-h-0 flex-1 overflow-auto p-3\"><CodeBlock code={text} language=\"svg\" /></div>}\n </ViewerFrame>;\n}\n\ntype DocumentController = MarkdownController | HtmlController;\n\n/** One artifact version's frame and body. Mounted per resource and revision, so switching version starts clean. */\nfunction Version({ artifact, pinned, menu, options, onSelect, onClose }: {\n readonly artifact: ArtifactDescriptor; readonly pinned: boolean; readonly menu: VersionMenu; readonly options: ViewerOptions;\n readonly onSelect: (value: string) => void; readonly onClose: () => void;\n}) {\n const { type } = artifact;\n const { client, identity } = options;\n const { labels } = useAppText();\n const path = artifact.target.resource.path;\n const custom = options.viewers?.[type];\n const create = useMemo(() => {\n const base = (snapshot: ResourceSnapshot) => ({ identity, client, instanceId: randomUUID(), epoch: 'workspace', source: { kind: 'saved' as const, snapshot } });\n if (custom) return undefined;\n if (type === 'markdown') return (snapshot: ResourceSnapshot): DocumentController => createMarkdownController({ ...base(snapshot), readOnly: pinned });\n if (type === 'html') return (snapshot: ResourceSnapshot): DocumentController => createHtmlController({ ...base(snapshot), readOnly: true });\n return undefined;\n }, [type, pinned, client, identity, custom]);\n const saved = useSaved<DocumentController>({ client, target: artifact.target, revision: pinned ? artifact.revision : undefined, create });\n const controller = saved.controller;\n // The text to copy and download is the buffer when a viewer holds one, so the person's unsaved edits are included.\n const text = useSyncExternalStore(controller ? controller.subscribe : NOOP, () => controller ? controller.getSnapshot().text : saved.text, () => saved.text);\n const extension = EXTENSION.exec(path)?.[1] ?? 'txt';\n const revision = saved.kind === 'open' ? saved.snapshot.ref.revision : pinned ? artifact.revision : undefined;\n // A host viewer and the canvas (which shows only a \"no viewer\" note) never report a mount: a presented file in them stays unconfirmed.\n useReportShown(options.onShown, path, !custom && type !== 'canvas' && saved.kind === 'open' ? saved.snapshot.ref.revision : undefined);\n // The history menu: newest first, the newest marked \"Latest\" (choosing it follows the latest), the one on display checked.\n const versions = menu.items.length > 1 ? <ViewerVersions testId=\"artifact-versions\" current={menu.current} onSelect={onSelect} versions={menu.items} /> : undefined;\n const target = { artifact: artifactKey(artifact), version: pinned ? artifact.revision : 'latest' };\n const frame = {\n title: artifact.title, testId: 'artifact', titleTestId: 'artifact-panel-title', controls: versions, onClose, target, ...(revision === undefined ? {} : { revision }), ...(options.share ? { onShare: options.share } : {}),\n subtitle: <>\n <span data-testid=\"artifact-panel-type\">{typeLabel(artifact)}</span>\n <span aria-hidden=\"true\">·</span>\n <span data-testid=\"artifact-panel-version\">{pinned ? (menu.savedAt !== undefined ? labels.savedAt(savedLabel(menu.savedAt, Date.now(), labels.earlierVersion)) : labels.olderVersion) : menu.savedAt ? labels.latestSavedAt(savedLabel(menu.savedAt, Date.now(), labels.earlierVersion)) : labels.latest}</span>\n </>,\n ...(pinned ? { status: { label: labels.readOnly, kind: 'read-only' as const } } : {}),\n };\n const filename = `${artifact.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'artifact'}.${extension}`;\n const body = (children: ReactNode) => <div data-testid=\"artifact-body\" data-state={saved.kind} className=\"flex h-full min-h-0 flex-1 flex-col overflow-hidden\">{children}</div>;\n if (custom) return body(custom({ target: artifact.target, title: artifact.title, ...(pinned ? { revision: artifact.revision } : {}), frame: { testId: 'artifact', titleTestId: 'artifact-panel-title', controls: versions, onClose, target } }));\n // Until the version is open the bar offers no Share: the pane that shows it brings its own frame, so this one is replaced (remounted)\n // when it opens, and a Share pressed just then would copy the link with its \"Link copied\" notice lost with the old frame.\n if (saved.kind !== 'open') {\n const { onShare: _, ...waiting } = frame;\n return body(<ViewerFrame {...waiting}><p className=\"m-0 p-4 text-sm text-muted-foreground\" role=\"status\">{saved.kind === 'loading' ? labels.loading : saved.kind === 'invalid' ? labels.artifactNotText : labels.versionUnavailable}</p></ViewerFrame>);\n }\n const document = (children: ReactNode) => <div data-testid=\"document\" data-revision={saved.snapshot.ref.revision} className=\"h-full min-h-0\">{children}</div>;\n if (type === 'markdown' && controller) return body(document(<MarkdownPane controller={controller as MarkdownController} initialMode=\"rich\" {...frame} />));\n if (type === 'html' && controller) return body(document(<HtmlPane controller={controller as HtmlController} {...(options.interactive ? { interactive: options.interactive } : {})} {...frame} />));\n const actions = { onCopy: () => copyText(text ?? ''), onDownload: () => downloadFile(filename, text ?? '', `${artifact.mediaType};charset=utf-8`) };\n if (type === 'svg') return body(<SvgView text={saved.text} title={artifact.title} frame={{ ...frame, ...actions }} />);\n if (type === 'canvas') return body(<ViewerFrame {...frame}><p className=\"m-0 p-4 text-sm text-muted-foreground\" role=\"status\">{labels.noCanvasViewer}</p></ViewerFrame>);\n return body(<ViewerFrame {...frame} {...actions}><div className=\"min-h-0 flex-1 overflow-auto p-3\"><CodeBlock code={saved.text} language={artifact.language ?? 'text'} /></div></ViewerFrame>);\n}\n\n/**\n * One artifact in the panel. `active.follow` shows the latest saved revision under the newest known title (`versions`: every version of\n * this file the conversation presented or saved, newest first); otherwise the one revision in `active.descriptor` is pinned and read-only.\n * `onSelect` receives `latest` or a revision from the version menu.\n */\nexport function ArtifactPanel({ active, versions, options, onSelect, onClose }: {\n readonly active: { readonly descriptor: ArtifactDescriptor; readonly follow: boolean };\n readonly versions: readonly ArtifactDescriptor[];\n readonly options: ViewerOptions;\n readonly onSelect: (value: string) => void;\n readonly onClose: () => void;\n}) {\n const { follow } = active;\n const artifact = follow ? versions[0] ?? active.descriptor : active.descriptor;\n const path = artifact.target.resource.path;\n const revisions = useHistory(options.history, path);\n const { labels } = useAppText();\n const menu = versionMenu(artifact, follow, revisions, labels.earlierVersion);\n return <div data-testid=\"artifact-panel\" data-artifact-id={artifactKey(artifact)} data-artifact-type={artifact.type} data-follow={follow ? 'true' : 'false'} data-artifact-position={menu.number} data-artifact-revision={follow ? undefined : artifact.revision}\n className=\"pi-chat flex h-full min-h-0 flex-col\">\n <Version key={`${path}@${follow ? 'latest' : artifact.revision}`} artifact={artifact} pinned={!follow} menu={menu} options={options} onSelect={onSelect} onClose={onClose} />\n </div>;\n}\n",
|
|
49
|
+
"content": "'use client';\n\n// The artifact panel: the standard viewer bar (title, type, version switcher, Refresh, Share, copy, download, close) over the viewer for\n// the artifact's type. It follows the latest saved revision while nothing is unsaved (\"Latest\") or pins one older version, read-only. It\n// lists the versions the host's workspace provider retains for the file (`history`) and reads through the host's resource client. It never\n// executes artifact content (HTML runs only in the viewers' sandboxed preview when the host passes `interactive`).\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { CodeXmlIcon, EyeIcon } from 'lucide-react';\nimport { randomUUID } from '@hachej/boring-files/platform';\nimport type { ResourceClient, ResourceSnapshot } from '@hachej/boring-files';\nimport type { ResourceIdentity } from '@hachej/boring-files/remote';\nimport type { SavedRevision as FileRevision } from '@hachej/boring-files/revision';\nimport { createMarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport type { MarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport { createHtmlController } from '@hachej/boring-ui-kit/html';\nimport type { HtmlController } from '@hachej/boring-ui-kit/html';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { artifactKey, collectArtifacts } from '../pi-chat/pi-chat';\nimport type { ArtifactDescriptor, ArtifactKind, ArtifactTarget, ArtifactsConfig } from '../pi-chat/pi-chat';\nimport { CodeBlock } from '../pi-chat/code-block';\nimport { artifactKind, typeLabel } from '../pi-chat/artifact';\nimport { HtmlPane } from '../viewers/html-pane';\nimport { MarkdownPane } from '../viewers/markdown-pane';\nimport { ViewerFrame, ViewerToggle } from '../viewers/viewer-frame';\nimport type { ViewerFrameProps, ViewerShare } from '../viewers/viewer-frame';\nimport { ViewerVersions } from '../viewers/menu';\nimport type { ViewerVersion } from '../viewers/menu';\nimport type { InteractiveHtml } from '../viewers/interactive-html';\nimport { downloadFile } from '../viewers/download';\nimport { copyText } from '../utils/utils';\nimport { Separator } from '@/components/ui/separator';\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';\nimport { savedLabel, useSaved } from './use-saved';\nimport { useAppText } from './app-labels';\n\nconst NOOP = () => () => {};\nconst POLL_MS = 1500;\nconst EXTENSION = /\\.([A-Za-z0-9]{1,8})$/;\n\n/** One retained revision of a file with its save time (`files.saves(path)` of `@hachej/boring-files/workspace`). */\nexport type SavedRevision = FileRevision;\n\n/** What a host viewer for an artifact type or file kind the block does not render itself (for example a tldraw canvas) receives. */\nexport interface CustomViewerProps {\n readonly target: ArtifactTarget;\n readonly title: string;\n /** A pinned revision, read-only; absent: follow the latest. */\n readonly revision?: string | undefined;\n /** Pass these on to the `ViewerFrame` around it: test ids, the version switcher or back button, Close, the share target. */\n readonly frame: Pick<ViewerFrameProps, 'testId' | 'titleTestId' | 'headerStart' | 'controls' | 'onClose' | 'target'>;\n}\n/** Host viewers by artifact type or file kind (`canvas`, or any other). They take precedence over the built-in ones. */\nexport type CustomViewers = Readonly<Record<string, (props: CustomViewerProps) => ReactNode>>;\n\n/** Everything a viewer in the panel reads through: one resource client, the version history, host viewers and policy. */\nexport interface ViewerOptions {\n readonly client: ResourceClient;\n readonly identity: ResourceIdentity;\n readonly history?: ((path: string) => Promise<readonly SavedRevision[]>) | undefined;\n readonly viewers?: CustomViewers | undefined;\n /** The app's artifact kinds: their label names the artifact in the bar and their `renderer` draws it (before `viewers` and the built-in viewers). */\n readonly kinds?: readonly ArtifactKind[] | undefined;\n /** The host's opt-in to running HTML pages in the sandboxed preview. */\n readonly interactive?: InteractiveHtml | undefined;\n readonly share?: ViewerShare | undefined;\n readonly onUnsavedChange?: ((unsaved: boolean) => void) | undefined;\n readonly onBeforeLeaveChange?: ((flush: (() => Promise<boolean>) | null) => void) | undefined;\n /** Told each revision a built-in viewer has mounted and shows (after it loads, never before): the page's acknowledgement of `present`. */\n readonly onShown?: ((shown: { readonly path: string; readonly revision: string; readonly hidden?: true }) => void) | undefined;\n}\n\n/** Reports `revision` of `path` once the viewer showing it has mounted (an effect, so never before the panel really shows it), and `hidden` when it stops. */\nexport function useReportShown(onShown: ViewerOptions['onShown'], path: string, revision: string | undefined): void {\n useEffect(() => {\n if (revision === undefined || !onShown) return undefined;\n onShown({ path, revision });\n return () => onShown({ path, revision, hidden: true });\n }, [onShown, path, revision]);\n}\n\n/** Every artifact version the conversation's tool results produced (the version switcher's source), oldest first. */\nexport function useArtifactVersions(controller: NativeChatController | undefined, detect?: ArtifactsConfig['detect']): ArtifactDescriptor[] {\n const view = useSyncExternalStore(controller ? controller.subscribe : NOOP, () => controller?.getSnapshot().view, () => undefined);\n return useMemo(() => collectArtifacts(view, detect), [view, detect]);\n}\n\n/** How many messages the person has sent in the conversation: it changes when a new turn starts. */\nexport function useTurn(controller: NativeChatController | undefined): number {\n const view = useSyncExternalStore(controller ? controller.subscribe : NOOP, () => controller?.getSnapshot().view, () => undefined);\n return useMemo(() => (view?.entries ?? []).reduce((count, entry) => count + (entry.model ?? []).filter(message => message.role === 'user').length, 0), [view]);\n}\n\n/** The revisions the workspace provider retains for a file with their save times, newest first; followed while the panel is open. */\nfunction useHistory(history: ViewerOptions['history'], path: string): readonly SavedRevision[] {\n const [revisions, setRevisions] = useState<readonly SavedRevision[]>([]);\n useEffect(() => {\n if (!history) return;\n let cancelled = false;\n const key = (saves: readonly SavedRevision[]) => saves.map(save => `${save.revision}@${save.savedAt}`).join();\n const load = () => history(path).then(saves => { if (!cancelled) setRevisions(current => key(current) === key(saves) ? current : saves); }).catch(() => {});\n void load();\n const timer = setInterval(load, POLL_MS);\n return () => { cancelled = true; clearInterval(timer); };\n }, [history, path]);\n return revisions;\n}\n\ninterface VersionMenu {\n readonly items: readonly ViewerVersion[];\n readonly current: string;\n /** Position counted from the oldest retained revision: only a test hook. */\n readonly number: number | undefined;\n readonly savedAt: number | undefined;\n}\n\n/** The version menu, newest first: each retained revision is named by when it was saved, and the newest is \"Latest\". The pin is the revision. */\nfunction versionChoices(artifact: ArtifactDescriptor, follow: boolean, revisions: readonly SavedRevision[], earlier: string): VersionMenu {\n const at = follow ? 0 : revisions.findIndex(save => save.revision === artifact.revision);\n const shown = at < 0 ? undefined : revisions[at];\n return { items: revisions.map((save, index) => ({ id: index === 0 ? 'latest' : save.revision, label: savedLabel(save.savedAt, Date.now(), earlier), savedAt: save.savedAt, ...(save.source ? { source: save.source } : {}), latest: index === 0 })),\n current: follow ? 'latest' : artifact.revision, number: shown ? revisions.length - at : undefined, savedAt: shown?.savedAt };\n}\n\nfunction SvgView({ text, title, frame }: { readonly text: string; readonly title: string; readonly frame: ViewerFrameProps }) {\n const { labels } = useAppText();\n const [mode, setMode] = useState<'preview' | 'source'>('preview');\n // An SVG is shown only as an image from a blob URL: it never becomes markup in this page, so its scripts cannot run.\n const url = useMemo(() => URL.createObjectURL(new Blob([text], { type: 'image/svg+xml' })), [text]);\n useEffect(() => () => URL.revokeObjectURL(url), [url]);\n return <ViewerFrame {...frame} controls={<>\n <ViewerToggle label={labels.view} value={mode} onChange={setMode} options={[\n { id: 'preview', label: labels.preview, text: labels.preview, icon: <EyeIcon className=\"size-4\" aria-hidden=\"true\" />, testId: 'artifact-mode' },\n { id: 'source', label: labels.source, text: labels.source, icon: <CodeXmlIcon className=\"size-4\" aria-hidden=\"true\" />, testId: 'artifact-mode' }]} />\n {frame.controls}</>}>\n {mode === 'preview'\n ? <div className=\"boring-viewer-checker flex min-h-0 flex-1 items-center justify-center overflow-auto p-6\"><img data-testid=\"artifact-svg\" src={url} alt={title} className=\"max-h-full max-w-full\" /></div>\n : <div className=\"min-h-0 flex-1 overflow-auto p-3\"><CodeBlock code={text} language=\"svg\" /></div>}\n </ViewerFrame>;\n}\n\n/** Rows of a CSV text (comma or semicolon separated, double-quoted cells with `\"\"` escapes), at most 2000. */\nexport function csvRows(text: string): string[][] {\n const rows: string[][] = []; let row: string[] = [], cell = '', quoted = false;\n for (let i = 0; i < text.length && rows.length < 2000; i++) {\n const c = text[i];\n if (quoted) { if (c === '\"' && text[i + 1] === '\"') { cell += '\"'; i++; } else if (c === '\"') quoted = false; else cell += c; }\n else if (c === '\"') quoted = true;\n else if (c === ',' || c === ';') { row.push(cell); cell = ''; }\n else if (c === '\\n' || c === '\\r') { if (c === '\\r' && text[i + 1] === '\\n') i++; row.push(cell); rows.push(row); row = []; cell = ''; }\n else cell += c;\n }\n if ((cell || row.length) && rows.length < 2000) { row.push(cell); rows.push(row); }\n return rows;\n}\n\n/** A CSV artifact as a table: the first row is the header. */\nfunction CsvTable({ text }: { readonly text: string }) {\n const [head = [], ...rows] = csvRows(text);\n return <Table data-testid=\"artifact-csv\">\n <TableHeader><TableRow>{head.map((cell, i) => <TableHead key={i}>{cell}</TableHead>)}</TableRow></TableHeader>\n <TableBody>{rows.map((row, i) => <TableRow key={i}>{row.map((cell, j) => <TableCell key={j} className=\"align-top\">{cell}</TableCell>)}</TableRow>)}</TableBody>\n </Table>;\n}\n\ntype DocumentController = MarkdownController | HtmlController;\n\n/** One artifact version's frame and body. Mounted per resource and revision, so switching version starts clean. */\nfunction Version({ artifact, pinned, menu, options, onSelect, onClose }: {\n readonly artifact: ArtifactDescriptor; readonly pinned: boolean; readonly menu: VersionMenu; readonly options: ViewerOptions;\n readonly onSelect: (value: string) => void; readonly onClose: () => void;\n}) {\n const { type } = artifact;\n const { client, identity } = options;\n const { labels } = useAppText();\n const path = artifact.target.resource.path;\n const kind = artifactKind(artifact, options.kinds);\n const custom = kind?.renderer ? undefined : options.viewers?.[type];\n const create = useMemo(() => {\n const base = (snapshot: ResourceSnapshot) => ({ identity, client, instanceId: randomUUID(), epoch: 'workspace', source: { kind: 'saved' as const, snapshot } });\n if (custom || kind?.renderer) return undefined;\n if (type === 'markdown') return (snapshot: ResourceSnapshot): DocumentController => createMarkdownController({ ...base(snapshot), readOnly: pinned });\n if (type === 'html') return (snapshot: ResourceSnapshot): DocumentController => createHtmlController({ ...base(snapshot), readOnly: true });\n return undefined;\n }, [type, pinned, client, identity, custom, kind?.renderer]);\n const autosave = useRef<{ controller: DocumentController; flush: () => Promise<boolean> } | null>(null);\n const saved = useSaved<DocumentController>({ client, target: artifact.target, revision: pinned ? artifact.revision : undefined, create,\n beforeDispose: controller => autosave.current?.controller === controller ? autosave.current.flush() : undefined });\n const controller = saved.controller;\n const registerAutosave = useCallback((flush: (() => Promise<boolean>) | null) => {\n autosave.current = flush && controller ? { controller, flush } : null;\n }, [controller]);\n useLayoutEffect(() => {\n if (!controller) return;\n const notify = () => {\n const state = controller.getSnapshot();\n options.onUnsavedChange?.(state.dirty || state.save.kind === 'pending' || state.save.kind === 'settled' && state.save.result.kind === 'unknown');\n };\n notify(); const stop = controller.subscribe(notify);\n return () => { stop(); options.onUnsavedChange?.(false); };\n }, [controller, options.onUnsavedChange]);\n // The text to copy and download is the buffer when a viewer holds one, so the person's unsaved edits are included.\n const text = useSyncExternalStore(controller ? controller.subscribe : NOOP, () => controller ? controller.getSnapshot().text : saved.text, () => saved.text);\n const extension = EXTENSION.exec(path)?.[1] ?? 'txt';\n const revision = saved.kind === 'open' ? saved.snapshot.ref.revision : pinned ? artifact.revision : undefined;\n // A host viewer and the canvas (which shows only a \"no viewer\" note) never report a mount: a presented file in them stays unconfirmed.\n useReportShown(options.onShown, path, !custom && type !== 'canvas' && saved.kind === 'open' ? saved.snapshot.ref.revision : undefined);\n // Choosing newest follows live saves; Current marks the displayed revision.\n const versions = menu.items.length > 1 ? <ViewerVersions testId=\"artifact-versions\" current={menu.current} onSelect={onSelect} versions={menu.items} /> : undefined;\n const target = { artifact: artifactKey(artifact), version: pinned ? artifact.revision : 'latest' };\n const frame = {\n title: artifact.title, testId: 'artifact', titleTestId: 'artifact-panel-title', controls: versions, onClose, target, ...(revision === undefined ? {} : { revision }), ...(options.share ? { onShare: options.share } : {}),\n subtitle: <>\n <span title={path}>{path}</span><span aria-hidden=\"true\">·</span>\n <span data-testid=\"artifact-panel-type\">{typeLabel(artifact, options.kinds)}</span>\n <span aria-hidden=\"true\">·</span>\n <span data-testid=\"artifact-panel-version\">{pinned ? (menu.savedAt !== undefined ? labels.savedAt(savedLabel(menu.savedAt, Date.now(), labels.earlierVersion)) : labels.olderVersion) : menu.savedAt ? labels.latestSavedAt(savedLabel(menu.savedAt, Date.now(), labels.earlierVersion)) : labels.latest}</span>\n </>,\n ...(pinned ? { status: { label: labels.readOnly, kind: 'read-only' as const } } : {}),\n };\n const filename = `${artifact.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'artifact'}.${extension}`;\n const body = (children: ReactNode) => <div data-testid=\"artifact-body\" data-state={saved.kind} className=\"flex h-full min-h-0 flex-1 flex-col overflow-hidden\">{children}</div>;\n if (custom) return body(custom({ target: artifact.target, title: artifact.title, ...(pinned ? { revision: artifact.revision } : {}), frame: { testId: 'artifact', titleTestId: 'artifact-panel-title', controls: versions, onClose, target } }));\n // Until the version is open the bar offers no Share: the pane that shows it brings its own frame, so this one is replaced (remounted)\n // when it opens, and a Share pressed just then would copy the link with its \"Link copied\" notice lost with the old frame.\n if (saved.kind !== 'open') {\n const { onShare: _, ...waiting } = frame;\n return body(<ViewerFrame {...waiting}><p className=\"m-0 p-4 text-sm text-muted-foreground\" role=\"status\">{saved.kind === 'loading' ? labels.loading : saved.kind === 'invalid' ? labels.artifactNotText : labels.versionUnavailable}</p></ViewerFrame>);\n }\n const document = (children: ReactNode) => <div data-testid=\"document\" data-revision={saved.snapshot.ref.revision} className=\"flex h-full min-h-0 flex-col\">\n {pinned && <><p role=\"note\" data-testid=\"version-view-only\" className=\"m-0 shrink-0 bg-muted/30 px-3 py-2 text-xs text-muted-foreground\">{labels.versionViewOnly}</p><Separator /></>}\n <div className=\"min-h-0 flex-1\">{children}</div>\n </div>;\n if (type === 'markdown' && controller) return body(document(<MarkdownPane controller={controller as MarkdownController} initialMode=\"rich\" onAutosaveChange={registerAutosave} onBeforeLeaveChange={options.onBeforeLeaveChange} {...frame} />));\n if (type === 'html' && controller) return body(document(<HtmlPane controller={controller as HtmlController} {...(options.interactive ? { interactive: options.interactive } : {})} {...frame} />));\n const actions = { onCopy: () => copyText(text ?? ''), onDownload: () => downloadFile(filename, text ?? '', `${artifact.mediaType};charset=utf-8`) };\n if (kind?.renderer) return body(<ViewerFrame {...frame} {...actions}><div data-testid=\"artifact-kind-view\" className=\"min-h-0 flex-1 overflow-auto\">{kind.renderer({ artifact, text: saved.text })}</div></ViewerFrame>);\n if (type === 'code' && artifact.language === 'csv') return body(<ViewerFrame {...frame} {...actions}><div className=\"min-h-0 flex-1 overflow-auto\"><CsvTable text={saved.text} /></div></ViewerFrame>);\n if (type === 'svg') return body(<SvgView text={saved.text} title={artifact.title} frame={{ ...frame, ...actions }} />);\n if (type === 'canvas') return body(<ViewerFrame {...frame}><p className=\"m-0 p-4 text-sm text-muted-foreground\" role=\"status\">{labels.noCanvasViewer}</p></ViewerFrame>);\n return body(<ViewerFrame {...frame} {...actions}><div className=\"min-h-0 flex-1 overflow-auto p-3\"><CodeBlock code={saved.text} language={artifact.language ?? 'text'} /></div></ViewerFrame>);\n}\n\n/**\n * One artifact in the panel. `active.follow` shows the latest saved revision under the newest known title (`versions`: every version of\n * this file the conversation presented or saved, newest first); otherwise the one revision in `active.descriptor` is pinned and read-only.\n * `onSelect` receives `latest` or a revision from the version menu.\n */\nexport function ArtifactPanel({ active, versions, options, onSelect, onClose }: {\n readonly active: { readonly descriptor: ArtifactDescriptor; readonly follow: boolean };\n readonly versions: readonly ArtifactDescriptor[];\n readonly options: ViewerOptions;\n readonly onSelect: (value: string) => void;\n readonly onClose: () => void;\n}) {\n const { follow } = active;\n const artifact = follow ? versions[0] ?? active.descriptor : active.descriptor;\n const path = artifact.target.resource.path;\n const revisions = useHistory(options.history, path);\n const { labels } = useAppText();\n const menu = versionChoices(artifact, follow, revisions, labels.earlierVersion);\n return <div data-testid=\"artifact-panel\" data-artifact-id={artifactKey(artifact)} data-artifact-type={artifact.type} data-follow={follow ? 'true' : 'false'} data-artifact-position={menu.number} data-artifact-revision={follow ? undefined : artifact.revision}\n className=\"pi-chat flex h-full min-h-0 flex-col\">\n <Version key={`${path}@${follow ? 'latest' : artifact.revision}`} artifact={artifact} pinned={!follow} menu={menu} options={options} onSelect={onSelect} onClose={onClose} />\n </div>;\n}\n",
|
|
38
50
|
"type": "registry:component",
|
|
39
51
|
"target": "components/pi-app/artifact-panel.tsx"
|
|
40
52
|
},
|
|
41
53
|
{
|
|
42
54
|
"path": "registry/pi-app/file-viewer.tsx",
|
|
43
|
-
"content": "'use client';\n\n// Opens one workspace file in the viewer that suits its type, always in the standard viewer frame (viewers item): Markdown and HTML in\n// their editors (revisioned through the host's resource client, so saves go back to the file and an edit made behind the editor's back is\n// a conflict), images and PDFs read-only from the file's bytes, anything else as text. A host viewer (`viewers[kind]`) takes precedence.\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { randomUUID } from '@hachej/boring-files/platform';\nimport type { ResourceLocator, ResourceSnapshot } from '@hachej/boring-files';\nimport { createMarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport type { MarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport { createHtmlController } from '@hachej/boring-ui-kit/html';\nimport type { HtmlController } from '@hachej/boring-ui-kit/html';\nimport { HtmlPane } from '../viewers/html-pane';\nimport { ImagePane } from '../viewers/image-pane';\nimport { MarkdownPane } from '../viewers/markdown-pane';\nimport { PdfPane } from '../viewers/pdf-pane';\nimport { ViewerFrame } from '../viewers/viewer-frame';\nimport { downloadFile } from '../viewers/download';\nimport { formatBytes } from '../viewers/media';\nimport { copyText } from '../utils/utils';\nimport { mediaTypeOf, viewerKindOf } from '@hachej/boring-files/file-types';\nimport type { ViewerKind as FileKind } from '@hachej/boring-files/file-types';\nimport { KIND_LABEL, decodeText } from './file-kinds';\nimport { useReportShown } from './artifact-panel';\nimport type { ViewerOptions } from './artifact-panel';\nimport { useSaved } from './use-saved';\nimport { useAppText } from './app-labels';\n\nconst POLL_MS = 1500;\nconst nameOf = (path: string) => path.split('/').pop() || path;\n\ninterface Common {\n readonly path: string;\n readonly kind: FileKind;\n readonly locator: ResourceLocator;\n readonly options: ViewerOptions;\n readonly onClose: () => void;\n readonly back: ReactNode;\n readonly onUnsavedChange?: ((unsaved: boolean) => void) | undefined;\n}\n\nconst subtitleOf = (locator: ResourceLocator) => <span data-testid=\"file-path\" className=\"truncate\">{locator.resource.path}</span>;\n\n/** Markdown or HTML: a revisioned controller over the file. A new revision remounts the pane; it starts in the mode the person chose. */\nfunction FileDocument({ path, kind, locator, options, onClose, back, onUnsavedChange }: Common) {\n const { client, identity } = options;\n const create = useMemo(() => (snapshot: ResourceSnapshot): MarkdownController | HtmlController => (kind === 'markdown' ? createMarkdownController : createHtmlController)({\n identity, client, instanceId: randomUUID(), epoch: 'workspace', source: { kind: 'saved', snapshot } }), [kind, client, identity]);\n const saved = useSaved({ client, target: locator, create });\n useLayoutEffect(() => {\n const controller = saved.controller;\n if (!controller) return;\n const notify = () => {\n const snapshot = controller.getSnapshot();\n onUnsavedChange?.(snapshot.dirty || snapshot.save.kind === 'pending'\n || (snapshot.save.kind === 'settled' && snapshot.save.result.kind === 'unknown'));\n };\n notify();\n const unsubscribe = controller.subscribe(notify);\n return () => { unsubscribe(); onUnsavedChange?.(false); };\n }, [saved.controller, onUnsavedChange]);\n const { labels } = useAppText();\n const [mode, setMode] = useState<string>(kind === 'markdown' ? 'rich' : 'preview');\n useReportShown(options.onShown, path, saved.kind === 'open' && saved.controller ? saved.snapshot.ref.revision : undefined);\n const common = { title: nameOf(path), subtitle: subtitleOf(locator), target: { file: path }, onClose, titleTestId: 'file-title', controls: back, ...(options.share ? { onShare: options.share } : {}) };\n // Until the document is open the bar offers no Share, Copy or menu actions of its own: they would act on a document that is not shown.\n if (saved.kind !== 'open' || !saved.controller) {\n return <ViewerFrame title={common.title} subtitle={common.subtitle} onClose={onClose} controls={back}>\n <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{saved.kind === 'loading' ? labels.loading : saved.kind === 'missing' ? labels.fileMissing : labels.fileNotText}</p>\n </ViewerFrame>;\n }\n const revision = saved.snapshot.ref.revision;\n // The preview selector the journeys use keeps working: the wrapper holds the document's text.\n return <div data-testid=\"file-preview\" className=\"h-full min-h-0\" data-revision={revision}>\n {kind === 'markdown'\n ? <MarkdownPane key={revision} controller={saved.controller as MarkdownController} revision={revision} initialMode={mode as 'rich'} onModeChange={setMode} {...common} />\n : <HtmlPane key={revision} controller={saved.controller as HtmlController} revision={revision} initialMode={mode as 'preview'} onModeChange={setMode} {...(options.interactive ? { interactive: options.interactive } : {})} {...common} />}\n </div>;\n}\n\ntype Loaded = { readonly kind: 'loading' | 'missing' } | { readonly kind: 'ready'; readonly bytes: Uint8Array; readonly revision: string; readonly text: string | undefined };\n\n/** Image, PDF and text files: read through the resource client, refreshed on demand (and followed while open, for text). */\nfunction FileBytes({ path, kind, locator, options, onClose, back }: Common) {\n const { labels } = useAppText();\n const targetKey = JSON.stringify(locator);\n const binding = useMemo(() => ({ client: options.client, locator, path }), [options.client, targetKey, path]); // eslint-disable-line react-hooks/exhaustive-deps\n const [stored, setStored] = useState<{ readonly binding: typeof binding; readonly value: Loaded }>();\n const file: Loaded = stored?.binding === binding ? stored.value : { kind: 'loading' };\n useReportShown(options.onShown, path, file.kind === 'ready' ? file.revision : undefined);\n const running = useRef<AbortController | undefined>(undefined);\n const load = useCallback(async () => {\n running.current?.abort();\n const abort = new AbortController(); running.current = abort;\n try {\n const read = await binding.client.read({ target: binding.locator, revision: { kind: 'latest' } }, abort.signal);\n if (abort.signal.aborted || running.current !== abort) return;\n if (read.kind !== 'available') {\n setStored(current => current?.binding === binding && current.value.kind === 'ready' ? current : { binding, value: { kind: 'missing' } });\n return;\n }\n const { bytes, ref } = read.snapshot;\n setStored({ binding, value: { kind: 'ready', bytes, revision: ref.revision, text: decodeText(binding.path, bytes) } });\n } catch {\n if (!abort.signal.aborted && running.current === abort) setStored(current => current?.binding === binding && current.value.kind === 'ready' ? current : { binding, value: { kind: 'missing' } });\n }\n }, [binding]);\n useEffect(() => {\n void load();\n const timer = kind === 'text' ? setInterval(() => { void load(); }, POLL_MS) : undefined;\n return () => { running.current?.abort(); clearInterval(timer); };\n }, [load, kind]);\n const common = { subtitle: subtitleOf(locator), target: { file: path }, onClose, titleTestId: 'file-title', controls: back, ...(options.share ? { onShare: options.share } : {}) };\n // Until the file is read the bar offers no Share: the image and PDF panes bring their own frame, so this one is replaced (remounted)\n // when the bytes arrive, and a Share pressed just then would copy the link with its \"Link copied\" notice lost with the old frame.\n if (file.kind !== 'ready') {\n return <ViewerFrame title={nameOf(path)} subtitle={common.subtitle} onClose={onClose} titleTestId=\"file-title\" controls={back}>\n <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{file.kind === 'loading' ? labels.loading : labels.fileMissing}</p>\n </ViewerFrame>;\n }\n if (kind === 'image') return <ImagePane name={nameOf(path)} mediaType={mediaTypeOf(path)} bytes={file.bytes} onRefresh={load} {...common} />;\n if (kind === 'pdf') return <PdfPane name={nameOf(path)} bytes={file.bytes} onRefresh={load} {...common} />;\n const { text } = file, size = file.bytes.byteLength;\n return <ViewerFrame title={nameOf(path)} {...common} subtitle={<>{subtitleOf(locator)}<span aria-hidden=\"true\">·</span><span>{formatBytes(size)}</span></>}\n onRefresh={load} {...(text === undefined ? {} : { onCopy: () => copyText(text), onDownload: () => downloadFile(nameOf(path), text, 'text/plain;charset=utf-8') })}>\n {text === undefined\n ? <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.binaryFile(formatBytes(size))}</p>\n : <div className=\"min-h-0 flex-1 overflow-auto p-3\"><pre data-testid=\"file-preview\" className=\"m-0 rounded-lg bg-muted p-3 font-mono text-[13px] leading-relaxed break-words whitespace-pre-wrap\">{text}</pre></div>}\n </ViewerFrame>;\n}\n\n/**\n * One file in the panel. `path` is the host's name for it (shown in the share target and `data-path`); `locator` is the resource the\n * client reads. Remount per file with a `key`. `onBack` adds a back button to the bar (for example to the host's file list).\n */\nexport function FileViewer({ path, locator, options, onClose, onBack, backLabel, onUnsavedChange }: {\n readonly path: string;\n readonly locator: ResourceLocator;\n readonly options: ViewerOptions;\n readonly onClose: () => void;\n readonly onBack?: (() => void) | undefined;\n readonly backLabel?: string | undefined;\n /** Built-in editable viewers report unsaved and unconfirmed changes for navigation guards. */\n readonly onUnsavedChange?: ((unsaved: boolean) => void) | undefined;\n}) {\n const kind = viewerKindOf(path);\n const { labels, icons } = useAppText();\n const back = onBack ? <button type=\"button\" data-testid=\"file-back\" onClick={onBack} className=\"inline-flex h-10 cursor-pointer items-center gap-1 rounded-lg border border-border bg-background px-2 text-sm md:h-8 md:text-xs\"><icons.back className=\"size-3.5\" aria-hidden=\"true\" />{backLabel ?? labels.back}</button> : undefined;\n const custom = options.viewers?.[kind];\n const common: Common = { path, kind, locator, options, onClose, back, onUnsavedChange };\n return <div data-testid=\"file-viewer\" data-kind={kind} data-path={path} aria-label={labels.fileViewer(KIND_LABEL[kind])} className=\"flex h-full min-h-0 flex-col overflow-hidden [&>*]:min-h-0 [&>*]:flex-1\">\n {custom ? custom({ target: locator, title: nameOf(path), frame: { titleTestId: 'file-title', onClose, target: { file: path }, controls: back } })\n : kind === 'markdown' || kind === 'html' ? <FileDocument {...common} />\n : kind === 'canvas' ? <ViewerFrame title={nameOf(path)} subtitle={subtitleOf(locator)} onClose={onClose} controls={back}><p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.noCanvasViewer}</p></ViewerFrame>\n : <FileBytes {...common} />}\n </div>;\n}\n",
|
|
55
|
+
"content": "'use client';\n\n// Opens one workspace file in the viewer that suits its type, always in the standard viewer frame (viewers item): Markdown and HTML in\n// their editors (revisioned through the host's resource client, so saves go back to the file and an edit made behind the editor's back is\n// a conflict), images and PDFs read-only from the file's bytes, anything else as text. A host viewer (`viewers[kind]`) takes precedence.\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { randomUUID } from '@hachej/boring-files/platform';\nimport type { ResourceLocator, ResourceSnapshot } from '@hachej/boring-files';\nimport { createMarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport type { MarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport { createHtmlController } from '@hachej/boring-ui-kit/html';\nimport type { HtmlController } from '@hachej/boring-ui-kit/html';\nimport { HtmlPane } from '../viewers/html-pane';\nimport { ImagePane } from '../viewers/image-pane';\nimport { MarkdownPane } from '../viewers/markdown-pane';\nimport { PdfPane } from '../viewers/pdf-pane';\nimport { ViewerFrame } from '../viewers/viewer-frame';\nimport { downloadFile } from '../viewers/download';\nimport { formatBytes } from '../viewers/media';\nimport { copyText } from '../utils/utils';\nimport { mediaTypeOf, viewerKindOf } from '@hachej/boring-files/file-types';\nimport type { ViewerKind as FileKind } from '@hachej/boring-files/file-types';\nimport { KIND_LABEL, decodeText } from './file-kinds';\nimport { useReportShown } from './artifact-panel';\nimport type { ViewerOptions } from './artifact-panel';\nimport { useSaved } from './use-saved';\nimport { useAppText } from './app-labels';\n\nconst POLL_MS = 1500;\nconst nameOf = (path: string) => path.split('/').pop() || path;\n\ninterface Common {\n readonly path: string;\n readonly kind: FileKind;\n readonly locator: ResourceLocator;\n readonly options: ViewerOptions;\n readonly onClose: () => void;\n readonly back: ReactNode;\n readonly onUnsavedChange?: ((unsaved: boolean) => void) | undefined;\n readonly onBeforeLeaveChange?: ((flush: (() => Promise<boolean>) | null) => void) | undefined;\n}\n\nconst subtitleOf = (locator: ResourceLocator) => <span data-testid=\"file-path\" className=\"truncate\">{locator.resource.path}</span>;\n\n/** Markdown or HTML: a revisioned controller over the file. A new revision remounts the pane; it starts in the mode the person chose. */\nfunction FileDocument({ path, kind, locator, options, onClose, back, onUnsavedChange, onBeforeLeaveChange }: Common) {\n const { client, identity } = options;\n const create = useMemo(() => (snapshot: ResourceSnapshot): MarkdownController | HtmlController => (kind === 'markdown' ? createMarkdownController : createHtmlController)({\n identity, client, instanceId: randomUUID(), epoch: 'workspace', source: { kind: 'saved', snapshot } }), [kind, client, identity]);\n const autosave = useRef<{ controller: MarkdownController | HtmlController; flush: () => Promise<boolean> } | null>(null);\n const saved = useSaved({ client, target: locator, create, beforeDispose: controller => autosave.current?.controller === controller ? autosave.current.flush() : undefined });\n const registerAutosave = useCallback((flush: (() => Promise<boolean>) | null) => {\n autosave.current = flush && saved.controller ? { controller: saved.controller, flush } : null;\n }, [saved.controller]);\n useLayoutEffect(() => {\n const controller = saved.controller;\n if (!controller) return;\n const notify = () => {\n const snapshot = controller.getSnapshot();\n onUnsavedChange?.(snapshot.dirty || snapshot.save.kind === 'pending'\n || (snapshot.save.kind === 'settled' && snapshot.save.result.kind === 'unknown'));\n };\n notify();\n const unsubscribe = controller.subscribe(notify);\n return () => { unsubscribe(); onUnsavedChange?.(false); };\n }, [saved.controller, onUnsavedChange]);\n const { labels } = useAppText();\n const [mode, setMode] = useState<string>(kind === 'markdown' ? 'rich' : 'preview');\n useReportShown(options.onShown, path, saved.kind === 'open' && saved.controller ? saved.snapshot.ref.revision : undefined);\n const common = { title: nameOf(path), subtitle: subtitleOf(locator), target: { file: path }, onClose, titleTestId: 'file-title', headerStart: back, ...(options.share ? { onShare: options.share } : {}) };\n // Until the document is open the bar offers no Share, Copy or menu actions of its own: they would act on a document that is not shown.\n if (saved.kind !== 'open' || !saved.controller) {\n return <ViewerFrame title={common.title} subtitle={common.subtitle} onClose={onClose} headerStart={back}>\n <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{saved.kind === 'loading' ? labels.loading : saved.kind === 'missing' ? labels.fileMissing : labels.fileNotText}</p>\n </ViewerFrame>;\n }\n const revision = saved.snapshot.ref.revision;\n // The preview selector the journeys use keeps working: the wrapper holds the document's text.\n return <div data-testid=\"file-preview\" className=\"h-full min-h-0\" data-revision={revision}>\n {kind === 'markdown'\n ? <MarkdownPane key={revision} controller={saved.controller as MarkdownController} revision={revision} initialMode={mode as 'rich'} onModeChange={setMode} onAutosaveChange={registerAutosave} onBeforeLeaveChange={onBeforeLeaveChange} {...common} />\n : <HtmlPane key={revision} controller={saved.controller as HtmlController} revision={revision} initialMode={mode as 'preview'} onModeChange={setMode} {...(options.interactive ? { interactive: options.interactive } : {})} {...common} />}\n </div>;\n}\n\ntype Loaded = { readonly kind: 'loading' | 'missing' } | { readonly kind: 'ready'; readonly bytes: Uint8Array; readonly revision: string; readonly text: string | undefined };\n\n/** Image, PDF and text files: read through the resource client, refreshed on demand (and followed while open, for text). */\nfunction FileBytes({ path, kind, locator, options, onClose, back }: Common) {\n const { labels } = useAppText();\n const targetKey = JSON.stringify(locator);\n const binding = useMemo(() => ({ client: options.client, locator, path }), [options.client, targetKey, path]); // eslint-disable-line react-hooks/exhaustive-deps\n const [stored, setStored] = useState<{ readonly binding: typeof binding; readonly value: Loaded }>();\n const file: Loaded = stored?.binding === binding ? stored.value : { kind: 'loading' };\n useReportShown(options.onShown, path, file.kind === 'ready' ? file.revision : undefined);\n const running = useRef<AbortController | undefined>(undefined);\n const load = useCallback(async () => {\n running.current?.abort();\n const abort = new AbortController(); running.current = abort;\n try {\n const read = await binding.client.read({ target: binding.locator, revision: { kind: 'latest' } }, abort.signal);\n if (abort.signal.aborted || running.current !== abort) return;\n if (read.kind !== 'available') {\n setStored(current => current?.binding === binding && current.value.kind === 'ready' ? current : { binding, value: { kind: 'missing' } });\n return;\n }\n const { bytes, ref } = read.snapshot;\n setStored({ binding, value: { kind: 'ready', bytes, revision: ref.revision, text: decodeText(binding.path, bytes) } });\n } catch {\n if (!abort.signal.aborted && running.current === abort) setStored(current => current?.binding === binding && current.value.kind === 'ready' ? current : { binding, value: { kind: 'missing' } });\n }\n }, [binding]);\n useEffect(() => {\n void load();\n const timer = kind === 'text' ? setInterval(() => { void load(); }, POLL_MS) : undefined;\n return () => { running.current?.abort(); clearInterval(timer); };\n }, [load, kind]);\n const common = { subtitle: subtitleOf(locator), target: { file: path }, onClose, titleTestId: 'file-title', headerStart: back, ...(options.share ? { onShare: options.share } : {}) };\n // Until the file is read the bar offers no Share: the image and PDF panes bring their own frame, so this one is replaced (remounted)\n // when the bytes arrive, and a Share pressed just then would copy the link with its \"Link copied\" notice lost with the old frame.\n if (file.kind !== 'ready') {\n return <ViewerFrame title={nameOf(path)} subtitle={common.subtitle} onClose={onClose} titleTestId=\"file-title\" headerStart={back}>\n <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{file.kind === 'loading' ? labels.loading : labels.fileMissing}</p>\n </ViewerFrame>;\n }\n if (kind === 'image') return <ImagePane name={nameOf(path)} mediaType={mediaTypeOf(path)} bytes={file.bytes} onRefresh={load} {...common} />;\n if (kind === 'pdf') return <PdfPane name={nameOf(path)} bytes={file.bytes} onRefresh={load} {...common} />;\n const { text } = file, size = file.bytes.byteLength;\n return <ViewerFrame title={nameOf(path)} {...common} subtitle={<>{subtitleOf(locator)}<span aria-hidden=\"true\">·</span><span>{formatBytes(size)}</span></>}\n onRefresh={load} {...(text === undefined ? {} : { onCopy: () => copyText(text), onDownload: () => downloadFile(nameOf(path), text, 'text/plain;charset=utf-8') })}>\n {text === undefined\n ? <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.binaryFile(formatBytes(size))}</p>\n : <div className=\"min-h-0 flex-1 overflow-auto p-3\"><pre data-testid=\"file-preview\" className=\"m-0 font-mono text-sm leading-relaxed break-words whitespace-pre-wrap\">{text}</pre></div>}\n </ViewerFrame>;\n}\n\n/**\n * One file in the panel. `path` is the host's name for it (shown in the share target and `data-path`); `locator` is the resource the\n * client reads. Remount per file with a `key`. `onBack` adds a back button to the bar (for example to the host's file list).\n */\nexport function FileViewer({ path, locator, options, onClose, onBack, backLabel, onUnsavedChange, onBeforeLeaveChange }: {\n readonly path: string;\n readonly locator: ResourceLocator;\n readonly options: ViewerOptions;\n readonly onClose: () => void;\n readonly onBack?: (() => void) | undefined;\n readonly backLabel?: string | undefined;\n /** Built-in editable viewers report unsaved and unconfirmed changes for navigation guards. */\n readonly onUnsavedChange?: ((unsaved: boolean) => void) | undefined;\n /** The Markdown autosave barrier, for flush-before-navigation guards. Null for explicit-save viewers. */\n readonly onBeforeLeaveChange?: ((flush: (() => Promise<boolean>) | null) => void) | undefined;\n}) {\n const kind = viewerKindOf(path);\n const { labels, icons } = useAppText();\n const back = onBack ? <Button variant=\"ghost\" size=\"sm\" data-testid=\"file-back\" aria-label={backLabel ?? labels.back} title={backLabel ?? labels.back} onClick={onBack} className=\"shrink-0\"><icons.back data-icon=\"inline-start\" aria-hidden=\"true\" /><span className=\"max-sm:sr-only\">{backLabel ?? labels.back}</span></Button> : undefined;\n const custom = options.viewers?.[kind];\n const common: Common = { path, kind, locator, options, onClose, back, onUnsavedChange, onBeforeLeaveChange };\n return <div data-testid=\"file-viewer\" data-kind={kind} data-path={path} aria-label={labels.fileViewer(KIND_LABEL[kind])} className=\"flex h-full min-h-0 flex-col overflow-hidden [&>*]:min-h-0 [&>*]:flex-1\">\n {custom ? custom({ target: locator, title: nameOf(path), frame: { titleTestId: 'file-title', onClose, target: { file: path }, headerStart: back } })\n : kind === 'markdown' || kind === 'html' ? <FileDocument {...common} />\n : kind === 'canvas' ? <ViewerFrame title={nameOf(path)} subtitle={subtitleOf(locator)} onClose={onClose} headerStart={back}><p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.noCanvasViewer}</p></ViewerFrame>\n : <FileBytes {...common} />}\n </div>;\n}\n",
|
|
44
56
|
"type": "registry:component",
|
|
45
57
|
"target": "components/pi-app/file-viewer.tsx"
|
|
46
58
|
},
|
|
@@ -52,7 +64,7 @@
|
|
|
52
64
|
},
|
|
53
65
|
{
|
|
54
66
|
"path": "registry/pi-app/use-saved.ts",
|
|
55
|
-
"content": "import { useEffect, useLayoutEffect, useMemo, useState } from 'react';\nimport type { ResourceClient, ResourceLocator, ResourceSnapshot } from '@hachej/boring-files';\n\nconst POLL_MS = 1500;\nconst readText = (bytes: Uint8Array): string | undefined => { try { return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); } catch { return undefined; } };\n\n/** What a viewer controller exposes to `useSaved`: its buffer state (never replaced while dirty or saving) and teardown. */\nexport interface SavedController {\n readonly getSnapshot: () => { readonly dirty?: boolean; readonly save?: { readonly kind: string }; readonly base?: { readonly kind: string; readonly target?: object } };\n readonly dispose: () => void;\n}\n\nexport type SavedState<C extends SavedController> =\n | { readonly kind: 'loading' | 'missing' | 'denied' | 'unavailable' | 'invalid'; readonly text?: undefined; readonly controller?: undefined }\n | { readonly kind: 'open'; readonly snapshot: ResourceSnapshot; readonly text: string; readonly controller: C | undefined };\n\n/**\n * The saved text of one resource: the latest revision (polled while the local buffer is clean, never replacing unsaved\n * edits) or one exact revision read once. `create` may return a viewer controller for each saved snapshot; it is disposed when\n * a newer revision replaces it and on unmount.\n */\nexport function useSaved<C extends SavedController = SavedController>({ client, target, revision, create }: {\n readonly client: ResourceClient;\n readonly target: ResourceLocator;\n readonly revision?: string | undefined;\n readonly create?: ((snapshot: ResourceSnapshot) => C) | undefined;\n}): SavedState<C> {\n const key = JSON.stringify([target.resource.providerId, target.resource.path, target.view.kind, target.view.kind === 'working' ? target.view.viewId : null, revision]);\n const binding = useMemo(() => ({ client, key, create }), [client, key]); // eslint-disable-line react-hooks/exhaustive-deps\n useLayoutEffect(() => { binding.create = create; }, [binding, create]);\n const [stored, setStored] = useState<{ readonly binding: typeof binding; readonly state: SavedState<C> }>();\n useEffect(() => {\n let cancelled = false, reading = false;\n let state: SavedState<C> = { kind: 'loading' };\n const update = (next: SavedState<C>) => { state = next; setStored({ binding, state }); };\n update(state);\n const follow = async () => {\n if (cancelled || reading) return;\n const now = state;\n const before = now.kind === 'open' ? now.controller?.getSnapshot() : undefined;\n if (before && (before.dirty || before.save?.kind === 'pending')) return;\n reading = true;\n let read;\n try { read = await client.read({ target, revision: revision ? { kind: 'exact', value: revision } : { kind: 'latest' } }).catch(() => undefined); }\n finally { reading = false; }\n if (cancelled || !read || state !== now) return;\n const local = now.kind === 'open' ? now.controller?.getSnapshot() : undefined;\n if (local !== before) return;\n if (read.kind !== 'available') { if (now.kind !== 'open') update({ kind: read.kind }); return; }\n if (now.kind === 'open') {\n if (now.snapshot.ref.revision === read.snapshot.ref.revision) return;\n if (local?.base?.kind === 'revision' && (local.base.target as { readonly revision?: string } | undefined)?.revision === read.snapshot.ref.revision) return;\n now.controller
|
|
67
|
+
"content": "import { useEffect, useLayoutEffect, useMemo, useState } from 'react';\nimport type { ResourceClient, ResourceLocator, ResourceSnapshot } from '@hachej/boring-files';\n\nconst POLL_MS = 1500;\nconst readText = (bytes: Uint8Array): string | undefined => { try { return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); } catch { return undefined; } };\n\n/** What a viewer controller exposes to `useSaved`: its buffer state (never replaced while dirty or saving) and teardown. */\nexport interface SavedController {\n readonly getSnapshot: () => { readonly dirty?: boolean; readonly save?: { readonly kind: string }; readonly base?: { readonly kind: string; readonly target?: object } };\n readonly dispose: () => void;\n}\n\nexport type SavedState<C extends SavedController> =\n | { readonly kind: 'loading' | 'missing' | 'denied' | 'unavailable' | 'invalid'; readonly text?: undefined; readonly controller?: undefined }\n | { readonly kind: 'open'; readonly snapshot: ResourceSnapshot; readonly text: string; readonly controller: C | undefined };\n\n/**\n * The saved text of one resource: the latest revision (polled while the local buffer is clean, never replacing unsaved\n * edits) or one exact revision read once. `create` may return a viewer controller for each saved snapshot; it is disposed when\n * a newer revision replaces it and on unmount.\n */\nexport function useSaved<C extends SavedController = SavedController>({ client, target, revision, create, beforeDispose }: {\n readonly client: ResourceClient;\n readonly target: ResourceLocator;\n readonly revision?: string | undefined;\n readonly create?: ((snapshot: ResourceSnapshot) => C) | undefined;\n /** Owner teardown may wait for an editor's final conditional flush. Unsubscribe remains synchronous. */\n readonly beforeDispose?: ((controller: C) => Promise<unknown> | undefined) | undefined;\n}): SavedState<C> {\n const key = JSON.stringify([target.resource.providerId, target.resource.path, target.view.kind, target.view.kind === 'working' ? target.view.viewId : null, revision]);\n const binding = useMemo(() => ({ client, key, create, beforeDispose }), [client, key]); // eslint-disable-line react-hooks/exhaustive-deps\n useLayoutEffect(() => { binding.create = create; binding.beforeDispose = beforeDispose; }, [binding, create, beforeDispose]);\n const [stored, setStored] = useState<{ readonly binding: typeof binding; readonly state: SavedState<C> }>();\n useEffect(() => {\n let cancelled = false, reading = false;\n let state: SavedState<C> = { kind: 'loading' };\n const update = (next: SavedState<C>) => { state = next; setStored({ binding, state }); };\n update(state);\n const release = (controller: C | undefined) => {\n if (!controller) return;\n const flushing = binding.beforeDispose?.(controller);\n if (flushing) void flushing.finally(() => controller.dispose()).catch(() => {});\n else controller.dispose();\n };\n const follow = async () => {\n if (cancelled || reading) return;\n const now = state;\n const before = now.kind === 'open' ? now.controller?.getSnapshot() : undefined;\n if (before && (before.dirty || before.save?.kind === 'pending')) return;\n reading = true;\n let read;\n try { read = await client.read({ target, revision: revision ? { kind: 'exact', value: revision } : { kind: 'latest' } }).catch(() => undefined); }\n finally { reading = false; }\n if (cancelled || !read || state !== now) return;\n const local = now.kind === 'open' ? now.controller?.getSnapshot() : undefined;\n if (local !== before) return;\n if (read.kind !== 'available') { if (now.kind !== 'open') update({ kind: read.kind }); return; }\n if (now.kind === 'open') {\n if (now.snapshot.ref.revision === read.snapshot.ref.revision) return;\n if (local?.base?.kind === 'revision' && (local.base.target as { readonly revision?: string } | undefined)?.revision === read.snapshot.ref.revision) return;\n release(now.controller);\n }\n const text = readText(read.snapshot.bytes);\n if (text === undefined) { update({ kind: 'invalid' }); return; }\n let controller: C | undefined;\n try { controller = binding.create?.(read.snapshot); } catch { update({ kind: 'invalid' }); return; }\n update({ kind: 'open', snapshot: read.snapshot, text, controller });\n };\n void follow();\n const timer = revision ? undefined : setInterval(() => { void follow(); }, POLL_MS);\n return () => { cancelled = true; clearInterval(timer); release(state.controller); };\n }, [binding]); // eslint-disable-line react-hooks/exhaustive-deps\n return stored?.binding === binding ? stored.state : { kind: 'loading' };\n}\n\n/** A save time for a person: the clock time (with seconds, so two quick saves differ) today, the date as well on another day. */\nexport function savedLabel(savedAt: number | undefined, now = Date.now(), earlier = 'Earlier version'): string {\n if (!savedAt) return earlier;\n const day = (value: number) => new Date(value).toDateString();\n return new Date(savedAt).toLocaleString([], day(savedAt) === day(now) ? { timeStyle: 'medium' } : { dateStyle: 'medium', timeStyle: 'medium' });\n}\n",
|
|
56
68
|
"type": "registry:hook",
|
|
57
69
|
"target": "components/pi-app/use-saved.ts"
|
|
58
70
|
},
|
|
@@ -64,19 +76,13 @@
|
|
|
64
76
|
},
|
|
65
77
|
{
|
|
66
78
|
"path": "registry/pi-app/new-version-notice.tsx",
|
|
67
|
-
"content": "import { Button } from '
|
|
79
|
+
"content": "import { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { useAppText } from './app-labels';\nimport { cn } from '../utils/utils';\n\n/**\n * The \"a new version is available\" notice: a status line with a Reload button. Render it while `useNewVersion` returns true\n * (`{stale && <NewVersionNotice />}`). Reload is `location.reload()`, so unsaved work in the page is lost: the host decides when to show it.\n */\nexport function NewVersionNotice({ className }: { readonly className?: string }) {\n const { labels } = useAppText();\n return (\n <div role=\"status\" data-testid=\"new-version-notice\" className={cn('relative flex flex-wrap items-center justify-between gap-3 bg-muted px-4 py-2 text-sm text-foreground', className)}>\n <Separator className=\"absolute inset-x-0 bottom-0\" />\n <span>{labels.newVersion}</span>\n <Button variant=\"outline\" size=\"sm\" data-testid=\"new-version-reload\" onClick={() => globalThis.location.reload()}>{labels.reload}</Button>\n </div>\n );\n}\n",
|
|
68
80
|
"type": "registry:component",
|
|
69
81
|
"target": "components/pi-app/new-version-notice.tsx"
|
|
70
82
|
},
|
|
71
|
-
{
|
|
72
|
-
"path": "registry/pi-app/use-conversations.ts",
|
|
73
|
-
"content": "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport type { ConversationItem, ConversationsConfig } from '../pi-chat/pi-chat';\n\n/** One row as `createConversationsHandler` of `@hachej/boring-agent/conversations` returns it. */\ninterface ServedConversation {\n readonly id: number | string;\n readonly title: string | null;\n readonly lastMessage: string | null;\n readonly updatedAt: number;\n readonly archived: boolean;\n}\n\nconst item = (served: ServedConversation): ConversationItem => ({ id: String(served.id), title: served.title ?? undefined, updatedAt: served.updatedAt, lastMessage: served.lastMessage, archived: served.archived });\n\n/**\n * The sessions list over the host's conversations handler (`createConversationsHandler` of `@hachej/boring-agent/conversations`, mounted at\n * `endpoint`): the active list (polled every `pollMs` while mounted), server-side search with the Archived filter, New, rename, archive,\n * delete and fork. New and fork select the conversation they made through `onSelect`. `onChange` runs after every change the person made\n * (the host may reload what depends on the list). `fetch` is the host's authenticated fetch. Returns `undefined` until the first list arrives.\n */\nexport function useConversations({ endpoint, fetch, activeId, onSelect, onChange, pollMs = 4000 }: {\n /** Undefined: nothing is listed yet (for example while the host is still loading). */\n readonly endpoint: string | URL | undefined;\n readonly fetch: (request: Request) => Promise<Response>;\n readonly activeId: string | undefined;\n readonly onSelect: (id: string) => void;\n readonly onChange?: (() => void | Promise<void>) | undefined;\n readonly pollMs?: number;\n}): (ConversationsConfig & { readonly reload: () => Promise<void> }) | undefined {\n const [items, setItems] = useState<readonly ConversationItem[] | undefined>();\n const latest = useRef({ fetch, onSelect, onChange, activeId });\n latest.current = { fetch, onSelect, onChange, activeId };\n const base = endpoint === undefined ? undefined : String(endpoint);\n const call = useCallback(async (params: Record<string, string>, body?: unknown, signal?: AbortSignal) => {\n if (base === undefined) throw new Error('No conversations endpoint');\n const url = new URL(base, globalThis.location?.href);\n for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);\n const init: RequestInit = body === undefined ? { ...(signal ? { signal } : {}) } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), ...(signal ? { signal } : {}) };\n const response = await latest.current.fetch(new Request(url, init));\n if (!response.ok) throw new Error(`Conversations: ${response.status}`);\n return await response.json() as { conversations?: readonly ServedConversation[]; conversationId?: number | string };\n }, [base]);\n const reload = useCallback(async () => { const result = await call({}); setItems((result.conversations ?? []).map(item)); }, [call]);\n // Another endpoint (another owner's list) starts empty; switching conversations only reloads.\n useEffect(() => { setItems(undefined); }, [base]);\n useEffect(() => {\n if (base === undefined) return;\n let off = false;\n const load = () => call({}).then(result => { if (!off) setItems((result.conversations ?? []).map(item)); }).catch(() => {});\n void load();\n const timer = setInterval(load, pollMs);\n return () => { off = true; clearInterval(timer); };\n }, [call, pollMs, activeId]); // eslint-disable-line react-hooks/exhaustive-deps\n return useMemo(() => {\n if (!items) return undefined;\n const changed = async () => { await reload().catch(() => {}); await latest.current.onChange?.(); };\n const op = (name: string, body: unknown) => call({ op: name }, body);\n return {\n items, activeId, reload,\n onSelect: (id: string) => latest.current.onSelect(id),\n onNew: () => { void op('create', {}).then(async result => { await changed(); if (result.conversationId !== undefined) latest.current.onSelect(String(result.conversationId)); }).catch(() => {}); },\n search: async (query: string, { archived }: { readonly archived: boolean }, signal: AbortSignal) =>\n ((await call({ ...(query ? { q: query } : {}), ...(archived ? { archived: '1' } : {}) }, undefined, signal)).conversations ?? []).map(item),\n rename: async (id: string, title: string) => { await op('rename', { conversationId: id, title }); await changed(); },\n archive: async (id: string, archived: boolean) => { await op('archive', { conversationId: id, archived }); await changed(); },\n remove: async (id: string) => { await op('delete', { conversationId: id }); await changed(); },\n fork: async (at: string) => {\n const from = latest.current.activeId;\n if (from === undefined) return;\n const { conversationId } = await op('fork', { conversationId: from, at });\n await changed();\n if (conversationId !== undefined) latest.current.onSelect(String(conversationId));\n },\n };\n }, [items, activeId, call, reload]);\n}\n",
|
|
74
|
-
"type": "registry:hook",
|
|
75
|
-
"target": "components/pi-app/use-conversations.ts"
|
|
76
|
-
},
|
|
77
83
|
{
|
|
78
84
|
"path": "registry/pi-app/app-labels.ts",
|
|
79
|
-
"content": "/*\n * The words and icons of the pi-app block as plain data: its own (sessions pane, artifact panel, file viewer) and the viewers' it composes\n * (`defaultViewerLabels`). `AgentWorkspace` takes a partial `labels` and `icons`; the chat's go through `chat.labels` and `chat.icons`.\n */\nimport { createContext, createElement, useContext } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowLeftIcon, LibraryIcon, MessageSquareIcon, PanelLeftIcon, PictureInPicture2Icon,
|
|
85
|
+
"content": "/*\n * The words and icons of the pi-app block as plain data: its own (sessions pane, artifact panel, file viewer) and the viewers' it composes\n * (`defaultViewerLabels`). `AgentWorkspace` takes a partial `labels` and `icons`; the chat's go through `chat.labels` and `chat.icons`.\n */\nimport { createContext, createElement, useContext } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowLeftIcon, HistoryIcon, LibraryIcon, MessageSquareIcon, PanelLeftIcon, PictureInPicture2Icon, SquarePenIcon, XIcon } from 'lucide-react';\nimport type { BlockIcon } from '../button/actions';\nimport { defaultViewerIcons, defaultViewerLabels } from '../viewers/viewer-window';\n\nexport const defaultAppLabels = {\n ...defaultViewerLabels,\n sessionsTitle: 'Chats',\n workspaceNavigation: 'Workspace navigation',\n openNavigation: 'Open navigation',\n closeNavigation: 'Close navigation',\n hideNavigation: 'Hide navigation',\n showNavigation: 'Show navigation',\n chat: 'Chat',\n library: 'Library',\n agent: 'Agent',\n backToChat: 'Back to chat',\n newChat: 'New chat',\n closeSessions: 'Close chats',\n openSessions: 'Open chats',\n hideSessions: 'Hide chats',\n showSessions: 'Show chats',\n history: 'History',\n showHistory: 'Show history',\n hideHistory: 'Hide history',\n floatChat: 'Float chat',\n floatHint: 'Release to float the chat',\n resizePanel: 'Resize artifact panel',\n artifactPanel: 'Artifact panel',\n back: 'Back',\n savedAt: (when: string) => `Saved ${when}`,\n latestSavedAt: (when: string) => `Latest, saved ${when}`,\n olderVersion: 'Older version',\n earlierVersion: 'Earlier version',\n artifactNotText: 'This artifact cannot be shown as text.',\n versionUnavailable: 'This version is not available.',\n noCanvasViewer: 'This host has no canvas viewer (pass one in `viewers.canvas`).',\n fileMissing: 'This file no longer exists.',\n attachmentTooLarge: (limit: string) => `Larger than ${limit}. Not saved.`,\n fileNotText: 'This file cannot be shown as text.',\n binaryFile: (size: string) => `This is a binary file (${size}); it cannot be shown as text.`,\n fileViewer: (kind: string) => `${kind} viewer`,\n newVersion: 'A new version is available.',\n};\nexport type AppLabels = typeof defaultAppLabels;\n\nexport const defaultAppIcons = {\n ...defaultViewerIcons,\n sessionsToggle: PanelLeftIcon as BlockIcon,\n history: HistoryIcon as BlockIcon,\n library: LibraryIcon as BlockIcon,\n chat: MessageSquareIcon as BlockIcon,\n newChat: SquarePenIcon as BlockIcon,\n closeSessions: XIcon as BlockIcon,\n floatChat: PictureInPicture2Icon as BlockIcon,\n back: ArrowLeftIcon as BlockIcon,\n};\nexport type AppIcons = typeof defaultAppIcons;\n\nexport interface AppText { readonly labels: AppLabels; readonly icons: AppIcons }\nconst AppTextContext = createContext<AppText>({ labels: defaultAppLabels, icons: defaultAppIcons });\n/** The block's labels and icons (the defaults outside an `AgentWorkspace`). */\nexport const useAppText = (): AppText => useContext(AppTextContext);\nexport function AppTextProvider({ value, children }: { readonly value: AppText; readonly children: ReactNode }) {\n return createElement(AppTextContext.Provider, { value }, children);\n}\n",
|
|
80
86
|
"type": "registry:lib",
|
|
81
87
|
"target": "components/pi-app/app-labels.ts"
|
|
82
88
|
},
|
|
@@ -88,7 +94,7 @@
|
|
|
88
94
|
},
|
|
89
95
|
{
|
|
90
96
|
"path": "registry/pi-app/app-labels-fr.ts",
|
|
91
|
-
"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",
|
|
97
|
+
"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 history: 'Historique',\n showHistory: 'Afficher l’historique',\n hideHistory: 'Masquer l’historique',\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",
|
|
92
98
|
"type": "registry:lib",
|
|
93
99
|
"target": "components/pi-app/app-labels-fr.ts"
|
|
94
100
|
},
|