@robota-sdk/agent-ui-web 3.1.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-ui-web",
3
- "version": "3.1.2",
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-command": "3.1.2",
64
- "@robota-sdk/agent-interface-execution": "3.1.2",
65
- "@robota-sdk/agent-interface-session": "3.1.2",
66
- "@robota-sdk/agent-interface-transport": "3.1.2",
67
- "@robota-sdk/agent-transport": "3.1.2"
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
- export const Composer = forwardRef<
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 = totalAttachedBytes;
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
- addCandidates(picked.map((f) => ({ name: f.name, size: f.size, absolutePath: f.path }))),
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(() => setAttachmentNotice('Could not open the file picker. Try again.'));
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'} refused.
152
+ {entry.deniedToolCalls} tool {entry.deniedToolCalls === 1 ? 'call was' : 'calls were'}{' '}
153
+ refused.
109
154
  </p>
110
155
  )}
111
156
 
112
- <div className="flex-1 space-y-2 overflow-y-auto px-4 py-3">
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
- {!complete && status !== 'loading' && (
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
- {!complete && status === 'loading' && records.length > 0 && (
137
- <p className="text-[12px] text-subtle">Loading more…</p>
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;