@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,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
+ }