@simon-says-build/ssb-overlay 0.8.0 → 0.9.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/README.md +6 -0
- package/install/install.mjs +6 -2
- package/package.json +5 -2
- package/src/SsbHost.js +148 -86
- package/src/core.js +23 -3
package/README.md
CHANGED
|
@@ -26,6 +26,12 @@ 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
|
+
|
|
29
35
|
## Help, for everyone who uses the app (0.8.0)
|
|
30
36
|
|
|
31
37
|
`<SsbHelp>` is the web Help widget's native twin (docs/HELPDESK.md): ANY
|
package/install/install.mjs
CHANGED
|
@@ -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
|
|
22
|
-
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simon-says-build/ssb-overlay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -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/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
|
|
8
|
-
// (2026-10-
|
|
9
|
-
//
|
|
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,
|
|
24
|
-
NativeModules,
|
|
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;
|
|
@@ -130,6 +132,7 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
|
|
|
130
132
|
const [mode, setMode] = useState('idle'); // idle | sheet | drawing | capturing
|
|
131
133
|
const [shot, setShot] = useState(null); // the screen as it was when the button was tapped
|
|
132
134
|
const [items, setItems] = useState([]); // what is drawn: strokes and boxes, in screen points (core.js)
|
|
135
|
+
const [openShot, setOpenShot] = useState(null); // the message whose full screen is showing (its preview otherwise)
|
|
133
136
|
const [live, setLive] = useState(null); // the item under the finger
|
|
134
137
|
const liveRef = useRef(null);
|
|
135
138
|
const [tool, setTool] = useState('pencil'); // pencil | box
|
|
@@ -208,19 +211,20 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
|
|
|
208
211
|
|
|
209
212
|
const open = mode !== 'idle';
|
|
210
213
|
useEffect(() => { if (open) load(); }, [open, chatId]);
|
|
211
|
-
// The top of the panel:
|
|
214
|
+
// The top of the panel: THIS CONVERSATION's work (2026-10-22: not every job
|
|
215
|
+
// on the app, whoever asked), asked when it opens and while it stays open.
|
|
212
216
|
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]);
|
|
217
|
+
try { const r = await ssb.work(here, chatId || ''); setWork(r?.inFlight || []); setElsewhere(r?.readyElsewhere || []); } catch { /* the last list stands */ }
|
|
218
|
+
}, [ssb, here, chatId]);
|
|
215
219
|
// ▶ Demo: one demo of everything Ready in this build, played by this build.
|
|
216
220
|
const startDemo = useCallback(async () => {
|
|
217
221
|
setBusy(true); setError(null);
|
|
218
222
|
try {
|
|
219
|
-
const r = await ssb.demo(here);
|
|
223
|
+
const r = await ssb.demo(here, chatId || '');
|
|
220
224
|
if (!playDemo({ id: r.demoId, token: r.token, api: config.api, here: true })) throw new Error('the demo could not start');
|
|
221
225
|
Keyboard.dismiss(); setMode('idle');
|
|
222
226
|
} catch (e) { setError(e.message); } finally { setBusy(false); }
|
|
223
|
-
}, [ssb, here, config.api]);
|
|
227
|
+
}, [ssb, here, config.api, chatId]);
|
|
224
228
|
useEffect(() => {
|
|
225
229
|
if (mode !== 'sheet' || !gate) return undefined;
|
|
226
230
|
loadWork();
|
|
@@ -498,8 +502,27 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
|
|
|
498
502
|
h(View, { ref: chromeRef, collapsable: false, pointerEvents: 'box-none', style: StyleSheet.absoluteFill }, chrome));
|
|
499
503
|
}
|
|
500
504
|
|
|
505
|
+
// THE SHEET (2026-10-22, Michael: "a compact chat as a sheet, where you can
|
|
506
|
+
// expand fully, which shows the persistent panel at the top"). It opens HALF
|
|
507
|
+
// way: the conversation and the composer, with the app still showing above
|
|
508
|
+
// it and the work as one line (workSummary). Pulled up — or the line, or ⌃,
|
|
509
|
+
// tapped — it is the full screen it used to open as, with THE WORK at the top.
|
|
510
|
+
// Pulled down from half, or the backdrop tapped, it closes.
|
|
511
|
+
//
|
|
512
|
+
// @gorhom/bottom-sheet (a peer, with Reanimated and gesture-handler — both
|
|
513
|
+
// apps the overlay ships in already carry all three): its snap points, its
|
|
514
|
+
// keyboard handling (the composer is its TextInput, so the sheet rides the
|
|
515
|
+
// keyboard) and its footer, which keeps the composer on the sheet's visible
|
|
516
|
+
// bottom edge at either height. Its own GestureHandlerRootView, so it works
|
|
517
|
+
// wherever the app put <SsbHost>.
|
|
518
|
+
const SNAPS = ['50%', '100%'];
|
|
519
|
+
const COMPACT = 0, FULL = 1;
|
|
520
|
+
|
|
501
521
|
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 }) {
|
|
522
|
+
const sheet = useRef(null);
|
|
502
523
|
const scroll = useRef(null);
|
|
524
|
+
const [at, setAt] = useState(COMPACT);
|
|
525
|
+
const [footH, setFootH] = useState(120);
|
|
503
526
|
const [open, setOpen] = useState(null); // the draft whose detail is shown
|
|
504
527
|
const { flying, waiting, sent } = workView({ inFlight: work, chat });
|
|
505
528
|
const offer = offerOf(chat);
|
|
@@ -508,80 +531,114 @@ function Panel({ gate, chat, work, elsewhere, onDemo, screen, insets, draft, set
|
|
|
508
531
|
const ready = flying.filter((x) => x.state === 'ready');
|
|
509
532
|
const away = elsewhereLine(elsewhere);
|
|
510
533
|
const hasWork = flying.length || waiting.length || sent.length || banner || away;
|
|
534
|
+
const summary = workSummary({ flying, waiting });
|
|
535
|
+
const full = at === FULL;
|
|
511
536
|
const showMe = (x) => (x.state === 'ready' && banner?.url ? banner.url : null);
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
537
|
+
const snap = (i) => sheet.current?.snapToIndex(i);
|
|
538
|
+
|
|
539
|
+
const backdrop = useCallback((p) => h(BottomSheetBackdrop, { ...p, appearsOnIndex: COMPACT, disappearsOnIndex: -1, opacity: 0.45, pressBehavior: 'close' }), []);
|
|
540
|
+
|
|
541
|
+
// THE COMPOSER, or while talking the voice controls in its place: voice is
|
|
542
|
+
// the other way to say it, so Hang up sits where Send does.
|
|
543
|
+
const composer = h(View, { style: [s.composer, { paddingBottom: Math.max(12, insets.bottom > 60 ? 16 : insets.bottom) }], onLayout: (e) => setFootH(e.nativeEvent.layout.height) },
|
|
544
|
+
talk ? h(View, { style: s.talkBar, testID: 'ssb-talk-bar' },
|
|
545
|
+
h(View, { style: [s.talkDot, talk.status !== 'live' && { backgroundColor: MUTED }] }),
|
|
546
|
+
h(Text, { style: s.talkText }, talkLine(talk))) : h(BottomSheetTextInput, {
|
|
547
|
+
testID: 'ssb-composer', value: draft, onChangeText: setDraft, multiline: true, placeholder: 'Ask SSB, or say what should change…',
|
|
548
|
+
placeholderTextColor: MUTED, keyboardAppearance: 'dark', style: s.input,
|
|
549
|
+
}),
|
|
550
|
+
h(View, { style: s.row },
|
|
551
|
+
h(Pressable, { testID: 'ssb-draw', onPress: onDraw, accessibilityRole: 'button', accessibilityLabel: 'Draw on the screen', style: s.chip }, h(Text, { style: s.chipText }, '✎ Draw')),
|
|
552
|
+
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,
|
|
553
|
+
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,
|
|
554
|
+
h(View, { style: { flex: 1 } }),
|
|
555
|
+
talk
|
|
556
|
+
? 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'))
|
|
557
|
+
: 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] },
|
|
558
|
+
busy ? h(ActivityIndicator, { size: 'small', color: '#fff' }) : h(Text, { style: s.primaryText }, 'Send'))));
|
|
559
|
+
// The footer is a component TYPE to the sheet: a new function per keystroke
|
|
560
|
+
// remounted the text box after every letter (the harness sent "T" for a
|
|
561
|
+
// sentence). So it is made once, and the composer reaches it by context.
|
|
562
|
+
const footer = useCallback((p) => h(BottomSheetFooter, p, h(ComposerSlot)), []);
|
|
563
|
+
|
|
564
|
+
// THE WORK, at the top of the full sheet: in flight, then drafts to start, then what this chat started.
|
|
565
|
+
const workList = h(ScrollView, { style: [s.work, { maxHeight: Math.round(screen.height * 0.42) }], testID: 'ssb-work', keyboardShouldPersistTaps: 'handled' },
|
|
566
|
+
banner ? h(Pressable, {
|
|
567
|
+
testID: 'ssb-demo', disabled: !banner.url, onPress: () => onOpenLink(banner.url),
|
|
568
|
+
accessibilityRole: banner.url ? 'button' : 'text', style: [s.demo, banner.url && s.demoReady],
|
|
569
|
+
}, h(Text, { style: [s.demoText, banner.url && { color: '#fff' }] }, banner.text), banner.url ? h(Text, { style: s.demoGo }, '▶ See it') : null) : null,
|
|
570
|
+
flying.length ? h(View, { style: s.sectionRow },
|
|
571
|
+
h(Text, { style: [s.section, { flex: 1 }], testID: 'ssb-section-flight' }, 'In flight'),
|
|
572
|
+
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] },
|
|
573
|
+
h(Text, { style: s.primaryText }, `▶ Demo (${ready.length})`)) : null) : null,
|
|
574
|
+
// A row opens its job in the SimonSays app, the console when that app
|
|
575
|
+
// is not on the phone (functions/in-app.mjs jobLinksFor) — for those
|
|
576
|
+
// who can open it there; anyone else's rows stay plain.
|
|
577
|
+
...flying.map((x) => h(x.links ? Pressable : View, {
|
|
578
|
+
key: `f${x.id}`, style: s.workRow, testID: `ssb-flight-${x.id}`,
|
|
579
|
+
...(x.links ? { onPress: () => onOpenLink(x.links.app, x.links.web), accessibilityRole: 'link', accessibilityLabel: `Open in SimonSays: ${x.title}` } : {}),
|
|
580
|
+
},
|
|
581
|
+
h(Text, { style: s.workTitle, numberOfLines: 2 }, x.links ? `${x.title} ›` : x.title),
|
|
582
|
+
showMe(x)
|
|
583
|
+
? 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'))
|
|
584
|
+
: h(Text, { style: [s.state, s[`state_${x.state}`]] }, STATE_WORD[x.state] || x.state))),
|
|
585
|
+
away ? h(Text, { style: [s.draftBody, { paddingHorizontal: 16, paddingVertical: 8 }], testID: 'ssb-ready-elsewhere' }, away) : null,
|
|
586
|
+
waiting.length ? h(View, { style: s.sectionRow },
|
|
587
|
+
h(Text, { style: [s.section, { flex: 1 }] }, 'Drafts'),
|
|
588
|
+
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,
|
|
589
|
+
...waiting.map((p) => h(View, { key: `d${p.id}`, style: s.draftRow, testID: `ssb-draft-${p.id}` },
|
|
590
|
+
h(View, { style: s.workLine },
|
|
591
|
+
h(Pressable, { onPress: () => setOpen(open === p.id ? null : p.id), style: { flex: 1 }, accessibilityRole: 'button', accessibilityLabel: p.title },
|
|
592
|
+
h(Text, { style: s.workTitle, numberOfLines: open === p.id ? undefined : 2 }, p.title)),
|
|
593
|
+
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'))),
|
|
594
|
+
open === p.id ? h(View, null,
|
|
595
|
+
p.detail ? h(Text, { style: s.draftBody }, p.detail) : null,
|
|
596
|
+
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)),
|
|
597
|
+
...sent.map((p) => h(View, { key: `s${p.id}`, style: s.workRow, testID: `ssb-sent-${p.id}` },
|
|
598
|
+
h(Text, { style: s.workTitle, numberOfLines: 2 }, `✓ ${p.title}`),
|
|
599
|
+
p.jobUrl ? h(Pressable, { onPress: () => Linking.openURL(p.jobUrl).catch(() => {}), accessibilityRole: 'link' }, h(Text, { style: s.link }, 'Started →')) : h(Text, { style: s.state }, 'Started'))));
|
|
600
|
+
|
|
601
|
+
// Half open: the work is one line, and a ready demo keeps its button —
|
|
602
|
+
// it is the thing most worth a tap.
|
|
603
|
+
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 },
|
|
604
|
+
h(Text, { style: s.summaryText, numberOfLines: 1 }, summary || banner.text),
|
|
605
|
+
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,
|
|
606
|
+
h(Text, { style: s.summaryGo }, '⌃')) : null;
|
|
607
|
+
|
|
608
|
+
return h(GestureHandlerRootView, { style: StyleSheet.absoluteFill, pointerEvents: 'box-none' },
|
|
609
|
+
h(ComposerCtx.Provider, { value: composer }, h(BottomSheet, {
|
|
610
|
+
ref: sheet, index: COMPACT, snapPoints: SNAPS, enableDynamicSizing: false, enablePanDownToClose: true,
|
|
611
|
+
topInset: insets.top, onChange: (i) => { if (i >= 0) setAt(i); }, onClose,
|
|
612
|
+
backdropComponent: backdrop, footerComponent: footer,
|
|
613
|
+
keyboardBehavior: 'interactive', keyboardBlurBehavior: 'restore', android_keyboardInputMode: 'adjustResize',
|
|
614
|
+
backgroundStyle: s.sheetBg, handleIndicatorStyle: s.grabber,
|
|
615
|
+
},
|
|
616
|
+
h(View, { style: { flex: 1 }, testID: 'ssb-panel' },
|
|
617
|
+
h(View, { style: s.header },
|
|
618
|
+
h(View, { style: s.headLogoRing }, h(Image, { source: LOGO, style: s.headLogo, accessibilityLabel: 'SSB' })),
|
|
619
|
+
h(View, { style: { flex: 1 } },
|
|
620
|
+
h(Text, { style: s.title }, 'Tell SSB'),
|
|
621
|
+
h(Text, { style: s.sub, numberOfLines: 1 }, `${gate.app?.name || 'this app'} · as ${gate.person?.label || 'you'}`)),
|
|
622
|
+
chat ? h(Pressable, { onPress: onNew, accessibilityRole: 'button', accessibilityLabel: 'New conversation', style: s.quiet }, h(Text, { style: s.quietText }, 'New')) : null,
|
|
623
|
+
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 ? '⌄' : '⌃')),
|
|
624
|
+
h(Pressable, { testID: 'ssb-close', onPress: () => sheet.current?.close(), accessibilityRole: 'button', accessibilityLabel: 'Close SSB', style: s.iconBtn }, h(Text, { style: s.iconText }, '✕'))),
|
|
625
|
+
full ? (hasWork ? workList : null) : compactWork,
|
|
626
|
+
// THE CONVERSATION, below; the footer (the composer) floats over its end.
|
|
627
|
+
h(BottomSheetScrollView, { ref: scroll, style: s.thread, contentContainerStyle: { paddingTop: 8, paddingBottom: footH + 8 }, keyboardShouldPersistTaps: 'handled', onContentSizeChange: () => scroll.current?.scrollToEnd({ animated: true }) },
|
|
628
|
+
msgs.length ? null : h(Text, { style: s.empty }, 'Say what should change on this screen, or tap Draw to scribble on it.'),
|
|
629
|
+
...msgs.map((m) => h(View, { key: m.id, style: [s.bubble, m.role === 'user' ? s.mine : s.theirs] },
|
|
630
|
+
// The marked part as the preview; a tap shows the full screen
|
|
631
|
+
// (2026-10-23, functions/shot-crop.mjs — the console and the
|
|
632
|
+
// extension do the same).
|
|
633
|
+
m.image ? h(Pressable, { onPress: () => setOpenShot(openShot === m.id ? null : m.id), accessibilityRole: 'imagebutton', accessibilityLabel: openShot === m.id ? 'Show the marked part' : 'Show the full screen' },
|
|
634
|
+
h(Image, { source: { uri: openShot === m.id || !m.preview ? m.image : m.preview }, style: openShot === m.id ? s.shotFull : s.thumb, resizeMode: 'contain' })) : null,
|
|
635
|
+
h(Text, { style: m.role === 'user' ? s.mineText : s.theirsText }, m.text),
|
|
636
|
+
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)),
|
|
637
|
+
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,
|
|
638
|
+
error ? h(Text, { style: s.error }, error) : null)))));
|
|
584
639
|
}
|
|
640
|
+
const ComposerCtx = React.createContext(null);
|
|
641
|
+
const ComposerSlot = () => React.useContext(ComposerCtx);
|
|
585
642
|
|
|
586
643
|
const shadow = { shadowColor: '#000', shadowOpacity: 0.45, shadowRadius: 18, shadowOffset: { width: 0, height: 8 }, elevation: 12 };
|
|
587
644
|
const glow = (c, r = 12) => ({ shadowColor: c, shadowOpacity: 0.55, shadowRadius: r, shadowOffset: { width: 0, height: 0 } });
|
|
@@ -611,8 +668,12 @@ const s = StyleSheet.create({
|
|
|
611
668
|
// The panel. Spelled out: StyleSheet.absoluteFillObject is gone in React
|
|
612
669
|
// Native 0.86, and spreading `undefined` left the panel with no size at all
|
|
613
670
|
// (the harness caught it).
|
|
614
|
-
|
|
615
|
-
|
|
671
|
+
sheetBg: { backgroundColor: BG, borderTopLeftRadius: 22, borderTopRightRadius: 22, borderWidth: 1, borderColor: LINE },
|
|
672
|
+
grabber: { backgroundColor: MUTED, width: 40 },
|
|
673
|
+
summary: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: LINE, backgroundColor: SURFACE },
|
|
674
|
+
summaryText: { flex: 1, fontSize: 13, fontWeight: '700', color: TEXT },
|
|
675
|
+
summaryGo: { color: MUTED, fontSize: 16, fontWeight: '800' },
|
|
676
|
+
header: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingTop: 4, paddingHorizontal: 16, paddingBottom: 12, borderBottomWidth: 1, borderBottomColor: LINE },
|
|
616
677
|
headLogoRing: { width: 40, height: 40, borderRadius: 20, ...glow(BLUE, 10) },
|
|
617
678
|
headLogo: { width: 40, height: 40, borderRadius: 20 },
|
|
618
679
|
title: { fontSize: 17, fontWeight: '800', color: TEXT, letterSpacing: 0.3 },
|
|
@@ -651,6 +712,7 @@ const s = StyleSheet.create({
|
|
|
651
712
|
mineText: { color: '#fff', fontSize: 15, lineHeight: 21 },
|
|
652
713
|
theirsText: { color: TEXT, fontSize: 15, lineHeight: 21 },
|
|
653
714
|
thumb: { width: 140, height: 220, borderRadius: 8, marginBottom: 6, backgroundColor: '#0006' },
|
|
715
|
+
shotFull: { width: 260, height: 460, borderRadius: 8, marginBottom: 6, backgroundColor: '#0006' },
|
|
654
716
|
error: { color: RED, fontSize: 13, paddingHorizontal: 16, paddingTop: 6 },
|
|
655
717
|
composer: { paddingHorizontal: 12, paddingTop: 10, borderTopWidth: 1, borderTopColor: LINE, backgroundColor: SURFACE },
|
|
656
718
|
input: { minHeight: 44, maxHeight: 120, fontSize: 16, color: TEXT, backgroundColor: BG, borderWidth: 1, borderColor: LINE, borderRadius: 14, paddingHorizontal: 13, paddingTop: 11, paddingBottom: 11 },
|
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.
|
|
8
|
+
export const OVERLAY_VERSION = '0.9.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
|
|
@@ -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
|
-
|
|
53
|
-
|
|
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).
|