@robota-sdk/agent-ui-web 3.2.1 → 3.3.1
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 +29 -0
- package/dist/index.cjs +161 -118
- package/dist/index.d.cts +65 -63
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +65 -63
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +161 -118
- package/dist/index.js.map +1 -1
- package/docs/SPEC.md +6 -1
- package/package.json +6 -6
- package/src/client/ws-session-client.ts +1 -1
- package/src/clipboard.ts +2 -2
- package/src/components/AgentActivityPanel.tsx +46 -39
- package/src/components/AgentSwitcherSheet.tsx +2 -2
- package/src/components/Composer.tsx +40 -37
- package/src/components/ConversationView.tsx +14 -14
- package/src/components/Dialog.tsx +6 -6
- package/src/components/DiffLines.tsx +2 -2
- package/src/components/ExecutionDetailSheet.tsx +1 -1
- package/src/components/HelpSheet.tsx +2 -2
- package/src/components/PermissionPrompt.tsx +22 -22
- package/src/components/PopupMenu.tsx +1 -1
- package/src/components/ProjectPanel.tsx +2 -2
- package/src/components/SessionMonitor.tsx +1 -1
- package/src/components/SessionSidebar.tsx +8 -8
- package/src/components/SessionSurface.tsx +83 -56
- package/src/components/SessionSurfaceChrome.tsx +8 -8
- package/src/components/SettingsGeneralSection.tsx +1 -1
- package/src/components/SettingsMcpSection.tsx +1 -1
- package/src/components/SettingsPermissionsSection.tsx +1 -1
- package/src/components/SettingsPluginsSection.tsx +1 -1
- package/src/components/SettingsProvidersSection.tsx +3 -3
- package/src/components/SettingsScreen.tsx +3 -3
- package/src/components/StatusControls.tsx +12 -12
- package/src/components/composer-attachments.ts +5 -5
- package/src/driver-labels.ts +3 -3
- package/src/execution-entry-status.ts +1 -1
- package/src/goal-view.ts +1 -1
- package/src/hooks/command-menu.ts +2 -2
- package/src/hooks/excluded-commands.ts +2 -2
- package/src/hooks/prompt-state.ts +4 -4
- package/src/hooks/server-message-handling.ts +13 -11
- package/src/hooks/session-client-types.ts +36 -30
- package/src/hooks/ui-intent-state.ts +6 -6
- package/src/hooks/use-agent-switcher-state.ts +2 -2
- package/src/hooks/use-app-view-requests.ts +8 -1
- package/src/hooks/use-media-query.ts +1 -1
- package/src/hooks/use-model-list.ts +1 -1
- package/src/hooks/use-project-panel.ts +1 -1
- package/src/hooks/use-schedules-state.ts +4 -4
- package/src/hooks/use-session-directory.ts +11 -6
- package/src/hooks/use-settings-state.ts +4 -4
- package/src/hooks/useSessionClient.ts +81 -50
- package/src/index.ts +5 -5
- package/src/schedule-view.ts +1 -1
- package/src/styles/surface.css +1 -1
|
@@ -7,7 +7,7 @@ import { Dialog } from './Dialog.js';
|
|
|
7
7
|
|
|
8
8
|
import type { TCommandCatalog } from '../hooks/session-client-types.js';
|
|
9
9
|
|
|
10
|
-
/** One keyboard shortcut this surface answers
|
|
10
|
+
/** One keyboard shortcut this surface answers */
|
|
11
11
|
interface IShortcut {
|
|
12
12
|
readonly keys: string;
|
|
13
13
|
readonly description: string;
|
|
@@ -22,7 +22,7 @@ const SHORTCUTS: readonly IShortcut[] = [
|
|
|
22
22
|
];
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* The GUI's Help sheet
|
|
25
|
+
* The GUI's Help sheet, a `Dialog` opened by `/help` — client-only: `useSessionClient.ts`'s
|
|
26
26
|
* `send` catches `/help` before it ever reaches the session and opens this instead, so there is no
|
|
27
27
|
* round trip and nothing terminal-style to replace it with. It lists the same commands and skills the
|
|
28
28
|
* `/` menu offers (`commandMenuFor`, unfiltered by any query — an excluded command has no row here
|
|
@@ -19,19 +19,19 @@ import type { TActionResponse } from '@robota-sdk/agent-interface-transport';
|
|
|
19
19
|
import type { TDriverId } from '@robota-sdk/agent-interface-session';
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Renders the owner's pending permission/ask prompts
|
|
22
|
+
* Renders the owner's pending permission/ask prompts. Under local == remote the paired
|
|
23
23
|
* browser owner answers its OWN prompts; the first pending prompt is shown. A gated tool call blocks
|
|
24
24
|
* until answered, so this is on the critical path — not decorative.
|
|
25
25
|
*
|
|
26
26
|
* `modal` covers the page; `dock` sits above the composer, as desktop agent apps place a pending
|
|
27
27
|
* question next to where the answer is typed. Once armed and focused, its keys answer it: 1–9 picks
|
|
28
28
|
* an option, Esc cancels a question or denies a permission — but it never takes that focus away from
|
|
29
|
-
* a field the person is typing in
|
|
29
|
+
* a field the person is typing in. A click on a button always answers at once. An ask whose
|
|
30
30
|
* request sets `allowFreeText` also shows a text field (masked when `masked` is set); Enter there
|
|
31
|
-
* submits it, and its own keys never reach the prompt's shortcuts
|
|
31
|
+
* submits it, and its own keys never reach the prompt's shortcuts. The field's typed value
|
|
32
32
|
* lives only in `FreeTextField`, keyed by `prompt.id`: a cancelled or answered prompt's text can
|
|
33
33
|
* never reach the next prompt's render, masked or not, because the field is a fresh component
|
|
34
|
-
* instance rather than a value carried over in this component's own state
|
|
34
|
+
* instance rather than a value carried over in this component's own state.
|
|
35
35
|
*
|
|
36
36
|
* An ask whose `maxSelect` is above 1 is a multi-select: each option is a toggle (a click or 1–9 ticks
|
|
37
37
|
* it, nothing answers yet), and Submit — or Enter at the prompt, once the selection is allowed —
|
|
@@ -43,7 +43,7 @@ interface IPermissionPromptProps {
|
|
|
43
43
|
prompts: readonly TPendingPrompt[];
|
|
44
44
|
onAnswerPermission: (id: string, result: boolean) => void;
|
|
45
45
|
onAnswerAsk: (id: string, response: TActionResponse) => void;
|
|
46
|
-
/** This connection's own driver id
|
|
46
|
+
/** This connection's own driver id: a prompt raised by its own turn shows no requester. */
|
|
47
47
|
ownDriverId?: TDriverId | null;
|
|
48
48
|
layout?: 'modal' | 'dock';
|
|
49
49
|
/** How long a new prompt waits before its keys answer it; tests pass their own. */
|
|
@@ -72,7 +72,7 @@ function isEditableElement(element: Element | null): boolean {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
|
-
*
|
|
75
|
+
* The one destructive option in the provider profile action menu (the ask built
|
|
76
76
|
* by `askProviderProfileAction` in `agent-command`'s `provider-command-profile.ts`, `request.id`
|
|
77
77
|
* `'provider-profile-action'`): "Delete" answers to no digit and renders apart from Switch/Edit/Test/
|
|
78
78
|
* Duplicate, styled destructive — the exact audited bug ("Delete looks the same as Switch, answers to
|
|
@@ -132,7 +132,7 @@ export function PermissionPrompt({
|
|
|
132
132
|
const promptId = prompt?.id;
|
|
133
133
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
134
134
|
const fieldRef = useRef<HTMLInputElement>(null);
|
|
135
|
-
// A question with a text field
|
|
135
|
+
// A question with a text field: the field is the prompt's primary control, so focus goes
|
|
136
136
|
// there instead of the container wherever this component would otherwise focus the container itself.
|
|
137
137
|
const hasFreeText = prompt?.kind === 'ask' && prompt.request.allowFreeText === true;
|
|
138
138
|
const [armedId, setArmedId] = useState<string | undefined>(undefined);
|
|
@@ -162,8 +162,8 @@ export function PermissionPrompt({
|
|
|
162
162
|
// Answering one prompt renders the next into the same buttons, so a button focused to answer the
|
|
163
163
|
// last one would take Enter, Space or a held key as an answer to this one. Focus goes back to the
|
|
164
164
|
// prompt itself, whose keys wait for the prompt to arm. A reused button is still there to find this
|
|
165
|
-
// way — but the free-text field is its own component, remounted (not updated) for the new prompt.id
|
|
166
|
-
//
|
|
165
|
+
// way — but the free-text field is its own component, remounted (not updated) for the new prompt.id,
|
|
166
|
+
// and removing a focused DOM node drops focus to <body> as an intrinsic side
|
|
167
167
|
// effect, before this effect ever runs. `pendingFocusReturnRef` (set at answer time, and not yet
|
|
168
168
|
// cleared — the effect that clears it is a passive one, ordered after this layout effect) still
|
|
169
169
|
// remembers that focus was in this prompt, so that case is reclaimed too.
|
|
@@ -178,7 +178,7 @@ export function PermissionPrompt({
|
|
|
178
178
|
}
|
|
179
179
|
}, [promptId]);
|
|
180
180
|
// Once armed the prompt takes focus, so its keys answer it rather than type wherever the person
|
|
181
|
-
// already is — UNLESS that is an editable field, whose keystrokes must never be stolen
|
|
181
|
+
// already is — UNLESS that is an editable field, whose keystrokes must never be stolen.
|
|
182
182
|
useEffect(() => {
|
|
183
183
|
if (!armed) return;
|
|
184
184
|
if (isEditableElement(document.activeElement)) return;
|
|
@@ -199,8 +199,8 @@ export function PermissionPrompt({
|
|
|
199
199
|
}, [promptId, onFocusReturn]);
|
|
200
200
|
if (!prompt) return null;
|
|
201
201
|
|
|
202
|
-
//
|
|
203
|
-
// `provider-command-profile-lifecycle.ts`) is now built on the shared `ConfirmDialog`
|
|
202
|
+
// The provider-delete confirmation (`buildProviderDelete` in agent-command's
|
|
203
|
+
// `provider-command-profile-lifecycle.ts`) is now built on the shared `ConfirmDialog`
|
|
204
204
|
// instead of the generic ask grid — a destructive-styled "Delete", Cancel focused by default, and
|
|
205
205
|
// no accidental backdrop-click dismissal. `confirmAction`'s own `CONFIRM_YES`/`CONFIRM_NO` values
|
|
206
206
|
// ('yes'/'no', from `@robota-sdk/agent-core`) are answered directly; this bypasses the rest of this
|
|
@@ -220,7 +220,7 @@ export function PermissionPrompt({
|
|
|
220
220
|
);
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
//
|
|
223
|
+
// Display-only: the prompt belongs to the driver whose turn raised it. Shown so the owner
|
|
224
224
|
// can tell a co-driver's tool-gate from their own — it NEVER changes who is authorized to answer (owner).
|
|
225
225
|
const requesterLabel =
|
|
226
226
|
prompt.requesterDriverId && !isSameSurface(prompt.requesterDriverId, ownDriverId)
|
|
@@ -301,7 +301,7 @@ export function PermissionPrompt({
|
|
|
301
301
|
if (index < 2) answerPermission(prompt.id, index === 0);
|
|
302
302
|
return;
|
|
303
303
|
}
|
|
304
|
-
//
|
|
304
|
+
// A destructive option (Delete) is excluded from the digit-answerable list entirely —
|
|
305
305
|
// no stray digit, including the one it happened to render at before this menu was reordered, can
|
|
306
306
|
// ever answer it.
|
|
307
307
|
const digitOptions = (prompt.request.options ?? []).filter(
|
|
@@ -313,7 +313,7 @@ export function PermissionPrompt({
|
|
|
313
313
|
if (multi) toggleOption(option.value);
|
|
314
314
|
else answerAsk(prompt.id, { type: 'answer', values: [option.value] });
|
|
315
315
|
};
|
|
316
|
-
//
|
|
316
|
+
// An Edit/Write request carries the same server-built diff `tool_end` would show.
|
|
317
317
|
const hasDiff = prompt.kind === 'permission' && (prompt.diffLines?.length ?? 0) > 0;
|
|
318
318
|
const shellCommand =
|
|
319
319
|
prompt.kind === 'permission' && typeof prompt.toolArgs['command'] === 'string'
|
|
@@ -322,7 +322,7 @@ export function PermissionPrompt({
|
|
|
322
322
|
const isShellCommand = prompt.kind === 'permission' && prompt.toolName === 'Bash' && shellCommand !== undefined;
|
|
323
323
|
const askRequestId = prompt.kind === 'ask' ? prompt.request.id : undefined;
|
|
324
324
|
const askOptions = prompt.kind === 'ask' ? (prompt.request.options ?? []) : [];
|
|
325
|
-
//
|
|
325
|
+
// Delete (in the provider profile action menu) renders apart from the rest, destructive-
|
|
326
326
|
// styled and with no digit shortcut — every other ask's options are unaffected (`destructiveAskOption`
|
|
327
327
|
// stays undefined, so `primaryAskOptions` is just `askOptions`, same order, same shortcuts).
|
|
328
328
|
const destructiveAskOption = askOptions.find((opt) => isDestructiveAskOption(askRequestId, opt.value));
|
|
@@ -381,10 +381,10 @@ export function PermissionPrompt({
|
|
|
381
381
|
</>
|
|
382
382
|
)}
|
|
383
383
|
</p>
|
|
384
|
-
{/*
|
|
384
|
+
{/* An Edit/Write request with a server-built diff preview shows "Edit
|
|
385
385
|
<path>" (or "Background agent <label> wants to edit <path>") and the diff, in
|
|
386
386
|
place of "Allow <tool> to run?" and raw arguments.
|
|
387
|
-
|
|
387
|
+
A background agent's own request ALWAYS names it — whether or
|
|
388
388
|
not there's a diff to show — so this never reads as an unattributed ask for
|
|
389
389
|
exactly the riskiest calls (a diff-bearing Edit/Write). */}
|
|
390
390
|
{prompt.requester?.kind === 'background-agent' ? (
|
|
@@ -505,7 +505,7 @@ export function PermissionPrompt({
|
|
|
505
505
|
))}
|
|
506
506
|
</div>
|
|
507
507
|
)}
|
|
508
|
-
{/*
|
|
508
|
+
{/* Delete sits apart at the bottom, styled destructive, with no digit
|
|
509
509
|
shortcut — the audited bug was that it looked and behaved just like Switch. */}
|
|
510
510
|
{destructiveAskOption && (
|
|
511
511
|
<div className="flex flex-wrap items-start gap-3">
|
|
@@ -579,7 +579,7 @@ const BUTTON =
|
|
|
579
579
|
const PRIMARY_BUTTON = `${BUTTON} bg-primary text-primary-foreground hover:opacity-90`;
|
|
580
580
|
const SECONDARY_BUTTON = `${BUTTON} bg-raised text-foreground hover:bg-hover`;
|
|
581
581
|
const GHOST_BUTTON = `${BUTTON} text-muted-foreground hover:bg-hover hover:text-foreground`;
|
|
582
|
-
/**
|
|
582
|
+
/** A destructive ask option (Delete) — visually distinct from Switch/Edit/Test/Duplicate. */
|
|
583
583
|
const DESTRUCTIVE_BUTTON = `${BUTTON} bg-destructive/12 text-destructive hover:bg-destructive/20`;
|
|
584
584
|
|
|
585
585
|
/** Imperative escape hatch a parent can use to wipe the field's value without owning it. */
|
|
@@ -628,7 +628,7 @@ const FreeTextField = forwardRef<IFreeTextFieldHandle, IFreeTextFieldProps>(func
|
|
|
628
628
|
onCancel();
|
|
629
629
|
};
|
|
630
630
|
/**
|
|
631
|
-
* A key that reaches the field types into it — the prompt's own shortcuts never see it
|
|
631
|
+
* A key that reaches the field types into it — the prompt's own shortcuts never see it.
|
|
632
632
|
* The Enter that only finishes an IME composition (Korean/Japanese/Chinese) must not submit the
|
|
633
633
|
* still-uncommitted text — `isComposing` (or `keyCode` 229, on browsers that predate it) marks that
|
|
634
634
|
* Enter; the keystroke is left alone so the browser can commit the composition normally.
|
|
@@ -738,7 +738,7 @@ function ToolArgs({ args }: { args: unknown }): React.ReactElement | null {
|
|
|
738
738
|
}
|
|
739
739
|
|
|
740
740
|
/**
|
|
741
|
-
*
|
|
741
|
+
* A Shell request shows just its command — and the working directory only when the server
|
|
742
742
|
* says it differs from the workspace — instead of every raw argument.
|
|
743
743
|
*/
|
|
744
744
|
function ShellCommandPreview({
|
|
@@ -22,7 +22,7 @@ export interface IPopupMenuSection {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* A HIG pop-up menu
|
|
25
|
+
* A HIG pop-up menu: `role="menu"`, its choices as `menuitemradio` with `aria-checked`
|
|
26
26
|
* (or `menuitem` for a plain action, e.g. "Manage providers…"), grouped into optional labelled
|
|
27
27
|
* sections. Arrow keys move a roving focus, Enter/Space activates the focused item, and Esc — or a
|
|
28
28
|
* click outside — closes the menu and returns focus to the trigger that opened it, so the person
|
|
@@ -13,8 +13,8 @@ import type {
|
|
|
13
13
|
} from '../hooks/session-client-types.js';
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
17
|
-
* readable text. The owner's design principle 4
|
|
16
|
+
* The Project panel: git status (Changes), one file's diff on click, and project memory as
|
|
17
|
+
* readable text. The owner's design principle 4: everything the host implements is usable from
|
|
18
18
|
* the GUI through organized screens — this is git status/diff and project memory's screen. Checkpoints
|
|
19
19
|
* (rewind) are deliberately absent (see `agent-gui-web/docs/SPEC.md`'s design decisions): edit
|
|
20
20
|
* checkpoints only work on a host that can prove a write stays inside the project
|
|
@@ -102,7 +102,7 @@ export function SessionMonitor({ wsUrl, className }: ISessionMonitorProps): Reac
|
|
|
102
102
|
ownDriverId={ownDriverId}
|
|
103
103
|
/>
|
|
104
104
|
) : (
|
|
105
|
-
//
|
|
105
|
+
// This renders in the same slot as `ConversationView` (never
|
|
106
106
|
// together), so it carries this page's `main` landmark here too — nothing above this
|
|
107
107
|
// slot supplies one.
|
|
108
108
|
<main className="flex h-full items-center justify-center" aria-label="Conversation">
|
|
@@ -32,7 +32,7 @@ export function formatUpdatedAt(updatedAt: string, now: number = Date.now()): st
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
* A session's title
|
|
35
|
+
* A session's title: its name, else its stable first-message title, else "New session".
|
|
36
36
|
* Never `preview` — that field is the raw latest reply, which is what changes every turn and is what
|
|
37
37
|
* this title exists to stop showing.
|
|
38
38
|
*/
|
|
@@ -75,12 +75,12 @@ interface IMenuAnchor {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
|
-
*
|
|
78
|
+
* This workspace's sessions on the left of the conversation, as in Claude Code Desktop:
|
|
79
79
|
* start a new one, or click another to make it current. Rows the host could not read are listed,
|
|
80
80
|
* disabled, so a damaged session never looks deleted. A refused switch comes back as a notice.
|
|
81
81
|
* A host that keeps several sessions live marks the rows running now and counts who else is on them.
|
|
82
82
|
*
|
|
83
|
-
*
|
|
83
|
+
* Each row also carries a "More" menu (or right-click) with Rename (inline) and Delete
|
|
84
84
|
* (confirmed). Rename on the current row reuses the `/rename` command, which also updates the live
|
|
85
85
|
* session's own name and title bar; rename on any other row writes the stored record directly.
|
|
86
86
|
*/
|
|
@@ -99,7 +99,7 @@ export function SessionSidebar({
|
|
|
99
99
|
const unreadable = listing?.unreadableSessionIds ?? [];
|
|
100
100
|
const lockedLine = lockedSessionsLine(listing?.lockedSessionCount);
|
|
101
101
|
const failed = state.sessionsError?.code === 'list_failed' ? state.sessionsError.message : null;
|
|
102
|
-
//
|
|
102
|
+
// Session switching is disabled while disconnected, like the status chips and
|
|
103
103
|
// the `/` command menu — a switch could not reach the host either.
|
|
104
104
|
const connected = state.status === 'connected';
|
|
105
105
|
|
|
@@ -203,7 +203,7 @@ export function SessionSidebar({
|
|
|
203
203
|
const isCurrent = session.id === current;
|
|
204
204
|
const others = otherClients(session, isCurrent);
|
|
205
205
|
const isRenaming = renamingId === session.id;
|
|
206
|
-
//
|
|
206
|
+
// The explicit `aria-label` below replaces name-from-content outright, so
|
|
207
207
|
// the live dot's and "N other(s)" span's own text no longer reaches the accessible name —
|
|
208
208
|
// folded back in here as a description instead, alongside the relative time.
|
|
209
209
|
const statusParts = [
|
|
@@ -238,12 +238,12 @@ export function SessionSidebar({
|
|
|
238
238
|
<button
|
|
239
239
|
type="button"
|
|
240
240
|
aria-current={isCurrent ? 'true' : undefined}
|
|
241
|
-
//
|
|
241
|
+
// An explicit name + description — the row's own visible text has no
|
|
242
242
|
// whitespace between its parts, so an unlabelled button reads as one run-together
|
|
243
243
|
// string ("Title validation fixjust now") to a screen reader.
|
|
244
244
|
aria-label={sessionTitle(session)}
|
|
245
245
|
aria-describedby={describedBy}
|
|
246
|
-
//
|
|
246
|
+
// While disconnected, the tooltip says why instead of previewing.
|
|
247
247
|
title={connected ? session.preview || session.id : 'Reconnecting…'}
|
|
248
248
|
disabled={!connected}
|
|
249
249
|
onClick={() => {
|
|
@@ -409,7 +409,7 @@ export function SessionSidebar({
|
|
|
409
409
|
|
|
410
410
|
/** The collapsed sidebar: a narrow rail that opens it again. */
|
|
411
411
|
export function SessionSidebarRail({ state }: { state: IWsSessionState }): React.ReactElement {
|
|
412
|
-
//
|
|
412
|
+
// Starting a session is disabled while disconnected, matching the open sidebar.
|
|
413
413
|
const connected = state.status === 'connected';
|
|
414
414
|
return (
|
|
415
415
|
<div className="flex w-12 flex-shrink-0 flex-col items-center gap-1 bg-sidebar py-2.5">
|
|
@@ -38,7 +38,7 @@ const APP_VIEW_HOST_VERSION = '1.0.0';
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
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
|
|
41
|
+
* (never together), so it carries the same `main` landmark and label — nothing
|
|
42
42
|
* above this slot supplies one.
|
|
43
43
|
*/
|
|
44
44
|
function EmptyState(): React.ReactElement {
|
|
@@ -59,14 +59,14 @@ function EmptyState(): React.ReactElement {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
62
|
+
* Shown in place of the conversation while `sessionStatus.setupRequired` holds: the
|
|
63
63
|
* session is running (the GUI itself connected fine), but has no provider to reply with yet. "Set up
|
|
64
64
|
* provider" runs `/provider add` as a command; its questions dock above where the composer would be
|
|
65
65
|
* (the same `PermissionPrompt` any ask uses), and the panel clears itself once the session reports a
|
|
66
66
|
* real provider, live — no restart, no reload.
|
|
67
67
|
*
|
|
68
68
|
* Renders in the same slot as `ConversationView`/`EmptyState` (never together), so it carries the
|
|
69
|
-
* page's `main` landmark here too
|
|
69
|
+
* page's `main` landmark here too — nothing above this slot supplies one.
|
|
70
70
|
*/
|
|
71
71
|
function SetupPanel({
|
|
72
72
|
onSetUp,
|
|
@@ -106,7 +106,7 @@ function SetupPanel({
|
|
|
106
106
|
export function SessionSurface({
|
|
107
107
|
state,
|
|
108
108
|
surface,
|
|
109
|
-
personalUsageEnabled = false,
|
|
109
|
+
personalUsageEnabled: personalUsageRequested = false,
|
|
110
110
|
draftMode = 'persistent',
|
|
111
111
|
onReconnect,
|
|
112
112
|
pickFiles,
|
|
@@ -121,23 +121,25 @@ export function SessionSurface({
|
|
|
121
121
|
/** Secure desktop discards unsent text and attachment paths when the session closes. */
|
|
122
122
|
draftMode?: 'persistent' | 'volatile';
|
|
123
123
|
/**
|
|
124
|
-
* Present only when the host can restart the runtime after the connection is lost for good (
|
|
125
|
-
*
|
|
124
|
+
* Present only when the host can restart the runtime after the connection is lost for good (the
|
|
125
|
+
* desktop app); absent in the browser, where the banner says how to reopen it instead.
|
|
126
126
|
*/
|
|
127
127
|
onReconnect?: () => Promise<void>;
|
|
128
|
-
/** Forwarded to the composer's attach button — present only on the desktop app
|
|
128
|
+
/** Forwarded to the composer's attach button — present only on the desktop app. */
|
|
129
129
|
pickFiles?: () => Promise<readonly IPickedFile[]>;
|
|
130
|
-
/** Forwarded to the composer's drag-and-drop — present only on the desktop app
|
|
130
|
+
/** Forwarded to the composer's drag-and-drop — present only on the desktop app. */
|
|
131
131
|
getPathForFile?: (file: File) => string;
|
|
132
132
|
/**
|
|
133
133
|
* The Project panel's memory "Open in editor" action — present only when the host can open a file
|
|
134
|
-
* in an external editor (the desktop app
|
|
134
|
+
* in an external editor (the desktop app). Absent in the browser, where the action
|
|
135
135
|
* itself is hidden (only shown "if the host supports it").
|
|
136
136
|
*/
|
|
137
137
|
onOpenMemoryInEditor?: (path: string) => void;
|
|
138
138
|
|
|
139
139
|
appViewSandboxUrl?: string;
|
|
140
140
|
}): React.ReactElement {
|
|
141
|
+
// An observer sees the session but changes nothing; the host stays the authority, this is presentation.
|
|
142
|
+
const readOnly = state.role === 'observe';
|
|
141
143
|
const [view, setView] = useState<TSessionView>('chat');
|
|
142
144
|
const { identity } = useProductIdentity();
|
|
143
145
|
const appViewHost = useMemo(
|
|
@@ -150,11 +152,13 @@ export function SessionSurface({
|
|
|
150
152
|
}),
|
|
151
153
|
[appViewSandboxUrl, state.readAppView, state.callAppTool, identity.displayName, surface],
|
|
152
154
|
);
|
|
155
|
+
// The personal usage read is not admitted for an observer.
|
|
156
|
+
const personalUsageEnabled = personalUsageRequested && !readOnly;
|
|
153
157
|
const composerRef = useRef<IComposerHandle>(null);
|
|
154
158
|
const tasks = state.executionWorkspace?.entries ?? [];
|
|
155
159
|
// The main thread alone is this conversation; the rail earns its width only for work beside it.
|
|
156
160
|
const hasTasks = tasks.some((entry) => entry.kind !== 'main_thread');
|
|
157
|
-
//
|
|
161
|
+
// The entry open in the detail sheet, resolved fresh from the live snapshot each render
|
|
158
162
|
// (so it reflects the entry's own updates, e.g. a Stop taking effect) rather than a stale copy.
|
|
159
163
|
const openEntry = tasks.find((entry) => entry.id === state.openEntryId) ?? null;
|
|
160
164
|
// A loop's `cancel` means "stop the loop" (`/loop stop <id>`) — never cancel-background-task,
|
|
@@ -167,9 +171,9 @@ export function SessionSurface({
|
|
|
167
171
|
state.send({ type: 'cancel-background-task', taskId: entry.sourceId });
|
|
168
172
|
}
|
|
169
173
|
};
|
|
170
|
-
//
|
|
171
|
-
// background task/loop beside the conversation — "Work in progress" covers all four
|
|
172
|
-
//
|
|
174
|
+
// The rail also earns its width for a schedule or a goal, even with no running
|
|
175
|
+
// background task/loop beside the conversation — "Work in progress" covers all four, not only
|
|
176
|
+
// execution-workspace entries.
|
|
173
177
|
const currentGoal = state.sessionStatus?.goal ?? null;
|
|
174
178
|
const hasWorkInProgress = hasTasks || state.scheduledTasks.length > 0 || currentGoal !== null;
|
|
175
179
|
const isEmpty =
|
|
@@ -177,11 +181,13 @@ export function SessionSurface({
|
|
|
177
181
|
!state.streamingText &&
|
|
178
182
|
!state.isThinking &&
|
|
179
183
|
state.activeTools.length === 0;
|
|
180
|
-
//
|
|
184
|
+
// No provider is configured — the session is running (a placeholder stands in for the
|
|
181
185
|
// real one), but there is nothing to reply with yet.
|
|
182
186
|
const setupRequired = state.sessionStatus?.setupRequired === true;
|
|
187
|
+
// Switching, starting, renaming and deleting sessions are driver actions: an observer has no sidebar.
|
|
183
188
|
const hasSessionList =
|
|
184
|
-
|
|
189
|
+
!readOnly &&
|
|
190
|
+
((state.sessionListing ?? null) !== null || state.sessionsError?.code === 'list_failed');
|
|
185
191
|
const sidebarOpen = hasSessionList && state.sessionSidebarOpen;
|
|
186
192
|
const usage = personalUsageEnabled && view === 'usage';
|
|
187
193
|
const project = view === 'project';
|
|
@@ -189,8 +195,8 @@ export function SessionSurface({
|
|
|
189
195
|
const currentRow = listing?.sessions.find((session) => session.id === listing.currentSessionId);
|
|
190
196
|
// A rename seen on this page wins; otherwise the host's listing names the current session.
|
|
191
197
|
const title = state.sessionName ?? (currentRow ? sessionTitle(currentRow) : null);
|
|
192
|
-
//
|
|
193
|
-
//
|
|
198
|
+
// Below `md` the open sidebar is a sheet over the conversation (the shared `Dialog`)
|
|
199
|
+
// rather than a column beside it: a dimmed backdrop, Esc, an outside click or choosing a
|
|
194
200
|
// session all close it again, and it traps Tab inside it while open.
|
|
195
201
|
const isNarrow = useMediaQuery(NARROW_WINDOW_QUERY);
|
|
196
202
|
const sheetActive = sidebarOpen && isNarrow;
|
|
@@ -292,24 +298,28 @@ export function SessionSurface({
|
|
|
292
298
|
<div className="min-h-0 flex-1">
|
|
293
299
|
<PersonalUsageDashboard state={state} />
|
|
294
300
|
{/* A gated turn waits on this answer, so it shows over whatever view is open. */}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
301
|
+
{readOnly ? null : (
|
|
302
|
+
<PermissionPrompt
|
|
303
|
+
layout="modal"
|
|
304
|
+
prompts={state.pendingPrompts}
|
|
305
|
+
onAnswerPermission={state.answerPermission}
|
|
306
|
+
onAnswerAsk={state.answerAsk}
|
|
307
|
+
ownDriverId={state.ownDriverId}
|
|
308
|
+
/>
|
|
309
|
+
)}
|
|
302
310
|
</div>
|
|
303
311
|
) : project ? (
|
|
304
312
|
<div className="min-h-0 flex-1">
|
|
305
313
|
<ProjectPanel state={state} onOpenMemoryInEditor={onOpenMemoryInEditor} />
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
314
|
+
{readOnly ? null : (
|
|
315
|
+
<PermissionPrompt
|
|
316
|
+
layout="modal"
|
|
317
|
+
prompts={state.pendingPrompts}
|
|
318
|
+
onAnswerPermission={state.answerPermission}
|
|
319
|
+
onAnswerAsk={state.answerAsk}
|
|
320
|
+
ownDriverId={state.ownDriverId}
|
|
321
|
+
/>
|
|
322
|
+
)}
|
|
313
323
|
</div>
|
|
314
324
|
) : (
|
|
315
325
|
<div className="flex min-h-0 flex-1">
|
|
@@ -320,7 +330,7 @@ export function SessionSurface({
|
|
|
320
330
|
onReconnect={onReconnect}
|
|
321
331
|
/>
|
|
322
332
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
323
|
-
{setupRequired ? (
|
|
333
|
+
{setupRequired && !readOnly ? (
|
|
324
334
|
<SetupPanel
|
|
325
335
|
onSetUp={() => state.send({ type: 'command', name: 'provider', args: 'add' })}
|
|
326
336
|
disabled={state.status !== 'connected'}
|
|
@@ -328,7 +338,7 @@ export function SessionSurface({
|
|
|
328
338
|
) : isEmpty ? (
|
|
329
339
|
<EmptyState />
|
|
330
340
|
) : (
|
|
331
|
-
<AppViewHostContext.Provider value={appViewHost}>
|
|
341
|
+
<AppViewHostContext.Provider value={readOnly ? null : appViewHost}>
|
|
332
342
|
<ConversationView
|
|
333
343
|
messages={state.messages}
|
|
334
344
|
activeTools={state.activeTools}
|
|
@@ -344,19 +354,32 @@ export function SessionSurface({
|
|
|
344
354
|
>
|
|
345
355
|
<GoalBar
|
|
346
356
|
status={state.sessionStatus ?? null}
|
|
347
|
-
onStop={
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
onAnswerPermission={state.answerPermission}
|
|
353
|
-
onAnswerAsk={state.answerAsk}
|
|
354
|
-
ownDriverId={state.ownDriverId}
|
|
355
|
-
onFocusReturn={() => composerRef.current?.focus()}
|
|
357
|
+
onStop={
|
|
358
|
+
readOnly
|
|
359
|
+
? undefined
|
|
360
|
+
: () => state.send({ type: 'command', name: 'goal', args: 'cancel' })
|
|
361
|
+
}
|
|
356
362
|
/>
|
|
357
|
-
{
|
|
363
|
+
{readOnly ? null : (
|
|
364
|
+
<PermissionPrompt
|
|
365
|
+
layout="dock"
|
|
366
|
+
prompts={state.pendingPrompts}
|
|
367
|
+
onAnswerPermission={state.answerPermission}
|
|
368
|
+
onAnswerAsk={state.answerAsk}
|
|
369
|
+
ownDriverId={state.ownDriverId}
|
|
370
|
+
onFocusReturn={() => composerRef.current?.focus()}
|
|
371
|
+
/>
|
|
372
|
+
)}
|
|
373
|
+
{/* Hidden while there is no provider to send to — the setup panel's own
|
|
358
374
|
button is the only way in, so nobody types into a composer that goes nowhere. */}
|
|
359
|
-
{
|
|
375
|
+
{readOnly ? (
|
|
376
|
+
<p
|
|
377
|
+
role="status"
|
|
378
|
+
className="rounded-2xl bg-card px-4 py-3 text-center text-[14px] text-muted-foreground"
|
|
379
|
+
>
|
|
380
|
+
Read-only: you are watching this session.
|
|
381
|
+
</p>
|
|
382
|
+
) : setupRequired ? null : (
|
|
360
383
|
<Composer
|
|
361
384
|
ref={composerRef}
|
|
362
385
|
draftMode={draftMode}
|
|
@@ -380,7 +403,7 @@ export function SessionSurface({
|
|
|
380
403
|
modelList={state.modelList}
|
|
381
404
|
onRequestModelList={() => state.requestModelList()}
|
|
382
405
|
onSilentCommand={(name, args) => state.sendCommandSilently(name, args)}
|
|
383
|
-
//
|
|
406
|
+
// "Manage providers…" now opens Settings at the Providers & Models
|
|
384
407
|
// section, replacing the model menu's former server-driven profile flow.
|
|
385
408
|
onManageProviders={() => state.openSettings('providers')}
|
|
386
409
|
onSubmit={(prompt) => {
|
|
@@ -409,13 +432,17 @@ export function SessionSurface({
|
|
|
409
432
|
selectedEntryId={state.openEntryId ?? undefined}
|
|
410
433
|
onSelect={(entry) => state.openExecutionDetail(entry.id)}
|
|
411
434
|
onReturnToConversation={() => state.closeExecutionDetail()}
|
|
412
|
-
onStop={stopExecutionEntry}
|
|
435
|
+
onStop={readOnly ? undefined : stopExecutionEntry}
|
|
413
436
|
schedules={state.scheduledTasks}
|
|
414
437
|
goal={currentGoal}
|
|
415
|
-
onPauseSchedule={state.pauseSchedule}
|
|
416
|
-
onResumeSchedule={state.resumeSchedule}
|
|
417
|
-
onDeleteSchedule={state.deleteSchedule}
|
|
418
|
-
onCancelGoal={
|
|
438
|
+
onPauseSchedule={readOnly ? undefined : state.pauseSchedule}
|
|
439
|
+
onResumeSchedule={readOnly ? undefined : state.resumeSchedule}
|
|
440
|
+
onDeleteSchedule={readOnly ? undefined : state.deleteSchedule}
|
|
441
|
+
onCancelGoal={
|
|
442
|
+
readOnly
|
|
443
|
+
? undefined
|
|
444
|
+
: () => state.send({ type: 'command', name: 'goal', args: 'cancel' })
|
|
445
|
+
}
|
|
419
446
|
/>
|
|
420
447
|
</aside>
|
|
421
448
|
)}
|
|
@@ -428,14 +455,14 @@ export function SessionSurface({
|
|
|
428
455
|
complete={state.executionDetailComplete}
|
|
429
456
|
onClose={() => state.closeExecutionDetail()}
|
|
430
457
|
onLoadMore={() => state.loadMoreExecutionDetail()}
|
|
431
|
-
onStop={openEntry ? () => stopExecutionEntry(openEntry) : undefined}
|
|
458
|
+
onStop={openEntry && !readOnly ? () => stopExecutionEntry(openEntry) : undefined}
|
|
432
459
|
inputState={state.executionDetailInput}
|
|
433
460
|
mode={state.executionDetailMode}
|
|
434
461
|
unreadable={state.executionDetailUnreadable}
|
|
435
462
|
onShowEarlier={state.showEarlierExecutionDetail}
|
|
436
463
|
onShowLive={state.showLiveExecutionDetail}
|
|
437
464
|
onSend={
|
|
438
|
-
openEntry
|
|
465
|
+
openEntry && !readOnly
|
|
439
466
|
? (prompt) =>
|
|
440
467
|
state.send({
|
|
441
468
|
type: 'send-background-task',
|
|
@@ -445,7 +472,7 @@ export function SessionSurface({
|
|
|
445
472
|
: undefined
|
|
446
473
|
}
|
|
447
474
|
onContinue={
|
|
448
|
-
openEntry?.resumeSessionId
|
|
475
|
+
openEntry?.resumeSessionId && !readOnly
|
|
449
476
|
? () => {
|
|
450
477
|
if (openEntry.resumeSessionId) state.switchSession(openEntry.resumeSessionId);
|
|
451
478
|
}
|
|
@@ -457,8 +484,8 @@ export function SessionSurface({
|
|
|
457
484
|
</div>
|
|
458
485
|
|
|
459
486
|
<SessionNotices state={state} />
|
|
460
|
-
<SettingsScreen state={state} />
|
|
461
|
-
<AgentSwitcherSheet state={state} />
|
|
487
|
+
{readOnly ? null : <SettingsScreen state={state} />}
|
|
488
|
+
{readOnly ? null : <AgentSwitcherSheet state={state} />}
|
|
462
489
|
<HelpSheet
|
|
463
490
|
open={state.helpOpen}
|
|
464
491
|
onClose={() => state.closeHelp()}
|
|
@@ -470,7 +497,7 @@ export function SessionSurface({
|
|
|
470
497
|
|
|
471
498
|
/**
|
|
472
499
|
* A centered chrome frame for the pre-session (loading) and fatal states — reused by app shells. Its
|
|
473
|
-
* content area is this page's `main` landmark
|
|
500
|
+
* content area is this page's `main` landmark: nothing above `CenteredChrome`'s
|
|
474
501
|
* three call sites (starting, fatal, the trust question) supplies one, and none of the three ever
|
|
475
502
|
* render alongside `SessionSurface`'s own. `header` stays a sibling of `main`, not inside it, so it
|
|
476
503
|
* keeps its own implicit `banner` role.
|