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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +400 -0
  2. package/LICENSE +661 -0
  3. package/README.md +19 -2
  4. package/dist/browser/index.d.ts +715 -0
  5. package/dist/browser/index.d.ts.map +1 -0
  6. package/dist/browser/index.js +7923 -0
  7. package/dist/browser/index.js.map +1 -0
  8. package/dist/node/index.cjs +7973 -0
  9. package/dist/node/index.d.cts +715 -0
  10. package/dist/node/index.d.cts.map +1 -0
  11. package/dist/node/index.d.ts +715 -0
  12. package/dist/node/index.d.ts.map +1 -0
  13. package/dist/node/index.js +7923 -0
  14. package/dist/node/index.js.map +1 -0
  15. package/docs/README.md +62 -0
  16. package/docs/SPEC.md +124 -0
  17. package/package.json +99 -3
  18. package/src/client/runtime-host.ts +84 -0
  19. package/src/client/ws-session-client.ts +131 -0
  20. package/src/clipboard.ts +81 -0
  21. package/src/components/AgentActivityPanel.tsx +371 -0
  22. package/src/components/AgentSwitcherSheet.tsx +93 -0
  23. package/src/components/Brand.tsx +33 -0
  24. package/src/components/Composer.tsx +685 -0
  25. package/src/components/ConversationView.tsx +772 -0
  26. package/src/components/Dialog.tsx +230 -0
  27. package/src/components/DiffLines.tsx +81 -0
  28. package/src/components/ExecutionDetailSheet.tsx +142 -0
  29. package/src/components/HelpSheet.tsx +104 -0
  30. package/src/components/PermissionPrompt.tsx +661 -0
  31. package/src/components/PersonalUsageDashboard.tsx +39 -0
  32. package/src/components/PersonalUsageDashboardDetails.tsx +368 -0
  33. package/src/components/PersonalUsageDashboardSections.tsx +250 -0
  34. package/src/components/PopupMenu.tsx +166 -0
  35. package/src/components/ProjectPanel.tsx +268 -0
  36. package/src/components/SessionMonitor.tsx +180 -0
  37. package/src/components/SessionSidebar.tsx +429 -0
  38. package/src/components/SessionSurface.tsx +455 -0
  39. package/src/components/SessionSurfaceChrome.tsx +316 -0
  40. package/src/components/SettingsFields.tsx +159 -0
  41. package/src/components/SettingsGeneralSection.tsx +49 -0
  42. package/src/components/SettingsMcpSection.tsx +157 -0
  43. package/src/components/SettingsPermissionsSection.tsx +137 -0
  44. package/src/components/SettingsPluginsSection.tsx +165 -0
  45. package/src/components/SettingsProvidersSection.tsx +211 -0
  46. package/src/components/SettingsScreen.tsx +340 -0
  47. package/src/components/StatusControls.tsx +294 -0
  48. package/src/components/composer-attachments.ts +246 -0
  49. package/src/components/personal-usage-dashboard-types.ts +26 -0
  50. package/src/driver-labels.ts +88 -0
  51. package/src/execution-entry-status.ts +55 -0
  52. package/src/goal-view.ts +19 -0
  53. package/src/hooks/command-menu.ts +65 -0
  54. package/src/hooks/excluded-commands.ts +52 -0
  55. package/src/hooks/prompt-state.ts +83 -0
  56. package/src/hooks/server-message-handling.ts +92 -0
  57. package/src/hooks/session-client-types.ts +329 -0
  58. package/src/hooks/ui-intent-state.ts +74 -0
  59. package/src/hooks/use-agent-switcher-state.ts +125 -0
  60. package/src/hooks/use-execution-detail.ts +118 -0
  61. package/src/hooks/use-media-query.ts +27 -0
  62. package/src/hooks/use-model-list.ts +45 -0
  63. package/src/hooks/use-personal-usage.ts +138 -0
  64. package/src/hooks/use-project-panel.ts +117 -0
  65. package/src/hooks/use-schedules-state.ts +92 -0
  66. package/src/hooks/use-session-directory.ts +202 -0
  67. package/src/hooks/use-settings-state.ts +115 -0
  68. package/src/hooks/useSessionClient.ts +991 -0
  69. package/src/index.ts +66 -0
  70. package/src/product-identity.tsx +26 -0
  71. package/src/schedule-view.ts +92 -0
  72. package/src/styles/surface.css +231 -0
  73. package/src/styles/surface.css.d.mts +1 -0
  74. package/src/styles/theme.css +38 -0
  75. package/src/styles/theme.css.d.mts +1 -0
@@ -0,0 +1,340 @@
1
+ 'use client';
2
+
3
+ import { ChevronLeft, X } from 'lucide-react';
4
+ import React, { useEffect, useState } from 'react';
5
+
6
+ import { ConfirmDialog, Dialog } from './Dialog.js';
7
+ import { SettingsGeneralSection } from './SettingsGeneralSection.js';
8
+ import { SettingsMcpSection } from './SettingsMcpSection.js';
9
+ import { describePermissionRuleRemoval, SettingsPermissionsSection } from './SettingsPermissionsSection.js';
10
+ import { SettingsPluginsSection } from './SettingsPluginsSection.js';
11
+ import { SettingsProvidersSection } from './SettingsProvidersSection.js';
12
+
13
+ import type { IWsSessionState } from '../hooks/useSessionClient.js';
14
+ import type { ISettingsPermissionRule, ISettingsPlugin } from '@robota-sdk/agent-interface-session';
15
+
16
+ interface ISettingsSectionDefinition {
17
+ id: string;
18
+ label: string;
19
+ }
20
+
21
+ /**
22
+ * The Settings screen's sections (#3282 §4). Adding a later section (Advisor) is one entry here plus
23
+ * one component in the switch below — nothing else in this file changes.
24
+ */
25
+ const SECTIONS: readonly ISettingsSectionDefinition[] = [
26
+ { id: 'general', label: 'General' },
27
+ { id: 'permissions', label: 'Permissions' },
28
+ { id: 'mcp', label: 'MCP Servers' },
29
+ { id: 'plugins', label: 'Plugins' },
30
+ { id: 'providers', label: 'Providers & Models' },
31
+ ];
32
+
33
+ type TPendingSkipAllChecks = { kind: 'mode' } | { kind: 'preset'; presetId: string };
34
+ type TPendingPluginAction =
35
+ | { kind: 'install'; pluginId: string }
36
+ | { kind: 'uninstall'; plugin: ISettingsPlugin };
37
+
38
+ /**
39
+ * A large modal sheet with a section sidebar on the left and content on the right (#3282 §4a) — the
40
+ * GUI's Settings screen, opened by the sidebar's gear button, the desktop app's ⌘, menu item, or
41
+ * `/settings` (its `show-settings` `ui_intent` calls `state.openSettings()`, wired in
42
+ * `useSessionClient`). Every change applies at once; there is no Save button. Esc and the Close
43
+ * button both close it and return focus to whatever opened it (`Dialog` owns that).
44
+ */
45
+ export function SettingsScreen({ state }: { state: IWsSessionState }): React.ReactElement | null {
46
+ const [activeSectionId, setActiveSectionId] = useState<string>('general');
47
+ // Narrow layout only: true shows the section LIST; false shows the selected section's content,
48
+ // pushed in over the list, with the Back button above returning to it.
49
+ const [showList, setShowList] = useState(true);
50
+ const [pendingSkipAllChecks, setPendingSkipAllChecks] = useState<TPendingSkipAllChecks | null>(
51
+ null,
52
+ );
53
+ const [pendingRuleRemoval, setPendingRuleRemoval] = useState<ISettingsPermissionRule | null>(
54
+ null,
55
+ );
56
+ const [pendingPluginAction, setPendingPluginAction] = useState<TPendingPluginAction | null>(null);
57
+ const [mcpReloading, setMcpReloading] = useState(false);
58
+ const [pluginsReloading, setPluginsReloading] = useState(false);
59
+ // #3282 §4b: the profile a pending "Delete…" confirms, or null when none is pending.
60
+ const [pendingProviderDelete, setPendingProviderDelete] = useState<string | null>(null);
61
+
62
+ // Each time the screen opens, land on the section the opener asked for (`/plugin` → Plugins,
63
+ // "Manage providers…" → Providers & Models; the gear and `/settings` ask for none, which lands on
64
+ // General) and, for narrow windows, on the section list.
65
+ useEffect(() => {
66
+ if (!state.settingsOpen) return;
67
+ setActiveSectionId(state.settingsInitialSectionId ?? 'general');
68
+ setShowList(true);
69
+ }, [state.settingsOpen, state.settingsInitialSectionId]);
70
+
71
+ // A reply to ANY request clears a reload spinner — the common case is the reload's own reply;
72
+ // a snapshot refreshed from elsewhere in the meantime is a reasonable time to stop showing it too.
73
+ // `settingsError` is in the dependency list too: a reload that fails updates the error, not the
74
+ // snapshot, and without it the button would spin forever on a failed reload.
75
+ useEffect(() => {
76
+ setMcpReloading(false);
77
+ setPluginsReloading(false);
78
+ }, [state.settingsSnapshot, state.settingsError]);
79
+
80
+ if (!state.settingsOpen) return null;
81
+
82
+ const snapshot = state.settingsSnapshot;
83
+ const activeSection = SECTIONS.find((section) => section.id === activeSectionId) ?? SECTIONS[0];
84
+
85
+ function selectSection(id: string): void {
86
+ setActiveSectionId(id);
87
+ setShowList(false);
88
+ }
89
+
90
+ function requestPermissionModeChange(mode: string): void {
91
+ if (snapshot && mode === snapshot.permissionMode.skipsAllChecksMode) {
92
+ setPendingSkipAllChecks({ kind: 'mode' });
93
+ return;
94
+ }
95
+ state.updateSettings({ field: 'permissionMode', mode });
96
+ }
97
+
98
+ function requestPresetChange(presetId: string): void {
99
+ if (snapshot?.preset.skipsAllChecksPresetIds.includes(presetId)) {
100
+ setPendingSkipAllChecks({ kind: 'preset', presetId });
101
+ return;
102
+ }
103
+ state.updateSettings({ field: 'preset', presetId });
104
+ }
105
+
106
+ function reloadMcpServers(): void {
107
+ setMcpReloading(true);
108
+ state.updateSettings({ field: 'reloadMcpServers' });
109
+ }
110
+
111
+ function reloadPlugins(): void {
112
+ setPluginsReloading(true);
113
+ state.updateSettings({ field: 'reloadPlugins' });
114
+ }
115
+
116
+ function confirmPluginAction(): void {
117
+ if (pendingPluginAction?.kind === 'install') {
118
+ state.updateSettings({ field: 'installPlugin', pluginId: pendingPluginAction.pluginId });
119
+ } else if (pendingPluginAction?.kind === 'uninstall') {
120
+ state.updateSettings({ field: 'uninstallPlugin', pluginId: pendingPluginAction.plugin.id });
121
+ }
122
+ setPendingPluginAction(null);
123
+ }
124
+
125
+ return (
126
+ <>
127
+ <Dialog
128
+ open
129
+ onClose={() => state.closeSettings()}
130
+ title="Settings"
131
+ panelClassName="flex h-[min(680px,85vh)] w-full max-w-3xl flex-col overflow-hidden rounded-2xl bg-card text-card-foreground shadow-[0_8px_30px_-12px_rgb(0_0_0/0.45)] focus:outline-none"
132
+ >
133
+ <div className="flex h-12 flex-shrink-0 items-center gap-1 border-b border-border px-3">
134
+ {!showList ? (
135
+ <button
136
+ type="button"
137
+ aria-label="Back to sections"
138
+ onClick={() => setShowList(true)}
139
+ className="rounded-md p-1.5 text-muted-foreground hover:bg-hover hover:text-foreground md:hidden"
140
+ >
141
+ <ChevronLeft size={18} strokeWidth={1.75} />
142
+ </button>
143
+ ) : null}
144
+ <h2 className="px-1.5 text-[15px] font-semibold text-foreground">Settings</h2>
145
+ <button
146
+ type="button"
147
+ aria-label="Close Settings"
148
+ onClick={() => state.closeSettings()}
149
+ className="ml-auto rounded-md p-1.5 text-muted-foreground hover:bg-hover hover:text-foreground"
150
+ >
151
+ <X size={17} strokeWidth={1.75} />
152
+ </button>
153
+ </div>
154
+
155
+ <div className="flex min-h-0 flex-1">
156
+ <nav
157
+ aria-label="Settings sections"
158
+ className={`w-full flex-shrink-0 overflow-y-auto border-r border-border bg-sidebar px-2 py-3 md:block md:w-[200px] ${
159
+ showList ? 'block' : 'hidden'
160
+ }`}
161
+ >
162
+ <ul className="space-y-0.5">
163
+ {SECTIONS.map((section) => (
164
+ <li key={section.id}>
165
+ <button
166
+ type="button"
167
+ onClick={() => selectSection(section.id)}
168
+ aria-current={section.id === activeSectionId ? 'true' : undefined}
169
+ className={`w-full rounded-lg px-2.5 py-2 text-left text-[13.5px] ${
170
+ section.id === activeSectionId
171
+ ? 'bg-raised font-medium text-foreground'
172
+ : 'text-foreground/85 hover:bg-hover'
173
+ }`}
174
+ >
175
+ {section.label}
176
+ </button>
177
+ </li>
178
+ ))}
179
+ </ul>
180
+ </nav>
181
+
182
+ <div
183
+ className={`min-h-0 flex-1 overflow-y-auto px-5 py-4 md:block ${
184
+ showList ? 'hidden' : 'block'
185
+ }`}
186
+ >
187
+ {snapshot === null ? (
188
+ state.settingsStatus === 'error' ? (
189
+ <div className="flex flex-col items-start gap-2">
190
+ <p className="text-[13.5px] text-destructive">
191
+ {state.settingsError ?? 'Settings could not be read.'}
192
+ </p>
193
+ <button
194
+ type="button"
195
+ onClick={() => state.openSettings()}
196
+ className="rounded-lg bg-raised px-3 py-1.5 text-[13px] text-foreground hover:bg-hover"
197
+ >
198
+ Try again
199
+ </button>
200
+ </div>
201
+ ) : (
202
+ <p className="text-[13.5px] text-muted-foreground">Loading settings…</p>
203
+ )
204
+ ) : (
205
+ <>
206
+ {state.settingsError ? (
207
+ <p className="mb-3 rounded-lg bg-destructive/12 px-3 py-2 text-[13px] text-destructive">
208
+ {state.settingsError}
209
+ </p>
210
+ ) : null}
211
+ {activeSection.id === 'general' ? (
212
+ <SettingsGeneralSection
213
+ snapshot={snapshot}
214
+ onUpdate={state.updateSettings}
215
+ onRequestPresetChange={requestPresetChange}
216
+ />
217
+ ) : activeSection.id === 'mcp' ? (
218
+ <SettingsMcpSection
219
+ snapshot={snapshot}
220
+ onToggleServer={(serverId, enabled) =>
221
+ state.updateSettings({ field: 'mcpServerEnabled', serverId, enabled })
222
+ }
223
+ onReload={reloadMcpServers}
224
+ reloading={mcpReloading}
225
+ />
226
+ ) : activeSection.id === 'plugins' ? (
227
+ <SettingsPluginsSection
228
+ snapshot={snapshot}
229
+ onTogglePlugin={(pluginId, enabled) =>
230
+ state.updateSettings({ field: 'pluginEnabled', pluginId, enabled })
231
+ }
232
+ onReload={reloadPlugins}
233
+ reloading={pluginsReloading}
234
+ onRequestInstall={(pluginId) => setPendingPluginAction({ kind: 'install', pluginId })}
235
+ onRequestUninstall={(plugin) => setPendingPluginAction({ kind: 'uninstall', plugin })}
236
+ />
237
+ ) : activeSection.id === 'providers' ? (
238
+ <SettingsProvidersSection
239
+ snapshot={snapshot}
240
+ modelList={state.modelList}
241
+ onRequestModelList={state.requestModelList}
242
+ onUse={(profileName) => state.updateSettings({ field: 'providerProfile', profileName })}
243
+ onModelChange={(profileName, modelId) =>
244
+ state.updateSettings({ field: 'providerModel', profileName, modelId })
245
+ }
246
+ onCommand={(args) => state.send({ type: 'command', name: 'provider', args })}
247
+ onRequestDelete={(profileName) => setPendingProviderDelete(profileName)}
248
+ />
249
+ ) : (
250
+ <SettingsPermissionsSection
251
+ snapshot={snapshot}
252
+ onRequestModeChange={requestPermissionModeChange}
253
+ onToggleSandbox={(enabled) => state.updateSettings({ field: 'sandbox', enabled })}
254
+ onRequestRemoveRule={(rule) => setPendingRuleRemoval(rule)}
255
+ />
256
+ )}
257
+ </>
258
+ )}
259
+ </div>
260
+ </div>
261
+ </Dialog>
262
+
263
+ <ConfirmDialog
264
+ open={pendingSkipAllChecks !== null}
265
+ title="Skip all permission checks?"
266
+ body="Every action runs without asking first — file edits, shell commands and network access included. You can turn this back on at any time."
267
+ confirmLabel="Skip all checks"
268
+ destructive
269
+ onCancel={() => setPendingSkipAllChecks(null)}
270
+ onConfirm={() => {
271
+ if (pendingSkipAllChecks?.kind === 'mode' && snapshot) {
272
+ state.updateSettings({
273
+ field: 'permissionMode',
274
+ mode: snapshot.permissionMode.skipsAllChecksMode,
275
+ });
276
+ } else if (pendingSkipAllChecks?.kind === 'preset') {
277
+ state.updateSettings({ field: 'preset', presetId: pendingSkipAllChecks.presetId });
278
+ }
279
+ setPendingSkipAllChecks(null);
280
+ }}
281
+ />
282
+
283
+ <ConfirmDialog
284
+ open={pendingRuleRemoval !== null}
285
+ title="Remove this rule?"
286
+ body={pendingRuleRemoval ? describePermissionRuleRemoval(pendingRuleRemoval) : ''}
287
+ confirmLabel="Remove"
288
+ destructive
289
+ onCancel={() => setPendingRuleRemoval(null)}
290
+ onConfirm={() => {
291
+ if (pendingRuleRemoval) {
292
+ state.updateSettings({
293
+ field: 'removePermissionRule',
294
+ scope: pendingRuleRemoval.scope,
295
+ kind: pendingRuleRemoval.kind,
296
+ pattern: pendingRuleRemoval.pattern,
297
+ });
298
+ }
299
+ setPendingRuleRemoval(null);
300
+ }}
301
+ />
302
+
303
+ <ConfirmDialog
304
+ open={pendingPluginAction !== null}
305
+ title={pendingPluginAction?.kind === 'uninstall' ? 'Uninstall this plugin?' : 'Install this plugin?'}
306
+ body={pendingPluginAction ? describePluginAction(pendingPluginAction) : ''}
307
+ confirmLabel={pendingPluginAction?.kind === 'uninstall' ? 'Uninstall' : 'Install'}
308
+ destructive
309
+ onCancel={() => setPendingPluginAction(null)}
310
+ onConfirm={confirmPluginAction}
311
+ />
312
+
313
+ <ConfirmDialog
314
+ open={pendingProviderDelete !== null}
315
+ title={pendingProviderDelete ? `Delete profile "${pendingProviderDelete}"?` : ''}
316
+ body="This removes the profile and its stored key. This cannot be undone."
317
+ confirmLabel="Delete"
318
+ destructive
319
+ onCancel={() => setPendingProviderDelete(null)}
320
+ onConfirm={() => {
321
+ if (pendingProviderDelete) {
322
+ state.updateSettings({ field: 'deleteProviderProfile', profileName: pendingProviderDelete });
323
+ }
324
+ setPendingProviderDelete(null);
325
+ }}
326
+ />
327
+ </>
328
+ );
329
+ }
330
+
331
+ /** Splits `<name>@<marketplace>` for the confirmation copy; a bare name shows with no source. */
332
+ function describePluginAction(action: TPendingPluginAction): string {
333
+ if (action.kind === 'uninstall') {
334
+ return `"${action.plugin.name}" will be removed from this computer.`;
335
+ }
336
+ const at = action.pluginId.lastIndexOf('@');
337
+ const name = at === -1 ? action.pluginId : action.pluginId.slice(0, at);
338
+ const source = at === -1 ? undefined : action.pluginId.slice(at + 1);
339
+ return `Install "${name}"${source ? ` from ${source}` : ''}. It can run code on this computer.`;
340
+ }
@@ -0,0 +1,294 @@
1
+ 'use client';
2
+
3
+ import { Gauge, Shield, Sparkles } from 'lucide-react';
4
+ import { useRef, useState } from 'react';
5
+
6
+ import { ConfirmDialog } from './Dialog.js';
7
+ import { PopupMenu } from './PopupMenu.js';
8
+
9
+ import type { IPopupMenuItem, IPopupMenuSection } from './PopupMenu.js';
10
+ import type { TCommandCatalog, TModelListSnapshot, TSessionStatus } from '../hooks/session-client-types.js';
11
+
12
+ /**
13
+ * #3282 §2 (part 2) — the plain label for each effort selection. Kept in step with
14
+ * `EFFORT_LEVEL_LABELS` in `packages/agent-framework/src/effort/effort-resolution.ts` (which
15
+ * `agent-command`'s `/effort` re-exports and reports as its own outcome text) — this package cannot
16
+ * import that one directly (it is a Node-side package this browser bundle does not depend on), so the
17
+ * values are restated here. `Record<TSessionStatus['effort'], …>` still keeps this exhaustive against
18
+ * the real wire type: an effort value added there without a label here is a compile error.
19
+ */
20
+ const EFFORT_LEVEL_LABELS: Readonly<Record<TSessionStatus['effort'], string>> = {
21
+ auto: 'Auto',
22
+ none: 'None',
23
+ minimal: 'Minimal',
24
+ low: 'Low',
25
+ medium: 'Medium',
26
+ high: 'High',
27
+ xhigh: 'Extra high',
28
+ max: 'Maximum',
29
+ };
30
+
31
+ /** The primary row (#3282 §2 design point 4); the rest sit under "More". */
32
+ const EFFORT_PRIMARY: readonly TSessionStatus['effort'][] = ['auto', 'low', 'medium', 'high'];
33
+ const EFFORT_MORE: readonly TSessionStatus['effort'][] = ['none', 'minimal', 'xhigh', 'max'];
34
+
35
+ /** The `mode` command's subcommand entries (plain label + description per mode), from the catalog. */
36
+ function modeSubcommands(
37
+ catalog: TCommandCatalog | null,
38
+ ): readonly { name: string; displayName?: string; description: string }[] {
39
+ return catalog?.commands.find((command) => command.name === 'mode')?.subcommands ?? [];
40
+ }
41
+
42
+ /** The current mode's plain label, falling back to the raw id before the catalog has loaded. */
43
+ function modeLabel(catalog: TCommandCatalog | null, mode: string): string {
44
+ return modeSubcommands(catalog).find((sub) => sub.name === mode)?.displayName ?? mode;
45
+ }
46
+
47
+ const CHIP_CLASS =
48
+ 'flex min-w-0 items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-muted-foreground hover:bg-hover hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-transparent';
49
+ const CHIP_WARNING_CLASS = 'text-warning hover:text-warning';
50
+
51
+ /** One status chip that opens a `PopupMenu` HIG pop-up beneath it (#3282 §2). */
52
+ function StatusChip({
53
+ chipLabel,
54
+ value,
55
+ icon,
56
+ connected,
57
+ warning = false,
58
+ menuLabel,
59
+ sections,
60
+ onOpen,
61
+ }: {
62
+ chipLabel: string;
63
+ value: string;
64
+ icon: React.ReactElement;
65
+ connected: boolean;
66
+ warning?: boolean;
67
+ menuLabel: string;
68
+ sections: readonly IPopupMenuSection[];
69
+ /** Called just before the menu opens — e.g. to (re)fetch the model list. */
70
+ onOpen?: () => void;
71
+ }): React.ReactElement {
72
+ const [open, setOpen] = useState(false);
73
+ const triggerRef = useRef<HTMLButtonElement>(null);
74
+ return (
75
+ <span className="relative">
76
+ <button
77
+ ref={triggerRef}
78
+ type="button"
79
+ aria-label={`${chipLabel}: ${value}`}
80
+ aria-haspopup="menu"
81
+ aria-expanded={open}
82
+ // #3289 §2: below `md` the value text is hidden (icon-only) — the tooltip is where it stays
83
+ // reachable on hover and keyboard focus, so it always carries the value, not just "Change X".
84
+ title={connected ? `${chipLabel}: ${value}` : 'Reconnecting…'}
85
+ disabled={!connected}
86
+ onClick={() => {
87
+ onOpen?.();
88
+ setOpen(true);
89
+ }}
90
+ className={`${CHIP_CLASS} ${warning ? CHIP_WARNING_CLASS : ''}`}
91
+ >
92
+ {icon}
93
+ {/* #3289 §2: below `md` (the same breakpoint the session-list sheet uses) the chip collapses
94
+ to its icon alone — never a truncated fragment ("d…", "claude-…") — the value stays
95
+ reachable via the tooltip above and the accessible name either way. */}
96
+ <span className="hidden truncate md:inline">{value}</span>
97
+ </button>
98
+ {open ? (
99
+ <PopupMenu
100
+ label={menuLabel}
101
+ sections={sections}
102
+ triggerRef={triggerRef}
103
+ onClose={() => setOpen(false)}
104
+ />
105
+ ) : null}
106
+ </span>
107
+ );
108
+ }
109
+
110
+ const ICON_PROPS = { size: 14, strokeWidth: 1.75, 'aria-hidden': true } as const;
111
+
112
+ /**
113
+ * Model · mode · effort, each a pop-up menu (#3282 §2), and the context the conversation fills.
114
+ * Every menu applies its choice through the same command wire path the equivalent typed slash
115
+ * command uses — `onSilentCommand` — so `/model <id>`, `/mode <name>` and `/effort <value>` and a
116
+ * click in these menus can never drift apart; the control's own chip label (fed by the session's next
117
+ * `session_status`) confirms the change instead of a conversation card.
118
+ */
119
+ export function StatusRow({
120
+ status,
121
+ catalog,
122
+ modelList,
123
+ onRequestModelList,
124
+ onManageProviders,
125
+ onSilentCommand,
126
+ connected,
127
+ }: {
128
+ status: TSessionStatus | null;
129
+ catalog: TCommandCatalog | null;
130
+ modelList: TModelListSnapshot | null;
131
+ onRequestModelList: () => void;
132
+ /** #3282 §4b: "Manage providers…" opens Settings at the Providers & Models section. */
133
+ onManageProviders: () => void;
134
+ /** Applies a model/mode/effort choice without a conversation card (see the module doc). */
135
+ onSilentCommand: (name: string, args?: string) => void;
136
+ connected: boolean;
137
+ }): React.ReactElement {
138
+ const used = status ? Math.round(status.context.usedPercentage) : null;
139
+ // #3282 §2: "Skip all checks" asks for confirmation on the shared `ConfirmDialog` (#3331) before
140
+ // applying — every other mode applies at once. `true` while that confirmation is open.
141
+ const [pendingBypassConfirm, setPendingBypassConfirm] = useState(false);
142
+
143
+ const modelSections: IPopupMenuSection[] = [
144
+ ...(modelList?.groups ?? []).map((group) => ({
145
+ heading: group.providerLabel,
146
+ items: group.models.map(
147
+ (model): IPopupMenuItem => ({
148
+ key: model.id,
149
+ label: model.label,
150
+ checked: model.id === modelList?.currentModel && group.profileName === modelList?.currentProfile,
151
+ onSelect: () => onSilentCommand('model', model.id),
152
+ }),
153
+ ),
154
+ })),
155
+ {
156
+ items: [
157
+ {
158
+ key: 'manage-providers',
159
+ label: 'Manage providers…',
160
+ kind: 'action',
161
+ // #3282 §4b: opens Settings at the "Providers & Models" section — the same list, Use/Model/
162
+ // Edit/Test/Duplicate/Delete actions and "Add provider…" flow now live there instead of in
163
+ // this menu's own server-driven picker.
164
+ onSelect: onManageProviders,
165
+ },
166
+ ],
167
+ },
168
+ ];
169
+
170
+ const modeSections: IPopupMenuSection[] = [
171
+ {
172
+ items: modeSubcommands(catalog).map(
173
+ (sub): IPopupMenuItem => ({
174
+ key: sub.name,
175
+ label: sub.displayName ?? sub.name,
176
+ description: sub.description,
177
+ checked: sub.name === status?.permissionMode,
178
+ // #3282 §2: every mode but "Skip all checks" applies at once; that one opens a
179
+ // confirmation first (rendered below) instead of applying here directly.
180
+ onSelect:
181
+ sub.name === 'bypassPermissions'
182
+ ? () => setPendingBypassConfirm(true)
183
+ : () => onSilentCommand('mode', sub.name),
184
+ }),
185
+ ),
186
+ },
187
+ ];
188
+
189
+ const effortSections: IPopupMenuSection[] = [
190
+ {
191
+ items: EFFORT_PRIMARY.map(
192
+ (value): IPopupMenuItem => ({
193
+ key: value,
194
+ label: EFFORT_LEVEL_LABELS[value],
195
+ checked: value === status?.effort,
196
+ onSelect: () => onSilentCommand('effort', value),
197
+ }),
198
+ ),
199
+ },
200
+ {
201
+ heading: 'More',
202
+ items: EFFORT_MORE.map(
203
+ (value): IPopupMenuItem => ({
204
+ key: value,
205
+ label: EFFORT_LEVEL_LABELS[value],
206
+ checked: value === status?.effort,
207
+ onSelect: () => onSilentCommand('effort', value),
208
+ }),
209
+ ),
210
+ },
211
+ ];
212
+
213
+ return (
214
+ <div className="flex min-w-0 flex-1 items-center gap-0.5">
215
+ {status ? (
216
+ <>
217
+ <StatusChip
218
+ chipLabel="mode"
219
+ value={modeLabel(catalog, status.permissionMode)}
220
+ icon={<Shield {...ICON_PROPS} />}
221
+ connected={connected}
222
+ warning={status.permissionMode === 'bypassPermissions'}
223
+ menuLabel="Mode"
224
+ sections={modeSections}
225
+ />
226
+ <span className="ml-auto" />
227
+ <StatusChip
228
+ chipLabel="model"
229
+ value={status.model}
230
+ icon={<Sparkles {...ICON_PROPS} />}
231
+ connected={connected}
232
+ menuLabel="Model"
233
+ sections={modelSections}
234
+ onOpen={onRequestModelList}
235
+ />
236
+ <StatusChip
237
+ chipLabel="effort"
238
+ value={EFFORT_LEVEL_LABELS[status.effort]}
239
+ icon={<Gauge {...ICON_PROPS} />}
240
+ connected={connected}
241
+ menuLabel="Effort"
242
+ sections={effortSections}
243
+ />
244
+ </>
245
+ ) : (
246
+ <span className="ml-auto px-2 text-[13px] text-subtle">…</span>
247
+ )}
248
+ <span
249
+ className="flex items-center gap-1.5 px-1.5 text-[12.5px] tabular-nums text-subtle"
250
+ title="Context used"
251
+ aria-label={`context ${used ?? 0}% used`}
252
+ >
253
+ <ContextRing percent={used ?? 0} />
254
+ {used === null ? '' : `${used}%`}
255
+ </span>
256
+ {/* #3282 §2: matches the wording the Settings screen's own permission-mode picker already
257
+ ships for the identical action (#3331's `SettingsScreen.tsx`), so switching to "Skip all
258
+ checks" reads the same warning regardless of which control was used. */}
259
+ <ConfirmDialog
260
+ open={pendingBypassConfirm}
261
+ title="Skip all permission checks?"
262
+ body="Every action runs without asking first — file edits, shell commands and network access included. You can turn this back on at any time."
263
+ confirmLabel="Skip all checks"
264
+ destructive
265
+ onCancel={() => setPendingBypassConfirm(false)}
266
+ onConfirm={() => {
267
+ setPendingBypassConfirm(false);
268
+ onSilentCommand('mode', 'bypassPermissions');
269
+ }}
270
+ />
271
+ </div>
272
+ );
273
+ }
274
+
275
+ function ContextRing({ percent }: { percent: number }): React.ReactElement {
276
+ const r = 5;
277
+ const circumference = 2 * Math.PI * r;
278
+ const filled = Math.min(100, Math.max(0, percent)) / 100;
279
+ const tone = percent >= 80 ? 'stroke-warning' : 'stroke-muted-foreground';
280
+ return (
281
+ <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
282
+ <circle cx="7" cy="7" r={r} className="fill-none stroke-raised" strokeWidth="2" />
283
+ <circle
284
+ cx="7"
285
+ cy="7"
286
+ r={r}
287
+ className={`fill-none ${tone}`}
288
+ strokeWidth="2"
289
+ strokeDasharray={`${circumference * filled} ${circumference}`}
290
+ transform="rotate(-90 7 7)"
291
+ />
292
+ </svg>
293
+ );
294
+ }