@adecore/agents-react 0.31.7 → 0.31.9

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.
@@ -15,6 +15,9 @@ declare const _default: {
15
15
  list: {
16
16
  label: string;
17
17
  default: string;
18
+ ready: string;
19
+ needsSetup: string;
20
+ signedOut: string;
18
21
  addAccount: string;
19
22
  notInstalled: string;
20
23
  missing: string;
@@ -15,6 +15,9 @@ export default {
15
15
  list: {
16
16
  label: 'Agent CLIs and their accounts',
17
17
  default: 'Default',
18
+ ready: 'Ready',
19
+ needsSetup: 'Needs setup',
20
+ signedOut: 'Signed out',
18
21
  addAccount: 'Add a {{provider}} account',
19
22
  notInstalled: 'Not installed',
20
23
  missing: 'Missing',
@@ -15,6 +15,9 @@ declare const _default: {
15
15
  list: {
16
16
  label: string;
17
17
  default: string;
18
+ ready: string;
19
+ needsSetup: string;
20
+ signedOut: string;
18
21
  addAccount: string;
19
22
  notInstalled: string;
20
23
  missing: string;
@@ -15,6 +15,9 @@ export default {
15
15
  list: {
16
16
  label: "Agent-CLI's en hun accounts",
17
17
  default: 'Standaard',
18
+ ready: 'Klaar',
19
+ needsSetup: 'Nog instellen',
20
+ signedOut: 'Uitgelogd',
18
21
  addAccount: 'Een {{provider}}-account toevoegen',
19
22
  notInstalled: 'Niet geïnstalleerd',
20
23
  missing: 'Ontbreekt',
@@ -0,0 +1,11 @@
1
+ import type { AgentKind } from '@adecore/agent-contracts';
2
+ import type { Picked } from './pane-target.js';
3
+ import { type ProviderGroups } from './provider-groups.js';
4
+ interface ProviderListProps {
5
+ groups: ProviderGroups;
6
+ current: Picked | null;
7
+ canAddAccount(kind: AgentKind): boolean;
8
+ onPick(picked: Picked): void;
9
+ }
10
+ export declare function ProviderList({ groups, current, canAddAccount, onPick }: ProviderListProps): import("react").JSX.Element;
11
+ export {};
@@ -0,0 +1,39 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import clsx from 'clsx';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { Plus } from 'lucide-react';
5
+ import { AccountDot } from '../agents/AccountDot.js';
6
+ import { accountName } from '../agents/accounts.js';
7
+ import { IconButton, SectionLabel } from '@adecore/ui';
8
+ import { CliMark } from './parts.js';
9
+ import { accountNote } from './provider-groups.js';
10
+ const NOTE_TONES = {
11
+ muted: 'text-text-muted',
12
+ warning: 'text-status-needs-you'
13
+ };
14
+ function Note({ note }) {
15
+ return note === null ? null : _jsx("span", { className: clsx('shrink-0 truncate text-xs', NOTE_TONES[note.tone]), children: note.text });
16
+ }
17
+ /*
18
+ * A row of the list with a control of its own beside the part that picks it, so the two never nest. Every row
19
+ * keeps the control's room, so the words on the right line up whether a row has one or not.
20
+ */
21
+ function ListRow({ selected, onSelect, children, trailing, className }) {
22
+ return (_jsxs("div", { className: clsx('group flex min-w-0 shrink-0 items-center gap-1 rounded-lg pr-1', selected ? 'bg-text/5' : 'hover:bg-surface-hover'), children: [_jsx("button", { type: "button", "aria-current": selected ? 'true' : undefined, className: clsx('flex min-w-0 grow items-center gap-3 self-stretch rounded-lg pl-3 text-left focus-visible:-outline-offset-2', className), onClick: onSelect, children: children }), _jsx("span", { className: "flex w-7 shrink-0 justify-center", children: trailing })] }));
23
+ }
24
+ /* The providers by whether they are ready, need setup or are not installed; a group without providers is left out. */
25
+ export function ProviderList({ groups, current, canAddAccount, onPick }) {
26
+ const { t } = useTranslation('agent-providers');
27
+ const picksCli = (kind) => (current?.kind === 'cli' || current?.kind === 'install') && current.cli === kind;
28
+ const picksAccount = (id) => current?.kind === 'account' && current.id === id;
29
+ const row = ({ provider, entries, note }) => {
30
+ // One account has no row of its own, so picking it lights up its provider.
31
+ const several = entries.length > 1;
32
+ const selected = picksCli(provider.kind) || (!several && entries[0] !== undefined && picksAccount(entries[0].id));
33
+ return (_jsxs("div", { className: "flex min-w-0 shrink-0 flex-col gap-0.5", children: [_jsxs(ListRow, { selected: selected, onSelect: () => onPick({ kind: 'cli', cli: provider.kind }), className: "py-2", trailing: canAddAccount(provider.kind) && (_jsx(IconButton, { icon: Plus, size: "sm", label: t('list.addAccount', { provider: provider.name }), className: clsx('group-hover:opacity-100 focus-visible:opacity-100', selected ? 'opacity-100' : 'opacity-0'), onClick: () => onPick({ kind: 'add', cli: provider.kind }) })), children: [_jsx(CliMark, { kind: provider.kind }), _jsx("span", { className: "min-w-0 grow truncate text-sm font-medium text-text", children: provider.name }), !several && _jsx(Note, { note: note })] }), several &&
34
+ entries.map((entry) => (_jsxs(ListRow, { selected: picksAccount(entry.id), onSelect: () => onPick({ kind: 'account', id: entry.id }), className: "py-1.5", children: [_jsx("span", { className: "grid w-3.5 shrink-0 place-items-center", children: _jsx(AccountDot, { color: entry.account.color, className: "size-2.25" }) }), _jsx("span", { className: "min-w-0 grow truncate text-sm text-text", children: accountName(entry, provider.name) }), entry.isDefault && (_jsx("span", { className: "shrink-0 rounded-md bg-surface-active px-1.5 text-xs text-text-muted", children: t('list.default') })), _jsx(Note, { note: accountNote(entry.status) })] }, entry.id)))] }, provider.kind));
35
+ };
36
+ const section = (label, rows) => (_jsxs("section", { "aria-label": label, className: "flex min-w-0 shrink-0 flex-col gap-0.5 pb-2", children: [_jsx(SectionLabel, { className: "px-3 pt-1 pb-1", children: label }), rows] }, label));
37
+ return (_jsxs(_Fragment, { children: [groups.ready.length > 0 && section(t('list.ready'), groups.ready.map(row)), groups.setup.length > 0 && section(t('list.needsSetup'), groups.setup.map(row)), groups.ready.length === 0 && groups.setup.length === 0 && _jsx("p", { className: "px-3 py-2 text-xs text-text-muted", children: t('list.none') }), groups.missing.length > 0 &&
38
+ section(t('list.notInstalled'), _jsxs(_Fragment, { children: [groups.missing.map((provider) => (_jsxs(ListRow, { selected: picksCli(provider.kind), onSelect: () => onPick({ kind: 'install', cli: provider.kind }), className: "py-2", children: [_jsx(CliMark, { kind: provider.kind, className: "opacity-60" }), _jsx("span", { className: "min-w-0 grow truncate text-sm font-medium text-text-muted", children: provider.name })] }, provider.kind))), groups.missing.every((provider) => provider.install === undefined) && (_jsx("p", { className: "mt-1 px-3 text-xs text-text-faint", children: t('list.notInstalledNote') }))] }))] }));
39
+ }
@@ -1,11 +1,13 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import type { ProviderInfo } from '@adecore/agent-contracts';
3
3
  import type { ProviderInstallStep } from './install-steps.js';
4
+ import { type ProviderSetupStatus } from './provider-groups.js';
4
5
  interface ProvidersPaneProps {
5
6
  target?: string | null;
6
7
  detailOf?(provider: ProviderInfo): ReactNode | null;
7
8
  defaults?: boolean;
8
9
  installStepOf?(provider: ProviderInfo): ProviderInstallStep | null;
10
+ setupStatusOf?(provider: ProviderInfo): ProviderSetupStatus | null;
9
11
  }
10
- export declare function ProvidersPane({ target, detailOf, defaults, installStepOf }: ProvidersPaneProps): import("react").JSX.Element;
12
+ export declare function ProvidersPane({ target, detailOf, defaults, installStepOf, setupStatusOf }: ProvidersPaneProps): import("react").JSX.Element;
11
13
  export {};
@@ -1,11 +1,8 @@
1
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo, useState } from 'react';
3
- import clsx from 'clsx';
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
4
3
  import { useTranslation } from 'react-i18next';
5
- import { Plus } from 'lucide-react';
6
- import { AccountDot } from '../agents/AccountDot.js';
7
- import { ACCOUNT_TONE_CLASSES, accountLoginOf, accountName, accountsOfKind, accountStatusLine } from '../agents/accounts.js';
8
- import { ErrorBoundary, IconButton, SectionLabel, Skeleton } from '@adecore/ui';
4
+ import { accountLoginOf, accountsOfKind } from '../agents/accounts.js';
5
+ import { ErrorBoundary, Skeleton } from '@adecore/ui';
9
6
  import { MasterDetail } from '@adecore/ui/settings';
10
7
  import { useChatHost } from '../host.js';
11
8
  import { useChatScope } from '../scope.js';
@@ -14,15 +11,12 @@ import { AddAccountForm } from './AddAccountForm.js';
14
11
  import { CliDetail } from './CliDetail.js';
15
12
  import { InstallPage } from './InstallPage.js';
16
13
  import { pickOfTarget } from './pane-target.js';
17
- import { CliMark } from './parts.js';
14
+ import { groupProviders } from './provider-groups.js';
15
+ import { ProviderList } from './ProviderList.js';
18
16
  import { useProviderAccounts } from '../state/provider-accounts.js';
19
17
  import { useProviders } from '../state/providers.js';
20
- /* A row of the list with a control of its own beside the part that picks it, so the two never nest. */
21
- function ListRow({ selected, onSelect, children, trailing, className }) {
22
- return (_jsxs("div", { className: clsx('flex min-w-0 shrink-0 items-center gap-2 rounded-lg pr-2', selected ? 'bg-text/5' : 'hover:bg-surface-hover', className), children: [_jsx("button", { type: "button", "aria-current": selected ? 'true' : undefined, className: "flex min-w-0 grow items-center gap-3 self-stretch rounded-lg py-2 pl-3 text-left focus-visible:-outline-offset-2", onClick: onSelect, children: children }), trailing] }));
23
- }
24
18
  /* The agent CLIs of the host in scope, each with its accounts, beside the detail of what is picked. */
25
- export function ProvidersPane({ target = null, detailOf, defaults = true, installStepOf }) {
19
+ export function ProvidersPane({ target = null, detailOf, defaults = true, installStepOf, setupStatusOf }) {
26
20
  const { t } = useTranslation('agent-providers');
27
21
  const { id: scopeId } = useChatScope();
28
22
  const providers = useProviders((s) => s.providers);
@@ -32,9 +26,8 @@ export function ProvidersPane({ target = null, detailOf, defaults = true, instal
32
26
  const { openLogin } = host;
33
27
  const loginBlocked = host.useLoginBlocked(scopeId);
34
28
  const [picked, setPicked] = useState(() => pickOfTarget(target, providers));
35
- // A provider that never runs in a terminal is no CLI to install: it is turned on in its own detail, so it is always listed.
36
- const installed = useMemo(() => providers.filter((provider) => provider.installed || !provider.capabilities.terminal), [providers]);
37
- const missing = useMemo(() => providers.filter((provider) => !provider.installed && provider.capabilities.terminal), [providers]);
29
+ const groups = groupProviders(providers, accounts, setupStatusOf);
30
+ const installed = [...groups.ready, ...groups.setup].map((row) => row.provider);
38
31
  const entriesOf = (kind) => accountsOfKind(accounts, kind);
39
32
  const loginOf = (kind) => accountLoginOf(accounts, kind);
40
33
  // What was picked may be gone since (an account removed elsewhere); the first CLI stands in for it.
@@ -43,7 +36,6 @@ export function ProvidersPane({ target = null, detailOf, defaults = true, instal
43
36
  const currentAccount = current?.kind === 'account' ? accounts?.accounts[current.id] : undefined;
44
37
  const currentCli = current === null ? null : current.kind === 'account' ? currentAccount?.kind : current.cli;
45
38
  const provider = (current?.kind === 'install' ? providers : installed).find((entry) => entry.kind === currentCli) ?? null;
46
- const picksCli = (kind) => (current?.kind === 'cli' || current?.kind === 'install') && current.cli === kind;
47
39
  // A search result lands on a row of a CLI's detail, so a picked account makes way for its CLI.
48
40
  const [seenTarget, setSeenTarget] = useState(target);
49
41
  if (target !== seenTarget) {
@@ -62,15 +54,7 @@ export function ProvidersPane({ target = null, detailOf, defaults = true, instal
62
54
  void openLogin(scopeId, provider.kind, id, name);
63
55
  }
64
56
  };
65
- const group = (cli) => {
66
- const entries = cli.installed ? entriesOf(cli.kind) : [];
67
- return (_jsxs("div", { className: "flex min-w-0 shrink-0 flex-col gap-0.5 pt-1", children: [_jsxs(ListRow, { selected: picksCli(cli.kind), onSelect: () => setPicked({ kind: 'cli', cli: cli.kind }), className: "pr-1", trailing: loginOf(cli.kind) !== null && (_jsx(IconButton, { icon: Plus, size: "sm", label: t('list.addAccount', { provider: cli.name }), onClick: () => setPicked({ kind: 'add', cli: cli.kind }) })), children: [_jsx(CliMark, { kind: cli.kind }), _jsx("span", { className: "min-w-0 grow truncate text-xs font-medium text-text", children: cli.name }), cli.capabilities.terminal && cli.version && _jsx("span", { className: "shrink-0 font-mono text-code text-text-faint", children: cli.version })] }), entries.map((entry) => {
68
- const name = accountName(entry, cli.name);
69
- const status = accountStatusLine(entry.status, loginOf(cli.kind) === 'terminal');
70
- return (_jsxs(ListRow, { selected: current?.kind === 'account' && current.id === entry.id, onSelect: () => setPicked({ kind: 'account', id: entry.id }), children: [_jsx("span", { className: "grid w-3.5 shrink-0 place-items-center", children: _jsx(AccountDot, { color: entry.account.color, className: "size-2.25" }) }), _jsxs("span", { className: "flex min-w-0 grow flex-col", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-2 text-sm text-text", children: [_jsx("span", { className: "truncate", children: name }), entry.isDefault && (_jsx("span", { className: "shrink-0 rounded-md bg-surface-active px-1.5 text-xs text-text-muted", children: t('list.default') }))] }), _jsx("span", { className: clsx('truncate text-xs', ACCOUNT_TONE_CLASSES[status.tone]), children: status.text })] })] }, entry.id));
71
- })] }, cli.kind));
72
- };
73
- const list = !loaded ? (_jsxs("div", { className: "flex flex-col gap-3 p-3", children: [_jsx(Skeleton, { className: "w-32" }), _jsx(Skeleton, { className: "w-40" })] })) : (_jsxs(_Fragment, { children: [installed.map(group), installed.length === 0 && _jsx("p", { className: "px-3 py-2 text-xs text-text-muted", children: t('list.none') }), missing.length > 0 && (_jsxs("div", { className: "mt-1.5 flex min-w-0 shrink-0 flex-col border-t border-border pt-2.5 pb-2", children: [_jsx(SectionLabel, { className: "px-3 pt-1 pb-1.5", children: t('list.notInstalled') }), missing.map((cli) => (_jsxs(ListRow, { selected: picksCli(cli.kind), onSelect: () => setPicked({ kind: 'install', cli: cli.kind }), children: [_jsx(CliMark, { kind: cli.kind, className: "opacity-60" }), _jsx("span", { className: "min-w-0 grow truncate text-xs font-medium text-text-muted", children: cli.name }), _jsx("span", { className: "shrink-0 text-xs text-text-faint", children: t('list.missing') })] }, cli.kind))), missing.every((cli) => cli.install === undefined) && _jsx("p", { className: "mt-1 px-3 text-xs text-text-faint", children: t('list.notInstalledNote') })] }))] }));
57
+ const list = !loaded ? (_jsxs("div", { className: "flex flex-col gap-3 p-3", children: [_jsx(Skeleton, { className: "w-32" }), _jsx(Skeleton, { className: "w-40" })] })) : (_jsx(ProviderList, { groups: groups, current: current, canAddAccount: (kind) => loginOf(kind) !== null, onPick: setPicked }));
74
58
  const detail = () => {
75
59
  if (current === null || provider === null) {
76
60
  return null;
@@ -8,4 +8,5 @@ export { carryOptions } from '../agents/model-options.js';
8
8
  export { type ModelRequirement } from '../agents/model-requirement.js';
9
9
  export { ProvidersPane } from './ProvidersPane.js';
10
10
  export type { ProviderInstallStep } from './install-steps.js';
11
+ export type { ProviderSetupStatus } from './provider-groups.js';
11
12
  export { CliMark, CliTile } from './parts.js';
@@ -0,0 +1,18 @@
1
+ import type { ProviderAccounts, ProviderAccountStatus, ProviderInfo } from '@adecore/agent-contracts';
2
+ import { type AccountEntry } from '../agents/accounts.js';
3
+ export interface ProviderSetupStatus {
4
+ text: string;
5
+ tone: 'muted' | 'warning';
6
+ }
7
+ export interface ProviderRow {
8
+ provider: ProviderInfo;
9
+ entries: AccountEntry[];
10
+ note: ProviderSetupStatus | null;
11
+ }
12
+ export interface ProviderGroups {
13
+ ready: ProviderRow[];
14
+ setup: ProviderRow[];
15
+ missing: ProviderInfo[];
16
+ }
17
+ export declare function accountNote(status: ProviderAccountStatus | null): ProviderSetupStatus | null;
18
+ export declare function groupProviders(providers: readonly ProviderInfo[], accounts: ProviderAccounts | null, setupStatusOf?: (provider: ProviderInfo) => ProviderSetupStatus | null): ProviderGroups;
@@ -0,0 +1,52 @@
1
+ import i18next from 'i18next';
2
+ import { accountsOfKind } from '../agents/accounts.js';
3
+ /* What an account says beside its name: its plan while it is ready, a short word when it is not, nothing while it is checked. */
4
+ export function accountNote(status) {
5
+ const words = (key) => i18next.t(`agent-providers:${key}`);
6
+ switch (status?.state ?? 'checking') {
7
+ case 'checking':
8
+ return null;
9
+ case 'ready':
10
+ return status?.plan ? { text: status.plan, tone: 'muted' } : null;
11
+ case 'signed-out':
12
+ return { text: words('list.signedOut'), tone: 'warning' };
13
+ case 'failed':
14
+ return { text: words('status.failed'), tone: 'warning' };
15
+ case 'folder-missing':
16
+ return { text: words('status.folderMissing'), tone: 'warning' };
17
+ case 'disabled':
18
+ return { text: words('status.disabled'), tone: 'muted' };
19
+ case 'not-found':
20
+ return { text: words('status.notFound'), tone: 'muted' };
21
+ case 'unavailable':
22
+ return { text: words('status.unavailable'), tone: 'muted' };
23
+ }
24
+ }
25
+ // An account still being checked counts as usable, so a provider does not hop between groups while the checks run.
26
+ const usable = (entry) => entry.status === null || entry.status.state === 'ready' || entry.status.state === 'checking';
27
+ /*
28
+ * The providers in the three groups of the list. A provider the host says is not set up needs setup whatever
29
+ * its accounts say; otherwise one usable account, or none to read, makes it ready.
30
+ */
31
+ export function groupProviders(providers, accounts, setupStatusOf) {
32
+ const groups = { ready: [], setup: [], missing: [] };
33
+ for (const provider of providers) {
34
+ // A provider that never runs in a terminal is no CLI to install: it is turned on in its own detail.
35
+ if (!provider.installed && provider.capabilities.terminal) {
36
+ groups.missing.push(provider);
37
+ continue;
38
+ }
39
+ const entries = provider.installed ? accountsOfKind(accounts, provider.kind) : [];
40
+ const hostStatus = setupStatusOf?.(provider) ?? null;
41
+ if (hostStatus !== null) {
42
+ groups.setup.push({ provider, entries, note: hostStatus });
43
+ }
44
+ else if (entries.length === 0 || entries.some(usable)) {
45
+ groups.ready.push({ provider, entries, note: entries.length === 1 ? accountNote(entries[0].status) : null });
46
+ }
47
+ else {
48
+ groups.setup.push({ provider, entries, note: accountNote(entries[0].status) });
49
+ }
50
+ }
51
+ return groups;
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents-react",
3
- "version": "0.31.7",
3
+ "version": "0.31.9",
4
4
  "description": "React agent chat threads, composers, approvals, provider accounts and usage views.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -67,11 +67,11 @@
67
67
  "test:visuals": "bun --config=integration.bunfig.toml --conditions=source test ./src/chat/visual-viewport.integration.test.ts"
68
68
  },
69
69
  "dependencies": {
70
- "@adecore/agent-contracts": "0.31.7",
71
- "@adecore/editor": "0.31.7",
72
- "@adecore/editor-react": "0.31.7",
73
- "@adecore/intelligent-ui": "0.31.7",
74
- "@adecore/merge": "0.31.7",
70
+ "@adecore/agent-contracts": "0.31.9",
71
+ "@adecore/editor": "0.31.9",
72
+ "@adecore/editor-react": "0.31.9",
73
+ "@adecore/intelligent-ui": "0.31.9",
74
+ "@adecore/merge": "0.31.9",
75
75
  "@codemirror/commands": "^6.11.1",
76
76
  "@codemirror/language": "^6.12.4",
77
77
  "@codemirror/state": "^6.7.6",
@@ -94,14 +94,14 @@
94
94
  "zustand": "^5.0.15"
95
95
  },
96
96
  "peerDependencies": {
97
- "@adecore/ui": "^0.31.7",
97
+ "@adecore/ui": "^0.31.9",
98
98
  "i18next": "^26.4.2",
99
99
  "react": "^19.3.0",
100
100
  "react-dom": "^19.3.0",
101
101
  "react-i18next": "^17.0.15"
102
102
  },
103
103
  "devDependencies": {
104
- "@adecore/ui": "0.31.7",
104
+ "@adecore/ui": "0.31.9",
105
105
  "@types/bun": "^1.4.2",
106
106
  "@types/react": "^19.3.0",
107
107
  "@types/react-dom": "^19.3.0",
@@ -15,6 +15,9 @@ export default {
15
15
  list: {
16
16
  label: 'Agent CLIs and their accounts',
17
17
  default: 'Default',
18
+ ready: 'Ready',
19
+ needsSetup: 'Needs setup',
20
+ signedOut: 'Signed out',
18
21
  addAccount: 'Add a {{provider}} account',
19
22
  notInstalled: 'Not installed',
20
23
  missing: 'Missing',
@@ -15,6 +15,9 @@ export default {
15
15
  list: {
16
16
  label: "Agent-CLI's en hun accounts",
17
17
  default: 'Standaard',
18
+ ready: 'Klaar',
19
+ needsSetup: 'Nog instellen',
20
+ signedOut: 'Uitgelogd',
18
21
  addAccount: 'Een {{provider}}-account toevoegen',
19
22
  notInstalled: 'Niet geïnstalleerd',
20
23
  missing: 'Ontbreekt',
@@ -0,0 +1,144 @@
1
+ import type { ReactNode } from 'react';
2
+ import clsx from 'clsx';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { Plus } from 'lucide-react';
5
+ import type { AgentKind } from '@adecore/agent-contracts';
6
+ import { AccountDot } from '../agents/AccountDot';
7
+ import { accountName } from '../agents/accounts';
8
+ import { IconButton, SectionLabel } from '@adecore/ui';
9
+ import type { Picked } from './pane-target';
10
+ import { CliMark } from './parts';
11
+ import { accountNote, type ProviderGroups, type ProviderRow, type ProviderSetupStatus } from './provider-groups';
12
+
13
+ const NOTE_TONES: Record<ProviderSetupStatus['tone'], string> = {
14
+ muted: 'text-text-muted',
15
+ warning: 'text-status-needs-you'
16
+ };
17
+
18
+ function Note({ note }: { note: ProviderSetupStatus | null }) {
19
+ return note === null ? null : <span className={clsx('shrink-0 truncate text-xs', NOTE_TONES[note.tone])}>{note.text}</span>;
20
+ }
21
+
22
+ /*
23
+ * A row of the list with a control of its own beside the part that picks it, so the two never nest. Every row
24
+ * keeps the control's room, so the words on the right line up whether a row has one or not.
25
+ */
26
+ function ListRow({
27
+ selected,
28
+ onSelect,
29
+ children,
30
+ trailing,
31
+ className
32
+ }: {
33
+ selected: boolean;
34
+ onSelect(): void;
35
+ children: ReactNode;
36
+ trailing?: ReactNode;
37
+ className?: string;
38
+ }) {
39
+ return (
40
+ <div className={clsx('group flex min-w-0 shrink-0 items-center gap-1 rounded-lg pr-1', selected ? 'bg-text/5' : 'hover:bg-surface-hover')}>
41
+ <button
42
+ type="button"
43
+ aria-current={selected ? 'true' : undefined}
44
+ className={clsx('flex min-w-0 grow items-center gap-3 self-stretch rounded-lg pl-3 text-left focus-visible:-outline-offset-2', className)}
45
+ onClick={onSelect}
46
+ >
47
+ {children}
48
+ </button>
49
+ <span className="flex w-7 shrink-0 justify-center">{trailing}</span>
50
+ </div>
51
+ );
52
+ }
53
+
54
+ interface ProviderListProps {
55
+ groups: ProviderGroups;
56
+ current: Picked | null;
57
+ canAddAccount(kind: AgentKind): boolean;
58
+ onPick(picked: Picked): void;
59
+ }
60
+
61
+ /* The providers by whether they are ready, need setup or are not installed; a group without providers is left out. */
62
+ export function ProviderList({ groups, current, canAddAccount, onPick }: ProviderListProps) {
63
+ const { t } = useTranslation('agent-providers');
64
+ const picksCli = (kind: AgentKind): boolean => (current?.kind === 'cli' || current?.kind === 'install') && current.cli === kind;
65
+ const picksAccount = (id: string): boolean => current?.kind === 'account' && current.id === id;
66
+
67
+ const row = ({ provider, entries, note }: ProviderRow) => {
68
+ // One account has no row of its own, so picking it lights up its provider.
69
+ const several = entries.length > 1;
70
+ const selected = picksCli(provider.kind) || (!several && entries[0] !== undefined && picksAccount(entries[0].id));
71
+ return (
72
+ <div key={provider.kind} className="flex min-w-0 shrink-0 flex-col gap-0.5">
73
+ <ListRow
74
+ selected={selected}
75
+ onSelect={() => onPick({ kind: 'cli', cli: provider.kind })}
76
+ className="py-2"
77
+ trailing={
78
+ canAddAccount(provider.kind) && (
79
+ <IconButton
80
+ icon={Plus}
81
+ size="sm"
82
+ label={t('list.addAccount', { provider: provider.name })}
83
+ className={clsx('group-hover:opacity-100 focus-visible:opacity-100', selected ? 'opacity-100' : 'opacity-0')}
84
+ onClick={() => onPick({ kind: 'add', cli: provider.kind })}
85
+ />
86
+ )
87
+ }
88
+ >
89
+ <CliMark kind={provider.kind} />
90
+ <span className="min-w-0 grow truncate text-sm font-medium text-text">{provider.name}</span>
91
+ {!several && <Note note={note} />}
92
+ </ListRow>
93
+ {several &&
94
+ entries.map((entry) => (
95
+ <ListRow key={entry.id} selected={picksAccount(entry.id)} onSelect={() => onPick({ kind: 'account', id: entry.id })} className="py-1.5">
96
+ <span className="grid w-3.5 shrink-0 place-items-center">
97
+ <AccountDot color={entry.account.color} className="size-2.25" />
98
+ </span>
99
+ <span className="min-w-0 grow truncate text-sm text-text">{accountName(entry, provider.name)}</span>
100
+ {entry.isDefault && (
101
+ <span className="shrink-0 rounded-md bg-surface-active px-1.5 text-xs text-text-muted">{t('list.default')}</span>
102
+ )}
103
+ <Note note={accountNote(entry.status)} />
104
+ </ListRow>
105
+ ))}
106
+ </div>
107
+ );
108
+ };
109
+
110
+ const section = (label: string, rows: ReactNode) => (
111
+ <section key={label} aria-label={label} className="flex min-w-0 shrink-0 flex-col gap-0.5 pb-2">
112
+ <SectionLabel className="px-3 pt-1 pb-1">{label}</SectionLabel>
113
+ {rows}
114
+ </section>
115
+ );
116
+
117
+ return (
118
+ <>
119
+ {groups.ready.length > 0 && section(t('list.ready'), groups.ready.map(row))}
120
+ {groups.setup.length > 0 && section(t('list.needsSetup'), groups.setup.map(row))}
121
+ {groups.ready.length === 0 && groups.setup.length === 0 && <p className="px-3 py-2 text-xs text-text-muted">{t('list.none')}</p>}
122
+ {groups.missing.length > 0 &&
123
+ section(
124
+ t('list.notInstalled'),
125
+ <>
126
+ {groups.missing.map((provider) => (
127
+ <ListRow
128
+ key={provider.kind}
129
+ selected={picksCli(provider.kind)}
130
+ onSelect={() => onPick({ kind: 'install', cli: provider.kind })}
131
+ className="py-2"
132
+ >
133
+ <CliMark kind={provider.kind} className="opacity-60" />
134
+ <span className="min-w-0 grow truncate text-sm font-medium text-text-muted">{provider.name}</span>
135
+ </ListRow>
136
+ ))}
137
+ {groups.missing.every((provider) => provider.install === undefined) && (
138
+ <p className="mt-1 px-3 text-xs text-text-faint">{t('list.notInstalledNote')}</p>
139
+ )}
140
+ </>
141
+ )}
142
+ </>
143
+ );
144
+ }
@@ -1,11 +1,8 @@
1
- import { useMemo, useState, type ReactNode } from 'react';
2
- import clsx from 'clsx';
1
+ import { useState, type ReactNode } from 'react';
3
2
  import { useTranslation } from 'react-i18next';
4
- import { Plus } from 'lucide-react';
5
3
  import type { AgentKind, ProviderInfo } from '@adecore/agent-contracts';
6
- import { AccountDot } from '../agents/AccountDot';
7
- import { ACCOUNT_TONE_CLASSES, accountLoginOf, accountName, accountsOfKind, accountStatusLine, type AccountEntry } from '../agents/accounts';
8
- import { ErrorBoundary, IconButton, SectionLabel, Skeleton } from '@adecore/ui';
4
+ import { accountLoginOf, accountsOfKind, type AccountEntry } from '../agents/accounts';
5
+ import { ErrorBoundary, Skeleton } from '@adecore/ui';
9
6
  import { MasterDetail } from '@adecore/ui/settings';
10
7
  import { useChatHost } from '../host';
11
8
  import { useChatScope } from '../scope';
@@ -15,39 +12,11 @@ import { CliDetail } from './CliDetail';
15
12
  import { InstallPage } from './InstallPage';
16
13
  import type { ProviderInstallStep } from './install-steps';
17
14
  import { pickOfTarget, type Picked } from './pane-target';
18
- import { CliMark } from './parts';
15
+ import { groupProviders, type ProviderSetupStatus } from './provider-groups';
16
+ import { ProviderList } from './ProviderList';
19
17
  import { useProviderAccounts } from '../state/provider-accounts';
20
18
  import { useProviders } from '../state/providers';
21
19
 
22
- /* A row of the list with a control of its own beside the part that picks it, so the two never nest. */
23
- function ListRow({
24
- selected,
25
- onSelect,
26
- children,
27
- trailing,
28
- className
29
- }: {
30
- selected: boolean;
31
- onSelect(): void;
32
- children: ReactNode;
33
- trailing?: ReactNode;
34
- className?: string;
35
- }) {
36
- return (
37
- <div className={clsx('flex min-w-0 shrink-0 items-center gap-2 rounded-lg pr-2', selected ? 'bg-text/5' : 'hover:bg-surface-hover', className)}>
38
- <button
39
- type="button"
40
- aria-current={selected ? 'true' : undefined}
41
- className="flex min-w-0 grow items-center gap-3 self-stretch rounded-lg py-2 pl-3 text-left focus-visible:-outline-offset-2"
42
- onClick={onSelect}
43
- >
44
- {children}
45
- </button>
46
- {trailing}
47
- </div>
48
- );
49
- }
50
-
51
20
  interface ProvidersPaneProps {
52
21
  /* The row a search result leads to, by its `searchId`; the pane opens on the CLI it belongs to. `providers.install.<kind>` opens on that CLI's install page, or on its detail once it is installed. */
53
22
  target?: string | null;
@@ -57,10 +26,12 @@ interface ProvidersPaneProps {
57
26
  defaults?: boolean;
58
27
  /* A step of the app's own on the install page of a CLI, after its install, such as a server its chat needs; null adds none. */
59
28
  installStepOf?(provider: ProviderInfo): ProviderInstallStep | null;
29
+ /* Why a provider is not usable yet when the app knows better than its accounts, such as one turned off in its own detail; null leaves it to the accounts. */
30
+ setupStatusOf?(provider: ProviderInfo): ProviderSetupStatus | null;
60
31
  }
61
32
 
62
33
  /* The agent CLIs of the host in scope, each with its accounts, beside the detail of what is picked. */
63
- export function ProvidersPane({ target = null, detailOf, defaults = true, installStepOf }: ProvidersPaneProps) {
34
+ export function ProvidersPane({ target = null, detailOf, defaults = true, installStepOf, setupStatusOf }: ProvidersPaneProps) {
64
35
  const { t } = useTranslation('agent-providers');
65
36
  const { id: scopeId } = useChatScope();
66
37
  const providers = useProviders((s) => s.providers);
@@ -71,9 +42,8 @@ export function ProvidersPane({ target = null, detailOf, defaults = true, instal
71
42
  const loginBlocked = host.useLoginBlocked(scopeId);
72
43
  const [picked, setPicked] = useState<Picked | null>(() => pickOfTarget(target, providers));
73
44
 
74
- // A provider that never runs in a terminal is no CLI to install: it is turned on in its own detail, so it is always listed.
75
- const installed = useMemo(() => providers.filter((provider) => provider.installed || !provider.capabilities.terminal), [providers]);
76
- const missing = useMemo(() => providers.filter((provider) => !provider.installed && provider.capabilities.terminal), [providers]);
45
+ const groups = groupProviders(providers, accounts, setupStatusOf);
46
+ const installed = [...groups.ready, ...groups.setup].map((row) => row.provider);
77
47
  const entriesOf = (kind: AgentKind): AccountEntry[] => accountsOfKind(accounts, kind);
78
48
  const loginOf = (kind: AgentKind) => accountLoginOf(accounts, kind);
79
49
 
@@ -84,7 +54,6 @@ export function ProvidersPane({ target = null, detailOf, defaults = true, instal
84
54
  const currentAccount = current?.kind === 'account' ? accounts?.accounts[current.id] : undefined;
85
55
  const currentCli = current === null ? null : current.kind === 'account' ? (currentAccount?.kind as AgentKind | undefined) : current.cli;
86
56
  const provider = (current?.kind === 'install' ? providers : installed).find((entry) => entry.kind === currentCli) ?? null;
87
- const picksCli = (kind: AgentKind): boolean => (current?.kind === 'cli' || current?.kind === 'install') && current.cli === kind;
88
57
 
89
58
  // A search result lands on a row of a CLI's detail, so a picked account makes way for its CLI.
90
59
  const [seenTarget, setSeenTarget] = useState(target);
@@ -105,80 +74,13 @@ export function ProvidersPane({ target = null, detailOf, defaults = true, instal
105
74
  }
106
75
  };
107
76
 
108
- const group = (cli: ProviderInfo) => {
109
- const entries = cli.installed ? entriesOf(cli.kind) : [];
110
- return (
111
- <div key={cli.kind} className="flex min-w-0 shrink-0 flex-col gap-0.5 pt-1">
112
- <ListRow
113
- selected={picksCli(cli.kind)}
114
- onSelect={() => setPicked({ kind: 'cli', cli: cli.kind })}
115
- className="pr-1"
116
- trailing={
117
- loginOf(cli.kind) !== null && (
118
- <IconButton
119
- icon={Plus}
120
- size="sm"
121
- label={t('list.addAccount', { provider: cli.name })}
122
- onClick={() => setPicked({ kind: 'add', cli: cli.kind })}
123
- />
124
- )
125
- }
126
- >
127
- <CliMark kind={cli.kind} />
128
- <span className="min-w-0 grow truncate text-xs font-medium text-text">{cli.name}</span>
129
- {cli.capabilities.terminal && cli.version && <span className="shrink-0 font-mono text-code text-text-faint">{cli.version}</span>}
130
- </ListRow>
131
- {entries.map((entry) => {
132
- const name = accountName(entry, cli.name);
133
- const status = accountStatusLine(entry.status, loginOf(cli.kind) === 'terminal');
134
- return (
135
- <ListRow
136
- key={entry.id}
137
- selected={current?.kind === 'account' && current.id === entry.id}
138
- onSelect={() => setPicked({ kind: 'account', id: entry.id })}
139
- >
140
- <span className="grid w-3.5 shrink-0 place-items-center">
141
- <AccountDot color={entry.account.color} className="size-2.25" />
142
- </span>
143
- <span className="flex min-w-0 grow flex-col">
144
- <span className="flex min-w-0 items-center gap-2 text-sm text-text">
145
- <span className="truncate">{name}</span>
146
- {entry.isDefault && (
147
- <span className="shrink-0 rounded-md bg-surface-active px-1.5 text-xs text-text-muted">{t('list.default')}</span>
148
- )}
149
- </span>
150
- <span className={clsx('truncate text-xs', ACCOUNT_TONE_CLASSES[status.tone])}>{status.text}</span>
151
- </span>
152
- </ListRow>
153
- );
154
- })}
155
- </div>
156
- );
157
- };
158
-
159
77
  const list = !loaded ? (
160
78
  <div className="flex flex-col gap-3 p-3">
161
79
  <Skeleton className="w-32" />
162
80
  <Skeleton className="w-40" />
163
81
  </div>
164
82
  ) : (
165
- <>
166
- {installed.map(group)}
167
- {installed.length === 0 && <p className="px-3 py-2 text-xs text-text-muted">{t('list.none')}</p>}
168
- {missing.length > 0 && (
169
- <div className="mt-1.5 flex min-w-0 shrink-0 flex-col border-t border-border pt-2.5 pb-2">
170
- <SectionLabel className="px-3 pt-1 pb-1.5">{t('list.notInstalled')}</SectionLabel>
171
- {missing.map((cli) => (
172
- <ListRow key={cli.kind} selected={picksCli(cli.kind)} onSelect={() => setPicked({ kind: 'install', cli: cli.kind })}>
173
- <CliMark kind={cli.kind} className="opacity-60" />
174
- <span className="min-w-0 grow truncate text-xs font-medium text-text-muted">{cli.name}</span>
175
- <span className="shrink-0 text-xs text-text-faint">{t('list.missing')}</span>
176
- </ListRow>
177
- ))}
178
- {missing.every((cli) => cli.install === undefined) && <p className="mt-1 px-3 text-xs text-text-faint">{t('list.notInstalledNote')}</p>}
179
- </div>
180
- )}
181
- </>
83
+ <ProviderList groups={groups} current={current} canAddAccount={(kind) => loginOf(kind) !== null} onPick={setPicked} />
182
84
  );
183
85
 
184
86
  const detail = (): ReactNode => {
@@ -9,4 +9,5 @@ export { carryOptions } from '../agents/model-options';
9
9
  export { type ModelRequirement } from '../agents/model-requirement';
10
10
  export { ProvidersPane } from './ProvidersPane';
11
11
  export type { ProviderInstallStep } from './install-steps';
12
+ export type { ProviderSetupStatus } from './provider-groups';
12
13
  export { CliMark, CliTile } from './parts';
@@ -0,0 +1,77 @@
1
+ import i18next from 'i18next';
2
+ import type { ProviderAccounts, ProviderAccountStatus, ProviderInfo } from '@adecore/agent-contracts';
3
+ import { accountsOfKind, type AccountEntry } from '../agents/accounts';
4
+
5
+ /* A few words beside a provider in the list; `warning` asks the person to act. */
6
+ export interface ProviderSetupStatus {
7
+ text: string;
8
+ tone: 'muted' | 'warning';
9
+ }
10
+
11
+ export interface ProviderRow {
12
+ provider: ProviderInfo;
13
+ entries: AccountEntry[];
14
+ /* What the row says on its right: the plan of its one account, or what is still missing. */
15
+ note: ProviderSetupStatus | null;
16
+ }
17
+
18
+ export interface ProviderGroups {
19
+ ready: ProviderRow[];
20
+ setup: ProviderRow[];
21
+ missing: ProviderInfo[];
22
+ }
23
+
24
+ /* What an account says beside its name: its plan while it is ready, a short word when it is not, nothing while it is checked. */
25
+ export function accountNote(status: ProviderAccountStatus | null): ProviderSetupStatus | null {
26
+ const words = (key: string): string => i18next.t(`agent-providers:${key}`);
27
+ switch (status?.state ?? 'checking') {
28
+ case 'checking':
29
+ return null;
30
+ case 'ready':
31
+ return status?.plan ? { text: status.plan, tone: 'muted' } : null;
32
+ case 'signed-out':
33
+ return { text: words('list.signedOut'), tone: 'warning' };
34
+ case 'failed':
35
+ return { text: words('status.failed'), tone: 'warning' };
36
+ case 'folder-missing':
37
+ return { text: words('status.folderMissing'), tone: 'warning' };
38
+ case 'disabled':
39
+ return { text: words('status.disabled'), tone: 'muted' };
40
+ case 'not-found':
41
+ return { text: words('status.notFound'), tone: 'muted' };
42
+ case 'unavailable':
43
+ return { text: words('status.unavailable'), tone: 'muted' };
44
+ }
45
+ }
46
+
47
+ // An account still being checked counts as usable, so a provider does not hop between groups while the checks run.
48
+ const usable = (entry: AccountEntry): boolean => entry.status === null || entry.status.state === 'ready' || entry.status.state === 'checking';
49
+
50
+ /*
51
+ * The providers in the three groups of the list. A provider the host says is not set up needs setup whatever
52
+ * its accounts say; otherwise one usable account, or none to read, makes it ready.
53
+ */
54
+ export function groupProviders(
55
+ providers: readonly ProviderInfo[],
56
+ accounts: ProviderAccounts | null,
57
+ setupStatusOf?: (provider: ProviderInfo) => ProviderSetupStatus | null
58
+ ): ProviderGroups {
59
+ const groups: ProviderGroups = { ready: [], setup: [], missing: [] };
60
+ for (const provider of providers) {
61
+ // A provider that never runs in a terminal is no CLI to install: it is turned on in its own detail.
62
+ if (!provider.installed && provider.capabilities.terminal) {
63
+ groups.missing.push(provider);
64
+ continue;
65
+ }
66
+ const entries = provider.installed ? accountsOfKind(accounts, provider.kind) : [];
67
+ const hostStatus = setupStatusOf?.(provider) ?? null;
68
+ if (hostStatus !== null) {
69
+ groups.setup.push({ provider, entries, note: hostStatus });
70
+ } else if (entries.length === 0 || entries.some(usable)) {
71
+ groups.ready.push({ provider, entries, note: entries.length === 1 ? accountNote(entries[0]!.status) : null });
72
+ } else {
73
+ groups.setup.push({ provider, entries, note: accountNote(entries[0]!.status) });
74
+ }
75
+ }
76
+ return groups;
77
+ }