@simon-says-build/ssb-overlay 0.1.0 β†’ 0.4.0

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.
Binary file
package/index.d.ts CHANGED
@@ -33,6 +33,12 @@ export function SsbHost(props: {
33
33
  addNotificationResponseReceivedListener(fn: (r: unknown) => void): { remove(): void };
34
34
  getLastNotificationResponseAsync?(): Promise<unknown>;
35
35
  };
36
+ /**
37
+ * πŸŽ™ Talk: SSB by voice, which can draft and start jobs. The app's own LiveKit client:
38
+ * `{ Room }` from livekit-client, and @livekit/react-native's AudioSession start/stop.
39
+ * Without it there is no Talk button.
40
+ */
41
+ voice?: { Room: new (opts?: object) => any; startAudio?: () => Promise<void> | void; stopAudio?: () => Promise<void> | void };
36
42
  /** Keep the button off the home indicator / tab bar. Default {top: 60, bottom: 40}. */
37
43
  insets?: { top?: number; bottom?: number };
38
44
  children?: ReactNode;
package/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export { SsbHost } from './src/SsbHost.js';
2
- export { overlayConfig, inAppApi, markOf, sendBody, ssbLinkOf } from './src/core.js';
2
+ export { overlayConfig, inAppApi, drawingMark, sendBody, ssbLinkOf } from './src/core.js';
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@simon-says-build/ssb-overlay",
3
- "version": "0.1.0",
3
+ "version": "0.4.0",
4
4
  "description": "The SSB button inside a native app SimonSays controls: screenshot the screen, mark it, and ask SSB for a change. Renders nothing for anyone SSB does not let in.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "types": "index.d.ts",
8
- "files": ["index.js", "index.d.ts", "src/", "README.md"],
8
+ "files": ["index.js", "index.d.ts", "src/", "assets/", "README.md"],
9
9
  "peerDependencies": {
10
10
  "react": ">=18",
11
11
  "react-native": ">=0.76"
package/src/SsbHost.js CHANGED
@@ -3,12 +3,18 @@
3
3
  // Renders its children and nothing else until SSB says the signed-in login
4
4
  // may use it (GET /api/in-app/me: a member of the app's organization, an SSB
5
5
  // operator, or a cast login on the app's allowed list, by default the
6
- // super-admin). Then a small SSB button floats bottom-right. Tapping it takes
7
- // a picture of the screen as it is and opens a sheet: the conversation with
8
- // SSB about this app (Tell SSB's, functions/annotate.mjs), the picture to
9
- // attach, and **Mark** β€” tap or drag on the live screen, type beside the
10
- // mark, send: the mark is captured ON the screen, with the field and our
11
- // chrome hidden. SSB replies on the thread and proposes jobs; the drafts bar
6
+ // super-admin). Then SSB's logo floats on the screen (drag it anywhere).
7
+ // Tapping it takes a picture of the screen as it is and opens SSB FULL SCREEN
8
+ // (2026-10-20, the product owner: "abandon the idea of a partial overlaying
9
+ // sheet … like a full-screen overlay, unless you're in draw / box mode, or
10
+ // voice mode"): the WORK at the top β€” what is in flight on this app, this
11
+ // chat's drafts to start β€” the way the extension's card has it, and the
12
+ // conversation with SSB below (Tell SSB's, functions/annotate.mjs). Every send carries
13
+ // the screen (2026-10-19, the product owner: "always send the screen, don't
14
+ // make it an option"). **Draw** puts a pencil and a box on the live screen:
15
+ // scribble on or box what should change (as much as it takes, Undo), say what,
16
+ // send β€” the drawing is captured ON the screen, with the field and our chrome
17
+ // hidden. (It was **Mark**, one pin or one box, until 0.2.0.) SSB replies on the thread and proposes jobs; the drafts bar
12
18
  // starts them. The same conversation is on the console.
13
19
  //
14
20
  // No JSX, so it needs nothing from the app's Babel setup beyond React Native's.
@@ -18,13 +24,23 @@ import {
18
24
  ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions,
19
25
  } from 'react-native';
20
26
  import {
21
- demoBanner, describeHost, draftsLine, draftsOf, fieldTop, inAppApi, markOf, markPoint, markRect, namedHosts,
22
- newReply, offerOf, overlayConfig, pollEvery, sendBody, smallestAt, ssbLinkOf,
27
+ FAB, addPoint, clampFab, demoBanner, fabHome, isDrag, describeHost, draftsOf, fieldTop, inAppApi, namedHosts, newReply, offerOf,
28
+ STATE_WORD, boxItem, workView, drawingBox, drawingMark, drawingPoint, itemRect, overlayConfig, pollEvery, SNAPSHOT_TOPIC, snapshotOf, talkLine, segmentsOf, sendBody, smallestAt, ssbLinkOf,
23
29
  } from './core.js';
24
30
 
25
31
  const h = React.createElement;
26
- const BLUE = '#2f5bea', INK = '#161a23', MUTED = '#6b7280', LINE = '#eef0f3', RED = '#e5484d';
32
+ // THE LOOK (2026-10-20, the product owner: "a futuristic look … dark,
33
+ // matching the 'real-object' look of our logo"): the toy's black chassis, and
34
+ // its four pads for meaning β€” blue to act, green for ready and talking,
35
+ // yellow for a draft waiting on you, red to draw and to end. What is live glows.
36
+ const BG = '#0b0d12', SURFACE = '#141821', RAISED = '#1b2030', LINE = '#232a3a';
37
+ const TEXT = '#eef2fa', MUTED = '#8a93a8';
38
+ const BLUE = '#3d7bff', GREEN = '#2fd27a', YELLOW = '#f6c544', RED = '#ff4d5a';
39
+ const INK = BG; // the drawing hint and the talk pill: the chassis
27
40
  const DEFAULT_INSETS = { top: 60, bottom: 40 };
41
+ const INK_W = 4; // the pencil's line, in points
42
+ // The button IS SSB's logo, the round Simon toy (web/public/logo-192.png, 156px: 3x of 52pt).
43
+ const LOGO = require('../assets/ssb-logo.png');
28
44
 
29
45
  // React keeps the fiber on the host instance (Fabric: __internalInstanceHandle).
30
46
  const fiberOf = (instance) => instance?.__internalInstanceHandle ?? instance?._internalInstanceHandle ?? null;
@@ -49,11 +65,11 @@ const frames = (n = 2) => new Promise((resolve) => {
49
65
  step(n);
50
66
  });
51
67
 
52
- export function SsbHost({ appId, backend, api, getToken, sessionKey, route, capture, storage, pushToken, notifications, insets, children }) {
68
+ export function SsbHost({ appId, backend, api, getToken, sessionKey, route, capture, storage, pushToken, notifications, voice, insets, children }) {
53
69
  const config = useMemo(() => overlayConfig({ appId, backend, api }), [appId, backend, api]);
54
70
  useSsbLinks(notifications);
55
71
  if (!config || typeof getToken !== 'function') return children ?? null;
56
- return h(Host, { config, getToken, sessionKey, route, capture, storage, pushToken, insets: { ...DEFAULT_INSETS, ...(insets || {}) } }, children);
72
+ return h(Host, { config, getToken, sessionKey, route, capture, storage, pushToken, voice, insets: { ...DEFAULT_INSETS, ...(insets || {}) } }, children);
57
73
  }
58
74
 
59
75
  // A tapped "Ready to see" notification (functions/in-app-notify.mjs) opens
@@ -69,30 +85,43 @@ function useSsbLinks(notifications) {
69
85
  }, [notifications]);
70
86
  }
71
87
 
72
- function Host({ config, getToken, sessionKey, route, capture, storage, pushToken, insets, children }) {
88
+ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken, voice, insets, children }) {
73
89
  const screen = useWindowDimensions();
74
90
  const tokenRef = useRef(getToken);
75
91
  tokenRef.current = getToken;
76
92
  const ssb = useMemo(() => inAppApi(config, () => tokenRef.current()), [config]);
77
93
  const rootRef = useRef(null);
94
+ const screenRef = useRef(screen);
95
+ screenRef.current = screen;
96
+ const insetsRef = useRef(insets);
97
+ insetsRef.current = insets;
78
98
  const chromeRef = useRef(null);
79
99
  const storeKey = `ssb-overlay:chat:${config.appId}`;
80
100
 
81
101
  const [gate, setGate] = useState(null); // {show, person, app, canJob} once SSB has answered
82
- const [mode, setMode] = useState('idle'); // idle | sheet | marking | noting | capturing
102
+ const [mode, setMode] = useState('idle'); // idle | sheet | drawing | capturing
83
103
  const [shot, setShot] = useState(null); // the screen as it was when the button was tapped
84
- const [attach, setAttach] = useState(true);
85
- const [mark, setMark] = useState(null);
86
- const [element, setElement] = useState(null);
87
- const [drag, setDrag] = useState(null);
88
- const dragRef = useRef(null);
104
+ const [items, setItems] = useState([]); // what is drawn: strokes and boxes, in screen points (core.js)
105
+ const [live, setLive] = useState(null); // the item under the finger
106
+ const liveRef = useRef(null);
107
+ const [tool, setTool] = useState('pencil'); // pencil | box
108
+ // πŸŽ™ Talk: a LiveKit room with SSB's voice agent (the host's livekit-client Room).
109
+ const [talk, setTalk] = useState(null); // null | { status: connecting | waiting | live, muted }
110
+ const roomRef = useRef(null);
111
+ const toolRef = useRef(tool);
112
+ toolRef.current = tool;
113
+ // The button goes wherever it is dragged (2026-10-19), remembered per app on this phone.
114
+ const fabKey = `ssb-overlay:fab:${config.appId}`;
115
+ const [fab, setFab] = useState(null);
116
+ const fabRef = useRef(null);
117
+ fabRef.current = fab;
89
118
  const [note, setNote] = useState('');
90
119
  const [draft, setDraft] = useState('');
91
120
  const [chatId, setChatId] = useState(null);
92
121
  const [chat, setChat] = useState(null);
93
122
  const [busy, setBusy] = useState(false);
94
123
  const [error, setError] = useState(null);
95
- const [showDrafts, setShowDrafts] = useState(false);
124
+ const [work, setWork] = useState([]); // the app's work in flight (GET /api/in-app/work)
96
125
  const [seen, setSeen] = useState(null);
97
126
  const [kbTop, setKbTop] = useState(screen.height);
98
127
 
@@ -110,6 +139,10 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
110
139
  return () => { live = false; };
111
140
  }, [ssb, sessionKey]);
112
141
 
142
+ useEffect(() => {
143
+ if (!storage?.getItem) return;
144
+ Promise.resolve(storage.getItem(fabKey)).then((v) => { try { const p = JSON.parse(v); if (Number.isFinite(p?.x) && Number.isFinite(p?.y)) setFab(p); } catch { /* none kept */ } }).catch(() => {});
145
+ }, [fabKey]);
113
146
  useEffect(() => {
114
147
  if (!storage?.getItem) return;
115
148
  Promise.resolve(storage.getItem(storeKey)).then((id) => { if (id) setChatId(id); }).catch(() => {});
@@ -125,16 +158,16 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
125
158
  return () => { show.remove(); hide.remove(); };
126
159
  }, [screen.height]);
127
160
 
128
- // A new screen makes a waiting mark and the picture stale (the extension's
129
- // rule): the mark is dropped and its words move to the composer.
161
+ // A new screen makes the drawing and the picture stale (the extension's
162
+ // rule): the scribbles are dropped and their words move to the composer.
130
163
  const lastRoute = useRef(route);
131
164
  useEffect(() => {
132
165
  if (lastRoute.current === route) return;
133
166
  lastRoute.current = route;
134
167
  setShot(null);
135
- if (mode === 'marking' || mode === 'noting') {
168
+ if (mode === 'drawing') {
136
169
  if (note.trim()) setDraft((d) => (d ? `${d}\n${note}` : note));
137
- setMark(null); setElement(null); setNote(''); setMode('sheet');
170
+ setItems([]); setNote(''); setMode('sheet');
138
171
  }
139
172
  }, [route]);
140
173
 
@@ -147,19 +180,27 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
147
180
 
148
181
  const open = mode !== 'idle';
149
182
  useEffect(() => { if (open) load(); }, [open, chatId]);
183
+ // The top of the panel: the app's work, asked when it opens and while it stays open.
184
+ const loadWork = useCallback(async () => { try { setWork((await ssb.work())?.inFlight || []); } catch { /* the last list stands */ } }, [ssb]);
185
+ useEffect(() => {
186
+ if (mode !== 'sheet' || !gate) return undefined;
187
+ loadWork();
188
+ const t = setInterval(loadWork, 10000);
189
+ return () => clearInterval(t);
190
+ }, [mode, gate, loadWork]);
150
191
  useEffect(() => {
151
192
  if (!gate || !chatId) return undefined;
152
- const every = pollEvery({ open, thinking: !!chat?.thinking || chat?.demo?.status === 'preparing' });
193
+ const every = talk ? 2000 : pollEvery({ open, thinking: !!chat?.thinking || chat?.demo?.status === 'preparing' });
153
194
  if (!every) return undefined;
154
195
  const t = setInterval(load, every);
155
196
  return () => clearInterval(t);
156
- }, [gate, chatId, open, chat?.thinking, load]);
197
+ }, [gate, chatId, open, chat?.thinking, load, !!talk]);
157
198
 
158
199
  const lastReplyId = (c) => [...(c?.messages || [])].reverse().find((m) => m.role !== 'user' && !m.streaming)?.id ?? null;
159
200
  const [seenDemo, setSeenDemo] = useState(null);
160
201
  const unread = !open && chat && (newReply(chat, seen) || (chat.demo?.status === 'ready' && chat.demo.url !== seenDemo));
161
202
 
162
- // ---- open, mark, send ----------------------------------------------------
203
+ // ---- open, draw, send ----------------------------------------------------
163
204
  const grab = useCallback(async () => {
164
205
  if (!capture) return null;
165
206
  await frames(2);
@@ -170,15 +211,20 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
170
211
  setError(null);
171
212
  setMode('capturing');
172
213
  const pic = await grab();
173
- setShot(pic); setAttach(!!pic);
214
+ setShot(pic);
174
215
  setMode('sheet');
175
216
  }, [grab]);
176
217
 
218
+ // A demo opens IN the app (or the dev client): SSB steps aside first, or the
219
+ // full-screen panel would sit on top of what it opened.
220
+ const openLink = useCallback((url) => { if (!url) return; Keyboard.dismiss(); setMode('idle'); Linking.openURL(url).catch(() => {}); }, []);
221
+ const openRef = useRef(openSheet);
222
+ openRef.current = openSheet;
177
223
  const close = useCallback(() => {
178
224
  Keyboard.dismiss();
179
225
  setSeen(lastReplyId(chat));
180
226
  setSeenDemo(chat?.demo?.url ?? null);
181
- setMark(null); setElement(null); setNote(''); setShowDrafts(false);
227
+ setItems([]); setNote('');
182
228
  setMode('idle');
183
229
  }, [chat]);
184
230
 
@@ -199,101 +245,207 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
199
245
  const sendDraft = useCallback(async () => {
200
246
  const text = draft.trim();
201
247
  if (!text || busy) return;
202
- const ok = await deliver(sendBody({ chatId, text, route, platform: Platform.OS, screen, screenshot: attach ? shot : null }));
203
- if (ok) { setDraft(''); if (attach) setAttach(false); }
204
- }, [draft, busy, deliver, chatId, route, screen, attach, shot]);
205
-
206
- const placed = useCallback(async (g) => {
207
- const m = markOf(g, screen);
208
- setMark(m); setDrag(null); setMode('noting');
209
- // What the app has under the mark, named the way the app names it.
248
+ // Always the screen: the picture taken when the button was tapped.
249
+ const ok = await deliver(sendBody({ chatId, text, route, platform: Platform.OS, screen, screenshot: shot }));
250
+ if (ok) setDraft('');
251
+ }, [draft, busy, deliver, chatId, route, screen, shot]);
252
+
253
+ // What the app has under the scribbles, named the way the app names it.
254
+ const elementUnder = useCallback(async (pt) => {
255
+ if (!pt) return null;
210
256
  try {
211
- const root = fiberOf(rootRef.current);
212
- const skip = fiberOf(chromeRef.current);
213
- const hosts = namedHosts(root, skip);
257
+ const hosts = namedHosts(fiberOf(rootRef.current), fiberOf(chromeRef.current));
214
258
  const cands = await Promise.all(hosts.map(async (fiber) => ({ fiber, rect: await measure(fiber) })));
215
- const hit = smallestAt(cands, markPoint(m, screen));
216
- setElement(hit ? describeHost(hit.fiber) : null);
217
- } catch { setElement(null); }
218
- }, [screen]);
259
+ const hit = smallestAt(cands, pt);
260
+ return hit ? describeHost(hit.fiber) : null;
261
+ } catch { return null; }
262
+ }, []);
219
263
 
220
- const sendMark = useCallback(async () => {
264
+ const sendDrawing = useCallback(async () => {
221
265
  const text = note.trim();
222
- if (!text || busy || !mark) return;
266
+ if (!text || busy || !items.length) return;
223
267
  Keyboard.dismiss();
224
- setMode('capturing'); // only the mark stays on screen
268
+ const element = await elementUnder(drawingPoint(items));
269
+ setMode('capturing'); // only the drawing stays on screen
225
270
  const pic = await grab();
226
271
  setMode('sheet');
227
- const ok = await deliver(sendBody({ chatId, text, route, platform: Platform.OS, screen, mark, element, screenshot: pic }));
228
- if (ok) { setMark(null); setElement(null); setNote(''); setAttach(false); }
272
+ const ok = await deliver(sendBody({ chatId, text, route, platform: Platform.OS, screen, mark: drawingMark(items, screen), element, screenshot: pic || shot }));
273
+ if (ok) { setItems([]); setNote(''); }
229
274
  else setDraft((d) => d || text);
230
- }, [note, busy, mark, grab, deliver, chatId, route, screen, element]);
275
+ }, [note, busy, items, elementUnder, grab, deliver, chatId, route, screen, shot]);
276
+
277
+ // ---- πŸŽ™ Talk ------------------------------------------------------------------
278
+ const endTalk = useCallback(async () => {
279
+ const room = roomRef.current;
280
+ roomRef.current = null;
281
+ setTalk(null);
282
+ try { await room?.disconnect(); } catch { /* gone */ }
283
+ try { await voice?.stopAudio?.(); } catch { /* not ours */ }
284
+ load();
285
+ }, [voice, load]);
286
+
287
+ const startTalk = useCallback(async () => {
288
+ if (!voice?.Room || roomRef.current || talk) return;
289
+ setError(null);
290
+ setTalk({ status: 'connecting', muted: false });
291
+ try {
292
+ const r = await ssb.voice({ chatId, route, platform: Platform.OS, page: { title: `In the app (${Platform.OS}): ${route || '/'}` }, ...(shot ? { screenshot: shot } : {}) });
293
+ if (r.chatId) rememberChat(r.chatId);
294
+ if (!r.configured) { setTalk(null); setError(r.say || 'Voice is not set up yet.'); return; }
295
+ await voice.startAudio?.();
296
+ const room = new voice.Room({ adaptiveStream: true, dynacast: true });
297
+ roomRef.current = room;
298
+ let shown = false;
299
+ const show = () => {
300
+ if (shown || !roomRef.current) return;
301
+ shown = true;
302
+ room.localParticipant.sendText(snapshotOf({ route, screenshot: shot, first: true }), { topic: SNAPSHOT_TOPIC }).catch(() => {});
303
+ setTalk((t) => (t ? { ...t, status: 'live' } : t));
304
+ };
305
+ room.on('participantConnected', show);
306
+ room.on('disconnected', () => { if (roomRef.current === room) endTalk(); });
307
+ await room.connect(r.url, r.token);
308
+ await room.localParticipant.setMicrophoneEnabled(true);
309
+ if (room.remoteParticipants?.size) show(); else setTalk((t) => (t ? { ...t, status: 'waiting' } : t));
310
+ // VOICE MODE shows the app: SSB steps aside to a pill, so they can move
311
+ // around while they talk (and SSB is told each screen they reach).
312
+ Keyboard.dismiss();
313
+ setMode('idle');
314
+ } catch (e) {
315
+ setError(`Could not start talking: ${String(e.message || e).split('\n')[0]}`);
316
+ endTalk();
317
+ }
318
+ }, [voice, talk, ssb, chatId, route, shot, rememberChat, endTalk]);
319
+
320
+ const toggleMute = useCallback(async () => {
321
+ const room = roomRef.current;
322
+ if (!room || !talk) return;
323
+ const muted = !talk.muted;
324
+ try { await room.localParticipant.setMicrophoneEnabled(!muted); setTalk({ ...talk, muted }); } catch { /* left as it was */ }
325
+ }, [talk]);
326
+
327
+ // A new screen while talking: SSB is told where they went.
328
+ useEffect(() => {
329
+ const room = roomRef.current;
330
+ if (room && talk?.status === 'live') room.localParticipant.sendText(snapshotOf({ route }), { topic: SNAPSHOT_TOPIC }).catch(() => {});
331
+ }, [route]);
332
+ // The person leaves (signs out, the gate closes, the host unmounts): the talk ends.
333
+ useEffect(() => () => { const room = roomRef.current; roomRef.current = null; room?.disconnect?.(); voice?.stopAudio?.(); }, []);
334
+ useEffect(() => { if (!gate && roomRef.current) endTalk(); }, [gate]);
231
335
 
232
336
  const decide = useCallback(async (p, action) => {
233
337
  setBusy(true); setError(null);
234
338
  try {
235
339
  await ssb.decide(chatId, p.id, action, action === 'raise' ? { kind: p.kind, title: p.title, detail: p.detail, severity: p.severity } : {});
236
340
  await load();
341
+ loadWork();
237
342
  return true;
238
343
  } catch (e) { setError(`${p.title}: ${e.message}`); return false; }
239
344
  finally { setBusy(false); }
240
- }, [ssb, chatId, load]);
345
+ }, [ssb, chatId, load, loadWork]);
241
346
 
242
347
  const startAll = useCallback(async () => {
243
348
  for (const p of draftsOf(chat).waiting) if (!(await decide(p, 'raise'))) break;
244
349
  }, [chat, decide]);
245
350
 
351
+ // The pencil and the box. The item lives in a ref (a move must not wait on a render); state only draws it.
352
+ const finish = () => {
353
+ const it = liveRef.current; liveRef.current = null; setLive(null);
354
+ const done = it?.kind === 'box' ? boxItem(it) : it?.points?.length ? it : null;
355
+ if (done) setItems((all) => [...all, done]);
356
+ };
246
357
  const pan = useMemo(() => PanResponder.create({
247
358
  onStartShouldSetPanResponder: () => true,
248
359
  onMoveShouldSetPanResponder: () => true,
249
- // The gesture lives in a ref (the release must not run inside a state updater); state only draws it.
250
- onPanResponderGrant: (e) => { const { pageX, pageY } = e.nativeEvent; dragRef.current = { x0: pageX, y0: pageY, x1: pageX, y1: pageY }; setDrag(dragRef.current); },
251
- onPanResponderMove: (e) => { const d = dragRef.current; if (!d) return; dragRef.current = { ...d, x1: e.nativeEvent.pageX, y1: e.nativeEvent.pageY }; setDrag(dragRef.current); },
252
- onPanResponderRelease: (e) => { const d = dragRef.current; dragRef.current = null; if (d) placed({ ...d, x1: e.nativeEvent.pageX, y1: e.nativeEvent.pageY }); },
253
- onPanResponderTerminate: () => { dragRef.current = null; setDrag(null); },
254
- }), [placed]);
360
+ onPanResponderGrant: (e) => {
361
+ const { pageX: x, pageY: y } = e.nativeEvent;
362
+ liveRef.current = toolRef.current === 'box' ? { kind: 'box', x0: x, y0: y, x1: x, y1: y } : { kind: 'stroke', points: [{ x, y }] };
363
+ setLive(liveRef.current);
364
+ },
365
+ onPanResponderMove: (e) => {
366
+ const it = liveRef.current;
367
+ if (!it) return;
368
+ const p = { x: e.nativeEvent.pageX, y: e.nativeEvent.pageY };
369
+ if (it.kind === 'box') { liveRef.current = { ...it, x1: p.x, y1: p.y }; setLive(liveRef.current); return; }
370
+ const points = addPoint(it.points, p);
371
+ if (points !== it.points) { liveRef.current = { ...it, points }; setLive(liveRef.current); }
372
+ },
373
+ onPanResponderRelease: finish,
374
+ onPanResponderTerminate: finish,
375
+ }), []);
376
+
377
+ // The button: a drag moves it, a tap opens SSB. Its start is kept for the gesture.
378
+ const fabStart = useRef(null);
379
+ const fabPan = useMemo(() => PanResponder.create({
380
+ onStartShouldSetPanResponder: () => true,
381
+ onPanResponderGrant: () => { fabStart.current = fabRef.current ?? fabHome(screenRef.current, insetsRef.current); },
382
+ onPanResponderMove: (_e, g) => {
383
+ if (!fabStart.current || !isDrag(g.dx, g.dy)) return;
384
+ setFab(clampFab({ x: fabStart.current.x + g.dx, y: fabStart.current.y + g.dy }, screenRef.current, insetsRef.current));
385
+ },
386
+ onPanResponderRelease: (_e, g) => {
387
+ fabStart.current = null;
388
+ if (!isDrag(g.dx, g.dy)) { openRef.current(); return; }
389
+ const at = fabRef.current;
390
+ if (at && storage?.setItem) Promise.resolve(storage.setItem(fabKey, JSON.stringify(at))).catch(() => {});
391
+ },
392
+ onPanResponderTerminate: () => { fabStart.current = null; },
393
+ }), [fabKey, storage]);
255
394
 
256
395
  // ---- drawing ------------------------------------------------------------
257
- const markView = (m, key = 'mark') => {
258
- const r = markRect(m, screen);
259
- return m.kind === 'box'
260
- ? h(View, { key, pointerEvents: 'none', style: [s.box, r] })
261
- : h(View, { key, pointerEvents: 'none', style: [s.pin, r] }, h(Text, { style: s.pinText }, '1'));
262
- };
396
+ const drawingViews = (list) => list.flatMap((it, i) => (it.kind === 'box'
397
+ ? [h(View, { key: `b${i}`, pointerEvents: 'none', style: [s.box, itemRect(it)] })]
398
+ : segmentsOf(it.points, INK_W).map((g, j) => h(View, {
399
+ key: `${i}-${j}`, pointerEvents: 'none',
400
+ style: [s.ink, { left: g.left, top: g.top, width: g.width, transform: [{ rotate: `${g.angle}rad` }] }],
401
+ }))));
263
402
 
264
403
  const chrome = (() => {
265
404
  if (!gate) return null;
266
- if (mode === 'capturing') return mark ? markView(mark) : null;
267
- if (mode === 'idle') {
268
- return h(Pressable, {
269
- testID: 'ssb-button', accessibilityRole: 'button', accessibilityLabel: 'Ask SSB about this screen',
270
- onPress: openSheet, style: ({ pressed }) => [s.fab, { bottom: insets.bottom + 16 }, pressed && s.pressed],
271
- }, h(Text, { style: s.fabText }, 'SSB'), unread ? h(View, { style: s.dot }) : null);
405
+ if (mode === 'capturing') return items.length ? h(View, { style: StyleSheet.absoluteFill, pointerEvents: 'none' }, ...drawingViews(items)) : null;
406
+ if (mode === 'idle' && talk) {
407
+ return h(View, { style: [s.pill, { top: insets.top + 8 }], testID: 'ssb-talk-pill' },
408
+ h(View, { style: [s.talkDot, talk.status !== 'live' && { backgroundColor: MUTED }] }),
409
+ h(Text, { style: s.pillText }, talkLine(talk)),
410
+ h(Pressable, { onPress: toggleMute, accessibilityRole: 'button', accessibilityLabel: talk.muted ? 'Unmute' : 'Mute', style: s.pillBtn }, h(Text, { style: s.pillBtnText }, talk.muted ? 'Unmute' : 'Mute')),
411
+ h(Pressable, { testID: 'ssb-talk-open', onPress: () => setMode('sheet'), accessibilityRole: 'button', accessibilityLabel: 'Open SSB', style: s.pillBtn }, h(Text, { style: s.pillBtnText }, 'Open')),
412
+ h(Pressable, { testID: 'ssb-talk-end', onPress: endTalk, accessibilityRole: 'button', accessibilityLabel: 'End the talk', style: [s.pillBtn, s.talkEnd] }, h(Text, { style: [s.pillBtnText, { color: '#fff' }] }, 'End')));
272
413
  }
273
- if (mode === 'marking') {
274
- const live = drag ? markOf(drag, screen) : null;
275
- return h(View, { style: StyleSheet.absoluteFill, ...pan.panHandlers, testID: 'ssb-mark-surface' },
276
- live ? markView(live, 'live') : null,
277
- h(View, { style: [s.hint, { top: insets.top + 8 }] },
278
- h(Text, { style: s.hintText }, 'Tap or drag on what this is about'),
279
- h(Pressable, { onPress: () => setMode('sheet'), accessibilityRole: 'button', accessibilityLabel: 'Cancel marking' }, h(Text, { style: s.hintLink }, 'Cancel'))));
414
+ if (mode === 'idle') {
415
+ const at = clampFab(fab ?? fabHome(screen, insets), screen, insets); // a rotation can strand a kept spot
416
+ return h(View, {
417
+ testID: 'ssb-button', accessible: true, accessibilityRole: 'button', accessibilityLabel: 'Ask SSB about this screen',
418
+ accessibilityHint: 'Drag to move it', onAccessibilityTap: openSheet,
419
+ style: [s.fab, { left: at.x, top: at.y }, talk && s.fabTalking], ...fabPan.panHandlers,
420
+ }, h(Image, { source: LOGO, style: s.fabLogo }), unread ? h(View, { style: s.dot }) : null);
280
421
  }
281
- if (mode === 'noting' && mark) {
282
- const top = fieldTop({ rect: markRect(mark, screen), screen, keyboardTop: kbTop, insets });
422
+ if (mode === 'drawing') {
423
+ const box = drawingBox(items);
424
+ const top = box ? fieldTop({ rect: box, screen, keyboardTop: kbTop, insets }) : null;
283
425
  return h(View, { style: StyleSheet.absoluteFill, pointerEvents: 'box-none' },
284
- markView(mark),
285
- h(View, { style: [s.field, { top }] },
426
+ // The surface first, so the hint and the field above it still take their own taps.
427
+ h(View, { style: StyleSheet.absoluteFill, ...pan.panHandlers, testID: 'ssb-draw-surface' },
428
+ ...drawingViews(live ? [...items, live] : items)),
429
+ h(View, { style: [s.hint, { top: insets.top + 8 }] },
430
+ ...['pencil', 'box'].map((t) => h(Pressable, {
431
+ key: t, onPress: () => setTool(t), accessibilityRole: 'button', accessibilityState: { selected: tool === t },
432
+ accessibilityLabel: t === 'box' ? 'Box: drag a box round it' : 'Pencil: scribble on it', style: [s.toolBtn, tool === t && s.toolOn],
433
+ }, h(Text, { style: [s.toolText, tool === t && s.toolTextOn] }, t === 'box' ? 'β–­ Box' : '✎ Pencil'))),
434
+ h(View, { style: s.hintSep }),
435
+ items.length ? h(Pressable, { onPress: () => setItems((all) => all.slice(0, -1)), accessibilityRole: 'button', accessibilityLabel: 'Undo the last one', style: s.hintBtn }, h(Text, { style: s.hintLink }, 'Undo')) : null,
436
+ h(Pressable, { onPress: () => { Keyboard.dismiss(); setItems([]); setMode('sheet'); }, accessibilityRole: 'button', accessibilityLabel: 'Stop drawing', style: s.hintBtn }, h(Text, { style: s.hintLink }, 'Cancel'))),
437
+ top != null ? h(View, { style: [s.field, { top }] },
286
438
  h(TextInput, {
287
- testID: 'ssb-mark-note', autoFocus: true, value: note, onChangeText: setNote, placeholder: 'What should change here?',
288
- placeholderTextColor: MUTED, style: s.fieldInput, returnKeyType: 'send', onSubmitEditing: sendMark, blurOnSubmit: false,
439
+ testID: 'ssb-draw-note', value: note, onChangeText: setNote, placeholder: 'What should change here?',
440
+ placeholderTextColor: MUTED, keyboardAppearance: 'dark', style: s.fieldInput, returnKeyType: 'send', onSubmitEditing: sendDrawing, blurOnSubmit: false,
289
441
  }),
290
- h(Pressable, { onPress: () => { setMark(null); setNote(''); setMode('sheet'); }, accessibilityRole: 'button', accessibilityLabel: 'Drop the mark', style: s.iconBtn }, h(Text, { style: s.iconText }, 'βœ•')),
291
- h(Pressable, { testID: 'ssb-mark-send', onPress: sendMark, disabled: !note.trim() || busy, accessibilityRole: 'button', accessibilityLabel: 'Send to SSB', style: [s.sendRound, (!note.trim() || busy) && s.disabled] }, h(Text, { style: s.sendRoundText }, 'β†’'))));
442
+ h(Pressable, { testID: 'ssb-draw-send', onPress: sendDrawing, disabled: !note.trim() || busy, accessibilityRole: 'button', accessibilityLabel: 'Send to SSB', style: [s.sendRound, (!note.trim() || busy) && s.disabled] }, h(Text, { style: s.sendRoundText }, 'β†’'))) : null);
292
443
  }
293
- if (mode === 'sheet') return h(Sheet, {
294
- gate, chat, screen, insets, shot, attach, setAttach, draft, setDraft, busy, error, showDrafts, setShowDrafts,
295
- onSend: sendDraft, onMark: () => { Keyboard.dismiss(); setMode('marking'); }, onClose: close,
296
- onNew: () => { rememberChat(null); setChat(null); }, onDecide: decide, onStartAll: startAll,
444
+ if (mode === 'sheet') return h(Panel, {
445
+ gate, chat, work, screen, insets, draft, setDraft, busy, error,
446
+ onSend: sendDraft, onDraw: () => { Keyboard.dismiss(); setMode('drawing'); }, onClose: close,
447
+ canTalk: !!voice?.Room, talk, onTalk: startTalk, onEndTalk: endTalk, onMute: toggleMute,
448
+ onNew: () => { rememberChat(null); setChat(null); }, onDecide: decide, onStartAll: startAll, onOpenLink: openLink,
297
449
  });
298
450
  return null;
299
451
  })();
@@ -303,115 +455,161 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
303
455
  h(View, { ref: chromeRef, collapsable: false, pointerEvents: 'box-none', style: StyleSheet.absoluteFill }, chrome));
304
456
  }
305
457
 
306
- function Sheet({ gate, chat, screen, insets, shot, attach, setAttach, draft, setDraft, busy, error, showDrafts, setShowDrafts, onSend, onMark, onClose, onNew, onDecide, onStartAll }) {
458
+ function Panel({ gate, chat, work, screen, insets, draft, setDraft, busy, error, onSend, onDraw, onClose, onNew, onDecide, onStartAll, onOpenLink, canTalk, talk, onTalk, onEndTalk, onMute }) {
307
459
  const scroll = useRef(null);
308
- const drafts = draftsOf(chat);
309
- const line = draftsLine(drafts);
460
+ const [open, setOpen] = useState(null); // the draft whose detail is shown
461
+ const { flying, waiting, sent } = workView({ inFlight: work, chat });
310
462
  const offer = offerOf(chat);
311
463
  const banner = demoBanner(chat);
312
464
  const msgs = chat?.messages || [];
313
- return h(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, style: s.sheetWrap, pointerEvents: 'box-none' },
314
- h(View, { style: [s.sheet, { maxHeight: screen.height * 0.78, paddingBottom: Math.max(12, insets.bottom - 20) }], testID: 'ssb-sheet' },
465
+ const hasWork = flying.length || waiting.length || sent.length || banner;
466
+ const showMe = (x) => (x.state === 'ready' && banner?.url ? banner.url : null);
467
+ return h(View, { style: [s.panel, { paddingTop: insets.top }], testID: 'ssb-panel' },
468
+ h(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, style: { flex: 1 } },
315
469
  h(View, { style: s.header },
470
+ h(View, { style: s.headLogoRing }, h(Image, { source: LOGO, style: s.headLogo, accessibilityLabel: 'SSB' })),
316
471
  h(View, { style: { flex: 1 } },
317
472
  h(Text, { style: s.title }, 'Tell SSB'),
318
473
  h(Text, { style: s.sub, numberOfLines: 1 }, `${gate.app?.name || 'this app'} Β· as ${gate.person?.label || 'you'}`)),
319
474
  chat ? h(Pressable, { onPress: onNew, accessibilityRole: 'button', accessibilityLabel: 'New conversation', style: s.quiet }, h(Text, { style: s.quietText }, 'New')) : null,
320
475
  h(Pressable, { onPress: onClose, accessibilityRole: 'button', accessibilityLabel: 'Close SSB', style: s.iconBtn }, h(Text, { style: s.iconText }, 'βœ•'))),
321
- banner ? h(Pressable, {
322
- testID: 'ssb-demo', disabled: !banner.url, onPress: () => banner.url && Linking.openURL(banner.url).catch(() => {}),
323
- accessibilityRole: banner.url ? 'button' : 'text', style: [s.demo, banner.url && s.demoReady],
324
- }, h(Text, { style: [s.demoText, banner.url && { color: '#fff' }] }, banner.text), banner.url ? h(Text, { style: s.demoGo }, 'β–Ά See it') : null) : null,
325
- line ? h(View, { style: s.bar },
326
- h(Pressable, { onPress: () => setShowDrafts(!showDrafts), style: { flex: 1 }, accessibilityRole: 'button', accessibilityLabel: line }, h(Text, { style: s.barText }, `${line} ${showDrafts ? 'β–΄' : 'β–Ύ'}`)),
327
- drafts.waiting.length ? h(Pressable, { testID: 'ssb-start-all', onPress: onStartAll, disabled: busy, style: [s.primary, busy && s.disabled] }, h(Text, { style: s.primaryText }, 'Start working')) : null) : null,
328
- showDrafts ? h(ScrollView, { style: s.drafts },
329
- ...drafts.waiting.map((p) => h(View, { key: p.id, style: s.draft },
330
- h(Text, { style: s.draftTitle }, p.title),
331
- p.detail ? h(Text, { style: s.draftBody, numberOfLines: 4 }, p.detail) : null,
332
- h(View, { style: s.row },
333
- h(Pressable, { onPress: () => onDecide(p, 'dismiss'), disabled: busy, style: s.quiet }, h(Text, { style: s.quietText }, 'Dismiss')),
334
- h(Pressable, { onPress: () => onDecide(p, 'raise'), disabled: busy, style: [s.primary, busy && s.disabled] }, h(Text, { style: s.primaryText }, 'Start'))))),
335
- ...drafts.sent.map((p) => h(View, { key: p.id, style: s.draft },
336
- h(Text, { style: s.draftTitle }, `βœ“ ${p.title}`),
337
- p.jobUrl ? h(Pressable, { onPress: () => Linking.openURL(p.jobUrl).catch(() => {}) }, h(Text, { style: s.link }, 'Watch the job β†’')) : null))) : null,
338
- h(ScrollView, { ref: scroll, style: s.thread, contentContainerStyle: { paddingVertical: 8 }, onContentSizeChange: () => scroll.current?.scrollToEnd({ animated: true }) },
339
- msgs.length ? null : h(Text, { style: s.empty }, 'Say what should change on this screen, or tap Mark to point at it.'),
476
+ talk ? h(View, { style: s.talkBar, testID: 'ssb-talk-bar' },
477
+ h(View, { style: [s.talkDot, talk.status !== 'live' && { backgroundColor: MUTED }] }),
478
+ h(Text, { style: s.talkText }, talkLine(talk)),
479
+ h(Pressable, { onPress: onMute, accessibilityRole: 'button', accessibilityLabel: talk.muted ? 'Unmute' : 'Mute', style: s.talkBtn }, h(Text, { style: s.talkBtnText }, talk.muted ? 'Unmute' : 'Mute')),
480
+ h(Pressable, { onPress: onEndTalk, accessibilityRole: 'button', accessibilityLabel: 'End the talk', style: [s.talkBtn, s.talkEnd] }, h(Text, { style: [s.talkBtnText, { color: '#fff' }] }, 'End'))) : null,
481
+ // THE WORK, at the top: in flight, then drafts to start, then what this chat started.
482
+ hasWork ? h(ScrollView, { style: [s.work, { maxHeight: Math.round(screen.height * 0.42) }], testID: 'ssb-work', keyboardShouldPersistTaps: 'handled' },
483
+ banner ? h(Pressable, {
484
+ testID: 'ssb-demo', disabled: !banner.url, onPress: () => onOpenLink(banner.url),
485
+ accessibilityRole: banner.url ? 'button' : 'text', style: [s.demo, banner.url && s.demoReady],
486
+ }, h(Text, { style: [s.demoText, banner.url && { color: '#fff' }] }, banner.text), banner.url ? h(Text, { style: s.demoGo }, 'β–Ά See it') : null) : null,
487
+ flying.length ? h(Text, { style: s.section, testID: 'ssb-section-flight' }, 'In flight') : null,
488
+ ...flying.map((x) => h(View, { key: `f${x.id}`, style: s.workRow, testID: `ssb-flight-${x.id}` },
489
+ h(Text, { style: s.workTitle, numberOfLines: 2 }, x.title),
490
+ showMe(x)
491
+ ? h(Pressable, { onPress: () => onOpenLink(showMe(x)), accessibilityRole: 'button', accessibilityLabel: `Show me: ${x.title}`, style: [s.primary, s.small] }, h(Text, { style: s.primaryText }, 'β–Ά Show me'))
492
+ : h(Text, { style: [s.state, s[`state_${x.state}`]] }, STATE_WORD[x.state] || x.state))),
493
+ waiting.length ? h(View, { style: s.sectionRow },
494
+ h(Text, { style: [s.section, { flex: 1 }] }, 'Drafts'),
495
+ waiting.length > 1 ? h(Pressable, { testID: 'ssb-start-all', onPress: onStartAll, disabled: busy, accessibilityRole: 'button', accessibilityLabel: 'Start all', style: [s.primary, s.small, busy && s.disabled] }, h(Text, { style: s.primaryText }, `Start all (${waiting.length})`)) : null) : null,
496
+ ...waiting.map((p) => h(View, { key: `d${p.id}`, style: s.draftRow, testID: `ssb-draft-${p.id}` },
497
+ h(View, { style: s.workLine },
498
+ h(Pressable, { onPress: () => setOpen(open === p.id ? null : p.id), style: { flex: 1 }, accessibilityRole: 'button', accessibilityLabel: p.title },
499
+ h(Text, { style: s.workTitle, numberOfLines: open === p.id ? undefined : 2 }, p.title)),
500
+ h(Pressable, { testID: `ssb-draft-start-${p.id}`, onPress: () => onDecide(p, 'raise'), disabled: busy, accessibilityRole: 'button', accessibilityLabel: `Start: ${p.title}`, style: [s.primary, s.small, busy && s.disabled] }, h(Text, { style: s.primaryText }, 'Start'))),
501
+ open === p.id ? h(View, null,
502
+ p.detail ? h(Text, { style: s.draftBody }, p.detail) : null,
503
+ h(Pressable, { onPress: () => onDecide(p, 'dismiss'), disabled: busy, accessibilityRole: 'button', accessibilityLabel: `Dismiss: ${p.title}`, style: [s.quiet, { alignSelf: 'flex-start' }] }, h(Text, { style: s.quietText }, 'Dismiss'))) : null)),
504
+ ...sent.map((p) => h(View, { key: `s${p.id}`, style: s.workRow, testID: `ssb-sent-${p.id}` },
505
+ h(Text, { style: s.workTitle, numberOfLines: 2 }, `βœ“ ${p.title}`),
506
+ p.jobUrl ? h(Pressable, { onPress: () => Linking.openURL(p.jobUrl).catch(() => {}), accessibilityRole: 'link' }, h(Text, { style: s.link }, 'Started β†’')) : h(Text, { style: s.state }, 'Started')))) : null,
507
+ // THE CONVERSATION, below.
508
+ h(ScrollView, { ref: scroll, style: s.thread, contentContainerStyle: { paddingVertical: 8 }, keyboardShouldPersistTaps: 'handled', onContentSizeChange: () => scroll.current?.scrollToEnd({ animated: true }) },
509
+ msgs.length ? null : h(Text, { style: s.empty }, 'Say what should change on this screen, or tap Draw to scribble on it.'),
340
510
  ...msgs.map((m) => h(View, { key: m.id, style: [s.bubble, m.role === 'user' ? s.mine : s.theirs] },
341
511
  m.image ? h(Image, { source: { uri: m.image }, style: s.thumb, resizeMode: 'contain' }) : null,
342
512
  h(Text, { style: m.role === 'user' ? s.mineText : s.theirsText }, m.text),
343
513
  m.id === offer ? h(Pressable, { onPress: onStartAll, disabled: busy, style: [s.primary, { marginTop: 8, alignSelf: 'flex-start' }] }, h(Text, { style: s.primaryText }, 'Confirm and start work')) : null)),
344
514
  chat?.thinking ? h(View, { style: [s.bubble, s.theirs, s.row] }, h(ActivityIndicator, { size: 'small', color: MUTED }), h(Text, { style: [s.theirsText, { marginLeft: 8 }] }, 'SSB is thinking…')) : null),
345
515
  error ? h(Text, { style: s.error }, error) : null,
346
- h(View, { style: s.composer },
516
+ h(View, { style: [s.composer, { paddingBottom: Math.max(12, insets.bottom > 60 ? 16 : insets.bottom) }] },
347
517
  h(TextInput, {
348
518
  testID: 'ssb-composer', value: draft, onChangeText: setDraft, multiline: true, placeholder: 'Ask SSB, or say what should change…',
349
- placeholderTextColor: MUTED, style: s.input,
519
+ placeholderTextColor: MUTED, keyboardAppearance: 'dark', style: s.input,
350
520
  }),
351
521
  h(View, { style: s.row },
352
- shot ? h(Pressable, { onPress: () => setAttach(!attach), accessibilityRole: 'checkbox', accessibilityState: { checked: attach }, accessibilityLabel: 'Attach this screen', style: [s.chip, attach && s.chipOn] },
353
- h(Image, { source: { uri: shot }, style: s.chipThumb }),
354
- h(Text, { style: [s.chipText, attach && { color: BLUE }] }, attach ? 'Screen attached' : 'Attach screen')) : null,
355
- h(Pressable, { testID: 'ssb-mark', onPress: onMark, accessibilityRole: 'button', accessibilityLabel: 'Mark the screen', style: s.chip }, h(Text, { style: s.chipText }, 'β—Ž Mark')),
522
+ h(Pressable, { testID: 'ssb-draw', onPress: onDraw, accessibilityRole: 'button', accessibilityLabel: 'Draw on the screen', style: s.chip }, h(Text, { style: s.chipText }, '✎ Draw')),
523
+ canTalk && !talk ? h(Pressable, { testID: 'ssb-talk', onPress: onTalk, accessibilityRole: 'button', accessibilityLabel: 'Talk to SSB', style: s.chip }, h(Text, { style: s.chipText }, 'πŸŽ™ Talk')) : null,
356
524
  h(View, { style: { flex: 1 } }),
357
525
  h(Pressable, { testID: 'ssb-send', onPress: onSend, disabled: !draft.trim() || busy, accessibilityRole: 'button', accessibilityLabel: 'Send to SSB', style: [s.primary, (!draft.trim() || busy) && s.disabled] },
358
526
  busy ? h(ActivityIndicator, { size: 'small', color: '#fff' }) : h(Text, { style: s.primaryText }, 'Send'))))));
359
527
  }
360
528
 
361
- const shadow = { shadowColor: '#000', shadowOpacity: 0.25, shadowRadius: 18, shadowOffset: { width: 0, height: 6 }, elevation: 12 };
529
+ const shadow = { shadowColor: '#000', shadowOpacity: 0.45, shadowRadius: 18, shadowOffset: { width: 0, height: 8 }, elevation: 12 };
530
+ const glow = (c, r = 12) => ({ shadowColor: c, shadowOpacity: 0.55, shadowRadius: r, shadowOffset: { width: 0, height: 0 } });
362
531
  const s = StyleSheet.create({
363
532
  root: { flex: 1 },
364
533
  pressed: { opacity: 0.7 },
365
- disabled: { opacity: 0.5 },
366
- fab: { position: 'absolute', right: 16, width: 48, height: 48, borderRadius: 24, backgroundColor: BLUE, alignItems: 'center', justifyContent: 'center', ...shadow },
367
- fabText: { color: '#fff', fontSize: 13, fontWeight: '800', letterSpacing: 0.5 },
368
- dot: { position: 'absolute', top: 2, right: 2, width: 12, height: 12, borderRadius: 6, backgroundColor: RED, borderWidth: 2, borderColor: '#fff' },
369
- pin: { position: 'absolute', borderRadius: 14, backgroundColor: RED, borderWidth: 2, borderColor: '#fff', alignItems: 'center', justifyContent: 'center', ...shadow },
370
- pinText: { color: '#fff', fontSize: 13, fontWeight: '700' },
371
- box: { position: 'absolute', borderWidth: 3, borderColor: BLUE, borderRadius: 6, backgroundColor: 'rgba(47,91,234,0.08)' },
372
- hint: { position: 'absolute', alignSelf: 'center', flexDirection: 'row', alignItems: 'center', paddingVertical: 8, paddingHorizontal: 14, borderRadius: 18, backgroundColor: INK, ...shadow },
373
- hintText: { color: '#fff', fontSize: 14, fontWeight: '600', marginRight: 12 },
374
- hintLink: { color: '#9db1ff', fontSize: 14, fontWeight: '600' },
375
- field: { position: 'absolute', left: 12, right: 12, height: 56, flexDirection: 'row', alignItems: 'center', paddingLeft: 14, paddingRight: 6, borderRadius: 14, backgroundColor: '#fff', ...shadow },
376
- fieldInput: { flex: 1, fontSize: 16, color: INK },
377
- sendRound: { width: 40, height: 40, borderRadius: 20, backgroundColor: BLUE, alignItems: 'center', justifyContent: 'center', marginLeft: 4 },
534
+ disabled: { opacity: 0.45 },
535
+ fab: { position: 'absolute', width: FAB, height: FAB, borderRadius: FAB / 2, alignItems: 'center', justifyContent: 'center', ...shadow },
536
+ fabLogo: { width: FAB, height: FAB, borderRadius: FAB / 2 },
537
+ fabTalking: { borderWidth: 3, borderColor: GREEN, ...glow(GREEN, 14) },
538
+ dot: { position: 'absolute', top: 2, right: 2, width: 12, height: 12, borderRadius: 6, backgroundColor: RED, borderWidth: 2, borderColor: BG, ...glow(RED, 6) },
539
+ // Drawing on the live screen.
540
+ ink: { position: 'absolute', height: INK_W, borderRadius: INK_W / 2, backgroundColor: RED },
541
+ box: { position: 'absolute', borderWidth: 3, borderColor: RED, borderRadius: 6, backgroundColor: 'rgba(255,77,90,0.08)' },
542
+ hint: { position: 'absolute', alignSelf: 'center', flexDirection: 'row', alignItems: 'center', paddingVertical: 8, paddingHorizontal: 12, borderRadius: 20, backgroundColor: INK, borderWidth: 1, borderColor: LINE, ...shadow },
543
+ toolBtn: { paddingVertical: 5, paddingHorizontal: 10, borderRadius: 12, marginRight: 4 },
544
+ toolOn: { backgroundColor: RED, ...glow(RED, 8) },
545
+ toolText: { color: TEXT, fontSize: 14, fontWeight: '600' },
546
+ toolTextOn: { color: '#fff' },
547
+ hintSep: { width: 1, height: 18, backgroundColor: LINE, marginHorizontal: 6 },
548
+ hintLink: { color: '#8fb3ff', fontSize: 14, fontWeight: '600' },
549
+ hintBtn: { paddingHorizontal: 6, paddingVertical: 2 },
550
+ field: { position: 'absolute', left: 12, right: 12, height: 56, flexDirection: 'row', alignItems: 'center', paddingLeft: 14, paddingRight: 6, borderRadius: 16, backgroundColor: SURFACE, borderWidth: 1, borderColor: LINE, ...shadow },
551
+ fieldInput: { flex: 1, fontSize: 16, color: TEXT },
552
+ sendRound: { width: 40, height: 40, borderRadius: 20, backgroundColor: BLUE, alignItems: 'center', justifyContent: 'center', marginLeft: 4, ...glow(BLUE, 10) },
378
553
  sendRoundText: { color: '#fff', fontSize: 20, fontWeight: '700' },
379
- sheetWrap: { ...StyleSheet.absoluteFillObject, justifyContent: 'flex-end' },
380
- sheet: { backgroundColor: '#fff', borderTopLeftRadius: 18, borderTopRightRadius: 18, ...shadow },
381
- header: { flexDirection: 'row', alignItems: 'center', paddingTop: 14, paddingHorizontal: 16, paddingBottom: 10, borderBottomWidth: 1, borderBottomColor: LINE },
382
- title: { fontSize: 16, fontWeight: '700', color: INK },
554
+ // The panel. Spelled out: StyleSheet.absoluteFillObject is gone in React
555
+ // Native 0.86, and spreading `undefined` left the panel with no size at all
556
+ // (the harness caught it).
557
+ panel: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: BG },
558
+ header: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingTop: 12, paddingHorizontal: 16, paddingBottom: 12, borderBottomWidth: 1, borderBottomColor: LINE },
559
+ headLogoRing: { width: 40, height: 40, borderRadius: 20, ...glow(BLUE, 10) },
560
+ headLogo: { width: 40, height: 40, borderRadius: 20 },
561
+ title: { fontSize: 17, fontWeight: '800', color: TEXT, letterSpacing: 0.3 },
383
562
  sub: { fontSize: 12, color: MUTED, marginTop: 2 },
384
- bar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 8, backgroundColor: '#fffaf0', borderBottomWidth: 1, borderBottomColor: '#f5d38a' },
385
- barText: { fontSize: 13, fontWeight: '600', color: INK },
386
- demo: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 10, backgroundColor: '#f0fdf4', borderBottomWidth: 1, borderBottomColor: LINE },
387
- demoReady: { backgroundColor: BLUE },
388
- demoText: { flex: 1, fontSize: 13, fontWeight: '600', color: INK },
389
- demoGo: { color: '#fff', fontSize: 14, fontWeight: '800', marginLeft: 8 },
390
- drafts: { maxHeight: 260, borderBottomWidth: 1, borderBottomColor: LINE },
391
- draft: { paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: LINE },
392
- draftTitle: { fontSize: 14, fontWeight: '600', color: INK },
393
- draftBody: { fontSize: 13, color: MUTED, marginTop: 4, lineHeight: 18 },
394
- link: { color: BLUE, fontSize: 13, marginTop: 6 },
395
- thread: { paddingHorizontal: 12, flexGrow: 0, minHeight: 80 },
396
- empty: { color: MUTED, fontSize: 14, textAlign: 'center', paddingVertical: 18, paddingHorizontal: 12 },
397
- bubble: { maxWidth: '86%', borderRadius: 14, paddingVertical: 8, paddingHorizontal: 12, marginVertical: 4 },
398
- mine: { alignSelf: 'flex-end', backgroundColor: INK },
399
- theirs: { alignSelf: 'flex-start', backgroundColor: '#f1f3f6' },
400
- mineText: { color: '#fff', fontSize: 15, lineHeight: 20 },
401
- theirsText: { color: INK, fontSize: 15, lineHeight: 20 },
402
- thumb: { width: 140, height: 220, borderRadius: 8, marginBottom: 6, backgroundColor: '#0002' },
563
+ talkBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 8, backgroundColor: 'rgba(47,210,122,0.08)', borderBottomWidth: 1, borderBottomColor: LINE },
564
+ talkDot: { width: 10, height: 10, borderRadius: 5, backgroundColor: GREEN, marginRight: 8, ...glow(GREEN, 6) },
565
+ talkText: { flex: 1, fontSize: 14, fontWeight: '600', color: TEXT },
566
+ talkBtn: { paddingVertical: 6, paddingHorizontal: 12, borderRadius: 10, borderWidth: 1, borderColor: LINE, backgroundColor: RAISED, marginLeft: 6 },
567
+ talkEnd: { backgroundColor: RED, borderColor: RED },
568
+ talkBtnText: { fontSize: 13, fontWeight: '700', color: TEXT },
569
+ // The work, at the top.
570
+ work: { flexGrow: 0, borderBottomWidth: 1, borderBottomColor: LINE, backgroundColor: SURFACE },
571
+ section: { fontSize: 11, fontWeight: '800', letterSpacing: 1.4, textTransform: 'uppercase', color: MUTED, paddingHorizontal: 16, paddingTop: 12, paddingBottom: 6 },
572
+ sectionRow: { flexDirection: 'row', alignItems: 'center', paddingRight: 16 },
573
+ workRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 11, gap: 10, borderTopWidth: 1, borderTopColor: LINE },
574
+ draftRow: { paddingHorizontal: 16, paddingVertical: 11, borderTopWidth: 1, borderTopColor: LINE, borderLeftWidth: 3, borderLeftColor: YELLOW, backgroundColor: 'rgba(246,197,68,0.06)' },
575
+ workLine: { flexDirection: 'row', alignItems: 'center', gap: 10 },
576
+ workTitle: { flex: 1, fontSize: 15, fontWeight: '600', color: TEXT },
577
+ state: { fontSize: 12, fontWeight: '800', letterSpacing: 0.3, color: MUTED, paddingVertical: 4, paddingHorizontal: 9, borderRadius: 9, backgroundColor: RAISED, borderWidth: 1, borderColor: LINE, overflow: 'hidden' },
578
+ state_ready: { color: BG, backgroundColor: GREEN, borderColor: GREEN },
579
+ state_building: { color: '#9cc0ff', backgroundColor: 'rgba(61,123,255,0.14)', borderColor: 'rgba(61,123,255,0.5)' },
580
+ state_checking: { color: '#9cc0ff', backgroundColor: 'rgba(61,123,255,0.14)', borderColor: 'rgba(61,123,255,0.5)' },
581
+ state_blocked: { color: '#fff', backgroundColor: RED, borderColor: RED },
582
+ demo: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 11, backgroundColor: 'rgba(47,210,122,0.08)' },
583
+ demoReady: { backgroundColor: 'rgba(47,210,122,0.16)' },
584
+ demoText: { flex: 1, fontSize: 13, fontWeight: '600', color: TEXT },
585
+ demoGo: { color: GREEN, fontSize: 14, fontWeight: '800', marginLeft: 8 },
586
+ draftBody: { fontSize: 13, color: MUTED, marginTop: 6, lineHeight: 19 },
587
+ link: { color: '#8fb3ff', fontSize: 13, fontWeight: '600' },
588
+ // The conversation takes what the work and the composer leave.
589
+ thread: { flex: 1, paddingHorizontal: 12 },
590
+ empty: { color: MUTED, fontSize: 14, textAlign: 'center', paddingVertical: 24, paddingHorizontal: 20, lineHeight: 20 },
591
+ bubble: { maxWidth: '86%', borderRadius: 16, paddingVertical: 9, paddingHorizontal: 13, marginVertical: 4 },
592
+ mine: { alignSelf: 'flex-end', backgroundColor: BLUE, borderBottomRightRadius: 4 },
593
+ theirs: { alignSelf: 'flex-start', backgroundColor: RAISED, borderWidth: 1, borderColor: LINE, borderBottomLeftRadius: 4 },
594
+ mineText: { color: '#fff', fontSize: 15, lineHeight: 21 },
595
+ theirsText: { color: TEXT, fontSize: 15, lineHeight: 21 },
596
+ thumb: { width: 140, height: 220, borderRadius: 8, marginBottom: 6, backgroundColor: '#0006' },
403
597
  error: { color: RED, fontSize: 13, paddingHorizontal: 16, paddingTop: 6 },
404
- composer: { paddingHorizontal: 12, paddingTop: 8, borderTopWidth: 1, borderTopColor: LINE },
405
- input: { minHeight: 44, maxHeight: 120, fontSize: 16, color: INK, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 12, paddingHorizontal: 12, paddingTop: 10, paddingBottom: 10 },
598
+ composer: { paddingHorizontal: 12, paddingTop: 10, borderTopWidth: 1, borderTopColor: LINE, backgroundColor: SURFACE },
599
+ input: { minHeight: 44, maxHeight: 120, fontSize: 16, color: TEXT, backgroundColor: BG, borderWidth: 1, borderColor: LINE, borderRadius: 14, paddingHorizontal: 13, paddingTop: 11, paddingBottom: 11 },
406
600
  row: { flexDirection: 'row', alignItems: 'center', marginTop: 8, gap: 8 },
407
- chip: { flexDirection: 'row', alignItems: 'center', paddingVertical: 6, paddingHorizontal: 10, borderRadius: 10, borderWidth: 1, borderColor: '#d1d5db' },
408
- chipOn: { borderColor: BLUE, backgroundColor: '#e8eeff' },
409
- chipThumb: { width: 16, height: 28, borderRadius: 3, marginRight: 6, backgroundColor: '#0002' },
410
- chipText: { fontSize: 13, color: INK, fontWeight: '600' },
411
- primary: { paddingVertical: 9, paddingHorizontal: 16, borderRadius: 10, backgroundColor: BLUE, minWidth: 64, alignItems: 'center' },
412
- primaryText: { color: '#fff', fontSize: 14, fontWeight: '700' },
601
+ chip: { flexDirection: 'row', alignItems: 'center', paddingVertical: 7, paddingHorizontal: 11, borderRadius: 12, borderWidth: 1, borderColor: LINE, backgroundColor: RAISED },
602
+ chipText: { fontSize: 13, color: TEXT, fontWeight: '600' },
603
+ primary: { paddingVertical: 9, paddingHorizontal: 16, borderRadius: 12, backgroundColor: BLUE, minWidth: 64, alignItems: 'center', ...glow(BLUE, 10) },
604
+ primaryText: { color: '#fff', fontSize: 14, fontWeight: '800', letterSpacing: 0.2 },
605
+ small: { paddingVertical: 6, paddingHorizontal: 12, minWidth: 0 },
413
606
  quiet: { paddingVertical: 6, paddingHorizontal: 10 },
414
607
  quietText: { color: MUTED, fontSize: 14, fontWeight: '600' },
415
- iconBtn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
416
- iconText: { color: MUTED, fontSize: 18 },
608
+ iconBtn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: 18, backgroundColor: RAISED },
609
+ iconText: { color: TEXT, fontSize: 16 },
610
+ // Voice mode: a pill over the app.
611
+ pill: { position: 'absolute', left: 12, right: 12, flexDirection: 'row', alignItems: 'center', paddingVertical: 8, paddingLeft: 14, paddingRight: 6, borderRadius: 24, backgroundColor: INK, borderWidth: 1, borderColor: 'rgba(47,210,122,0.45)', ...shadow },
612
+ pillText: { flex: 1, color: TEXT, fontSize: 14, fontWeight: '600' },
613
+ pillBtn: { paddingVertical: 6, paddingHorizontal: 10, borderRadius: 14, backgroundColor: RAISED, marginLeft: 6 },
614
+ pillBtnText: { color: TEXT, fontSize: 13, fontWeight: '700' },
417
615
  });
package/src/core.js CHANGED
@@ -42,33 +42,88 @@ export function inAppApi(config, getToken, fetchImpl = globalThis.fetch) {
42
42
  chat: (id) => call(`/chats/${encodeURIComponent(id)}`),
43
43
  decide: (chatId, proposalId, action, body) => post(`/chats/${encodeURIComponent(chatId)}/proposals/${encodeURIComponent(proposalId)}/${action}`, body),
44
44
  pushToken: (token, platform) => post('/push-token', { token, platform }),
45
+ voice: (body) => post('/voice', body),
46
+ work: () => call('/work'),
45
47
  };
46
48
  }
47
49
 
48
- /** A finished gesture on the screen, as a mark in fractions of the screen: a tap is a pin, a drag a box. */
49
- export function markOf({ x0, y0, x1, y1 }, screen) {
50
- const fx = (v) => Math.min(1, Math.max(0, v / screen.width));
51
- const fy = (v) => Math.min(1, Math.max(0, v / screen.height));
52
- if (Math.hypot(x1 - x0, y1 - y0) < PIN_SLOP) return { kind: 'pin', x: fx(x1), y: fy(y1) };
53
- const left = Math.min(x0, x1), top = Math.min(y0, y1);
54
- return { kind: 'box', x: fx(left), y: fy(top), w: fx(Math.abs(x1 - x0)), h: fy(Math.abs(y1 - y0)) };
50
+ // ---- the pencil: scribbles on the live screen --------------------------------
51
+ const MIN_STEP = 3; // a point closer than this to the last one adds nothing
52
+
53
+ /** A stroke with `p` added, unless it is too close to the last point to matter. */
54
+ export function addPoint(stroke, p) {
55
+ const last = stroke[stroke.length - 1];
56
+ if (last && Math.hypot(p.x - last.x, p.y - last.y) < MIN_STEP) return stroke;
57
+ return [...stroke, { x: p.x, y: p.y }];
58
+ }
59
+
60
+ /**
61
+ * A stroke as straight segments a View can draw: each is a bar `width` long,
62
+ * centred on its midpoint and turned by `angle` (radians). A single point is a dot.
63
+ */
64
+ export function segmentsOf(stroke, thickness = 4) {
65
+ if (stroke.length === 1) return [{ left: stroke[0].x - thickness / 2, top: stroke[0].y - thickness / 2, width: thickness, angle: 0 }];
66
+ const out = [];
67
+ for (let i = 1; i < stroke.length; i++) {
68
+ const a = stroke[i - 1], b = stroke[i];
69
+ const len = Math.hypot(b.x - a.x, b.y - a.y);
70
+ const width = len + thickness; // overlap the joints so a curve has no gaps
71
+ out.push({ left: (a.x + b.x) / 2 - width / 2, top: (a.y + b.y) / 2 - thickness / 2, width, angle: Math.atan2(b.y - a.y, b.x - a.x) });
72
+ }
73
+ return out;
74
+ }
75
+
76
+ // A drawing is a list of items, each a pencil stroke or a box:
77
+ // { kind: 'stroke', points: [{x, y}] } | { kind: 'box', x0, y0, x1, y1 } (screen points)
78
+ const MIN_BOX = 8; // a box smaller than this was a slip, not a box
79
+
80
+ /** A box item from its drag, or null when it is too small to mean anything. */
81
+ export function boxItem(d) {
82
+ if (Math.abs(d.x1 - d.x0) < MIN_BOX || Math.abs(d.y1 - d.y0) < MIN_BOX) return null;
83
+ return { kind: 'box', x0: d.x0, y0: d.y0, x1: d.x1, y1: d.y1 };
55
84
  }
56
85
 
57
- /** The mark's box on the screen, in points (a pin is a small square round its point). */
58
- export function markRect(mark, screen) {
59
- const x = mark.x * screen.width, y = mark.y * screen.height;
60
- if (mark.kind === 'box') return { left: x, top: y, width: mark.w * screen.width, height: mark.h * screen.height };
61
- return { left: x - 14, top: y - 14, width: 28, height: 28 };
86
+ /** One item's box, in points. */
87
+ export function itemRect(it) {
88
+ if (it.kind === 'box') {
89
+ const left = Math.min(it.x0, it.x1), top = Math.min(it.y0, it.y1);
90
+ return { left, top, width: Math.abs(it.x1 - it.x0), height: Math.abs(it.y1 - it.y0) };
91
+ }
92
+ const xs = it.points.map((p) => p.x), ys = it.points.map((p) => p.y);
93
+ const left = Math.min(...xs), top = Math.min(...ys);
94
+ return { left, top, width: Math.max(...xs) - left, height: Math.max(...ys) - top };
62
95
  }
63
96
 
64
- /** The point a mark is about: a pin's point, a box's middle. */
65
- export const markPoint = (mark, screen) => mark.kind === 'box'
66
- ? { x: (mark.x + mark.w / 2) * screen.width, y: (mark.y + mark.h / 2) * screen.height }
67
- : { x: mark.x * screen.width, y: mark.y * screen.height };
97
+ /** The box round everything drawn, or null with nothing drawn. */
98
+ export function drawingBox(items) {
99
+ const rs = items.filter((it) => it.kind === 'box' || it.points?.length).map(itemRect);
100
+ if (!rs.length) return null;
101
+ const left = Math.min(...rs.map((r) => r.left)), top = Math.min(...rs.map((r) => r.top));
102
+ return { left, top, width: Math.max(...rs.map((r) => r.left + r.width)) - left, height: Math.max(...rs.map((r) => r.top + r.height)) - top };
103
+ }
68
104
 
69
105
  /**
70
- * Where the field beside the mark goes: just above the keyboard when that is
71
- * clear of the mark, else under the status bar. `keyboardTop` is the
106
+ * What was drawn, as the ONE mark a send carries (annotate.mjs cleanMarks):
107
+ * a `box` when only boxes were drawn, else a `pen`, over the drawing's box,
108
+ * in fractions of the screen.
109
+ */
110
+ export function drawingMark(items, screen) {
111
+ const r = drawingBox(items);
112
+ if (!r) return null;
113
+ const f = (v, d) => Math.min(1, Math.max(0, v / d));
114
+ return { kind: items.every((it) => it.kind === 'box') ? 'box' : 'pen',
115
+ x: f(r.left, screen.width), y: f(r.top, screen.height), w: f(r.width, screen.width), h: f(r.height, screen.height) };
116
+ }
117
+
118
+ /** The point the drawing is about (what the app has under it is named): the middle of its box. */
119
+ export const drawingPoint = (items) => {
120
+ const r = drawingBox(items);
121
+ return r ? { x: r.left + r.width / 2, y: r.top + r.height / 2 } : null;
122
+ };
123
+
124
+ /**
125
+ * Where the note field goes: just above the keyboard when that is clear of
126
+ * the drawing, else under the status bar. `keyboardTop` is the
72
127
  * keyboard's top edge in screen points (the screen height when it is down).
73
128
  */
74
129
  export function fieldTop({ rect, screen, keyboardTop, insets, fieldH = 56 }) {
@@ -202,3 +257,54 @@ export function demoBanner(chat) {
202
257
  if (d.status === 'failed') return { text: `Done: ${what}. The demo couldn't be prepared${d.error ? `: ${d.error}` : '.'}` };
203
258
  return null;
204
259
  }
260
+
261
+ // ---- the button: wherever it is put --------------------------------------------
262
+ export const FAB = 52;
263
+ const TAP_SLOP = 8; // a press that moved less than this is a tap, not a drag
264
+
265
+ /** Where the button sits by default: bottom-right, clear of the insets. */
266
+ export const fabHome = (screen, insets) => ({ x: screen.width - FAB - 16, y: screen.height - FAB - 16 - insets.bottom });
267
+
268
+ /** A position kept on screen: the whole button visible, off the status bar. */
269
+ export function clampFab(pos, screen, insets) {
270
+ return {
271
+ x: Math.min(screen.width - FAB - 4, Math.max(4, pos.x)),
272
+ y: Math.min(screen.height - FAB - 4, Math.max(insets.top, pos.y)),
273
+ };
274
+ }
275
+
276
+ /** Did the finger travel far enough to be a drag? */
277
+ export const isDrag = (dx, dy) => Math.hypot(dx, dy) >= TAP_SLOP;
278
+
279
+ // ---- πŸŽ™ Talk (functions/voice-chat.mjs, voice-agent/src/chat.mjs) --------------
280
+ export const SNAPSHOT_TOPIC = 'ssb.guide.snapshot'; // the agent's (voice-agent/src/guide.mjs TOPIC.snapshot)
281
+
282
+ /** What the agent is sent about the screen: the route, the picture when there is one, and whether this is the opening look. */
283
+ export const snapshotOf = ({ route, screenshot, first = false }) => JSON.stringify({ route: route || '/', ...(screenshot ? { screenshot } : {}), ...(first ? { first: true } : {}) });
284
+
285
+ /** The talk bar's words. */
286
+ export function talkLine(talk) {
287
+ if (!talk) return '';
288
+ if (talk.status === 'connecting') return 'Connecting to SSB…';
289
+ if (talk.status === 'waiting') return 'Waiting for SSB to join…';
290
+ return talk.muted ? 'Talking with SSB Β· muted' : 'Talking with SSB';
291
+ }
292
+
293
+ // ---- the panel's top: the work (2026-10-20, the product owner: "the web
294
+ // extension has jobs and in-progress things at the top, with chat down the
295
+ // bottom … maybe we can move to that pattern") ------------------------------
296
+ /** The extension's words for a job in flight (extension/overlay.js inFlightHtml). */
297
+ export const STATE_WORD = Object.freeze({ ready: 'Ready', queued: 'Queued', building: 'Building', checking: 'Checking', blocked: 'Blocked' });
298
+
299
+ /**
300
+ * What the top of the panel shows: the app's work in flight (GET
301
+ * /api/in-app/work, functions/sessions.mjs inFlightOf β€” whatever chat it came
302
+ * from), this chat's drafts waiting to start, and what this chat started that
303
+ * is not in flight any more (the row says so, with its job).
304
+ */
305
+ export function workView({ inFlight = [], chat = null } = {}) {
306
+ const flying = Array.isArray(inFlight) ? inFlight : [];
307
+ const flyingIds = new Set(flying.map((x) => x.id));
308
+ const { waiting, sent } = draftsOf(chat);
309
+ return { flying, waiting, sent: sent.filter((p) => !p.jobId || !flyingIds.has(p.jobId)) };
310
+ }