@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.
- package/CHANGELOG.md +548 -0
- package/LICENSE +661 -0
- package/README.md +19 -2
- package/dist/browser/index.d.ts +736 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +8525 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/node/index.cjs +8575 -0
- package/dist/node/index.d.cts +736 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +736 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +8525 -0
- package/dist/node/index.js.map +1 -0
- package/docs/README.md +62 -0
- package/docs/SPEC.md +130 -0
- package/package.json +99 -3
- package/src/app-view/bridge.ts +217 -0
- package/src/app-view/call-tool-result.ts +58 -0
- package/src/app-view/host-context.ts +37 -0
- package/src/app-view/types.ts +12 -0
- package/src/client/runtime-host.ts +84 -0
- package/src/client/ws-session-client.ts +131 -0
- package/src/clipboard.ts +81 -0
- package/src/components/AgentActivityPanel.tsx +371 -0
- package/src/components/AgentSwitcherSheet.tsx +93 -0
- package/src/components/AppView.tsx +147 -0
- package/src/components/Brand.tsx +33 -0
- package/src/components/Composer.tsx +685 -0
- package/src/components/ConversationView.tsx +789 -0
- package/src/components/Dialog.tsx +230 -0
- package/src/components/DiffLines.tsx +81 -0
- package/src/components/ExecutionDetailSheet.tsx +142 -0
- package/src/components/HelpSheet.tsx +104 -0
- package/src/components/PermissionPrompt.tsx +762 -0
- package/src/components/PersonalUsageDashboard.tsx +39 -0
- package/src/components/PersonalUsageDashboardDetails.tsx +368 -0
- package/src/components/PersonalUsageDashboardSections.tsx +250 -0
- package/src/components/PopupMenu.tsx +166 -0
- package/src/components/ProjectPanel.tsx +268 -0
- package/src/components/SessionMonitor.tsx +180 -0
- package/src/components/SessionSidebar.tsx +429 -0
- package/src/components/SessionSurface.tsx +482 -0
- package/src/components/SessionSurfaceChrome.tsx +319 -0
- package/src/components/SettingsFields.tsx +159 -0
- package/src/components/SettingsGeneralSection.tsx +49 -0
- package/src/components/SettingsMcpSection.tsx +157 -0
- package/src/components/SettingsPermissionsSection.tsx +137 -0
- package/src/components/SettingsPluginsSection.tsx +165 -0
- package/src/components/SettingsProvidersSection.tsx +211 -0
- package/src/components/SettingsScreen.tsx +340 -0
- package/src/components/StatusControls.tsx +294 -0
- package/src/components/composer-attachments.ts +246 -0
- package/src/components/personal-usage-dashboard-types.ts +26 -0
- package/src/driver-labels.ts +88 -0
- package/src/execution-entry-status.ts +55 -0
- package/src/goal-view.ts +19 -0
- package/src/hooks/command-menu.ts +65 -0
- package/src/hooks/excluded-commands.ts +52 -0
- package/src/hooks/prompt-state.ts +83 -0
- package/src/hooks/server-message-handling.ts +95 -0
- package/src/hooks/session-client-types.ts +344 -0
- package/src/hooks/ui-intent-state.ts +74 -0
- package/src/hooks/use-agent-switcher-state.ts +125 -0
- package/src/hooks/use-app-view-requests.ts +178 -0
- package/src/hooks/use-execution-detail.ts +118 -0
- package/src/hooks/use-media-query.ts +27 -0
- package/src/hooks/use-model-list.ts +45 -0
- package/src/hooks/use-personal-usage.ts +138 -0
- package/src/hooks/use-project-panel.ts +117 -0
- package/src/hooks/use-schedules-state.ts +92 -0
- package/src/hooks/use-session-directory.ts +202 -0
- package/src/hooks/use-settings-state.ts +115 -0
- package/src/hooks/useSessionClient.ts +1051 -0
- package/src/index.ts +72 -0
- package/src/product-identity.tsx +26 -0
- package/src/schedule-view.ts +92 -0
- package/src/styles/surface.css +231 -0
- package/src/styles/surface.css.d.mts +1 -0
- package/src/styles/theme.css +38 -0
- package/src/styles/theme.css.d.mts +1 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { AppViewHostContext } from '../app-view/host-context.js';
|
|
4
|
+
import { useProductIdentity } from '../product-identity.js';
|
|
5
|
+
import { AgentActivityPanel } from './AgentActivityPanel.js';
|
|
6
|
+
import { AgentSwitcherSheet } from './AgentSwitcherSheet.js';
|
|
7
|
+
import { ProductMark, ProductWordmark } from './Brand.js';
|
|
8
|
+
import { Composer, GoalBar } from './Composer.js';
|
|
9
|
+
import { ConversationView } from './ConversationView.js';
|
|
10
|
+
import { Dialog } from './Dialog.js';
|
|
11
|
+
import { ExecutionDetailSheet } from './ExecutionDetailSheet.js';
|
|
12
|
+
import { HelpSheet } from './HelpSheet.js';
|
|
13
|
+
import { PermissionPrompt } from './PermissionPrompt.js';
|
|
14
|
+
import { PersonalUsageDashboard } from './PersonalUsageDashboard.js';
|
|
15
|
+
import { ProjectPanel } from './ProjectPanel.js';
|
|
16
|
+
import { SessionSidebar, SessionSidebarRail, sessionTitle } from './SessionSidebar.js';
|
|
17
|
+
import { ConnectionBanner, SessionNotices, SessionTitleBar } from './SessionSurfaceChrome.js';
|
|
18
|
+
import { SettingsScreen } from './SettingsScreen.js';
|
|
19
|
+
import { NARROW_WINDOW_QUERY, useMediaQuery } from '../hooks/use-media-query.js';
|
|
20
|
+
|
|
21
|
+
import type { IComposerHandle, IPickedFile } from './Composer.js';
|
|
22
|
+
import type { TSessionView } from './SessionSurfaceChrome.js';
|
|
23
|
+
import type { IWsSessionState } from '../hooks/useSessionClient.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The desktop session shell — the GUI analog of the TUI's presentation. Pure presentation over an
|
|
27
|
+
* `IWsSessionState`: no hooks, no transport, no session/command/permission logic — it renders the
|
|
28
|
+
* reconstructed session and forwards user intent through the reducer's `send`/`answer*`. The session
|
|
29
|
+
* sidebar runs the full height on the left once the host has listed its sessions (a host that cannot
|
|
30
|
+
* has none); beside it the title bar, the conversation, the pending question docked above the
|
|
31
|
+
* composer, and the background-activity rail on the right when work runs beside the conversation.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** The column every part of the conversation shares, so messages, prompt and composer line up. */
|
|
35
|
+
const COLUMN = 'mx-auto w-full max-w-[760px] px-6';
|
|
36
|
+
|
|
37
|
+
const APP_VIEW_HOST_VERSION = '1.0.0';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Designed empty state shown before the first turn. Renders in the same slot as `ConversationView`
|
|
41
|
+
* (never together), so it carries the same `main` landmark and label (#3289 §3 review) — nothing
|
|
42
|
+
* above this slot supplies one.
|
|
43
|
+
*/
|
|
44
|
+
function EmptyState(): React.ReactElement {
|
|
45
|
+
return (
|
|
46
|
+
<main className="flex h-full items-center justify-center" aria-label="Conversation">
|
|
47
|
+
<div className="gui-rise flex max-w-[440px] flex-col items-center gap-4 px-8 text-center">
|
|
48
|
+
<ProductMark size={40} />
|
|
49
|
+
<h2 className="text-[26px] font-semibold tracking-[-0.02em] text-foreground">
|
|
50
|
+
What are we working on?
|
|
51
|
+
</h2>
|
|
52
|
+
<p className="text-[15px] leading-relaxed text-muted-foreground">
|
|
53
|
+
Session connected. Send a message to start — the agent works in the host runtime and asks
|
|
54
|
+
you here before anything that needs your permission.
|
|
55
|
+
</p>
|
|
56
|
+
</div>
|
|
57
|
+
</main>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Issue #3282 §3 — shown in place of the conversation while `sessionStatus.setupRequired` holds: the
|
|
63
|
+
* session is running (the GUI itself connected fine), but has no provider to reply with yet. "Set up
|
|
64
|
+
* provider" runs `/provider add` as a command; its questions dock above where the composer would be
|
|
65
|
+
* (the same `PermissionPrompt` any ask uses), and the panel clears itself once the session reports a
|
|
66
|
+
* real provider, live — no restart, no reload.
|
|
67
|
+
*
|
|
68
|
+
* Renders in the same slot as `ConversationView`/`EmptyState` (never together), so it carries the
|
|
69
|
+
* page's `main` landmark here too (#3289 §3 review) — nothing above this slot supplies one.
|
|
70
|
+
*/
|
|
71
|
+
function SetupPanel({
|
|
72
|
+
onSetUp,
|
|
73
|
+
disabled,
|
|
74
|
+
}: {
|
|
75
|
+
onSetUp: () => void;
|
|
76
|
+
disabled: boolean;
|
|
77
|
+
}): React.ReactElement {
|
|
78
|
+
return (
|
|
79
|
+
<main className="flex h-full items-center justify-center" aria-label="Set up a provider">
|
|
80
|
+
<div className="gui-rise flex max-w-[440px] flex-col items-center gap-4 px-8 text-center">
|
|
81
|
+
<ProductMark size={40} />
|
|
82
|
+
<h2 className="text-[26px] font-semibold tracking-[-0.02em] text-foreground">
|
|
83
|
+
Connect a model provider to start.
|
|
84
|
+
</h2>
|
|
85
|
+
<p className="text-[15px] leading-relaxed text-muted-foreground">
|
|
86
|
+
Choose a provider and model to start. Enter a key if your provider requires one. You can
|
|
87
|
+
add more providers or change them later the same way.
|
|
88
|
+
</p>
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
onClick={onSetUp}
|
|
92
|
+
disabled={disabled}
|
|
93
|
+
className="rounded-lg bg-primary px-4 py-2 text-[14px] font-medium text-primary-foreground hover:opacity-90 disabled:opacity-60"
|
|
94
|
+
>
|
|
95
|
+
Set up provider
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
</main>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The full desktop layout over an `IWsSessionState`. `surface` is an optional label shown next to the
|
|
104
|
+
* mark (e.g. "app").
|
|
105
|
+
*/
|
|
106
|
+
export function SessionSurface({
|
|
107
|
+
state,
|
|
108
|
+
surface,
|
|
109
|
+
personalUsageEnabled = false,
|
|
110
|
+
onReconnect,
|
|
111
|
+
pickFiles,
|
|
112
|
+
getPathForFile,
|
|
113
|
+
onOpenMemoryInEditor,
|
|
114
|
+
appViewSandboxUrl,
|
|
115
|
+
}: {
|
|
116
|
+
state: IWsSessionState;
|
|
117
|
+
surface?: string;
|
|
118
|
+
/** Desktop shells opt in; embedded/session-only surfaces keep their existing chat-only contract. */
|
|
119
|
+
personalUsageEnabled?: boolean;
|
|
120
|
+
/**
|
|
121
|
+
* Present only when the host can restart the runtime after the connection is lost for good (issue
|
|
122
|
+
* #3280 §5, the desktop app); absent in the browser, where the banner says how to reopen it instead.
|
|
123
|
+
*/
|
|
124
|
+
onReconnect?: () => Promise<void>;
|
|
125
|
+
/** Forwarded to the composer's attach button — present only on the desktop app (#3282 §4d). */
|
|
126
|
+
pickFiles?: () => Promise<readonly IPickedFile[]>;
|
|
127
|
+
/** Forwarded to the composer's drag-and-drop — present only on the desktop app (#3282 §4d). */
|
|
128
|
+
getPathForFile?: (file: File) => string;
|
|
129
|
+
/**
|
|
130
|
+
* The Project panel's memory "Open in editor" action — present only when the host can open a file
|
|
131
|
+
* in an external editor (the desktop app, #3282 §4c). Absent in the browser, where the action
|
|
132
|
+
* itself is hidden (only shown "if the host supports it").
|
|
133
|
+
*/
|
|
134
|
+
onOpenMemoryInEditor?: (path: string) => void;
|
|
135
|
+
|
|
136
|
+
appViewSandboxUrl?: string;
|
|
137
|
+
}): React.ReactElement {
|
|
138
|
+
const [view, setView] = useState<TSessionView>('chat');
|
|
139
|
+
const { identity } = useProductIdentity();
|
|
140
|
+
const appViewHost = useMemo(
|
|
141
|
+
() => ({
|
|
142
|
+
...(appViewSandboxUrl !== undefined ? { sandboxUrl: appViewSandboxUrl } : {}),
|
|
143
|
+
readAppView: state.readAppView,
|
|
144
|
+
callAppTool: state.callAppTool,
|
|
145
|
+
hostInfo: { name: identity.displayName, version: APP_VIEW_HOST_VERSION },
|
|
146
|
+
platform: surface === 'app' ? ('desktop' as const) : ('web' as const),
|
|
147
|
+
}),
|
|
148
|
+
[appViewSandboxUrl, state.readAppView, state.callAppTool, identity.displayName, surface],
|
|
149
|
+
);
|
|
150
|
+
const composerRef = useRef<IComposerHandle>(null);
|
|
151
|
+
const tasks = state.executionWorkspace?.entries ?? [];
|
|
152
|
+
// The main thread alone is this conversation; the rail earns its width only for work beside it.
|
|
153
|
+
const hasTasks = tasks.some((entry) => entry.kind !== 'main_thread');
|
|
154
|
+
// #3288 §1: the entry open in the detail sheet, resolved fresh from the live snapshot each render
|
|
155
|
+
// (so it reflects the entry's own updates, e.g. a Stop taking effect) rather than a stale copy.
|
|
156
|
+
const openEntry = tasks.find((entry) => entry.id === state.openEntryId) ?? null;
|
|
157
|
+
// A loop's `cancel` means "stop the loop" (`/loop stop <id>`) — never cancel-background-task,
|
|
158
|
+
// which for a self-paced loop would only cancel its disposable wake timer, leaving the loop
|
|
159
|
+
// itself active (see IExecutionWorkspaceEntry.loopId).
|
|
160
|
+
const stopExecutionEntry = (entry: { loopId?: string; sourceId: string }): void => {
|
|
161
|
+
if (entry.loopId !== undefined) {
|
|
162
|
+
state.send({ type: 'command', name: 'loop', args: `stop ${entry.loopId}` });
|
|
163
|
+
} else {
|
|
164
|
+
state.send({ type: 'cancel-background-task', taskId: entry.sourceId });
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
// #3282 §4 part b-3: the rail also earns its width for a schedule or a goal, even with no running
|
|
168
|
+
// background task/loop beside the conversation — "Work in progress" covers all four (issue #3282
|
|
169
|
+
// §4's Expected section), not only execution-workspace entries.
|
|
170
|
+
const currentGoal = state.sessionStatus?.goal ?? null;
|
|
171
|
+
const hasWorkInProgress = hasTasks || state.scheduledTasks.length > 0 || currentGoal !== null;
|
|
172
|
+
const isEmpty =
|
|
173
|
+
state.messages.length === 0 &&
|
|
174
|
+
!state.streamingText &&
|
|
175
|
+
!state.isThinking &&
|
|
176
|
+
state.activeTools.length === 0;
|
|
177
|
+
// #3282 §3: no provider is configured — the session is running (a placeholder stands in for the
|
|
178
|
+
// real one), but there is nothing to reply with yet.
|
|
179
|
+
const setupRequired = state.sessionStatus?.setupRequired === true;
|
|
180
|
+
const hasSessionList =
|
|
181
|
+
(state.sessionListing ?? null) !== null || state.sessionsError?.code === 'list_failed';
|
|
182
|
+
const sidebarOpen = hasSessionList && state.sessionSidebarOpen;
|
|
183
|
+
const usage = personalUsageEnabled && view === 'usage';
|
|
184
|
+
const project = view === 'project';
|
|
185
|
+
const listing = state.sessionListing ?? null;
|
|
186
|
+
const currentRow = listing?.sessions.find((session) => session.id === listing.currentSessionId);
|
|
187
|
+
// A rename seen on this page wins; otherwise the host's listing names the current session.
|
|
188
|
+
const title = state.sessionName ?? (currentRow ? sessionTitle(currentRow) : null);
|
|
189
|
+
// #3289 §2 — below `md` the open sidebar is a sheet over the conversation (the shared `Dialog`,
|
|
190
|
+
// #3331) rather than a column beside it: a dimmed backdrop, Esc, an outside click or choosing a
|
|
191
|
+
// session all close it again, and it traps Tab inside it while open.
|
|
192
|
+
const isNarrow = useMediaQuery(NARROW_WINDOW_QUERY);
|
|
193
|
+
const sheetActive = sidebarOpen && isNarrow;
|
|
194
|
+
const closeSidebar = (): void => state.setSessionSidebarOpen?.(false);
|
|
195
|
+
// The sheet's own opener — the rail's "Show sessions" button — unmounts in the SAME update that
|
|
196
|
+
// mounts the sheet (the ternary below swaps `SessionSidebarRail` for `Dialog`), so passing it as
|
|
197
|
+
// `Dialog`'s `restoreFocusTo` would only hand back a node already detached from the document by the
|
|
198
|
+
// time `Dialog` reads it — a `.focus()` on that node is a harmless no-op, not a restore. This effect
|
|
199
|
+
// does the real work instead: a `focusin` listener captures the opener the moment it actually had
|
|
200
|
+
// focus, while the sheet is closed; on close, a control that reappeared under the same `aria-label`
|
|
201
|
+
// (the rail's button, remounted) is as good a return address as the original node.
|
|
202
|
+
const sheetOpenerRef = useRef<HTMLElement | null>(null);
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
if (sheetActive) return undefined;
|
|
205
|
+
const onFocusIn = (event: FocusEvent): void => {
|
|
206
|
+
if (event.target instanceof HTMLElement) sheetOpenerRef.current = event.target;
|
|
207
|
+
};
|
|
208
|
+
document.addEventListener('focusin', onFocusIn);
|
|
209
|
+
return () => document.removeEventListener('focusin', onFocusIn);
|
|
210
|
+
}, [sheetActive]);
|
|
211
|
+
const wasSheetActiveRef = useRef(false);
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (wasSheetActiveRef.current && !sheetActive) {
|
|
214
|
+
const opener = sheetOpenerRef.current;
|
|
215
|
+
const label = opener?.getAttribute('aria-label');
|
|
216
|
+
const target =
|
|
217
|
+
opener && document.contains(opener)
|
|
218
|
+
? opener
|
|
219
|
+
: label
|
|
220
|
+
? document.querySelector<HTMLElement>(`[aria-label="${label}"]`)
|
|
221
|
+
: null;
|
|
222
|
+
target?.focus();
|
|
223
|
+
}
|
|
224
|
+
wasSheetActiveRef.current = sheetActive;
|
|
225
|
+
}, [sheetActive]);
|
|
226
|
+
// Choosing a session from the sidebar shows its conversation, whichever view was open — and, while
|
|
227
|
+
// it is a narrow-window sheet, closes it too, exactly as a backdrop click or Esc would.
|
|
228
|
+
const sidebarState: IWsSessionState = {
|
|
229
|
+
...state,
|
|
230
|
+
switchSession: (sessionId) => {
|
|
231
|
+
setView('chat');
|
|
232
|
+
state.switchSession?.(sessionId);
|
|
233
|
+
if (sheetActive) closeSidebar();
|
|
234
|
+
},
|
|
235
|
+
newSession: () => {
|
|
236
|
+
setView('chat');
|
|
237
|
+
state.newSession?.();
|
|
238
|
+
if (sheetActive) closeSidebar();
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
const sidebarPanel = (
|
|
242
|
+
<SessionSidebar
|
|
243
|
+
state={sidebarState}
|
|
244
|
+
brand={<ProductWordmark surface={surface} />}
|
|
245
|
+
className={
|
|
246
|
+
sheetActive
|
|
247
|
+
? 'h-full'
|
|
248
|
+
: 'absolute inset-y-0 left-0 z-30 shadow-2xl shadow-black/40 md:static md:z-auto md:shadow-none'
|
|
249
|
+
}
|
|
250
|
+
/>
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<div className="agent-ui relative flex h-full bg-background text-foreground">
|
|
255
|
+
{hasSessionList ? (
|
|
256
|
+
sidebarOpen ? (
|
|
257
|
+
isNarrow ? (
|
|
258
|
+
<Dialog
|
|
259
|
+
open={sheetActive}
|
|
260
|
+
onClose={closeSidebar}
|
|
261
|
+
title="Sessions"
|
|
262
|
+
panelClassName="mr-auto flex h-full w-[272px] max-w-[85vw] flex-col self-stretch overflow-hidden rounded-2xl bg-sidebar shadow-2xl shadow-black/40 focus:outline-none"
|
|
263
|
+
>
|
|
264
|
+
{sidebarPanel}
|
|
265
|
+
</Dialog>
|
|
266
|
+
) : (
|
|
267
|
+
// Wide windows keep it as a static column beside the conversation.
|
|
268
|
+
sidebarPanel
|
|
269
|
+
)
|
|
270
|
+
) : (
|
|
271
|
+
<SessionSidebarRail state={sidebarState} />
|
|
272
|
+
)
|
|
273
|
+
) : null}
|
|
274
|
+
|
|
275
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
276
|
+
<SessionTitleBar
|
|
277
|
+
status={state.status}
|
|
278
|
+
surface={surface}
|
|
279
|
+
title={title}
|
|
280
|
+
workspace={state.sessionStatus?.workspace}
|
|
281
|
+
showBrand={!sidebarOpen}
|
|
282
|
+
view={view}
|
|
283
|
+
onView={setView}
|
|
284
|
+
personalUsageEnabled={personalUsageEnabled}
|
|
285
|
+
connectionLost={state.connectionLost ?? false}
|
|
286
|
+
/>
|
|
287
|
+
|
|
288
|
+
{usage ? (
|
|
289
|
+
<div className="min-h-0 flex-1">
|
|
290
|
+
<PersonalUsageDashboard state={state} />
|
|
291
|
+
{/* A gated turn waits on this answer, so it shows over whatever view is open. */}
|
|
292
|
+
<PermissionPrompt
|
|
293
|
+
layout="modal"
|
|
294
|
+
prompts={state.pendingPrompts}
|
|
295
|
+
onAnswerPermission={state.answerPermission}
|
|
296
|
+
onAnswerAsk={state.answerAsk}
|
|
297
|
+
ownDriverId={state.ownDriverId}
|
|
298
|
+
/>
|
|
299
|
+
</div>
|
|
300
|
+
) : project ? (
|
|
301
|
+
<div className="min-h-0 flex-1">
|
|
302
|
+
<ProjectPanel state={state} onOpenMemoryInEditor={onOpenMemoryInEditor} />
|
|
303
|
+
<PermissionPrompt
|
|
304
|
+
layout="modal"
|
|
305
|
+
prompts={state.pendingPrompts}
|
|
306
|
+
onAnswerPermission={state.answerPermission}
|
|
307
|
+
onAnswerAsk={state.answerAsk}
|
|
308
|
+
ownDriverId={state.ownDriverId}
|
|
309
|
+
/>
|
|
310
|
+
</div>
|
|
311
|
+
) : (
|
|
312
|
+
<div className="flex min-h-0 flex-1">
|
|
313
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
314
|
+
<ConnectionBanner
|
|
315
|
+
status={state.status}
|
|
316
|
+
connectionLost={state.connectionLost ?? false}
|
|
317
|
+
onReconnect={onReconnect}
|
|
318
|
+
/>
|
|
319
|
+
<div className="min-h-0 flex-1 overflow-hidden">
|
|
320
|
+
{setupRequired ? (
|
|
321
|
+
<SetupPanel
|
|
322
|
+
onSetUp={() => state.send({ type: 'command', name: 'provider', args: 'add' })}
|
|
323
|
+
disabled={state.status !== 'connected'}
|
|
324
|
+
/>
|
|
325
|
+
) : isEmpty ? (
|
|
326
|
+
<EmptyState />
|
|
327
|
+
) : (
|
|
328
|
+
<AppViewHostContext.Provider value={appViewHost}>
|
|
329
|
+
<ConversationView
|
|
330
|
+
messages={state.messages}
|
|
331
|
+
activeTools={state.activeTools}
|
|
332
|
+
streamingText={state.streamingText}
|
|
333
|
+
isThinking={state.isThinking}
|
|
334
|
+
ownDriverId={state.ownDriverId}
|
|
335
|
+
/>
|
|
336
|
+
</AppViewHostContext.Provider>
|
|
337
|
+
)}
|
|
338
|
+
</div>
|
|
339
|
+
<div
|
|
340
|
+
className={`${COLUMN} relative flex flex-shrink-0 flex-col gap-2 pb-4 before:pointer-events-none before:absolute before:inset-x-0 before:-top-8 before:h-8 before:bg-gradient-to-t before:from-background before:to-transparent`}
|
|
341
|
+
>
|
|
342
|
+
<GoalBar
|
|
343
|
+
status={state.sessionStatus ?? null}
|
|
344
|
+
onStop={() => state.send({ type: 'command', name: 'goal', args: 'cancel' })}
|
|
345
|
+
/>
|
|
346
|
+
<PermissionPrompt
|
|
347
|
+
layout="dock"
|
|
348
|
+
prompts={state.pendingPrompts}
|
|
349
|
+
onAnswerPermission={state.answerPermission}
|
|
350
|
+
onAnswerAsk={state.answerAsk}
|
|
351
|
+
ownDriverId={state.ownDriverId}
|
|
352
|
+
onFocusReturn={() => composerRef.current?.focus()}
|
|
353
|
+
/>
|
|
354
|
+
{/* #3282 §3: hidden while there is no provider to send to — the setup panel's own
|
|
355
|
+
button is the only way in, so nobody types into a composer that goes nowhere. */}
|
|
356
|
+
{setupRequired ? null : (
|
|
357
|
+
<Composer
|
|
358
|
+
ref={composerRef}
|
|
359
|
+
catalog={state.commandCatalog ?? null}
|
|
360
|
+
status={state.sessionStatus ?? null}
|
|
361
|
+
connected={state.status === 'connected'}
|
|
362
|
+
running={state.isThinking}
|
|
363
|
+
onStop={() => state.send({ type: 'abort' })}
|
|
364
|
+
queued={state.queuedPrompt}
|
|
365
|
+
pickFiles={pickFiles}
|
|
366
|
+
getPathForFile={getPathForFile}
|
|
367
|
+
onCancelQueue={() => {
|
|
368
|
+
state.send({ type: 'cancel-queue' });
|
|
369
|
+
// No push confirms a cleared queue (unlike a resolved prompt); ask, so the row
|
|
370
|
+
// reliably disappears instead of trusting the clear went through.
|
|
371
|
+
state.send({ type: 'get-pending' });
|
|
372
|
+
}}
|
|
373
|
+
onCommand={(name, args) =>
|
|
374
|
+
state.send({ type: 'command', name, ...(args ? { args } : {}) })
|
|
375
|
+
}
|
|
376
|
+
modelList={state.modelList}
|
|
377
|
+
onRequestModelList={() => state.requestModelList()}
|
|
378
|
+
onSilentCommand={(name, args) => state.sendCommandSilently(name, args)}
|
|
379
|
+
// #3282 §4b: "Manage providers…" now opens Settings at the Providers & Models
|
|
380
|
+
// section, replacing the model menu's former server-driven profile flow.
|
|
381
|
+
onManageProviders={() => state.openSettings('providers')}
|
|
382
|
+
onSubmit={(prompt) => {
|
|
383
|
+
if (!prompt.startsWith('/')) {
|
|
384
|
+
state.send({ type: 'submit', prompt });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const [name, ...rest] = prompt.slice(1).trim().split(/\s+/u);
|
|
388
|
+
if (!name) return;
|
|
389
|
+
const args = rest.join(' ');
|
|
390
|
+
state.send({ type: 'command', name, ...(args ? { args } : {}) });
|
|
391
|
+
}}
|
|
392
|
+
/>
|
|
393
|
+
)}
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
|
|
397
|
+
{hasWorkInProgress && (
|
|
398
|
+
<aside
|
|
399
|
+
aria-label="Agents"
|
|
400
|
+
className="flex w-72 flex-shrink-0 overflow-hidden bg-sidebar"
|
|
401
|
+
>
|
|
402
|
+
<AgentActivityPanel
|
|
403
|
+
tasks={tasks}
|
|
404
|
+
className="flex-1"
|
|
405
|
+
selectedEntryId={state.openEntryId ?? undefined}
|
|
406
|
+
onSelect={(entry) => state.openExecutionDetail(entry.id)}
|
|
407
|
+
onReturnToConversation={() => state.closeExecutionDetail()}
|
|
408
|
+
onStop={stopExecutionEntry}
|
|
409
|
+
schedules={state.scheduledTasks}
|
|
410
|
+
goal={currentGoal}
|
|
411
|
+
onPauseSchedule={state.pauseSchedule}
|
|
412
|
+
onResumeSchedule={state.resumeSchedule}
|
|
413
|
+
onDeleteSchedule={state.deleteSchedule}
|
|
414
|
+
onCancelGoal={() => state.send({ type: 'command', name: 'goal', args: 'cancel' })}
|
|
415
|
+
/>
|
|
416
|
+
</aside>
|
|
417
|
+
)}
|
|
418
|
+
|
|
419
|
+
<ExecutionDetailSheet
|
|
420
|
+
entry={openEntry}
|
|
421
|
+
status={state.executionDetailStatus}
|
|
422
|
+
records={state.executionDetailRecords}
|
|
423
|
+
error={state.executionDetailError}
|
|
424
|
+
complete={state.executionDetailComplete}
|
|
425
|
+
onClose={() => state.closeExecutionDetail()}
|
|
426
|
+
onLoadMore={() => state.loadMoreExecutionDetail()}
|
|
427
|
+
onStop={openEntry ? () => stopExecutionEntry(openEntry) : undefined}
|
|
428
|
+
/>
|
|
429
|
+
</div>
|
|
430
|
+
)}
|
|
431
|
+
</div>
|
|
432
|
+
|
|
433
|
+
<SessionNotices state={state} />
|
|
434
|
+
<SettingsScreen state={state} />
|
|
435
|
+
<AgentSwitcherSheet state={state} />
|
|
436
|
+
<HelpSheet
|
|
437
|
+
open={state.helpOpen}
|
|
438
|
+
onClose={() => state.closeHelp()}
|
|
439
|
+
catalog={state.commandCatalog}
|
|
440
|
+
/>
|
|
441
|
+
</div>
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* A centered chrome frame for the pre-session (loading) and fatal states — reused by app shells. Its
|
|
447
|
+
* content area is this page's `main` landmark (#3289 §3 review): nothing above `CenteredChrome`'s
|
|
448
|
+
* three call sites (starting, fatal, the trust question) supplies one, and none of the three ever
|
|
449
|
+
* render alongside `SessionSurface`'s own. `header` stays a sibling of `main`, not inside it, so it
|
|
450
|
+
* keeps its own implicit `banner` role.
|
|
451
|
+
*/
|
|
452
|
+
export function CenteredChrome({
|
|
453
|
+
tone,
|
|
454
|
+
children,
|
|
455
|
+
}: {
|
|
456
|
+
tone: 'muted' | 'fatal';
|
|
457
|
+
children: React.ReactNode;
|
|
458
|
+
}): React.ReactElement {
|
|
459
|
+
return (
|
|
460
|
+
<div className="agent-ui flex h-full flex-col bg-background text-foreground">
|
|
461
|
+
<header className="flex h-12 flex-shrink-0 items-center px-5">
|
|
462
|
+
<ProductWordmark />
|
|
463
|
+
</header>
|
|
464
|
+
<main className="flex flex-1 items-center justify-center">
|
|
465
|
+
<div className="gui-rise flex max-w-[520px] flex-col items-center gap-4 px-8 text-center">
|
|
466
|
+
<span
|
|
467
|
+
className={`h-2.5 w-2.5 rounded-full ${
|
|
468
|
+
tone === 'fatal' ? 'bg-destructive' : 'animate-pulse bg-warning'
|
|
469
|
+
}`}
|
|
470
|
+
/>
|
|
471
|
+
<div
|
|
472
|
+
className={`text-[15px] leading-relaxed ${
|
|
473
|
+
tone === 'fatal' ? 'text-foreground' : 'text-muted-foreground'
|
|
474
|
+
}`}
|
|
475
|
+
>
|
|
476
|
+
{children}
|
|
477
|
+
</div>
|
|
478
|
+
</div>
|
|
479
|
+
</main>
|
|
480
|
+
</div>
|
|
481
|
+
);
|
|
482
|
+
}
|