@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.
@@ -1,33 +1,29 @@
1
- /**
2
- * #3288 §1: the detail sheet's own correlated request/response state — which entry is open, its
3
- * accumulated transcript records, and whether more can be loaded. Owned independently from chat
4
- * state, the same way `use-personal-usage.ts` owns the usage-report protocol: one `requestId` per
5
- * outstanding read, so a stale reply for an entry the operator already closed (or a page they moved
6
- * past) is dropped rather than clobbering what is now on screen.
7
- */
8
-
9
- import { useCallback, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
10
2
 
11
3
  import type { TClientMessage } from '../client/ws-session-client.js';
12
4
  import type {
5
+ IBackgroundTaskLogUnreadable,
13
6
  IExecutionDetailCursor,
14
7
  IExecutionDetailRecord,
8
+ IExecutionWorkspaceSnapshot,
15
9
  } from '@robota-sdk/agent-interface-execution';
16
10
  import type { TServerMessage } from '@robota-sdk/agent-transport';
17
11
 
18
12
  export type TExecutionDetailStatus = 'idle' | 'loading' | 'ready' | 'error';
13
+ type TDetailMode = 'live' | 'transcript';
19
14
 
20
15
  export interface IExecutionDetailState {
21
- /** The entry currently open in the detail sheet, or null when it is closed. */
22
16
  openEntryId: string | null;
23
17
  executionDetailStatus: TExecutionDetailStatus;
24
- /** Every record read so far for the open entry, oldest first — pages accumulate via loadMoreExecutionDetail. */
25
18
  executionDetailRecords: readonly IExecutionDetailRecord[];
26
19
  executionDetailError: string | null;
27
- /** True once a page came back with no `nextCursor` — nothing more to load. */
28
20
  executionDetailComplete: boolean;
21
+ executionDetailMode: TDetailMode;
22
+ executionDetailUnreadable: IBackgroundTaskLogUnreadable | undefined;
29
23
  openExecutionDetail: (entryId: string) => void;
30
24
  loadMoreExecutionDetail: () => void;
25
+ showEarlierExecutionDetail: () => void;
26
+ showLiveExecutionDetail: () => void;
31
27
  closeExecutionDetail: () => void;
32
28
  }
33
29
 
@@ -39,37 +35,65 @@ function nextRequestId(): string {
39
35
 
40
36
  export function useExecutionDetailState(
41
37
  send: (msg: TClientMessage) => void,
42
- ): IExecutionDetailState & { handleExecutionDetailMessage: (msg: TServerMessage) => boolean } {
38
+ ): IExecutionDetailState & {
39
+ handleExecutionDetailMessage: (msg: TServerMessage) => boolean;
40
+ handleExecutionWorkspace: (snapshot: IExecutionWorkspaceSnapshot) => void;
41
+ } {
43
42
  const [openEntryId, setOpenEntryId] = useState<string | null>(null);
44
43
  const [status, setStatus] = useState<TExecutionDetailStatus>('idle');
45
44
  const [records, setRecords] = useState<readonly IExecutionDetailRecord[]>([]);
46
45
  const [error, setError] = useState<string | null>(null);
47
46
  const [complete, setComplete] = useState(false);
47
+ const [mode, setMode] = useState<TDetailMode>('live');
48
+ const [unreadable, setUnreadable] = useState<IBackgroundTaskLogUnreadable>();
48
49
  const requestIdRef = useRef<string | null>(null);
49
50
  const entryIdRef = useRef<string | null>(null);
51
+ const modeRef = useRef<TDetailMode>('live');
50
52
  const nextCursorRef = useRef<IExecutionDetailCursor | undefined>(undefined);
53
+ const queuedRef = useRef<{ cursor?: IExecutionDetailCursor; refresh: boolean } | null>(null);
54
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
55
+ const lastReadAtRef = useRef(0);
51
56
 
52
57
  const readPage = useCallback(
53
- (entryId: string, cursor?: IExecutionDetailCursor): void => {
58
+ (entryId: string, cursor?: IExecutionDetailCursor, refresh = false): void => {
59
+ if (requestIdRef.current !== null) {
60
+ if (!refresh || queuedRef.current === null || queuedRef.current.refresh) {
61
+ queuedRef.current = { cursor, refresh };
62
+ }
63
+ return;
64
+ }
54
65
  const requestId = nextRequestId();
55
66
  requestIdRef.current = requestId;
56
- entryIdRef.current = entryId;
57
- setStatus('loading');
67
+ lastReadAtRef.current = Date.now();
68
+ if (!refresh) setStatus('loading');
58
69
  setError(null);
59
70
  send({ type: 'read-execution-detail', requestId, entryId, cursor });
60
71
  },
61
72
  [send],
62
73
  );
63
74
 
75
+ const clearPending = useCallback(() => {
76
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
77
+ timerRef.current = null;
78
+ queuedRef.current = null;
79
+ }, []);
80
+ useEffect(() => clearPending, [clearPending]);
81
+
64
82
  const openExecutionDetail = useCallback(
65
83
  (entryId: string): void => {
84
+ clearPending();
85
+ requestIdRef.current = null;
86
+ entryIdRef.current = entryId;
87
+ modeRef.current = 'live';
88
+ setMode('live');
66
89
  setOpenEntryId(entryId);
67
90
  setRecords([]);
91
+ setUnreadable(undefined);
68
92
  setComplete(false);
69
93
  nextCursorRef.current = undefined;
70
94
  readPage(entryId);
71
95
  },
72
- [readPage],
96
+ [clearPending, readPage],
73
97
  );
74
98
 
75
99
  const loadMoreExecutionDetail = useCallback((): void => {
@@ -77,32 +101,103 @@ export function useExecutionDetailState(
77
101
  readPage(openEntryId, nextCursorRef.current);
78
102
  }, [openEntryId, status, complete, readPage]);
79
103
 
104
+ const changeMode = useCallback(
105
+ (next: TDetailMode): void => {
106
+ const entryId = entryIdRef.current;
107
+ if (!entryId) return;
108
+ clearPending();
109
+ modeRef.current = next;
110
+ setMode(next);
111
+ setRecords([]);
112
+ setUnreadable(undefined);
113
+ setComplete(false);
114
+ setStatus('loading');
115
+ nextCursorRef.current = undefined;
116
+ readPage(entryId, next === 'transcript' ? { offset: 0 } : undefined);
117
+ },
118
+ [clearPending, readPage],
119
+ );
120
+
121
+ const showEarlierExecutionDetail = useCallback(() => changeMode('transcript'), [changeMode]);
122
+ const showLiveExecutionDetail = useCallback(() => changeMode('live'), [changeMode]);
123
+
80
124
  const closeExecutionDetail = useCallback((): void => {
125
+ clearPending();
81
126
  setOpenEntryId(null);
82
127
  requestIdRef.current = null;
83
128
  entryIdRef.current = null;
84
129
  setRecords([]);
130
+ setUnreadable(undefined);
85
131
  setStatus('idle');
86
132
  setError(null);
87
133
  setComplete(false);
88
- }, []);
134
+ }, [clearPending]);
135
+
136
+ const scheduleRefresh = useCallback(
137
+ (entryId: string): void => {
138
+ if (timerRef.current !== null) return;
139
+ const delay = Math.max(0, 250 - (Date.now() - lastReadAtRef.current));
140
+ timerRef.current = setTimeout(() => {
141
+ timerRef.current = null;
142
+ if (entryIdRef.current === entryId && modeRef.current === 'live') {
143
+ readPage(entryId, undefined, true);
144
+ }
145
+ }, delay);
146
+ },
147
+ [readPage],
148
+ );
89
149
 
90
- const handleExecutionDetailMessage = useCallback((msg: TServerMessage): boolean => {
91
- if (msg.type !== 'execution_detail' && msg.type !== 'execution_detail_error') return false;
92
- if (requestIdRef.current !== msg.requestId) return true; // A stale/superseded read — ignored.
93
- if (msg.type === 'execution_detail_error') {
94
- setStatus('error');
95
- setError(msg.message);
150
+ const handleExecutionWorkspace = useCallback(
151
+ (snapshot: IExecutionWorkspaceSnapshot): void => {
152
+ const entryId = entryIdRef.current;
153
+ if (!entryId || modeRef.current !== 'live') return;
154
+ const entry = snapshot.entries.find((item) => item.id === entryId);
155
+ if (entry?.resumeSessionId && entry.controls.includes('attach')) scheduleRefresh(entryId);
156
+ },
157
+ [scheduleRefresh],
158
+ );
159
+
160
+ const handleExecutionDetailMessage = useCallback(
161
+ (msg: TServerMessage): boolean => {
162
+ if (msg.type !== 'execution_detail' && msg.type !== 'execution_detail_error') return false;
163
+ if (requestIdRef.current !== msg.requestId) return true;
164
+ requestIdRef.current = null;
165
+ const queued = queuedRef.current;
166
+ queuedRef.current = null;
167
+ if (!queued || queued.refresh) {
168
+ if (msg.type === 'execution_detail_error') {
169
+ setStatus('error');
170
+ setError(msg.message);
171
+ } else {
172
+ setRecords((previous) =>
173
+ msg.page.cursor === undefined || msg.page.cursor.offset === 0
174
+ ? msg.page.records
175
+ : [...previous, ...msg.page.records],
176
+ );
177
+ setUnreadable((previous) => {
178
+ const current = msg.page.unreadable;
179
+ if (msg.page.cursor === undefined || msg.page.cursor.offset === 0) return current;
180
+ if (!previous) return current;
181
+ if (!current) return previous;
182
+ return {
183
+ locked: previous.locked + current.locked,
184
+ noKeys: previous.noKeys + current.noKeys,
185
+ doesNotOpen: previous.doesNotOpen + current.doesNotOpen,
186
+ };
187
+ });
188
+ nextCursorRef.current = msg.page.nextCursor;
189
+ setComplete(msg.page.nextCursor === undefined);
190
+ setStatus('ready');
191
+ }
192
+ }
193
+ if (queued && entryIdRef.current) {
194
+ if (queued.refresh) scheduleRefresh(entryIdRef.current);
195
+ else readPage(entryIdRef.current, queued.cursor);
196
+ }
96
197
  return true;
97
- }
98
- setRecords((previous) =>
99
- msg.page.cursor === undefined ? msg.page.records : [...previous, ...msg.page.records],
100
- );
101
- nextCursorRef.current = msg.page.nextCursor;
102
- setComplete(msg.page.nextCursor === undefined);
103
- setStatus('ready');
104
- return true;
105
- }, []);
198
+ },
199
+ [readPage, scheduleRefresh],
200
+ );
106
201
 
107
202
  return {
108
203
  openEntryId,
@@ -110,9 +205,14 @@ export function useExecutionDetailState(
110
205
  executionDetailRecords: records,
111
206
  executionDetailError: error,
112
207
  executionDetailComplete: complete,
208
+ executionDetailMode: mode,
209
+ executionDetailUnreadable: unreadable,
113
210
  openExecutionDetail,
114
211
  loadMoreExecutionDetail,
212
+ showEarlierExecutionDetail,
213
+ showLiveExecutionDetail,
115
214
  closeExecutionDetail,
116
215
  handleExecutionDetailMessage,
216
+ handleExecutionWorkspace,
117
217
  };
118
218
  }
@@ -368,8 +368,18 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
368
368
  // Their request ids, for this connection: a refusal names the request it answers. A switch
369
369
  // answers with no id, so an id that succeeded stays here until the connection is replaced.
370
370
  const sessionChangeRequestIdsRef = useRef(new Set<string>());
371
+ const [executionDetailInput, setExecutionDetailInput] = useState<{
372
+ taskId: string;
373
+ status: 'pending' | 'sent' | 'failed';
374
+ }>();
375
+ const executionDetailInputRef = useRef<string | null>(null);
371
376
  const send = useCallback(
372
377
  (msg: TClientMessage): void => {
378
+ if (msg.type === 'send-background-task') {
379
+ if (executionDetailInputRef.current !== null) return;
380
+ executionDetailInputRef.current = msg.taskId;
381
+ setExecutionDetailInput({ taskId: msg.taskId, status: 'pending' });
382
+ }
373
383
  if (msg.type === 'command') {
374
384
  // #3282 §4e: `/help` is the GUI's own Help sheet — never sent to the session.
375
385
  if (msg.name === 'help') {
@@ -433,8 +443,12 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
433
443
  [send],
434
444
  );
435
445
  const { handleUsageMessage, ...personalUsageState } = usePersonalUsageState(send);
436
- const { handleExecutionDetailMessage, closeExecutionDetail, ...executionDetailState } =
437
- useExecutionDetailState(send);
446
+ const {
447
+ handleExecutionDetailMessage,
448
+ handleExecutionWorkspace,
449
+ closeExecutionDetail,
450
+ ...executionDetailState
451
+ } = useExecutionDetailState(send);
438
452
  const { handleModelListMessage, ...modelListState } = useModelListState(send);
439
453
  const { handleProjectMessage, ...projectPanelState } = useProjectPanelState(send);
440
454
  const isConnected = useCallback((): boolean => statusRef.current === 'connected', []);
@@ -465,7 +479,9 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
465
479
  const { handleSchedulesMessage, requestSchedules, ...schedulesState } = useSchedulesState(send);
466
480
 
467
481
  const clearCompactionNotices = useCallback((): void => {
468
- setSessionNotices((previous) => previous.filter((notice) => notice.kind !== 'context-compacted'));
482
+ setSessionNotices((previous) =>
483
+ previous.filter((notice) => notice.kind !== 'context-compacted'),
484
+ );
469
485
  }, []);
470
486
 
471
487
  const handleMessage = useCallback(
@@ -552,19 +568,29 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
552
568
  break;
553
569
  }
554
570
  case 'execution_workspace_event': {
571
+ handleExecutionWorkspace(msg.snapshot);
555
572
  setExecutionWorkspace(msg.snapshot);
556
573
  break;
557
574
  }
558
575
  // #3288 §1: a Stop that did not take effect (e.g. the task/loop had already finished) is
559
576
  // worth telling the operator; a success shows itself through the snapshot update above.
560
577
  case 'background_task_control_result': {
578
+ if (msg.action === 'send' && executionDetailInputRef.current === msg.taskId) {
579
+ executionDetailInputRef.current = null;
580
+ setExecutionDetailInput({
581
+ taskId: msg.taskId,
582
+ status: msg.success ? 'sent' : 'failed',
583
+ });
584
+ }
561
585
  if (!msg.success) {
562
586
  setSessionNotices((previous) => [
563
587
  ...previous,
564
588
  {
565
589
  id: nextId(),
566
590
  kind: 'background-task-control-failed',
567
- message: msg.message ?? `Could not stop ${msg.taskId}.`,
591
+ message:
592
+ msg.message ??
593
+ `Could not ${msg.action === 'send' ? 'send to' : 'stop'} ${msg.taskId}.`,
568
594
  },
569
595
  ]);
570
596
  }
@@ -765,6 +791,13 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
765
791
  break;
766
792
  }
767
793
  case 'protocol_error': {
794
+ if (
795
+ executionDetailInputRef.current !== null &&
796
+ msg.message === 'Not permitted for an observer: send-background-task'
797
+ ) {
798
+ setExecutionDetailInput({ taskId: executionDetailInputRef.current, status: 'failed' });
799
+ executionDetailInputRef.current = null;
800
+ }
768
801
  if (sessionChangesInFlightRef.current > 0) {
769
802
  // A host older than `session_change_failed` refuses a session change with this.
770
803
  settleSessionChange();
@@ -911,6 +944,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
911
944
  handleAgentDefinitionsMessage,
912
945
  handleAppViewMessage,
913
946
  handleExecutionDetailMessage,
947
+ handleExecutionWorkspace,
914
948
  handleModelListMessage,
915
949
  handleProjectMessage,
916
950
  handleSchedulesMessage,
@@ -954,6 +988,11 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
954
988
  if (next !== 'connected') {
955
989
  rejectPendingAppRequests();
956
990
  clearCompactionNotices();
991
+ closeExecutionDetail();
992
+ if (executionDetailInputRef.current !== null) {
993
+ setExecutionDetailInput({ taskId: executionDetailInputRef.current, status: 'failed' });
994
+ executionDetailInputRef.current = null;
995
+ }
957
996
  }
958
997
  // Every client learns what it can offer and what is in effect as soon as it is attached.
959
998
  if (next === 'connected') {
@@ -988,6 +1027,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
988
1027
  makeClient,
989
1028
  handleMessage,
990
1029
  clearCompactionNotices,
1030
+ closeExecutionDetail,
991
1031
  requestSessions,
992
1032
  requestSchedules,
993
1033
  armRestore,
@@ -1013,6 +1053,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
1013
1053
  answerAsk,
1014
1054
  ...personalUsageState,
1015
1055
  ...executionDetailState,
1056
+ executionDetailInput,
1016
1057
  closeExecutionDetail,
1017
1058
  ...modelListState,
1018
1059
  ...projectPanelState,
package/src/index.ts CHANGED
@@ -29,6 +29,8 @@ export { rememberSessionForRestore } from './hooks/use-session-directory.js';
29
29
 
30
30
  // ── WS session client (loopback / localhost) ────────────────
31
31
  export { createWsSessionClient } from './client/ws-session-client.js';
32
+ export { createDesktopSessionClient } from './client/desktop-session-client.js';
33
+ export type { IDesktopSessionBridge } from './client/desktop-session-client.js';
32
34
  export type { IWsSessionClientCallbacks, TConnectionStatus } from './client/ws-session-client.js';
33
35
  export { resolveClientRuntimeHost } from './client/runtime-host.js';
34
36
  export type {