@hachej/boring-ui-kit 0.4.44 → 0.4.46
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 +20 -15
- 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 +10 -10
- 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 +3 -3
- 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 +189 -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/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.46",
|
|
8
|
+
"@hachej/boring-feedback@^0.4.46",
|
|
9
|
+
"@hachej/boring-files@^0.4.46",
|
|
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.46/r/pi-app.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/pi-app-kinds.json",
|
|
18
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/pi-chat.json",
|
|
19
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/pi-chat-markdown.json",
|
|
20
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/pi-chat-plugins.json",
|
|
21
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/pi-chat-voice.json",
|
|
22
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/feedback.json",
|
|
23
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/task-board.json",
|
|
24
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/task-mentions.json",
|
|
25
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/utils.json",
|
|
26
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/pi-artifacts.json",
|
|
27
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/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 /** 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",
|
|
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 { isImproveAgent, 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\ninterface PmPanelConfig { readonly spec: ImproveProp; readonly hostOrigins?: readonly string[] | undefined }\nfunction PmShellProvider({ config, ...rest }: { readonly config: unknown; 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 return <PmPanelProvider spec={(config as PmPanelConfig).spec} {...rest} />;\n}\nfunction PmShellPage({ config, appName, locale }: { readonly config: unknown; readonly appName: string; readonly locale?: string | undefined }) {\n const { spec, hostOrigins } = config as PmPanelConfig;\n return <PmStandalone spec={spec} appName={appName} locale={locale} hostOrigins={hostOrigins} />;\n}\nconst PM_PAGE = { matches: () => new URLSearchParams(window.location.search).get('pm') === 'standalone', Page: PmShellPage };\n\n/** The product manager in `AppShell`'s `panel`: « Améliorer l'app » in the account menu, the PM panel and the point layer; `?pm=standalone` shows only the PM. One line: `pmPanel({ agent: 'pm' })` (the kit opens the app's PM door), or a host `ImproveSpec`. `hostOrigins`: the parent origins that may give the standalone PM their tokens (default: `PM_HOST_ORIGINS`, Studio). */\nexport function pmPanel(spec: ImproveProp, options?: { readonly hostOrigins?: readonly string[] | undefined }) {\n const host = isImproveAgent(spec) ? undefined : spec;\n return { config: { spec, hostOrigins: options?.hostOrigins } satisfies PmPanelConfig, Provider: PmShellProvider, Layout: ImproveLayout, page: PM_PAGE, locale: host?.chatProps?.locale, identity: host?.controller };\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,13 +59,13 @@
|
|
|
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}\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 =
|
|
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
|
},
|
|
66
66
|
{
|
|
67
67
|
"path": "registry/pm-panel/pm-host-theme.ts",
|
|
68
|
-
"content": "'use client';\n\nimport { useEffect } from 'react';\n\n/** The parents whose style a standalone PM takes without any app change. A preview host is added with `
|
|
68
|
+
"content": "'use client';\n\nimport { useEffect } from 'react';\n\n/** The parents whose style a standalone PM takes without any app change. A preview host is added with `pmPanel`'s `hostOrigins`. */\nexport const PM_HOST_ORIGINS: readonly string[] = ['https://studio.senecaapp.ai'];\n\nconst TOKEN = /^--(?:background|foreground|card(?:-foreground)?|popover(?:-foreground)?|primary(?:-foreground)?|secondary(?:-foreground)?|muted(?:-foreground)?|accent(?:-foreground)?|destructive|border|input|ring|radius|font-sans|pm-[a-z0-9-]+)$/;\nconst VALUE = /^[\\w\\s#%.,()/+'\"-]{1,160}$/;\nconst FORBIDDEN = /url|image|expression|attr|src|\\/\\*/i;\n\n/** A host's token set, kept only where the name is a known theme token and the value cannot load, run or escape anything. */\nexport function acceptedTokens(tokens: unknown): readonly (readonly [string, string])[] {\n if (!tokens || typeof tokens !== 'object') return [];\n return Object.entries(tokens).filter((entry): entry is [string, string] => {\n const [name, value] = entry;\n return TOKEN.test(name) && typeof value === 'string' && VALUE.test(value) && !FORBIDDEN.test(value);\n });\n}\n\nfunction applyTheme(theme: unknown) {\n if (theme !== 'light' && theme !== 'dark') return;\n document.documentElement.classList.toggle('dark', theme === 'dark');\n document.documentElement.style.colorScheme = theme;\n}\n\n/** `?theme=light|dark`, then `{ boring: 'pm-theme', theme?, tokens? }` at any time from an allowlisted parent. Without either the app's tokens stand. */\nexport function usePmHostTheme(origins: readonly string[]): void {\n const key = origins.join(' ');\n useEffect(() => {\n applyTheme(new URLSearchParams(window.location.search).get('theme'));\n if (window.parent === window) return;\n const allowed = key.split(' ');\n const listen = (event: MessageEvent) => {\n const data = event.data as { boring?: unknown; theme?: unknown; tokens?: unknown } | null;\n if (event.source !== window.parent || !allowed.includes(event.origin) || data?.boring !== 'pm-theme') return;\n applyTheme(data.theme);\n for (const [name, value] of acceptedTokens(data.tokens)) document.documentElement.style.setProperty(name, value);\n };\n window.addEventListener('message', listen);\n // The parent may be waiting for this frame before it sends its look.\n const parent = window.location.ancestorOrigins?.[0] ?? (document.referrer ? new URL(document.referrer).origin : undefined);\n if (parent && allowed.includes(parent)) window.parent.postMessage({ boring: 'pm-ready' }, parent);\n return () => window.removeEventListener('message', listen);\n }, [key]);\n}\n",
|
|
69
69
|
"type": "registry:hook",
|
|
70
70
|
"target": "components/pm-panel/pm-host-theme.ts"
|
|
71
71
|
},
|
package/r/provider-setup.json
CHANGED