@simon-says-build/ssb-overlay 0.7.0 → 0.9.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.
package/README.md CHANGED
@@ -26,4 +26,31 @@ import { captureScreen } from "react-native-view-shot";
26
26
  Nothing secret goes in the bundle: the app's own session token is checked by
27
27
  SSB against the app's backend (functions/in-app.mjs).
28
28
 
29
+ SSB opens as a bottom sheet (0.9.0): half way up with the chat, full screen
30
+ with the work at the top. It needs `@gorhom/bottom-sheet` 5,
31
+ `react-native-reanimated`, `react-native-worklets` and
32
+ `react-native-gesture-handler` in the app (`npx expo install` them; the
33
+ installer does).
34
+
35
+ ## Help, for everyone who uses the app (0.8.0)
36
+
37
+ `<SsbHelp>` is the web Help widget's native twin (docs/HELPDESK.md): ANY
38
+ signed-in user of the app asks how it works; SSB answers, and hands it to a
39
+ person on the organization's help desk when it cannot help. It shows only
40
+ where the organization switched its help desk on for the app.
41
+
42
+ ```tsx
43
+ import { SsbHelp, SsbHelpChat, useSsbHelp } from "@simon-says-build/ssb-overlay";
44
+
45
+ <SsbHelp appId="impulse-native" backend="firebase:impulse-mode"
46
+ getToken={() => auth.currentUser?.getIdToken() ?? null} sessionKey={user?.uid}
47
+ route={usePathname()} bubble={showHelpEverywhere}>
48
+ {/* the app */}
49
+ </SsbHelp>
50
+
51
+ // Where Help belongs in the app: `useSsbHelp().show` / `.unread`, a screen
52
+ // rendering <SsbHelpChat onClose={router.back} />, and `bubble` for a Help
53
+ // button on every screen (the person's own switch, kept by the app).
54
+ ```
55
+
29
56
  `npm test` runs the pure half.
package/index.d.ts CHANGED
@@ -55,3 +55,34 @@ export function SsbHost(props: {
55
55
  bundle?: string;
56
56
  children?: ReactNode;
57
57
  }): ReactNode;
58
+
59
+ /**
60
+ * Help for the app's OWN users (0.8.0, docs/HELPDESK.md): the web Help
61
+ * widget's native twin, on the same API and desk. Wrap the app's root; it
62
+ * renders `children` unchanged unless the help API lets this login ask (any
63
+ * login the app's backend verifies, where the organization switched its help
64
+ * desk on). Put Help where it belongs with `useSsbHelp()` and `<SsbHelpChat>`;
65
+ * `bubble` floats it on every screen.
66
+ */
67
+ export function SsbHelp(props: {
68
+ appId?: string;
69
+ /** Where the app signs in: its Supabase URL, or `firebase:<projectId>`. */
70
+ backend?: string;
71
+ api?: string;
72
+ getToken: () => Promise<string | null | undefined>;
73
+ sessionKey?: string | null;
74
+ /** The current route: sent with each question, so the answer knows the screen. */
75
+ route?: string;
76
+ /** Float a Help button on every screen (the person's own switch). Default false. */
77
+ bubble?: boolean;
78
+ /** Keep the bubble above the tab bar. Default {bottom: 100}. */
79
+ insets?: { bottom?: number };
80
+ colors?: { accent?: string; background?: string; text?: string; muted?: string; bubble?: string; team?: string; line?: string };
81
+ children?: ReactNode;
82
+ }): ReactNode;
83
+
84
+ /** The Help conversation as a screen of the app's own. `onClose` shows a close button. */
85
+ export function SsbHelpChat(props: { onClose?: () => void; title?: string }): ReactNode;
86
+
87
+ /** Whether this login may ask Help, whether a reply waits unread, and open it full screen. */
88
+ export function useSsbHelp(): { show: boolean; unread: boolean; open: () => void };
package/index.js CHANGED
@@ -1,2 +1,4 @@
1
1
  export { SsbHost } from './src/SsbHost.js';
2
+ export { SsbHelp, SsbHelpChat, useSsbHelp } from './src/SsbHelp.js';
3
+ export { helpConfig, helpApi } from './src/help.js';
2
4
  export { overlayConfig, inAppApi, drawingMark, sendBody, ssbLinkOf } from './src/core.js';
@@ -18,8 +18,12 @@ const read = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return
18
18
  const json = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
19
19
  const exists = (p) => fs.existsSync(p);
20
20
 
21
- /** The packages an install adds (react-native-view-shot through `expo install`, so its version fits the SDK). */
22
- export const PACKAGES = ['@simon-says-build/ssb-overlay', '@simon-says-build/demo-player', 'react-native-view-shot'];
21
+ /** The packages an install adds, through `expo install` so native versions fit the SDK:
22
+ * react-native-view-shot for the screen, and the bottom sheet SSB opens in
23
+ * (@gorhom/bottom-sheet with Reanimated, worklets and gesture-handler, 2026-10-22)
24
+ * — an app that has them keeps its own versions. */
25
+ export const PACKAGES = ['@simon-says-build/ssb-overlay', '@simon-says-build/demo-player', 'react-native-view-shot',
26
+ '@gorhom/bottom-sheet', 'react-native-reanimated', 'react-native-worklets', 'react-native-gesture-handler'];
23
27
 
24
28
  // ---- what the app is ----------------------------------------------------------
25
29
  /** Every fact the install decides by, read off the repo. Pure apart from reading files. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@simon-says-build/ssb-overlay",
3
- "version": "0.7.0",
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.",
3
+ "version": "0.9.0",
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",
7
7
  "url": "git+https://github.com/simon-says-studio/simon-says-build.git",
@@ -22,9 +22,12 @@
22
22
  "README.md"
23
23
  ],
24
24
  "peerDependencies": {
25
+ "@gorhom/bottom-sheet": "^5.2.0",
25
26
  "@simon-says-build/demo-player": ">=0.1.0",
26
27
  "react": ">=18",
27
- "react-native": ">=0.76"
28
+ "react-native": ">=0.76",
29
+ "react-native-gesture-handler": ">=2.16",
30
+ "react-native-reanimated": ">=3.16"
28
31
  },
29
32
  "scripts": {
30
33
  "test": "node --test test/*.test.mjs"
package/src/SsbHelp.js ADDED
@@ -0,0 +1,165 @@
1
+ // <SsbHelp>: Help for an app's OWN users, inside a native app — the web Help
2
+ // widget's twin (web/public/help/widget.js, docs/HELPDESK.md), on the same API
3
+ // and the same desk. Not <SsbHost>: that is SSB for the people who change the
4
+ // app; this is for everyone who uses it (docs/TWO-ELEMENTS.md).
5
+ //
6
+ // Wrap the app's root in <SsbHelp>. It renders its children and nothing else
7
+ // until the help API says this login may ask (`show`). Then:
8
+ // - `useSsbHelp()` gives the app `{ show, unread, open }`, to put Help where
9
+ // it belongs in the app (Impulse: the top of the chats list);
10
+ // - <SsbHelpChat /> is the conversation as a screen of the app's own;
11
+ // - `bubble` floats a Help button on every screen, opening the same
12
+ // conversation full screen — the app decides when (a switch the person sets).
13
+ //
14
+ // One conversation, held here, so the bubble and a screen show the same thread.
15
+ // No JSX, so it needs nothing from the app's Babel setup beyond React Native's.
16
+ import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
17
+ import {
18
+ ActivityIndicator, KeyboardAvoidingView, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View,
19
+ } from 'react-native';
20
+ import { canAskPerson, helpApi, helpConfig, helpPollEvery, helpSendBody, helpUnread, helpWho, ticketLine } from './help.js';
21
+
22
+ const h = React.createElement;
23
+ const DEFAULT_COLORS = { accent: '#2f5bea', background: '#ffffff', text: '#1f2430', muted: '#6b7280', bubble: '#f3f4f6', team: '#fef3c7', line: '#e5e7eb' };
24
+ const DEFAULT_INSETS = { bottom: 100 };
25
+ const Ctx = createContext({ show: false, unread: false, open: () => {} });
26
+
27
+ /** `{ show, unread, open }`: whether this login may ask, a reply waiting, and open the conversation full screen. */
28
+ export const useSsbHelp = () => useContext(Ctx);
29
+
30
+ export function SsbHelp({ appId, backend, api: apiOrigin, getToken, sessionKey, route, bubble = false, insets, colors, children }) {
31
+ const config = useMemo(() => helpConfig({ appId, backend, api: apiOrigin }), [appId, backend, apiOrigin]);
32
+ const tokenRef = useRef(getToken);
33
+ tokenRef.current = getToken;
34
+ const api = useMemo(() => (config ? helpApi(config, () => tokenRef.current?.()) : null), [config]);
35
+ const [me, setMe] = useState(null);
36
+ const [thread, setThread] = useState({ messages: [], thinking: false, ticket: null });
37
+ const [screens, setScreens] = useState(0); // <SsbHelpChat>s mounted: the conversation is open
38
+ const [modal, setModal] = useState(false);
39
+ const [seen, setSeen] = useState(0);
40
+ const open = screens > 0 || modal;
41
+ const C = { ...DEFAULT_COLORS, ...(colors || {}) };
42
+
43
+ // May this login ask? Again whenever the login changes.
44
+ useEffect(() => {
45
+ let live = true;
46
+ setMe(null);
47
+ if (!api) return undefined;
48
+ api.me().then((r) => { if (live) setMe(r?.show ? r : null); }).catch(() => { if (live) setMe(null); });
49
+ return () => { live = false; };
50
+ }, [api, sessionKey]);
51
+
52
+ const read = useCallback(async () => {
53
+ if (!api || !me?.exists || !me.chatId) return;
54
+ try {
55
+ const r = await api.chat(me.chatId);
56
+ setThread({ messages: r.messages || [], thinking: !!r.thinking, ticket: r.ticket || null });
57
+ } catch { /* the next poll */ }
58
+ }, [api, me]);
59
+ useEffect(() => {
60
+ if (!me) return undefined;
61
+ read();
62
+ const t = setInterval(read, helpPollEvery({ open, thinking: thread.thinking }));
63
+ return () => clearInterval(t);
64
+ }, [me, open, thread.thinking, read]);
65
+ useEffect(() => { if (open) setSeen(thread.messages.length); }, [open, thread.messages.length]);
66
+
67
+ const send = useCallback(async (text) => {
68
+ const r = await api.send(helpSendBody({ text, route, me }));
69
+ setMe((m) => ({ ...m, chatId: r.chatId, exists: true }));
70
+ setThread((t) => ({ messages: [...t.messages, { id: `local-${Date.now()}`, role: 'user', text: String(text).trim() }], thinking: !r.ticket, ticket: r.ticket || t.ticket }));
71
+ }, [api, route, me]);
72
+ const askPerson = useCallback(async () => {
73
+ const r = await api.escalate(me.chatId);
74
+ setThread((t) => ({ ...t, ticket: r.ticket || t.ticket }));
75
+ read();
76
+ }, [api, me, read]);
77
+
78
+ const value = useMemo(() => ({
79
+ show: !!me, unread: !open && helpUnread(thread.messages, seen), open: () => setModal(true),
80
+ me, thread, send, askPerson, colors: C, mount: () => { setScreens((n) => n + 1); return () => setScreens((n) => n - 1); },
81
+ }), [me, open, thread, seen, send, askPerson, C.accent]); // eslint-disable-line react-hooks/exhaustive-deps
82
+
83
+ const bottom = (insets || DEFAULT_INSETS).bottom ?? DEFAULT_INSETS.bottom;
84
+ return h(Ctx.Provider, { value },
85
+ children,
86
+ me && bubble && !open ? h(Pressable, {
87
+ testID: 'ssb-help-bubble', accessibilityRole: 'button', accessibilityLabel: 'Help',
88
+ onPress: () => setModal(true), style: [s.bubble, { bottom, backgroundColor: C.accent }],
89
+ }, h(Text, { style: s.bubbleText }, '?'), value.unread ? h(View, { style: s.dot, testID: 'ssb-help-unread' }) : null) : null,
90
+ me ? h(Modal, { visible: modal, animationType: 'slide', presentationStyle: 'pageSheet', onRequestClose: () => setModal(false) },
91
+ h(SsbHelpChat, { onClose: () => setModal(false) })) : null,
92
+ );
93
+ }
94
+
95
+ /** The conversation, as a screen of the app's own (or the bubble's sheet). `onClose` shows a close button. */
96
+ export function SsbHelpChat({ onClose, title }) {
97
+ const ctx = useContext(Ctx);
98
+ const { me, thread, send, askPerson, colors: C = DEFAULT_COLORS, mount } = ctx;
99
+ const [draft, setDraft] = useState('');
100
+ const [sending, setSending] = useState(false);
101
+ const [error, setError] = useState('');
102
+ const scroll = useRef(null);
103
+ useEffect(() => (mount ? mount() : undefined), []); // eslint-disable-line react-hooks/exhaustive-deps
104
+ if (!me) return h(View, { style: [s.empty, { backgroundColor: C.background }] }, h(ActivityIndicator, null));
105
+ const msgs = thread.messages || [];
106
+ const line = ticketLine(thread.ticket || me.ticket);
107
+ const go = async () => {
108
+ const text = draft.trim();
109
+ if (!text || sending) return;
110
+ setSending(true); setError('');
111
+ try { await send(text); setDraft(''); } catch (e) { setError(e.message); }
112
+ setSending(false);
113
+ };
114
+ const person = async () => { setError(''); try { await askPerson(); } catch (e) { setError(e.message); } };
115
+ const first = (me.requester?.label || '').split(/[ @]/)[0];
116
+ return h(KeyboardAvoidingView, { style: [s.root, { backgroundColor: C.background }], behavior: Platform.OS === 'ios' ? 'padding' : undefined, testID: 'ssb-help-chat' },
117
+ h(View, { style: [s.header, { borderBottomColor: C.line }] },
118
+ h(Text, { style: [s.title, { color: C.text }] }, title || `Help${me.app?.name ? ` · ${me.app.name}` : ''}`),
119
+ onClose ? h(Pressable, { onPress: onClose, accessibilityLabel: 'Close', testID: 'ssb-help-close', hitSlop: 12 }, h(Text, { style: [s.close, { color: C.muted }] }, '✕')) : null),
120
+ line ? h(Text, { style: [s.ticket, { color: C.accent, borderBottomColor: C.line }], testID: 'ssb-help-ticket' }, line) : null,
121
+ h(ScrollView, { ref: scroll, style: s.body, contentContainerStyle: s.bodyIn, onContentSizeChange: () => scroll.current?.scrollToEnd?.({ animated: true }) },
122
+ msgs.length ? null : h(Text, { style: [s.msg, s.left, { backgroundColor: C.bubble, color: C.text }] },
123
+ `Hi${first ? ` ${first}` : ''} — ask anything about ${me.app?.name || 'the app'}. If I can't help, I'll get you a person.`),
124
+ ...msgs.map((m) => (m.role === 'event'
125
+ ? h(Text, { key: m.id, style: [s.event, { color: C.muted }] }, m.text)
126
+ : h(View, { key: m.id, style: m.role === 'user' ? s.rightWrap : s.leftWrap },
127
+ h(Text, { style: [s.who, { color: C.muted }] }, helpWho(m)),
128
+ h(Text, { style: [s.msg, m.role === 'user' ? { backgroundColor: C.accent, color: '#fff' } : { backgroundColor: m.role === 'agent' ? C.team : C.bubble, color: C.text }] }, m.text)))),
129
+ thread.thinking ? h(Text, { style: [s.event, { color: C.muted, fontStyle: 'italic' }], testID: 'ssb-help-thinking' }, 'Thinking…') : null),
130
+ h(View, { style: [s.footer, { borderTopColor: C.line }] },
131
+ error ? h(Text, { style: s.error }, error) : null,
132
+ h(View, { style: s.row },
133
+ h(TextInput, { value: draft, onChangeText: setDraft, placeholder: 'How do I…', placeholderTextColor: C.muted, multiline: true, testID: 'ssb-help-input', style: [s.input, { color: C.text, borderColor: C.line }] }),
134
+ h(Pressable, { onPress: go, disabled: sending || !draft.trim(), testID: 'ssb-help-send', style: [s.send, { backgroundColor: C.accent }, (sending || !draft.trim()) && s.disabled] },
135
+ h(Text, { style: s.sendText }, sending ? '…' : 'Send'))),
136
+ canAskPerson(msgs, thread.ticket || me.ticket) ? h(Pressable, { onPress: person, testID: 'ssb-help-person' }, h(Text, { style: [s.person, { color: C.accent }] }, 'Talk to a person')) : null));
137
+ }
138
+
139
+ const s = StyleSheet.create({
140
+ root: { flex: 1 },
141
+ empty: { flex: 1, alignItems: 'center', justifyContent: 'center' },
142
+ header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: 1 },
143
+ title: { flex: 1, fontSize: 17, fontWeight: '700' },
144
+ close: { fontSize: 20 },
145
+ ticket: { paddingHorizontal: 16, paddingVertical: 8, fontSize: 13, borderBottomWidth: 1 },
146
+ body: { flex: 1 },
147
+ bodyIn: { padding: 16, gap: 8 },
148
+ leftWrap: { alignItems: 'flex-start' },
149
+ rightWrap: { alignItems: 'flex-end' },
150
+ left: { alignSelf: 'flex-start' },
151
+ who: { fontSize: 11, marginBottom: 2 },
152
+ msg: { maxWidth: '85%', paddingVertical: 8, paddingHorizontal: 12, borderRadius: 14, fontSize: 15, lineHeight: 21, overflow: 'hidden' },
153
+ event: { fontSize: 12, textAlign: 'center', marginVertical: 4 },
154
+ footer: { borderTopWidth: 1, padding: 10, gap: 6 },
155
+ row: { flexDirection: 'row', alignItems: 'flex-end', gap: 8 },
156
+ input: { flex: 1, minHeight: 44, maxHeight: 120, borderWidth: 1, borderRadius: 12, paddingHorizontal: 12, paddingVertical: 10, fontSize: 15 },
157
+ send: { paddingHorizontal: 16, height: 44, borderRadius: 12, alignItems: 'center', justifyContent: 'center' },
158
+ sendText: { color: '#fff', fontWeight: '700', fontSize: 15 },
159
+ disabled: { opacity: 0.5 },
160
+ person: { fontSize: 13, fontWeight: '600' },
161
+ error: { color: '#b91c1c', fontSize: 13 },
162
+ bubble: { position: 'absolute', right: 16, width: 52, height: 52, borderRadius: 26, alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOpacity: 0.25, shadowRadius: 10, shadowOffset: { width: 0, height: 4 }, elevation: 6 },
163
+ bubbleText: { color: '#fff', fontSize: 24, fontWeight: '800' },
164
+ dot: { position: 'absolute', top: 2, right: 2, width: 12, height: 12, borderRadius: 6, backgroundColor: '#ef4444', borderWidth: 2, borderColor: '#fff' },
165
+ });
package/src/SsbHost.js CHANGED
@@ -4,10 +4,9 @@
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
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
7
+ // Tapping it takes a picture of the screen as it is and opens SSB as a SHEET,
8
+ // half way up (2026-10-22: it opened full screen from 2026-10-20 until then);
9
+ // pulled up it is full screen, with the WORK at the top — what is in flight on this app, this
11
10
  // chat's drafts to start — the way the extension's card has it, and the
12
11
  // conversation with SSB below (Tell SSB's, functions/annotate.mjs). Every send carries
13
12
  // the screen (2026-10-19, the product owner: "always send the screen, don't
@@ -20,15 +19,18 @@
20
19
  // No JSX, so it needs nothing from the app's Babel setup beyond React Native's.
21
20
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
22
21
  import {
23
- ActivityIndicator, Image, Keyboard, KeyboardAvoidingView, Linking, PanResponder, Platform, Pressable,
24
- NativeModules, ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions,
22
+ ActivityIndicator, Image, Keyboard, Linking, PanResponder, Platform, Pressable,
23
+ NativeModules, StyleSheet, Text, TextInput, View, useWindowDimensions,
25
24
  } from 'react-native';
26
25
  // The app's demo player (a peer): ▶ Demo hands it the demo to play in this
27
26
  // build. An older player has no playDemo, and the button is not offered.
28
27
  import { playDemo, PLAYER_VERSION } from '@simon-says-build/demo-player';
28
+ // The sheet (see Panel): peers the app already carries.
29
+ import BottomSheet, { BottomSheetBackdrop, BottomSheetFooter, BottomSheetScrollView, BottomSheetTextInput } from '@gorhom/bottom-sheet';
30
+ import { GestureHandlerRootView, ScrollView } from 'react-native-gesture-handler';
29
31
  import {
30
32
  FAB, addPoint, clampFab, demoBanner, fabHome, isDrag, describeHost, draftsOf, fieldTop, inAppApi, namedHosts, newReply, offerOf,
31
- STATE_WORD, boxItem, bundleOriginOf, clientInfo, elsewhereLine, workView, drawingBox, drawingMark, drawingPoint, itemRect, overlayConfig, pollEvery, SNAPSHOT_TOPIC, snapshotOf, talkLine, segmentsOf, sendBody, smallestAt, ssbLinkOf,
33
+ STATE_WORD, workSummary, boxItem, bundleOriginOf, clientInfo, elsewhereLine, workView, drawingBox, drawingMark, drawingPoint, itemRect, overlayConfig, pollEvery, SNAPSHOT_TOPIC, snapshotOf, talkLine, segmentsOf, sendBody, smallestAt, ssbLinkOf,
32
34
  } from './core.js';
33
35
 
34
36
  const h = React.createElement;
@@ -208,19 +210,20 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
208
210
 
209
211
  const open = mode !== 'idle';
210
212
  useEffect(() => { if (open) load(); }, [open, chatId]);
211
- // The top of the panel: the app's work, asked when it opens and while it stays open.
213
+ // The top of the panel: THIS CONVERSATION's work (2026-10-22: not every job
214
+ // on the app, whoever asked), asked when it opens and while it stays open.
212
215
  const loadWork = useCallback(async () => {
213
- try { const r = await ssb.work(here); setWork(r?.inFlight || []); setElsewhere(r?.readyElsewhere || []); } catch { /* the last list stands */ }
214
- }, [ssb, here]);
216
+ try { const r = await ssb.work(here, chatId || ''); setWork(r?.inFlight || []); setElsewhere(r?.readyElsewhere || []); } catch { /* the last list stands */ }
217
+ }, [ssb, here, chatId]);
215
218
  // ▶ Demo: one demo of everything Ready in this build, played by this build.
216
219
  const startDemo = useCallback(async () => {
217
220
  setBusy(true); setError(null);
218
221
  try {
219
- const r = await ssb.demo(here);
222
+ const r = await ssb.demo(here, chatId || '');
220
223
  if (!playDemo({ id: r.demoId, token: r.token, api: config.api, here: true })) throw new Error('the demo could not start');
221
224
  Keyboard.dismiss(); setMode('idle');
222
225
  } catch (e) { setError(e.message); } finally { setBusy(false); }
223
- }, [ssb, here, config.api]);
226
+ }, [ssb, here, config.api, chatId]);
224
227
  useEffect(() => {
225
228
  if (mode !== 'sheet' || !gate) return undefined;
226
229
  loadWork();
@@ -498,8 +501,27 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
498
501
  h(View, { ref: chromeRef, collapsable: false, pointerEvents: 'box-none', style: StyleSheet.absoluteFill }, chrome));
499
502
  }
500
503
 
504
+ // THE SHEET (2026-10-22, Michael: "a compact chat as a sheet, where you can
505
+ // expand fully, which shows the persistent panel at the top"). It opens HALF
506
+ // way: the conversation and the composer, with the app still showing above
507
+ // it and the work as one line (workSummary). Pulled up — or the line, or ⌃,
508
+ // tapped — it is the full screen it used to open as, with THE WORK at the top.
509
+ // Pulled down from half, or the backdrop tapped, it closes.
510
+ //
511
+ // @gorhom/bottom-sheet (a peer, with Reanimated and gesture-handler — both
512
+ // apps the overlay ships in already carry all three): its snap points, its
513
+ // keyboard handling (the composer is its TextInput, so the sheet rides the
514
+ // keyboard) and its footer, which keeps the composer on the sheet's visible
515
+ // bottom edge at either height. Its own GestureHandlerRootView, so it works
516
+ // wherever the app put <SsbHost>.
517
+ const SNAPS = ['50%', '100%'];
518
+ const COMPACT = 0, FULL = 1;
519
+
501
520
  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 }) {
521
+ const sheet = useRef(null);
502
522
  const scroll = useRef(null);
523
+ const [at, setAt] = useState(COMPACT);
524
+ const [footH, setFootH] = useState(120);
503
525
  const [open, setOpen] = useState(null); // the draft whose detail is shown
504
526
  const { flying, waiting, sent } = workView({ inFlight: work, chat });
505
527
  const offer = offerOf(chat);
@@ -508,80 +530,110 @@ function Panel({ gate, chat, work, elsewhere, onDemo, screen, insets, draft, set
508
530
  const ready = flying.filter((x) => x.state === 'ready');
509
531
  const away = elsewhereLine(elsewhere);
510
532
  const hasWork = flying.length || waiting.length || sent.length || banner || away;
533
+ const summary = workSummary({ flying, waiting });
534
+ const full = at === FULL;
511
535
  const showMe = (x) => (x.state === 'ready' && banner?.url ? banner.url : null);
512
- return h(View, { style: [s.panel, { paddingTop: insets.top }], testID: 'ssb-panel' },
513
- h(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, style: { flex: 1 } },
514
- h(View, { style: s.header },
515
- h(View, { style: s.headLogoRing }, h(Image, { source: LOGO, style: s.headLogo, accessibilityLabel: 'SSB' })),
516
- h(View, { style: { flex: 1 } },
517
- h(Text, { style: s.title }, 'Tell SSB'),
518
- h(Text, { style: s.sub, numberOfLines: 1 }, `${gate.app?.name || 'this app'} · as ${gate.person?.label || 'you'}`)),
519
- chat ? h(Pressable, { onPress: onNew, accessibilityRole: 'button', accessibilityLabel: 'New conversation', style: s.quiet }, h(Text, { style: s.quietText }, 'New')) : null,
520
- h(Pressable, { onPress: onClose, accessibilityRole: 'button', accessibilityLabel: 'Close SSB', style: s.iconBtn }, h(Text, { style: s.iconText }, '✕'))),
521
- // THE WORK, at the top: in flight, then drafts to start, then what this chat started.
522
- hasWork ? h(ScrollView, { style: [s.work, { maxHeight: Math.round(screen.height * 0.42) }], testID: 'ssb-work', keyboardShouldPersistTaps: 'handled' },
523
- banner ? h(Pressable, {
524
- testID: 'ssb-demo', disabled: !banner.url, onPress: () => onOpenLink(banner.url),
525
- accessibilityRole: banner.url ? 'button' : 'text', style: [s.demo, banner.url && s.demoReady],
526
- }, h(Text, { style: [s.demoText, banner.url && { color: '#fff' }] }, banner.text), banner.url ? h(Text, { style: s.demoGo }, '▶ See it') : null) : null,
527
- flying.length ? h(View, { style: s.sectionRow },
528
- h(Text, { style: [s.section, { flex: 1 }], testID: 'ssb-section-flight' }, 'In flight'),
529
- ready.length && onDemo ? h(Pressable, { testID: 'ssb-demo-ready', onPress: onDemo, disabled: busy, accessibilityRole: 'button', accessibilityLabel: `Demo what is ready in this build (${ready.length})`, style: [s.primary, s.small, busy && s.disabled] },
530
- h(Text, { style: s.primaryText }, `▶ Demo (${ready.length})`)) : null) : null,
531
- // A row opens its job in the SimonSays app, the console when that app
532
- // is not on the phone (functions/in-app.mjs jobLinksFor) — for those
533
- // who can open it there; anyone else's rows stay plain.
534
- ...flying.map((x) => h(x.links ? Pressable : View, {
535
- key: `f${x.id}`, style: s.workRow, testID: `ssb-flight-${x.id}`,
536
- ...(x.links ? { onPress: () => onOpenLink(x.links.app, x.links.web), accessibilityRole: 'link', accessibilityLabel: `Open in SimonSays: ${x.title}` } : {}),
537
- },
538
- h(Text, { style: s.workTitle, numberOfLines: 2 }, x.links ? `${x.title} ›` : x.title),
539
- showMe(x)
540
- ? 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'))
541
- : h(Text, { style: [s.state, s[`state_${x.state}`]] }, STATE_WORD[x.state] || x.state))),
542
- away ? h(Text, { style: [s.draftBody, { paddingHorizontal: 16, paddingVertical: 8 }], testID: 'ssb-ready-elsewhere' }, away) : null,
543
- waiting.length ? h(View, { style: s.sectionRow },
544
- h(Text, { style: [s.section, { flex: 1 }] }, 'Drafts'),
545
- 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,
546
- ...waiting.map((p) => h(View, { key: `d${p.id}`, style: s.draftRow, testID: `ssb-draft-${p.id}` },
547
- h(View, { style: s.workLine },
548
- h(Pressable, { onPress: () => setOpen(open === p.id ? null : p.id), style: { flex: 1 }, accessibilityRole: 'button', accessibilityLabel: p.title },
549
- h(Text, { style: s.workTitle, numberOfLines: open === p.id ? undefined : 2 }, p.title)),
550
- 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'))),
551
- open === p.id ? h(View, null,
552
- p.detail ? h(Text, { style: s.draftBody }, p.detail) : null,
553
- 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)),
554
- ...sent.map((p) => h(View, { key: `s${p.id}`, style: s.workRow, testID: `ssb-sent-${p.id}` },
555
- h(Text, { style: s.workTitle, numberOfLines: 2 }, `✓ ${p.title}`),
556
- 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,
557
- // THE CONVERSATION, below.
558
- h(ScrollView, { ref: scroll, style: s.thread, contentContainerStyle: { paddingVertical: 8 }, keyboardShouldPersistTaps: 'handled', onContentSizeChange: () => scroll.current?.scrollToEnd({ animated: true }) },
559
- msgs.length ? null : h(Text, { style: s.empty }, 'Say what should change on this screen, or tap Draw to scribble on it.'),
560
- ...msgs.map((m) => h(View, { key: m.id, style: [s.bubble, m.role === 'user' ? s.mine : s.theirs] },
561
- m.image ? h(Image, { source: { uri: m.image }, style: s.thumb, resizeMode: 'contain' }) : null,
562
- h(Text, { style: m.role === 'user' ? s.mineText : s.theirsText }, m.text),
563
- 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)),
564
- 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),
565
- error ? h(Text, { style: s.error }, error) : null,
566
- // THE COMPOSER, or while talking the voice controls in its place: voice is
567
- // the other way to say it, so Hang up sits where Send does.
568
- h(View, { style: [s.composer, { paddingBottom: Math.max(12, insets.bottom > 60 ? 16 : insets.bottom) }] },
569
- talk ? h(View, { style: s.talkBar, testID: 'ssb-talk-bar' },
570
- h(View, { style: [s.talkDot, talk.status !== 'live' && { backgroundColor: MUTED }] }),
571
- h(Text, { style: s.talkText }, talkLine(talk))) : h(TextInput, {
572
- testID: 'ssb-composer', value: draft, onChangeText: setDraft, multiline: true, placeholder: 'Ask SSB, or say what should change…',
573
- placeholderTextColor: MUTED, keyboardAppearance: 'dark', style: s.input,
574
- }),
575
- h(View, { style: s.row },
576
- h(Pressable, { testID: 'ssb-draw', onPress: onDraw, accessibilityRole: 'button', accessibilityLabel: 'Draw on the screen', style: s.chip }, h(Text, { style: s.chipText }, '✎ Draw')),
577
- 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,
578
- talk ? h(Pressable, { testID: 'ssb-mute', onPress: onMute, accessibilityRole: 'button', accessibilityLabel: talk.muted ? 'Unmute' : 'Mute', style: s.chip }, h(Text, { style: s.chipText }, talk.muted ? 'Unmute' : 'Mute')) : null,
579
- h(View, { style: { flex: 1 } }),
580
- talk
581
- ? h(Pressable, { testID: 'ssb-hangup', onPress: onEndTalk, accessibilityRole: 'button', accessibilityLabel: 'End the talk', style: [s.primary, s.hangup] }, h(Text, { style: s.primaryText }, 'Hang up'))
582
- : 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] },
583
- busy ? h(ActivityIndicator, { size: 'small', color: '#fff' }) : h(Text, { style: s.primaryText }, 'Send'))))));
536
+ const snap = (i) => sheet.current?.snapToIndex(i);
537
+
538
+ const backdrop = useCallback((p) => h(BottomSheetBackdrop, { ...p, appearsOnIndex: COMPACT, disappearsOnIndex: -1, opacity: 0.45, pressBehavior: 'close' }), []);
539
+
540
+ // THE COMPOSER, or while talking the voice controls in its place: voice is
541
+ // the other way to say it, so Hang up sits where Send does.
542
+ const composer = h(View, { style: [s.composer, { paddingBottom: Math.max(12, insets.bottom > 60 ? 16 : insets.bottom) }], onLayout: (e) => setFootH(e.nativeEvent.layout.height) },
543
+ talk ? h(View, { style: s.talkBar, testID: 'ssb-talk-bar' },
544
+ h(View, { style: [s.talkDot, talk.status !== 'live' && { backgroundColor: MUTED }] }),
545
+ h(Text, { style: s.talkText }, talkLine(talk))) : h(BottomSheetTextInput, {
546
+ testID: 'ssb-composer', value: draft, onChangeText: setDraft, multiline: true, placeholder: 'Ask SSB, or say what should change…',
547
+ placeholderTextColor: MUTED, keyboardAppearance: 'dark', style: s.input,
548
+ }),
549
+ h(View, { style: s.row },
550
+ h(Pressable, { testID: 'ssb-draw', onPress: onDraw, accessibilityRole: 'button', accessibilityLabel: 'Draw on the screen', style: s.chip }, h(Text, { style: s.chipText }, '✎ Draw')),
551
+ 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,
552
+ talk ? h(Pressable, { testID: 'ssb-mute', onPress: onMute, accessibilityRole: 'button', accessibilityLabel: talk.muted ? 'Unmute' : 'Mute', style: s.chip }, h(Text, { style: s.chipText }, talk.muted ? 'Unmute' : 'Mute')) : null,
553
+ h(View, { style: { flex: 1 } }),
554
+ talk
555
+ ? h(Pressable, { testID: 'ssb-hangup', onPress: onEndTalk, accessibilityRole: 'button', accessibilityLabel: 'End the talk', style: [s.primary, s.hangup] }, h(Text, { style: s.primaryText }, 'Hang up'))
556
+ : 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] },
557
+ busy ? h(ActivityIndicator, { size: 'small', color: '#fff' }) : h(Text, { style: s.primaryText }, 'Send'))));
558
+ // The footer is a component TYPE to the sheet: a new function per keystroke
559
+ // remounted the text box after every letter (the harness sent "T" for a
560
+ // sentence). So it is made once, and the composer reaches it by context.
561
+ const footer = useCallback((p) => h(BottomSheetFooter, p, h(ComposerSlot)), []);
562
+
563
+ // THE WORK, at the top of the full sheet: in flight, then drafts to start, then what this chat started.
564
+ const workList = h(ScrollView, { style: [s.work, { maxHeight: Math.round(screen.height * 0.42) }], testID: 'ssb-work', keyboardShouldPersistTaps: 'handled' },
565
+ banner ? h(Pressable, {
566
+ testID: 'ssb-demo', disabled: !banner.url, onPress: () => onOpenLink(banner.url),
567
+ accessibilityRole: banner.url ? 'button' : 'text', style: [s.demo, banner.url && s.demoReady],
568
+ }, h(Text, { style: [s.demoText, banner.url && { color: '#fff' }] }, banner.text), banner.url ? h(Text, { style: s.demoGo }, '▶ See it') : null) : null,
569
+ flying.length ? h(View, { style: s.sectionRow },
570
+ h(Text, { style: [s.section, { flex: 1 }], testID: 'ssb-section-flight' }, 'In flight'),
571
+ ready.length && onDemo ? h(Pressable, { testID: 'ssb-demo-ready', onPress: onDemo, disabled: busy, accessibilityRole: 'button', accessibilityLabel: `Demo what is ready in this build (${ready.length})`, style: [s.primary, s.small, busy && s.disabled] },
572
+ h(Text, { style: s.primaryText }, `▶ Demo (${ready.length})`)) : null) : null,
573
+ // A row opens its job in the SimonSays app, the console when that app
574
+ // is not on the phone (functions/in-app.mjs jobLinksFor) — for those
575
+ // who can open it there; anyone else's rows stay plain.
576
+ ...flying.map((x) => h(x.links ? Pressable : View, {
577
+ key: `f${x.id}`, style: s.workRow, testID: `ssb-flight-${x.id}`,
578
+ ...(x.links ? { onPress: () => onOpenLink(x.links.app, x.links.web), accessibilityRole: 'link', accessibilityLabel: `Open in SimonSays: ${x.title}` } : {}),
579
+ },
580
+ h(Text, { style: s.workTitle, numberOfLines: 2 }, x.links ? `${x.title} ›` : x.title),
581
+ showMe(x)
582
+ ? 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'))
583
+ : h(Text, { style: [s.state, s[`state_${x.state}`]] }, STATE_WORD[x.state] || x.state))),
584
+ away ? h(Text, { style: [s.draftBody, { paddingHorizontal: 16, paddingVertical: 8 }], testID: 'ssb-ready-elsewhere' }, away) : null,
585
+ waiting.length ? h(View, { style: s.sectionRow },
586
+ h(Text, { style: [s.section, { flex: 1 }] }, 'Drafts'),
587
+ 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,
588
+ ...waiting.map((p) => h(View, { key: `d${p.id}`, style: s.draftRow, testID: `ssb-draft-${p.id}` },
589
+ h(View, { style: s.workLine },
590
+ h(Pressable, { onPress: () => setOpen(open === p.id ? null : p.id), style: { flex: 1 }, accessibilityRole: 'button', accessibilityLabel: p.title },
591
+ h(Text, { style: s.workTitle, numberOfLines: open === p.id ? undefined : 2 }, p.title)),
592
+ 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'))),
593
+ open === p.id ? h(View, null,
594
+ p.detail ? h(Text, { style: s.draftBody }, p.detail) : null,
595
+ 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)),
596
+ ...sent.map((p) => h(View, { key: `s${p.id}`, style: s.workRow, testID: `ssb-sent-${p.id}` },
597
+ h(Text, { style: s.workTitle, numberOfLines: 2 }, `✓ ${p.title}`),
598
+ p.jobUrl ? h(Pressable, { onPress: () => Linking.openURL(p.jobUrl).catch(() => {}), accessibilityRole: 'link' }, h(Text, { style: s.link }, 'Started →')) : h(Text, { style: s.state }, 'Started'))));
599
+
600
+ // Half open: the work is one line, and a ready demo keeps its button —
601
+ // it is the thing most worth a tap.
602
+ 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 },
603
+ h(Text, { style: s.summaryText, numberOfLines: 1 }, summary || banner.text),
604
+ banner?.url ? h(Pressable, { testID: 'ssb-demo', onPress: () => onOpenLink(banner.url), accessibilityRole: 'button', style: [s.primary, s.small] }, h(Text, { style: s.primaryText }, '▶ See it')) : null,
605
+ h(Text, { style: s.summaryGo }, '⌃')) : null;
606
+
607
+ return h(GestureHandlerRootView, { style: StyleSheet.absoluteFill, pointerEvents: 'box-none' },
608
+ h(ComposerCtx.Provider, { value: composer }, h(BottomSheet, {
609
+ ref: sheet, index: COMPACT, snapPoints: SNAPS, enableDynamicSizing: false, enablePanDownToClose: true,
610
+ topInset: insets.top, onChange: (i) => { if (i >= 0) setAt(i); }, onClose,
611
+ backdropComponent: backdrop, footerComponent: footer,
612
+ keyboardBehavior: 'interactive', keyboardBlurBehavior: 'restore', android_keyboardInputMode: 'adjustResize',
613
+ backgroundStyle: s.sheetBg, handleIndicatorStyle: s.grabber,
614
+ },
615
+ h(View, { style: { flex: 1 }, testID: 'ssb-panel' },
616
+ h(View, { style: s.header },
617
+ h(View, { style: s.headLogoRing }, h(Image, { source: LOGO, style: s.headLogo, accessibilityLabel: 'SSB' })),
618
+ h(View, { style: { flex: 1 } },
619
+ h(Text, { style: s.title }, 'Tell SSB'),
620
+ h(Text, { style: s.sub, numberOfLines: 1 }, `${gate.app?.name || 'this app'} · as ${gate.person?.label || 'you'}`)),
621
+ chat ? h(Pressable, { onPress: onNew, accessibilityRole: 'button', accessibilityLabel: 'New conversation', style: s.quiet }, h(Text, { style: s.quietText }, 'New')) : null,
622
+ 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 ? '⌄' : '⌃')),
623
+ h(Pressable, { testID: 'ssb-close', onPress: () => sheet.current?.close(), accessibilityRole: 'button', accessibilityLabel: 'Close SSB', style: s.iconBtn }, h(Text, { style: s.iconText }, '✕'))),
624
+ full ? (hasWork ? workList : null) : compactWork,
625
+ // THE CONVERSATION, below; the footer (the composer) floats over its end.
626
+ h(BottomSheetScrollView, { ref: scroll, style: s.thread, contentContainerStyle: { paddingTop: 8, paddingBottom: footH + 8 }, keyboardShouldPersistTaps: 'handled', onContentSizeChange: () => scroll.current?.scrollToEnd({ animated: true }) },
627
+ msgs.length ? null : h(Text, { style: s.empty }, 'Say what should change on this screen, or tap Draw to scribble on it.'),
628
+ ...msgs.map((m) => h(View, { key: m.id, style: [s.bubble, m.role === 'user' ? s.mine : s.theirs] },
629
+ m.image ? h(Image, { source: { uri: m.image }, style: s.thumb, resizeMode: 'contain' }) : null,
630
+ h(Text, { style: m.role === 'user' ? s.mineText : s.theirsText }, m.text),
631
+ 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)),
632
+ 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,
633
+ error ? h(Text, { style: s.error }, error) : null)))));
584
634
  }
635
+ const ComposerCtx = React.createContext(null);
636
+ const ComposerSlot = () => React.useContext(ComposerCtx);
585
637
 
586
638
  const shadow = { shadowColor: '#000', shadowOpacity: 0.45, shadowRadius: 18, shadowOffset: { width: 0, height: 8 }, elevation: 12 };
587
639
  const glow = (c, r = 12) => ({ shadowColor: c, shadowOpacity: 0.55, shadowRadius: r, shadowOffset: { width: 0, height: 0 } });
@@ -611,8 +663,12 @@ const s = StyleSheet.create({
611
663
  // The panel. Spelled out: StyleSheet.absoluteFillObject is gone in React
612
664
  // Native 0.86, and spreading `undefined` left the panel with no size at all
613
665
  // (the harness caught it).
614
- panel: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: BG },
615
- header: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingTop: 12, paddingHorizontal: 16, paddingBottom: 12, borderBottomWidth: 1, borderBottomColor: LINE },
666
+ sheetBg: { backgroundColor: BG, borderTopLeftRadius: 22, borderTopRightRadius: 22, borderWidth: 1, borderColor: LINE },
667
+ grabber: { backgroundColor: MUTED, width: 40 },
668
+ summary: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: LINE, backgroundColor: SURFACE },
669
+ summaryText: { flex: 1, fontSize: 13, fontWeight: '700', color: TEXT },
670
+ summaryGo: { color: MUTED, fontSize: 16, fontWeight: '800' },
671
+ header: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingTop: 4, paddingHorizontal: 16, paddingBottom: 12, borderBottomWidth: 1, borderBottomColor: LINE },
616
672
  headLogoRing: { width: 40, height: 40, borderRadius: 20, ...glow(BLUE, 10) },
617
673
  headLogo: { width: 40, height: 40, borderRadius: 20 },
618
674
  title: { fontSize: 17, fontWeight: '800', color: TEXT, letterSpacing: 0.3 },
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.7.0';
8
+ export const OVERLAY_VERSION = '0.9.0';
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
@@ -49,8 +49,12 @@ export function inAppApi(config, getToken, fetchImpl = globalThis.fetch) {
49
49
  voice: (body) => post('/voice', body),
50
50
  // `bundle`: where this app's JS came from (a preview's tunnel), so SSB
51
51
  // scopes Ready to this build; undefined = the whole list, as before.
52
- work: (bundle) => call(bundle === undefined ? '/work' : `/work?bundle=${encodeURIComponent(bundle)}`),
53
- demo: (bundle) => post('/demo', { bundle }),
52
+ // `chat`: only this conversation's work ("" before there is one: nothing).
53
+ work: (bundle, chat) => {
54
+ const q = [bundle === undefined ? null : `bundle=${encodeURIComponent(bundle)}`, chat === undefined ? null : `chat=${encodeURIComponent(chat || '')}`].filter(Boolean);
55
+ return call(q.length ? `/work?${q.join('&')}` : '/work');
56
+ },
57
+ demo: (bundle, chatId) => post('/demo', { bundle, ...(chatId !== undefined ? { chatId: chatId || '' } : {}) }),
54
58
  };
55
59
  }
56
60
 
@@ -316,6 +320,22 @@ export function workView({ inFlight = [], chat = null } = {}) {
316
320
  return { flying, waiting, sent: sent.filter((p) => !p.jobId || !flyingIds.has(p.jobId)) };
317
321
  }
318
322
 
323
+ // The COMPACT sheet's one line about the work (2026-10-22): the full sheet
324
+ // lists it at the top; half-open, the chat has the room and the work is a
325
+ // line that says how much there is — tap it, or pull the sheet up, to see it.
326
+ // Ready first (it is what to look at), then the rest by state, then drafts.
327
+ // "" when there is nothing, so the line is not drawn.
328
+ export function workSummary({ flying = [], waiting = [] } = {}) {
329
+ const n = {};
330
+ for (const x of flying) n[x.state] = (n[x.state] || 0) + 1;
331
+ const parts = ['ready', 'building', 'checking', 'queued', 'blocked']
332
+ .filter((k) => n[k]).map((k) => `${n[k]} ${(STATE_WORD[k] || k).toLowerCase()}`);
333
+ const other = flying.length - parts.reduce((a, p) => a + Number(p.split(' ')[0]), 0);
334
+ if (other > 0) parts.push(`${other} in flight`);
335
+ if (waiting.length) parts.push(`${waiting.length} draft${waiting.length === 1 ? '' : 's'} to start`);
336
+ return parts.join(' · ');
337
+ }
338
+
319
339
  // READY IS THIS BUILD (2026-10-20, docs/IN-APP-SSB.md): the origin the app's
320
340
  // JS bundle was loaded from — a preview's tunnel — or "" for a build that
321
341
  // loads from a file (a store or local build, which holds no SSB preview).
package/src/help.js ADDED
@@ -0,0 +1,76 @@
1
+ // HELP for an app's own users, the pure half (no React Native here, so
2
+ // node --test runs it). The web Help widget's native twin
3
+ // (web/public/help/widget.js, docs/HELPDESK.md): the same API, the same
4
+ // conversation, the same desk. The server is functions/help-widget.mjs: ANY
5
+ // login the app's backend verifies is let in (this is a customer asking for
6
+ // help, not someone changing the app), only where the app's organization
7
+ // switched its help desk on. A native app sends no Origin, so no CORS applies.
8
+
9
+ import { DEFAULT_API } from './core.js';
10
+
11
+ /** The Help settings, or null when they are not set (Help renders nothing). */
12
+ export function helpConfig({ appId, backend, api } = {}) {
13
+ if (!appId || !backend) return null;
14
+ return { appId: String(appId), backend: String(backend).replace(/\/+$/, ''), api: String(api || DEFAULT_API).replace(/\/+$/, '') };
15
+ }
16
+
17
+ /** The help API (/api/help/:appId/*). Every call carries the app's CURRENT session token. */
18
+ export function helpApi(config, getToken, fetchImpl = globalThis.fetch) {
19
+ const base = `${config.api}/api/help/${encodeURIComponent(config.appId)}`;
20
+ const call = async (path, init = {}) => {
21
+ const token = await getToken();
22
+ if (!token) { const e = new Error('signed out'); e.status = 401; throw e; }
23
+ const res = await fetchImpl(`${base}${path}`, {
24
+ ...init,
25
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}`, 'x-ssb-backend': config.backend, ...(init.headers || {}) },
26
+ });
27
+ const json = await res.json().catch(() => ({}));
28
+ if (!res.ok) { const e = new Error(json.error || `Help answered ${res.status}`); e.status = res.status; throw e; }
29
+ return json;
30
+ };
31
+ const post = (path, body) => call(path, { method: 'POST', body: JSON.stringify(body || {}) });
32
+ return {
33
+ me: () => call('/me'),
34
+ send: (body) => post('/messages', body),
35
+ chat: (id) => call(`/chats/${encodeURIComponent(id)}`),
36
+ escalate: (chatId) => post('/escalate', { chatId }),
37
+ };
38
+ }
39
+
40
+ /** What a message to Help carries: the words, and where in the app they were. */
41
+ export function helpSendBody({ text, route, me }) {
42
+ return {
43
+ text: String(text || '').trim().slice(0, 6000),
44
+ ...(route ? { url: `app:${String(route).slice(0, 400)}` } : {}),
45
+ ...(me?.exists && me.chatId ? { chatId: me.chatId } : {}),
46
+ };
47
+ }
48
+
49
+ /** How often to read the thread: often while SSB thinks, less while open, rarely closed. */
50
+ export function helpPollEvery({ open, thinking }) {
51
+ if (open) return thinking ? 2500 : 6000;
52
+ return 30000;
53
+ }
54
+
55
+ /** Who says a message, as the panel labels it. */
56
+ export function helpWho(m) {
57
+ if (m.role === 'user') return 'You';
58
+ if (m.role === 'agent') return `${m.byLabel || 'Support'} · team`;
59
+ return 'Assistant';
60
+ }
61
+
62
+ /** The line under the header while a person has the conversation, or ''. */
63
+ export function ticketLine(ticket) {
64
+ if (!ticket) return '';
65
+ if (ticket.status === 'solved') return 'Solved — ask anything new.';
66
+ return `A person${ticket.orgName ? ` from ${ticket.orgName}` : ''} has this${ticket.assignee ? ` · ${ticket.assignee}` : ''}`;
67
+ }
68
+
69
+ /** "Talk to a person" is offered once there is a conversation and nobody has it. */
70
+ export const canAskPerson = (messages, ticket) => (messages || []).length > 0 && (!ticket || ticket.status === 'solved');
71
+
72
+ /** A reply landed while the panel was closed (the bubble's dot). */
73
+ export function helpUnread(messages, seen) {
74
+ const list = messages || [];
75
+ return list.length > seen && list[list.length - 1]?.role !== 'user';
76
+ }