mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-20 06:28:19 +02:00
style: more consistent style across pages
This commit is contained in:
1 parent
b61e582e24
commit
ffd762bdae
31 files changed
+3001
-884
No files matched your search
+336
-263
@@ -1,14 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LuArrowRight,
|
||||
LuCircleCheck,
|
||||
LuCloud,
|
||||
LuEye,
|
||||
LuEyeOff,
|
||||
LuLogOut,
|
||||
LuRefreshCw,
|
||||
LuServer,
|
||||
LuTriangleAlert,
|
||||
LuUser,
|
||||
} from "react-icons/lu";
|
||||
import {
|
||||
@@ -24,11 +28,18 @@ import {
|
||||
AnimatedTabsList,
|
||||
AnimatedTabsTrigger,
|
||||
} from "@/components/ui/animated-tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { SoftFields } from "@/components/ui/field-variant";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RippleButton } from "@/components/ui/ripple";
|
||||
import {
|
||||
FeatureIcon,
|
||||
SECTION_LABEL_CLASS,
|
||||
SettingsPanel,
|
||||
StatusLight,
|
||||
} from "@/components/ui/settings-panel";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
@@ -241,6 +252,72 @@ export function AccountPage({
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const subscriptionTone = (
|
||||
status: string | null | undefined,
|
||||
): "success" | "warning" | "muted" => {
|
||||
if (status === "active" || status === "trialing") return "success";
|
||||
if (status === "past_due" || status === "unpaid") return "warning";
|
||||
return "muted";
|
||||
};
|
||||
|
||||
const details: { id: string; label: string; value: ReactNode }[] = [];
|
||||
if (isLoggedIn && user) {
|
||||
details.push(
|
||||
{
|
||||
id: "plan",
|
||||
label: t("account.fields.plan"),
|
||||
value: <span className="uppercase">{effectivePlan}</span>,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
label: t("account.fields.status"),
|
||||
value: (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<StatusLight tone={subscriptionTone(user.subscriptionStatus)} />
|
||||
{user.subscriptionStatus ?? "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
);
|
||||
if (user.teamRole) {
|
||||
details.push({
|
||||
id: "teamRole",
|
||||
label: t("account.fields.teamRole"),
|
||||
value: user.teamRole,
|
||||
});
|
||||
}
|
||||
if (user.planPeriod) {
|
||||
details.push({
|
||||
id: "period",
|
||||
label: t("account.fields.period"),
|
||||
value: user.planPeriod,
|
||||
});
|
||||
}
|
||||
if (typeof user.deviceOrdinal === "number") {
|
||||
details.push({
|
||||
id: "device",
|
||||
label: t("account.fields.device"),
|
||||
value: t("account.deviceOrdinal", {
|
||||
ordinal: user.deviceOrdinal,
|
||||
count: user.deviceCount ?? user.deviceOrdinal,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const connectionTone = {
|
||||
unknown: "muted",
|
||||
testing: "muted",
|
||||
connected: "success",
|
||||
error: "destructive",
|
||||
} as const;
|
||||
const connectionLabel = {
|
||||
unknown: t("account.selfHosted.statusUnknown"),
|
||||
testing: t("appFeedback.checking"),
|
||||
connected: t("sync.status.connected"),
|
||||
error: t("sync.status.error"),
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="flex max-h-[calc(100vh-5rem)] max-w-3xl flex-col">
|
||||
@@ -269,16 +346,31 @@ export function AccountPage({
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
|
||||
<AnimatedTabsContent value="account" className="mt-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid size-12 shrink-0 place-items-center rounded-full bg-accent text-accent-foreground">
|
||||
<LuUser className="size-6" />
|
||||
</div>
|
||||
<AnimatedTabsContent
|
||||
value="account"
|
||||
className="@container mt-4 flex flex-col gap-3"
|
||||
>
|
||||
<SettingsPanel className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"grid size-11 shrink-0 place-items-center rounded-full text-base font-semibold uppercase",
|
||||
isLoggedIn && user
|
||||
? "bg-primary/10 text-foreground"
|
||||
: "bg-foreground/6 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoggedIn && user ? (
|
||||
user.email.charAt(0)
|
||||
) : (
|
||||
<LuUser className="size-5" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isLoggedIn && user ? (
|
||||
<>
|
||||
<h2 className="truncate text-base font-semibold">
|
||||
<h2 className="truncate text-sm font-semibold">
|
||||
{user.email}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
@@ -292,7 +384,7 @@ export function AccountPage({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-base font-semibold">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("account.signedOut")}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
@@ -301,120 +393,79 @@ export function AccountPage({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="soft"
|
||||
onClick={() => {
|
||||
void handleRefresh();
|
||||
}}
|
||||
disabled={isRefreshing || isLoggingOut}
|
||||
aria-busy={isRefreshing}
|
||||
className="h-8 gap-1.5 rounded-lg text-xs"
|
||||
>
|
||||
<LuRefreshCw
|
||||
className={cn(
|
||||
"size-3.5",
|
||||
isRefreshing &&
|
||||
"animate-spin motion-reduce:animate-none",
|
||||
)}
|
||||
/>
|
||||
{t("account.refresh")}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="soft"
|
||||
isLoading={isLoggingOut}
|
||||
disabled={isRefreshing}
|
||||
onClick={() => {
|
||||
void handleLogout();
|
||||
}}
|
||||
className="h-8 gap-1.5 rounded-lg text-xs text-destructive-text hover:bg-destructive/10"
|
||||
>
|
||||
<LuLogOut className="size-3.5" />
|
||||
{t("account.logout")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
) : (
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={onOpenSignIn}
|
||||
className="h-8 gap-1.5 rounded-lg text-xs"
|
||||
>
|
||||
<LuCloud className="size-3.5" />
|
||||
{t("account.signIn")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{remoteHoursVisible && (
|
||||
// A headline block, not one field among six: the allowance
|
||||
// is the number a customer needs before a launch is
|
||||
// refused, which is the only way they ever saw it before.
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2.5">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("cookieBot.hours.label")}
|
||||
</p>
|
||||
{formatDate(quota?.period_end) && (
|
||||
<p className="text-xs tabular-nums text-muted-foreground">
|
||||
{t("cookieBot.hours.resets", {
|
||||
date: formatDate(quota?.period_end),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-lg leading-none font-semibold tabular-nums">
|
||||
{quota ? formatHours(quota.remaining_hours) : "—"}
|
||||
<span className="ml-1 text-sm font-normal text-muted-foreground">
|
||||
{t("cookieBot.hours.remainingOf", {
|
||||
total: quota
|
||||
? formatHours(quota.granted_hours)
|
||||
: "—",
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<RemoteHoursMeter
|
||||
quota={quota}
|
||||
isLoading={isQuotaLoading}
|
||||
variant="inline"
|
||||
className="mt-2"
|
||||
/>
|
||||
<div className="mt-2 flex items-baseline justify-between gap-3">
|
||||
<p className="text-xs tabular-nums text-muted-foreground">
|
||||
{t("cookieBot.hours.used", {
|
||||
used: quota ? formatHours(quota.used_hours) : "—",
|
||||
total: quota
|
||||
? formatHours(quota.granted_hours)
|
||||
: "—",
|
||||
})}
|
||||
</p>
|
||||
{showTeamUsage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveTab("team-usage");
|
||||
}}
|
||||
className="text-xs text-muted-foreground underline underline-offset-2 transition-colors duration-100 hover:text-foreground"
|
||||
>
|
||||
{t("account.viewTeamUsage")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoggedIn && user && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.plan")}
|
||||
</p>
|
||||
<p className="mt-0.5 font-medium uppercase">
|
||||
{effectivePlan}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.status")}
|
||||
</p>
|
||||
<p className="mt-0.5">
|
||||
{user.subscriptionStatus ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
{user.teamRole && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.teamRole")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.teamRole}</p>
|
||||
{details.length > 0 && (
|
||||
<dl className="grid grid-cols-2 gap-2 text-xs @xl:grid-cols-3">
|
||||
{details.map((detail) => (
|
||||
<div
|
||||
key={detail.id}
|
||||
className="min-w-0 rounded-lg bg-foreground/4 px-3 py-2"
|
||||
>
|
||||
<dt className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{detail.label}
|
||||
</dt>
|
||||
<dd className="mt-0.5 truncate font-medium">
|
||||
{detail.value}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{user.planPeriod && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.period")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.planPeriod}</p>
|
||||
</div>
|
||||
)}
|
||||
{typeof user.deviceOrdinal === "number" && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.device")}
|
||||
</p>
|
||||
<p className="mt-0.5">
|
||||
{t("account.deviceOrdinal", {
|
||||
ordinal: user.deviceOrdinal,
|
||||
count: user.deviceCount ?? user.deviceOrdinal,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{isLoggedIn &&
|
||||
user &&
|
||||
getEntitlements(user).browserAutomation &&
|
||||
user.isPrimaryDevice === false && (
|
||||
<p className="text-xs text-warning-text">
|
||||
<p className="flex items-start gap-2 text-xs text-warning-text">
|
||||
<LuTriangleAlert className="mt-0.5 size-3.5 shrink-0" />
|
||||
{t("account.automationPrimaryOnly")}
|
||||
</p>
|
||||
)}
|
||||
@@ -423,52 +474,68 @@ export function AccountPage({
|
||||
getEntitlements(user).browserAutomation &&
|
||||
user.isPrimaryDevice === true &&
|
||||
(user.deviceCount ?? 1) > 1 && (
|
||||
<p className="text-xs text-success-text">
|
||||
<p className="flex items-start gap-2 text-xs text-success-text">
|
||||
<LuCircleCheck className="mt-0.5 size-3.5 shrink-0" />
|
||||
{t("account.automationActiveHere")}
|
||||
</p>
|
||||
)}
|
||||
</SettingsPanel>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
{remoteHoursVisible && (
|
||||
// A headline block, not one field among six: the allowance
|
||||
// is the number a customer needs before a launch is
|
||||
// refused, which is the only way they ever saw it before.
|
||||
<SettingsPanel>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<p className={SECTION_LABEL_CLASS}>
|
||||
{t("cookieBot.hours.label")}
|
||||
</p>
|
||||
{formatDate(quota?.period_end) && (
|
||||
<p className="text-xs tabular-nums text-muted-foreground">
|
||||
{t("cookieBot.hours.resets", {
|
||||
date: formatDate(quota?.period_end),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-2xl leading-none font-semibold tabular-nums">
|
||||
{quota ? formatHours(quota.remaining_hours) : "—"}
|
||||
<span className="ml-1.5 text-sm font-normal text-muted-foreground">
|
||||
{t("cookieBot.hours.remainingOf", {
|
||||
total: quota ? formatHours(quota.granted_hours) : "—",
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<RemoteHoursMeter
|
||||
quota={quota}
|
||||
isLoading={isQuotaLoading}
|
||||
variant="inline"
|
||||
className="mt-3"
|
||||
/>
|
||||
<div className="mt-2 flex items-baseline justify-between gap-3">
|
||||
<p className="text-xs tabular-nums text-muted-foreground">
|
||||
{t("cookieBot.hours.used", {
|
||||
used: quota ? formatHours(quota.used_hours) : "—",
|
||||
total: quota ? formatHours(quota.granted_hours) : "—",
|
||||
})}
|
||||
</p>
|
||||
{showTeamUsage && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void handleRefresh();
|
||||
setActiveTab("team-usage");
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
className="group/team h-7 gap-1 rounded-lg px-2 text-xs"
|
||||
>
|
||||
<LuRefreshCw className="size-3" />
|
||||
{t("account.refresh")}
|
||||
{t("account.viewTeamUsage")}
|
||||
<LuArrowRight className="size-3.5 transition-transform duration-150 group-hover/team:translate-x-0.5 motion-reduce:transition-none" />
|
||||
</Button>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
isLoading={isLoggingOut}
|
||||
disabled={isRefreshing}
|
||||
onClick={() => {
|
||||
void handleLogout();
|
||||
}}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuLogOut className="size-3" />
|
||||
{t("account.logout")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onOpenSignIn}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuCloud className="size-3" />
|
||||
{t("account.signIn")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsPanel>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
|
||||
{showTeamUsage && (
|
||||
@@ -477,7 +544,10 @@ export function AccountPage({
|
||||
</AnimatedTabsContent>
|
||||
)}
|
||||
|
||||
<AnimatedTabsContent value="self-hosted" className="mt-4">
|
||||
<AnimatedTabsContent
|
||||
value="self-hosted"
|
||||
className="@container mt-4"
|
||||
>
|
||||
{selfHostedDisabled ? (
|
||||
// Defensive: the tab trigger is disabled while the user is
|
||||
// logged in, so this branch shouldn't be reachable via UI —
|
||||
@@ -488,113 +558,125 @@ export function AccountPage({
|
||||
{t("account.selfHosted.disabledWhileLoggedIn")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("account.selfHosted.title")}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.selfHosted.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="self-hosted-server-url"
|
||||
className="text-xs"
|
||||
>
|
||||
{t("sync.serverUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
id="self-hosted-server-url"
|
||||
type="url"
|
||||
placeholder={t("sync.serverUrlPlaceholder")}
|
||||
value={serverUrl}
|
||||
onChange={(e) => {
|
||||
setServerUrl(e.target.value);
|
||||
setConnectionStatus("unknown");
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="self-hosted-token" className="text-xs">
|
||||
{t("sync.token")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="self-hosted-token"
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder={t("sync.tokenPlaceholder")}
|
||||
value={token}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
setConnectionStatus("unknown");
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="pr-9"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowToken((v) => !v);
|
||||
}}
|
||||
aria-label={
|
||||
showToken
|
||||
? t("common.aria.hideToken")
|
||||
: t("common.aria.showToken")
|
||||
}
|
||||
className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showToken ? (
|
||||
<LuEyeOff className="size-3.5" />
|
||||
) : (
|
||||
<LuEye className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<SettingsPanel className="flex flex-col gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<FeatureIcon active={connectionStatus === "connected"}>
|
||||
<LuServer />
|
||||
</FeatureIcon>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t("account.selfHosted.title")}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
|
||||
{t("account.selfHosted.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("account.selfHosted.connectionStatus")}
|
||||
</span>
|
||||
{connectionStatus === "connected" && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-success text-success-foreground"
|
||||
>
|
||||
{t("sync.status.connected")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "error" && (
|
||||
<Badge variant="destructive">
|
||||
{t("sync.status.error")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "testing" && (
|
||||
<Badge variant="secondary">
|
||||
{t("sync.status.syncing")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "unknown" && (
|
||||
<Badge variant="secondary">
|
||||
{t("account.selfHosted.statusUnknown")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<SoftFields>
|
||||
<div className="grid gap-4 @xl:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="self-hosted-server-url"
|
||||
className={FIELD_LABEL_CLASS}
|
||||
>
|
||||
{t("sync.serverUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
id="self-hosted-server-url"
|
||||
type="url"
|
||||
placeholder={t("sync.serverUrlPlaceholder")}
|
||||
value={serverUrl}
|
||||
onChange={(e) => {
|
||||
setServerUrl(e.target.value);
|
||||
setConnectionStatus("unknown");
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="self-hosted-token"
|
||||
className={FIELD_LABEL_CLASS}
|
||||
>
|
||||
{t("sync.token")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="self-hosted-token"
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder={t("sync.tokenPlaceholder")}
|
||||
value={token}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
setConnectionStatus("unknown");
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setShowToken((v) => !v);
|
||||
}}
|
||||
aria-label={
|
||||
showToken
|
||||
? t("common.aria.hideToken")
|
||||
: t("common.aria.showToken")
|
||||
}
|
||||
className="absolute top-1/2 right-1.5 size-6 -translate-y-1/2 rounded-md"
|
||||
>
|
||||
{showToken ? (
|
||||
<LuEyeOff className="size-3.5" />
|
||||
) : (
|
||||
<LuEye className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SoftFields>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
role="status"
|
||||
className="mr-auto inline-flex items-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<StatusLight
|
||||
tone={connectionTone[connectionStatus]}
|
||||
live={
|
||||
connectionStatus === "testing" ||
|
||||
connectionStatus === "connected"
|
||||
}
|
||||
/>
|
||||
<span>{t("account.selfHosted.connectionStatus")}</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{connectionLabel[connectionStatus]}
|
||||
</span>
|
||||
</span>
|
||||
{hasConfig && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
disabled={isSavingSelfHosted || isTestingConnection}
|
||||
onClick={() => void handleDisconnectSelfHosted()}
|
||||
className="h-8 rounded-lg text-xs hover:bg-destructive/10 hover:text-destructive-text"
|
||||
>
|
||||
{t("account.selfHosted.disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
isLoading={isTestingConnection}
|
||||
disabled={!serverUrl || isSavingSelfHosted}
|
||||
onClick={() => void handleTestConnection()}
|
||||
className="h-8 text-xs"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
>
|
||||
{t("account.selfHosted.testConnection")}
|
||||
</LoadingButton>
|
||||
@@ -603,23 +685,12 @@ export function AccountPage({
|
||||
isLoading={isSavingSelfHosted}
|
||||
disabled={!serverUrl || !token || isTestingConnection}
|
||||
onClick={() => void handleSaveSelfHosted()}
|
||||
className="h-8 text-xs"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</LoadingButton>
|
||||
{hasConfig && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={isSavingSelfHosted || isTestingConnection}
|
||||
onClick={() => void handleDisconnectSelfHosted()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("account.selfHosted.disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsPanel>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
@@ -629,3 +700,5 @@ export function AccountPage({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const FIELD_LABEL_CLASS = "text-xs font-medium text-muted-foreground";
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useId, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuTrash2 } from "react-icons/lu";
|
||||
import { LuSearch, LuTrash2 } from "react-icons/lu";
|
||||
import {
|
||||
activityDetailText,
|
||||
useTimeFormat,
|
||||
@@ -102,6 +102,7 @@ export function AgentActivityFeed({
|
||||
<Select value={agent} onValueChange={setAgent}>
|
||||
<SelectTrigger
|
||||
id={agentId}
|
||||
variant="soft"
|
||||
className="h-8 w-48 text-xs"
|
||||
data-testid="agent-activity-agent"
|
||||
>
|
||||
@@ -119,16 +120,20 @@ export function AgentActivityFeed({
|
||||
<Label htmlFor={searchId} className="sr-only">
|
||||
{t("agent.activity.profileSearch")}
|
||||
</Label>
|
||||
<Input
|
||||
id={searchId}
|
||||
value={query}
|
||||
placeholder={t("agent.activity.profileSearch")}
|
||||
className="h-8 w-48 text-sm"
|
||||
data-testid="agent-activity-search"
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<div className="relative">
|
||||
<LuSearch className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id={searchId}
|
||||
variant="soft"
|
||||
value={query}
|
||||
placeholder={t("agent.activity.profileSearch")}
|
||||
className="h-8 w-48 pl-8 text-xs md:text-xs"
|
||||
data-testid="agent-activity-search"
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={errorsId}
|
||||
@@ -144,10 +149,11 @@ export function AgentActivityFeed({
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
className="ml-auto gap-1.5"
|
||||
className="ml-auto h-8 gap-1.5 rounded-lg text-xs"
|
||||
disabled={clearing || agentConsole.activity.length === 0}
|
||||
aria-busy={clearing}
|
||||
data-testid="agent-activity-clear"
|
||||
onClick={async () => {
|
||||
setClearing(true);
|
||||
@@ -178,7 +184,7 @@ export function AgentActivityFeed({
|
||||
role="list"
|
||||
aria-label={t("agent.activity.label")}
|
||||
data-testid="agent-activity-list"
|
||||
className="min-h-0 flex-1 overflow-y-auto rounded-md border border-border"
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
{entries.length === 0 ? (
|
||||
<p className="py-16 text-center text-sm text-muted-foreground">
|
||||
@@ -199,7 +205,7 @@ export function AgentActivityFeed({
|
||||
key={entry.id}
|
||||
role="listitem"
|
||||
data-testid={`agent-activity-${entry.id}`}
|
||||
className="absolute top-0 left-0 flex w-full items-center gap-3 border-b border-border px-3 text-xs"
|
||||
className="absolute top-0 left-0 flex w-full items-center gap-3 rounded-md px-3 text-xs transition-colors duration-100 hover:bg-foreground/4"
|
||||
style={{
|
||||
height: ROW_HEIGHT,
|
||||
transform: `translateY(${row.start}px)`,
|
||||
|
||||
@@ -97,8 +97,7 @@ export function NoteAction({
|
||||
icon,
|
||||
onConfirm,
|
||||
disabled,
|
||||
variant = "outline",
|
||||
size = "sm",
|
||||
variant = "soft",
|
||||
testId,
|
||||
className,
|
||||
}: {
|
||||
@@ -109,8 +108,7 @@ export function NoteAction({
|
||||
icon?: ReactNode;
|
||||
onConfirm: (note: string | null) => Promise<boolean>;
|
||||
disabled?: boolean;
|
||||
variant?: "outline" | "default" | "ghost" | "secondary";
|
||||
size?: "sm" | "default";
|
||||
variant?: "soft" | "subtle" | "default";
|
||||
testId?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
@@ -137,10 +135,10 @@ export function NoteAction({
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size={size}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
data-testid={testId}
|
||||
className={cn("gap-1.5", className)}
|
||||
className={cn("h-8 gap-1.5 rounded-lg text-xs", className)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
@@ -154,16 +152,20 @@ export function NoteAction({
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
<Label htmlFor={noteId} className="text-xs">
|
||||
<Label
|
||||
htmlFor={noteId}
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{title ?? t("agent.actions.noteOptional")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={noteId}
|
||||
variant="soft"
|
||||
value={note}
|
||||
rows={3}
|
||||
maxLength={MAX_TEXT_CHARS}
|
||||
placeholder={placeholder}
|
||||
className="min-h-16 text-sm"
|
||||
className="min-h-16 resize-none text-sm"
|
||||
data-testid={testId ? `${testId}-note` : undefined}
|
||||
onChange={(event) => {
|
||||
setNote(event.target.value);
|
||||
@@ -172,8 +174,9 @@ export function NoteAction({
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
@@ -183,7 +186,9 @@ export function NoteAction({
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
data-testid={testId ? `${testId}-confirm` : undefined}
|
||||
>
|
||||
{confirmLabel ?? label}
|
||||
@@ -220,7 +225,12 @@ export function SetupPanel({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground [&>svg]:size-10">{icon}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="grid size-12 place-items-center rounded-2xl bg-foreground/5 text-muted-foreground [&>svg]:size-5"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<h3 className="text-sm font-medium text-foreground">{title}</h3>
|
||||
<p className="max-w-md text-xs text-muted-foreground">{body}</p>
|
||||
{children}
|
||||
@@ -242,7 +252,7 @@ export function ProgressBar({
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={percent}
|
||||
className="h-1 w-full overflow-hidden rounded-full bg-muted"
|
||||
className="h-1 w-full overflow-hidden rounded-full bg-foreground/8"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-300 motion-reduce:transition-none"
|
||||
|
||||
@@ -67,7 +67,7 @@ function useSessionNamer(sessions: readonly AgentSession[]): SessionNamer {
|
||||
|
||||
function ProfileChip({ name }: { name: string }) {
|
||||
return (
|
||||
<span className="max-w-48 truncate rounded bg-muted px-1.5 py-0.5 text-[11px] text-foreground">
|
||||
<span className="max-w-48 truncate rounded-md bg-foreground/6 px-1.5 py-0.5 text-[11px] text-foreground">
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
@@ -103,8 +103,9 @@ function RequestCard({
|
||||
const dismissButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
disabled={busy}
|
||||
data-testid="agent-dismiss"
|
||||
onClick={() => {
|
||||
@@ -119,7 +120,7 @@ function RequestCard({
|
||||
<article
|
||||
data-testid={`agent-request-${item.id}`}
|
||||
aria-labelledby={`${baseId}-title`}
|
||||
className="flex flex-col gap-2 rounded-md border border-warning/50 bg-warning/5 p-3"
|
||||
className="flex flex-col gap-2 rounded-xl bg-warning/10 p-3"
|
||||
>
|
||||
<header className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
{isHelp ? (
|
||||
@@ -149,9 +150,9 @@ function RequestCard({
|
||||
{item.profile_id && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
className="h-8 gap-1.5 rounded-lg text-xs"
|
||||
data-testid="agent-show-window"
|
||||
onClick={() => {
|
||||
if (item.profile_id)
|
||||
@@ -184,7 +185,8 @@ function RequestCard({
|
||||
key={choice}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
variant="soft"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
disabled={busy}
|
||||
data-testid="agent-choice"
|
||||
onClick={() => {
|
||||
@@ -208,10 +210,11 @@ function RequestCard({
|
||||
</Label>
|
||||
<Input
|
||||
id={`${baseId}-answer`}
|
||||
variant="soft"
|
||||
value={answer}
|
||||
maxLength={MAX_TEXT_CHARS}
|
||||
placeholder={t("agent.request.answerPlaceholder")}
|
||||
className="h-8 text-sm"
|
||||
className="h-8 text-xs md:text-xs"
|
||||
data-testid="agent-answer-input"
|
||||
onChange={(event) => {
|
||||
setAnswer(event.target.value);
|
||||
@@ -220,7 +223,7 @@ function RequestCard({
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
className="h-8 gap-1.5 rounded-lg text-xs"
|
||||
disabled={busy || answer.trim().length === 0}
|
||||
data-testid="agent-answer-send"
|
||||
>
|
||||
@@ -312,8 +315,8 @@ function SessionButton({
|
||||
data-testid={`agent-session-${session.session_id}`}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer flex-col gap-1 rounded-md px-2 py-2 text-left transition-colors duration-100 hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
selected && "bg-accent text-accent-foreground",
|
||||
"flex w-full cursor-pointer flex-col gap-1 rounded-lg px-2 py-2 text-left transition-colors duration-100 hover:bg-foreground/5 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
selected && "bg-foreground/7 hover:bg-foreground/7",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
@@ -407,8 +410,8 @@ function SessionList({
|
||||
onSelectSession(null);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-2 text-left text-sm font-medium transition-colors duration-100 hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
selectedSessionId === null && "bg-accent text-accent-foreground",
|
||||
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-left text-sm font-medium transition-colors duration-100 hover:bg-foreground/5 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
selectedSessionId === null && "bg-foreground/7 hover:bg-foreground/7",
|
||||
)}
|
||||
>
|
||||
{t("agent.sessions.all")}
|
||||
@@ -549,7 +552,7 @@ function ThreadEntry({
|
||||
<span aria-hidden="true">·</span>
|
||||
{time}
|
||||
</span>
|
||||
<div className="flex items-start gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm text-card-foreground">
|
||||
<div className="flex items-start gap-2 rounded-xl bg-foreground/5 px-3 py-2 text-sm text-foreground">
|
||||
{isHelp ? (
|
||||
<LuHand className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
@@ -624,14 +627,14 @@ function ProfilePicker({
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-label={t("agent.composer.profile")}
|
||||
data-testid="agent-composer-profile"
|
||||
className="max-w-56 justify-between gap-2 font-normal"
|
||||
className="h-8 max-w-56 justify-between gap-2 rounded-lg text-xs font-normal"
|
||||
>
|
||||
<span className="truncate">
|
||||
{selected ?? t("agent.composer.noProfile")}
|
||||
@@ -742,7 +745,7 @@ function Composer({
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex shrink-0 flex-col gap-2 border-t border-border p-3"
|
||||
className="flex shrink-0 flex-col gap-2 border-t border-foreground/6 p-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
@@ -763,6 +766,7 @@ function Composer({
|
||||
? t("agent.composer.placeholderOne", { agent: sessionName })
|
||||
: t("agent.composer.placeholderAll")
|
||||
}
|
||||
variant="soft"
|
||||
className="min-h-14 resize-none text-sm"
|
||||
data-testid="agent-composer-input"
|
||||
onChange={(event) => {
|
||||
@@ -795,7 +799,7 @@ function Composer({
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="ml-auto gap-1.5"
|
||||
className="ml-auto h-8 gap-1.5 rounded-lg text-xs"
|
||||
disabled={busy || text.trim().length === 0}
|
||||
data-testid="agent-composer-send"
|
||||
>
|
||||
@@ -860,7 +864,7 @@ export function AgentConversation({
|
||||
onSelectSession={onSelectSession}
|
||||
now={now}
|
||||
/>
|
||||
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden rounded-md border border-border">
|
||||
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl bg-foreground/3">
|
||||
<div
|
||||
ref={threadRef}
|
||||
role="log"
|
||||
|
||||
+179
-166
@@ -33,13 +33,14 @@ import {
|
||||
AnimatedTabsTrigger,
|
||||
} from "@/components/ui/animated-tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyToClipboard } from "@/components/ui/copy-to-clipboard";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { SoftFields } from "@/components/ui/field-variant";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
@@ -164,16 +165,20 @@ function PauseControl({ agentConsole }: { agentConsole: AgentConsole }) {
|
||||
void pause();
|
||||
}}
|
||||
>
|
||||
<Label htmlFor={noteId} className="text-xs">
|
||||
<Label
|
||||
htmlFor={noteId}
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{t("agent.pause.noteLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={noteId}
|
||||
variant="soft"
|
||||
rows={3}
|
||||
value={note}
|
||||
maxLength={MAX_TEXT_CHARS}
|
||||
placeholder={t("agent.pause.notePlaceholder")}
|
||||
className="min-h-16 text-sm"
|
||||
className="min-h-16 resize-none text-sm"
|
||||
data-testid="agent-pause-note"
|
||||
onChange={(event) => {
|
||||
setNote(event.target.value);
|
||||
@@ -185,8 +190,9 @@ function PauseControl({ agentConsole }: { agentConsole: AgentConsole }) {
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
@@ -196,7 +202,9 @@ function PauseControl({ agentConsole }: { agentConsole: AgentConsole }) {
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
data-testid="agent-pause-confirm"
|
||||
>
|
||||
{t("agent.pause.confirm")}
|
||||
@@ -218,7 +226,7 @@ function PausedBanner({ agentConsole }: { agentConsole: AgentConsole }) {
|
||||
<div
|
||||
role="status"
|
||||
data-testid="agent-paused-banner"
|
||||
className="flex shrink-0 flex-wrap items-center gap-3 rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-sm text-warning-text"
|
||||
className="flex shrink-0 flex-wrap items-center gap-3 rounded-xl bg-warning/10 px-3 py-2 text-sm text-warning-text"
|
||||
>
|
||||
<LuPause className="size-4 shrink-0" aria-hidden="true" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
@@ -234,9 +242,10 @@ function PausedBanner({ agentConsole }: { agentConsole: AgentConsole }) {
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
variant="soft"
|
||||
className="h-8 gap-1.5 rounded-lg text-xs"
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
data-testid="agent-resume"
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
@@ -342,6 +351,7 @@ export function AgentPage({
|
||||
>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
data-testid="agent-setup-sign-in"
|
||||
onClick={onOpenAccount}
|
||||
>
|
||||
@@ -359,7 +369,8 @@ export function AgentPage({
|
||||
<ProBadge />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
data-testid="agent-setup-plan-account"
|
||||
onClick={onOpenAccount}
|
||||
>
|
||||
@@ -378,6 +389,7 @@ export function AgentPage({
|
||||
>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
data-testid="agent-setup-turn-on"
|
||||
onClick={onConnectAgent}
|
||||
>
|
||||
@@ -394,27 +406,22 @@ export function AgentPage({
|
||||
body={t("agent.setup.waitingBody")}
|
||||
>
|
||||
<div className="flex w-full max-w-xl flex-col gap-2 text-left">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{t("agent.setup.starterLabel")}
|
||||
</span>
|
||||
<CopyToClipboard
|
||||
text={t("agent.setup.starter")}
|
||||
size="sm"
|
||||
successMessage={t("agent.setup.starterCopied")}
|
||||
className="h-7 w-7 p-0"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("agent.setup.starterLabel")}
|
||||
</span>
|
||||
<CodeSnippet
|
||||
multiline
|
||||
wrap
|
||||
data-testid="agent-starter-text"
|
||||
className="max-h-40 overflow-y-auto rounded-md border border-border bg-muted/40 p-3 text-xs break-words whitespace-pre-wrap text-foreground select-text"
|
||||
>
|
||||
{t("agent.setup.starter")}
|
||||
</p>
|
||||
code={t("agent.setup.starter")}
|
||||
copyLabel={t("agent.setup.starterLabel")}
|
||||
successMessage={t("agent.setup.starterCopied")}
|
||||
className="font-sans text-xs leading-relaxed"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5 self-center"
|
||||
variant="soft"
|
||||
className="mt-1 h-8 gap-1.5 self-center rounded-lg text-xs"
|
||||
data-testid="agent-setup-connect"
|
||||
onClick={onConnectAgent}
|
||||
>
|
||||
@@ -448,7 +455,7 @@ export function AgentPage({
|
||||
{remoteOff && (
|
||||
<div
|
||||
data-testid="agent-setup-remote-off"
|
||||
className="flex shrink-0 flex-wrap items-center gap-3 rounded-md border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground"
|
||||
className="flex shrink-0 flex-wrap items-center gap-3 rounded-xl bg-foreground/4 px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<LuPower className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1">
|
||||
@@ -456,7 +463,8 @@ export function AgentPage({
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
data-testid="agent-setup-turn-on"
|
||||
onClick={onConnectAgent}
|
||||
>
|
||||
@@ -489,148 +497,153 @@ export function AgentPage({
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="flex max-h-[85vh] max-w-[min(80rem,calc(100%-4rem))] flex-col">
|
||||
<div
|
||||
data-testid="agent-page"
|
||||
className="@container flex min-h-0 w-full flex-1 flex-col gap-3"
|
||||
>
|
||||
<div className="flex shrink-0 flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
{subPage ? (
|
||||
<>
|
||||
<h2 className="text-base leading-none font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</>
|
||||
<SoftFields>
|
||||
<div
|
||||
data-testid="agent-page"
|
||||
className="@container flex min-h-0 w-full flex-1 flex-col gap-3"
|
||||
>
|
||||
<div className="flex shrink-0 flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
{subPage ? (
|
||||
<>
|
||||
<h2 className="text-base leading-none font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{blocker === null && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{quota && (
|
||||
<span
|
||||
data-testid="agent-quota"
|
||||
className={cn(
|
||||
"text-xs tabular-nums",
|
||||
quota.left === 0
|
||||
? "text-warning-text"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t("agent.header.quota", {
|
||||
left: quota.left,
|
||||
limit: quota.limit,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<PauseControl agentConsole={agentConsole} />
|
||||
<RippleButton
|
||||
size="sm"
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg text-xs"
|
||||
data-testid="agent-connect"
|
||||
onClick={onConnectAgent}
|
||||
>
|
||||
<LuPlug className="size-3.5" aria-hidden="true" />
|
||||
{t("agent.header.connect")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{blocker === null && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{quota && (
|
||||
<span
|
||||
data-testid="agent-quota"
|
||||
className={cn(
|
||||
"text-xs tabular-nums",
|
||||
quota.left === 0
|
||||
? "text-warning-text"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
|
||||
{blocker === null && <PausedBanner agentConsole={agentConsole} />}
|
||||
|
||||
<AnimatedTabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => {
|
||||
changeTab(value as AgentTab);
|
||||
}}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<AnimatedTabsList className="shrink-0">
|
||||
{TABS.map((tab) => (
|
||||
<AnimatedTabsTrigger
|
||||
key={tab}
|
||||
value={tab}
|
||||
data-testid={`agent-tab-${tab}`}
|
||||
>
|
||||
{t("agent.header.quota", {
|
||||
left: quota.left,
|
||||
limit: quota.limit,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<PauseControl agentConsole={agentConsole} />
|
||||
<RippleButton
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
data-testid="agent-connect"
|
||||
onClick={onConnectAgent}
|
||||
>
|
||||
<LuPlug className="size-4" aria-hidden="true" />
|
||||
{t("agent.header.connect")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{blocker === null && <PausedBanner agentConsole={agentConsole} />}
|
||||
|
||||
<AnimatedTabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => {
|
||||
changeTab(value as AgentTab);
|
||||
}}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<AnimatedTabsList className="shrink-0">
|
||||
{TABS.map((tab) => (
|
||||
<AnimatedTabsTrigger
|
||||
key={tab}
|
||||
value={tab}
|
||||
data-testid={`agent-tab-${tab}`}
|
||||
>
|
||||
{t(`agent.tabs.${tab}`)}
|
||||
{tab === "conversation" &&
|
||||
agentConsole.openRequests.length > 0 && (
|
||||
<span className="rounded-full bg-warning/15 px-1.5 text-xs tabular-nums text-warning-text">
|
||||
{agentConsole.openRequests.length}
|
||||
</span>
|
||||
)}
|
||||
</AnimatedTabsTrigger>
|
||||
))}
|
||||
</AnimatedTabsList>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="conversation"
|
||||
className="mt-3 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
{conversationBody()}
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="profiles"
|
||||
className="mt-3 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
{blockerPanel ?? (
|
||||
<AgentProfiles
|
||||
agentConsole={agentConsole}
|
||||
profileNames={profileNames}
|
||||
now={now}
|
||||
onNote={openComposerFor}
|
||||
/>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="activity"
|
||||
className="mt-3 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
{blockerPanel ?? (
|
||||
<AgentActivityFeed
|
||||
agentConsole={agentConsole}
|
||||
profileNames={profileNames}
|
||||
now={now}
|
||||
/>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="recipes"
|
||||
className="mt-3 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
{blockerPanel ??
|
||||
(automation ? (
|
||||
<AgentRecipes profiles={profiles} />
|
||||
) : (
|
||||
<SetupPanel
|
||||
testId="agent-recipes-plan"
|
||||
icon={<LuLock />}
|
||||
title={t("agent.tabs.recipes")}
|
||||
body={t("agent.recipes.needsAutomation")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProBadge />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onOpenAccount}
|
||||
>
|
||||
{t("agent.setup.planAction")}
|
||||
</Button>
|
||||
</div>
|
||||
</SetupPanel>
|
||||
{t(`agent.tabs.${tab}`)}
|
||||
{tab === "conversation" &&
|
||||
agentConsole.openRequests.length > 0 && (
|
||||
<span className="rounded-full bg-warning/15 px-1.5 text-xs tabular-nums text-warning-text">
|
||||
{agentConsole.openRequests.length}
|
||||
</span>
|
||||
)}
|
||||
</AnimatedTabsTrigger>
|
||||
))}
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
</div>
|
||||
</AnimatedTabsList>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="conversation"
|
||||
className="mt-3 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
{conversationBody()}
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="profiles"
|
||||
className="mt-3 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
{blockerPanel ?? (
|
||||
<AgentProfiles
|
||||
agentConsole={agentConsole}
|
||||
profileNames={profileNames}
|
||||
now={now}
|
||||
onNote={openComposerFor}
|
||||
/>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="activity"
|
||||
className="mt-3 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
{blockerPanel ?? (
|
||||
<AgentActivityFeed
|
||||
agentConsole={agentConsole}
|
||||
profileNames={profileNames}
|
||||
now={now}
|
||||
/>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="recipes"
|
||||
className="mt-3 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
{blockerPanel ??
|
||||
(automation ? (
|
||||
<AgentRecipes profiles={profiles} />
|
||||
) : (
|
||||
<SetupPanel
|
||||
testId="agent-recipes-plan"
|
||||
icon={<LuLock />}
|
||||
title={t("agent.tabs.recipes")}
|
||||
body={t("agent.recipes.needsAutomation")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProBadge />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="soft"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
onClick={onOpenAccount}
|
||||
>
|
||||
{t("agent.setup.planAction")}
|
||||
</Button>
|
||||
</div>
|
||||
</SetupPanel>
|
||||
))}
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
</div>
|
||||
</SoftFields>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
LuAppWindow,
|
||||
LuHand,
|
||||
LuMessageSquareText,
|
||||
LuSearch,
|
||||
LuUndo2,
|
||||
} from "react-icons/lu";
|
||||
import {
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
NoteAction,
|
||||
useTimeFormat,
|
||||
} from "@/components/agent-console-parts";
|
||||
import { DATA_TABLE_CLASSES } from "@/components/table-controls";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -41,10 +43,10 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATE_CLASS: Record<AgentProfileState, string> = {
|
||||
held: "border-primary/40 bg-primary/10 text-primary-text",
|
||||
working: "border-success/40 bg-success/10 text-success-text",
|
||||
error: "border-destructive/40 bg-destructive/10 text-destructive-text",
|
||||
idle: "border-border text-muted-foreground",
|
||||
held: "bg-primary/10 text-primary-text",
|
||||
working: "bg-success/10 text-success-text",
|
||||
error: "bg-destructive/10 text-destructive-text",
|
||||
idle: "bg-foreground/6 text-muted-foreground",
|
||||
};
|
||||
|
||||
export function AgentProfiles({
|
||||
@@ -119,16 +121,20 @@ export function AgentProfiles({
|
||||
<Label htmlFor={searchId} className="sr-only">
|
||||
{t("agent.profiles.search")}
|
||||
</Label>
|
||||
<Input
|
||||
id={searchId}
|
||||
value={query}
|
||||
placeholder={t("agent.profiles.search")}
|
||||
className="h-8 w-56 text-sm"
|
||||
data-testid="agent-profiles-search"
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<div className="relative">
|
||||
<LuSearch className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id={searchId}
|
||||
variant="soft"
|
||||
value={query}
|
||||
placeholder={t("agent.profiles.search")}
|
||||
className="h-8 w-56 pl-8 text-xs md:text-xs"
|
||||
data-testid="agent-profiles-search"
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={errorsId}
|
||||
@@ -179,11 +185,11 @@ export function AgentProfiles({
|
||||
{t("agent.profiles.noMatches")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-md border border-border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8">
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<Table className="data-table">
|
||||
<TableHeader className={DATA_TABLE_CLASSES.header}>
|
||||
<TableRow className={DATA_TABLE_CLASSES.headerRow}>
|
||||
<TableHead className={cn(DATA_TABLE_CLASSES.head, "w-8")}>
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
aria-label={t("common.aria.selectAll")}
|
||||
@@ -196,19 +202,42 @@ export function AgentProfiles({
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t("agent.profiles.columns.profile")}</TableHead>
|
||||
<TableHead className="hidden @2xl:table-cell">
|
||||
<TableHead className={DATA_TABLE_CLASSES.head}>
|
||||
{t("agent.profiles.columns.profile")}
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={cn(
|
||||
DATA_TABLE_CLASSES.head,
|
||||
"hidden @2xl:table-cell",
|
||||
)}
|
||||
>
|
||||
{t("agent.profiles.columns.agent")}
|
||||
</TableHead>
|
||||
<TableHead>{t("agent.profiles.columns.lastAction")}</TableHead>
|
||||
<TableHead className="hidden @xl:table-cell">
|
||||
<TableHead className={DATA_TABLE_CLASSES.head}>
|
||||
{t("agent.profiles.columns.lastAction")}
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={cn(
|
||||
DATA_TABLE_CLASSES.head,
|
||||
"hidden @xl:table-cell",
|
||||
)}
|
||||
>
|
||||
{t("agent.profiles.columns.result")}
|
||||
</TableHead>
|
||||
<TableHead className="hidden @xl:table-cell">
|
||||
<TableHead
|
||||
className={cn(
|
||||
DATA_TABLE_CLASSES.head,
|
||||
"hidden @xl:table-cell",
|
||||
)}
|
||||
>
|
||||
{t("agent.profiles.columns.when")}
|
||||
</TableHead>
|
||||
<TableHead>{t("agent.profiles.columns.state")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<TableHead className={DATA_TABLE_CLASSES.head}>
|
||||
{t("agent.profiles.columns.state")}
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={cn(DATA_TABLE_CLASSES.head, "text-right")}
|
||||
>
|
||||
{t("common.labels.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
@@ -222,6 +251,8 @@ export function AgentProfiles({
|
||||
return (
|
||||
<TableRow
|
||||
key={row.profile_id}
|
||||
data-table-row={row.profile_id}
|
||||
className={DATA_TABLE_CLASSES.row}
|
||||
data-testid={`agent-profile-${row.profile_id}`}
|
||||
data-state={
|
||||
selected.has(row.profile_id) ? "selected" : undefined
|
||||
@@ -298,7 +329,7 @@ export function AgentProfiles({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
className={cn("font-normal", STATE_CLASS[row.state])}
|
||||
>
|
||||
{t(`agent.profiles.state.${row.state}`)}
|
||||
@@ -338,9 +369,9 @@ export function AgentProfiles({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
className="row-reveal size-8 rounded-lg"
|
||||
aria-label={t("agent.actions.showWindow")}
|
||||
data-testid="agent-profile-show-window"
|
||||
onClick={() => {
|
||||
@@ -358,9 +389,9 @@ export function AgentProfiles({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
className="row-reveal size-8 rounded-lg"
|
||||
aria-label={t("agent.actions.note")}
|
||||
data-testid="agent-profile-note"
|
||||
onClick={() => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { StatusLight } from "@/components/ui/settings-panel";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import {
|
||||
getRecipeRecording,
|
||||
@@ -123,7 +124,7 @@ export function RecipeRecorder({
|
||||
return (
|
||||
<div
|
||||
data-slot="agent-recipe-recorder"
|
||||
className="flex flex-col gap-3 rounded-md border border-border bg-card p-3"
|
||||
className="flex flex-col gap-3 rounded-xl bg-foreground/3 p-3"
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("agent.recipes.recording.description")}
|
||||
@@ -131,8 +132,8 @@ export function RecipeRecorder({
|
||||
|
||||
{isRecording ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<LuCircleDot className="size-3.5 text-destructive" />
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<StatusLight tone="destructive" live />
|
||||
{t("agent.recipes.recording.recording")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -141,8 +142,10 @@ export function RecipeRecorder({
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
className="h-8 gap-1.5 rounded-lg text-xs"
|
||||
disabled={isBusy}
|
||||
aria-busy={isBusy}
|
||||
onClick={() => {
|
||||
void stop();
|
||||
}}
|
||||
@@ -154,7 +157,10 @@ export function RecipeRecorder({
|
||||
) : (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex min-w-48 flex-col gap-1">
|
||||
<Label htmlFor="agent-recorder-profile" className="text-xs">
|
||||
<Label
|
||||
htmlFor="agent-recorder-profile"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{t("agent.recipes.recording.hint")}
|
||||
</Label>
|
||||
<Select
|
||||
@@ -164,7 +170,8 @@ export function RecipeRecorder({
|
||||
>
|
||||
<SelectTrigger
|
||||
id="agent-recorder-profile"
|
||||
className="h-8 text-xs"
|
||||
variant="soft"
|
||||
className="h-8 w-full text-xs"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -179,7 +186,9 @@ export function RecipeRecorder({
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 rounded-lg text-xs"
|
||||
disabled={isBusy || !profileId}
|
||||
aria-busy={isBusy}
|
||||
onClick={() => {
|
||||
void start();
|
||||
}}
|
||||
|
||||
@@ -139,7 +139,7 @@ export function RecipeStepsEditor({
|
||||
// Rows are reordered and removed, so the index is the identity here.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`step-${index}`}
|
||||
className="flex flex-col gap-2 rounded-md border border-border p-2"
|
||||
className="flex flex-col gap-2 rounded-lg bg-foreground/4 p-2"
|
||||
data-slot="agent-recipe-step"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -166,9 +166,10 @@ export function RecipeStepsEditor({
|
||||
</Select>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
type="button"
|
||||
className="size-8 rounded-lg"
|
||||
disabled={disabled || index === 0}
|
||||
aria-label={t("agent.recipes.moveStepUp")}
|
||||
onClick={() => {
|
||||
@@ -178,9 +179,10 @@ export function RecipeStepsEditor({
|
||||
<LuArrowUp className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
type="button"
|
||||
className="size-8 rounded-lg"
|
||||
disabled={disabled || index === steps.length - 1}
|
||||
aria-label={t("agent.recipes.moveStepDown")}
|
||||
onClick={() => {
|
||||
@@ -190,9 +192,10 @@ export function RecipeStepsEditor({
|
||||
<LuArrowDown className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
type="button"
|
||||
className="size-8 rounded-lg"
|
||||
disabled={disabled}
|
||||
aria-label={t("agent.recipes.removeStep")}
|
||||
onClick={() => {
|
||||
@@ -410,7 +413,7 @@ function Field({
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor={id} className="text-xs">
|
||||
<Label htmlFor={id} className="text-xs font-medium text-muted-foreground">
|
||||
{label}
|
||||
</Label>
|
||||
{cloneElement(children, { id })}
|
||||
|
||||
@@ -90,19 +90,19 @@ export function AgentRecipes({ profiles }: AgentRecipesProps) {
|
||||
</p>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg text-xs"
|
||||
disabled={editing !== null}
|
||||
onClick={() => {
|
||||
setEditing("new");
|
||||
}}
|
||||
>
|
||||
<GoPlus className="size-4" />
|
||||
<GoPlus className="size-3.5" />
|
||||
{t("agent.recipes.new")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
{error !== null && (
|
||||
<p className="shrink-0 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive-text">
|
||||
<p className="shrink-0 rounded-xl bg-destructive/10 p-3 text-sm text-destructive-text">
|
||||
{translateBackendError(t, error)}
|
||||
</p>
|
||||
)}
|
||||
@@ -152,7 +152,7 @@ export function AgentRecipes({ profiles }: AgentRecipesProps) {
|
||||
recipes.map((recipe) => (
|
||||
<div
|
||||
key={recipe.id}
|
||||
className="flex items-center gap-3 rounded-md border border-border bg-card p-3"
|
||||
className="flex items-center gap-3 rounded-xl bg-foreground/3 p-3 transition-colors duration-150 hover:bg-foreground/5"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
@@ -165,8 +165,9 @@ export function AgentRecipes({ profiles }: AgentRecipesProps) {
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
onClick={() => {
|
||||
setEditing(recipe);
|
||||
}}
|
||||
@@ -174,8 +175,9 @@ export function AgentRecipes({ profiles }: AgentRecipesProps) {
|
||||
{t("common.buttons.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
className="h-8 rounded-lg text-xs hover:bg-destructive/10 hover:text-destructive-text"
|
||||
onClick={() => {
|
||||
setPendingRemoval(recipe);
|
||||
}}
|
||||
@@ -262,14 +264,19 @@ function RecipeEditor({
|
||||
return (
|
||||
<form
|
||||
data-slot="agent-recipe-editor"
|
||||
className="flex shrink-0 flex-col gap-3 rounded-md border border-border bg-card p-3"
|
||||
className="flex shrink-0 flex-col gap-3 rounded-xl bg-foreground/3 p-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSave();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="agent-recipe-name">{t("agent.recipes.name")}</Label>
|
||||
<Label
|
||||
htmlFor="agent-recipe-name"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{t("agent.recipes.name")}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-recipe-name"
|
||||
value={name}
|
||||
@@ -280,7 +287,9 @@ function RecipeEditor({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>{t("agent.recipes.steps")}</Label>
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
{t("agent.recipes.steps")}
|
||||
</Label>
|
||||
<RecipeStepsEditor
|
||||
steps={steps}
|
||||
onChange={setSteps}
|
||||
@@ -291,9 +300,10 @@ function RecipeEditor({
|
||||
{t("agent.recipes.stepsHint")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
type="button"
|
||||
className="h-8 gap-1.5 rounded-lg text-xs"
|
||||
disabled={isSaving}
|
||||
onClick={() => {
|
||||
setSteps([...steps, emptyStep("navigate")]);
|
||||
@@ -305,10 +315,21 @@ function RecipeEditor({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onCancel} type="button">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
<RippleButton type="submit" size="sm" disabled={!canSave}>
|
||||
<RippleButton
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
disabled={!canSave}
|
||||
>
|
||||
{isSaving ? t("common.buttons.saving") : t("common.buttons.save")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { type ReactNode, useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AnimatedTabs,
|
||||
AnimatedTabsContent,
|
||||
AnimatedTabsList,
|
||||
AnimatedTabsTrigger,
|
||||
} from "@/components/ui/animated-tabs";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import { SECTION_LABEL_CLASS } from "@/components/ui/settings-panel";
|
||||
import { useInputModality } from "@/hooks/use-input-modality";
|
||||
import type { SnippetLanguage } from "@/lib/api-examples";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface CodeExample {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
method?: string;
|
||||
paid?: boolean;
|
||||
snippets: Partial<Record<SnippetLanguage, { display: string; copy: string }>>;
|
||||
response?: string;
|
||||
}
|
||||
|
||||
const LANGUAGE_ORDER: SnippetLanguage[] = [
|
||||
"curl",
|
||||
"javascript",
|
||||
"python",
|
||||
"json",
|
||||
];
|
||||
|
||||
function languagesOf(example: CodeExample) {
|
||||
return LANGUAGE_ORDER.filter((language) => example.snippets[language]);
|
||||
}
|
||||
|
||||
export function CodeExamplesDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
examples,
|
||||
highlights,
|
||||
note,
|
||||
copyMessage,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
examples: CodeExample[];
|
||||
highlights: readonly string[];
|
||||
note?: ReactNode;
|
||||
copyMessage: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const inputModality = useInputModality();
|
||||
const animate = !reduceMotion && inputModality === "pointer";
|
||||
const markerId = useId();
|
||||
const [selectedId, setSelectedId] = useState(examples[0]?.id ?? "");
|
||||
// The language is the reader's, not the example's: it stays as they move
|
||||
// between examples, and an example without it falls back to its own first.
|
||||
const [language, setLanguage] = useState<SnippetLanguage>("curl");
|
||||
|
||||
const selected =
|
||||
examples.find((example) => example.id === selectedId) ?? examples[0];
|
||||
if (!selected) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
data-testid="code-examples-dialog"
|
||||
className="flex max-h-[85vh] max-w-4xl flex-col gap-4"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="@container flex min-h-0 flex-1 flex-col">
|
||||
<TabsPrimitive.Root
|
||||
value={selected.id}
|
||||
onValueChange={setSelectedId}
|
||||
orientation="vertical"
|
||||
className="flex min-h-0 flex-1 flex-col gap-4 @2xl:flex-row"
|
||||
>
|
||||
<TabsPrimitive.List
|
||||
aria-label={title}
|
||||
className="isolate flex shrink-0 scrollbar-none gap-1 overflow-x-auto @2xl:w-52 @2xl:flex-col @2xl:overflow-visible"
|
||||
>
|
||||
{examples.map((example) => {
|
||||
const active = example.id === selected.id;
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
key={example.id}
|
||||
value={example.id}
|
||||
data-testid={`code-example-${example.id}`}
|
||||
className={cn(
|
||||
"relative flex shrink-0 cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs whitespace-nowrap transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-foreground/15",
|
||||
active
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground hover:bg-foreground/4 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId={
|
||||
animate ? `example-marker-${markerId}` : undefined
|
||||
}
|
||||
initial={false}
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 -z-10 rounded-lg bg-foreground/7"
|
||||
transition={
|
||||
animate
|
||||
? { type: "spring", stiffness: 420, damping: 36 }
|
||||
: { duration: 0 }
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{example.method && (
|
||||
<span className="w-9 shrink-0 font-mono text-[10px] text-muted-foreground">
|
||||
{example.method}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{example.title}
|
||||
</span>
|
||||
{example.paid && <ProBadge />}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
})}
|
||||
</TabsPrimitive.List>
|
||||
|
||||
{examples.map((example) => {
|
||||
const languages = languagesOf(example);
|
||||
const shown = languages.includes(language)
|
||||
? language
|
||||
: (languages[0] ?? "curl");
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
key={example.id}
|
||||
value={example.id}
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-col gap-3 overflow-y-auto outline-none"
|
||||
>
|
||||
<motion.div
|
||||
initial={animate ? { x: 6 } : false}
|
||||
animate={{ x: 0 }}
|
||||
transition={{ duration: 0.18, ease: MOTION_EASE_OUT }}
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||
{example.title}
|
||||
{example.paid && (
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{t("integrations.examples.paid")}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{example.description}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<AnimatedTabs
|
||||
value={shown}
|
||||
onValueChange={(value) => {
|
||||
setLanguage(value as SnippetLanguage);
|
||||
}}
|
||||
className="flex min-w-0 flex-col gap-2"
|
||||
>
|
||||
<AnimatedTabsList
|
||||
aria-label={t("integrations.examples.language")}
|
||||
>
|
||||
{languages.map((item) => (
|
||||
<AnimatedTabsTrigger
|
||||
key={item}
|
||||
value={item}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{t(`integrations.examples.languages.${item}`)}
|
||||
</AnimatedTabsTrigger>
|
||||
))}
|
||||
</AnimatedTabsList>
|
||||
{languages.map((item) => {
|
||||
const snippet = example.snippets[item];
|
||||
if (!snippet) return null;
|
||||
return (
|
||||
<AnimatedTabsContent key={item} value={item}>
|
||||
<motion.div
|
||||
initial={animate ? { y: 4 } : false}
|
||||
animate={{ y: 0 }}
|
||||
transition={{
|
||||
duration: 0.16,
|
||||
ease: MOTION_EASE_OUT,
|
||||
}}
|
||||
>
|
||||
<CodeSnippet
|
||||
multiline
|
||||
code={snippet.display}
|
||||
copyText={snippet.copy}
|
||||
highlights={highlights}
|
||||
successMessage={copyMessage}
|
||||
className="text-xs leading-relaxed"
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatedTabsContent>
|
||||
);
|
||||
})}
|
||||
</AnimatedTabs>
|
||||
|
||||
{example.response && (
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<p className={SECTION_LABEL_CLASS}>
|
||||
{t("integrations.examples.response")}
|
||||
</p>
|
||||
<CodeSnippet
|
||||
multiline
|
||||
copyable={false}
|
||||
code={example.response}
|
||||
className="text-xs leading-relaxed text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TabsPrimitive.Content>
|
||||
);
|
||||
})}
|
||||
</TabsPrimitive.Root>
|
||||
</div>
|
||||
|
||||
{note && (
|
||||
<p className="shrink-0 text-xs leading-relaxed text-muted-foreground">
|
||||
{note}
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,12 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LuActivity } from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { OperationFlow } from "@/components/ui/operation-flow";
|
||||
import { SettingsPanel } from "@/components/ui/settings-panel";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Diagnostic {
|
||||
configured: boolean;
|
||||
@@ -22,8 +25,10 @@ interface Diagnostic {
|
||||
*/
|
||||
export function IntegrationDiagnostics({
|
||||
target,
|
||||
className,
|
||||
}: {
|
||||
target: "api" | "remote";
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [result, setResult] = useState<Diagnostic | null>(null);
|
||||
@@ -52,23 +57,33 @@ export function IntegrationDiagnostics({
|
||||
: "appFeedback.unavailable",
|
||||
);
|
||||
return (
|
||||
<section
|
||||
<SettingsPanel
|
||||
data-slot="integration-diagnostics"
|
||||
className="space-y-3 rounded-md bg-muted/30 p-4"
|
||||
className={cn("space-y-4", className)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("appFeedback.connectionTest")}
|
||||
</h3>
|
||||
<Button
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("appFeedback.connectionTest")}
|
||||
</h3>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t(
|
||||
target === "api"
|
||||
? "appFeedback.apiProbeScope"
|
||||
: "appFeedback.remoteProbeScope",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
variant="soft"
|
||||
className="h-8 gap-1.5 rounded-lg text-xs"
|
||||
isLoading={busy}
|
||||
onClick={() => void check()}
|
||||
>
|
||||
{t(busy ? "appFeedback.checking" : "appFeedback.testConnection")}
|
||||
</Button>
|
||||
<LuActivity className="size-3.5" />
|
||||
{t("appFeedback.testConnection")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
<OperationFlow
|
||||
label={t("appFeedback.connectionTest")}
|
||||
@@ -113,7 +128,7 @@ export function IntegrationDiagnostics({
|
||||
/>
|
||||
<div
|
||||
role="status"
|
||||
className="text-xs leading-relaxed text-muted-foreground"
|
||||
className="text-xs leading-relaxed text-muted-foreground tabular-nums"
|
||||
>
|
||||
{busy
|
||||
? t("appFeedback.checking")
|
||||
@@ -123,21 +138,16 @@ export function IntegrationDiagnostics({
|
||||
})
|
||||
: t("appFeedback.notChecked")}
|
||||
{result?.http_status != null && (
|
||||
<span className="ml-2 font-mono">HTTP {result.http_status}</span>
|
||||
<span className="ml-2 rounded-md bg-foreground/5 px-1.5 py-0.5 font-mono">
|
||||
HTTP {result.http_status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t(
|
||||
target === "api"
|
||||
? "appFeedback.apiProbeScope"
|
||||
: "appFeedback.remoteProbeScope",
|
||||
)}
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="break-words text-xs text-destructive-text">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</SettingsPanel>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,25 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuCheck, LuCloud, LuPlug, LuTrash2 } from "react-icons/lu";
|
||||
import {
|
||||
LuArrowRight,
|
||||
LuCloud,
|
||||
LuEye,
|
||||
LuEyeOff,
|
||||
LuPlug,
|
||||
LuRefreshCw,
|
||||
LuTrash2,
|
||||
} from "react-icons/lu";
|
||||
import {
|
||||
type CodeExample,
|
||||
CodeExamplesDialog,
|
||||
} from "@/components/code-examples-dialog";
|
||||
import { IntegrationDiagnostics } from "@/components/integration-diagnostics";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { AgentIcon } from "@/components/mcp-agent-icon";
|
||||
import { AnimatedDisclosureContent } from "@/components/ui/animated-disclosure";
|
||||
import { AnimatedSwitch } from "@/components/ui/animated-switch";
|
||||
import {
|
||||
AnimatedTabs,
|
||||
@@ -16,6 +29,9 @@ import {
|
||||
AnimatedTabsTrigger,
|
||||
} from "@/components/ui/animated-tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet";
|
||||
import { ConfirmationMark } from "@/components/ui/confirmation-mark";
|
||||
import { CopyToClipboard } from "@/components/ui/copy-to-clipboard";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -25,8 +41,26 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { OperationFlow } from "@/components/ui/operation-flow";
|
||||
import {
|
||||
FeatureIcon,
|
||||
SECTION_LABEL_CLASS,
|
||||
SettingsPanel,
|
||||
SettingsRow,
|
||||
StatusLight,
|
||||
} from "@/components/ui/settings-panel";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||
import {
|
||||
curlSnippet,
|
||||
LOCAL_API_EXAMPLES,
|
||||
maskToken,
|
||||
mcpClientConfig,
|
||||
oneLine,
|
||||
PROFILE_ID_PLACEHOLDER,
|
||||
REMOTE_MCP_EXAMPLES,
|
||||
REQUEST_LANGUAGES,
|
||||
requestSnippet,
|
||||
} from "@/lib/api-examples";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { canUseRemoteControl } from "@/lib/entitlements";
|
||||
import {
|
||||
@@ -42,7 +76,6 @@ import {
|
||||
} from "@/lib/mcp";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CopyToClipboard } from "./ui/copy-to-clipboard";
|
||||
|
||||
interface AppSettings {
|
||||
api_enabled: boolean;
|
||||
@@ -103,6 +136,12 @@ export function IntegrationsDialog({
|
||||
null,
|
||||
);
|
||||
const [isRotatingCredential, setIsRotatingCredential] = useState(false);
|
||||
const [apiExamplesOpen, setApiExamplesOpen] = useState(false);
|
||||
const [remoteExamplesOpen, setRemoteExamplesOpen] = useState(false);
|
||||
const apiSwitchId = useId();
|
||||
const apiPortId = useId();
|
||||
const apiTokenId = useId();
|
||||
const remoteSwitchId = useId();
|
||||
const [activeTab, setActiveTab] = useState<IntegrationsTab>(initialTab);
|
||||
// Mod+I re-targets an open dialog through `initialTab`. The tabs are
|
||||
// controlled (see `shownTab`), so a remount would not adopt the new value;
|
||||
@@ -420,22 +459,103 @@ export function IntegrationsDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePort = async () => {
|
||||
const port = settings.api_port;
|
||||
if (port < 1 || port > 65535) {
|
||||
showErrorToast(t("integrations.apiInvalidPort"), {
|
||||
description: t("integrations.apiInvalidPortDescription"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsApiStarting(true);
|
||||
try {
|
||||
await invoke("stop_api_server");
|
||||
const next = await invoke<AppSettings>("save_app_settings", {
|
||||
settings,
|
||||
});
|
||||
setSettings(next);
|
||||
const actualPort = await invoke<number>("start_api_server", { port });
|
||||
setApiServerPort(actualPort);
|
||||
if (actualPort !== port) {
|
||||
showErrorToast(t("integrations.apiPortInUse", { port }), {
|
||||
description: t("integrations.apiFallbackPort", { port: actualPort }),
|
||||
});
|
||||
} else {
|
||||
showSuccessToast(t("integrations.apiRunning", { port: actualPort }));
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorToast(t("integrations.apiStartFailed"), {
|
||||
description: translateBackendError(t, e),
|
||||
});
|
||||
} finally {
|
||||
setIsApiStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const credentialPresent = credential?.present ?? false;
|
||||
const credentialPrefix = credentialPrefixOf(credential);
|
||||
// The example names the credential it expects by its visible prefix; the
|
||||
// dialog never has the plaintext on screen, so the line is a template either
|
||||
// way and copying it hands over exactly what is shown.
|
||||
// The examples name the credential they expect by its visible prefix; the
|
||||
// dialog never has the plaintext on screen, so the lines are templates
|
||||
// either way and copying one hands over exactly what is shown.
|
||||
const exampleBearer = credentialPrefix
|
||||
? t("integrations.remote.credentialPrefix", { prefix: credentialPrefix })
|
||||
: "${TOKEN}";
|
||||
const remoteExampleRequest = [
|
||||
`curl -X POST -H "Authorization: Bearer ${exampleBearer}" \\`,
|
||||
` -H "Content-Type: application/json" \\`,
|
||||
` -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \\`,
|
||||
` ${REMOTE_MCP_URL}`,
|
||||
].join("\n");
|
||||
const remoteTarget = { baseUrl: REMOTE_MCP_URL, token: exampleBearer };
|
||||
const remoteQuickStart = oneLine(
|
||||
curlSnippet(REMOTE_MCP_EXAMPLES[0].request, remoteTarget),
|
||||
);
|
||||
const fxLine = fxExportLine(settings.mcp_remote_key);
|
||||
|
||||
const apiBaseUrl = `http://127.0.0.1:${apiServerPort ?? settings.api_port}`;
|
||||
const apiToken = settings.api_token ?? "";
|
||||
const maskedApiToken = maskToken(apiToken);
|
||||
// The local token is this machine's own, so a copied example carries it
|
||||
// and runs as pasted; the page itself shows only its ends.
|
||||
const apiDisplayTarget = { baseUrl: apiBaseUrl, token: maskedApiToken };
|
||||
const apiCopyTarget = { baseUrl: apiBaseUrl, token: apiToken || "TOKEN" };
|
||||
const apiQuickStart = LOCAL_API_EXAMPLES[0].request;
|
||||
|
||||
const apiExamples: CodeExample[] = LOCAL_API_EXAMPLES.map((example) => ({
|
||||
id: example.id,
|
||||
title: t(`integrations.examples.items.${example.id}.title`),
|
||||
description: t(`integrations.examples.items.${example.id}.description`),
|
||||
method: example.request.method,
|
||||
paid: example.paid,
|
||||
response: example.response,
|
||||
snippets: Object.fromEntries(
|
||||
REQUEST_LANGUAGES.map((language) => [
|
||||
language,
|
||||
{
|
||||
display: requestSnippet(language, example.request, apiDisplayTarget),
|
||||
copy: requestSnippet(language, example.request, apiCopyTarget),
|
||||
},
|
||||
]),
|
||||
),
|
||||
}));
|
||||
|
||||
const remoteConfig = mcpClientConfig(REMOTE_MCP_URL, exampleBearer);
|
||||
const remoteExamples: CodeExample[] = [
|
||||
...REMOTE_MCP_EXAMPLES.map((example) => ({
|
||||
id: example.id,
|
||||
title: t(`integrations.examples.items.${example.id}.title`),
|
||||
description: t(`integrations.examples.items.${example.id}.description`),
|
||||
method: example.request.method,
|
||||
response: example.response,
|
||||
snippets: Object.fromEntries(
|
||||
REQUEST_LANGUAGES.map((language) => {
|
||||
const code = requestSnippet(language, example.request, remoteTarget);
|
||||
return [language, { display: code, copy: code }];
|
||||
}),
|
||||
),
|
||||
})),
|
||||
{
|
||||
id: "clientConfig",
|
||||
title: t("integrations.examples.items.clientConfig.title"),
|
||||
description: t("integrations.examples.items.clientConfig.description"),
|
||||
snippets: { json: { display: remoteConfig, copy: remoteConfig } },
|
||||
},
|
||||
];
|
||||
|
||||
// Remote control is not something a regular user is told about: the tab
|
||||
// exists only for an account entitled to it, or while the bridge is already
|
||||
// on so it can always be switched off. Nothing else on the page names it.
|
||||
@@ -447,17 +567,20 @@ export function IntegrationsDialog({
|
||||
activeTab === "remote" && !remoteTabShown ? "api" : activeTab;
|
||||
|
||||
const clientsGrid = (
|
||||
<div className="@container flex flex-col gap-3">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
<section className="flex flex-col gap-2">
|
||||
<h3 className={SECTION_LABEL_CLASS}>
|
||||
{t("integrations.mcp.clientsLabel")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-1 gap-3 @2xl:grid-cols-2">
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-2 @2xl:grid-cols-2">
|
||||
{agents.map((agent) => {
|
||||
const busy = busyAgentIds.has(agent.id);
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="flex flex-col justify-center gap-2 rounded-md border bg-card px-3 py-2.5"
|
||||
className={cn(
|
||||
"flex flex-col justify-center gap-2 rounded-xl px-3 py-2.5 transition-colors duration-200",
|
||||
agent.connected ? "bg-foreground/5" : "bg-foreground/3",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<AgentIcon id={agent.id} category={agent.category} />
|
||||
@@ -472,28 +595,30 @@ export function IntegrationsDialog({
|
||||
{agent.connected ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-foreground">
|
||||
<LuCheck className="size-3" />
|
||||
<ConfirmationMark />
|
||||
{t("appFeedback.configured")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-destructive-text"
|
||||
className="size-8 rounded-lg hover:text-destructive-text"
|
||||
disabled={busy}
|
||||
onClick={() => void handleRemoveAgent(agent)}
|
||||
aria-label={t("integrations.mcp.removeAriaLabel", {
|
||||
name: agent.display_name,
|
||||
})}
|
||||
>
|
||||
<LuTrash2 className="size-4" />
|
||||
<LuTrash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
onClick={() => void handleAddAgent(agent)}
|
||||
>
|
||||
{t("integrations.mcp.add")}
|
||||
@@ -502,7 +627,7 @@ export function IntegrationsDialog({
|
||||
</div>
|
||||
{agent.connected && (
|
||||
<details data-slot="client-route" className="text-xs">
|
||||
<summary className="cursor-pointer rounded-sm py-1 text-muted-foreground focus-visible:outline-2 focus-visible:outline-ring">
|
||||
<summary className="w-fit cursor-pointer rounded-sm py-1 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring">
|
||||
{t("appFeedback.routeDetails")}
|
||||
</summary>
|
||||
<OperationFlow
|
||||
@@ -544,9 +669,8 @@ export function IntegrationsDialog({
|
||||
</p>
|
||||
{fxLine && (
|
||||
<CopyToClipboard
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
variant="subtle"
|
||||
className="size-8 shrink-0 rounded-lg"
|
||||
text={fxLine}
|
||||
successMessage={t("integrations.remote.fxExportCopied")}
|
||||
/>
|
||||
@@ -557,7 +681,20 @@ export function IntegrationsDialog({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
const examplesButton = (onClick: () => void) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
className="group/examples h-7 gap-1 rounded-lg px-2 text-xs"
|
||||
onClick={onClick}
|
||||
>
|
||||
{t("integrations.examples.open")}
|
||||
<LuArrowRight className="size-3.5 transition-transform duration-150 group-hover/examples:translate-x-0.5 motion-reduce:transition-none" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -593,28 +730,25 @@ export function IntegrationsDialog({
|
||||
)}
|
||||
</AnimatedTabsList>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="api"
|
||||
className="@container mt-4 flex flex-col gap-4"
|
||||
>
|
||||
<IntegrationDiagnostics
|
||||
key={`${apiServerPort}:${settings.api_token ?? ""}`}
|
||||
target="api"
|
||||
/>
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<LuPlug className="mt-0.5 size-5 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.apiEnableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.apiEnableDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<AnimatedTabsContent value="api" className="@container mt-4">
|
||||
<SettingsPanel>
|
||||
<div className="flex items-start gap-3">
|
||||
<FeatureIcon active={apiServerPort !== null}>
|
||||
<LuPlug />
|
||||
</FeatureIcon>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<Label
|
||||
htmlFor={apiSwitchId}
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("integrations.apiEnableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("integrations.apiEnableDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
id={apiSwitchId}
|
||||
checked={apiServerPort !== null}
|
||||
disabled={isApiStarting}
|
||||
onCheckedChange={(checked) =>
|
||||
@@ -622,199 +756,175 @@ export function IntegrationsDialog({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{apiServerPort && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="size-1.5 rounded-full bg-success" />
|
||||
<AnimatedDisclosureContent open={apiServerPort !== null}>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-3 text-xs @md:pl-12">
|
||||
<StatusLight tone="success" live />
|
||||
<span className="text-muted-foreground">
|
||||
{t("integrations.apiRunningOn")}
|
||||
</span>
|
||||
<code className="rounded bg-muted px-2 py-1 font-mono text-[11px]">
|
||||
http://127.0.0.1:{apiServerPort}
|
||||
</code>
|
||||
<CodeSnippet
|
||||
code={`http://127.0.0.1:${apiServerPort ?? settings.api_port}`}
|
||||
className="w-fit"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AnimatedDisclosureContent>
|
||||
</SettingsPanel>
|
||||
|
||||
{settings.api_enabled && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiPortLabel")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={apiPortDraft}
|
||||
onChange={(e) => {
|
||||
setApiPortDraft(e.target.value);
|
||||
const val = Number.parseInt(e.target.value, 10);
|
||||
if (
|
||||
!Number.isNaN(val) &&
|
||||
val >= 1 &&
|
||||
val <= 65535
|
||||
) {
|
||||
setSettings({ ...settings, api_port: val });
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const val = Number.parseInt(apiPortDraft, 10);
|
||||
if (Number.isNaN(val) || val < 1 || val > 65535) {
|
||||
setApiPortDraft(String(settings.api_port));
|
||||
}
|
||||
}}
|
||||
className="w-24 font-mono"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
isApiStarting ||
|
||||
apiServerPort === settings.api_port
|
||||
<AnimatedDisclosureContent open={settings.api_enabled}>
|
||||
<div className="flex flex-col gap-4 pt-3">
|
||||
<SettingsPanel className="divide-y divide-foreground/6 py-3">
|
||||
<SettingsRow
|
||||
label={t("integrations.apiPortLabel")}
|
||||
description={t("integrations.apiPortDescription")}
|
||||
htmlFor={apiPortId}
|
||||
>
|
||||
<Input
|
||||
id={apiPortId}
|
||||
type="number"
|
||||
variant="soft"
|
||||
value={apiPortDraft}
|
||||
onChange={(e) => {
|
||||
setApiPortDraft(e.target.value);
|
||||
const val = Number.parseInt(e.target.value, 10);
|
||||
if (
|
||||
!Number.isNaN(val) &&
|
||||
val >= 1 &&
|
||||
val <= 65535
|
||||
) {
|
||||
setSettings({ ...settings, api_port: val });
|
||||
}
|
||||
onClick={async () => {
|
||||
const port = settings.api_port;
|
||||
if (port < 1 || port > 65535) {
|
||||
showErrorToast(
|
||||
t("integrations.apiInvalidPort"),
|
||||
{
|
||||
description: t(
|
||||
"integrations.apiInvalidPortDescription",
|
||||
),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
setIsApiStarting(true);
|
||||
try {
|
||||
await invoke("stop_api_server");
|
||||
const next = await invoke<AppSettings>(
|
||||
"save_app_settings",
|
||||
{ settings },
|
||||
);
|
||||
setSettings(next);
|
||||
const actualPort = await invoke<number>(
|
||||
"start_api_server",
|
||||
{ port },
|
||||
);
|
||||
setApiServerPort(actualPort);
|
||||
if (actualPort !== port) {
|
||||
showErrorToast(
|
||||
t("integrations.apiPortInUse", { port }),
|
||||
{
|
||||
description: t(
|
||||
"integrations.apiFallbackPort",
|
||||
{ port: actualPort },
|
||||
),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(
|
||||
t("integrations.apiRunning", {
|
||||
port: actualPort,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorToast(
|
||||
t("integrations.apiStartFailed"),
|
||||
{
|
||||
description: translateBackendError(t, e),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setIsApiStarting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiTokenLabel")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showApiToken ? "text" : "password"}
|
||||
value={settings.api_token ?? ""}
|
||||
readOnly
|
||||
className="pr-10 font-mono"
|
||||
/>
|
||||
}}
|
||||
onBlur={() => {
|
||||
const val = Number.parseInt(apiPortDraft, 10);
|
||||
if (Number.isNaN(val) || val < 1 || val > 65535) {
|
||||
setApiPortDraft(String(settings.api_port));
|
||||
}
|
||||
}}
|
||||
className="h-8 w-24 font-mono text-xs md:text-xs"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="soft"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
isLoading={isApiStarting}
|
||||
disabled={apiServerPort === settings.api_port}
|
||||
onClick={() => void handleSavePort()}
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</LoadingButton>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t("integrations.apiTokenLabel")}
|
||||
description={t("integrations.apiTokenDescription")}
|
||||
htmlFor={apiTokenId}
|
||||
>
|
||||
<div className="relative w-full @xl:w-72">
|
||||
<Input
|
||||
id={apiTokenId}
|
||||
type={showApiToken ? "text" : "password"}
|
||||
variant="soft"
|
||||
value={apiToken}
|
||||
readOnly
|
||||
className="h-8 pr-16 font-mono text-xs md:text-xs"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-1 flex items-center gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-0 right-0 h-full px-3 hover:bg-transparent"
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
className="size-6 rounded-md"
|
||||
aria-label={
|
||||
showApiToken
|
||||
? t("common.aria.hideToken")
|
||||
: t("common.aria.showToken")
|
||||
}
|
||||
onClick={() => {
|
||||
setShowApiToken(!showApiToken);
|
||||
}}
|
||||
>
|
||||
{showApiToken ? (
|
||||
<EyeOff className="size-4" />
|
||||
<LuEyeOff className="size-3.5" />
|
||||
) : (
|
||||
<Eye className="size-4" />
|
||||
<LuEye className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<CopyToClipboard
|
||||
text={apiToken}
|
||||
variant="subtle"
|
||||
className="size-6 rounded-md"
|
||||
successMessage={t("integrations.tokenCopied")}
|
||||
/>
|
||||
</div>
|
||||
<CopyToClipboard
|
||||
text={settings.api_token ?? ""}
|
||||
successMessage={t("integrations.tokenCopied")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsPanel>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className={SECTION_LABEL_CLASS}>
|
||||
{t("integrations.apiExampleRequest")}
|
||||
</Label>
|
||||
<CopyToClipboard
|
||||
text={`curl -H "Authorization: Bearer ${settings.api_token ?? "${TOKEN}"}" \\\n http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
|
||||
successMessage={t("common.buttons.copied")}
|
||||
/>
|
||||
</h3>
|
||||
{examplesButton(() => {
|
||||
setApiExamplesOpen(true);
|
||||
})}
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded bg-background p-3 font-mono text-[11px] whitespace-pre">
|
||||
{`curl -H "Authorization: Bearer \${TOKEN}" \\
|
||||
http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<CodeSnippet
|
||||
prompt
|
||||
data-testid="integrations-api-example"
|
||||
code={oneLine(
|
||||
curlSnippet(apiQuickStart, apiDisplayTarget),
|
||||
)}
|
||||
copyText={oneLine(
|
||||
curlSnippet(apiQuickStart, apiCopyTarget),
|
||||
)}
|
||||
highlights={[maskedApiToken]}
|
||||
copyLabel={t("integrations.apiExampleRequest")}
|
||||
successMessage={t("common.buttons.copied")}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</AnimatedDisclosureContent>
|
||||
|
||||
<IntegrationDiagnostics
|
||||
key={`${apiServerPort}:${settings.api_token ?? ""}`}
|
||||
target="api"
|
||||
className="mt-4"
|
||||
/>
|
||||
|
||||
<CodeExamplesDialog
|
||||
open={apiExamplesOpen}
|
||||
onOpenChange={setApiExamplesOpen}
|
||||
title={t("integrations.examples.apiTitle")}
|
||||
description={t("integrations.examples.apiDescription")}
|
||||
examples={apiExamples}
|
||||
highlights={[maskedApiToken, PROFILE_ID_PLACEHOLDER]}
|
||||
note={t("integrations.examples.tokenIncluded")}
|
||||
copyMessage={t("common.buttons.copied")}
|
||||
/>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
{remoteTabShown && (
|
||||
<AnimatedTabsContent
|
||||
value="remote"
|
||||
className="mt-4 flex flex-col gap-5"
|
||||
>
|
||||
<IntegrationDiagnostics
|
||||
key={`${credentialPrefixOf(credential)}:${remote?.enabled}`}
|
||||
target="remote"
|
||||
/>
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<LuCloud className="mt-0.5 size-5 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.remote.enableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.remote.enableDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<AnimatedTabsContent value="remote" className="@container mt-4">
|
||||
<SettingsPanel>
|
||||
<div className="flex items-start gap-3">
|
||||
<FeatureIcon active={remote?.connected ?? false}>
|
||||
<LuCloud />
|
||||
</FeatureIcon>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<Label
|
||||
htmlFor={remoteSwitchId}
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("integrations.remote.enableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("integrations.remote.enableDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
id={remoteSwitchId}
|
||||
checked={remote?.enabled ?? false}
|
||||
disabled={
|
||||
!termsAccepted || !isLoggedIn || isRemoteStarting
|
||||
@@ -826,86 +936,83 @@ export function IntegrationsDialog({
|
||||
</div>
|
||||
|
||||
{!isLoggedIn && (
|
||||
<p className="text-xs text-warning-text">
|
||||
<p className="pt-3 text-xs text-warning-text @md:pl-12">
|
||||
{t("integrations.remote.signInRequired")}
|
||||
</p>
|
||||
)}
|
||||
{isLoggedIn && !termsAccepted && (
|
||||
<p className="text-xs text-warning-text">
|
||||
<p className="pt-3 text-xs text-warning-text @md:pl-12">
|
||||
{t("integrations.mcpAcceptTermsFirst")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{remote?.enabled && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
remote.connected
|
||||
? "bg-success"
|
||||
: "bg-muted-foreground",
|
||||
<AnimatedDisclosureContent open={remote?.enabled ?? false}>
|
||||
{remote && (
|
||||
<div className="flex flex-col gap-2 pt-3 @md:pl-12">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
{/* Three states, not two. A refusal the bridge
|
||||
will keep receiving (an unentitled plan, a taken
|
||||
slot, a dead credential) re-dials for ever
|
||||
without ever connecting, and "Connecting as"
|
||||
over the top of the error underneath it read as
|
||||
a hang rather than an answer. */}
|
||||
<StatusLight
|
||||
tone={
|
||||
remote.connected
|
||||
? "success"
|
||||
: remote.lastError
|
||||
? "destructive"
|
||||
: "muted"
|
||||
}
|
||||
live={remote.connected || !remote.lastError}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{remote.connected
|
||||
? t("integrations.remote.connected")
|
||||
: remote.lastError
|
||||
? t("integrations.remote.notConnected")
|
||||
: t("integrations.remote.connecting")}
|
||||
</span>
|
||||
{/* Only under a phrase that governs it. "Connected
|
||||
as" and "Connecting as" are open phrases; "Not
|
||||
connected" is closed, and the id dangling after
|
||||
it read as a sentence fragment in all ten
|
||||
locales. */}
|
||||
{!(remote.lastError && !remote.connected) && (
|
||||
<code className="rounded-md bg-foreground/5 px-2 py-0.5 font-mono text-[11px]">
|
||||
{remote.instanceId}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
{!remote.connected && remote.lastError && (
|
||||
<p className="text-xs text-destructive-text">
|
||||
{translateBackendError(t, remote.lastError)}
|
||||
</p>
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{/* Three states, not two. A refusal the bridge will
|
||||
keep receiving (an unentitled plan, a taken slot,
|
||||
a dead credential) re-dials for ever without ever
|
||||
connecting, and "Connecting as" over the top of the
|
||||
error underneath it read as a hang rather than an
|
||||
answer. */}
|
||||
{remote.connected
|
||||
? t("integrations.remote.connected")
|
||||
: remote.lastError
|
||||
? t("integrations.remote.notConnected")
|
||||
: t("integrations.remote.connecting")}
|
||||
</span>
|
||||
{/* Only under a phrase that governs it. "Connected as"
|
||||
and "Connecting as" are open phrases; "Not connected"
|
||||
is closed, and the id dangling after it read as a
|
||||
sentence fragment in all ten locales. */}
|
||||
{!(remote.lastError && !remote.connected) && (
|
||||
<code className="rounded bg-muted px-2 py-1 font-mono text-[11px]">
|
||||
{remote.instanceId}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remote?.enabled &&
|
||||
!remote.connected &&
|
||||
remote.lastError && (
|
||||
<p className="text-xs text-destructive-text">
|
||||
{translateBackendError(t, remote.lastError)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AnimatedDisclosureContent>
|
||||
</SettingsPanel>
|
||||
|
||||
{/* Gated on entitlement: handing an unentitled customer a
|
||||
URL, a credential or an "Add" button that can only answer
|
||||
402 is the same wrong-diagnosis trap as telling them their
|
||||
desktop is offline. */}
|
||||
{remote?.enabled &&
|
||||
(remoteEntitled || !remoteEntitlementKnown) && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.remote.credentialLabel")}
|
||||
</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isRotatingCredential}
|
||||
onClick={() => void handleRotateCredential()}
|
||||
>
|
||||
{credentialPresent
|
||||
? t("integrations.remote.credentialRotate")
|
||||
: t("integrations.remote.credentialCreate")}
|
||||
</Button>
|
||||
</div>
|
||||
<AnimatedDisclosureContent
|
||||
open={
|
||||
(remote?.enabled ?? false) &&
|
||||
(remoteEntitled || !remoteEntitlementKnown)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4 pt-3">
|
||||
<SettingsPanel className="divide-y divide-foreground/6 py-3">
|
||||
<SettingsRow
|
||||
label={t("integrations.remote.credentialLabel")}
|
||||
description={t("integrations.remote.credentialHint")}
|
||||
>
|
||||
{credentialPresent ? (
|
||||
credentialPrefix && (
|
||||
<code className="w-fit rounded bg-muted px-2 py-1 font-mono text-[11px]">
|
||||
<code className="rounded-md bg-foreground/5 px-2 py-1 font-mono text-[11px]">
|
||||
{t("integrations.remote.credentialPrefix", {
|
||||
prefix: credentialPrefix,
|
||||
})}
|
||||
@@ -916,51 +1023,76 @@ export function IntegrationsDialog({
|
||||
{t("integrations.remote.credentialNone")}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.remote.credentialHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.remote.endpointLabel")}
|
||||
</Label>
|
||||
<CopyToClipboard
|
||||
text={REMOTE_MCP_URL}
|
||||
successMessage={t(
|
||||
"integrations.remote.endpointCopied",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={REMOTE_MCP_URL}
|
||||
readOnly
|
||||
className="font-mono text-xs"
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="soft"
|
||||
className="h-8 shrink-0 gap-1.5 rounded-lg text-xs"
|
||||
isLoading={isRotatingCredential}
|
||||
onClick={() => void handleRotateCredential()}
|
||||
>
|
||||
{credentialPresent && (
|
||||
<LuRefreshCw className="size-3.5" />
|
||||
)}
|
||||
{credentialPresent
|
||||
? t("integrations.remote.credentialRotate")
|
||||
: t("integrations.remote.credentialCreate")}
|
||||
</LoadingButton>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t("integrations.remote.endpointLabel")}
|
||||
description={t(
|
||||
"integrations.remote.endpointDescription",
|
||||
)}
|
||||
>
|
||||
<CodeSnippet
|
||||
code={REMOTE_MCP_URL}
|
||||
copyLabel={t("integrations.remote.endpointLabel")}
|
||||
successMessage={t(
|
||||
"integrations.remote.endpointCopied",
|
||||
)}
|
||||
className="w-full @xl:w-auto"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.remote.endpointDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsPanel>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.remote.exampleRequest")}
|
||||
</Label>
|
||||
<CopyToClipboard
|
||||
text={remoteExampleRequest}
|
||||
successMessage={t("common.buttons.copied")}
|
||||
/>
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded bg-background p-3 font-mono text-[11px] whitespace-pre">
|
||||
{remoteExampleRequest}
|
||||
</pre>
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className={SECTION_LABEL_CLASS}>
|
||||
{t("integrations.remote.exampleRequest")}
|
||||
</h3>
|
||||
{examplesButton(() => {
|
||||
setRemoteExamplesOpen(true);
|
||||
})}
|
||||
</div>
|
||||
<CodeSnippet
|
||||
prompt
|
||||
code={remoteQuickStart}
|
||||
highlights={[exampleBearer]}
|
||||
copyLabel={t("integrations.remote.exampleRequest")}
|
||||
successMessage={t("common.buttons.copied")}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{clientsGrid}
|
||||
</>
|
||||
)}
|
||||
{clientsGrid}
|
||||
</div>
|
||||
</AnimatedDisclosureContent>
|
||||
|
||||
<IntegrationDiagnostics
|
||||
key={`${credentialPrefixOf(credential)}:${remote?.enabled}`}
|
||||
target="remote"
|
||||
className="mt-4"
|
||||
/>
|
||||
|
||||
<CodeExamplesDialog
|
||||
open={remoteExamplesOpen}
|
||||
onOpenChange={setRemoteExamplesOpen}
|
||||
title={t("integrations.examples.remoteTitle")}
|
||||
description={t("integrations.examples.remoteDescription")}
|
||||
examples={remoteExamples}
|
||||
highlights={[exampleBearer]}
|
||||
note={t("integrations.examples.credentialTemplate")}
|
||||
copyMessage={t("common.buttons.copied")}
|
||||
/>
|
||||
</AnimatedTabsContent>
|
||||
)}
|
||||
</AnimatedTabs>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { CopyToClipboard } from "@/components/ui/copy-to-clipboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function escapeRegExp(text: string) {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** Marks the parts of a snippet the reader has to swap for their own value. */
|
||||
function withHighlights(code: string, highlights: readonly string[]) {
|
||||
const tokens = [...new Set(highlights.filter(Boolean))];
|
||||
if (tokens.length === 0) return code;
|
||||
const parts = code.split(
|
||||
new RegExp(`(${tokens.map(escapeRegExp).join("|")})`, "g"),
|
||||
);
|
||||
const nodes: ReactNode[] = [];
|
||||
let offset = 0;
|
||||
parts.forEach((part, index) => {
|
||||
if (part) {
|
||||
nodes.push(
|
||||
index % 2 === 1 ? (
|
||||
<span
|
||||
key={offset}
|
||||
data-slot="code-placeholder"
|
||||
className="rounded-sm bg-primary/10 px-0.5 font-medium text-foreground"
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
),
|
||||
);
|
||||
}
|
||||
offset += part.length;
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
interface CodeSnippetProps {
|
||||
code: string;
|
||||
/** What the copy button writes, when it differs from what is shown. */
|
||||
copyText?: string;
|
||||
highlights?: readonly string[];
|
||||
/** Draws a shell prompt that is never copied. */
|
||||
prompt?: boolean;
|
||||
multiline?: boolean;
|
||||
/** Wraps long lines, for prose rather than code. */
|
||||
wrap?: boolean;
|
||||
copyable?: boolean;
|
||||
copyLabel?: string;
|
||||
successMessage?: string;
|
||||
className?: string;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export function CodeSnippet({
|
||||
code,
|
||||
copyText,
|
||||
highlights = [],
|
||||
prompt = false,
|
||||
multiline = false,
|
||||
wrap = false,
|
||||
copyable = true,
|
||||
copyLabel,
|
||||
successMessage,
|
||||
className,
|
||||
"data-testid": testId,
|
||||
}: CodeSnippetProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="code-snippet"
|
||||
className={cn(
|
||||
"group/snippet relative min-w-0 rounded-lg bg-foreground/4 font-mono text-[11px] leading-5 text-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<pre
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
"[font-family:inherit] select-text",
|
||||
wrap
|
||||
? "break-words whitespace-pre-wrap"
|
||||
: "scrollbar-none overflow-x-auto whitespace-pre",
|
||||
multiline ? "max-h-72 overflow-y-auto p-3" : "px-3 py-1.5",
|
||||
copyable && "pr-10",
|
||||
)}
|
||||
>
|
||||
{prompt && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground select-none"
|
||||
>
|
||||
${" "}
|
||||
</span>
|
||||
)}
|
||||
<code className="[font-family:inherit]">
|
||||
{withHighlights(code, highlights)}
|
||||
</code>
|
||||
</pre>
|
||||
{copyable && (
|
||||
<CopyToClipboard
|
||||
text={copyText ?? code}
|
||||
variant="subtle"
|
||||
aria-label={copyLabel}
|
||||
successMessage={successMessage}
|
||||
className={cn(
|
||||
"absolute right-1 size-7 rounded-md bg-background/60 opacity-0 backdrop-blur-sm transition-opacity duration-150 group-focus-within/snippet:opacity-100 group-hover/snippet:opacity-100 focus-visible:opacity-100 data-[copied=true]:opacity-100 [@media(hover:none)]:opacity-100",
|
||||
multiline ? "top-1.5" : "top-1/2 -translate-y-1/2",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { LuCheck, LuCopy } from "react-icons/lu";
|
||||
import { Button, type ButtonProps } from "@/components/ui/button";
|
||||
import { showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CopyToClipboardProps {
|
||||
text: string;
|
||||
@@ -12,6 +13,7 @@ interface CopyToClipboardProps {
|
||||
size?: "default" | "sm" | "lg" | "icon";
|
||||
className?: string;
|
||||
successMessage?: string;
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
export function CopyToClipboard({
|
||||
@@ -19,7 +21,8 @@ export function CopyToClipboard({
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
className,
|
||||
successMessage = "Copied to clipboard",
|
||||
successMessage,
|
||||
"aria-label": ariaLabel,
|
||||
}: CopyToClipboardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -28,35 +31,41 @@ export function CopyToClipboard({
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
showSuccessToast(successMessage);
|
||||
showSuccessToast(successMessage ?? t("toasts.success.copied"));
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy to clipboard:", error);
|
||||
}
|
||||
}, [text, successMessage]);
|
||||
}, [text, successMessage, t]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={`relative ${className ?? ""}`}
|
||||
// Lets a control that is only shown on hover stay up while it confirms.
|
||||
data-copied={copied}
|
||||
className={cn("relative", className)}
|
||||
onClick={copyToClipboard}
|
||||
aria-label={copied ? t("common.aria.copied") : t("common.aria.copy")}
|
||||
aria-label={
|
||||
copied ? t("common.aria.copied") : (ariaLabel ?? t("common.aria.copy"))
|
||||
}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{copied ? t("common.srOnly.copied") : t("common.srOnly.copy")}
|
||||
</span>
|
||||
<LuCopy
|
||||
className={`size-4 transition-all duration-150 ${
|
||||
copied ? "scale-0" : "scale-100"
|
||||
}`}
|
||||
className={cn(
|
||||
"size-4 transition-all duration-150",
|
||||
copied ? "scale-0" : "scale-100",
|
||||
)}
|
||||
/>
|
||||
<LuCheck
|
||||
className={`absolute inset-0 m-auto size-4 text-foreground transition-all duration-150 ${
|
||||
copied ? "scale-100" : "scale-0"
|
||||
}`}
|
||||
className={cn(
|
||||
"absolute inset-0 m-auto size-4 text-foreground transition-all duration-150",
|
||||
copied ? "scale-100" : "scale-0",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const SECTION_LABEL_CLASS =
|
||||
"text-[11px] font-medium tracking-wide text-muted-foreground uppercase";
|
||||
|
||||
/** One quiet surface for a group of related settings. */
|
||||
export function SettingsPanel({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"section">) {
|
||||
return (
|
||||
<section
|
||||
className={cn("rounded-xl bg-foreground/3 p-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A setting with its explanation on the left and its control on the right. */
|
||||
export function SettingsRow({
|
||||
label,
|
||||
description,
|
||||
htmlFor,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
htmlFor?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 py-3 first:pt-0 last:pb-0 @xl:flex-row @xl:items-center @xl:justify-between @xl:gap-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-0.5 @xl:max-w-[50%]">
|
||||
<Label htmlFor={htmlFor} className="text-sm font-medium">
|
||||
{label}
|
||||
</Label>
|
||||
{description && (
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2 @xl:justify-end">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The icon beside a feature's on/off switch; it lights up while the feature runs. */
|
||||
export function FeatureIcon({
|
||||
children,
|
||||
active,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
active: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"grid size-9 shrink-0 place-items-center rounded-lg transition-colors duration-200 [&_svg]:size-4.5",
|
||||
active
|
||||
? "bg-success/12 text-success-text"
|
||||
: "bg-foreground/6 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A status light. `live` pulses for a connection that is up right now. */
|
||||
export function StatusLight({
|
||||
tone,
|
||||
live = false,
|
||||
className,
|
||||
}: {
|
||||
tone: "success" | "warning" | "destructive" | "muted";
|
||||
live?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const color = {
|
||||
success: "bg-success",
|
||||
warning: "bg-warning",
|
||||
destructive: "bg-destructive",
|
||||
muted: "bg-muted-foreground",
|
||||
}[tone];
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("relative inline-flex size-2 shrink-0", className)}
|
||||
>
|
||||
{live && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inset-0 animate-ping rounded-full opacity-60 motion-reduce:hidden",
|
||||
color,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className={cn("relative size-2 rounded-full", color)} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { useFieldVariant } from "@/components/ui/field-variant";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
const textareaVariants = cva(
|
||||
"flex min-h-[80px] w-full px-3 py-2 text-sm transition-[color,background-color,border-color,box-shadow] placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"rounded-md border border-input bg-background ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
soft: "rounded-lg bg-foreground/5 hover:bg-foreground/7 focus-visible:bg-foreground/7 focus-visible:ring-2 focus-visible:ring-foreground/15 aria-invalid:bg-destructive/10 aria-invalid:ring-2 aria-invalid:ring-destructive/40",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement> &
|
||||
VariantProps<typeof textareaVariants>;
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
({ className, variant, ...props }, ref) => {
|
||||
const inherited = useFieldVariant();
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
textareaVariants({ variant: variant ?? inherited }),
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
Reference in new issue
Block a user