@robota-sdk/agent-ui-web 0.0.0-stage → 3.0.0-beta.100

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +548 -0
  2. package/LICENSE +661 -0
  3. package/README.md +19 -2
  4. package/dist/browser/index.d.ts +736 -0
  5. package/dist/browser/index.d.ts.map +1 -0
  6. package/dist/browser/index.js +8525 -0
  7. package/dist/browser/index.js.map +1 -0
  8. package/dist/node/index.cjs +8575 -0
  9. package/dist/node/index.d.cts +736 -0
  10. package/dist/node/index.d.cts.map +1 -0
  11. package/dist/node/index.d.ts +736 -0
  12. package/dist/node/index.d.ts.map +1 -0
  13. package/dist/node/index.js +8525 -0
  14. package/dist/node/index.js.map +1 -0
  15. package/docs/README.md +62 -0
  16. package/docs/SPEC.md +130 -0
  17. package/package.json +99 -3
  18. package/src/app-view/bridge.ts +217 -0
  19. package/src/app-view/call-tool-result.ts +58 -0
  20. package/src/app-view/host-context.ts +37 -0
  21. package/src/app-view/types.ts +12 -0
  22. package/src/client/runtime-host.ts +84 -0
  23. package/src/client/ws-session-client.ts +131 -0
  24. package/src/clipboard.ts +81 -0
  25. package/src/components/AgentActivityPanel.tsx +371 -0
  26. package/src/components/AgentSwitcherSheet.tsx +93 -0
  27. package/src/components/AppView.tsx +147 -0
  28. package/src/components/Brand.tsx +33 -0
  29. package/src/components/Composer.tsx +685 -0
  30. package/src/components/ConversationView.tsx +789 -0
  31. package/src/components/Dialog.tsx +230 -0
  32. package/src/components/DiffLines.tsx +81 -0
  33. package/src/components/ExecutionDetailSheet.tsx +142 -0
  34. package/src/components/HelpSheet.tsx +104 -0
  35. package/src/components/PermissionPrompt.tsx +762 -0
  36. package/src/components/PersonalUsageDashboard.tsx +39 -0
  37. package/src/components/PersonalUsageDashboardDetails.tsx +368 -0
  38. package/src/components/PersonalUsageDashboardSections.tsx +250 -0
  39. package/src/components/PopupMenu.tsx +166 -0
  40. package/src/components/ProjectPanel.tsx +268 -0
  41. package/src/components/SessionMonitor.tsx +180 -0
  42. package/src/components/SessionSidebar.tsx +429 -0
  43. package/src/components/SessionSurface.tsx +482 -0
  44. package/src/components/SessionSurfaceChrome.tsx +319 -0
  45. package/src/components/SettingsFields.tsx +159 -0
  46. package/src/components/SettingsGeneralSection.tsx +49 -0
  47. package/src/components/SettingsMcpSection.tsx +157 -0
  48. package/src/components/SettingsPermissionsSection.tsx +137 -0
  49. package/src/components/SettingsPluginsSection.tsx +165 -0
  50. package/src/components/SettingsProvidersSection.tsx +211 -0
  51. package/src/components/SettingsScreen.tsx +340 -0
  52. package/src/components/StatusControls.tsx +294 -0
  53. package/src/components/composer-attachments.ts +246 -0
  54. package/src/components/personal-usage-dashboard-types.ts +26 -0
  55. package/src/driver-labels.ts +88 -0
  56. package/src/execution-entry-status.ts +55 -0
  57. package/src/goal-view.ts +19 -0
  58. package/src/hooks/command-menu.ts +65 -0
  59. package/src/hooks/excluded-commands.ts +52 -0
  60. package/src/hooks/prompt-state.ts +83 -0
  61. package/src/hooks/server-message-handling.ts +95 -0
  62. package/src/hooks/session-client-types.ts +344 -0
  63. package/src/hooks/ui-intent-state.ts +74 -0
  64. package/src/hooks/use-agent-switcher-state.ts +125 -0
  65. package/src/hooks/use-app-view-requests.ts +178 -0
  66. package/src/hooks/use-execution-detail.ts +118 -0
  67. package/src/hooks/use-media-query.ts +27 -0
  68. package/src/hooks/use-model-list.ts +45 -0
  69. package/src/hooks/use-personal-usage.ts +138 -0
  70. package/src/hooks/use-project-panel.ts +117 -0
  71. package/src/hooks/use-schedules-state.ts +92 -0
  72. package/src/hooks/use-session-directory.ts +202 -0
  73. package/src/hooks/use-settings-state.ts +115 -0
  74. package/src/hooks/useSessionClient.ts +1051 -0
  75. package/src/index.ts +72 -0
  76. package/src/product-identity.tsx +26 -0
  77. package/src/schedule-view.ts +92 -0
  78. package/src/styles/surface.css +231 -0
  79. package/src/styles/surface.css.d.mts +1 -0
  80. package/src/styles/theme.css +38 -0
  81. package/src/styles/theme.css.d.mts +1 -0
@@ -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
+ }
@@ -0,0 +1,118 @@
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';
10
+
11
+ import type { TClientMessage } from '../client/ws-session-client.js';
12
+ import type {
13
+ IExecutionDetailCursor,
14
+ IExecutionDetailRecord,
15
+ } from '@robota-sdk/agent-interface-execution';
16
+ import type { TServerMessage } from '@robota-sdk/agent-transport';
17
+
18
+ export type TExecutionDetailStatus = 'idle' | 'loading' | 'ready' | 'error';
19
+
20
+ export interface IExecutionDetailState {
21
+ /** The entry currently open in the detail sheet, or null when it is closed. */
22
+ openEntryId: string | null;
23
+ executionDetailStatus: TExecutionDetailStatus;
24
+ /** Every record read so far for the open entry, oldest first — pages accumulate via loadMoreExecutionDetail. */
25
+ executionDetailRecords: readonly IExecutionDetailRecord[];
26
+ executionDetailError: string | null;
27
+ /** True once a page came back with no `nextCursor` — nothing more to load. */
28
+ executionDetailComplete: boolean;
29
+ openExecutionDetail: (entryId: string) => void;
30
+ loadMoreExecutionDetail: () => void;
31
+ closeExecutionDetail: () => void;
32
+ }
33
+
34
+ let requestCounter = 0;
35
+ function nextRequestId(): string {
36
+ requestCounter += 1;
37
+ return `execdetail_${requestCounter}_${Date.now()}`;
38
+ }
39
+
40
+ export function useExecutionDetailState(
41
+ send: (msg: TClientMessage) => void,
42
+ ): IExecutionDetailState & { handleExecutionDetailMessage: (msg: TServerMessage) => boolean } {
43
+ const [openEntryId, setOpenEntryId] = useState<string | null>(null);
44
+ const [status, setStatus] = useState<TExecutionDetailStatus>('idle');
45
+ const [records, setRecords] = useState<readonly IExecutionDetailRecord[]>([]);
46
+ const [error, setError] = useState<string | null>(null);
47
+ const [complete, setComplete] = useState(false);
48
+ const requestIdRef = useRef<string | null>(null);
49
+ const entryIdRef = useRef<string | null>(null);
50
+ const nextCursorRef = useRef<IExecutionDetailCursor | undefined>(undefined);
51
+
52
+ const readPage = useCallback(
53
+ (entryId: string, cursor?: IExecutionDetailCursor): void => {
54
+ const requestId = nextRequestId();
55
+ requestIdRef.current = requestId;
56
+ entryIdRef.current = entryId;
57
+ setStatus('loading');
58
+ setError(null);
59
+ send({ type: 'read-execution-detail', requestId, entryId, cursor });
60
+ },
61
+ [send],
62
+ );
63
+
64
+ const openExecutionDetail = useCallback(
65
+ (entryId: string): void => {
66
+ setOpenEntryId(entryId);
67
+ setRecords([]);
68
+ setComplete(false);
69
+ nextCursorRef.current = undefined;
70
+ readPage(entryId);
71
+ },
72
+ [readPage],
73
+ );
74
+
75
+ const loadMoreExecutionDetail = useCallback((): void => {
76
+ if (!openEntryId || status === 'loading' || complete) return;
77
+ readPage(openEntryId, nextCursorRef.current);
78
+ }, [openEntryId, status, complete, readPage]);
79
+
80
+ const closeExecutionDetail = useCallback((): void => {
81
+ setOpenEntryId(null);
82
+ requestIdRef.current = null;
83
+ entryIdRef.current = null;
84
+ setRecords([]);
85
+ setStatus('idle');
86
+ setError(null);
87
+ setComplete(false);
88
+ }, []);
89
+
90
+ const handleExecutionDetailMessage = useCallback((msg: TServerMessage): boolean => {
91
+ if (msg.type !== 'execution_detail' && msg.type !== 'execution_detail_error') return false;
92
+ if (requestIdRef.current !== msg.requestId) return true; // A stale/superseded read — ignored.
93
+ if (msg.type === 'execution_detail_error') {
94
+ setStatus('error');
95
+ setError(msg.message);
96
+ return true;
97
+ }
98
+ setRecords((previous) =>
99
+ msg.page.cursor === undefined ? msg.page.records : [...previous, ...msg.page.records],
100
+ );
101
+ nextCursorRef.current = msg.page.nextCursor;
102
+ setComplete(msg.page.nextCursor === undefined);
103
+ setStatus('ready');
104
+ return true;
105
+ }, []);
106
+
107
+ return {
108
+ openEntryId,
109
+ executionDetailStatus: status,
110
+ executionDetailRecords: records,
111
+ executionDetailError: error,
112
+ executionDetailComplete: complete,
113
+ openExecutionDetail,
114
+ loadMoreExecutionDetail,
115
+ closeExecutionDetail,
116
+ handleExecutionDetailMessage,
117
+ };
118
+ }
@@ -0,0 +1,27 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * Tracks whether `query` currently matches, reactively (#3289 §2 — the narrow-window session sheet
5
+ * below `md`). A host without `window.matchMedia` (an older embedding, or a non-browser test) reports
6
+ * `false` and never subscribes, rather than throwing.
7
+ */
8
+ export function useMediaQuery(query: string): boolean {
9
+ const [matches, setMatches] = useState<boolean>(() => {
10
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
11
+ return window.matchMedia(query).matches;
12
+ });
13
+
14
+ useEffect(() => {
15
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return undefined;
16
+ const mql = window.matchMedia(query);
17
+ const onChange = (): void => setMatches(mql.matches);
18
+ onChange();
19
+ mql.addEventListener('change', onChange);
20
+ return () => mql.removeEventListener('change', onChange);
21
+ }, [query]);
22
+
23
+ return matches;
24
+ }
25
+
26
+ /** Tailwind's `md` breakpoint (768px) — below it, the session list is a sheet rather than a column. */
27
+ export const NARROW_WINDOW_QUERY = '(max-width: 767.98px)';
@@ -0,0 +1,45 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+
3
+ import type { IWsSessionState, TModelListSnapshot } from './session-client-types.js';
4
+ import type { TClientMessage } from '../client/ws-session-client.js';
5
+ import type { TServerMessage } from '@robota-sdk/agent-transport';
6
+
7
+ type TModelListState = Pick<IWsSessionState, 'modelList' | 'requestModelList'>;
8
+
9
+ let requestCounter = 0;
10
+ function nextRequestId(): string {
11
+ requestCounter += 1;
12
+ return `model_list_${requestCounter}_${Date.now()}`;
13
+ }
14
+
15
+ /**
16
+ * #3282 §2 (part 2) — the model list the model control's pop-up menu is built from, requested on
17
+ * demand (`list-models`) and correlated by `requestId` (mirrors `usePersonalUsageState`'s pattern) so
18
+ * a reply to an older request — one already superseded by a newer one, e.g. across a reconnect — is
19
+ * never applied over the list a still-open menu is currently showing.
20
+ */
21
+ export function useModelListState(send: (msg: TClientMessage) => void): TModelListState & {
22
+ handleModelListMessage: (msg: TServerMessage) => boolean;
23
+ } {
24
+ const [modelList, setModelList] = useState<TModelListSnapshot | null>(null);
25
+ const requestRef = useRef<string | null>(null);
26
+
27
+ const handleModelListMessage = useCallback((msg: TServerMessage): boolean => {
28
+ if (msg.type !== 'model_list') return false;
29
+ if (requestRef.current !== msg.requestId) return true;
30
+ setModelList({
31
+ groups: msg.groups,
32
+ currentModel: msg.currentModel,
33
+ ...(msg.currentProfile !== undefined ? { currentProfile: msg.currentProfile } : {}),
34
+ });
35
+ return true;
36
+ }, []);
37
+
38
+ const requestModelList = useCallback((): void => {
39
+ const requestId = nextRequestId();
40
+ requestRef.current = requestId;
41
+ send({ type: 'list-models', requestId });
42
+ }, [send]);
43
+
44
+ return { modelList, requestModelList, handleModelListMessage };
45
+ }
@@ -0,0 +1,138 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+
3
+ import type {
4
+ IWsSessionState,
5
+ TCurrentSessionUsageReport,
6
+ TPersonalUsageReport,
7
+ TStoredSessionUsageReport,
8
+ } from './session-client-types.js';
9
+ import type { TClientMessage } from '../client/ws-session-client.js';
10
+ import type { TServerMessage } from '@robota-sdk/agent-transport';
11
+
12
+ type TPersonalUsageState = Pick<
13
+ IWsSessionState,
14
+ | 'personalUsageStatus'
15
+ | 'personalUsageReport'
16
+ | 'personalUsageError'
17
+ | 'requestPersonalUsage'
18
+ | 'storedSessionUsageStatus'
19
+ | 'storedSessionUsageReport'
20
+ | 'storedSessionUsageSessionId'
21
+ | 'storedSessionUsageError'
22
+ | 'requestStoredSessionUsage'
23
+ | 'currentSessionUsageStatus'
24
+ | 'currentSessionUsageReport'
25
+ | 'requestCurrentSessionUsage'
26
+ >;
27
+
28
+ let requestCounter = 0;
29
+ function nextRequestId(prefix: string): string {
30
+ requestCounter += 1;
31
+ return `${prefix}_${requestCounter}_${Date.now()}`;
32
+ }
33
+
34
+ /** Own the correlated aggregate/stored/current usage protocol state independently from chat state. */
35
+ export function usePersonalUsageState(send: (msg: TClientMessage) => void): TPersonalUsageState & {
36
+ handleUsageMessage: (msg: TServerMessage) => boolean;
37
+ } {
38
+ const [personalUsageStatus, setPersonalUsageStatus] =
39
+ useState<IWsSessionState['personalUsageStatus']>('idle');
40
+ const [personalUsageReport, setPersonalUsageReport] = useState<TPersonalUsageReport | null>(null);
41
+ const [personalUsageError, setPersonalUsageError] = useState<string | null>(null);
42
+ const [storedSessionUsageStatus, setStoredSessionUsageStatus] =
43
+ useState<IWsSessionState['storedSessionUsageStatus']>('idle');
44
+ const [storedSessionUsageReport, setStoredSessionUsageReport] =
45
+ useState<TStoredSessionUsageReport | null>(null);
46
+ const [storedSessionUsageSessionId, setStoredSessionUsageSessionId] = useState<string | null>(
47
+ null,
48
+ );
49
+ const [storedSessionUsageError, setStoredSessionUsageError] = useState<string | null>(null);
50
+ const [currentSessionUsageStatus, setCurrentSessionUsageStatus] =
51
+ useState<IWsSessionState['currentSessionUsageStatus']>('idle');
52
+ const [currentSessionUsageReport, setCurrentSessionUsageReport] =
53
+ useState<TCurrentSessionUsageReport | null>(null);
54
+ const personalRequestRef = useRef<string | null>(null);
55
+ const storedRequestRef = useRef<string | null>(null);
56
+
57
+ const handleUsageMessage = useCallback((msg: TServerMessage): boolean => {
58
+ if (msg.type === 'personal_usage_report' || msg.type === 'personal_usage_report_error') {
59
+ if (personalRequestRef.current !== msg.requestId) return true;
60
+ setPersonalUsageReport(msg.type === 'personal_usage_report' ? msg.report : null);
61
+ setPersonalUsageError(msg.type === 'personal_usage_report_error' ? msg.message : null);
62
+ setPersonalUsageStatus(msg.type === 'personal_usage_report' ? 'ready' : 'error');
63
+ return true;
64
+ }
65
+ if (msg.type === 'usage_report') {
66
+ setCurrentSessionUsageReport(msg.report);
67
+ setCurrentSessionUsageStatus('ready');
68
+ return true;
69
+ }
70
+ if (
71
+ msg.type === 'stored_session_usage_report' ||
72
+ msg.type === 'stored_session_usage_report_error'
73
+ ) {
74
+ if (storedRequestRef.current !== msg.requestId) return true;
75
+ setStoredSessionUsageReport(msg.type === 'stored_session_usage_report' ? msg.report : null);
76
+ setStoredSessionUsageSessionId(msg.sessionId);
77
+ setStoredSessionUsageError(
78
+ msg.type === 'stored_session_usage_report_error' ? msg.message : null,
79
+ );
80
+ setStoredSessionUsageStatus(msg.type === 'stored_session_usage_report' ? 'ready' : 'error');
81
+ return true;
82
+ }
83
+ if (msg.type === 'protocol_error') {
84
+ setCurrentSessionUsageStatus((current) => (current === 'loading' ? 'error' : current));
85
+ }
86
+ return false;
87
+ }, []);
88
+
89
+ const requestPersonalUsage = useCallback(
90
+ (period: '7d' | '30d'): void => {
91
+ const requestId = nextRequestId('personal_usage');
92
+ personalRequestRef.current = requestId;
93
+ setPersonalUsageStatus('loading');
94
+ setPersonalUsageError(null);
95
+ send({
96
+ type: 'get-personal-usage-report',
97
+ requestId,
98
+ period,
99
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
100
+ });
101
+ },
102
+ [send],
103
+ );
104
+
105
+ const requestStoredSessionUsage = useCallback(
106
+ (sessionId: string): void => {
107
+ const requestId = nextRequestId('stored_session_usage');
108
+ storedRequestRef.current = requestId;
109
+ setStoredSessionUsageStatus('loading');
110
+ setStoredSessionUsageReport(null);
111
+ setStoredSessionUsageSessionId(sessionId);
112
+ setStoredSessionUsageError(null);
113
+ send({ type: 'get-stored-session-usage-report', requestId, sessionId });
114
+ },
115
+ [send],
116
+ );
117
+
118
+ const requestCurrentSessionUsage = useCallback((): void => {
119
+ setCurrentSessionUsageStatus('loading');
120
+ send({ type: 'get-usage-report' });
121
+ }, [send]);
122
+
123
+ return {
124
+ personalUsageStatus,
125
+ personalUsageReport,
126
+ personalUsageError,
127
+ requestPersonalUsage,
128
+ storedSessionUsageStatus,
129
+ storedSessionUsageReport,
130
+ storedSessionUsageSessionId,
131
+ storedSessionUsageError,
132
+ requestStoredSessionUsage,
133
+ currentSessionUsageStatus,
134
+ currentSessionUsageReport,
135
+ requestCurrentSessionUsage,
136
+ handleUsageMessage,
137
+ };
138
+ }
@@ -0,0 +1,117 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+
3
+ import type { IWsSessionState } from './session-client-types.js';
4
+ import type { TClientMessage } from '../client/ws-session-client.js';
5
+ import type { TServerMessage } from '@robota-sdk/agent-transport';
6
+
7
+ type TProjectPanelState = Pick<
8
+ IWsSessionState,
9
+ | 'projectStatusState'
10
+ | 'projectStatus'
11
+ | 'requestProjectStatus'
12
+ | 'projectDiffState'
13
+ | 'projectDiffPath'
14
+ | 'projectDiff'
15
+ | 'requestProjectDiff'
16
+ | 'projectMemoryState'
17
+ | 'projectMemory'
18
+ | 'requestProjectMemory'
19
+ >;
20
+
21
+ let requestCounter = 0;
22
+ function nextRequestId(prefix: string): string {
23
+ requestCounter += 1;
24
+ return `${prefix}_${requestCounter}_${Date.now()}`;
25
+ }
26
+
27
+ /**
28
+ * #3282 §4c — the Project panel's correlated request/response state (status, one file's diff, project
29
+ * memory), independent from chat state. Mirrors `usePersonalUsageState`'s shape: a status per read, a
30
+ * `requestId` ref that drops a stale reply (a diff request superseded by clicking another file before
31
+ * the first answered), and a request function per read.
32
+ */
33
+ export function useProjectPanelState(
34
+ send: (msg: TClientMessage) => void,
35
+ ): TProjectPanelState & { handleProjectMessage: (msg: TServerMessage) => boolean } {
36
+ const [projectStatusState, setProjectStatusState] =
37
+ useState<IWsSessionState['projectStatusState']>('idle');
38
+ const [projectStatus, setProjectStatus] = useState<IWsSessionState['projectStatus']>(null);
39
+ const [projectDiffState, setProjectDiffState] =
40
+ useState<IWsSessionState['projectDiffState']>('idle');
41
+ const [projectDiffPath, setProjectDiffPath] = useState<string | null>(null);
42
+ const [projectDiff, setProjectDiff] = useState<IWsSessionState['projectDiff']>(null);
43
+ const [projectMemoryState, setProjectMemoryState] =
44
+ useState<IWsSessionState['projectMemoryState']>('idle');
45
+ const [projectMemory, setProjectMemory] = useState<IWsSessionState['projectMemory']>(null);
46
+
47
+ const statusRequestRef = useRef<string | null>(null);
48
+ const diffRequestRef = useRef<string | null>(null);
49
+ const memoryRequestRef = useRef<string | null>(null);
50
+
51
+ const handleProjectMessage = useCallback((msg: TServerMessage): boolean => {
52
+ if (msg.type === 'project_status') {
53
+ if (statusRequestRef.current !== msg.requestId) return true;
54
+ setProjectStatus(msg.result);
55
+ setProjectStatusState('ready');
56
+ return true;
57
+ }
58
+ if (msg.type === 'project_diff') {
59
+ if (diffRequestRef.current !== msg.requestId) return true;
60
+ setProjectDiff(msg.result);
61
+ setProjectDiffState('ready');
62
+ return true;
63
+ }
64
+ if (msg.type === 'project_memory') {
65
+ if (memoryRequestRef.current !== msg.requestId) return true;
66
+ setProjectMemory(msg.result);
67
+ setProjectMemoryState('ready');
68
+ return true;
69
+ }
70
+ if (msg.type === 'protocol_error' && msg.requestId !== undefined) {
71
+ if (statusRequestRef.current === msg.requestId) setProjectStatusState('error');
72
+ if (diffRequestRef.current === msg.requestId) setProjectDiffState('error');
73
+ if (memoryRequestRef.current === msg.requestId) setProjectMemoryState('error');
74
+ }
75
+ return false;
76
+ }, []);
77
+
78
+ const requestProjectStatus = useCallback((): void => {
79
+ const requestId = nextRequestId('project_status');
80
+ statusRequestRef.current = requestId;
81
+ setProjectStatusState('loading');
82
+ send({ type: 'project-status', requestId });
83
+ }, [send]);
84
+
85
+ const requestProjectDiff = useCallback(
86
+ (path: string): void => {
87
+ const requestId = nextRequestId('project_diff');
88
+ diffRequestRef.current = requestId;
89
+ setProjectDiffPath(path);
90
+ setProjectDiffState('loading');
91
+ setProjectDiff(null);
92
+ send({ type: 'project-diff', requestId, path });
93
+ },
94
+ [send],
95
+ );
96
+
97
+ const requestProjectMemory = useCallback((): void => {
98
+ const requestId = nextRequestId('project_memory');
99
+ memoryRequestRef.current = requestId;
100
+ setProjectMemoryState('loading');
101
+ send({ type: 'project-memory', requestId });
102
+ }, [send]);
103
+
104
+ return {
105
+ projectStatusState,
106
+ projectStatus,
107
+ requestProjectStatus,
108
+ projectDiffState,
109
+ projectDiffPath,
110
+ projectDiff,
111
+ requestProjectDiff,
112
+ projectMemoryState,
113
+ projectMemory,
114
+ requestProjectMemory,
115
+ handleProjectMessage,
116
+ };
117
+ }