@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,84 @@
1
+ import type { IPickedFile } from '../components/Composer.js';
2
+
3
+ /** Runtime events delivered by a native host. Browser pages have no process to supervise. */
4
+ export type TClientRuntimeState = 'starting' | 'ready' | 'fatal';
5
+
6
+ /** A folder decision must be shown before requesting the runtime endpoint. */
7
+ export interface IClientTrustQuestion {
8
+ readonly folder: string;
9
+ readonly loads: readonly string[];
10
+ }
11
+
12
+ export type TClientTrustChoice = 'trust' | 'restricted' | 'quit';
13
+
14
+ /**
15
+ * Browser-safe shape for a narrow native preload bridge. The native host implements validation,
16
+ * trust, process lifecycle, and endpoint ownership; the renderer receives only these operations.
17
+ */
18
+ export interface IClientRuntimeBridge {
19
+ readonly runtimeMode?: 'local' | 'remote';
20
+ getEndpoint(): Promise<string | null>;
21
+ signalReady(): void;
22
+ onState(listener: (state: TClientRuntimeState, detail?: string) => void): () => void;
23
+ restartRuntime(): Promise<void>;
24
+ trustQuestion(): Promise<IClientTrustQuestion | null>;
25
+ answerTrust(choice: TClientTrustChoice): Promise<{ error?: string }>;
26
+ pickFiles?(): Promise<readonly IPickedFile[]>;
27
+ getPathForFile?(file: File): string;
28
+ onOpenSettings?(listener: () => void): () => void;
29
+ openPath?(path: string): Promise<{ error?: string }>;
30
+ }
31
+
32
+ /** A React renderer can use this contract with either a desktop bridge or a browser endpoint. */
33
+ export interface IClientRuntimeHost {
34
+ readonly kind: 'desktop' | 'browser';
35
+ getEndpoint(): Promise<string | null>;
36
+ signalReady(): void;
37
+ onState(listener: (state: TClientRuntimeState, detail?: string) => void): () => void;
38
+ readonly restartRuntime?: () => Promise<void>;
39
+ readonly trustQuestion?: () => Promise<IClientTrustQuestion | null>;
40
+ readonly answerTrust?: (choice: TClientTrustChoice) => Promise<{ error?: string }>;
41
+ readonly pickFiles?: () => Promise<readonly IPickedFile[]>;
42
+ readonly getPathForFile?: (file: File) => string;
43
+ onOpenSettings(listener: () => void): () => void;
44
+ readonly openMemoryInEditor?: (path: string) => void;
45
+ }
46
+
47
+ export interface IClientRuntimeEnvironment {
48
+ readonly bridge?: IClientRuntimeBridge;
49
+ readonly document: Pick<Document, 'querySelector'>;
50
+ readonly location: Pick<Location, 'search' | 'host' | 'protocol'>;
51
+ }
52
+
53
+ /** Resolve host capabilities without importing Electron or a Node CLI into the browser graph. */
54
+ export function resolveClientRuntimeHost(environment: IClientRuntimeEnvironment): IClientRuntimeHost {
55
+ const { bridge } = environment;
56
+ if (bridge) {
57
+ const local = bridge.runtimeMode !== 'remote';
58
+ return {
59
+ kind: 'desktop',
60
+ getEndpoint: () => bridge.getEndpoint(),
61
+ signalReady: () => bridge.signalReady(),
62
+ onState: (listener) => bridge.onState(listener),
63
+ restartRuntime: () => bridge.restartRuntime(),
64
+ trustQuestion: local ? () => bridge.trustQuestion() : undefined,
65
+ answerTrust: local ? (choice) => bridge.answerTrust(choice) : undefined,
66
+ pickFiles: local && bridge.pickFiles ? () => bridge.pickFiles!() : undefined,
67
+ getPathForFile: local && bridge.getPathForFile ? (file) => bridge.getPathForFile!(file) : undefined,
68
+ onOpenSettings: (listener) => bridge.onOpenSettings?.(listener) ?? (() => {}),
69
+ openMemoryInEditor: local && bridge.openPath ? (path) => { void bridge.openPath!(path); } : undefined,
70
+ };
71
+ }
72
+
73
+ const injected = environment.document.querySelector('meta[name="ws-url"]')?.getAttribute('content')?.trim();
74
+ const query = new URLSearchParams(environment.location.search).get('ws')?.trim();
75
+ const protocol = environment.location.protocol === 'https:' ? 'wss:' : 'ws:';
76
+ const endpoint = injected || query || `${protocol}//${environment.location.host}`;
77
+ return {
78
+ kind: 'browser',
79
+ getEndpoint: async () => endpoint,
80
+ signalReady: () => {},
81
+ onState: () => () => {},
82
+ onOpenSettings: () => () => {},
83
+ };
84
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Browser WebSocket client for a running agent-cli sidecar session.
3
+ * Wraps the native WebSocket API with typed agent-transport-ws messages.
4
+ */
5
+
6
+ import { decodeFrame, decodeServerMessage } from '@robota-sdk/agent-transport/client';
7
+
8
+ import type { TServerMessage, TClientMessage } from '@robota-sdk/agent-transport';
9
+
10
+ export type { TServerMessage, TClientMessage };
11
+
12
+ export type TConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
13
+
14
+ export interface IWsSessionClientCallbacks {
15
+ onMessage: (msg: TServerMessage) => void;
16
+ onStatusChange: (status: TConnectionStatus) => void;
17
+ /**
18
+ * The connection is gone for good: the retries ran out without reaching the server again. The status
19
+ * stays `disconnected`; this is what tells a host the runtime is not coming back by itself.
20
+ */
21
+ onGiveUp?: () => void;
22
+ }
23
+
24
+ export interface IWsSessionClient {
25
+ connect: () => void;
26
+ disconnect: () => void;
27
+ send: (msg: TClientMessage) => void;
28
+ status: () => TConnectionStatus;
29
+ }
30
+
31
+ const RECONNECT_DELAY_MS = 2000;
32
+ const MAX_RECONNECT_ATTEMPTS = 10;
33
+
34
+ export function createWsSessionClient(
35
+ url: string,
36
+ callbacks: IWsSessionClientCallbacks,
37
+ ): IWsSessionClient {
38
+ let ws: WebSocket | null = null;
39
+ let currentStatus: TConnectionStatus = 'disconnected';
40
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
41
+ let reconnectAttempts = 0;
42
+ let intentionalDisconnect = false;
43
+
44
+ function setStatus(s: TConnectionStatus): void {
45
+ currentStatus = s;
46
+ callbacks.onStatusChange(s);
47
+ }
48
+
49
+ function scheduleReconnect(): void {
50
+ if (intentionalDisconnect) return;
51
+ if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
52
+ callbacks.onGiveUp?.();
53
+ return;
54
+ }
55
+ reconnectTimer = setTimeout(() => {
56
+ reconnectAttempts++;
57
+ doConnect();
58
+ }, RECONNECT_DELAY_MS);
59
+ }
60
+
61
+ function doConnect(): void {
62
+ setStatus('connecting');
63
+ ws = new WebSocket(url);
64
+
65
+ ws.onopen = (): void => {
66
+ reconnectAttempts = 0;
67
+ setStatus('connected');
68
+ // Request full message history on connect
69
+ send({ type: 'get-messages' });
70
+ };
71
+
72
+ ws.onmessage = (event: MessageEvent): void => {
73
+ const data = event.data;
74
+ if (typeof data !== 'string') return;
75
+ // Issue #2045: `raw → owner decoder → typed`. A malformed frame (invalid JSON or an invalid
76
+ // shape) must not throw inside the handler (it would break the socket's message pump and
77
+ // freeze the UI) and must not be cast onward; surface it via the normal path.
78
+ const decoded = decodeFrame(data, decodeServerMessage);
79
+ if (!decoded.ok) {
80
+ callbacks.onMessage({
81
+ type: 'protocol_error',
82
+ message: `Malformed message from server (${decoded.reason})`,
83
+ });
84
+ return;
85
+ }
86
+ callbacks.onMessage(decoded.message);
87
+ };
88
+
89
+ ws.onclose = (): void => {
90
+ ws = null;
91
+ if (!intentionalDisconnect) {
92
+ setStatus('disconnected');
93
+ scheduleReconnect();
94
+ } else {
95
+ setStatus('disconnected');
96
+ }
97
+ };
98
+
99
+ ws.onerror = (): void => {
100
+ setStatus('error');
101
+ };
102
+ }
103
+
104
+ function connect(): void {
105
+ intentionalDisconnect = false;
106
+ if (reconnectTimer !== null) {
107
+ clearTimeout(reconnectTimer);
108
+ reconnectTimer = null;
109
+ }
110
+ doConnect();
111
+ }
112
+
113
+ function disconnect(): void {
114
+ intentionalDisconnect = true;
115
+ if (reconnectTimer !== null) {
116
+ clearTimeout(reconnectTimer);
117
+ reconnectTimer = null;
118
+ }
119
+ ws?.close();
120
+ ws = null;
121
+ setStatus('disconnected');
122
+ }
123
+
124
+ function send(msg: TClientMessage): void {
125
+ if (ws?.readyState === WebSocket.OPEN) {
126
+ ws.send(JSON.stringify(msg));
127
+ }
128
+ }
129
+
130
+ return { connect, disconnect, send, status: () => currentStatus };
131
+ }
@@ -0,0 +1,81 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * Copies `text` to the clipboard for the "Copy code" / "Copy message" buttons (#3289 §2): the async
5
+ * Clipboard API first, and — for a page that denies it (no permission granted yet, a browser that
6
+ * never implements it, or a context that disallows it outright) — a same-tick `execCommand('copy')`
7
+ * fallback through a throwaway, invisible textarea. Rejects only once both have failed, so a caller
8
+ * can show its own "Couldn't copy" message rather than a browser one.
9
+ */
10
+ export async function copyToClipboard(text: string): Promise<void> {
11
+ if (navigator.clipboard?.writeText) {
12
+ try {
13
+ await navigator.clipboard.writeText(text);
14
+ return;
15
+ } catch {
16
+ // Falls through to the legacy path — some browsers reject outside a user gesture, or before a
17
+ // permission prompt has ever been answered.
18
+ }
19
+ }
20
+ if (!legacyCopy(text)) throw new Error('copy failed');
21
+ }
22
+
23
+ /**
24
+ * `execCommand('copy')` needs a selection in the page, so the text is staged in an offscreen field —
25
+ * which needs focus to be selectable, so whatever had focus (typically the "Copy code" / "Copy
26
+ * message" button a keyboard user just activated) is restored afterward rather than left on `<body>`.
27
+ */
28
+ function legacyCopy(text: string): boolean {
29
+ if (typeof document === 'undefined' || typeof document.execCommand !== 'function') return false;
30
+ const previouslyFocused =
31
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
32
+ const field = document.createElement('textarea');
33
+ field.value = text;
34
+ field.setAttribute('readonly', '');
35
+ field.style.position = 'fixed';
36
+ field.style.top = '0';
37
+ field.style.left = '0';
38
+ field.style.opacity = '0';
39
+ field.style.pointerEvents = 'none';
40
+ document.body.appendChild(field);
41
+ field.focus();
42
+ field.select();
43
+ let ok = false;
44
+ try {
45
+ ok = document.execCommand('copy');
46
+ } catch {
47
+ ok = false;
48
+ }
49
+ document.body.removeChild(field);
50
+ previouslyFocused?.focus();
51
+ return ok;
52
+ }
53
+
54
+ export type TCopyStatus = 'idle' | 'copied' | 'error';
55
+
56
+ /** How long the "Copied" / "Couldn't copy" feedback holds before the button reads its normal label again. */
57
+ const FEEDBACK_MS = 2000;
58
+
59
+ /**
60
+ * The "Copy code" / "Copy message" buttons' shared feedback state: `copied` or `error` for a couple of
61
+ * seconds, then back to `idle` — drives both the button's own label swap and an `aria-live` region a
62
+ * screen reader announces without moving focus (#3289 §2).
63
+ */
64
+ export function useCopyFeedback(): { status: TCopyStatus; copy: (text: string) => void } {
65
+ const [status, setStatus] = useState<TCopyStatus>('idle');
66
+
67
+ useEffect(() => {
68
+ if (status === 'idle') return undefined;
69
+ const timer = setTimeout(() => setStatus('idle'), FEEDBACK_MS);
70
+ return () => clearTimeout(timer);
71
+ }, [status]);
72
+
73
+ const copy = (text: string): void => {
74
+ copyToClipboard(text).then(
75
+ () => setStatus('copied'),
76
+ () => setStatus('error'),
77
+ );
78
+ };
79
+
80
+ return { status, copy };
81
+ }
@@ -0,0 +1,371 @@
1
+ import {
2
+ CircleCheck,
3
+ CircleDashed,
4
+ CircleX,
5
+ LoaderCircle,
6
+ Pause,
7
+ Play,
8
+ ShieldAlert,
9
+ Square,
10
+ Target,
11
+ } from 'lucide-react';
12
+ import React, { useState } from 'react';
13
+
14
+ import { ConfirmDialog } from './Dialog.js';
15
+ import { describeGoalStatus } from '../goal-view.js';
16
+ import {
17
+ describeScheduleInstruction,
18
+ describeScheduleStatus,
19
+ describeScheduleTiming,
20
+ } from '../schedule-view.js';
21
+
22
+ import { describeExecutionStatus, hadDeniedToolCalls } from '../execution-entry-status.js';
23
+
24
+ import type {
25
+ IBackgroundTaskState,
26
+ IExecutionWorkspaceEntry,
27
+ TExecutionAttention,
28
+ TExecutionWorkspaceStatus,
29
+ } from '@robota-sdk/agent-interface-execution';
30
+ import type { IGoalState } from '@robota-sdk/agent-interface-session';
31
+
32
+ interface IAgentActivityPanelProps {
33
+ tasks: readonly IExecutionWorkspaceEntry[];
34
+ /** #3282 §4 part b-3: the "Scheduled" group — every `kind: 'scheduled'` background task. */
35
+ schedules?: readonly IBackgroundTaskState<'scheduled'>[];
36
+ /** #3282 §4 part b-3: the current goal (`/goal`), or null/absent when none is set. */
37
+ goal?: IGoalState | null;
38
+ onPauseSchedule?: (taskId: string) => void;
39
+ onResumeSchedule?: (taskId: string) => void;
40
+ onDeleteSchedule?: (taskId: string) => void;
41
+ onCancelGoal?: () => void;
42
+ className?: string;
43
+ /** #3288 §1: open a non-main-thread entry's detail sheet. */
44
+ onSelect?: (entry: IExecutionWorkspaceEntry) => void;
45
+ /** #3288 §1: the main thread entry was clicked — the conversation beside this panel is it. */
46
+ onReturnToConversation?: () => void;
47
+ /** #3288 §1: Stop was clicked on a running/queued entry. */
48
+ onStop?: (entry: IExecutionWorkspaceEntry) => void;
49
+ /** #3288 §1: the entry whose detail sheet is currently open, for a highlighted row. */
50
+ selectedEntryId?: string;
51
+ }
52
+
53
+ export function AgentActivityPanel({
54
+ tasks,
55
+ schedules = [],
56
+ goal = null,
57
+ onPauseSchedule,
58
+ onResumeSchedule,
59
+ onDeleteSchedule,
60
+ onCancelGoal,
61
+ className,
62
+ onSelect,
63
+ onReturnToConversation,
64
+ onStop,
65
+ selectedEntryId,
66
+ }: IAgentActivityPanelProps): React.ReactElement {
67
+ const runningCount = tasks.filter((t) => t.status === 'running').length;
68
+ // A deleted schedule is `cancelled` forever (the manager never removes the record) — the group
69
+ // shows only what is still relevant, exactly like Delete disappearing the row it was pressed on.
70
+ const visibleSchedules = schedules.filter((task) => task.status !== 'cancelled');
71
+ const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
72
+ const pendingDelete = visibleSchedules.find((task) => task.id === pendingDeleteId) ?? null;
73
+
74
+ return (
75
+ <div className={`agent-ui flex flex-col overflow-hidden ${className ?? ''}`}>
76
+ <div className="flex h-12 flex-shrink-0 items-center gap-2 px-4">
77
+ <span className="text-[14px] font-medium text-foreground">Agents</span>
78
+ {runningCount > 0 && (
79
+ <span className="ml-auto text-[13px] tabular-nums text-subtle">
80
+ {runningCount} running
81
+ </span>
82
+ )}
83
+ </div>
84
+
85
+ <div className="flex-1 space-y-4 overflow-y-auto px-3 pb-3">
86
+ {tasks.length > 0 && (
87
+ <div className="space-y-2">
88
+ {tasks.map((entry) => (
89
+ <AgentCard
90
+ key={entry.id}
91
+ entry={entry}
92
+ selected={entry.id === selectedEntryId}
93
+ onOpen={
94
+ entry.kind === 'main_thread'
95
+ ? onReturnToConversation
96
+ : onSelect && (() => onSelect(entry))
97
+ }
98
+ onStop={
99
+ onStop && entry.controls?.includes('cancel') ? () => onStop(entry) : undefined
100
+ }
101
+ />
102
+ ))}
103
+ </div>
104
+ )}
105
+
106
+ {visibleSchedules.length > 0 && (
107
+ <div>
108
+ <p className="px-0.5 pb-1.5 text-[12px] font-medium uppercase tracking-wide text-subtle">
109
+ Scheduled
110
+ </p>
111
+ <div className="space-y-2">
112
+ {visibleSchedules.map((task) => (
113
+ <ScheduleCard
114
+ key={task.id}
115
+ task={task}
116
+ onPause={onPauseSchedule}
117
+ onResume={onResumeSchedule}
118
+ onRequestDelete={() => setPendingDeleteId(task.id)}
119
+ />
120
+ ))}
121
+ </div>
122
+ </div>
123
+ )}
124
+
125
+ {goal && (
126
+ <div>
127
+ <p className="px-0.5 pb-1.5 text-[12px] font-medium uppercase tracking-wide text-subtle">
128
+ Goal
129
+ </p>
130
+ <GoalCard goal={goal} onCancel={onCancelGoal} />
131
+ </div>
132
+ )}
133
+ </div>
134
+
135
+ <ConfirmDialog
136
+ open={pendingDelete !== null}
137
+ title="Delete this schedule?"
138
+ body={
139
+ pendingDelete
140
+ ? `"${describeScheduleInstruction(pendingDelete)}" will stop running. This cannot be undone.`
141
+ : ''
142
+ }
143
+ confirmLabel="Delete"
144
+ destructive
145
+ onConfirm={() => {
146
+ if (pendingDelete) onDeleteSchedule?.(pendingDelete.id);
147
+ setPendingDeleteId(null);
148
+ }}
149
+ onCancel={() => setPendingDeleteId(null)}
150
+ />
151
+ </div>
152
+ );
153
+ }
154
+
155
+ function AgentCard({
156
+ entry,
157
+ selected,
158
+ onOpen,
159
+ onStop,
160
+ }: {
161
+ entry: IExecutionWorkspaceEntry;
162
+ selected: boolean;
163
+ onOpen?: () => void;
164
+ onStop?: () => void;
165
+ }): React.ReactElement {
166
+ const needsYou = entry.attention === 'permission' || entry.status === 'waiting_permission';
167
+ const failed = entry.attention === 'failed' || entry.status === 'failed';
168
+ return (
169
+ <div
170
+ role={onOpen ? 'button' : undefined}
171
+ tabIndex={onOpen ? 0 : undefined}
172
+ onClick={onOpen}
173
+ onKeyDown={
174
+ onOpen
175
+ ? (event) => {
176
+ if (event.key === 'Enter' || event.key === ' ') {
177
+ event.preventDefault();
178
+ onOpen();
179
+ }
180
+ }
181
+ : undefined
182
+ }
183
+ aria-label={onOpen ? entry.title : undefined}
184
+ className={`rounded-xl px-3.5 py-3 transition-opacity duration-500 ${
185
+ needsYou ? 'bg-warning/10' : failed ? 'bg-destructive/10' : 'bg-card'
186
+ } ${entry.status === 'completed' ? 'opacity-60' : ''} ${
187
+ selected ? 'ring-1 ring-inset ring-accent' : ''
188
+ } ${onOpen ? 'cursor-pointer' : ''}`}
189
+ >
190
+ <div className="flex items-center gap-2.5">
191
+ <StatusIcon status={entry.status} attention={entry.attention} deniedToolCalls={entry.deniedToolCalls} />
192
+ <span className="min-w-0 flex-1 truncate text-[14px] font-medium text-foreground">
193
+ {entry.title}
194
+ </span>
195
+ <AttentionTag status={entry.status} attention={entry.attention} deniedToolCalls={entry.deniedToolCalls} />
196
+ {onStop && (
197
+ <button
198
+ type="button"
199
+ aria-label={`Stop ${entry.title}`}
200
+ onClick={(event) => {
201
+ event.stopPropagation();
202
+ onStop();
203
+ }}
204
+ className="flex-shrink-0 rounded-md px-1.5 py-px text-[12px] text-subtle hover:bg-destructive/10 hover:text-destructive"
205
+ >
206
+ Stop
207
+ </button>
208
+ )}
209
+ </div>
210
+ {entry.currentAction && (
211
+ <p className="mt-1.5 truncate pl-[26px] text-[13px] leading-snug text-muted-foreground">
212
+ {entry.currentAction}
213
+ </p>
214
+ )}
215
+ {entry.preview && (
216
+ <p className="mt-1 truncate pl-[26px] font-mono text-[12px] leading-snug text-subtle">
217
+ {entry.preview}
218
+ </p>
219
+ )}
220
+ </div>
221
+ );
222
+ }
223
+
224
+ /** #3282 §4 part b-3: one row of the "Scheduled" group — what it runs, when next, status, controls. */
225
+ function ScheduleCard({
226
+ task,
227
+ onPause,
228
+ onResume,
229
+ onRequestDelete,
230
+ }: {
231
+ task: IBackgroundTaskState<'scheduled'>;
232
+ onPause?: (taskId: string) => void;
233
+ onResume?: (taskId: string) => void;
234
+ onRequestDelete: () => void;
235
+ }): React.ReactElement {
236
+ const isPaused = task.status === 'paused';
237
+ return (
238
+ <div className="rounded-xl bg-card px-3.5 py-3">
239
+ <p className="truncate text-[14px] font-medium text-foreground">
240
+ {describeScheduleInstruction(task)}
241
+ </p>
242
+ <p className="mt-1 text-[13px] text-muted-foreground">
243
+ {describeScheduleTiming(task)} · {describeScheduleStatus(task)}
244
+ </p>
245
+ <div className="mt-2 flex items-center gap-3">
246
+ {isPaused ? (
247
+ <button
248
+ type="button"
249
+ onClick={() => onResume?.(task.id)}
250
+ className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-muted-foreground hover:bg-hover hover:text-foreground"
251
+ >
252
+ <Play size={11} fill="currentColor" aria-hidden="true" />
253
+ Resume
254
+ </button>
255
+ ) : (
256
+ <button
257
+ type="button"
258
+ onClick={() => onPause?.(task.id)}
259
+ className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-muted-foreground hover:bg-hover hover:text-foreground"
260
+ >
261
+ <Pause size={11} fill="currentColor" aria-hidden="true" />
262
+ Pause
263
+ </button>
264
+ )}
265
+ <button
266
+ type="button"
267
+ onClick={onRequestDelete}
268
+ className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-destructive hover:bg-destructive/10"
269
+ >
270
+ Delete…
271
+ </button>
272
+ </div>
273
+ </div>
274
+ );
275
+ }
276
+
277
+ /** #3282 §4 part b-3: the current goal — objective, plain-word status, and Cancel. */
278
+ function GoalCard({
279
+ goal,
280
+ onCancel,
281
+ }: {
282
+ goal: IGoalState;
283
+ onCancel?: () => void;
284
+ }): React.ReactElement {
285
+ return (
286
+ <div className="rounded-xl bg-card px-3.5 py-3">
287
+ <div className="flex items-center gap-2.5">
288
+ <Target size={16} strokeWidth={1.9} className="flex-shrink-0 text-accent" />
289
+ <span className="min-w-0 flex-1 truncate text-[14px] font-medium text-foreground">
290
+ {goal.objective}
291
+ </span>
292
+ </div>
293
+ <p className="mt-1.5 pl-[26px] text-[13px] text-muted-foreground">
294
+ {describeGoalStatus(goal)}
295
+ </p>
296
+ {goal.status === 'active' && (
297
+ <div className="mt-2 pl-[26px]">
298
+ <button
299
+ type="button"
300
+ onClick={onCancel}
301
+ className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-muted-foreground hover:bg-hover hover:text-foreground"
302
+ >
303
+ <Square size={11} fill="currentColor" aria-hidden="true" />
304
+ Cancel goal
305
+ </button>
306
+ </div>
307
+ )}
308
+ </div>
309
+ );
310
+ }
311
+
312
+ function StatusIcon({
313
+ status,
314
+ attention,
315
+ deniedToolCalls,
316
+ }: {
317
+ status: TExecutionWorkspaceStatus;
318
+ attention: TExecutionAttention;
319
+ deniedToolCalls?: number;
320
+ }): React.ReactElement {
321
+ const props = { size: 16, strokeWidth: 1.9, className: 'flex-shrink-0' } as const;
322
+ if (attention === 'permission' || status === 'waiting_permission') {
323
+ return <ShieldAlert {...props} className={`${props.className} text-warning`} />;
324
+ }
325
+ if (attention === 'failed' || status === 'failed') {
326
+ return <CircleX {...props} className={`${props.className} text-destructive`} />;
327
+ }
328
+ if (hadDeniedToolCalls(status, deniedToolCalls)) {
329
+ return <ShieldAlert {...props} className={`${props.className} text-warning`} />;
330
+ }
331
+ if (status === 'running') {
332
+ return (
333
+ <LoaderCircle
334
+ {...props}
335
+ className={`${props.className} animate-spin text-muted-foreground`}
336
+ />
337
+ );
338
+ }
339
+ if (status === 'completed') {
340
+ return <CircleCheck {...props} className={`${props.className} text-success`} />;
341
+ }
342
+ if (status === 'cancelled') {
343
+ return <CircleX {...props} className={`${props.className} text-subtle`} />;
344
+ }
345
+ return <CircleDashed {...props} className={`${props.className} text-subtle`} />;
346
+ }
347
+
348
+ const STATUS_TAG_TONE: Record<string, string> = {
349
+ warning: 'bg-warning/15 text-warning',
350
+ destructive: 'bg-destructive/12 text-destructive',
351
+ muted: 'text-subtle',
352
+ };
353
+
354
+ function AttentionTag({
355
+ attention,
356
+ status,
357
+ deniedToolCalls,
358
+ }: {
359
+ attention: TExecutionAttention;
360
+ status: TExecutionWorkspaceStatus;
361
+ deniedToolCalls?: number;
362
+ }): React.ReactElement | null {
363
+ const described = describeExecutionStatus(status, attention, deniedToolCalls);
364
+ // A running row already has its spinner; no text tag repeats that.
365
+ if (!described || described.label === 'Running') return null;
366
+ return (
367
+ <span className={`flex-shrink-0 rounded-md px-1.5 py-px text-[12px] ${STATUS_TAG_TONE[described.tone]}`}>
368
+ {described.label}
369
+ </span>
370
+ );
371
+ }