@robota-sdk/agent-ui-web 0.0.0-stage → 3.0.0-beta.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +548 -0
- package/LICENSE +661 -0
- package/README.md +19 -2
- package/dist/browser/index.d.ts +736 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +8525 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/node/index.cjs +8575 -0
- package/dist/node/index.d.cts +736 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +736 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +8525 -0
- package/dist/node/index.js.map +1 -0
- package/docs/README.md +62 -0
- package/docs/SPEC.md +130 -0
- package/package.json +99 -3
- package/src/app-view/bridge.ts +217 -0
- package/src/app-view/call-tool-result.ts +58 -0
- package/src/app-view/host-context.ts +37 -0
- package/src/app-view/types.ts +12 -0
- package/src/client/runtime-host.ts +84 -0
- package/src/client/ws-session-client.ts +131 -0
- package/src/clipboard.ts +81 -0
- package/src/components/AgentActivityPanel.tsx +371 -0
- package/src/components/AgentSwitcherSheet.tsx +93 -0
- package/src/components/AppView.tsx +147 -0
- package/src/components/Brand.tsx +33 -0
- package/src/components/Composer.tsx +685 -0
- package/src/components/ConversationView.tsx +789 -0
- package/src/components/Dialog.tsx +230 -0
- package/src/components/DiffLines.tsx +81 -0
- package/src/components/ExecutionDetailSheet.tsx +142 -0
- package/src/components/HelpSheet.tsx +104 -0
- package/src/components/PermissionPrompt.tsx +762 -0
- package/src/components/PersonalUsageDashboard.tsx +39 -0
- package/src/components/PersonalUsageDashboardDetails.tsx +368 -0
- package/src/components/PersonalUsageDashboardSections.tsx +250 -0
- package/src/components/PopupMenu.tsx +166 -0
- package/src/components/ProjectPanel.tsx +268 -0
- package/src/components/SessionMonitor.tsx +180 -0
- package/src/components/SessionSidebar.tsx +429 -0
- package/src/components/SessionSurface.tsx +482 -0
- package/src/components/SessionSurfaceChrome.tsx +319 -0
- package/src/components/SettingsFields.tsx +159 -0
- package/src/components/SettingsGeneralSection.tsx +49 -0
- package/src/components/SettingsMcpSection.tsx +157 -0
- package/src/components/SettingsPermissionsSection.tsx +137 -0
- package/src/components/SettingsPluginsSection.tsx +165 -0
- package/src/components/SettingsProvidersSection.tsx +211 -0
- package/src/components/SettingsScreen.tsx +340 -0
- package/src/components/StatusControls.tsx +294 -0
- package/src/components/composer-attachments.ts +246 -0
- package/src/components/personal-usage-dashboard-types.ts +26 -0
- package/src/driver-labels.ts +88 -0
- package/src/execution-entry-status.ts +55 -0
- package/src/goal-view.ts +19 -0
- package/src/hooks/command-menu.ts +65 -0
- package/src/hooks/excluded-commands.ts +52 -0
- package/src/hooks/prompt-state.ts +83 -0
- package/src/hooks/server-message-handling.ts +95 -0
- package/src/hooks/session-client-types.ts +344 -0
- package/src/hooks/ui-intent-state.ts +74 -0
- package/src/hooks/use-agent-switcher-state.ts +125 -0
- package/src/hooks/use-app-view-requests.ts +178 -0
- package/src/hooks/use-execution-detail.ts +118 -0
- package/src/hooks/use-media-query.ts +27 -0
- package/src/hooks/use-model-list.ts +45 -0
- package/src/hooks/use-personal-usage.ts +138 -0
- package/src/hooks/use-project-panel.ts +117 -0
- package/src/hooks/use-schedules-state.ts +92 -0
- package/src/hooks/use-session-directory.ts +202 -0
- package/src/hooks/use-settings-state.ts +115 -0
- package/src/hooks/useSessionClient.ts +1051 -0
- package/src/index.ts +72 -0
- package/src/product-identity.tsx +26 -0
- package/src/schedule-view.ts +92 -0
- package/src/styles/surface.css +231 -0
- package/src/styles/surface.css.d.mts +1 -0
- package/src/styles/theme.css +38 -0
- package/src/styles/theme.css.d.mts +1 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Trash2 } from 'lucide-react';
|
|
2
|
+
import React, { useId } from 'react';
|
|
3
|
+
|
|
4
|
+
import { SettingsPopupField } from './SettingsFields.js';
|
|
5
|
+
|
|
6
|
+
import type { ISettingsPermissionRule, ISettingsSnapshot } from '@robota-sdk/agent-interface-session';
|
|
7
|
+
|
|
8
|
+
const KIND_LABEL: Readonly<Record<ISettingsPermissionRule['kind'], string>> = {
|
|
9
|
+
allow: 'Allow',
|
|
10
|
+
deny: 'Deny',
|
|
11
|
+
ask: 'Ask',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const KIND_VERB: Readonly<Record<ISettingsPermissionRule['kind'], string>> = {
|
|
15
|
+
allow: 'allowed',
|
|
16
|
+
deny: 'denied',
|
|
17
|
+
ask: 'asked about',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function PermissionRuleRow({
|
|
21
|
+
rule,
|
|
22
|
+
onRequestRemove,
|
|
23
|
+
}: {
|
|
24
|
+
rule: ISettingsPermissionRule;
|
|
25
|
+
onRequestRemove: (rule: ISettingsPermissionRule) => void;
|
|
26
|
+
}): React.ReactElement {
|
|
27
|
+
return (
|
|
28
|
+
<li className="flex items-center justify-between gap-3 rounded-lg px-2.5 py-2 hover:bg-hover">
|
|
29
|
+
<div className="min-w-0">
|
|
30
|
+
<p className="truncate text-[13.5px] text-foreground">
|
|
31
|
+
<span className="font-medium">{KIND_LABEL[rule.kind]}</span>{' '}
|
|
32
|
+
<span className="font-mono text-[12.5px]">{rule.pattern}</span>
|
|
33
|
+
</p>
|
|
34
|
+
<p className="truncate text-[12px] text-muted-foreground">{rule.source}</p>
|
|
35
|
+
</div>
|
|
36
|
+
{rule.removable ? (
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
aria-label={`Remove rule: ${KIND_LABEL[rule.kind]} ${rule.pattern}`}
|
|
40
|
+
onClick={() => onRequestRemove(rule)}
|
|
41
|
+
className="flex-shrink-0 rounded-md p-1.5 text-muted-foreground hover:bg-hover hover:text-destructive"
|
|
42
|
+
>
|
|
43
|
+
<Trash2 size={15} strokeWidth={1.75} />
|
|
44
|
+
</button>
|
|
45
|
+
) : null}
|
|
46
|
+
</li>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Default permission mode, the rules `/permissions` shows, and the sandbox switch (#3282 §4a).
|
|
52
|
+
* "Skip all checks" as the mode and a preset that would set it both go through
|
|
53
|
+
* `onRequestModeChange`'s parent-owned confirmation before anything is sent.
|
|
54
|
+
*/
|
|
55
|
+
export function SettingsPermissionsSection({
|
|
56
|
+
snapshot,
|
|
57
|
+
onRequestModeChange,
|
|
58
|
+
onToggleSandbox,
|
|
59
|
+
onRequestRemoveRule,
|
|
60
|
+
}: {
|
|
61
|
+
snapshot: ISettingsSnapshot;
|
|
62
|
+
onRequestModeChange: (mode: string) => void;
|
|
63
|
+
onToggleSandbox: (enabled: boolean) => void;
|
|
64
|
+
onRequestRemoveRule: (rule: ISettingsPermissionRule) => void;
|
|
65
|
+
}): React.ReactElement {
|
|
66
|
+
const sandboxLabelId = useId();
|
|
67
|
+
const { sandbox } = snapshot;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<section aria-labelledby="settings-permissions-heading" className="flex flex-col gap-5">
|
|
71
|
+
<div>
|
|
72
|
+
<h3 id="settings-permissions-heading" className="sr-only">
|
|
73
|
+
Permissions
|
|
74
|
+
</h3>
|
|
75
|
+
<SettingsPopupField
|
|
76
|
+
label="Default permission mode"
|
|
77
|
+
value={snapshot.permissionMode.current}
|
|
78
|
+
options={snapshot.permissionMode.choices}
|
|
79
|
+
onChange={onRequestModeChange}
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<div>
|
|
84
|
+
<h4 className="mb-1.5 text-[13px] font-medium text-foreground">Rules</h4>
|
|
85
|
+
{snapshot.permissionRules.length === 0 ? (
|
|
86
|
+
<p className="text-[13px] leading-snug text-muted-foreground">
|
|
87
|
+
No permission rules are configured — the default mode above governs every action.
|
|
88
|
+
</p>
|
|
89
|
+
) : (
|
|
90
|
+
<ul className="space-y-0.5">
|
|
91
|
+
{snapshot.permissionRules.map((rule) => (
|
|
92
|
+
<PermissionRuleRow key={rule.id} rule={rule} onRequestRemove={onRequestRemoveRule} />
|
|
93
|
+
))}
|
|
94
|
+
</ul>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<div className="flex items-start justify-between gap-4 border-t border-border/60 pt-4">
|
|
99
|
+
<div className="min-w-0">
|
|
100
|
+
<span id={sandboxLabelId} className="text-[14px] font-medium text-foreground">
|
|
101
|
+
Sandbox
|
|
102
|
+
</span>
|
|
103
|
+
<p className="mt-0.5 text-[12.5px] leading-snug text-muted-foreground">
|
|
104
|
+
{sandbox.description}
|
|
105
|
+
</p>
|
|
106
|
+
{!sandbox.available && sandbox.unavailableReason ? (
|
|
107
|
+
<p className="mt-0.5 text-[12.5px] text-destructive">
|
|
108
|
+
Not available on this machine: {sandbox.unavailableReason}
|
|
109
|
+
</p>
|
|
110
|
+
) : null}
|
|
111
|
+
</div>
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
role="switch"
|
|
115
|
+
aria-checked={sandbox.enabled}
|
|
116
|
+
aria-labelledby={sandboxLabelId}
|
|
117
|
+
disabled={!sandbox.available}
|
|
118
|
+
onClick={() => onToggleSandbox(!sandbox.enabled)}
|
|
119
|
+
className={`relative h-6 w-11 flex-shrink-0 rounded-full transition-colors ${
|
|
120
|
+
sandbox.enabled ? 'bg-primary' : 'bg-raised'
|
|
121
|
+
} ${!sandbox.available ? 'cursor-not-allowed opacity-50' : ''}`}
|
|
122
|
+
>
|
|
123
|
+
<span
|
|
124
|
+
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${
|
|
125
|
+
sandbox.enabled ? 'translate-x-[22px]' : 'translate-x-0.5'
|
|
126
|
+
}`}
|
|
127
|
+
/>
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
</section>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Reused by the removal confirmation copy in `SettingsScreen`. */
|
|
135
|
+
export function describePermissionRuleRemoval(rule: ISettingsPermissionRule): string {
|
|
136
|
+
return `"${rule.pattern}" will no longer be ${KIND_VERB[rule.kind]} automatically.`;
|
|
137
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { RefreshCw, Trash2 } from 'lucide-react';
|
|
4
|
+
import React, { useId, useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import type { ISettingsPlugin, ISettingsSnapshot } from '@robota-sdk/agent-interface-session';
|
|
7
|
+
|
|
8
|
+
function PluginRow({
|
|
9
|
+
plugin,
|
|
10
|
+
canInstall,
|
|
11
|
+
onToggleEnabled,
|
|
12
|
+
onRequestUninstall,
|
|
13
|
+
}: {
|
|
14
|
+
plugin: ISettingsPlugin;
|
|
15
|
+
canInstall: boolean;
|
|
16
|
+
onToggleEnabled: (enabled: boolean) => void;
|
|
17
|
+
onRequestUninstall: () => void;
|
|
18
|
+
}): React.ReactElement {
|
|
19
|
+
const labelId = useId();
|
|
20
|
+
return (
|
|
21
|
+
<li className="flex items-start justify-between gap-3 border-b border-border/60 py-3 last:border-b-0">
|
|
22
|
+
<div className="min-w-0 flex-1">
|
|
23
|
+
<span id={labelId} className="truncate text-[14px] font-medium text-foreground">
|
|
24
|
+
{plugin.name}
|
|
25
|
+
</span>
|
|
26
|
+
{plugin.description ? (
|
|
27
|
+
<p className="mt-0.5 text-[12.5px] leading-snug text-muted-foreground">{plugin.description}</p>
|
|
28
|
+
) : null}
|
|
29
|
+
</div>
|
|
30
|
+
<div className="flex flex-shrink-0 items-center gap-2">
|
|
31
|
+
{canInstall ? (
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
aria-label={`Uninstall ${plugin.name}`}
|
|
35
|
+
onClick={onRequestUninstall}
|
|
36
|
+
className="rounded-md p-1.5 text-muted-foreground hover:bg-hover hover:text-destructive"
|
|
37
|
+
>
|
|
38
|
+
<Trash2 size={15} strokeWidth={1.75} />
|
|
39
|
+
</button>
|
|
40
|
+
) : null}
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
role="switch"
|
|
44
|
+
aria-checked={plugin.enabled}
|
|
45
|
+
aria-labelledby={labelId}
|
|
46
|
+
onClick={() => onToggleEnabled(!plugin.enabled)}
|
|
47
|
+
className={`relative h-6 w-11 flex-shrink-0 rounded-full transition-colors ${
|
|
48
|
+
plugin.enabled ? 'bg-primary' : 'bg-raised'
|
|
49
|
+
}`}
|
|
50
|
+
>
|
|
51
|
+
<span
|
|
52
|
+
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${
|
|
53
|
+
plugin.enabled ? 'translate-x-[22px]' : 'translate-x-0.5'
|
|
54
|
+
}`}
|
|
55
|
+
/>
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
</li>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The Plugins section (#3282 §4 part b-2): every installed plugin's name, description and enabled
|
|
64
|
+
* state, an enable/disable switch, a section-level Reload, and — local surfaces only — install and
|
|
65
|
+
* uninstall, since installing runs third-party code. `canInstall` (from the snapshot) mirrors the
|
|
66
|
+
* refusal `/plugin install|uninstall` itself gives a remote device or an observer.
|
|
67
|
+
*/
|
|
68
|
+
export function SettingsPluginsSection({
|
|
69
|
+
snapshot,
|
|
70
|
+
onTogglePlugin,
|
|
71
|
+
onReload,
|
|
72
|
+
reloading,
|
|
73
|
+
onRequestInstall,
|
|
74
|
+
onRequestUninstall,
|
|
75
|
+
}: {
|
|
76
|
+
snapshot: ISettingsSnapshot;
|
|
77
|
+
onTogglePlugin: (pluginId: string, enabled: boolean) => void;
|
|
78
|
+
onReload: () => void;
|
|
79
|
+
reloading: boolean;
|
|
80
|
+
onRequestInstall: (pluginId: string) => void;
|
|
81
|
+
onRequestUninstall: (plugin: ISettingsPlugin) => void;
|
|
82
|
+
}): React.ReactElement {
|
|
83
|
+
const [installDraft, setInstallDraft] = useState('');
|
|
84
|
+
const { plugins, canInstall } = snapshot.plugins;
|
|
85
|
+
const installId = useId();
|
|
86
|
+
|
|
87
|
+
function submitInstall(): void {
|
|
88
|
+
const trimmed = installDraft.trim();
|
|
89
|
+
if (trimmed.length === 0) return;
|
|
90
|
+
onRequestInstall(trimmed);
|
|
91
|
+
setInstallDraft('');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<section aria-labelledby="settings-plugins-heading" className="flex flex-col gap-3">
|
|
96
|
+
<div className="flex items-center justify-between gap-3">
|
|
97
|
+
<h3 id="settings-plugins-heading" className="text-[14px] font-medium text-foreground">
|
|
98
|
+
Plugins
|
|
99
|
+
</h3>
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
onClick={onReload}
|
|
103
|
+
disabled={reloading}
|
|
104
|
+
className="inline-flex items-center gap-1.5 rounded-lg bg-raised px-2.5 py-1.5 text-[13px] text-foreground hover:bg-hover disabled:opacity-60"
|
|
105
|
+
>
|
|
106
|
+
<RefreshCw size={13} strokeWidth={1.75} className={reloading ? 'animate-spin' : ''} />
|
|
107
|
+
Reload plugins
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
{plugins.length === 0 ? (
|
|
111
|
+
<p className="text-[13px] leading-snug text-muted-foreground">No plugins are installed.</p>
|
|
112
|
+
) : (
|
|
113
|
+
<ul>
|
|
114
|
+
{plugins.map((plugin) => (
|
|
115
|
+
<PluginRow
|
|
116
|
+
key={plugin.id}
|
|
117
|
+
plugin={plugin}
|
|
118
|
+
canInstall={canInstall}
|
|
119
|
+
onToggleEnabled={(enabled) => onTogglePlugin(plugin.id, enabled)}
|
|
120
|
+
onRequestUninstall={() => onRequestUninstall(plugin)}
|
|
121
|
+
/>
|
|
122
|
+
))}
|
|
123
|
+
</ul>
|
|
124
|
+
)}
|
|
125
|
+
<div className="border-t border-border/60 pt-3">
|
|
126
|
+
{canInstall ? (
|
|
127
|
+
<div className="flex flex-col gap-1.5">
|
|
128
|
+
<label htmlFor={installId} className="text-[13px] font-medium text-foreground">
|
|
129
|
+
Install a plugin
|
|
130
|
+
</label>
|
|
131
|
+
<div className="flex gap-2">
|
|
132
|
+
<input
|
|
133
|
+
id={installId}
|
|
134
|
+
type="text"
|
|
135
|
+
placeholder="name@marketplace"
|
|
136
|
+
value={installDraft}
|
|
137
|
+
onChange={(event) => setInstallDraft(event.target.value)}
|
|
138
|
+
onKeyDown={(event) => {
|
|
139
|
+
if (event.key === 'Enter') {
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
submitInstall();
|
|
142
|
+
}
|
|
143
|
+
}}
|
|
144
|
+
className="min-w-0 flex-1 rounded-lg border border-border bg-raised px-2.5 py-1.5 text-[13.5px] text-foreground"
|
|
145
|
+
/>
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
onClick={submitInstall}
|
|
149
|
+
disabled={installDraft.trim().length === 0}
|
|
150
|
+
className="flex-shrink-0 rounded-lg bg-raised px-3 py-1.5 text-[13px] text-foreground hover:bg-hover disabled:opacity-60"
|
|
151
|
+
>
|
|
152
|
+
Install
|
|
153
|
+
</button>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
) : (
|
|
157
|
+
<p className="text-[12.5px] leading-snug text-muted-foreground">
|
|
158
|
+
Installing and uninstalling plugins runs code on this computer, so it only works from the
|
|
159
|
+
desktop app or the page opened here — not from a remote device.
|
|
160
|
+
</p>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
</section>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Check } from 'lucide-react';
|
|
4
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import { PopupMenu } from './PopupMenu.js';
|
|
7
|
+
|
|
8
|
+
import type { ISettingsProviderProfile, ISettingsSnapshot } from '@robota-sdk/agent-interface-session';
|
|
9
|
+
import type { TModelListSnapshot } from '../hooks/session-client-types.js';
|
|
10
|
+
|
|
11
|
+
const BUTTON =
|
|
12
|
+
'rounded-lg px-2.5 py-1.5 text-[12.5px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50';
|
|
13
|
+
const PRIMARY_BUTTON = `${BUTTON} bg-primary text-primary-foreground hover:opacity-90`;
|
|
14
|
+
const SECONDARY_BUTTON = `${BUTTON} bg-raised text-foreground hover:bg-hover`;
|
|
15
|
+
const DESTRUCTIVE_BUTTON = `${BUTTON} text-destructive hover:bg-destructive/10`;
|
|
16
|
+
|
|
17
|
+
/** The "Model" action: a `PopupMenu` of this ONE profile's catalog models (#3282 §4b). */
|
|
18
|
+
function ProviderModelButton({
|
|
19
|
+
profile,
|
|
20
|
+
modelList,
|
|
21
|
+
onSelect,
|
|
22
|
+
}: {
|
|
23
|
+
profile: ISettingsProviderProfile;
|
|
24
|
+
modelList: TModelListSnapshot | null;
|
|
25
|
+
onSelect: (profileName: string, modelId: string) => void;
|
|
26
|
+
}): React.ReactElement {
|
|
27
|
+
const [open, setOpen] = useState(false);
|
|
28
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
29
|
+
const models = modelList?.groups.find((group) => group.profileName === profile.name)?.models ?? [];
|
|
30
|
+
return (
|
|
31
|
+
<span className="relative inline-block">
|
|
32
|
+
<button
|
|
33
|
+
ref={triggerRef}
|
|
34
|
+
type="button"
|
|
35
|
+
aria-haspopup="menu"
|
|
36
|
+
aria-expanded={open}
|
|
37
|
+
disabled={models.length === 0}
|
|
38
|
+
onClick={() => setOpen(true)}
|
|
39
|
+
className={SECONDARY_BUTTON}
|
|
40
|
+
>
|
|
41
|
+
Model
|
|
42
|
+
</button>
|
|
43
|
+
{open ? (
|
|
44
|
+
<PopupMenu
|
|
45
|
+
label={`Models for ${profile.name}`}
|
|
46
|
+
sections={[
|
|
47
|
+
{
|
|
48
|
+
items: models.map((model) => ({
|
|
49
|
+
key: model.id,
|
|
50
|
+
label: model.label,
|
|
51
|
+
checked: profile.model !== undefined && model.id === profile.model.id,
|
|
52
|
+
onSelect: () => onSelect(profile.name, model.id),
|
|
53
|
+
})),
|
|
54
|
+
},
|
|
55
|
+
]}
|
|
56
|
+
triggerRef={triggerRef}
|
|
57
|
+
onClose={() => setOpen(false)}
|
|
58
|
+
/>
|
|
59
|
+
) : null}
|
|
60
|
+
</span>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function ProviderProfileRow({
|
|
65
|
+
profile,
|
|
66
|
+
modelList,
|
|
67
|
+
onUse,
|
|
68
|
+
onModelChange,
|
|
69
|
+
onCommand,
|
|
70
|
+
onRequestDelete,
|
|
71
|
+
}: {
|
|
72
|
+
profile: ISettingsProviderProfile;
|
|
73
|
+
modelList: TModelListSnapshot | null;
|
|
74
|
+
onUse: (profileName: string) => void;
|
|
75
|
+
onModelChange: (profileName: string, modelId: string) => void;
|
|
76
|
+
onCommand: (args: string) => void;
|
|
77
|
+
onRequestDelete: (profileName: string) => void;
|
|
78
|
+
}): React.ReactElement {
|
|
79
|
+
return (
|
|
80
|
+
<li className="rounded-xl border border-border/60 p-3">
|
|
81
|
+
<div className="flex items-start gap-1.5">
|
|
82
|
+
{profile.current ? (
|
|
83
|
+
<Check size={14} strokeWidth={2.25} className="mt-0.5 flex-shrink-0 text-primary" aria-hidden="true" />
|
|
84
|
+
) : (
|
|
85
|
+
<span className="mt-0.5 w-3.5 flex-shrink-0" aria-hidden="true" />
|
|
86
|
+
)}
|
|
87
|
+
<div className="min-w-0">
|
|
88
|
+
<p className="truncate text-[14px] font-medium text-foreground">
|
|
89
|
+
{profile.name}
|
|
90
|
+
{profile.current ? <span className="sr-only"> (in use)</span> : null}
|
|
91
|
+
</p>
|
|
92
|
+
<p className="truncate text-[12.5px] text-muted-foreground">
|
|
93
|
+
{profile.providerLabel}
|
|
94
|
+
{profile.model ? ` · ${profile.model.label}` : ' · No model configured'}
|
|
95
|
+
</p>
|
|
96
|
+
{profile.connectionState ? (
|
|
97
|
+
<p className="text-[12.5px] text-destructive">{profile.connectionState}</p>
|
|
98
|
+
) : null}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
disabled={profile.current}
|
|
105
|
+
onClick={() => onUse(profile.name)}
|
|
106
|
+
className={PRIMARY_BUTTON}
|
|
107
|
+
>
|
|
108
|
+
Use
|
|
109
|
+
</button>
|
|
110
|
+
<ProviderModelButton profile={profile} modelList={modelList} onSelect={onModelChange} />
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
onClick={() => onCommand(`edit ${profile.name}`)}
|
|
114
|
+
className={SECONDARY_BUTTON}
|
|
115
|
+
>
|
|
116
|
+
Edit…
|
|
117
|
+
</button>
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
onClick={() => onCommand(`test ${profile.name}`)}
|
|
121
|
+
className={SECONDARY_BUTTON}
|
|
122
|
+
>
|
|
123
|
+
Test connection
|
|
124
|
+
</button>
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
onClick={() => onCommand(`duplicate ${profile.name}`)}
|
|
128
|
+
className={SECONDARY_BUTTON}
|
|
129
|
+
>
|
|
130
|
+
Duplicate…
|
|
131
|
+
</button>
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
onClick={() => onRequestDelete(profile.name)}
|
|
135
|
+
className={`${DESTRUCTIVE_BUTTON} ml-auto`}
|
|
136
|
+
>
|
|
137
|
+
Delete…
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
</li>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The configured provider profiles (#3282 §4b), each row a plain provider name, its model's label,
|
|
146
|
+
* a checkmark on the one in use, and a plain connection state when one is known. Every action goes
|
|
147
|
+
* through the same path the equivalent command does: Use/Model apply through `state.updateSettings`
|
|
148
|
+
* (the same `field` patterns Preset/Mode/Sandbox use); Edit/Test/Duplicate/Add dispatch the raw
|
|
149
|
+
* `/provider` command — driven by the SAME `ask_request` rendering the first-run setup panel and the
|
|
150
|
+
* model menu's former "Manage providers…" flow already use, so a masked API key field and a
|
|
151
|
+
* confirmation step need nothing new here.
|
|
152
|
+
*/
|
|
153
|
+
export function SettingsProvidersSection({
|
|
154
|
+
snapshot,
|
|
155
|
+
modelList,
|
|
156
|
+
onRequestModelList,
|
|
157
|
+
onUse,
|
|
158
|
+
onModelChange,
|
|
159
|
+
onCommand,
|
|
160
|
+
onRequestDelete,
|
|
161
|
+
}: {
|
|
162
|
+
snapshot: ISettingsSnapshot;
|
|
163
|
+
modelList: TModelListSnapshot | null;
|
|
164
|
+
onRequestModelList: () => void;
|
|
165
|
+
onUse: (profileName: string) => void;
|
|
166
|
+
onModelChange: (profileName: string, modelId: string) => void;
|
|
167
|
+
/** Dispatches `/provider <args>` (Edit/Test/Duplicate) or `/provider add` (Add) unmodified. */
|
|
168
|
+
onCommand: (args: string) => void;
|
|
169
|
+
onRequestDelete: (profileName: string) => void;
|
|
170
|
+
}): React.ReactElement {
|
|
171
|
+
// The "Model" button needs each profile's catalog as soon as this section is visible, not only
|
|
172
|
+
// once a person opens one — `list-models` is a cheap, observer-safe read (#3282 §2).
|
|
173
|
+
// Intentionally empty deps: fetch once per mount, not on every render.
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
onRequestModelList();
|
|
176
|
+
}, []);
|
|
177
|
+
|
|
178
|
+
const { profiles } = snapshot.providers;
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<section aria-labelledby="settings-providers-heading" className="flex flex-col gap-4">
|
|
182
|
+
<h3 id="settings-providers-heading" className="sr-only">
|
|
183
|
+
Providers & Models
|
|
184
|
+
</h3>
|
|
185
|
+
{profiles.length === 0 ? (
|
|
186
|
+
<p className="text-[13.5px] text-muted-foreground">No providers configured yet.</p>
|
|
187
|
+
) : (
|
|
188
|
+
<ul className="flex flex-col gap-3">
|
|
189
|
+
{profiles.map((profile) => (
|
|
190
|
+
<ProviderProfileRow
|
|
191
|
+
key={profile.name}
|
|
192
|
+
profile={profile}
|
|
193
|
+
modelList={modelList}
|
|
194
|
+
onUse={onUse}
|
|
195
|
+
onModelChange={onModelChange}
|
|
196
|
+
onCommand={onCommand}
|
|
197
|
+
onRequestDelete={onRequestDelete}
|
|
198
|
+
/>
|
|
199
|
+
))}
|
|
200
|
+
</ul>
|
|
201
|
+
)}
|
|
202
|
+
<button
|
|
203
|
+
type="button"
|
|
204
|
+
onClick={() => onCommand('add')}
|
|
205
|
+
className="self-start rounded-lg bg-raised px-3 py-1.5 text-[13px] font-medium text-foreground hover:bg-hover"
|
|
206
|
+
>
|
|
207
|
+
Add provider…
|
|
208
|
+
</button>
|
|
209
|
+
</section>
|
|
210
|
+
);
|
|
211
|
+
}
|