style: more consistent style across pages

This commit is contained in:
zhom committed 2026-10-07 17:23:47 +04:00
1 parent b61e582e24
commit ffd762bdae
31 files changed
+3001 -884

No files matched your search

+336 -263
View File
@@ -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";
+21 -15
View File
@@ -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)`,
+21 -11
View File
@@ -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"
+22 -18
View File
@@ -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
View File
@@ -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>
);
+62 -31
View File
@@ -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={() => {
+15 -6
View File
@@ -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();
}}
+8 -5
View File
@@ -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 })}
+33 -12
View File
@@ -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>
+251
View File
@@ -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>
);
}
+33 -23
View File
@@ -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>
);
}
+451 -319
View File
@@ -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>
+116
View File
@@ -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>
);
}
+20 -11
View File
@@ -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>
);
+119
View File
@@ -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>
);
}
+25 -3
View File
@@ -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}