@hachej/boring-ui-kit 0.4.31 → 0.4.32
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 +12 -11
- package/r/provider-setup.json +1 -1
- package/r/registry.json +180 -179
- package/r/requests-board.json +1 -1
- package/r/shared-chat.json +6 -6
- package/r/task-board.json +2 -2
- package/r/task-list-viewer.json +2 -2
- package/r/task-mentions.json +4 -4
- package/r/version-history.json +2 -2
- package/r/viewer-code.json +2 -2
- package/r/viewer-frame.json +4 -4
- package/r/viewer-html.json +4 -4
- package/r/viewer-image.json +2 -2
- package/r/viewer-markdown.json +5 -5
- package/r/viewer-pdf.json +3 -3
- package/r/viewers.json +6 -6
- package/r/windows.json +1 -1
package/r/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.32/r/pi-app.json",
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.32/r/pi-ambient.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.32/r/app-shell.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.32/r/utils.json"
|
|
16
16
|
],
|
|
17
17
|
"files": [
|
|
18
18
|
{
|
package/r/pm-panel.json
CHANGED
|
@@ -4,26 +4,27 @@
|
|
|
4
4
|
"title": "Shared PM composer and in-app panel",
|
|
5
5
|
"description": "« Améliorer l'app »: one PmComposer (touch and desktop presets, FR/EN strings) with « Montrer », the point strip, numbered pins and timestamped chips; AppShell improve composes the in-flow sheet, strip and the full panel, and PmChat gives Studio the same conversation. Pins and chips come from the public feedback picker; the request card and « Mes demandes » render through StudioTaskBoardSeam (D-417).",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-feedback@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.32",
|
|
8
|
+
"@hachej/boring-feedback@^0.4.32",
|
|
9
9
|
"@earendil-works/pi-durable@~1.4.0",
|
|
10
10
|
"lucide-react@^1.51.0",
|
|
11
11
|
"react@^19.0.0",
|
|
12
12
|
"react-dom@^19.0.0"
|
|
13
13
|
],
|
|
14
14
|
"registryDependencies": [
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
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.
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.32/r/pi-chat.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.32/r/pi-chat-markdown.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.32/r/pi-chat-plugins.json",
|
|
18
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.32/r/pi-chat-voice.json",
|
|
19
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.32/r/feedback.json",
|
|
20
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.32/r/task-board.json",
|
|
21
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.32/r/task-mentions.json",
|
|
22
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.32/r/utils.json"
|
|
22
23
|
],
|
|
23
24
|
"files": [
|
|
24
25
|
{
|
|
25
26
|
"path": "registry/pm-panel/pm-panel.tsx",
|
|
26
|
-
"content": "'use client';\n\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { PointerEvent as ReactPointerEvent, ReactNode } from 'react';\nimport { ChevronUpIcon, XIcon } from 'lucide-react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { PiChat } from '../pi-chat/pi-chat';\nimport { ChatTextProvider } from '../pi-chat/labels';\nimport { chatTextFor } from '../pi-chat/locale';\nimport { withChatPlugins } from '../pi-chat-plugins/chat-plugins';\nimport { cn } from '../utils/utils';\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 { 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 type { MentionsConfig } from '../pi-chat/config';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\n\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 title: string }\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\nconst PmContext = createContext<Binding | Pending | null>(null);\n\n/**\n * `spec` is either the host's own `ImproveSpec` or the one line `{ agent: 'pm' }`, which the kit resolves to that agent's conversation\n * through the app's door on the first open (`useImproveAgent`). `onFull` reports the full panel (C7) so the shell gives it its own Back step.\n */\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 title = pmTextFor(locale).title;\n const value = useMemo<Binding | Pending | null>(() => resolved.spec ? { spec: resolved.spec, open, close, onFull } : spec ? { open, close, problem: resolved.problem, title } : null, [resolved.spec, resolved.problem, spec, open, close, onFull, title]);\n return <PmContext.Provider value={value}>{children}</PmContext.Provider>;\n}\n\n/**\n * The app's content and tab bar remain in flow. The sheet (C2, C6) and the strip (C3–C5) take layout space, so the page shrinks and stays\n * scrollable and tappable; only the full panel (C7) overlays the page, from 40px below the top.\n */\nexport function ImproveLayout({ children, navigation, screen }: { readonly children: ReactNode; readonly navigation?: ReactNode; readonly screen?: string }) {\n const binding = useContext(PmContext);\n return <BoundLayout binding={binding} navigation={navigation} screen={screen}>{children}</BoundLayout>;\n}\n\nfunction BoundLayout({ binding: bound, children, navigation, screen }: { readonly binding: Binding | Pending | null; readonly children: ReactNode; readonly navigation?: ReactNode; readonly screen: string | undefined }) {\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 [placement, setPlacement] = useState({ controller, mode: 'sheet', show: false });\n const report = useCallback((controller: ImproveSpec['controller'], mode: string, show: boolean) => setPlacement(current => current.controller === controller && current.mode === mode && current.show === show ? current : { controller, mode, show }), []);\n const current = placement.controller === controller ? placement : { mode: 'sheet', show: false };\n const [opened, setOpened] = useState(false);\n if (binding?.open && !opened) setOpened(true);\n const strip = current.mode === 'point' || current.mode === 'navigate';\n return <div data-pm-layout=\"\" className=\"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden\">\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 {/* Mounted on the first open, then kept: the picks, pins and recording survive × (spec 2, draft persistence). */}\n {binding && opened && <PanelSession key={instance.sequence} binding={binding} page={page} screen={screen} report={report} />}\n {pending?.open && <section data-feedback-ignore=\"\" data-testid=\"pm-panel\" data-mode=\"connecting\" className=\"flex shrink-0 flex-col gap-1.5 rounded-t-pm-sheet bg-background px-3 pt-2 pb-6 shadow-pm-sheet\">\n <div className=\"flex items-center\"><h2 className=\"m-0 min-w-0 flex-1 truncate text-[13px] font-pm-title\">{pending.title}</h2>\n <Button variant=\"ghost\" size=\"icon\" aria-label=\"Fermer\" data-testid=\"pm-close\" onClick={pending.close} className=\"min-h-9 min-w-9\"><XIcon className=\"size-4\" aria-hidden=\"true\" /></Button></div>\n <p role={pending.problem ? 'alert' : 'status'} className=\"m-0 px-1 text-sm text-muted-foreground\">{pending.problem ?? 'Connexion…'}</p>\n </section>}\n {/* The tab bar shows under the strip and works normally; the sheet sits at the bottom instead of it. */}\n <div data-pm-navigation=\"\" hidden={Boolean(bound?.open && !strip)} className=\"shrink-0\">{navigation}</div>\n </div>;\n}\n\nfunction PanelSession({ binding, page, screen, report }: { readonly binding: Binding; readonly page: HTMLElement | null; readonly screen: string | undefined; readonly report: (controller: ImproveSpec['controller'], mode: string, show: boolean) => void }) {\n const pm = usePmPanel(binding.spec, binding.open, page, screen, { comment: useFinePointer() });\n const { mode, show } = pm.state;\n useLayoutEffect(() => { report(binding.spec.controller, mode, show); }, [binding.spec.controller, mode, show, report]);\n return <>\n <Panel binding={binding} pm={pm} strip={mode === 'point' || mode === 'navigate'} full={mode === 'full'} />\n {pm.comment && page && <ChatTextProvider value={chatTextFor(binding.spec.chatProps?.locale ?? 'fr')}><PointComment comment={pm.comment} frame={page} text={pm.text} /></ChatTextProvider>}\n </>;\n}\n\n/** The desktop preset's click to comment (spec 1 §3b) follows the pointer: web width without a touch screen as the main pointer, never a phone. */\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\n/** The grabber: drag up opens the full panel, drag down closes (the draft stays). */\nfunction Grabber({ wide, onUp, onDown }: { readonly wide: boolean; readonly onUp?: (() => void) | undefined; readonly onDown: () => void }) {\n const start = useRef<number | undefined>(undefined);\n const end = (event: ReactPointerEvent) => {\n if (start.current === undefined) return;\n const moved = event.clientY - start.current; start.current = undefined;\n if (moved < -24) onUp?.(); else if (moved > 24) onDown();\n };\n return <div data-testid=\"pm-grabber\" aria-hidden=\"true\" className=\"flex shrink-0 cursor-grab touch-none justify-center pb-1\" onPointerDown={event => { start.current = event.clientY; event.currentTarget.setPointerCapture(event.pointerId); }} onPointerUp={end} onPointerCancel={() => { start.current = undefined; }}>\n <div className={cn('h-1 rounded-pm-round', wide ? 'w-[100px] bg-border' : 'w-10 bg-pm-line')} />\n </div>;\n}\n\nfunction assistantText(entry: EntryRecord): string | undefined {\n const messages = entry.model?.filter(message => message.role === 'assistant');\n if (!messages?.length) return undefined;\n const text = messages.map(message => typeof message.content === 'string' ? message.content : message.content.filter(part => part.type === 'text').map(part => part.text).join('')).join('\\n').trim();\n return text || undefined;\n}\n\n/** The PM thread's entries: sent points with their chips, the PM's reply (plain text on touch, a bubble on desktop) and its request card. */\nexport function pmEntries({ preset, text, mentions, renderEntry, renderRequest, edit }: {\n readonly preset: PmPreset; readonly text: PmText; readonly mentions: MentionsConfig | undefined;\n readonly renderEntry?: ((entry: EntryRecord) => ReturnType<NonNullable<PiChatProps['renderEntry']>>) | undefined;\n readonly renderRequest?: StudioTaskBoardSeam['renderRequest']; readonly edit: (text: string) => void;\n}) {\n return (entry: EntryRecord) => {\n const sent = pointMessage(entry, mentions, { preset, text });\n if (sent) return sent;\n const own = renderEntry?.(entry);\n if (own) return own;\n const reply = assistantText(entry);\n if (reply === undefined) return undefined;\n return { inline: true, content: <div data-testid=\"pm-reply\" data-role=\"assistant\" className=\"flex flex-col items-start gap-3.5\">\n <p className={cn('m-0 whitespace-pre-wrap rounded-pm-bubble rounded-bl-pm-tail bg-pm-reply', preset === 'touch' ? 'text-[15px] leading-[1.45]' : 'max-w-[88%] px-3 py-[9px] text-sm leading-[1.45]')}>{reply}</p>\n {renderRequest?.({ entry, edit })}\n </div> };\n };\n}\n\n/**\n * The PM conversation for a column (Studio, desktop preset) or any host surface: the thread and the one composer, which the strip\n * replaces while pointing. `page` is the element the point layer picks in (Studio: the app's device frame).\n */\nexport function PmChat({ spec, page, screen, preset = 'desktop' }: { readonly spec: ImproveSpec; readonly page: HTMLElement | null; readonly screen?: string; readonly preset?: PmPreset }) {\n const pm = usePmPanel(spec, true, page, screen, { comment: preset === 'desktop' });\n const { text } = pm;\n const mentions = usePmMentions(spec.tasks, spec.chatProps?.mentions, text);\n const edit = useCallback((value: string) => { spec.controller.setText(value); }, [spec.controller]);\n const strip = pm.state.mode === 'point' || pm.state.mode === 'navigate';\n const props = { preset, text, selections: pm.state.chips, onRemove: pm.remove, show: pm.state.show, onShow: (value: boolean) => pm.dispatch({ type: 'show', value }),\n onPoint: () => pm.dispatch({ type: 'point' }), onReturn: pm.returnToComposer, onOpenRecorder: () => pm.dispatch({ type: 'return' }), focusInput: pm.focusVersion > 0 && !strip, voice: pm.voice, meta: pm.meta, notice: pm.sent ? text.sentToPm : undefined };\n return <ChatTextProvider value={chatTextFor(spec.chatProps?.locale ?? 'en')}><div data-testid=\"pm-chat\" data-mode={pm.state.mode} data-preset={preset} className={cn('flex min-h-0 flex-1 flex-col',\n '[&_[data-boring=pi-chat]]:bg-transparent [&_[data-testid=empty-state]]:hidden [&_[data-testid=transcript-scroll]>div:last-child]:flex [&_[data-testid=transcript-scroll]>div:last-child]:min-h-full [&_[data-testid=transcript-scroll]>div:last-child]:flex-col [&_[data-testid=transcript-scroll]>div:last-child]:justify-end [&_[data-boring=pi-chat]>footer]:px-3.5 [&_[data-boring=pi-chat]>footer]:pt-0 [&_[data-boring=pi-chat]>footer]:pb-3.5 [&_[data-testid=transcript]]:gap-2.5 [&_[data-testid=transcript-scroll]>div:last-child]:px-[18px] [&_[data-testid=transcript-scroll]>div:last-child]:py-4 [&_[data-testid=transcript-history]]:min-h-0 [&_[data-testid=transcript-history]>span:not([role])]:hidden [&_[data-testid=transcript-navigation]]:pointer-events-none [&_[data-testid=transcript-navigation]]:absolute [&_[data-testid=transcript-navigation]]:inset-x-0 [&_[data-testid=transcript-navigation]]:bottom-0 [&_[data-testid=transcript-navigation]_*]:pointer-events-auto')}>\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 <PiChat {...withChatPlugins({ ...spec.chatProps, ...(mentions ? { mentions } : {}) })} controller={spec.controller} locale={spec.chatProps?.locale ?? 'en'} header={false} emptyLayout=\"docked\"\n labels={{ ...spec.chatProps?.labels, placeholder: `${text.placeholderBefore}↖${text.placeholderAfter}`, add: text.attach, send: text.send }} onFiles={spec.chatProps?.onFiles ?? imageFiles}\n sendContext={pm.sendContext} renderEntry={pmEntries({ preset, text, mentions, renderEntry: spec.chatProps?.renderEntry, renderRequest: spec.tasks?.renderRequest, edit })}\n renderComposer={composer => strip ? <PmComposer variant=\"strip\" {...props} /> : <PmComposer {...props} composer={{ ...composer, sendBlocked: composer.sendBlocked || pm.voice.phase === 'processing' }} />} />\n {pm.comment && page && <PointComment comment={pm.comment} frame={page} text={text} />}\n </div></ChatTextProvider>;\n}\n\nfunction Panel({ binding, pm, strip, full }: { readonly binding: Binding; readonly pm: ReturnType<typeof usePmPanel>; readonly strip: boolean; readonly full: boolean }) {\n const { spec, open, close: closeLayer } = binding;\n const [requests, setRequests] = useState(false);\n // Closing leaves « Mes demandes »: the next full panel opens on the thread.\n const close = () => { setRequests(false); closeLayer(); };\n const mentions = usePmMentions(spec.tasks, spec.chatProps?.mentions, pm.text);\n const sheet = useRef<HTMLDivElement | null>(null);\n const submit = pm.sendContext;\n const { dispatch, text } = pm;\n // Plain-text sends have no context attachment, but still expand on observed native admission.\n // A comment sent from the popover keeps the strip (spec 1 §3b): only the composer's own sends open C7.\n const admitted = pm.snapshot.send.kind === 'admitted' && !pm.quietSend(pm.snapshot.send.attempt.draft.text) ? pm.snapshot.send.attempt.requestId : undefined;\n const previousAdmission = useRef<string | undefined>(undefined);\n // The full panel is its own Back step (full, then sheet, then closed): the shell owns the history entries.\n const { onFull } = binding;\n useEffect(() => { onFull?.(full, () => dispatch({ type: 'collapse' })); }, [full, onFull, dispatch]);\n useEffect(() => {\n if (admitted && admitted !== previousAdmission.current) dispatch({ type: 'full' });\n if (admitted) previousAdmission.current = admitted;\n }, [admitted, dispatch]);\n const uncertain = pm.snapshot.send.kind === 'unknown' ? pm.snapshot.send.attempt : undefined;\n const focusVersion = pm.focusVersion;\n useEffect(() => {\n if (!focusVersion || !open || strip) return;\n const input = sheet.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 }, [focusVersion, open, strip, spec.controller]);\n const edit = useCallback((value: string) => { spec.controller.setText(value); }, [spec.controller]);\n const pointProps = { preset: 'touch' as const, text, selections: pm.state.chips, onRemove: pm.remove, show: pm.state.show, onShow: (value: boolean) => pm.dispatch({ type: 'show', value }),\n onPoint: () => pm.dispatch({ type: 'point' }), onReturn: pm.returnToComposer, onOpenRecorder: () => pm.dispatch({ type: 'return' }), focusInput: focusVersion > 0 && open && !strip, voice: pm.voice, meta: pm.meta, notice: pm.sent ? text.sentToPm : undefined };\n const chatText = chatTextFor(spec.chatProps?.locale ?? 'fr');\n const review = !full && pm.meta !== undefined;\n const transcript = pm.voice.phase === 'processing' ? <p data-testid=\"pm-transcribing\" role=\"status\" className=\"m-0 px-2\">Transcription…</p> : pm.liveText && <p role=\"status\" data-testid=\"pm-live-transcript\" className=\"m-0 px-2\">{pm.liveText}</p>;\n const renderEntry = pmEntries({ preset: 'touch', text, mentions, renderEntry: spec.chatProps?.renderEntry, renderRequest: spec.tasks?.renderRequest, edit });\n const header = full ? <>\n <div className=\"flex shrink-0 items-center gap-2.5 py-2 pr-2 pl-4\">\n <div className=\"min-w-0 flex-1\">\n <h2 data-testid=\"pm-title\" className=\"m-0 truncate text-[15px] leading-tight font-pm-title\">{text.title}</h2>\n <p className=\"m-0 truncate text-xs text-muted-foreground\">{text.subtitle(spec.appName ?? spec.label ?? '')}</p>\n </div>\n <span className=\"flex shrink-0 [&_button]:px-0 [&_button]:text-sm [&_button]:font-semibold [&_button]:text-foreground [&_button]:underline\"><Button variant=\"link\" data-testid=\"pm-requests\" disabled={!spec.tasks} onClick={() => setRequests(value => !value)} className=\"min-h-11\">{text.requests}</Button></span>\n <Button variant=\"ghost\" size=\"icon\" aria-label={text.close} title={text.close} data-testid=\"pm-close\" onClick={close} className=\"min-h-11 min-w-11\"><XIcon className=\"size-[18px]\" aria-hidden=\"true\" /></Button>\n </div>\n <Separator />\n </> : review ? <div className=\"flex shrink-0 items-center px-1 py-0.5\">\n <h2 data-testid=\"pm-title\" className=\"m-0 min-w-0 flex-1 truncate text-sm font-pm-title\">{text.title}</h2>\n <span data-testid=\"pm-review-hint\" className=\"text-xs text-muted-foreground\">{text.review}</span>\n </div> : <div className=\"flex shrink-0 items-center\">\n <h2 data-testid=\"pm-title\" className=\"m-0 min-w-0 flex-1 truncate text-[13px] font-pm-title\">{text.title}</h2>\n <Button variant=\"ghost\" size=\"icon\" aria-label={text.openPanel} title={text.openPanel} data-testid=\"pm-expand\" onClick={() => pm.dispatch({ type: 'full' })} className=\"min-h-9 min-w-9\"><ChevronUpIcon className=\"size-[18px]\" aria-hidden=\"true\" /></Button>\n <Button variant=\"ghost\" size=\"icon\" aria-label={text.close} title={text.close} data-testid=\"pm-close\" onClick={close} className=\"min-h-9 min-w-9\"><XIcon className=\"size-4\" aria-hidden=\"true\" /></Button>\n </div>;\n return <section hidden={!open} data-feedback-ignore=\"\" data-testid=\"pm-panel\" data-mode={pm.state.mode} data-review={review || undefined}\n className={cn(open && !strip && (full ? 'absolute inset-x-0 top-10 bottom-0 z-40 flex flex-col' : 'flex max-h-[85dvh] min-h-0 shrink-0 flex-col'), strip && 'shrink-0')}>\n <p role=\"status\" aria-live=\"polite\" data-testid=\"pm-announce\" className=\"sr-only\">{pm.announcement}</p>\n <ChatTextProvider value={chatText}>\n <div hidden={!strip}><PmComposer variant=\"strip\" {...pointProps} /></div>\n <div hidden={strip} ref={sheet} data-testid=\"pm-sheet\" className={cn('min-h-0 flex-1 flex-col rounded-t-pm-sheet bg-background', strip ? 'hidden' : 'flex', full ? 'shadow-pm-panel pt-2' : 'gap-1.5 px-3 pt-2 pb-6 shadow-pm-sheet')}>\n <Grabber wide={full} onUp={full ? undefined : () => pm.dispatch({ type: 'full' })} onDown={close} />\n {requests && full ? <>\n <div className=\"flex shrink-0 items-center py-1 pr-2 pl-2\"><Button variant=\"ghost\" data-testid=\"pm-requests-back\" onClick={() => setRequests(false)} className=\"min-h-11\">{text.back}</Button></div>\n <Separator />\n <div className=\"min-h-0 flex-1 overflow-auto\" data-testid=\"pm-task-board-seam\">{spec.tasks && <TaskBoard tasks={spec.tasks.tasks} variant=\"compact\" filter=\"mine\" currentUser={spec.tasks.currentUser} bar={false} title={text.requests}\n onOpen={spec.tasks.onOpen} cardActions={spec.tasks.cardActions} locale={text.locale} viewerAction=\"confirmation\" className=\"p-4\" />}</div>\n </> : header}\n {pm.problem && <p role=\"alert\" className=\"m-0 px-4 text-sm\">{pm.problem}</p>}\n <div hidden={requests && full} className={cn('min-h-0 flex-1 flex-col', requests && full ? 'hidden' : 'flex',\n full ? '[&_[data-boring=pi-chat]>footer]:px-4 [&_[data-boring=pi-chat]>footer]:pb-6 [&_[data-testid=transcript]]:gap-3.5 [&_[data-testid=transcript]]:text-[15px] [&_[data-testid=transcript-scroll]>div:last-child]:p-4 [&_[data-testid=transcript-history]]:min-h-0 [&_[data-testid=transcript-history]>span:not([role])]:hidden [&_[data-testid=transcript-navigation]]:pointer-events-none [&_[data-testid=transcript-navigation]]:absolute [&_[data-testid=transcript-navigation]]:inset-x-0 [&_[data-testid=transcript-navigation]]:bottom-0 [&_[data-testid=transcript-navigation]_*]:pointer-events-auto'\n : '[&_[data-testid=transcript]]:hidden [&_[data-boring=pi-chat]>.relative]:hidden [&_[data-boring=pi-chat]>footer]:p-0')}>\n <PiChat {...withChatPlugins({ ...spec.chatProps, ...(mentions ? { mentions } : {}) })} controller={spec.controller} locale={spec.chatProps?.locale ?? 'fr'} header={false} emptyLayout=\"docked\" keyboardInset={false}\n labels={{ ...spec.chatProps?.labels, placeholder: `${text.placeholderBefore}↖${text.placeholderAfter}`, add: text.attach, send: text.send }} onFiles={spec.chatProps?.onFiles ?? imageFiles}\n sendContext={submit} renderEntry={renderEntry} renderComposer={composer => <PmComposer {...pointProps} composer={{ ...composer, sendBlocked: composer.sendBlocked || pm.voice.phase === 'processing' || pm.snapshot.send.kind === 'validating' || pm.snapshot.send.kind === 'submitting', ...(uncertain ? { text: uncertain.draft.text.split(POINT_PREFACE)[0]!.trimEnd(), disabled: true } : {}) }} transcript={transcript} />} />\n </div>\n </div>\n </ChatTextProvider>\n </section>;\n}\n",
|
|
27
|
+
"content": "'use client';\n\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { PointerEvent as ReactPointerEvent, ReactNode } from 'react';\nimport { ChevronUpIcon, XIcon } from 'lucide-react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { PiChat } from '../pi-chat/pi-chat';\nimport { ChatTextProvider } from '../pi-chat/labels';\nimport { chatTextFor } from '../pi-chat/locale';\nimport { withChatPlugins } from '../pi-chat-plugins/chat-plugins';\nimport { Markdown } from '../pi-chat-markdown/markdown';\nimport { cn } from '../utils/utils';\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 { 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 type { MentionsConfig } from '../pi-chat/config';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\n\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 title: string }\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\nconst PmContext = createContext<Binding | Pending | null>(null);\n\n/**\n * `spec` is either the host's own `ImproveSpec` or the one line `{ agent: 'pm' }`, which the kit resolves to that agent's conversation\n * through the app's door on the first open (`useImproveAgent`). `onFull` reports the full panel (C7) so the shell gives it its own Back step.\n */\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 title = pmTextFor(locale).title;\n const value = useMemo<Binding | Pending | null>(() => resolved.spec ? { spec: resolved.spec, open, close, onFull } : spec ? { open, close, problem: resolved.problem, title } : null, [resolved.spec, resolved.problem, spec, open, close, onFull, title]);\n return <PmContext.Provider value={value}>{children}</PmContext.Provider>;\n}\n\n/**\n * The app's content and tab bar remain in flow. The sheet (C2, C6) and the strip (C3–C5) take layout space, so the page shrinks and stays\n * scrollable and tappable; only the full panel (C7) overlays the page, from 40px below the top.\n */\nexport function ImproveLayout({ children, navigation, screen }: { readonly children: ReactNode; readonly navigation?: ReactNode; readonly screen?: string }) {\n const binding = useContext(PmContext);\n return <BoundLayout binding={binding} navigation={navigation} screen={screen}>{children}</BoundLayout>;\n}\n\nfunction BoundLayout({ binding: bound, children, navigation, screen }: { readonly binding: Binding | Pending | null; readonly children: ReactNode; readonly navigation?: ReactNode; readonly screen: string | undefined }) {\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 [placement, setPlacement] = useState({ controller, mode: 'sheet', show: false });\n const report = useCallback((controller: ImproveSpec['controller'], mode: string, show: boolean) => setPlacement(current => current.controller === controller && current.mode === mode && current.show === show ? current : { controller, mode, show }), []);\n const current = placement.controller === controller ? placement : { mode: 'sheet', show: false };\n const [opened, setOpened] = useState(false);\n if (binding?.open && !opened) setOpened(true);\n const strip = current.mode === 'point' || current.mode === 'navigate';\n return <div data-pm-layout=\"\" className=\"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden\">\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 {/* Mounted on the first open, then kept: the picks, pins and recording survive × (spec 2, draft persistence). */}\n {binding && opened && <PanelSession key={instance.sequence} binding={binding} page={page} screen={screen} report={report} />}\n {pending?.open && <section data-feedback-ignore=\"\" data-testid=\"pm-panel\" data-mode=\"connecting\" className=\"flex shrink-0 flex-col gap-1.5 rounded-t-pm-sheet bg-background px-3 pt-2 pb-6 shadow-pm-sheet\">\n <div className=\"flex items-center\"><h2 className=\"m-0 min-w-0 flex-1 truncate text-[13px] font-pm-title\">{pending.title}</h2>\n <Button variant=\"ghost\" size=\"icon\" aria-label=\"Fermer\" data-testid=\"pm-close\" onClick={pending.close} className=\"min-h-9 min-w-9\"><XIcon className=\"size-4\" aria-hidden=\"true\" /></Button></div>\n <p role={pending.problem ? 'alert' : 'status'} className=\"m-0 px-1 text-sm text-muted-foreground\">{pending.problem ?? 'Connexion…'}</p>\n </section>}\n {/* The tab bar shows under the strip and works normally; the sheet sits at the bottom instead of it. */}\n <div data-pm-navigation=\"\" hidden={Boolean(bound?.open && !strip)} className=\"shrink-0\">{navigation}</div>\n </div>;\n}\n\nfunction PanelSession({ binding, page, screen, report }: { readonly binding: Binding; readonly page: HTMLElement | null; readonly screen: string | undefined; readonly report: (controller: ImproveSpec['controller'], mode: string, show: boolean) => void }) {\n const pm = usePmPanel(binding.spec, binding.open, page, screen, { comment: useFinePointer() });\n const { mode, show } = pm.state;\n useLayoutEffect(() => { report(binding.spec.controller, mode, show); }, [binding.spec.controller, mode, show, report]);\n return <>\n <Panel binding={binding} pm={pm} strip={mode === 'point' || mode === 'navigate'} full={mode === 'full'} />\n {pm.comment && page && <ChatTextProvider value={chatTextFor(binding.spec.chatProps?.locale ?? 'fr')}><PointComment comment={pm.comment} frame={page} text={pm.text} /></ChatTextProvider>}\n </>;\n}\n\n/** The desktop preset's click to comment (spec 1 §3b) follows the pointer: web width without a touch screen as the main pointer, never a phone. */\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\n/** The grabber: drag up opens the full panel, drag down closes (the draft stays). */\nfunction Grabber({ wide, onUp, onDown }: { readonly wide: boolean; readonly onUp?: (() => void) | undefined; readonly onDown: () => void }) {\n const start = useRef<number | undefined>(undefined);\n const end = (event: ReactPointerEvent) => {\n if (start.current === undefined) return;\n const moved = event.clientY - start.current; start.current = undefined;\n if (moved < -24) onUp?.(); else if (moved > 24) onDown();\n };\n return <div data-testid=\"pm-grabber\" aria-hidden=\"true\" className=\"flex shrink-0 cursor-grab touch-none justify-center pb-1\" onPointerDown={event => { start.current = event.clientY; event.currentTarget.setPointerCapture(event.pointerId); }} onPointerUp={end} onPointerCancel={() => { start.current = undefined; }}>\n <div className={cn('h-1 rounded-pm-round', wide ? 'w-[100px] bg-border' : 'w-10 bg-pm-line')} />\n </div>;\n}\n\nfunction assistantText(entry: EntryRecord): string | undefined {\n const messages = entry.model?.filter(message => message.role === 'assistant');\n if (!messages?.length) return undefined;\n const text = messages.map(message => typeof message.content === 'string' ? message.content : message.content.filter(part => part.type === 'text').map(part => part.text).join('')).join('\\n').trim();\n return text || undefined;\n}\nconst failedRun = (entry: EntryRecord) => entry.model?.filter(message => message.role === 'assistant').at(-1)?.stopReason === 'error';\n\n/**\n * The PM thread's entries: sent points with their chips, the PM's reply (Markdown; plain text on touch, a bubble on desktop) and its\n * request card. `member` is the app panel's member thread (C7): a run that ends in error without a reply shows one muted line.\n */\nexport function pmEntries({ preset, text, mentions, renderEntry, renderRequest, edit, member = false }: {\n readonly preset: PmPreset; readonly text: PmText; readonly mentions: MentionsConfig | undefined;\n readonly renderEntry?: ((entry: EntryRecord) => ReturnType<NonNullable<PiChatProps['renderEntry']>>) | undefined;\n readonly renderRequest?: StudioTaskBoardSeam['renderRequest']; readonly edit: (text: string) => void; readonly member?: boolean;\n}) {\n return (entry: EntryRecord) => {\n const sent = pointMessage(entry, mentions, { preset, text });\n if (sent) return sent;\n const own = renderEntry?.(entry);\n if (own) return own;\n const reply = assistantText(entry);\n if (reply === undefined) return member && failedRun(entry) ? { inline: true, content: <p data-testid=\"pm-failed\" className=\"m-0 text-sm text-muted-foreground\">{text.failed}</p> } : undefined;\n // Headings stay at body size inside the panel.\n return { inline: true, content: <div data-testid=\"pm-reply\" data-role=\"assistant\" className=\"flex flex-col items-start gap-3.5\">\n <div className={cn('rounded-pm-bubble rounded-bl-pm-tail bg-pm-reply [&_:is(h1,h2,h3,h4,h5,h6)]:text-[length:inherit]', preset === 'touch' ? 'text-[15px] leading-[1.45]' : 'max-w-[88%] px-3 py-[9px] text-sm leading-[1.45]')}><Markdown text={reply} /></div>\n {renderRequest?.({ entry, edit })}\n </div> };\n };\n}\n\n/**\n * The PM conversation for a column (Studio, desktop preset) or any host surface: the thread and the one composer, which the strip\n * replaces while pointing. `page` is the element the point layer picks in (Studio: the app's device frame).\n */\nexport function PmChat({ spec, page, screen, preset = 'desktop' }: { readonly spec: ImproveSpec; readonly page: HTMLElement | null; readonly screen?: string; readonly preset?: PmPreset }) {\n const pm = usePmPanel(spec, true, page, screen, { comment: preset === 'desktop' });\n const { text } = pm;\n const mentions = usePmMentions(spec.tasks, spec.chatProps?.mentions, text);\n const edit = useCallback((value: string) => { spec.controller.setText(value); }, [spec.controller]);\n const strip = pm.state.mode === 'point' || pm.state.mode === 'navigate';\n const props = { preset, text, selections: pm.state.chips, onRemove: pm.remove, show: pm.state.show, onShow: (value: boolean) => pm.dispatch({ type: 'show', value }),\n onPoint: () => pm.dispatch({ type: 'point' }), onReturn: pm.returnToComposer, onOpenRecorder: () => pm.dispatch({ type: 'return' }), focusInput: pm.focusVersion > 0 && !strip, voice: pm.voice, meta: pm.meta, notice: pm.sent ? text.sentToPm : undefined };\n return <ChatTextProvider value={chatTextFor(spec.chatProps?.locale ?? 'en')}><div data-testid=\"pm-chat\" data-mode={pm.state.mode} data-preset={preset} className={cn('flex min-h-0 flex-1 flex-col',\n '[&_[data-boring=pi-chat]]:bg-transparent [&_[data-testid=empty-state]]:hidden [&_[data-testid=transcript-scroll]>div:last-child]:flex [&_[data-testid=transcript-scroll]>div:last-child]:min-h-full [&_[data-testid=transcript-scroll]>div:last-child]:flex-col [&_[data-testid=transcript-scroll]>div:last-child]:justify-end [&_[data-boring=pi-chat]>footer]:px-3.5 [&_[data-boring=pi-chat]>footer]:pt-0 [&_[data-boring=pi-chat]>footer]:pb-3.5 [&_[data-testid=transcript]]:gap-2.5 [&_[data-testid=transcript-scroll]>div:last-child]:px-[18px] [&_[data-testid=transcript-scroll]>div:last-child]:py-4 [&_[data-testid=transcript-history]]:min-h-0 [&_[data-testid=transcript-history]>span:not([role])]:hidden [&_[data-testid=transcript-navigation]]:pointer-events-none [&_[data-testid=transcript-navigation]]:absolute [&_[data-testid=transcript-navigation]]:inset-x-0 [&_[data-testid=transcript-navigation]]:bottom-0 [&_[data-testid=transcript-navigation]_*]:pointer-events-auto')}>\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 <PiChat {...withChatPlugins({ ...spec.chatProps, ...(mentions ? { mentions } : {}) })} controller={spec.controller} locale={spec.chatProps?.locale ?? 'en'} header={false} emptyLayout=\"docked\"\n labels={{ ...spec.chatProps?.labels, placeholder: `${text.placeholderBefore}↖${text.placeholderAfter}`, add: text.attach, send: text.send }} onFiles={spec.chatProps?.onFiles ?? imageFiles}\n sendContext={pm.sendContext} renderEntry={pmEntries({ preset, text, mentions, renderEntry: spec.chatProps?.renderEntry, renderRequest: spec.tasks?.renderRequest, edit })}\n renderComposer={composer => strip ? <PmComposer variant=\"strip\" {...props} /> : <PmComposer {...props} composer={{ ...composer, sendBlocked: composer.sendBlocked || pm.voice.phase === 'processing' }} />} />\n {pm.comment && page && <PointComment comment={pm.comment} frame={page} text={text} />}\n </div></ChatTextProvider>;\n}\n\nfunction Panel({ binding, pm, strip, full }: { readonly binding: Binding; readonly pm: ReturnType<typeof usePmPanel>; readonly strip: boolean; readonly full: boolean }) {\n const { spec, open, close: closeLayer } = binding;\n const [requests, setRequests] = useState(false);\n // Closing leaves « Mes demandes »: the next full panel opens on the thread.\n const close = () => { setRequests(false); closeLayer(); };\n const mentions = usePmMentions(spec.tasks, spec.chatProps?.mentions, pm.text);\n const sheet = useRef<HTMLDivElement | null>(null);\n const submit = pm.sendContext;\n const { dispatch, text } = pm;\n // Plain-text sends have no context attachment, but still expand on observed native admission.\n // A comment sent from the popover keeps the strip (spec 1 §3b): only the composer's own sends open C7.\n const admitted = pm.snapshot.send.kind === 'admitted' && !pm.quietSend(pm.snapshot.send.attempt.draft.text) ? pm.snapshot.send.attempt.requestId : undefined;\n const previousAdmission = useRef<string | undefined>(undefined);\n // The full panel is its own Back step (full, then sheet, then closed): the shell owns the history entries.\n const { onFull } = binding;\n useEffect(() => { onFull?.(full, () => dispatch({ type: 'collapse' })); }, [full, onFull, dispatch]);\n useEffect(() => {\n if (admitted && admitted !== previousAdmission.current) dispatch({ type: 'full' });\n if (admitted) previousAdmission.current = admitted;\n }, [admitted, dispatch]);\n const uncertain = pm.snapshot.send.kind === 'unknown' ? pm.snapshot.send.attempt : undefined;\n const focusVersion = pm.focusVersion;\n useEffect(() => {\n if (!focusVersion || !open || strip) return;\n const input = sheet.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 }, [focusVersion, open, strip, spec.controller]);\n const edit = useCallback((value: string) => { spec.controller.setText(value); }, [spec.controller]);\n const pointProps = { preset: 'touch' as const, text, selections: pm.state.chips, onRemove: pm.remove, show: pm.state.show, onShow: (value: boolean) => pm.dispatch({ type: 'show', value }),\n onPoint: () => pm.dispatch({ type: 'point' }), onReturn: pm.returnToComposer, onOpenRecorder: () => pm.dispatch({ type: 'return' }), focusInput: focusVersion > 0 && open && !strip, voice: pm.voice, meta: pm.meta, notice: pm.sent ? text.sentToPm : undefined };\n const chatText = chatTextFor(spec.chatProps?.locale ?? 'fr');\n const review = !full && pm.meta !== undefined;\n const transcript = pm.voice.phase === 'processing' ? <p data-testid=\"pm-transcribing\" role=\"status\" className=\"m-0 px-2\">Transcription…</p> : pm.liveText && <p role=\"status\" data-testid=\"pm-live-transcript\" className=\"m-0 px-2\">{pm.liveText}</p>;\n const renderEntry = pmEntries({ preset: 'touch', text, mentions, renderEntry: spec.chatProps?.renderEntry, renderRequest: spec.tasks?.renderRequest, edit, member: true });\n const header = full ? <>\n <div className=\"flex shrink-0 items-center gap-2.5 py-2 pr-2 pl-4\">\n <div className=\"min-w-0 flex-1\">\n <h2 data-testid=\"pm-title\" className=\"m-0 truncate text-[15px] leading-tight font-pm-title\">{text.title}</h2>\n <p className=\"m-0 truncate text-xs text-muted-foreground\">{text.subtitle(spec.appName ?? spec.label ?? '')}</p>\n </div>\n <span className=\"flex shrink-0 [&_button]:px-0 [&_button]:text-sm [&_button]:font-semibold [&_button]:text-foreground [&_button]:underline\"><Button variant=\"link\" data-testid=\"pm-requests\" disabled={!spec.tasks} onClick={() => setRequests(value => !value)} className=\"min-h-11\">{text.requests}</Button></span>\n <Button variant=\"ghost\" size=\"icon\" aria-label={text.close} title={text.close} data-testid=\"pm-close\" onClick={close} className=\"min-h-11 min-w-11\"><XIcon className=\"size-[18px]\" aria-hidden=\"true\" /></Button>\n </div>\n <Separator />\n </> : review ? <div className=\"flex shrink-0 items-center px-1 py-0.5\">\n <h2 data-testid=\"pm-title\" className=\"m-0 min-w-0 flex-1 truncate text-sm font-pm-title\">{text.title}</h2>\n <span data-testid=\"pm-review-hint\" className=\"text-xs text-muted-foreground\">{text.review}</span>\n </div> : <div className=\"flex shrink-0 items-center\">\n <h2 data-testid=\"pm-title\" className=\"m-0 min-w-0 flex-1 truncate text-[13px] font-pm-title\">{text.title}</h2>\n <Button variant=\"ghost\" size=\"icon\" aria-label={text.openPanel} title={text.openPanel} data-testid=\"pm-expand\" onClick={() => pm.dispatch({ type: 'full' })} className=\"min-h-9 min-w-9\"><ChevronUpIcon className=\"size-[18px]\" aria-hidden=\"true\" /></Button>\n <Button variant=\"ghost\" size=\"icon\" aria-label={text.close} title={text.close} data-testid=\"pm-close\" onClick={close} className=\"min-h-9 min-w-9\"><XIcon className=\"size-4\" aria-hidden=\"true\" /></Button>\n </div>;\n return <section hidden={!open} data-feedback-ignore=\"\" data-testid=\"pm-panel\" data-mode={pm.state.mode} data-review={review || undefined}\n className={cn(open && !strip && (full ? 'absolute inset-x-0 top-10 bottom-0 z-40 flex flex-col' : 'flex max-h-[85dvh] min-h-0 shrink-0 flex-col'), strip && 'shrink-0')}>\n <p role=\"status\" aria-live=\"polite\" data-testid=\"pm-announce\" className=\"sr-only\">{pm.announcement}</p>\n <ChatTextProvider value={chatText}>\n <div hidden={!strip}><PmComposer variant=\"strip\" {...pointProps} /></div>\n <div hidden={strip} ref={sheet} data-testid=\"pm-sheet\" className={cn('min-h-0 flex-1 flex-col rounded-t-pm-sheet bg-background', strip ? 'hidden' : 'flex', full ? 'shadow-pm-panel pt-2' : 'gap-1.5 px-3 pt-2 pb-6 shadow-pm-sheet')}>\n <Grabber wide={full} onUp={full ? undefined : () => pm.dispatch({ type: 'full' })} onDown={close} />\n {requests && full ? <>\n <div className=\"flex shrink-0 items-center py-1 pr-2 pl-2\"><Button variant=\"ghost\" data-testid=\"pm-requests-back\" onClick={() => setRequests(false)} className=\"min-h-11\">{text.back}</Button></div>\n <Separator />\n <div className=\"min-h-0 flex-1 overflow-auto\" data-testid=\"pm-task-board-seam\">{spec.tasks && <TaskBoard tasks={spec.tasks.tasks} variant=\"compact\" filter=\"mine\" currentUser={spec.tasks.currentUser} bar={false} title={text.requests}\n onOpen={spec.tasks.onOpen} cardActions={spec.tasks.cardActions} locale={text.locale} viewerAction=\"confirmation\" className=\"p-4\" />}</div>\n </> : header}\n {pm.problem && <p role=\"alert\" className=\"m-0 px-4 text-sm\">{pm.problem}</p>}\n <div hidden={requests && full} className={cn('min-h-0 flex-1 flex-col', requests && full ? 'hidden' : 'flex',\n full ? '[&_[data-boring=pi-chat]>footer]:px-4 [&_[data-boring=pi-chat]>footer]:pb-6 [&_[data-testid=transcript]]:gap-3.5 [&_[data-testid=transcript]]:text-[15px] [&_[data-testid=transcript-scroll]>div:last-child]:p-4 [&_[data-testid=transcript-history]]:min-h-0 [&_[data-testid=transcript-history]>span:not([role])]:hidden [&_[data-testid=transcript-navigation]]:pointer-events-none [&_[data-testid=transcript-navigation]]:absolute [&_[data-testid=transcript-navigation]]:inset-x-0 [&_[data-testid=transcript-navigation]]:bottom-0 [&_[data-testid=transcript-navigation]_*]:pointer-events-auto'\n : '[&_[data-testid=transcript]]:hidden [&_[data-boring=pi-chat]>.relative]:hidden [&_[data-boring=pi-chat]>footer]:p-0')}>\n <PiChat {...withChatPlugins({ ...spec.chatProps, ...(mentions ? { mentions } : {}) })} controller={spec.controller} locale={spec.chatProps?.locale ?? 'fr'} header={false} emptyLayout=\"docked\" keyboardInset={false} presentation=\"natural\"\n labels={{ ...spec.chatProps?.labels, placeholder: `${text.placeholderBefore}↖${text.placeholderAfter}`, add: text.attach, send: text.send }} onFiles={spec.chatProps?.onFiles ?? imageFiles}\n sendContext={submit} renderEntry={renderEntry} renderComposer={composer => <PmComposer {...pointProps} composer={{ ...composer, sendBlocked: composer.sendBlocked || pm.voice.phase === 'processing' || pm.snapshot.send.kind === 'validating' || pm.snapshot.send.kind === 'submitting', ...(uncertain ? { text: uncertain.draft.text.split(POINT_PREFACE)[0]!.trimEnd(), disabled: true } : {}) }} transcript={transcript} />} />\n </div>\n </div>\n </ChatTextProvider>\n </section>;\n}\n",
|
|
27
28
|
"type": "registry:component",
|
|
28
29
|
"target": "components/pm-panel/pm-panel.tsx"
|
|
29
30
|
},
|
|
@@ -83,7 +84,7 @@
|
|
|
83
84
|
},
|
|
84
85
|
{
|
|
85
86
|
"path": "registry/pm-panel/pm-text.ts",
|
|
86
|
-
"content": "/** Spec 1 §9 and spec 2's strings, character for character: FR for the app, EN for Studio. */\nexport interface PmText {\n readonly locale: 'fr' | 'en';\n readonly placeholderBefore: string;\n readonly placeholderAfter: string;\n readonly hintIdle: string;\n readonly hintRec: string;\n /** The touch strip's C4 hint when the whole one would take more than two lines (spec 1 §2). */\n readonly hintRecShort: string;\n readonly mode: string;\n readonly select: string;\n readonly navigate: string;\n readonly done: string;\n readonly doneLabel: string;\n readonly speak: string;\n readonly recorder: string;\n readonly attach: string;\n readonly dictate: string;\n readonly send: string;\n readonly remove: string;\n readonly show: string;\n readonly showTitle: string;\n readonly meta: (seconds: number | undefined, captures: number) => string;\n readonly sentMeta: (seconds: number | undefined, captures: number) => string;\n readonly mentionHeader: (query: string) => string;\n readonly onYou: string;\n readonly added: (n: number, kind: string, label: string) => string;\n readonly removed: (n: number) => string;\n readonly title: string;\n readonly openPanel: string;\n readonly close: string;\n readonly review: string;\n readonly requests: string;\n readonly back: string;\n readonly subtitle: (app: string) => string;\n /** Click to comment (desktop, spec 1 §3b). */\n readonly commentOn: (kind: string, label: string) => string;\n readonly commentPlaceholder: string;\n readonly addToMessage: string;\n readonly sendToPm: string;\n readonly sentToPm: string;\n /** The request card's two answers (C7): « Modifier » goes back to the composer, « Confirmer » is the viewer's answer to the task. */\n readonly modify: string;\n readonly confirm: string;\n}\n\n/** `0:14`: whole seconds, never a fraction. */\nexport const clock = (seconds: number): string => {\n const whole = Math.max(0, Math.floor(seconds));\n return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`;\n};\nconst captures = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;\n\nexport const pmTextFr: PmText = {\n locale: 'fr',\n placeholderBefore: 'Décrivez le problème, ou ', placeholderAfter: ' pour me le montrer.',\n hintIdle: 'Touchez ce que vous voulez montrer', hintRec: 'Parlez et touchez ce que vous montrez', hintRecShort: 'Parlez et touchez',\n mode: 'Mode', select: 'Sélectionner', navigate: 'Naviguer', done: 'Terminer', doneLabel: 'Terminer : relire dans le composer', speak: 'Parler',\n recorder: \"Ouvrir l'enregistreur\", attach: 'Joindre un fichier', dictate: 'Dicter', send: 'Envoyer', remove: 'Retirer', show: 'Montrer', showTitle: 'Montrer',\n meta: (seconds, n) => seconds === undefined ? `Écrit · ${captures(n, 'capture', 'captures')}` : `Dicté · ${clock(seconds)} · ${captures(n, 'capture', 'captures')} · modifiable`,\n sentMeta: (seconds, n) => seconds === undefined ? `Écrit · ${captures(n, 'capture', 'captures')}` : `Dicté · ${clock(seconds)} · ${captures(n, 'capture', 'captures')}`,\n mentionHeader: query => `TÂCHES · ${query}`, onYou: 'À vous',\n added: (n, kind, label) => `Élément ${n} ajouté : ${kind} · ${label}`, removed: n => `Élément ${n} retiré`,\n title: \"Améliorer l'app\", openPanel: 'Ouvrir le panneau', close: 'Fermer', review: 'Relisez, puis envoyez', requests: 'Mes demandes', back: '‹ Retour',\n subtitle: app => `Le chef de projet · ${app}`,\n commentOn: (kind, label) => `Commenter ${kind} · ${label}`, commentPlaceholder: 'Votre commentaire…', addToMessage: 'Ajouter au message', sendToPm: 'Envoyer au PM', sentToPm: 'Envoyé au PM',\n modify: 'Modifier', confirm: 'Confirmer',\n};\n\nexport const pmTextEn: PmText = {\n locale: 'en',\n placeholderBefore: 'Describe the problem, or ', placeholderAfter: ' to show me',\n hintIdle: 'Tap what you want to show', hintRec: 'Speak and tap what you show', hintRecShort: 'Speak and tap',\n mode: 'Mode', select: 'Select', navigate: 'Navigate', done: 'Done', doneLabel: 'Done: reread in the composer', speak: 'Speak',\n recorder: 'Open the recorder', attach: 'Attach a file', dictate: 'Dictate', send: 'Send', remove: 'Remove', show: 'Montrer', showTitle: 'Montrer: point at the app',\n meta: (seconds, n) => seconds === undefined ? `Written · ${captures(n, 'capture', 'captures')}` : `Dictated · ${clock(seconds)} · ${captures(n, 'capture', 'captures')} · editable`,\n sentMeta: (seconds, n) => seconds === undefined ? `Written · ${captures(n, 'capture', 'captures')}` : `Dictated · ${clock(seconds)} · ${captures(n, 'capture', 'captures')}`,\n mentionHeader: query => `TASKS · ${query}`, onYou: 'On you',\n added: (n, kind, label) => `Item ${n} added: ${kind} · ${label}`, removed: n => `Item ${n} removed`,\n title: 'Improve the app', openPanel: 'Open the panel', close: 'Close', review: 'Review, then send', requests: 'My requests', back: '‹ Back',\n subtitle: app => `The project lead · ${app}`,\n commentOn: (kind, label) => `Comment on ${kind} · ${label}`, commentPlaceholder: 'Your comment…', addToMessage: 'Add to message', sendToPm: 'Send to the PM', sentToPm: 'Sent to the PM',\n modify: 'Modify', confirm: 'Confirm',\n};\n\nexport const pmTextFor = (locale: string | undefined): PmText => locale?.startsWith('en') ? pmTextEn : pmTextFr;\n",
|
|
87
|
+
"content": "/** Spec 1 §9 and spec 2's strings, character for character: FR for the app, EN for Studio. */\nexport interface PmText {\n readonly locale: 'fr' | 'en';\n readonly placeholderBefore: string;\n readonly placeholderAfter: string;\n readonly hintIdle: string;\n readonly hintRec: string;\n /** The touch strip's C4 hint when the whole one would take more than two lines (spec 1 §2). */\n readonly hintRecShort: string;\n readonly mode: string;\n readonly select: string;\n readonly navigate: string;\n readonly done: string;\n readonly doneLabel: string;\n readonly speak: string;\n readonly recorder: string;\n readonly attach: string;\n readonly dictate: string;\n readonly send: string;\n readonly remove: string;\n readonly show: string;\n readonly showTitle: string;\n readonly meta: (seconds: number | undefined, captures: number) => string;\n readonly sentMeta: (seconds: number | undefined, captures: number) => string;\n readonly mentionHeader: (query: string) => string;\n readonly onYou: string;\n readonly added: (n: number, kind: string, label: string) => string;\n readonly removed: (n: number) => string;\n readonly title: string;\n readonly openPanel: string;\n readonly close: string;\n readonly review: string;\n readonly requests: string;\n readonly back: string;\n readonly subtitle: (app: string) => string;\n /** Click to comment (desktop, spec 1 §3b). */\n readonly commentOn: (kind: string, label: string) => string;\n readonly commentPlaceholder: string;\n readonly addToMessage: string;\n readonly sendToPm: string;\n readonly sentToPm: string;\n /** The request card's two answers (C7): « Modifier » goes back to the composer, « Confirmer » is the viewer's answer to the task. */\n readonly modify: string;\n readonly confirm: string;\n /** The app panel's one muted line when the PM's run ends in error without a reply: the member never sees a tool or a raw error (C7). */\n readonly failed: string;\n}\n\n/** `0:14`: whole seconds, never a fraction. */\nexport const clock = (seconds: number): string => {\n const whole = Math.max(0, Math.floor(seconds));\n return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`;\n};\nconst captures = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;\n\nexport const pmTextFr: PmText = {\n locale: 'fr',\n placeholderBefore: 'Décrivez le problème, ou ', placeholderAfter: ' pour me le montrer.',\n hintIdle: 'Touchez ce que vous voulez montrer', hintRec: 'Parlez et touchez ce que vous montrez', hintRecShort: 'Parlez et touchez',\n mode: 'Mode', select: 'Sélectionner', navigate: 'Naviguer', done: 'Terminer', doneLabel: 'Terminer : relire dans le composer', speak: 'Parler',\n recorder: \"Ouvrir l'enregistreur\", attach: 'Joindre un fichier', dictate: 'Dicter', send: 'Envoyer', remove: 'Retirer', show: 'Montrer', showTitle: 'Montrer',\n meta: (seconds, n) => seconds === undefined ? `Écrit · ${captures(n, 'capture', 'captures')}` : `Dicté · ${clock(seconds)} · ${captures(n, 'capture', 'captures')} · modifiable`,\n sentMeta: (seconds, n) => seconds === undefined ? `Écrit · ${captures(n, 'capture', 'captures')}` : `Dicté · ${clock(seconds)} · ${captures(n, 'capture', 'captures')}`,\n mentionHeader: query => `TÂCHES · ${query}`, onYou: 'À vous',\n added: (n, kind, label) => `Élément ${n} ajouté : ${kind} · ${label}`, removed: n => `Élément ${n} retiré`,\n title: \"Améliorer l'app\", openPanel: 'Ouvrir le panneau', close: 'Fermer', review: 'Relisez, puis envoyez', requests: 'Mes demandes', back: '‹ Retour',\n subtitle: app => `Le chef de projet · ${app}`,\n commentOn: (kind, label) => `Commenter ${kind} · ${label}`, commentPlaceholder: 'Votre commentaire…', addToMessage: 'Ajouter au message', sendToPm: 'Envoyer au PM', sentToPm: 'Envoyé au PM',\n modify: 'Modifier', confirm: 'Confirmer', failed: \"Le chef de projet n'a pas pu terminer. Réessayez.\",\n};\n\nexport const pmTextEn: PmText = {\n locale: 'en',\n placeholderBefore: 'Describe the problem, or ', placeholderAfter: ' to show me',\n hintIdle: 'Tap what you want to show', hintRec: 'Speak and tap what you show', hintRecShort: 'Speak and tap',\n mode: 'Mode', select: 'Select', navigate: 'Navigate', done: 'Done', doneLabel: 'Done: reread in the composer', speak: 'Speak',\n recorder: 'Open the recorder', attach: 'Attach a file', dictate: 'Dictate', send: 'Send', remove: 'Remove', show: 'Montrer', showTitle: 'Montrer: point at the app',\n meta: (seconds, n) => seconds === undefined ? `Written · ${captures(n, 'capture', 'captures')}` : `Dictated · ${clock(seconds)} · ${captures(n, 'capture', 'captures')} · editable`,\n sentMeta: (seconds, n) => seconds === undefined ? `Written · ${captures(n, 'capture', 'captures')}` : `Dictated · ${clock(seconds)} · ${captures(n, 'capture', 'captures')}`,\n mentionHeader: query => `TASKS · ${query}`, onYou: 'On you',\n added: (n, kind, label) => `Item ${n} added: ${kind} · ${label}`, removed: n => `Item ${n} removed`,\n title: 'Improve the app', openPanel: 'Open the panel', close: 'Close', review: 'Review, then send', requests: 'My requests', back: '‹ Back',\n subtitle: app => `The project lead · ${app}`,\n commentOn: (kind, label) => `Comment on ${kind} · ${label}`, commentPlaceholder: 'Your comment…', addToMessage: 'Add to message', sendToPm: 'Send to the PM', sentToPm: 'Sent to the PM',\n modify: 'Modify', confirm: 'Confirm', failed: \"The PM couldn't finish. Try again.\",\n};\n\nexport const pmTextFor = (locale: string | undefined): PmText => locale?.startsWith('en') ? pmTextEn : pmTextFr;\n",
|
|
87
88
|
"type": "registry:lib",
|
|
88
89
|
"target": "components/pm-panel/pm-text.ts"
|
|
89
90
|
},
|
package/r/provider-setup.json
CHANGED