@simon-says-build/ssb-overlay 0.9.4 → 0.10.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/package.json +1 -1
- package/src/SsbHost.js +82 -10
- package/src/core.js +39 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simon-says-build/ssb-overlay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
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
|
-
|
|
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.stateWord || r.news.length ? h(View, { style: s.sessChips },
|
|
696
|
+
r.stateWord ? h(Text, { style: [s.state, s[`sess_${r.state}`]], testID: `ssb-session-state-${r.id}` }, r.stateWord) : null,
|
|
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
|
-
|
|
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 }, '
|
|
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.
|
|
8
|
+
export const OVERLAY_VERSION = '0.10.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
|
|
@@ -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,42 @@ 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
|
+
/** One row of the index: its name, where it is, and what is new in it for you. */
|
|
325
|
+
export function sessionRow(x, now = Date.now()) {
|
|
326
|
+
const u = x?.updates || {};
|
|
327
|
+
const st = x?.status?.state || null;
|
|
328
|
+
const word = SESSION_STATE_WORD[st] || null;
|
|
329
|
+
const count = st === 'building' && x.status.total ? ` · ${x.status.landed} of ${x.status.total} in` : '';
|
|
330
|
+
const news = [
|
|
331
|
+
u.replied ? 'SSB replied' : null,
|
|
332
|
+
u.waiting ? `${u.waiting} draft${u.waiting === 1 ? '' : 's'} waiting` : null,
|
|
333
|
+
u.live ? `${u.live} live` : null,
|
|
334
|
+
].filter(Boolean);
|
|
335
|
+
return { id: x.id, title: x.title || x.preview || 'A session', state: st, stateWord: word ? `${word}${count}` : null, news, any: !!u.any, ago: agoOf(x.lastMessageAt, now) };
|
|
336
|
+
}
|
|
337
|
+
export function agoOf(iso, now = Date.now()) {
|
|
338
|
+
const s = (now - Date.parse(iso || '')) / 1000;
|
|
339
|
+
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`;
|
|
340
|
+
}
|
|
341
|
+
|
|
304
342
|
// ---- the panel's top: the work (2026-10-20, the product owner: "the web
|
|
305
343
|
// extension has jobs and in-progress things at the top, with chat down the
|
|
306
344
|
// bottom … maybe we can move to that pattern") ------------------------------
|