@agent-native/core 0.73.0 → 0.74.0

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 (49) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +39 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/durable-background.ts +15 -5
  5. package/corpus/core/src/agent/production-agent.ts +55 -8
  6. package/corpus/core/src/client/ConnectBuilderCard.tsx +7 -7
  7. package/corpus/core/src/client/NewWorkspaceAppFlow.tsx +1 -1
  8. package/corpus/core/src/client/blocks/library/AnnotatedCodeBlock.tsx +33 -18
  9. package/corpus/core/src/client/blocks/library/annotation-rail.tsx +3 -4
  10. package/corpus/core/src/client/components/CodeRequiredDialog.tsx +5 -8
  11. package/corpus/core/src/server/agent-chat-plugin.ts +7 -7
  12. package/corpus/core/src/server/self-dispatch.ts +8 -1
  13. package/corpus/templates/analytics/actions/compose-dashboard.ts +34 -0
  14. package/corpus/templates/analytics/actions/install-dashboard-template.ts +51 -3
  15. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +31 -8
  16. package/corpus/templates/analytics/changelog/2026-06-24-dashboard-filters-now-apply-consistently-to-first-party-traf.md +6 -0
  17. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +97 -28
  18. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +57 -10
  19. package/corpus/templates/clips/app/components/library/library-layout.tsx +7 -0
  20. package/corpus/templates/clips/app/routes/_app.settings._index.tsx +786 -323
  21. package/corpus/templates/clips/changelog/2026-06-24-settings-now-puts-what-s-new-in-a-compact-sidebar-prioritize.md +6 -0
  22. package/corpus/templates/plan/changelog/2026-06-24-annotated-code-callouts-now-use-numbered-gutter-markers-that.md +6 -0
  23. package/dist/agent/durable-background.d.ts.map +1 -1
  24. package/dist/agent/durable-background.js +15 -5
  25. package/dist/agent/durable-background.js.map +1 -1
  26. package/dist/agent/production-agent.d.ts.map +1 -1
  27. package/dist/agent/production-agent.js +52 -8
  28. package/dist/agent/production-agent.js.map +1 -1
  29. package/dist/client/ConnectBuilderCard.d.ts.map +1 -1
  30. package/dist/client/ConnectBuilderCard.js +6 -6
  31. package/dist/client/ConnectBuilderCard.js.map +1 -1
  32. package/dist/client/NewWorkspaceAppFlow.js +1 -1
  33. package/dist/client/NewWorkspaceAppFlow.js.map +1 -1
  34. package/dist/client/blocks/library/AnnotatedCodeBlock.d.ts.map +1 -1
  35. package/dist/client/blocks/library/AnnotatedCodeBlock.js +14 -10
  36. package/dist/client/blocks/library/AnnotatedCodeBlock.js.map +1 -1
  37. package/dist/client/blocks/library/annotation-rail.d.ts +3 -4
  38. package/dist/client/blocks/library/annotation-rail.d.ts.map +1 -1
  39. package/dist/client/blocks/library/annotation-rail.js +3 -4
  40. package/dist/client/blocks/library/annotation-rail.js.map +1 -1
  41. package/dist/client/components/CodeRequiredDialog.d.ts.map +1 -1
  42. package/dist/client/components/CodeRequiredDialog.js +2 -1
  43. package/dist/client/components/CodeRequiredDialog.js.map +1 -1
  44. package/dist/server/agent-chat-plugin.js +7 -7
  45. package/dist/server/agent-chat-plugin.js.map +1 -1
  46. package/dist/server/self-dispatch.d.ts.map +1 -1
  47. package/dist/server/self-dispatch.js +5 -1
  48. package/dist/server/self-dispatch.js.map +1 -1
  49. package/package.json +1 -1
@@ -1,7 +1,9 @@
1
- import { useEffect, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
2
  import {
3
3
  IconBrain,
4
4
  IconBrandSlack,
5
+ IconCheck,
6
+ IconChevronDown,
5
7
  IconCloud,
6
8
  IconExternalLink,
7
9
  IconKey,
@@ -14,12 +16,16 @@ import {
14
16
  useSession,
15
17
  agentNativePath,
16
18
  appApiPath,
17
- openBuilderConnectPopup,
19
+ parseChangelog,
18
20
  useActionQuery,
21
+ useBuilderConnectFlow,
22
+ useBuilderStatus,
19
23
  ChangelogSettingsCard,
20
24
  } from "@agent-native/core/client";
21
25
  import changelog from "../../CHANGELOG.md?raw";
22
26
  import { toast } from "sonner";
27
+ import { cn } from "@/lib/utils";
28
+ import { Badge } from "@/components/ui/badge";
23
29
  import { Button } from "@/components/ui/button";
24
30
  import {
25
31
  Card,
@@ -28,6 +34,11 @@ import {
28
34
  CardHeader,
29
35
  CardTitle,
30
36
  } from "@/components/ui/card";
37
+ import {
38
+ Collapsible,
39
+ CollapsibleContent,
40
+ CollapsibleTrigger,
41
+ } from "@/components/ui/collapsible";
31
42
  import { Input } from "@/components/ui/input";
32
43
  import { Label } from "@/components/ui/label";
33
44
  import { PageHeader } from "@/components/library/page-header";
@@ -95,6 +106,39 @@ const S3_STORAGE_FIELDS = [
95
106
  },
96
107
  ] as const;
97
108
 
109
+ const AI_PROVIDER_FIELDS = [
110
+ {
111
+ key: "ANTHROPIC_API_KEY",
112
+ label: "Anthropic",
113
+ placeholder: "sk-ant-...",
114
+ storage: "agent-engine",
115
+ },
116
+ {
117
+ key: "OPENAI_API_KEY",
118
+ label: "OpenAI",
119
+ placeholder: "sk-...",
120
+ storage: "agent-engine",
121
+ },
122
+ {
123
+ key: "GEMINI_API_KEY",
124
+ label: "Gemini",
125
+ placeholder: "AI...",
126
+ storage: "secret",
127
+ },
128
+ {
129
+ key: "GROQ_API_KEY",
130
+ label: "Groq",
131
+ placeholder: "gsk_...",
132
+ storage: "secret",
133
+ },
134
+ {
135
+ key: "OPENROUTER_API_KEY",
136
+ label: "OpenRouter",
137
+ placeholder: "sk-or-...",
138
+ storage: "agent-engine",
139
+ },
140
+ ] as const;
141
+
98
142
  interface ClipsUserSettings {
99
143
  defaultPlaybackSpeed?: string;
100
144
  emailNotifications?: boolean;
@@ -253,10 +297,105 @@ async function saveS3StorageSettings(
253
297
  }
254
298
  }
255
299
 
300
+ async function saveAgentEngineApiKey(
301
+ key: string,
302
+ value: string,
303
+ ): Promise<void> {
304
+ const res = await fetch(
305
+ agentNativePath("/_agent-native/agent-engine/api-key"),
306
+ {
307
+ method: "POST",
308
+ headers: { "Content-Type": "application/json" },
309
+ body: JSON.stringify({ key, value, scope: "user" }),
310
+ },
311
+ );
312
+
313
+ if (!res.ok) {
314
+ const body = (await res.json().catch(() => null)) as {
315
+ error?: string;
316
+ } | null;
317
+ throw new Error(body?.error ?? `Save failed (${res.status})`);
318
+ }
319
+ }
320
+
321
+ async function saveRegisteredSecret(key: string, value: string): Promise<void> {
322
+ const res = await fetch(
323
+ agentNativePath(`/_agent-native/secrets/${encodeURIComponent(key)}`),
324
+ {
325
+ method: "POST",
326
+ headers: { "Content-Type": "application/json" },
327
+ body: JSON.stringify({ value }),
328
+ },
329
+ );
330
+
331
+ if (!res.ok) {
332
+ const body = (await res.json().catch(() => null)) as {
333
+ error?: string;
334
+ } | null;
335
+ throw new Error(body?.error ?? `Save failed (${res.status})`);
336
+ }
337
+ }
338
+
339
+ function CompactChangelogAside() {
340
+ const entries = useMemo(() => parseChangelog(changelog), []);
341
+ const [expanded, setExpanded] = useState(false);
342
+
343
+ if (entries.length === 0) return null;
344
+
345
+ const latestBody = entries[0]?.body ?? "";
346
+ const canExpand = latestBody.length > 260 || entries.length > 1;
347
+
348
+ return (
349
+ <aside className="2xl:sticky 2xl:top-6 2xl:self-start">
350
+ <div className="relative">
351
+ <div
352
+ className={cn(
353
+ "overflow-hidden transition-[max-height] duration-200 ease-out",
354
+ expanded ? "max-h-[70vh]" : "max-h-72",
355
+ )}
356
+ >
357
+ <ChangelogSettingsCard
358
+ markdown={changelog}
359
+ limit={1}
360
+ className="rounded-md"
361
+ />
362
+ </div>
363
+ {canExpand && !expanded ? (
364
+ <div className="pointer-events-none absolute inset-x-px bottom-10 h-16 bg-gradient-to-t from-card to-transparent" />
365
+ ) : null}
366
+ {canExpand ? (
367
+ <div className="mt-2 flex justify-end">
368
+ <Button
369
+ type="button"
370
+ variant="ghost"
371
+ size="sm"
372
+ className="h-7 px-2 text-xs text-muted-foreground"
373
+ onClick={() => setExpanded((value) => !value)}
374
+ >
375
+ {expanded ? "Collapse" : "Expand"}
376
+ </Button>
377
+ </div>
378
+ ) : null}
379
+ </div>
380
+ </aside>
381
+ );
382
+ }
383
+
256
384
  export default function SettingsIndexRoute() {
257
385
  const { session } = useSession();
258
386
  const email = session?.email ?? "";
259
387
  const storageStatus = useVideoStorageStatus();
388
+ const builderStatus = useBuilderStatus();
389
+ const builderConnect = useBuilderConnectFlow({
390
+ popupUrl:
391
+ builderStatus.status?.cliAuthUrl ?? builderStatus.status?.connectUrl,
392
+ trackingSource: "clips_settings",
393
+ trackingFlow: "clips_setup",
394
+ onConnected: async () => {
395
+ await Promise.all([storageStatus.refetch(), builderStatus.refetch()]);
396
+ toast.success("Builder.io connected");
397
+ },
398
+ });
260
399
  const slackStatus = useActionQuery<SlackInstallationsResponse>(
261
400
  "list-slack-installations",
262
401
  undefined,
@@ -275,6 +414,45 @@ export default function SettingsIndexRoute() {
275
414
  const [transcriptCleanupEnabled, setTranscriptCleanupEnabled] =
276
415
  useState(true);
277
416
  const [s3Values, setS3Values] = useState<Record<string, string>>({});
417
+ const [s3Expanded, setS3Expanded] = useState(false);
418
+ const [apiKeysExpanded, setApiKeysExpanded] = useState(false);
419
+ const [apiKeyValues, setApiKeyValues] = useState<Record<string, string>>({});
420
+ const [apiKeyStatus, setApiKeyStatus] = useState<Record<string, boolean>>({});
421
+ const [apiKeyStatusLoading, setApiKeyStatusLoading] = useState(true);
422
+ const [savingApiKey, setSavingApiKey] = useState<string | null>(null);
423
+
424
+ const refreshApiKeyStatus = useCallback(async () => {
425
+ setApiKeyStatusLoading(true);
426
+ try {
427
+ const [envRes, secretsRes] = await Promise.all([
428
+ fetch(agentNativePath("/_agent-native/env-status")),
429
+ fetch(agentNativePath("/_agent-native/secrets")),
430
+ ]);
431
+ const envData = envRes.ok
432
+ ? ((await envRes.json()) as Array<{
433
+ key: string;
434
+ configured?: boolean;
435
+ }>)
436
+ : [];
437
+ const secretsData = secretsRes.ok
438
+ ? ((await secretsRes.json()) as Array<{
439
+ key: string;
440
+ status?: string;
441
+ }>)
442
+ : [];
443
+ const next = Object.fromEntries(
444
+ envData.map((entry) => [entry.key, Boolean(entry.configured)]),
445
+ );
446
+ for (const entry of secretsData) {
447
+ next[entry.key] = entry.status === "set";
448
+ }
449
+ setApiKeyStatus(next);
450
+ } catch {
451
+ setApiKeyStatus({});
452
+ } finally {
453
+ setApiKeyStatusLoading(false);
454
+ }
455
+ }, []);
278
456
 
279
457
  useEffect(() => {
280
458
  let cancelled = false;
@@ -291,6 +469,10 @@ export default function SettingsIndexRoute() {
291
469
  };
292
470
  }, []);
293
471
 
472
+ useEffect(() => {
473
+ void refreshApiKeyStatus();
474
+ }, [refreshApiKeyStatus]);
475
+
294
476
  async function handleSave() {
295
477
  setSaving(true);
296
478
  try {
@@ -339,6 +521,33 @@ export default function SettingsIndexRoute() {
339
521
  }
340
522
  }
341
523
 
524
+ async function handleSaveApiKey(key: string) {
525
+ const value = (apiKeyValues[key] ?? "").trim();
526
+ if (!value) {
527
+ toast.error("Paste a provider key first.");
528
+ return;
529
+ }
530
+
531
+ setSavingApiKey(key);
532
+ try {
533
+ const field = AI_PROVIDER_FIELDS.find((item) => item.key === key);
534
+ if (field?.storage === "secret") {
535
+ await saveRegisteredSecret(key, value);
536
+ } else {
537
+ await saveAgentEngineApiKey(key, value);
538
+ }
539
+ setApiKeyValues((current) => ({ ...current, [key]: "" }));
540
+ setApiKeyStatus((current) => ({ ...current, [key]: true }));
541
+ window.dispatchEvent(new CustomEvent("agent-engine:configured-changed"));
542
+ await refreshApiKeyStatus();
543
+ toast.success("API key saved");
544
+ } catch (err) {
545
+ toast.error(err instanceof Error ? err.message : "Failed to save key");
546
+ } finally {
547
+ setSavingApiKey(null);
548
+ }
549
+ }
550
+
342
551
  async function handleConnectSlack() {
343
552
  const beforeCount = slackInstallations.length;
344
553
  setConnectingSlack(true);
@@ -380,6 +589,24 @@ export default function SettingsIndexRoute() {
380
589
 
381
590
  const storageConfigured = !!storageStatus.data?.configured;
382
591
  const activeProviderName = storageStatus.data?.activeProvider?.name ?? null;
592
+ const activeProviderId = storageStatus.data?.activeProvider?.id ?? null;
593
+ const builderConnected = Boolean(
594
+ builderConnect.configured ||
595
+ builderStatus.status?.configured ||
596
+ storageStatus.data?.builderConfigured ||
597
+ activeProviderId === "builder",
598
+ );
599
+ const builderOrgName =
600
+ builderConnect.orgName ?? builderStatus.status?.orgName ?? null;
601
+ const builderStatusLoading =
602
+ storageStatus.isLoading ||
603
+ builderStatus.loading ||
604
+ !builderConnect.hasFetchedStatus;
605
+ const s3Configured = activeProviderId === "s3";
606
+ const s3Collapsed = builderConnected && !s3Expanded;
607
+ const configuredApiKeyCount = AI_PROVIDER_FIELDS.filter(
608
+ (field) => apiKeyStatus[field.key],
609
+ ).length;
383
610
  const slackInstallations = slackStatus.data?.installations ?? [];
384
611
  const slackOauthConfigured = slackStatus.data?.oauthConfigured ?? false;
385
612
  const slackSigningConfigured = slackStatus.data?.signingConfigured ?? false;
@@ -392,346 +619,582 @@ export default function SettingsIndexRoute() {
392
619
  Settings
393
620
  </h1>
394
621
  </PageHeader>
395
- <div className="p-6 max-w-2xl mx-auto space-y-6">
396
- <p className="text-sm text-muted-foreground">
397
- Preferences and connected services for this Clips workspace.
398
- </p>
399
-
400
- <ChangelogSettingsCard markdown={changelog} />
401
-
402
- <Card id="video-storage" className="scroll-mt-16">
403
- <CardHeader>
404
- <CardTitle className="text-base flex items-center gap-2">
405
- <IconCloud className="size-4 text-primary" />
406
- Video storage
407
- </CardTitle>
408
- </CardHeader>
409
- <CardContent className="space-y-5">
410
- <div className="flex items-center justify-between gap-4 rounded-md border border-border bg-accent/30 px-3 py-2.5">
411
- <div className="min-w-0">
412
- <div className="flex items-center gap-2 text-sm font-medium">
413
- <IconServer className="h-4 w-4 text-muted-foreground" />
414
- {storageStatus.isLoading
415
- ? "Checking storage"
416
- : storageConfigured
417
- ? (activeProviderName ?? "Storage connected")
418
- : "Not connected"}
622
+ <div className="mx-auto w-full max-w-6xl p-6">
623
+ <div className="grid gap-6 2xl:grid-cols-[minmax(0,1fr)_360px]">
624
+ <div className="min-w-0 space-y-6">
625
+ <p className="text-sm text-muted-foreground">
626
+ Preferences and connected services for this Clips workspace.
627
+ </p>
628
+
629
+ <Card id="video-storage" className="scroll-mt-16">
630
+ <CardHeader>
631
+ <CardTitle className="text-base flex items-center gap-2">
632
+ <IconCloud className="size-4 text-primary" />
633
+ Video storage
634
+ </CardTitle>
635
+ <CardDescription>
636
+ Builder.io is the primary storage path for Clips uploads. S3
637
+ is available when you need to bring your own bucket.
638
+ </CardDescription>
639
+ </CardHeader>
640
+ <CardContent className="space-y-4">
641
+ <div
642
+ className={cn(
643
+ "flex flex-col gap-3 rounded-md border px-3 py-3 sm:flex-row sm:items-center sm:justify-between",
644
+ builderConnected
645
+ ? "border-primary/35 bg-primary/5"
646
+ : "border-border bg-accent/30",
647
+ )}
648
+ >
649
+ <div className="min-w-0">
650
+ <div className="flex items-center gap-2 text-sm font-medium">
651
+ {builderConnected ? (
652
+ <IconCheck className="h-4 w-4 text-primary" />
653
+ ) : (
654
+ <IconKey className="h-4 w-4 text-muted-foreground" />
655
+ )}
656
+ {builderStatusLoading
657
+ ? "Checking Builder.io"
658
+ : builderConnected
659
+ ? "Builder.io connected"
660
+ : "Connect Builder.io"}
661
+ </div>
662
+ <p className="mt-0.5 text-xs text-muted-foreground">
663
+ {builderConnected
664
+ ? builderOrgName
665
+ ? `Using Builder.io for ${builderOrgName}.`
666
+ : "New clips use the connected Builder.io provider."
667
+ : "Includes object storage, uploads, and managed transcription for new clips."}
668
+ </p>
669
+ </div>
670
+ {builderConnected ? (
671
+ <Badge variant="secondary" className="shrink-0">
672
+ Connected
673
+ </Badge>
674
+ ) : (
675
+ <Button
676
+ type="button"
677
+ variant="outline"
678
+ size="sm"
679
+ className="shrink-0"
680
+ onClick={() =>
681
+ builderConnect.start({
682
+ trackingSource: "clips_settings_video_storage",
683
+ trackingFlow: "video_storage",
684
+ })
685
+ }
686
+ disabled={
687
+ builderConnect.connecting || builderStatusLoading
688
+ }
689
+ >
690
+ {builderConnect.connecting ? (
691
+ <IconLoader2 className="h-4 w-4 animate-spin" />
692
+ ) : (
693
+ <IconExternalLink className="h-4 w-4" />
694
+ )}
695
+ Connect Builder.io
696
+ </Button>
697
+ )}
419
698
  </div>
420
- <p className="mt-0.5 text-xs text-muted-foreground">
421
- {storageConfigured
422
- ? "New clips will upload to the connected provider."
423
- : "Save S3-compatible credentials or connect Builder.io from the recorder."}
424
- </p>
425
- </div>
426
- <span className="shrink-0 rounded-full bg-background px-2 py-1 text-[10px] font-medium text-muted-foreground">
427
- {storageConfigured ? "Connected" : "Pending"}
428
- </span>
429
- </div>
430
699
 
431
- <div className="grid gap-4 sm:grid-cols-2">
432
- {S3_STORAGE_FIELDS.map((field) => (
433
- <div key={field.key} className="space-y-1.5">
434
- <Label htmlFor={field.key}>{field.label}</Label>
435
- <Input
436
- id={field.key}
437
- type={
438
- "secret" in field && field.secret ? "password" : "text"
439
- }
440
- value={s3Values[field.key] ?? ""}
441
- onChange={(event) =>
442
- setS3Values((current) => ({
443
- ...current,
444
- [field.key]: event.target.value,
445
- }))
700
+ <Collapsible
701
+ open={builderConnected ? !s3Collapsed : true}
702
+ onOpenChange={(open) => setS3Expanded(open)}
703
+ >
704
+ <div className="rounded-md border border-border">
705
+ <div className="flex flex-col gap-3 px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
706
+ <div className="min-w-0">
707
+ <div className="flex flex-wrap items-center gap-2 text-sm font-medium">
708
+ <IconServer className="h-4 w-4 text-muted-foreground" />
709
+ S3-compatible storage
710
+ <Badge variant="outline" className="text-[10px]">
711
+ Secondary
712
+ </Badge>
713
+ {s3Configured ? (
714
+ <Badge variant="secondary" className="text-[10px]">
715
+ Active
716
+ </Badge>
717
+ ) : null}
718
+ </div>
719
+ <p className="mt-0.5 text-xs text-muted-foreground">
720
+ {builderConnected
721
+ ? "Use this only if this workspace should upload to your own bucket instead of Builder.io."
722
+ : storageConfigured && activeProviderName
723
+ ? `Currently using ${activeProviderName}.`
724
+ : "Use your own bucket if you do not want Builder.io storage."}
725
+ </p>
726
+ </div>
727
+ {builderConnected ? (
728
+ <CollapsibleTrigger asChild>
729
+ <Button
730
+ type="button"
731
+ variant="ghost"
732
+ size="sm"
733
+ className="shrink-0"
734
+ >
735
+ {s3Collapsed ? "Configure S3" : "Hide S3"}
736
+ <IconChevronDown
737
+ className={cn(
738
+ "h-4 w-4 transition-transform",
739
+ !s3Collapsed && "rotate-180",
740
+ )}
741
+ />
742
+ </Button>
743
+ </CollapsibleTrigger>
744
+ ) : null}
745
+ </div>
746
+
747
+ <CollapsibleContent>
748
+ <div className="space-y-4 border-t border-border px-3 py-4">
749
+ <div className="grid gap-4 sm:grid-cols-2">
750
+ {S3_STORAGE_FIELDS.map((field) => (
751
+ <div key={field.key} className="space-y-1.5">
752
+ <Label htmlFor={field.key}>{field.label}</Label>
753
+ <Input
754
+ id={field.key}
755
+ type={
756
+ "secret" in field && field.secret
757
+ ? "password"
758
+ : "text"
759
+ }
760
+ value={s3Values[field.key] ?? ""}
761
+ onChange={(event) =>
762
+ setS3Values((current) => ({
763
+ ...current,
764
+ [field.key]: event.target.value,
765
+ }))
766
+ }
767
+ placeholder={field.placeholder}
768
+ autoComplete="off"
769
+ disabled={savingStorage}
770
+ />
771
+ </div>
772
+ ))}
773
+ </div>
774
+
775
+ <div className="flex justify-end">
776
+ <Button
777
+ onClick={handleSaveS3Storage}
778
+ disabled={savingStorage || storageStatus.isLoading}
779
+ >
780
+ {savingStorage && (
781
+ <IconLoader2 className="h-4 w-4 animate-spin" />
782
+ )}
783
+ Save storage
784
+ </Button>
785
+ </div>
786
+ </div>
787
+ </CollapsibleContent>
788
+ </div>
789
+ </Collapsible>
790
+ </CardContent>
791
+ </Card>
792
+
793
+ <Card id="slack" className="scroll-mt-16">
794
+ <CardHeader>
795
+ <CardTitle className="text-base flex items-center gap-2">
796
+ <IconBrandSlack className="size-4 text-primary" />
797
+ Agent-Native Clips for Slack
798
+ </CardTitle>
799
+ <CardDescription>
800
+ Share a public clip, paste the link in Slack, and it plays
801
+ inline — no extra steps for viewers. Connect each workspace
802
+ once.
803
+ </CardDescription>
804
+ </CardHeader>
805
+ <CardContent className="space-y-4">
806
+ <div className="flex flex-col gap-3 rounded-md border border-border bg-accent/30 px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
807
+ <div className="min-w-0">
808
+ <div className="flex items-center gap-2 text-sm font-medium">
809
+ <IconBrandSlack className="h-4 w-4 text-muted-foreground" />
810
+ {slackStatus.isLoading
811
+ ? "Checking Slack"
812
+ : slackConnected
813
+ ? `${slackInstallations.length} workspace${
814
+ slackInstallations.length === 1 ? "" : "s"
815
+ } connected`
816
+ : slackOauthConfigured
817
+ ? "Not connected"
818
+ : "OAuth credentials needed"}
819
+ </div>
820
+ <p className="mt-0.5 text-xs text-muted-foreground">
821
+ Public Clips links can render as playable Slack previews.
822
+ </p>
823
+ </div>
824
+ <Button
825
+ type="button"
826
+ variant="outline"
827
+ size="sm"
828
+ className="shrink-0"
829
+ onClick={handleConnectSlack}
830
+ disabled={
831
+ connectingSlack ||
832
+ slackStatus.isLoading ||
833
+ !slackOauthConfigured
446
834
  }
447
- placeholder={field.placeholder}
448
- autoComplete="off"
449
- disabled={savingStorage}
450
- />
835
+ >
836
+ {connectingSlack ? (
837
+ <IconLoader2 className="h-4 w-4 animate-spin" />
838
+ ) : (
839
+ <IconExternalLink className="h-4 w-4" />
840
+ )}
841
+ Connect Slack
842
+ </Button>
451
843
  </div>
452
- ))}
453
- </div>
454
844
 
455
- <div className="flex justify-end">
456
- <Button
457
- onClick={handleSaveS3Storage}
458
- disabled={savingStorage || storageStatus.isLoading}
459
- >
460
- {savingStorage && (
461
- <IconLoader2 className="h-4 w-4 animate-spin" />
462
- )}
463
- Save storage
464
- </Button>
465
- </div>
466
- </CardContent>
467
- </Card>
468
-
469
- <Card id="slack" className="scroll-mt-16">
470
- <CardHeader>
471
- <CardTitle className="text-base flex items-center gap-2">
472
- <IconBrandSlack className="size-4 text-primary" />
473
- Agent-Native Clips for Slack
474
- </CardTitle>
475
- <CardDescription>
476
- Share a public clip, paste the link in Slack, and it plays inline
477
- — no extra steps for viewers. Connect each workspace once.
478
- </CardDescription>
479
- </CardHeader>
480
- <CardContent className="space-y-4">
481
- <div className="flex flex-col gap-3 rounded-md border border-border bg-accent/30 px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
482
- <div className="min-w-0">
483
- <div className="flex items-center gap-2 text-sm font-medium">
484
- <IconBrandSlack className="h-4 w-4 text-muted-foreground" />
485
- {slackStatus.isLoading
486
- ? "Checking Slack"
487
- : slackConnected
488
- ? `${slackInstallations.length} workspace${
489
- slackInstallations.length === 1 ? "" : "s"
490
- } connected`
491
- : slackOauthConfigured
492
- ? "Not connected"
493
- : "OAuth credentials needed"}
494
- </div>
495
- <p className="mt-0.5 text-xs text-muted-foreground">
496
- Public Clips links can render as playable Slack previews.
497
- </p>
498
- </div>
499
- <Button
500
- type="button"
501
- variant="outline"
502
- size="sm"
503
- className="shrink-0"
504
- onClick={handleConnectSlack}
505
- disabled={
506
- connectingSlack ||
507
- slackStatus.isLoading ||
508
- !slackOauthConfigured
509
- }
510
- >
511
- {connectingSlack ? (
512
- <IconLoader2 className="h-4 w-4 animate-spin" />
513
- ) : (
514
- <IconExternalLink className="h-4 w-4" />
515
- )}
516
- Connect Slack
517
- </Button>
518
- </div>
845
+ {!slackOauthConfigured ? (
846
+ <div className="rounded-md border border-border p-3 text-xs text-muted-foreground">
847
+ Set SLACK_CLIENT_ID and SLACK_CLIENT_SECRET for this
848
+ deployment before connecting workspaces.
849
+ </div>
850
+ ) : null}
519
851
 
520
- {!slackOauthConfigured ? (
521
- <div className="rounded-md border border-border p-3 text-xs text-muted-foreground">
522
- Set SLACK_CLIENT_ID and SLACK_CLIENT_SECRET for this deployment
523
- before connecting workspaces.
524
- </div>
525
- ) : null}
526
-
527
- {!slackSigningConfigured ? (
528
- <div className="rounded-md border border-border p-3 text-xs text-muted-foreground">
529
- Set SLACK_SIGNING_SECRET so Slack event callbacks can be
530
- verified.
531
- </div>
532
- ) : null}
533
-
534
- {slackInstallations.length > 0 ? (
535
- <div className="space-y-2">
536
- {slackInstallations.map((installation) => (
537
- <div
538
- key={installation.id}
539
- className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2"
540
- >
541
- <div className="min-w-0">
542
- <div className="truncate text-sm font-medium">
543
- {installation.teamName || installation.teamId}
544
- </div>
545
- <div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
546
- <span>{installation.status}</span>
547
- {installation.enterpriseName ? (
548
- <span>{installation.enterpriseName}</span>
549
- ) : null}
550
- <span>Connected by {installation.ownerEmail}</span>
852
+ {!slackSigningConfigured ? (
853
+ <div className="rounded-md border border-border p-3 text-xs text-muted-foreground">
854
+ Set SLACK_SIGNING_SECRET so Slack event callbacks can be
855
+ verified.
856
+ </div>
857
+ ) : null}
858
+
859
+ {slackInstallations.length > 0 ? (
860
+ <div className="space-y-2">
861
+ {slackInstallations.map((installation) => (
862
+ <div
863
+ key={installation.id}
864
+ className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2"
865
+ >
866
+ <div className="min-w-0">
867
+ <div className="truncate text-sm font-medium">
868
+ {installation.teamName || installation.teamId}
869
+ </div>
870
+ <div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
871
+ <span>{installation.status}</span>
872
+ {installation.enterpriseName ? (
873
+ <span>{installation.enterpriseName}</span>
874
+ ) : null}
875
+ <span>Connected by {installation.ownerEmail}</span>
876
+ </div>
877
+ </div>
878
+ <Button
879
+ type="button"
880
+ variant="ghost"
881
+ size="icon"
882
+ className="shrink-0"
883
+ aria-label={`Disconnect ${
884
+ installation.teamName || installation.teamId
885
+ }`}
886
+ onClick={() => setDisconnectSlackTarget(installation)}
887
+ >
888
+ <IconTrash className="h-4 w-4" />
889
+ </Button>
551
890
  </div>
891
+ ))}
892
+ </div>
893
+ ) : null}
894
+ </CardContent>
895
+ </Card>
896
+
897
+ <Card id="ai-providers" className="scroll-mt-16">
898
+ <CardHeader>
899
+ <CardTitle className="text-base flex items-center gap-2">
900
+ <IconBrain className="size-4 text-primary" />
901
+ AI setup
902
+ </CardTitle>
903
+ <CardDescription>
904
+ Builder.io is the default path for managed AI credits.
905
+ Provider keys are optional and can be added here.
906
+ </CardDescription>
907
+ </CardHeader>
908
+ <CardContent className="space-y-4">
909
+ <div
910
+ className={cn(
911
+ "flex flex-col gap-3 rounded-md border px-3 py-3 sm:flex-row sm:items-center sm:justify-between",
912
+ builderConnected
913
+ ? "border-primary/35 bg-primary/5"
914
+ : "border-border bg-accent/30",
915
+ )}
916
+ >
917
+ <div className="min-w-0">
918
+ <div className="flex items-center gap-2 text-sm font-medium">
919
+ {builderConnected ? (
920
+ <IconCheck className="h-4 w-4 text-primary" />
921
+ ) : (
922
+ <IconKey className="h-4 w-4 text-muted-foreground" />
923
+ )}
924
+ {builderStatusLoading
925
+ ? "Checking Builder.io"
926
+ : builderConnected
927
+ ? "Builder.io connected"
928
+ : "Builder.io is the easiest setup"}
552
929
  </div>
930
+ <p className="mt-0.5 text-xs text-muted-foreground">
931
+ {builderConnected
932
+ ? "Included AI credits and managed transcription are available for Clips."
933
+ : "Connect Builder first for included AI credits, object storage, uploads, and managed transcription."}
934
+ </p>
935
+ </div>
936
+ {builderConnected ? (
937
+ <Badge variant="secondary" className="shrink-0">
938
+ Connected
939
+ </Badge>
940
+ ) : (
553
941
  <Button
554
942
  type="button"
555
- variant="ghost"
556
- size="icon"
943
+ variant="outline"
944
+ size="sm"
557
945
  className="shrink-0"
558
- aria-label={`Disconnect ${
559
- installation.teamName || installation.teamId
560
- }`}
561
- onClick={() => setDisconnectSlackTarget(installation)}
946
+ onClick={() =>
947
+ builderConnect.start({
948
+ trackingSource: "clips_settings_ai_setup",
949
+ trackingFlow: "connect_llm",
950
+ })
951
+ }
952
+ disabled={
953
+ builderConnect.connecting || builderStatusLoading
954
+ }
562
955
  >
563
- <IconTrash className="h-4 w-4" />
956
+ {builderConnect.connecting ? (
957
+ <IconLoader2 className="h-4 w-4 animate-spin" />
958
+ ) : (
959
+ <IconExternalLink className="h-4 w-4" />
960
+ )}
961
+ Connect Builder.io
564
962
  </Button>
963
+ )}
964
+ </div>
965
+
966
+ <Collapsible
967
+ open={apiKeysExpanded}
968
+ onOpenChange={setApiKeysExpanded}
969
+ >
970
+ <div className="rounded-md border border-border">
971
+ <CollapsibleTrigger asChild>
972
+ <button
973
+ type="button"
974
+ className="flex w-full items-center justify-between gap-3 px-3 py-3 text-left"
975
+ >
976
+ <div className="min-w-0">
977
+ <div className="flex items-center gap-2 text-sm font-medium">
978
+ <IconKey className="h-4 w-4 text-muted-foreground" />
979
+ Bring your own provider key
980
+ {configuredApiKeyCount > 0 ? (
981
+ <Badge
982
+ variant="secondary"
983
+ className="text-[10px]"
984
+ >
985
+ {configuredApiKeyCount} set
986
+ </Badge>
987
+ ) : null}
988
+ </div>
989
+ <p className="mt-0.5 text-xs text-muted-foreground">
990
+ Add Anthropic, OpenAI, Gemini, Groq, or OpenRouter
991
+ keys for provider-billed usage.
992
+ </p>
993
+ </div>
994
+ <IconChevronDown
995
+ className={cn(
996
+ "h-4 w-4 shrink-0 text-muted-foreground transition-transform",
997
+ apiKeysExpanded && "rotate-180",
998
+ )}
999
+ />
1000
+ </button>
1001
+ </CollapsibleTrigger>
1002
+ <CollapsibleContent>
1003
+ <div className="space-y-3 border-t border-border px-3 py-4">
1004
+ {apiKeyStatusLoading ? (
1005
+ <div className="text-xs text-muted-foreground">
1006
+ Checking provider keys…
1007
+ </div>
1008
+ ) : null}
1009
+ <div className="grid gap-3 sm:grid-cols-2">
1010
+ {AI_PROVIDER_FIELDS.map((field) => {
1011
+ const configured = Boolean(apiKeyStatus[field.key]);
1012
+ const savingThisKey = savingApiKey === field.key;
1013
+ return (
1014
+ <div key={field.key} className="space-y-1.5">
1015
+ <div className="flex items-center justify-between gap-2">
1016
+ <Label htmlFor={field.key}>
1017
+ {field.label}
1018
+ </Label>
1019
+ {configured ? (
1020
+ <span className="flex items-center gap-1 text-[10px] font-medium text-primary">
1021
+ <IconCheck className="h-3 w-3" />
1022
+ Set
1023
+ </span>
1024
+ ) : null}
1025
+ </div>
1026
+ <div className="flex gap-2">
1027
+ <Input
1028
+ id={field.key}
1029
+ type="password"
1030
+ value={apiKeyValues[field.key] ?? ""}
1031
+ onChange={(event) =>
1032
+ setApiKeyValues((current) => ({
1033
+ ...current,
1034
+ [field.key]: event.target.value,
1035
+ }))
1036
+ }
1037
+ onKeyDown={(event) => {
1038
+ if (event.key === "Enter") {
1039
+ void handleSaveApiKey(field.key);
1040
+ }
1041
+ }}
1042
+ placeholder={
1043
+ configured
1044
+ ? "Replace key…"
1045
+ : field.placeholder
1046
+ }
1047
+ autoComplete="off"
1048
+ disabled={savingThisKey}
1049
+ />
1050
+ <Button
1051
+ type="button"
1052
+ variant="outline"
1053
+ size="sm"
1054
+ className="shrink-0"
1055
+ onClick={() => handleSaveApiKey(field.key)}
1056
+ disabled={
1057
+ savingThisKey ||
1058
+ !(apiKeyValues[field.key] ?? "").trim()
1059
+ }
1060
+ >
1061
+ {savingThisKey ? (
1062
+ <IconLoader2 className="h-4 w-4 animate-spin" />
1063
+ ) : (
1064
+ "Save"
1065
+ )}
1066
+ </Button>
1067
+ </div>
1068
+ </div>
1069
+ );
1070
+ })}
1071
+ </div>
1072
+ </div>
1073
+ </CollapsibleContent>
565
1074
  </div>
566
- ))}
567
- </div>
568
- ) : null}
569
- </CardContent>
570
- </Card>
571
-
572
- <Card id="ai-providers" className="scroll-mt-16">
573
- <CardHeader>
574
- <CardTitle className="text-base flex items-center gap-2">
575
- <IconBrain className="size-4 text-primary" />
576
- AI setup
577
- </CardTitle>
578
- </CardHeader>
579
- <CardContent className="space-y-4">
580
- <div className="flex flex-col gap-3 rounded-md border border-border bg-accent/30 px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
581
- <div className="min-w-0">
582
- <div className="flex items-center gap-2 text-sm font-medium">
583
- <IconKey className="h-4 w-4 text-muted-foreground" />
584
- Builder.io is the easiest setup
1075
+ </Collapsible>
1076
+ </CardContent>
1077
+ </Card>
1078
+
1079
+ <Card>
1080
+ <CardHeader>
1081
+ <CardTitle className="text-base flex items-center gap-2">
1082
+ <IconUser className="size-4 text-primary" />
1083
+ Profile
1084
+ </CardTitle>
1085
+ </CardHeader>
1086
+ <CardContent className="space-y-4">
1087
+ <div className="space-y-1.5">
1088
+ <Label htmlFor="email">Email</Label>
1089
+ <Input id="email" value={email} readOnly disabled />
1090
+ </div>
1091
+ <div className="space-y-1.5">
1092
+ <Label htmlFor="display-name">Display name</Label>
1093
+ <Input
1094
+ id="display-name"
1095
+ value={displayName}
1096
+ onChange={(e) => setDisplayName(e.target.value)}
1097
+ placeholder="Your name"
1098
+ disabled={loading}
1099
+ />
585
1100
  </div>
586
- <p className="mt-0.5 text-xs text-muted-foreground">
587
- Connect Builder first for included AI credits, object storage,
588
- uploads, and managed transcription. BYOK is still available
589
- from the agent sidebar.
590
- </p>
591
- </div>
1101
+ </CardContent>
1102
+ </Card>
1103
+
1104
+ <Card>
1105
+ <CardHeader>
1106
+ <CardTitle className="text-base">Playback</CardTitle>
1107
+ </CardHeader>
1108
+ <CardContent className="space-y-4">
1109
+ <div className="space-y-1.5">
1110
+ <Label htmlFor="speed">Default playback speed</Label>
1111
+ <Select
1112
+ value={defaultSpeed}
1113
+ onValueChange={setDefaultSpeed}
1114
+ disabled={loading}
1115
+ >
1116
+ <SelectTrigger id="speed" className="w-40">
1117
+ <SelectValue />
1118
+ </SelectTrigger>
1119
+ <SelectContent>
1120
+ {SPEEDS.map((s) => (
1121
+ <SelectItem key={s} value={s}>
1122
+ {s}×
1123
+ </SelectItem>
1124
+ ))}
1125
+ </SelectContent>
1126
+ </Select>
1127
+ <p className="text-xs text-muted-foreground">
1128
+ Applied automatically when you open a recording.
1129
+ </p>
1130
+ </div>
1131
+ </CardContent>
1132
+ </Card>
1133
+
1134
+ <Card>
1135
+ <CardHeader>
1136
+ <CardTitle className="text-base">Transcript</CardTitle>
1137
+ </CardHeader>
1138
+ <CardContent>
1139
+ <div className="flex items-center justify-between gap-4">
1140
+ <div>
1141
+ <Label
1142
+ htmlFor="transcript-cleanup"
1143
+ className="cursor-pointer"
1144
+ >
1145
+ Background cleanup
1146
+ </Label>
1147
+ <p className="text-xs text-muted-foreground mt-0.5">
1148
+ Show the native transcript immediately, then clean it up
1149
+ in the background when available.
1150
+ </p>
1151
+ </div>
1152
+ <Switch
1153
+ id="transcript-cleanup"
1154
+ checked={transcriptCleanupEnabled}
1155
+ onCheckedChange={setTranscriptCleanupEnabled}
1156
+ disabled={loading}
1157
+ />
1158
+ </div>
1159
+ </CardContent>
1160
+ </Card>
1161
+
1162
+ <Card>
1163
+ <CardHeader>
1164
+ <CardTitle className="text-base">Notifications</CardTitle>
1165
+ </CardHeader>
1166
+ <CardContent>
1167
+ <div className="flex items-center justify-between">
1168
+ <div>
1169
+ <Label htmlFor="email-notif" className="cursor-pointer">
1170
+ Email notifications
1171
+ </Label>
1172
+ <p className="text-xs text-muted-foreground mt-0.5">
1173
+ Get an email when someone comments, reacts, or shares a
1174
+ recording with you.
1175
+ </p>
1176
+ </div>
1177
+ <Switch
1178
+ id="email-notif"
1179
+ checked={emailNotifications}
1180
+ onCheckedChange={setEmailNotifications}
1181
+ disabled={loading}
1182
+ />
1183
+ </div>
1184
+ </CardContent>
1185
+ </Card>
1186
+
1187
+ <div className="flex justify-end">
592
1188
  <Button
593
- type="button"
594
- variant="outline"
595
- size="sm"
596
- className="shrink-0"
597
- onClick={() => {
598
- openBuilderConnectPopup({
599
- source: "clips_settings_ai_providers",
600
- });
601
- toast.message("Finish connecting Builder.io in the popup.");
602
- }}
1189
+ onClick={handleSave}
1190
+ disabled={loading || saving}
1191
+ className="bg-primary hover:bg-primary/90"
603
1192
  >
604
- <IconExternalLink className="h-4 w-4" />
605
- Connect Builder.io
1193
+ {saving ? "Saving…" : "Save changes"}
606
1194
  </Button>
607
1195
  </div>
608
-
609
- <div className="rounded-md border border-border p-3">
610
- <div className="text-sm font-medium">
611
- Bring your own provider key
612
- </div>
613
- <p className="mt-1 text-xs text-muted-foreground">
614
- Open the agent sidebar menu, then API Keys & Connections, to add
615
- Anthropic, OpenAI, Gemini, Groq, or other supported keys. Usage
616
- bills to the provider account you connect.
617
- </p>
618
- </div>
619
- </CardContent>
620
- </Card>
621
-
622
- <Card>
623
- <CardHeader>
624
- <CardTitle className="text-base flex items-center gap-2">
625
- <IconUser className="size-4 text-primary" />
626
- Profile
627
- </CardTitle>
628
- </CardHeader>
629
- <CardContent className="space-y-4">
630
- <div className="space-y-1.5">
631
- <Label htmlFor="email">Email</Label>
632
- <Input id="email" value={email} readOnly disabled />
633
- </div>
634
- <div className="space-y-1.5">
635
- <Label htmlFor="display-name">Display name</Label>
636
- <Input
637
- id="display-name"
638
- value={displayName}
639
- onChange={(e) => setDisplayName(e.target.value)}
640
- placeholder="Your name"
641
- disabled={loading}
642
- />
643
- </div>
644
- </CardContent>
645
- </Card>
646
-
647
- <Card>
648
- <CardHeader>
649
- <CardTitle className="text-base">Playback</CardTitle>
650
- </CardHeader>
651
- <CardContent className="space-y-4">
652
- <div className="space-y-1.5">
653
- <Label htmlFor="speed">Default playback speed</Label>
654
- <Select
655
- value={defaultSpeed}
656
- onValueChange={setDefaultSpeed}
657
- disabled={loading}
658
- >
659
- <SelectTrigger id="speed" className="w-40">
660
- <SelectValue />
661
- </SelectTrigger>
662
- <SelectContent>
663
- {SPEEDS.map((s) => (
664
- <SelectItem key={s} value={s}>
665
- {s}×
666
- </SelectItem>
667
- ))}
668
- </SelectContent>
669
- </Select>
670
- <p className="text-xs text-muted-foreground">
671
- Applied automatically when you open a recording.
672
- </p>
673
- </div>
674
- </CardContent>
675
- </Card>
676
-
677
- <Card>
678
- <CardHeader>
679
- <CardTitle className="text-base">Transcript</CardTitle>
680
- </CardHeader>
681
- <CardContent>
682
- <div className="flex items-center justify-between gap-4">
683
- <div>
684
- <Label htmlFor="transcript-cleanup" className="cursor-pointer">
685
- Background cleanup
686
- </Label>
687
- <p className="text-xs text-muted-foreground mt-0.5">
688
- Show the native transcript immediately, then clean it up in
689
- the background when available.
690
- </p>
691
- </div>
692
- <Switch
693
- id="transcript-cleanup"
694
- checked={transcriptCleanupEnabled}
695
- onCheckedChange={setTranscriptCleanupEnabled}
696
- disabled={loading}
697
- />
698
- </div>
699
- </CardContent>
700
- </Card>
701
-
702
- <Card>
703
- <CardHeader>
704
- <CardTitle className="text-base">Notifications</CardTitle>
705
- </CardHeader>
706
- <CardContent>
707
- <div className="flex items-center justify-between">
708
- <div>
709
- <Label htmlFor="email-notif" className="cursor-pointer">
710
- Email notifications
711
- </Label>
712
- <p className="text-xs text-muted-foreground mt-0.5">
713
- Get an email when someone comments, reacts, or shares a
714
- recording with you.
715
- </p>
716
- </div>
717
- <Switch
718
- id="email-notif"
719
- checked={emailNotifications}
720
- onCheckedChange={setEmailNotifications}
721
- disabled={loading}
722
- />
723
- </div>
724
- </CardContent>
725
- </Card>
726
-
727
- <div className="flex justify-end">
728
- <Button
729
- onClick={handleSave}
730
- disabled={loading || saving}
731
- className="bg-primary hover:bg-primary/90"
732
- >
733
- {saving ? "Saving…" : "Save changes"}
734
- </Button>
1196
+ </div>
1197
+ <CompactChangelogAside />
735
1198
  </div>
736
1199
  </div>
737
1200
  <AlertDialog