@robota-sdk/agent-ui-web 3.1.2 → 3.3.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/CHANGELOG.md +43 -0
- package/dist/index.cjs +475 -97
- package/dist/index.d.cts +31 -3
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +31 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +476 -99
- package/dist/index.js.map +1 -1
- package/docs/SPEC.md +5 -0
- package/package.json +6 -6
- package/src/client/desktop-session-client.ts +78 -0
- package/src/components/AgentActivityPanel.tsx +38 -31
- package/src/components/Composer.tsx +71 -24
- package/src/components/ExecutionDetailSheet.tsx +116 -6
- package/src/components/SessionSurface.tsx +89 -36
- package/src/hooks/server-message-handling.ts +2 -0
- package/src/hooks/session-client-types.ts +12 -1
- package/src/hooks/use-app-view-requests.ts +8 -1
- package/src/hooks/use-execution-detail.ts +133 -33
- package/src/hooks/use-session-directory.ts +7 -2
- package/src/hooks/useSessionClient.ts +81 -9
- package/src/index.ts +2 -0
|
@@ -106,7 +106,8 @@ function SetupPanel({
|
|
|
106
106
|
export function SessionSurface({
|
|
107
107
|
state,
|
|
108
108
|
surface,
|
|
109
|
-
personalUsageEnabled = false,
|
|
109
|
+
personalUsageEnabled: personalUsageRequested = 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.
|
|
@@ -135,6 +138,8 @@ export function SessionSurface({
|
|
|
135
138
|
|
|
136
139
|
appViewSandboxUrl?: string;
|
|
137
140
|
}): React.ReactElement {
|
|
141
|
+
// An observer sees the session but changes nothing; the host stays the authority, this is presentation.
|
|
142
|
+
const readOnly = state.role === 'observe';
|
|
138
143
|
const [view, setView] = useState<TSessionView>('chat');
|
|
139
144
|
const { identity } = useProductIdentity();
|
|
140
145
|
const appViewHost = useMemo(
|
|
@@ -147,6 +152,8 @@ export function SessionSurface({
|
|
|
147
152
|
}),
|
|
148
153
|
[appViewSandboxUrl, state.readAppView, state.callAppTool, identity.displayName, surface],
|
|
149
154
|
);
|
|
155
|
+
// The personal usage read is not admitted for an observer.
|
|
156
|
+
const personalUsageEnabled = personalUsageRequested && !readOnly;
|
|
150
157
|
const composerRef = useRef<IComposerHandle>(null);
|
|
151
158
|
const tasks = state.executionWorkspace?.entries ?? [];
|
|
152
159
|
// The main thread alone is this conversation; the rail earns its width only for work beside it.
|
|
@@ -177,8 +184,10 @@ export function SessionSurface({
|
|
|
177
184
|
// #3282 §3: no provider is configured — the session is running (a placeholder stands in for the
|
|
178
185
|
// real one), but there is nothing to reply with yet.
|
|
179
186
|
const setupRequired = state.sessionStatus?.setupRequired === true;
|
|
187
|
+
// Switching, starting, renaming and deleting sessions are driver actions: an observer has no sidebar.
|
|
180
188
|
const hasSessionList =
|
|
181
|
-
|
|
189
|
+
!readOnly &&
|
|
190
|
+
((state.sessionListing ?? null) !== null || state.sessionsError?.code === 'list_failed');
|
|
182
191
|
const sidebarOpen = hasSessionList && state.sessionSidebarOpen;
|
|
183
192
|
const usage = personalUsageEnabled && view === 'usage';
|
|
184
193
|
const project = view === 'project';
|
|
@@ -289,24 +298,28 @@ export function SessionSurface({
|
|
|
289
298
|
<div className="min-h-0 flex-1">
|
|
290
299
|
<PersonalUsageDashboard state={state} />
|
|
291
300
|
{/* A gated turn waits on this answer, so it shows over whatever view is open. */}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
301
|
+
{readOnly ? null : (
|
|
302
|
+
<PermissionPrompt
|
|
303
|
+
layout="modal"
|
|
304
|
+
prompts={state.pendingPrompts}
|
|
305
|
+
onAnswerPermission={state.answerPermission}
|
|
306
|
+
onAnswerAsk={state.answerAsk}
|
|
307
|
+
ownDriverId={state.ownDriverId}
|
|
308
|
+
/>
|
|
309
|
+
)}
|
|
299
310
|
</div>
|
|
300
311
|
) : project ? (
|
|
301
312
|
<div className="min-h-0 flex-1">
|
|
302
313
|
<ProjectPanel state={state} onOpenMemoryInEditor={onOpenMemoryInEditor} />
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
314
|
+
{readOnly ? null : (
|
|
315
|
+
<PermissionPrompt
|
|
316
|
+
layout="modal"
|
|
317
|
+
prompts={state.pendingPrompts}
|
|
318
|
+
onAnswerPermission={state.answerPermission}
|
|
319
|
+
onAnswerAsk={state.answerAsk}
|
|
320
|
+
ownDriverId={state.ownDriverId}
|
|
321
|
+
/>
|
|
322
|
+
)}
|
|
310
323
|
</div>
|
|
311
324
|
) : (
|
|
312
325
|
<div className="flex min-h-0 flex-1">
|
|
@@ -317,7 +330,7 @@ export function SessionSurface({
|
|
|
317
330
|
onReconnect={onReconnect}
|
|
318
331
|
/>
|
|
319
332
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
320
|
-
{setupRequired ? (
|
|
333
|
+
{setupRequired && !readOnly ? (
|
|
321
334
|
<SetupPanel
|
|
322
335
|
onSetUp={() => state.send({ type: 'command', name: 'provider', args: 'add' })}
|
|
323
336
|
disabled={state.status !== 'connected'}
|
|
@@ -325,7 +338,7 @@ export function SessionSurface({
|
|
|
325
338
|
) : isEmpty ? (
|
|
326
339
|
<EmptyState />
|
|
327
340
|
) : (
|
|
328
|
-
<AppViewHostContext.Provider value={appViewHost}>
|
|
341
|
+
<AppViewHostContext.Provider value={readOnly ? null : appViewHost}>
|
|
329
342
|
<ConversationView
|
|
330
343
|
messages={state.messages}
|
|
331
344
|
activeTools={state.activeTools}
|
|
@@ -341,21 +354,35 @@ export function SessionSurface({
|
|
|
341
354
|
>
|
|
342
355
|
<GoalBar
|
|
343
356
|
status={state.sessionStatus ?? null}
|
|
344
|
-
onStop={
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
onAnswerPermission={state.answerPermission}
|
|
350
|
-
onAnswerAsk={state.answerAsk}
|
|
351
|
-
ownDriverId={state.ownDriverId}
|
|
352
|
-
onFocusReturn={() => composerRef.current?.focus()}
|
|
357
|
+
onStop={
|
|
358
|
+
readOnly
|
|
359
|
+
? undefined
|
|
360
|
+
: () => state.send({ type: 'command', name: 'goal', args: 'cancel' })
|
|
361
|
+
}
|
|
353
362
|
/>
|
|
363
|
+
{readOnly ? null : (
|
|
364
|
+
<PermissionPrompt
|
|
365
|
+
layout="dock"
|
|
366
|
+
prompts={state.pendingPrompts}
|
|
367
|
+
onAnswerPermission={state.answerPermission}
|
|
368
|
+
onAnswerAsk={state.answerAsk}
|
|
369
|
+
ownDriverId={state.ownDriverId}
|
|
370
|
+
onFocusReturn={() => composerRef.current?.focus()}
|
|
371
|
+
/>
|
|
372
|
+
)}
|
|
354
373
|
{/* #3282 §3: hidden while there is no provider to send to — the setup panel's own
|
|
355
374
|
button is the only way in, so nobody types into a composer that goes nowhere. */}
|
|
356
|
-
{
|
|
375
|
+
{readOnly ? (
|
|
376
|
+
<p
|
|
377
|
+
role="status"
|
|
378
|
+
className="rounded-2xl bg-card px-4 py-3 text-center text-[14px] text-muted-foreground"
|
|
379
|
+
>
|
|
380
|
+
Read-only: you are watching this session.
|
|
381
|
+
</p>
|
|
382
|
+
) : setupRequired ? null : (
|
|
357
383
|
<Composer
|
|
358
384
|
ref={composerRef}
|
|
385
|
+
draftMode={draftMode}
|
|
359
386
|
catalog={state.commandCatalog ?? null}
|
|
360
387
|
status={state.sessionStatus ?? null}
|
|
361
388
|
connected={state.status === 'connected'}
|
|
@@ -405,13 +432,17 @@ export function SessionSurface({
|
|
|
405
432
|
selectedEntryId={state.openEntryId ?? undefined}
|
|
406
433
|
onSelect={(entry) => state.openExecutionDetail(entry.id)}
|
|
407
434
|
onReturnToConversation={() => state.closeExecutionDetail()}
|
|
408
|
-
onStop={stopExecutionEntry}
|
|
435
|
+
onStop={readOnly ? undefined : stopExecutionEntry}
|
|
409
436
|
schedules={state.scheduledTasks}
|
|
410
437
|
goal={currentGoal}
|
|
411
|
-
onPauseSchedule={state.pauseSchedule}
|
|
412
|
-
onResumeSchedule={state.resumeSchedule}
|
|
413
|
-
onDeleteSchedule={state.deleteSchedule}
|
|
414
|
-
onCancelGoal={
|
|
438
|
+
onPauseSchedule={readOnly ? undefined : state.pauseSchedule}
|
|
439
|
+
onResumeSchedule={readOnly ? undefined : state.resumeSchedule}
|
|
440
|
+
onDeleteSchedule={readOnly ? undefined : state.deleteSchedule}
|
|
441
|
+
onCancelGoal={
|
|
442
|
+
readOnly
|
|
443
|
+
? undefined
|
|
444
|
+
: () => state.send({ type: 'command', name: 'goal', args: 'cancel' })
|
|
445
|
+
}
|
|
415
446
|
/>
|
|
416
447
|
</aside>
|
|
417
448
|
)}
|
|
@@ -424,15 +455,37 @@ export function SessionSurface({
|
|
|
424
455
|
complete={state.executionDetailComplete}
|
|
425
456
|
onClose={() => state.closeExecutionDetail()}
|
|
426
457
|
onLoadMore={() => state.loadMoreExecutionDetail()}
|
|
427
|
-
onStop={openEntry ? () => stopExecutionEntry(openEntry) : undefined}
|
|
458
|
+
onStop={openEntry && !readOnly ? () => stopExecutionEntry(openEntry) : undefined}
|
|
459
|
+
inputState={state.executionDetailInput}
|
|
460
|
+
mode={state.executionDetailMode}
|
|
461
|
+
unreadable={state.executionDetailUnreadable}
|
|
462
|
+
onShowEarlier={state.showEarlierExecutionDetail}
|
|
463
|
+
onShowLive={state.showLiveExecutionDetail}
|
|
464
|
+
onSend={
|
|
465
|
+
openEntry && !readOnly
|
|
466
|
+
? (prompt) =>
|
|
467
|
+
state.send({
|
|
468
|
+
type: 'send-background-task',
|
|
469
|
+
taskId: openEntry.sourceId,
|
|
470
|
+
input: { prompt },
|
|
471
|
+
})
|
|
472
|
+
: undefined
|
|
473
|
+
}
|
|
474
|
+
onContinue={
|
|
475
|
+
openEntry?.resumeSessionId && !readOnly
|
|
476
|
+
? () => {
|
|
477
|
+
if (openEntry.resumeSessionId) state.switchSession(openEntry.resumeSessionId);
|
|
478
|
+
}
|
|
479
|
+
: undefined
|
|
480
|
+
}
|
|
428
481
|
/>
|
|
429
482
|
</div>
|
|
430
483
|
)}
|
|
431
484
|
</div>
|
|
432
485
|
|
|
433
486
|
<SessionNotices state={state} />
|
|
434
|
-
<SettingsScreen state={state} />
|
|
435
|
-
<AgentSwitcherSheet state={state} />
|
|
487
|
+
{readOnly ? null : <SettingsScreen state={state} />}
|
|
488
|
+
{readOnly ? null : <AgentSwitcherSheet state={state} />}
|
|
436
489
|
<HelpSheet
|
|
437
490
|
open={state.helpOpen}
|
|
438
491
|
onClose={() => state.closeHelp()}
|
|
@@ -89,6 +89,8 @@ export const SERVER_MESSAGE_HANDLING = {
|
|
|
89
89
|
background_task_control_result: 'visible-notice',
|
|
90
90
|
protocol_error: 'visible-notice',
|
|
91
91
|
resume_gap: 'transport-control',
|
|
92
|
+
// The connection's effective role: the session client holds it and stops sending what an observer may not.
|
|
93
|
+
surface_role: 'reducer-state',
|
|
92
94
|
// #3282 §4a: the Settings screen's own correlated request/response state (`useSettingsState`).
|
|
93
95
|
settings: 'reducer-state',
|
|
94
96
|
settings_error: 'reducer-state',
|
|
@@ -16,7 +16,11 @@ import type {
|
|
|
16
16
|
IExecutionDetailRecord,
|
|
17
17
|
IExecutionWorkspaceSnapshot,
|
|
18
18
|
} from '@robota-sdk/agent-interface-execution';
|
|
19
|
-
import type {
|
|
19
|
+
import type {
|
|
20
|
+
IWireAgentDefinitionSummary,
|
|
21
|
+
TServerMessage,
|
|
22
|
+
TSessionSurfaceRole,
|
|
23
|
+
} from '@robota-sdk/agent-transport';
|
|
20
24
|
|
|
21
25
|
export type { TAppToolArguments, TAppToolOutcome, TAppView } from '../app-view/types.js';
|
|
22
26
|
|
|
@@ -204,6 +208,8 @@ export interface IWsSessionState<TStatus extends string = TConnectionStatus> {
|
|
|
204
208
|
sessionName: string | null;
|
|
205
209
|
/** This connection's own driver id, learned from the server's first frame; null until then. */
|
|
206
210
|
ownDriverId: TDriverId | null;
|
|
211
|
+
/** The role the host gave this connection, learned when it connects; null until the host answers. */
|
|
212
|
+
role: TSessionSurfaceRole | null;
|
|
207
213
|
/** Null until the session has answered; refreshed after every command. */
|
|
208
214
|
commandCatalog: TCommandCatalog | null;
|
|
209
215
|
/** Null until the session has answered; refreshed after every command and turn. */
|
|
@@ -286,6 +292,11 @@ export interface IWsSessionState<TStatus extends string = TConnectionStatus> {
|
|
|
286
292
|
executionDetailError: string | null;
|
|
287
293
|
/** True once the last page read had no further cursor — nothing more to load. */
|
|
288
294
|
executionDetailComplete: boolean;
|
|
295
|
+
executionDetailInput?: { taskId: string; status: 'pending' | 'sent' | 'failed' };
|
|
296
|
+
executionDetailMode?: 'live' | 'transcript';
|
|
297
|
+
executionDetailUnreadable?: { locked: number; noKeys: number; doesNotOpen: number };
|
|
298
|
+
showEarlierExecutionDetail?: () => void;
|
|
299
|
+
showLiveExecutionDetail?: () => void;
|
|
289
300
|
openExecutionDetail: (entryId: string) => void;
|
|
290
301
|
loadMoreExecutionDetail: () => void;
|
|
291
302
|
closeExecutionDetail: () => void;
|
|
@@ -20,6 +20,7 @@ function nextRequestId(prefix: string): string {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
const CONNECTION_CLOSED = 'The connection to the session closed.';
|
|
23
|
+
const READ_ONLY = 'This session is read-only.';
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* The key a finished call's app view is read and called by, taken from the tool row's `appView`
|
|
@@ -53,6 +54,7 @@ function callAppToolMessage(
|
|
|
53
54
|
export function useAppViewRequests(
|
|
54
55
|
send: (msg: TClientMessage) => void,
|
|
55
56
|
isConnected: () => boolean,
|
|
57
|
+
isObserving: () => boolean,
|
|
56
58
|
): {
|
|
57
59
|
readAppView: (viewKey: string, signal?: AbortSignal) => Promise<TAppView>;
|
|
58
60
|
callAppTool: (
|
|
@@ -92,6 +94,11 @@ export function useAppViewRequests(
|
|
|
92
94
|
reject(new Error(signal?.aborted ? 'The app view closed.' : CONNECTION_CLOSED));
|
|
93
95
|
return;
|
|
94
96
|
}
|
|
97
|
+
// An observer's request is never sent, so no reply would come.
|
|
98
|
+
if (isObserving()) {
|
|
99
|
+
reject(new Error(READ_ONLY));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
95
102
|
const requestId = nextRequestId(`${requestOwner}_${prefix}`);
|
|
96
103
|
const dispose = (): void => signal?.removeEventListener('abort', abort);
|
|
97
104
|
const abort = (): void => {
|
|
@@ -109,7 +116,7 @@ export function useAppViewRequests(
|
|
|
109
116
|
reject(error instanceof Error ? error : new Error(String(error)));
|
|
110
117
|
}
|
|
111
118
|
}),
|
|
112
|
-
[isConnected, requestOwner, send],
|
|
119
|
+
[isConnected, isObserving, requestOwner, send],
|
|
113
120
|
);
|
|
114
121
|
|
|
115
122
|
const readAppView = useCallback(
|
|
@@ -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 & {
|
|
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
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
}
|
|
@@ -81,7 +81,10 @@ function initialSidebarOpen(): boolean {
|
|
|
81
81
|
* on its primary one. After a reconnect to such a host this surface goes back to the session it was
|
|
82
82
|
* on; an older host has one session for everyone, so there is nothing to go back to.
|
|
83
83
|
*/
|
|
84
|
-
export function useSessionDirectoryState(
|
|
84
|
+
export function useSessionDirectoryState(
|
|
85
|
+
send: (msg: TClientMessage) => void,
|
|
86
|
+
isObserving: () => boolean,
|
|
87
|
+
): TSessionDirectoryState & {
|
|
85
88
|
handleSessionsMessage: (msg: TServerMessage) => boolean;
|
|
86
89
|
/** Whether the host can list sessions, as far as its last answer says. */
|
|
87
90
|
canListSessions: () => boolean;
|
|
@@ -148,12 +151,14 @@ export function useSessionDirectoryState(send: (msg: TClientMessage) => void): T
|
|
|
148
151
|
// launch with no remembered id never reaches here (target stays null), so nothing switches.
|
|
149
152
|
clearStoredRestoreId(browserNamespace);
|
|
150
153
|
if (target === listing.currentSessionId) return;
|
|
154
|
+
// An observer never moves the session, so there is nothing to go back to.
|
|
155
|
+
if (isObserving()) return;
|
|
151
156
|
if (!keepsSessionsLive(listing)) return;
|
|
152
157
|
// A session the host no longer lists (an empty one it never saved) is nothing to go back to.
|
|
153
158
|
if (!listing.sessions.some((row) => row.id === target)) return;
|
|
154
159
|
send({ type: 'switch-session', sessionId: target, requestId: nextRequestId('session_change') });
|
|
155
160
|
},
|
|
156
|
-
[send, browserNamespace],
|
|
161
|
+
[send, browserNamespace, isObserving],
|
|
157
162
|
);
|
|
158
163
|
|
|
159
164
|
const handleSessionsMessage = useCallback(
|