@circuitwall/jarela 1.9.1 → 1.9.3

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 (72) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +2 -2
  3. package/.next/standalone/.next/build-manifest.json +2 -2
  4. package/.next/standalone/.next/prerender-manifest.json +3 -3
  5. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  15. package/.next/standalone/.next/server/app/_not-found.html +2 -2
  16. package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
  17. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  18. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  20. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  22. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/standalone/.next/server/app/page.js +3989 -3805
  24. package/.next/standalone/.next/server/app/page.js.map +1 -1
  25. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  26. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  27. package/.next/standalone/.next/server/app-paths-manifest.json +2 -2
  28. package/.next/standalone/.next/server/chunks/1813.js.map +1 -1
  29. package/.next/standalone/.next/server/chunks/319.js.map +1 -1
  30. package/.next/standalone/.next/server/chunks/4045.js.map +1 -1
  31. package/.next/standalone/.next/server/chunks/4741.js.map +1 -1
  32. package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
  33. package/.next/standalone/.next/server/pages/404.html +2 -2
  34. package/.next/standalone/.next/server/pages/500.html +1 -1
  35. package/.next/standalone/.next/server/proxy.js.map +1 -1
  36. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  37. package/.next/standalone/.next/static/chunks/2747-4a6287cacd57d231.js.map +1 -1
  38. package/.next/standalone/.next/static/chunks/3457-6d51726379cee3b7.js.map +1 -1
  39. package/.next/standalone/.next/static/chunks/{1998-31a617131197a83a.js → 962-fe2372e00f85e23a.js} +111 -2
  40. package/.next/standalone/.next/static/chunks/962-fe2372e00f85e23a.js.map +1 -0
  41. package/.next/standalone/.next/static/chunks/app/layout-84c6f211a7a1ca36.js.map +1 -1
  42. package/.next/standalone/.next/static/chunks/app/{page-cd662565eba5ef59.js → page-c5b9f4407416c3f9.js} +3493 -3390
  43. package/.next/standalone/.next/static/chunks/app/page-c5b9f4407416c3f9.js.map +1 -0
  44. package/.next/standalone/.next/static/chunks/main-3eb94471f04b2368.js.map +1 -1
  45. package/.next/standalone/.next/static/css/b8e04d59a2bfff04.css +5 -0
  46. package/.next/standalone/.next/static/css/b8e04d59a2bfff04.css.map +1 -0
  47. package/.next/standalone/package.json +1 -1
  48. package/CHANGELOG.md +47 -0
  49. package/README.md +6 -6
  50. package/components/agents/agent-editor/VoiceFields.tsx +1 -1
  51. package/components/credentials/AddCredentialDialog.tsx +30 -141
  52. package/components/credentials/CredentialsPanel.tsx +146 -49
  53. package/components/{integrations/IntegrationsPanel.tsx → credentials/IntegrationCard.tsx} +9 -168
  54. package/components/documents/AddSourceForm.tsx +4 -4
  55. package/components/documents/DocumentsPanel.tsx +1 -1
  56. package/components/integrations/NetworkPanel.tsx +104 -0
  57. package/components/layout/AppShell.tsx +6 -0
  58. package/components/layout/MenuPanel.tsx +15 -91
  59. package/components/profile/ProfileEditor.tsx +1 -1
  60. package/components/proposals/ApprovalsBanner.tsx +2 -2
  61. package/components/settings/AppearancePanel.tsx +93 -0
  62. package/components/settings/SettingsPanel.tsx +94 -0
  63. package/contexts/AppContext.tsx +1 -1
  64. package/hooks/useUrlSync.ts +1 -1
  65. package/lib/ui/navigate.ts +1 -1
  66. package/package.json +1 -1
  67. package/.next/standalone/.next/static/chunks/1998-31a617131197a83a.js.map +0 -1
  68. package/.next/standalone/.next/static/chunks/app/page-cd662565eba5ef59.js.map +0 -1
  69. package/.next/standalone/.next/static/css/11aaed27d2989cc1.css +0 -5
  70. package/.next/standalone/.next/static/css/11aaed27d2989cc1.css.map +0 -1
  71. /package/.next/standalone/.next/static/{tTk-KuLcT7O-E0z6PdMmO → EOkgU73YJOpR-vFcKMgL0}/_buildManifest.js +0 -0
  72. /package/.next/standalone/.next/static/{tTk-KuLcT7O-E0z6PdMmO → EOkgU73YJOpR-vFcKMgL0}/_ssgManifest.js +0 -0
@@ -1,12 +1,11 @@
1
1
  "use client";
2
- import { ChevronLeft, ExternalLink, Loader2, X } from "lucide-react";
3
- import { useEffect, useMemo, useState } from "react";
2
+ import { ChevronLeft, X } from "lucide-react";
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
4
  import { api } from "@/api/client";
5
5
  import type { IntegrationDefinition, IntegrationStatus } from "@/api/types";
6
6
  import { useEscapeKey } from "@/hooks/useEscapeKey";
7
7
  import { pushErrorToast } from "@/lib/ui/error-report";
8
-
9
- const SECRET_MASK = "********";
8
+ import { IntegrationCard } from "./IntegrationCard";
10
9
 
11
10
  type Category = NonNullable<IntegrationDefinition["category"]>;
12
11
 
@@ -46,17 +45,13 @@ interface Props {
46
45
  // Called after a successful save with the integration name that was
47
46
  // configured. Hosts can use this to refresh their views.
48
47
  onSaved?: (integrationName: string) => void;
49
- // Hook for "open in Built-in integrations sub-tab" — host wires it to
50
- // flip the Credentials tab onto the integrations sub. When omitted the
51
- // link is hidden.
52
- onOpenInIntegrations?: (integrationName: string) => void;
53
48
  }
54
49
 
55
50
  // Unified picker for adding/editing any credential. Shows manifest
56
- // providers grouped by category, then drops the user into a manifest-
57
- // driven form. Backed by api.integrations.* saves land in the same
58
- // credentials table the IntegrationsPanel writes to.
59
- export function AddCredentialDialog({ initialCategory, directProviderName, lockCategory, onClose, onSaved, onOpenInIntegrations }: Props) {
51
+ // providers grouped by category, then drops the user into the same
52
+ // IntegrationCard editor the Credentials panel renders inline so
53
+ // OAuth Connect, Test, and the setup guides are all in one place.
54
+ export function AddCredentialDialog({ initialCategory, directProviderName, lockCategory, onClose, onSaved }: Props) {
60
55
  const [defs, setDefs] = useState<IntegrationDefinition[]>([]);
61
56
  const [statuses, setStatuses] = useState<Record<string, IntegrationStatus>>({});
62
57
  const [loading, setLoading] = useState(true);
@@ -66,6 +61,16 @@ export function AddCredentialDialog({ initialCategory, directProviderName, lockC
66
61
 
67
62
  useEscapeKey(onClose);
68
63
 
64
+ const reload = useCallback(async () => {
65
+ try {
66
+ const res = await api.integrations.list();
67
+ setDefs(res.definitions);
68
+ setStatuses(Object.fromEntries(res.statuses.map((s) => [s.name, s])));
69
+ } catch (e) {
70
+ pushErrorToast({ title: "Couldn't load credentials catalog", error: e, context: { panel: "credentials", action: "catalog.load" } });
71
+ }
72
+ }, []);
73
+
69
74
  useEffect(() => {
70
75
  let cancelled = false;
71
76
  (async () => {
@@ -178,16 +183,19 @@ export function AddCredentialDialog({ initialCategory, directProviderName, lockC
178
183
  )}
179
184
 
180
185
  {step === "form" && activeDef && (
181
- <ProviderForm
182
- def={activeDef}
183
- status={activeStatus}
184
- onSaved={() => {
185
- onSaved?.(activeDef.name);
186
- onClose();
187
- }}
188
- onCancel={lockCategory || directProviderName ? onClose : backToPicker}
189
- onOpenInIntegrations={onOpenInIntegrations ? () => { onOpenInIntegrations(activeDef.name); onClose(); } : undefined}
190
- />
186
+ <div className="p-4 max-h-[70vh] overflow-y-auto">
187
+ <IntegrationCard
188
+ definition={activeDef}
189
+ status={activeStatus ?? undefined}
190
+ onChanged={() => {
191
+ if (typeof window !== "undefined") {
192
+ window.dispatchEvent(new CustomEvent("jarela:credentials-changed"));
193
+ }
194
+ onSaved?.(activeDef.name);
195
+ void reload();
196
+ }}
197
+ />
198
+ </div>
191
199
  )}
192
200
  {step === "form" && !activeDef && (
193
201
  <p className="text-fg-faint text-sm text-center py-8">Loading…</p>
@@ -196,122 +204,3 @@ export function AddCredentialDialog({ initialCategory, directProviderName, lockC
196
204
  </div>
197
205
  );
198
206
  }
199
-
200
- interface ProviderFormProps {
201
- def: IntegrationDefinition;
202
- status: IntegrationStatus | null;
203
- onSaved: () => void;
204
- onCancel: () => void;
205
- onOpenInIntegrations?: () => void;
206
- }
207
-
208
- function ProviderForm({ def, status, onSaved, onCancel, onOpenInIntegrations }: ProviderFormProps) {
209
- const [values, setValues] = useState<Record<string, string>>(() => ({ ...(status?.values ?? {}) }));
210
- const [saving, setSaving] = useState(false);
211
- const [error, setError] = useState<string | null>(null);
212
-
213
- async function save() {
214
- setError(null);
215
- setSaving(true);
216
- try {
217
- await api.integrations.save(def.name, values);
218
- // Notify both panels that the credentials surface changed so they
219
- // refresh without a full reload.
220
- if (typeof window !== "undefined") {
221
- window.dispatchEvent(new CustomEvent("jarela:credentials-changed"));
222
- }
223
- onSaved();
224
- } catch (e) {
225
- setError(e instanceof Error ? e.message : String(e));
226
- pushErrorToast({
227
- title: `Couldn't save ${def.label}`,
228
- error: e,
229
- context: { panel: "credentials", action: "integration.save", integration: def.name },
230
- });
231
- } finally {
232
- setSaving(false);
233
- }
234
- }
235
-
236
- // OAuth providers benefit from the IntegrationsPanel's "Connect" flow.
237
- // Surface a hint and a shortcut rather than forcing the user to paste
238
- // refresh tokens by hand.
239
- const isOauth = def.fields.some((f) => f.key === "client_id" || f.key === "refresh_token");
240
-
241
- return (
242
- <div className="p-4 space-y-3 max-h-[70vh] overflow-y-auto">
243
- <p className="text-[11px] text-fg-muted leading-snug">{def.description}</p>
244
-
245
- {isOauth && onOpenInIntegrations && (
246
- <div className="px-3 py-2 rounded border border-sky-700/40 bg-sky-900/15 text-[11px] text-sky-700 dark:text-sky-300 flex items-start gap-2">
247
- <span className="flex-1">
248
- This provider supports one-click OAuth. Built-in integrations has the “Connect” button that walks you through it.
249
- </span>
250
- <button
251
- type="button"
252
- onClick={onOpenInIntegrations}
253
- className="text-[11px] underline hover:text-sky-600 dark:hover:text-sky-200 shrink-0"
254
- >
255
- Open
256
- </button>
257
- </div>
258
- )}
259
-
260
- {def.fields.map((f) => (
261
- <label key={f.key} className="block text-xs text-fg-subtle">
262
- <span className="flex items-center gap-1.5">
263
- {f.label}
264
- {f.required && <span className="text-rose-700 dark:text-rose-400 ml-0.5">*</span>}
265
- </span>
266
- <input
267
- type={f.secret ? "password" : "text"}
268
- value={values[f.key] ?? ""}
269
- onChange={(e) => setValues((p) => ({ ...p, [f.key]: e.target.value }))}
270
- onFocus={(e) => {
271
- // Clicking a masked secret field clears it so the user can type a fresh
272
- // value without manually selecting and replacing the dots.
273
- if (f.secret && e.target.value === SECRET_MASK) {
274
- setValues((p) => ({ ...p, [f.key]: "" }));
275
- }
276
- }}
277
- placeholder={f.placeholder}
278
- className="mt-1 w-full px-2 py-1.5 text-sm rounded border border-border bg-surface-3 text-fg font-mono"
279
- />
280
- </label>
281
- ))}
282
-
283
- {error && (
284
- <div className="px-2 py-1.5 rounded bg-rose-950/40 border border-rose-800 text-xs text-rose-700 dark:text-rose-300">
285
- {error}
286
- </div>
287
- )}
288
-
289
- <div className="flex items-center gap-2 pt-1">
290
- <button
291
- onClick={save}
292
- disabled={saving}
293
- className="px-3 py-1.5 text-xs rounded bg-accent text-white hover:bg-accent-hover disabled:opacity-50 inline-flex items-center gap-1.5"
294
- >
295
- {saving && <Loader2 size={11} className="animate-spin" />}
296
- {saving ? "Saving…" : status?.configured ? "Update" : "Save"}
297
- </button>
298
- <button
299
- onClick={onCancel}
300
- className="px-3 py-1.5 text-xs rounded border border-border text-fg-muted hover:bg-surface-3"
301
- >
302
- Cancel
303
- </button>
304
- {onOpenInIntegrations && (
305
- <button
306
- type="button"
307
- onClick={onOpenInIntegrations}
308
- className="ml-auto inline-flex items-center gap-1 text-[11px] text-accent hover:text-accent/80"
309
- title="Open in Built-in integrations — full editor with Test/OAuth"
310
- >
311
- Advanced <ExternalLink size={10} />
312
- </button>
313
- )}
314
- </div>
315
- </div>
316
- );
317
- }
@@ -1,29 +1,32 @@
1
1
  "use client";
2
- import { Key, Pencil, Plus, Trash2 } from "lucide-react";
2
+ import { Filter, Key, Pencil, Plus, Trash2 } from "lucide-react";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { api } from "@/api/client";
5
- import type { Credential, IntegrationDefinition } from "@/api/types";
5
+ import type { Credential, IntegrationDefinition, IntegrationStatus, UserProfile } from "@/api/types";
6
6
  import { useAppContext } from "@/contexts/AppContext";
7
7
  import { useDeepLinkScroll } from "@/hooks/useDeepLinkScroll";
8
- import { IntegrationsPanel } from "@/components/integrations/IntegrationsPanel";
8
+ import { NetworkPanel } from "@/components/integrations/NetworkPanel";
9
+ import { PRESET_CATEGORIES } from "@/lib/integrations/categories";
9
10
  import { AddCredentialDialog } from "./AddCredentialDialog";
11
+ import { IntegrationCard } from "./IntegrationCard";
10
12
 
11
13
  // "Credentials" is the single home for every auth surface. The default
12
- // sub-tab is the lightweight, category-grouped list of saved credentials
13
- // ("API keys & secrets"); the second sub-tab hosts the rich
14
- // IntegrationsPanel with one-click OAuth, Test buttons, env-sync, and
15
- // the network section.
14
+ // sub-tab is the unified list: model API keys, integration keys, and
15
+ // OAuth (Gmail/Outlook/etc.) all live here, grouped by category, with
16
+ // each known integration rendered as an inline editor (save / test /
17
+ // Connect). The sibling "Network & environment" sub-tab hosts the
18
+ // non-auth bits — HTTP proxy, allowed sites, env-var aliases, and the
19
+ // env-sync button.
16
20
  //
17
- // MCP servers (a capability, not an auth) moved out to Tools; built-in
18
- // integrations (which used to live under their own "Connections" tab)
19
- // moved in here. Result: one mental model — *Credentials* answers
20
- // "what accounts has this agent been given access to".
21
+ // MCP servers (a capability, not an auth) live under Tools. Result: one
22
+ // mental model *Credentials* answers "what accounts has this agent
23
+ // been given access to".
21
24
 
22
- type Sub = "list" | "integrations";
25
+ type Sub = "list" | "network";
23
26
 
24
27
  const SUB_TITLES: Record<Sub, string> = {
25
- list: "API keys & secrets",
26
- integrations: "Built-in integrations",
28
+ list: "Credentials",
29
+ network: "Network & environment",
27
30
  };
28
31
 
29
32
  type Category = NonNullable<IntegrationDefinition["category"]>;
@@ -48,6 +51,13 @@ const CATEGORY_LABELS: Record<Category, string> = {
48
51
  other: "Other",
49
52
  };
50
53
 
54
+ const PRESET_LABELS: Record<NonNullable<UserProfile["preset"]>, string> = {
55
+ home: "Home",
56
+ work: "Work",
57
+ dev: "Developer",
58
+ custom: "Everything",
59
+ };
60
+
51
61
  function describeCredential(c: Credential): string {
52
62
  const keys = Object.keys(c.params).filter((k) => k !== "base_url" && k !== "extra_headers");
53
63
  if (keys.length === 0) return "Not configured";
@@ -60,7 +70,11 @@ function describeCredential(c: Credential): string {
60
70
  export function CredentialsPanel() {
61
71
  const { state, dispatch } = useAppContext();
62
72
  const raw = state.selectedItem.credentials;
63
- const active: Sub = raw === "integrations" ? "integrations" : "list";
73
+ // Backward compatibility: the second sub-tab used to be keyed
74
+ // "integrations" (when it was called "Built-in integrations"). Honour
75
+ // older deep links and saved selections by routing them to the new
76
+ // "network" key.
77
+ const active: Sub = raw === "network" || raw === "integrations" ? "network" : "list";
64
78
 
65
79
  const setSub = (s: Sub) =>
66
80
  dispatch({ type: "SET_SELECTION", tab: "credentials", itemId: s });
@@ -72,7 +86,7 @@ export function CredentialsPanel() {
72
86
  aria-label="Credentials sub-section"
73
87
  className="flex gap-1 border-b border-[var(--border)] bg-[var(--bg-secondary)] px-3 pt-2"
74
88
  >
75
- {(["list", "integrations"] as Sub[]).map((s) => {
89
+ {(["list", "network"] as Sub[]).map((s) => {
76
90
  const selected = s === active;
77
91
  return (
78
92
  <button
@@ -94,32 +108,39 @@ export function CredentialsPanel() {
94
108
  })}
95
109
  </div>
96
110
  <div className="flex-1 min-h-0 overflow-auto">
97
- {active === "list" ? <CredentialsListPanel /> : <IntegrationsPanel />}
111
+ {active === "list" ? <CredentialsListPanel /> : <NetworkPanel />}
98
112
  </div>
99
113
  </div>
100
114
  );
101
115
  }
102
116
 
103
- function CredentialsListPanel() {
117
+ export function CredentialsListPanel() {
104
118
  const { dispatch } = useAppContext();
105
119
  const [credentials, setCredentials] = useState<Credential[]>([]);
106
120
  const [defs, setDefs] = useState<IntegrationDefinition[]>([]);
121
+ const [statuses, setStatuses] = useState<Record<string, IntegrationStatus>>({});
122
+ const [preset, setPreset] = useState<UserProfile["preset"]>(null);
107
123
  const [loading, setLoading] = useState(true);
108
124
  const [addOpen, setAddOpen] = useState(false);
109
125
  const [editingProvider, setEditingProvider] = useState<string | null>(null);
126
+ const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
110
127
  const [deleteError, setDeleteError] = useState<string | null>(null);
111
128
  const containerRef = useRef<HTMLDivElement>(null);
112
129
  useDeepLinkScroll("credentials", "credential", containerRef);
130
+ useDeepLinkScroll("credentials", "integration", containerRef);
113
131
 
114
132
  const refresh = useCallback(async () => {
115
133
  setLoading(true);
116
134
  try {
117
- const [rows, ints] = await Promise.all([
135
+ const [rows, ints, profile] = await Promise.all([
118
136
  api.credentials.list(),
119
- api.integrations.list().catch(() => ({ definitions: [] as IntegrationDefinition[], statuses: [] })),
137
+ api.integrations.list().catch(() => ({ definitions: [] as IntegrationDefinition[], statuses: [] as IntegrationStatus[] })),
138
+ api.profile.get().catch(() => null),
120
139
  ]);
121
140
  setCredentials(rows);
122
141
  setDefs(ints.definitions);
142
+ setStatuses(Object.fromEntries(ints.statuses.map((s) => [s.name, s])));
143
+ setPreset(profile?.preset ?? null);
123
144
  } finally {
124
145
  setLoading(false);
125
146
  }
@@ -154,35 +175,74 @@ function CredentialsListPanel() {
154
175
  }
155
176
  }
156
177
 
157
- // OAuth providers + advanced editors live on the Built-in integrations
158
- // sub-tab. The "open" affordance just flips the sub-tab; per-row scroll
159
- // would require routing two ids and the integrations list is short.
160
- function openIntegrationsSubTab() {
161
- dispatch({ type: "SET_SELECTION", tab: "credentials", itemId: "integrations" });
162
- }
178
+ // Persona filter: if the user has chosen a preset, hide unconfigured
179
+ // integrations outside that bucket. Configured-but-out-of-bucket
180
+ // entries stay visible so a saved credential never silently vanishes.
181
+ const visibleDefs = useMemo(() => {
182
+ if (!preset) return defs;
183
+ const allowed = PRESET_CATEGORIES[preset];
184
+ if (allowed === null) return defs;
185
+ return defs.filter((def) => {
186
+ if (!def.category) return true;
187
+ if (allowed.has(def.category)) return true;
188
+ return statuses[def.name]?.configured === true;
189
+ });
190
+ }, [defs, preset, statuses]);
163
191
 
192
+ const hiddenCount = defs.length - visibleDefs.length;
193
+
194
+ // Group: every visible definition belongs in its category bucket
195
+ // (renders as IntegrationCard); credentials WITHOUT a matching
196
+ // definition (legacy model rows etc.) also fall into the right
197
+ // bucket so the user sees a single grouped list.
164
198
  const grouped = useMemo(() => {
165
- const byCat = new Map<Category, Credential[]>();
199
+ type Row =
200
+ | { kind: "def"; def: IntegrationDefinition }
201
+ | { kind: "credential"; credential: Credential };
202
+ const byCat = new Map<Category, Row[]>();
203
+ const defNames = new Set(visibleDefs.map((d) => d.name));
204
+
205
+ for (const def of visibleDefs) {
206
+ const cat = (def.category ?? "other") as Category;
207
+ const arr = byCat.get(cat) ?? [];
208
+ arr.push({ kind: "def", def });
209
+ byCat.set(cat, arr);
210
+ }
166
211
  for (const c of credentials) {
212
+ if (defNames.has(c.provider)) continue; // covered by the integration card
167
213
  const cat = (defByName.get(c.provider)?.category ?? "other") as Category;
168
214
  const arr = byCat.get(cat) ?? [];
169
- arr.push(c);
215
+ arr.push({ kind: "credential", credential: c });
170
216
  byCat.set(cat, arr);
171
217
  }
172
218
  return CATEGORY_ORDER
173
219
  .filter((c) => byCat.has(c))
174
220
  .map((c) => [c, byCat.get(c)!] as const);
175
- }, [credentials, defByName]);
221
+ }, [visibleDefs, credentials, defByName]);
176
222
 
177
223
  return (
178
224
  <div className="flex flex-col h-full">
179
225
  <div className="border-b border-border px-4 py-3 flex items-center gap-2">
180
226
  <Key size={14} className="text-fg-subtle" />
181
227
  <h2 className="text-sm font-semibold text-fg mr-auto">Credentials</h2>
228
+ {preset && preset !== "custom" && (
229
+ <button
230
+ type="button"
231
+ onClick={() => dispatch({ type: "SET_TAB", tab: "profile" })}
232
+ title={`Filtered to "${PRESET_LABELS[preset]}" preset. Click to change in Profile.`}
233
+ className="inline-flex items-center gap-1 px-2 py-1 text-[11px] rounded-full border border-border bg-surface-2 text-fg-muted hover:bg-surface-3"
234
+ >
235
+ <Filter size={11} />
236
+ <span>{PRESET_LABELS[preset]}</span>
237
+ {hiddenCount > 0 && (
238
+ <span className="text-fg-faint">· {hiddenCount} hidden</span>
239
+ )}
240
+ </button>
241
+ )}
182
242
  <button
183
243
  onClick={() => setAddOpen(true)}
184
244
  className="flex items-center gap-1 text-xs text-accent hover:text-accent-hover transition-colors"
185
- title="Pick a provider and connect it. Same row also shows in Built-in integrations."
245
+ title="Pick a provider and connect it. Same editors are available inline below."
186
246
  >
187
247
  <Plus size={14} /> Add credential
188
248
  </button>
@@ -190,8 +250,10 @@ function CredentialsListPanel() {
190
250
 
191
251
  <div ref={containerRef} className="flex-1 overflow-y-auto">
192
252
  <div className="px-4 py-2 space-y-4">
193
- {loading && credentials.length === 0 && <p className="text-fg-faint text-sm py-6 text-center">Loading…</p>}
194
- {!loading && credentials.length === 0 && (
253
+ {loading && credentials.length === 0 && defs.length === 0 && (
254
+ <p className="text-fg-faint text-sm py-6 text-center">Loading…</p>
255
+ )}
256
+ {!loading && grouped.length === 0 && (
195
257
  <p className="text-fg-faint text-sm py-6 text-center">
196
258
  No credentials yet. Click <span className="font-medium">+ Add credential</span> to connect a model provider (OpenAI, Anthropic, Gemini…) or an integration (Gmail, GitHub, Atlassian…).
197
259
  </p>
@@ -202,30 +264,67 @@ function CredentialsListPanel() {
202
264
  {grouped.map(([cat, rows]) => (
203
265
  <section key={cat}>
204
266
  <h3 className="text-[11px] uppercase tracking-wide text-fg-faint mb-1 px-1">{CATEGORY_LABELS[cat]}</h3>
205
- <div className="divide-y divide-border/60 border border-border/60 rounded-xl overflow-hidden bg-surface-2/40">
206
- {rows.map((c) => {
207
- const def = defByName.get(c.provider);
208
- const label = def?.label ?? c.provider;
267
+ <div className="space-y-2">
268
+ {rows.map((row) => {
269
+ if (row.kind === "def") {
270
+ const def = row.def;
271
+ const status = statuses[def.name];
272
+ const isOpen = expandedProvider === def.name || !!status?.configured;
273
+ if (!isOpen) {
274
+ // Collapse unconfigured integrations into a compact row.
275
+ return (
276
+ <div
277
+ key={def.name}
278
+ data-deep-link-id={def.name}
279
+ className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-border/60 bg-surface-2/40"
280
+ >
281
+ <div className="w-1.5 h-1.5 rounded-full bg-fg-faint" />
282
+ <div className="flex-1 min-w-0">
283
+ <div className="flex items-center gap-2 mb-0.5">
284
+ <span className="text-sm font-medium text-fg truncate">{def.label}</span>
285
+ <span className="text-[10px] px-1.5 py-0.5 rounded border bg-surface-3 text-fg-muted border-border font-mono">{def.name}</span>
286
+ </div>
287
+ <p className="text-[11px] text-fg-subtle truncate">{def.description}</p>
288
+ </div>
289
+ <button
290
+ onClick={() => setExpandedProvider(def.name)}
291
+ className="text-[11px] text-accent hover:text-accent-hover shrink-0"
292
+ title="Open editor"
293
+ >
294
+ Connect
295
+ </button>
296
+ </div>
297
+ );
298
+ }
299
+ return (
300
+ <IntegrationCard
301
+ key={def.name}
302
+ definition={def}
303
+ status={status}
304
+ onChanged={refresh}
305
+ />
306
+ );
307
+ }
308
+ // Legacy credential row (no matching integration definition).
309
+ const c = row.credential;
209
310
  return (
210
- <div key={c.id} data-deep-link-id={c.id} className="flex items-center gap-3 px-3 py-2.5 group">
311
+ <div key={c.id} data-deep-link-id={c.id} className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-border/60 bg-surface-2/40 group">
211
312
  <div className="flex-1 min-w-0">
212
313
  <div className="flex items-center gap-2 mb-0.5">
213
- <span className="text-sm font-medium text-fg truncate">{label}</span>
314
+ <span className="text-sm font-medium text-fg truncate">{c.provider}</span>
214
315
  <span className="text-[10px] px-1.5 py-0.5 rounded border bg-surface-3 text-fg-muted border-border font-mono">{c.provider}</span>
215
316
  <span className="text-[10px] text-fg-faint">{c.auth_method}</span>
216
317
  </div>
217
318
  <p className="text-[11px] text-fg-subtle">{describeCredential(c)}</p>
218
319
  </div>
219
320
  <div className="flex gap-1 opacity-40 group-hover:opacity-100 pointer-coarse:opacity-100 transition-opacity shrink-0">
220
- {def && (
221
- <button
222
- onClick={() => setEditingProvider(c.provider)}
223
- className="p-1 text-fg-subtle hover:text-fg transition-colors"
224
- title="Edit credential"
225
- >
226
- <Pencil size={13} />
227
- </button>
228
- )}
321
+ <button
322
+ onClick={() => setEditingProvider(c.provider)}
323
+ className="p-1 text-fg-subtle hover:text-fg transition-colors"
324
+ title="Edit credential"
325
+ >
326
+ <Pencil size={13} />
327
+ </button>
229
328
  <button
230
329
  onClick={() => handleDelete(c)}
231
330
  className="p-1 text-fg-subtle hover:text-red-700 dark:hover:text-red-400 transition-colors"
@@ -247,7 +346,6 @@ function CredentialsListPanel() {
247
346
  <AddCredentialDialog
248
347
  onClose={() => { setAddOpen(false); refresh(); }}
249
348
  onSaved={() => refresh()}
250
- onOpenInIntegrations={() => { setAddOpen(false); openIntegrationsSubTab(); }}
251
349
  />
252
350
  )}
253
351
  {editingProvider && (
@@ -255,7 +353,6 @@ function CredentialsListPanel() {
255
353
  directProviderName={editingProvider}
256
354
  onClose={() => { setEditingProvider(null); refresh(); }}
257
355
  onSaved={() => refresh()}
258
- onOpenInIntegrations={() => { setEditingProvider(null); openIntegrationsSubTab(); }}
259
356
  />
260
357
  )}
261
358
  </div>