@stigmer/react 0.5.0 → 0.5.1

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 (94) hide show
  1. package/composer/ContextChip.d.ts +7 -2
  2. package/composer/ContextChip.d.ts.map +1 -1
  3. package/composer/ContextChip.js +2 -1
  4. package/composer/ContextChip.js.map +1 -1
  5. package/composer/SessionComposer.d.ts +11 -0
  6. package/composer/SessionComposer.d.ts.map +1 -1
  7. package/composer/SessionComposer.js +33 -4
  8. package/composer/SessionComposer.js.map +1 -1
  9. package/environment/usePersonalEnvironment.d.ts.map +1 -1
  10. package/environment/usePersonalEnvironment.js +1 -0
  11. package/environment/usePersonalEnvironment.js.map +1 -1
  12. package/index.d.ts +2 -2
  13. package/index.d.ts.map +1 -1
  14. package/index.js +1 -1
  15. package/index.js.map +1 -1
  16. package/inline-edit/InlineEditKeyValue.d.ts +5 -1
  17. package/inline-edit/InlineEditKeyValue.d.ts.map +1 -1
  18. package/inline-edit/InlineEditKeyValue.js +3 -3
  19. package/inline-edit/InlineEditKeyValue.js.map +1 -1
  20. package/internal/useFetch.js +2 -2
  21. package/internal/useFetch.js.map +1 -1
  22. package/mcp-server/McpServerDetailView.d.ts.map +1 -1
  23. package/mcp-server/McpServerDetailView.js +145 -46
  24. package/mcp-server/McpServerDetailView.js.map +1 -1
  25. package/models/ModelRegistryContext.d.ts +2 -0
  26. package/models/ModelRegistryContext.d.ts.map +1 -1
  27. package/models/ModelRegistryContext.js +1 -0
  28. package/models/ModelRegistryContext.js.map +1 -1
  29. package/models/ModelSelector.d.ts.map +1 -1
  30. package/models/ModelSelector.js +2 -2
  31. package/models/ModelSelector.js.map +1 -1
  32. package/models/__tests__/useModelRegistry.test.js +4 -3
  33. package/models/__tests__/useModelRegistry.test.js.map +1 -1
  34. package/models/useModelRegistry.d.ts +2 -0
  35. package/models/useModelRegistry.d.ts.map +1 -1
  36. package/models/useModelRegistry.js +3 -2
  37. package/models/useModelRegistry.js.map +1 -1
  38. package/package.json +4 -4
  39. package/provider.d.ts.map +1 -1
  40. package/provider.js +69 -22
  41. package/provider.js.map +1 -1
  42. package/session/__tests__/session-spec-converters.test.d.ts +2 -0
  43. package/session/__tests__/session-spec-converters.test.d.ts.map +1 -0
  44. package/session/__tests__/session-spec-converters.test.js +162 -0
  45. package/session/__tests__/session-spec-converters.test.js.map +1 -0
  46. package/session/__tests__/useNewSessionFlow.test.js +2 -2
  47. package/session/__tests__/useNewSessionFlow.test.js.map +1 -1
  48. package/session/__tests__/usePersistedModel.test.js +1 -1
  49. package/session/__tests__/usePersistedModel.test.js.map +1 -1
  50. package/session/group-sessions.d.ts +17 -0
  51. package/session/group-sessions.d.ts.map +1 -1
  52. package/session/group-sessions.js +46 -0
  53. package/session/group-sessions.js.map +1 -1
  54. package/session/index.d.ts +4 -2
  55. package/session/index.d.ts.map +1 -1
  56. package/session/index.js +2 -1
  57. package/session/index.js.map +1 -1
  58. package/session/session-spec-converters.d.ts +24 -0
  59. package/session/session-spec-converters.d.ts.map +1 -0
  60. package/session/session-spec-converters.js +72 -0
  61. package/session/session-spec-converters.js.map +1 -0
  62. package/session/useSessionConversation.d.ts.map +1 -1
  63. package/session/useSessionConversation.js +1 -56
  64. package/session/useSessionConversation.js.map +1 -1
  65. package/session/useSessionPageFlow.d.ts +5 -0
  66. package/session/useSessionPageFlow.d.ts.map +1 -1
  67. package/session/useSessionPageFlow.js +20 -6
  68. package/session/useSessionPageFlow.js.map +1 -1
  69. package/session/useSessionSearch.d.ts +57 -0
  70. package/session/useSessionSearch.d.ts.map +1 -0
  71. package/session/useSessionSearch.js +94 -0
  72. package/session/useSessionSearch.js.map +1 -0
  73. package/src/composer/ContextChip.tsx +20 -11
  74. package/src/composer/SessionComposer.tsx +52 -3
  75. package/src/environment/usePersonalEnvironment.ts +1 -0
  76. package/src/index.ts +5 -0
  77. package/src/inline-edit/InlineEditKeyValue.tsx +23 -0
  78. package/src/internal/useFetch.ts +2 -2
  79. package/src/mcp-server/McpServerDetailView.tsx +429 -55
  80. package/src/models/ModelRegistryContext.ts +3 -0
  81. package/src/models/ModelSelector.tsx +25 -2
  82. package/src/models/__tests__/useModelRegistry.test.tsx +5 -3
  83. package/src/models/useModelRegistry.ts +5 -2
  84. package/src/provider.tsx +69 -18
  85. package/src/session/__tests__/session-spec-converters.test.ts +185 -0
  86. package/src/session/__tests__/useNewSessionFlow.test.tsx +2 -2
  87. package/src/session/__tests__/usePersistedModel.test.tsx +1 -1
  88. package/src/session/group-sessions.ts +65 -0
  89. package/src/session/index.ts +8 -2
  90. package/src/session/session-spec-converters.ts +86 -0
  91. package/src/session/useSessionConversation.ts +5 -64
  92. package/src/session/useSessionPageFlow.ts +28 -7
  93. package/src/session/useSessionSearch.ts +149 -0
  94. package/styles.css +1 -1
@@ -253,20 +253,16 @@ export function McpServerDetailView({
253
253
  credentials.refetch();
254
254
  }
255
255
 
256
- setShowCredentialForm(false);
257
-
258
256
  if (mcpServer?.metadata?.id) {
259
257
  const envKeys = Object.keys(mcpServer.spec?.env ?? {});
260
258
  const connectOrg = activeOrg ?? org;
261
- if (options.saveForFuture) {
262
- await connection.connect(mcpServer.metadata.id, connectOrg, undefined, envKeys);
263
- } else {
264
- await connection.connect(mcpServer.metadata.id, connectOrg, values, envKeys);
265
- }
259
+ await connection.connect(mcpServer.metadata.id, connectOrg, values, envKeys);
266
260
  refetch();
267
261
  }
262
+
263
+ setShowCredentialForm(false);
268
264
  } catch {
269
- // error state is managed by the hooks
265
+ // error state is managed by the hooks — form stays open for retry
270
266
  }
271
267
  },
272
268
  [credentials, mcpServer, connection, refetch],
@@ -443,8 +439,6 @@ export function McpServerDetailView({
443
439
  onConnect={handleConnectClick}
444
440
  onClearConnectionError={combinedClearError}
445
441
  hasDiscoveredTools={hasDiscoveredTools}
446
- toolCount={tools.length}
447
- policyCount={totalPolicyCount}
448
442
  credentialsLoading={credentials.isLoading}
449
443
  oauthPhase={oauth.phase}
450
444
  authMode={credentials.authMode}
@@ -635,8 +629,6 @@ function ConnectBar({
635
629
  onConnect,
636
630
  onClearConnectionError,
637
631
  hasDiscoveredTools,
638
- toolCount,
639
- policyCount,
640
632
  credentialsLoading,
641
633
  oauthPhase,
642
634
  authMode,
@@ -670,8 +662,6 @@ function ConnectBar({
670
662
  readonly onConnect: () => void;
671
663
  readonly onClearConnectionError: () => void;
672
664
  readonly hasDiscoveredTools: boolean;
673
- readonly toolCount: number;
674
- readonly policyCount: number;
675
665
  readonly credentialsLoading: boolean;
676
666
  readonly oauthPhase: OAuthConnectPhase;
677
667
  readonly authMode: "manual" | "oauth";
@@ -743,7 +733,7 @@ function ConnectBar({
743
733
  }
744
734
  if (isOrgOAuthApp && showOAuthPrimary) return "Using your OAuth app";
745
735
  if (manualOverride) return "Entering token manually";
746
- if (hasDiscoveredTools) return formatConnectionSummary(toolCount, policyCount);
736
+ if (hasDiscoveredTools) return "Connected";
747
737
  return "Not connected yet";
748
738
  })();
749
739
 
@@ -1086,13 +1076,6 @@ function oauthPhaseLabel(phase: OAuthConnectPhase): string {
1086
1076
  }
1087
1077
  }
1088
1078
 
1089
- function formatConnectionSummary(toolCount: number, policyCount: number): string {
1090
- const toolLabel = `${toolCount} tool${toolCount !== 1 ? "s" : ""}`;
1091
- if (policyCount === 0) return toolLabel;
1092
- const policyLabel = `${policyCount} ${policyCount !== 1 ? "policies" : "policy"}`;
1093
- return `${toolLabel}, ${policyLabel}`;
1094
- }
1095
-
1096
1079
  function formatTokenExpiry(expiresAtSeconds: bigint): string | null {
1097
1080
  if (expiresAtSeconds === BigInt(0)) return null;
1098
1081
  const nowSeconds = BigInt(Math.floor(Date.now() / 1000));
@@ -1289,6 +1272,20 @@ function ServerConfigSection({
1289
1272
  value: import("@stigmer/sdk").McpServerInput[K],
1290
1273
  ) => Promise<boolean>;
1291
1274
  }) {
1275
+ const [headersEditing, setHeadersEditing] = useState(false);
1276
+ const [queryParamsEditing, setQueryParamsEditing] = useState(false);
1277
+
1278
+ const currentHttpConfig = useMemo(() => {
1279
+ if (serverType?.case !== "http") return null;
1280
+ const v = serverType.value;
1281
+ return {
1282
+ url: v.url,
1283
+ headers: v.headers && Object.keys(v.headers).length > 0 ? { ...v.headers } : undefined,
1284
+ queryParams: v.queryParams && Object.keys(v.queryParams).length > 0 ? { ...v.queryParams } : undefined,
1285
+ timeoutSeconds: v.timeoutSeconds || undefined,
1286
+ };
1287
+ }, [serverType]);
1288
+
1292
1289
  const handleTransportChange = useCallback(
1293
1290
  async (newType: string) => {
1294
1291
  if (!saveMcpField) return false;
@@ -1304,6 +1301,46 @@ function ServerConfigSection({
1304
1301
  [saveMcpField],
1305
1302
  );
1306
1303
 
1304
+ const headerRows: KeyValueRow[] = useMemo(() => {
1305
+ if (!currentHttpConfig?.headers) return [];
1306
+ return Object.entries(currentHttpConfig.headers).map(([key, value]) => ({ key, value }));
1307
+ }, [currentHttpConfig?.headers]);
1308
+
1309
+ const queryParamRows: KeyValueRow[] = useMemo(() => {
1310
+ if (!currentHttpConfig?.queryParams) return [];
1311
+ return Object.entries(currentHttpConfig.queryParams).map(([key, value]) => ({ key, value }));
1312
+ }, [currentHttpConfig?.queryParams]);
1313
+
1314
+ const handleHeadersSave = useCallback(
1315
+ async (rows: KeyValueRow[]) => {
1316
+ if (!saveMcpField || !currentHttpConfig) return false;
1317
+ const headers: Record<string, string> = {};
1318
+ for (const row of rows) {
1319
+ if (row.key.trim()) headers[row.key.trim()] = row.value;
1320
+ }
1321
+ return saveMcpField("http", {
1322
+ ...currentHttpConfig,
1323
+ headers: Object.keys(headers).length > 0 ? headers : undefined,
1324
+ });
1325
+ },
1326
+ [saveMcpField, currentHttpConfig],
1327
+ );
1328
+
1329
+ const handleQueryParamsSave = useCallback(
1330
+ async (rows: KeyValueRow[]) => {
1331
+ if (!saveMcpField || !currentHttpConfig) return false;
1332
+ const queryParams: Record<string, string> = {};
1333
+ for (const row of rows) {
1334
+ if (row.key.trim()) queryParams[row.key.trim()] = row.value;
1335
+ }
1336
+ return saveMcpField("http", {
1337
+ ...currentHttpConfig,
1338
+ queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined,
1339
+ });
1340
+ },
1341
+ [saveMcpField, currentHttpConfig],
1342
+ );
1343
+
1307
1344
  return (
1308
1345
  <Section title="Server Configuration">
1309
1346
  <div className="flex flex-col gap-2 p-3">
@@ -1390,10 +1427,7 @@ function ServerConfigSection({
1390
1427
  <InlineEditText
1391
1428
  value={serverType.value.url}
1392
1429
  onSave={async (v) =>
1393
- saveMcpField("http", {
1394
- url: v,
1395
- timeoutSeconds: serverType.value.timeoutSeconds || undefined,
1396
- })
1430
+ saveMcpField("http", { ...currentHttpConfig!, url: v })
1397
1431
  }
1398
1432
  isSaving={isSaving}
1399
1433
  placeholder="https://example.com/mcp"
@@ -1414,7 +1448,7 @@ function ServerConfigSection({
1414
1448
  value={serverType.value.timeoutSeconds > 0 ? String(serverType.value.timeoutSeconds) : ""}
1415
1449
  onSave={async (v) =>
1416
1450
  saveMcpField("http", {
1417
- url: serverType.value.url,
1451
+ ...currentHttpConfig!,
1418
1452
  timeoutSeconds: v ? Number(v) : undefined,
1419
1453
  })
1420
1454
  }
@@ -1435,10 +1469,144 @@ function ServerConfigSection({
1435
1469
  </>
1436
1470
  )}
1437
1471
  </div>
1472
+
1473
+ {serverType?.case === "http" && (editable || headerRows.length > 0) && (
1474
+ <HttpKeyValueSubsection
1475
+ title="Headers"
1476
+ count={headerRows.length}
1477
+ rows={headerRows}
1478
+ editable={editable}
1479
+ isSaving={isSaving}
1480
+ editing={headersEditing}
1481
+ onEditingChange={setHeadersEditing}
1482
+ onSave={handleHeadersSave}
1483
+ keyLabel="Header name"
1484
+ />
1485
+ )}
1486
+
1487
+ {serverType?.case === "http" && (editable || queryParamRows.length > 0) && (
1488
+ <HttpKeyValueSubsection
1489
+ title="Query Parameters"
1490
+ count={queryParamRows.length}
1491
+ rows={queryParamRows}
1492
+ editable={editable}
1493
+ isSaving={isSaving}
1494
+ editing={queryParamsEditing}
1495
+ onEditingChange={setQueryParamsEditing}
1496
+ onSave={handleQueryParamsSave}
1497
+ keyLabel="Parameter name"
1498
+ />
1499
+ )}
1438
1500
  </Section>
1439
1501
  );
1440
1502
  }
1441
1503
 
1504
+ /** Renders a key-value subsection (headers or query params) within ServerConfigSection. */
1505
+ function HttpKeyValueSubsection({
1506
+ title,
1507
+ count,
1508
+ rows,
1509
+ editable,
1510
+ isSaving,
1511
+ editing,
1512
+ onEditingChange,
1513
+ onSave,
1514
+ keyLabel,
1515
+ }: {
1516
+ readonly title: string;
1517
+ readonly count: number;
1518
+ readonly rows: readonly KeyValueRow[];
1519
+ readonly editable?: boolean;
1520
+ readonly isSaving?: boolean;
1521
+ readonly editing: boolean;
1522
+ readonly onEditingChange: (v: boolean) => void;
1523
+ readonly onSave: (rows: KeyValueRow[]) => Promise<boolean>;
1524
+ readonly keyLabel: string;
1525
+ }) {
1526
+ return (
1527
+ <div className="border-t border-border">
1528
+ <div className="flex items-center justify-between px-3 py-2">
1529
+ <div className="flex items-center gap-1.5">
1530
+ <span className="text-xs font-medium text-muted-foreground">{title}</span>
1531
+ {count > 0 && (
1532
+ <span className="inline-flex min-w-[1.25rem] items-center justify-center rounded-full bg-muted px-1 py-px text-[10px] font-medium leading-none text-muted-foreground">
1533
+ {count}
1534
+ </span>
1535
+ )}
1536
+ </div>
1537
+ {editable && (
1538
+ <button
1539
+ type="button"
1540
+ onClick={() => onEditingChange(!editing)}
1541
+ className="text-[11px] text-muted-foreground underline decoration-muted-foreground/40 underline-offset-2 hover:text-foreground hover:decoration-foreground"
1542
+ >
1543
+ {editing ? "Done" : "Edit"}
1544
+ </button>
1545
+ )}
1546
+ </div>
1547
+ {editable && editing ? (
1548
+ <InlineEditKeyValue
1549
+ value={[...rows]}
1550
+ onSave={onSave}
1551
+ isSaving={isSaving}
1552
+ editing={editing}
1553
+ onEditingChange={onEditingChange}
1554
+ keyLabel={keyLabel}
1555
+ showValue
1556
+ valueLabel="Value"
1557
+ />
1558
+ ) : (
1559
+ <div className="flex flex-col divide-y divide-border">
1560
+ {rows.map((row) => (
1561
+ <div key={row.key} className="flex items-start gap-2 px-3 py-1.5">
1562
+ <code className="shrink-0 font-mono text-xs font-medium text-foreground">
1563
+ {row.key}
1564
+ </code>
1565
+ <span className="min-w-0 break-all font-mono text-xs text-muted-foreground">
1566
+ {renderHeaderValue(row.value)}
1567
+ </span>
1568
+ </div>
1569
+ ))}
1570
+ </div>
1571
+ )}
1572
+ </div>
1573
+ );
1574
+ }
1575
+
1576
+ const ENV_VAR_PLACEHOLDER = /\$\{([^}]+)\}/g;
1577
+
1578
+ /** Renders a header value, highlighting ${VAR} placeholders with a variable badge. */
1579
+ function renderHeaderValue(value: string): React.ReactNode {
1580
+ if (!ENV_VAR_PLACEHOLDER.test(value)) return value;
1581
+
1582
+ ENV_VAR_PLACEHOLDER.lastIndex = 0;
1583
+ const parts: React.ReactNode[] = [];
1584
+ let lastIndex = 0;
1585
+ let match: RegExpExecArray | null;
1586
+
1587
+ while ((match = ENV_VAR_PLACEHOLDER.exec(value)) !== null) {
1588
+ if (match.index > lastIndex) {
1589
+ parts.push(value.slice(lastIndex, match.index));
1590
+ }
1591
+ parts.push(
1592
+ <span
1593
+ key={match.index}
1594
+ className="inline-flex items-center gap-0.5 rounded bg-primary-subtle px-1 py-px text-[10px] font-medium text-primary"
1595
+ title={`Resolved from environment variable: ${match[1]}`}
1596
+ >
1597
+ {match[0]}
1598
+ </span>,
1599
+ );
1600
+ lastIndex = match.index + match[0].length;
1601
+ }
1602
+
1603
+ if (lastIndex < value.length) {
1604
+ parts.push(value.slice(lastIndex));
1605
+ }
1606
+
1607
+ return <>{parts}</>;
1608
+ }
1609
+
1442
1610
  function SourceSection({
1443
1611
  spec,
1444
1612
  }: {
@@ -1669,6 +1837,19 @@ function ToolsTabContent({
1669
1837
  }: {
1670
1838
  readonly tools: readonly DiscoveredTool[];
1671
1839
  }) {
1840
+ const [search, setSearch] = useState("");
1841
+ const [expandedTool, setExpandedTool] = useState<string | null>(null);
1842
+
1843
+ const filtered = useMemo(() => {
1844
+ if (!search.trim()) return tools;
1845
+ const q = search.toLowerCase();
1846
+ return tools.filter(
1847
+ (t) =>
1848
+ t.name.toLowerCase().includes(q) ||
1849
+ t.description?.toLowerCase().includes(q),
1850
+ );
1851
+ }, [tools, search]);
1852
+
1672
1853
  if (tools.length === 0) {
1673
1854
  return (
1674
1855
  <div className="px-3 py-8 text-center">
@@ -1680,20 +1861,97 @@ function ToolsTabContent({
1680
1861
  );
1681
1862
  }
1682
1863
 
1864
+ const isFiltered = search.trim().length > 0;
1865
+
1683
1866
  return (
1684
- <div className="flex flex-col divide-y divide-border">
1685
- {tools.map((tool) => (
1686
- <div key={tool.name} className="px-3 py-2.5">
1687
- <code className="font-mono text-sm font-medium text-foreground">
1688
- {tool.name}
1689
- </code>
1690
- {tool.description && (
1691
- <p className="mt-0.5 text-xs text-muted-foreground">
1692
- {tool.description}
1693
- </p>
1867
+ <div className="flex flex-col">
1868
+ <div className="flex items-center gap-2 px-3 pb-2">
1869
+ <div className="relative flex-1">
1870
+ <SearchIcon className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
1871
+ <input
1872
+ type="text"
1873
+ value={search}
1874
+ onChange={(e) => setSearch(e.target.value)}
1875
+ placeholder="Search tools…"
1876
+ aria-label="Search tools"
1877
+ className="w-full rounded-md border border-border bg-background py-1.5 pl-7 pr-7 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
1878
+ />
1879
+ {isFiltered && (
1880
+ <button
1881
+ type="button"
1882
+ onClick={() => setSearch("")}
1883
+ aria-label="Clear search"
1884
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
1885
+ >
1886
+ <CloseIcon className="size-3" />
1887
+ </button>
1694
1888
  )}
1695
1889
  </div>
1696
- ))}
1890
+ <span className="shrink-0 text-[10px] text-muted-foreground">
1891
+ {isFiltered ? `${filtered.length} of ${tools.length}` : tools.length}
1892
+ </span>
1893
+ </div>
1894
+
1895
+ {filtered.length === 0 ? (
1896
+ <div className="px-3 py-6 text-center">
1897
+ <p className="text-xs text-muted-foreground">
1898
+ No tools matching &ldquo;{search}&rdquo;
1899
+ </p>
1900
+ </div>
1901
+ ) : (
1902
+ <div className="max-h-96 overflow-y-auto">
1903
+ <div className="flex flex-col divide-y divide-border">
1904
+ {filtered.map((tool) => {
1905
+ const isExpanded = expandedTool === tool.name;
1906
+ const hasSchema =
1907
+ tool.inputSchema != null &&
1908
+ Object.keys(tool.inputSchema).length > 0;
1909
+
1910
+ return (
1911
+ <div key={tool.name}>
1912
+ <button
1913
+ type="button"
1914
+ onClick={() =>
1915
+ setExpandedTool(isExpanded ? null : tool.name)
1916
+ }
1917
+ className="flex w-full items-start gap-2 px-3 py-2.5 text-left transition-colors hover:bg-muted-faint"
1918
+ aria-expanded={isExpanded}
1919
+ >
1920
+ <ChevronIcon
1921
+ className={cn(
1922
+ "mt-0.5 size-3 shrink-0 text-muted-foreground transition-transform",
1923
+ isExpanded && "rotate-90",
1924
+ )}
1925
+ />
1926
+ <div className="min-w-0 flex-1">
1927
+ <code className="font-mono text-sm font-medium text-foreground">
1928
+ {tool.name}
1929
+ </code>
1930
+ {tool.description && (
1931
+ <p className="mt-0.5 text-xs text-muted-foreground">
1932
+ {tool.description}
1933
+ </p>
1934
+ )}
1935
+ </div>
1936
+ {hasSchema && (
1937
+ <span className="mt-0.5 shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
1938
+ schema
1939
+ </span>
1940
+ )}
1941
+ </button>
1942
+ {isExpanded && hasSchema && (
1943
+ <div className="border-t border-border bg-muted-faint px-3 py-2">
1944
+ <pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded border border-border bg-background p-2 font-mono text-[11px] text-foreground">
1945
+ {JSON.stringify(tool.inputSchema, null, 2)}
1946
+ </pre>
1947
+ </div>
1948
+ )}
1949
+ </div>
1950
+ );
1951
+ })}
1952
+ </div>
1953
+ </div>
1954
+ )}
1697
1955
  </div>
1698
1956
  );
1699
1957
  }
@@ -1707,9 +1965,33 @@ function PoliciesTabContent({
1707
1965
  readonly classifiedPolicies: readonly ToolApprovalPolicy[];
1708
1966
  readonly hasDiscoveredTools: boolean;
1709
1967
  }) {
1710
- const hasPinnedPolicies = pinnedPolicies.length > 0;
1711
- const hasClassifiedPolicies = classifiedPolicies.length > 0;
1712
- const hasAnyPolicies = hasPinnedPolicies || hasClassifiedPolicies;
1968
+ const [search, setSearch] = useState("");
1969
+
1970
+ const totalCount = pinnedPolicies.length + classifiedPolicies.length;
1971
+ const hasAnyPolicies = totalCount > 0;
1972
+
1973
+ const filteredPinned = useMemo(() => {
1974
+ if (!search.trim()) return pinnedPolicies;
1975
+ const q = search.toLowerCase();
1976
+ return pinnedPolicies.filter(
1977
+ (p) =>
1978
+ p.toolName.toLowerCase().includes(q) ||
1979
+ p.message?.toLowerCase().includes(q),
1980
+ );
1981
+ }, [pinnedPolicies, search]);
1982
+
1983
+ const filteredClassified = useMemo(() => {
1984
+ if (!search.trim()) return classifiedPolicies;
1985
+ const q = search.toLowerCase();
1986
+ return classifiedPolicies.filter(
1987
+ (p) =>
1988
+ p.toolName.toLowerCase().includes(q) ||
1989
+ p.message?.toLowerCase().includes(q),
1990
+ );
1991
+ }, [classifiedPolicies, search]);
1992
+
1993
+ const filteredTotal = filteredPinned.length + filteredClassified.length;
1994
+ const isFiltered = search.trim().length > 0;
1713
1995
 
1714
1996
  if (!hasAnyPolicies) {
1715
1997
  return (
@@ -1726,19 +2008,56 @@ function PoliciesTabContent({
1726
2008
 
1727
2009
  return (
1728
2010
  <div className="flex flex-col">
1729
- {hasPinnedPolicies && (
1730
- <PolicyGroup
1731
- icon={<PinIcon className="size-3.5" />}
1732
- label="Pinned"
1733
- policies={pinnedPolicies}
1734
- />
1735
- )}
1736
- {hasClassifiedPolicies && (
1737
- <PolicyGroup
1738
- icon={<SparklesIcon className="size-3.5" />}
1739
- label="Auto-classified"
1740
- policies={classifiedPolicies}
1741
- />
2011
+ <div className="flex items-center gap-2 px-3 pb-2">
2012
+ <div className="relative flex-1">
2013
+ <SearchIcon className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
2014
+ <input
2015
+ type="text"
2016
+ value={search}
2017
+ onChange={(e) => setSearch(e.target.value)}
2018
+ placeholder="Search policies…"
2019
+ aria-label="Search policies"
2020
+ className="w-full rounded-md border border-border bg-background py-1.5 pl-7 pr-7 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
2021
+ />
2022
+ {isFiltered && (
2023
+ <button
2024
+ type="button"
2025
+ onClick={() => setSearch("")}
2026
+ aria-label="Clear search"
2027
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
2028
+ >
2029
+ <CloseIcon className="size-3" />
2030
+ </button>
2031
+ )}
2032
+ </div>
2033
+ <span className="shrink-0 text-[10px] text-muted-foreground">
2034
+ {isFiltered ? `${filteredTotal} of ${totalCount}` : totalCount}
2035
+ </span>
2036
+ </div>
2037
+
2038
+ {filteredTotal === 0 ? (
2039
+ <div className="px-3 py-6 text-center">
2040
+ <p className="text-xs text-muted-foreground">
2041
+ No policies matching &ldquo;{search}&rdquo;
2042
+ </p>
2043
+ </div>
2044
+ ) : (
2045
+ <div className="max-h-96 overflow-y-auto">
2046
+ {filteredPinned.length > 0 && (
2047
+ <PolicyGroup
2048
+ icon={<PinIcon className="size-3.5" />}
2049
+ label="Pinned"
2050
+ policies={filteredPinned}
2051
+ />
2052
+ )}
2053
+ {filteredClassified.length > 0 && (
2054
+ <PolicyGroup
2055
+ icon={<SparklesIcon className="size-3.5" />}
2056
+ label="Auto-classified"
2057
+ policies={filteredClassified}
2058
+ />
2059
+ )}
2060
+ </div>
1742
2061
  )}
1743
2062
  </div>
1744
2063
  );
@@ -1771,7 +2090,10 @@ function PolicyGroup({
1771
2090
  <code className="font-mono text-sm font-medium text-foreground">
1772
2091
  {policy.toolName}
1773
2092
  </code>
1774
- <ShieldIcon className="size-3 text-amber-500 dark:text-amber-400" />
2093
+ <span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400">
2094
+ <ShieldIcon className="size-2.5" />
2095
+ requires approval
2096
+ </span>
1775
2097
  </div>
1776
2098
  {policy.message && (
1777
2099
  <p className="mt-0.5 text-xs text-muted-foreground">
@@ -2055,6 +2377,58 @@ function ExternalLinkIcon({ className }: { readonly className?: string }) {
2055
2377
  );
2056
2378
  }
2057
2379
 
2380
+ function SearchIcon({ className }: { readonly className?: string }) {
2381
+ return (
2382
+ <svg
2383
+ className={className}
2384
+ viewBox="0 0 16 16"
2385
+ fill="none"
2386
+ stroke="currentColor"
2387
+ strokeWidth="1.5"
2388
+ strokeLinecap="round"
2389
+ strokeLinejoin="round"
2390
+ aria-hidden="true"
2391
+ >
2392
+ <circle cx="7" cy="7" r="4.5" />
2393
+ <path d="m10.5 10.5 3 3" />
2394
+ </svg>
2395
+ );
2396
+ }
2397
+
2398
+ function CloseIcon({ className }: { readonly className?: string }) {
2399
+ return (
2400
+ <svg
2401
+ className={className}
2402
+ viewBox="0 0 16 16"
2403
+ fill="none"
2404
+ stroke="currentColor"
2405
+ strokeWidth="2"
2406
+ strokeLinecap="round"
2407
+ strokeLinejoin="round"
2408
+ aria-hidden="true"
2409
+ >
2410
+ <path d="m4 4 8 8M12 4l-8 8" />
2411
+ </svg>
2412
+ );
2413
+ }
2414
+
2415
+ function ChevronIcon({ className }: { readonly className?: string }) {
2416
+ return (
2417
+ <svg
2418
+ className={className}
2419
+ viewBox="0 0 16 16"
2420
+ fill="none"
2421
+ stroke="currentColor"
2422
+ strokeWidth="2"
2423
+ strokeLinecap="round"
2424
+ strokeLinejoin="round"
2425
+ aria-hidden="true"
2426
+ >
2427
+ <path d="m6 4 4 4-4 4" />
2428
+ </svg>
2429
+ );
2430
+ }
2431
+
2058
2432
  function Spinner() {
2059
2433
  return (
2060
2434
  <svg
@@ -8,6 +8,8 @@ export interface ModelRegistryState {
8
8
  readonly models: readonly ModelInfo[];
9
9
  readonly isLoading: boolean;
10
10
  readonly error: Error | null;
11
+ /** Retry fetching the model registry. No-op while a fetch is in flight. */
12
+ readonly refetch: () => void;
11
13
  }
12
14
 
13
15
  /**
@@ -21,6 +23,7 @@ export const ModelRegistryContext = createContext<ModelRegistryState>({
21
23
  models: [],
22
24
  isLoading: true,
23
25
  error: null,
26
+ refetch: () => {},
24
27
  });
25
28
 
26
29
  /**
@@ -119,7 +119,7 @@ export function ModelSelector({
119
119
  }
120
120
  }, [initialHarness, isHarnessLocked]);
121
121
 
122
- const { models, featured, defaultModel, getModel, byProvider } = useModelRegistry(
122
+ const { models, featured, defaultModel, getModel, byProvider, isLoading, error, refetch } = useModelRegistry(
123
123
  { harness: activeHarness },
124
124
  );
125
125
 
@@ -393,7 +393,30 @@ export function ModelSelector({
393
393
  aria-label="Available models"
394
394
  className="max-h-72 overflow-y-auto p-1"
395
395
  >
396
- {visibleModels.length === 0 && (
396
+ {visibleModels.length === 0 && isLoading && (
397
+ <div className="flex items-center justify-center gap-2 px-2 py-3">
398
+ <div className="size-3 animate-spin rounded-full border border-muted border-t-primary" />
399
+ <span className="text-xs text-muted-foreground">Loading models…</span>
400
+ </div>
401
+ )}
402
+
403
+ {visibleModels.length === 0 && !isLoading && error != null && (
404
+ <div className="flex flex-col items-center gap-1.5 px-2 py-3">
405
+ <span className="text-xs text-muted-foreground">Failed to load models</span>
406
+ <button
407
+ type="button"
408
+ className={cn(
409
+ "rounded-md border border-border bg-background px-2.5 py-1 text-xs text-foreground",
410
+ "hover:bg-accent-hover transition-colors cursor-pointer",
411
+ )}
412
+ onClick={refetch}
413
+ >
414
+ Retry
415
+ </button>
416
+ </div>
417
+ )}
418
+
419
+ {visibleModels.length === 0 && !isLoading && error == null && (
397
420
  <div className="px-2 py-3 text-center text-xs text-muted-foreground">
398
421
  No models found
399
422
  </div>