@agent-native/dispatch 0.15.13 → 0.15.14

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.
@@ -41,9 +41,15 @@ import {
41
41
  IconMail,
42
42
  IconPlug,
43
43
  } from "@tabler/icons-react";
44
- import { useEffect, useMemo, useState } from "react";
44
+ import { type ReactNode, useEffect, useMemo, useState } from "react";
45
45
  import { toast } from "sonner";
46
46
 
47
+ import {
48
+ Accordion,
49
+ AccordionContent,
50
+ AccordionItem,
51
+ AccordionTrigger,
52
+ } from "./ui/accordion";
47
53
  import { Button } from "./ui/button";
48
54
  import {
49
55
  Collapsible,
@@ -118,20 +124,51 @@ function StatusPill({
118
124
  }) {
119
125
  const toneClass =
120
126
  tone === "success"
121
- ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-300"
127
+ ? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
122
128
  : tone === "warning"
123
- ? "border-amber-500/30 bg-amber-500/10 text-amber-300"
124
- : "border-border bg-muted/40 text-muted-foreground";
129
+ ? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
130
+ : "bg-muted text-muted-foreground";
125
131
 
126
132
  return (
127
133
  <span
128
- className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${toneClass}`}
134
+ className={`inline-flex items-center rounded-full px-2 py-1 text-[11px] font-medium ${toneClass}`}
129
135
  >
130
136
  {label}
131
137
  </span>
132
138
  );
133
139
  }
134
140
 
141
+ function DisclosureSection({
142
+ title,
143
+ summary,
144
+ children,
145
+ defaultOpen = false,
146
+ }: {
147
+ title: string;
148
+ summary?: string;
149
+ children: ReactNode;
150
+ defaultOpen?: boolean;
151
+ }) {
152
+ return (
153
+ <Collapsible defaultOpen={defaultOpen} className="border-t">
154
+ <CollapsibleTrigger className="group flex w-full cursor-pointer items-center justify-between gap-3 py-3 text-left">
155
+ <span className="min-w-0">
156
+ <span className="block text-sm font-medium text-foreground">
157
+ {title}
158
+ </span>
159
+ {summary ? (
160
+ <span className="mt-0.5 block truncate text-xs text-muted-foreground">
161
+ {summary}
162
+ </span>
163
+ ) : null}
164
+ </span>
165
+ <IconChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
166
+ </CollapsibleTrigger>
167
+ <CollapsibleContent className="pb-3">{children}</CollapsibleContent>
168
+ </Collapsible>
169
+ );
170
+ }
171
+
135
172
  /** Render a non-secret env value (e.g. EMAIL_AGENT_ADDRESS) as a copyable
136
173
  * text block. We can't read the actual value from the backend (env-status
137
174
  * only reports `configured: true|false`), so we offer a one-click reveal
@@ -443,7 +480,11 @@ export function MessagingSetupPanel() {
443
480
 
444
481
  return (
445
482
  <div className="space-y-4">
446
- <div className="grid gap-4 xl:grid-cols-2">
483
+ <Accordion
484
+ type="single"
485
+ collapsible
486
+ className="overflow-hidden rounded-2xl border bg-card"
487
+ >
447
488
  {CHANNELS.map((platform) => {
448
489
  const status = statusByPlatform.get(platform.id);
449
490
  const configured =
@@ -464,20 +505,27 @@ export function MessagingSetupPanel() {
464
505
  envKeys,
465
506
  envStatusByKey,
466
507
  );
508
+ const configuredCredentialCount = envKeys.filter(
509
+ (envKey) => envStatusByKey.get(envKey.key)?.configured,
510
+ ).length;
511
+ const credentialSummary = envLoading
512
+ ? "Checking saved credentials"
513
+ : missingRequiredCredentials
514
+ ? "Required credentials are missing"
515
+ : `${configuredCredentialCount} saved`;
467
516
  const Icon = PLATFORM_ICONS[platform.iconKey] ?? IconPlug;
468
517
 
469
518
  return (
470
- <section
519
+ <AccordionItem
471
520
  key={platform.id}
472
- className="rounded-2xl border bg-card p-5"
521
+ value={platform.id}
522
+ className="border-b last:border-b-0"
473
523
  >
474
- <div className="flex items-start justify-between gap-4">
475
- <div className="flex items-start gap-3">
476
- <div className="flex h-10 w-10 items-center justify-center rounded-xl border bg-muted/30 text-foreground">
477
- <Icon size={18} />
478
- </div>
479
- <div>
480
- <div className="flex items-center gap-2">
524
+ <AccordionTrigger className="gap-3 px-4 text-left hover:no-underline">
525
+ <div className="flex min-w-0 flex-1 items-center gap-3">
526
+ <Icon className="h-5 w-5 shrink-0 text-muted-foreground" />
527
+ <div className="min-w-0">
528
+ <div className="flex flex-wrap items-center gap-2">
481
529
  <h3 className="text-base font-semibold text-foreground">
482
530
  {platform.name}
483
531
  </h3>
@@ -486,289 +534,339 @@ export function MessagingSetupPanel() {
486
534
  enabled={enabled}
487
535
  />
488
536
  </div>
489
- <p className="mt-1 text-sm text-muted-foreground">
537
+ <p className="mt-1 truncate text-sm text-muted-foreground">
490
538
  {platform.description}
491
539
  </p>
492
540
  </div>
493
541
  </div>
494
- <div className="flex shrink-0 items-center gap-1">
495
- <Button
496
- asChild
497
- variant="ghost"
498
- size="sm"
499
- className="h-7 px-2 text-xs text-muted-foreground"
500
- >
501
- <a
502
- href={platform.documentation.href}
503
- target="_blank"
504
- rel="noreferrer"
505
- >
506
- Docs
507
- <IconExternalLink className="ml-1 h-3 w-3" />
508
- </a>
509
- </Button>
510
- {platform.documentation.externalHref ? (
511
- <Button
512
- asChild
513
- variant="ghost"
514
- size="sm"
515
- className="h-7 px-2 text-xs text-muted-foreground"
516
- >
517
- <a
518
- href={platform.documentation.externalHref}
519
- target="_blank"
520
- rel="noreferrer"
521
- >
522
- {platform.documentation.externalLabel ?? "Open"}
523
- <IconExternalLink className="ml-1 h-3 w-3" />
524
- </a>
525
- </Button>
526
- ) : null}
527
- </div>
528
- </div>
542
+ </AccordionTrigger>
529
543
 
530
- {platform.id === "slack" ? (
531
- <div className="mt-5 space-y-3 rounded-xl border bg-muted/20 p-4">
532
- <div className="flex flex-wrap items-center justify-between gap-3">
544
+ <AccordionContent className="px-4">
545
+ <div className="border-t">
546
+ <div className="flex flex-wrap items-center justify-between gap-3 py-3">
533
547
  <div>
534
- <div className="text-sm font-medium text-foreground">
535
- {t("messaging.managed.title")}
536
- </div>
537
- <p className="mt-1 text-xs text-muted-foreground">
538
- {t("messaging.managed.description")}
548
+ <p className="text-sm font-medium text-foreground">
549
+ {enabled
550
+ ? "Messages are flowing"
551
+ : configured
552
+ ? "Ready to connect"
553
+ : "Finish setup to connect"}
539
554
  </p>
540
- <p className="mt-1 text-xs text-muted-foreground">
541
- {t("messaging.managed.agentManifestDescription")}
555
+ <p className="mt-0.5 text-xs text-muted-foreground">
556
+ {credentialSummary}
542
557
  </p>
543
558
  </div>
544
- <div className="space-y-2">
545
- <div className="flex flex-wrap items-center gap-2">
546
- <Button asChild variant="outline" size="sm">
547
- <a href={managedSlackAgentManifestUrl()}>
548
- <IconFileDescription className="mr-2 h-4 w-4" />
549
- {t("messaging.managed.agentManifest")}
550
- </a>
559
+ <div className="flex flex-wrap items-center gap-2">
560
+ {platform.id === "telegram" && configured ? (
561
+ <Button
562
+ variant="outline"
563
+ size="sm"
564
+ onClick={() => runSetup(platform)}
565
+ disabled={setupPlatform === platform.id}
566
+ >
567
+ {setupPlatform === platform.id ? (
568
+ <>
569
+ <IconLoader2 className="mr-2 h-4 w-4 animate-spin" />
570
+ Setting up...
571
+ </>
572
+ ) : (
573
+ "Set up webhook"
574
+ )}
551
575
  </Button>
552
- {missingRequiredCredentials ? (
553
- <Button size="sm" disabled>
554
- <IconBrandSlack className="mr-2 h-4 w-4" />
555
- {t("messaging.managed.addToSlack")}
556
- </Button>
557
- ) : (
558
- <Button asChild size="sm">
559
- <a href={managedIntegrationOAuthUrl("slack")}>
560
- <IconBrandSlack className="mr-2 h-4 w-4" />
561
- {t("messaging.managed.addToSlack")}
562
- </a>
563
- </Button>
564
- )}
565
- </div>
566
- {missingRequiredCredentials ? (
567
- <p className="max-w-72 text-xs text-amber-700 dark:text-amber-300">
568
- {t("messaging.managed.requiredCredentials")}
569
- </p>
570
576
  ) : null}
577
+ {!configured && !enabled ? (
578
+ <Tooltip>
579
+ <TooltipTrigger asChild>
580
+ <span tabIndex={0}>
581
+ <Button size="sm" disabled>
582
+ Enable
583
+ </Button>
584
+ </span>
585
+ </TooltipTrigger>
586
+ <TooltipContent>
587
+ Save the required credentials first.
588
+ </TooltipContent>
589
+ </Tooltip>
590
+ ) : (
591
+ <Button
592
+ size="sm"
593
+ onClick={() => togglePlatform(platform, enabled)}
594
+ disabled={togglingPlatform === platform.id}
595
+ >
596
+ {togglingPlatform === platform.id ? (
597
+ <>
598
+ <IconLoader2 className="mr-2 h-4 w-4 animate-spin" />
599
+ Saving...
600
+ </>
601
+ ) : enabled ? (
602
+ "Disable"
603
+ ) : (
604
+ "Enable"
605
+ )}
606
+ </Button>
607
+ )}
571
608
  </div>
572
609
  </div>
573
- {installations.length ? (
574
- <div className="space-y-2">
575
- {installations.map((installation) => (
576
- <div
577
- key={installation.id}
578
- className="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-background/60 px-3 py-2"
579
- >
580
- <div>
581
- <div className="text-sm font-medium text-foreground">
582
- {installation.teamName ||
583
- installation.enterpriseName ||
584
- installation.teamId ||
585
- t("messaging.managed.workspaceFallback")}
586
- </div>
587
- <div className="mt-0.5 text-xs text-muted-foreground">
588
- {t("messaging.managed.scopesUpdated", {
589
- count: installation.scopes.length,
590
- date: formatDate(installation.updatedAt, {
591
- dateStyle: "medium",
592
- }),
593
- })}
594
- </div>
595
- </div>
596
- <div className="flex items-center gap-2">
597
- <StatusPill
598
- tone={
599
- installation.health === "healthy"
600
- ? "success"
601
- : installation.health === "degraded" ||
602
- installation.health === "revoked"
603
- ? "warning"
604
- : "neutral"
605
- }
606
- label={t(
607
- `messaging.managed.health.${installation.health}`,
608
- )}
609
- />
610
- <Button
611
- variant="outline"
612
- size="sm"
613
- onClick={() =>
614
- runInstallationAction(installation, "test")
615
- }
616
- disabled={
617
- installationAction === `test:${installation.id}`
618
- }
619
- >
620
- {t("messaging.managed.test")}
610
+
611
+ {platform.id === "slack" ? (
612
+ <DisclosureSection
613
+ title={t("messaging.managed.title")}
614
+ summary={
615
+ installations.length
616
+ ? `${installations.length} connected`
617
+ : "No workspaces connected yet"
618
+ }
619
+ >
620
+ <div className="space-y-3">
621
+ <p className="text-xs text-muted-foreground">
622
+ {t("messaging.managed.description")}{" "}
623
+ {t("messaging.managed.agentManifestDescription")}
624
+ </p>
625
+ <div className="flex flex-wrap items-center gap-2">
626
+ <Button asChild variant="outline" size="sm">
627
+ <a href={managedSlackAgentManifestUrl()}>
628
+ <IconFileDescription className="mr-2 h-4 w-4" />
629
+ {t("messaging.managed.agentManifest")}
630
+ </a>
631
+ </Button>
632
+ {missingRequiredCredentials ? (
633
+ <Button size="sm" disabled>
634
+ <IconBrandSlack className="mr-2 h-4 w-4" />
635
+ {t("messaging.managed.addToSlack")}
621
636
  </Button>
622
- <Button
623
- variant="ghost"
624
- size="sm"
625
- onClick={() =>
626
- runInstallationAction(
627
- installation,
628
- "disconnect",
629
- )
630
- }
631
- disabled={
632
- installationAction ===
633
- `disconnect:${installation.id}`
634
- }
635
- >
636
- {t("messaging.managed.disconnect")}
637
+ ) : (
638
+ <Button asChild size="sm">
639
+ <a href={managedIntegrationOAuthUrl("slack")}>
640
+ <IconBrandSlack className="mr-2 h-4 w-4" />
641
+ {t("messaging.managed.addToSlack")}
642
+ </a>
637
643
  </Button>
638
- </div>
644
+ )}
639
645
  </div>
640
- ))}
641
- </div>
642
- ) : (
643
- <p className="text-xs text-muted-foreground">
644
- {t("messaging.managed.empty")}
645
- </p>
646
- )}
647
- </div>
648
- ) : null}
649
-
650
- {platform.id === "slack" && scopes.length ? (
651
- <div className="mt-4 space-y-3 rounded-xl border bg-muted/20 p-4">
652
- <div>
653
- <div className="text-sm font-medium text-foreground">
654
- {t("messaging.managed.channelAccessTitle")}
655
- </div>
656
- <p className="mt-1 text-xs text-muted-foreground">
657
- {t("messaging.managed.channelAccessDescription")}
658
- </p>
659
- </div>
660
- {scopes.map((scope) => {
661
- const subjectId = JSON.stringify([
662
- scope.platform,
663
- scope.tenantId,
664
- scope.conversationId,
665
- ]);
666
- const budget = budgets.find(
667
- (item) =>
668
- item.subjectType === "scope" &&
669
- item.subjectId === subjectId &&
670
- item.period === "month",
671
- );
672
- return (
673
- <div
674
- key={scope.id}
675
- className="space-y-3 rounded-lg border bg-background/60 p-3"
676
- >
677
- <div className="flex flex-wrap items-center justify-between gap-2">
678
- <div>
679
- <div className="font-mono text-xs text-foreground">
680
- {scope.conversationId}
681
- </div>
682
- <div className="mt-0.5 text-xs text-muted-foreground">
683
- {t("messaging.managed.isolatedIdentity", {
684
- trust: t(
685
- `messaging.managed.trust.${scope.trust}`,
686
- ),
687
- })}
688
- </div>
646
+ {missingRequiredCredentials ? (
647
+ <p className="text-xs text-amber-700 dark:text-amber-300">
648
+ {t("messaging.managed.requiredCredentials")}
649
+ </p>
650
+ ) : null}
651
+ {installations.length ? (
652
+ <div className="divide-y rounded-lg bg-muted/20 px-3">
653
+ {installations.map((installation) => (
654
+ <div
655
+ key={installation.id}
656
+ className="flex flex-wrap items-center justify-between gap-3 py-3"
657
+ >
658
+ <div>
659
+ <div className="text-sm font-medium text-foreground">
660
+ {installation.teamName ||
661
+ installation.enterpriseName ||
662
+ installation.teamId ||
663
+ t("messaging.managed.workspaceFallback")}
664
+ </div>
665
+ <div className="mt-0.5 text-xs text-muted-foreground">
666
+ {t("messaging.managed.scopesUpdated", {
667
+ count: installation.scopes.length,
668
+ date: formatDate(installation.updatedAt, {
669
+ dateStyle: "medium",
670
+ }),
671
+ })}
672
+ </div>
673
+ </div>
674
+ <div className="flex items-center gap-2">
675
+ <StatusPill
676
+ tone={
677
+ installation.health === "healthy"
678
+ ? "success"
679
+ : installation.health === "degraded" ||
680
+ installation.health === "revoked"
681
+ ? "warning"
682
+ : "neutral"
683
+ }
684
+ label={t(
685
+ `messaging.managed.health.${installation.health}`,
686
+ )}
687
+ />
688
+ <Button
689
+ variant="outline"
690
+ size="sm"
691
+ onClick={() =>
692
+ runInstallationAction(
693
+ installation,
694
+ "test",
695
+ )
696
+ }
697
+ disabled={
698
+ installationAction ===
699
+ `test:${installation.id}`
700
+ }
701
+ >
702
+ {t("messaging.managed.test")}
703
+ </Button>
704
+ <Button
705
+ variant="ghost"
706
+ size="sm"
707
+ onClick={() =>
708
+ runInstallationAction(
709
+ installation,
710
+ "disconnect",
711
+ )
712
+ }
713
+ disabled={
714
+ installationAction ===
715
+ `disconnect:${installation.id}`
716
+ }
717
+ >
718
+ {t("messaging.managed.disconnect")}
719
+ </Button>
720
+ </div>
721
+ </div>
722
+ ))}
689
723
  </div>
690
- <StatusPill
691
- tone={
692
- scope.trust === "trusted" ? "success" : "warning"
693
- }
694
- label={t(`messaging.managed.trust.${scope.trust}`)}
695
- />
696
- </div>
697
- <div className="grid gap-2 sm:grid-cols-3">
698
- {[
699
- [
700
- "requireMention",
701
- t("messaging.managed.requireMention"),
702
- ],
703
- ["allowGuests", t("messaging.managed.allowGuests")],
704
- [
705
- "allowExternalShared",
706
- t("messaging.managed.allowSlackConnect"),
707
- ],
708
- ].map(([key, label]) => (
709
- <label
710
- key={key}
711
- className="flex items-center justify-between gap-2 rounded-md border px-2.5 py-2 text-xs"
724
+ ) : (
725
+ <p className="text-xs text-muted-foreground">
726
+ {t("messaging.managed.empty")}
727
+ </p>
728
+ )}
729
+ </div>
730
+ </DisclosureSection>
731
+ ) : null}
732
+
733
+ {platform.id === "slack" && scopes.length ? (
734
+ <DisclosureSection
735
+ title={t("messaging.managed.channelAccessTitle")}
736
+ summary={`${scopes.length} channel${scopes.length === 1 ? "" : "s"}`}
737
+ >
738
+ <div className="space-y-3">
739
+ <p className="text-xs text-muted-foreground">
740
+ {t("messaging.managed.channelAccessDescription")}
741
+ </p>
742
+ {scopes.map((scope) => {
743
+ const subjectId = JSON.stringify([
744
+ scope.platform,
745
+ scope.tenantId,
746
+ scope.conversationId,
747
+ ]);
748
+ const budget = budgets.find(
749
+ (item) =>
750
+ item.subjectType === "scope" &&
751
+ item.subjectId === subjectId &&
752
+ item.period === "month",
753
+ );
754
+ return (
755
+ <Collapsible
756
+ key={scope.id}
757
+ className="border-b last:border-b-0"
712
758
  >
713
- {label}
714
- <Switch
715
- checked={
716
- scope.policy[
717
- key as keyof ClientIntegrationScope["policy"]
718
- ]
719
- }
720
- disabled={savingScope === scope.id}
721
- onCheckedChange={(checked) =>
722
- updateScopePolicy(scope, { [key]: checked })
723
- }
724
- />
725
- </label>
726
- ))}
727
- </div>
728
- <div className="flex flex-wrap items-end gap-2">
729
- <div className="min-w-40 flex-1 space-y-1">
730
- <label className="text-xs font-medium text-foreground">
731
- {t("messaging.managed.monthlyBudgetUsd")}
732
- </label>
733
- <Input
734
- inputMode="decimal"
735
- value={
736
- scopeBudget[scope.id] ??
737
- (budget
738
- ? String(budget.limitMicros / 1_000_000)
739
- : "")
740
- }
741
- onChange={(event) =>
742
- setScopeBudget((current) => ({
743
- ...current,
744
- [scope.id]: event.target.value,
745
- }))
746
- }
747
- placeholder="25"
748
- />
749
- </div>
750
- <Button
751
- variant="outline"
752
- onClick={() => saveScopeBudget(scope)}
753
- disabled={savingScope === `budget:${scope.id}`}
754
- >
755
- {t("messaging.managed.saveBudget")}
756
- </Button>
757
- </div>
759
+ <CollapsibleTrigger className="group flex w-full cursor-pointer items-center justify-between gap-3 py-3 text-left">
760
+ <span className="min-w-0">
761
+ <span className="block truncate font-mono text-xs text-foreground">
762
+ {scope.conversationId}
763
+ </span>
764
+ <span className="mt-0.5 block text-xs text-muted-foreground">
765
+ {t("messaging.managed.isolatedIdentity", {
766
+ trust: t(
767
+ `messaging.managed.trust.${scope.trust}`,
768
+ ),
769
+ })}
770
+ </span>
771
+ </span>
772
+ <span className="flex shrink-0 items-center gap-2">
773
+ <StatusPill
774
+ tone={
775
+ scope.trust === "trusted"
776
+ ? "success"
777
+ : "warning"
778
+ }
779
+ label={t(
780
+ `messaging.managed.trust.${scope.trust}`,
781
+ )}
782
+ />
783
+ <IconChevronRight className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
784
+ </span>
785
+ </CollapsibleTrigger>
786
+ <CollapsibleContent className="pb-3">
787
+ <div className="grid gap-2 sm:grid-cols-3">
788
+ {[
789
+ [
790
+ "requireMention",
791
+ t("messaging.managed.requireMention"),
792
+ ],
793
+ [
794
+ "allowGuests",
795
+ t("messaging.managed.allowGuests"),
796
+ ],
797
+ [
798
+ "allowExternalShared",
799
+ t("messaging.managed.allowSlackConnect"),
800
+ ],
801
+ ].map(([key, label]) => (
802
+ <label
803
+ key={key}
804
+ className="flex items-center justify-between gap-2 rounded-md border px-2.5 py-2 text-xs"
805
+ >
806
+ {label}
807
+ <Switch
808
+ checked={
809
+ scope.policy[
810
+ key as keyof ClientIntegrationScope["policy"]
811
+ ]
812
+ }
813
+ disabled={savingScope === scope.id}
814
+ onCheckedChange={(checked) =>
815
+ updateScopePolicy(scope, {
816
+ [key]: checked,
817
+ })
818
+ }
819
+ />
820
+ </label>
821
+ ))}
822
+ </div>
823
+ <div className="flex flex-wrap items-end gap-2">
824
+ <div className="min-w-40 flex-1 space-y-1">
825
+ <label className="text-xs font-medium text-foreground">
826
+ {t("messaging.managed.monthlyBudgetUsd")}
827
+ </label>
828
+ <Input
829
+ inputMode="decimal"
830
+ value={
831
+ scopeBudget[scope.id] ??
832
+ (budget
833
+ ? String(
834
+ budget.limitMicros / 1_000_000,
835
+ )
836
+ : "")
837
+ }
838
+ onChange={(event) =>
839
+ setScopeBudget((current) => ({
840
+ ...current,
841
+ [scope.id]: event.target.value,
842
+ }))
843
+ }
844
+ placeholder="25"
845
+ />
846
+ </div>
847
+ <Button
848
+ variant="outline"
849
+ onClick={() => saveScopeBudget(scope)}
850
+ disabled={
851
+ savingScope === `budget:${scope.id}`
852
+ }
853
+ >
854
+ {t("messaging.managed.saveBudget")}
855
+ </Button>
856
+ </div>
857
+ </CollapsibleContent>
858
+ </Collapsible>
859
+ );
860
+ })}
758
861
  </div>
759
- );
760
- })}
761
- </div>
762
- ) : null}
862
+ </DisclosureSection>
863
+ ) : null}
763
864
 
764
- <Collapsible className="mt-5">
765
- <CollapsibleTrigger className="group flex w-full cursor-pointer items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground">
766
- <IconChevronRight className="h-3.5 w-3.5 transition-transform group-data-[state=open]:rotate-90" />
767
- <span>Setup steps</span>
768
- </CollapsibleTrigger>
769
- <CollapsibleContent>
770
- <div className="mt-2 rounded-xl border bg-muted/20 p-4">
771
- <ol className="space-y-2 text-sm text-muted-foreground">
865
+ <DisclosureSection
866
+ title="Setup steps"
867
+ summary={`${platform.setup.steps.length} step${platform.setup.steps.length === 1 ? "" : "s"}`}
868
+ >
869
+ <ol className="space-y-2 rounded-lg bg-muted/20 px-3 py-3 text-sm text-muted-foreground">
772
870
  {platform.setup.steps.map((step, index) => (
773
871
  <li key={step} className="flex gap-2">
774
872
  <span className="text-muted-foreground/60">
@@ -778,252 +876,243 @@ export function MessagingSetupPanel() {
778
876
  </li>
779
877
  ))}
780
878
  </ol>
781
- </div>
782
- </CollapsibleContent>
783
- </Collapsible>
879
+ </DisclosureSection>
784
880
 
785
- <div className="mt-4 space-y-3">
786
- <div className="flex items-center gap-2">
787
- <div className="text-sm font-medium text-foreground">
788
- Credentials
789
- </div>
790
- {envLoading ? (
791
- <span className="text-xs text-muted-foreground">
792
- Checking...
793
- </span>
794
- ) : null}
795
- </div>
796
- <div className="space-y-3">
797
- {primaryEnvKeys.map((envKey) => {
798
- const envStatus = envStatusByKey.get(envKey.key);
799
- const isConfigured = !!envStatus?.configured;
800
- const helpText = envKey.helpText ?? envStatus?.helpText;
801
- const label =
802
- envKey.label || envStatus?.label || envKey.key;
803
- // Email agent address is not a secret — show it plainly
804
- // so users can copy and share it.
805
- const isPublicValue = envKey.key === "EMAIL_AGENT_ADDRESS";
806
- return (
807
- <div key={envKey.key} className="space-y-1.5">
808
- <div className="flex items-center justify-between gap-3">
809
- <div className="flex items-center gap-1.5">
810
- <label className="text-xs font-medium text-foreground">
811
- {label}
812
- {!envKey.required ? (
813
- <span className="ml-1 text-muted-foreground">
814
- (optional)
815
- </span>
816
- ) : null}
817
- </label>
818
- {helpText ? (
819
- <HelpTooltip content={helpText} />
820
- ) : null}
821
- </div>
822
- {isConfigured ? (
823
- <StatusPill tone="success" label="Saved" />
824
- ) : (
825
- <StatusPill
826
- tone="neutral"
827
- label={envKey.required ? "Missing" : "Not set"}
828
- />
829
- )}
830
- </div>
831
- {isConfigured && isPublicValue ? (
832
- <PublicValueReveal envKey={envKey.key} />
833
- ) : !isConfigured ? (
834
- <Input
835
- type={isPublicValue ? "text" : "password"}
836
- value={envValues[envKey.key] || ""}
837
- onChange={(event) =>
838
- setEnvValues((current) => ({
839
- ...current,
840
- [envKey.key]: event.target.value,
841
- }))
842
- }
843
- placeholder={
844
- isPublicValue
845
- ? "agent@yourcompany.com"
846
- : `Enter ${label}`
847
- }
848
- autoComplete="off"
849
- />
850
- ) : null}
851
- </div>
852
- );
853
- })}
854
- </div>
855
- {legacyEnvKeys.length ? (
856
- <Collapsible>
857
- <CollapsibleTrigger className="group flex w-full cursor-pointer items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground">
858
- <IconChevronRight className="h-3.5 w-3.5 transition-transform group-data-[state=open]:rotate-90" />
859
- <span>{legacyEnvKeys[0]?.label}</span>
860
- </CollapsibleTrigger>
861
- <CollapsibleContent>
862
- <div className="mt-2 space-y-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-4">
863
- <p className="text-xs text-muted-foreground">
864
- {legacyEnvKeys[0]?.helpText}
865
- </p>
866
- {legacyEnvKeys.map((envKey) => {
867
- const envStatus = envStatusByKey.get(envKey.key);
868
- const isConfigured = !!envStatus?.configured;
869
- const helpText =
870
- envKey.helpText ?? envStatus?.helpText;
871
- const label =
872
- envKey.label || envStatus?.label || envKey.key;
873
- return (
874
- <div key={envKey.key} className="space-y-1.5">
875
- <div className="flex items-center justify-between gap-3">
876
- <div className="flex items-center gap-1.5">
877
- <label className="text-xs font-medium text-foreground">
878
- {label}
879
- </label>
880
- {helpText ? (
881
- <HelpTooltip content={helpText} />
881
+ <DisclosureSection
882
+ title="Credentials"
883
+ summary={credentialSummary}
884
+ >
885
+ <div className="space-y-3">
886
+ {primaryEnvKeys.map((envKey) => {
887
+ const envStatus = envStatusByKey.get(envKey.key);
888
+ const isConfigured = !!envStatus?.configured;
889
+ const helpText = envKey.helpText ?? envStatus?.helpText;
890
+ const label =
891
+ envKey.label || envStatus?.label || envKey.key;
892
+ // Email agent address is not a secret — show it plainly
893
+ // so users can copy and share it.
894
+ const isPublicValue =
895
+ envKey.key === "EMAIL_AGENT_ADDRESS";
896
+ return (
897
+ <div key={envKey.key} className="space-y-1.5">
898
+ <div className="flex items-center justify-between gap-3">
899
+ <div className="flex items-center gap-1.5">
900
+ <label className="text-xs font-medium text-foreground">
901
+ {label}
902
+ {!envKey.required ? (
903
+ <span className="ml-1 text-muted-foreground">
904
+ (optional)
905
+ </span>
882
906
  ) : null}
883
- </div>
884
- <StatusPill
885
- tone={isConfigured ? "success" : "neutral"}
886
- label={isConfigured ? "Saved" : "Not set"}
887
- />
907
+ </label>
908
+ {helpText ? (
909
+ <HelpTooltip content={helpText} />
910
+ ) : null}
888
911
  </div>
889
- {!isConfigured ? (
890
- <Input
891
- type="password"
892
- value={envValues[envKey.key] || ""}
893
- onChange={(event) =>
894
- setEnvValues((current) => ({
895
- ...current,
896
- [envKey.key]: event.target.value,
897
- }))
912
+ {isConfigured ? (
913
+ <StatusPill tone="success" label="Saved" />
914
+ ) : (
915
+ <StatusPill
916
+ tone="neutral"
917
+ label={
918
+ envKey.required ? "Missing" : "Not set"
898
919
  }
899
- placeholder={`Enter ${label}`}
900
- autoComplete="off"
901
920
  />
902
- ) : null}
921
+ )}
903
922
  </div>
904
- );
905
- })}
906
- {legacyEnvKeys.some(
907
- (envKey) =>
908
- !envStatusByKey.get(envKey.key)?.configured,
909
- ) ? (
910
- <Button
911
- variant="outline"
912
- onClick={() =>
913
- saveEnvKeys(
914
- platform,
915
- legacyEnvKeys.map((envKey) => envKey.key),
916
- )
917
- }
918
- disabled={savingKeysFor === platform.id}
919
- >
920
- {savingKeysFor === platform.id
921
- ? "Saving..."
922
- : "Save credentials"}
923
- </Button>
924
- ) : null}
925
- </div>
926
- </CollapsibleContent>
927
- </Collapsible>
928
- ) : null}
929
- {missingRequiredCredentials ? (
930
- <Button
931
- variant="outline"
932
- onClick={() =>
933
- saveEnvKeys(
934
- platform,
935
- envKeys.map((k) => k.key),
936
- )
937
- }
938
- disabled={savingKeysFor === platform.id}
939
- >
940
- {savingKeysFor === platform.id ? (
941
- <>
942
- <IconLoader2 className="mr-2 h-4 w-4 animate-spin" />
943
- Saving...
944
- </>
945
- ) : (
946
- "Save credentials"
947
- )}
948
- </Button>
949
- ) : null}
950
- </div>
923
+ {isConfigured && isPublicValue ? (
924
+ <PublicValueReveal envKey={envKey.key} />
925
+ ) : !isConfigured ? (
926
+ <Input
927
+ type={isPublicValue ? "text" : "password"}
928
+ value={envValues[envKey.key] || ""}
929
+ onChange={(event) =>
930
+ setEnvValues((current) => ({
931
+ ...current,
932
+ [envKey.key]: event.target.value,
933
+ }))
934
+ }
935
+ placeholder={
936
+ isPublicValue
937
+ ? "agent@yourcompany.com"
938
+ : `Enter ${label}`
939
+ }
940
+ autoComplete="off"
941
+ />
942
+ ) : null}
943
+ </div>
944
+ );
945
+ })}
946
+ </div>
947
+ {legacyEnvKeys.length ? (
948
+ <Collapsible>
949
+ <CollapsibleTrigger className="group flex w-full cursor-pointer items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground">
950
+ <IconChevronRight className="h-3.5 w-3.5 transition-transform group-data-[state=open]:rotate-90" />
951
+ <span>{legacyEnvKeys[0]?.label}</span>
952
+ </CollapsibleTrigger>
953
+ <CollapsibleContent>
954
+ <div className="mt-2 space-y-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-4">
955
+ <p className="text-xs text-muted-foreground">
956
+ {legacyEnvKeys[0]?.helpText}
957
+ </p>
958
+ {legacyEnvKeys.map((envKey) => {
959
+ const envStatus = envStatusByKey.get(envKey.key);
960
+ const isConfigured = !!envStatus?.configured;
961
+ const helpText =
962
+ envKey.helpText ?? envStatus?.helpText;
963
+ const label =
964
+ envKey.label || envStatus?.label || envKey.key;
965
+ return (
966
+ <div key={envKey.key} className="space-y-1.5">
967
+ <div className="flex items-center justify-between gap-3">
968
+ <div className="flex items-center gap-1.5">
969
+ <label className="text-xs font-medium text-foreground">
970
+ {label}
971
+ </label>
972
+ {helpText ? (
973
+ <HelpTooltip content={helpText} />
974
+ ) : null}
975
+ </div>
976
+ <StatusPill
977
+ tone={
978
+ isConfigured ? "success" : "neutral"
979
+ }
980
+ label={isConfigured ? "Saved" : "Not set"}
981
+ />
982
+ </div>
983
+ {!isConfigured ? (
984
+ <Input
985
+ type="password"
986
+ value={envValues[envKey.key] || ""}
987
+ onChange={(event) =>
988
+ setEnvValues((current) => ({
989
+ ...current,
990
+ [envKey.key]: event.target.value,
991
+ }))
992
+ }
993
+ placeholder={`Enter ${label}`}
994
+ autoComplete="off"
995
+ />
996
+ ) : null}
997
+ </div>
998
+ );
999
+ })}
1000
+ {legacyEnvKeys.some(
1001
+ (envKey) =>
1002
+ !envStatusByKey.get(envKey.key)?.configured,
1003
+ ) ? (
1004
+ <Button
1005
+ variant="outline"
1006
+ onClick={() =>
1007
+ saveEnvKeys(
1008
+ platform,
1009
+ legacyEnvKeys.map((envKey) => envKey.key),
1010
+ )
1011
+ }
1012
+ disabled={savingKeysFor === platform.id}
1013
+ >
1014
+ {savingKeysFor === platform.id
1015
+ ? "Saving..."
1016
+ : "Save credentials"}
1017
+ </Button>
1018
+ ) : null}
1019
+ </div>
1020
+ </CollapsibleContent>
1021
+ </Collapsible>
1022
+ ) : null}
1023
+ {missingRequiredCredentials ? (
1024
+ <Button
1025
+ variant="outline"
1026
+ onClick={() =>
1027
+ saveEnvKeys(
1028
+ platform,
1029
+ envKeys.map((k) => k.key),
1030
+ )
1031
+ }
1032
+ disabled={savingKeysFor === platform.id}
1033
+ >
1034
+ {savingKeysFor === platform.id ? (
1035
+ <>
1036
+ <IconLoader2 className="mr-2 h-4 w-4 animate-spin" />
1037
+ Saving...
1038
+ </>
1039
+ ) : (
1040
+ "Save credentials"
1041
+ )}
1042
+ </Button>
1043
+ ) : null}
1044
+ </DisclosureSection>
951
1045
 
952
- {status?.webhookUrl ? (
953
- <div className="mt-4 space-y-2">
954
- <div className="text-sm font-medium text-foreground">
955
- Webhook URL
956
- </div>
957
- <div className="flex items-center gap-2">
958
- <code className="flex-1 truncate rounded-md border bg-muted/30 px-3 py-2 text-xs text-foreground">
959
- {status.webhookUrl}
960
- </code>
961
- <Button
962
- variant="outline"
963
- size="icon"
964
- onClick={() => copyWebhook(status.webhookUrl!)}
965
- aria-label={`Copy ${platform.name} webhook URL`}
1046
+ {status?.webhookUrl ? (
1047
+ <DisclosureSection
1048
+ title="Webhook URL"
1049
+ summary="Copy the endpoint for this channel"
966
1050
  >
967
- {copiedWebhook === status.webhookUrl ? (
968
- <IconCheck className="h-4 w-4" />
969
- ) : (
970
- <IconCopy className="h-4 w-4" />
971
- )}
972
- </Button>
973
- </div>
974
- </div>
975
- ) : null}
1051
+ <div className="flex items-center gap-2">
1052
+ <code className="flex-1 truncate rounded-md bg-muted/30 px-3 py-2 text-xs text-foreground">
1053
+ {status.webhookUrl}
1054
+ </code>
1055
+ <Button
1056
+ variant="outline"
1057
+ size="icon"
1058
+ onClick={() => copyWebhook(status.webhookUrl!)}
1059
+ aria-label={`Copy ${platform.name} webhook URL`}
1060
+ >
1061
+ {copiedWebhook === status.webhookUrl ? (
1062
+ <IconCheck className="h-4 w-4" />
1063
+ ) : (
1064
+ <IconCopy className="h-4 w-4" />
1065
+ )}
1066
+ </Button>
1067
+ </div>
1068
+ </DisclosureSection>
1069
+ ) : null}
976
1070
 
977
- <div className="mt-5 flex flex-wrap items-center justify-end gap-2 border-t border-border pt-4">
978
- {platform.id === "telegram" && configured ? (
979
- <Button
980
- variant="outline"
981
- onClick={() => runSetup(platform)}
982
- disabled={setupPlatform === platform.id}
1071
+ <DisclosureSection
1072
+ title="Resources"
1073
+ summary="Documentation and external links"
983
1074
  >
984
- {setupPlatform === platform.id ? (
985
- <>
986
- <IconLoader2 className="mr-2 h-4 w-4 animate-spin" />
987
- Setting up...
988
- </>
989
- ) : (
990
- "Set up webhook"
991
- )}
992
- </Button>
993
- ) : null}
994
- {!configured && !enabled ? (
995
- <Tooltip>
996
- <TooltipTrigger asChild>
997
- <span tabIndex={0}>
998
- <Button disabled>Enable</Button>
999
- </span>
1000
- </TooltipTrigger>
1001
- <TooltipContent>
1002
- Save the required credentials first.
1003
- </TooltipContent>
1004
- </Tooltip>
1005
- ) : (
1006
- <Button
1007
- onClick={() => togglePlatform(platform, enabled)}
1008
- disabled={togglingPlatform === platform.id}
1009
- >
1010
- {togglingPlatform === platform.id ? (
1011
- <>
1012
- <IconLoader2 className="mr-2 h-4 w-4 animate-spin" />
1013
- Saving...
1014
- </>
1015
- ) : enabled ? (
1016
- "Disable"
1017
- ) : (
1018
- "Enable"
1019
- )}
1020
- </Button>
1021
- )}
1022
- </div>
1023
- </section>
1075
+ <div className="flex flex-wrap gap-2">
1076
+ <Button
1077
+ asChild
1078
+ variant="ghost"
1079
+ size="sm"
1080
+ className="px-0 text-xs text-muted-foreground hover:bg-transparent hover:text-foreground"
1081
+ >
1082
+ <a
1083
+ href={platform.documentation.href}
1084
+ target="_blank"
1085
+ rel="noreferrer"
1086
+ >
1087
+ Docs
1088
+ <IconExternalLink className="ml-1 h-3 w-3" />
1089
+ </a>
1090
+ </Button>
1091
+ {platform.documentation.externalHref ? (
1092
+ <Button
1093
+ asChild
1094
+ variant="ghost"
1095
+ size="sm"
1096
+ className="px-0 text-xs text-muted-foreground hover:bg-transparent hover:text-foreground"
1097
+ >
1098
+ <a
1099
+ href={platform.documentation.externalHref}
1100
+ target="_blank"
1101
+ rel="noreferrer"
1102
+ >
1103
+ {platform.documentation.externalLabel ?? "Open"}
1104
+ <IconExternalLink className="ml-1 h-3 w-3" />
1105
+ </a>
1106
+ </Button>
1107
+ ) : null}
1108
+ </div>
1109
+ </DisclosureSection>
1110
+ </div>
1111
+ </AccordionContent>
1112
+ </AccordionItem>
1024
1113
  );
1025
1114
  })}
1026
- </div>
1115
+ </Accordion>
1027
1116
 
1028
1117
  {loading ? (
1029
1118
  <div className="rounded-2xl border border-dashed px-4 py-6 text-sm text-muted-foreground">