@hachej/boring-ui-kit 0.4.42 → 0.4.43
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 +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 +185 -185
- package/r/requests-board.json +1 -1
- package/r/shared-chat.json +6 -6
- package/r/task-board.json +4 -4
- 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/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.43/r/pi-app.json",
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/pi-ambient.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/app-shell.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/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.43",
|
|
8
|
+
"@hachej/boring-feedback@^0.4.43",
|
|
9
|
+
"@hachej/boring-files@^0.4.43",
|
|
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.43/r/pi-app.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/pi-app-kinds.json",
|
|
18
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/pi-chat.json",
|
|
19
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/pi-chat-markdown.json",
|
|
20
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/pi-chat-plugins.json",
|
|
21
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/pi-chat-voice.json",
|
|
22
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/feedback.json",
|
|
23
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/task-board.json",
|
|
24
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/task-mentions.json",
|
|
25
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/utils.json",
|
|
26
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/pi-artifacts.json",
|
|
27
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/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 { ChevronDownIcon, ListTodoIcon, XIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\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 { Markdown } from '../pi-chat-markdown/markdown';\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 { TaskDetail, TaskRequestCard, taskTokens } 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 { PmRequestStatus, 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=\"min-h-0 flex-1 overflow-auto\">{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\nfunction PmRequests({ seam, text }: { readonly seam: StudioTaskBoardSeam; readonly text: PmText }) {\n const [expanded, setExpanded] = useState(false);\n const [technical, setTechnical] = useState(false);\n const [selected, setSelected] = useState<string>();\n const tasks = selectTasks(seam.tasks, { filter: 'mine', currentUser: seam.currentUser });\n const visible = tasks.filter(task => !task.technical);\n const open = visible.filter(task => requestOutcome(task) === undefined).sort((a, b) => Number(b.waiting_on_you === true) - Number(a.waiting_on_you === true));\n const finished = visible.filter(task => requestOutcome(task) !== undefined);\n const internal = tasks.filter(task => task.technical);\n const detail = tasks.find(task => task.id === selected);\n const openTask = (task: TaskRecord) => { setSelected(task.id); seam.onOpen(task); };\n const label = { live: text.outcomeLive, withdrawn: text.outcomeWithdrawn, closed: text.outcomeClosed } as const;\n const card = (task: TaskRecord) => {\n const outcome = requestOutcome(task);\n return <TaskRequestCard key={task.id} task={task} variant=\"compact\" locale={text.locale} href=\"#\" onOpen={openTask}\n actions={outcome ? undefined : seam.cardActions?.(task)} pill={outcome\n ? <span className={cn('contents', taskTokens.pill)}><Badge variant={outcome === 'live' ? 'secondary' : 'outline'} data-task-outcome={outcome}>{label[outcome]}</Badge></span>\n : <PmRequestStatus task={task} text={text} />} />;\n };\n if (detail) return <div className=\"flex flex-col gap-4 p-4\">\n <Button variant=\"ghost\" data-testid=\"pm-request-back\" className=\"self-start\" onClick={() => setSelected(undefined)}>{text.back}</Button>\n <TaskDetail task={detail} actions={requestOutcome(detail) ? undefined : seam.cardActions?.(detail)} />\n {detail.spec && <Markdown text={detail.spec} />}\n {detail.history?.map((item, index) => <p key={item.id ?? index} className=\"m-0 text-sm\">{item.note}</p>)}\n </div>;\n return <div className=\"flex flex-col gap-4 p-4\">\n <section data-boring=\"task-board\" className=\"flex flex-col gap-3\" aria-label={text.requests}>\n <h3 className=\"m-0 text-sm font-semibold\">{text.requests}</h3>\n {([true, false] as const).map(waiting => {\n const group = open.filter(task => (task.waiting_on_you === true) === waiting);\n return group.length > 0 && <section key={String(waiting)} className=\"flex flex-col gap-2\" aria-label={waiting ? text.awaiting : text.inProgress}>\n <h4 className=\"m-0 text-xs text-muted-foreground\">{waiting ? text.awaiting : text.inProgress}</h4>\n {group.map(card)}\n </section>;\n })}\n </section>\n {finished.length > 0 && <section data-testid=\"pm-finished\" aria-label={text.finished(finished.length)} className=\"flex flex-col gap-2\">\n <Button variant=\"ghost\" data-testid=\"pm-finished-toggle\" aria-expanded={expanded} aria-controls=\"pm-finished-list\" onClick={() => setExpanded(!expanded)}\n className=\"min-h-11 justify-between px-1 text-sm font-semibold text-muted-foreground\">\n {text.finished(finished.length)}<ChevronDownIcon aria-hidden=\"true\" className={cn('size-4 transition-transform', expanded && 'rotate-180')} />\n </Button>\n {expanded && <div id=\"pm-finished-list\" role=\"list\" aria-label={text.finished(finished.length)} className=\"flex flex-col gap-2\">{finished.map(card)}</div>}\n </section>}\n {internal.length > 0 && <section className=\"flex flex-col gap-2\">\n <Button variant=\"ghost\" data-testid=\"pm-technical-toggle\" aria-expanded={technical} onClick={() => setTechnical(!technical)} className=\"min-h-11 justify-between\">{text.technical} ({internal.length})<ChevronDownIcon aria-hidden=\"true\" /></Button>\n {technical && <div role=\"list\" className=\"flex flex-col gap-2\">{internal.map(card)}</div>}\n </section>}\n </div>;\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}\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",
|
|
33
33
|
"type": "registry:component",
|
|
34
34
|
"target": "components/pm-panel/pm-panel.tsx"
|
|
35
35
|
},
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
"path": "registry/pm-panel/use-pm-panel.ts",
|
|
44
|
-
"content": "'use client';\n\nimport { useCallback, useEffect, useId, useLayoutEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from 'react';\nimport { createPrivacyPolicy } from '@hachej/boring-feedback/page';\nimport type { PrivacyPolicy, SpeechSegment } from '@hachej/boring-feedback/page';\nimport { useVoiceAttempt } from '@hachej/boring-ui-kit/chat-react/voice';\nimport { appendToDraft, voiceRefusalLabel } from '@hachej/boring-ui-kit/chat-text';\nimport type { VoiceMessageHandle, VoiceMessageConfig } from '../pi-chat/config';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\nimport type { ComposerProps } from '../pi-chat/composer';\nimport { derive, waitingPart } from '@hachej/boring-ui-kit/chat-view';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { frenchChatLabels } from '../pi-chat/labels-fr';\nimport type { PointChip } from './point-state';\nimport { alignedTranscript, createPointState, marker, pointReducer, renumberText } from './point-state';\nimport { pmTextFor } from './pm-text';\nimport { useAppFrame } from './point-frames';\nimport { usePins, usePointPage } from './use-point-page';\nimport type { PointPick, PointPin } from './use-point-page';\nimport { POINT_PREFACE } from './point-message';\n\nexport interface PmTranscript { readonly text: string; readonly segments?: readonly SpeechSegment[] }\n/** Optional host voice enrichment. Authorization, retention and transcription remain with the host. */\nexport interface PmVoice {\n readonly transcribe: (audio: Blob, signal: AbortSignal) => Promise<string | PmTranscript>;\n readonly maxMs?: number;\n /** Optional live text with timings. The returned cleanup releases only this capture's listener. */\n readonly live?: (signal: AbortSignal, update: (transcript: PmTranscript) => void) => (() => void);\n}\nexport interface ImproveSpec {\n readonly controller: NativeChatController;\n readonly chatProps?: Omit<PiChatProps, 'controller'>;\n /** Existing agent-page composition: History and the PM's read-only resource Library. */\n readonly conversations?: import('../pi-chat/config').ConversationsConfig;\n readonly conversationId?: string;\n readonly resources?: import('../pi-app/agent-workspace').WorkspaceResources;\n readonly artifactKinds?: readonly import('../pi-app/viewer-kinds').PanelKind[];\n readonly label?: string;\n /** The app's name in the full panel's subtitle: « Le chef de projet · {appName} ». */\n readonly appName?: string;\n readonly voice?: VoiceMessageConfig & { readonly send?: 'auto' | 'draft' | 'message' };\n readonly feedback?: import('@hachej/boring-feedback/ui').FeedbackSession;\n readonly policy?: PrivacyPolicy;\n readonly pointVoice?: PmVoice;\n readonly tasks?: StudioTaskBoardSeam;\n}\n/**\n * The app's task board (`task-board` item): the host's authorized records. The panel draws « Mes demandes » with `TaskBoard` and the @\n * picker with `task-mentions` from them; it owns no card, board or task store.\n */\nexport interface StudioTaskBoardSeam {\n readonly tasks: readonly import('@hachej/boring-ui-kit/task-board').TaskRecord[];\n /** The viewer: « Mes demandes » and @ show the tasks they requested (`requesterId`). */\n readonly currentUser: string;\n /** Host-owned news or notices above the existing request list; no second task store. */\n readonly header?: import('react').ReactNode;\n /** Host-projected unread news; when given, owns the Tasks icon's dot. */\n readonly unread?: boolean;\n /** Opening/closing the Tasks panel is observation only; the host owns any exact-ID acknowledgement. */\n readonly onOpenChange?: (open: boolean) => void;\n /** Opens a task: the @ chip, a card in « Mes demandes ». */\n readonly onOpen: (task: import('@hachej/boring-ui-kit/task-board').TaskRecord) => void;\n /** Host-authorized compact card actions in « Mes demandes » (`TaskBoard cardActions`). */\n readonly cardActions?: (task: import('@hachej/boring-ui-kit/task-board').TaskRecord) => import('react').ReactNode;\n /** The request card under a PM reply (C7, `TaskRequestCard variant=\"compact\"`), or undefined when that reply carries no request. `edit` puts the request text back in the composer (« Modifier »). */\n readonly renderRequest?: (context: { readonly entry: import('@earendil-works/pi-durable').EntryRecord; readonly edit: (text: string) => void }) => import('react').ReactNode;\n /** Reads the host's records again: the app panel calls it when it opens and when « Mes demandes » opens. */\n readonly refresh?: () => void;\n}\n\ninterface CaptureBatch {\n readonly context: object;\n readonly controller: NativeChatController;\n readonly ids: readonly string[];\n readonly text: string;\n readonly speech: PmTranscript;\n readonly priorRequestId: string | undefined;\n requestId?: string;\n}\nconst defaultPolicy = createPrivacyPolicy();\nconst NOTHING = new Error('nothing heard');\nconst noPins: readonly PointPin[] = [];\nfunction keepMarkers(controller: NativeChatController, chips: readonly PointChip[]) {\n const text = controller.getSnapshot().draft.text;\n const missing = chips.filter(chip => !text.includes(marker(chip.number))).map(chip => marker(chip.number)).join(' ');\n if (missing) controller.setText(appendToDraft(text, missing));\n}\n\nconst ADD_TO_MESSAGE = 'boring-ui.pm.add-to-message';\nconst readAddToMessage = () => { try { return localStorage.getItem(ADD_TO_MESSAGE) === '1'; } catch { return false; } };\n\n/** The open click-to-comment popover (spec 1 §3b): the pick it comments on, the field and the two ways out. */\nexport interface PmComment {\n readonly chip: PointChip;\n readonly element: Element;\n readonly text: string;\n readonly setText: (value: string) => void;\n readonly addToMessage: boolean;\n readonly setAddToMessage: (value: boolean) => void;\n /** Enter or the send button: straight to the PM, or into the draft when « Ajouter au message » is checked. */\n readonly submit: () => void;\n /** A click outside: an empty field cancels the pick, text goes into the draft. */\n readonly outside: () => void;\n readonly transcribe: ((audio: Blob, signal: AbortSignal) => Promise<string>) | undefined;\n}\n\n/** `comment`: a click in `point.idle` opens the comment popover at the pin (the desktop preset, spec 1 §3b). `sibling`: the app is in a sibling frame under this name, and picks there come back as chips. */\nexport function usePmPanel(spec: ImproveSpec, open: boolean, page: HTMLElement | null, screen = (typeof document === 'undefined' ? '' : document.title) || 'app', { comment: commenting = false, sibling }: { readonly comment?: boolean; readonly sibling?: string | undefined } = {}) {\n const text = pmTextFor(spec.chatProps?.locale);\n const [announcement, setAnnouncement] = useState('');\n const [state, dispatch] = useReducer(pointReducer, undefined, createPointState);\n const [problem, setProblem] = useState<string>();\n const [live, setLive] = useState<PmTranscript>({ text: '' });\n const [dictated, setDictated] = useState<number>();\n const [speech, setSpeech] = useState<PmTranscript>({ text: '' });\n const [focusVersion, setFocusVersion] = useState(0);\n const latest = useRef({ spec, state, open, dictated, speech });\n const mounted = useRef(false), pendingBatch = useRef<CaptureBatch | undefined>(undefined), batchSequence = useRef(0);\n const batchPrefix = useId();\n useLayoutEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\n useLayoutEffect(() => { latest.current = { spec, state, open, dictated, speech }; });\n const snapshot = useSyncExternalStore(spec.controller.subscribe, spec.controller.getSnapshot, spec.controller.getSnapshot);\n const [answering, setAnswering] = useState(false);\n const pendingAnswer = useRef(false);\n const question = useMemo(() => {\n const part = waitingPart(derive(snapshot.view, { mode: 'expert', renderEntry: undefined, renderTool: undefined, groupTool: undefined }).rows);\n return part?.kind === 'question' ? part : undefined;\n }, [snapshot.view]);\n // In a PM conversation the plain composer answers its pending native question, rather than queuing behind the durable wait.\n const answerComposer = (composer: ComposerProps): ComposerProps => {\n const answer = spec.chatProps?.actions?.answer;\n if (!question?.questionId || !answer || snapshot.draft.attachments.length || state.chips.length) return composer;\n const id = question.questionId;\n return { ...composer, working: false, sendBlocked: composer.sendBlocked || answering, onSend: () => {\n const value = spec.controller.getSnapshot().draft.text;\n if (!value.trim() || pendingAnswer.current || composer.sendBlocked) return;\n pendingAnswer.current = true; setAnswering(true); setProblem(undefined);\n let timer: ReturnType<typeof setTimeout>;\n const timeout = new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error('Answer timed out')), 15_000); });\n void Promise.race([Promise.resolve().then(() => answer(id, value.trim())), timeout]).then(outcome => {\n if (outcome.kind !== 'answered') throw new Error('Answer refused');\n if (latest.current.spec.controller === spec.controller && spec.controller.getSnapshot().draft.text === value) spec.controller.setText('');\n }).catch(() => { if (mounted.current && latest.current.spec.controller === spec.controller) setProblem(text.answerFailed); })\n .finally(() => { clearTimeout(timer); pendingAnswer.current = false; if (mounted.current) setAnswering(false); });\n } };\n };\n const policy = spec.policy ?? defaultPolicy;\n const voiceConfig = spec.pointVoice ?? spec.voice;\n const core = useVoiceAttempt<{ readonly controller: NativeChatController; readonly config: PmVoice; readonly offset: number; readonly priorSpeech: PmTranscript }>({\n valid: item => latest.current.open && latest.current.spec.controller === item.controller,\n refused: refusal => setProblem(frenchChatLabels[voiceRefusalLabel[refusal.code ?? 'failed']]),\n });\n const coreRef = useRef(core);\n useLayoutEffect(() => { coreRef.current = core; });\n // Esc = « ✓ Terminer » while the strip shows (spec 1 §1): it keeps and transcribes the recording. Registered in a layout effect at mount, so it\n // runs before the voice attempt's own Escape (which cancels a plain dictation) and the picker's; elsewhere Escape keeps their meaning.\n const pointingRef = useRef(false), returnRef = useRef<() => void>(() => {}), removeRef = useRef<(id: string) => void>(() => {});\n // The open comment popover: Esc cancels its pick first; the strip's × is the second Esc.\n const commentRef = useRef<{ readonly id: string; readonly cancel: () => void; readonly outside: () => void } | undefined>(undefined);\n useLayoutEffect(() => {\n const onKey = (event: KeyboardEvent) => {\n if (event.key !== 'Escape' || event.isComposing || !pointingRef.current) return;\n event.preventDefault(); event.stopImmediatePropagation();\n if (commentRef.current) commentRef.current.cancel(); else returnRef.current();\n };\n window.addEventListener('keydown', onKey, { capture: true });\n return () => window.removeEventListener('keydown', onKey, { capture: true });\n }, []);\n const item = core.attempts[0];\n const recording = item?.phase === 'recording' || item?.phase === 'starting';\n const cancel = useCallback(() => {\n const attempt = coreRef.current.current()[0], current = latest.current;\n if (attempt?.controller === current.spec.controller) keepMarkers(attempt.controller, current.state.chips);\n coreRef.current.clear(); setLive({ text: '' });\n }, []);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a close releases this viewer's microphone and live transcript once, never native work\n useEffect(() => { if (!open) cancel(); }, [open, cancel]);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a new borrowed controller fences and releases the old viewer's capture once\n useEffect(() => { dispatch({ type: 'return' }); cancel(); }, [spec.controller, cancel]);\n useEffect(() => { if (open) dispatch({ type: 'return' }); }, [open]);\n useEffect(() => {\n if (!item || !item.config.live) return;\n return item.config.live(item.abort.signal, transcript => { if (coreRef.current.valid(item)) setLive(transcript); });\n }, [item]);\n const start = useCallback(() => {\n const current = latest.current, api = coreRef.current;\n const config = current.spec.pointVoice ?? current.spec.voice;\n if (!config || api.current().length || !current.open) return;\n setProblem(undefined); setLive({ text: '' });\n api.start({ controller: current.spec.controller, config, offset: current.dictated ?? 0, priorSpeech: current.speech }, config.maxMs);\n }, []);\n const finish = useCallback(() => {\n const api = coreRef.current, attempt = api.current()[0];\n if (!attempt) return;\n dispatch({ type: 'return' });\n if (attempt.phase === 'processing') return;\n if (attempt.phase === 'starting') { keepMarkers(attempt.controller, latest.current.state.chips); api.remove(attempt); setFocusVersion(value => value + 1); return; }\n void (async () => {\n try {\n const audio = await api.stop(attempt);\n if (!api.valid(attempt)) return;\n if (!audio) throw NOTHING;\n const result = await attempt.config.transcribe(audio.audio, attempt.abort.signal);\n if (!api.valid(attempt)) return;\n const transcript = typeof result === 'string' ? { text: result } : result;\n if (!transcript.text.trim()) throw NOTHING;\n const current = latest.current;\n const typed = attempt.controller.getSnapshot().draft.text;\n const timed = current.state.chips.filter(chip => !typed.includes(marker(chip.number))).map(chip => ({ ...chip, at: chip.at === null ? null : Math.max(0, chip.at - attempt.offset) }));\n const words = alignedTranscript(transcript.text.trim(), transcript.segments, timed);\n attempt.controller.setText(appendToDraft(attempt.controller.getSnapshot().draft.text, words));\n const segments = transcript.segments?.map(segment => ({ ...segment, start: segment.start + attempt.offset, end: segment.end + attempt.offset,\n ...(segment.words ? { words: segment.words.map(word => ({ ...word, start: word.start + attempt.offset, end: word.end + attempt.offset })) } : {}) }));\n setDictated(attempt.offset + (audio.endedAt - audio.startedAt) / 1000);\n setSpeech({ text: appendToDraft(attempt.priorSpeech.text, transcript.text), segments: [...(attempt.priorSpeech.segments ?? []), ...(segments ?? [])] }); setLive({ text: '' }); setFocusVersion(value => value + 1); dispatch({ type: 'return' });\n } catch (error) {\n if (!api.valid(attempt)) return;\n // Nothing said: the picks (if any) become chips, the draft is otherwise untouched, and that is not a failure.\n keepMarkers(attempt.controller, latest.current.state.chips); setFocusVersion(value => value + 1);\n if (error !== NOTHING) setProblem('La transcription a échoué. Vos éléments et votre texte sont conservés.');\n }\n finally { if (api.valid(attempt)) api.remove(attempt); }\n })();\n }, []);\n const voice: VoiceMessageHandle = {\n canStart: Boolean(voiceConfig) && open && snapshot.connection.kind === 'connected' && snapshot.send.kind !== 'unknown' && snapshot.send.kind !== 'submitting' && snapshot.send.kind !== 'validating', phase: item?.phase ?? 'idle', seconds: item?.seconds ?? 0,\n captureSequence: core.captureSequence, levels: item?.levels ?? [], paused: item?.paused ?? false, pending: [], problem,\n start, finish, cancel,\n ...(item?.canPause ? { pause: () => core.pause(item), resume: () => core.resume(item) } : {}),\n };\n const [commentAt, setCommentAt] = useState<{ readonly id: string; readonly element: Element }>();\n const [commentText, setCommentText] = useState('');\n const [addToMessage, setAddToMessageState] = useState(readAddToMessage);\n const [sentNotice, setSentNotice] = useState(0);\n const commentingRef = useRef(commenting);\n useLayoutEffect(() => { commentingRef.current = commenting; });\n const anchors = useRef(new Map<string, import('@hachej/boring-feedback/page').AppElementAnchor>());\n const receivePick = (pick: PointPick, element?: Element) => {\n const number = latest.current.state.nextNumber, controller = latest.current.spec.controller;\n controller.setAttachments([...controller.getSnapshot().draft.attachments, { id: pick.id, name: `${marker(number)} @ ${pick.kind} · ${pick.label} · capture jointe`, content: { type: 'image', ...pick.image } }]);\n anchors.current.set(pick.id, pick.anchor);\n const attempt = coreRef.current.current()[0];\n const recordingNow = attempt?.phase === 'recording' || attempt?.phase === 'starting';\n const at = recordingNow ? (attempt.offset ?? 0) + (attempt.capture.progress()?.elapsedMs ?? 0) / 1000 : null;\n dispatch({ type: 'pick', chip: { id: pick.id, kind: pick.kind, label: pick.label, screen: pick.screen, at, box: pick.box } });\n // While recording the voice is the comment: the click only pins.\n if (element && commentingRef.current && !recordingNow) { setCommentText(''); setCommentAt({ id: pick.id, element }); }\n setAnnouncement(text.added(number, pick.kind, pick.label));\n setProblem(undefined);\n };\n const picking = open && state.show && state.mode === 'point';\n const pins = usePins(state.chips, open && state.mode !== 'full');\n usePointPage({ picking: picking && !sibling, pins: sibling ? noPins : pins, page, screen, policy, text,\n // With the comment popover open, a click on the page is a click outside it: it settles that pick and picks nothing.\n intercept: () => { if (!commentRef.current) return false; commentRef.current.outside(); return true; },\n onPick: receivePick, onUnpick: id => removeRef.current(id), onProblem: setProblem });\n const appFrame = useAppFrame({ enabled: sibling !== undefined, app: sibling ?? '', picking, pins, onPick: receivePick, onUnpick: id => removeRef.current(id), onProblem: setProblem, onDone: () => returnRef.current() });\n // Messages sent from the popover: the app panel stays on the strip when they are admitted (no C7).\n const quietTexts = useRef(new Set<string>());\n useEffect(() => {\n if (!sentNotice) return;\n const timer = setTimeout(() => setSentNotice(0), 3000);\n return () => clearTimeout(timer);\n }, [sentNotice]);\n // Native attachment remove controls also remove the corresponding marker and pin.\n useEffect(() => {\n for (const chip of state.chips) if (!pendingBatch.current?.ids.includes(chip.id) && !snapshot.draft.attachments.some(attachment => attachment.id === chip.id) && snapshot.send.kind !== 'submitting' && snapshot.send.kind !== 'unknown' && snapshot.send.kind !== 'validating') {\n dispatch({ type: 'remove', id: chip.id }); anchors.current.delete(chip.id);\n }\n }, [snapshot.draft.attachments, snapshot.send.kind, state.chips]);\n const completeBatch = useCallback((batch: CaptureBatch) => {\n if (!mounted.current || latest.current.spec.controller !== batch.controller || pendingBatch.current !== batch) return;\n pendingBatch.current = undefined;\n dispatch({ type: 'sent', ids: batch.ids });\n if (latest.current.speech === batch.speech) { setDictated(undefined); setSpeech({ text: '' }); }\n for (const id of batch.ids) anchors.current.delete(id);\n }, []);\n const sendContext = useMemo(() => {\n const context = {};\n return {\n count: state.chips.length || (dictated !== undefined ? 1 : 0),\n attach: (text: string) => {\n if (pendingBatch.current) return Promise.resolve({ kind: 'refused' as const, reason: 'Vérifiez d’abord le message en cours.' });\n const payload = { format: 'pm.points@1', captureBatch: `${batchPrefix}-${++batchSequence.current}`, dictatedSeconds: dictated, dictatedMs: dictated === undefined ? undefined : Math.round(dictated * 1000),\n transcriptSegments: speech.segments ?? [], alignment: speech.segments?.some(segment => segment.words?.length) ? 'word' : speech.segments?.length ? 'segment' : 'unavailable',\n policy: { version: policy.version, widened: policy.widened }, selections: state.chips.map(({ id, number, kind, label, screen, at }) => ({ n: number, kind, label, screen,\n atMs: at === null ? null : Math.round(at * 1000), t: at === null ? null : Math.round(at * 1000), selector: anchors.current.get(id)?.signals.path.join(' > '), screenshot: { imageIndex: snapshot.draft.attachments.findIndex(image => image.id === id), mimeType: snapshot.draft.attachments.find(image => image.id === id)?.content.mimeType }, anchor: anchors.current.get(id) })) };\n const attached = appendToDraft(text, `${POINT_PREFACE}${JSON.stringify(payload)}`);\n const selected: CaptureBatch = { context, controller: spec.controller, ids: state.chips.map(chip => chip.id), text: attached, speech,\n priorRequestId: 'attempt' in snapshot.send ? snapshot.send.attempt.requestId : undefined };\n pendingBatch.current = selected;\n return Promise.resolve({ kind: 'ok' as const, text: attached });\n },\n sent: () => { const selected = pendingBatch.current; if (selected?.context === context) completeBatch(selected); },\n };\n }, [state.chips, dictated, policy, speech, snapshot.draft.attachments, snapshot.send, spec.controller, batchPrefix, completeBatch]);\n // Reconciliation observes the original native request; it is never another send or a second admission authority.\n useEffect(() => {\n const batch = pendingBatch.current;\n if (!batch || batch.controller !== spec.controller) return;\n if ('attempt' in snapshot.send && snapshot.send.attempt.draft.text === batch.text && snapshot.send.attempt.requestId !== batch.priorRequestId) {\n batch.requestId ??= snapshot.send.attempt.requestId;\n if (snapshot.send.kind === 'admitted' && batch.requestId === snapshot.send.attempt.requestId) completeBatch(batch);\n } else if (snapshot.send.kind === 'blocked') pendingBatch.current = undefined;\n }, [snapshot.send, spec.controller, completeBatch]);\n const liveText = recording ? alignedTranscript(live.text, live.segments, state.chips.filter(chip => !snapshot.draft.text.includes(marker(chip.number))).map(chip => ({ ...chip, at: chip.at === null ? null : Math.max(0, chip.at - (item?.offset ?? 0)) }))) : '';\n const remove = (id: string) => {\n const chip = state.chips.find(value => value.id === id);\n if (!chip) return;\n const draft = spec.controller.getSnapshot().draft;\n spec.controller.setText(renumberText(draft.text, chip.number));\n // Attachment names carry the marker too: they follow the renumbering.\n spec.controller.setAttachments(draft.attachments.filter(image => image.id !== id).map(image => {\n const other = state.chips.find(value => value.id === image.id);\n return other && other.number > chip.number ? { ...image, name: image.name.replace(marker(other.number), marker(other.number - 1)) } : image;\n }));\n dispatch({ type: 'remove', id }); anchors.current.delete(id);\n setAnnouncement(text.removed(chip.number));\n };\n const commentId = commentAt?.id;\n const commentChip = commentId ? state.chips.find(chip => chip.id === commentId) : undefined;\n const closeComment = () => { commentRef.current = undefined; setCommentAt(undefined); setCommentText(''); };\n const cancelComment = () => { if (commentId) remove(commentId); closeComment(); };\n // « Ajouter au message », or a click outside with text: the chip, then the text after it, in the draft (spec 1 §3b).\n const keepComment = () => {\n if (!commentChip) return;\n const draft = spec.controller.getSnapshot().draft.text, words = commentText.trim();\n spec.controller.setText(appendToDraft(draft, words ? `${marker(commentChip.number)} ${words}` : marker(commentChip.number)));\n closeComment();\n };\n // Straight to the PM: the comment and its one chip, the capture and that pick, as their own message; the draft keeps everything else.\n const sendComment = () => {\n const words = commentText.trim();\n if (!commentChip || !words) return;\n const image = spec.controller.getSnapshot().draft.attachments.find(attachment => attachment.id === commentChip.id);\n const anchor = anchors.current.get(commentChip.id);\n const payload = { format: 'pm.points@1', captureBatch: `${batchPrefix}-${++batchSequence.current}`, transcriptSegments: [], alignment: 'unavailable', policy: { version: policy.version, widened: policy.widened },\n selections: [{ n: 1, kind: commentChip.kind, label: commentChip.label, screen: commentChip.screen, atMs: null, t: null, selector: anchor?.signals.path.join(' > '),\n screenshot: { imageIndex: image ? 0 : -1, mimeType: image?.content.mimeType }, anchor }] };\n const message = `${words} ${marker(1)}\\n${POINT_PREFACE}${JSON.stringify(payload)}`;\n remove(commentChip.id); closeComment();\n quietTexts.current.add(message);\n const controller = spec.controller;\n void controller.send('followUp', { text: message, attachments: image ? [{ ...image, name: image.name.replace(marker(commentChip.number), marker(1)) }] : [], restore: { text: words } })\n .then(submission => { if (submission && mounted.current) setSentNotice(value => value + 1); }, () => { if (mounted.current) setProblem('L’envoi a échoué. Votre commentaire est dans le brouillon.'); });\n };\n const setAddToMessage = (value: boolean) => {\n setAddToMessageState(value);\n try { localStorage.setItem(ADD_TO_MESSAGE, value ? '1' : '0'); } catch { /* the choice lasts this session */ }\n };\n const comment: PmComment | undefined = commentChip && commentAt ? {\n chip: commentChip, element: commentAt.element, text: commentText, setText: setCommentText, addToMessage, setAddToMessage,\n submit: () => { if (addToMessage) keepComment(); else sendComment(); },\n outside: () => { if (commentText.trim()) keepComment(); else cancelComment(); },\n transcribe: voiceConfig ? async (audio, signal) => { const result = await voiceConfig.transcribe(audio, signal); return typeof result === 'string' ? result : result.text; } : undefined,\n } : undefined;\n // The popover's pick went away (sent, removed or the panel closed): nothing is left to comment on.\n if (commentAt && (!commentChip || !open || state.mode !== 'point')) setCommentAt(undefined);\n const returnToComposer = () => {\n // × settles an open popover like a click outside it; a cancelled pick gets no marker.\n const cancelled = comment && !comment.text.trim() ? comment.chip.id : undefined;\n comment?.outside();\n if (recording) { finish(); return; }\n keepMarkers(spec.controller, state.chips.filter(chip => chip.id !== cancelled));\n dispatch({ type: 'return' }); setFocusVersion(value => value + 1);\n };\n useLayoutEffect(() => {\n returnRef.current = returnToComposer; removeRef.current = remove; pointingRef.current = open && (state.mode === 'point' || state.mode === 'navigate');\n commentRef.current = comment ? { id: comment.chip.id, cancel: cancelComment, outside: comment.outside } : undefined;\n });\n const quietSend = useCallback((text: string | undefined) => text !== undefined && quietTexts.current.has(text), []);\n\n // The meta line under the draft (spec 1 §5): dictated time and captures, « Écrit · n captures » without dictation, nothing with neither.\n const meta = dictated !== undefined || state.chips.length ? text.meta(dictated, state.chips.length) : undefined;\n return { state, dispatch, voice, recording, problem, liveText, sendContext, dictated, snapshot, remove, returnToComposer, focusVersion, text, meta, announcement, comment, sent: sentNotice > 0, quietSend, answerComposer, canPoint: sibling === undefined || appFrame };\n}\n\nexport type PmPanelState = ReturnType<typeof usePmPanel>;\n",
|
|
44
|
+
"content": "'use client';\n\nimport { useCallback, useEffect, useId, useLayoutEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from 'react';\nimport { createPrivacyPolicy } from '@hachej/boring-feedback/page';\nimport type { PrivacyPolicy, SpeechSegment } from '@hachej/boring-feedback/page';\nimport { useVoiceAttempt } from '@hachej/boring-ui-kit/chat-react/voice';\nimport { appendToDraft, voiceRefusalLabel } from '@hachej/boring-ui-kit/chat-text';\nimport type { VoiceMessageHandle, VoiceMessageConfig } from '../pi-chat/config';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\nimport type { ComposerProps } from '../pi-chat/composer';\nimport { derive, waitingPart } from '@hachej/boring-ui-kit/chat-view';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { frenchChatLabels } from '../pi-chat/labels-fr';\nimport type { PointChip } from './point-state';\nimport { alignedTranscript, createPointState, marker, pointReducer, renumberText } from './point-state';\nimport { pmTextFor } from './pm-text';\nimport { useAppFrame } from './point-frames';\nimport { usePins, usePointPage } from './use-point-page';\nimport type { PointPick, PointPin } from './use-point-page';\nimport { POINT_PREFACE } from './point-message';\n\nexport interface PmTranscript { readonly text: string; readonly segments?: readonly SpeechSegment[] }\n/** Optional host voice enrichment. Authorization, retention and transcription remain with the host. */\nexport interface PmVoice {\n readonly transcribe: (audio: Blob, signal: AbortSignal) => Promise<string | PmTranscript>;\n readonly maxMs?: number;\n /** Optional live text with timings. The returned cleanup releases only this capture's listener. */\n readonly live?: (signal: AbortSignal, update: (transcript: PmTranscript) => void) => (() => void);\n}\nexport interface ImproveSpec {\n readonly controller: NativeChatController;\n readonly chatProps?: Omit<PiChatProps, 'controller'>;\n /** Existing agent-page composition: History and the PM's read-only resource Library. */\n readonly conversations?: import('../pi-chat/config').ConversationsConfig;\n readonly conversationId?: string;\n readonly resources?: import('../pi-app/agent-workspace').WorkspaceResources;\n readonly artifactKinds?: readonly import('../pi-app/viewer-kinds').PanelKind[];\n readonly label?: string;\n /** The app's name in the full panel's subtitle: « Le chef de projet · {appName} ». */\n readonly appName?: string;\n readonly voice?: VoiceMessageConfig & { readonly send?: 'auto' | 'draft' | 'message' };\n readonly feedback?: import('@hachej/boring-feedback/ui').FeedbackSession;\n readonly policy?: PrivacyPolicy;\n readonly pointVoice?: PmVoice;\n readonly tasks?: StudioTaskBoardSeam;\n}\n/**\n * The app's task board (`task-board` item): the host's authorized records. The panel draws « Mes demandes » with `TaskBoard` and the @\n * picker with `task-mentions` from them; it owns no card, board or task store.\n */\nexport interface StudioTaskBoardSeam {\n readonly tasks: readonly import('@hachej/boring-ui-kit/task-board').TaskRecord[];\n /** The viewer: « Mes demandes » and @ show the tasks they requested (`requesterId`). */\n readonly currentUser: string;\n /** Host-owned news or notices above the existing request list; no second task store. */\n readonly header?: import('react').ReactNode;\n /** Host-projected unread news; when given, owns the Tasks icon's dot. */\n readonly unread?: boolean;\n /** Opening/closing the Tasks panel is observation only; the host owns any exact-ID acknowledgement. */\n readonly onOpenChange?: (open: boolean) => void;\n /** Opens a task: the @ chip, a card in « Mes demandes ». */\n readonly onOpen: (task: import('@hachej/boring-ui-kit/task-board').TaskRecord) => void;\n /** Answers the question a task waits on, as the viewer (« Confirmer » or their own words). */\n readonly answer?: (task: import('@hachej/boring-ui-kit/task-board').TaskRecord, text: string) => void;\n /** Host-authorized actions in a request's detail (« Retirer »; `TaskBoard actions`). */\n readonly cardActions?: (task: import('@hachej/boring-ui-kit/task-board').TaskRecord) => import('react').ReactNode;\n /** The request card under a PM reply (C7, `TaskRequestCard variant=\"compact\"`), or undefined when that reply carries no request. `edit` puts the request text back in the composer (« Modifier »). */\n readonly renderRequest?: (context: { readonly entry: import('@earendil-works/pi-durable').EntryRecord; readonly edit: (text: string) => void }) => import('react').ReactNode;\n /** Reads the host's records again: the app panel calls it when it opens and when « Mes demandes » opens. */\n readonly refresh?: () => void;\n}\n\ninterface CaptureBatch {\n readonly context: object;\n readonly controller: NativeChatController;\n readonly ids: readonly string[];\n readonly text: string;\n readonly speech: PmTranscript;\n readonly priorRequestId: string | undefined;\n requestId?: string;\n}\nconst defaultPolicy = createPrivacyPolicy();\nconst NOTHING = new Error('nothing heard');\nconst noPins: readonly PointPin[] = [];\nfunction keepMarkers(controller: NativeChatController, chips: readonly PointChip[]) {\n const text = controller.getSnapshot().draft.text;\n const missing = chips.filter(chip => !text.includes(marker(chip.number))).map(chip => marker(chip.number)).join(' ');\n if (missing) controller.setText(appendToDraft(text, missing));\n}\n\nconst ADD_TO_MESSAGE = 'boring-ui.pm.add-to-message';\nconst readAddToMessage = () => { try { return localStorage.getItem(ADD_TO_MESSAGE) === '1'; } catch { return false; } };\n\n/** The open click-to-comment popover (spec 1 §3b): the pick it comments on, the field and the two ways out. */\nexport interface PmComment {\n readonly chip: PointChip;\n readonly element: Element;\n readonly text: string;\n readonly setText: (value: string) => void;\n readonly addToMessage: boolean;\n readonly setAddToMessage: (value: boolean) => void;\n /** Enter or the send button: straight to the PM, or into the draft when « Ajouter au message » is checked. */\n readonly submit: () => void;\n /** A click outside: an empty field cancels the pick, text goes into the draft. */\n readonly outside: () => void;\n readonly transcribe: ((audio: Blob, signal: AbortSignal) => Promise<string>) | undefined;\n}\n\n/** `comment`: a click in `point.idle` opens the comment popover at the pin (the desktop preset, spec 1 §3b). `sibling`: the app is in a sibling frame under this name, and picks there come back as chips. */\nexport function usePmPanel(spec: ImproveSpec, open: boolean, page: HTMLElement | null, screen = (typeof document === 'undefined' ? '' : document.title) || 'app', { comment: commenting = false, sibling }: { readonly comment?: boolean; readonly sibling?: string | undefined } = {}) {\n const text = pmTextFor(spec.chatProps?.locale);\n const [announcement, setAnnouncement] = useState('');\n const [state, dispatch] = useReducer(pointReducer, undefined, createPointState);\n const [problem, setProblem] = useState<string>();\n const [live, setLive] = useState<PmTranscript>({ text: '' });\n const [dictated, setDictated] = useState<number>();\n const [speech, setSpeech] = useState<PmTranscript>({ text: '' });\n const [focusVersion, setFocusVersion] = useState(0);\n const latest = useRef({ spec, state, open, dictated, speech });\n const mounted = useRef(false), pendingBatch = useRef<CaptureBatch | undefined>(undefined), batchSequence = useRef(0);\n const batchPrefix = useId();\n useLayoutEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\n useLayoutEffect(() => { latest.current = { spec, state, open, dictated, speech }; });\n const snapshot = useSyncExternalStore(spec.controller.subscribe, spec.controller.getSnapshot, spec.controller.getSnapshot);\n const [answering, setAnswering] = useState(false);\n const pendingAnswer = useRef(false);\n const question = useMemo(() => {\n const part = waitingPart(derive(snapshot.view, { mode: 'expert', renderEntry: undefined, renderTool: undefined, groupTool: undefined }).rows);\n return part?.kind === 'question' ? part : undefined;\n }, [snapshot.view]);\n // In a PM conversation the plain composer answers its pending native question, rather than queuing behind the durable wait.\n const answerComposer = (composer: ComposerProps): ComposerProps => {\n const answer = spec.chatProps?.actions?.answer;\n if (!question?.questionId || !answer || snapshot.draft.attachments.length || state.chips.length) return composer;\n const id = question.questionId;\n return { ...composer, working: false, sendBlocked: composer.sendBlocked || answering, onSend: () => {\n const value = spec.controller.getSnapshot().draft.text;\n if (!value.trim() || pendingAnswer.current || composer.sendBlocked) return;\n pendingAnswer.current = true; setAnswering(true); setProblem(undefined);\n let timer: ReturnType<typeof setTimeout>;\n const timeout = new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error('Answer timed out')), 15_000); });\n void Promise.race([Promise.resolve().then(() => answer(id, value.trim())), timeout]).then(outcome => {\n if (outcome.kind !== 'answered') throw new Error('Answer refused');\n if (latest.current.spec.controller === spec.controller && spec.controller.getSnapshot().draft.text === value) spec.controller.setText('');\n }).catch(() => { if (mounted.current && latest.current.spec.controller === spec.controller) setProblem(text.answerFailed); })\n .finally(() => { clearTimeout(timer); pendingAnswer.current = false; if (mounted.current) setAnswering(false); });\n } };\n };\n const policy = spec.policy ?? defaultPolicy;\n const voiceConfig = spec.pointVoice ?? spec.voice;\n const core = useVoiceAttempt<{ readonly controller: NativeChatController; readonly config: PmVoice; readonly offset: number; readonly priorSpeech: PmTranscript }>({\n valid: item => latest.current.open && latest.current.spec.controller === item.controller,\n refused: refusal => setProblem(frenchChatLabels[voiceRefusalLabel[refusal.code ?? 'failed']]),\n });\n const coreRef = useRef(core);\n useLayoutEffect(() => { coreRef.current = core; });\n // Esc = « ✓ Terminer » while the strip shows (spec 1 §1): it keeps and transcribes the recording. Registered in a layout effect at mount, so it\n // runs before the voice attempt's own Escape (which cancels a plain dictation) and the picker's; elsewhere Escape keeps their meaning.\n const pointingRef = useRef(false), returnRef = useRef<() => void>(() => {}), removeRef = useRef<(id: string) => void>(() => {});\n // The open comment popover: Esc cancels its pick first; the strip's × is the second Esc.\n const commentRef = useRef<{ readonly id: string; readonly cancel: () => void; readonly outside: () => void } | undefined>(undefined);\n useLayoutEffect(() => {\n const onKey = (event: KeyboardEvent) => {\n if (event.key !== 'Escape' || event.isComposing || !pointingRef.current) return;\n event.preventDefault(); event.stopImmediatePropagation();\n if (commentRef.current) commentRef.current.cancel(); else returnRef.current();\n };\n window.addEventListener('keydown', onKey, { capture: true });\n return () => window.removeEventListener('keydown', onKey, { capture: true });\n }, []);\n const item = core.attempts[0];\n const recording = item?.phase === 'recording' || item?.phase === 'starting';\n const cancel = useCallback(() => {\n const attempt = coreRef.current.current()[0], current = latest.current;\n if (attempt?.controller === current.spec.controller) keepMarkers(attempt.controller, current.state.chips);\n coreRef.current.clear(); setLive({ text: '' });\n }, []);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a close releases this viewer's microphone and live transcript once, never native work\n useEffect(() => { if (!open) cancel(); }, [open, cancel]);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a new borrowed controller fences and releases the old viewer's capture once\n useEffect(() => { dispatch({ type: 'return' }); cancel(); }, [spec.controller, cancel]);\n useEffect(() => { if (open) dispatch({ type: 'return' }); }, [open]);\n useEffect(() => {\n if (!item || !item.config.live) return;\n return item.config.live(item.abort.signal, transcript => { if (coreRef.current.valid(item)) setLive(transcript); });\n }, [item]);\n const start = useCallback(() => {\n const current = latest.current, api = coreRef.current;\n const config = current.spec.pointVoice ?? current.spec.voice;\n if (!config || api.current().length || !current.open) return;\n setProblem(undefined); setLive({ text: '' });\n api.start({ controller: current.spec.controller, config, offset: current.dictated ?? 0, priorSpeech: current.speech }, config.maxMs);\n }, []);\n const finish = useCallback(() => {\n const api = coreRef.current, attempt = api.current()[0];\n if (!attempt) return;\n dispatch({ type: 'return' });\n if (attempt.phase === 'processing') return;\n if (attempt.phase === 'starting') { keepMarkers(attempt.controller, latest.current.state.chips); api.remove(attempt); setFocusVersion(value => value + 1); return; }\n void (async () => {\n try {\n const audio = await api.stop(attempt);\n if (!api.valid(attempt)) return;\n if (!audio) throw NOTHING;\n const result = await attempt.config.transcribe(audio.audio, attempt.abort.signal);\n if (!api.valid(attempt)) return;\n const transcript = typeof result === 'string' ? { text: result } : result;\n if (!transcript.text.trim()) throw NOTHING;\n const current = latest.current;\n const typed = attempt.controller.getSnapshot().draft.text;\n const timed = current.state.chips.filter(chip => !typed.includes(marker(chip.number))).map(chip => ({ ...chip, at: chip.at === null ? null : Math.max(0, chip.at - attempt.offset) }));\n const words = alignedTranscript(transcript.text.trim(), transcript.segments, timed);\n attempt.controller.setText(appendToDraft(attempt.controller.getSnapshot().draft.text, words));\n const segments = transcript.segments?.map(segment => ({ ...segment, start: segment.start + attempt.offset, end: segment.end + attempt.offset,\n ...(segment.words ? { words: segment.words.map(word => ({ ...word, start: word.start + attempt.offset, end: word.end + attempt.offset })) } : {}) }));\n setDictated(attempt.offset + (audio.endedAt - audio.startedAt) / 1000);\n setSpeech({ text: appendToDraft(attempt.priorSpeech.text, transcript.text), segments: [...(attempt.priorSpeech.segments ?? []), ...(segments ?? [])] }); setLive({ text: '' }); setFocusVersion(value => value + 1); dispatch({ type: 'return' });\n } catch (error) {\n if (!api.valid(attempt)) return;\n // Nothing said: the picks (if any) become chips, the draft is otherwise untouched, and that is not a failure.\n keepMarkers(attempt.controller, latest.current.state.chips); setFocusVersion(value => value + 1);\n if (error !== NOTHING) setProblem('La transcription a échoué. Vos éléments et votre texte sont conservés.');\n }\n finally { if (api.valid(attempt)) api.remove(attempt); }\n })();\n }, []);\n const voice: VoiceMessageHandle = {\n canStart: Boolean(voiceConfig) && open && snapshot.connection.kind === 'connected' && snapshot.send.kind !== 'unknown' && snapshot.send.kind !== 'submitting' && snapshot.send.kind !== 'validating', phase: item?.phase ?? 'idle', seconds: item?.seconds ?? 0,\n captureSequence: core.captureSequence, levels: item?.levels ?? [], paused: item?.paused ?? false, pending: [], problem,\n start, finish, cancel,\n ...(item?.canPause ? { pause: () => core.pause(item), resume: () => core.resume(item) } : {}),\n };\n const [commentAt, setCommentAt] = useState<{ readonly id: string; readonly element: Element }>();\n const [commentText, setCommentText] = useState('');\n const [addToMessage, setAddToMessageState] = useState(readAddToMessage);\n const [sentNotice, setSentNotice] = useState(0);\n const commentingRef = useRef(commenting);\n useLayoutEffect(() => { commentingRef.current = commenting; });\n const anchors = useRef(new Map<string, import('@hachej/boring-feedback/page').AppElementAnchor>());\n const receivePick = (pick: PointPick, element?: Element) => {\n const number = latest.current.state.nextNumber, controller = latest.current.spec.controller;\n controller.setAttachments([...controller.getSnapshot().draft.attachments, { id: pick.id, name: `${marker(number)} @ ${pick.kind} · ${pick.label} · capture jointe`, content: { type: 'image', ...pick.image } }]);\n anchors.current.set(pick.id, pick.anchor);\n const attempt = coreRef.current.current()[0];\n const recordingNow = attempt?.phase === 'recording' || attempt?.phase === 'starting';\n const at = recordingNow ? (attempt.offset ?? 0) + (attempt.capture.progress()?.elapsedMs ?? 0) / 1000 : null;\n dispatch({ type: 'pick', chip: { id: pick.id, kind: pick.kind, label: pick.label, screen: pick.screen, at, box: pick.box } });\n // While recording the voice is the comment: the click only pins.\n if (element && commentingRef.current && !recordingNow) { setCommentText(''); setCommentAt({ id: pick.id, element }); }\n setAnnouncement(text.added(number, pick.kind, pick.label));\n setProblem(undefined);\n };\n const picking = open && state.show && state.mode === 'point';\n const pins = usePins(state.chips, open && state.mode !== 'full');\n usePointPage({ picking: picking && !sibling, pins: sibling ? noPins : pins, page, screen, policy, text,\n // With the comment popover open, a click on the page is a click outside it: it settles that pick and picks nothing.\n intercept: () => { if (!commentRef.current) return false; commentRef.current.outside(); return true; },\n onPick: receivePick, onUnpick: id => removeRef.current(id), onProblem: setProblem });\n const appFrame = useAppFrame({ enabled: sibling !== undefined, app: sibling ?? '', picking, pins, onPick: receivePick, onUnpick: id => removeRef.current(id), onProblem: setProblem, onDone: () => returnRef.current() });\n // Messages sent from the popover: the app panel stays on the strip when they are admitted (no C7).\n const quietTexts = useRef(new Set<string>());\n useEffect(() => {\n if (!sentNotice) return;\n const timer = setTimeout(() => setSentNotice(0), 3000);\n return () => clearTimeout(timer);\n }, [sentNotice]);\n // Native attachment remove controls also remove the corresponding marker and pin.\n useEffect(() => {\n for (const chip of state.chips) if (!pendingBatch.current?.ids.includes(chip.id) && !snapshot.draft.attachments.some(attachment => attachment.id === chip.id) && snapshot.send.kind !== 'submitting' && snapshot.send.kind !== 'unknown' && snapshot.send.kind !== 'validating') {\n dispatch({ type: 'remove', id: chip.id }); anchors.current.delete(chip.id);\n }\n }, [snapshot.draft.attachments, snapshot.send.kind, state.chips]);\n const completeBatch = useCallback((batch: CaptureBatch) => {\n if (!mounted.current || latest.current.spec.controller !== batch.controller || pendingBatch.current !== batch) return;\n pendingBatch.current = undefined;\n dispatch({ type: 'sent', ids: batch.ids });\n if (latest.current.speech === batch.speech) { setDictated(undefined); setSpeech({ text: '' }); }\n for (const id of batch.ids) anchors.current.delete(id);\n }, []);\n const sendContext = useMemo(() => {\n const context = {};\n return {\n count: state.chips.length || (dictated !== undefined ? 1 : 0),\n attach: (text: string) => {\n if (pendingBatch.current) return Promise.resolve({ kind: 'refused' as const, reason: 'Vérifiez d’abord le message en cours.' });\n const payload = { format: 'pm.points@1', captureBatch: `${batchPrefix}-${++batchSequence.current}`, dictatedSeconds: dictated, dictatedMs: dictated === undefined ? undefined : Math.round(dictated * 1000),\n transcriptSegments: speech.segments ?? [], alignment: speech.segments?.some(segment => segment.words?.length) ? 'word' : speech.segments?.length ? 'segment' : 'unavailable',\n policy: { version: policy.version, widened: policy.widened }, selections: state.chips.map(({ id, number, kind, label, screen, at }) => ({ n: number, kind, label, screen,\n atMs: at === null ? null : Math.round(at * 1000), t: at === null ? null : Math.round(at * 1000), selector: anchors.current.get(id)?.signals.path.join(' > '), screenshot: { imageIndex: snapshot.draft.attachments.findIndex(image => image.id === id), mimeType: snapshot.draft.attachments.find(image => image.id === id)?.content.mimeType }, anchor: anchors.current.get(id) })) };\n const attached = appendToDraft(text, `${POINT_PREFACE}${JSON.stringify(payload)}`);\n const selected: CaptureBatch = { context, controller: spec.controller, ids: state.chips.map(chip => chip.id), text: attached, speech,\n priorRequestId: 'attempt' in snapshot.send ? snapshot.send.attempt.requestId : undefined };\n pendingBatch.current = selected;\n return Promise.resolve({ kind: 'ok' as const, text: attached });\n },\n sent: () => { const selected = pendingBatch.current; if (selected?.context === context) completeBatch(selected); },\n };\n }, [state.chips, dictated, policy, speech, snapshot.draft.attachments, snapshot.send, spec.controller, batchPrefix, completeBatch]);\n // Reconciliation observes the original native request; it is never another send or a second admission authority.\n useEffect(() => {\n const batch = pendingBatch.current;\n if (!batch || batch.controller !== spec.controller) return;\n if ('attempt' in snapshot.send && snapshot.send.attempt.draft.text === batch.text && snapshot.send.attempt.requestId !== batch.priorRequestId) {\n batch.requestId ??= snapshot.send.attempt.requestId;\n if (snapshot.send.kind === 'admitted' && batch.requestId === snapshot.send.attempt.requestId) completeBatch(batch);\n } else if (snapshot.send.kind === 'blocked') pendingBatch.current = undefined;\n }, [snapshot.send, spec.controller, completeBatch]);\n const liveText = recording ? alignedTranscript(live.text, live.segments, state.chips.filter(chip => !snapshot.draft.text.includes(marker(chip.number))).map(chip => ({ ...chip, at: chip.at === null ? null : Math.max(0, chip.at - (item?.offset ?? 0)) }))) : '';\n const remove = (id: string) => {\n const chip = state.chips.find(value => value.id === id);\n if (!chip) return;\n const draft = spec.controller.getSnapshot().draft;\n spec.controller.setText(renumberText(draft.text, chip.number));\n // Attachment names carry the marker too: they follow the renumbering.\n spec.controller.setAttachments(draft.attachments.filter(image => image.id !== id).map(image => {\n const other = state.chips.find(value => value.id === image.id);\n return other && other.number > chip.number ? { ...image, name: image.name.replace(marker(other.number), marker(other.number - 1)) } : image;\n }));\n dispatch({ type: 'remove', id }); anchors.current.delete(id);\n setAnnouncement(text.removed(chip.number));\n };\n const commentId = commentAt?.id;\n const commentChip = commentId ? state.chips.find(chip => chip.id === commentId) : undefined;\n const closeComment = () => { commentRef.current = undefined; setCommentAt(undefined); setCommentText(''); };\n const cancelComment = () => { if (commentId) remove(commentId); closeComment(); };\n // « Ajouter au message », or a click outside with text: the chip, then the text after it, in the draft (spec 1 §3b).\n const keepComment = () => {\n if (!commentChip) return;\n const draft = spec.controller.getSnapshot().draft.text, words = commentText.trim();\n spec.controller.setText(appendToDraft(draft, words ? `${marker(commentChip.number)} ${words}` : marker(commentChip.number)));\n closeComment();\n };\n // Straight to the PM: the comment and its one chip, the capture and that pick, as their own message; the draft keeps everything else.\n const sendComment = () => {\n const words = commentText.trim();\n if (!commentChip || !words) return;\n const image = spec.controller.getSnapshot().draft.attachments.find(attachment => attachment.id === commentChip.id);\n const anchor = anchors.current.get(commentChip.id);\n const payload = { format: 'pm.points@1', captureBatch: `${batchPrefix}-${++batchSequence.current}`, transcriptSegments: [], alignment: 'unavailable', policy: { version: policy.version, widened: policy.widened },\n selections: [{ n: 1, kind: commentChip.kind, label: commentChip.label, screen: commentChip.screen, atMs: null, t: null, selector: anchor?.signals.path.join(' > '),\n screenshot: { imageIndex: image ? 0 : -1, mimeType: image?.content.mimeType }, anchor }] };\n const message = `${words} ${marker(1)}\\n${POINT_PREFACE}${JSON.stringify(payload)}`;\n remove(commentChip.id); closeComment();\n quietTexts.current.add(message);\n const controller = spec.controller;\n void controller.send('followUp', { text: message, attachments: image ? [{ ...image, name: image.name.replace(marker(commentChip.number), marker(1)) }] : [], restore: { text: words } })\n .then(submission => { if (submission && mounted.current) setSentNotice(value => value + 1); }, () => { if (mounted.current) setProblem('L’envoi a échoué. Votre commentaire est dans le brouillon.'); });\n };\n const setAddToMessage = (value: boolean) => {\n setAddToMessageState(value);\n try { localStorage.setItem(ADD_TO_MESSAGE, value ? '1' : '0'); } catch { /* the choice lasts this session */ }\n };\n const comment: PmComment | undefined = commentChip && commentAt ? {\n chip: commentChip, element: commentAt.element, text: commentText, setText: setCommentText, addToMessage, setAddToMessage,\n submit: () => { if (addToMessage) keepComment(); else sendComment(); },\n outside: () => { if (commentText.trim()) keepComment(); else cancelComment(); },\n transcribe: voiceConfig ? async (audio, signal) => { const result = await voiceConfig.transcribe(audio, signal); return typeof result === 'string' ? result : result.text; } : undefined,\n } : undefined;\n // The popover's pick went away (sent, removed or the panel closed): nothing is left to comment on.\n if (commentAt && (!commentChip || !open || state.mode !== 'point')) setCommentAt(undefined);\n const returnToComposer = () => {\n // × settles an open popover like a click outside it; a cancelled pick gets no marker.\n const cancelled = comment && !comment.text.trim() ? comment.chip.id : undefined;\n comment?.outside();\n if (recording) { finish(); return; }\n keepMarkers(spec.controller, state.chips.filter(chip => chip.id !== cancelled));\n dispatch({ type: 'return' }); setFocusVersion(value => value + 1);\n };\n useLayoutEffect(() => {\n returnRef.current = returnToComposer; removeRef.current = remove; pointingRef.current = open && (state.mode === 'point' || state.mode === 'navigate');\n commentRef.current = comment ? { id: comment.chip.id, cancel: cancelComment, outside: comment.outside } : undefined;\n });\n const quietSend = useCallback((text: string | undefined) => text !== undefined && quietTexts.current.has(text), []);\n\n // The meta line under the draft (spec 1 §5): dictated time and captures, « Écrit · n captures » without dictation, nothing with neither.\n const meta = dictated !== undefined || state.chips.length ? text.meta(dictated, state.chips.length) : undefined;\n return { state, dispatch, voice, recording, problem, liveText, sendContext, dictated, snapshot, remove, returnToComposer, focusVersion, text, meta, announcement, comment, sent: sentNotice > 0, quietSend, answerComposer, canPoint: sibling === undefined || appFrame };\n}\n\nexport type PmPanelState = ReturnType<typeof usePmPanel>;\n",
|
|
45
45
|
"type": "registry:hook",
|
|
46
46
|
"target": "components/pm-panel/use-pm-panel.ts"
|
|
47
47
|
},
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
},
|
|
102
102
|
{
|
|
103
103
|
"path": "registry/pm-panel/pm-task-source.tsx",
|
|
104
|
-
"content": "'use client';\n\n// The one-line app's task source (spec 2 « Mes demandes » and C7's request card): the host's same-origin task API, behind a feature\n// check. This file is the one adapter between the host's records and `TaskRecord`: the host contract's field names change here only.\nimport { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport type { TaskRecord, TaskState } from '@hachej/boring-ui-kit/task-board';\nimport { taskReference } from '@hachej/boring-ui-kit/task-board';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { TaskRequestCard } from '../task-board/task-board';\nimport type { ImproveSpec, StudioTaskBoardSeam } from './use-pm-panel';\nimport type { PmText } from './pm-text';\n\n/**\n * One task of `GET {tasks}?mine=1` (`{ tasks, cursor }`, hachej/boring-factory#892): the viewer's own requests for this app, as requester\n * or waiting on them. The D-ref is `ref` (list) or `number` (`tasks_create`), the update time `updated` or `updated_at`.\n */\nexport interface HostTask {\n readonly id: string;\n readonly ref?: string;\n readonly number?: string;\n readonly title: string;\n readonly display?: string;\n readonly technical?: boolean;\n readonly state: string;\n /** Host wording, shown as is. */\n readonly status?: string;\n readonly created?: string | number;\n readonly created_at?: string | number;\n readonly updated?: string | number;\n readonly updated_at?: string | number | null;\n readonly waiting_on_you?: boolean;\n /** The question, when the task waits on the viewer. */\n readonly question?: string;\n readonly waiting_on?: { readonly question?: string };\n /** ISO time the requester answered « Confirmer »; absent until then. */\n readonly confirmed_at?: string;\n /** The viewer filed the task. `mine=1` also lists tasks only waiting on the viewer, so only `true` lets them withdraw it. */\n readonly is_requester?: boolean;\n /** Why a `done` or `closed` task ended (the platform's field, 2026-10-29); omitted on open tasks. */\n readonly closed_reason?: 'shipped' | 'withdrawn' | 'closed';\n}\n\n/** A finished request's muted label in « Mes demandes »: « En ligne » (shipped or done), « Retirée » (withdrawn), « Fermée » (any other close). */\nexport type TaskOutcome = 'live' | 'withdrawn' | 'closed';\n\n/**\n * A host task as the one line draws it: `is_requester` stays here, in the adapter, for « Retirer » only; `outcome` is set on finished\n * tasks only.\n */\nexport type HostTaskRecord = TaskRecord & { readonly is_requester?: true; readonly outcome?: TaskOutcome };\n\nconst STATES: readonly TaskState[] = ['waiting', 'working', 'open', 'done', 'closed'];\nconst REASONS: Readonly<Record<string, TaskOutcome>> = { shipped: 'live', withdrawn: 'withdrawn', closed: 'closed' };\n\n/** The host's `closed_reason` on a `done` or `closed` task; without it, `done` is « En ligne » and `closed` is « Fermée ». Open: undefined. */\nexport function taskOutcome(state: string, reason?: string): TaskOutcome | undefined {\n if (state !== 'done' && state !== 'closed') return undefined;\n return (reason !== undefined && Object.hasOwn(REASONS, reason) ? REASONS[reason] : undefined) ?? (state === 'done' ? 'live' : 'closed');\n}\n\n/** « Terminées »' label for any record: the host's outcome when the one line mapped it, else from the state alone. Undefined while open. */\nexport const requestOutcome = (task: TaskRecord): TaskOutcome | undefined => (task as HostTaskRecord).outcome ?? taskOutcome(task.state);\nconst stamp = (value: string | number | null | undefined) => value == null ? undefined : new Date(value).toISOString();\n\n/** Every listed task is the viewer's (the host filtered them), so the viewer is their requester for « Mes demandes ». */\nexport function toTaskRecord(task: HostTask, viewer: string): HostTaskRecord {\n const ended = task.state === 'done' || task.state === 'closed' || task.status === 'abandoned' || task.status === 'shipped';\n const waiting = !ended && task.waiting_on_you === true;\n const created = stamp(task.created ?? task.created_at), updated = stamp(task.updated ?? task.updated_at), confirmed = stamp(task.confirmed_at), ref = task.ref ?? task.number, question = task.question ?? task.waiting_on?.question;\n const state: TaskState = task.status === 'abandoned' ? 'closed' : task.status === 'shipped' ? 'done' : waiting ? 'waiting' : STATES.includes(task.state as TaskState) ? task.state as TaskState : 'open', outcome = taskOutcome(state, task.closed_reason ?? (task.status === 'abandoned' ? 'withdrawn' : undefined));\n return {\n id: task.id, ...(ref ? { number: ref } : {}), title: task.title, ...(task.display ? { display: task.display } : {}), ...(task.technical === undefined ? {} : { technical: task.technical }), ...(task.status ? { status: task.status } : {}), requesterId: viewer, waiting_on_you: waiting, state,\n ...(waiting ? { waiting_on: { who: viewer, type: 'person' as const, ...(question ? { question } : {}) } } : {}),\n ...(created ? { created_at: created } : {}), ...(updated ? { updated_at: updated } : {}), ...(confirmed ? { confirmed_at: confirmed } : {}),\n ...(task.is_requester === true ? { is_requester: true as const } : {}), ...(outcome ? { outcome } : {}),\n };\n}\n\n/** Member request vocabulary is shared by the thread card and « Mes demandes », not inferred from status prose. */\nexport function PmRequestStatus({ task, text }: { readonly task: TaskRecord; readonly text: PmText }) {\n const outcome = requestOutcome(task);\n const status = outcome === 'live' ? 'live' : outcome === 'withdrawn' ? 'withdrawn' : outcome === 'closed' ? 'closed'\n : task.waiting_on_you === true ? 'waiting' : task.status === 'blocked' ? 'blocked' : task.status === 'verifying' ? 'verifying' : 'preparing';\n const label = status === 'live' ? text.outcomeLive : status === 'withdrawn' ? text.outcomeWithdrawn : status === 'closed' ? text.outcomeClosed\n : status === 'waiting' ? text.awaiting : status === 'blocked' ? text.blocked : status === 'verifying' ? text.verifying : text.preparing;\n return <Badge data-task-status={status} variant={status === 'blocked' ? 'destructive' : 'secondary'}>{label}</Badge>;\n}\n\n/** `null`: no task source for this viewer just now (404 or 403). The hook reads again later; it never latches it (decided 2026-10-29). */\nexport async function readTasks(url: string, viewer: string, signal?: AbortSignal): Promise<readonly HostTaskRecord[] | null> {\n const response = await fetch(`${url}?mine=1`, { credentials: 'same-origin', ...(signal ? { signal } : {}) });\n if (response.status === 404 || response.status === 403) return null;\n if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);\n const body = await response.json() as { readonly tasks?: readonly HostTask[] };\n return (body.tasks ?? []).map(task => toTaskRecord(task, viewer));\n}\n\n/** Answers the question the task waits on, as the viewer. « Confirmer » is such an answer. */\nexport async function answerTask(url: string, id: string, text: string): Promise<void> {\n const response = await fetch(`${url}/${encodeURIComponent(id)}/answer`, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }), signal: AbortSignal.timeout(15_000) });\n if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);\n}\n\n/**\n * Withdraws the viewer's own request (`POST {tasks}/<id>/withdraw`, requester only): `withdrawn` on 200, whether its body is empty or\n * `{ task }` (the body is not read: the list is read again); `started` on 409, once work has started; `refused` otherwise (404 for a\n * non-requester, a network failure).\n */\nexport async function withdrawTask(url: string, id: string): Promise<'withdrawn' | 'started' | 'refused'> {\n const response = await fetch(`${url}/${encodeURIComponent(id)}/withdraw`, { method: 'POST', credentials: 'same-origin', signal: AbortSignal.timeout(15_000) }).catch(() => undefined);\n return response?.ok ? 'withdrawn' : response?.status === 409 ? 'started' : 'refused';\n}\n\n/** Withdraws only what the viewer filed and nobody started: `is_requester`, and `open` or waiting on the viewer. */\nexport const withdrawable = (task: TaskRecord) => (task as HostTaskRecord).is_requester === true && (task.state === 'open' || task.waiting_on_you === true);\n\n/** « Retirer » on the requester's card in « Mes demandes »: one tap asks « Retirer cette demande ? », the second withdraws. */\nfunction WithdrawAction({ task, failed, text, onWithdraw }: { readonly task: HostTaskRecord; readonly failed: boolean; readonly text: PmText; readonly onWithdraw: (task: HostTaskRecord) => Promise<void> }) {\n const [asking, setAsking] = useState(false), [busy, setBusy] = useState(false);\n const withdraw = () => { setBusy(true); void onWithdraw(task).finally(() => { setBusy(false); setAsking(false); }); };\n return <>\n {withdrawable(task) && (asking ? <>\n <p data-testid=\"pm-withdraw-ask\" className=\"col-span-2 m-0 flex min-h-0! items-center text-sm font-medium\">{text.withdrawAsk}</p>\n <Button variant=\"destructive\" data-testid=\"pm-withdraw-confirm\" disabled={busy} onClick={withdraw}>{text.withdraw}</Button>\n <Button variant=\"outline\" data-testid=\"pm-withdraw-cancel\" disabled={busy} onClick={() => setAsking(false)}>{text.cancel}</Button>\n </> : <Button variant=\"outline\" data-testid=\"pm-withdraw\" className=\"col-span-2\" onClick={() => setAsking(true)}>{text.withdraw}</Button>)}\n {failed && <p role=\"status\" data-testid=\"pm-withdraw-failed\" className=\"col-span-2 m-0 min-h-0! text-[13px] text-muted-foreground\">{text.withdrawFailed}</p>}\n </>;\n}\n\nconst REF = /\\bD-\\d+\\b/, UUID = /\\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\b/i;\n/** A D-ref or task id in a tool result: `details` fields first (`ref`, `number`, `id`, nested `task`), then the text. */\nfunction referenceIn(value: unknown): { readonly ref?: string; readonly id?: string } | undefined {\n if (typeof value === 'string') { const ref = REF.exec(value)?.[0], id = UUID.exec(value)?.[0]; return ref || id ? { ...(ref ? { ref } : {}), ...(id ? { id } : {}) } : undefined; }\n if (!value || typeof value !== 'object') return undefined;\n const record = value as Record<string, unknown>;\n for (const key of ['ref', 'number', 'id', 'task']) { const found = record[key] === undefined ? undefined : referenceIn(record[key]); if (found) return found; }\n return undefined;\n}\n\n/**\n * The PM's task tool (hachej/boring-factory#892): `content[0].text` is JSON `{ created, task: { id, number: 'D-n', title, state, status,\n * updated_at, waiting_on_you?, confirmed_at?, is_requester? } }`. With an id and a title, `task` draws the request card on its own.\n */\nexport const TASK_CREATE_TOOL = 'tasks_create';\nexport interface CreatedTask { readonly ref?: string; readonly id?: string; readonly task?: HostTask }\nfunction exactTask(message: { readonly toolName: string; readonly content: readonly { readonly type: string; readonly text?: string }[] }): CreatedTask | undefined {\n if (message.toolName !== TASK_CREATE_TOOL || message.content[0]?.type !== 'text') return undefined;\n try {\n const task = (JSON.parse(message.content[0].text ?? '') as { readonly task?: Record<string, unknown> }).task;\n const id = typeof task?.['id'] === 'string' ? task['id'] : undefined, ref = typeof task?.['number'] === 'string' ? task['number'] : undefined;\n if (!id && !ref) return undefined;\n const whole = id && typeof task?.['title'] === 'string' && typeof task['state'] === 'string' ? task as unknown as HostTask : undefined;\n return { ...(ref ? { ref } : {}), ...(id ? { id } : {}), ...(whole ? { task: whole } : {}) };\n } catch { return undefined; }\n}\n\n/**\n * The task a PM reply created, from a tool result run in the same turn before that reply: `tasks_create`'s JSON first; then, tolerant\n * (an older or renamed tool), any tool whose name contains `task` and whose result carries a D-ref or an id. Adjust here when it changes.\n */\nexport function createdTask(entries: readonly EntryRecord[], reply: EntryRecord): CreatedTask | undefined {\n const at = entries.findIndex(entry => entry.id === reply.id);\n for (let index = (at < 0 ? entries.length : at) - 1; index >= 0; index--) {\n for (const message of [...(entries[index]!.model ?? [])].reverse()) {\n if (message.role === 'user') return undefined;\n if (message.role !== 'toolResult' || message.isError || !/task/i.test(message.toolName)) continue;\n const found = exactTask(message) ?? referenceIn(message.details) ?? referenceIn(message.content.map(part => part.type === 'text' ? part.text : '').join('\\n'));\n if (found) return found;\n }\n }\n return undefined;\n}\n\n/** The request card's record: the `tasks_create` result merged with the list's record, the list winning on conflicts. */\nexport function requestRecord(made: CreatedTask, listed: readonly HostTaskRecord[], viewer: string): HostTaskRecord | undefined {\n const fromList = listed.find(each => each.id === made.id || (made.ref !== undefined && taskReference(each) === made.ref));\n const fromTool = made.task ? toTaskRecord(made.task, viewer) : undefined;\n if (!fromList || !fromTool) return fromList ?? fromTool;\n const { waiting_on: _, ...tool } = fromTool;\n return { ...tool, ...fromList };\n}\n\n/**\n * `spec.tasks` for the one line: read when `active`, again when the conversation grows (a PM reply may have created a task), on `refresh`\n * (the panel opens, « Mes demandes » opens), after an answer and after a withdraw refused with 409. A 403, 404 or network error is\n * « unavailable for now », never latched: the next read may answer (decided 2026-10-29). The request card is drawn from the `tasks_create`\n * result even before or without the list. A request withdrawn in this session is « Retirée » at once, before the host's `closed_reason` says\n * so, and its chat card goes; « Confirmer » shows « Envoyée » at once.\n */\nexport function useHostTasks(url: string | undefined, viewer: string | undefined, controller: ImproveSpec['controller'] | undefined, text: PmText): StudioTaskBoardSeam | undefined {\n const [read, setTasks] = useState<readonly HostTaskRecord[] | null>();\n // Withdrawn in this session: id → when, so the card moves to « Terminées » as « Retirée » (newest first) whatever the host says yet.\n const [withdrawn, setWithdrawn] = useState<ReadonlyMap<string, string>>(() => new Map());\n const [refused, setRefused] = useState<ReadonlySet<string>>(() => new Set());\n // « Envoyée » at once: confirmed, nobody started yet. Kept until the host's record says so (or the answer fails).\n const [confirmed, setConfirmed] = useState<ReadonlyMap<string, string>>(() => new Map());\n const [confirming, setConfirming] = useState<ReadonlySet<string>>(() => new Set());\n const [confirmFailed, setConfirmFailed] = useState<ReadonlySet<string>>(() => new Set());\n const pendingConfirm = useRef(new Set<string>());\n const settle = useCallback((task: HostTaskRecord): HostTaskRecord => {\n const gone = withdrawn.get(task.id);\n // The host has the last word once it says something newer: shipped, or reopened after the withdraw.\n const reopened = task.outcome === undefined && Date.parse(task.updated_at ?? '') > Date.parse(gone ?? '');\n if (gone !== undefined && task.outcome !== 'live' && !reopened) {\n const { waiting_on: _, is_requester: __, ...rest } = task;\n return { ...rest, state: 'closed', waiting_on_you: false, outcome: 'withdrawn', updated_at: task.state === 'closed' ? task.updated_at ?? gone : gone };\n }\n const at = confirmed.get(task.id);\n if (at === undefined || !task.waiting_on_you) return task;\n const { waiting_on: _, ...rest } = task;\n return { ...rest, state: 'open', waiting_on_you: false, confirmed_at: task.confirmed_at ?? at };\n }, [confirmed, withdrawn]);\n const tasks = useMemo(() => (read ?? []).map(settle), [read, settle]);\n const entries = useSyncExternalStore(controller?.subscribe ?? noSubscribe, () => controller?.getSnapshot().view?.entries.length ?? 0, () => 0);\n const [answered, setAnswered] = useState(0);\n const [asked, setAsked] = useState(0);\n const refresh = useCallback(() => setAsked(count => count + 1), []);\n useEffect(() => {\n if (!url || !viewer) return;\n const abort = new AbortController();\n // A network error keeps what was read before; 403 and 404 mean « unavailable for now ».\n const timer = setTimeout(() => { readTasks(url, viewer, abort.signal).then(setTasks, () => { if (!abort.signal.aborted) setTasks(current => current ?? null); }); }, 300);\n return () => { clearTimeout(timer); abort.abort(); };\n }, [url, viewer, entries, answered, asked]);\n const confirm = useCallback(async (task: TaskRecord) => {\n if (!url) return;\n if (pendingConfirm.current.has(task.id)) return;\n pendingConfirm.current.add(task.id);\n setConfirming(current => new Set(current).add(task.id));\n setConfirmFailed(current => { const next = new Set(current); next.delete(task.id); return next; });\n const done = await answerTask(url, task.id, text.confirm).then(() => true, () => false);\n pendingConfirm.current.delete(task.id);\n setConfirming(current => { const next = new Set(current); next.delete(task.id); return next; });\n if (done) setConfirmed(current => new Map(current).set(task.id, task.confirmed_at ?? new Date().toISOString()));\n else setConfirmFailed(current => new Set(current).add(task.id));\n setAnswered(count => count + 1);\n }, [url, text.confirm]);\n const withdraw = useCallback(async (task: HostTaskRecord) => {\n if (!url) return;\n setRefused(current => { const next = new Set(current); next.delete(task.id); return next; });\n const outcome = await withdrawTask(url, task.id);\n if (outcome !== 'refused') setAnswered(count => count + 1);\n if (outcome === 'withdrawn') { setWithdrawn(current => new Map(current).set(task.id, new Date().toISOString())); return; }\n // Started meanwhile (409): the list is read again, so the card shows its real state.\n setRefused(current => new Set(current).add(task.id));\n }, [url]);\n return useMemo(() => !url || !viewer ? undefined : {\n tasks, currentUser: viewer, refresh,\n // No task route in the one line: a card is the request, its answer happens in the PM chat.\n onOpen: () => undefined,\n cardActions: (task: TaskRecord) => withdrawable(task) || refused.has(task.id) ? <WithdrawAction key={task.id} task={task} failed={refused.has(task.id)} text={text} onWithdraw={withdraw} /> : undefined,\n renderRequest: ({ entry, edit }) => {\n const made = createdTask(controller?.getSnapshot().view?.entries ?? [], entry);\n const found = made && !withdrawn.has(made.id ?? '') ? requestRecord(made, tasks, viewer) : undefined;\n if (!found) return undefined;\n const task = settle(found);\n return <TaskRequestCard task={task} variant=\"compact\" locale={text.locale} pill={<PmRequestStatus task={task} text={text} />} actions={task.waiting_on_you ? <>\n <Button variant=\"outline\" data-testid=\"pm-request-modify\" disabled={confirming.has(task.id)} onClick={() => edit(task.title)}>{text.modify}</Button>\n <Button data-testid=\"pm-request-confirm\" disabled={confirming.has(task.id)} aria-busy={confirming.has(task.id) || undefined} onClick={() => { void confirm(task); }}>{confirming.has(task.id) ? text.confirming : text.confirm}</Button>\n {confirmFailed.has(task.id) && <p role=\"alert\" data-testid=\"pm-confirm-failed\" className=\"col-span-2 m-0 text-sm\">{text.confirmFailed}</p>}\n </> : undefined} />;\n },\n }, [url, tasks, viewer, text, confirm, withdraw, refused, withdrawn, settle, refresh, controller, confirming, confirmFailed]);\n}\nconst noSubscribe = () => () => undefined;\n",
|
|
104
|
+
"content": "'use client';\n\n// The one-line app's task source (spec 2 « Mes demandes » and C7's request card): the host's same-origin task API, behind a feature\n// check. This file is the one adapter between the host's records and `TaskRecord`: the host contract's field names change here only.\nimport { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport type { TaskRecord, TaskState } from '@hachej/boring-ui-kit/task-board';\nimport { taskReference } from '@hachej/boring-ui-kit/task-board';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { TaskRequestCard } from '../task-board/task-board';\nimport type { ImproveSpec, StudioTaskBoardSeam } from './use-pm-panel';\nimport type { PmText } from './pm-text';\n\n/**\n * One task of `GET {tasks}?mine=1` (`{ tasks, cursor }`, hachej/boring-factory#892): the viewer's own requests for this app, as requester\n * or waiting on them. The D-ref is `ref` (list) or `number` (`tasks_create`), the update time `updated` or `updated_at`.\n */\nexport interface HostTask {\n readonly id: string;\n readonly ref?: string;\n readonly number?: string;\n readonly title: string;\n readonly display?: string;\n readonly technical?: boolean;\n readonly state: string;\n /** Host wording, shown as is. */\n readonly status?: string;\n readonly created?: string | number;\n readonly created_at?: string | number;\n readonly updated?: string | number;\n readonly updated_at?: string | number | null;\n readonly waiting_on_you?: boolean;\n /** The question, when the task waits on the viewer. */\n readonly question?: string;\n readonly waiting_on?: { readonly question?: string };\n /** ISO time the requester answered « Confirmer »; absent until then. */\n readonly confirmed_at?: string;\n /** The viewer filed the task. `mine=1` also lists tasks only waiting on the viewer, so only `true` lets them withdraw it. */\n readonly is_requester?: boolean;\n /** Why a `done` or `closed` task ended (the platform's field, 2026-10-29); omitted on open tasks. */\n readonly closed_reason?: 'shipped' | 'withdrawn' | 'closed';\n}\n\n/** A finished request's muted label in « Mes demandes »: « En ligne » (shipped or done), « Retirée » (withdrawn), « Fermée » (any other close). */\nexport type TaskOutcome = 'live' | 'withdrawn' | 'closed';\n\n/**\n * A host task as the one line draws it: `is_requester` stays here, in the adapter, for « Retirer » only; `outcome` is set on finished\n * tasks only.\n */\nexport type HostTaskRecord = TaskRecord & { readonly is_requester?: true; readonly outcome?: TaskOutcome };\n\nconst STATES: readonly TaskState[] = ['waiting', 'working', 'open', 'done', 'closed'];\nconst REASONS: Readonly<Record<string, TaskOutcome>> = { shipped: 'live', withdrawn: 'withdrawn', closed: 'closed' };\n\n/** The host's `closed_reason` on a `done` or `closed` task; without it, `done` is « En ligne » and `closed` is « Fermée ». Open: undefined. */\nexport function taskOutcome(state: string, reason?: string): TaskOutcome | undefined {\n if (state !== 'done' && state !== 'closed') return undefined;\n return (reason !== undefined && Object.hasOwn(REASONS, reason) ? REASONS[reason] : undefined) ?? (state === 'done' ? 'live' : 'closed');\n}\n\n/** « Terminées »' label for any record: the host's outcome when the one line mapped it, else from the state alone. Undefined while open. */\nexport const requestOutcome = (task: TaskRecord): TaskOutcome | undefined => (task as HostTaskRecord).outcome ?? taskOutcome(task.state);\nconst stamp = (value: string | number | null | undefined) => value == null ? undefined : new Date(value).toISOString();\n\n/** Every listed task is the viewer's (the host filtered them), so the viewer is their requester for « Mes demandes ». */\nexport function toTaskRecord(task: HostTask, viewer: string): HostTaskRecord {\n const ended = task.state === 'done' || task.state === 'closed' || task.status === 'abandoned' || task.status === 'shipped';\n const waiting = !ended && task.waiting_on_you === true;\n const created = stamp(task.created ?? task.created_at), updated = stamp(task.updated ?? task.updated_at), confirmed = stamp(task.confirmed_at), ref = task.ref ?? task.number, question = task.question ?? task.waiting_on?.question;\n const state: TaskState = task.status === 'abandoned' ? 'closed' : task.status === 'shipped' ? 'done' : waiting ? 'waiting' : STATES.includes(task.state as TaskState) ? task.state as TaskState : 'open', outcome = taskOutcome(state, task.closed_reason ?? (task.status === 'abandoned' ? 'withdrawn' : undefined));\n return {\n id: task.id, ...(ref ? { number: ref } : {}), title: task.title, ...(task.display ? { display: task.display } : {}), ...(task.technical === undefined ? {} : { technical: task.technical }), ...(outcome === 'withdrawn' ? { status: 'withdrawn' } : task.status ? { status: task.status } : {}), requesterId: viewer, waiting_on_you: waiting, state,\n ...(waiting ? { waiting_on: { who: viewer, type: 'person' as const, ...(question ? { question } : {}) } } : {}),\n ...(created ? { created_at: created } : {}), ...(updated ? { updated_at: updated } : {}), ...(confirmed ? { confirmed_at: confirmed } : {}),\n ...(task.is_requester === true ? { is_requester: true as const } : {}), ...(outcome ? { outcome } : {}),\n };\n}\n\n/** Member request vocabulary is shared by the thread card and « Mes demandes », not inferred from status prose. */\nexport function PmRequestStatus({ task, text }: { readonly task: TaskRecord; readonly text: PmText }) {\n const outcome = requestOutcome(task);\n const status = outcome === 'live' ? 'live' : outcome === 'withdrawn' ? 'withdrawn' : outcome === 'closed' ? 'closed'\n : task.waiting_on_you === true ? 'waiting' : task.status === 'blocked' ? 'blocked' : task.status === 'verifying' ? 'verifying' : 'preparing';\n const label = status === 'live' ? text.outcomeLive : status === 'withdrawn' ? text.outcomeWithdrawn : status === 'closed' ? text.outcomeClosed\n : status === 'waiting' ? text.awaiting : status === 'blocked' ? text.blocked : status === 'verifying' ? text.verifying : text.preparing;\n return <Badge data-task-status={status} variant={status === 'blocked' ? 'destructive' : 'secondary'}>{label}</Badge>;\n}\n\n/** `null`: no task source for this viewer just now (404 or 403). The hook reads again later; it never latches it (decided 2026-10-29). */\nexport async function readTasks(url: string, viewer: string, signal?: AbortSignal): Promise<readonly HostTaskRecord[] | null> {\n const response = await fetch(`${url}?mine=1`, { credentials: 'same-origin', ...(signal ? { signal } : {}) });\n if (response.status === 404 || response.status === 403) return null;\n if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);\n const body = await response.json() as { readonly tasks?: readonly HostTask[] };\n return (body.tasks ?? []).map(task => toTaskRecord(task, viewer));\n}\n\n/** Answers the question the task waits on, as the viewer. « Confirmer » is such an answer. */\nexport async function answerTask(url: string, id: string, text: string): Promise<void> {\n const response = await fetch(`${url}/${encodeURIComponent(id)}/answer`, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }), signal: AbortSignal.timeout(15_000) });\n if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);\n}\n\n/**\n * Withdraws the viewer's own request (`POST {tasks}/<id>/withdraw`, requester only): `withdrawn` on 200, whether its body is empty or\n * `{ task }` (the body is not read: the list is read again); `started` on 409, once work has started; `refused` otherwise (404 for a\n * non-requester, a network failure).\n */\nexport async function withdrawTask(url: string, id: string): Promise<'withdrawn' | 'started' | 'refused'> {\n const response = await fetch(`${url}/${encodeURIComponent(id)}/withdraw`, { method: 'POST', credentials: 'same-origin', signal: AbortSignal.timeout(15_000) }).catch(() => undefined);\n return response?.ok ? 'withdrawn' : response?.status === 409 ? 'started' : 'refused';\n}\n\n/** Withdraws only what the viewer filed and nobody started: `is_requester`, and `open` or waiting on the viewer. */\nexport const withdrawable = (task: TaskRecord) => (task as HostTaskRecord).is_requester === true && (task.state === 'open' || task.waiting_on_you === true);\n\n/** « Retirer » on the requester's card in « Mes demandes »: one tap asks « Retirer cette demande ? », the second withdraws. */\nfunction WithdrawAction({ task, failed, text, onWithdraw }: { readonly task: HostTaskRecord; readonly failed: boolean; readonly text: PmText; readonly onWithdraw: (task: HostTaskRecord) => Promise<void> }) {\n const [asking, setAsking] = useState(false), [busy, setBusy] = useState(false);\n const withdraw = () => { setBusy(true); void onWithdraw(task).finally(() => { setBusy(false); setAsking(false); }); };\n return <>\n {withdrawable(task) && (asking ? <>\n <p data-testid=\"pm-withdraw-ask\" className=\"col-span-2 m-0 flex min-h-0! items-center text-sm font-medium\">{text.withdrawAsk}</p>\n <Button variant=\"destructive\" data-testid=\"pm-withdraw-confirm\" disabled={busy} onClick={withdraw}>{text.withdraw}</Button>\n <Button variant=\"outline\" data-testid=\"pm-withdraw-cancel\" disabled={busy} onClick={() => setAsking(false)}>{text.cancel}</Button>\n </> : <Button variant=\"outline\" data-testid=\"pm-withdraw\" className=\"col-span-2\" onClick={() => setAsking(true)}>{text.withdraw}</Button>)}\n {failed && <p role=\"status\" data-testid=\"pm-withdraw-failed\" className=\"col-span-2 m-0 min-h-0! text-[13px] text-muted-foreground\">{text.withdrawFailed}</p>}\n </>;\n}\n\nconst REF = /\\bD-\\d+\\b/, UUID = /\\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\b/i;\n/** A D-ref or task id in a tool result: `details` fields first (`ref`, `number`, `id`, nested `task`), then the text. */\nfunction referenceIn(value: unknown): { readonly ref?: string; readonly id?: string } | undefined {\n if (typeof value === 'string') { const ref = REF.exec(value)?.[0], id = UUID.exec(value)?.[0]; return ref || id ? { ...(ref ? { ref } : {}), ...(id ? { id } : {}) } : undefined; }\n if (!value || typeof value !== 'object') return undefined;\n const record = value as Record<string, unknown>;\n for (const key of ['ref', 'number', 'id', 'task']) { const found = record[key] === undefined ? undefined : referenceIn(record[key]); if (found) return found; }\n return undefined;\n}\n\n/**\n * The PM's task tool (hachej/boring-factory#892): `content[0].text` is JSON `{ created, task: { id, number: 'D-n', title, state, status,\n * updated_at, waiting_on_you?, confirmed_at?, is_requester? } }`. With an id and a title, `task` draws the request card on its own.\n */\nexport const TASK_CREATE_TOOL = 'tasks_create';\nexport interface CreatedTask { readonly ref?: string; readonly id?: string; readonly task?: HostTask }\nfunction exactTask(message: { readonly toolName: string; readonly content: readonly { readonly type: string; readonly text?: string }[] }): CreatedTask | undefined {\n if (message.toolName !== TASK_CREATE_TOOL || message.content[0]?.type !== 'text') return undefined;\n try {\n const task = (JSON.parse(message.content[0].text ?? '') as { readonly task?: Record<string, unknown> }).task;\n const id = typeof task?.['id'] === 'string' ? task['id'] : undefined, ref = typeof task?.['number'] === 'string' ? task['number'] : undefined;\n if (!id && !ref) return undefined;\n const whole = id && typeof task?.['title'] === 'string' && typeof task['state'] === 'string' ? task as unknown as HostTask : undefined;\n return { ...(ref ? { ref } : {}), ...(id ? { id } : {}), ...(whole ? { task: whole } : {}) };\n } catch { return undefined; }\n}\n\n/**\n * The task a PM reply created, from a tool result run in the same turn before that reply: `tasks_create`'s JSON first; then, tolerant\n * (an older or renamed tool), any tool whose name contains `task` and whose result carries a D-ref or an id. Adjust here when it changes.\n */\nexport function createdTask(entries: readonly EntryRecord[], reply: EntryRecord): CreatedTask | undefined {\n const at = entries.findIndex(entry => entry.id === reply.id);\n for (let index = (at < 0 ? entries.length : at) - 1; index >= 0; index--) {\n for (const message of [...(entries[index]!.model ?? [])].reverse()) {\n if (message.role === 'user') return undefined;\n if (message.role !== 'toolResult' || message.isError || !/task/i.test(message.toolName)) continue;\n const found = exactTask(message) ?? referenceIn(message.details) ?? referenceIn(message.content.map(part => part.type === 'text' ? part.text : '').join('\\n'));\n if (found) return found;\n }\n }\n return undefined;\n}\n\n/** The request card's record: the `tasks_create` result merged with the list's record, the list winning on conflicts. */\nexport function requestRecord(made: CreatedTask, listed: readonly HostTaskRecord[], viewer: string): HostTaskRecord | undefined {\n const fromList = listed.find(each => each.id === made.id || (made.ref !== undefined && taskReference(each) === made.ref));\n const fromTool = made.task ? toTaskRecord(made.task, viewer) : undefined;\n if (!fromList || !fromTool) return fromList ?? fromTool;\n const { waiting_on: _, ...tool } = fromTool;\n return { ...tool, ...fromList };\n}\n\n/**\n * `spec.tasks` for the one line: read when `active`, again when the conversation grows (a PM reply may have created a task), on `refresh`\n * (the panel opens, « Mes demandes » opens), after an answer and after a withdraw refused with 409. A 403, 404 or network error is\n * « unavailable for now », never latched: the next read may answer (decided 2026-10-29). The request card is drawn from the `tasks_create`\n * result even before or without the list. A request withdrawn in this session is « Retirée » at once, before the host's `closed_reason` says\n * so, and its chat card goes; « Confirmer » shows « Envoyée » at once.\n */\nexport function useHostTasks(url: string | undefined, viewer: string | undefined, controller: ImproveSpec['controller'] | undefined, text: PmText): StudioTaskBoardSeam | undefined {\n const [read, setTasks] = useState<readonly HostTaskRecord[] | null>();\n // Withdrawn in this session: id → when, so the card moves to « Terminées » as « Retirée » (newest first) whatever the host says yet.\n const [withdrawn, setWithdrawn] = useState<ReadonlyMap<string, string>>(() => new Map());\n const [refused, setRefused] = useState<ReadonlySet<string>>(() => new Set());\n // « Envoyée » at once: confirmed, nobody started yet. Kept until the host's record says so (or the answer fails).\n const [confirmed, setConfirmed] = useState<ReadonlyMap<string, string>>(() => new Map());\n const [confirming, setConfirming] = useState<ReadonlySet<string>>(() => new Set());\n const [confirmFailed, setConfirmFailed] = useState<ReadonlySet<string>>(() => new Set());\n const pendingConfirm = useRef(new Set<string>());\n const settle = useCallback((task: HostTaskRecord): HostTaskRecord => {\n const gone = withdrawn.get(task.id);\n // The host has the last word once it says something newer: shipped, or reopened after the withdraw.\n const reopened = task.outcome === undefined && Date.parse(task.updated_at ?? '') > Date.parse(gone ?? '');\n if (gone !== undefined && task.outcome !== 'live' && !reopened) {\n const { waiting_on: _, is_requester: __, ...rest } = task;\n return { ...rest, state: 'closed', status: 'withdrawn', waiting_on_you: false, outcome: 'withdrawn', updated_at: task.state === 'closed' ? task.updated_at ?? gone : gone };\n }\n const at = confirmed.get(task.id);\n if (at === undefined || !task.waiting_on_you) return task;\n const { waiting_on: _, ...rest } = task;\n return { ...rest, state: 'open', waiting_on_you: false, confirmed_at: task.confirmed_at ?? at };\n }, [confirmed, withdrawn]);\n // The board answers a task's question: a requester's waiting request offers « Confirmer », anyone can answer in their own words.\n const tasks = useMemo(() => (read ?? []).map(settle).map((task): HostTaskRecord => {\n const question = task.waiting_on_you ? task.waiting_on?.question : undefined;\n return question ? { ...task, questions: [{ id: 'waiting', text: question, ...(task.is_requester ? { options: [text.confirm] } : {}) }] } : task;\n }), [read, settle, text.confirm]);\n const entries = useSyncExternalStore(controller?.subscribe ?? noSubscribe, () => controller?.getSnapshot().view?.entries.length ?? 0, () => 0);\n const [answered, setAnswered] = useState(0);\n const [asked, setAsked] = useState(0);\n const refresh = useCallback(() => setAsked(count => count + 1), []);\n useEffect(() => {\n if (!url || !viewer) return;\n const abort = new AbortController();\n // A network error keeps what was read before; 403 and 404 mean « unavailable for now ».\n const timer = setTimeout(() => { readTasks(url, viewer, abort.signal).then(setTasks, () => { if (!abort.signal.aborted) setTasks(current => current ?? null); }); }, 300);\n return () => { clearTimeout(timer); abort.abort(); };\n }, [url, viewer, entries, answered, asked]);\n const confirm = useCallback(async (task: TaskRecord, answer?: string) => {\n if (!url) return;\n if (pendingConfirm.current.has(task.id)) return;\n pendingConfirm.current.add(task.id);\n setConfirming(current => new Set(current).add(task.id));\n setConfirmFailed(current => { const next = new Set(current); next.delete(task.id); return next; });\n const done = await answerTask(url, task.id, answer ?? text.confirm).then(() => true, () => false);\n pendingConfirm.current.delete(task.id);\n setConfirming(current => { const next = new Set(current); next.delete(task.id); return next; });\n if (done) setConfirmed(current => new Map(current).set(task.id, task.confirmed_at ?? new Date().toISOString()));\n else setConfirmFailed(current => new Set(current).add(task.id));\n setAnswered(count => count + 1);\n }, [url, text.confirm]);\n const withdraw = useCallback(async (task: HostTaskRecord) => {\n if (!url) return;\n setRefused(current => { const next = new Set(current); next.delete(task.id); return next; });\n const outcome = await withdrawTask(url, task.id);\n if (outcome !== 'refused') setAnswered(count => count + 1);\n if (outcome === 'withdrawn') { setWithdrawn(current => new Map(current).set(task.id, new Date().toISOString())); return; }\n // Started meanwhile (409): the list is read again, so the card shows its real state.\n setRefused(current => new Set(current).add(task.id));\n }, [url]);\n return useMemo(() => !url || !viewer ? undefined : {\n tasks, currentUser: viewer, refresh, answer: (task: TaskRecord, value: string) => { void confirm(task, value); },\n // No task route in the one line: a card is the request, its answer happens in the PM chat.\n onOpen: () => undefined,\n cardActions: (task: TaskRecord) => withdrawable(task) || refused.has(task.id) ? <WithdrawAction key={task.id} task={task} failed={refused.has(task.id)} text={text} onWithdraw={withdraw} /> : undefined,\n renderRequest: ({ entry, edit }) => {\n const made = createdTask(controller?.getSnapshot().view?.entries ?? [], entry);\n const found = made && !withdrawn.has(made.id ?? '') ? requestRecord(made, tasks, viewer) : undefined;\n if (!found) return undefined;\n const task = settle(found);\n return <TaskRequestCard task={task} variant=\"compact\" locale={text.locale} pill={<PmRequestStatus task={task} text={text} />} actions={task.waiting_on_you ? <>\n <Button variant=\"outline\" data-testid=\"pm-request-modify\" disabled={confirming.has(task.id)} onClick={() => edit(task.title)}>{text.modify}</Button>\n <Button data-testid=\"pm-request-confirm\" disabled={confirming.has(task.id)} aria-busy={confirming.has(task.id) || undefined} onClick={() => { void confirm(task); }}>{confirming.has(task.id) ? text.confirming : text.confirm}</Button>\n {confirmFailed.has(task.id) && <p role=\"alert\" data-testid=\"pm-confirm-failed\" className=\"col-span-2 m-0 text-sm\">{text.confirmFailed}</p>}\n </> : undefined} />;\n },\n }, [url, tasks, viewer, text, confirm, withdraw, refused, withdrawn, settle, refresh, controller, confirming, confirmFailed]);\n}\nconst noSubscribe = () => () => undefined;\n",
|
|
105
105
|
"type": "registry:component",
|
|
106
106
|
"target": "components/pm-panel/pm-task-source.tsx"
|
|
107
107
|
},
|
package/r/provider-setup.json
CHANGED