@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.
@@ -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('');
@@ -368,8 +373,21 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
368
373
  // Their request ids, for this connection: a refusal names the request it answers. A switch
369
374
  // answers with no id, so an id that succeeded stays here until the connection is replaced.
370
375
  const sessionChangeRequestIdsRef = useRef(new Set<string>());
376
+ const [executionDetailInput, setExecutionDetailInput] = useState<{
377
+ taskId: string;
378
+ status: 'pending' | 'sent' | 'failed';
379
+ }>();
380
+ const executionDetailInputRef = useRef<string | null>(null);
371
381
  const send = useCallback(
372
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);
386
+ if (msg.type === 'send-background-task') {
387
+ if (refused || executionDetailInputRef.current !== null) return;
388
+ executionDetailInputRef.current = msg.taskId;
389
+ setExecutionDetailInput({ taskId: msg.taskId, status: 'pending' });
390
+ }
373
391
  if (msg.type === 'command') {
374
392
  // #3282 §4e: `/help` is the GUI's own Help sheet — never sent to the session.
375
393
  if (msg.name === 'help') {
@@ -393,15 +411,17 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
393
411
  });
394
412
  return;
395
413
  }
414
+ if (refused) return;
396
415
  commandsInFlightRef.current += 1;
397
416
  }
417
+ if (refused) return;
398
418
  if (msg.type === 'switch-session' || msg.type === 'new-session') {
399
419
  sessionChangesInFlightRef.current += 1;
400
420
  if (msg.requestId !== undefined) sessionChangeRequestIdsRef.current.add(msg.requestId);
401
421
  }
402
422
  clientRef.current?.send(msg);
403
423
  },
404
- [appendEntry],
424
+ [appendEntry, isObserving],
405
425
  );
406
426
  /** One session change this surface asked for has been answered. */
407
427
  const settleSessionChange = useCallback((requestId?: string): void => {
@@ -433,20 +453,24 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
433
453
  [send],
434
454
  );
435
455
  const { handleUsageMessage, ...personalUsageState } = usePersonalUsageState(send);
436
- const { handleExecutionDetailMessage, closeExecutionDetail, ...executionDetailState } =
437
- useExecutionDetailState(send);
456
+ const {
457
+ handleExecutionDetailMessage,
458
+ handleExecutionWorkspace,
459
+ closeExecutionDetail,
460
+ ...executionDetailState
461
+ } = useExecutionDetailState(send);
438
462
  const { handleModelListMessage, ...modelListState } = useModelListState(send);
439
463
  const { handleProjectMessage, ...projectPanelState } = useProjectPanelState(send);
440
464
  const isConnected = useCallback((): boolean => statusRef.current === 'connected', []);
441
465
  const { handleAppViewMessage, rejectPendingAppRequests, readAppView, callAppTool } =
442
- useAppViewRequests(send, isConnected);
466
+ useAppViewRequests(send, isConnected, isObserving);
443
467
  const {
444
468
  handleSessionsMessage,
445
469
  canListSessions,
446
470
  markCurrent,
447
471
  armRestore,
448
472
  ...sessionDirectoryState
449
- } = useSessionDirectoryState(send);
473
+ } = useSessionDirectoryState(send, isObserving);
450
474
  const { requestSessions, setSessionSidebarOpen } = sessionDirectoryState;
451
475
  const { handleSettingsMessage, openSettings, refreshSettings, ...settingsState } =
452
476
  useSettingsState(send);
@@ -465,7 +489,9 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
465
489
  const { handleSchedulesMessage, requestSchedules, ...schedulesState } = useSchedulesState(send);
466
490
 
467
491
  const clearCompactionNotices = useCallback((): void => {
468
- setSessionNotices((previous) => previous.filter((notice) => notice.kind !== 'context-compacted'));
492
+ setSessionNotices((previous) =>
493
+ previous.filter((notice) => notice.kind !== 'context-compacted'),
494
+ );
469
495
  }, []);
470
496
 
471
497
  const handleMessage = useCallback(
@@ -488,6 +514,11 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
488
514
  // types already do below, never in place of it.
489
515
  if (handleSchedulesMessage(msg)) return;
490
516
  switch (msg.type) {
517
+ case 'surface_role': {
518
+ roleRef.current = msg.role;
519
+ setRole(msg.role);
520
+ break;
521
+ }
491
522
  case 'messages': {
492
523
  // #3288 §2: `display` is the server's projection of this same history into tool rows,
493
524
  // diffs and "Changed files" — a reload/reconnect/resume replay renders THAT. `display` is
@@ -552,19 +583,29 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
552
583
  break;
553
584
  }
554
585
  case 'execution_workspace_event': {
586
+ handleExecutionWorkspace(msg.snapshot);
555
587
  setExecutionWorkspace(msg.snapshot);
556
588
  break;
557
589
  }
558
590
  // #3288 §1: a Stop that did not take effect (e.g. the task/loop had already finished) is
559
591
  // worth telling the operator; a success shows itself through the snapshot update above.
560
592
  case 'background_task_control_result': {
593
+ if (msg.action === 'send' && executionDetailInputRef.current === msg.taskId) {
594
+ executionDetailInputRef.current = null;
595
+ setExecutionDetailInput({
596
+ taskId: msg.taskId,
597
+ status: msg.success ? 'sent' : 'failed',
598
+ });
599
+ }
561
600
  if (!msg.success) {
562
601
  setSessionNotices((previous) => [
563
602
  ...previous,
564
603
  {
565
604
  id: nextId(),
566
605
  kind: 'background-task-control-failed',
567
- message: msg.message ?? `Could not stop ${msg.taskId}.`,
606
+ message:
607
+ msg.message ??
608
+ `Could not ${msg.action === 'send' ? 'send to' : 'stop'} ${msg.taskId}.`,
568
609
  },
569
610
  ]);
570
611
  }
@@ -765,6 +806,13 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
765
806
  break;
766
807
  }
767
808
  case 'protocol_error': {
809
+ if (
810
+ executionDetailInputRef.current !== null &&
811
+ msg.message === 'Not permitted for an observer: send-background-task'
812
+ ) {
813
+ setExecutionDetailInput({ taskId: executionDetailInputRef.current, status: 'failed' });
814
+ executionDetailInputRef.current = null;
815
+ }
768
816
  if (sessionChangesInFlightRef.current > 0) {
769
817
  // A host older than `session_change_failed` refuses a session change with this.
770
818
  settleSessionChange();
@@ -911,6 +959,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
911
959
  handleAgentDefinitionsMessage,
912
960
  handleAppViewMessage,
913
961
  handleExecutionDetailMessage,
962
+ handleExecutionWorkspace,
914
963
  handleModelListMessage,
915
964
  handleProjectMessage,
916
965
  handleSchedulesMessage,
@@ -950,10 +999,19 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
950
999
  const onStatusChange = (next: TStatus): void => {
951
1000
  statusRef.current = next;
952
1001
  setStatus(next);
1002
+ if (next !== 'connected') {
1003
+ roleRef.current = null;
1004
+ setRole(null);
1005
+ }
953
1006
  // A reply lost with the connection never arrives; the views waiting on one learn that now.
954
1007
  if (next !== 'connected') {
955
1008
  rejectPendingAppRequests();
956
1009
  clearCompactionNotices();
1010
+ closeExecutionDetail();
1011
+ if (executionDetailInputRef.current !== null) {
1012
+ setExecutionDetailInput({ taskId: executionDetailInputRef.current, status: 'failed' });
1013
+ executionDetailInputRef.current = null;
1014
+ }
957
1015
  }
958
1016
  // Every client learns what it can offer and what is in effect as soon as it is attached.
959
1017
  if (next === 'connected') {
@@ -963,6 +1021,8 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
963
1021
  sessionChangesInFlightRef.current = 0;
964
1022
  sessionChangeRequestIdsRef.current.clear();
965
1023
  pendingIntentRef.current = null;
1024
+ // First, so its reply precedes the session listing the reconnect restore reads.
1025
+ client.send({ type: 'get-surface-role' });
966
1026
  client.send({ type: 'get-commands' });
967
1027
  client.send({ type: 'get-status' });
968
1028
  // #3280 §2: a reconnect that lands back on the SAME session (the common case) fires no
@@ -976,7 +1036,16 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
976
1036
  requestSchedules();
977
1037
  }
978
1038
  };
979
- const client = makeClient({ onMessage: handleMessage, onStatusChange });
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
+ };
980
1049
  clientRef.current = client;
981
1050
  client.connect();
982
1051
  return () => {
@@ -988,6 +1057,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
988
1057
  makeClient,
989
1058
  handleMessage,
990
1059
  clearCompactionNotices,
1060
+ closeExecutionDetail,
991
1061
  requestSessions,
992
1062
  requestSchedules,
993
1063
  armRestore,
@@ -1005,6 +1075,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
1005
1075
  commandCatalog,
1006
1076
  sessionStatus,
1007
1077
  ownDriverId,
1078
+ role,
1008
1079
  send,
1009
1080
  sendCommandSilently,
1010
1081
  pendingPrompts,
@@ -1013,6 +1084,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
1013
1084
  answerAsk,
1014
1085
  ...personalUsageState,
1015
1086
  ...executionDetailState,
1087
+ executionDetailInput,
1016
1088
  closeExecutionDetail,
1017
1089
  ...modelListState,
1018
1090
  ...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 {