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

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 +400 -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,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
+ }
@@ -0,0 +1,92 @@
1
+ import { useCallback, 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 TSchedulesState = Pick<
8
+ IWsSessionState,
9
+ 'scheduledTasks' | 'pauseSchedule' | 'resumeSchedule' | 'deleteSchedule'
10
+ >;
11
+
12
+ /**
13
+ * #3282 §4 part b-3: the Agents panel's "Scheduled" group. No `requestId` correlates
14
+ * `get-background-tasks`/`background_tasks` (the message carries none — see `wire-messages.ts`), so
15
+ * this owns that whole message type for the GUI: every reply is a full refresh of the roster,
16
+ * filtered server-side to `kind: 'scheduled'` (the same tasks `/schedule list` shows).
17
+ *
18
+ * Pause/resume send the SAME `command` text `/schedule pause <id>` / `/schedule resume <id>` runs.
19
+ * Delete sends the existing `cancel-background-task` write — the same one Stop already uses for any
20
+ * other background-task kind (#3288/#3332) — since a schedule IS a background task, and cancelling
21
+ * one is permanent (distinct from the reversible pause), matching #3282 §4's "Delete…" requirement.
22
+ */
23
+ export function useSchedulesState(send: (msg: TClientMessage) => void): TSchedulesState & {
24
+ /** Handles `background_tasks` and a `cancel` `background_task_control_result` — both refresh. */
25
+ handleSchedulesMessage: (msg: TServerMessage) => boolean;
26
+ requestSchedules: () => void;
27
+ } {
28
+ const [scheduledTasks, setScheduledTasks] = useState<IWsSessionState['scheduledTasks']>([]);
29
+
30
+ const requestSchedules = useCallback((): void => {
31
+ send({ type: 'get-background-tasks', filter: { kind: 'scheduled' } });
32
+ }, [send]);
33
+
34
+ const handleSchedulesMessage = useCallback(
35
+ (msg: TServerMessage): boolean => {
36
+ if (msg.type === 'background_tasks') {
37
+ // Defensive narrowing: the request already asked for `kind: 'scheduled'` only, but the wire
38
+ // type carries the full union — this is the one place that turns "should be" into "is".
39
+ setScheduledTasks(
40
+ msg.tasks.filter(
41
+ (task): task is IWsSessionState['scheduledTasks'][number] => task.kind === 'scheduled',
42
+ ),
43
+ );
44
+ return true;
45
+ }
46
+ if (msg.type === 'background_task_control_result' && msg.action === 'cancel') {
47
+ // A schedule's Delete — refresh so a successful cancel drops it from the group at once; a
48
+ // refused one leaves the row exactly as it was (the central reducer's own case already
49
+ // turns a failure into a plain-message session notice — #3288 §1 — so this never duplicates it).
50
+ if (msg.success) requestSchedules();
51
+ return false; // not exclusively ours — other future consumers may still want this event
52
+ }
53
+ if (msg.type === 'command_result' && msg.name === 'schedule' && msg.success) {
54
+ // Pause/resume ran through the command path — refresh so the row's status/next-run follows.
55
+ requestSchedules();
56
+ return false; // the normal command-result handling (the conversation card) still applies
57
+ }
58
+ return false;
59
+ },
60
+ [requestSchedules],
61
+ );
62
+
63
+ const pauseSchedule = useCallback(
64
+ (taskId: string): void => {
65
+ send({ type: 'command', name: 'schedule', args: `pause ${taskId}` });
66
+ },
67
+ [send],
68
+ );
69
+
70
+ const resumeSchedule = useCallback(
71
+ (taskId: string): void => {
72
+ send({ type: 'command', name: 'schedule', args: `resume ${taskId}` });
73
+ },
74
+ [send],
75
+ );
76
+
77
+ const deleteSchedule = useCallback(
78
+ (taskId: string): void => {
79
+ send({ type: 'cancel-background-task', taskId });
80
+ },
81
+ [send],
82
+ );
83
+
84
+ return {
85
+ scheduledTasks,
86
+ pauseSchedule,
87
+ resumeSchedule,
88
+ deleteSchedule,
89
+ handleSchedulesMessage,
90
+ requestSchedules,
91
+ };
92
+ }