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

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.
@@ -268,7 +268,7 @@ function noticeSentence(notice: ISessionNotice): string | null {
268
268
  }
269
269
 
270
270
  /**
271
- * Session and protocol failures, as dismissible toasts over the top-right corner: they stay until
271
+ * Session outcomes and protocol failures, as dismissible toasts over the top-right corner: they stay until
272
272
  * dismissed, and never push the conversation or the composer out of view. Command output is not
273
273
  * here — it belongs to the conversation. A provider error classified at the wire boundary shows one
274
274
  * plain sentence with the next step; its raw text sits behind "Details" rather than as the headline.
@@ -280,13 +280,16 @@ export function SessionNotices({ state }: { state: IWsSessionState }): React.Rea
280
280
  <div className="pointer-events-none absolute right-4 top-16 z-40 flex w-[min(400px,calc(100%-32px))] flex-col gap-2">
281
281
  {notices.slice(-3).map((notice) => {
282
282
  const sentence = noticeSentence(notice);
283
+ const compacted = notice.kind === 'context-compacted';
283
284
  return (
284
285
  <div
285
286
  key={notice.id}
286
- role="alert"
287
+ role={compacted ? 'status' : 'alert'}
287
288
  className="gui-rise pointer-events-auto flex items-start gap-3 rounded-xl bg-popover px-4 py-3 text-[14px] leading-snug text-popover-foreground shadow-xl shadow-black/30"
288
289
  >
289
- <span className="mt-[7px] h-2 w-2 flex-shrink-0 rounded-full bg-destructive" />
290
+ <span
291
+ className={`mt-[7px] h-2 w-2 flex-shrink-0 rounded-full ${compacted ? 'bg-primary' : 'bg-destructive'}`}
292
+ />
290
293
  <span className="max-h-40 flex-1 overflow-y-auto whitespace-pre-wrap break-words">
291
294
  {sentence ?? notice.message}
292
295
  {sentence && (
@@ -20,11 +20,11 @@ export const SERVER_MESSAGE_HANDLING = {
20
20
  error: 'visible-notice',
21
21
  command_result: 'reducer-state',
22
22
  messages: 'reducer-state',
23
- // #3189: the full history and its change signal serve a client that renders the whole session
24
- // (the TUI); this surface builds its transcript from `messages` and the streamed turn.
23
+ // The full system history serves the TUI; this transcript uses `messages` and streamed turns.
24
+ // A history change carries optional compaction metadata for a notice without replacing the turn.
25
25
  history: 'intentionally-not-rendered',
26
- history_changed: 'intentionally-not-rendered',
27
- context: 'intentionally-not-rendered',
26
+ history_changed: 'visible-notice',
27
+ context: 'reducer-state',
28
28
  turn_source: 'intentionally-not-rendered',
29
29
  commands: 'reducer-state',
30
30
  session_status: 'reducer-state',
@@ -62,10 +62,9 @@ export const SERVER_MESSAGE_HANDLING = {
62
62
  project_status: 'reducer-state',
63
63
  project_diff: 'reducer-state',
64
64
  project_memory: 'reducer-state',
65
- // The app-view host (the next step) consumes both: the document a finished call named, and the
66
- // outcome of a tool call it made for that view.
67
- app_view: 'intentionally-not-rendered',
68
- app_tool_result: 'intentionally-not-rendered',
65
+
66
+ app_view: 'reducer-state',
67
+ app_tool_result: 'reducer-state',
69
68
  background_task_event: 'intentionally-not-rendered',
70
69
  background_job_group_event: 'intentionally-not-rendered',
71
70
  plan_event: 'intentionally-not-rendered',
@@ -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
  /**
@@ -151,7 +157,8 @@ export interface ISessionNotice {
151
157
  | 'protocol-error'
152
158
  | 'session-change-refused'
153
159
  | 'background-task-control-failed'
154
- | 'command-failed';
160
+ | 'command-failed'
161
+ | 'context-compacted';
155
162
  message: string;
156
163
  /**
157
164
  * A `session-error` classified at the wire boundary (#3289 §3) — present only when the host could
@@ -326,4 +333,12 @@ export interface IWsSessionState<TStatus extends string = TConnectionStatus> {
326
333
  * session notice) — no separate error state here.
327
334
  */
328
335
  deleteSchedule: (taskId: string) => void;
336
+
337
+ readAppView: (viewKey: string, signal?: AbortSignal) => Promise<TAppView>;
338
+ callAppTool: (
339
+ viewKey: string,
340
+ name: string,
341
+ args: TAppToolArguments,
342
+ signal?: AbortSignal,
343
+ ) => Promise<TAppToolOutcome>;
329
344
  }
@@ -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,
@@ -457,6 +464,10 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
457
464
  } = useAgentSwitcherState(send);
458
465
  const { handleSchedulesMessage, requestSchedules, ...schedulesState } = useSchedulesState(send);
459
466
 
467
+ const clearCompactionNotices = useCallback((): void => {
468
+ setSessionNotices((previous) => previous.filter((notice) => notice.kind !== 'context-compacted'));
469
+ }, []);
470
+
460
471
  const handleMessage = useCallback(
461
472
  (msg: TServerMessage): void => {
462
473
  // ARCH-2164: touching the exhaustive registry here keeps every decoded variant tied to an
@@ -466,6 +477,8 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
466
477
  if (handleExecutionDetailMessage(msg)) return;
467
478
  if (handleModelListMessage(msg)) return;
468
479
  if (handleProjectMessage(msg)) return;
480
+ // A reply to a request an app view's host made (the view's document, or a tool it called).
481
+ if (handleAppViewMessage(msg)) return;
469
482
  if (handleSessionsMessage(msg)) return;
470
483
  if (handleSettingsMessage(msg)) return;
471
484
  if (handleAgentDefinitionsMessage(msg)) return;
@@ -649,10 +662,12 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
649
662
  }
650
663
  // #3189: the host made another session current — drop what this one showed, re-read it all.
651
664
  case 'session_switched': {
665
+ rejectPendingAppRequests('The active session changed.');
652
666
  settleSessionChange();
653
667
  // Nothing of the old session stays on screen until the new one's answers arrive.
654
668
  sessionStatusRef.current = null;
655
669
  setSessionStatus(null);
670
+ clearCompactionNotices();
656
671
  streamingTextRef.current = '';
657
672
  turnSegmentsRef.current = [];
658
673
  setStreamingText('');
@@ -675,6 +690,8 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
675
690
  break;
676
691
  }
677
692
  case 'history_cleared': {
693
+ clearCompactionNotices();
694
+ rejectPendingAppRequests('The session history was cleared.');
678
695
  streamingTextRef.current = '';
679
696
  turnSegmentsRef.current = [];
680
697
  setStreamingText('');
@@ -782,6 +799,29 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
782
799
  setCommandCatalog(catalog);
783
800
  break;
784
801
  }
802
+ case 'context': {
803
+ // A later status snapshot supplies identity and the latest context when none is known yet.
804
+ if (sessionStatusRef.current !== null) {
805
+ const next = { ...sessionStatusRef.current, context: msg.state };
806
+ sessionStatusRef.current = next;
807
+ setSessionStatus(next);
808
+ }
809
+ break;
810
+ }
811
+ case 'history_changed': {
812
+ const compact = msg.compact;
813
+ if (compact?.trigger === 'auto') {
814
+ setSessionNotices((previous) => [
815
+ ...previous,
816
+ {
817
+ id: nextId(),
818
+ kind: 'context-compacted',
819
+ message: `Auto compacted context: ${Math.round(compact.beforeUsedPercentage)}% → ${Math.round(compact.afterUsedPercentage)}%`,
820
+ },
821
+ ]);
822
+ }
823
+ break;
824
+ }
785
825
  case 'session_status': {
786
826
  sessionStatusRef.current = msg.status;
787
827
  setSessionStatus(msg.status);
@@ -866,8 +906,10 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
866
906
  appendEntry,
867
907
  canListSessions,
868
908
  closeExecutionDetail,
909
+ clearCompactionNotices,
869
910
  finishTurn,
870
911
  handleAgentDefinitionsMessage,
912
+ handleAppViewMessage,
871
913
  handleExecutionDetailMessage,
872
914
  handleModelListMessage,
873
915
  handleProjectMessage,
@@ -882,6 +924,7 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
882
924
  requestSchedules,
883
925
  requestSessions,
884
926
  resolveSwitchResult,
927
+ rejectPendingAppRequests,
885
928
  send,
886
929
  setSessionSidebarOpen,
887
930
  settleSessionChange,
@@ -905,7 +948,13 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
905
948
 
906
949
  useEffect(() => {
907
950
  const onStatusChange = (next: TStatus): void => {
951
+ statusRef.current = next;
908
952
  setStatus(next);
953
+ // A reply lost with the connection never arrives; the views waiting on one learn that now.
954
+ if (next !== 'connected') {
955
+ rejectPendingAppRequests();
956
+ clearCompactionNotices();
957
+ }
909
958
  // Every client learns what it can offer and what is in effect as soon as it is attached.
910
959
  if (next === 'connected') {
911
960
  // A reply lost with the old connection never arrives; stop waiting for it.
@@ -933,8 +982,17 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
933
982
  return () => {
934
983
  client.disconnect();
935
984
  clientRef.current = null;
985
+ rejectPendingAppRequests();
936
986
  };
937
- }, [makeClient, handleMessage, requestSessions, requestSchedules, armRestore]);
987
+ }, [
988
+ makeClient,
989
+ handleMessage,
990
+ clearCompactionNotices,
991
+ requestSessions,
992
+ requestSchedules,
993
+ armRestore,
994
+ rejectPendingAppRequests,
995
+ ]);
938
996
 
939
997
  return {
940
998
  status,
@@ -966,6 +1024,8 @@ export function useSessionClient<TStatus extends string = TConnectionStatus>(
966
1024
  openAgentSwitcher,
967
1025
  ...agentSwitcherState,
968
1026
  ...schedulesState,
1027
+ readAppView,
1028
+ callAppTool,
969
1029
  helpOpen,
970
1030
  openHelp,
971
1031
  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';