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

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 (75) hide show
  1. package/CHANGELOG.md +409 -0
  2. package/LICENSE +661 -0
  3. package/README.md +19 -2
  4. package/dist/browser/index.d.ts +715 -0
  5. package/dist/browser/index.d.ts.map +1 -0
  6. package/dist/browser/index.js +7923 -0
  7. package/dist/browser/index.js.map +1 -0
  8. package/dist/node/index.cjs +7973 -0
  9. package/dist/node/index.d.cts +715 -0
  10. package/dist/node/index.d.cts.map +1 -0
  11. package/dist/node/index.d.ts +715 -0
  12. package/dist/node/index.d.ts.map +1 -0
  13. package/dist/node/index.js +7923 -0
  14. package/dist/node/index.js.map +1 -0
  15. package/docs/README.md +62 -0
  16. package/docs/SPEC.md +124 -0
  17. package/package.json +99 -3
  18. package/src/client/runtime-host.ts +84 -0
  19. package/src/client/ws-session-client.ts +131 -0
  20. package/src/clipboard.ts +81 -0
  21. package/src/components/AgentActivityPanel.tsx +371 -0
  22. package/src/components/AgentSwitcherSheet.tsx +93 -0
  23. package/src/components/Brand.tsx +33 -0
  24. package/src/components/Composer.tsx +685 -0
  25. package/src/components/ConversationView.tsx +772 -0
  26. package/src/components/Dialog.tsx +230 -0
  27. package/src/components/DiffLines.tsx +81 -0
  28. package/src/components/ExecutionDetailSheet.tsx +142 -0
  29. package/src/components/HelpSheet.tsx +104 -0
  30. package/src/components/PermissionPrompt.tsx +661 -0
  31. package/src/components/PersonalUsageDashboard.tsx +39 -0
  32. package/src/components/PersonalUsageDashboardDetails.tsx +368 -0
  33. package/src/components/PersonalUsageDashboardSections.tsx +250 -0
  34. package/src/components/PopupMenu.tsx +166 -0
  35. package/src/components/ProjectPanel.tsx +268 -0
  36. package/src/components/SessionMonitor.tsx +180 -0
  37. package/src/components/SessionSidebar.tsx +429 -0
  38. package/src/components/SessionSurface.tsx +455 -0
  39. package/src/components/SessionSurfaceChrome.tsx +316 -0
  40. package/src/components/SettingsFields.tsx +159 -0
  41. package/src/components/SettingsGeneralSection.tsx +49 -0
  42. package/src/components/SettingsMcpSection.tsx +157 -0
  43. package/src/components/SettingsPermissionsSection.tsx +137 -0
  44. package/src/components/SettingsPluginsSection.tsx +165 -0
  45. package/src/components/SettingsProvidersSection.tsx +211 -0
  46. package/src/components/SettingsScreen.tsx +340 -0
  47. package/src/components/StatusControls.tsx +294 -0
  48. package/src/components/composer-attachments.ts +246 -0
  49. package/src/components/personal-usage-dashboard-types.ts +26 -0
  50. package/src/driver-labels.ts +88 -0
  51. package/src/execution-entry-status.ts +55 -0
  52. package/src/goal-view.ts +19 -0
  53. package/src/hooks/command-menu.ts +65 -0
  54. package/src/hooks/excluded-commands.ts +52 -0
  55. package/src/hooks/prompt-state.ts +83 -0
  56. package/src/hooks/server-message-handling.ts +92 -0
  57. package/src/hooks/session-client-types.ts +329 -0
  58. package/src/hooks/ui-intent-state.ts +74 -0
  59. package/src/hooks/use-agent-switcher-state.ts +125 -0
  60. package/src/hooks/use-execution-detail.ts +118 -0
  61. package/src/hooks/use-media-query.ts +27 -0
  62. package/src/hooks/use-model-list.ts +45 -0
  63. package/src/hooks/use-personal-usage.ts +138 -0
  64. package/src/hooks/use-project-panel.ts +117 -0
  65. package/src/hooks/use-schedules-state.ts +92 -0
  66. package/src/hooks/use-session-directory.ts +202 -0
  67. package/src/hooks/use-settings-state.ts +115 -0
  68. package/src/hooks/useSessionClient.ts +991 -0
  69. package/src/index.ts +66 -0
  70. package/src/product-identity.tsx +26 -0
  71. package/src/schedule-view.ts +92 -0
  72. package/src/styles/surface.css +231 -0
  73. package/src/styles/surface.css.d.mts +1 -0
  74. package/src/styles/theme.css +38 -0
  75. package/src/styles/theme.css.d.mts +1 -0
@@ -0,0 +1,202 @@
1
+ import { useProductIdentity } from '../product-identity.js';
2
+ import { useCallback, useRef, useState } from 'react';
3
+
4
+ import type { IWsSessionState, TSessionListing, TSessionsError } from './session-client-types.js';
5
+ import type { TClientMessage } from '../client/ws-session-client.js';
6
+ import type { TServerMessage } from '@robota-sdk/agent-transport';
7
+
8
+ type TSessionDirectoryState = Pick<
9
+ IWsSessionState,
10
+ | 'sessionListing'
11
+ | 'sessionsError'
12
+ | 'requestSessions'
13
+ | 'switchSession'
14
+ | 'newSession'
15
+ | 'renameSessionInList'
16
+ | 'deleteSession'
17
+ | 'sessionSidebarOpen'
18
+ | 'setSessionSidebarOpen'
19
+ >;
20
+
21
+ let requestCounter = 0;
22
+ function nextRequestId(kind: 'sessions' | 'session_change' | 'session_rename' | 'session_delete'): string {
23
+ requestCounter += 1;
24
+ return `${kind}_${requestCounter}_${Date.now()}`;
25
+ }
26
+
27
+ /** A host that keeps several sessions live says which rows are; an older host says nothing. */
28
+ function keepsSessionsLive(listing: TSessionListing): boolean {
29
+ return listing.sessions.some((row) => typeof row.live === 'boolean');
30
+ }
31
+
32
+ /**
33
+ * Issue #3280 §5: a desktop Reconnect reloads the page, which loses every bit of JS state — so the
34
+ * session to go back to cannot live in a ref like `lastSwitchedIdRef` below. `sessionStorage` survives
35
+ * the reload (and only that tab), so it carries the id across it. Namespaced so a page hosting other
36
+ * state under the same origin does not collide.
37
+ */
38
+ function restoreSessionStorageKey(namespace: string): string { return `${namespace}.restoreSessionId`; }
39
+
40
+ /**
41
+ * Remember `sessionId` so the reload a desktop Reconnect triggers can switch back to it once the
42
+ * first session listing arrives (see {@link useSessionDirectoryState}). Best-effort: a host whose
43
+ * storage is unavailable simply lands on whatever session the daemon puts a fresh connection on, same
44
+ * as before this existed.
45
+ */
46
+ export function rememberSessionForRestore(namespace: string, sessionId: string): void {
47
+ try {
48
+ window.sessionStorage.setItem(restoreSessionStorageKey(namespace), sessionId);
49
+ } catch {
50
+ /* storage unavailable — nothing to restore after the reload */
51
+ }
52
+ }
53
+
54
+ function readStoredRestoreId(namespace: string): string | null {
55
+ try {
56
+ return window.sessionStorage.getItem(restoreSessionStorageKey(namespace));
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ function clearStoredRestoreId(namespace: string): void {
63
+ try {
64
+ window.sessionStorage.removeItem(restoreSessionStorageKey(namespace));
65
+ } catch {
66
+ /* noop — nothing was readable to begin with */
67
+ }
68
+ }
69
+
70
+ /** A wide window starts with the sidebar open; a narrow one keeps the conversation in view. */
71
+ function initialSidebarOpen(): boolean {
72
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return true;
73
+ return window.matchMedia('(min-width: 768px)').matches;
74
+ }
75
+
76
+ /**
77
+ * #3189 — the host's session list beside the conversation: requests correlated by id (the latest
78
+ * wins), the start/switch commands, and whether the sidebar is shown.
79
+ *
80
+ * A host that keeps sessions live binds each connection to its own session and puts a new connection
81
+ * on its primary one. After a reconnect to such a host this surface goes back to the session it was
82
+ * on; an older host has one session for everyone, so there is nothing to go back to.
83
+ */
84
+ export function useSessionDirectoryState(send: (msg: TClientMessage) => void): TSessionDirectoryState & {
85
+ handleSessionsMessage: (msg: TServerMessage) => boolean;
86
+ /** Whether the host can list sessions, as far as its last answer says. */
87
+ canListSessions: () => boolean;
88
+ /** The host made `sessionId` current; show it as current until the fresh list arrives. */
89
+ markCurrent: (sessionId: string) => void;
90
+ /** The connection is (re)established: the next listing decides whether to go back. */
91
+ armRestore: () => void;
92
+ } {
93
+ const { storage: { browserNamespace } } = useProductIdentity();
94
+ const [sessionListing, setSessionListing] = useState<TSessionListing | null>(null);
95
+ const [sessionsError, setSessionsError] = useState<TSessionsError | null>(null);
96
+ const [sessionSidebarOpen, setSessionSidebarOpen] = useState(initialSidebarOpen);
97
+ const latestRequestRef = useRef<string | null>(null);
98
+ const notAvailableRef = useRef(false);
99
+
100
+ const requestSessions = useCallback((): void => {
101
+ const requestId = nextRequestId('sessions');
102
+ latestRequestRef.current = requestId;
103
+ send({ type: 'list-sessions', requestId });
104
+ }, [send]);
105
+ // The host answers a switch to the current session with nothing; asking it would leave the
106
+ // surface waiting for a reply that never comes.
107
+ const currentIdRef = useRef<string | null>(null);
108
+ currentIdRef.current = sessionListing?.currentSessionId ?? null;
109
+ const switchSession = useCallback(
110
+ (sessionId: string): void => {
111
+ if (sessionId === currentIdRef.current) return;
112
+ send({ type: 'switch-session', sessionId, requestId: nextRequestId('session_change') });
113
+ },
114
+ [send, browserNamespace],
115
+ );
116
+ const newSession = useCallback(
117
+ (): void => send({ type: 'new-session', requestId: nextRequestId('session_change') }),
118
+ [send, browserNamespace],
119
+ );
120
+ // #3289 §1: rename or delete a row in the list — current or not. Both answer on their own
121
+ // correlated reply (`session_renamed_in_list`/`session_deleted`, or a `_failed` counterpart); the
122
+ // reducer in `useSessionClient` refreshes the listing on success and raises a notice on failure.
123
+ const renameSessionInList = useCallback(
124
+ (sessionId: string, name: string): void =>
125
+ send({ type: 'rename-session', sessionId, name, requestId: nextRequestId('session_rename') }),
126
+ [send, browserNamespace],
127
+ );
128
+ const deleteSession = useCallback(
129
+ (sessionId: string): void =>
130
+ send({ type: 'delete-session', sessionId, requestId: nextRequestId('session_delete') }),
131
+ [send, browserNamespace],
132
+ );
133
+
134
+ // The session this surface last moved to, and — once per connection — whether to go back to it.
135
+ const lastSwitchedIdRef = useRef<string | null>(null);
136
+ const restoreTargetRef = useRef<string | null>(null);
137
+ const armRestore = useCallback((): void => {
138
+ // A remembered id (issue #3280 §5, a desktop Reconnect's reload) wins over the same-page target:
139
+ // on a fresh page, `lastSwitchedIdRef` is always null anyway, so this only ever adds a source.
140
+ restoreTargetRef.current = readStoredRestoreId(browserNamespace) ?? lastSwitchedIdRef.current;
141
+ }, [browserNamespace]);
142
+ const restoreAfterReconnect = useCallback(
143
+ (listing: TSessionListing): void => {
144
+ const target = restoreTargetRef.current;
145
+ restoreTargetRef.current = null;
146
+ if (target === null) return;
147
+ // Spent on this first listing whether or not the switch below actually happens — a fresh
148
+ // launch with no remembered id never reaches here (target stays null), so nothing switches.
149
+ clearStoredRestoreId(browserNamespace);
150
+ if (target === listing.currentSessionId) return;
151
+ if (!keepsSessionsLive(listing)) return;
152
+ // A session the host no longer lists (an empty one it never saved) is nothing to go back to.
153
+ if (!listing.sessions.some((row) => row.id === target)) return;
154
+ send({ type: 'switch-session', sessionId: target, requestId: nextRequestId('session_change') });
155
+ },
156
+ [send, browserNamespace],
157
+ );
158
+
159
+ const handleSessionsMessage = useCallback(
160
+ (msg: TServerMessage): boolean => {
161
+ if (msg.type !== 'sessions' && msg.type !== 'sessions_error') return false;
162
+ if (msg.requestId !== latestRequestRef.current) return true;
163
+ if (msg.type === 'sessions') {
164
+ notAvailableRef.current = false;
165
+ setSessionListing(msg.listing);
166
+ setSessionsError(null);
167
+ restoreAfterReconnect(msg.listing);
168
+ return true;
169
+ }
170
+ restoreTargetRef.current = null;
171
+ notAvailableRef.current = msg.code === 'not_available';
172
+ if (msg.code === 'not_available') setSessionListing(null);
173
+ setSessionsError({ code: msg.code, message: msg.message });
174
+ return true;
175
+ },
176
+ [restoreAfterReconnect],
177
+ );
178
+
179
+ const canListSessions = useCallback((): boolean => !notAvailableRef.current, []);
180
+ const markCurrent = useCallback((sessionId: string): void => {
181
+ lastSwitchedIdRef.current = sessionId;
182
+ setSessionListing((previous) =>
183
+ previous === null ? previous : { ...previous, currentSessionId: sessionId },
184
+ );
185
+ }, [browserNamespace]);
186
+
187
+ return {
188
+ sessionListing,
189
+ sessionsError,
190
+ requestSessions,
191
+ switchSession,
192
+ newSession,
193
+ renameSessionInList,
194
+ deleteSession,
195
+ sessionSidebarOpen,
196
+ setSessionSidebarOpen,
197
+ handleSessionsMessage,
198
+ canListSessions,
199
+ markCurrent,
200
+ armRestore,
201
+ };
202
+ }
@@ -0,0 +1,115 @@
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 { ISettingsSnapshot, TSettingsPatch } from '@robota-sdk/agent-interface-session';
6
+ import type { TServerMessage } from '@robota-sdk/agent-transport';
7
+
8
+ type TSettingsState = Pick<
9
+ IWsSessionState,
10
+ | 'settingsOpen'
11
+ | 'settingsStatus'
12
+ | 'settingsSnapshot'
13
+ | 'settingsError'
14
+ | 'settingsInitialSectionId'
15
+ | 'openSettings'
16
+ | 'closeSettings'
17
+ | 'updateSettings'
18
+ >;
19
+
20
+ let requestCounter = 0;
21
+ function nextRequestId(prefix: string): string {
22
+ requestCounter += 1;
23
+ return `${prefix}_${requestCounter}_${Date.now()}`;
24
+ }
25
+
26
+ /**
27
+ * #3282 §4a: the Settings screen's own correlated request/response state, independent from chat
28
+ * state — the same shape `usePersonalUsageState` already uses for `get-personal-usage-report`.
29
+ *
30
+ * `openSettings` re-fetches every time (opening from the gear, `/settings`, or reopening after a
31
+ * close): the simplest correct way for a change made elsewhere — another typed command, another
32
+ * client — to reach an open-then-reopened screen, per #3282 §4's "pick the simplest correct option."
33
+ *
34
+ * A failed UPDATE never touches `settingsSnapshot`, so a control bound to it shows the value it had
35
+ * before the write — "a failed write leaves the control at its previous value" — while
36
+ * `settingsError` carries the plain message to show beside it. A failed INITIAL load (no snapshot to
37
+ * fall back to) is the one case that flips `settingsStatus` to `'error'`.
38
+ */
39
+ export function useSettingsState(send: (msg: TClientMessage) => void): TSettingsState & {
40
+ handleSettingsMessage: (msg: TServerMessage) => boolean;
41
+ /**
42
+ * #3282 §4b: re-fetch without touching `settingsOpen`/`settingsInitialSectionId` — for a change
43
+ * made elsewhere while the screen is already open (e.g. "Add provider…"'s ask flow finishing).
44
+ * Unlike `openSettings`, this never resets which section is showing.
45
+ */
46
+ refreshSettings: () => void;
47
+ } {
48
+ const [settingsOpen, setSettingsOpen] = useState(false);
49
+ const [settingsStatus, setSettingsStatus] = useState<IWsSessionState['settingsStatus']>('idle');
50
+ const [settingsSnapshot, setSettingsSnapshot] = useState<ISettingsSnapshot | null>(null);
51
+ const [settingsError, setSettingsError] = useState<string | null>(null);
52
+ // #3282 §4 part b-2 / §4b: which section to land on THIS open — `/plugin` opens straight to
53
+ // Plugins, "Manage providers…" straight to Providers & Models; everything else (the gear,
54
+ // `/settings`) opens on the screen's own default (General).
55
+ const [settingsInitialSectionId, setSettingsInitialSectionId] = useState<string | null>(null);
56
+ const requestRef = useRef<string | null>(null);
57
+
58
+ const handleSettingsMessage = useCallback((msg: TServerMessage): boolean => {
59
+ if (msg.type !== 'settings' && msg.type !== 'settings_error') return false;
60
+ if (requestRef.current !== msg.requestId) return true; // superseded by a later request
61
+ if (msg.type === 'settings') {
62
+ setSettingsSnapshot(msg.settings);
63
+ setSettingsStatus('ready');
64
+ setSettingsError(null);
65
+ return true;
66
+ }
67
+ setSettingsError(msg.message);
68
+ setSettingsStatus((current) => (current === 'ready' ? 'ready' : 'error'));
69
+ return true;
70
+ }, []);
71
+
72
+ const fetchSettings = useCallback((): void => {
73
+ setSettingsStatus((current) => (current === 'ready' ? 'ready' : 'loading'));
74
+ setSettingsError(null);
75
+ const requestId = nextRequestId('get_settings');
76
+ requestRef.current = requestId;
77
+ send({ type: 'get-settings', requestId });
78
+ }, [send]);
79
+
80
+ const openSettings = useCallback(
81
+ (sectionId?: string): void => {
82
+ setSettingsOpen(true);
83
+ setSettingsInitialSectionId(sectionId ?? null);
84
+ fetchSettings();
85
+ },
86
+ [fetchSettings],
87
+ );
88
+
89
+ const closeSettings = useCallback((): void => {
90
+ setSettingsOpen(false);
91
+ }, []);
92
+
93
+ const updateSettings = useCallback(
94
+ (patch: TSettingsPatch): void => {
95
+ const requestId = nextRequestId('update_settings');
96
+ requestRef.current = requestId;
97
+ setSettingsError(null);
98
+ send({ type: 'update-settings', requestId, patch });
99
+ },
100
+ [send],
101
+ );
102
+
103
+ return {
104
+ settingsOpen,
105
+ settingsStatus,
106
+ settingsSnapshot,
107
+ settingsError,
108
+ settingsInitialSectionId,
109
+ openSettings,
110
+ closeSettings,
111
+ updateSettings,
112
+ handleSettingsMessage,
113
+ refreshSettings: fetchSettings,
114
+ };
115
+ }