@robota-sdk/agent-ui-web 3.0.0-beta.95 → 3.0.0-beta.96

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,8 +1,10 @@
1
+ import type { TAppToolArguments, TAppToolOutcome, TAppView } from '../app-view/types.js';
1
2
  import type { TConnectionStatus, TClientMessage } from '../client/ws-session-client.js';
2
3
  import type { TPendingPrompt } from './prompt-state.js';
3
4
  import type { TActionResponse } from '@robota-sdk/agent-interface-transport';
4
5
  import type {
5
6
  IDiffLine,
7
+ IToolAppViewRef,
6
8
  IToolState,
7
9
  ISettingsSnapshot,
8
10
  TDriverId,
@@ -16,6 +18,8 @@ import type {
16
18
  } from '@robota-sdk/agent-interface-execution';
17
19
  import type { IWireAgentDefinitionSummary, TServerMessage } from '@robota-sdk/agent-transport';
18
20
 
21
+ export type { TAppToolArguments, TAppToolOutcome, TAppView } from '../app-view/types.js';
22
+
19
23
  export type TPersonalUsageReport = Extract<
20
24
  TServerMessage,
21
25
  { type: 'personal_usage_report' }
@@ -125,6 +129,8 @@ export interface IActiveTool {
125
129
  commandName?: string;
126
130
  /** #3288: true for an internal signal tool (e.g. goal-status) — never rendered as a call. */
127
131
  internal?: boolean;
132
+
133
+ appView?: IToolAppViewRef;
128
134
  }
129
135
 
130
136
  /**
@@ -326,4 +332,12 @@ export interface IWsSessionState<TStatus extends string = TConnectionStatus> {
326
332
  * session notice) — no separate error state here.
327
333
  */
328
334
  deleteSchedule: (taskId: string) => void;
335
+
336
+ readAppView: (viewKey: string, signal?: AbortSignal) => Promise<TAppView>;
337
+ callAppTool: (
338
+ viewKey: string,
339
+ name: string,
340
+ args: TAppToolArguments,
341
+ signal?: AbortSignal,
342
+ ) => Promise<TAppToolOutcome>;
329
343
  }
@@ -0,0 +1,178 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+
3
+ import type { TClientMessage } from '../client/ws-session-client.js';
4
+ import type { TAppToolArguments, TAppToolOutcome, TAppView } from '../app-view/types.js';
5
+ import type { IActiveTool } from './session-client-types.js';
6
+ import type { TServerMessage } from '@robota-sdk/agent-transport';
7
+
8
+ type TPendingRequest = {
9
+ reject: (error: Error) => void;
10
+ dispose: () => void;
11
+ } & (
12
+ | { answer: 'app_view'; resolve: (value: TAppView) => void }
13
+ | { answer: 'app_tool_result'; resolve: (value: TAppToolOutcome) => void }
14
+ );
15
+
16
+ let requestCounter = 0;
17
+ function nextRequestId(prefix: string): string {
18
+ requestCounter += 1;
19
+ return `${prefix}_${requestCounter}_${Date.now()}`;
20
+ }
21
+
22
+ const CONNECTION_CLOSED = 'The connection to the session closed.';
23
+
24
+ /**
25
+ * The key a finished call's app view is read and called by, taken from the tool row's `appView`
26
+ * reference; `undefined` when the row gives none. Everything outside this module treats it as an
27
+ * opaque string — it is read here and put on the two wire messages below, nowhere else.
28
+ */
29
+ export function appViewKey(tool: IActiveTool): string | undefined {
30
+ return tool.appView?.viewId;
31
+ }
32
+
33
+ function readAppViewMessage(requestId: string, viewKey: string): TClientMessage {
34
+ return { type: 'read-app-view', requestId, viewId: viewKey };
35
+ }
36
+
37
+ function callAppToolMessage(
38
+ requestId: string,
39
+ viewKey: string,
40
+ name: string,
41
+ args: TAppToolArguments,
42
+ ): TClientMessage {
43
+ return { type: 'call-app-tool', requestId, viewId: viewKey, name, arguments: args };
44
+ }
45
+
46
+ /**
47
+ * The app-view host's two requests to the session, each a promise settled by the reply that echoes
48
+ * its `requestId` (`app_view` / `app_tool_result`, or a `protocol_error` naming it). Unlike the
49
+ * panels' latest-wins state, several can be outstanding at once (one per frame on screen), so each
50
+ * keeps its own entry. A reply that lost its connection never arrives: the owner of the connection
51
+ * rejects every outstanding request when it drops, is replaced or goes away (`rejectPendingAppRequests`).
52
+ */
53
+ export function useAppViewRequests(
54
+ send: (msg: TClientMessage) => void,
55
+ isConnected: () => boolean,
56
+ ): {
57
+ readAppView: (viewKey: string, signal?: AbortSignal) => Promise<TAppView>;
58
+ callAppTool: (
59
+ viewKey: string,
60
+ name: string,
61
+ args: TAppToolArguments,
62
+ signal?: AbortSignal,
63
+ ) => Promise<TAppToolOutcome>;
64
+ handleAppViewMessage: (msg: TServerMessage) => boolean;
65
+ rejectPendingAppRequests: (reason?: string) => void;
66
+ } {
67
+ const pendingRef = useRef(new Map<string, TPendingRequest>());
68
+ const [requestOwner] = useState(() => nextRequestId('app_request'));
69
+
70
+ const rejectPendingAppRequests = useCallback((reason: string = CONNECTION_CLOSED): void => {
71
+ const pending = [...pendingRef.current.values()];
72
+ pendingRef.current.clear();
73
+ for (const request of pending) {
74
+ request.dispose();
75
+ request.reject(new Error(reason));
76
+ }
77
+ }, []);
78
+
79
+ const request = useCallback(
80
+ <T extends TAppView | TAppToolOutcome>(
81
+ prefix: string,
82
+ message: (requestId: string) => TClientMessage,
83
+ pending: (
84
+ resolve: (value: T) => void,
85
+ reject: (error: Error) => void,
86
+ dispose: () => void,
87
+ ) => TPendingRequest,
88
+ signal?: AbortSignal,
89
+ ): Promise<T> =>
90
+ new Promise<T>((resolve, reject) => {
91
+ if (!isConnected() || signal?.aborted) {
92
+ reject(new Error(signal?.aborted ? 'The app view closed.' : CONNECTION_CLOSED));
93
+ return;
94
+ }
95
+ const requestId = nextRequestId(`${requestOwner}_${prefix}`);
96
+ const dispose = (): void => signal?.removeEventListener('abort', abort);
97
+ const abort = (): void => {
98
+ pendingRef.current.delete(requestId);
99
+ dispose();
100
+ reject(new Error('The app view closed.'));
101
+ };
102
+ pendingRef.current.set(requestId, pending(resolve, reject, dispose));
103
+ signal?.addEventListener('abort', abort, { once: true });
104
+ try {
105
+ send(message(requestId));
106
+ } catch (error: unknown) {
107
+ pendingRef.current.delete(requestId);
108
+ dispose();
109
+ reject(error instanceof Error ? error : new Error(String(error)));
110
+ }
111
+ }),
112
+ [isConnected, requestOwner, send],
113
+ );
114
+
115
+ const readAppView = useCallback(
116
+ (viewKey: string, signal?: AbortSignal): Promise<TAppView> =>
117
+ request<TAppView>(
118
+ 'app_view',
119
+ (requestId) => readAppViewMessage(requestId, viewKey),
120
+ (resolve, reject, dispose) => ({ answer: 'app_view', resolve, reject, dispose }),
121
+ signal,
122
+ ),
123
+ [request],
124
+ );
125
+
126
+ const callAppTool = useCallback(
127
+ (
128
+ viewKey: string,
129
+ name: string,
130
+ args: TAppToolArguments,
131
+ signal?: AbortSignal,
132
+ ): Promise<TAppToolOutcome> =>
133
+ request<TAppToolOutcome>(
134
+ 'app_tool',
135
+ (requestId) => callAppToolMessage(requestId, viewKey, name, args),
136
+ (resolve, reject, dispose) => ({ answer: 'app_tool_result', resolve, reject, dispose }),
137
+ signal,
138
+ ),
139
+ [request],
140
+ );
141
+
142
+ /** True when the message answered one of this hook's requests; every other message is not its own. */
143
+ const handleAppViewMessage = useCallback(
144
+ (msg: TServerMessage): boolean => {
145
+ if (msg.type === 'app_view' || msg.type === 'app_tool_result') {
146
+ const pending = pendingRef.current.get(msg.requestId);
147
+ // A reply to a request that already settled (or never was ours) matches nothing.
148
+ if (pending === undefined || pending.answer !== msg.type) return true;
149
+ pendingRef.current.delete(msg.requestId);
150
+ pending.dispose();
151
+ if (msg.type === 'app_view' && pending.answer === 'app_view') {
152
+ pending.resolve(msg.view);
153
+ } else if (msg.type === 'app_tool_result' && pending.answer === 'app_tool_result') {
154
+ const { success, data, parts, error } = msg;
155
+ pending.resolve({
156
+ success,
157
+ ...(data !== undefined ? { data } : {}),
158
+ ...(parts !== undefined ? { parts } : {}),
159
+ ...(error !== undefined ? { error } : {}),
160
+ });
161
+ }
162
+ return true;
163
+ }
164
+ if (msg.type === 'protocol_error' && msg.requestId !== undefined) {
165
+ const pending = pendingRef.current.get(msg.requestId);
166
+ if (pending === undefined) return msg.requestId.startsWith(`${requestOwner}_`);
167
+ pendingRef.current.delete(msg.requestId);
168
+ pending.dispose();
169
+ pending.reject(new Error(msg.message));
170
+ return true;
171
+ }
172
+ return false;
173
+ },
174
+ [requestOwner],
175
+ );
176
+
177
+ return { readAppView, callAppTool, handleAppViewMessage, rejectPendingAppRequests };
178
+ }
@@ -23,6 +23,7 @@ import {
23
23
  import { createWsSessionClient } from '../client/ws-session-client.js';
24
24
  import { SERVER_MESSAGE_HANDLING } from './server-message-handling.js';
25
25
  import { useAgentSwitcherState } from './use-agent-switcher-state.js';
26
+ import { useAppViewRequests } from './use-app-view-requests.js';
26
27
  import { useExecutionDetailState } from './use-execution-detail.js';
27
28
  import { useModelListState } from './use-model-list.js';
28
29
  import { usePersonalUsageState } from './use-personal-usage.js';
@@ -153,7 +154,7 @@ function toolEndFields(
153
154
  state: IToolState,
154
155
  ): Pick<
155
156
  IActiveTool,
156
- 'status' | 'result' | 'diffLines' | 'diffFile' | 'toolResultData' | 'toolResultParts'
157
+ 'status' | 'result' | 'diffLines' | 'diffFile' | 'toolResultData' | 'toolResultParts' | 'appView'
157
158
  > {
158
159
  return {
159
160
  // A tool that finished with an error result renders failed (ToolCard/ToolGroup key off
@@ -168,6 +169,7 @@ function toolEndFields(
168
169
  ...(state.diffFile ? { diffFile: state.diffFile } : {}),
169
170
  ...(state.toolResultData !== undefined ? { toolResultData: state.toolResultData } : {}),
170
171
  ...(state.toolResultParts ? { toolResultParts: state.toolResultParts } : {}),
172
+ ...(state.appView ? { appView: state.appView } : {}),
171
173
  };
172
174
  }
173
175
 
@@ -268,6 +270,8 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
268
270
  makeClient: TMakeSessionClient<TStatus>,
269
271
  ): IWsSessionState<TStatus> {
270
272
  const [status, setStatus] = useState<TStatus>('disconnected' as TStatus);
273
+ // The app-view requests read this to refuse at once when no connection exists to answer them.
274
+ const statusRef = useRef<TStatus>('disconnected' as TStatus);
271
275
  const [messages, setMessages] = useState<TConversationEntry[]>([]);
272
276
  const [activeTools, setActiveTools] = useState<IActiveTool[]>([]);
273
277
  const [streamingText, setStreamingText] = useState('');
@@ -433,6 +437,9 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
433
437
  useExecutionDetailState(send);
434
438
  const { handleModelListMessage, ...modelListState } = useModelListState(send);
435
439
  const { handleProjectMessage, ...projectPanelState } = useProjectPanelState(send);
440
+ const isConnected = useCallback((): boolean => statusRef.current === 'connected', []);
441
+ const { handleAppViewMessage, rejectPendingAppRequests, readAppView, callAppTool } =
442
+ useAppViewRequests(send, isConnected);
436
443
  const {
437
444
  handleSessionsMessage,
438
445
  canListSessions,
@@ -466,6 +473,8 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
466
473
  if (handleExecutionDetailMessage(msg)) return;
467
474
  if (handleModelListMessage(msg)) return;
468
475
  if (handleProjectMessage(msg)) return;
476
+ // A reply to a request an app view's host made (the view's document, or a tool it called).
477
+ if (handleAppViewMessage(msg)) return;
469
478
  if (handleSessionsMessage(msg)) return;
470
479
  if (handleSettingsMessage(msg)) return;
471
480
  if (handleAgentDefinitionsMessage(msg)) return;
@@ -649,6 +658,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
649
658
  }
650
659
  // #3189: the host made another session current — drop what this one showed, re-read it all.
651
660
  case 'session_switched': {
661
+ rejectPendingAppRequests('The active session changed.');
652
662
  settleSessionChange();
653
663
  // Nothing of the old session stays on screen until the new one's answers arrive.
654
664
  sessionStatusRef.current = null;
@@ -675,6 +685,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
675
685
  break;
676
686
  }
677
687
  case 'history_cleared': {
688
+ rejectPendingAppRequests('The session history was cleared.');
678
689
  streamingTextRef.current = '';
679
690
  turnSegmentsRef.current = [];
680
691
  setStreamingText('');
@@ -868,6 +879,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
868
879
  closeExecutionDetail,
869
880
  finishTurn,
870
881
  handleAgentDefinitionsMessage,
882
+ handleAppViewMessage,
871
883
  handleExecutionDetailMessage,
872
884
  handleModelListMessage,
873
885
  handleProjectMessage,
@@ -882,6 +894,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
882
894
  requestSchedules,
883
895
  requestSessions,
884
896
  resolveSwitchResult,
897
+ rejectPendingAppRequests,
885
898
  send,
886
899
  setSessionSidebarOpen,
887
900
  settleSessionChange,
@@ -905,7 +918,10 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
905
918
 
906
919
  useEffect(() => {
907
920
  const onStatusChange = (next: TStatus): void => {
921
+ statusRef.current = next;
908
922
  setStatus(next);
923
+ // A reply lost with the connection never arrives; the views waiting on one learn that now.
924
+ if (next !== 'connected') rejectPendingAppRequests();
909
925
  // Every client learns what it can offer and what is in effect as soon as it is attached.
910
926
  if (next === 'connected') {
911
927
  // A reply lost with the old connection never arrives; stop waiting for it.
@@ -933,8 +949,16 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
933
949
  return () => {
934
950
  client.disconnect();
935
951
  clientRef.current = null;
952
+ rejectPendingAppRequests();
936
953
  };
937
- }, [makeClient, handleMessage, requestSessions, requestSchedules, armRestore]);
954
+ }, [
955
+ makeClient,
956
+ handleMessage,
957
+ requestSessions,
958
+ requestSchedules,
959
+ armRestore,
960
+ rejectPendingAppRequests,
961
+ ]);
938
962
 
939
963
  return {
940
964
  status,
@@ -966,6 +990,8 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
966
990
  openAgentSwitcher,
967
991
  ...agentSwitcherState,
968
992
  ...schedulesState,
993
+ readAppView,
994
+ callAppTool,
969
995
  helpOpen,
970
996
  openHelp,
971
997
  closeHelp,
package/src/index.ts CHANGED
@@ -16,7 +16,13 @@ export type {
16
16
  ISessionClientHandle,
17
17
  TMakeSessionClient,
18
18
  } from './hooks/useSessionClient.js';
19
- export type { TSessionListing, TSessionsError } from './hooks/session-client-types.js';
19
+ export type {
20
+ TAppToolArguments,
21
+ TAppToolOutcome,
22
+ TAppView,
23
+ TSessionListing,
24
+ TSessionsError,
25
+ } from './hooks/session-client-types.js';
20
26
  // Issue #3280 §5: a host that can restart the runtime calls this before reloading the page, so the
21
27
  // first session listing after reconnecting can switch back to the session the person was in.
22
28
  export { rememberSessionForRestore } from './hooks/use-session-directory.js';