@hachej/boring-ui-kit 0.4.4 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/dist/voice-capture.d.ts +43 -0
- package/dist/voice-capture.js +186 -0
- package/package.json +6 -2
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +9 -9
- package/r/board-viewer.json +5 -5
- package/r/button.json +1 -1
- package/r/diagram-viewer.json +1 -1
- package/r/dock.json +1 -1
- package/r/documents.json +10 -10
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +5 -5
- package/r/file-tree.json +5 -5
- package/r/highlights.json +3 -3
- package/r/html-viewer.json +3 -3
- package/r/maquette-viewer.json +2 -2
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +1 -1
- package/r/pi-ambient.json +6 -6
- package/r/pi-app.json +14 -14
- package/r/pi-chat.json +34 -12
- package/r/pi-workspace.json +4 -4
- package/r/provider-setup.json +2 -2
- package/r/record-viewer.json +1 -1
- package/r/registry.json +147 -97
- package/r/requests-board.json +1 -1
- package/r/shared-chat.json +4 -4
- package/r/task-list-viewer.json +2 -2
- package/r/utils.json +6 -0
- package/r/version-history.json +3 -3
- package/r/viewers.json +6 -6
- package/r/windows.json +28 -0
package/README.md
CHANGED
|
@@ -68,6 +68,14 @@ The default schema includes headings, lists, task lists, quotes, code and text m
|
|
|
68
68
|
|
|
69
69
|
`test/packages/ui-markdown-editor.test.mjs`, `test/packages/ui-markdown-navigation.test.mjs` and `test/packages/ui-markdown-mounted.test.mjs` and `test/packages/ui-markdown-proposals.test.mjs` drive the real React/Tiptap renderer, DOM selections and SQLite provider. `npm run test:editor-consumer` requires `npm_config_cache` pointing to a writable cache with the pinned archives; it installs actual package tarballs, checks declarations with `skipLibCheck: false`, drives the controls and bundles for browsers without Pi or server filesystem imports. These checks do not establish browser layout, caret, IME or accessibility qualification. Chromium journeys remain pending.
|
|
70
70
|
|
|
71
|
+
## Optional microphone capture
|
|
72
|
+
|
|
73
|
+
`createVoiceCapture` from `@hachej/boring-ui-kit/voice-capture` records browser audio without React, files or Pi. Call `start()` from a person's gesture. It feature-detects microphone access and returns a recording or a typed refusal. `stop()` returns the Blob, recorded MIME type, page-clock sample times and `truncated`; `cancel()` discards audio and releases only this capture's microphone tracks, including a permission request that finishes late.
|
|
74
|
+
|
|
75
|
+
`maxMs` caps capture at five minutes by default. Reaching the cap stops the microphone; callers still choose when to consume the recording through `stop()`. Transcription and persistence remain host capabilities. The existing `@hachej/boring-feedback/page` capture API re-exports this implementation unchanged. The headless root imports neither entry.
|
|
76
|
+
|
|
77
|
+
The [registry voice-message guide](../../registry/README.md#voice-messages-in-pi-chat) describes PiChat's optional hold-to-send composition. Public capture behavior runs in `test/packages/ui.test.mjs`, compatibility in `test/packages/feedback-voice.test.mjs`, and dependency-free installation in `test/scaffold/isolated-ui.test.mjs`.
|
|
78
|
+
|
|
71
79
|
## Document marks
|
|
72
80
|
|
|
73
81
|
`@hachej/boring-ui-kit/markdown-quote` exports `captureMarkdownQuote` and
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Why the microphone could not be used, for a short human message: `denied` (the person or the browser refused the permission),
|
|
2
|
+
* `no-microphone`, `busy` (in use or could not be opened), `insecure` (http on a name other than localhost: no media devices at all),
|
|
3
|
+
* `unsupported` (no MediaRecorder, or it cannot record this microphone), `failed` (anything else). */
|
|
4
|
+
export type VoiceRefusal = 'denied' | 'no-microphone' | 'busy' | 'insecure' | 'unsupported' | 'failed';
|
|
5
|
+
export type VoiceStart = {
|
|
6
|
+
readonly kind: 'recording';
|
|
7
|
+
} | {
|
|
8
|
+
readonly kind: 'refused';
|
|
9
|
+
readonly reason: string;
|
|
10
|
+
readonly code?: VoiceRefusal;
|
|
11
|
+
};
|
|
12
|
+
export interface VoiceRecording {
|
|
13
|
+
readonly audio: Blob;
|
|
14
|
+
readonly mimeType: string;
|
|
15
|
+
/** Page time (`performance.now()` ms) of the first recorded sample. */
|
|
16
|
+
readonly startedAt: number;
|
|
17
|
+
readonly endedAt: number;
|
|
18
|
+
/** The recording reached `maxMs` and stopped on its own. */
|
|
19
|
+
readonly truncated: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface VoiceCapture {
|
|
22
|
+
readonly start: () => Promise<VoiceStart>;
|
|
23
|
+
/** Ends the recording and returns it; null when nothing was recorded (never started, cancelled, or empty). */
|
|
24
|
+
readonly stop: () => Promise<VoiceRecording | null>;
|
|
25
|
+
/** Ends the recording, releases the microphone and discards the audio. */
|
|
26
|
+
readonly cancel: () => void;
|
|
27
|
+
}
|
|
28
|
+
export interface VoiceCaptureOptions {
|
|
29
|
+
readonly getUserMedia?: (constraints: MediaStreamConstraints) => Promise<MediaStream>;
|
|
30
|
+
/** Longest recording in milliseconds (default 5 minutes); the recording stops there and `stop()` reports `truncated`. */
|
|
31
|
+
readonly maxMs?: number;
|
|
32
|
+
/** The page clock (default `performance.now()`). */
|
|
33
|
+
readonly now?: () => number;
|
|
34
|
+
}
|
|
35
|
+
export declare const DEFAULT_MAX_VOICE_MS: number;
|
|
36
|
+
/** Container types tried in order; the first the browser records wins. */
|
|
37
|
+
export declare const VOICE_MIME_TYPES: readonly ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"];
|
|
38
|
+
/** The browser's microphone, or null where the page cannot have one (insecure origin, no media devices). */
|
|
39
|
+
export declare function browserGetUserMedia(): ((constraints: MediaStreamConstraints) => Promise<MediaStream>) | null;
|
|
40
|
+
/** Short human messages, one per refusal; each says what to do next. */
|
|
41
|
+
export declare const VOICE_REFUSALS: Readonly<Record<VoiceRefusal, string>>;
|
|
42
|
+
export declare function createVoiceCapture(options?: VoiceCaptureOptions): VoiceCapture;
|
|
43
|
+
//# sourceMappingURL=voice-capture.d.ts.map
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Shared browser microphone capture. Chat keeps the audio; feedback also uses the page-clock time of the first recorded sample
|
|
2
|
+
// to align speech with the pointer.
|
|
3
|
+
//
|
|
4
|
+
// Microphone access (getUserMedia) exists only in secure contexts (https or localhost) and needs the person's permission. The
|
|
5
|
+
// capture never opens it on load: only `start()`, called from the person's click, does, through an injectable `getUserMedia`
|
|
6
|
+
// whose default feature-detects `navigator.mediaDevices.getUserMedia` and refuses with a reason when it is missing. A host that
|
|
7
|
+
// owns microphone policy passes its own. `npm run check` (scripts/check-platform-apis.mjs) allowlists this file on that condition.
|
|
8
|
+
//
|
|
9
|
+
// The clock. MediaRecorder hands audio over late and in chunks, so neither its `start` event nor a chunk's arrival says when the
|
|
10
|
+
// first recorded sample was heard. `npm run feedback:voice-sync` (tools/feedback-voice-sync.mjs) measured seven candidate
|
|
11
|
+
// strategies in Chromium against an independent reader of the same microphone; the page time read immediately before
|
|
12
|
+
// `MediaRecorder.start()` was the most accurate (2–4 ms mean, under 9 ms max over three batches, with the fake device's dropped
|
|
13
|
+
// time removed), while the `start` event is about 60 ms late (up to 125 ms) and stop-minus-duration 30–40 ms late. So
|
|
14
|
+
// `startedAt` is that time.
|
|
15
|
+
// `endedAt` is `startedAt` plus the decoded duration when the page can decode the recording, else the `stop` event's time.
|
|
16
|
+
// Measured in Chromium only; a microphone that warms up after getUserMedia resolves (no audio yet when recording starts) would
|
|
17
|
+
// make `startedAt` early by the warm-up.
|
|
18
|
+
export const DEFAULT_MAX_VOICE_MS = 5 * 60_000;
|
|
19
|
+
/** Container types tried in order; the first the browser records wins. */
|
|
20
|
+
export const VOICE_MIME_TYPES = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus'];
|
|
21
|
+
/** The browser's microphone, or null where the page cannot have one (insecure origin, no media devices). */
|
|
22
|
+
export function browserGetUserMedia() {
|
|
23
|
+
if (typeof globalThis.navigator?.mediaDevices?.getUserMedia !== 'function')
|
|
24
|
+
return null;
|
|
25
|
+
const devices = globalThis.navigator.mediaDevices;
|
|
26
|
+
return constraints => devices.getUserMedia(constraints);
|
|
27
|
+
}
|
|
28
|
+
/** Short human messages, one per refusal; each says what to do next. */
|
|
29
|
+
export const VOICE_REFUSALS = {
|
|
30
|
+
denied: 'Microphone blocked. Allow it for this site in the browser, then press Record again.',
|
|
31
|
+
'no-microphone': 'No microphone found. Connect one, then press Record again.',
|
|
32
|
+
busy: 'The microphone is busy or could not be opened. Close other apps using it and try again.',
|
|
33
|
+
insecure: 'Recording needs a secure page (https or localhost).',
|
|
34
|
+
unsupported: 'This browser cannot record audio here.',
|
|
35
|
+
failed: 'The microphone could not be started.',
|
|
36
|
+
};
|
|
37
|
+
const refused = (code, detail) => ({ kind: 'refused', code, reason: detail ? `${VOICE_REFUSALS[code]} (${detail})` : VOICE_REFUSALS[code] });
|
|
38
|
+
function refusalOf(error) {
|
|
39
|
+
const name = error?.name;
|
|
40
|
+
if (name === 'NotAllowedError' || name === 'PermissionDeniedError' || name === 'SecurityError')
|
|
41
|
+
return refused('denied');
|
|
42
|
+
if (name === 'NotFoundError' || name === 'DevicesNotFoundError' || name === 'OverconstrainedError')
|
|
43
|
+
return refused('no-microphone');
|
|
44
|
+
if (name === 'NotReadableError' || name === 'TrackStartError' || name === 'AbortError')
|
|
45
|
+
return refused('busy');
|
|
46
|
+
return refused('failed', error instanceof Error ? error.message : String(error));
|
|
47
|
+
}
|
|
48
|
+
/** Media devices missing on a plain-http page: the usual reason is an insecure origin (http on a name that is not local; a local
|
|
49
|
+
* http page has them). Wording only; the capability itself is feature-detected (`browserGetUserMedia`). */
|
|
50
|
+
const plainHttpPage = () => globalThis.location?.protocol === 'http:';
|
|
51
|
+
const release = (stream) => { for (const track of stream?.getTracks() ?? [])
|
|
52
|
+
track.stop(); };
|
|
53
|
+
/** Duration in milliseconds of recorded audio, when the page can decode it (OfflineAudioContext or AudioContext). */
|
|
54
|
+
async function decodedMs(audio) {
|
|
55
|
+
try {
|
|
56
|
+
const Offline = globalThis.OfflineAudioContext;
|
|
57
|
+
if (typeof Offline !== 'function' || audio.size === 0)
|
|
58
|
+
return null;
|
|
59
|
+
const context = new Offline(1, 1, 48_000);
|
|
60
|
+
const buffer = await context.decodeAudioData(await audio.arrayBuffer());
|
|
61
|
+
return Number.isFinite(buffer.duration) ? buffer.duration * 1000 : null;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function createVoiceCapture(options = {}) {
|
|
68
|
+
const now = options.now ?? (() => performance.now());
|
|
69
|
+
const maxMs = options.maxMs ?? DEFAULT_MAX_VOICE_MS;
|
|
70
|
+
let session = null;
|
|
71
|
+
let opening = false;
|
|
72
|
+
let cancelledWhileOpening = false;
|
|
73
|
+
const finish = (current, truncated) => {
|
|
74
|
+
if (current.timer !== null) {
|
|
75
|
+
clearTimeout(current.timer);
|
|
76
|
+
current.timer = null;
|
|
77
|
+
}
|
|
78
|
+
if (truncated)
|
|
79
|
+
current.truncated = true;
|
|
80
|
+
if (current.recorder.state !== 'inactive') {
|
|
81
|
+
try {
|
|
82
|
+
current.recorder.stop();
|
|
83
|
+
}
|
|
84
|
+
catch { /* already stopping */ }
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
async start() {
|
|
89
|
+
if (session || opening)
|
|
90
|
+
return { kind: 'refused', code: 'failed', reason: 'Already recording.' };
|
|
91
|
+
const Recorder = globalThis.MediaRecorder;
|
|
92
|
+
const open = options.getUserMedia ?? browserGetUserMedia();
|
|
93
|
+
// No media devices on an insecure origin: say that, not "no microphone" (the person can fix the address, not the hardware).
|
|
94
|
+
if (!open)
|
|
95
|
+
return refused(plainHttpPage() ? 'insecure' : 'unsupported');
|
|
96
|
+
if (typeof Recorder !== 'function')
|
|
97
|
+
return refused('unsupported', 'no MediaRecorder');
|
|
98
|
+
// getUserMedia is called synchronously from here, inside the person's click (no await before it), so browsers that tie the
|
|
99
|
+
// permission prompt to a user gesture show it.
|
|
100
|
+
opening = true;
|
|
101
|
+
cancelledWhileOpening = false;
|
|
102
|
+
let stream;
|
|
103
|
+
try {
|
|
104
|
+
stream = await open({ audio: true });
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
opening = false;
|
|
108
|
+
return refusalOf(error);
|
|
109
|
+
}
|
|
110
|
+
if (cancelledWhileOpening) {
|
|
111
|
+
opening = false;
|
|
112
|
+
release(stream);
|
|
113
|
+
return { kind: 'refused', code: 'failed', reason: 'Cancelled before the microphone opened.' };
|
|
114
|
+
}
|
|
115
|
+
const supported = (type) => { try {
|
|
116
|
+
return typeof Recorder.isTypeSupported !== 'function' || Recorder.isTypeSupported(type);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return false;
|
|
120
|
+
} };
|
|
121
|
+
const preferred = VOICE_MIME_TYPES.find(supported);
|
|
122
|
+
let recorder;
|
|
123
|
+
try {
|
|
124
|
+
recorder = preferred ? new Recorder(stream, { mimeType: preferred }) : new Recorder(stream);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
opening = false;
|
|
128
|
+
release(stream);
|
|
129
|
+
return refused('unsupported', error instanceof Error ? error.message : String(error));
|
|
130
|
+
}
|
|
131
|
+
let settle;
|
|
132
|
+
const current = {
|
|
133
|
+
stream, recorder, mimeType: preferred ?? '', chunks: [], startedAt: null, stoppedAt: null, truncated: false, timer: null,
|
|
134
|
+
stopped: new Promise(resolve => { settle = resolve; }), cancelled: false,
|
|
135
|
+
};
|
|
136
|
+
recorder.addEventListener('dataavailable', event => { if (event.data && event.data.size > 0)
|
|
137
|
+
current.chunks.push(event.data); });
|
|
138
|
+
recorder.addEventListener('stop', () => { current.stoppedAt = now(); release(stream); settle(); });
|
|
139
|
+
recorder.addEventListener('error', () => { current.stoppedAt ??= now(); release(stream); settle(); });
|
|
140
|
+
try {
|
|
141
|
+
current.startedAt = now();
|
|
142
|
+
recorder.start(1000);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
opening = false;
|
|
146
|
+
release(stream);
|
|
147
|
+
return refused('failed', error instanceof Error ? error.message : String(error));
|
|
148
|
+
}
|
|
149
|
+
if (Number.isFinite(maxMs) && maxMs > 0)
|
|
150
|
+
current.timer = setTimeout(() => finish(current, true), maxMs);
|
|
151
|
+
session = current;
|
|
152
|
+
opening = false;
|
|
153
|
+
return { kind: 'recording' };
|
|
154
|
+
},
|
|
155
|
+
async stop() {
|
|
156
|
+
const current = session;
|
|
157
|
+
if (!current)
|
|
158
|
+
return null;
|
|
159
|
+
finish(current, false);
|
|
160
|
+
await current.stopped;
|
|
161
|
+
session = null;
|
|
162
|
+
if (current.cancelled)
|
|
163
|
+
return null;
|
|
164
|
+
const mimeType = current.recorder.mimeType || current.mimeType || current.chunks[0]?.type || 'audio/webm';
|
|
165
|
+
const audio = new Blob(current.chunks, { type: mimeType });
|
|
166
|
+
if (audio.size === 0)
|
|
167
|
+
return null;
|
|
168
|
+
const startedAt = current.startedAt ?? current.stoppedAt ?? now();
|
|
169
|
+
const duration = await decodedMs(audio);
|
|
170
|
+
const endedAt = duration === null ? Math.max(startedAt, current.stoppedAt ?? now()) : startedAt + duration;
|
|
171
|
+
return { audio, mimeType, startedAt, endedAt, truncated: current.truncated };
|
|
172
|
+
},
|
|
173
|
+
cancel() {
|
|
174
|
+
const current = session;
|
|
175
|
+
if (opening)
|
|
176
|
+
cancelledWhileOpening = true;
|
|
177
|
+
if (!current)
|
|
178
|
+
return;
|
|
179
|
+
session = null;
|
|
180
|
+
current.cancelled = true;
|
|
181
|
+
current.chunks.length = 0;
|
|
182
|
+
finish(current, false);
|
|
183
|
+
release(current.stream);
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hachej/boring-ui-kit",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -55,6 +55,10 @@
|
|
|
55
55
|
"types": "./dist/native-chat.d.ts",
|
|
56
56
|
"import": "./dist/native-chat.js"
|
|
57
57
|
},
|
|
58
|
+
"./voice-capture": {
|
|
59
|
+
"types": "./dist/voice-capture.d.ts",
|
|
60
|
+
"import": "./dist/voice-capture.js"
|
|
61
|
+
},
|
|
58
62
|
"./shared-chat": {
|
|
59
63
|
"types": "./dist/shared-chat/index.d.ts",
|
|
60
64
|
"import": "./dist/shared-chat/index.js"
|
|
@@ -126,7 +130,7 @@
|
|
|
126
130
|
}
|
|
127
131
|
},
|
|
128
132
|
"peerDependencies": {
|
|
129
|
-
"@hachej/boring-files": "^0.4.
|
|
133
|
+
"@hachej/boring-files": "^0.4.5",
|
|
130
134
|
"@earendil-works/pi-durable": "~1.2.0",
|
|
131
135
|
"react": "^19.0.0",
|
|
132
136
|
"react-dom": "^19.0.0",
|
package/r/agent-target.json
CHANGED
|
@@ -4,15 +4,15 @@
|
|
|
4
4
|
"title": "Point and dictate on app data",
|
|
5
5
|
"description": "Tap explicitly marked JSON app context, dictate once and show the same app agent’s native reply without opening chat. Host-controlled presentation; no feedback report or second controller.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-feedback@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.5",
|
|
8
|
+
"@hachej/boring-feedback@^0.4.5",
|
|
9
9
|
"@earendil-works/pi-durable@~1.2.0",
|
|
10
10
|
"react@^19.0.0",
|
|
11
11
|
"react-dom@^19.0.0"
|
|
12
12
|
],
|
|
13
13
|
"registryDependencies": [
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.5/r/app-shell.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.5/r/pi-chat.json",
|
|
16
16
|
"button",
|
|
17
17
|
"card",
|
|
18
18
|
"message",
|
package/r/app-shell.json
CHANGED
|
@@ -4,19 +4,19 @@
|
|
|
4
4
|
"title": "App shell: layouts, list/detail and agent layer",
|
|
5
5
|
"description": "AppShell with three layouts. B \"workspace\" puts a ListDetail on a screen (phone: the list, then the detail as a stack step with Back; wide: a 20rem list and the detail side by side, sidebar as an icon rail). C \"chat\" wraps the app's own AgentWorkspace (untouched, passed as the nav item's render; install the pi-app item yourself) in the same sidebar, account menu and header; it takes no shell agent. A \"screen\": one screen at a time, every screen kept mounted. On a phone bottom tabs (at most four; more give three plus a « Plus » sheet) and an avatar button that opens the account sheet; on a wide screen the shadcn sidebar (icon rail, NavUser). One header per screen through useScreenHeader (title, subtitle, back, up to two actions, the rest behind « … »). The account menu holds « Améliorer l'app » (the improve agent), the app's own entries and sign out. AppSheet is the one bottom sheet (one open at a time, Back closes it). It mounts the AgentLayer: one agent's PiChat above the app (phone: a drawer with peek, half and full snap points; wide: a right panel), the improve agent in the same layer, optional push-to-talk and point mode. Also useAgentContext and readAgentContext (the screen on show travels as `context` with each submit) and useBackLayer, the one Back manager. Built on explicitly pinned Base UI drawer and dropdown-menu recipes, plus the sidebar and avatar of the host's own shadcn style.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-feedback@^0.4.
|
|
8
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
7
|
+
"@hachej/boring-feedback@^0.4.5",
|
|
8
|
+
"@hachej/boring-ui-kit@^0.4.5",
|
|
9
9
|
"@base-ui/react@^1.9.0",
|
|
10
10
|
"react@^19.0.0",
|
|
11
11
|
"react-dom@^19.0.0",
|
|
12
12
|
"lucide-react@^1.51.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.
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.5/r/pi-chat.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.5/r/dock.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.5/r/feedback.json",
|
|
18
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.5/r/utils.json",
|
|
19
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.5/r/button.json",
|
|
20
20
|
"drawer",
|
|
21
21
|
"sidebar",
|
|
22
22
|
"avatar",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"files": [
|
|
31
31
|
{
|
|
32
32
|
"path": "registry/app-shell/agent-layer.tsx",
|
|
33
|
-
"content": "'use client';\n\n// The AgentLayer: ONE agent's PiChat above the app. Compact (below 768px): a bottom drawer with three fixed snap points, peek, half and\n// full (shadcn `drawer`, Base UI variant). Expanded: a `Dock` (the `dock` item) beside the app, docked on the left or the right (default right)\n// or floating over it; the person chooses and the choice is remembered. The same layer shows\n// the product manager (« Améliorer l’app ») when the account menu asks for it. Push-to-talk is an option of the agent.\n// Host concern: Pi has no layout. The chat is the borrowed NativeChatController rendered by PiChat; the screen context travels as the\n// submit's `context` (agent-context.ts). Nothing here is configurable beyond AgentSpec.\nimport { createPortal } from 'react-dom';\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ComponentType, CSSProperties, HTMLAttributes, PointerEvent as ReactPointerEvent, ReactElement, ReactNode, Ref, RefObject } from 'react';\nimport { ArrowLeftIcon, ChevronDownIcon, ChevronUpIcon, Loader2Icon, MicIcon, PanelLeftIcon, PanelRightIcon, PictureInPicture2Icon, SparklesIcon, XIcon } from 'lucide-react';\n// Behaviour only: Base UI's keyboard-inset provider for the host drawer's content. It renders nothing visible.\nimport * as BaseDrawerParts from '@base-ui/react/drawer';\nimport { Button } from '@/components/ui/button';\nimport { Card } from '@/components/ui/card';\nimport { Separator } from '@/components/ui/separator';\nimport type { FeedbackSession } from '@hachej/boring-feedback/ui';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { Drawer, DrawerContent } from '@/components/ui/drawer';\nimport { useIsMobile } from '@/hooks/use-mobile';\nimport { Dock, DockLayout, DockMain } from '../dock/dock';\nimport type { DockApi } from '../dock/dock';\nimport { PiChat } from '../pi-chat/pi-chat';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\nimport { NoteBubble, useComposerFeedback } from '../feedback/feedback-session';\nimport { feedbackLabelsFr } from '../feedback/feedback-labels';\nimport { PointBar, usePointing } from '../feedback/point-mode';\nimport { cn } from '../utils/utils';\nimport { useBackLayer } from './back';\nimport { parseAgentLink } from './agent-link';\nimport { closeShellLayer, registerAgentLayerCloser } from './shell-layer';\nimport { appShellText } from './text';\nimport type { AppShellLocale, AppShellText } from './text';\nimport { useVisualViewport } from './use-visual-viewport';\nimport { AgentContextProvider } from './agent-context';\nimport { HeaderActions } from './header-actions';\nimport type { ScreenHeaderAction } from './header-actions';\nimport { useDictation } from './use-dictation';\nimport type { Dictation as Voice } from './use-dictation';\n\nexport interface AgentSpec {\n readonly controller: NativeChatController;\n /** The agent's name in the expanded sidebar (default: the chat's title, then « Assistant »). */\n readonly label?: string;\n /** The agent's icon in the sidebar (default: the sparkles of the header trigger). */\n readonly icon?: ComponentType<{ className?: string }>;\n readonly chatProps?: Omit<PiChatProps, 'controller'>;\n /** Push-to-talk. `send` 'auto' submits the transcript; 'draft' (default) only fills the composer. */\n readonly voice?: { readonly transcribe: (audio: Blob, signal: AbortSignal) => Promise<string>; readonly send?: 'auto' | 'draft' };\n /** Point mode (the product manager, in practice). */\n readonly feedback?: FeedbackSession;\n /** Host actions in the layer's title row (before the dock controls, or the snap and close controls when compact), as the screen header's. Two shown, the rest in « … ». Not at peek. */\n readonly headerActions?: readonly ScreenHeaderAction[];\n}\n\n/** Compact: closed, peek, half or full. Expanded: closed or open. */\nexport type AgentLayerState = 'closed' | 'peek' | 'half' | 'full' | 'open';\ntype Which = 'agent' | 'improve';\ntype Snap = 'closed' | 'peek' | 'half' | 'full';\n\nexport interface AgentLayerApi {\n readonly agent: NativeChatController | undefined;\n readonly state: AgentLayerState;\n readonly showing: Which | null;\n open(which?: Which, at?: 'half' | 'full'): void;\n close(): void;\n readonly expanded: boolean;\n}\n\n// The three fixed snap points: a peek (one composer row and a line of the last reply, `PEEK_REM` above the tab bar), 55% and the full height.\nconst PEEK_REM = 6;\n/** The shell's z-scale (tabs 20, peek 30, drawer and panel 40, other sheets 50, point bar 60). */\nconst Z = { tabs: 20, peek: 30, layer: 40, sheet: 50, point: 60 } as const;\nconst HALF = 0.55;\nconst FULL = 1;\n/** Hold the trigger this long before it records; shorter is a tap. */\nconst HOLD_MS = 350;\n\n/**\n * What a close request from the drawer becomes. A swipe, an outside press, a lost focus and Escape never close the layer: they step down\n * to peek (half without an `agent`), so the modal state goes and the coach stays reachable. Only the Close control closes it. `reason` is\n * Base UI's `details.reason` (`'swipe'`, `'outside-press'`, `'focus-out'`, `'escape-key'`, `'close-watcher'`, `'close-press'`, `'trigger-press'`,\n * `'imperative-action'`, `'none'`).\n */\nexport function nextSnapOnClose(reason: string, hasPeek: boolean): 'peek' | 'half' | 'close' {\n return reason === 'swipe' || reason === 'outside-press' || reason === 'focus-out' || reason === 'escape-key' ? (hasPeek ? 'peek' : 'half') : 'close';\n}\n\n/**\n * Whether a close request comes from something the layer does not own: a full-screen overlay portalled above it (`[data-boring-overlay]`,\n * for example the composer's camera). Its taps count as outside presses and its focus as a focus-out of the drawer, but they must change\n * nothing: the caller cancels the request and leaves the snap alone. Only those three reasons are looked at, and the event target, the\n * related target (focus-out) or the focused element may be the overlay.\n */\nexport function isOverlayEvent(details: { readonly reason: string; readonly event?: Event | undefined }): boolean {\n if (details.reason !== 'outside-press' && details.reason !== 'focus-out' && details.reason !== 'escape-key') return false;\n const inside = (node: unknown) => node instanceof Element && node.closest('[data-boring-overlay]') !== null;\n const event = details.event as (Event & { relatedTarget?: EventTarget | null }) | undefined;\n return inside(event?.target) || inside(event?.relatedTarget) || inside(globalThis.document?.activeElement);\n}\n\ninterface Internal extends Omit<AgentLayerApi, 'agent'> {\n readonly agent: AgentSpec | undefined;\n readonly improve: AgentSpec | undefined;\n readonly snap: Snap;\n readonly setSnap: (snap: Snap) => void;\n /** Whether the wide layout's dock is floating over the app (reported by the panel); with `expanded` it gives the chats' placement. */\n readonly dockFloating: boolean;\n readonly setDockFloating: (floating: boolean) => void;\n readonly showAgent: () => void;\n /** Until this time (ms) an outside press is the AgentTrigger's own click. */\n readonly triggerUntil: { current: number };\n readonly voice: Voice;\n /** The peek height in px: `PEEK_REM` plus the tab bar the peek sits above. */\n readonly peekPoint: string;\n /** The measured tab bar height in px, given explicitly to the parts portalled to the body (they cannot inherit the shell's variables). */\n readonly tabbar: number;\n /** The persistent element holding both chats: it moves between the drawer and the panel, so closing never unmounts a chat. */\n readonly host: HTMLElement | null;\n /** The shell's `locale`: the layer's own words when the agent spec sets none (`agent.chatProps.locale` first). */\n readonly locale: AppShellLocale | undefined;\n /** Expanded only: the side the dock sits on while docked (the viewer's choice, remembered). */\n readonly side: DockSide;\n readonly setSide: (side: DockSide) => void;\n /** Set by a dock control: the next placement or side change puts focus back on the panel's first dock control. */\n readonly restoreFocus: { current: boolean };\n}\n\ntype DockSide = 'left' | 'right';\n/** The dock's remembered state lives under this prefix (DockLayout's storageKey): `<key>.agent.floating`, `.width`, and the side. */\nconst DOCK_KEY = 'boring.app-shell';\nconst SIDE_KEY = `${DOCK_KEY}.agent.side`;\n\nconst LayerContext = createContext<Internal | null>(null);\n\nfunction useInternal(): Internal {\n const layer = useContext(LayerContext);\n if (!layer) throw new Error('useAgentLayer needs an AgentLayerProvider');\n return layer;\n}\n\n/** The layer's state and controls. */\nexport function useAgentLayer(): AgentLayerApi {\n const { state, showing, open, close, expanded, agent: spec } = useInternal();\n const agent = spec?.controller;\n return useMemo(() => ({ state, showing, open, close, expanded, agent }), [state, showing, open, close, expanded, agent]);\n}\n\n/** Push-to-talk uses the same dictation lifecycle as page targeting, over the existing composer/controller. */\nfunction useVoice(agent: AgentSpec | undefined, locale: AppShellLocale | undefined, done: (outcome: 'sent' | 'drafted') => void): Voice {\n const voice = useDictation({\n transcribe: agent?.voice?.transcribe,\n locale: agent?.chatProps?.locale ?? locale,\n onText: async (spoken, signal) => {\n if (!agent || signal.aborted) return;\n const fill = () => {\n if (signal.aborted) return;\n const draft = agent.controller.getSnapshot().draft.text;\n agent.controller.setText(`${draft}${draft === '' || /\\s$/.test(draft) ? '' : ' '}${spoken}`);\n done('drafted');\n };\n if (agent.voice?.send !== 'auto') { fill(); return; }\n try {\n const sent = await agent.controller.send(undefined, { text: spoken });\n if (!signal.aborted) done(sent ? 'sent' : 'drafted');\n } catch { fill(); }\n },\n });\n const { cancel } = voice;\n useEffect(() => () => cancel(), [agent?.controller, cancel]);\n return voice;\n}\n\n/** The height in px of the tab bar the peek sits above (`--app-shell-tabbar` on the shell root), and one rem. */\nfunction useMeasuredPeek(probe: RefObject<HTMLDivElement | null>): { readonly peekPoint: string; readonly tabbar: number } {\n const [px, setPx] = useState(PEEK_REM * 16);\n const [tabbar, setTabbar] = useState(0);\n useLayoutEffect(() => {\n const measure = () => {\n const tab = probe.current?.offsetHeight ?? 0;\n const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;\n setPx(Math.round(PEEK_REM * rem + tab));\n setTabbar(tab);\n };\n measure();\n window.addEventListener('resize', measure);\n const observer = typeof ResizeObserver === 'undefined' || !probe.current ? undefined : new ResizeObserver(measure);\n if (observer && probe.current) observer.observe(probe.current);\n return () => { window.removeEventListener('resize', measure); observer?.disconnect(); };\n }, [probe]);\n return { peekPoint: `${px}px`, tabbar };\n}\n\n/** Places the layer in the app. `agent` is the one assistant; `improve` is the product manager. Either may be absent. */\nexport function AgentLayerProvider({ agent, improve, locale, agentLink, children }: { readonly agent?: AgentSpec | undefined; readonly improve?: AgentSpec | undefined; readonly locale?: AppShellLocale | undefined; readonly agentLink?: false | undefined; readonly children: ReactNode }): ReactElement {\n const compact = useIsMobile();\n const expanded = !compact;\n const [snap, setSnapState] = useState<Snap>('closed');\n const [dockFloating, setDockFloating] = useState(false);\n const [shown, setShown] = useState<Which>(agent ? 'agent' : 'improve');\n const probe = useRef<HTMLDivElement>(null);\n const { peekPoint, tabbar } = useMeasuredPeek(probe);\n const triggerUntil = useRef(0);\n const restoreFocus = useRef(false);\n const [side, setSideState] = useState<DockSide>('right');\n // The side is the viewer's, remembered beside the Dock's own placement; read after mount so server and client markup agree.\n useLayoutEffect(() => { try { const saved = globalThis.localStorage?.getItem(SIDE_KEY); if (saved === 'left' || saved === 'right') setSideState(saved); } catch { /* a convenience */ } }, []);\n const setSide = useCallback((next: DockSide) => { setSideState(next); try { globalThis.localStorage?.setItem(SIDE_KEY, next); } catch { /* a convenience */ } }, []);\n const specs = useRef({ agent, improve, snap, shown });\n specs.current = { agent, improve, snap, shown };\n\n const setSnap = useCallback((next: Snap) => setSnapState(next), []);\n const close = useCallback(() => setSnapState('closed'), []);\n // One full-screen layer at a time: opening the agent or product manager closes a view of the app (`useShellLayer`), and a view closes these.\n useEffect(() => registerAgentLayerCloser(close), [close]);\n const open = useCallback((which?: Which, at: 'half' | 'full' = 'half') => {\n const { agent: a, improve: i } = specs.current;\n const target = which ?? (a ? 'agent' : 'improve');\n if (target === 'agent' ? !a : !i) return;\n closeShellLayer();\n setShown(target);\n setSnapState(at);\n }, []);\n const showAgent = useCallback(() => { if (specs.current.agent) setShown('agent'); }, []);\n\n // A spec that goes away takes the layer with it.\n const shownSpec = shown === 'agent' ? agent : improve;\n useEffect(() => { if (!shownSpec) { setSnapState('closed'); if (shown === 'improve' && agent) setShown('agent'); else if (shown === 'agent' && improve) setShown('improve'); } }, [shownSpec, shown, agent, improve]);\n\n // The chats live in one element that is moved into the open drawer or panel; React keeps them mounted (and so their presentation state\n // and uploads) while the layer is closed, on a phone and on a wide screen. Nothing is mounted before the layer is first opened.\n const [host] = useState(() => typeof document === 'undefined' ? null : Object.assign(document.createElement('div'), { className: 'flex min-h-0 flex-1 flex-col [&_[data-boring=pi-chat]>header]:hidden' }));\n const opened = useRef(false);\n if (snap !== 'closed') opened.current = true;\n\n // Consume the launch URL before an open layer pushes its Back entry. Keep the parsed request through StrictMode's effect replay.\n const launch = useRef<ReturnType<typeof parseAgentLink> | undefined>(undefined);\n useEffect(() => {\n if (launch.current === undefined) {\n launch.current = agentLink === false ? null : parseAgentLink(window.location.search);\n if (agentLink !== false) {\n const url = new URL(window.location.href);\n if (['agent', 'open', 'input'].some(key => url.searchParams.has(key))) {\n for (const key of ['agent', 'open', 'input']) url.searchParams.delete(key);\n history.replaceState(history.state, '', `${url.pathname}${url.search}${url.hash}`);\n }\n }\n }\n const link = launch.current;\n if (!link) return;\n const which = link.agent === 'improve' ? 'improve' : 'agent';\n open(which, link.open);\n const spec = specs.current[which];\n if (link.input !== 'text' || !spec) return;\n // The drawer's slide and initial focus finish first. Voice deliberately leaves focus off the composer and never calls start().\n const timer = setTimeout(() => {\n const current = specs.current;\n if (current.snap !== 'closed' && current.shown === which && current[which]?.controller === spec.controller) {\n host?.querySelector<HTMLElement>(`[data-spec=${which}] [data-testid=composer-input]`)?.focus({ preventScroll: true });\n }\n }, 520);\n return () => clearTimeout(timer);\n }, [host, open]);\n\n const voice = useVoice(agent, locale, outcome => {\n if (outcome === 'drafted') { setShown('agent'); setSnapState('half'); }\n });\n\n const state: AgentLayerState = snap === 'closed' ? 'closed' : expanded ? 'open' : snap;\n const value = useMemo<Internal>(() => ({\n state, showing: snap === 'closed' ? null : shown, open, close, expanded, agent, improve, snap, setSnap, dockFloating, setDockFloating, showAgent, triggerUntil, voice, peekPoint, tabbar, host, locale, side, setSide, restoreFocus,\n }), [state, snap, shown, open, close, expanded, agent, improve, setSnap, dockFloating, showAgent, triggerUntil, voice, peekPoint, tabbar, host, locale, side, setSide]);\n\n // The shell's z-scale (tabs 20, peek 30, drawer and panel 40, other sheets 50, point bar 60) as custom properties; `display: contents` keeps the box tree unchanged.\n const scale = { '--app-shell-z-tabs': Z.tabs, '--app-shell-z-peek': Z.peek, '--app-shell-z-layer': Z.layer, '--app-shell-z-sheet': Z.sheet, '--app-shell-z-point': Z.point } as CSSProperties;\n return <AgentContextProvider>\n <LayerContext.Provider value={value}>\n <div data-app-shell=\"\" className=\"contents\" style={scale}>\n <div ref={probe} aria-hidden=\"true\" className=\"pointer-events-none invisible fixed top-0 left-0 w-0\" style={{ height: 'var(--app-shell-tabbar, 0px)' }} />\n {children}\n {host && opened.current && createPortal(<Chats showing={shown} />, host)}\n </div>\n </LayerContext.Provider>\n </AgentContextProvider>;\n}\n\n/** The header button that opens the layer: a tap opens half; with `agent.voice`, press and hold records and a slide off cancels. Absent without `agent`. */\nexport function AgentTrigger({ className, children }: { readonly className?: string; readonly children?: ReactNode }) {\n const layer = useInternal();\n const { agent, voice } = layer;\n const text = appShellText(agent?.chatProps?.locale ?? layer.locale);\n const hold = useRef<{ timer: ReturnType<typeof setTimeout> | undefined; held: boolean; id: number } | null>(null);\n if (!agent) return null;\n\n const toggle = () => {\n layer.triggerUntil.current = Date.now() + 500;\n if (layer.state !== 'closed' && layer.showing === 'agent' && layer.expanded) layer.close();\n else layer.open('agent', 'half');\n };\n const release = (event: ReactPointerEvent<HTMLButtonElement>) => {\n const current = hold.current;\n hold.current = null;\n if (!current) return;\n clearTimeout(current.timer);\n if (event.currentTarget.hasPointerCapture?.(current.id)) event.currentTarget.releasePointerCapture(current.id);\n if (current.held) { if (event.type === 'pointerup') voice.finish(); else voice.cancel(); }\n else if (event.type === 'pointerup') toggle();\n };\n const down = (event: ReactPointerEvent<HTMLButtonElement>) => {\n if (event.button !== 0) return;\n const target = event.currentTarget;\n target.setPointerCapture?.(event.pointerId);\n const state = { timer: undefined as ReturnType<typeof setTimeout> | undefined, held: false, id: event.pointerId };\n hold.current = state;\n if (agent.voice) {\n state.timer = setTimeout(() => {\n state.held = true;\n if (layer.snap === 'closed') layer.setSnap('peek');\n layer.showAgent();\n voice.start();\n }, HOLD_MS);\n }\n };\n const move = (event: ReactPointerEvent<HTMLButtonElement>) => {\n const current = hold.current;\n if (!current?.held) return;\n const box = event.currentTarget.getBoundingClientRect();\n const slack = 24;\n if (event.clientX < box.left - slack || event.clientX > box.right + slack || event.clientY < box.top - slack || event.clientY > box.bottom + slack) {\n hold.current = null;\n voice.cancel();\n }\n };\n return <Button variant=\"ghost\" size=\"icon\" data-agent-trigger=\"\" data-testid=\"agent-trigger\" aria-label={text.open} title={text.open} aria-expanded={layer.state !== 'closed'}\n onPointerDown={down} onPointerUp={release} onPointerCancel={release} onPointerMove={move} onContextMenu={event => event.preventDefault()}\n onClick={event => { if (event.detail === 0) toggle(); }}\n className={cn('touch-none', className)}>\n {children ?? <SparklesIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Button>;\n}\n\nconst clock = (seconds: number) => `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;\n\n/**\n * Push-to-talk in the composer row (PiChat's `tools` slot): press and hold, release to send or fill; a slide off cancels. Pressing never moves\n * focus (no keyboard opens or closes under the finger), and a browser `pointercancel` once recording keeps the audio like a release: only the slide off cancels.\n */\nfunction TalkButton({ text }: { readonly text: AppShellText }) {\n const { voice } = useInternal();\n const recording = voice.phase === 'recording' || voice.phase === 'starting';\n const busy = voice.phase === 'transcribing';\n const button = useRef<HTMLButtonElement>(null);\n // A native, non-passive listener (React's touchstart is passive): a touch press must not focus anything either.\n useEffect(() => {\n const node = button.current;\n const keep = (event: Event) => { if (event.cancelable) event.preventDefault(); };\n node?.addEventListener('touchstart', keep, { passive: false });\n return () => node?.removeEventListener('touchstart', keep);\n }, []);\n const down = (event: ReactPointerEvent<HTMLButtonElement>) => { event.preventDefault(); event.currentTarget.setPointerCapture?.(event.pointerId); voice.start(); };\n const up = (event: ReactPointerEvent<HTMLButtonElement>) => { if (event.currentTarget.hasPointerCapture?.(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); voice.finish(); };\n const move = (event: ReactPointerEvent<HTMLButtonElement>) => {\n if (!recording) return;\n const box = event.currentTarget.getBoundingClientRect(), slack = 24;\n if (event.clientX < box.left - slack || event.clientX > box.right + slack || event.clientY < box.top - slack || event.clientY > box.bottom + slack) voice.cancel();\n };\n return <Button ref={button} variant=\"ghost\" size=\"icon\" data-testid=\"agent-talk\" data-state={voice.phase} disabled={busy} aria-label={text.holdToTalk} title={text.holdToTalk}\n onPointerDown={down} onMouseDown={event => event.preventDefault()} onPointerUp={up} onPointerCancel={up} onPointerMove={move} onContextMenu={event => event.preventDefault()}\n className=\"touch-none\">\n {busy ? <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" /> : <MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Button>;\n}\n\n/** What went wrong with the microphone, in the flow under the title row (never over the messages). */\nfunction VoiceProblem() {\n const { voice } = useInternal();\n return voice.problem ? <p role=\"alert\" data-testid=\"agent-voice-problem\" className=\"m-0 shrink-0 px-3 pb-1 text-xs leading-4 text-destructive\">{voice.problem}</p> : null;\n}\n\n/** One agent's PiChat. It stays mounted while hidden, so its scroll, focus and drafts survive a swap with the other spec. */\nfunction SpecChat({ spec, visible, role }: { readonly spec: AgentSpec; readonly visible: boolean; readonly role: Which }) {\n const layer = useInternal();\n const locale = spec.chatProps?.locale ?? layer.locale;\n // One annotate toggle: pins on desktop, point mode (which hides the layer while the person taps the app) on touch and compact screens.\n const feedback = useComposerFeedback(spec.feedback);\n const pointing = usePointing(spec.feedback);\n // The pin's mic uses the shell's transcription: this agent's own, else the coach's.\n const transcribe = (spec.voice ?? layer.agent?.voice)?.transcribe;\n const fr = locale === 'fr';\n return <div data-spec={role} hidden={!visible} className={cn('min-h-0 min-w-0 flex-1', visible ? 'flex flex-col' : 'hidden')}>\n <PiChat {...spec.chatProps} {...(locale ? { locale } : {})} controller={spec.controller} keyboardInset={false} placement={layer.expanded && !layer.dockFloating ? 'docked' : 'floating'} {...(feedback ? { feedback } : {})}\n {...(role === 'agent' && spec.voice ? { tools: <>{spec.chatProps?.tools}<TalkButton text={appShellText(locale)} /></> } : {})} />\n {/* The note editor of ordinary feedback lives inside the layer, in the modal's focus tree; while pointing the layer steps aside and AgentLayer renders it. */}\n {/* In the flow, inside the visible content box: the popup is transformed (a fixed element would be placed against its off-screen bottom). */}\n {spec.feedback && visible && !pointing && <div className=\"shrink-0 px-2 pb-2 empty:hidden\">\n <NoteBubble session={spec.feedback} {...(transcribe ? { transcribe } : {})} {...(fr ? { labels: feedbackLabelsFr } : {})} /></div>}\n </div>;\n}\n\n/** Where the chats show: the persistent host element is moved in once, when the drawer or panel mounts. */\nfunction ChatSlot() {\n const { host } = useInternal();\n const place = useCallback((slot: HTMLDivElement | null) => { if (slot && host) slot.appendChild(host); }, [host]);\n // Coarse pointers: the chat's own transcript buttons (copy, show earlier) get the layer's 44px rule from here, without touching pi-chat.\n return <div ref={place} className=\"flex min-h-0 flex-1 flex-col [&_[data-testid=transcript]_button]:pointer-coarse:min-h-11 [&_[data-testid=transcript]_button]:pointer-coarse:min-w-11\" />;\n}\n\n/** Both specs, each mounted once it has been shown. */\nfunction Chats({ showing }: { readonly showing: Which }) {\n const { agent, improve } = useInternal();\n const seen = useRef(new Set<Which>());\n seen.current.add(showing);\n return <>\n {agent && seen.current.has('agent') && <SpecChat spec={agent} visible={showing === 'agent'} role=\"agent\" />}\n {improve && seen.current.has('improve') && <SpecChat spec={improve} visible={showing === 'improve'} role=\"improve\" />}\n </>;\n}\n\n/** Title row: « ← » when the improve view has an agent to go back to, the title, then Expand or Collapse (compact only), the dock controls (expanded only) and Close. */\nfunction Header({ text, title, compactSnap, closeRef, controls, className, ...rest }: { readonly text: AppShellText; readonly title: string; readonly compactSnap?: Snap; readonly closeRef?: Ref<HTMLButtonElement>; readonly controls?: ReactNode } & Omit<HTMLAttributes<HTMLDivElement>, 'title'>) {\n const layer = useInternal();\n const back = layer.showing === 'improve' && layer.agent;\n const actions = (layer.showing === 'improve' ? layer.improve : layer.agent)?.headerActions ?? [];\n return <div data-testid=\"agent-layer-header\" {...rest} className={cn('flex shrink-0 items-center gap-1 px-2 pb-1', className)}>\n {back && <Button variant=\"ghost\" size=\"icon\" data-testid=\"agent-layer-back\" aria-label={text.back} title={text.back} onClick={layer.showAgent}><ArrowLeftIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n <h2 data-testid=\"agent-layer-title\" className=\"m-0 min-w-0 flex-1 truncate px-1 text-sm font-semibold\">{title}</h2>\n {actions.length > 0 && <HeaderActions actions={actions} compact={compactSnap !== undefined} text={text} prefix=\"agent-layer\" run={id => actions.find(action => action.id === id)?.onSelect()} />}\n {compactSnap === 'half' && <Button variant=\"ghost\" size=\"icon\" data-testid=\"agent-layer-expand\" aria-label={text.expand} title={text.expand} onClick={() => layer.setSnap('full')}><ChevronUpIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {compactSnap === 'full' && <Button variant=\"ghost\" size=\"icon\" data-testid=\"agent-layer-collapse\" aria-label={text.collapse} title={text.collapse} onClick={() => layer.setSnap('half')}><ChevronDownIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {controls}\n <Button variant=\"ghost\" size=\"icon\" ref={closeRef} data-testid=\"agent-layer-close\" aria-label={text.close} title={text.close} onClick={layer.close}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </div>;\n}\n\nfunction useTitle(): { text: AppShellText; title: string } {\n const layer = useInternal();\n const spec = layer.showing === 'improve' ? layer.improve : layer.agent;\n const text = appShellText(spec?.chatProps?.locale ?? layer.locale);\n const title = layer.showing === 'improve' ? text.improve : (spec?.chatProps?.labels?.title ?? text.assistant);\n return { text, title };\n}\n\nconst toSnap = (point: number | string | null, peekPoint: string): Snap | null =>\n point === peekPoint ? 'peek' : point === HALF ? 'half' : point === FULL ? 'full' : null;\n\nfunction CompactSheet({ pointing }: { readonly pointing: boolean }) {\n const layer = useInternal();\n const { snap, agent, voice } = layer;\n const hasPeek = Boolean(agent);\n const { text, title } = useTitle();\n const liveViewport = useVisualViewport();\n const closeRef = useRef<HTMLButtonElement>(null);\n const vetoed = useRef(false);\n // Set while the layer closes; cleared when it opens again (a close is final until the trigger or a choice opens the layer).\n const closing = useRef(false);\n useEffect(() => { if (snap !== 'closed') closing.current = false; }, [snap]);\n const open = snap !== 'closed' && !pointing;\n const peek = snap === 'peek';\n const points = useMemo(() => hasPeek ? [layer.peekPoint, HALF, FULL] : [HALF, FULL], [hasPeek, layer.peekPoint]);\n const value = peek ? layer.peekPoint : snap === 'full' ? FULL : HALF;\n const recording = voice.phase !== 'idle';\n // While the person talks the sheet holds still: the viewport height and the keyboard inset are pinned at what they were when the recording\n // began, the composer takes no focus and no keyboard, so nothing moves under the finger on the microphone.\n const sheet = useRef<HTMLDivElement>(null);\n const idleViewport = useRef(liveViewport);\n if (!recording) idleViewport.current = liveViewport;\n const vv = recording ? idleViewport.current : liveViewport;\n const [pinnedBottom, setPinnedBottom] = useState<string | undefined>();\n useLayoutEffect(() => {\n const popup = sheet.current?.closest('[data-slot=drawer-popup]');\n if (!recording || !sheet.current) return;\n if (popup) setPinnedBottom(getComputedStyle(popup).bottom);\n const inputs = [...sheet.current.querySelectorAll<HTMLTextAreaElement>('[data-testid=composer-input]')].map(input => ({ input, mode: input.getAttribute('inputmode'), readOnly: input.readOnly }));\n for (const { input } of inputs) { input.readOnly = true; input.setAttribute('inputmode', 'none'); }\n return () => {\n setPinnedBottom(undefined);\n for (const { input, mode, readOnly } of inputs) { input.readOnly = readOnly; if (mode === null) input.removeAttribute('inputmode'); else input.setAttribute('inputmode', mode); }\n };\n }, [recording]);\n\n useBackLayer(snap === 'half' || snap === 'full', layer.close, 'agent');\n\n // The popup is taller than what shows and slides down by `--drawer-snap-point-offset` (a registered, non-inheriting property, so only the\n // popup itself can read it): padding the popup by that offset makes its content box exactly the visible part. At peek the tab bar below is left out too.\n // The popup is portalled to the body, outside the shell root: it is given the measured tab bar height and the z-values explicitly.\n const style = { '--app-shell-vv-height': vv ? `${vv.height}px` : '100dvh', '--app-shell-tabbar': `${layer.tabbar}px`, '--app-shell-z-layer': Z.layer,\n paddingBottom: peek ? 'calc(var(--drawer-snap-point-offset, 0px) + var(--app-shell-tabbar, 0px))' : 'var(--drawer-snap-point-offset, 0px)',\n ...(pinnedBottom ? { bottom: pinnedBottom } : {}) } as CSSProperties;\n // Entering the modal half or full from the non-modal peek does not rerun Base UI's initial focus: if focus is still on the page, take it to Close.\n const wasPeek = useRef(false);\n useEffect(() => {\n const entering = wasPeek.current && !peek && open;\n wasPeek.current = peek;\n if (!entering) return;\n // The element that held focus when the sheet left peek (a page control now behind the modal). Base UI re-applies its modal guards as the\n // sheet moves, so retake focus once they have settled and again after the slide, but only while focus is still stranded there (or lost to the body).\n const stranded = document.activeElement;\n const take = () => {\n const popup = closeRef.current?.closest('[data-slot=drawer-popup]');\n const active = document.activeElement;\n // Still stranded, or dropped to the body by the modal guards: retake. Focus that went anywhere else is the person's and is left alone.\n if (popup && (active === stranded || active === document.body || active === null) && !popup.contains(active)) closeRef.current?.focus({ preventScroll: true });\n };\n const early = setTimeout(take, 60), late = setTimeout(take, 520);\n return () => { clearTimeout(early); clearTimeout(late); };\n }, [peek, open]);\n return <Drawer open={open} modal={!peek} showSwipeHandle swipeDirection=\"down\" snapPoints={points} snapPoint={value}\n onOpenChange={(next, details) => {\n if (next) return;\n if (isOverlayEvent(details)) { details.cancel(); return; }\n const outcome = nextSnapOnClose(details.reason, hasPeek);\n if (outcome === 'close') { closing.current = true; layer.close(); return; }\n details.cancel();\n vetoed.current = true;\n setTimeout(() => { vetoed.current = false; }, 120);\n // Focus leaving the non-modal peek changes nothing, and neither does the click that finished a press on the AgentTrigger (outside the\n // drawer, so it counts as an outside press): the trigger already chose the snap.\n if (details.reason === 'focus-out' || Date.now() < layer.triggerUntil.current) return;\n layer.setSnap(outcome);\n }}\n onSnapPointChange={point => {\n // Vetoing a swipe-close makes the drawer snap back to where it was: that echo must not undo the snap we chose.\n // Base UI also reports the first snap point (peek) as the drawer closes: that echo must not reopen the layer.\n if (vetoed.current || closing.current) return;\n const next = toSnap(point, layer.peekPoint);\n if (next && (next !== 'peek' || hasPeek)) layer.setSnap(next);\n }}>\n <DrawerContent data-testid=\"agent-layer\" aria-label={title} data-state={snap} data-peek={peek} initialFocus={() => closeRef.current ?? false} style={style}\n className={cn('app-shell box-border z-(--app-shell-z-layer) data-[swipe-direction=down]:bottom-(--drawer-keyboard-inset,0px) data-[swipe-axis=y]:[--drawer-content-max-height:calc(var(--app-shell-vv-height,100dvh)-1.5rem)]',\n // The peek is the top PEEK_REM of the popup; the part that overlaps the tab bar is clipped away, so the tabs stay visible and tappable.\n 'data-[peek=true]:not-data-swiping:[clip-path:inset(0_0_calc(100%_-_6rem)_0)]')}>\n <BaseDrawerParts.Drawer.VirtualKeyboardProvider>\n <div ref={sheet} data-testid=\"agent-layer-sheet\" onFocusCapture={event => { if (!recording && (event.target as HTMLElement).matches('[data-testid=composer-input]')) layer.setSnap('full'); }}\n className={cn('relative flex min-h-0 flex-1 flex-col overflow-hidden overscroll-contain',\n // The layer's own header carries the title and Close, so the chat's is hidden. At peek: one line of the last reply above the\n // composer, no empty-state overlay, room for Close.\n '[&_[data-boring=pi-chat]>header]:hidden',\n // At peek: ONE compact row (a one-line input, the microphone and Send; the other composer controls wait for half), and the last\n // reply as a single truncated line above it.\n peek && ['[&_[data-boring=pi-chat]>.relative]:overflow-hidden [&_[data-boring=pi-chat]>.relative>.pointer-events-none]:hidden',\n '[&_[data-boring=pi-chat]>footer]:px-2 [&_[data-boring=pi-chat]>footer]:pt-1 [&_[data-boring=pi-chat]>footer]:pb-2',\n '[&_[data-testid=transcript]]:gap-0 [&_[data-testid=transcript]]:p-0 [&_[data-testid=transcript]]:px-3 [&_[data-testid=transcript]>*]:max-h-5 [&_[data-testid=transcript]>*]:truncate [&_[data-testid=transcript]_button]:hidden',\n '[&_[data-testid=composer]_[data-slot=input-group]]:flex-row! [&_[data-testid=composer]_[data-slot=input-group]]:items-center',\n '[&_[data-testid=composer-input]]:min-h-11 [&_[data-testid=composer-input]]:max-h-11 [&_[data-testid=composer-input]]:flex-1 [&_[data-testid=composer-input]]:px-4 [&_[data-testid=composer-input]]:py-2.5',\n '[&_[data-testid=composer-controls]]:w-auto [&_[data-testid=composer-controls]]:shrink-0 [&_[data-testid=composer-controls]]:py-0 [&_[data-testid=composer-controls]>span.flex-1]:hidden',\n '[&_[data-testid=composer-plus]]:hidden [&_[data-testid=composer-annotate]]:hidden'])}>\n {!peek && <Header text={text} title={title} compactSnap={snap} closeRef={closeRef} />}\n {!peek && <VoiceProblem />}\n {peek && recording && <div role=\"status\" data-testid=\"agent-listening\" data-phase={voice.phase} className=\"absolute inset-0 z-20 flex items-center justify-center gap-2 bg-popover text-sm font-medium\">\n <MicIcon className=\"size-4 text-destructive\" aria-hidden=\"true\" />{voice.phase === 'transcribing' ? <Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" /> : `${text.listening} ${clock(voice.seconds)}`}\n </div>}\n {/* The transcript scrolls and never drags the sheet: only the handle and the title row swipe. */}\n <div data-base-ui-swipe-ignore=\"\" className=\"flex min-h-0 flex-1 flex-col\"><ChatSlot /></div>\n </div>\n </BaseDrawerParts.Drawer.VirtualKeyboardProvider>\n </DrawerContent>\n </Drawer>;\n}\n\n/** The panel inside the dock: the same element docked or floating, so a placement change keeps focus and the chats where they are. */\nfunction DockPanel({ api, side }: { readonly api: DockApi; readonly side: DockSide }) {\n const layer = useInternal();\n const { text, title } = useTitle();\n const open = layer.snap !== 'closed';\n const floating = api.floating;\n // The chats report this as their placement: the phone drawer and the floating panel are `floating`, the dock is `docked`.\n const { setDockFloating } = layer;\n useLayoutEffect(() => { setDockFloating(floating); return () => setDockFloating(false); }, [floating, setDockFloating]);\n const box = useRef<HTMLDivElement>(null);\n const drag = useRef<{ dx: number; dy: number } | null>(null);\n const [pos, setPos] = useState<{ left: number; top: number } | null>(null);\n useEffect(() => {\n if (!open) return;\n const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape' && !event.defaultPrevented) layer.close(); };\n document.addEventListener('keydown', onKey);\n return () => document.removeEventListener('keydown', onKey);\n }, [open, layer]);\n // A dock control (or the divider's Alt+Arrow) moves or removes the element that had focus: it goes to the panel's first dock control instead of the page.\n useLayoutEffect(() => {\n const lost = !document.activeElement || document.activeElement === document.body;\n const wanted = layer.restoreFocus.current || (open && lost && api.placement !== 'closed');\n layer.restoreFocus.current = false;\n if (wanted) box.current?.querySelector<HTMLElement>('[data-dock-control]')?.focus({ preventScroll: true });\n }, [api.placement, side, layer.restoreFocus]);\n // A dragged window stays reachable: its remembered position is clamped to the viewport on open, on placement change and on every resize.\n const [viewport, setViewport] = useState(0);\n useEffect(() => { const resize = () => setViewport(count => count + 1); window.addEventListener('resize', resize); return () => window.removeEventListener('resize', resize); }, []);\n useLayoutEffect(() => {\n const rect = box.current?.getBoundingClientRect();\n if (!pos || !floating || !open || !rect || !rect.width) return;\n const left = Math.min(Math.max(pos.left, 0), Math.max(0, innerWidth - rect.width)), top = Math.min(Math.max(pos.top, 0), Math.max(0, innerHeight - rect.height));\n if (left !== pos.left || top !== pos.top) setPos({ left, top });\n }, [pos, floating, open, viewport]);\n const act = (run: () => void) => () => { layer.restoreFocus.current = true; run(); };\n const control = (id: string, label: string, Icon: ComponentType<{ className?: string }>, run: () => void) =>\n <Button variant=\"ghost\" size=\"icon\" key={id} data-testid={`agent-layer-${id}`} data-dock-control=\"\" aria-label={label} title={label} onClick={act(run)}><Icon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>;\n const controls = <>\n {!floating && control('float', text.float, PictureInPicture2Icon, () => api.float())}\n {(floating || side === 'right') && control('dock-left', text.dockLeft, PanelLeftIcon, () => { layer.setSide('left'); api.dock(); })}\n {(floating || side === 'left') && control('dock-right', text.dockRight, PanelRightIcon, () => { layer.setSide('right'); api.dock(); })}\n </>;\n const down = (event: ReactPointerEvent<HTMLDivElement>) => {\n const rect = box.current?.getBoundingClientRect();\n if (!floating || !rect || event.button !== 0 || (event.target as HTMLElement).closest('button, a[href]')) return;\n drag.current = { dx: event.clientX - rect.left, dy: event.clientY - rect.top };\n event.currentTarget.setPointerCapture?.(event.pointerId);\n };\n const move = (event: ReactPointerEvent<HTMLDivElement>) => {\n const grab = drag.current, rect = box.current?.getBoundingClientRect();\n if (!grab || !rect) return;\n setPos({ left: Math.min(Math.max(event.clientX - grab.dx, 0), Math.max(0, innerWidth - rect.width)), top: Math.min(Math.max(event.clientY - grab.dy, 0), Math.max(0, innerHeight - rect.height)) });\n };\n const end = () => { drag.current = null; };\n // Floating, the host's Card is drawn behind the layer (a sibling layer, so docking or floating never remounts the chat).\n return <div ref={box} data-testid=\"agent-layer\" data-state={open ? 'open' : 'closed'} data-placement={api.placement} data-side={side} role={floating ? 'dialog' : 'complementary'} aria-label={title}\n style={floating ? pos ?? { right: 16, bottom: 16 } : undefined}\n className={cn('app-shell relative flex min-h-0 flex-col',\n floating ? 'fixed isolate z-(--app-shell-z-layer) h-[min(36rem,calc(100dvh-2rem))] w-[min(24rem,calc(100vw-2rem))]'\n : 'h-full min-w-0 flex-1 bg-background text-foreground')}>\n {floating && <Card aria-hidden=\"true\" data-boring=\"chrome\" className=\"pointer-events-none absolute inset-0 -z-10\" />}\n {!floating && <Separator orientation=\"vertical\" className={cn('absolute inset-y-0', side === 'right' ? 'left-0' : 'right-0')} />}\n <Header text={text} title={title} controls={controls} className={cn('pt-2', floating && 'cursor-move touch-none select-none')} onPointerDown={down} onPointerMove={move} onPointerUp={end} onPointerCancel={end} />\n <div className=\"flex min-h-0 flex-1 flex-col overflow-hidden\"><VoiceProblem /><ChatSlot /></div>\n </div>;\n}\n\nfunction AgentDock({ side }: { readonly side: DockSide }) {\n const layer = useInternal();\n const { title } = useTitle();\n return <Dock id=\"agent\" side={side} floatable defaultWidth={384} minWidth={320} dragBelow={260} open={layer.snap !== 'closed'} onOpenChange={next => { if (!next) layer.close(); }} labels={{ region: title }}>\n {api => <DockPanel api={api} side={side} />}\n </Dock>;\n}\n\n/**\n * Wraps the app's content (placed once by the shell): on a wide screen the agent dock sits on its left or right and the content reflows,\n * or it floats over the content; on a phone this is only the content (the layer is the bottom drawer). Host concern: Pi has no layout.\n */\nexport function AgentDockLayout({ children }: { readonly children: ReactNode }): ReactElement {\n const layer = useInternal();\n const dock = layer.expanded && (layer.agent || layer.improve) ? <AgentDock side={layer.side} /> : null;\n return <DockLayout storageKey={DOCK_KEY} persist=\"local\">\n {layer.side === 'left' && dock}\n <DockMain>{children}</DockMain>\n {layer.side === 'right' && dock}\n </DockLayout>;\n}\n\n/** The layer itself: the drawer on a phone (the wide dock is `AgentDockLayout`). Placed once by the shell, for point mode on both. */\nexport function AgentLayer(): ReactElement | null {\n const layer = useInternal();\n const pointingAgent = usePointing(layer.agent?.feedback), pointingImprove = usePointing(layer.improve?.feedback);\n const session = pointingAgent ? layer.agent?.feedback : pointingImprove ? layer.improve?.feedback : undefined;\n // Back ends pointing first (the `point` priority); the layer keeps its own history entry underneath.\n useBackLayer(session !== undefined, () => session?.discard(), 'point');\n if (!layer.agent && !layer.improve) return null;\n const pointed = pointingAgent ? layer.agent : layer.improve;\n const transcribe = (pointed?.voice ?? layer.agent?.voice)?.transcribe;\n const locale = pointed?.chatProps?.locale ?? layer.locale;\n // Point mode: the compact sheet steps aside (the wide dock does not cover the app); the bar sits above the tab bar, outside the layer.\n return <>\n {!layer.expanded && <CompactSheet pointing={session !== undefined} />}\n {session && <>\n <PointBar session={session} bottomOffset={`${layer.tabbar}px`} className=\"z-[60]\" {...(locale === 'fr' ? { labels: feedbackLabelsFr } : {})} />\n <NoteBubble session={session} {...(transcribe ? { transcribe } : {})} {...(locale === 'fr' ? { labels: feedbackLabelsFr } : {})} />\n </>}\n </>;\n}\n",
|
|
33
|
+
"content": "'use client';\n\n// The AgentLayer: ONE agent's PiChat above the app. Compact (below 768px): a bottom drawer with three fixed snap points, peek, half and\n// full (shadcn `drawer`, Base UI variant). Expanded: a `Dock` (the `dock` item) beside the app, docked on the left or the right (default right)\n// or floating over it; the person chooses and the choice is remembered. The same layer shows\n// the product manager (« Améliorer l’app ») when the account menu asks for it. Push-to-talk is an option of the agent.\n// Host concern: Pi has no layout. The chat is the borrowed NativeChatController rendered by PiChat; the screen context travels as the\n// submit's `context` (agent-context.ts). Nothing here is configurable beyond AgentSpec.\nimport { createPortal } from 'react-dom';\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ComponentType, CSSProperties, HTMLAttributes, PointerEvent as ReactPointerEvent, ReactElement, ReactNode, Ref, RefObject } from 'react';\nimport { ArrowLeftIcon, ChevronDownIcon, ChevronUpIcon, Loader2Icon, MicIcon, PanelLeftIcon, PanelRightIcon, PictureInPicture2Icon, SparklesIcon, XIcon } from 'lucide-react';\n// Behaviour only: Base UI's keyboard-inset provider for the host drawer's content. It renders nothing visible.\nimport * as BaseDrawerParts from '@base-ui/react/drawer';\nimport { Button } from '@/components/ui/button';\nimport { Card } from '@/components/ui/card';\nimport { Separator } from '@/components/ui/separator';\nimport type { FeedbackSession } from '@hachej/boring-feedback/ui';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { Drawer, DrawerContent } from '@/components/ui/drawer';\nimport { useIsMobile } from '@/hooks/use-mobile';\nimport { Dock, DockLayout, DockMain } from '../dock/dock';\nimport { useDraggable } from '../utils/use-draggable';\nimport type { DockApi } from '../dock/dock';\nimport { PiChat } from '../pi-chat/pi-chat';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\nimport type { VoiceMessageConfig } from '../pi-chat/config';\nimport { useHoldToRecord, useRecordingCancellation } from '../pi-chat/use-hold-to-record';\nimport { useVoiceMessage } from '../pi-chat/use-voice-message';\nimport type { PendingVoice } from '../pi-chat/use-voice-message';\nimport { voiceClock } from '../pi-chat/voice-message';\nimport { chatTextFor } from '../pi-chat/locale';\nimport { NoteBubble, useComposerFeedback } from '../feedback/feedback-session';\nimport { feedbackLabelsFr } from '../feedback/feedback-labels';\nimport { PointBar, usePointing } from '../feedback/point-mode';\nimport { cn } from '../utils/utils';\nimport { useBackLayer } from './back';\nimport { parseAgentLink } from './agent-link';\nimport { closeShellLayer, registerAgentLayerCloser } from './shell-layer';\nimport { appShellText } from './text';\nimport type { AppShellLocale, AppShellText } from './text';\nimport { useVisualViewport } from './use-visual-viewport';\nimport { AgentContextProvider } from './agent-context';\nimport { HeaderActions } from './header-actions';\nimport type { ScreenHeaderAction } from './header-actions';\nimport { useDictation } from './use-dictation';\nimport type { Dictation } from './use-dictation';\n\nexport interface AgentSpec {\n readonly controller: NativeChatController;\n /** The agent's name in the expanded sidebar (default: the chat's title, then « Assistant »). */\n readonly label?: string;\n /** The agent's icon in the sidebar (default: the sparkles of the header trigger). */\n readonly icon?: ComponentType<{ className?: string }>;\n readonly chatProps?: Omit<PiChatProps, 'controller'>;\n /** Push-to-talk. `message` retains audio plus transcript; `auto` sends text only; `draft` (default) fills the composer. */\n readonly voice?: VoiceMessageConfig & { readonly send?: 'auto' | 'draft' | 'message' };\n /** Point mode (the product manager, in practice). */\n readonly feedback?: FeedbackSession;\n /** Host actions in the layer's title row (before the dock controls, or the snap and close controls when compact), as the screen header's. Two shown, the rest in « … ». Not at peek. */\n readonly headerActions?: readonly ScreenHeaderAction[];\n}\n\n/** Compact: closed, peek, half or full. Expanded: closed or open. */\nexport type AgentLayerState = 'closed' | 'peek' | 'half' | 'full' | 'open';\ntype Which = 'agent' | 'improve';\ntype Snap = 'closed' | 'peek' | 'half' | 'full';\n\nexport interface AgentLayerApi {\n readonly agent: NativeChatController | undefined;\n readonly state: AgentLayerState;\n readonly showing: Which | null;\n open(which?: Which, at?: 'half' | 'full'): void;\n close(): void;\n readonly expanded: boolean;\n}\n\n// The three fixed snap points: a peek (one composer row and a line of the last reply, `PEEK_REM` above the tab bar), 55% and the full height.\nconst PEEK_REM = 6;\n/** The shell's z-scale (tabs 20, peek 30, drawer and panel 40, other sheets 50, point bar 60). */\nconst Z = { tabs: 20, peek: 30, layer: 40, sheet: 50, point: 60 } as const;\nconst HALF = 0.55;\nconst FULL = 1;\n/** Hold the trigger this long before it records; shorter is a tap. */\nconst HOLD_MS = 350;\n\n/**\n * What a close request from the drawer becomes. A swipe, an outside press, a lost focus and Escape never close the layer: they step down\n * to peek (half without an `agent`), so the modal state goes and the coach stays reachable. Only the Close control closes it. `reason` is\n * Base UI's `details.reason` (`'swipe'`, `'outside-press'`, `'focus-out'`, `'escape-key'`, `'close-watcher'`, `'close-press'`, `'trigger-press'`,\n * `'imperative-action'`, `'none'`).\n */\nexport function nextSnapOnClose(reason: string, hasPeek: boolean): 'peek' | 'half' | 'close' {\n return reason === 'swipe' || reason === 'outside-press' || reason === 'focus-out' || reason === 'escape-key' ? (hasPeek ? 'peek' : 'half') : 'close';\n}\n\ninterface Voice extends Dictation {\n readonly pending?: readonly PendingVoice[] | undefined;\n readonly captureSequence?: number;\n readonly canStart?: boolean;\n}\n\n/**\n * Whether a close request comes from something the layer does not own: a full-screen overlay portalled above it (`[data-boring-overlay]`,\n * for example the composer's camera). Its taps count as outside presses and its focus as a focus-out of the drawer, but they must change\n * nothing: the caller cancels the request and leaves the snap alone. Only those three reasons are looked at, and the event target, the\n * related target (focus-out) or the focused element may be the overlay.\n */\nexport function isOverlayEvent(details: { readonly reason: string; readonly event?: Event | undefined }): boolean {\n if (details.reason !== 'outside-press' && details.reason !== 'focus-out' && details.reason !== 'escape-key') return false;\n const inside = (node: unknown) => node instanceof Element && node.closest('[data-boring-overlay]') !== null;\n const event = details.event as (Event & { relatedTarget?: EventTarget | null }) | undefined;\n return inside(event?.target) || inside(event?.relatedTarget) || inside(globalThis.document?.activeElement);\n}\n\ninterface Internal extends Omit<AgentLayerApi, 'agent'> {\n readonly agent: AgentSpec | undefined;\n readonly improve: AgentSpec | undefined;\n readonly snap: Snap;\n readonly setSnap: (snap: Snap) => void;\n /** Whether the wide layout's dock is floating over the app (reported by the panel); with `expanded` it gives the chats' placement. */\n readonly dockFloating: boolean;\n readonly setDockFloating: (floating: boolean) => void;\n readonly showAgent: () => void;\n /** Until this time (ms) an outside press is the AgentTrigger's own click. */\n readonly triggerUntil: { current: number };\n readonly voice: Voice;\n /** The peek height in px: `PEEK_REM` plus the tab bar the peek sits above. */\n readonly peekPoint: string;\n /** The measured tab bar height in px, given explicitly to the parts portalled to the body (they cannot inherit the shell's variables). */\n readonly tabbar: number;\n /** The persistent element holding both chats: it moves between the drawer and the panel, so closing never unmounts a chat. */\n readonly host: HTMLElement | null;\n /** The shell's `locale`: the layer's own words when the agent spec sets none (`agent.chatProps.locale` first). */\n readonly locale: AppShellLocale | undefined;\n /** Expanded only: the side the dock sits on while docked (the viewer's choice, remembered). */\n readonly side: DockSide;\n readonly setSide: (side: DockSide) => void;\n /** Set by a dock control: the next placement or side change puts focus back on the panel's first dock control. */\n readonly restoreFocus: { current: boolean };\n}\n\ntype DockSide = 'left' | 'right';\n/** The dock's remembered state lives under this prefix (DockLayout's storageKey): `<key>.agent.floating`, `.width`, and the side. */\nconst DOCK_KEY = 'boring.app-shell';\nconst SIDE_KEY = `${DOCK_KEY}.agent.side`;\n\nconst LayerContext = createContext<Internal | null>(null);\n\nfunction useInternal(): Internal {\n const layer = useContext(LayerContext);\n if (!layer) throw new Error('useAgentLayer needs an AgentLayerProvider');\n return layer;\n}\n\n/** The layer's state and controls. */\nexport function useAgentLayer(): AgentLayerApi {\n const { state, showing, open, close, expanded, agent: spec } = useInternal();\n const agent = spec?.controller;\n return useMemo(() => ({ state, showing, open, close, expanded, agent }), [state, showing, open, close, expanded, agent]);\n}\n\n/** Push-to-talk uses the same dictation lifecycle as page targeting, over the existing composer/controller. */\nfunction useVoice(agent: AgentSpec | undefined, locale: AppShellLocale | undefined, done: (outcome: 'sent' | 'drafted') => void): Voice {\n const message = useVoiceMessage({ controller: agent?.controller, config: agent?.voice?.send === 'message' ? agent.voice : undefined, attachments: agent?.chatProps?.attachments, labels: chatTextFor(agent?.chatProps?.locale ?? locale).labels, admitted: () => done('sent') });\n const voice = useDictation({\n transcribe: agent?.voice?.send === 'message' ? undefined : agent?.voice?.transcribe,\n locale: agent?.chatProps?.locale ?? locale,\n onText: async (spoken, signal) => {\n if (!agent || signal.aborted) return;\n const fill = () => {\n if (signal.aborted) return;\n const draft = agent.controller.getSnapshot().draft.text;\n agent.controller.setText(`${draft}${draft === '' || /\\s$/.test(draft) ? '' : ' '}${spoken}`);\n done('drafted');\n };\n if (agent.voice?.send !== 'auto') { fill(); return; }\n try {\n const sent = await agent.controller.send(undefined, { text: spoken });\n if (!signal.aborted) done(sent ? 'sent' : 'drafted');\n } catch { fill(); }\n },\n });\n const { cancel } = voice;\n useEffect(() => () => cancel(), [agent?.controller, agent?.voice?.send, Boolean(agent?.voice), cancel]);\n const phase: Dictation['phase'] = message.phase === 'starting' || message.phase === 'recording' ? message.phase : message.phase === 'processing' || message.phase === 'sending' ? 'transcribing' : 'idle';\n return agent?.voice?.send === 'message' ? { ...message, phase } : voice;\n}\n\n/** The height in px of the tab bar the peek sits above (`--app-shell-tabbar` on the shell root), and one rem. */\nfunction useMeasuredPeek(probe: RefObject<HTMLDivElement | null>): { readonly peekPoint: string; readonly tabbar: number } {\n const [px, setPx] = useState(PEEK_REM * 16);\n const [tabbar, setTabbar] = useState(0);\n useLayoutEffect(() => {\n const measure = () => {\n const tab = probe.current?.offsetHeight ?? 0;\n const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;\n setPx(Math.round(PEEK_REM * rem + tab));\n setTabbar(tab);\n };\n measure();\n window.addEventListener('resize', measure);\n const observer = typeof ResizeObserver === 'undefined' || !probe.current ? undefined : new ResizeObserver(measure);\n if (observer && probe.current) observer.observe(probe.current);\n return () => { window.removeEventListener('resize', measure); observer?.disconnect(); };\n }, [probe]);\n return { peekPoint: `${px}px`, tabbar };\n}\n\n/** Places the layer in the app. `agent` is the one assistant; `improve` is the product manager. Either may be absent. */\nexport function AgentLayerProvider({ agent, improve, locale, agentLink, children }: { readonly agent?: AgentSpec | undefined; readonly improve?: AgentSpec | undefined; readonly locale?: AppShellLocale | undefined; readonly agentLink?: false | undefined; readonly children: ReactNode }): ReactElement {\n const compact = useIsMobile();\n const expanded = !compact;\n const [snap, setSnapState] = useState<Snap>('closed');\n const [dockFloating, setDockFloating] = useState(false);\n const [shown, setShown] = useState<Which>(agent ? 'agent' : 'improve');\n const probe = useRef<HTMLDivElement>(null);\n const { peekPoint, tabbar } = useMeasuredPeek(probe);\n const triggerUntil = useRef(0);\n const restoreFocus = useRef(false);\n const [side, setSideState] = useState<DockSide>('right');\n // The side is the viewer's, remembered beside the Dock's own placement; read after mount so server and client markup agree.\n useLayoutEffect(() => { try { const saved = globalThis.localStorage?.getItem(SIDE_KEY); if (saved === 'left' || saved === 'right') setSideState(saved); } catch { /* a convenience */ } }, []);\n const setSide = useCallback((next: DockSide) => { setSideState(next); try { globalThis.localStorage?.setItem(SIDE_KEY, next); } catch { /* a convenience */ } }, []);\n const specs = useRef({ agent, improve, snap, shown });\n specs.current = { agent, improve, snap, shown };\n\n const setSnap = useCallback((next: Snap) => setSnapState(next), []);\n const close = useCallback(() => setSnapState('closed'), []);\n // One full-screen layer at a time: opening the agent or product manager closes a view of the app (`useShellLayer`), and a view closes these.\n useEffect(() => registerAgentLayerCloser(close), [close]);\n const open = useCallback((which?: Which, at: 'half' | 'full' = 'half') => {\n const { agent: a, improve: i } = specs.current;\n const target = which ?? (a ? 'agent' : 'improve');\n if (target === 'agent' ? !a : !i) return;\n closeShellLayer();\n setShown(target);\n setSnapState(at);\n }, []);\n const showAgent = useCallback(() => { if (specs.current.agent) setShown('agent'); }, []);\n\n // A spec that goes away takes the layer with it.\n const shownSpec = shown === 'agent' ? agent : improve;\n useEffect(() => { if (!shownSpec) { setSnapState('closed'); if (shown === 'improve' && agent) setShown('agent'); else if (shown === 'agent' && improve) setShown('improve'); } }, [shownSpec, shown, agent, improve]);\n\n // The chats live in one element that is moved into the open drawer or panel; React keeps them mounted (and so their presentation state\n // and uploads) while the layer is closed, on a phone and on a wide screen. Nothing is mounted before the layer is first opened.\n const [host] = useState(() => typeof document === 'undefined' ? null : Object.assign(document.createElement('div'), { className: 'flex min-h-0 flex-1 flex-col [&_[data-boring=pi-chat]>header]:hidden' }));\n const opened = useRef(false);\n if (snap !== 'closed') opened.current = true;\n\n // Consume the launch URL before an open layer pushes its Back entry. Keep the parsed request through StrictMode's effect replay.\n const launch = useRef<ReturnType<typeof parseAgentLink> | undefined>(undefined);\n useEffect(() => {\n if (launch.current === undefined) {\n launch.current = agentLink === false ? null : parseAgentLink(window.location.search);\n if (agentLink !== false) {\n const url = new URL(window.location.href);\n if (['agent', 'open', 'input'].some(key => url.searchParams.has(key))) {\n for (const key of ['agent', 'open', 'input']) url.searchParams.delete(key);\n history.replaceState(history.state, '', `${url.pathname}${url.search}${url.hash}`);\n }\n }\n }\n const link = launch.current;\n if (!link) return;\n const which = link.agent === 'improve' ? 'improve' : 'agent';\n open(which, link.open);\n const spec = specs.current[which];\n if (link.input !== 'text' || !spec) return;\n // The drawer's slide and initial focus finish first. Voice deliberately leaves focus off the composer and never calls start().\n const timer = setTimeout(() => {\n const current = specs.current;\n if (current.snap !== 'closed' && current.shown === which && current[which]?.controller === spec.controller) {\n host?.querySelector<HTMLElement>(`[data-spec=${which}] [data-testid=composer-input]`)?.focus({ preventScroll: true });\n }\n }, 520);\n return () => clearTimeout(timer);\n }, [host, open]);\n\n const voice = useVoice(agent, locale, outcome => {\n if (outcome === 'drafted') { setShown('agent'); setSnapState('half'); }\n });\n const revealedFailures = useRef(new Set<number>());\n useEffect(() => {\n const failure = voice.pending?.findLast(item => item.phase === 'transcribe-failed' || item.phase === 'upload-failed' || item.phase === 'blocked' || item.phase === 'unconfirmed');\n const captureProblem = agent?.voice?.send === 'message' && voice.problem;\n const failureId = captureProblem ? voice.captureSequence : failure?.id;\n if (failureId === undefined) { revealedFailures.current.clear(); return; }\n if (revealedFailures.current.has(failureId)) return;\n revealedFailures.current.add(failureId);\n if (!expanded && shown === 'agent' && snap === 'peek') setSnapState('half');\n }, [expanded, shown, snap, agent?.voice?.send, voice.captureSequence, voice.problem, voice.pending?.map(item => `${item.id}:${item.phase}`).join(',')]);\n\n const state: AgentLayerState = snap === 'closed' ? 'closed' : expanded ? 'open' : snap;\n const value = useMemo<Internal>(() => ({\n state, showing: snap === 'closed' ? null : shown, open, close, expanded, agent, improve, snap, setSnap, dockFloating, setDockFloating, showAgent, triggerUntil, voice, peekPoint, tabbar, host, locale, side, setSide, restoreFocus,\n }), [state, snap, shown, open, close, expanded, agent, improve, setSnap, dockFloating, showAgent, triggerUntil, voice, peekPoint, tabbar, host, locale, side, setSide]);\n\n // The shell's z-scale (tabs 20, peek 30, drawer and panel 40, other sheets 50, point bar 60) as custom properties; `display: contents` keeps the box tree unchanged.\n const scale = { '--app-shell-z-tabs': Z.tabs, '--app-shell-z-peek': Z.peek, '--app-shell-z-layer': Z.layer, '--app-shell-z-sheet': Z.sheet, '--app-shell-z-point': Z.point } as CSSProperties;\n return <AgentContextProvider>\n <LayerContext.Provider value={value}>\n <div data-app-shell=\"\" className=\"contents\" style={scale}>\n <div ref={probe} aria-hidden=\"true\" className=\"pointer-events-none invisible fixed top-0 left-0 w-0\" style={{ height: 'var(--app-shell-tabbar, 0px)' }} />\n {children}\n {host && opened.current && createPortal(<Chats showing={shown} />, host)}\n </div>\n </LayerContext.Provider>\n </AgentContextProvider>;\n}\n\n/** The header button that opens the layer: a tap opens half; with `agent.voice`, press and hold records and a slide off cancels. Absent without `agent`. */\nexport function AgentTrigger({ className, children }: { readonly className?: string; readonly children?: ReactNode }) {\n const layer = useInternal();\n const { agent, voice } = layer;\n const text = appShellText(agent?.chatProps?.locale ?? layer.locale);\n const hold = useRef<{ timer: ReturnType<typeof setTimeout> | undefined; held: boolean; id: number } | null>(null);\n const cancelHold = () => {\n const current = hold.current;\n hold.current = null;\n if (!current) return;\n clearTimeout(current.timer);\n if (current.held) voice.cancel();\n };\n useRecordingCancellation(cancelHold);\n useEffect(() => () => cancelHold(), [agent?.controller]);\n if (!agent) return null;\n\n const toggle = () => {\n layer.triggerUntil.current = Date.now() + 500;\n if (layer.state !== 'closed' && layer.showing === 'agent' && layer.expanded) layer.close();\n else layer.open('agent', 'half');\n };\n const release = (event: ReactPointerEvent<HTMLButtonElement>) => {\n const current = hold.current;\n hold.current = null;\n if (!current) return;\n clearTimeout(current.timer);\n if (event.currentTarget.hasPointerCapture?.(current.id)) event.currentTarget.releasePointerCapture(current.id);\n if (current.held) { if (event.type === 'pointerup') voice.finish(); else voice.cancel(); }\n else if (event.type === 'pointerup') toggle();\n };\n const down = (event: ReactPointerEvent<HTMLButtonElement>) => {\n if (event.button !== 0) return;\n const target = event.currentTarget;\n target.setPointerCapture?.(event.pointerId);\n const state = { timer: undefined as ReturnType<typeof setTimeout> | undefined, held: false, id: event.pointerId };\n hold.current = state;\n if (agent.voice) {\n state.timer = setTimeout(() => {\n state.held = true;\n if (layer.snap === 'closed') layer.setSnap('peek');\n layer.showAgent();\n voice.start();\n }, HOLD_MS);\n }\n };\n const move = (event: ReactPointerEvent<HTMLButtonElement>) => {\n const current = hold.current;\n if (!current?.held) return;\n const box = event.currentTarget.getBoundingClientRect();\n const slack = 24;\n if (event.clientX < box.left - slack || event.clientX > box.right + slack || event.clientY < box.top - slack || event.clientY > box.bottom + slack) {\n hold.current = null;\n voice.cancel();\n }\n };\n return <Button variant=\"ghost\" size=\"icon\" data-agent-trigger=\"\" data-testid=\"agent-trigger\" aria-label={text.open} title={text.open} aria-expanded={layer.state !== 'closed'}\n onPointerDown={down} onPointerUp={release} onPointerCancel={release} onPointerMove={move} onContextMenu={event => event.preventDefault()}\n onClick={event => { if (event.detail === 0) toggle(); }}\n className={cn('touch-none', className)}>\n {children ?? <SparklesIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Button>;\n}\n\n/**\n * Push-to-talk in the composer row (PiChat's `tools` slot): press and hold, release to send or fill; a slide off cancels. Pressing never moves\n * focus (no keyboard opens or closes under the finger), and a browser `pointercancel` once recording keeps the audio like a release: only the slide off cancels.\n */\nfunction TalkButton({ text }: { readonly text: AppShellText }) {\n const { voice } = useInternal();\n const busy = voice.phase === 'transcribing';\n const disabled = busy || (voice.phase === 'idle' && voice.canStart === false);\n const hold = useHoldToRecord(voice, disabled);\n return <Button {...hold} variant=\"ghost\" size=\"icon\" data-testid=\"agent-talk\" data-state={voice.phase} disabled={disabled} aria-label={text.holdToTalk} title={text.holdToTalk}\n className=\"touch-none\">\n {busy ? <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" /> : <MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Button>;\n}\n\n/** What went wrong with the microphone, in the flow under the title row (never over the messages). */\nfunction VoiceProblem() {\n const { voice } = useInternal();\n return voice.problem ? <p role=\"alert\" data-testid=\"agent-voice-problem\" className=\"m-0 shrink-0 px-3 pb-1 text-xs leading-4 text-destructive\">{voice.problem}</p> : null;\n}\n\n/** One agent's PiChat. It stays mounted while hidden, so its scroll, focus and drafts survive a swap with the other spec. */\nfunction SpecChat({ spec, visible, role }: { readonly spec: AgentSpec; readonly visible: boolean; readonly role: Which }) {\n const layer = useInternal();\n const locale = spec.chatProps?.locale ?? layer.locale;\n // One annotate toggle: pins on desktop, point mode (which hides the layer while the person taps the app) on touch and compact screens.\n const feedback = useComposerFeedback(spec.feedback);\n const pointing = usePointing(spec.feedback);\n // The pin's mic uses the shell's transcription: this agent's own, else the coach's.\n const transcribe = (spec.voice ?? layer.agent?.voice)?.transcribe;\n const fr = locale === 'fr';\n return <div data-spec={role} hidden={!visible} className={cn('min-h-0 min-w-0 flex-1', visible ? 'flex flex-col' : 'hidden')}>\n <PiChat {...spec.chatProps} {...(locale ? { locale } : {})} controller={spec.controller} keyboardInset={false} placement={layer.expanded && !layer.dockFloating ? 'docked' : 'floating'} {...(feedback ? { feedback } : {})}\n {...(role === 'agent' && spec.voice ? { voiceMessage: undefined, tools: <>{spec.chatProps?.tools}<TalkButton text={appShellText(locale)} /></>, pendingVoice: layer.voice.pending } : {})} />\n {/* The note editor of ordinary feedback lives inside the layer, in the modal's focus tree; while pointing the layer steps aside and AgentLayer renders it. */}\n {/* In the flow, inside the visible content box: the popup is transformed (a fixed element would be placed against its off-screen bottom). */}\n {spec.feedback && visible && !pointing && <div className=\"shrink-0 px-2 pb-2 empty:hidden\">\n <NoteBubble session={spec.feedback} {...(transcribe ? { transcribe } : {})} {...(fr ? { labels: feedbackLabelsFr } : {})} /></div>}\n </div>;\n}\n\n/** Where the chats show: the persistent host element is moved in once, when the drawer or panel mounts. */\nfunction ChatSlot() {\n const { host } = useInternal();\n const place = useCallback((slot: HTMLDivElement | null) => { if (slot && host) slot.appendChild(host); }, [host]);\n // Coarse pointers: the chat's own transcript buttons (copy, show earlier) get the layer's 44px rule from here, without touching pi-chat.\n return <div ref={place} className=\"flex min-h-0 flex-1 flex-col [&_[data-testid=transcript]_button]:pointer-coarse:min-h-11 [&_[data-testid=transcript]_button]:pointer-coarse:min-w-11\" />;\n}\n\n/** Both specs, each mounted once it has been shown. */\nfunction Chats({ showing }: { readonly showing: Which }) {\n const { agent, improve } = useInternal();\n const seen = useRef(new Set<Which>());\n seen.current.add(showing);\n return <>\n {agent && seen.current.has('agent') && <SpecChat spec={agent} visible={showing === 'agent'} role=\"agent\" />}\n {improve && seen.current.has('improve') && <SpecChat spec={improve} visible={showing === 'improve'} role=\"improve\" />}\n </>;\n}\n\n/** Title row: « ← » when the improve view has an agent to go back to, the title, then Expand or Collapse (compact only), the dock controls (expanded only) and Close. */\nfunction Header({ text, title, compactSnap, closeRef, controls, className, ...rest }: { readonly text: AppShellText; readonly title: string; readonly compactSnap?: Snap; readonly closeRef?: Ref<HTMLButtonElement>; readonly controls?: ReactNode } & Omit<HTMLAttributes<HTMLDivElement>, 'title'>) {\n const layer = useInternal();\n const back = layer.showing === 'improve' && layer.agent;\n const actions = (layer.showing === 'improve' ? layer.improve : layer.agent)?.headerActions ?? [];\n return <div data-testid=\"agent-layer-header\" {...rest} className={cn('flex shrink-0 items-center gap-1 px-2 pb-1', className)}>\n {back && <Button variant=\"ghost\" size=\"icon\" data-testid=\"agent-layer-back\" aria-label={text.back} title={text.back} onClick={layer.showAgent}><ArrowLeftIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n <h2 data-testid=\"agent-layer-title\" className=\"m-0 min-w-0 flex-1 truncate px-1 text-sm font-semibold\">{title}</h2>\n {actions.length > 0 && <HeaderActions actions={actions} compact={compactSnap !== undefined} text={text} prefix=\"agent-layer\" run={id => actions.find(action => action.id === id)?.onSelect()} />}\n {compactSnap === 'half' && <Button variant=\"ghost\" size=\"icon\" data-testid=\"agent-layer-expand\" aria-label={text.expand} title={text.expand} onClick={() => layer.setSnap('full')}><ChevronUpIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {compactSnap === 'full' && <Button variant=\"ghost\" size=\"icon\" data-testid=\"agent-layer-collapse\" aria-label={text.collapse} title={text.collapse} onClick={() => layer.setSnap('half')}><ChevronDownIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {controls}\n <Button variant=\"ghost\" size=\"icon\" ref={closeRef} data-testid=\"agent-layer-close\" aria-label={text.close} title={text.close} onClick={layer.close}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </div>;\n}\n\nfunction useTitle(): { text: AppShellText; title: string } {\n const layer = useInternal();\n const spec = layer.showing === 'improve' ? layer.improve : layer.agent;\n const text = appShellText(spec?.chatProps?.locale ?? layer.locale);\n const title = layer.showing === 'improve' ? text.improve : (spec?.chatProps?.labels?.title ?? text.assistant);\n return { text, title };\n}\n\nconst toSnap = (point: number | string | null, peekPoint: string): Snap | null =>\n point === peekPoint ? 'peek' : point === HALF ? 'half' : point === FULL ? 'full' : null;\n\nfunction CompactSheet({ pointing }: { readonly pointing: boolean }) {\n const layer = useInternal();\n const { snap, agent, voice } = layer;\n const hasPeek = Boolean(agent);\n const { text, title } = useTitle();\n const liveViewport = useVisualViewport();\n const closeRef = useRef<HTMLButtonElement>(null);\n const vetoed = useRef(false);\n // Set while the layer closes; cleared when it opens again (a close is final until the trigger or a choice opens the layer).\n const closing = useRef(false);\n useEffect(() => { if (snap !== 'closed') closing.current = false; }, [snap]);\n const open = snap !== 'closed' && !pointing;\n const peek = snap === 'peek';\n const points = useMemo(() => hasPeek ? [layer.peekPoint, HALF, FULL] : [HALF, FULL], [hasPeek, layer.peekPoint]);\n const value = peek ? layer.peekPoint : snap === 'full' ? FULL : HALF;\n const recording = voice.phase !== 'idle';\n // While the person talks the sheet holds still: the viewport height and the keyboard inset are pinned at what they were when the recording\n // began, the composer takes no focus and no keyboard, so nothing moves under the finger on the microphone.\n const sheet = useRef<HTMLDivElement>(null);\n const idleViewport = useRef(liveViewport);\n if (!recording) idleViewport.current = liveViewport;\n const vv = recording ? idleViewport.current : liveViewport;\n const [pinnedBottom, setPinnedBottom] = useState<string | undefined>();\n useLayoutEffect(() => {\n const popup = sheet.current?.closest('[data-slot=drawer-popup]');\n if (!recording || !sheet.current) return;\n if (popup) setPinnedBottom(getComputedStyle(popup).bottom);\n const inputs = [...sheet.current.querySelectorAll<HTMLTextAreaElement>('[data-testid=composer-input]')].map(input => ({ input, mode: input.getAttribute('inputmode'), readOnly: input.readOnly }));\n for (const { input } of inputs) { input.readOnly = true; input.setAttribute('inputmode', 'none'); }\n return () => {\n setPinnedBottom(undefined);\n for (const { input, mode, readOnly } of inputs) { input.readOnly = readOnly; if (mode === null) input.removeAttribute('inputmode'); else input.setAttribute('inputmode', mode); }\n };\n }, [recording]);\n\n useBackLayer(snap === 'half' || snap === 'full', layer.close, 'agent');\n\n // The popup is taller than what shows and slides down by `--drawer-snap-point-offset` (a registered, non-inheriting property, so only the\n // popup itself can read it): padding the popup by that offset makes its content box exactly the visible part. At peek the tab bar below is left out too.\n // The popup is portalled to the body, outside the shell root: it is given the measured tab bar height and the z-values explicitly.\n const style = { '--app-shell-vv-height': vv ? `${vv.height}px` : '100dvh', '--app-shell-tabbar': `${layer.tabbar}px`, '--app-shell-z-layer': Z.layer,\n paddingBottom: peek ? 'calc(var(--drawer-snap-point-offset, 0px) + var(--app-shell-tabbar, 0px))' : 'var(--drawer-snap-point-offset, 0px)',\n ...(pinnedBottom ? { bottom: pinnedBottom } : {}) } as CSSProperties;\n // Entering the modal half or full from the non-modal peek does not rerun Base UI's initial focus: if focus is still on the page, take it to Close.\n const wasPeek = useRef(false);\n useEffect(() => {\n const entering = wasPeek.current && !peek && open;\n wasPeek.current = peek;\n if (!entering) return;\n // The element that held focus when the sheet left peek (a page control now behind the modal). Base UI re-applies its modal guards as the\n // sheet moves, so retake focus once they have settled and again after the slide, but only while focus is still stranded there (or lost to the body).\n const stranded = document.activeElement;\n const take = () => {\n const popup = closeRef.current?.closest('[data-slot=drawer-popup]');\n const active = document.activeElement;\n // Still stranded, or dropped to the body by the modal guards: retake. Focus that went anywhere else is the person's and is left alone.\n if (popup && (active === stranded || active === document.body || active === null) && !popup.contains(active)) closeRef.current?.focus({ preventScroll: true });\n };\n const early = setTimeout(take, 60), late = setTimeout(take, 520);\n return () => { clearTimeout(early); clearTimeout(late); };\n }, [peek, open]);\n return <Drawer open={open} modal={!peek} showSwipeHandle swipeDirection=\"down\" snapPoints={points} snapPoint={value}\n onOpenChange={(next, details) => {\n if (next) return;\n if (isOverlayEvent(details)) { details.cancel(); return; }\n const outcome = nextSnapOnClose(details.reason, hasPeek);\n if (outcome === 'close') { closing.current = true; layer.close(); return; }\n details.cancel();\n vetoed.current = true;\n setTimeout(() => { vetoed.current = false; }, 120);\n // Focus leaving the non-modal peek changes nothing, and neither does the click that finished a press on the AgentTrigger (outside the\n // drawer, so it counts as an outside press): the trigger already chose the snap.\n if (details.reason === 'focus-out' || Date.now() < layer.triggerUntil.current) return;\n layer.setSnap(outcome);\n }}\n onSnapPointChange={point => {\n // Vetoing a swipe-close makes the drawer snap back to where it was: that echo must not undo the snap we chose.\n // Base UI also reports the first snap point (peek) as the drawer closes: that echo must not reopen the layer.\n if (vetoed.current || closing.current) return;\n const next = toSnap(point, layer.peekPoint);\n if (next && (next !== 'peek' || hasPeek)) layer.setSnap(next);\n }}>\n <DrawerContent data-testid=\"agent-layer\" aria-label={title} data-state={snap} data-peek={peek} initialFocus={() => closeRef.current ?? false} style={style}\n className={cn('app-shell box-border z-(--app-shell-z-layer) data-[swipe-direction=down]:bottom-(--drawer-keyboard-inset,0px) data-[swipe-axis=y]:[--drawer-content-max-height:calc(var(--app-shell-vv-height,100dvh)-1.5rem)]',\n // The peek is the top PEEK_REM of the popup; the part that overlaps the tab bar is clipped away, so the tabs stay visible and tappable.\n 'data-[peek=true]:not-data-swiping:[clip-path:inset(0_0_calc(100%_-_6rem)_0)]')}>\n <BaseDrawerParts.Drawer.VirtualKeyboardProvider>\n <div ref={sheet} data-testid=\"agent-layer-sheet\" onFocusCapture={event => { if (!recording && (event.target as HTMLElement).matches('[data-testid=composer-input]')) layer.setSnap('full'); }}\n className={cn('relative flex min-h-0 flex-1 flex-col overflow-hidden overscroll-contain',\n // The layer's own header carries the title and Close, so the chat's is hidden. At peek: one line of the last reply above the\n // composer, no empty-state overlay, room for Close.\n '[&_[data-boring=pi-chat]>header]:hidden',\n // At peek: ONE compact row (a one-line input, the microphone and Send; the other composer controls wait for half), and the last\n // reply as a single truncated line above it.\n peek && ['[&_[data-boring=pi-chat]>.relative]:overflow-hidden [&_[data-boring=pi-chat]>.relative>.pointer-events-none]:hidden',\n '[&_[data-boring=pi-chat]>footer]:px-2 [&_[data-boring=pi-chat]>footer]:pt-1 [&_[data-boring=pi-chat]>footer]:pb-2',\n '[&_[data-testid=transcript]]:gap-0 [&_[data-testid=transcript]]:p-0 [&_[data-testid=transcript]]:px-3 [&_[data-testid=transcript]>*]:max-h-5 [&_[data-testid=transcript]>*]:truncate [&_[data-testid=transcript]_button]:hidden',\n '[&_[data-testid=composer]_[data-slot=input-group]]:flex-row! [&_[data-testid=composer]_[data-slot=input-group]]:items-center',\n '[&_[data-testid=composer-input]]:min-h-11 [&_[data-testid=composer-input]]:max-h-11 [&_[data-testid=composer-input]]:flex-1 [&_[data-testid=composer-input]]:px-4 [&_[data-testid=composer-input]]:py-2.5',\n '[&_[data-testid=composer-controls]]:w-auto [&_[data-testid=composer-controls]]:shrink-0 [&_[data-testid=composer-controls]]:py-0 [&_[data-testid=composer-controls]>span.flex-1]:hidden',\n '[&_[data-testid=composer-plus]]:hidden [&_[data-testid=composer-annotate]]:hidden'])}>\n {!peek && <Header text={text} title={title} compactSnap={snap} closeRef={closeRef} />}\n {!peek && <VoiceProblem />}\n {peek && recording && <div role=\"status\" data-testid=\"agent-listening\" data-phase={voice.phase} className=\"absolute inset-0 z-20 flex items-center justify-center gap-2 bg-popover text-sm font-medium\">\n <MicIcon className=\"size-4 text-destructive\" aria-hidden=\"true\" />{voice.phase === 'transcribing' ? <><Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{voiceClock(voice.seconds)}</> : `${text.listening} ${voiceClock(voice.seconds)}`}\n </div>}\n {/* The transcript scrolls and never drags the sheet: only the handle and the title row swipe. */}\n <div data-base-ui-swipe-ignore=\"\" className=\"flex min-h-0 flex-1 flex-col\"><ChatSlot /></div>\n </div>\n </BaseDrawerParts.Drawer.VirtualKeyboardProvider>\n </DrawerContent>\n </Drawer>;\n}\n\n/** The panel inside the dock: the same element docked or floating, so a placement change keeps focus and the chats where they are. */\nfunction DockPanel({ api, side }: { readonly api: DockApi; readonly side: DockSide }) {\n const layer = useInternal();\n const { text, title } = useTitle();\n const open = layer.snap !== 'closed';\n const floating = api.floating;\n // The chats report this as their placement: the phone drawer and the floating panel are `floating`, the dock is `docked`.\n const { setDockFloating } = layer;\n useLayoutEffect(() => { setDockFloating(floating); return () => setDockFloating(false); }, [floating, setDockFloating]);\n const box = useRef<HTMLDivElement>(null);\n const [pos, setPos] = useState<{ left: number; top: number } | null>(null);\n useEffect(() => {\n if (!open) return;\n const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape' && !event.defaultPrevented) layer.close(); };\n document.addEventListener('keydown', onKey);\n return () => document.removeEventListener('keydown', onKey);\n }, [open, layer]);\n // A dock control (or the divider's Alt+Arrow) moves or removes the element that had focus: it goes to the panel's first dock control instead of the page.\n useLayoutEffect(() => {\n const lost = !document.activeElement || document.activeElement === document.body;\n const wanted = layer.restoreFocus.current || (open && lost && api.placement !== 'closed');\n layer.restoreFocus.current = false;\n if (wanted) box.current?.querySelector<HTMLElement>('[data-dock-control]')?.focus({ preventScroll: true });\n }, [api.placement, side, layer.restoreFocus]);\n const drag = useDraggable({ box, position: open ? pos : null, onPositionChange: setPos, enabled: floating });\n const act = (run: () => void) => () => { layer.restoreFocus.current = true; run(); };\n const control = (id: string, label: string, Icon: ComponentType<{ className?: string }>, run: () => void) =>\n <Button variant=\"ghost\" size=\"icon\" key={id} data-testid={`agent-layer-${id}`} data-dock-control=\"\" aria-label={label} title={label} onClick={act(run)}><Icon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>;\n const controls = <>\n {!floating && control('float', text.float, PictureInPicture2Icon, () => api.float())}\n {(floating || side === 'right') && control('dock-left', text.dockLeft, PanelLeftIcon, () => { layer.setSide('left'); api.dock(); })}\n {(floating || side === 'left') && control('dock-right', text.dockRight, PanelRightIcon, () => { layer.setSide('right'); api.dock(); })}\n </>;\n // Floating, the host's Card is drawn behind the layer (a sibling layer, so docking or floating never remounts the chat).\n return <div ref={box} data-testid=\"agent-layer\" data-state={open ? 'open' : 'closed'} data-placement={api.placement} data-side={side} role={floating ? 'dialog' : 'complementary'} aria-label={title}\n style={floating ? pos ?? { right: 16, bottom: 16 } : undefined}\n className={cn('app-shell relative flex min-h-0 flex-col',\n floating ? 'fixed isolate z-(--app-shell-z-layer) h-[min(36rem,calc(100dvh-2rem))] w-[min(24rem,calc(100vw-2rem))]'\n : 'h-full min-w-0 flex-1 bg-background text-foreground')}>\n {floating && <Card aria-hidden=\"true\" data-boring=\"chrome\" className=\"pointer-events-none absolute inset-0 -z-10\" />}\n {!floating && <Separator orientation=\"vertical\" className={cn('absolute inset-y-0', side === 'right' ? 'left-0' : 'right-0')} />}\n <Header text={text} title={title} controls={controls} className={cn('pt-2', floating && 'cursor-move touch-none select-none')} {...drag} />\n <div className=\"flex min-h-0 flex-1 flex-col overflow-hidden\"><VoiceProblem /><ChatSlot /></div>\n </div>;\n}\n\nfunction AgentDock({ side }: { readonly side: DockSide }) {\n const layer = useInternal();\n const { title } = useTitle();\n return <Dock id=\"agent\" side={side} floatable defaultWidth={384} minWidth={320} dragBelow={260} open={layer.snap !== 'closed'} onOpenChange={next => { if (!next) layer.close(); }} labels={{ region: title }}>\n {api => <DockPanel api={api} side={side} />}\n </Dock>;\n}\n\n/**\n * Wraps the app's content (placed once by the shell): on a wide screen the agent dock sits on its left or right and the content reflows,\n * or it floats over the content; on a phone this is only the content (the layer is the bottom drawer). Host concern: Pi has no layout.\n */\nexport function AgentDockLayout({ children }: { readonly children: ReactNode }): ReactElement {\n const layer = useInternal();\n const dock = layer.expanded && (layer.agent || layer.improve) ? <AgentDock side={layer.side} /> : null;\n return <DockLayout storageKey={DOCK_KEY} persist=\"local\">\n {layer.side === 'left' && dock}\n <DockMain>{children}</DockMain>\n {layer.side === 'right' && dock}\n </DockLayout>;\n}\n\n/** The layer itself: the drawer on a phone (the wide dock is `AgentDockLayout`). Placed once by the shell, for point mode on both. */\nexport function AgentLayer(): ReactElement | null {\n const layer = useInternal();\n const pointingAgent = usePointing(layer.agent?.feedback), pointingImprove = usePointing(layer.improve?.feedback);\n const session = pointingAgent ? layer.agent?.feedback : pointingImprove ? layer.improve?.feedback : undefined;\n // Back ends pointing first (the `point` priority); the layer keeps its own history entry underneath.\n useBackLayer(session !== undefined, () => session?.discard(), 'point');\n if (!layer.agent && !layer.improve) return null;\n const pointed = pointingAgent ? layer.agent : layer.improve;\n const transcribe = (pointed?.voice ?? layer.agent?.voice)?.transcribe;\n const locale = pointed?.chatProps?.locale ?? layer.locale;\n // Point mode: the compact sheet steps aside (the wide dock does not cover the app); the bar sits above the tab bar, outside the layer.\n return <>\n {!layer.expanded && <CompactSheet pointing={session !== undefined} />}\n {session && <>\n <PointBar session={session} bottomOffset={`${layer.tabbar}px`} className=\"z-[60]\" {...(locale === 'fr' ? { labels: feedbackLabelsFr } : {})} />\n <NoteBubble session={session} {...(transcribe ? { transcribe } : {})} {...(locale === 'fr' ? { labels: feedbackLabelsFr } : {})} />\n </>}\n </>;\n}\n",
|
|
34
34
|
"type": "registry:component",
|
|
35
35
|
"target": "components/app-shell/agent-layer.tsx"
|
|
36
36
|
},
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
},
|
|
133
133
|
{
|
|
134
134
|
"path": "registry/app-shell/use-dictation.ts",
|
|
135
|
-
"content": "'use client';\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { createVoiceCapture } from '@hachej/boring-
|
|
135
|
+
"content": "'use client';\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { createVoiceCapture } from '@hachej/boring-ui-kit/voice-capture';\nimport type { VoiceCapture, VoiceRefusal } from '@hachej/boring-ui-kit/voice-capture';\nimport { chatTextFor } from '../pi-chat/locale';\nimport type { ChatLocale } from '../pi-chat/locale';\n\nexport type DictationPhase = 'idle' | 'starting' | 'recording' | 'transcribing';\nexport interface Dictation {\n readonly phase: DictationPhase;\n readonly seconds: number;\n readonly problem: string | undefined;\n readonly start: () => void;\n readonly finish: () => void;\n readonly cancel: () => void;\n}\nexport type DictationLabels = Pick<ReturnType<typeof chatTextFor>['labels'],\n 'voiceDenied' | 'voiceNoMicrophone' | 'voiceBusy' | 'voiceInsecure' | 'voiceUnsupported' | 'voiceStartFailed' | 'voiceNothingHeard' | 'voiceTranscribeFailed'>;\nexport interface DictationOptions {\n readonly transcribe: ((audio: Blob, signal: AbortSignal) => Promise<string>) | undefined;\n readonly onText: (text: string, signal: AbortSignal) => void | Promise<void>;\n readonly locale?: ChatLocale | undefined;\n readonly labels?: Partial<DictationLabels> | undefined;\n}\n\nconst REFUSAL: Readonly<Record<VoiceRefusal, 'voiceDenied' | 'voiceNoMicrophone' | 'voiceBusy' | 'voiceInsecure' | 'voiceUnsupported' | 'voiceStartFailed'>> = {\n denied: 'voiceDenied', 'no-microphone': 'voiceNoMicrophone', busy: 'voiceBusy', insecure: 'voiceInsecure', unsupported: 'voiceUnsupported', failed: 'voiceStartFailed',\n};\n\n/** Gesture-started dictation. Each attempt captures its callbacks; cancellation fences late permission, transcription and text delivery. */\nexport function useDictation(options: DictationOptions): Dictation {\n const [phase, setPhase] = useState<DictationPhase>('idle');\n const [seconds, setSeconds] = useState(0);\n const [problem, setProblem] = useState<string | undefined>();\n const latest = useRef(options);\n latest.current = options;\n const phaseRef = useRef<DictationPhase>('idle');\n const current = useRef<{ readonly capture: VoiceCapture; readonly abort: AbortController; readonly options: DictationOptions } | null>(null);\n const set = (next: DictationPhase) => { phaseRef.current = next; setPhase(next); };\n\n useEffect(() => () => { current.current?.abort.abort(); current.current?.capture.cancel(); current.current = null; }, []);\n useEffect(() => {\n if (phase !== 'recording') return;\n const started = Date.now();\n const timer = setInterval(() => setSeconds(Math.floor((Date.now() - started) / 1000)), 250);\n return () => clearInterval(timer);\n }, [phase]);\n\n const cancel = useCallback(() => {\n const old = current.current;\n current.current = null;\n old?.abort.abort();\n old?.capture.cancel();\n setProblem(undefined);\n set('idle');\n }, []);\n const start = useCallback(() => {\n const selected = latest.current;\n if (!selected.transcribe || phaseRef.current !== 'idle') return;\n const attempt = { capture: createVoiceCapture(), abort: new AbortController(), options: selected };\n current.current = attempt;\n setSeconds(0);\n setProblem(undefined);\n set('starting');\n // No await before start: permission belongs to this gesture. A fresh capture cannot cancel a later attempt when permission arrives late.\n void attempt.capture.start().then(result => {\n if (current.current !== attempt) { attempt.capture.cancel(); return; }\n if (result.kind === 'refused') {\n current.current = null;\n set('idle');\n const words = { ...chatTextFor(selected.locale).labels, ...selected.labels };\n setProblem(words[result.code ? REFUSAL[result.code] : 'voiceStartFailed']);\n return;\n }\n set('recording');\n }).catch(() => {\n if (current.current !== attempt) return;\n current.current = null;\n attempt.capture.cancel();\n set('idle');\n setProblem(selected.labels?.voiceStartFailed ?? chatTextFor(selected.locale).labels.voiceStartFailed);\n });\n }, []);\n const finish = useCallback(() => {\n if (phaseRef.current === 'starting') { cancel(); return; }\n const attempt = current.current;\n if (!attempt || phaseRef.current !== 'recording') return;\n const selected = attempt.options;\n const words = { ...chatTextFor(selected.locale).labels, ...selected.labels };\n set('transcribing');\n void (async () => {\n try {\n const recording = await attempt.capture.stop();\n if (current.current !== attempt) return;\n if (!recording) { setProblem(words.voiceNothingHeard); return; }\n const text = (await selected.transcribe!(recording.audio, attempt.abort.signal)).trim();\n if (current.current !== attempt) return;\n if (!text) { setProblem(words.voiceNothingHeard); return; }\n await selected.onText(text, attempt.abort.signal);\n } catch { if (current.current === attempt) setProblem(words.voiceTranscribeFailed); }\n finally { if (current.current === attempt) { current.current = null; set('idle'); } }\n })();\n }, [cancel]);\n return useMemo(() => ({ phase, seconds, problem, start, finish, cancel }), [phase, seconds, problem, start, finish, cancel]);\n}\n",
|
|
136
136
|
"type": "registry:hook",
|
|
137
137
|
"target": "components/app-shell/use-dictation.ts"
|
|
138
138
|
}
|