@hachej/boring-ui-kit 0.4.43 → 0.4.45
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/package.json +2 -2
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +10 -10
- package/r/board-viewer.json +6 -6
- package/r/button.json +1 -1
- package/r/dock.json +1 -1
- package/r/documents.json +12 -12
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +6 -6
- package/r/file-tree.json +4 -4
- package/r/highlights.json +3 -3
- package/r/html-viewer.json +3 -3
- package/r/looks-viewer.json +2 -2
- package/r/maquette-viewer.json +1 -1
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +1 -1
- package/r/pi-ambient.json +8 -8
- package/r/pi-app-kinds.json +10 -10
- package/r/pi-app.json +11 -11
- package/r/pi-artifacts.json +2 -2
- package/r/pi-chat-approval.json +2 -2
- package/r/pi-chat-attachments.json +1 -1
- package/r/pi-chat-markdown.json +2 -2
- package/r/pi-chat-mentions.json +1 -1
- package/r/pi-chat-pickers.json +1 -1
- package/r/pi-chat-plugins.json +11 -11
- package/r/pi-chat-question.json +1 -1
- package/r/pi-chat-slash.json +2 -2
- package/r/pi-chat-subagent.json +1 -1
- package/r/pi-chat-voice.json +3 -3
- package/r/pi-chat.json +5 -5
- package/r/pi-workspace.json +4 -4
- package/r/placed-agent.json +4 -4
- package/r/pm-panel.json +18 -18
- package/r/provider-setup.json +1 -1
- package/r/registry.json +185 -185
- package/r/requests-board.json +1 -1
- package/r/shared-chat.json +6 -6
- package/r/task-board.json +2 -2
- package/r/task-list-viewer.json +2 -2
- package/r/task-mentions.json +4 -4
- package/r/version-history.json +2 -2
- package/r/viewer-code.json +2 -2
- package/r/viewer-frame.json +4 -4
- package/r/viewer-html.json +4 -4
- package/r/viewer-image.json +2 -2
- package/r/viewer-markdown.json +5 -5
- package/r/viewer-pdf.json +3 -3
- package/r/viewers.json +6 -6
- package/r/windows.json +1 -1
package/r/pi-workspace.json
CHANGED
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
"react-dom@^19.0.0"
|
|
9
9
|
],
|
|
10
10
|
"registryDependencies": [
|
|
11
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-chat.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/utils.json",
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/dock.json"
|
|
14
14
|
],
|
|
15
15
|
"files": [
|
|
16
16
|
{
|
|
17
17
|
"path": "registry/pi-workspace/workspace.tsx",
|
|
18
|
-
"content": "'use client';\n\nimport { PHONE_BELOW } from '../utils/use-media';\nimport { useBackLayer } from '../utils/back';\nimport { readFlag, writeFlag, readPersisted, writePersisted } from '../utils/persisted';\n\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\nimport { Badge } from '@/components/ui/badge';\nimport { Card } from '@/components/ui/card';\nimport { cn } from '../utils/utils';\n\n/** What the open panel can do to its workspace. Pass `onFullscreenChange` on to the viewer's top bar; it is absent in the phone sheet. */\nexport interface WorkspacePanelApi {\n readonly fullscreen: boolean;\n /** Absent on a phone, where the panel is already a full-screen sheet. */\n readonly onFullscreenChange: ((next: boolean) => void) | undefined;\n readonly close: () => unknown;\n readonly sheet: boolean;\n /** Float the chat over the panel (see `floatBelow`). Absent without `floatBelow`, while the chat already floats and in the phone sheet. pi-app's `AgentWorkspace` turns it into the viewers' \"Float chat\" action (`ViewerWindow.actions`). */\n readonly floatChat: (() => void) | undefined;\n}\n\n/** What `chat` can render as a function: whether the chat floats over the full-width panel, and how to put it back beside it. */\nexport interface WorkspaceChatLayout {\n readonly floating: boolean;\n readonly dock: () => void;\n}\n\nexport interface ArtifactWorkspaceProps {\n /**\n * Usually a `PiChat`. It stays mounted while the panel opens, resizes, goes full screen and closes. With `floatBelow`, pass a function that\n * returns the docked chat or, while `floating`, an `AmbientChat` on the same controller (`onDock` there is `dock`): the chat is then a different surface over one session.\n */\n readonly chat: ReactNode | ((layout: WorkspaceChatLayout) => ReactNode);\n /** Whether the panel is open. Closing is the host's decision (`onClose`); the workspace never unmounts `chat`. */\n readonly open: boolean;\n /** The panel content, usually a viewer whose top bar is the shared `ViewerFrame`. Receives what it needs for its close and full screen buttons. */\n readonly panel: ReactNode | ((api: WorkspacePanelApi) => ReactNode);\n /** Return false when a synchronous host guard refuses to close; Back then restores the sheet entry. */\n readonly onClose: () => unknown;\n /** Full screen is controlled when both are given; otherwise the workspace keeps it. Escape leaves it. */\n readonly fullscreen?: boolean;\n readonly onFullscreenChange?: (next: boolean) => void;\n /** Width of the panel is remembered for this browser session under this key. */\n readonly storageKey?: string;\n /**\n * Initial panel width before the person drags the divider: pixels, or a fraction of the workspace's width when at most 1 (0.55: a bit\n * over half). Without it the panel takes all but 420px of chat. Double-clicking the divider comes back to it.\n */\n readonly defaultWidth?: number;\n readonly minPanel?: number;\n readonly minChat?: number;\n /** Below this container width the panel is a full-screen sheet and the divider is gone. */\n readonly sheetBelow?: number;\n /**\n * Opt in to a floating chat: when the divider is dragged until the chat is narrower than this many pixels, a hint appears and releasing floats the chat\n * (the panel takes the full width). Alt+Left on the divider and `api.floatChat` do the same without a pointer. Remembered for the session; ignored in the phone sheet.\n * Off by default.\n */\n readonly floatBelow?: number;\n /** The panel's accessible name. */\n readonly panelLabel?: string;\n /**\n * `split` (default): the panel is a full-height column with a hard border and a visible divider. `inset`: the panel is a rounded card inside the\n * workspace (a margin around it, a border and a soft shadow on the card only) and the divider remains visible and strengthens on hover, focus or drag, so the panel\n * reads as part of the chat's own surface, as in Claude's artifact panel. Resizing works the same.\n */\n readonly variant?: 'split' | 'inset';\n /** The divider's accessible name and the hint shown while a drag would float the chat. */\n readonly labels?: { readonly resize?: string | undefined; readonly floatHint?: string | undefined } | undefined;\n readonly className?: string;\n}\n\nconst STEP = 24, BIG_STEP = 96;\nconst read = (key: string): number | undefined => { const value = Number(readPersisted(key)); return Number.isFinite(value) && value > 0 ? value : undefined; };\nconst write = (key: string, value: number) => writePersisted(key, String(Math.round(value)));\n\n/**\n * Chat with an artifact panel beside it, as in Claude's chat apps. The panel opens on the right at full height and the chat\n * narrows; a divider (pointer or arrow keys) resizes it, the width is remembered for the session; the panel can go full screen\n * (Escape leaves) and on a phone it is a full-screen sheet. With `floatBelow` the chat can float over a full-width panel instead of sitting beside it. The workspace owns layout only: what is open, and the viewer in it, are the host's.\n */\nexport function ArtifactWorkspace({ chat, open, panel, onClose, fullscreen: controlledFullscreen, onFullscreenChange, storageKey = 'boring.artifact-panel.width', defaultWidth,\n minPanel = 360, minChat = 360, sheetBelow = PHONE_BELOW, floatBelow, panelLabel = 'Artifact panel', variant = 'split', labels, className }: ArtifactWorkspaceProps) {\n const root = useRef<HTMLDivElement>(null);\n const [size, setSize] = useState(0);\n const [stored, setStored] = useState<number | undefined>(() => read(storageKey) ?? (defaultWidth !== undefined && defaultWidth > 1 ? defaultWidth : undefined));\n const closeRef = useRef(onClose);\n const fullscreenRef = useRef(false);\n const opener = useRef<HTMLElement | null>(null);\n const swipe = useRef<{ x: number; y: number } | null>(null);\n const [local, setLocal] = useState(false);\n const [entered, setEntered] = useState(false);\n const [dragging, setDragging] = useState(false);\n const [floatHint, setFloatHint] = useState(false);\n const [floatState, setFloatState] = useState(() => floatBelow !== undefined && readFlag(`${storageKey}.floating`));\n const controlled = controlledFullscreen !== undefined && onFullscreenChange !== undefined;\n const sheet = size > 0 && size < sheetBelow;\n const floating = open && !sheet && floatBelow !== undefined && floatState;\n const fullscreen = open && !sheet && (controlled ? controlledFullscreen : local);\n useLayoutEffect(() => { closeRef.current = onClose; fullscreenRef.current = fullscreen; });\n const setFullscreen = useCallback((next: boolean) => { if (controlled) onFullscreenChange!(next); else setLocal(next); }, [controlled, onFullscreenChange]);\n\n useLayoutEffect(() => {\n const element = root.current;\n if (!element) return;\n const measure = () => setSize(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 setFloating = useCallback((next: boolean) => { setFloatState(next); setFloatHint(false); writeFlag(`${storageKey}.floating`, next); }, [storageKey]);\n useEffect(() => {\n // With the panel closed there is nothing to float over: the chat docks back.\n // eslint-disable-next-line react-hooks/set-state-in-effect -- closing resets the panel's entry, fullscreen and floating once, after commit\n if (!open) { setEntered(false); setLocal(false); setFloating(false); return; }\n const frame = requestAnimationFrame(() => setEntered(true));\n return () => cancelAnimationFrame(frame);\n }, [open, setFloating]);\n useEffect(() => {\n if (!fullscreen) return;\n const escape = (event: globalThis.KeyboardEvent) => { if (event.key === 'Escape' && !event.defaultPrevented) setFullscreen(false); };\n document.addEventListener('keydown', escape);\n return () => document.removeEventListener('keydown', escape);\n }, [fullscreen, setFullscreen]);\n\n useEffect(() => {\n if (!open) return;\n const element = root.current;\n opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n const escape = (event: globalThis.KeyboardEvent) => {\n if (event.key !== 'Escape' || event.defaultPrevented || fullscreenRef.current) return;\n event.preventDefault(); closeRef.current();\n };\n document.addEventListener('keydown', escape);\n return () => {\n document.removeEventListener('keydown', escape);\n const previous = opener.current;\n const target = previous?.isConnected && previous !== document.body ? previous : element?.querySelector<HTMLElement>('[data-testid=composer-input]');\n target?.focus({ preventScroll: true });\n // A shadcn Button transitions its inherited visibility, so it stays hidden (unfocusable) briefly after the sheet closes: retry per frame.\n if (target) { let frames = 30; const retry = () => { if (document.activeElement === target || document.activeElement !== document.body || frames-- <= 0) return; target.focus({ preventScroll: true }); requestAnimationFrame(retry); }; requestAnimationFrame(retry); }\n };\n }, [open]);\n useBackLayer(open && sheet, onClose, 'artifact');\n\n const max = Math.max(minPanel, size - minChat);\n const initial = defaultWidth === undefined ? size - 420 : defaultWidth <= 1 ? size * defaultWidth : defaultWidth;\n const width = Math.min(Math.max(stored ?? Math.max(minPanel, initial), minPanel), max);\n const resize = (next: number) => { const clamped = Math.min(Math.max(next, minPanel), max); setStored(clamped); write(storageKey, clamped); };\n const drag = (event: PointerEvent<HTMLElement>) => {\n if (!dragging) return;\n const box = root.current?.getBoundingClientRect();\n if (!box) return;\n const target = box.right - event.clientX;\n // Past the threshold the width stays at the last one above it; releasing there floats the chat.\n const below = floatBelow !== undefined && size - target < floatBelow;\n setFloatHint(below);\n if (!below) resize(target);\n };\n const release = (event: PointerEvent<HTMLElement>) => { event.currentTarget.releasePointerCapture(event.pointerId); setDragging(false); if (floatHint) setFloating(true); setFloatHint(false); };\n const keys = (event: KeyboardEvent<HTMLElement>) => {\n if (floatBelow !== undefined && event.altKey && event.key === 'ArrowLeft') { event.preventDefault(); setFloating(true); return; }\n const step = event.shiftKey ? BIG_STEP : STEP;\n const next = event.key === 'ArrowLeft' ? width + step : event.key === 'ArrowRight' ? width - step : event.key === 'Home' ? max : event.key === 'End' ? minPanel : undefined;\n if (next === undefined) return;\n event.preventDefault(); resize(next);\n };\n\n const api: WorkspacePanelApi = { fullscreen, onFullscreenChange: sheet ? undefined : setFullscreen, close: onClose, sheet, floatChat: floatBelow !== undefined && !sheet && !floating ? () => setFloating(true) : undefined };\n const covering = open && (sheet || fullscreen);\n // The inset panel is drawn by the host's Card behind it (a sibling layer, so a placement change never remounts the panel);\n // every other placement is a plain region of the page.\n const inset = variant === 'inset' && !sheet && !fullscreen && !floating;\n return <div ref={root} data-boring=\"artifact-workspace\" data-open={open ? 'true' : 'false'} data-fullscreen={fullscreen ? 'true' : 'false'} data-sheet={sheet && open ? 'true' : 'false'}\n className={cn('relative flex h-full min-h-0 min-w-0 flex-1', className)}>\n <div data-testid=\"workspace-chat\" data-floating={floating ? 'true' : undefined} {...(covering ? { inert: true, 'aria-hidden': true } : {})}\n className={cn('relative flex min-h-0 min-w-0 flex-col', floating ? 'w-0 flex-none' : 'flex-1', covering && 'invisible')}>\n {typeof chat === 'function' ? chat({ floating, dock: () => setFloating(false) }) : chat}\n {floatHint && <div role=\"status\" data-testid=\"workspace-float-hint\" className=\"pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-background/75 p-4\">\n <Badge variant=\"secondary\">{labels?.floatHint ?? 'Release to float the chat'}</Badge>\n </div>}\n </div>\n {open && <>\n {!sheet && !fullscreen && !floating && <div role=\"separator\" tabIndex={0} aria-orientation=\"vertical\" aria-label={labels?.resize ?? 'Resize artifact panel'} aria-valuemin={minPanel} aria-valuemax={max} aria-valuenow={Math.round(width)}\n data-testid=\"workspace-divider\" data-dragging={dragging ? 'true' : undefined}\n onPointerDown={event => { event.preventDefault(); event.currentTarget.focus(); event.currentTarget.setPointerCapture(event.pointerId); setDragging(true); }} onPointerMove={drag}\n onPointerUp={release} onPointerCancel={() => { setDragging(false); setFloatHint(false); }}\n onKeyDown={keys} onDoubleClick={() => resize(initial)}\n className=\"group relative z-10 -mx-1.5 w-3 shrink-0 cursor-col-resize touch-none select-none outline-none\">\n <span aria-hidden=\"true\" className={cn('absolute inset-y-0 left-1/2 w-px -translate-x-1/2', 'bg-border/65', 'transition-colors group-hover:w-0.5 group-hover:bg-ring group-focus-visible:w-0.5 group-focus-visible:bg-ring motion-reduce:transition-none', dragging && 'w-0.5 bg-ring')} />\n </div>}\n <aside onTouchStart={event => { const point = event.touches[0]; swipe.current = sheet && point && point.clientX < 32 ? { x: point.clientX, y: point.clientY } : null; }} onTouchEnd={event => { const point = event.changedTouches[0], start = swipe.current; swipe.current = null; if (point && start && point.clientX - start.x > 80 && Math.abs(point.clientY - start.y) < 60) onClose(); }} data-testid=\"workspace-panel\" aria-label={panelLabel} data-state={entered ? 'open' : 'entering'} data-floating={floating ? 'true' : undefined} data-fullscreen={fullscreen ? 'true' : 'false'} data-sheet={sheet ? 'true' : 'false'}\n style={sheet || fullscreen || floating ? undefined : { width, minWidth: minPanel }}\n className={cn('flex min-h-0 min-w-0 flex-col overflow-hidden transition-opacity motion-reduce:transition-none', !inset && 'bg-background text-foreground',\n sheet ? (size >= window.innerWidth - 1 ? 'fixed inset-0 z-50' : 'absolute inset-0 z-50') : fullscreen ? 'fixed inset-0 z-50' : floating ? 'flex-1' : inset ? 'relative isolate my-3 me-3 ms-0 shrink-0' : 'shrink-0',\n entered ? 'opacity-100' : 'opacity-0', dragging && 'pointer-events-none select-none')}>\n {inset && <Card aria-hidden=\"true\" data-boring=\"chrome\" className=\"pointer-events-none absolute inset-0 -z-10\" />}\n {typeof panel === 'function' ? panel(api) : panel}\n </aside>\n </>}\n </div>;\n}\n",
|
|
18
|
+
"content": "'use client';\n\nimport { PHONE_BELOW } from '../utils/use-media';\nimport { useBackLayer } from '../utils/back';\nimport { readFlag, writeFlag, readPersisted, writePersisted } from '../utils/persisted';\n\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\nimport { Badge } from '@/components/ui/badge';\nimport { Card } from '@/components/ui/card';\nimport { cn } from '../utils/utils';\n\n/** What the open panel can do to its workspace. Pass `onFullscreenChange` on to the viewer's top bar; it is absent in the phone sheet. */\nexport interface WorkspacePanelApi {\n readonly fullscreen: boolean;\n /** Absent on a phone, where the panel is already a full-screen sheet. */\n readonly onFullscreenChange: ((next: boolean) => void) | undefined;\n readonly close: () => unknown;\n readonly sheet: boolean;\n /** Float the chat over the panel (see `floatBelow`). Absent without `floatBelow`, while the chat already floats and in the phone sheet. pi-app's `AgentWorkspace` turns it into the viewers' \"Float chat\" action (`ViewerWindow.actions`). */\n readonly floatChat: (() => void) | undefined;\n}\n\n/** What `chat` can render as a function: whether the chat floats over the full-width panel, and how to put it back beside it. */\nexport interface WorkspaceChatLayout {\n readonly floating: boolean;\n readonly dock: () => void;\n}\n\nexport interface ArtifactWorkspaceProps {\n /**\n * Usually a `PiChat`. It stays mounted while the panel opens, resizes, goes full screen and closes. With `floatBelow`, pass a function that\n * returns the docked chat or, while `floating`, an `AmbientChat` on the same controller (`onDock` there is `dock`): the chat is then a different surface over one session.\n */\n readonly chat: ReactNode | ((layout: WorkspaceChatLayout) => ReactNode);\n /** Whether the panel is open. Closing is the host's decision (`onClose`); the workspace never unmounts `chat`. */\n readonly open: boolean;\n /** The panel content, usually a viewer whose top bar is the shared `ViewerFrame`. Receives what it needs for its close and full screen buttons. */\n readonly panel: ReactNode | ((api: WorkspacePanelApi) => ReactNode);\n /** Return false when a synchronous host guard refuses to close; Back then restores the sheet entry. */\n readonly onClose: () => unknown;\n /** Full screen is controlled when both are given; otherwise the workspace keeps it. Escape leaves it. */\n readonly fullscreen?: boolean;\n readonly onFullscreenChange?: (next: boolean) => void;\n /** Width of the panel is remembered for this browser session under this key. */\n readonly storageKey?: string;\n /**\n * Initial panel width before the person drags the divider: pixels, or a fraction of the workspace's width when at most 1 (0.55: a bit\n * over half). Without it the panel takes all but 420px of chat. Double-clicking the divider comes back to it.\n */\n readonly defaultWidth?: number;\n readonly minPanel?: number;\n readonly minChat?: number;\n /** Below this container width the panel is a full-screen sheet and the divider is gone. */\n readonly sheetBelow?: number;\n /** The sheet covers this workspace only, never the window, even when the workspace fills it: what sits above stays on screen. */\n readonly contained?: boolean;\n /**\n * Opt in to a floating chat: when the divider is dragged until the chat is narrower than this many pixels, a hint appears and releasing floats the chat\n * (the panel takes the full width). Alt+Left on the divider and `api.floatChat` do the same without a pointer. Remembered for the session; ignored in the phone sheet.\n * Off by default.\n */\n readonly floatBelow?: number;\n /** The panel's accessible name. */\n readonly panelLabel?: string;\n /**\n * `split` (default): the panel is a full-height column with a hard border and a visible divider. `inset`: the panel is a rounded card inside the\n * workspace (a margin around it, a border and a soft shadow on the card only) and the divider remains visible and strengthens on hover, focus or drag, so the panel\n * reads as part of the chat's own surface, as in Claude's artifact panel. Resizing works the same.\n */\n readonly variant?: 'split' | 'inset';\n /** The divider's accessible name and the hint shown while a drag would float the chat. */\n readonly labels?: { readonly resize?: string | undefined; readonly floatHint?: string | undefined } | undefined;\n readonly className?: string;\n}\n\nconst STEP = 24, BIG_STEP = 96;\nconst read = (key: string): number | undefined => { const value = Number(readPersisted(key)); return Number.isFinite(value) && value > 0 ? value : undefined; };\nconst write = (key: string, value: number) => writePersisted(key, String(Math.round(value)));\n\n/**\n * Chat with an artifact panel beside it, as in Claude's chat apps. The panel opens on the right at full height and the chat\n * narrows; a divider (pointer or arrow keys) resizes it, the width is remembered for the session; the panel can go full screen\n * (Escape leaves) and on a phone it is a full-screen sheet. With `floatBelow` the chat can float over a full-width panel instead of sitting beside it. The workspace owns layout only: what is open, and the viewer in it, are the host's.\n */\nexport function ArtifactWorkspace({ chat, open, panel, onClose, fullscreen: controlledFullscreen, onFullscreenChange, storageKey = 'boring.artifact-panel.width', defaultWidth,\n minPanel = 360, minChat = 360, sheetBelow = PHONE_BELOW, contained = false, floatBelow, panelLabel = 'Artifact panel', variant = 'split', labels, className }: ArtifactWorkspaceProps) {\n const root = useRef<HTMLDivElement>(null);\n const [size, setSize] = useState(0);\n const [stored, setStored] = useState<number | undefined>(() => read(storageKey) ?? (defaultWidth !== undefined && defaultWidth > 1 ? defaultWidth : undefined));\n const closeRef = useRef(onClose);\n const fullscreenRef = useRef(false);\n const opener = useRef<HTMLElement | null>(null);\n const swipe = useRef<{ x: number; y: number } | null>(null);\n const [local, setLocal] = useState(false);\n const [entered, setEntered] = useState(false);\n const [dragging, setDragging] = useState(false);\n const [floatHint, setFloatHint] = useState(false);\n const [floatState, setFloatState] = useState(() => floatBelow !== undefined && readFlag(`${storageKey}.floating`));\n const controlled = controlledFullscreen !== undefined && onFullscreenChange !== undefined;\n const sheet = size > 0 && size < sheetBelow;\n const floating = open && !sheet && floatBelow !== undefined && floatState;\n const fullscreen = open && !sheet && (controlled ? controlledFullscreen : local);\n useLayoutEffect(() => { closeRef.current = onClose; fullscreenRef.current = fullscreen; });\n const setFullscreen = useCallback((next: boolean) => { if (controlled) onFullscreenChange!(next); else setLocal(next); }, [controlled, onFullscreenChange]);\n\n useLayoutEffect(() => {\n const element = root.current;\n if (!element) return;\n const measure = () => setSize(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 setFloating = useCallback((next: boolean) => { setFloatState(next); setFloatHint(false); writeFlag(`${storageKey}.floating`, next); }, [storageKey]);\n useEffect(() => {\n // With the panel closed there is nothing to float over: the chat docks back.\n // eslint-disable-next-line react-hooks/set-state-in-effect -- closing resets the panel's entry, fullscreen and floating once, after commit\n if (!open) { setEntered(false); setLocal(false); setFloating(false); return; }\n const frame = requestAnimationFrame(() => setEntered(true));\n return () => cancelAnimationFrame(frame);\n }, [open, setFloating]);\n useEffect(() => {\n if (!fullscreen) return;\n const escape = (event: globalThis.KeyboardEvent) => { if (event.key === 'Escape' && !event.defaultPrevented) setFullscreen(false); };\n document.addEventListener('keydown', escape);\n return () => document.removeEventListener('keydown', escape);\n }, [fullscreen, setFullscreen]);\n\n useEffect(() => {\n if (!open) return;\n const element = root.current;\n opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n const escape = (event: globalThis.KeyboardEvent) => {\n if (event.key !== 'Escape' || event.defaultPrevented || fullscreenRef.current) return;\n event.preventDefault(); closeRef.current();\n };\n document.addEventListener('keydown', escape);\n return () => {\n document.removeEventListener('keydown', escape);\n const previous = opener.current;\n const target = previous?.isConnected && previous !== document.body ? previous : element?.querySelector<HTMLElement>('[data-testid=composer-input]');\n target?.focus({ preventScroll: true });\n // A shadcn Button transitions its inherited visibility, so it stays hidden (unfocusable) briefly after the sheet closes: retry per frame.\n if (target) { let frames = 30; const retry = () => { if (document.activeElement === target || document.activeElement !== document.body || frames-- <= 0) return; target.focus({ preventScroll: true }); requestAnimationFrame(retry); }; requestAnimationFrame(retry); }\n };\n }, [open]);\n useBackLayer(open && sheet, onClose, 'artifact');\n\n const max = Math.max(minPanel, size - minChat);\n const initial = defaultWidth === undefined ? size - 420 : defaultWidth <= 1 ? size * defaultWidth : defaultWidth;\n const width = Math.min(Math.max(stored ?? Math.max(minPanel, initial), minPanel), max);\n const resize = (next: number) => { const clamped = Math.min(Math.max(next, minPanel), max); setStored(clamped); write(storageKey, clamped); };\n const drag = (event: PointerEvent<HTMLElement>) => {\n if (!dragging) return;\n const box = root.current?.getBoundingClientRect();\n if (!box) return;\n const target = box.right - event.clientX;\n // Past the threshold the width stays at the last one above it; releasing there floats the chat.\n const below = floatBelow !== undefined && size - target < floatBelow;\n setFloatHint(below);\n if (!below) resize(target);\n };\n const release = (event: PointerEvent<HTMLElement>) => { event.currentTarget.releasePointerCapture(event.pointerId); setDragging(false); if (floatHint) setFloating(true); setFloatHint(false); };\n const keys = (event: KeyboardEvent<HTMLElement>) => {\n if (floatBelow !== undefined && event.altKey && event.key === 'ArrowLeft') { event.preventDefault(); setFloating(true); return; }\n const step = event.shiftKey ? BIG_STEP : STEP;\n const next = event.key === 'ArrowLeft' ? width + step : event.key === 'ArrowRight' ? width - step : event.key === 'Home' ? max : event.key === 'End' ? minPanel : undefined;\n if (next === undefined) return;\n event.preventDefault(); resize(next);\n };\n\n const api: WorkspacePanelApi = { fullscreen, onFullscreenChange: sheet ? undefined : setFullscreen, close: onClose, sheet, floatChat: floatBelow !== undefined && !sheet && !floating ? () => setFloating(true) : undefined };\n const covering = open && (sheet || fullscreen);\n // The inset panel is drawn by the host's Card behind it (a sibling layer, so a placement change never remounts the panel);\n // every other placement is a plain region of the page.\n const inset = variant === 'inset' && !sheet && !fullscreen && !floating;\n return <div ref={root} data-boring=\"artifact-workspace\" data-open={open ? 'true' : 'false'} data-fullscreen={fullscreen ? 'true' : 'false'} data-sheet={sheet && open ? 'true' : 'false'}\n className={cn('relative flex h-full min-h-0 min-w-0 flex-1', className)}>\n <div data-testid=\"workspace-chat\" data-floating={floating ? 'true' : undefined} {...(covering ? { inert: true, 'aria-hidden': true } : {})}\n className={cn('relative flex min-h-0 min-w-0 flex-col', floating ? 'w-0 flex-none' : 'flex-1', covering && 'invisible')}>\n {typeof chat === 'function' ? chat({ floating, dock: () => setFloating(false) }) : chat}\n {floatHint && <div role=\"status\" data-testid=\"workspace-float-hint\" className=\"pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-background/75 p-4\">\n <Badge variant=\"secondary\">{labels?.floatHint ?? 'Release to float the chat'}</Badge>\n </div>}\n </div>\n {open && <>\n {!sheet && !fullscreen && !floating && <div role=\"separator\" tabIndex={0} aria-orientation=\"vertical\" aria-label={labels?.resize ?? 'Resize artifact panel'} aria-valuemin={minPanel} aria-valuemax={max} aria-valuenow={Math.round(width)}\n data-testid=\"workspace-divider\" data-dragging={dragging ? 'true' : undefined}\n onPointerDown={event => { event.preventDefault(); event.currentTarget.focus(); event.currentTarget.setPointerCapture(event.pointerId); setDragging(true); }} onPointerMove={drag}\n onPointerUp={release} onPointerCancel={() => { setDragging(false); setFloatHint(false); }}\n onKeyDown={keys} onDoubleClick={() => resize(initial)}\n className=\"group relative z-10 -mx-1.5 w-3 shrink-0 cursor-col-resize touch-none select-none outline-none\">\n <span aria-hidden=\"true\" className={cn('absolute inset-y-0 left-1/2 w-px -translate-x-1/2', 'bg-border/65', 'transition-colors group-hover:w-0.5 group-hover:bg-ring group-focus-visible:w-0.5 group-focus-visible:bg-ring motion-reduce:transition-none', dragging && 'w-0.5 bg-ring')} />\n </div>}\n <aside onTouchStart={event => { const point = event.touches[0]; swipe.current = sheet && point && point.clientX < 32 ? { x: point.clientX, y: point.clientY } : null; }} onTouchEnd={event => { const point = event.changedTouches[0], start = swipe.current; swipe.current = null; if (point && start && point.clientX - start.x > 80 && Math.abs(point.clientY - start.y) < 60) onClose(); }} data-testid=\"workspace-panel\" aria-label={panelLabel} data-state={entered ? 'open' : 'entering'} data-floating={floating ? 'true' : undefined} data-fullscreen={fullscreen ? 'true' : 'false'} data-sheet={sheet ? 'true' : 'false'}\n style={sheet || fullscreen || floating ? undefined : { width, minWidth: minPanel }}\n className={cn('flex min-h-0 min-w-0 flex-col overflow-hidden transition-opacity motion-reduce:transition-none', !inset && 'bg-background text-foreground',\n sheet ? (!contained && size >= window.innerWidth - 1 ? 'fixed inset-0 z-50' : 'absolute inset-0 z-50') : fullscreen ? 'fixed inset-0 z-50' : floating ? 'flex-1' : inset ? 'relative isolate my-3 me-3 ms-0 shrink-0' : 'shrink-0',\n entered ? 'opacity-100' : 'opacity-0', dragging && 'pointer-events-none select-none')}>\n {inset && <Card aria-hidden=\"true\" data-boring=\"chrome\" className=\"pointer-events-none absolute inset-0 -z-10\" />}\n {typeof panel === 'function' ? panel(api) : panel}\n </aside>\n </>}\n </div>;\n}\n",
|
|
19
19
|
"type": "registry:component",
|
|
20
20
|
"target": "components/pi-workspace/workspace.tsx"
|
|
21
21
|
},
|
package/r/placed-agent.json
CHANGED
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
"lucide-react@^1.51.0"
|
|
10
10
|
],
|
|
11
11
|
"registryDependencies": [
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-app.json",
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-ambient.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/app-shell.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/utils.json"
|
|
16
16
|
],
|
|
17
17
|
"files": [
|
|
18
18
|
{
|
package/r/pm-panel.json
CHANGED
|
@@ -4,32 +4,32 @@
|
|
|
4
4
|
"title": "Shared PM composer and in-app panel",
|
|
5
5
|
"description": "« Améliorer l'app »: the embedded AgentWorkspace chat, History, read-only Library and host Tasks panel, with the shared pointing strip, numbered pins and timestamped chips. Docked beside the app on desktop and full screen on phones; PmChat gives Studio the same component.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-feedback@^0.4.
|
|
9
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.45",
|
|
8
|
+
"@hachej/boring-feedback@^0.4.45",
|
|
9
|
+
"@hachej/boring-files@^0.4.45",
|
|
10
10
|
"@earendil-works/pi-durable@~1.4.0",
|
|
11
11
|
"lucide-react@^1.51.0",
|
|
12
12
|
"react@^19.0.0",
|
|
13
13
|
"react-dom@^19.0.0"
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
17
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
18
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
19
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
20
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
21
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
22
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
23
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
24
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
25
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
26
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
27
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-app.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-app-kinds.json",
|
|
18
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-chat.json",
|
|
19
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-chat-markdown.json",
|
|
20
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-chat-plugins.json",
|
|
21
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-chat-voice.json",
|
|
22
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/feedback.json",
|
|
23
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/task-board.json",
|
|
24
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/task-mentions.json",
|
|
25
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/utils.json",
|
|
26
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-artifacts.json",
|
|
27
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/viewer-frame.json"
|
|
28
28
|
],
|
|
29
29
|
"files": [
|
|
30
30
|
{
|
|
31
31
|
"path": "registry/pm-panel/pm-panel.tsx",
|
|
32
|
-
"content": "'use client';\n\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { ListTodoIcon, XIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { AgentWorkspace } from '../pi-app/agent-workspace';\nimport type { WorkspaceBarPanel } from '../pi-app/workspace-bar';\nimport { defaultKinds } from '../pi-app-kinds/pi-app-kinds';\nimport { ChatTextProvider } from '../pi-chat/labels';\nimport { chatTextFor } from '../pi-chat/locale';\nimport { cn } from '../utils/utils';\nimport { usePhone } from '../utils/use-media';\nimport { PmComposer } from './pm-composer';\nimport { PointComment } from './point-comment';\nimport { usePmPanel } from './use-pm-panel';\nimport { POINT_PREFACE, pointMessage } from './point-message';\nimport { usePmMentions } from './pm-mentions';\nimport { TaskBoard } from '../task-board/task-board';\nimport type { TaskRecord } from '@hachej/boring-ui-kit/task-board';\nimport { selectTasks } from '@hachej/boring-ui-kit/task-board';\nimport { requestOutcome } from './pm-task-source';\nimport type { ImproveSpec, StudioTaskBoardSeam } from './use-pm-panel';\nimport { useImproveAgent } from './use-improve-agent';\nimport type { ImproveProp } from './use-improve-agent';\nimport { pmTextFor } from './pm-text';\nimport type { PmPreset } from './pm-composer';\nimport type { PmText } from './pm-text';\nimport { useSiblingPointing } from './point-frames';\nimport { createPrivacyPolicy } from '@hachej/boring-feedback/page';\nimport { PM_HOST_ORIGINS, usePmHostTheme } from './pm-host-theme';\nimport { PointChipView } from './inline-point-input';\n\nexport { PM_HOST_ORIGINS } from './pm-host-theme';\nexport { PmComposer, PointerIcon } from './pm-composer';\nexport type { PmComposerProps, PmPreset } from './pm-composer';\nexport { usePmPanel } from './use-pm-panel';\nexport type { PmComment, PmPanelState } from './use-pm-panel';\nexport { PointComment } from './point-comment';\nexport { usePmMentions } from './pm-mentions';\nexport { pmTextEn, pmTextFor, pmTextFr } from './pm-text';\nexport type { PmText } from './pm-text';\nexport { PointChipView } from './inline-point-input';\nexport { pointMessage } from './point-message';\nexport type { ImproveSpec, PmVoice, PmTranscript, StudioTaskBoardSeam } from './use-pm-panel';\nexport { useImproveAgent, isImproveAgent } from './use-improve-agent';\nexport type { ImproveAgent, ImproveProp } from './use-improve-agent';\n\ninterface Binding { readonly spec: ImproveSpec; readonly open: boolean; readonly close: () => void; readonly onFull?: ((full: boolean, collapse: () => void) => void) | undefined }\ninterface Pending { readonly open: boolean; readonly close: () => void; readonly problem: string | undefined; readonly retry: () => void; readonly text: PmText }\nconst PmContext = createContext<Binding | Pending | null>(null);\nconst AppNameContext = createContext('');\nconst defaultPolicy = createPrivacyPolicy();\n// What a mock-up the PM presents may load besides its own inline code: libraries from two public hosts, in the kit's sandboxed preview.\nconst PM_INTERACTIVE = { scriptSources: ['https://cdnjs.cloudflare.com', 'https://cdn.jsdelivr.net'] };\nasync function imageFiles(files: readonly File[]) {\n return Promise.all(files.map(async (file, index) => {\n if (!/^image\\/(png|jpeg|gif|webp)$/.test(file.type) || file.size > 3 * 1024 * 1024) throw new Error('Joignez une image de moins de 3 Mo.');\n let binary = '';\n for (const byte of new Uint8Array(await file.arrayBuffer())) binary += String.fromCharCode(byte);\n return { id: `pm-file-${Date.now()}-${index}`, name: file.name, content: { type: 'image' as const, mimeType: file.type, data: btoa(binary) } };\n }));\n}\n\n/** One borrowed PM conversation, resolved through the app's existing door on the first open. */\nexport function PmPanelProvider({ spec, open, close, appName, locale, onFull, children }: { readonly spec?: ImproveProp | undefined; readonly open: boolean; readonly close: () => void; readonly appName?: string | undefined; readonly locale?: string | undefined; readonly onFull?: ((full: boolean, collapse: () => void) => void) | undefined; readonly children: ReactNode }) {\n const resolved = useImproveAgent(spec, open, { ...(appName ? { appName } : {}), ...(locale ? { locale } : {}) });\n const text = pmTextFor(locale);\n const value = useMemo<Binding | Pending | null>(() => resolved.spec ? { spec: resolved.spec, open, close, onFull } : spec ? { open, close, problem: resolved.problem, retry: resolved.retry, text } : null, [resolved.spec, resolved.problem, resolved.retry, spec, open, close, onFull, text]);\n return <PmContext.Provider value={value}><AppNameContext.Provider value={appName ?? ''}>{children}</AppNameContext.Provider></PmContext.Provider>;\n}\n\n/** Desktop: beside the pointable app. Phone: full-screen chat, or the in-flow strip while pointing. */\nexport function ImproveLayout({ children, navigation, screen }: { readonly children: ReactNode; readonly navigation?: ReactNode; readonly screen?: string }) {\n const bound = useContext(PmContext);\n const [page, setPage] = useState<HTMLDivElement | null>(null);\n const binding = bound && 'spec' in bound ? bound : null, pending = bound && !('spec' in bound) ? bound : null;\n const controller = binding?.spec.controller;\n const [instance, setInstance] = useState({ controller, sequence: 0 });\n if (instance.controller !== controller) setInstance({ controller, sequence: instance.sequence + 1 });\n const [pointing, setPointing] = useState(false);\n const [opened, setOpened] = useState(false);\n if (binding?.open && !opened) setOpened(true);\n return <div data-pm-layout=\"\" className=\"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden md:flex-row\">\n <div ref={setPage} data-pm-page=\"\" data-pm-screen={screen} className=\"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden\">{children}</div>\n {bound && inFrame() && <SiblingPoint page={page} screen={screen ?? ''} bound={bound} />}\n {binding && opened && <PanelSession key={instance.sequence} binding={binding} page={page} screen={screen} report={setPointing} />}\n {pending?.open && <PmConnecting text={pending.text} problem={pending.problem} retry={pending.retry} close={pending.close} className=\"absolute inset-0 z-40 md:static md:w-[26rem] md:shrink-0\" />}\n <div data-pm-navigation=\"\" hidden={Boolean(bound?.open && !pointing)} className=\"shrink-0 md:hidden\">{navigation}</div>\n </div>;\n}\n\nconst inFrame = () => typeof window !== 'undefined' && window.parent !== window;\n\n/** This page is framed beside a standalone PM: pointing started there runs here, and this frame's own PM steps aside for it. */\nfunction SiblingPoint({ page, screen, bound }: { readonly page: HTMLElement | null; readonly screen: string; readonly bound: Binding | Pending }) {\n const text = 'spec' in bound ? pmTextFor(bound.spec.chatProps?.locale) : bound.text;\n useSiblingPointing({ app: useContext(AppNameContext), page, screen, policy: ('spec' in bound ? bound.spec.policy : undefined) ?? defaultPolicy, text, onStart: bound.close });\n return null;\n}\n\nfunction PanelSession({ binding, page, screen, report }: { readonly binding: Binding; readonly page: HTMLElement | null; readonly screen: string | undefined; readonly report: (pointing: boolean) => void }) {\n const pm = usePmPanel(binding.spec, binding.open, page, screen, { comment: useFinePointer() });\n const strip = pm.state.mode === 'point' || pm.state.mode === 'navigate';\n const returning = useRef(pm.returnToComposer);\n useLayoutEffect(() => { returning.current = pm.returnToComposer; });\n const collapse = useCallback(() => returning.current(), []);\n useLayoutEffect(() => { report(strip); }, [strip, report]);\n const onFull = binding.onFull;\n useEffect(() => { onFull?.(strip, collapse); }, [strip, collapse, onFull]);\n useEffect(() => () => { onFull?.(false, collapse); }, [onFull, collapse]);\n return <section hidden={!binding.open} data-feedback-ignore=\"\" data-testid=\"pm-panel\" data-mode={strip ? pm.state.mode : 'chat'}\n className={cn('min-h-0 min-w-0 bg-background md:relative md:w-[26rem] md:shrink-0', binding.open && 'flex flex-col', strip ? 'shrink-0' : 'absolute inset-0 z-40')}>\n <PmWorkspace spec={binding.spec} pm={pm} page={page} preset=\"touch\" open={binding.open} close={binding.close} />\n <Separator orientation=\"vertical\" className=\"absolute inset-y-0 left-0 z-50 hidden w-px md:block\" />\n </section>;\n}\n\nconst COARSE = '(pointer: coarse)', WIDE = '(min-width: 768px)';\nconst fine = () => !matchMedia(COARSE).matches && matchMedia(WIDE).matches;\nfunction useFinePointer() {\n return useSyncExternalStore(listener => {\n const queries = [matchMedia(COARSE), matchMedia(WIDE)];\n for (const query of queries) query.addEventListener('change', listener);\n return () => { for (const query of queries) query.removeEventListener('change', listener); };\n }, fine, () => false);\n}\n\nfunction PmConnecting({ text, problem, retry, close, className }: { readonly text: PmText; readonly problem: string | undefined; readonly retry: () => void; readonly close?: (() => void) | undefined; readonly className: string }) {\n return <section data-feedback-ignore=\"\" data-testid=\"pm-panel\" data-mode=\"connecting\" className={cn('flex min-h-0 flex-col gap-3 bg-background p-4', className)}>\n <h2 className=\"m-0 text-sm\">{text.title}</h2>\n <p role={problem ? 'alert' : 'status'} className=\"m-0 text-sm text-muted-foreground\">{problem ? text.connectionFailed : text.connecting}</p>\n {problem && <Button variant=\"outline\" onClick={retry} data-testid=\"pm-connect-retry\">{text.retry}</Button>}\n {close && <Button variant=\"ghost\" data-testid=\"pm-close\" onClick={close}>{text.close}</Button>}\n </section>;\n}\n\nexport interface PmChatProps {\n /** One line, `{ agent: 'pm' }` (the kit opens the app's PM door), or a host `ImproveSpec`. */\n readonly spec: ImproveProp;\n /** The page this chat points at when it is in the same document. Without it, pointing needs `siblingApp`. */\n readonly page?: HTMLElement | null;\n /** The app's name: this PM sits in a frame beside the app's frame, and pointing runs there. */\n readonly siblingApp?: string;\n readonly screen?: string;\n readonly preset?: PmPreset;\n readonly appName?: string;\n readonly locale?: string;\n readonly close?: (() => void) | undefined;\n}\n\n/** The PM, plugged anywhere: the regular chat filling its container. What the PM presents sits beside it when the container is wide enough, otherwise replaces it with Back. */\nexport function PmChat({ spec, appName, locale, close, ...rest }: PmChatProps) {\n const resolved = useImproveAgent(spec, true, { ...(appName ? { appName } : {}), ...(locale ? { locale } : {}) });\n const controller = resolved.spec?.controller;\n const [instance, setInstance] = useState({ controller, sequence: 0 });\n if (instance.controller !== controller) setInstance({ controller, sequence: instance.sequence + 1 });\n if (!resolved.spec) return <PmConnecting text={pmTextFor(locale)} problem={resolved.problem} retry={resolved.retry} close={close} className=\"flex-1\" />;\n return <PmChatSession key={instance.sequence} spec={resolved.spec} close={close} {...rest} />;\n}\n\nfunction PmChatSession({ spec, page = null, siblingApp, screen, preset = 'desktop', close }: Omit<PmChatProps, 'spec' | 'appName' | 'locale'> & { readonly spec: ImproveSpec }) {\n const pm = usePmPanel(spec, true, page, screen, { comment: preset === 'desktop' && siblingApp === undefined, sibling: siblingApp });\n return <div data-testid=\"pm-chat\" data-mode={pm.state.mode} className=\"flex min-h-0 min-w-0 flex-1 flex-col\"><PmWorkspace spec={spec} page={page} pm={pm} preset={preset} open close={close} beside={siblingApp !== undefined} /></div>;\n}\n\n/** `?pm=standalone`: only the PM, full frame, for a host's column beside the app's frame. Same origin and session as the app, no route of its own. */\nexport function PmStandalone({ spec, appName, locale, hostOrigins = PM_HOST_ORIGINS }: { readonly spec: ImproveProp; readonly appName: string; readonly locale?: string | undefined; readonly hostOrigins?: readonly string[] | undefined }) {\n usePmHostTheme(hostOrigins);\n return <main data-testid=\"pm-standalone\" className=\"app-shell flex h-dvh min-h-0 flex-col overflow-hidden bg-background text-foreground\">\n <PmChat spec={spec} appName={appName} siblingApp={appName} preset=\"touch\" {...(locale ? { locale } : {})} />\n </main>;\n}\n\n/** Wiring only: the regular agent page, with the existing point composer and a host Tasks panel. */\nfunction PmWorkspace({ spec, pm, page, preset, open, close, beside = false }: { readonly spec: ImproveSpec; readonly pm: ReturnType<typeof usePmPanel>; readonly page: HTMLElement | null; readonly preset: PmPreset; readonly open: boolean; readonly close?: (() => void) | undefined; readonly beside?: boolean }) {\n const { text } = pm;\n const files = spec.resources ? { resources: spec.resources } : { resources: undefined };\n // On a phone the chat gives the page its screen while pointing. A PM beside the app's frame covers nothing.\n const phone = usePhone() && !beside;\n const strip = pm.state.mode === 'point' || pm.state.mode === 'navigate';\n const root = useRef<HTMLDivElement | null>(null);\n const kinds = useMemo(() => spec.artifactKinds ?? defaultKinds({ locale: text.locale }), [spec.artifactKinds, text.locale]);\n const mentions = usePmMentions(spec.tasks, spec.chatProps?.mentions, text);\n const edit = useCallback((value: string) => { spec.controller.setText(value); }, [spec.controller]);\n const refreshTasks = spec.tasks?.refresh;\n useEffect(() => { if (open) refreshTasks?.(); }, [open, refreshTasks]);\n const seenKey = `boring.pm.ships.${JSON.stringify(pm.snapshot.identity)}`;\n const [seen, setSeen] = useState<readonly string[]>(() => { try { const value: unknown = JSON.parse(localStorage.getItem(seenKey) ?? '[]'); return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; } catch { return []; } });\n const ships = spec.tasks ? selectTasks(spec.tasks.tasks, { filter: 'mine', currentUser: spec.tasks.currentUser }).filter(task => !task.technical && requestOutcome(task) === 'live').map(task => `${task.id}:${task.updated_at ?? ''}`) : [];\n const tasksLabel = text.locale === 'fr' ? 'Tâches' : 'Tasks';\n const barPanels: readonly WorkspaceBarPanel[] = [{ id: 'tasks', label: tasksLabel, icon: ListTodoIcon, dot: spec.tasks?.unread ?? ships.some(ship => !seen.includes(ship)), onOpenChange: spec.tasks?.onOpenChange, onOpen: () => {\n refreshTasks?.(); setSeen(ships); try { localStorage.setItem(seenKey, JSON.stringify(ships)); } catch { /* viewer storage is optional */ }\n }, content: () => <div data-testid=\"pm-task-board-seam\" className=\"flex min-h-0 flex-1 flex-col\">{spec.tasks ? <>{spec.tasks.header}<PmRequests seam={spec.tasks} text={text} /></> : <p role=\"status\" className=\"p-4 text-sm text-muted-foreground\">{text.connectionFailed}</p>}</div> }];\n useEffect(() => {\n if (!pm.focusVersion || !open || strip) return;\n const input = root.current?.querySelector<HTMLElement>('[data-testid=composer-input]');\n input?.focus({ preventScroll: true });\n if (input instanceof HTMLTextAreaElement) input.setSelectionRange(input.value.length, input.value.length);\n }, [pm.focusVersion, open, strip, spec.controller]);\n const pointProps = { preset, text, selections: pm.state.chips, onRemove: pm.remove, show: pm.state.show, onShow: (value: boolean) => pm.dispatch({ type: 'show', value }),\n canPoint: pm.canPoint, onPoint: () => pm.dispatch({ type: 'point' }), onReturn: pm.returnToComposer, onOpenRecorder: () => pm.dispatch({ type: 'return' }), focusInput: pm.focusVersion > 0 && open && !strip, voice: pm.voice, meta: pm.meta, notice: pm.sent ? text.sentToPm : undefined };\n const uncertain = pm.snapshot.send.kind === 'unknown' ? pm.snapshot.send.attempt : undefined;\n const transcript = pm.voice.phase === 'processing' ? <p data-testid=\"pm-transcribing\" role=\"status\" className=\"m-0 px-2\">{text.locale === 'fr' ? 'Transcription…' : 'Transcribing…'}</p> : pm.liveText && <p role=\"status\" data-testid=\"pm-live-transcript\" className=\"m-0 px-2\">{pm.liveText}</p>;\n return <ChatTextProvider value={chatTextFor(text.locale)}><div ref={root} className=\"flex min-h-0 flex-1 flex-col\">\n <p role=\"status\" aria-live=\"polite\" data-testid=\"pm-announce\" className=\"sr-only\">{pm.announcement}</p>\n {pm.problem && <p role=\"alert\" className=\"m-0 px-4 text-sm\">{pm.problem}</p>}\n <div hidden={strip && phone} className={cn('min-h-0 flex-1 flex-col', strip && phone ? 'hidden' : 'flex')}>\n <AgentWorkspace layout=\"embedded\" title={spec.appName ?? spec.label ?? text.title} locale={text.locale} controller={spec.controller}\n conversationId={spec.conversationId ?? String(pm.snapshot.conversationId)} conversations={spec.conversations ?? spec.chatProps?.conversations}\n {...files} artifactKinds={kinds} interactive={PM_INTERACTIVE} autoOpen={Boolean(spec.resources)} barPanels={barPanels} barOptions={{ panelsFirst: true }}\n controls={close && <Button variant=\"ghost\" size=\"icon\" data-testid=\"pm-close\" aria-label={text.close} title={text.close} onClick={close}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n chat={{ ...spec.chatProps, ...(mentions ? { mentions } : {}),\n labels: { ...spec.chatProps?.labels, placeholder: `${text.placeholderBefore}↖${text.placeholderAfter}`, add: text.attach, send: text.send },\n onFiles: spec.chatProps?.onFiles ?? imageFiles, sendContext: pm.sendContext, replyError: spec.chatProps?.replyError ?? (() => text.failed),\n renderEntry: entry => pointMessage(entry, mentions, { preset, text }) ?? spec.chatProps?.renderEntry?.(entry),\n replyFooter: reply => { const entry = pm.snapshot.view?.entries.find(entry => String(entry.id) === reply.entryId); return <>{spec.chatProps?.replyFooter?.(reply)}{entry && spec.tasks?.renderRequest?.({ entry, edit })}</>; },\n renderComposer: composer => strip && phone ? null : strip ? <>{beside && pm.state.chips.length > 0 && <div data-testid=\"pm-beside-chips\" className=\"flex flex-wrap gap-1.5 pb-2\">{pm.state.chips.map(chip => <PointChipView key={chip.id} chip={chip} preset={preset} labels={text} onRemove={() => pm.remove(chip.id)} />)}</div>}<PmComposer variant=\"strip\" {...pointProps} /></> : <PmComposer {...pointProps} composer={pm.answerComposer({ ...composer, sendBlocked: composer.sendBlocked || pm.voice.phase === 'processing', ...(uncertain ? { text: uncertain.draft.text.split(POINT_PREFACE)[0]!.trimEnd(), disabled: true } : {}) })} transcript={transcript} /> }} />\n </div>\n {strip && phone && <PmComposer variant=\"strip\" {...pointProps} />}\n {pm.comment && page && <PointComment comment={pm.comment} frame={page} text={text} />}\n </div></ChatTextProvider>;\n}\n\n/** « Mes demandes »: the one board, the viewer's own tasks. The panel's width stacks it; the detail is where a request is answered or withdrawn. */\nfunction PmRequests({ seam, text }: { readonly seam: StudioTaskBoardSeam; readonly text: PmText }) {\n const [selected, setSelected] = useState<string | null>(null);\n const answer = (task: TaskRecord, _question: unknown, value: string) => seam.answer?.(task, value);\n return <TaskBoard tasks={seam.tasks} filter=\"mine\" currentUser={seam.currentUser} bar={false} title={text.requests} locale={text.locale} selectedTask={selected}\n onSelectedTaskChange={id => { setSelected(id); const task = seam.tasks.find(each => each.id === id); if (task) seam.onOpen(task); }}\n actions={task => requestOutcome(task) ? undefined : seam.cardActions?.(task)} onAnswer={answer} onOther={answer} className=\"min-h-0 flex-1\" />;\n}\n",
|
|
32
|
+
"content": "'use client';\n\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { ListTodoIcon, XIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { AgentWorkspace } from '../pi-app/agent-workspace';\nimport type { WorkspaceBarPanel } from '../pi-app/workspace-bar';\nimport { defaultKinds } from '../pi-app-kinds/pi-app-kinds';\nimport { ChatTextProvider } from '../pi-chat/labels';\nimport { chatTextFor } from '../pi-chat/locale';\nimport { cn } from '../utils/utils';\nimport { usePhone } from '../utils/use-media';\nimport { PmComposer } from './pm-composer';\nimport { PointComment } from './point-comment';\nimport { usePmPanel } from './use-pm-panel';\nimport { POINT_PREFACE, pointMessage } from './point-message';\nimport { usePmMentions } from './pm-mentions';\nimport { TaskBoard } from '../task-board/task-board';\nimport type { TaskRecord } from '@hachej/boring-ui-kit/task-board';\nimport { selectTasks } from '@hachej/boring-ui-kit/task-board';\nimport { requestOutcome } from './pm-task-source';\nimport type { ImproveSpec, StudioTaskBoardSeam } from './use-pm-panel';\nimport { useImproveAgent } from './use-improve-agent';\nimport type { ImproveProp } from './use-improve-agent';\nimport { pmTextFor } from './pm-text';\nimport type { PmPreset } from './pm-composer';\nimport type { PmText } from './pm-text';\nimport { useSiblingPointing } from './point-frames';\nimport { createPrivacyPolicy } from '@hachej/boring-feedback/page';\nimport { PM_HOST_ORIGINS, usePmHostTheme } from './pm-host-theme';\nimport { PointChipView } from './inline-point-input';\n\nexport { PM_HOST_ORIGINS } from './pm-host-theme';\nexport { PmComposer, PointerIcon } from './pm-composer';\nexport type { PmComposerProps, PmPreset } from './pm-composer';\nexport { usePmPanel } from './use-pm-panel';\nexport type { PmComment, PmPanelState } from './use-pm-panel';\nexport { PointComment } from './point-comment';\nexport { usePmMentions } from './pm-mentions';\nexport { pmTextEn, pmTextFor, pmTextFr } from './pm-text';\nexport type { PmText } from './pm-text';\nexport { PointChipView } from './inline-point-input';\nexport { pointMessage } from './point-message';\nexport type { ImproveSpec, PmVoice, PmTranscript, StudioTaskBoardSeam } from './use-pm-panel';\nexport { useImproveAgent, isImproveAgent } from './use-improve-agent';\nexport type { ImproveAgent, ImproveProp } from './use-improve-agent';\n\ninterface Binding { readonly spec: ImproveSpec; readonly open: boolean; readonly close: () => void; readonly onFull?: ((full: boolean, collapse: () => void) => void) | undefined }\ninterface Pending { readonly open: boolean; readonly close: () => void; readonly problem: string | undefined; readonly retry: () => void; readonly text: PmText }\nconst PmContext = createContext<Binding | Pending | null>(null);\nconst AppNameContext = createContext('');\nconst defaultPolicy = createPrivacyPolicy();\n// What a mock-up the PM presents may load besides its own inline code: libraries from two public hosts, in the kit's sandboxed preview.\nconst PM_INTERACTIVE = { scriptSources: ['https://cdnjs.cloudflare.com', 'https://cdn.jsdelivr.net'] };\nasync function imageFiles(files: readonly File[]) {\n return Promise.all(files.map(async (file, index) => {\n if (!/^image\\/(png|jpeg|gif|webp)$/.test(file.type) || file.size > 3 * 1024 * 1024) throw new Error('Joignez une image de moins de 3 Mo.');\n let binary = '';\n for (const byte of new Uint8Array(await file.arrayBuffer())) binary += String.fromCharCode(byte);\n return { id: `pm-file-${Date.now()}-${index}`, name: file.name, content: { type: 'image' as const, mimeType: file.type, data: btoa(binary) } };\n }));\n}\n\n/** One borrowed PM conversation, resolved through the app's existing door on the first open. */\nexport function PmPanelProvider({ spec, open, close, appName, locale, onFull, children }: { readonly spec?: ImproveProp | undefined; readonly open: boolean; readonly close: () => void; readonly appName?: string | undefined; readonly locale?: string | undefined; readonly onFull?: ((full: boolean, collapse: () => void) => void) | undefined; readonly children: ReactNode }) {\n const resolved = useImproveAgent(spec, open, { ...(appName ? { appName } : {}), ...(locale ? { locale } : {}) });\n const text = pmTextFor(locale);\n const value = useMemo<Binding | Pending | null>(() => resolved.spec ? { spec: resolved.spec, open, close, onFull } : spec ? { open, close, problem: resolved.problem, retry: resolved.retry, text } : null, [resolved.spec, resolved.problem, resolved.retry, spec, open, close, onFull, text]);\n return <PmContext.Provider value={value}><AppNameContext.Provider value={appName ?? ''}>{children}</AppNameContext.Provider></PmContext.Provider>;\n}\n\n/** Desktop: beside the pointable app. Phone: full-screen chat, or the in-flow strip while pointing. */\nexport function ImproveLayout({ children, navigation, screen }: { readonly children: ReactNode; readonly navigation?: ReactNode; readonly screen?: string }) {\n const bound = useContext(PmContext);\n const [page, setPage] = useState<HTMLDivElement | null>(null);\n const binding = bound && 'spec' in bound ? bound : null, pending = bound && !('spec' in bound) ? bound : null;\n const controller = binding?.spec.controller;\n const [instance, setInstance] = useState({ controller, sequence: 0 });\n if (instance.controller !== controller) setInstance({ controller, sequence: instance.sequence + 1 });\n const [pointing, setPointing] = useState(false);\n const [opened, setOpened] = useState(false);\n if (binding?.open && !opened) setOpened(true);\n return <div data-pm-layout=\"\" className=\"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden md:flex-row\">\n <div ref={setPage} data-pm-page=\"\" data-pm-screen={screen} className=\"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden\">{children}</div>\n {bound && inFrame() && <SiblingPoint page={page} screen={screen ?? ''} bound={bound} />}\n {binding && opened && <PanelSession key={instance.sequence} binding={binding} page={page} screen={screen} report={setPointing} />}\n {pending?.open && <PmConnecting text={pending.text} problem={pending.problem} retry={pending.retry} close={pending.close} className=\"absolute inset-0 z-40 md:static md:w-[26rem] md:shrink-0\" />}\n <div data-pm-navigation=\"\" hidden={Boolean(bound?.open && !pointing)} className=\"shrink-0 md:hidden\">{navigation}</div>\n </div>;\n}\n\nconst inFrame = () => typeof window !== 'undefined' && window.parent !== window;\n\n/** This page is framed beside a standalone PM: pointing started there runs here, and this frame's own PM steps aside for it. */\nfunction SiblingPoint({ page, screen, bound }: { readonly page: HTMLElement | null; readonly screen: string; readonly bound: Binding | Pending }) {\n const text = 'spec' in bound ? pmTextFor(bound.spec.chatProps?.locale) : bound.text;\n useSiblingPointing({ app: useContext(AppNameContext), page, screen, policy: ('spec' in bound ? bound.spec.policy : undefined) ?? defaultPolicy, text, onStart: bound.close });\n return null;\n}\n\nfunction PanelSession({ binding, page, screen, report }: { readonly binding: Binding; readonly page: HTMLElement | null; readonly screen: string | undefined; readonly report: (pointing: boolean) => void }) {\n const pm = usePmPanel(binding.spec, binding.open, page, screen, { comment: useFinePointer() });\n const strip = pm.state.mode === 'point' || pm.state.mode === 'navigate';\n const returning = useRef(pm.returnToComposer);\n useLayoutEffect(() => { returning.current = pm.returnToComposer; });\n const collapse = useCallback(() => returning.current(), []);\n useLayoutEffect(() => { report(strip); }, [strip, report]);\n const onFull = binding.onFull;\n useEffect(() => { onFull?.(strip, collapse); }, [strip, collapse, onFull]);\n useEffect(() => () => { onFull?.(false, collapse); }, [onFull, collapse]);\n return <section hidden={!binding.open} data-feedback-ignore=\"\" data-testid=\"pm-panel\" data-mode={strip ? pm.state.mode : 'chat'}\n className={cn('min-h-0 min-w-0 bg-background md:relative md:w-[26rem] md:shrink-0', binding.open && 'flex flex-col', strip ? 'shrink-0' : 'absolute inset-0 z-40')}>\n <PmWorkspace spec={binding.spec} pm={pm} page={page} preset=\"touch\" open={binding.open} close={binding.close} />\n <Separator orientation=\"vertical\" className=\"absolute inset-y-0 left-0 z-50 hidden w-px md:block\" />\n </section>;\n}\n\nconst COARSE = '(pointer: coarse)', WIDE = '(min-width: 768px)';\nconst fine = () => !matchMedia(COARSE).matches && matchMedia(WIDE).matches;\nfunction useFinePointer() {\n return useSyncExternalStore(listener => {\n const queries = [matchMedia(COARSE), matchMedia(WIDE)];\n for (const query of queries) query.addEventListener('change', listener);\n return () => { for (const query of queries) query.removeEventListener('change', listener); };\n }, fine, () => false);\n}\n\nfunction PmConnecting({ text, problem, retry, close, className }: { readonly text: PmText; readonly problem: string | undefined; readonly retry: () => void; readonly close?: (() => void) | undefined; readonly className: string }) {\n return <section data-feedback-ignore=\"\" data-testid=\"pm-panel\" data-mode=\"connecting\" className={cn('flex min-h-0 flex-col gap-3 bg-background p-4', className)}>\n <h2 className=\"m-0 text-sm\">{text.title}</h2>\n <p role={problem ? 'alert' : 'status'} className=\"m-0 text-sm text-muted-foreground\">{problem ? text.connectionFailed : text.connecting}</p>\n {problem && <Button variant=\"outline\" onClick={retry} data-testid=\"pm-connect-retry\">{text.retry}</Button>}\n {close && <Button variant=\"ghost\" data-testid=\"pm-close\" onClick={close}>{text.close}</Button>}\n </section>;\n}\n\nexport interface PmChatProps {\n /** One line, `{ agent: 'pm' }` (the kit opens the app's PM door), or a host `ImproveSpec`. */\n readonly spec: ImproveProp;\n /** The page this chat points at when it is in the same document. Without it, pointing needs `siblingApp`. */\n readonly page?: HTMLElement | null;\n /** The app's name: this PM sits in a frame beside the app's frame, and pointing runs there. */\n readonly siblingApp?: string;\n readonly screen?: string;\n readonly preset?: PmPreset;\n readonly appName?: string;\n readonly locale?: string;\n readonly close?: (() => void) | undefined;\n /** The bar's title, instead of the app's name (a host that already shows it). */\n readonly title?: string | undefined;\n}\n\n/** The PM, plugged anywhere: the regular chat filling its container. What the PM presents sits beside it when the container is wide enough, otherwise replaces it with Back. */\nexport function PmChat({ spec, appName, locale, close, ...rest }: PmChatProps) {\n const resolved = useImproveAgent(spec, true, { ...(appName ? { appName } : {}), ...(locale ? { locale } : {}) });\n const controller = resolved.spec?.controller;\n const [instance, setInstance] = useState({ controller, sequence: 0 });\n if (instance.controller !== controller) setInstance({ controller, sequence: instance.sequence + 1 });\n if (!resolved.spec) return <PmConnecting text={pmTextFor(locale)} problem={resolved.problem} retry={resolved.retry} close={close} className=\"flex-1\" />;\n return <PmChatSession key={instance.sequence} spec={resolved.spec} close={close} {...rest} />;\n}\n\nfunction PmChatSession({ spec, page = null, siblingApp, screen, preset = 'desktop', close, title }: Omit<PmChatProps, 'spec' | 'appName' | 'locale'> & { readonly spec: ImproveSpec }) {\n const pm = usePmPanel(spec, true, page, screen, { comment: preset === 'desktop' && siblingApp === undefined, sibling: siblingApp });\n return <div data-testid=\"pm-chat\" data-mode={pm.state.mode} className=\"flex min-h-0 min-w-0 flex-1 flex-col\"><PmWorkspace spec={spec} page={page} pm={pm} preset={preset} open close={close} beside={siblingApp !== undefined} title={title} /></div>;\n}\n\n/** `?pm=standalone`: only the PM, full frame, for a host's column beside the app's frame. Same origin and session as the app, no route of its own. */\nexport function PmStandalone({ spec, appName, locale, hostOrigins = PM_HOST_ORIGINS }: { readonly spec: ImproveProp; readonly appName: string; readonly locale?: string | undefined; readonly hostOrigins?: readonly string[] | undefined }) {\n usePmHostTheme(hostOrigins);\n return <main data-testid=\"pm-standalone\" className=\"app-shell flex h-dvh min-h-0 flex-col overflow-hidden bg-background text-foreground\">\n <PmChat spec={spec} appName={appName} siblingApp={appName} preset=\"touch\" title={window.parent === window ? undefined : 'PM'} {...(locale ? { locale } : {})} />\n </main>;\n}\n\n/** Wiring only: the regular agent page, with the existing point composer and a host Tasks panel. */\nfunction PmWorkspace({ spec, pm, page, preset, open, close, beside = false, title }: { readonly title?: string | undefined; readonly spec: ImproveSpec; readonly pm: ReturnType<typeof usePmPanel>; readonly page: HTMLElement | null; readonly preset: PmPreset; readonly open: boolean; readonly close?: (() => void) | undefined; readonly beside?: boolean }) {\n const { text } = pm;\n const files = spec.resources ? { resources: spec.resources } : { resources: undefined };\n // On a phone the chat gives the page its screen while pointing. A PM beside the app's frame covers nothing.\n const phone = usePhone() && !beside;\n const strip = pm.state.mode === 'point' || pm.state.mode === 'navigate';\n const root = useRef<HTMLDivElement | null>(null);\n const kinds = useMemo(() => spec.artifactKinds ?? defaultKinds({ locale: text.locale }), [spec.artifactKinds, text.locale]);\n const mentions = usePmMentions(spec.tasks, spec.chatProps?.mentions, text);\n const edit = useCallback((value: string) => { spec.controller.setText(value); }, [spec.controller]);\n const refreshTasks = spec.tasks?.refresh;\n useEffect(() => { if (open) refreshTasks?.(); }, [open, refreshTasks]);\n const seenKey = `boring.pm.ships.${JSON.stringify(pm.snapshot.identity)}`;\n const [seen, setSeen] = useState<readonly string[]>(() => { try { const value: unknown = JSON.parse(localStorage.getItem(seenKey) ?? '[]'); return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; } catch { return []; } });\n const ships = spec.tasks ? selectTasks(spec.tasks.tasks, { filter: 'mine', currentUser: spec.tasks.currentUser }).filter(task => !task.technical && requestOutcome(task) === 'live').map(task => `${task.id}:${task.updated_at ?? ''}`) : [];\n const tasksLabel = text.locale === 'fr' ? 'Tâches' : 'Tasks';\n // Beside the app in a host's frame, the host owns the one way to the tasks (Julien, 2026-10-31: no second icon).\n const barPanels: readonly WorkspaceBarPanel[] = beside ? [] : [{ id: 'tasks', label: tasksLabel, icon: ListTodoIcon, dot: spec.tasks?.unread ?? ships.some(ship => !seen.includes(ship)), onOpenChange: spec.tasks?.onOpenChange, onOpen: () => {\n refreshTasks?.(); setSeen(ships); try { localStorage.setItem(seenKey, JSON.stringify(ships)); } catch { /* viewer storage is optional */ }\n }, content: () => <div data-testid=\"pm-task-board-seam\" className=\"flex min-h-0 flex-1 flex-col\">{spec.tasks ? <>{spec.tasks.header}<PmRequests seam={spec.tasks} text={text} /></> : <p role=\"status\" className=\"p-4 text-sm text-muted-foreground\">{text.connectionFailed}</p>}</div> }];\n useEffect(() => {\n if (!pm.focusVersion || !open || strip) return;\n const input = root.current?.querySelector<HTMLElement>('[data-testid=composer-input]');\n input?.focus({ preventScroll: true });\n if (input instanceof HTMLTextAreaElement) input.setSelectionRange(input.value.length, input.value.length);\n }, [pm.focusVersion, open, strip, spec.controller]);\n const pointProps = { preset, text, selections: pm.state.chips, onRemove: pm.remove, show: pm.state.show, onShow: (value: boolean) => pm.dispatch({ type: 'show', value }),\n canPoint: pm.canPoint, onPoint: () => pm.dispatch({ type: 'point' }), onReturn: pm.returnToComposer, onOpenRecorder: () => pm.dispatch({ type: 'return' }), focusInput: pm.focusVersion > 0 && open && !strip, voice: pm.voice, meta: pm.meta, notice: pm.sent ? text.sentToPm : undefined };\n const uncertain = pm.snapshot.send.kind === 'unknown' ? pm.snapshot.send.attempt : undefined;\n const transcript = pm.voice.phase === 'processing' ? <p data-testid=\"pm-transcribing\" role=\"status\" className=\"m-0 px-2\">{text.locale === 'fr' ? 'Transcription…' : 'Transcribing…'}</p> : pm.liveText && <p role=\"status\" data-testid=\"pm-live-transcript\" className=\"m-0 px-2\">{pm.liveText}</p>;\n return <ChatTextProvider value={chatTextFor(text.locale)}><div ref={root} className=\"flex min-h-0 flex-1 flex-col\">\n <p role=\"status\" aria-live=\"polite\" data-testid=\"pm-announce\" className=\"sr-only\">{pm.announcement}</p>\n {pm.problem && <p role=\"alert\" className=\"m-0 px-4 text-sm\">{pm.problem}</p>}\n <div hidden={strip && phone} className={cn('min-h-0 flex-1 flex-col', strip && phone ? 'hidden' : 'flex')}>\n <AgentWorkspace layout=\"embedded\" title={title ?? spec.appName ?? spec.label ?? text.title} locale={text.locale} controller={spec.controller}\n conversationId={spec.conversationId ?? String(pm.snapshot.conversationId)} conversations={spec.conversations ?? spec.chatProps?.conversations}\n {...files} artifactKinds={kinds} interactive={PM_INTERACTIVE} autoOpen={Boolean(spec.resources)} barPanels={barPanels} barOptions={{ panelsFirst: true }}\n controls={close && <Button variant=\"ghost\" size=\"icon\" data-testid=\"pm-close\" aria-label={text.close} title={text.close} onClick={close}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n chat={{ ...spec.chatProps, ...(mentions ? { mentions } : {}),\n labels: { ...spec.chatProps?.labels, placeholder: `${text.placeholderBefore}↖${text.placeholderAfter}`, add: text.attach, send: text.send },\n onFiles: spec.chatProps?.onFiles ?? imageFiles, sendContext: pm.sendContext, replyError: spec.chatProps?.replyError ?? (() => text.failed),\n renderEntry: entry => pointMessage(entry, mentions, { preset, text }) ?? spec.chatProps?.renderEntry?.(entry),\n replyFooter: reply => { const entry = pm.snapshot.view?.entries.find(entry => String(entry.id) === reply.entryId); return <>{spec.chatProps?.replyFooter?.(reply)}{entry && spec.tasks?.renderRequest?.({ entry, edit })}</>; },\n renderComposer: composer => strip && phone ? null : strip ? <>{beside && pm.state.chips.length > 0 && <div data-testid=\"pm-beside-chips\" className=\"flex flex-wrap gap-1.5 pb-2\">{pm.state.chips.map(chip => <PointChipView key={chip.id} chip={chip} preset={preset} labels={text} onRemove={() => pm.remove(chip.id)} />)}</div>}<PmComposer variant=\"strip\" {...pointProps} /></> : <PmComposer {...pointProps} composer={pm.answerComposer({ ...composer, sendBlocked: composer.sendBlocked || pm.voice.phase === 'processing', ...(uncertain ? { text: uncertain.draft.text.split(POINT_PREFACE)[0]!.trimEnd(), disabled: true } : {}) })} transcript={transcript} /> }} />\n </div>\n {strip && phone && <PmComposer variant=\"strip\" {...pointProps} />}\n {pm.comment && page && <PointComment comment={pm.comment} frame={page} text={text} />}\n </div></ChatTextProvider>;\n}\n\n/** « Mes demandes »: the one board, the viewer's own tasks. The panel's width stacks it; the detail is where a request is answered or withdrawn. */\nfunction PmRequests({ seam, text }: { readonly seam: StudioTaskBoardSeam; readonly text: PmText }) {\n const [selected, setSelected] = useState<string | null>(null);\n const answer = (task: TaskRecord, _question: unknown, value: string) => seam.answer?.(task, value);\n return <TaskBoard tasks={seam.tasks} filter=\"mine\" currentUser={seam.currentUser} bar={false} title={text.requests} locale={text.locale} selectedTask={selected}\n onSelectedTaskChange={id => { setSelected(id); const task = seam.tasks.find(each => each.id === id); if (task) seam.onOpen(task); }}\n actions={task => requestOutcome(task) ? undefined : seam.cardActions?.(task)} onAnswer={answer} onOther={answer} className=\"min-h-0 flex-1\" />;\n}\n",
|
|
33
33
|
"type": "registry:component",
|
|
34
34
|
"target": "components/pm-panel/pm-panel.tsx"
|
|
35
35
|
},
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
61
|
"path": "registry/pm-panel/point-frames.ts",
|
|
62
|
-
"content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport { usePointPage } from './use-point-page';\nimport type { PointPick, PointPin } from './use-point-page';\nimport type { PrivacyPolicy } from '@hachej/boring-feedback/page';\nimport type { PmText } from './pm-text';\n\n// Pointing across two sibling frames of one host page: the PM in one (`?pm=standalone`), the app in the other, both on the app's origin\n// with the person's session. Delivery is `postMessage` restricted to this origin; a receiver also requires a sibling source, which keeps\n// one person's two tabs apart, and the same app name. The parent page, whatever its origin, reads and sends nothing here.\nconst CHANNEL = 'boring-pm-point';\ntype ToApp = { readonly type: 'probe' } | { readonly type: 'state'; readonly picking: boolean; readonly pins: readonly PointPin[] };\ntype ToPm = { readonly type: 'here' } | { readonly type: 'pick'; readonly pick: PointPick } | { readonly type: 'unpick'; readonly id: string } | { readonly type: 'problem'; readonly message: string } | { readonly type: 'done' };\n\nfunction siblings(): readonly Window[] {\n if (window.parent === window) return [];\n const frames = window.parent.frames, found: Window[] = [];\n for (let index = 0; index < frames.length; index++) { const frame = frames[index]; if (frame && frame !== window) found.push(frame); }\n return found;\n}\nconst post = (target: Window, app: string, message: ToApp | ToPm) => target.postMessage({ channel: CHANNEL, app, message }, window.location.origin);\nfunction received<T>(event: MessageEvent, app: string): { readonly message: T; readonly source: Window } | undefined {\n const source = event.source as Window | null;\n if (event.origin !== window.location.origin || !source || source === window || window.parent === window || source.parent !== window.parent) return undefined;\n const data = event.data as { channel?: unknown; app?: unknown; message?: T } | null;\n return data?.channel === CHANNEL && data.app === app && data.message ? { message: data.message, source } : undefined;\n}\n\ninterface PmSide {\n readonly enabled: boolean;\n readonly app: string;\n readonly picking: boolean;\n readonly pins: readonly PointPin[];\n readonly onPick: (pick: PointPick) => void;\n readonly onUnpick: (id: string) => void;\n readonly onProblem: (message: string) => void;\n readonly onDone: () => void;\n}\n\n/** In the PM frame: tells the sibling app frame what to do, takes its picks. The result says an app frame answered. */\nexport function useAppFrame({ enabled, app, picking, pins, onPick, onUnpick, onProblem, onDone }: PmSide): boolean {\n const [frame, setFrame] = useState<Window>();\n const latest = useRef({ picking, pins, onPick, onUnpick, onProblem, onDone });\n useLayoutEffect(() => { latest.current = { picking, pins, onPick, onUnpick, onProblem, onDone }; });\n useEffect(() => {\n if (!enabled) return;\n const listen = (event: MessageEvent) => {\n const got = received<ToPm>(event, app);\n if (!got) return;\n const { message, source } = got, current = latest.current;\n setFrame(source);\n if (message.type === 'here') post(source, app, { type: 'state', picking: current.picking, pins: current.pins });\n else if (message.type === 'pick') current.onPick(message.pick)
|
|
62
|
+
"content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport { usePointPage } from './use-point-page';\nimport type { PointPick, PointPin } from './use-point-page';\nimport type { PrivacyPolicy } from '@hachej/boring-feedback/page';\nimport type { PmText } from './pm-text';\n\n// Pointing across two sibling frames of one host page: the PM in one (`?pm=standalone`), the app in the other, both on the app's origin\n// with the person's session. Delivery is `postMessage` restricted to this origin; a receiver also requires a sibling source, which keeps\n// one person's two tabs apart, and the same app name. The parent page, whatever its origin, reads and sends nothing here.\nconst CHANNEL = 'boring-pm-point';\ntype ToApp = { readonly type: 'probe' } | { readonly type: 'state'; readonly picking: boolean; readonly pins: readonly PointPin[] };\ntype ToPm = { readonly type: 'here' } | { readonly type: 'pick'; readonly pick: PointPick } | { readonly type: 'unpick'; readonly id: string } | { readonly type: 'problem'; readonly message: string } | { readonly type: 'done' };\n\nfunction siblings(): readonly Window[] {\n if (window.parent === window) return [];\n const frames = window.parent.frames, found: Window[] = [];\n for (let index = 0; index < frames.length; index++) { const frame = frames[index]; if (frame && frame !== window) found.push(frame); }\n return found;\n}\nconst hostOrigin = () => window.location.ancestorOrigins?.[0] ?? (document.referrer ? new URL(document.referrer).origin : undefined);\n/** To the page holding the frames: no content, only that pointing started, stopped or landed. A phone shows one frame at a time and switches on this. */\nfunction tellHost(boring: 'pm-pointing' | 'pm-picked', picking?: boolean): void {\n const parent = hostOrigin();\n if (parent) window.parent.postMessage({ boring, ...(picking === undefined ? {} : { picking }) }, parent);\n}\nconst post = (target: Window, app: string, message: ToApp | ToPm) => target.postMessage({ channel: CHANNEL, app, message }, window.location.origin);\nfunction received<T>(event: MessageEvent, app: string): { readonly message: T; readonly source: Window } | undefined {\n const source = event.source as Window | null;\n if (event.origin !== window.location.origin || !source || source === window || window.parent === window || source.parent !== window.parent) return undefined;\n const data = event.data as { channel?: unknown; app?: unknown; message?: T } | null;\n return data?.channel === CHANNEL && data.app === app && data.message ? { message: data.message, source } : undefined;\n}\n\ninterface PmSide {\n readonly enabled: boolean;\n readonly app: string;\n readonly picking: boolean;\n readonly pins: readonly PointPin[];\n readonly onPick: (pick: PointPick) => void;\n readonly onUnpick: (id: string) => void;\n readonly onProblem: (message: string) => void;\n readonly onDone: () => void;\n}\n\n/** In the PM frame: tells the sibling app frame what to do, takes its picks. The result says an app frame answered. */\nexport function useAppFrame({ enabled, app, picking, pins, onPick, onUnpick, onProblem, onDone }: PmSide): boolean {\n const [frame, setFrame] = useState<Window>();\n const latest = useRef({ picking, pins, onPick, onUnpick, onProblem, onDone });\n useLayoutEffect(() => { latest.current = { picking, pins, onPick, onUnpick, onProblem, onDone }; });\n useEffect(() => {\n if (!enabled) return;\n const listen = (event: MessageEvent) => {\n // A host showing one frame at a time ends pointing once it comes back: « touch what you want » would point at nothing.\n if (event.source === window.parent && event.origin === hostOrigin() && (event.data as { boring?: unknown } | null)?.boring === 'pm-end-pointing') { latest.current.onDone(); return; }\n const got = received<ToPm>(event, app);\n if (!got) return;\n const { message, source } = got, current = latest.current;\n setFrame(source);\n if (message.type === 'here') post(source, app, { type: 'state', picking: current.picking, pins: current.pins });\n else if (message.type === 'pick') { current.onPick(message.pick); tellHost('pm-picked'); }\n else if (message.type === 'unpick') current.onUnpick(message.id);\n else if (message.type === 'problem') current.onProblem(message.message);\n else if (message.type === 'done') current.onDone();\n };\n window.addEventListener('message', listen);\n for (const sibling of siblings()) post(sibling, app, { type: 'probe' });\n return () => window.removeEventListener('message', listen);\n }, [enabled, app]);\n useEffect(() => { if (enabled && frame) post(frame, app, { type: 'state', picking, pins }); }, [enabled, frame, app, picking, pins]);\n useEffect(() => { if (enabled && window.parent !== window) tellHost('pm-pointing', picking); }, [enabled, picking]);\n useEffect(() => {\n if (!enabled || !frame) return;\n const release = () => post(frame, app, { type: 'state', picking: false, pins: [] });\n window.addEventListener('pagehide', release);\n return () => { window.removeEventListener('pagehide', release); release(); };\n }, [enabled, frame, app]);\n return Boolean(frame);\n}\n\nconst noPins: readonly PointPin[] = [];\n\n/** In the app frame: only the page half runs here, for the PM of a sibling frame. `onStart`: it began pointing, this frame's own PM steps aside. */\nexport function useSiblingPointing({ app, page, screen, policy, text, onStart }: { readonly app: string; readonly page: HTMLElement | null; readonly screen: string; readonly policy: PrivacyPolicy; readonly text: PmText; readonly onStart: () => void }): void {\n const [session, setSession] = useState<{ readonly source: Window; readonly picking: boolean; readonly pins: readonly PointPin[] }>();\n const starting = useRef(onStart);\n useLayoutEffect(() => { starting.current = onStart; });\n useEffect(() => {\n const listen = (event: MessageEvent) => {\n const got = received<ToApp>(event, app);\n if (!got) return;\n const { message, source } = got;\n if (message.type === 'probe') { post(source, app, { type: 'here' }); return; }\n if (message.picking) starting.current();\n setSession({ source, picking: message.picking, pins: message.pins });\n };\n window.addEventListener('message', listen);\n for (const sibling of siblings()) post(sibling, app, { type: 'here' });\n return () => window.removeEventListener('message', listen);\n }, [app]);\n const source = session?.source, picking = session?.picking ?? false;\n useEffect(() => {\n if (!source || !picking) return;\n // A removed PM frame may never get to say so: the page must not stay stuck picking.\n const timer = setInterval(() => { if (source.closed) setSession(undefined); }, 2000);\n const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape' && !event.isComposing) post(source, app, { type: 'done' }); };\n window.addEventListener('keydown', onKey);\n return () => { clearInterval(timer); window.removeEventListener('keydown', onKey); };\n }, [source, picking, app]);\n const send = (message: ToPm) => { if (source) post(source, app, message); };\n usePointPage({ picking, pins: session?.pins ?? noPins, page, screen, policy, text,\n onPick: pick => send({ type: 'pick', pick }), onUnpick: id => send({ type: 'unpick', id }), onProblem: message => send({ type: 'problem', message }) });\n}\n",
|
|
63
63
|
"type": "registry:hook",
|
|
64
64
|
"target": "components/pm-panel/point-frames.ts"
|
|
65
65
|
},
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
},
|
|
114
114
|
{
|
|
115
115
|
"path": "registry/pm-panel/use-improve-agent.ts",
|
|
116
|
-
"content": "'use client';\n\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport { useRemoteChat, useConversations } from '@hachej/boring-ui-kit/chat-react';\nimport { connectRevisionProvider } from '@hachej/boring-files/revision';\nimport type { RevisionProvider } from '@hachej/boring-files/revision';\nimport type { ChatIdentity } from '@hachej/boring-ui-kit/native-chat';\nimport type { ImproveSpec, PmTranscript } from './use-pm-panel';\nimport { useHostTasks } from './pm-task-source';\nimport { pmTextFor } from './pm-text';\n\n/**\n * The one line an app writes: `improve: { agent: 'pm' }`. The kit opens that agent's conversation through the app's own door\n * (hachej/boring-factory#854), same origin and as the signed-in person: `GET {base}/agent` (identity), `{base}/conversations`\n * (the viewer's latest, created when there is none) and `{base}/chat?conversation=` (the native chat wire). Dictation is the host's, not\n * the agent's: `POST api/host/transcribe?language=` (audio body, answers `{ text, segments? }`). The viewer's requests are the host's task\n * API, `_tasks/api` (see `pm-task-source.tsx`; a 404 or 403 is « unavailable for now », read again later). The files the PM presents\n * (`present`) and the read-only Library use the
|
|
116
|
+
"content": "'use client';\n\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport { useRemoteChat, useConversations } from '@hachej/boring-ui-kit/chat-react';\nimport { connectRevisionProvider } from '@hachej/boring-files/revision';\nimport type { RevisionProvider } from '@hachej/boring-files/revision';\nimport type { ChatIdentity } from '@hachej/boring-ui-kit/native-chat';\nimport type { ImproveSpec, PmTranscript } from './use-pm-panel';\nimport { useHostTasks } from './pm-task-source';\nimport { pmTextFor } from './pm-text';\n\n/**\n * The one line an app writes: `improve: { agent: 'pm' }`. The kit opens that agent's conversation through the app's own door\n * (hachej/boring-factory#854), same origin and as the signed-in person: `GET {base}/agent` (identity), `{base}/conversations`\n * (the viewer's latest, created when there is none) and `{base}/chat?conversation=` (the native chat wire). Dictation is the host's, not\n * the agent's: `POST api/host/transcribe?language=` (audio body, answers `{ text, segments? }`). The viewer's requests are the host's task\n * API, `_tasks/api` (see `pm-task-source.tsx`; a 404 or 403 is « unavailable for now », read again later). The files the PM presents\n * (`present`) and the read-only Library use the revision route its descriptor names (`resources.endpoint`).\n * Every path is relative to the page, like the app's own `api()` calls: the host may serve the app under a prefix (`/apps/<app>/`,\n * `/preview/<sha>/`).\n */\nexport interface ImproveAgent {\n readonly agent: string;\n /** Default `api/{agent}`, relative to the page; resolved against `document.baseURI` (so `/api/pm` stays at the origin's root). */\n readonly base?: string;\n}\nexport type ImproveProp = ImproveSpec | ImproveAgent;\nexport const isImproveAgent = (value: ImproveProp | undefined): value is ImproveAgent => !!value && 'agent' in value && typeof value.agent === 'string' && !('controller' in value);\n\n/** A path of the app, resolved like the app's own requests: against the page (`document.baseURI`), never the origin's root. */\nconst appUrl = (path: string) => new URL(path, document.baseURI).href;\n\ntype Door = { readonly identity: ChatIdentity; readonly conversationId: string; readonly files: RevisionProvider; readonly filesEndpoint: string } | { readonly error: string };\nconst sameOrigin = (request: Request) => fetch(new Request(request, { credentials: 'same-origin' }));\n\nasync function openDoor(path: string, signal: AbortSignal): Promise<Door> {\n const json = async (response: Response) => { if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); return response.json() as Promise<Record<string, unknown>>; };\n const base = appUrl(path);\n const descriptor = await json(await fetch(`${base}/agent`, { credentials: 'same-origin', signal }));\n const identity = (descriptor['identity'] ?? descriptor) as ChatIdentity;\n if (typeof identity?.runtimeId !== 'string' || typeof identity.scopeId !== 'string' || typeof identity.principalId !== 'string') throw new Error('The agent door returned no chat identity');\n const declared = (descriptor['resources'] as { readonly endpoint?: unknown } | undefined)?.endpoint;\n const filesEndpoint = typeof declared === 'string' ? appUrl(declared) : `${base}/resources`;\n const files = await connectRevisionProvider({ endpoint: filesEndpoint, fetch: sameOrigin, signal });\n const listed = await json(await fetch(`${base}/conversations`, { credentials: 'same-origin', signal }));\n const rows = (Array.isArray(listed['conversations']) ? listed['conversations'] : []) as { readonly id: string | number; readonly archived?: boolean; readonly updatedAt?: number }[];\n const latest = rows.filter(row => !row.archived).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];\n if (latest) return { identity, files, filesEndpoint, conversationId: String(latest.id) };\n const created = await json(await fetch(`${base}/conversations?op=create`, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: '{}', signal }));\n return { identity, files, filesEndpoint, conversationId: String(created['conversationId']) };\n}\n\n/** Resolves `{ agent }` once `active` (the first open), then keeps the controller. `undefined` while connecting or offline (`problem`). */\nexport function useImproveAgent(value: ImproveProp | undefined, active: boolean, defaults: { readonly appName?: string; readonly locale?: string }): { readonly spec: ImproveSpec | undefined; readonly problem: string | undefined; readonly retry: () => void } {\n const agent = isImproveAgent(value) ? value : undefined;\n const base = agent ? agent.base ?? `api/${encodeURIComponent(agent.agent)}` : undefined;\n const [door, setDoor] = useState<{ readonly base: string; readonly door: Door }>();\n const [requested, setRequested] = useState(false);\n const [attempt, setAttempt] = useState(0);\n const retry = useCallback(() => { setDoor(undefined); setAttempt(value => value + 1); }, []);\n if (active && agent && !requested) setRequested(true);\n useEffect(() => {\n if (!base || !requested) return;\n const abort = new AbortController();\n openDoor(base, AbortSignal.any([abort.signal, AbortSignal.timeout(15_000)])).then(result => setDoor({ base, door: result }), (error: unknown) => { if (!abort.signal.aborted) setDoor({ base, door: { error: error instanceof Error ? error.message : String(error) } }); });\n return () => abort.abort();\n }, [base, requested, attempt]);\n const open = door && door.base === base && 'identity' in door.door ? door.door : undefined;\n const [selected, setSelected] = useState<{ readonly base: string; readonly id: string }>();\n const select = useCallback((id: string) => { if (base) setSelected({ base, id }); }, [base]);\n const conversationId = selected?.base === base ? selected?.id : open?.conversationId;\n const conversations = useConversations({ endpoint: open && base ? appUrl(`${base}/conversations`) : undefined, fetch: sameOrigin, activeId: conversationId, onSelect: select });\n const remote = useRemoteChat({ conversationId, endpoint: id => new URL(`${appUrl(base ?? '')}/chat?conversation=${encodeURIComponent(String(id))}`), fetch: sameOrigin, identity: open?.identity });\n const ready = remote.status === 'ready' ? remote : undefined;\n const resources = useMemo(() => open && base ? { endpoint: open.filesEndpoint, fetch: sameOrigin, identity: open.files.identity } : undefined, [open, base]);\n const tasks = useHostTasks(open ? appUrl('_tasks/api') : undefined, open?.identity.principalId, ready?.controller, pmTextFor(defaults.locale));\n const spec = useMemo<ImproveSpec | undefined>(() => !agent ? value as ImproveSpec | undefined : ready ? {\n controller: ready.controller, ...(conversationId ? { conversationId } : {}), ...(conversations ? { conversations } : {}), ...(resources ? { resources } : {}), ...(defaults.appName ? { appName: defaults.appName } : {}), ...(tasks ? { tasks } : {}),\n chatProps: { actions: ready.actions, showHistory: false, ...(defaults.locale ? { locale: defaults.locale as 'fr' } : {}) },\n pointVoice: { transcribe: async (audio: Blob, signal: AbortSignal): Promise<string | PmTranscript> => {\n const response = await fetch(appUrl(`api/host/transcribe${defaults.locale ? `?language=${encodeURIComponent(defaults.locale)}` : ''}`), { method: 'POST', credentials: 'same-origin', headers: { 'content-type': audio.type || 'application/octet-stream' }, body: audio, signal });\n if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);\n const result = await response.json() as PmTranscript | { readonly text?: string };\n return typeof result.text === 'string' ? result as PmTranscript : '';\n } },\n } : undefined, [agent, value, ready, tasks, conversationId, conversations, resources, defaults.appName, defaults.locale]);\n const problem = door && 'error' in door.door ? door.door.error : remote.status === 'offline' ? remote.error : undefined;\n return { spec, problem, retry };\n}\n",
|
|
117
117
|
"type": "registry:hook",
|
|
118
118
|
"target": "components/pm-panel/use-improve-agent.ts"
|
|
119
119
|
}
|
package/r/provider-setup.json
CHANGED