feat(webui): add guided setup flows

* feat(channels): add guided setup flows

* test(channels): preserve setup config values

* fix(channels): reflect saved setup state

* refactor(channels): simplify setup state metadata

* fix(channels): harden setup lifecycle

* refactor(channels): centralize setup contracts

* fix(channels): route setup actions through webui shim

* fix(channels): adapt settings for compact screens

* fix(models): preserve default preset display

* feat(models): add curated Codex catalog

* fix(webui): stop attached gateway on interrupt

* fix(webui): simplify apps catalog

* docs(webui): clarify apps and runtime features

* feat(settings): add guided capability setup

* fix(webui): harden setup and managed services

* test: keep managed runtime checks portable

* test: scope POSIX runtime coverage

* fix(webui): simplify file settings

* feat(files): bundle document reading

* fix(webui): harden setup request boundaries

* fix(webui): prevent channel setup status squeeze

* fix(settings): group provider compatibility aliases

* refactor(settings): remove redundant setup surfaces

* fix(webui): harden guided setup lifecycle

* fix(webui): preserve channel setup compatibility
This commit is contained in:
Xubin Ren
2026-07-13 13:11:46 +08:00
committed by GitHub
parent 791c7fd505
commit fe0717b385
92 changed files with 15058 additions and 1311 deletions
+12 -11
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { useMemo } from "react";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { logoFallbackUrls } from "@/lib/provider-brand";
import type { CliAppInfo, McpPresetInfo } from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -137,16 +138,13 @@ export function CliAppMentionToken({
variant: "composer" | "message";
isHero?: boolean;
}) {
const [logoIndex, setLogoIndex] = useState(0);
const color = app.brand_color || "hsl(var(--primary))";
const mentionName = label.startsWith("@") ? label.slice(1) : label;
const logoUrls = useMemo(() => logoFallbackUrls(app.logo_url), [app.logo_url]);
const logoUrl = logoUrls[logoIndex];
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
const showLogo = Boolean(logoUrl);
const testIdPrefix = variant === "composer" ? "composer" : "message";
useEffect(() => setLogoIndex(0), [app.logo_url]);
return (
<span
data-testid={`${testIdPrefix}-cli-mention-${app.name}`}
@@ -175,7 +173,10 @@ export function CliAppMentionToken({
src={logoUrl ?? ""}
alt=""
className="h-full w-full object-contain"
onError={() => setLogoIndex((index) => index + 1)}
decoding="async"
loading="lazy"
onLoad={onLogoLoad}
onError={onLogoError}
/>
</span>
) : null}
@@ -196,16 +197,13 @@ export function McpPresetMentionToken({
variant: "composer" | "message";
isHero?: boolean;
}) {
const [logoIndex, setLogoIndex] = useState(0);
const color = preset.brand_color || "hsl(var(--primary))";
const mentionName = label.startsWith("@") ? label.slice(1) : label;
const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
const logoUrl = logoUrls[logoIndex];
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
const showLogo = Boolean(logoUrl);
const testIdPrefix = variant === "composer" ? "composer" : "message";
useEffect(() => setLogoIndex(0), [preset.logo_url]);
return (
<span
data-testid={`${testIdPrefix}-mcp-mention-${preset.name}`}
@@ -234,7 +232,10 @@ export function McpPresetMentionToken({
src={logoUrl ?? ""}
alt=""
className="h-full w-full object-contain"
onError={() => setLogoIndex((index) => index + 1)}
decoding="async"
loading="lazy"
onLoad={onLogoLoad}
onError={onLogoError}
/>
</span>
) : null}
+8 -15
View File
@@ -1,10 +1,7 @@
import {
Children,
isValidElement,
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
@@ -22,6 +19,7 @@ import {
isFilePatternReference,
isLikelyFilePath,
} from "@/components/FileReferenceChip";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { inferMediaKind } from "@/lib/media";
import { faviconUrls } from "@/lib/provider-brand";
import { remarkTexMath } from "@/lib/remark-tex-math";
@@ -304,7 +302,7 @@ function inlineLinkPreviewFromChildren(children: ReactNode): InlineLinkPreview |
}
function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
const { favicon, onFaviconError } = useFaviconFallback(link.host);
const { favicon, onFaviconError, onFaviconLoad } = useFaviconFallback(link.host);
const label = link.prefix
? `${link.prefix}${link.title}`
: link.title;
@@ -332,7 +330,9 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
src={favicon}
alt=""
className="h-3 w-3 rounded-[2px] object-contain"
decoding="async"
loading="lazy"
onLoad={onFaviconLoad}
onError={onFaviconError}
/>
) : (
@@ -348,19 +348,12 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
function useFaviconFallback(host: string) {
const faviconCandidates = useMemo(() => faviconUrls(host), [host]);
const [faviconIndex, setFaviconIndex] = useState(0);
useEffect(() => {
setFaviconIndex(0);
}, [host]);
const onFaviconError = useCallback(() => {
setFaviconIndex((index) => Math.min(index + 1, faviconCandidates.length));
}, [faviconCandidates.length]);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(faviconCandidates);
return {
favicon: faviconCandidates[faviconIndex] ?? null,
onFaviconError,
favicon: logoUrl ?? null,
onFaviconError: onLogoError,
onFaviconLoad: onLogoLoad,
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
import { cn } from "@/lib/utils";
export function ToggleButton({
checked,
disabled,
onChange,
ariaLabel,
label,
}: {
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
ariaLabel?: string;
label: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel ?? label}
disabled={disabled}
onClick={() => {
if (!disabled) onChange(!checked);
}}
className={cn(
"relative inline-flex h-[22px] w-[38px] shrink-0 items-center rounded-full p-[2px]",
"transition-colors duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
checked
? "bg-[#2997FF] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.035)]"
: "bg-muted shadow-[inset_0_0_0_1px_rgba(0,0,0,0.035)] hover:bg-muted/80",
disabled && "cursor-default opacity-60",
disabled && checked && "hover:bg-[#2997FF]",
disabled && !checked && "hover:bg-muted",
)}
>
<span
aria-hidden
className={cn(
"h-[18px] w-[18px] rounded-full bg-background shadow-[0_1px_2px_rgba(0,0,0,0.18),0_2px_7px_rgba(0,0,0,0.11)]",
"transition-transform duration-200 ease-out",
checked ? "translate-x-[16px]" : "translate-x-0",
)}
/>
<span className="sr-only">{label}</span>
</button>
);
}
@@ -0,0 +1,134 @@
import { useMemo, type ReactNode } from "react";
import type { useTranslation } from "react-i18next";
import {
CHANNEL_PRESENTATION,
type ChannelSetupPresentation,
} from "@/components/settings/channels/catalog";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { logoFallbackUrls } from "@/lib/provider-brand";
import type { NanobotFeatureInfo } from "@/lib/types";
export type ChannelFilter = "all" | "on" | "off";
export function channelSetup(feature: NanobotFeatureInfo): ChannelSetupPresentation {
return CHANNEL_PRESENTATION[feature.name]?.setup ?? {
summary:
"Enable turns on this channel in nanobot, but this integration still needs platform-specific setup before it can receive messages.",
steps: [
`Open ~/.nanobot/config.json and find channels.${feature.name}.`,
"Add the credentials required by that platform, using the channel documentation as the source of truth.",
"Restart nanobot, then send a small test message from that platform.",
],
};
}
export function ChannelLogo({
feature,
showBrandLogos,
}: {
feature: NanobotFeatureInfo;
showBrandLogos: boolean;
}) {
const presentation = CHANNEL_PRESENTATION[feature.name];
const initials = presentation?.initials ?? feature.display_name.slice(0, 2).toUpperCase();
const color = presentation?.color ?? "#6B7280";
const Icon = presentation?.icon;
const logoUrls = useMemo(() => logoFallbackUrls(presentation?.logoUrl), [presentation?.logoUrl]);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
if (showBrandLogos && logoUrl) {
return (
<span
className="grid h-10 w-10 shrink-0 place-items-center rounded-[12px] border border-border/45 bg-background"
style={{ boxShadow: `inset 0 0 0 1px ${color}22` }}
>
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className="h-5.5 w-5.5 max-h-6 max-w-6 object-contain"
onLoad={onLogoLoad}
onError={onLogoError}
/>
</span>
);
}
if (Icon) {
return (
<span
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] border border-border/45 bg-background"
style={{ color, boxShadow: `inset 0 0 0 1px ${color}18` }}
aria-hidden
>
<Icon className="h-5 w-5" strokeWidth={2.25} />
</span>
);
}
return (
<span
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] border border-border/45 bg-background text-[11px] font-bold"
style={{ color, boxShadow: `inset 0 0 0 1px ${color}18` }}
aria-hidden
>
{initials}
</span>
);
}
export function channelDisplayName(feature: NanobotFeatureInfo): string {
return CHANNEL_PRESENTATION[feature.name]?.displayName ?? feature.display_name;
}
export function channelDescription(feature: NanobotFeatureInfo, t: ReturnType<typeof useTranslation>["t"]): string {
const fallback =
CHANNEL_PRESENTATION[feature.name]?.description ??
`Use nanobot from ${channelDisplayName(feature)}.`;
return t(`settings.channels.items.${feature.name}.description`, { defaultValue: fallback });
}
export function channelRequirements(feature: NanobotFeatureInfo, t: ReturnType<typeof useTranslation>["t"]): string {
const fallback =
CHANNEL_PRESENTATION[feature.name]?.requirements ??
"Channel credentials and gateway settings";
return t(`settings.channels.items.${feature.name}.requirements`, { defaultValue: fallback });
}
export function channelMatchesFilter(feature: NanobotFeatureInfo, filter: ChannelFilter): boolean {
if (filter === "on") return feature.enabled;
if (filter === "off") return !feature.enabled;
return true;
}
export function channelStatusLabel(
feature: NanobotFeatureInfo,
tx: (key: string, fallback: string) => string,
): string {
if (feature.enabled) return tx("settings.values.on", "On");
return tx("settings.values.off", "Off");
}
export function channelSearchText(feature: NanobotFeatureInfo): string {
return [
channelDisplayName(feature),
feature.display_name,
feature.name,
feature.status,
CHANNEL_PRESENTATION[feature.name]?.description,
CHANNEL_PRESENTATION[feature.name]?.requirements,
]
.join(" ")
.toLowerCase();
}
export function ChannelStatusBadge({ children }: { children: ReactNode }) {
return (
<span className="shrink-0 rounded-full bg-muted/75 px-2 py-0.5 text-[11px] font-medium leading-4 text-muted-foreground">
{children}
</span>
);
}
@@ -0,0 +1,334 @@
import { useCallback, useEffect, useRef, useState } from "react";
import QRCode from "qrcode";
import { Check, Loader2, Network, RotateCcw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
cancelChannelConnect,
pollChannelConnect,
startChannelConnect,
} from "@/lib/api";
import type {
ChannelConnectPayload,
NanobotFeaturesPayload,
} from "@/lib/types";
export type ChannelQrConnectLabels = {
qrAlt: string;
scanTitle: string;
scanDescription: string;
waiting: string;
connected: string;
stopped: string;
connecting: string;
scanAgain: string;
connect: string;
};
export function ChannelQrConnectFlow({
token,
channelName,
startOptions = {},
idleLabel,
connectRequestId,
labels,
onFeaturesUpdate,
}: {
token: string;
channelName: "feishu" | "weixin";
startOptions?: {
domain?: "feishu" | "lark";
instanceId?: string;
mode?: "replace" | "create";
force?: boolean;
};
idleLabel?: string;
connectRequestId?: number;
labels: ChannelQrConnectLabels;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [connect, setConnect] = useState<ChannelConnectPayload | null>(null);
const [qrDataUrl, setQrDataUrl] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [handledRequestId, setHandledRequestId] = useState(0);
const pollInFlight = useRef(false);
const startDomain = startOptions.domain;
const startInstanceId = startOptions.instanceId;
const startMode = startOptions.mode;
const startForce = startOptions.force;
const pending = connect?.status === "pending";
const succeeded = connect?.status === "succeeded";
const canStart = !pending && !busy;
useEffect(() => {
if (!connect?.qr_url) {
setQrDataUrl("");
return;
}
let cancelled = false;
void QRCode.toDataURL(connect.qr_url, {
width: 184,
margin: 1,
color: { dark: "#111827", light: "#ffffff" },
})
.then((url) => {
if (!cancelled) setQrDataUrl(url);
})
.catch(() => {
if (!cancelled) setQrDataUrl("");
});
return () => {
cancelled = true;
};
}, [connect?.qr_url]);
useEffect(() => {
if (!connect?.session_id || connect.status !== "pending") return;
let cancelled = false;
const poll = async () => {
if (pollInFlight.current) return;
pollInFlight.current = true;
try {
const payload = await pollChannelConnect(token, channelName, connect.session_id);
if (cancelled) return;
setConnect((current) => ({
...(current ?? payload),
...payload,
qr_url: payload.qr_url ?? current?.qr_url,
}));
if (payload.nanobot_features) {
onFeaturesUpdate(payload.nanobot_features);
}
if (payload.status !== "pending") {
setError(null);
}
} catch (err) {
if (!cancelled) setError((err as Error).message);
} finally {
pollInFlight.current = false;
}
};
const initial = window.setTimeout(() => void poll(), 900);
const interval = window.setInterval(
() => void poll(),
Math.max(2500, connect.interval_ms ?? 5000),
);
return () => {
cancelled = true;
window.clearTimeout(initial);
window.clearInterval(interval);
};
}, [channelName, connect?.interval_ms, connect?.session_id, connect?.status, onFeaturesUpdate, token]);
const start = useCallback(async (force = false) => {
setBusy(true);
setError(null);
try {
const payload = await startChannelConnect(token, channelName, {
domain: startDomain,
instanceId: startInstanceId,
mode: startMode,
force: force || startForce,
});
setConnect(payload);
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}, [channelName, startDomain, startForce, startInstanceId, startMode, token]);
useEffect(() => {
if (!connectRequestId || connectRequestId === handledRequestId) return;
setHandledRequestId(connectRequestId);
void start();
}, [connectRequestId, handledRequestId, start]);
const cancel = async () => {
if (!connect?.session_id) {
setConnect(null);
return;
}
setBusy(true);
try {
const payload = await cancelChannelConnect(token, channelName, connect.session_id);
setConnect(payload);
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
};
return (
<div className="mt-3 space-y-3">
{pending ? (
<div className="grid gap-4 rounded-[14px] border border-border/70 p-4 sm:grid-cols-[auto_minmax(0,1fr)]">
<div className="grid h-[196px] w-[196px] place-items-center rounded-[14px] border border-border/60 bg-background">
{qrDataUrl ? (
<img
src={qrDataUrl}
alt={labels.qrAlt}
className="h-[184px] w-[184px]"
/>
) : (
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" aria-hidden />
)}
</div>
<div className="flex min-w-0 flex-col justify-center">
<div className="text-[13px] font-semibold text-foreground">
{labels.scanTitle}
</div>
<p className="mt-1 text-[12.5px] leading-5 text-muted-foreground">
{labels.scanDescription}
</p>
<div className="mt-3 flex items-center gap-2 text-[12px] text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
{labels.waiting}
</div>
<div className="mt-4 flex flex-wrap justify-end gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full px-3 text-[12px] font-semibold"
onClick={() => void cancel()}
disabled={busy}
>
{tx("settings.actions.cancel", "Cancel")}
</Button>
</div>
</div>
</div>
) : null}
{succeeded ? (
<div className="flex items-center gap-2 rounded-[12px] border border-emerald-500/20 px-3 py-2 text-[12px] font-medium text-emerald-700 dark:text-emerald-200">
<Check className="h-3.5 w-3.5" aria-hidden />
{connect.message ?? labels.connected}
</div>
) : null}
{connect && ["expired", "failed", "cancelled"].includes(connect.status) ? (
<div className="rounded-[12px] border border-border/60 px-3 py-2 text-[12px] leading-5 text-muted-foreground">
{connect.message || labels.stopped}
</div>
) : null}
{error ? (
<div className="rounded-[12px] border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
{error}
</div>
) : null}
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
onClick={() => void start(channelName === "weixin" && succeeded)}
disabled={!canStart}
>
{busy ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : succeeded ? (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
) : (
<Network className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{pending
? labels.connecting
: succeeded
? labels.scanAgain
: idleLabel ?? labels.connect}
</Button>
</div>
</div>
);
}
export function FeishuConnectFlow({
token,
instanceId = "default",
mode = "replace",
idleLabel,
connectRequestId,
onFeaturesUpdate,
}: {
token: string;
instanceId?: string;
mode?: "replace" | "create";
idleLabel?: string;
connectRequestId?: number;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<ChannelQrConnectFlow
token={token}
channelName="feishu"
startOptions={{ domain: "feishu", instanceId, mode }}
idleLabel={idleLabel}
connectRequestId={connectRequestId}
onFeaturesUpdate={onFeaturesUpdate}
labels={{
qrAlt: tx("settings.channels.feishuQrAlt", "Feishu connection QR code"),
scanTitle: tx("settings.channels.feishuScanTitle", "Scan with Feishu"),
scanDescription: tx(
"settings.channels.feishuScanDescription",
"Use Feishu or Lark on your phone to scan this code. nanobot will finish setup automatically after authorization.",
),
waiting: tx("settings.channels.feishuWaiting", "Waiting for authorization..."),
connected: tx("settings.channels.feishuConnected", "Feishu is connected."),
stopped: tx("settings.channels.feishuConnectStopped", "Connection stopped."),
connecting: tx("settings.channels.feishuConnecting", "Connecting..."),
scanAgain: tx("settings.channels.scanAgain", "Scan again"),
connect: tx("settings.channels.connect", "Connect"),
}}
/>
);
}
export function WeixinConnectFlow({
token,
idleLabel,
connectRequestId,
onFeaturesUpdate,
}: {
token: string;
idleLabel?: string;
connectRequestId?: number;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<ChannelQrConnectFlow
token={token}
channelName="weixin"
idleLabel={idleLabel}
connectRequestId={connectRequestId}
onFeaturesUpdate={onFeaturesUpdate}
labels={{
qrAlt: tx("settings.channels.weixinQrAlt", "WeChat login QR code"),
scanTitle: tx("settings.channels.weixinScanTitle", "Scan with WeChat"),
scanDescription: tx(
"settings.channels.weixinScanDescription",
"Use WeChat on your phone to scan this code. nanobot saves the account state locally after login.",
),
waiting: tx("settings.channels.weixinWaiting", "Waiting for WeChat scan..."),
connected: tx("settings.channels.weixinConnected", "WeChat is connected."),
stopped: tx("settings.channels.weixinConnectStopped", "WeChat login stopped."),
connecting: tx("settings.channels.weixinConnecting", "Connecting..."),
scanAgain: tx("settings.channels.scanAgain", "Scan again"),
connect: tx("settings.channels.connect", "Connect"),
}}
/>
);
}
@@ -0,0 +1,552 @@
import { useEffect, useMemo, useState } from "react";
import {
Check,
ChevronDown,
ChevronRight,
Clipboard,
Loader2,
Plus,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { ToggleButton } from "@/components/settings/ToggleButton";
import {
type ChannelProviderPreset,
type ChannelSetupPresentation,
} from "@/components/settings/channels/catalog";
import {
CredentialForm,
channelValuesForSubmit,
defaultChannelFieldValues,
} from "@/components/settings/channels/CredentialForm";
import {
ChannelLogo,
ChannelStatusBadge,
channelDescription,
channelDisplayName,
channelRequirements,
channelSetup,
channelStatusLabel,
} from "@/components/settings/channels/ChannelIdentity";
import {
FeishuConnectFlow,
WeixinConnectFlow,
} from "@/components/settings/channels/ChannelQrConnectFlow";
import {
ChannelProviderPresets,
ChannelSetupActions,
ChannelSetupLinks,
ChannelSetupSteps,
ChannelValidationBadge,
ChannelValidationChecks,
ChannelValidationDetails,
} from "@/components/settings/channels/ChannelSetupParts";
import { FeishuAssistantsPanel } from "@/components/settings/channels/FeishuAssistantsPanel";
import { Button } from "@/components/ui/button";
import {
configureChannel,
validateChannel,
} from "@/lib/api";
import { copyTextToClipboard } from "@/lib/clipboard";
import type {
ChannelValidationPayload,
NanobotFeatureInfo,
NanobotFeaturesPayload,
} from "@/lib/types";
import { cn } from "@/lib/utils";
export function ChannelCatalogRow({
feature,
selected,
showBrandLogos,
onSelect,
}: {
feature: NanobotFeatureInfo;
selected: boolean;
showBrandLogos: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<button
type="button"
aria-label={t("settings.channels.selectChannel", {
name: channelDisplayName(feature),
defaultValue: "View {{name}} settings",
})}
aria-pressed={selected}
onClick={onSelect}
className={cn(
"group flex w-full min-w-0 items-center gap-3 rounded-[14px] border px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border/80",
selected
? "border-border/55 bg-muted/35"
: "border-transparent hover:border-border/45 hover:bg-muted/25",
)}
>
<ChannelLogo feature={feature} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
{channelDisplayName(feature)}
</h3>
<p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">
{channelDescription(feature, t)}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ChannelStatusBadge>{channelStatusLabel(feature, tx)}</ChannelStatusBadge>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
selected && "translate-x-0.5 text-foreground",
)}
aria-hidden
/>
</div>
</button>
);
}
export function ChannelSetupPanel({
token,
feature,
actionKey,
chatAppsDocsUrl,
showBrandLogos,
onAction,
onFeaturesUpdate,
}: {
token: string;
feature: NanobotFeatureInfo;
actionKey: string | null;
chatAppsDocsUrl?: string;
showBrandLogos: boolean;
onAction: (action: "enable" | "disable", name: string) => void;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [connectRequestId, setConnectRequestId] = useState(0);
if (feature.name === "feishu") {
return (
<FeishuAssistantsPanel
token={token}
feature={feature}
showBrandLogos={showBrandLogos}
chatAppsDocsUrl={chatAppsDocsUrl}
onFeaturesUpdate={onFeaturesUpdate}
/>
);
}
const enableBusy = actionKey === `enable:${feature.name}`;
const disableBusy = actionKey === `disable:${feature.name}`;
const missingSupport = feature.enabled && !feature.installed;
const requiredWebui = feature.name === "websocket";
const channelChecked = requiredWebui || feature.enabled;
const channelBusy = enableBusy || disableBusy;
const setup = channelSetup(feature);
const needsSetupBeforeEnable =
!channelChecked
&& feature.configured === false
&& !(feature.name === "weixin" && setup.mode === "connect");
const channelToggleDisabled =
requiredWebui
|| channelBusy
|| needsSetupBeforeEnable
|| (!feature.install_supported && !feature.installed && !feature.enabled);
const installSupportLabel = tx("settings.nanobotFeatures.installSupport", "Install support");
const toggleAriaLabel = t("settings.channels.toggleChannel", {
name: channelDisplayName(feature),
defaultValue: "{{name}} channel",
});
return (
<aside className="min-h-full rounded-[20px] border border-border/80 bg-background p-5 shadow-none">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
<ChannelLogo feature={feature} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1">
<h3 className="truncate text-[18px] font-semibold leading-6 text-foreground">
{channelDisplayName(feature)}
</h3>
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">
{channelDescription(feature, t)}
</p>
{missingSupport && feature.install_supported ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={enableBusy}
onClick={() => onAction("enable", feature.name)}
className="mt-2 h-8 rounded-full px-3 text-[12px] font-semibold"
>
{enableBusy ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{installSupportLabel}
</Button>
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-2 pt-1">
<ChannelStatusBadge>{channelStatusLabel(feature, tx)}</ChannelStatusBadge>
{channelBusy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-hidden />
) : null}
<ToggleButton
checked={channelChecked}
disabled={channelToggleDisabled}
ariaLabel={toggleAriaLabel}
label={channelChecked ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
onChange={(checked) => {
if (
feature.name === "weixin"
&& checked
&& !channelChecked
&& feature.configured === false
) {
setConnectRequestId((current) => current + 1);
return;
}
onAction(checked ? "enable" : "disable", feature.name);
}}
/>
</div>
</div>
<ChannelSetupSurface
token={token}
feature={feature}
setup={setup}
chatAppsDocsUrl={chatAppsDocsUrl}
connectRequestId={connectRequestId}
onFeaturesUpdate={onFeaturesUpdate}
/>
</aside>
);
}
function ChannelSetupSurface({
token,
feature,
setup,
chatAppsDocsUrl,
connectRequestId,
onFeaturesUpdate,
}: {
token: string;
feature: NanobotFeatureInfo;
setup: ChannelSetupPresentation;
chatAppsDocsUrl?: string;
connectRequestId: number;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [notice, setNotice] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [validating, setValidating] = useState(false);
const [validation, setValidation] = useState<ChannelValidationPayload | null>(null);
const [visibleSecrets, setVisibleSecrets] = useState<Record<string, boolean>>({});
const [touchedFields, setTouchedFields] = useState<Set<string>>(() => new Set());
const configValuesKey = JSON.stringify(feature.config_values ?? {});
const configuredFields = useMemo(
() => new Set(feature.configured_fields ?? []),
[feature.configured_fields],
);
const mode = setup.mode ?? "credentials";
const fields = setup.fields ?? [];
const requiredFields = fields.filter((field) => !field.optional);
const primaryFields = requiredFields.length ? requiredFields : fields.slice(0, 1);
const optionalFields = fields.filter((field) => field.optional);
const manualFields = setup.manualFields ?? [];
const advancedFields = mode === "connect" ? manualFields : optionalFields;
const editableFields = mode === "credentials" ? fields : mode === "connect" ? manualFields : [];
const hasAdvanced = advancedFields.length > 0;
const requirements = channelRequirements(feature, t);
const summary = t(`settings.channels.items.${feature.name}.setup.summary`, {
defaultValue:
setup.summary ??
tx(
"settings.channels.setupSummary",
"Enable only turns on nanobot support. Add the platform credentials, then restart nanobot.",
),
});
const [fieldValues, setFieldValues] = useState<Record<string, string>>(() =>
defaultChannelFieldValues(editableFields, feature.config_values),
);
useEffect(() => {
setNotice(null);
setVisibleSecrets({});
setSaving(false);
setValidating(false);
setValidation(null);
setTouchedFields(new Set());
setFieldValues(defaultChannelFieldValues(editableFields, feature.config_values));
}, [configValuesKey, feature.name]);
const toggleSecret = (key: string) => {
setVisibleSecrets((current) => ({ ...current, [key]: !current[key] }));
};
const setFieldValue = (key: string, value: string) => {
setFieldValues((current) => ({ ...current, [key]: value }));
setTouchedFields((current) => new Set(current).add(key));
};
const applyPreset = (preset: ChannelProviderPreset) => {
setFieldValues((current) => ({ ...current, ...preset.values }));
setTouchedFields((current) => {
const next = new Set(current);
for (const key of Object.keys(preset.values)) next.add(key);
return next;
});
};
const copyCommand = () => {
if (!setup.command) return;
void copyTextToClipboard(setup.command).then((ok) => {
setNotice(
ok
? tx("settings.channels.commandCopied", "Command copied.")
: tx("settings.channels.commandCopyFailed", "Could not copy command."),
);
});
};
const saveCredentialSettings = async () => {
setSaving(true);
setValidating(true);
setNotice(null);
const values = channelValuesForSubmit(fields, fieldValues, touchedFields);
try {
const validationPayload = await validateChannel(token, feature.name, values);
setValidation(validationPayload);
if (!validationPayload.can_enable) {
setNotice(
validationPayload.message
?? tx("settings.channels.validationFailed", "Check the required setup before enabling."),
);
return;
}
const payload = await configureChannel(
token,
feature.name,
values,
{ enable: true },
);
if (payload.nanobot_features) {
onFeaturesUpdate(payload.nanobot_features);
}
setNotice(tx("settings.channels.checkedAndEnabled", "Checked and enabled."));
} catch (err) {
setNotice((err as Error).message);
} finally {
setSaving(false);
setValidating(false);
}
};
const checkCurrentSettings = async () => {
setValidating(true);
setNotice(null);
try {
const payload = await validateChannel(
token,
feature.name,
channelValuesForSubmit(fields, fieldValues, touchedFields),
);
setValidation(payload);
if (payload.message) setNotice(payload.message);
} catch (err) {
setNotice((err as Error).message);
} finally {
setValidating(false);
}
};
const primaryActionLabel = feature.enabled
? tx("settings.channels.checkConnection", "Check connection")
: tx("settings.channels.checkAndEnable", "Check and enable");
return (
<div className="mt-5 overflow-hidden rounded-[16px] border border-border/70 bg-background shadow-none">
<section className="px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[13px] font-semibold text-foreground">
{tx("settings.channels.requiredSetup", "Required setup")}
</div>
<div className="flex max-w-full flex-wrap justify-end gap-2">
{mode !== "webui" ? (
<ChannelValidationBadge
validation={validation}
validating={validating}
feature={feature}
/>
) : null}
{mode === "webui" ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2.5 py-1 text-[11.5px] font-medium text-emerald-700 dark:text-emerald-200">
<Check className="h-3.5 w-3.5" aria-hidden />
{tx("settings.channels.managedByWebui", "Managed by WebUI")}
</span>
) : null}
</div>
</div>
<p className="mt-1 text-[12.5px] leading-5 text-muted-foreground">{requirements}</p>
<p className="mt-3 text-[12.5px] leading-5 text-muted-foreground">{summary}</p>
<ChannelValidationDetails validation={validation} />
<ChannelSetupLinks feature={feature} setup={setup} chatAppsDocsUrl={chatAppsDocsUrl} />
<ChannelSetupActions feature={feature} setup={setup} onNotice={setNotice} />
{mode === "connect" && feature.name === "feishu" ? (
<FeishuConnectFlow
token={token}
connectRequestId={connectRequestId}
onFeaturesUpdate={onFeaturesUpdate}
/>
) : mode === "connect" && feature.name === "weixin" ? (
<WeixinConnectFlow
token={token}
idleLabel={t(`settings.channels.items.${feature.name}.setup.primaryAction`, {
defaultValue: setup.primaryActionLabel ?? tx("settings.channels.connect", "Connect"),
})}
connectRequestId={connectRequestId}
onFeaturesUpdate={onFeaturesUpdate}
/>
) : mode === "connect" ? (
<>
<div className="mt-3 flex flex-wrap justify-end gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
onClick={() =>
setNotice(
tx(
"settings.channels.connectPreview",
"The in-browser connect flow is next. For now, run the command below.",
),
)
}
>
{t(`settings.channels.items.${feature.name}.setup.primaryAction`, {
defaultValue: setup.primaryActionLabel ?? tx("settings.channels.connect", "Connect"),
})}
</Button>
{setup.command ? (
<Button
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full px-3 text-[12px] font-semibold"
onClick={copyCommand}
>
<Clipboard className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{tx("settings.channels.copyCommand", "Copy command")}
</Button>
) : null}
</div>
{setup.command ? (
<code className="mt-3 block rounded-[10px] border border-border/50 bg-muted/45 px-2.5 py-2 font-mono text-[11px] leading-5 text-foreground">
{setup.command}
</code>
) : null}
</>
) : mode === "credentials" ? (
<>
{setup.presets?.length ? (
<ChannelProviderPresets
featureName={feature.name}
presets={setup.presets}
onApply={applyPreset}
/>
) : null}
{primaryFields.length ? (
<CredentialForm
fields={primaryFields}
values={fieldValues}
configuredFields={configuredFields}
visibleSecrets={visibleSecrets}
onChange={setFieldValue}
onToggleSecret={toggleSecret}
/>
) : null}
<div className="mt-3 flex flex-wrap justify-end gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
onClick={() => void saveCredentialSettings()}
disabled={saving}
>
{saving || validating ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : null}
{primaryActionLabel}
</Button>
{feature.configured || validation ? (
<Button
type="button"
size="sm"
variant="ghost"
className="h-8 rounded-full px-3 text-[12px] font-semibold"
onClick={() => void checkCurrentSettings()}
disabled={saving || validating}
>
{tx("settings.channels.checkOnly", "Check only")}
</Button>
) : null}
</div>
</>
) : null}
</section>
{notice ? (
<div
role="status"
className="border-t border-border/60 px-4 py-3 text-[12px] leading-5 text-muted-foreground"
>
{notice}
</div>
) : null}
{setup.steps.length ? (
<ChannelSetupSteps featureName={feature.name} steps={setup.steps} tryIt={setup.tryIt} />
) : null}
{validation?.checks.length ? <ChannelValidationChecks validation={validation} /> : null}
{hasAdvanced ? (
<details className="group border-t border-border/60 px-4 py-3 text-[12px] leading-5 text-muted-foreground">
<summary className="cursor-pointer list-none text-[12px] font-semibold text-foreground">
<span className="inline-flex items-center gap-1.5">
{tx("settings.channels.advanced", "Advanced")}
<ChevronDown className="h-3.5 w-3.5 transition-transform group-open:rotate-180" aria-hidden />
</span>
</summary>
{advancedFields.length ? (
<div className="mt-3">
<CredentialForm
fields={advancedFields}
values={fieldValues}
configuredFields={configuredFields}
visibleSecrets={visibleSecrets}
onChange={setFieldValue}
onToggleSecret={toggleSecret}
compact
/>
</div>
) : null}
</details>
) : null}
</div>
);
}
@@ -0,0 +1,393 @@
import { useMemo, useState, type ReactNode } from "react";
import { Clipboard, ExternalLink, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
CHANNEL_PRESENTATION,
docsUrlWithBase,
type ChannelProviderPreset,
type ChannelSetupPresentation,
} from "@/components/settings/channels/catalog";
import {
channelValidationCheckIcon,
channelValidationCheckIconClass,
channelValidationStatusClass,
channelValidationStatusIcon,
channelValidationStatusLabel,
} from "@/components/settings/channels/CredentialForm";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { copyTextToClipboard } from "@/lib/clipboard";
import { logoFallbackUrls } from "@/lib/provider-brand";
import type {
ChannelValidationPayload,
NanobotFeatureInfo,
} from "@/lib/types";
import { cn } from "@/lib/utils";
export function ChannelGuideLink({
feature,
setup,
chatAppsDocsUrl,
compact = false,
}: {
feature: NanobotFeatureInfo;
setup: ChannelSetupPresentation;
chatAppsDocsUrl?: string;
compact?: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const presentation = CHANNEL_PRESENTATION[feature.name];
const logoUrls = useMemo(
() => logoFallbackUrls(setup.docsLogoUrl ?? presentation?.logoUrl),
[presentation?.logoUrl, setup.docsLogoUrl],
);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
const Icon = presentation?.icon;
const initials = presentation?.initials ?? feature.display_name.slice(0, 2).toUpperCase();
const color = presentation?.color ?? "#6B7280";
const docsUrl = docsUrlWithBase(setup.docsUrl, chatAppsDocsUrl);
if (!docsUrl) return null;
return (
<a
href={docsUrl}
target="_blank"
rel="noreferrer"
className={cn(
"inline-flex max-w-full items-center gap-2 border border-border/65 bg-background/90 font-semibold text-foreground shadow-sm transition-colors hover:border-border hover:bg-muted/45",
compact
? "shrink-0 rounded-full py-1 pl-1 pr-2.5 text-[11.5px]"
: "mt-3 rounded-[12px] py-1.5 pl-1.5 pr-3 text-[12px]",
)}
>
<span
className={cn(
"grid shrink-0 place-items-center overflow-hidden border border-border/45 bg-background font-bold",
compact ? "h-5 w-5 rounded-full text-[9px]" : "h-6 w-6 rounded-[7px] text-[10px]",
)}
style={{ color, boxShadow: `inset 0 0 0 1px ${color}16` }}
aria-hidden
>
{logoUrl ? (
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className={cn("object-contain", compact ? "h-3.5 w-3.5" : "h-4 w-4")}
onLoad={onLogoLoad}
onError={onLogoError}
/>
) : Icon ? (
<Icon className={compact ? "h-3 w-3" : "h-3.5 w-3.5"} strokeWidth={2.25} />
) : (
initials
)}
</span>
<span className="truncate">
{t(`settings.channels.items.${feature.name}.setup.docsLabel`, {
defaultValue: setup.docsLabel ?? tx("settings.channels.officialGuide", "Official guide"),
})}
</span>
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</a>
);
}
export function ChannelSetupLinks({
feature,
setup,
chatAppsDocsUrl,
}: {
feature: NanobotFeatureInfo;
setup: ChannelSetupPresentation;
chatAppsDocsUrl?: string;
}) {
return (
<div className="mt-3 flex flex-wrap items-center gap-2">
<ChannelOfficialLink feature={feature} setup={setup} />
<ChannelGuideLink feature={feature} setup={setup} chatAppsDocsUrl={chatAppsDocsUrl} compact />
</div>
);
}
export function ChannelOfficialLink({
feature,
setup,
}: {
feature: NanobotFeatureInfo;
setup: ChannelSetupPresentation;
}) {
const presentation = CHANNEL_PRESENTATION[feature.name];
const logoUrls = useMemo(
() => logoFallbackUrls(setup.docsLogoUrl ?? presentation?.logoUrl),
[presentation?.logoUrl, setup.docsLogoUrl],
);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
const Icon = presentation?.icon;
const color = presentation?.color ?? "#6B7280";
const label = setup.officialLabel;
if (!setup.officialUrl || !label) return null;
return (
<a
href={setup.officialUrl}
target="_blank"
rel="noreferrer"
className="inline-flex max-w-full shrink-0 items-center gap-2 rounded-full border border-border/65 bg-background/90 py-1 pl-1 pr-2.5 text-[11.5px] font-semibold text-foreground shadow-sm transition-colors hover:border-border hover:bg-muted/45"
>
<span
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-full border border-border/45 bg-background"
style={{ color, boxShadow: `inset 0 0 0 1px ${color}16` }}
aria-hidden
>
{logoUrl ? (
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className="h-3.5 w-3.5 object-contain"
onLoad={onLogoLoad}
onError={onLogoError}
/>
) : Icon ? (
<Icon className="h-3 w-3" strokeWidth={2.25} />
) : null}
</span>
<span className="truncate">{label}</span>
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</a>
);
}
export function ChannelSetupActions({
feature,
setup,
onNotice,
}: {
feature: NanobotFeatureInfo;
setup: ChannelSetupPresentation;
onNotice: (message: string | null) => void;
}) {
const { t } = useTranslation();
if (!setup.actions?.length) return null;
return (
<div className="mt-3 flex flex-wrap items-center gap-2">
{setup.actions.map((action) => (
<Button
key={action.id}
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
onClick={() => {
if (action.copyText) {
void copyTextToClipboard(action.copyText).then((ok) =>
onNotice(
ok
? t("settings.channels.helperCopied", {
name: action.label,
defaultValue: "{{name}} copied.",
})
: t("settings.channels.helperCopyFailed", {
name: action.label,
defaultValue: "Could not copy {{name}}.",
}),
),
);
}
}}
>
{action.copyText ? <Clipboard className="mr-1.5 h-3.5 w-3.5" aria-hidden /> : null}
{action.label}
</Button>
))}
<span className="sr-only">
{CHANNEL_PRESENTATION[feature.name]?.displayName ?? feature.display_name}
</span>
</div>
);
}
export function ChannelProviderPresets({
featureName,
presets,
onApply,
}: {
featureName: string;
presets: ChannelProviderPreset[];
onApply: (preset: ChannelProviderPreset) => void;
}) {
const { t } = useTranslation();
const [selected, setSelected] = useState("");
if (!presets.length) return null;
return (
<div className="mt-3">
<div className="mb-1 text-[11px] font-medium text-foreground/85">
{t(`settings.channels.items.${featureName}.providerPreset`, {
defaultValue: "Provider",
})}
</div>
<div
role="radiogroup"
aria-label={t(`settings.channels.items.${featureName}.providerPreset`, {
defaultValue: "Provider",
})}
className="grid rounded-[10px] bg-muted/75 p-0.5 text-[12px] font-medium text-muted-foreground shadow-[inset_0_0_0_1px_rgba(15,23,42,0.035)]"
style={{ gridTemplateColumns: `repeat(${presets.length}, minmax(0, 1fr))` }}
>
{presets.map((preset) => (
<button
key={preset.id}
type="button"
role="radio"
aria-checked={selected === preset.id}
onClick={() => {
setSelected(preset.id);
onApply(preset);
}}
className={cn(
"min-h-8 rounded-[8px] px-2 py-1.5 transition-colors hover:text-foreground",
selected === preset.id
&& "bg-background text-foreground shadow-[0_1px_2px_rgba(15,23,42,0.10),inset_0_0_0_1px_rgba(15,23,42,0.055)]",
)}
>
{preset.label}
</button>
))}
</div>
</div>
);
}
export function ChannelValidationBadge({
validation,
validating,
feature,
}: {
validation: ChannelValidationPayload | null;
validating: boolean;
feature: NanobotFeatureInfo;
}) {
const { t } = useTranslation();
const status = validation?.status ?? (feature.configured ? "configured" : "needs_setup");
const label = validating
? t("settings.channels.checking", { defaultValue: "Checking..." })
: channelValidationStatusLabel(status, t);
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11.5px] font-medium",
channelValidationStatusClass(status),
)}
>
{validating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
channelValidationStatusIcon(status)
)}
{label}
</span>
);
}
export function ChannelValidationDetails({ validation }: { validation: ChannelValidationPayload | null }) {
const message = validation?.message;
if (!validation?.identity?.name && !message) return null;
return (
<div className="mt-2 truncate text-[11.5px] text-muted-foreground">
{validation?.identity?.name
? validation.identity.workspace
? `${validation.identity.name} · ${validation.identity.workspace}`
: validation.identity.name
: message}
</div>
);
}
export function ChannelValidationChecks({ validation }: { validation: ChannelValidationPayload }) {
if (!validation.checks.length) return null;
return (
<div className="border-t border-border/60 px-4 py-4">
<div className="mb-2 text-[12px] font-semibold text-foreground">Connection checks</div>
<div className="space-y-2">
{validation.checks.slice(0, 6).map((check) => (
<div key={check.id} className="flex gap-2 text-[12px] leading-5">
<span className={cn("mt-0.5", channelValidationCheckIconClass(check.status))}>
{channelValidationCheckIcon(check.status)}
</span>
<div className="min-w-0 flex-1">
<div className="font-medium text-foreground/85">{check.label}</div>
{check.message ? (
<div className="text-muted-foreground">{check.message}</div>
) : null}
{check.action_url ? (
<a
href={check.action_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-foreground underline decoration-border underline-offset-4"
>
Open
<ExternalLink className="h-3 w-3" aria-hidden />
</a>
) : null}
</div>
</div>
))}
</div>
</div>
);
}
export function ChannelSetupSteps({
featureName,
steps,
action,
tryIt,
}: {
featureName: string;
steps: string[];
action?: ReactNode;
tryIt?: string;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<div className="border-t border-border/60 px-4 py-4 text-[12.5px] leading-5 text-muted-foreground">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-[12px] font-semibold text-foreground">
{tx("settings.channels.setupSteps", "Next steps")}
</div>
{action}
</div>
<ol className="space-y-1.5">
{steps.map((step, index) => (
<li key={step} className="flex gap-2">
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-background text-[10px] font-semibold text-muted-foreground shadow-sm">
{index + 1}
</span>
<span>
{t(`settings.channels.items.${featureName}.setup.steps.${index}`, {
defaultValue: step,
})}
</span>
</li>
))}
</ol>
{tryIt ? (
<div className="mt-3 rounded-[12px] border border-border/55 bg-background px-3 py-2 text-[12px] text-muted-foreground">
<span className="font-medium text-foreground">
{tx("settings.channels.tryIt", "Try it")}
</span>
<span className="ml-2">
{t(`settings.channels.items.${featureName}.setup.tryIt`, { defaultValue: tryIt })}
</span>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,231 @@
import type { ReactNode } from "react";
import { Check, CircleAlert, Eye, EyeOff, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import type { ChannelConfigField } from "@/components/settings/channels/catalog";
import { cn } from "@/lib/utils";
export function channelFieldValue(field: ChannelConfigField, values: Record<string, string>): string {
return values[field.key] ?? field.defaultValue ?? field.options?.[0]?.value ?? "";
}
export function defaultChannelFieldValues(
fields: ChannelConfigField[],
configValues: Record<string, string> | undefined = undefined,
): Record<string, string> {
return Object.fromEntries(
fields.map((field) => [
field.key,
configValues?.[field.key] ?? field.defaultValue ?? field.options?.[0]?.value ?? "",
]),
);
}
export function channelValuesForSave(
fields: ChannelConfigField[],
values: Record<string, string>,
): Record<string, string> {
const payload: Record<string, string> = {};
for (const field of fields) {
const value = channelFieldValue(field, values);
if (field.secret && !value.trim()) continue;
payload[field.key] = value;
}
return payload;
}
export function channelValuesForSubmit(
fields: ChannelConfigField[],
values: Record<string, string>,
touchedFields: Set<string>,
): Record<string, string> {
const payload: Record<string, string> = {};
for (const field of fields) {
const touched = touchedFields.has(field.key);
const value = channelFieldValue(field, values);
if (field.secret && !value.trim()) continue;
if (!touched && !value.trim()) continue;
if (!touched && field.options?.length) continue;
payload[field.key] = value;
}
return payload;
}
export function channelValidationStatusLabel(
status: string,
t: ReturnType<typeof useTranslation>["t"],
): string {
const labels: Record<string, string> = {
connected: "Connected",
configured: "Configured manually",
needs_setup: "Needs setup",
invalid: "Invalid",
unsupported: "Manual setup",
};
return t(`settings.channels.validation.${status}`, {
defaultValue: labels[status] ?? "Checked",
});
}
export function channelValidationStatusClass(status: string): string {
if (status === "connected") {
return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-200";
}
if (status === "configured") {
return "bg-blue-500/10 text-blue-700 dark:text-blue-200";
}
if (status === "invalid") {
return "bg-destructive/10 text-destructive";
}
return "bg-muted text-muted-foreground";
}
export function channelValidationStatusIcon(status: string): ReactNode {
if (status === "connected" || status === "configured") {
return <Check className="h-3.5 w-3.5" aria-hidden />;
}
if (status === "invalid") {
return <X className="h-3.5 w-3.5" aria-hidden />;
}
return <CircleAlert className="h-3.5 w-3.5" aria-hidden />;
}
export function channelValidationCheckIcon(status: string): ReactNode {
if (status === "pass") return <Check className="h-3.5 w-3.5" aria-hidden />;
if (status === "fail") return <X className="h-3.5 w-3.5" aria-hidden />;
if (status === "warn") return <CircleAlert className="h-3.5 w-3.5" aria-hidden />;
return <CircleAlert className="h-3.5 w-3.5" aria-hidden />;
}
export function channelValidationCheckIconClass(status: string): string {
if (status === "pass") return "text-emerald-600";
if (status === "fail") return "text-destructive";
if (status === "warn") return "text-amber-600";
return "text-muted-foreground";
}
export function CredentialForm({
fields,
values,
configuredFields,
visibleSecrets,
onChange,
onToggleSecret,
compact = false,
}: {
fields: ChannelConfigField[];
values: Record<string, string>;
configuredFields?: Set<string>;
visibleSecrets: Record<string, boolean>;
onChange: (key: string, value: string) => void;
onToggleSecret: (key: string) => void;
compact?: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<div className={cn(compact ? "space-y-2.5" : "mt-3 space-y-2.5")}>
{fields.map((field) => {
const visible = Boolean(visibleSecrets[field.key]);
const value = values[field.key] ?? "";
const savedSecret = Boolean(field.secret && configuredFields?.has(field.key) && !value.trim());
const showSecretToggle = Boolean(field.secret && value.trim());
const inputType = field.secret && !visible ? "password" : field.inputType ?? "text";
const selectedOption = channelFieldValue(field, values);
const header = (
<span className="flex items-center justify-between gap-2 text-[11px] font-medium text-foreground/85">
<span>{field.label}</span>
{savedSecret ? (
<span className="font-normal text-muted-foreground">
{tx("settings.channels.savedSecret", "Saved")}
</span>
) : field.optional && !compact ? (
<span className="font-normal text-muted-foreground">
{tx("settings.channels.optional", "Optional")}
</span>
) : null}
</span>
);
const help = field.help ? (
<span className="mt-1 block text-[11px] leading-4 text-muted-foreground">
{field.help}
</span>
) : null;
if (field.options?.length) {
return (
<div key={field.key} className="block">
{header}
<span
role="radiogroup"
aria-label={field.label}
className="mt-1 grid rounded-[10px] bg-muted/75 p-0.5 text-[12px] font-medium text-muted-foreground shadow-[inset_0_0_0_1px_rgba(15,23,42,0.035)]"
style={{ gridTemplateColumns: `repeat(${field.options.length}, minmax(0, 1fr))` }}
>
{field.options.map((option) => (
<button
key={option.value}
type="button"
role="radio"
aria-checked={selectedOption === option.value}
onClick={() => onChange(field.key, option.value)}
className={cn(
"min-h-8 rounded-[8px] px-2 py-1.5 transition-colors hover:text-foreground",
selectedOption === option.value
&& "bg-background text-foreground shadow-[0_1px_2px_rgba(15,23,42,0.10),inset_0_0_0_1px_rgba(15,23,42,0.055)]",
)}
>
{option.label}
</button>
))}
</span>
{help}
</div>
);
}
return (
<label key={field.key} className="block">
{header}
<span className="relative mt-1 block">
<Input
aria-label={field.label}
type={inputType}
inputMode={field.inputType === "number" ? "numeric" : undefined}
placeholder={
savedSecret
? tx("settings.channels.savedSecretPlaceholder", "Saved secret")
: field.placeholder
}
value={values[field.key] ?? ""}
onChange={(event) => onChange(field.key, event.target.value)}
className={cn(
"h-9 rounded-[10px] border-border/60 bg-muted/35 text-[13px]",
showSecretToggle && "pr-9",
)}
/>
{showSecretToggle ? (
<button
type="button"
aria-label={
visible
? tx("settings.channels.hideSecret", "Hide secret")
: tx("settings.channels.showSecret", "Show secret")
}
onClick={() => onToggleSecret(field.key)}
className="absolute right-2 top-1/2 grid h-6 w-6 -translate-y-1/2 place-items-center rounded-full text-muted-foreground hover:bg-background hover:text-foreground"
>
{visible ? (
<EyeOff className="h-3.5 w-3.5" aria-hidden />
) : (
<Eye className="h-3.5 w-3.5" aria-hidden />
)}
</button>
) : null}
</span>
{help}
</label>
);
})}
</div>
);
}
@@ -0,0 +1,478 @@
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, Loader2, RotateCcw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ToggleButton } from "@/components/settings/ToggleButton";
import {
CHANNEL_PRESENTATION,
type ChannelConfigField,
} from "@/components/settings/channels/catalog";
import {
CredentialForm,
channelValidationStatusClass,
channelValidationStatusIcon,
channelValuesForSave,
defaultChannelFieldValues,
} from "@/components/settings/channels/CredentialForm";
import {
ChannelLogo,
ChannelStatusBadge,
channelDisplayName,
channelSetup,
channelStatusLabel,
} from "@/components/settings/channels/ChannelIdentity";
import { FeishuConnectFlow } from "@/components/settings/channels/ChannelQrConnectFlow";
import {
ChannelGuideLink,
ChannelSetupSteps,
} from "@/components/settings/channels/ChannelSetupParts";
import { Button } from "@/components/ui/button";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import {
configureChannel,
disableNanobotFeature,
enableNanobotFeature,
} from "@/lib/api";
import { logoFallbackUrls } from "@/lib/provider-brand";
import type {
NanobotChannelInstanceInfo,
NanobotFeatureInfo,
NanobotFeaturesPayload,
} from "@/lib/types";
import { cn } from "@/lib/utils";
export function FeishuAssistantsPanel({
token,
feature,
showBrandLogos,
chatAppsDocsUrl,
onFeaturesUpdate,
}: {
token: string;
feature: NanobotFeatureInfo;
showBrandLogos: boolean;
chatAppsDocsUrl?: string;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const instances = feishuFeatureInstances(feature);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [busyInstanceId, setBusyInstanceId] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const selected = selectedId ? instances.find((instance) => instance.id === selectedId) : undefined;
const setup = channelSetup(feature);
const manualFields = setup.manualFields ?? [];
const [fieldValues, setFieldValues] = useState<Record<string, string>>(() =>
feishuInstanceFieldValues(manualFields, selected),
);
const [visibleSecrets, setVisibleSecrets] = useState<Record<string, boolean>>({});
const [savingFields, setSavingFields] = useState(false);
const connectedAssistantCount = instances.filter((instance) => instance.configured).length;
useEffect(() => {
if (selectedId && !instances.some((instance) => instance.id === selectedId)) {
setSelectedId(null);
}
}, [instances, selectedId]);
useEffect(() => {
setFieldValues(feishuInstanceFieldValues(manualFields, selected));
setVisibleSecrets({});
}, [
manualFields,
selected?.allow_from,
selected?.app_id,
selected?.domain,
selected?.group_policy,
selected?.id,
]);
const toggleInstance = async (instance: NanobotChannelInstanceInfo, checked: boolean) => {
setBusyInstanceId(instance.id);
setNotice(null);
try {
const payload = checked
? await enableNanobotFeature(token, "feishu", { instanceId: instance.id })
: await disableNanobotFeature(token, "feishu", { instanceId: instance.id });
onFeaturesUpdate(payload);
} catch (err) {
setNotice((err as Error).message);
} finally {
setBusyInstanceId(null);
}
};
const reconnectInstance = async (instance: NanobotChannelInstanceInfo) => {
setBusyInstanceId(instance.id);
setNotice(null);
try {
const payload = await enableNanobotFeature(token, "feishu", { instanceId: instance.id });
onFeaturesUpdate(payload);
} catch (err) {
setNotice((err as Error).message);
} finally {
setBusyInstanceId(null);
}
};
const saveSelectedInstanceSettings = async () => {
if (!selected) return;
setSavingFields(true);
setNotice(null);
try {
const payload = await configureChannel(
token,
"feishu",
channelValuesForSave(manualFields, fieldValues),
{ enable: selected.enabled, instanceId: selected.id },
);
if (payload.nanobot_features) {
onFeaturesUpdate(payload.nanobot_features);
}
setNotice(tx("settings.channels.savedSettings", "Saved settings."));
} catch (err) {
setNotice((err as Error).message);
} finally {
setSavingFields(false);
}
};
return (
<aside className="min-h-full rounded-[20px] border border-border/80 bg-background p-5 shadow-none">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<ChannelLogo feature={feature} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1">
<h3 className="truncate text-[18px] font-semibold leading-6 text-foreground">
{channelDisplayName(feature)}
</h3>
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">
{feishuAssistantCountLabel(connectedAssistantCount, tx)}
</p>
</div>
</div>
<ChannelStatusBadge>{channelStatusLabel(feature, tx)}</ChannelStatusBadge>
</div>
<div className="mt-5 space-y-3">
{instances.map((instance) => {
const expanded = selected?.id === instance.id;
return (
<article
key={instance.id}
className={cn(
"overflow-hidden rounded-[18px] border transition-colors",
expanded
? "border-border/75 bg-card/95 shadow-sm"
: "border-border/55 bg-background hover:border-border/75 hover:bg-muted/15",
)}
>
<div className="flex items-center gap-3 px-3 py-3">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 text-left"
onClick={() =>
setSelectedId((current) => (current === instance.id ? null : instance.id))
}
aria-expanded={expanded}
>
<FeishuAssistantAvatar
feature={feature}
instance={instance}
showBrandLogos={showBrandLogos}
size="lg"
/>
<span className="min-w-0 flex-1 truncate text-[13px] font-semibold text-foreground">
{feishuInstanceDisplayName(instance)}
</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
expanded && "rotate-180",
)}
aria-hidden
/>
</button>
<div className="flex shrink-0 items-center gap-2">
{busyInstanceId === instance.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-hidden />
) : null}
<ToggleButton
checked={instance.enabled}
disabled={busyInstanceId === instance.id || !instance.configured}
ariaLabel={t("settings.channels.toggleFeishuAssistant", {
name: feishuInstanceDisplayName(instance),
defaultValue: "{{name}} assistant",
})}
label={instance.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
onChange={(checked) => void toggleInstance(instance, checked)}
/>
</div>
</div>
{expanded ? (
<div className="border-t border-border/60">
<section className="px-4 py-4">
<div className="mb-3 flex items-start justify-between gap-3">
<p className="min-w-0 flex-1 truncate font-mono text-[11.5px] leading-6 text-muted-foreground">
{maskFeishuAppId(instance.app_id) || tx("settings.channels.noAppId", "No App ID")}
</p>
<FeishuAssistantConnectionBadge instance={instance} />
</div>
{instance.configured ? (
<div className="mt-3 flex justify-end">
<Button
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
onClick={() => void reconnectInstance(instance)}
disabled={busyInstanceId === instance.id || !instance.enabled}
>
{busyInstanceId === instance.id ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{tx("settings.channels.reconnectAssistant", "Reconnect")}
</Button>
</div>
) : (
<FeishuConnectFlow
key={`connect-${instance.id}`}
token={token}
instanceId={instance.id}
mode="replace"
idleLabel={tx("settings.channels.connect", "Connect")}
onFeaturesUpdate={onFeaturesUpdate}
/>
)}
</section>
<ChannelSetupSteps
featureName={feature.name}
steps={setup.steps}
action={
<ChannelGuideLink
feature={feature}
setup={setup}
chatAppsDocsUrl={chatAppsDocsUrl}
compact
/>
}
/>
{manualFields.length ? (
<details className="group border-t border-border/60 px-4 py-3 text-[12px] leading-5 text-muted-foreground">
<summary className="cursor-pointer list-none text-[12px] font-semibold text-foreground">
<span className="inline-flex items-center gap-1.5">
{tx("settings.channels.advanced", "Advanced")}
<ChevronDown
className="h-3.5 w-3.5 transition-transform group-open:rotate-180"
aria-hidden
/>
</span>
</summary>
<div className="mt-3">
<CredentialForm
fields={manualFields}
values={fieldValues}
visibleSecrets={visibleSecrets}
onChange={(key, value) =>
setFieldValues((current) => ({ ...current, [key]: value }))
}
onToggleSecret={(key) =>
setVisibleSecrets((current) => ({ ...current, [key]: !current[key] }))
}
compact
/>
<div className="mt-3 flex justify-end">
<Button
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
onClick={() => void saveSelectedInstanceSettings()}
disabled={savingFields}
>
{savingFields ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : null}
{tx("settings.channels.saveSettings", "Save settings")}
</Button>
</div>
</div>
</details>
) : null}
</div>
) : null}
</article>
);
})}
</div>
<div className="mt-4 overflow-hidden rounded-[16px] border border-border/70 bg-background px-4 py-4">
<div className="text-[13px] font-semibold text-foreground">
{tx("settings.channels.createFeishuAssistant", "Create another assistant")}
</div>
<p className="mt-1 text-[12.5px] leading-5 text-muted-foreground">
{tx(
"settings.channels.createFeishuAssistantHint",
"Create a separate Feishu bot for another team, space, or workflow.",
)}
</p>
<FeishuConnectFlow
key="create-feishu-assistant"
token={token}
instanceId="default"
mode="create"
idleLabel={tx("settings.channels.createAssistant", "Create assistant")}
onFeaturesUpdate={onFeaturesUpdate}
/>
</div>
{notice ? (
<div className="mt-3 rounded-[12px] border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
{notice}
</div>
) : null}
</aside>
);
}
function feishuFeatureInstances(feature: NanobotFeatureInfo): NanobotChannelInstanceInfo[] {
if (feature.instances?.length) return feature.instances;
return [{
id: "default",
name: "nanobot",
domain: "feishu",
enabled: feature.enabled,
configured: Boolean(feature.configured),
app_id: "",
}];
}
function feishuAssistantCountLabel(
count: number,
tx: (key: string, fallback: string) => string,
): string {
if (count === 0) {
return tx("settings.channels.noFeishuAssistants", "No assistant connected");
}
if (count === 1) {
return tx("settings.channels.oneFeishuAssistant", "1 assistant connected");
}
return tx("settings.channels.manyFeishuAssistants", `${count} assistants connected`);
}
function feishuInstanceDisplayName(instance: NanobotChannelInstanceInfo): string {
const displayName = instance.display_name?.trim();
if (displayName) return displayName;
const localName = instance.name?.trim();
if (localName) return localName;
return instance.id === "default" ? "nanobot" : "nanobot";
}
function FeishuAssistantConnectionBadge({ instance }: { instance: NanobotChannelInstanceInfo }) {
const { t } = useTranslation();
const status = instance.configured ? "connected" : "needs_setup";
const label = instance.configured
? t("settings.channels.feishuConfigured", { defaultValue: "Connected" })
: t("settings.channels.feishuNotConfigured", { defaultValue: "Needs authorization" });
return (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-[11.5px] font-medium",
channelValidationStatusClass(status),
)}
>
{channelValidationStatusIcon(status)}
{label}
</span>
);
}
function FeishuAssistantAvatar({
feature,
instance,
showBrandLogos,
size,
}: {
feature: NanobotFeatureInfo;
instance: NanobotChannelInstanceInfo;
showBrandLogos: boolean;
size: "sm" | "lg";
}) {
const presentation = CHANNEL_PRESENTATION[feature.name];
const [avatarFailed, setAvatarFailed] = useState(false);
const fallbackLogoUrls = useMemo(() => logoFallbackUrls(presentation?.logoUrl), [presentation?.logoUrl]);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(fallbackLogoUrls);
const remoteAvatarUrl = !avatarFailed ? instance.avatar_url?.trim() : "";
const imageUrl = remoteAvatarUrl || (showBrandLogos ? logoUrl : "");
const Icon = presentation?.icon;
const initials = presentation?.initials ?? feature.display_name.slice(0, 2).toUpperCase();
const color = presentation?.color ?? "#3370FF";
const frameClass = size === "lg" ? "h-11 w-11" : "h-9 w-9";
const fallbackImageClass = size === "lg" ? "h-6 w-6" : "h-5 w-5";
const iconClass = size === "lg" ? "h-5 w-5" : "h-4 w-4";
useEffect(() => {
setAvatarFailed(false);
}, [instance.avatar_url]);
return (
<span
className={cn(
"grid shrink-0 place-items-center overflow-hidden rounded-full border border-border/45 bg-background text-[10px] font-bold",
frameClass,
)}
style={{ color, boxShadow: `inset 0 0 0 1px ${color}18` }}
aria-hidden
>
{remoteAvatarUrl ? (
<img
src={remoteAvatarUrl}
alt=""
decoding="async"
loading="lazy"
className="h-full w-full object-cover"
onError={() => setAvatarFailed(true)}
/>
) : imageUrl ? (
<img
src={imageUrl}
alt=""
decoding="async"
loading="lazy"
className={cn("object-contain", fallbackImageClass)}
onLoad={onLogoLoad}
onError={onLogoError}
/>
) : Icon ? (
<Icon className={iconClass} strokeWidth={2.25} />
) : (
initials
)}
</span>
);
}
function maskFeishuAppId(appId: string | undefined): string {
if (!appId) return "";
if (appId.length <= 10) return appId;
return `${appId.slice(0, 7)}...${appId.slice(-4)}`;
}
function feishuInstanceFieldValues(
fields: ChannelConfigField[],
instance: NanobotChannelInstanceInfo | undefined,
): Record<string, string> {
const values = defaultChannelFieldValues(fields);
if (!instance) return values;
values["channels.feishu.appId"] = instance.app_id ?? "";
values["channels.feishu.appSecret"] = "";
values["channels.feishu.domain"] = instance.domain ?? values["channels.feishu.domain"] ?? "feishu";
values["channels.feishu.groupPolicy"] =
instance.group_policy ?? values["channels.feishu.groupPolicy"] ?? "mention";
values["channels.feishu.allowFrom"] = (instance.allow_from ?? []).join(", ");
return values;
}
File diff suppressed because it is too large Load Diff
@@ -30,6 +30,7 @@ import {
type ActivityEvidence,
} from "@/lib/activity-timeline";
import { useFileEditDisplayMode } from "@/hooks/useFileEditDisplayMode";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { hasRenderableFileDiff } from "@/lib/file-diff";
import type { FileEditDisplayMode } from "@/lib/local-preferences";
import { faviconUrls, logoFallbackUrls } from "@/lib/provider-brand";
@@ -943,10 +944,12 @@ function TraceIconMark({
fallbackIcon: LucideIcon;
active: boolean;
}) {
const [faviconIndex, setFaviconIndex] = useState(0);
const faviconUrl = trace.host ? faviconUrls(trace.host)[faviconIndex] : undefined;
useEffect(() => setFaviconIndex(0), [trace.host]);
const faviconCandidates = useMemo(() => (trace.host ? faviconUrls(trace.host) : []), [trace.host]);
const {
logoUrl: faviconUrl,
onLogoError: onFaviconError,
onLogoLoad: onFaviconLoad,
} = useLogoFallback(faviconCandidates);
if (trace.url && trace.host && faviconUrl) {
return (
@@ -962,7 +965,10 @@ function TraceIconMark({
src={faviconUrl}
alt=""
className="h-3.5 w-3.5 object-contain"
onError={() => setFaviconIndex((index) => index + 1)}
decoding="async"
loading="lazy"
onLoad={onFaviconLoad}
onError={onFaviconError}
/>
</span>
);
@@ -1661,19 +1667,16 @@ function CliRunGroup({
function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean; app?: CliAppInfo }) {
const { t } = useTranslation();
const [logoIndex, setLogoIndex] = useState(0);
const args = formatCliArgs(run);
const failed = run.status === "error";
const rowActive = active && run.status === "running";
const color = failed ? "#DC2626" : app?.brand_color || "#0891B2";
const logoUrls = useMemo(() => logoFallbackUrls(app?.logo_url), [app?.logo_url]);
const logoUrl = logoUrls[logoIndex];
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
const label = t(cliRunLabelKey(run, active), {
defaultValue: cliRunLabelDefault(run, active),
});
useEffect(() => setLogoIndex(0), [app?.logo_url]);
return (
<ActivityStep
as="li"
@@ -1699,8 +1702,11 @@ function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean;
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className="h-[78%] w-[78%] object-contain"
onError={() => setLogoIndex((index) => index + 1)}
onLoad={onLogoLoad}
onError={onLogoError}
/>
) : app ? (
cliAppInitials(app).slice(0, 2)
@@ -1772,19 +1778,16 @@ function McpRunGroup({
function McpRunRow({ run, active, preset }: { run: McpRunSummary; active: boolean; preset?: McpPresetInfo }) {
const { t } = useTranslation();
const [logoIndex, setLogoIndex] = useState(0);
const failed = run.status === "error";
const rowActive = active && run.status === "running";
const color = failed ? "#DC2626" : preset?.brand_color || "#6D5DF6";
const logoUrls = useMemo(() => logoFallbackUrls(preset?.logo_url), [preset?.logo_url]);
const logoUrl = logoUrls[logoIndex];
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
const displayName = preset?.display_name || run.displayName;
const label = t(mcpRunLabelKey(run, active), {
defaultValue: mcpRunLabelDefault(run, active),
});
useEffect(() => setLogoIndex(0), [preset?.logo_url]);
return (
<ActivityStep
as="li"
@@ -1810,8 +1813,11 @@ function McpRunRow({ run, active, preset }: { run: McpRunSummary; active: boolea
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className="h-[78%] w-[78%] object-contain"
onError={() => setLogoIndex((index) => index + 1)}
onLoad={onLogoLoad}
onError={onLogoError}
/>
) : preset ? (
mcpPresetInitials(preset).slice(0, 2)
+11 -10
View File
@@ -65,6 +65,7 @@ import {
type RestoredReadyImage,
} from "@/hooks/useAttachedImages";
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
import type {
@@ -2260,15 +2261,12 @@ function ComposerModelBadge({
}) {
const inferredProvider = needsSetup ? null : provider || inferProviderFromModelName(label);
const brand = providerBrand(inferredProvider);
const [logoIndex, setLogoIndex] = useState(0);
const logoUrl = brand?.logoUrls[logoIndex];
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
const showLogo = !!logoUrl;
const title = providerLabel ? `${label} · ${providerLabel}` : label;
const interactive = Boolean(onClick);
const Container = interactive ? "button" : "span";
useEffect(() => setLogoIndex(0), [inferredProvider]);
return (
<Container
title={title}
@@ -2305,8 +2303,11 @@ function ComposerModelBadge({
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className={cn("object-contain", isHero ? "h-3 w-3" : "h-3.5 w-3.5")}
onError={() => setLogoIndex((index) => index + 1)}
onLoad={onLogoLoad}
onError={onLogoError}
/>
) : brand ? (
<span
@@ -2502,15 +2503,12 @@ function MentionCandidateLogo({
candidate: MentionCandidate;
selected: boolean;
}) {
const [logoIndex, setLogoIndex] = useState(0);
const color = (candidate.kind === "cli"
? candidate.app.brand_color
: candidate.preset.brand_color) || "hsl(var(--primary))";
const rawLogoUrl = candidate.kind === "cli" ? candidate.app.logo_url : candidate.preset.logo_url;
const logoUrls = useMemo(() => logoFallbackUrls(rawLogoUrl), [rawLogoUrl]);
const logoUrl = logoUrls[logoIndex];
useEffect(() => setLogoIndex(0), [rawLogoUrl]);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
if (logoUrl) {
return (
@@ -2523,8 +2521,11 @@ function MentionCandidateLogo({
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className="h-5 w-5 object-contain"
onError={() => setLogoIndex((index) => index + 1)}
onLoad={onLogoLoad}
onError={onLogoError}
/>
</span>
);