@simon-says-build/ssb-overlay 0.9.4 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simon-says-build/ssb-overlay",
3
- "version": "0.9.4",
3
+ "version": "0.10.1",
4
4
  "description": "SSB inside a native app SimonSays controls: the SSB button for the people who change the app (screenshot, mark, ask for a change), and Help for everyone who uses it.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/SsbHost.js CHANGED
@@ -19,6 +19,14 @@
19
19
  // out from under the drawing. (It was **Mark**, one pin or one box, until 0.2.0.) SSB replies on the thread and proposes jobs; the drafts bar
20
20
  // starts them. The same conversation is on the console.
21
21
  //
22
+ // THE SESSIONS INDEX (2026-10-28, Simon: "in the native extension in an SSB
23
+ // mobile app for the first time, if you have existing sessions that haven't
24
+ // been released, we should show them along with their status … select one
25
+ // to resume it, or create a new session … drop the session dropdown picker …
26
+ // Instead we just have a back button"). The first open of this run, with
27
+ // open sessions of yours on this app (GET /api/in-app/sessions), is the
28
+ // index; a session's ‹ comes back to it; + New session lives there.
29
+ //
22
30
  // No JSX, so it needs nothing from the app's Babel setup beyond React Native's.
23
31
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
24
32
  import {
@@ -32,7 +40,7 @@ import { playDemo, PLAYER_VERSION } from '@simon-says-build/demo-player';
32
40
  import BottomSheet, { BottomSheetBackdrop, BottomSheetFooter, BottomSheetScrollView, BottomSheetTextInput } from '@gorhom/bottom-sheet';
33
41
  import { GestureHandlerRootView, ScrollView } from 'react-native-gesture-handler';
34
42
  import {
35
- FAB, addPoint, clampFab, demoBanner, fabHome, isDrag, describeHost, draftsOf, fieldTop, inAppApi, namedHosts, newReply, offerOf,
43
+ FAB, addPoint, clampFab, demoBanner, openSessionsOf, sessionRow, fabHome, isDrag, describeHost, draftsOf, fieldTop, inAppApi, namedHosts, newReply, offerOf,
36
44
  STATE_WORD, workSummary, boxItem, bundleOriginOf, clientInfo, elsewhereLine, workView, drawingBox, drawingMark, drawingPoint, itemRect, overlayConfig, pollEvery, SNAPSHOT_TOPIC, snapshotOf, talkLine, segmentsOf, sendBody, smallestAt, ssbLinkOf,
37
45
  } from './core.js';
38
46
 
@@ -166,6 +174,9 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
166
174
  const [busy, setBusy] = useState(false);
167
175
  const [error, setError] = useState(null);
168
176
  const [work, setWork] = useState([]); // the app's work in flight (GET /api/in-app/work)
177
+ const [sessions, setSessions] = useState(null); // the open sessions of yours here (GET /api/in-app/sessions)
178
+ const [index, setIndex] = useState(false); // the sheet shows the sessions index, not a session
179
+ const firstOpen = useRef(true); // this run's first open: the index, when there is one
169
180
  const [seen, setSeen] = useState(null);
170
181
  const [kbTop, setKbTop] = useState(screen.height);
171
182
  const kbUpRef = useRef(null);
@@ -254,6 +265,24 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
254
265
  return () => clearInterval(t);
255
266
  }, [gate, chatId, open, chat?.thinking, load, !!talk]);
256
267
 
268
+ // The sessions index. Fails open: no list, no index — the sheet is the
269
+ // conversation, as before there was one.
270
+ const loadSessions = useCallback(async () => {
271
+ try { const r = await ssb.sessions(); const list = Array.isArray(r?.sessions) ? r.sessions : []; setSessions(list); return list; }
272
+ catch { return null; }
273
+ }, [ssb]);
274
+ useEffect(() => {
275
+ if (mode !== 'sheet' || !index || !gate) return undefined;
276
+ const t = setInterval(loadSessions, 15000);
277
+ return () => clearInterval(t);
278
+ }, [mode, index, gate, loadSessions]);
279
+ const pickSession = useCallback((id) => {
280
+ if (id !== chatId) { setChat(null); setWork([]); rememberChat(id); }
281
+ setIndex(false);
282
+ }, [chatId, rememberChat]);
283
+ const newSession = useCallback(() => { rememberChat(null); setChat(null); setWork([]); setIndex(false); }, [rememberChat]);
284
+ const toIndex = useCallback(() => { Keyboard.dismiss(); setIndex(true); loadSessions(); }, [loadSessions]);
285
+
257
286
  const lastReplyId = (c) => [...(c?.messages || [])].reverse().find((m) => m.role !== 'user' && !m.streaming)?.id ?? null;
258
287
  const [seenDemo, setSeenDemo] = useState(null);
259
288
  const unread = !open && chat && (newReply(chat, seen) || (chat.demo?.status === 'ready' && chat.demo.url !== seenDemo));
@@ -268,10 +297,16 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
268
297
  const openSheet = useCallback(async () => {
269
298
  setError(null);
270
299
  setMode('capturing');
300
+ // The first open asks for the sessions while the screen is taken, at most
301
+ // a few seconds: an answer that never comes is no index.
302
+ const first = firstOpen.current;
303
+ firstOpen.current = false;
304
+ const listed = first ? Promise.race([loadSessions(), new Promise((r) => setTimeout(() => r(null), 4000))]) : null;
271
305
  const pic = await grab();
272
306
  setShot(pic);
307
+ if (listed && openSessionsOf(await listed).length) setIndex(true);
273
308
  setMode('sheet');
274
- }, [grab]);
309
+ }, [grab, loadSessions]);
275
310
 
276
311
  // A demo opens IN the app (or the dev client): SSB steps aside first, or the
277
312
  // full-screen panel would sit on top of what it opened.
@@ -535,7 +570,7 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
535
570
  onDemo: typeof playDemo === 'function' ? startDemo : null,
536
571
  onSend: sendDraft, onDraw: startDrawing, onClose: close,
537
572
  canTalk: !!voice?.Room, talk, onTalk: startTalk, onEndTalk: endTalk, onMute: toggleMute,
538
- onNew: () => { rememberChat(null); setChat(null); }, onDecide: decide, onStartAll: startAll, onOpenLink: openLink,
573
+ index, sessions, chatId, onBack: toIndex, onPick: pickSession, onNew: newSession, onDecide: decide, onStartAll: startAll, onOpenLink: openLink,
539
574
  });
540
575
  return null;
541
576
  })();
@@ -561,7 +596,7 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
561
596
  const SNAPS = ['50%', '100%'];
562
597
  const COMPACT = 0, FULL = 1;
563
598
 
564
- function Panel({ gate, chat, work, elsewhere, onDemo, screen, insets, draft, setDraft, busy, error, onSend, onDraw, onClose, onNew, onDecide, onStartAll, onOpenLink, canTalk, talk, onTalk, onEndTalk, onMute }) {
599
+ function Panel({ gate, chat, work, elsewhere, onDemo, screen, insets, draft, setDraft, busy, error, onSend, onDraw, onClose, index, sessions, chatId, onBack, onPick, onNew, onDecide, onStartAll, onOpenLink, canTalk, talk, onTalk, onEndTalk, onMute }) {
565
600
  const sheet = useRef(null);
566
601
  const scroll = useRef(null);
567
602
  const [at, setAt] = useState(COMPACT);
@@ -642,6 +677,26 @@ function Panel({ gate, chat, work, elsewhere, onDemo, screen, insets, draft, set
642
677
  h(Text, { style: s.workTitle, numberOfLines: 2 }, `✓ ${p.title}`),
643
678
  p.jobUrl ? h(Pressable, { onPress: () => Linking.openURL(p.jobUrl).catch(() => {}), accessibilityRole: 'link' }, h(Text, { style: s.link }, 'Started →')) : h(Text, { style: s.state }, 'Started'))));
644
679
 
680
+ // THE SESSIONS INDEX: + New session, then your open sessions, each with
681
+ // where it is and what is new in it. A tap resumes it.
682
+ const listed = openSessionsOf(sessions);
683
+ const othersNews = listed.some((x) => x.id !== chatId && x.updates?.any);
684
+ const indexList = index ? h(BottomSheetScrollView, { style: s.thread, testID: 'ssb-sessions', contentContainerStyle: { paddingTop: 12, paddingBottom: 24 } },
685
+ h(Pressable, { testID: 'ssb-new-session', onPress: onNew, accessibilityRole: 'button', accessibilityLabel: 'New session', style: [s.primary, s.newSession] }, h(Text, { style: s.primaryText }, '+ New session')),
686
+ !sessions ? h(ActivityIndicator, { size: 'small', color: MUTED, style: { marginTop: 16 } })
687
+ : !listed.length ? h(Text, { style: s.empty }, 'No open sessions of yours here.') : null,
688
+ ...listed.map((x) => {
689
+ const r = sessionRow(x);
690
+ return h(Pressable, { key: r.id, testID: `ssb-session-${r.id}`, onPress: () => onPick(r.id), accessibilityRole: 'button', accessibilityLabel: `Open the session: ${r.title}`, style: [s.sessRow, r.id === chatId && s.sessOn] },
691
+ h(View, { style: s.workLine },
692
+ r.any ? h(View, { style: s.sessDot }) : null,
693
+ h(Text, { style: s.workTitle, numberOfLines: 2 }, r.title),
694
+ h(Text, { style: s.sessAgo }, r.ago)),
695
+ r.pills.length || r.news.length ? h(View, { style: s.sessChips },
696
+ ...r.pills.map((p, i) => h(Text, { key: `p${i}`, style: [s.state, s[`sess_${p.state}`]], testID: i ? `ssb-session-state-${r.id}-${i}` : `ssb-session-state-${r.id}` }, p.word)),
697
+ ...r.news.map((n) => h(Text, { key: n, style: s.sessNews }, n))) : null);
698
+ })) : null;
699
+
645
700
  // Half open: the work is one line, and a ready demo keeps its button —
646
701
  // it is the thing most worth a tap.
647
702
  const compactWork = (summary || banner) ? h(Pressable, { testID: 'ssb-work-summary', onPress: () => snap(FULL), accessibilityRole: 'button', accessibilityLabel: `Show the work: ${summary || banner.text}`, style: s.summary },
@@ -653,22 +708,25 @@ function Panel({ gate, chat, work, elsewhere, onDemo, screen, insets, draft, set
653
708
  h(ComposerCtx.Provider, { value: composer }, h(BottomSheet, {
654
709
  ref: sheet, index: COMPACT, snapPoints: SNAPS, enableDynamicSizing: false, enablePanDownToClose: true,
655
710
  topInset: insets.top, onChange: (i) => { if (i >= 0) setAt(i); }, onClose,
656
- backdropComponent: backdrop, footerComponent: footer,
711
+ backdropComponent: backdrop, footerComponent: index ? undefined : footer,
657
712
  keyboardBehavior: 'interactive', keyboardBlurBehavior: 'restore', android_keyboardInputMode: 'adjustResize',
658
713
  backgroundStyle: s.sheetBg, handleIndicatorStyle: s.grabber,
659
714
  },
660
715
  h(View, { style: { flex: 1 }, testID: 'ssb-panel' },
661
716
  h(View, { style: s.header },
662
- h(View, { style: s.headLogoRing }, h(Image, { source: LOGO, style: s.headLogo, accessibilityLabel: 'SSB' })),
717
+ // In a session: ‹ back to the index (a dot when another session has
718
+ // news), then the session's name. On the index: SSB's mark.
719
+ index ? h(View, { style: s.headLogoRing }, h(Image, { source: LOGO, style: s.headLogo, accessibilityLabel: 'SSB' }))
720
+ : h(Pressable, { testID: 'ssb-back', onPress: onBack, accessibilityRole: 'button', accessibilityLabel: 'Back to your sessions', style: s.iconBtn },
721
+ h(Text, { style: s.backText }, '‹'), othersNews ? h(View, { style: s.backDot }) : null),
663
722
  h(View, { style: { flex: 1 } },
664
- h(Text, { style: s.title }, 'Tell SSB'),
723
+ h(Text, { style: s.title, numberOfLines: 1, testID: 'ssb-title' }, index ? 'Your sessions' : (chatId && chat?.title) || (chatId ? 'This session' : 'New session')),
665
724
  h(Text, { style: s.sub, numberOfLines: 1 }, `${gate.app?.name || 'this app'} · as ${gate.person?.label || 'you'}`)),
666
- chat ? h(Pressable, { onPress: onNew, accessibilityRole: 'button', accessibilityLabel: 'New conversation', style: s.quiet }, h(Text, { style: s.quietText }, 'New')) : null,
667
725
  h(Pressable, { testID: full ? 'ssb-collapse' : 'ssb-expand', onPress: () => snap(full ? COMPACT : FULL), accessibilityRole: 'button', accessibilityLabel: full ? 'Make SSB smaller' : 'Open SSB full screen', style: s.iconBtn }, h(Text, { style: s.iconText }, full ? '⌄' : '⌃')),
668
726
  h(Pressable, { testID: 'ssb-close', onPress: () => sheet.current?.close(), accessibilityRole: 'button', accessibilityLabel: 'Close SSB', style: s.iconBtn }, h(Text, { style: s.iconText }, '✕'))),
669
- full ? (hasWork ? workList : null) : compactWork,
727
+ index ? indexList : full ? (hasWork ? workList : null) : compactWork,
670
728
  // THE CONVERSATION, below; the footer (the composer) floats over its end.
671
- h(BottomSheetScrollView, { ref: scroll, style: s.thread, contentContainerStyle: { paddingTop: 8, paddingBottom: footH + 8 }, keyboardShouldPersistTaps: 'handled', onContentSizeChange: () => scroll.current?.scrollToEnd({ animated: true }) },
729
+ index ? null : h(BottomSheetScrollView, { ref: scroll, style: s.thread, contentContainerStyle: { paddingTop: 8, paddingBottom: footH + 8 }, keyboardShouldPersistTaps: 'handled', onContentSizeChange: () => scroll.current?.scrollToEnd({ animated: true }) },
672
730
  msgs.length ? null : h(Text, { style: s.empty }, 'Say what should change on this screen, or tap Draw to scribble on it.'),
673
731
  ...msgs.map((m) => h(View, { key: m.id, style: [s.bubble, m.role === 'user' ? s.mine : s.theirs] },
674
732
  // The marked part as the preview; a tap shows the full screen
@@ -771,6 +829,20 @@ const s = StyleSheet.create({
771
829
  quietText: { color: MUTED, fontSize: 14, fontWeight: '600' },
772
830
  iconBtn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: 18, backgroundColor: RAISED },
773
831
  iconText: { color: TEXT, fontSize: 16 },
832
+ // The sessions index, and ‹ back to it.
833
+ backText: { color: TEXT, fontSize: 26, lineHeight: 28, fontWeight: '600', marginTop: -2 },
834
+ backDot: { position: 'absolute', top: 4, right: 4, width: 9, height: 9, borderRadius: 5, backgroundColor: YELLOW, borderWidth: 1.5, borderColor: BG },
835
+ newSession: { alignSelf: 'stretch', marginHorizontal: 4, marginBottom: 10, paddingVertical: 12 },
836
+ sessRow: { paddingHorizontal: 12, paddingVertical: 11, marginHorizontal: 4, marginVertical: 3, borderRadius: 14, borderWidth: 1, borderColor: LINE, backgroundColor: SURFACE },
837
+ sessOn: { borderColor: 'rgba(61,123,255,0.6)' },
838
+ sessDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: YELLOW, ...glow(YELLOW, 4) },
839
+ sessAgo: { fontSize: 12, color: MUTED },
840
+ sessChips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8 },
841
+ sessNews: { fontSize: 12, fontWeight: '700', color: YELLOW, paddingVertical: 4, paddingHorizontal: 9, borderRadius: 9, borderWidth: 1, borderColor: 'rgba(246,197,68,0.4)', overflow: 'hidden' },
842
+ sess_ready: { color: BG, backgroundColor: GREEN, borderColor: GREEN },
843
+ 'sess_needs-you': { color: BG, backgroundColor: YELLOW, borderColor: YELLOW },
844
+ sess_building: { color: '#9cc0ff', backgroundColor: 'rgba(61,123,255,0.14)', borderColor: 'rgba(61,123,255,0.5)' },
845
+ sess_preparing: { color: '#9cc0ff', backgroundColor: 'rgba(61,123,255,0.14)', borderColor: 'rgba(61,123,255,0.5)' },
774
846
  // Voice mode: a pill over the app.
775
847
  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 },
776
848
  pillText: { flex: 1, color: TEXT, fontSize: 14, fontWeight: '600' },
package/src/core.js CHANGED
@@ -5,7 +5,7 @@
5
5
  export const DEFAULT_API = 'https://simonsays.build';
6
6
  // What this overlay is, said when it registers for pushes (clientInfo below).
7
7
  // Kept equal to package.json by test/core.test.mjs.
8
- export const OVERLAY_VERSION = '0.9.4';
8
+ export const OVERLAY_VERSION = '0.10.1';
9
9
  export const HOST = 5;
10
10
  export const TEXT = 6;
11
11
  const PIN_SLOP = 12; // a drag shorter than this is a tap: a pin, not a box
@@ -43,6 +43,8 @@ export function inAppApi(config, getToken, fetchImpl = globalThis.fetch) {
43
43
  me: () => call('/me'),
44
44
  send: (body) => post('/annotations', body),
45
45
  chat: (id) => call(`/chats/${encodeURIComponent(id)}`),
46
+ // The sessions index: the OPEN sessions of this app the person is in.
47
+ sessions: () => call('/sessions'),
46
48
  decide: (chatId, proposalId, action, body) => post(`/chats/${encodeURIComponent(chatId)}/proposals/${encodeURIComponent(proposalId)}/${action}`, body),
47
49
  // `client`: what this build is (clientInfo), so SSB knows what is installed.
48
50
  pushToken: (token, platform, client) => post('/push-token', { token, platform, ...(client ? { client } : {}) }),
@@ -301,6 +303,51 @@ export function talkLine(talk) {
301
303
  return talk.muted ? 'Talking with SSB · muted' : 'Talking with SSB';
302
304
  }
303
305
 
306
+ // ---- the sessions index (2026-10-28) ------------------------------------------
307
+ // Simon: "When you open the extension in a new tab, or in the native
308
+ // extension in an SSB mobile app for the first time, if you have existing
309
+ // sessions that haven't been released, we should show them along with their
310
+ // status in a 'sessions index' screen. Then you have the option to select
311
+ // one to resume it, or create a new session." GET /api/in-app/sessions
312
+ // (functions/sessions.mjs listSessions, `inApp`) lists only those already;
313
+ // the extension's card shows the same rows (extension/overlay.js sessionsHtml).
314
+
315
+ /** Where a session is, in the console's sessions page's words (schemas/session-card.mjs states). */
316
+ export const SESSION_STATE_WORD = Object.freeze({
317
+ ready: 'Ready for Guided Review', 'needs-you': 'Needs you', preparing: 'Recording the Guided Review',
318
+ building: 'Building', 'in-review': 'All in', idle: 'Just started',
319
+ });
320
+
321
+ /** Sessions worth an index: yours, not released. */
322
+ export const openSessionsOf = (list) => (Array.isArray(list) ? list : []).filter((x) => x && x.mine !== false && !x.done);
323
+
324
+ /**
325
+ * One row of the index: its name, where it is, and what is new in it for you.
326
+ * `pills`: where it is — one per app when it spans several (2026-10-28, per
327
+ * platform: web's Guided Review can be ready while the phone's work builds).
328
+ */
329
+ export function sessionRow(x, now = Date.now()) {
330
+ const u = x?.updates || {};
331
+ const st = x?.status?.state || null;
332
+ const word = SESSION_STATE_WORD[st] || null;
333
+ const inOf = (s, a) => (s === 'building' && a?.total ? ` · ${a.landed} of ${a.total} in` : '');
334
+ const count = inOf(st, x?.status);
335
+ const apps = Array.isArray(x?.status?.apps) ? x.status.apps.filter((a) => SESSION_STATE_WORD[a.state]) : [];
336
+ const pills = apps.length > 1
337
+ ? apps.map((a) => ({ state: a.state, word: `${a.name || 'An app'}: ${SESSION_STATE_WORD[a.state]}${inOf(a.state, a)}` }))
338
+ : word ? [{ state: st, word: `${word}${count}` }] : [];
339
+ const news = [
340
+ u.replied ? 'SSB replied' : null,
341
+ u.waiting ? `${u.waiting} draft${u.waiting === 1 ? '' : 's'} waiting` : null,
342
+ u.live ? `${u.live} live` : null,
343
+ ].filter(Boolean);
344
+ return { id: x.id, title: x.title || x.preview || 'A session', state: st, stateWord: word ? `${word}${count}` : null, pills, news, any: !!u.any, ago: agoOf(x.lastMessageAt, now) };
345
+ }
346
+ export function agoOf(iso, now = Date.now()) {
347
+ const s = (now - Date.parse(iso || '')) / 1000;
348
+ return !Number.isFinite(s) ? '' : s < 90 ? 'just now' : s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago`;
349
+ }
350
+
304
351
  // ---- the panel's top: the work (2026-10-20, the product owner: "the web
305
352
  // extension has jobs and in-progress things at the top, with chat down the
306
353
  // bottom … maybe we can move to that pattern") ------------------------------