-
+
{blurb}
diff --git a/web/src/app/features/gsv-console/messengers/MessengerOnboardingFlow.css b/web/src/app/features/gsv-console/messengers/MessengerOnboardingFlow.css
index 6a95e094..1c93e452 100644
--- a/web/src/app/features/gsv-console/messengers/MessengerOnboardingFlow.css
+++ b/web/src/app/features/gsv-console/messengers/MessengerOnboardingFlow.css
@@ -1,165 +1,132 @@
-.gsv-messenger-onboarding {
+/* Connect messenger bot — object-detail header + a progressive-disclosure
+ wizard (one large, well-spaced step at a time). Header mirrors
+ ConsoleDetailPage so it reads as the same family as the detail pages. */
+.gsv-connect {
+ flex: 1;
+ min-width: 0; /* shrink to the console column instead of min-content */
min-height: 100%;
- display: grid;
- place-items: start center;
- padding: clamp(18px, 3vw, 34px);
+ background: transparent;
+ /* respond to the column's OWN width, not the viewport (ChatDock/rail vary it) */
+ container-type: inline-size;
+ container-name: connect;
}
-.gsv-messenger-onboarding-shell {
- width: min(760px, 100%);
- display: grid;
- gap: 18px;
+.gsv-connect-shell {
+ width: min(720px, 100%);
+ margin: 0 auto;
+ /* generous bottom room so the in-card footer clears the floating chat dock */
+ padding: 34px 26px 104px;
+ display: flex;
+ flex-direction: column;
+ gap: 28px;
}
-.gsv-messenger-onboarding-head {
- display: grid;
- grid-template-columns: auto minmax(0, 1fr) auto;
- align-items: start;
- gap: 15px;
-}
+/* Header + blurb use the shared ConsoleDetailHeader / .gsv-console-detail-blurb. */
-.gsv-messenger-onboarding-head > div {
- min-width: 0;
+/* ── Stepper ──────────────────────────────────────────────────────── */
+.gsv-connect-stepper {
+ display: flex;
+ justify-content: center;
+ padding: 6px 0 2px;
}
-.gsv-messenger-onboarding-kicker {
- display: block;
- margin-bottom: 7px;
- color: var(--accent);
- font-size: 9px;
- letter-spacing: 0.18em;
+/* ── Current step panel (large + spaced) ──────────────────────────── */
+.gsv-connect-step {
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--border-raised);
+ background: var(--panel);
+ box-shadow: inset 0 0 0 1px var(--frame-inset), 0 0 30px rgba(80, 70, 180, 0.1);
}
-.gsv-messenger-onboarding-head h2 {
- margin: 0;
- color: var(--text-hi);
- font-size: clamp(21px, 3vw, 32px);
- line-height: 1;
- letter-spacing: 0;
- text-transform: uppercase;
+.gsv-connect-step-body {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ min-height: 200px;
+ padding: 32px 36px 36px;
}
-.gsv-messenger-onboarding-head p {
- margin: 10px 0 0;
- max-width: 62ch;
+.gsv-connect-step-desc {
+ margin: 0;
+ max-width: 560px;
color: var(--label);
font-family: var(--gsv-font-prose);
- font-size: 13px;
- line-height: 1.45;
+ font-size: 14px;
+ line-height: 1.65;
+ text-wrap: pretty;
}
-.gsv-messenger-onboarding-stepper {
+.gsv-connect-step-links {
display: flex;
- justify-content: center;
- padding: 2px 0 4px;
-}
-
-.gsv-messenger-onboarding-stepper .gsv-sp,
-.gsv-messenger-onboarding-stepper .gsv-sp-step {
- pointer-events: none;
-}
-
-.gsv-messenger-flow-panel {
- display: grid;
- gap: 22px;
- padding: 0;
- overflow: hidden;
-}
-
-.gsv-messenger-flow-panel > .gsv-messenger-form-grid,
-.gsv-messenger-flow-panel > .gsv-messenger-form-note,
-.gsv-messenger-flow-panel > .gsv-messenger-form-error,
-.gsv-messenger-flow-panel > .gsv-messenger-challenge,
-.gsv-messenger-flow-panel > p {
- margin-inline: 22px;
-}
-
-.gsv-messenger-form-grid {
- display: grid;
+ flex-wrap: wrap;
gap: 18px;
+ margin-top: 4px;
}
-.gsv-messenger-form-grid .lr {
- border: 1px solid var(--rule-inner) !important;
- background: rgba(11, 8, 30, 0.48) !important;
-}
-
-.gsv-messenger-form-note,
-.gsv-messenger-form-error {
- margin: 0;
- font-family: var(--gsv-font-prose);
- font-size: 13px;
- line-height: 1.55;
+.gsv-connect-token-field {
+ margin-top: 6px;
+ max-width: 520px;
}
-.gsv-messenger-form-note {
- color: var(--online);
+/* ── Success step ─────────────────────────────────────────────────── */
+.gsv-connect-caps {
+ display: flex;
+ flex-direction: column;
}
-.gsv-messenger-form-error {
- color: var(--error);
+/* ListRow rows sit under the THINGS YOU CAN DO SectionHeader, framed like the
+ detail-page sections (border with the header capping the top). */
+.gsv-connect-caps-rows {
+ border: 1px solid var(--border);
+ border-top: none;
}
-.gsv-messenger-challenge {
- display: grid;
- gap: 16px;
- padding: 18px;
+/* ── Footer nav — lives INSIDE the card so it can never sit under the
+ floating ChatDock launcher (bottom-right of the viewport). ── */
+.gsv-connect-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 12px;
+ padding: 18px 36px 20px;
+ border-top: 1px solid var(--border);
}
-.gsv-messenger-challenge p,
-.gsv-messenger-challenge small {
- margin: 0;
- color: #9d96d8;
- font-family: var(--gsv-font-prose);
- font-size: 13px;
- line-height: 1.55;
-}
+@container connect (max-width: 680px) {
+ .gsv-connect-shell {
+ padding: 22px 16px 96px;
+ }
-.gsv-messenger-challenge img {
- width: min(280px, 100%);
- image-rendering: pixelated;
- background: #fff;
-}
+ .gsv-connect-step-body {
+ padding: 24px 20px 26px;
+ }
-.gsv-messenger-challenge pre {
- max-width: 100%;
- overflow: auto;
- margin: 0;
- padding: 14px;
- border: 1px solid var(--rule-inner);
- color: var(--text);
- background: #08061d;
- white-space: pre-wrap;
- overflow-wrap: anywhere;
-}
+ .gsv-connect-actions {
+ flex-direction: column-reverse;
+ padding: 16px 20px 18px;
+ }
-.gsv-messenger-flow-actions {
- display: flex;
- flex-wrap: wrap;
- justify-content: space-between;
- gap: 12px;
- padding: 16px 20px 20px;
- border-top: 1px solid var(--rule-inner);
+ .gsv-connect-actions .gsv-btn {
+ width: 100%;
+ }
}
-@media (max-width: 520px) {
- .gsv-messenger-onboarding {
- padding: 18px 14px;
+/* Below ~520px the four stepper labels collide — keep just the numbered dots. */
+@container connect (max-width: 520px) {
+ .gsv-connect-stepper .gsv-sp-label {
+ display: none;
}
- .gsv-messenger-onboarding-head {
- grid-template-columns: auto minmax(0, 1fr);
+ .gsv-connect-shell {
+ padding: 20px 14px 96px;
}
- .gsv-messenger-onboarding-head > span:last-child {
- grid-column: 2;
+ .gsv-connect-step-body {
+ padding: 22px 16px 24px;
}
- .gsv-messenger-flow-panel > .gsv-messenger-form-grid,
- .gsv-messenger-flow-panel > .gsv-messenger-form-note,
- .gsv-messenger-flow-panel > .gsv-messenger-form-error,
- .gsv-messenger-flow-panel > .gsv-messenger-challenge,
- .gsv-messenger-flow-panel > p {
- margin-inline: 16px;
+ .gsv-connect-actions {
+ padding: 16px 16px 18px;
}
}
diff --git a/web/src/app/features/gsv-console/messengers/MessengerOnboardingFlow.tsx b/web/src/app/features/gsv-console/messengers/MessengerOnboardingFlow.tsx
index afe962ed..870c35ee 100644
--- a/web/src/app/features/gsv-console/messengers/MessengerOnboardingFlow.tsx
+++ b/web/src/app/features/gsv-console/messengers/MessengerOnboardingFlow.tsx
@@ -1,348 +1,349 @@
-import { useMemo, useState } from "preact/hooks";
+import type { JSX } from "preact";
+import { useState } from "preact/hooks";
+import { Alert } from "../../../components/ui/Alert";
import { Button } from "../../../components/ui/Button";
-import { Checkbox } from "../../../components/ui/Checkbox";
-import { IconButton } from "../../../components/ui/IconButton";
+import { Link } from "../../../components/ui/Link";
import { ListRow } from "../../../components/ui/ListRow";
-import { Select } from "../../../components/ui/Select";
import { SectionHeader } from "../../../components/ui/SectionHeader";
import { Stepper } from "../../../components/ui/Stepper";
-import { Surface } from "../../../components/ui/Surface";
-import { Tag } from "../../../components/ui/Tag";
import { TextInput } from "../../../components/ui/TextInput";
-import type { ConnectConsoleAdapterResult } from "../backend/consoleService";
-import { listRowStatusForTone } from "../components/consoleDetailRows";
-import type { ConsoleAdapter } from "../domain/consoleModels";
-import { useConnectConsoleAdapter } from "../hooks/useConsoleData";
-import {
- adapterDetailId,
- adapterName,
- iconForAdapterName,
- statusForAdapterFamily,
- toneForAdapterFamily,
-} from "./messengerPresentation";
+import { ConsoleDetailHeader } from "../components/ConsoleDetailHeader";
+import type { ConnectConsoleAdapterResult, IdentityLinkMutationResult } from "../backend/consoleService";
+import { useConnectConsoleAdapter, useConsumeIdentityLinkCode } from "../hooks/useConsoleData";
+import { BOTFATHER_URL, DISCORD_DEVELOPER_URL, MESSENGER_CAPABILITIES, adapterDocUrl } from "./messengerDocs";
+import { adapterDetailId, adapterName, deriveAccountId, iconForAdapterName } from "./messengerPresentation";
import "./MessengerOnboardingFlow.css";
type MessengerOnboardingFlowProps = {
- adapters: readonly ConsoleAdapter[];
- initialAccountId?: string | null;
- initialAdapter?: string | null;
+ adapterId: string;
onBack: () => void;
onConnected: (detailId: string) => void;
};
-type Challenge = NonNullable
;
-
-function defaultAccountId(adapter: string): string {
- if (adapter === "discord") return "main";
- if (adapter === "telegram") return "bot";
- return "primary";
-}
+/** Step indices for the progressive-disclosure wizard. */
+const STEP_CREATE = 0;
+const STEP_TOKEN = 1;
+const STEP_CONNECT = 2;
+const STEP_LINK = 3;
function errorText(error: unknown): string {
return error instanceof Error ? error.message : error ? String(error) : "";
}
-function validUrl(value: string): boolean {
- if (!value.trim()) return true;
- try {
- const url = new URL(value);
- return url.protocol === "http:" || url.protocol === "https:";
- } catch {
- return false;
- }
-}
-
-function buildAdapterConfig(args: {
- adapter: string;
- botToken: string;
- forcePairing: boolean;
- webhookBaseUrl: string;
- webhookSecret: string;
-}): Record | undefined {
- const config: Record = {};
- if (args.adapter === "whatsapp") {
- config.force = args.forcePairing;
- }
- if ((args.adapter === "discord" || args.adapter === "telegram") && args.botToken.trim()) {
- config.botToken = args.botToken.trim();
- }
- if (args.adapter === "telegram" && args.webhookBaseUrl.trim()) {
- config.webhookBaseUrl = args.webhookBaseUrl.trim();
- }
- if (args.adapter === "telegram" && args.webhookSecret.trim()) {
- config.webhookSecret = args.webhookSecret.trim();
- }
- return Object.keys(config).length > 0 ? config : undefined;
-}
-
-function ChallengePanel({ challenge }: { challenge: Challenge }) {
- const isImage = /^data:image\//i.test(challenge.data);
- return (
-
-
- {challenge.message || "Complete the adapter authentication step, then check account status."}
- {challenge.data ? (
- isImage ? (
-
- ) : (
- {challenge.data}
- )
- ) : null}
- {challenge.expiresAt ? EXPIRES {new Date(challenge.expiresAt).toLocaleString()} : null}
-
- );
+function linkedText(result: IdentityLinkMutationResult): string {
+ return result.link
+ ? `${adapterName(result.link.adapter)} / ${result.link.actorId}`
+ : "Messenger identity";
}
export function MessengerOnboardingFlow({
- adapters,
- initialAccountId = null,
- initialAdapter = null,
+ adapterId,
onBack,
onConnected,
-}: MessengerOnboardingFlowProps) {
- const availableAdapters = adapters.filter((adapter) => adapter.available && adapter.supportsConnect);
- const selectableAdapters = availableAdapters.length > 0 ? availableAdapters : adapters;
- const initialIndex = Math.max(0, selectableAdapters.findIndex((adapter) => adapter.adapter === initialAdapter));
+}: MessengerOnboardingFlowProps): JSX.Element {
const connect = useConnectConsoleAdapter();
- const [adapterIndex, setAdapterIndex] = useState(initialIndex);
- const selectedAdapter = selectableAdapters[adapterIndex] ?? selectableAdapters[0] ?? null;
- const selectedAdapterId = selectedAdapter?.adapter ?? "";
- const initialAccount = initialAccountId?.trim() || defaultAccountId(selectedAdapterId);
- const [accountId, setAccountId] = useState(initialAccount);
- const [accountTouched, setAccountTouched] = useState(Boolean(initialAccountId?.trim()));
- const [botToken, setBotToken] = useState("");
- const [forcePairing, setForcePairing] = useState(false);
- const [webhookBaseUrl, setWebhookBaseUrl] = useState("");
- const [webhookSecret, setWebhookSecret] = useState("");
+ const consumeLinkCode = useConsumeIdentityLinkCode();
+ const [step, setStep] = useState(STEP_CREATE);
+ const [token, setToken] = useState("");
+ const [linkCode, setLinkCode] = useState("");
const [result, setResult] = useState(null);
const [formError, setFormError] = useState("");
- const webhookValid = validUrl(webhookBaseUrl);
- const canSubmit = Boolean(selectedAdapterId && accountId.trim()) && webhookValid && !connect.isPending;
- const currentStep = result?.challenge ? 2 : accountId.trim() ? 1 : 0;
- const options = selectableAdapters.map((adapter) => adapterName(adapter.adapter));
+ const [linkError, setLinkError] = useState("");
+ const [linkResultText, setLinkResultText] = useState("");
- const selectedRow = useMemo(() => selectedAdapter ? {
- icon: iconForAdapterName(selectedAdapter.adapter),
- label: adapterName(selectedAdapter.adapter),
- status: listRowStatusForTone(toneForAdapterFamily(selectedAdapter)),
- statusLabel: statusForAdapterFamily(selectedAdapter),
- sub: selectedAdapter.available
- ? `${selectedAdapter.accounts.length} configured account${selectedAdapter.accounts.length === 1 ? "" : "s"}`
- : "Adapter worker not deployed",
- } : null, [selectedAdapter]);
+ const isTelegram = adapterId === "telegram";
+ const name = adapterName(adapterId);
+ const docUrl = adapterDocUrl(adapterId);
+ const botConnected = Boolean(result?.ok && result?.connected && result?.authenticated);
+ const linked = linkResultText.length > 0;
+ const canSubmit = token.trim().length > 0 && !connect.isPending;
+ const canLinkUser = botConnected && linkCode.trim().length > 0 && !consumeLinkCode.isPending;
+ // Steps 1-2 are performed on the messaging platform; 3-4 happen inside GSV.
+ const onPlatform = step <= STEP_TOKEN;
- const selectAdapter = (index: number) => {
- const next = selectableAdapters[index] ?? null;
- setAdapterIndex(index);
- setResult(null);
- setFormError("");
- if (next && !accountTouched) {
- setAccountId(next.adapter === initialAdapter && initialAccountId?.trim() ? initialAccountId.trim() : defaultAccountId(next.adapter));
+ const goNext = () => setStep((current) => Math.min(current + 1, STEP_CONNECT));
+ const goBack = () => {
+ if (step === STEP_CREATE) {
+ onBack();
+ return;
}
+ setStep((current) => current - 1);
+ };
+ const goToStep = (target: number) => {
+ if (linked || target >= step) return;
+ setStep(target);
};
const submit = async () => {
- if (!selectedAdapter || !canSubmit) {
+ if (!canSubmit) {
return;
}
setFormError("");
- setResult(null);
try {
+ const accountId = deriveAccountId(adapterId, token.trim());
const next = await connect.mutateAsync({
- adapter: selectedAdapter.adapter,
+ adapter: adapterId,
accountId,
- config: buildAdapterConfig({
- adapter: selectedAdapter.adapter,
- botToken,
- forcePairing,
- webhookBaseUrl,
- webhookSecret,
- }),
+ config: { botToken: token.trim() },
});
- setResult(next);
- if (!next.ok) {
- setFormError(next.error || next.message);
+ if (next.ok && next.connected && next.authenticated) {
+ setResult(next);
+ setLinkCode("");
+ setLinkError("");
+ setLinkResultText("");
+ setStep(STEP_LINK);
return;
}
- if (!next.challenge) {
- onConnected(adapterDetailId({
- adapter: next.adapter,
- accountId: next.accountId,
- connected: next.connected,
- authenticated: next.authenticated,
- mode: "",
- lastActivity: null,
- error: "",
- extra: {},
- }));
+ if (next.ok) {
+ // The adapter accepted the token but the bot isn't online/authenticated
+ // yet — e.g. the Discord gateway opened before READY, or the token is
+ // invalid/revoked. Don't advance and claim a false success.
+ setResult(next);
+ setFormError(
+ next.challenge?.message ||
+ "The bot connected but isn't authenticated yet. Check the token is valid, then try again.",
+ );
+ return;
}
+ setFormError(next.error || next.message);
} catch (error) {
setFormError(errorText(error));
}
};
- if (selectableAdapters.length === 0) {
- return (
-
-
-
-
-
- No adapter workers were discovered on this GSV instance.
-
-
-
-
-
-
- );
- }
+ const submitLinkCode = async () => {
+ if (!canLinkUser) {
+ return;
+ }
+ setLinkError("");
+ setLinkResultText("");
+ try {
+ const next = await consumeLinkCode.mutateAsync({ code: linkCode });
+ setLinkCode("");
+ setLinkResultText(linkedText(next));
+ } catch (error) {
+ setLinkError(errorText(error));
+ }
+ };
+
+ const goToDetail = () => {
+ if (!result) {
+ onBack();
+ return;
+ }
+ onConnected(adapterDetailId({
+ adapter: result.adapter,
+ accountId: result.accountId,
+ connected: result.connected,
+ authenticated: result.authenticated,
+ mode: "",
+ lastActivity: null,
+ error: "",
+ extra: {},
+ }));
+ };
+
+ const stepTitle =
+ step === STEP_CREATE
+ ? "Create your GSV messenger bot"
+ : step === STEP_TOKEN
+ ? "Generate an access token"
+ : step === STEP_CONNECT
+ ? "Insert your access token"
+ : "Link your GSV user";
return (
-
-
-
+
+
+
-
-
-
+
+ Link a {name} bot to your GSV so you can check files, approve tasks, and stay in control from anywhere.
+
-
-
-
);
diff --git a/web/src/app/features/gsv-console/messengers/MessengersPage.css b/web/src/app/features/gsv-console/messengers/MessengersPage.css
new file mode 100644
index 00000000..ac7926e4
--- /dev/null
+++ b/web/src/app/features/gsv-console/messengers/MessengersPage.css
@@ -0,0 +1,206 @@
+/* Messengers roster — modelled on the Crew page: glyph-textured panel capping a
+ responsive grid of platform cards (one per supported messenger). */
+.gsv-messengers {
+ position: relative;
+ flex: 1;
+ min-width: 0;
+ min-height: 100%;
+ overflow: hidden;
+ background: var(--void);
+}
+
+.gsv-messengers::before,
+.gsv-messengers::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
+
+.gsv-messengers::before {
+ z-index: 0;
+ background-image:
+ linear-gradient(rgba(150, 140, 255, 0.04) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(150, 140, 255, 0.04) 1px, transparent 1px);
+ background-size: 46px 46px;
+}
+
+.gsv-messengers::after {
+ z-index: 1;
+ background:
+ radial-gradient(circle at 16% 13%, rgba(182, 177, 255, 0.12), transparent 1px),
+ radial-gradient(circle at 62% 8%, rgba(205, 213, 230, 0.12), transparent 1px),
+ radial-gradient(circle at 85% 30%, rgba(182, 177, 255, 0.1), transparent 1px),
+ radial-gradient(circle at 40% 52%, rgba(205, 213, 230, 0.11), transparent 1px),
+ radial-gradient(circle at 22% 78%, rgba(182, 177, 255, 0.09), transparent 1px);
+}
+
+.gsv-messengers-panel {
+ position: relative;
+ z-index: 2;
+ min-height: 100%;
+ border: 1px solid var(--border-raised);
+ background: rgba(9, 7, 26, 0.72);
+ box-shadow: inset 0 0 0 1px var(--frame-inset), 0 0 30px rgba(80, 70, 180, 0.1);
+ /* respond to the panel's own width (console column), not the viewport */
+ container-type: inline-size;
+ container-name: messengers;
+}
+
+.gsv-messengers-grid {
+ min-width: 0;
+ display: grid;
+ /* min(312px,100%) lets the track shrink below 312 in a narrow console
+ column (e.g. ChatDock open) instead of forcing horizontal scroll. */
+ grid-template-columns: repeat(auto-fill, minmax(min(312px, 100%), 1fr));
+ align-items: stretch;
+ gap: 22px;
+ padding: 26px;
+}
+
+/* ── Card ─────────────────────────────────────────────────────────── */
+.gsv-messenger-card {
+ min-width: 0;
+ min-height: 430px;
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--border);
+ background: var(--panel);
+ box-shadow: 0 0 22px rgba(60, 52, 150, 0.12);
+}
+
+.gsv-messenger-card-head {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 20px 22px;
+ border-bottom: 1px solid var(--border);
+ background: var(--header-bar);
+}
+
+.gsv-messenger-card-glyph {
+ flex: none;
+ width: 46px;
+ height: 46px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--border-raised);
+ background: var(--panel-2);
+ color: var(--accent-bright);
+}
+
+.gsv-messenger-card-heading {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-width: 0;
+ align-items: flex-start;
+}
+
+.gsv-messenger-card-name {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ font-family: var(--gsv-font-mono);
+ font-size: 15px;
+ font-weight: 700;
+ letter-spacing: 0.14em;
+ color: var(--text-hi);
+ text-shadow: 0 0 7px rgba(150, 140, 255, 0.35);
+}
+
+.gsv-messenger-card-body {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 18px 22px;
+}
+
+.gsv-messenger-card-blurb {
+ margin: 0;
+ font-family: var(--gsv-font-prose);
+ font-size: 11px;
+ line-height: 1.65;
+ letter-spacing: 0.03em;
+ color: var(--prose-dim);
+}
+
+.gsv-messenger-card-bots {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.gsv-messenger-card-bots-label {
+ font-family: var(--gsv-font-mono);
+ font-size: 9.5px;
+ letter-spacing: 0.22em;
+ color: var(--meta);
+ padding-bottom: 2px;
+}
+
+.gsv-messenger-card-bots .lr {
+ border: 1px solid var(--rule-inner);
+}
+
+.gsv-messenger-card-hint {
+ font-size: 10.5px;
+ letter-spacing: 0.06em;
+ color: var(--text-dim);
+}
+
+/* "N more messengers" → opens the dedicated per-platform page. */
+.gsv-messenger-card-more {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ margin-top: 4px;
+ padding: 9px 4px 5px;
+ background: transparent;
+ border: none;
+ border-top: 1px solid var(--rule-inner);
+ font-family: var(--gsv-font-mono);
+ font-size: 11px;
+ letter-spacing: 0.04em;
+ color: var(--link);
+ cursor: pointer;
+ transition: color 120ms ease;
+}
+
+.gsv-messenger-card-more:hover,
+.gsv-messenger-card-more:focus-visible {
+ color: var(--link-hover);
+ outline: none;
+}
+
+.gsv-messenger-card-more-chevron {
+ font-size: 15px;
+ line-height: 1;
+}
+
+/* Dedicated per-platform bot list (inside ConsoleDetailPage children slot). */
+.gsv-messenger-platform-rows {
+ border: 1px solid var(--border);
+ border-top: none;
+}
+
+.gsv-messenger-card-foot {
+ margin-top: auto;
+ padding: 16px 22px 20px;
+ border-top: 1px solid var(--border);
+}
+
+@container messengers (max-width: 680px) {
+ .gsv-messengers-grid {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 16px;
+ padding: 16px;
+ }
+
+ /* single-column cards size to content instead of a tall fixed floor */
+ .gsv-messenger-card {
+ min-height: auto;
+ }
+}
diff --git a/web/src/app/features/gsv-console/messengers/MessengersPage.tsx b/web/src/app/features/gsv-console/messengers/MessengersPage.tsx
index 0f0bef0a..21c5efc1 100644
--- a/web/src/app/features/gsv-console/messengers/MessengersPage.tsx
+++ b/web/src/app/features/gsv-console/messengers/MessengersPage.tsx
@@ -1,5 +1,12 @@
import { useState } from "preact/hooks";
-import { SettingsListPanel } from "../components/SettingsListPanel";
+import { Button } from "../../../components/ui/Button";
+import { Icon } from "../../../components/ui/Icon";
+import { ListRow, type ListRowStatus } from "../../../components/ui/ListRow";
+import { SectionHeader } from "../../../components/ui/SectionHeader";
+import { Tag, type TagTone } from "../../../components/ui/Tag";
+import { Tooltip } from "../../../components/ui/Tooltip";
+import { listRowStatusForTone } from "../components/consoleDetailRows";
+import { ConsoleDetailPage } from "../components/ConsoleDetailPage";
import {
ConsolePage,
ConsoleResourceBoundary,
@@ -22,22 +29,21 @@ import {
} from "../hooks/useConsoleData";
import { useConsoleListSelection } from "../hooks/useConsoleListSelection";
import { MessengerDetailPage } from "./MessengerDetailPage";
-import {
- linksForMessengerAccount,
-} from "./MessengerIdentityLinks";
-import { MessengerLinkCodePanel } from "./MessengerLinkCodePanel";
+import { linksForMessengerAccount } from "./MessengerIdentityLinks";
import { MessengerOnboardingFlow } from "./MessengerOnboardingFlow";
import {
+ SUPPORTED_MESSENGER_ADAPTERS,
adapterDetailId,
adapterLabel,
adapterName,
adapterSub,
+ familyStatus,
iconForAdapterName,
parseAdapterDetailId,
statusForAdapter,
- statusForAdapterFamily,
toneForAdapter,
} from "./messengerPresentation";
+import "./MessengersPage.css";
type MessengersPageProps = {
initialCreate?: boolean;
@@ -46,6 +52,35 @@ type MessengersPageProps = {
onSelectionChange?: (selection: ConsoleListSelection | null) => void;
};
+const PLATFORM_BLURB: Record = {
+ telegram: "Message your GSV from Telegram — check files, approve tasks, and stay in control from anywhere.",
+ discord: "Bring your GSV into Discord — check files, approve tasks, and stay in control from anywhere.",
+};
+
+function platformBlurb(adapter: string): string {
+ return PLATFORM_BLURB[adapter] ?? `Connect ${adapterName(adapter)} to message your GSV remotely.`;
+}
+
+function placeholderAdapter(adapter: string): ConsoleAdapter {
+ return {
+ adapter,
+ available: false,
+ supportsConnect: true,
+ supportsDisconnect: false,
+ supportsSend: false,
+ supportsStatus: false,
+ supportsShellExec: false,
+ supportsActivity: false,
+ accounts: [],
+ };
+}
+
+function supportedAdapters(inventory: readonly ConsoleAdapter[]): ConsoleAdapter[] {
+ return SUPPORTED_MESSENGER_ADAPTERS.map(
+ (id) => inventory.find((entry) => entry.adapter === id) ?? placeholderAdapter(id),
+ );
+}
+
function resourceWithLocalEmptyState(resource: ConsoleResourceState): ConsoleResourceState {
return { ...resource, isEmpty: false };
}
@@ -57,80 +92,192 @@ function linkedIdentityCountLabel(count: number): string {
return `${count} linked ${count === 1 ? "identity" : "identities"}`;
}
-function accountRows(
- adapter: ConsoleAdapter,
- identityLinks: readonly ConsoleIdentityLink[],
- onOpenDetail: (account: ConsoleAdapterAccount) => void,
-) {
- return adapter.accounts.map((account) => ({
- id: adapterDetailId(account),
- icon: iconForAdapterName(account.adapter),
- label: adapterLabel(account),
- sub: [
- adapterSub(account),
- linkedIdentityCountLabel(linksForMessengerAccount(account, identityLinks).length),
- ].filter(Boolean).join(" / "),
- tone: toneForAdapter(account),
- statusLabel: statusForAdapter(account),
- onOpen: () => onOpenDetail(account),
- }));
+function accountSub(account: ConsoleAdapterAccount, identityLinks: readonly ConsoleIdentityLink[]): string {
+ return [
+ adapterSub(account),
+ linkedIdentityCountLabel(linksForMessengerAccount(account, identityLinks).length),
+ ].filter(Boolean).join(" / ");
+}
+
+function PlatformStatusBadge({ adapter }: { adapter: ConsoleAdapter }) {
+ const info = familyStatus(adapter);
+ const badge = ;
+ return info.tooltip ? (
+ {badge}
+ ) : badge;
+}
+
+const MAX_CARD_BOTS = 2;
+
+function MessengerCard({
+ adapter,
+ identityLinks,
+ onConnect,
+ onOpenDetail,
+ onOpenPlatform,
+}: {
+ adapter: ConsoleAdapter;
+ identityLinks: readonly ConsoleIdentityLink[];
+ onConnect: (adapter: ConsoleAdapter) => void;
+ onOpenDetail: (account: ConsoleAdapterAccount) => void;
+ onOpenPlatform: (adapter: ConsoleAdapter) => void;
+}) {
+ const platform = adapterName(adapter.adapter).toUpperCase();
+ const bots = adapter.accounts;
+ const visible = bots.slice(0, MAX_CARD_BOTS);
+ const extra = bots.length - visible.length;
+
+ return (
+
+
+
+
+
{platformBlurb(adapter.adapter)}
+
+ {bots.length > 0 ? (
+
+
+ {bots.length} {bots.length === 1 ? "BOT" : "BOTS"}
+
+ {visible.map((account) => (
+
onOpenDetail(account)}
+ />
+ ))}
+ {extra > 0 ? (
+ onOpenPlatform(adapter)}>
+ {extra} more messenger{extra === 1 ? "" : "s"}
+ ›
+
+ ) : null}
+
+ ) : (
+
No bot connected yet.
+ )}
+
+
+
+
+ );
}
-function MessengersConsoleSections({
+function MessengersRoster({
adapters,
identityLinks,
- identityLinksError,
- identityLinksRefreshing,
- onOpenCreate,
+ onConnect,
onOpenDetail,
+ onOpenPlatform,
refreshing,
}: {
adapters: readonly ConsoleAdapter[];
identityLinks: readonly ConsoleIdentityLink[];
- identityLinksError?: string;
- identityLinksRefreshing: boolean;
- onOpenCreate: (adapter: ConsoleAdapter) => void;
- onOpenDetail: (adapter: ConsoleAdapterAccount) => void;
+ onConnect: (adapter: ConsoleAdapter) => void;
+ onOpenDetail: (account: ConsoleAdapterAccount) => void;
+ onOpenPlatform: (adapter: ConsoleAdapter) => void;
refreshing: boolean;
}) {
+ const platforms = supportedAdapters(adapters);
+ const connected = platforms.filter((adapter) => familyStatus(adapter).status === "connected").length;
+ const meta = refreshing
+ ? "REFRESHING"
+ : `${platforms.length} SERVICES / ${connected} CONNECTED`;
+
return (
- <>
-
- {adapters.length === 0 ? (
-
- ) : adapters.map((adapter) => {
- const connected = adapter.accounts.filter((account) => account.connected && account.authenticated && !account.error).length;
- const meta = !adapter.available
- ? statusForAdapterFamily(adapter)
- : adapter.accounts.length === 0
- ? "READY"
- : `${connected}/${adapter.accounts.length} CONNECTED`;
- return (
- onOpenCreate(adapter) }
- : adapter.available
- ? { label: "CONNECT UNSUPPORTED" }
- : { label: "ADAPTER UNAVAILABLE" }}
- />
- );
- })}
- >
+
+
+
+
+ {platforms.map((adapter) => (
+
+ ))}
+
+
+
+ );
+}
+
+/** Dedicated per-platform page listing every bot for one messenger — opened
+ * from a card's "N more messengers" affordance. Reuses the standard
+ * ConsoleDetailPage chrome (header + back + primary action). */
+function MessengerPlatformPage({
+ adapter,
+ identityLinks,
+ onBack,
+ onConnect,
+ onOpenDetail,
+}: {
+ adapter: ConsoleAdapter;
+ identityLinks: readonly ConsoleIdentityLink[];
+ onBack: () => void;
+ onConnect: (adapter: ConsoleAdapter) => void;
+ onOpenDetail: (account: ConsoleAdapterAccount) => void;
+}) {
+ const info = familyStatus(adapter);
+ const platform = adapterName(adapter.adapter).toUpperCase();
+ const total = adapter.accounts.length;
+
+ return (
+ onConnect(adapter)}
+ onBack={onBack}
+ >
+
+
);
}
@@ -173,7 +320,6 @@ export function MessengersPage({
const accounts = useConsoleAccounts({ enabled: true });
const identityLinks = useConsoleIdentityLinks({ enabled: true });
const [preferredAdapter, setPreferredAdapter] = useState(null);
- const [preferredAccount, setPreferredAccount] = useState(null);
const { selectedDetail, selectDetail } = useConsoleListSelection({
initialCreate,
initialDetailId,
@@ -182,6 +328,22 @@ export function MessengersPage({
onSelectionChange,
});
+ const openCreate = (adapter: ConsoleAdapter) => {
+ setPreferredAdapter(adapter.adapter);
+ selectDetail({ kind: "messengers", id: NEW_DETAIL_ID, createNew: true, label: `New ${adapterName(adapter.adapter)}` });
+ };
+
+ const openDetail = (account: ConsoleAdapterAccount) =>
+ selectDetail({ kind: "messengers", id: adapterDetailId(account), label: `${adapterName(account.adapter)} · ${adapterLabel(account)}` });
+
+ const openPlatform = (adapter: ConsoleAdapter) =>
+ selectDetail({ kind: "messengers", id: adapter.adapter, label: `${adapterName(adapter.adapter)} · all bots` });
+
+ const reconnect = (account: ConsoleAdapterAccount) => {
+ setPreferredAdapter(account.adapter);
+ selectDetail({ kind: "messengers", id: NEW_DETAIL_ID, createNew: true, label: `Reconnect ${adapterName(account.adapter)}` });
+ };
+
return (
{
const identityLinksError = identityLinks.resource.isError ? identityLinks.resource.errorText : undefined;
const identityLinksRefreshing = identityLinks.resource.isLoading || identityLinks.resource.isRefreshing;
- return selectedDetail?.kind === "messengers" && selectedDetail.createNew ? (
- selectDetail(null)}
- onConnected={(id) => selectDetail({ kind: "messengers", id })}
- />
- ) : selectedDetail?.kind === "messengers" && selectedDetail.id !== NEW_DETAIL_ID
- ? renderMessengerDetail(accounts.accounts, data, identityLinks.links, identityLinksError, identityLinksRefreshing, selectedDetail.id, () => selectDetail(null), (account) => {
- setPreferredAdapter(account.adapter);
- setPreferredAccount(account.accountId);
- selectDetail({ kind: "messengers", id: NEW_DETAIL_ID, createNew: true, label: `Reconnect ${adapterName(account.adapter)} · ${account.accountId}` });
- }) ?? (
- {
- setPreferredAdapter(adapter.adapter);
- setPreferredAccount(null);
- selectDetail({ kind: "messengers", id: NEW_DETAIL_ID, createNew: true, label: `New ${adapterName(adapter.adapter)}` });
- }}
- onOpenDetail={(adapter) => selectDetail({ kind: "messengers", id: adapterDetailId(adapter), label: `${adapterName(adapter.adapter)} · ${adapterLabel(adapter)}` })}
- refreshing={adapters.resource.isRefreshing}
- />
- )
- : (
- {
- setPreferredAdapter(adapter.adapter);
- setPreferredAccount(null);
- selectDetail({ kind: "messengers", id: NEW_DETAIL_ID, createNew: true, label: `New ${adapterName(adapter.adapter)}` });
- }}
- onOpenDetail={(adapter) => selectDetail({ kind: "messengers", id: adapterDetailId(adapter), label: `${adapterName(adapter.adapter)} · ${adapterLabel(adapter)}` })}
- refreshing={adapters.resource.isRefreshing}
+
+ if (selectedDetail?.kind === "messengers" && selectedDetail.createNew) {
+ return (
+ selectDetail(null)}
+ onConnected={(id) => selectDetail({ kind: "messengers", id })}
/>
);
+ }
+
+ if (selectedDetail?.kind === "messengers" && selectedDetail.id !== NEW_DETAIL_ID) {
+ const platform = SUPPORTED_MESSENGER_ADAPTERS.find((id) => id === selectedDetail.id);
+ if (platform) {
+ const target = supportedAdapters(data).find((entry) => entry.adapter === platform);
+ if (target) {
+ // No bots yet → straight to the connect flow; otherwise the
+ // dedicated full-list page for the platform.
+ if (target.accounts.length === 0) {
+ return (
+ selectDetail(null)}
+ onConnected={(id) => selectDetail({ kind: "messengers", id })}
+ />
+ );
+ }
+ return (
+ selectDetail(null)}
+ onConnect={openCreate}
+ onOpenDetail={openDetail}
+ />
+ );
+ }
+ }
+
+ const detail = renderMessengerDetail(
+ accounts.accounts,
+ data,
+ identityLinks.links,
+ identityLinksError,
+ identityLinksRefreshing,
+ selectedDetail.id,
+ () => selectDetail(null),
+ reconnect,
+ );
+ if (detail) {
+ return detail;
+ }
+ }
+
+ return (
+
+ );
}}
/>
diff --git a/web/src/app/features/gsv-console/messengers/messengerDocs.ts b/web/src/app/features/gsv-console/messengers/messengerDocs.ts
new file mode 100644
index 00000000..3c853a01
--- /dev/null
+++ b/web/src/app/features/gsv-console/messengers/messengerDocs.ts
@@ -0,0 +1,42 @@
+// External documentation per adapter (GSV adapters repo).
+export const ADAPTER_DOC_URLS: Record = {
+ telegram: "https://github.com/deathbyknowledge/gsv/tree/main/adapters/telegram",
+ discord: "https://github.com/deathbyknowledge/gsv/tree/main/adapters/discord",
+};
+
+const ADAPTERS_ROOT_URL = "https://github.com/deathbyknowledge/gsv/tree/main/adapters";
+
+// Returns the doc URL for an adapter, falling back to the adapters root.
+export function adapterDocUrl(adapter: string): string {
+ return ADAPTER_DOC_URLS[adapter.toLowerCase()] ?? ADAPTERS_ROOT_URL;
+}
+
+// Telegram BotFather (where users create a bot + get a token).
+export const BOTFATHER_URL = "https://t.me/botfather";
+// Discord developer portal (where users create a bot application + token).
+export const DISCORD_DEVELOPER_URL = "https://discord.com/developers/applications";
+
+// "Things you can do with your messenger-bot" — shown on the success step.
+export interface MessengerCapability {
+ title: string;
+ detail: string;
+}
+
+export const MESSENGER_CAPABILITIES: MessengerCapability[] = [
+ {
+ title: "Check remote files",
+ detail: "Browse and pull files from your GSV from anywhere.",
+ },
+ {
+ title: "Approve tasks remotely",
+ detail: "Review and approve pending tasks without opening the console.",
+ },
+ {
+ title: "Message your GSV",
+ detail: "Chat with your GSV and get replies straight in the messenger.",
+ },
+ {
+ title: "Stay in control",
+ detail: "Run commands and steer your GSV remotely, wherever you are.",
+ },
+];
diff --git a/web/src/app/features/gsv-console/messengers/messengerPresentation.ts b/web/src/app/features/gsv-console/messengers/messengerPresentation.ts
index f053f245..d8e88246 100644
--- a/web/src/app/features/gsv-console/messengers/messengerPresentation.ts
+++ b/web/src/app/features/gsv-console/messengers/messengerPresentation.ts
@@ -118,3 +118,151 @@ export function adapterDetailSections(adapter: ConsoleAdapterAccount): ConsoleDe
},
];
}
+
+export type AdapterFamilyStatus = "not-enabled" | "connected" | "disconnected" | "attention";
+
+export interface AdapterFamilyStatusInfo {
+ status: AdapterFamilyStatus;
+ tone: StatusTone;
+ label: string;
+ connectedCount: number;
+ disconnectedCount: number;
+ total: number;
+ tooltip: string | null;
+}
+
+export function familyStatus(adapter: ConsoleAdapter): AdapterFamilyStatusInfo {
+ return familyStatusFromAccounts(adapter.accounts, adapter.available);
+}
+
+export function familyStatusFromAccounts(
+ accounts: readonly ConsoleAdapterAccount[],
+ available = true,
+): AdapterFamilyStatusInfo {
+ const total = accounts.length;
+ const connectedCount = accounts.filter(
+ (account) => account.connected && account.authenticated && !account.error,
+ ).length;
+ const disconnectedCount = total - connectedCount;
+
+ // When the adapter worker/service binding is absent, persisted account
+ // statuses are stale — a no-longer-deployed bot must not keep showing
+ // CONNECTED across the card grid, overview, and desktop objects.
+ if (!available) {
+ return {
+ status: total > 0 ? "attention" : "not-enabled",
+ tone: total > 0 ? "warn" : "idle",
+ label: total > 0 ? "UNAVAILABLE" : "NOT ENABLED",
+ connectedCount,
+ disconnectedCount,
+ total,
+ tooltip: total > 0 ? "Adapter worker unavailable — status may be stale" : null,
+ };
+ }
+
+ if (total === 0) {
+ return {
+ status: "not-enabled",
+ tone: "idle",
+ label: "NOT ENABLED",
+ connectedCount,
+ disconnectedCount,
+ total,
+ tooltip: null,
+ };
+ }
+
+ if (connectedCount === total) {
+ return {
+ status: "connected",
+ tone: "online",
+ label: "CONNECTED",
+ connectedCount,
+ disconnectedCount,
+ total,
+ tooltip: null,
+ };
+ }
+
+ if (connectedCount === 0) {
+ return {
+ status: "disconnected",
+ tone: "error",
+ label: "DISCONNECTED",
+ connectedCount,
+ disconnectedCount,
+ total,
+ tooltip: null,
+ };
+ }
+
+ return {
+ status: "attention",
+ tone: "warn",
+ label: "ATTENTION",
+ connectedCount,
+ disconnectedCount,
+ total,
+ tooltip: `${connectedCount} connected / ${disconnectedCount} disconnected`,
+ };
+}
+
+/** Messenger platforms GSV supports, in display order. These are ALWAYS shown
+ * (as "NOT ENABLED" when no bot is connected) — there is no empty state. */
+export const SUPPORTED_MESSENGER_ADAPTERS = ["telegram", "discord"] as const;
+
+export interface MessengerFamily {
+ adapter: string;
+ accounts: ConsoleAdapterAccount[];
+ status: AdapterFamilyStatusInfo;
+}
+
+/** Group a flat list of adapter accounts into the canonical supported platforms,
+ * each with its aggregated family status. Always returns one entry per platform. */
+export function messengerFamilies(
+ accounts: readonly ConsoleAdapterAccount[],
+ inventory: readonly ConsoleAdapter[] = [],
+): MessengerFamily[] {
+ const availableByAdapter = new Map(inventory.map((entry) => [entry.adapter, entry.available]));
+ return SUPPORTED_MESSENGER_ADAPTERS.map((adapter) => {
+ const own = accounts.filter((account) => account.adapter === adapter);
+ // Absent inventory entry → assume available so we don't falsely flag
+ // platforms when callers don't supply availability info.
+ const available = availableByAdapter.get(adapter) ?? true;
+ return { adapter, accounts: own, status: familyStatusFromAccounts(own, available) };
+ });
+}
+
+export function deriveTelegramAccountId(botToken: string): string {
+ const separator = botToken.indexOf(":");
+ if (separator <= 0) {
+ return "bot";
+ }
+ const botId = botToken.slice(0, separator).trim();
+ return /^\d+$/.test(botId) ? botId : "bot";
+}
+
+/** Discord bot tokens encode the bot's user id (a snowflake) in their first
+ * dot-delimited segment as base64url. Decoding it yields a stable, per-bot
+ * account id — so connecting a second Discord bot gets its own gateway status
+ * entry and Durable Object instead of reusing/overwriting the first ("main"),
+ * while reconnecting the same bot keeps reusing its instance. */
+export function deriveDiscordAccountId(botToken: string): string {
+ const segment = botToken.split(".")[0]?.trim() ?? "";
+ if (!segment) {
+ return "bot";
+ }
+ try {
+ const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
+ const decoded = atob(padded);
+ return /^\d+$/.test(decoded) ? decoded : "bot";
+ } catch {
+ return "bot";
+ }
+}
+
+/** Pick a stable per-bot account id from the bot token for the given adapter. */
+export function deriveAccountId(adapter: string, botToken: string): string {
+ return adapter === "telegram" ? deriveTelegramAccountId(botToken) : deriveDiscordAccountId(botToken);
+}
diff --git a/web/src/app/features/gsv-console/pages/ConsoleOverviewPanels.tsx b/web/src/app/features/gsv-console/pages/ConsoleOverviewPanels.tsx
index 387aff6d..13b932e4 100644
--- a/web/src/app/features/gsv-console/pages/ConsoleOverviewPanels.tsx
+++ b/web/src/app/features/gsv-console/pages/ConsoleOverviewPanels.tsx
@@ -23,6 +23,7 @@ import {
} from "../domain/agentPresentation";
import type {
ConsoleAccount,
+ ConsoleAdapter,
ConsoleAdapterAccount,
ConsoleConfigEntry,
ConsoleMcpServer,
@@ -39,6 +40,10 @@ import {
statusForProcess,
toneForProcess,
} from "../runtime/runtimePresentation";
+import {
+ type MessengerFamily,
+ messengerFamilies,
+} from "../messengers/messengerPresentation";
type OverviewRow = {
id: string;
@@ -136,17 +141,14 @@ function targetRow(target: ConsoleTarget): OverviewRow {
};
}
-function adapterRow(adapter: ConsoleAdapterAccount): OverviewRow {
- const hasError = adapter.error.trim().length > 0;
- const connected = adapter.connected && !hasError;
- const accountLabel = adapter.accountId.trim();
+function familyRow(family: MessengerFamily): OverviewRow {
return {
- id: `${adapter.adapter}:${adapter.accountId}`,
- icon: adapter.adapter === "telegram" ? "telegram" : adapter.adapter === "discord" ? "discord" : adapter.adapter === "whatsapp" ? "messenger" : "chat",
- label: formatTokenLabel(adapter.adapter),
- meta: joinMeta([accountLabel, hasError ? adapter.error : undefined]),
- tone: connected ? "online" : hasError ? "error" : "idle",
- statusLabel: hasError ? "ERROR" : undefined,
+ id: family.adapter,
+ icon: family.adapter === "telegram" ? "telegram" : "discord",
+ label: formatTokenLabel(family.adapter),
+ tone: family.status.tone,
+ statusLabel: family.status.label,
+ meta: family.status.tooltip ?? undefined,
};
}
@@ -219,10 +221,6 @@ function sortTargets(targets: readonly ConsoleTarget[]): ConsoleTarget[] {
return [...targets].sort((left, right) => Number(right.online) - Number(left.online) || left.label.localeCompare(right.label));
}
-function sortAdapters(adapters: readonly ConsoleAdapterAccount[]): ConsoleAdapterAccount[] {
- return [...adapters].sort((left, right) => Number(right.connected) - Number(left.connected) || left.adapter.localeCompare(right.adapter));
-}
-
function sortPackages(packages: readonly ConsolePackage[]): ConsolePackage[] {
return [...packages].sort((left, right) => {
if (left.reviewPending !== right.reviewPending) return left.reviewPending ? -1 : 1;
@@ -653,6 +651,7 @@ function ModelsTasksPanel({
function FleetPanel({
adapters,
+ adapterInventory,
integrations,
onOpenListCreate,
onOpenListDetail,
@@ -660,6 +659,7 @@ function FleetPanel({
targets,
}: {
adapters: readonly ConsoleAdapterAccount[];
+ adapterInventory: readonly ConsoleAdapter[];
integrations: readonly ConsoleMcpServer[];
onOpenListCreate?: OpenListCreate;
onOpenListDetail?: OpenListDetail;
@@ -667,7 +667,7 @@ function FleetPanel({
targets: readonly ConsoleTarget[];
}) {
const targetRows = sortTargets(targets).map(targetRow);
- const adapterRows = sortAdapters(adapters).map(adapterRow);
+ const adapterRows = messengerFamilies(adapters, adapterInventory).map(familyRow);
const integrationRows = sortMcpServers(integrations).map(integrationRow);
const openList = (surface: ConsoleOverviewTarget) => onOpenSurface ? () => onOpenSurface(surface) : undefined;
const openDetail = (kind: ConsoleListKind, row: OverviewRow, surface: ConsoleOverviewTarget) => (
@@ -700,7 +700,7 @@ function FleetPanel({
title="MESSENGERS"
onClick={openList("messengers")}
/>
- {adapterRows.length === 0 ? : rowLimit(adapterRows, 3).map((row) => (
+ {rowLimit(adapterRows, 3).map((row) => (
))}
@@ -807,6 +807,7 @@ export function SettingsOverviewDashboard({
{
kind: "machines",
detailId: "hank-linux",
});
+ expect(objects.find((object) => object.id === "messengers")?.children).toHaveLength(2);
expect(objects.find((object) => object.id === "messengers")?.children[0]?.route).toEqual({
kind: "messengers",
- detailId: "discord:crew",
+ detailId: "telegram",
});
+ expect(objects.find((object) => object.id === "messengers")?.children[1]?.route).toEqual({
+ kind: "messengers",
+ detailId: "discord",
+ });
+ expect(objects.find((object) => object.id === "messengers")?.children[0]?.statusLabel).toBe("NOT ENABLED");
+ expect(objects.find((object) => object.id === "messengers")?.children[1]?.statusLabel).toBe("CONNECTED");
expect(objects.find((object) => object.id === "integrations")?.children[0]?.route).toEqual({
kind: "integrations",
detailId: "custom-mcp",
diff --git a/web/src/app/features/gsv-shell/domain/desktopObjects.ts b/web/src/app/features/gsv-shell/domain/desktopObjects.ts
index d5e48446..1a6f3af4 100644
--- a/web/src/app/features/gsv-shell/domain/desktopObjects.ts
+++ b/web/src/app/features/gsv-shell/domain/desktopObjects.ts
@@ -1,5 +1,4 @@
import type {
- ConsoleAdapterAccount,
ConsoleMcpServer,
ConsoleOverviewData,
ConsolePackage,
@@ -14,6 +13,10 @@ import type {
ShellStatus,
} from "./shellModel";
import { isNativeWebPackageName } from "../../packages/nativePackages";
+import {
+ messengerFamilies,
+ type MessengerFamily,
+} from "../../gsv-console/messengers/messengerPresentation";
type DesktopObjectSpec = {
id: DesktopObjectId;
@@ -55,8 +58,8 @@ const SPECS: Record = {
id: "messengers",
label: "MESSENGERS",
glyph: "messengers",
- singular: "adapter account",
- plural: "adapter accounts",
+ singular: "messenger",
+ plural: "messengers",
x: 67,
y: 25,
},
@@ -99,7 +102,7 @@ export function buildDesktopObjectsFromConsole(data: ConsoleOverviewData | null
},
messengers: {
id: "messengers",
- children: safeArray(data?.adapters).map(adapterToChild).sort(compareChildren),
+ children: messengerFamilies(safeArray(data?.adapters), safeArray(data?.adapterInventory)).map(familyToChild),
},
integrations: {
id: "integrations",
@@ -154,28 +157,37 @@ function targetToChild(target: ConsoleTarget): DesktopChildObject {
};
}
-function adapterToChild(adapter: ConsoleAdapterAccount): DesktopChildObject {
- const adapterLabel = formatTokenLabel(adapter.adapter) || "Adapter";
- const accountLabel = firstNonEmpty(adapter.accountId) ?? "default";
- const modeLabel = formatTokenLabel(adapter.mode).toUpperCase();
- const error = firstNonEmpty(adapter.error);
- const status = adapterStatus(adapter);
-
+function familyToChild(family: MessengerFamily): DesktopChildObject {
return {
- id: stableId("adapter", [adapter.adapter, adapter.accountId], "default"),
- label: `${adapterLabel} · ${accountLabel}`,
- type: modeLabel ? `MESSENGER · ${modeLabel}` : "MESSENGER",
- blurb: error ?? `${adapterLabel} adapter account.`,
- status: status.status,
- statusLabel: status.label,
+ id: stableId("messenger", [family.adapter], family.adapter),
+ label: formatTokenLabel(family.adapter),
+ type: "MESSENGER",
+ blurb: familyBlurb(family),
+ status: family.status.tone as ShellStatus,
+ statusLabel: family.status.label,
glyph: "messengers",
route: {
kind: "messengers",
- detailId: `${adapter.adapter}:${adapter.accountId}`,
+ detailId: family.adapter,
},
};
}
+function familyBlurb(family: MessengerFamily): string {
+ switch (family.status.status) {
+ case "not-enabled":
+ return "Not enabled. Connect a bot to start messaging.";
+ case "connected":
+ return `${family.status.connectedCount} connected.`;
+ case "disconnected":
+ return "Disconnected.";
+ case "attention":
+ return family.status.tooltip ?? "Needs attention.";
+ default:
+ return "Messenger.";
+ }
+}
+
function packageToChild(pkg: ConsolePackage, branchId: PackageBranchId): DesktopChildObject {
const status = packageStatus(pkg);
@@ -291,19 +303,6 @@ function isNativeConsolePackage(pkg: ConsolePackage): boolean {
return isNativeWebPackageName(pkg.name) || isNativeWebPackageName(pkg.packageId);
}
-function adapterStatus(adapter: ConsoleAdapterAccount): { status: ShellStatus; label: string } {
- if (firstNonEmpty(adapter.error)) {
- return { status: "error", label: "ERROR" };
- }
- if (adapter.connected === true && adapter.authenticated === true) {
- return { status: "online", label: "CONNECTED" };
- }
- if (adapter.connected === true && adapter.authenticated !== true) {
- return { status: "warn", label: "AUTH REQUIRED" };
- }
- return { status: "idle", label: "DISCONNECTED" };
-}
-
function packageStatus(pkg: ConsolePackage): { status: ShellStatus; label: string } {
if (pkg.reviewPending === true || (pkg.reviewRequired === true && pkg.reviewApprovedAt === null)) {
return { status: "update", label: "REVIEW" };
diff --git a/web/src/design-system/catalog.tsx b/web/src/design-system/catalog.tsx
index a218694d..080749c7 100644
--- a/web/src/design-system/catalog.tsx
+++ b/web/src/design-system/catalog.tsx
@@ -57,6 +57,7 @@ import agentCard from "./stories/AgentCard.story";
import crewTile from "./stories/CrewTile.story";
import agentEditor from "./stories/AgentEditor.story";
import authLayout from "./stories/AuthLayout.story";
+import link from "./stories/Link.story";
const STORIES: Story[] = [
// Foundations
@@ -106,6 +107,7 @@ const STORIES: Story[] = [
systemMessage,
tabs,
authLayout,
+ link,
// Composite
confirmModal,
agentCard,
diff --git a/web/src/design-system/stories/Link.story.tsx b/web/src/design-system/stories/Link.story.tsx
new file mode 100644
index 00000000..bb65481a
--- /dev/null
+++ b/web/src/design-system/stories/Link.story.tsx
@@ -0,0 +1,40 @@
+import { Link } from "../../app/components/ui/Link";
+import type { Story } from "../story";
+
+const story: Story = {
+ title: "Link",
+ group: "Chrome",
+ blurb: "text link · real · external / internal · arrow variant",
+ render: () => (
+
+
+
External (opens in new tab)
+
+ Open BotFather
+
+
+
+
With arrow
+
+ Read the docs
+
+
+
+
Internal route
+
+ Go to settings
+
+
+
+
Inline in prose
+
+ Generate a token, then{" "}
+ find help here{" "}
+ if you get stuck.
+
+
+
+ ),
+};
+
+export default story;
diff --git a/web/src/styles/gsv-tokens.css b/web/src/styles/gsv-tokens.css
index c1fa07fc..480e0491 100644
--- a/web/src/styles/gsv-tokens.css
+++ b/web/src/styles/gsv-tokens.css
@@ -16,6 +16,7 @@
--frame-hi: #161240; /* render-bay glow */
--field-deep: #070612; /* deep input / code / image bay */
--shadow-deep: #060414; /* inset shadow + panel-mix tint */
+ --frame-inset: #060414; /* panel inset-frame shadow ring */
/* — borders & rules — */
--border: #322e74; /* primary hairline */
@@ -37,9 +38,13 @@
--text-title: #e2dfff; /* section title */
--accent: #b3aeff; /* chevron / accent dot */
--accent-bright: #cbc7ff; /* icons / selected accent */
+ --link: #8fb6ff; /* hyperlink (bluer, stands out) */
+ --link-hover: #b8d2ff; /* hyperlink hover */
--label: #b3aee2; /* sub-label */
--node-label: #c4bfee; /* node text */
--text-muted: #8c86c8; /* muted body / secondary copy */
+ --prose-dim: #9089d4; /* dim prose / blurb body */
+ --meta: #7d78b8; /* eyebrow / meta / counts */
--text-dim: #565199; /* meta / dimmest */
/* — status — */
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 54618e65..db0a0b52 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -1,5 +1,20 @@
import { defineConfig } from "vite";
+// In dev, the SPA derives the gateway URL from its own origin (e.g. ws://localhost:5173/ws),
+// so the dev server must forward gateway traffic to the running gateway worker. Without this,
+// login fails with "WebSocket connect timed out". Override the target with GSV_GATEWAY_URL.
+const gatewayTarget = process.env.GSV_GATEWAY_URL || "http://localhost:8787";
+
+// Paths owned by the gateway worker (everything else is served by Vite).
+const gatewayPaths = ["/ws", "/oauth", "/health", "/runtime", "/public", "/.well-known"];
+
+const proxy = Object.fromEntries(
+ gatewayPaths.map((path) => [
+ path,
+ { target: gatewayTarget, changeOrigin: true, ws: path === "/ws" },
+ ]),
+);
+
export default defineConfig({
root: ".",
publicDir: "public",
@@ -14,5 +29,6 @@ export default defineConfig({
server: {
port: 5173,
open: true,
+ proxy,
},
});