@robota-sdk/agent-ui-web 3.1.1 → 3.2.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/CHANGELOG.md +34 -0
- package/dist/index.cjs +400 -65
- package/dist/index.d.cts +28 -2
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +28 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +401 -67
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
- package/src/client/desktop-session-client.ts +78 -0
- package/src/components/Composer.tsx +58 -14
- package/src/components/ExecutionDetailSheet.tsx +116 -6
- package/src/components/SessionSurface.tsx +26 -0
- package/src/hooks/session-client-types.ts +5 -0
- package/src/hooks/use-execution-detail.ts +133 -33
- package/src/hooks/useSessionClient.ts +45 -4
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robota-sdk/agent-ui-web",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.1",
|
|
4
4
|
"description": "Browser-safe React session presentation, wire reducer, WebSocket client and runtime-host contract for consumer-owned agent renderers.",
|
|
5
5
|
"license": "AGPL-3.0-only OR LicenseRef-Commercial",
|
|
6
6
|
"type": "module",
|
|
@@ -60,11 +60,11 @@
|
|
|
60
60
|
"pretendard": "^1.3.9",
|
|
61
61
|
"react-markdown": "^10.1.0",
|
|
62
62
|
"remark-gfm": "^4.0.1",
|
|
63
|
-
"@robota-sdk/agent-interface-
|
|
64
|
-
"@robota-sdk/agent-interface-
|
|
65
|
-
"@robota-sdk/agent-
|
|
66
|
-
"@robota-sdk/agent-interface-
|
|
67
|
-
"@robota-sdk/agent-transport": "3.
|
|
63
|
+
"@robota-sdk/agent-interface-execution": "3.2.1",
|
|
64
|
+
"@robota-sdk/agent-interface-session": "3.2.1",
|
|
65
|
+
"@robota-sdk/agent-transport": "3.2.1",
|
|
66
|
+
"@robota-sdk/agent-interface-command": "3.2.1",
|
|
67
|
+
"@robota-sdk/agent-interface-transport": "3.2.1"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
70
|
"react": ">=18"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { decodeFrame, decodeServerMessage } from '@robota-sdk/agent-transport/client';
|
|
2
|
+
|
|
3
|
+
import type { TClientMessage, TServerMessage } from '@robota-sdk/agent-transport';
|
|
4
|
+
import type { ISessionClientHandle } from '../hooks/session-client-types.js';
|
|
5
|
+
import type { TConnectionStatus } from './ws-session-client.js';
|
|
6
|
+
|
|
7
|
+
/** The secure desktop preload exposes frames, never an owner credential or a daemon endpoint. */
|
|
8
|
+
export interface IDesktopSessionBridge {
|
|
9
|
+
subscribe(onFrame: (frame: string) => void, onClose: () => void, onReady: () => void): () => void;
|
|
10
|
+
send(frame: string): boolean;
|
|
11
|
+
close(): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createDesktopSessionClient(
|
|
15
|
+
bridge: IDesktopSessionBridge,
|
|
16
|
+
callbacks: {
|
|
17
|
+
readonly onMessage: (message: TServerMessage) => void;
|
|
18
|
+
readonly onStatusChange: (status: TConnectionStatus) => void;
|
|
19
|
+
},
|
|
20
|
+
): ISessionClientHandle {
|
|
21
|
+
let connected = false;
|
|
22
|
+
let subscribed = false;
|
|
23
|
+
let release: (() => void) | undefined;
|
|
24
|
+
let failed = false;
|
|
25
|
+
let revoked = false;
|
|
26
|
+
const close = (): void => {
|
|
27
|
+
revoked = true;
|
|
28
|
+
connected = false;
|
|
29
|
+
if (!failed && subscribed) callbacks.onStatusChange('disconnected');
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
connect() {
|
|
33
|
+
if (revoked || failed) return;
|
|
34
|
+
if (subscribed) return;
|
|
35
|
+
subscribed = true;
|
|
36
|
+
callbacks.onStatusChange('connecting');
|
|
37
|
+
try {
|
|
38
|
+
release = bridge.subscribe(
|
|
39
|
+
(frame) => {
|
|
40
|
+
if (!subscribed || failed || revoked) return;
|
|
41
|
+
const decoded = decodeFrame(frame, decodeServerMessage);
|
|
42
|
+
if (!decoded.ok) {
|
|
43
|
+
failed = true;
|
|
44
|
+
connected = false;
|
|
45
|
+
bridge.close();
|
|
46
|
+
callbacks.onStatusChange('error');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
callbacks.onMessage(decoded.message);
|
|
50
|
+
},
|
|
51
|
+
close,
|
|
52
|
+
() => {
|
|
53
|
+
if (!subscribed || failed || revoked) return;
|
|
54
|
+
connected = true;
|
|
55
|
+
callbacks.onStatusChange('connected');
|
|
56
|
+
bridge.send(JSON.stringify({ type: 'get-messages' }));
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
} catch {
|
|
60
|
+
close();
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
disconnect() {
|
|
64
|
+
subscribed = false;
|
|
65
|
+
connected = false;
|
|
66
|
+
release?.();
|
|
67
|
+
release = undefined;
|
|
68
|
+
callbacks.onStatusChange('disconnected');
|
|
69
|
+
},
|
|
70
|
+
send(message: TClientMessage) {
|
|
71
|
+
if (!connected || failed) return;
|
|
72
|
+
if (!bridge.send(JSON.stringify(message))) {
|
|
73
|
+
bridge.close();
|
|
74
|
+
close();
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useProductIdentity } from '../product-identity.js';
|
|
2
2
|
import { ArrowUp, Paperclip, Square, Target, X } from 'lucide-react';
|
|
3
|
-
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
|
3
|
+
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from 'react';
|
|
4
4
|
|
|
5
5
|
import { commandMenuFor } from '../hooks/command-menu.js';
|
|
6
6
|
import type { ICommandMenuItem } from '../hooks/command-menu.js';
|
|
@@ -131,9 +131,11 @@ function writeDraft(namespace: string, sessionId: string | undefined, value: ISt
|
|
|
131
131
|
* context). The row's controls run the session's own commands (`/provider`, `/mode`, `/effort`), so a
|
|
132
132
|
* setting changes through the one path every client shares.
|
|
133
133
|
*/
|
|
134
|
-
|
|
134
|
+
const ComposerInstance = forwardRef<
|
|
135
135
|
IComposerHandle,
|
|
136
136
|
{
|
|
137
|
+
/** Volatile drafts stay within this composer lifetime and never access persistent storage. */
|
|
138
|
+
draftMode?: 'persistent' | 'volatile';
|
|
137
139
|
onSubmit: (prompt: string) => void;
|
|
138
140
|
onCommand: (name: string, args?: string) => void;
|
|
139
141
|
catalog: TCommandCatalog | null;
|
|
@@ -177,6 +179,7 @@ export const Composer = forwardRef<
|
|
|
177
179
|
}
|
|
178
180
|
>(function Composer(
|
|
179
181
|
{
|
|
182
|
+
draftMode = 'persistent',
|
|
180
183
|
onSubmit,
|
|
181
184
|
onCommand,
|
|
182
185
|
catalog,
|
|
@@ -199,6 +202,20 @@ export const Composer = forwardRef<
|
|
|
199
202
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
200
203
|
useImperativeHandle(ref, () => ({ focus: () => textareaRef.current?.focus() }), []);
|
|
201
204
|
const sessionId = status?.sessionId;
|
|
205
|
+
const workspacePath = status?.workspace?.path;
|
|
206
|
+
const pickerGeneration = useRef(0);
|
|
207
|
+
const [fileInputEpoch, setFileInputEpoch] = useState(0);
|
|
208
|
+
const browserPickerOpen = useRef(false);
|
|
209
|
+
useLayoutEffect(() => {
|
|
210
|
+
pickerGeneration.current += 1;
|
|
211
|
+
browserPickerOpen.current = false;
|
|
212
|
+
return () => { pickerGeneration.current += 1; };
|
|
213
|
+
}, [sessionId, workspacePath]);
|
|
214
|
+
const invalidatePickers = (): void => {
|
|
215
|
+
pickerGeneration.current += 1;
|
|
216
|
+
browserPickerOpen.current = false;
|
|
217
|
+
setFileInputEpoch((epoch) => epoch + 1);
|
|
218
|
+
};
|
|
202
219
|
const sessionIdRef = useRef(sessionId);
|
|
203
220
|
// Whether a REAL session id has ever been seen. A switch goes A -> null -> B — `session_switched`
|
|
204
221
|
// clears `sessionStatus` before `get-status` answers (`useSessionClient.ts`) — so `sessionId` turns
|
|
@@ -211,7 +228,7 @@ export const Composer = forwardRef<
|
|
|
211
228
|
// state below by `setDraft`/`setAttachments`, the only two places that mutate it.
|
|
212
229
|
const stateRef = useRef<IStoredDraft>(EMPTY_DRAFT);
|
|
213
230
|
const [draft, setDraftState] = useState(() => {
|
|
214
|
-
const initial = readDraft(browserNamespace, sessionId);
|
|
231
|
+
const initial = draftMode === 'volatile' ? EMPTY_DRAFT : readDraft(browserNamespace, sessionId);
|
|
215
232
|
stateRef.current = initial;
|
|
216
233
|
return initial.text;
|
|
217
234
|
});
|
|
@@ -237,7 +254,7 @@ export const Composer = forwardRef<
|
|
|
237
254
|
const setDraft = (value: string): void => {
|
|
238
255
|
stateRef.current = { ...stateRef.current, text: value };
|
|
239
256
|
setDraftState(value);
|
|
240
|
-
writeDraft(browserNamespace, sessionIdRef.current, stateRef.current);
|
|
257
|
+
if (draftMode === 'persistent') writeDraft(browserNamespace, sessionIdRef.current, stateRef.current);
|
|
241
258
|
};
|
|
242
259
|
/** #3282 §4d: attachment chips persist alongside the text, under the same per-session draft key. */
|
|
243
260
|
const setAttachments = (
|
|
@@ -246,7 +263,7 @@ export const Composer = forwardRef<
|
|
|
246
263
|
const next = typeof updater === 'function' ? updater(stateRef.current.attachments) : updater;
|
|
247
264
|
stateRef.current = { ...stateRef.current, attachments: next };
|
|
248
265
|
setAttachmentsState(next);
|
|
249
|
-
writeDraft(browserNamespace, sessionIdRef.current, stateRef.current);
|
|
266
|
+
if (draftMode === 'persistent') writeDraft(browserNamespace, sessionIdRef.current, stateRef.current);
|
|
250
267
|
};
|
|
251
268
|
// #3280 §4: a session switch shows THAT session's own saved draft, never what was typed for
|
|
252
269
|
// another one. The session id becoming known for the very FIRST time (the fallback key was in use
|
|
@@ -256,6 +273,7 @@ export const Composer = forwardRef<
|
|
|
256
273
|
// arrives, so keystrokes typed during the round trip never land under the wrong session (or the
|
|
257
274
|
// fallback key).
|
|
258
275
|
useEffect(() => {
|
|
276
|
+
if (draftMode === 'volatile') return;
|
|
259
277
|
if (sessionId === undefined && hasKnownSessionRef.current) return;
|
|
260
278
|
if (sessionIdRef.current === sessionId) return;
|
|
261
279
|
const firstArrival = !hasKnownSessionRef.current;
|
|
@@ -274,24 +292,23 @@ export const Composer = forwardRef<
|
|
|
274
292
|
setDraftState(stored.text);
|
|
275
293
|
setAttachmentsState(stored.attachments);
|
|
276
294
|
setAttachmentNotice(null); // a notice belongs to the attempt just made in the session left behind
|
|
277
|
-
}, [browserNamespace, sessionId]);
|
|
295
|
+
}, [browserNamespace, draftMode, sessionId]);
|
|
278
296
|
|
|
279
297
|
const submit = (): void => {
|
|
280
298
|
if (!connected) return;
|
|
281
299
|
const text = draft.trim();
|
|
282
300
|
if (!text && attachments.length === 0) return;
|
|
301
|
+
invalidatePickers();
|
|
283
302
|
onSubmit(buildPromptWithAttachments(text, attachments));
|
|
284
303
|
setDraft('');
|
|
285
304
|
setAttachments([]);
|
|
286
305
|
setAttachmentNotice(null);
|
|
287
306
|
};
|
|
288
|
-
const workspacePath = status?.workspace?.path;
|
|
289
|
-
const totalAttachedBytes = attachments.reduce((sum, a) => sum + a.size, 0);
|
|
290
307
|
/** Evaluate every dropped/picked file in order, so a mixed batch attaches what it can. */
|
|
291
308
|
const addCandidates = (candidates: readonly ICandidateFile[]): void => {
|
|
292
309
|
if (candidates.length === 0) return;
|
|
293
|
-
let total =
|
|
294
|
-
let count = attachments.length;
|
|
310
|
+
let total = stateRef.current.attachments.reduce((sum, attachment) => sum + attachment.size, 0);
|
|
311
|
+
let count = stateRef.current.attachments.length;
|
|
295
312
|
const added: IDraftAttachment[] = [];
|
|
296
313
|
let notice: string | null = null;
|
|
297
314
|
for (const candidate of candidates) {
|
|
@@ -317,22 +334,37 @@ export const Composer = forwardRef<
|
|
|
317
334
|
absolutePath: getPathForFile ? getPathForFile(file) || undefined : undefined,
|
|
318
335
|
});
|
|
319
336
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
337
|
+
useEffect(() => {
|
|
338
|
+
const input = fileInputRef.current;
|
|
339
|
+
if (!input) return;
|
|
340
|
+
const cancel = (): void => { browserPickerOpen.current = false; };
|
|
341
|
+
input.addEventListener('cancel', cancel);
|
|
342
|
+
return () => { input.removeEventListener('cancel', cancel); };
|
|
343
|
+
}, [sessionId, workspacePath, fileInputEpoch]);
|
|
320
344
|
const handleAttachClick = (): void => {
|
|
321
345
|
if (!connected) return;
|
|
322
346
|
if (pickFiles) {
|
|
347
|
+
const generation = pickerGeneration.current;
|
|
323
348
|
pickFiles()
|
|
324
|
-
.then((picked) =>
|
|
325
|
-
|
|
326
|
-
|
|
349
|
+
.then((picked) => {
|
|
350
|
+
if (generation !== pickerGeneration.current) return;
|
|
351
|
+
addCandidates(picked.map((f) => ({ name: f.name, size: f.size, absolutePath: f.path })));
|
|
352
|
+
})
|
|
327
353
|
// The host's dialog IPC can reject (e.g. the window closed mid-pick) — say so rather than
|
|
328
354
|
// leaving an unhandled rejection and a button that silently did nothing (rule 5: never fail
|
|
329
355
|
// silently).
|
|
330
|
-
.catch(() =>
|
|
356
|
+
.catch(() => {
|
|
357
|
+
if (generation !== pickerGeneration.current) return;
|
|
358
|
+
setAttachmentNotice('Could not open the file picker. Try again.');
|
|
359
|
+
});
|
|
331
360
|
return;
|
|
332
361
|
}
|
|
362
|
+
if (browserPickerOpen.current) return;
|
|
363
|
+
browserPickerOpen.current = true;
|
|
333
364
|
fileInputRef.current?.click();
|
|
334
365
|
};
|
|
335
366
|
const handleFileInputChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
|
367
|
+
browserPickerOpen.current = false;
|
|
336
368
|
const files = Array.from(event.target.files ?? []);
|
|
337
369
|
event.target.value = ''; // allow picking the same (rejected) file again after fixing it
|
|
338
370
|
addCandidates(files.map(toCandidateFromFile));
|
|
@@ -365,6 +397,7 @@ export const Composer = forwardRef<
|
|
|
365
397
|
/** Choosing a command from the menu, by mouse or by keyboard (`acceptMenu` below). */
|
|
366
398
|
const chooseMenuItem = (item: ICommandMenuItem): void => {
|
|
367
399
|
if (isImmediateScreenCommand(item)) {
|
|
400
|
+
invalidatePickers();
|
|
368
401
|
setDraft('');
|
|
369
402
|
onCommand(item.name);
|
|
370
403
|
return;
|
|
@@ -541,6 +574,7 @@ export const Composer = forwardRef<
|
|
|
541
574
|
)}
|
|
542
575
|
<input
|
|
543
576
|
ref={fileInputRef}
|
|
577
|
+
key={JSON.stringify([sessionId ?? null, workspacePath ?? null, fileInputEpoch])}
|
|
544
578
|
type="file"
|
|
545
579
|
multiple
|
|
546
580
|
onChange={handleFileInputChange}
|
|
@@ -637,6 +671,16 @@ export const Composer = forwardRef<
|
|
|
637
671
|
);
|
|
638
672
|
});
|
|
639
673
|
|
|
674
|
+
/** Volatile state belongs to one session and never crosses a mode or session boundary. */
|
|
675
|
+
export const Composer = forwardRef<IComposerHandle, React.ComponentProps<typeof ComposerInstance>>(
|
|
676
|
+
function Composer(props, ref) {
|
|
677
|
+
const key = props.draftMode === 'volatile'
|
|
678
|
+
? JSON.stringify(['volatile', props.status?.sessionId ?? null])
|
|
679
|
+
: 'persistent';
|
|
680
|
+
return <ComposerInstance {...props} key={key} ref={ref} />;
|
|
681
|
+
},
|
|
682
|
+
);
|
|
683
|
+
|
|
640
684
|
/**
|
|
641
685
|
* The goal being pursued (`/goal`), above the composer while it is active — the objective, how far
|
|
642
686
|
* the turn budget has gone, and a way to stop it (`/goal cancel`).
|
|
@@ -15,6 +15,8 @@ const STATUS_TAG_TONE: Record<string, string> = {
|
|
|
15
15
|
muted: 'bg-subtle/15 text-subtle',
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
const FOLLOW_BOTTOM_MARGIN_PX = 32;
|
|
19
|
+
|
|
18
20
|
const RECORD_KIND_LABEL: Record<IExecutionDetailRecord['kind'], string> = {
|
|
19
21
|
message: '',
|
|
20
22
|
tool_activity: 'Tool',
|
|
@@ -39,6 +41,13 @@ export function ExecutionDetailSheet({
|
|
|
39
41
|
onClose,
|
|
40
42
|
onLoadMore,
|
|
41
43
|
onStop,
|
|
44
|
+
onSend,
|
|
45
|
+
inputState,
|
|
46
|
+
onContinue,
|
|
47
|
+
mode = 'live',
|
|
48
|
+
unreadable,
|
|
49
|
+
onShowEarlier,
|
|
50
|
+
onShowLive,
|
|
42
51
|
}: {
|
|
43
52
|
entry: IExecutionWorkspaceEntry | null;
|
|
44
53
|
status: TExecutionDetailStatus;
|
|
@@ -48,10 +57,45 @@ export function ExecutionDetailSheet({
|
|
|
48
57
|
onClose: () => void;
|
|
49
58
|
onLoadMore: () => void;
|
|
50
59
|
onStop?: () => void;
|
|
60
|
+
onSend?: (prompt: string) => void;
|
|
61
|
+
inputState?: { taskId: string; status: 'pending' | 'sent' | 'failed' };
|
|
62
|
+
onContinue?: () => void;
|
|
63
|
+
mode?: 'live' | 'transcript';
|
|
64
|
+
unreadable?: { locked: number; noKeys: number; doesNotOpen: number };
|
|
65
|
+
onShowEarlier?: () => void;
|
|
66
|
+
onShowLive?: () => void;
|
|
51
67
|
}): React.ReactElement | null {
|
|
68
|
+
const [prompt, setPrompt] = React.useState('');
|
|
69
|
+
const submitted = React.useRef<string | null>(null);
|
|
70
|
+
const outputRef = React.useRef<HTMLDivElement | null>(null);
|
|
71
|
+
const followingRef = React.useRef(true);
|
|
72
|
+
const isFork = Boolean(entry?.resumeSessionId && entry.controls.includes('attach'));
|
|
73
|
+
React.useLayoutEffect(() => {
|
|
74
|
+
followingRef.current = true;
|
|
75
|
+
}, [entry?.id, mode]);
|
|
76
|
+
React.useLayoutEffect(() => {
|
|
77
|
+
const output = outputRef.current;
|
|
78
|
+
if (output && isFork && mode === 'live' && followingRef.current) {
|
|
79
|
+
output.scrollTop = output.scrollHeight;
|
|
80
|
+
}
|
|
81
|
+
}, [entry?.id, isFork, mode, records]);
|
|
82
|
+
React.useEffect(() => {
|
|
83
|
+
setPrompt('');
|
|
84
|
+
submitted.current = null;
|
|
85
|
+
}, [entry?.id]);
|
|
86
|
+
React.useEffect(() => {
|
|
87
|
+
if (!entry || inputState?.taskId !== entry.sourceId || inputState.status === 'pending') return;
|
|
88
|
+
if (inputState.status === 'sent' && submitted.current !== null) {
|
|
89
|
+
const sent = submitted.current;
|
|
90
|
+
setPrompt((current) => (current.trim() === sent ? '' : current));
|
|
91
|
+
}
|
|
92
|
+
submitted.current = null;
|
|
93
|
+
}, [entry, inputState]);
|
|
52
94
|
if (!entry) return null;
|
|
53
95
|
const described = describeExecutionEntryStatus(entry);
|
|
54
96
|
const canStop = onStop !== undefined && entry.controls.includes('cancel');
|
|
97
|
+
const ended =
|
|
98
|
+
entry.status === 'completed' || entry.status === 'failed' || entry.status === 'cancelled';
|
|
55
99
|
const askedLine = entry.headline?.text ?? entry.preview;
|
|
56
100
|
|
|
57
101
|
return (
|
|
@@ -105,11 +149,39 @@ export function ExecutionDetailSheet({
|
|
|
105
149
|
)}
|
|
106
150
|
{entry.deniedToolCalls !== undefined && entry.deniedToolCalls > 0 && (
|
|
107
151
|
<p className="flex-shrink-0 px-4 pt-2 text-[12px] text-warning">
|
|
108
|
-
{entry.deniedToolCalls} tool {entry.deniedToolCalls === 1 ? 'call was' : 'calls were'}
|
|
152
|
+
{entry.deniedToolCalls} tool {entry.deniedToolCalls === 1 ? 'call was' : 'calls were'}{' '}
|
|
153
|
+
refused.
|
|
109
154
|
</p>
|
|
110
155
|
)}
|
|
111
156
|
|
|
112
|
-
|
|
157
|
+
{isFork && onShowEarlier && onShowLive && (
|
|
158
|
+
<div className="flex gap-3 border-b border-subtle/15 px-4 py-2 text-[12px]">
|
|
159
|
+
<button type="button" onClick={onShowLive} disabled={mode === 'live'}>
|
|
160
|
+
Live output
|
|
161
|
+
</button>
|
|
162
|
+
<button type="button" onClick={onShowEarlier} disabled={mode === 'transcript'}>
|
|
163
|
+
Earlier output
|
|
164
|
+
</button>
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
<div
|
|
168
|
+
ref={outputRef}
|
|
169
|
+
role={isFork ? (mode === 'live' ? 'log' : 'region') : undefined}
|
|
170
|
+
aria-label={
|
|
171
|
+
isFork ? (mode === 'live' ? 'Live fork output' : 'Earlier fork output') : undefined
|
|
172
|
+
}
|
|
173
|
+
aria-live={isFork && mode === 'live' ? 'polite' : undefined}
|
|
174
|
+
aria-relevant={isFork && mode === 'live' ? 'additions text' : undefined}
|
|
175
|
+
aria-atomic={isFork && mode === 'live' ? false : undefined}
|
|
176
|
+
tabIndex={isFork ? 0 : undefined}
|
|
177
|
+
onScroll={(event) => {
|
|
178
|
+
if (!isFork || mode !== 'live') return;
|
|
179
|
+
const output = event.currentTarget;
|
|
180
|
+
followingRef.current =
|
|
181
|
+
output.scrollHeight - output.scrollTop - output.clientHeight <= FOLLOW_BOTTOM_MARGIN_PX;
|
|
182
|
+
}}
|
|
183
|
+
className="flex-1 space-y-2 overflow-y-auto px-4 py-3"
|
|
184
|
+
>
|
|
113
185
|
{records.map((record) => (
|
|
114
186
|
<div key={record.id} className="text-[13px] leading-relaxed text-foreground">
|
|
115
187
|
{RECORD_KIND_LABEL[record.kind] && (
|
|
@@ -124,7 +196,13 @@ export function ExecutionDetailSheet({
|
|
|
124
196
|
<p className="text-[13px] text-subtle">Loading…</p>
|
|
125
197
|
)}
|
|
126
198
|
{error && <p className="text-[13px] text-destructive">{error}</p>}
|
|
127
|
-
{
|
|
199
|
+
{unreadable && (
|
|
200
|
+
<p className="text-[12px] text-warning">
|
|
201
|
+
Some records could not be read: {unreadable.locked} locked, {unreadable.noKeys} without
|
|
202
|
+
keys, {unreadable.doesNotOpen} unavailable.
|
|
203
|
+
</p>
|
|
204
|
+
)}
|
|
205
|
+
{(!isFork || mode === 'transcript') && !complete && status !== 'loading' && (
|
|
128
206
|
<button
|
|
129
207
|
type="button"
|
|
130
208
|
onClick={onLoadMore}
|
|
@@ -133,10 +211,42 @@ export function ExecutionDetailSheet({
|
|
|
133
211
|
Load more
|
|
134
212
|
</button>
|
|
135
213
|
)}
|
|
136
|
-
{!
|
|
137
|
-
|
|
138
|
-
|
|
214
|
+
{(!isFork || mode === 'transcript') &&
|
|
215
|
+
!complete &&
|
|
216
|
+
status === 'loading' &&
|
|
217
|
+
records.length > 0 && <p className="text-[12px] text-subtle">Loading more…</p>}
|
|
139
218
|
</div>
|
|
219
|
+
{isFork && !ended && entry.controls.includes('send') && onSend && (
|
|
220
|
+
<form
|
|
221
|
+
className="border-t border-subtle/15 p-3"
|
|
222
|
+
onSubmit={(event) => {
|
|
223
|
+
event.preventDefault();
|
|
224
|
+
if (!prompt.trim() || submitted.current !== null || inputState?.status === 'pending')
|
|
225
|
+
return;
|
|
226
|
+
submitted.current = prompt.trim();
|
|
227
|
+
onSend(submitted.current);
|
|
228
|
+
}}
|
|
229
|
+
>
|
|
230
|
+
<textarea
|
|
231
|
+
aria-label="Message the fork"
|
|
232
|
+
value={prompt}
|
|
233
|
+
onChange={(event) => setPrompt(event.target.value)}
|
|
234
|
+
className="w-full rounded border border-subtle/20 bg-background p-2 text-foreground"
|
|
235
|
+
/>
|
|
236
|
+
<button type="submit" disabled={!prompt.trim() || inputState?.status === 'pending'}>
|
|
237
|
+
Send to fork
|
|
238
|
+
</button>
|
|
239
|
+
</form>
|
|
240
|
+
)}
|
|
241
|
+
{isFork && ended && onContinue && (
|
|
242
|
+
<button
|
|
243
|
+
type="button"
|
|
244
|
+
onClick={onContinue}
|
|
245
|
+
className="border-t border-subtle/15 p-3 text-foreground"
|
|
246
|
+
>
|
|
247
|
+
Continue as session
|
|
248
|
+
</button>
|
|
249
|
+
)}
|
|
140
250
|
</div>
|
|
141
251
|
);
|
|
142
252
|
}
|
|
@@ -107,6 +107,7 @@ export function SessionSurface({
|
|
|
107
107
|
state,
|
|
108
108
|
surface,
|
|
109
109
|
personalUsageEnabled = false,
|
|
110
|
+
draftMode = 'persistent',
|
|
110
111
|
onReconnect,
|
|
111
112
|
pickFiles,
|
|
112
113
|
getPathForFile,
|
|
@@ -117,6 +118,8 @@ export function SessionSurface({
|
|
|
117
118
|
surface?: string;
|
|
118
119
|
/** Desktop shells opt in; embedded/session-only surfaces keep their existing chat-only contract. */
|
|
119
120
|
personalUsageEnabled?: boolean;
|
|
121
|
+
/** Secure desktop discards unsent text and attachment paths when the session closes. */
|
|
122
|
+
draftMode?: 'persistent' | 'volatile';
|
|
120
123
|
/**
|
|
121
124
|
* Present only when the host can restart the runtime after the connection is lost for good (issue
|
|
122
125
|
* #3280 §5, the desktop app); absent in the browser, where the banner says how to reopen it instead.
|
|
@@ -356,6 +359,7 @@ export function SessionSurface({
|
|
|
356
359
|
{setupRequired ? null : (
|
|
357
360
|
<Composer
|
|
358
361
|
ref={composerRef}
|
|
362
|
+
draftMode={draftMode}
|
|
359
363
|
catalog={state.commandCatalog ?? null}
|
|
360
364
|
status={state.sessionStatus ?? null}
|
|
361
365
|
connected={state.status === 'connected'}
|
|
@@ -425,6 +429,28 @@ export function SessionSurface({
|
|
|
425
429
|
onClose={() => state.closeExecutionDetail()}
|
|
426
430
|
onLoadMore={() => state.loadMoreExecutionDetail()}
|
|
427
431
|
onStop={openEntry ? () => stopExecutionEntry(openEntry) : undefined}
|
|
432
|
+
inputState={state.executionDetailInput}
|
|
433
|
+
mode={state.executionDetailMode}
|
|
434
|
+
unreadable={state.executionDetailUnreadable}
|
|
435
|
+
onShowEarlier={state.showEarlierExecutionDetail}
|
|
436
|
+
onShowLive={state.showLiveExecutionDetail}
|
|
437
|
+
onSend={
|
|
438
|
+
openEntry
|
|
439
|
+
? (prompt) =>
|
|
440
|
+
state.send({
|
|
441
|
+
type: 'send-background-task',
|
|
442
|
+
taskId: openEntry.sourceId,
|
|
443
|
+
input: { prompt },
|
|
444
|
+
})
|
|
445
|
+
: undefined
|
|
446
|
+
}
|
|
447
|
+
onContinue={
|
|
448
|
+
openEntry?.resumeSessionId
|
|
449
|
+
? () => {
|
|
450
|
+
if (openEntry.resumeSessionId) state.switchSession(openEntry.resumeSessionId);
|
|
451
|
+
}
|
|
452
|
+
: undefined
|
|
453
|
+
}
|
|
428
454
|
/>
|
|
429
455
|
</div>
|
|
430
456
|
)}
|
|
@@ -286,6 +286,11 @@ export interface IWsSessionState<TStatus extends string = TConnectionStatus> {
|
|
|
286
286
|
executionDetailError: string | null;
|
|
287
287
|
/** True once the last page read had no further cursor — nothing more to load. */
|
|
288
288
|
executionDetailComplete: boolean;
|
|
289
|
+
executionDetailInput?: { taskId: string; status: 'pending' | 'sent' | 'failed' };
|
|
290
|
+
executionDetailMode?: 'live' | 'transcript';
|
|
291
|
+
executionDetailUnreadable?: { locked: number; noKeys: number; doesNotOpen: number };
|
|
292
|
+
showEarlierExecutionDetail?: () => void;
|
|
293
|
+
showLiveExecutionDetail?: () => void;
|
|
289
294
|
openExecutionDetail: (entryId: string) => void;
|
|
290
295
|
loadMoreExecutionDetail: () => void;
|
|
291
296
|
closeExecutionDetail: () => void;
|