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