@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.
- package/CHANGELOG.md +400 -0
- package/LICENSE +661 -0
- package/README.md +19 -2
- package/dist/browser/index.d.ts +715 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +7923 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/node/index.cjs +7973 -0
- package/dist/node/index.d.cts +715 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +715 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +7923 -0
- package/dist/node/index.js.map +1 -0
- package/docs/README.md +62 -0
- package/docs/SPEC.md +124 -0
- package/package.json +99 -3
- 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/Brand.tsx +33 -0
- package/src/components/Composer.tsx +685 -0
- package/src/components/ConversationView.tsx +772 -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 +661 -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 +455 -0
- package/src/components/SessionSurfaceChrome.tsx +316 -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 +92 -0
- package/src/hooks/session-client-types.ts +329 -0
- package/src/hooks/ui-intent-state.ts +74 -0
- package/src/hooks/use-agent-switcher-state.ts +125 -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 +991 -0
- package/src/index.ts +66 -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,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
|
+
}
|