@robota-sdk/agent-ui-web 3.2.1 → 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 +18 -0
- package/dist/index.cjs +80 -37
- package/dist/index.d.cts +4 -2
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +80 -37
- package/dist/index.js.map +1 -1
- package/docs/SPEC.md +5 -0
- package/package.json +6 -6
- package/src/components/AgentActivityPanel.tsx +38 -31
- package/src/components/Composer.tsx +13 -10
- package/src/components/SessionSurface.tsx +65 -38
- package/src/hooks/server-message-handling.ts +2 -0
- package/src/hooks/session-client-types.ts +7 -1
- package/src/hooks/use-app-view-requests.ts +8 -1
- package/src/hooks/use-session-directory.ts +7 -2
- package/src/hooks/useSessionClient.ts +37 -6
package/docs/SPEC.md
CHANGED
|
@@ -68,6 +68,11 @@ does not own session lifecycle, conversation history, or agent runtime state.
|
|
|
68
68
|
a schedule or a goal alone (no running background task or loop) still opens the rail, and a
|
|
69
69
|
schedule's Pause/Resume and Delete, and a goal's Cancel, run the same command/control paths their
|
|
70
70
|
slash commands already use — the panel adds no write this package did not already expose.
|
|
71
|
+
- **The surface follows the host's role.** The session client learns its effective role from the
|
|
72
|
+
host when it connects and, while it observes, sends nothing an observer may not send; the surface
|
|
73
|
+
then offers only what an observer can do: the conversation, its work and the project's reads, with
|
|
74
|
+
no composer, answers, session switching or control that changes the session or its work. The host
|
|
75
|
+
remains the authority; hiding a control is presentation, not enforcement.
|
|
71
76
|
- The title bar and the page title both name the workspace folder the session works in, once the
|
|
72
77
|
host has said one; the desktop window follows the page title.
|
|
73
78
|
- A pending permission or ask prompt is visible whatever view is open, because a gated turn waits
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robota-sdk/agent-ui-web",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
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-transport": "3.2.
|
|
66
|
-
"@robota-sdk/agent-interface-
|
|
67
|
-
"@robota-sdk/agent-
|
|
63
|
+
"@robota-sdk/agent-interface-command": "^3.2.2",
|
|
64
|
+
"@robota-sdk/agent-interface-execution": "^3.2.2",
|
|
65
|
+
"@robota-sdk/agent-interface-transport": "^3.2.2",
|
|
66
|
+
"@robota-sdk/agent-interface-session": "^3.2.2",
|
|
67
|
+
"@robota-sdk/agent-transport": "^3.3.0"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
70
|
"react": ">=18"
|
|
@@ -115,7 +115,7 @@ export function AgentActivityPanel({
|
|
|
115
115
|
task={task}
|
|
116
116
|
onPause={onPauseSchedule}
|
|
117
117
|
onResume={onResumeSchedule}
|
|
118
|
-
onRequestDelete={() => setPendingDeleteId(task.id)}
|
|
118
|
+
onRequestDelete={onDeleteSchedule ? () => setPendingDeleteId(task.id) : undefined}
|
|
119
119
|
/>
|
|
120
120
|
))}
|
|
121
121
|
</div>
|
|
@@ -231,7 +231,8 @@ function ScheduleCard({
|
|
|
231
231
|
task: IBackgroundTaskState<'scheduled'>;
|
|
232
232
|
onPause?: (taskId: string) => void;
|
|
233
233
|
onResume?: (taskId: string) => void;
|
|
234
|
-
|
|
234
|
+
/** Absent when the schedule is shown without controls (an observer). */
|
|
235
|
+
onRequestDelete?: () => void;
|
|
235
236
|
}): React.ReactElement {
|
|
236
237
|
const isPaused = task.status === 'paused';
|
|
237
238
|
return (
|
|
@@ -242,34 +243,40 @@ function ScheduleCard({
|
|
|
242
243
|
<p className="mt-1 text-[13px] text-muted-foreground">
|
|
243
244
|
{describeScheduleTiming(task)} · {describeScheduleStatus(task)}
|
|
244
245
|
</p>
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
246
|
+
{onResume || onPause || onRequestDelete ? (
|
|
247
|
+
<div className="mt-2 flex items-center gap-3">
|
|
248
|
+
{isPaused
|
|
249
|
+
? onResume && (
|
|
250
|
+
<button
|
|
251
|
+
type="button"
|
|
252
|
+
onClick={() => onResume(task.id)}
|
|
253
|
+
className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-muted-foreground hover:bg-hover hover:text-foreground"
|
|
254
|
+
>
|
|
255
|
+
<Play size={11} fill="currentColor" aria-hidden="true" />
|
|
256
|
+
Resume
|
|
257
|
+
</button>
|
|
258
|
+
)
|
|
259
|
+
: onPause && (
|
|
260
|
+
<button
|
|
261
|
+
type="button"
|
|
262
|
+
onClick={() => onPause(task.id)}
|
|
263
|
+
className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-muted-foreground hover:bg-hover hover:text-foreground"
|
|
264
|
+
>
|
|
265
|
+
<Pause size={11} fill="currentColor" aria-hidden="true" />
|
|
266
|
+
Pause
|
|
267
|
+
</button>
|
|
268
|
+
)}
|
|
269
|
+
{onRequestDelete && (
|
|
270
|
+
<button
|
|
271
|
+
type="button"
|
|
272
|
+
onClick={onRequestDelete}
|
|
273
|
+
className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-destructive hover:bg-destructive/10"
|
|
274
|
+
>
|
|
275
|
+
Delete…
|
|
276
|
+
</button>
|
|
277
|
+
)}
|
|
278
|
+
</div>
|
|
279
|
+
) : null}
|
|
273
280
|
</div>
|
|
274
281
|
);
|
|
275
282
|
}
|
|
@@ -293,7 +300,7 @@ function GoalCard({
|
|
|
293
300
|
<p className="mt-1.5 pl-[26px] text-[13px] text-muted-foreground">
|
|
294
301
|
{describeGoalStatus(goal)}
|
|
295
302
|
</p>
|
|
296
|
-
{goal.status === 'active' && (
|
|
303
|
+
{goal.status === 'active' && onCancel && (
|
|
297
304
|
<div className="mt-2 pl-[26px]">
|
|
298
305
|
<button
|
|
299
306
|
type="button"
|
|
@@ -690,7 +690,8 @@ export function GoalBar({
|
|
|
690
690
|
onStop,
|
|
691
691
|
}: {
|
|
692
692
|
status: TSessionStatus | null;
|
|
693
|
-
|
|
693
|
+
/** Absent for an observer: the goal is shown without a Stop. */
|
|
694
|
+
onStop?: () => void;
|
|
694
695
|
}): React.ReactElement | null {
|
|
695
696
|
const goal = status?.goal;
|
|
696
697
|
if (!goal || goal.status !== 'active') return null;
|
|
@@ -707,15 +708,17 @@ export function GoalBar({
|
|
|
707
708
|
<span className="flex-shrink-0 text-[13px] tabular-nums text-subtle">
|
|
708
709
|
{goal.iterations}/{goal.maxIterations}
|
|
709
710
|
</span>
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
711
|
+
{onStop && (
|
|
712
|
+
<button
|
|
713
|
+
type="button"
|
|
714
|
+
aria-label="Stop goal"
|
|
715
|
+
onClick={onStop}
|
|
716
|
+
className="flex flex-shrink-0 items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-muted-foreground hover:bg-hover hover:text-foreground"
|
|
717
|
+
>
|
|
718
|
+
<Square size={11} fill="currentColor" aria-hidden="true" />
|
|
719
|
+
Stop
|
|
720
|
+
</button>
|
|
721
|
+
)}
|
|
719
722
|
<span
|
|
720
723
|
aria-hidden="true"
|
|
721
724
|
className="absolute bottom-0 left-0 h-[2px] bg-accent/70 transition-[width]"
|
|
@@ -106,7 +106,7 @@ function SetupPanel({
|
|
|
106
106
|
export function SessionSurface({
|
|
107
107
|
state,
|
|
108
108
|
surface,
|
|
109
|
-
personalUsageEnabled = false,
|
|
109
|
+
personalUsageEnabled: personalUsageRequested = false,
|
|
110
110
|
draftMode = 'persistent',
|
|
111
111
|
onReconnect,
|
|
112
112
|
pickFiles,
|
|
@@ -138,6 +138,8 @@ export function SessionSurface({
|
|
|
138
138
|
|
|
139
139
|
appViewSandboxUrl?: string;
|
|
140
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';
|
|
141
143
|
const [view, setView] = useState<TSessionView>('chat');
|
|
142
144
|
const { identity } = useProductIdentity();
|
|
143
145
|
const appViewHost = useMemo(
|
|
@@ -150,6 +152,8 @@ export function SessionSurface({
|
|
|
150
152
|
}),
|
|
151
153
|
[appViewSandboxUrl, state.readAppView, state.callAppTool, identity.displayName, surface],
|
|
152
154
|
);
|
|
155
|
+
// The personal usage read is not admitted for an observer.
|
|
156
|
+
const personalUsageEnabled = personalUsageRequested && !readOnly;
|
|
153
157
|
const composerRef = useRef<IComposerHandle>(null);
|
|
154
158
|
const tasks = state.executionWorkspace?.entries ?? [];
|
|
155
159
|
// The main thread alone is this conversation; the rail earns its width only for work beside it.
|
|
@@ -180,8 +184,10 @@ export function SessionSurface({
|
|
|
180
184
|
// #3282 §3: no provider is configured — the session is running (a placeholder stands in for the
|
|
181
185
|
// real one), but there is nothing to reply with yet.
|
|
182
186
|
const setupRequired = state.sessionStatus?.setupRequired === true;
|
|
187
|
+
// Switching, starting, renaming and deleting sessions are driver actions: an observer has no sidebar.
|
|
183
188
|
const hasSessionList =
|
|
184
|
-
|
|
189
|
+
!readOnly &&
|
|
190
|
+
((state.sessionListing ?? null) !== null || state.sessionsError?.code === 'list_failed');
|
|
185
191
|
const sidebarOpen = hasSessionList && state.sessionSidebarOpen;
|
|
186
192
|
const usage = personalUsageEnabled && view === 'usage';
|
|
187
193
|
const project = view === 'project';
|
|
@@ -292,24 +298,28 @@ export function SessionSurface({
|
|
|
292
298
|
<div className="min-h-0 flex-1">
|
|
293
299
|
<PersonalUsageDashboard state={state} />
|
|
294
300
|
{/* A gated turn waits on this answer, so it shows over whatever view is open. */}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
+
)}
|
|
302
310
|
</div>
|
|
303
311
|
) : project ? (
|
|
304
312
|
<div className="min-h-0 flex-1">
|
|
305
313
|
<ProjectPanel state={state} onOpenMemoryInEditor={onOpenMemoryInEditor} />
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
+
)}
|
|
313
323
|
</div>
|
|
314
324
|
) : (
|
|
315
325
|
<div className="flex min-h-0 flex-1">
|
|
@@ -320,7 +330,7 @@ export function SessionSurface({
|
|
|
320
330
|
onReconnect={onReconnect}
|
|
321
331
|
/>
|
|
322
332
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
323
|
-
{setupRequired ? (
|
|
333
|
+
{setupRequired && !readOnly ? (
|
|
324
334
|
<SetupPanel
|
|
325
335
|
onSetUp={() => state.send({ type: 'command', name: 'provider', args: 'add' })}
|
|
326
336
|
disabled={state.status !== 'connected'}
|
|
@@ -328,7 +338,7 @@ export function SessionSurface({
|
|
|
328
338
|
) : isEmpty ? (
|
|
329
339
|
<EmptyState />
|
|
330
340
|
) : (
|
|
331
|
-
<AppViewHostContext.Provider value={appViewHost}>
|
|
341
|
+
<AppViewHostContext.Provider value={readOnly ? null : appViewHost}>
|
|
332
342
|
<ConversationView
|
|
333
343
|
messages={state.messages}
|
|
334
344
|
activeTools={state.activeTools}
|
|
@@ -344,19 +354,32 @@ export function SessionSurface({
|
|
|
344
354
|
>
|
|
345
355
|
<GoalBar
|
|
346
356
|
status={state.sessionStatus ?? null}
|
|
347
|
-
onStop={
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
onAnswerPermission={state.answerPermission}
|
|
353
|
-
onAnswerAsk={state.answerAsk}
|
|
354
|
-
ownDriverId={state.ownDriverId}
|
|
355
|
-
onFocusReturn={() => composerRef.current?.focus()}
|
|
357
|
+
onStop={
|
|
358
|
+
readOnly
|
|
359
|
+
? undefined
|
|
360
|
+
: () => state.send({ type: 'command', name: 'goal', args: 'cancel' })
|
|
361
|
+
}
|
|
356
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
|
+
)}
|
|
357
373
|
{/* #3282 §3: hidden while there is no provider to send to — the setup panel's own
|
|
358
374
|
button is the only way in, so nobody types into a composer that goes nowhere. */}
|
|
359
|
-
{
|
|
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 : (
|
|
360
383
|
<Composer
|
|
361
384
|
ref={composerRef}
|
|
362
385
|
draftMode={draftMode}
|
|
@@ -409,13 +432,17 @@ export function SessionSurface({
|
|
|
409
432
|
selectedEntryId={state.openEntryId ?? undefined}
|
|
410
433
|
onSelect={(entry) => state.openExecutionDetail(entry.id)}
|
|
411
434
|
onReturnToConversation={() => state.closeExecutionDetail()}
|
|
412
|
-
onStop={stopExecutionEntry}
|
|
435
|
+
onStop={readOnly ? undefined : stopExecutionEntry}
|
|
413
436
|
schedules={state.scheduledTasks}
|
|
414
437
|
goal={currentGoal}
|
|
415
|
-
onPauseSchedule={state.pauseSchedule}
|
|
416
|
-
onResumeSchedule={state.resumeSchedule}
|
|
417
|
-
onDeleteSchedule={state.deleteSchedule}
|
|
418
|
-
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
|
+
}
|
|
419
446
|
/>
|
|
420
447
|
</aside>
|
|
421
448
|
)}
|
|
@@ -428,14 +455,14 @@ export function SessionSurface({
|
|
|
428
455
|
complete={state.executionDetailComplete}
|
|
429
456
|
onClose={() => state.closeExecutionDetail()}
|
|
430
457
|
onLoadMore={() => state.loadMoreExecutionDetail()}
|
|
431
|
-
onStop={openEntry ? () => stopExecutionEntry(openEntry) : undefined}
|
|
458
|
+
onStop={openEntry && !readOnly ? () => stopExecutionEntry(openEntry) : undefined}
|
|
432
459
|
inputState={state.executionDetailInput}
|
|
433
460
|
mode={state.executionDetailMode}
|
|
434
461
|
unreadable={state.executionDetailUnreadable}
|
|
435
462
|
onShowEarlier={state.showEarlierExecutionDetail}
|
|
436
463
|
onShowLive={state.showLiveExecutionDetail}
|
|
437
464
|
onSend={
|
|
438
|
-
openEntry
|
|
465
|
+
openEntry && !readOnly
|
|
439
466
|
? (prompt) =>
|
|
440
467
|
state.send({
|
|
441
468
|
type: 'send-background-task',
|
|
@@ -445,7 +472,7 @@ export function SessionSurface({
|
|
|
445
472
|
: undefined
|
|
446
473
|
}
|
|
447
474
|
onContinue={
|
|
448
|
-
openEntry?.resumeSessionId
|
|
475
|
+
openEntry?.resumeSessionId && !readOnly
|
|
449
476
|
? () => {
|
|
450
477
|
if (openEntry.resumeSessionId) state.switchSession(openEntry.resumeSessionId);
|
|
451
478
|
}
|
|
@@ -457,8 +484,8 @@ export function SessionSurface({
|
|
|
457
484
|
</div>
|
|
458
485
|
|
|
459
486
|
<SessionNotices state={state} />
|
|
460
|
-
<SettingsScreen state={state} />
|
|
461
|
-
<AgentSwitcherSheet state={state} />
|
|
487
|
+
{readOnly ? null : <SettingsScreen state={state} />}
|
|
488
|
+
{readOnly ? null : <AgentSwitcherSheet state={state} />}
|
|
462
489
|
<HelpSheet
|
|
463
490
|
open={state.helpOpen}
|
|
464
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. */
|
|
@@ -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(
|
|
@@ -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(
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* WITHOUT this core depending on that surface's types — keeping the dependency direction acyclic.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { isObserverMessageType } from '@robota-sdk/agent-transport';
|
|
9
10
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
10
11
|
|
|
11
12
|
import { excludedCommandMessage, isExcludedCommand } from './excluded-commands.js';
|
|
@@ -53,7 +54,7 @@ import type {
|
|
|
53
54
|
TPermissionResultValue,
|
|
54
55
|
} from '@robota-sdk/agent-interface-session';
|
|
55
56
|
import type { IExecutionWorkspaceSnapshot } from '@robota-sdk/agent-interface-execution';
|
|
56
|
-
import type { TServerMessage } from '@robota-sdk/agent-transport';
|
|
57
|
+
import type { TServerMessage, TSessionSurfaceRole } from '@robota-sdk/agent-transport';
|
|
57
58
|
|
|
58
59
|
export type {
|
|
59
60
|
IActiveTool,
|
|
@@ -272,6 +273,10 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
|
|
|
272
273
|
const [status, setStatus] = useState<TStatus>('disconnected' as TStatus);
|
|
273
274
|
// The app-view requests read this to refuse at once when no connection exists to answer them.
|
|
274
275
|
const statusRef = useRef<TStatus>('disconnected' as TStatus);
|
|
276
|
+
// The role the host gave this connection; null until it answers and again once the connection drops.
|
|
277
|
+
const [role, setRole] = useState<TSessionSurfaceRole | null>(null);
|
|
278
|
+
const roleRef = useRef<TSessionSurfaceRole | null>(null);
|
|
279
|
+
const isObserving = useCallback((): boolean => roleRef.current === 'observe', []);
|
|
275
280
|
const [messages, setMessages] = useState<TConversationEntry[]>([]);
|
|
276
281
|
const [activeTools, setActiveTools] = useState<IActiveTool[]>([]);
|
|
277
282
|
const [streamingText, setStreamingText] = useState('');
|
|
@@ -375,8 +380,11 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
|
|
|
375
380
|
const executionDetailInputRef = useRef<string | null>(null);
|
|
376
381
|
const send = useCallback(
|
|
377
382
|
(msg: TClientMessage): void => {
|
|
383
|
+
// A frame an observer may not send never leaves, so nothing may start waiting for its reply.
|
|
384
|
+
// The surface's own answers (`/help`, an excluded command) still run below.
|
|
385
|
+
const refused = isObserving() && !isObserverMessageType(msg.type);
|
|
378
386
|
if (msg.type === 'send-background-task') {
|
|
379
|
-
if (executionDetailInputRef.current !== null) return;
|
|
387
|
+
if (refused || executionDetailInputRef.current !== null) return;
|
|
380
388
|
executionDetailInputRef.current = msg.taskId;
|
|
381
389
|
setExecutionDetailInput({ taskId: msg.taskId, status: 'pending' });
|
|
382
390
|
}
|
|
@@ -403,15 +411,17 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
|
|
|
403
411
|
});
|
|
404
412
|
return;
|
|
405
413
|
}
|
|
414
|
+
if (refused) return;
|
|
406
415
|
commandsInFlightRef.current += 1;
|
|
407
416
|
}
|
|
417
|
+
if (refused) return;
|
|
408
418
|
if (msg.type === 'switch-session' || msg.type === 'new-session') {
|
|
409
419
|
sessionChangesInFlightRef.current += 1;
|
|
410
420
|
if (msg.requestId !== undefined) sessionChangeRequestIdsRef.current.add(msg.requestId);
|
|
411
421
|
}
|
|
412
422
|
clientRef.current?.send(msg);
|
|
413
423
|
},
|
|
414
|
-
[appendEntry],
|
|
424
|
+
[appendEntry, isObserving],
|
|
415
425
|
);
|
|
416
426
|
/** One session change this surface asked for has been answered. */
|
|
417
427
|
const settleSessionChange = useCallback((requestId?: string): void => {
|
|
@@ -453,14 +463,14 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
|
|
|
453
463
|
const { handleProjectMessage, ...projectPanelState } = useProjectPanelState(send);
|
|
454
464
|
const isConnected = useCallback((): boolean => statusRef.current === 'connected', []);
|
|
455
465
|
const { handleAppViewMessage, rejectPendingAppRequests, readAppView, callAppTool } =
|
|
456
|
-
useAppViewRequests(send, isConnected);
|
|
466
|
+
useAppViewRequests(send, isConnected, isObserving);
|
|
457
467
|
const {
|
|
458
468
|
handleSessionsMessage,
|
|
459
469
|
canListSessions,
|
|
460
470
|
markCurrent,
|
|
461
471
|
armRestore,
|
|
462
472
|
...sessionDirectoryState
|
|
463
|
-
} = useSessionDirectoryState(send);
|
|
473
|
+
} = useSessionDirectoryState(send, isObserving);
|
|
464
474
|
const { requestSessions, setSessionSidebarOpen } = sessionDirectoryState;
|
|
465
475
|
const { handleSettingsMessage, openSettings, refreshSettings, ...settingsState } =
|
|
466
476
|
useSettingsState(send);
|
|
@@ -504,6 +514,11 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
|
|
|
504
514
|
// types already do below, never in place of it.
|
|
505
515
|
if (handleSchedulesMessage(msg)) return;
|
|
506
516
|
switch (msg.type) {
|
|
517
|
+
case 'surface_role': {
|
|
518
|
+
roleRef.current = msg.role;
|
|
519
|
+
setRole(msg.role);
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
507
522
|
case 'messages': {
|
|
508
523
|
// #3288 §2: `display` is the server's projection of this same history into tool rows,
|
|
509
524
|
// diffs and "Changed files" — a reload/reconnect/resume replay renders THAT. `display` is
|
|
@@ -984,6 +999,10 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
|
|
|
984
999
|
const onStatusChange = (next: TStatus): void => {
|
|
985
1000
|
statusRef.current = next;
|
|
986
1001
|
setStatus(next);
|
|
1002
|
+
if (next !== 'connected') {
|
|
1003
|
+
roleRef.current = null;
|
|
1004
|
+
setRole(null);
|
|
1005
|
+
}
|
|
987
1006
|
// A reply lost with the connection never arrives; the views waiting on one learn that now.
|
|
988
1007
|
if (next !== 'connected') {
|
|
989
1008
|
rejectPendingAppRequests();
|
|
@@ -1002,6 +1021,8 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
|
|
|
1002
1021
|
sessionChangesInFlightRef.current = 0;
|
|
1003
1022
|
sessionChangeRequestIdsRef.current.clear();
|
|
1004
1023
|
pendingIntentRef.current = null;
|
|
1024
|
+
// First, so its reply precedes the session listing the reconnect restore reads.
|
|
1025
|
+
client.send({ type: 'get-surface-role' });
|
|
1005
1026
|
client.send({ type: 'get-commands' });
|
|
1006
1027
|
client.send({ type: 'get-status' });
|
|
1007
1028
|
// #3280 §2: a reconnect that lands back on the SAME session (the common case) fires no
|
|
@@ -1015,7 +1036,16 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
|
|
|
1015
1036
|
requestSchedules();
|
|
1016
1037
|
}
|
|
1017
1038
|
};
|
|
1018
|
-
const
|
|
1039
|
+
const inner = makeClient({ onMessage: handleMessage, onStatusChange });
|
|
1040
|
+
// The host is the authority; this only keeps an observer from sending what the host would refuse.
|
|
1041
|
+
const client: ISessionClientHandle = {
|
|
1042
|
+
connect: () => inner.connect(),
|
|
1043
|
+
disconnect: () => inner.disconnect(),
|
|
1044
|
+
send: (msg) => {
|
|
1045
|
+
if (roleRef.current === 'observe' && !isObserverMessageType(msg.type)) return;
|
|
1046
|
+
inner.send(msg);
|
|
1047
|
+
},
|
|
1048
|
+
};
|
|
1019
1049
|
clientRef.current = client;
|
|
1020
1050
|
client.connect();
|
|
1021
1051
|
return () => {
|
|
@@ -1045,6 +1075,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
|
|
|
1045
1075
|
commandCatalog,
|
|
1046
1076
|
sessionStatus,
|
|
1047
1077
|
ownDriverId,
|
|
1078
|
+
role,
|
|
1048
1079
|
send,
|
|
1049
1080
|
sendCommandSilently,
|
|
1050
1081
|
pendingPrompts,
|