mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-21 23:01:26 +02:00
style: more consistent style across pages
This commit is contained in:
1 parent
b61e582e24
commit
ffd762bdae
31 files changed
+3001
-884
No files matched your search
+336
-263
@@ -1,14 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LuArrowRight,
|
||||
LuCircleCheck,
|
||||
LuCloud,
|
||||
LuEye,
|
||||
LuEyeOff,
|
||||
LuLogOut,
|
||||
LuRefreshCw,
|
||||
LuServer,
|
||||
LuTriangleAlert,
|
||||
LuUser,
|
||||
} from "react-icons/lu";
|
||||
import {
|
||||
@@ -24,11 +28,18 @@ import {
|
||||
AnimatedTabsList,
|
||||
AnimatedTabsTrigger,
|
||||
} from "@/components/ui/animated-tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { SoftFields } from "@/components/ui/field-variant";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RippleButton } from "@/components/ui/ripple";
|
||||
import {
|
||||
FeatureIcon,
|
||||
SECTION_LABEL_CLASS,
|
||||
SettingsPanel,
|
||||
StatusLight,
|
||||
} from "@/components/ui/settings-panel";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
@@ -241,6 +252,72 @@ export function AccountPage({
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const subscriptionTone = (
|
||||
status: string | null | undefined,
|
||||
): "success" | "warning" | "muted" => {
|
||||
if (status === "active" || status === "trialing") return "success";
|
||||
if (status === "past_due" || status === "unpaid") return "warning";
|
||||
return "muted";
|
||||
};
|
||||
|
||||
const details: { id: string; label: string; value: ReactNode }[] = [];
|
||||
if (isLoggedIn && user) {
|
||||
details.push(
|
||||
{
|
||||
id: "plan",
|
||||
label: t("account.fields.plan"),
|
||||
value: <span className="uppercase">{effectivePlan}</span>,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
label: t("account.fields.status"),
|
||||
value: (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<StatusLight tone={subscriptionTone(user.subscriptionStatus)} />
|
||||
{user.subscriptionStatus ?? "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
);
|
||||
if (user.teamRole) {
|
||||
details.push({
|
||||
id: "teamRole",
|
||||
label: t("account.fields.teamRole"),
|
||||
value: user.teamRole,
|
||||
});
|
||||
}
|
||||
if (user.planPeriod) {
|
||||
details.push({
|
||||
id: "period",
|
||||
label: t("account.fields.period"),
|
||||
value: user.planPeriod,
|
||||
});
|
||||
}
|
||||
if (typeof user.deviceOrdinal === "number") {
|
||||
details.push({
|
||||
id: "device",
|
||||
label: t("account.fields.device"),
|
||||
value: t("account.deviceOrdinal", {
|
||||
ordinal: user.deviceOrdinal,
|
||||
count: user.deviceCount ?? user.deviceOrdinal,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const connectionTone = {
|
||||
unknown: "muted",
|
||||
testing: "muted",
|
||||
connected: "success",
|
||||
error: "destructive",
|
||||
} as const;
|
||||
const connectionLabel = {
|
||||
unknown: t("account.selfHosted.statusUnknown"),
|
||||
testing: t("appFeedback.checking"),
|
||||
connected: t("sync.status.connected"),
|
||||
error: t("sync.status.error"),
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="flex max-h-[calc(100vh-5rem)] max-w-3xl flex-col">
|
||||
@@ -269,16 +346,31 @@ export function AccountPage({
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
|
||||
<AnimatedTabsContent value="account" className="mt-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid size-12 shrink-0 place-items-center rounded-full bg-accent text-accent-foreground">
|
||||
<LuUser className="size-6" />
|
||||
</div>
|
||||
<AnimatedTabsContent
|
||||
value="account"
|
||||
className="@container mt-4 flex flex-col gap-3"
|
||||
>
|
||||
<SettingsPanel className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"grid size-11 shrink-0 place-items-center rounded-full text-base font-semibold uppercase",
|
||||
isLoggedIn && user
|
||||
? "bg-primary/10 text-foreground"
|
||||
: "bg-foreground/6 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoggedIn && user ? (
|
||||
user.email.charAt(0)
|
||||
) : (
|
||||
<LuUser className="size-5" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isLoggedIn && user ? (
|
||||
<>
|
||||
<h2 className="truncate text-base font-semibold">
|
||||
<h2 className="truncate text-sm font-semibold">
|
||||
{user.email}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
@@ -292,7 +384,7 @@ export function AccountPage({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-base font-semibold">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("account.signedOut")}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
@@ -301,120 +393,79 @@ export function AccountPage({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="soft"
|
||||
onClick={() => {
|
||||
void handleRefresh();
|
||||
}}
|
||||
disabled={isRefreshing || isLoggingOut}
|
||||
aria-busy={isRefreshing}
|
||||
className="h-8 gap-1.5 rounded-lg text-xs"
|
||||
>
|
||||
<LuRefreshCw
|
||||
className={cn(
|
||||
"size-3.5",
|
||||
isRefreshing &&
|
||||
"animate-spin motion-reduce:animate-none",
|
||||
)}
|
||||
/>
|
||||
{t("account.refresh")}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="soft"
|
||||
isLoading={isLoggingOut}
|
||||
disabled={isRefreshing}
|
||||
onClick={() => {
|
||||
void handleLogout();
|
||||
}}
|
||||
className="h-8 gap-1.5 rounded-lg text-xs text-destructive-text hover:bg-destructive/10"
|
||||
>
|
||||
<LuLogOut className="size-3.5" />
|
||||
{t("account.logout")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
) : (
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={onOpenSignIn}
|
||||
className="h-8 gap-1.5 rounded-lg text-xs"
|
||||
>
|
||||
<LuCloud className="size-3.5" />
|
||||
{t("account.signIn")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{remoteHoursVisible && (
|
||||
// A headline block, not one field among six: the allowance
|
||||
// is the number a customer needs before a launch is
|
||||
// refused, which is the only way they ever saw it before.
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2.5">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("cookieBot.hours.label")}
|
||||
</p>
|
||||
{formatDate(quota?.period_end) && (
|
||||
<p className="text-xs tabular-nums text-muted-foreground">
|
||||
{t("cookieBot.hours.resets", {
|
||||
date: formatDate(quota?.period_end),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-lg leading-none font-semibold tabular-nums">
|
||||
{quota ? formatHours(quota.remaining_hours) : "—"}
|
||||
<span className="ml-1 text-sm font-normal text-muted-foreground">
|
||||
{t("cookieBot.hours.remainingOf", {
|
||||
total: quota
|
||||
? formatHours(quota.granted_hours)
|
||||
: "—",
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<RemoteHoursMeter
|
||||
quota={quota}
|
||||
isLoading={isQuotaLoading}
|
||||
variant="inline"
|
||||
className="mt-2"
|
||||
/>
|
||||
<div className="mt-2 flex items-baseline justify-between gap-3">
|
||||
<p className="text-xs tabular-nums text-muted-foreground">
|
||||
{t("cookieBot.hours.used", {
|
||||
used: quota ? formatHours(quota.used_hours) : "—",
|
||||
total: quota
|
||||
? formatHours(quota.granted_hours)
|
||||
: "—",
|
||||
})}
|
||||
</p>
|
||||
{showTeamUsage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveTab("team-usage");
|
||||
}}
|
||||
className="text-xs text-muted-foreground underline underline-offset-2 transition-colors duration-100 hover:text-foreground"
|
||||
>
|
||||
{t("account.viewTeamUsage")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoggedIn && user && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.plan")}
|
||||
</p>
|
||||
<p className="mt-0.5 font-medium uppercase">
|
||||
{effectivePlan}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.status")}
|
||||
</p>
|
||||
<p className="mt-0.5">
|
||||
{user.subscriptionStatus ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
{user.teamRole && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.teamRole")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.teamRole}</p>
|
||||
{details.length > 0 && (
|
||||
<dl className="grid grid-cols-2 gap-2 text-xs @xl:grid-cols-3">
|
||||
{details.map((detail) => (
|
||||
<div
|
||||
key={detail.id}
|
||||
className="min-w-0 rounded-lg bg-foreground/4 px-3 py-2"
|
||||
>
|
||||
<dt className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{detail.label}
|
||||
</dt>
|
||||
<dd className="mt-0.5 truncate font-medium">
|
||||
{detail.value}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{user.planPeriod && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.period")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.planPeriod}</p>
|
||||
</div>
|
||||
)}
|
||||
{typeof user.deviceOrdinal === "number" && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.device")}
|
||||
</p>
|
||||
<p className="mt-0.5">
|
||||
{t("account.deviceOrdinal", {
|
||||
ordinal: user.deviceOrdinal,
|
||||
count: user.deviceCount ?? user.deviceOrdinal,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{isLoggedIn &&
|
||||
user &&
|
||||
getEntitlements(user).browserAutomation &&
|
||||
user.isPrimaryDevice === false && (
|
||||
<p className="text-xs text-warning-text">
|
||||
<p className="flex items-start gap-2 text-xs text-warning-text">
|
||||
<LuTriangleAlert className="mt-0.5 size-3.5 shrink-0" />
|
||||
{t("account.automationPrimaryOnly")}
|
||||
</p>
|
||||
)}
|
||||
@@ -423,52 +474,68 @@ export function AccountPage({
|
||||
getEntitlements(user).browserAutomation &&
|
||||
user.isPrimaryDevice === true &&
|
||||
(user.deviceCount ?? 1) > 1 && (
|
||||
<p className="text-xs text-success-text">
|
||||
<p className="flex items-start gap-2 text-xs text-success-text">
|
||||
<LuCircleCheck className="mt-0.5 size-3.5 shrink-0" />
|
||||
{t("account.automationActiveHere")}
|
||||
</p>
|
||||
)}
|
||||
</SettingsPanel>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
{remoteHoursVisible && (
|
||||
// A headline block, not one field among six: the allowance
|
||||
// is the number a customer needs before a launch is
|
||||
// refused, which is the only way they ever saw it before.
|
||||
<SettingsPanel>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<p className={SECTION_LABEL_CLASS}>
|
||||
{t("cookieBot.hours.label")}
|
||||
</p>
|
||||
{formatDate(quota?.period_end) && (
|
||||
<p className="text-xs tabular-nums text-muted-foreground">
|
||||
{t("cookieBot.hours.resets", {
|
||||
date: formatDate(quota?.period_end),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-2xl leading-none font-semibold tabular-nums">
|
||||
{quota ? formatHours(quota.remaining_hours) : "—"}
|
||||
<span className="ml-1.5 text-sm font-normal text-muted-foreground">
|
||||
{t("cookieBot.hours.remainingOf", {
|
||||
total: quota ? formatHours(quota.granted_hours) : "—",
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<RemoteHoursMeter
|
||||
quota={quota}
|
||||
isLoading={isQuotaLoading}
|
||||
variant="inline"
|
||||
className="mt-3"
|
||||
/>
|
||||
<div className="mt-2 flex items-baseline justify-between gap-3">
|
||||
<p className="text-xs tabular-nums text-muted-foreground">
|
||||
{t("cookieBot.hours.used", {
|
||||
used: quota ? formatHours(quota.used_hours) : "—",
|
||||
total: quota ? formatHours(quota.granted_hours) : "—",
|
||||
})}
|
||||
</p>
|
||||
{showTeamUsage && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void handleRefresh();
|
||||
setActiveTab("team-usage");
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
className="group/team h-7 gap-1 rounded-lg px-2 text-xs"
|
||||
>
|
||||
<LuRefreshCw className="size-3" />
|
||||
{t("account.refresh")}
|
||||
{t("account.viewTeamUsage")}
|
||||
<LuArrowRight className="size-3.5 transition-transform duration-150 group-hover/team:translate-x-0.5 motion-reduce:transition-none" />
|
||||
</Button>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
isLoading={isLoggingOut}
|
||||
disabled={isRefreshing}
|
||||
onClick={() => {
|
||||
void handleLogout();
|
||||
}}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuLogOut className="size-3" />
|
||||
{t("account.logout")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onOpenSignIn}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuCloud className="size-3" />
|
||||
{t("account.signIn")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsPanel>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
|
||||
{showTeamUsage && (
|
||||
@@ -477,7 +544,10 @@ export function AccountPage({
|
||||
</AnimatedTabsContent>
|
||||
)}
|
||||
|
||||
<AnimatedTabsContent value="self-hosted" className="mt-4">
|
||||
<AnimatedTabsContent
|
||||
value="self-hosted"
|
||||
className="@container mt-4"
|
||||
>
|
||||
{selfHostedDisabled ? (
|
||||
// Defensive: the tab trigger is disabled while the user is
|
||||
// logged in, so this branch shouldn't be reachable via UI —
|
||||
@@ -488,113 +558,125 @@ export function AccountPage({
|
||||
{t("account.selfHosted.disabledWhileLoggedIn")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("account.selfHosted.title")}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.selfHosted.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="self-hosted-server-url"
|
||||
className="text-xs"
|
||||
>
|
||||
{t("sync.serverUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
id="self-hosted-server-url"
|
||||
type="url"
|
||||
placeholder={t("sync.serverUrlPlaceholder")}
|
||||
value={serverUrl}
|
||||
onChange={(e) => {
|
||||
setServerUrl(e.target.value);
|
||||
setConnectionStatus("unknown");
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="self-hosted-token" className="text-xs">
|
||||
{t("sync.token")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="self-hosted-token"
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder={t("sync.tokenPlaceholder")}
|
||||
value={token}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
setConnectionStatus("unknown");
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="pr-9"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowToken((v) => !v);
|
||||
}}
|
||||
aria-label={
|
||||
showToken
|
||||
? t("common.aria.hideToken")
|
||||
: t("common.aria.showToken")
|
||||
}
|
||||
className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showToken ? (
|
||||
<LuEyeOff className="size-3.5" />
|
||||
) : (
|
||||
<LuEye className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<SettingsPanel className="flex flex-col gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<FeatureIcon active={connectionStatus === "connected"}>
|
||||
<LuServer />
|
||||
</FeatureIcon>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t("account.selfHosted.title")}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
|
||||
{t("account.selfHosted.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("account.selfHosted.connectionStatus")}
|
||||
</span>
|
||||
{connectionStatus === "connected" && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-success text-success-foreground"
|
||||
>
|
||||
{t("sync.status.connected")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "error" && (
|
||||
<Badge variant="destructive">
|
||||
{t("sync.status.error")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "testing" && (
|
||||
<Badge variant="secondary">
|
||||
{t("sync.status.syncing")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "unknown" && (
|
||||
<Badge variant="secondary">
|
||||
{t("account.selfHosted.statusUnknown")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<SoftFields>
|
||||
<div className="grid gap-4 @xl:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="self-hosted-server-url"
|
||||
className={FIELD_LABEL_CLASS}
|
||||
>
|
||||
{t("sync.serverUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
id="self-hosted-server-url"
|
||||
type="url"
|
||||
placeholder={t("sync.serverUrlPlaceholder")}
|
||||
value={serverUrl}
|
||||
onChange={(e) => {
|
||||
setServerUrl(e.target.value);
|
||||
setConnectionStatus("unknown");
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="self-hosted-token"
|
||||
className={FIELD_LABEL_CLASS}
|
||||
>
|
||||
{t("sync.token")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="self-hosted-token"
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder={t("sync.tokenPlaceholder")}
|
||||
value={token}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
setConnectionStatus("unknown");
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setShowToken((v) => !v);
|
||||
}}
|
||||
aria-label={
|
||||
showToken
|
||||
? t("common.aria.hideToken")
|
||||
: t("common.aria.showToken")
|
||||
}
|
||||
className="absolute top-1/2 right-1.5 size-6 -translate-y-1/2 rounded-md"
|
||||
>
|
||||
{showToken ? (
|
||||
<LuEyeOff className="size-3.5" />
|
||||
) : (
|
||||
<LuEye className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SoftFields>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
role="status"
|
||||
className="mr-auto inline-flex items-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<StatusLight
|
||||
tone={connectionTone[connectionStatus]}
|
||||
live={
|
||||
connectionStatus === "testing" ||
|
||||
connectionStatus === "connected"
|
||||
}
|
||||
/>
|
||||
<span>{t("account.selfHosted.connectionStatus")}</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{connectionLabel[connectionStatus]}
|
||||
</span>
|
||||
</span>
|
||||
{hasConfig && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
disabled={isSavingSelfHosted || isTestingConnection}
|
||||
onClick={() => void handleDisconnectSelfHosted()}
|
||||
className="h-8 rounded-lg text-xs hover:bg-destructive/10 hover:text-destructive-text"
|
||||
>
|
||||
{t("account.selfHosted.disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
isLoading={isTestingConnection}
|
||||
disabled={!serverUrl || isSavingSelfHosted}
|
||||
onClick={() => void handleTestConnection()}
|
||||
className="h-8 text-xs"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
>
|
||||
{t("account.selfHosted.testConnection")}
|
||||
</LoadingButton>
|
||||
@@ -603,23 +685,12 @@ export function AccountPage({
|
||||
isLoading={isSavingSelfHosted}
|
||||
disabled={!serverUrl || !token || isTestingConnection}
|
||||
onClick={() => void handleSaveSelfHosted()}
|
||||
className="h-8 text-xs"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</LoadingButton>
|
||||
{hasConfig && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={isSavingSelfHosted || isTestingConnection}
|
||||
onClick={() => void handleDisconnectSelfHosted()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("account.selfHosted.disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsPanel>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
@@ -629,3 +700,5 @@ export function AccountPage({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const FIELD_LABEL_CLASS = "text-xs font-medium text-muted-foreground";
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useId, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuTrash2 } from "react-icons/lu";
|
||||
import { LuSearch, LuTrash2 } from "react-icons/lu";
|
||||
import {
|
||||
activityDetailText,
|
||||
useTimeFormat,
|
||||
@@ -102,6 +102,7 @@ export function AgentActivityFeed({
|
||||
<Select value={agent} onValueChange={setAgent}>
|
||||
<SelectTrigger
|
||||
id={agentId}
|
||||
variant="soft"
|
||||
className="h-8 w-48 text-xs"
|
||||
data-testid="agent-activity-agent"
|
||||
>
|
||||
@@ -119,16 +120,20 @@ export function AgentActivityFeed({
|
||||
<Label htmlFor={searchId} className="sr-only">
|
||||
{t("agent.activity.profileSearch")}
|
||||
</Label>
|
||||
<Input
|
||||
id={searchId}
|
||||
value={query}
|
||||
placeholder={t("agent.activity.profileSearch")}
|
||||
className="h-8 w-48 text-sm"
|
||||
data-testid="agent-activity-search"
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<div className="relative">
|
||||
<LuSearch className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id={searchId}
|
||||
variant="soft"
|
||||
value={query}
|
||||
placeholder={t("agent.activity.profileSearch")}
|
||||
className="h-8 w-48 pl-8 text-xs md:text-xs"
|
||||
data-testid="agent-activity-search"
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={errorsId}
|
||||
@@ -144,10 +149,11 @@ export function AgentActivityFeed({
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
className="ml-auto gap-1.5"
|
||||
className="ml-auto h-8 gap-1.5 rounded-lg text-xs"
|
||||
disabled={clearing || agentConsole.activity.length === 0}
|
||||
aria-busy={clearing}
|
||||
data-testid="agent-activity-clear"
|
||||
onClick={async () => {
|
||||
setClearing(true);
|
||||
@@ -178,7 +184,7 @@ export function AgentActivityFeed({
|
||||
role="list"
|
||||
aria-label={t("agent.activity.label")}
|
||||
data-testid="agent-activity-list"
|
||||
className="min-h-0 flex-1 overflow-y-auto rounded-md border border-border"
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
{entries.length === 0 ? (
|
||||
<p className="py-16 text-center text-sm text-muted-foreground">
|
||||
@@ -199,7 +205,7 @@ export function AgentActivityFeed({
|
||||
key={entry.id}
|
||||
role="listitem"
|
||||
data-testid={`agent-activity-${entry.id}`}
|
||||
className="absolute top-0 left-0 flex w-full items-center gap-3 border-b border-border px-3 text-xs"
|
||||
className="absolute top-0 left-0 flex w-full items-center gap-3 rounded-md px-3 text-xs transition-colors duration-100 hover:bg-foreground/4"
|
||||
style={{
|
||||
height: ROW_HEIGHT,
|
||||
transform: `translateY(${row.start}px)`,
|
||||
|
||||
@@ -97,8 +97,7 @@ export function NoteAction({
|
||||
icon,
|
||||
onConfirm,
|
||||
disabled,
|
||||
variant = "outline",
|
||||
size = "sm",
|
||||
variant = "soft",
|
||||
testId,
|
||||
className,
|
||||
}: {
|
||||
@@ -109,8 +108,7 @@ export function NoteAction({
|
||||
icon?: ReactNode;
|
||||
onConfirm: (note: string | null) => Promise<boolean>;
|
||||
disabled?: boolean;
|
||||
variant?: "outline" | "default" | "ghost" | "secondary";
|
||||
size?: "sm" | "default";
|
||||
variant?: "soft" | "subtle" | "default";
|
||||
testId?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
@@ -137,10 +135,10 @@ export function NoteAction({
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size={size}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
data-testid={testId}
|
||||
className={cn("gap-1.5", className)}
|
||||
className={cn("h-8 gap-1.5 rounded-lg text-xs", className)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
@@ -154,16 +152,20 @@ export function NoteAction({
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
<Label htmlFor={noteId} className="text-xs">
|
||||
<Label
|
||||
htmlFor={noteId}
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{title ?? t("agent.actions.noteOptional")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={noteId}
|
||||
variant="soft"
|
||||
value={note}
|
||||
rows={3}
|
||||
maxLength={MAX_TEXT_CHARS}
|
||||
placeholder={placeholder}
|
||||
className="min-h-16 text-sm"
|
||||
className="min-h-16 resize-none text-sm"
|
||||
data-testid={testId ? `${testId}-note` : undefined}
|
||||
onChange={(event) => {
|
||||
setNote(event.target.value);
|
||||
@@ -172,8 +174,9 @@ export function NoteAction({
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
@@ -183,7 +186,9 @@ export function NoteAction({
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
data-testid={testId ? `${testId}-confirm` : undefined}
|
||||
>
|
||||
{confirmLabel ?? label}
|
||||
@@ -220,7 +225,12 @@ export function SetupPanel({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground [&>svg]:size-10">{icon}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="grid size-12 place-items-center rounded-2xl bg-foreground/5 text-muted-foreground [&>svg]:size-5"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<h3 className="text-sm font-medium text-foreground">{title}</h3>
|
||||
<p className="max-w-md text-xs text-muted-foreground">{body}</p>
|
||||
{children}
|
||||
@@ -242,7 +252,7 @@ export function ProgressBar({
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={percent}
|
||||
className="h-1 w-full overflow-hidden rounded-full bg-muted"
|
||||
className="h-1 w-full overflow-hidden rounded-full bg-foreground/8"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-300 motion-reduce:transition-none"
|
||||
|
||||
@@ -67,7 +67,7 @@ function useSessionNamer(sessions: readonly AgentSession[]): SessionNamer {
|
||||
|
||||
function ProfileChip({ name }: { name: string }) {
|
||||
return (
|
||||
<span className="max-w-48 truncate rounded bg-muted px-1.5 py-0.5 text-[11px] text-foreground">
|
||||
<span className="max-w-48 truncate rounded-md bg-foreground/6 px-1.5 py-0.5 text-[11px] text-foreground">
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
@@ -103,8 +103,9 @@ function RequestCard({
|
||||
const dismissButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
disabled={busy}
|
||||
data-testid="agent-dismiss"
|
||||
onClick={() => {
|
||||
@@ -119,7 +120,7 @@ function RequestCard({
|
||||
<article
|
||||
data-testid={`agent-request-${item.id}`}
|
||||
aria-labelledby={`${baseId}-title`}
|
||||
className="flex flex-col gap-2 rounded-md border border-warning/50 bg-warning/5 p-3"
|
||||
className="flex flex-col gap-2 rounded-xl bg-warning/10 p-3"
|
||||
>
|
||||
<header className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
{isHelp ? (
|
||||
@@ -149,9 +150,9 @@ function RequestCard({
|
||||
{item.profile_id && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
className="h-8 gap-1.5 rounded-lg text-xs"
|
||||
data-testid="agent-show-window"
|
||||
onClick={() => {
|
||||
if (item.profile_id)
|
||||
@@ -184,7 +185,8 @@ function RequestCard({
|
||||
key={choice}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
variant="soft"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
disabled={busy}
|
||||
data-testid="agent-choice"
|
||||
onClick={() => {
|
||||
@@ -208,10 +210,11 @@ function RequestCard({
|
||||
</Label>
|
||||
<Input
|
||||
id={`${baseId}-answer`}
|
||||
variant="soft"
|
||||
value={answer}
|
||||
maxLength={MAX_TEXT_CHARS}
|
||||
placeholder={t("agent.request.answerPlaceholder")}
|
||||
className="h-8 text-sm"
|
||||
className="h-8 text-xs md:text-xs"
|
||||
data-testid="agent-answer-input"
|
||||
onChange={(event) => {
|
||||
setAnswer(event.target.value);
|
||||
@@ -220,7 +223,7 @@ function RequestCard({
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
className="h-8 gap-1.5 rounded-lg text-xs"
|
||||
disabled={busy || answer.trim().length === 0}
|
||||
data-testid="agent-answer-send"
|
||||
>
|
||||
@@ -312,8 +315,8 @@ function SessionButton({
|
||||
data-testid={`agent-session-${session.session_id}`}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer flex-col gap-1 rounded-md px-2 py-2 text-left transition-colors duration-100 hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
selected && "bg-accent text-accent-foreground",
|
||||
"flex w-full cursor-pointer flex-col gap-1 rounded-lg px-2 py-2 text-left transition-colors duration-100 hover:bg-foreground/5 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
selected && "bg-foreground/7 hover:bg-foreground/7",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
@@ -407,8 +410,8 @@ function SessionList({
|
||||
onSelectSession(null);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-2 text-left text-sm font-medium transition-colors duration-100 hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
selectedSessionId === null && "bg-accent text-accent-foreground",
|
||||
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-left text-sm font-medium transition-colors duration-100 hover:bg-foreground/5 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
selectedSessionId === null && "bg-foreground/7 hover:bg-foreground/7",
|
||||
)}
|
||||
>
|
||||
{t("agent.sessions.all")}
|
||||
@@ -549,7 +552,7 @@ function ThreadEntry({
|
||||
<span aria-hidden="true">·</span>
|
||||
{time}
|
||||
</span>
|
||||
<div className="flex items-start gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm text-card-foreground">
|
||||
<div className="flex items-start gap-2 rounded-xl bg-foreground/5 px-3 py-2 text-sm text-foreground">
|
||||
{isHelp ? (
|
||||
<LuHand className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
@@ -624,14 +627,14 @@ function ProfilePicker({
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-label={t("agent.composer.profile")}
|
||||
data-testid="agent-composer-profile"
|
||||
className="max-w-56 justify-between gap-2 font-normal"
|
||||
className="h-8 max-w-56 justify-between gap-2 rounded-lg text-xs font-normal"
|
||||
>
|
||||
<span className="truncate">
|
||||
{selected ?? t("agent.composer.noProfile")}
|
||||
@@ -742,7 +745,7 @@ function Composer({
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex shrink-0 flex-col gap-2 border-t border-border p-3"
|
||||
className="flex shrink-0 flex-col gap-2 border-t border-foreground/6 p-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
@@ -763,6 +766,7 @@ function Composer({
|
||||
? t("agent.composer.placeholderOne", { agent: sessionName })
|
||||
: t("agent.composer.placeholderAll")
|
||||
}
|
||||
variant="soft"
|
||||
className="min-h-14 resize-none text-sm"
|
||||
data-testid="agent-composer-input"
|
||||
onChange={(event) => {
|
||||
@@ -795,7 +799,7 @@ function Composer({
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="ml-auto gap-1.5"
|
||||
className="ml-auto h-8 gap-1.5 rounded-lg text-xs"
|
||||
disabled={busy || text.trim().length === 0}
|
||||
data-testid="agent-composer-send"
|
||||
>
|
||||
@@ -860,7 +864,7 @@ export function AgentConversation({
|
||||
onSelectSession={onSelectSession}
|
||||
now={now}
|
||||
/>
|
||||
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden rounded-md border border-border">
|
||||
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl bg-foreground/3">
|
||||
<div
|
||||
ref={threadRef}
|
||||
role="log"
|
||||
|
||||
+179
-166
@@ -33,13 +33,14 @@ import {
|
||||
AnimatedTabsTrigger,
|
||||
} from "@/components/ui/animated-tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyToClipboard } from "@/components/ui/copy-to-clipboard";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { SoftFields } from "@/components/ui/field-variant";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
@@ -164,16 +165,20 @@ function PauseControl({ agentConsole }: { agentConsole: AgentConsole }) {
|
||||
void pause();
|
||||
}}
|
||||
>
|
||||
<Label htmlFor={noteId} className="text-xs">
|
||||
<Label
|
||||
htmlFor={noteId}
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{t("agent.pause.noteLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={noteId}
|
||||
variant="soft"
|
||||
rows={3}
|
||||
value={note}
|
||||
maxLength={MAX_TEXT_CHARS}
|
||||
placeholder={t("agent.pause.notePlaceholder")}
|
||||
className="min-h-16 text-sm"
|
||||
className="min-h-16 resize-none text-sm"
|
||||
data-testid="agent-pause-note"
|
||||
onChange={(event) => {
|
||||
setNote(event.target.value);
|
||||
@@ -185,8 +190,9 @@ function PauseControl({ agentConsole }: { agentConsole: AgentConsole }) {
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
@@ -196,7 +202,9 @@ function PauseControl({ agentConsole }: { agentConsole: AgentConsole }) {
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
data-testid="agent-pause-confirm"
|
||||
>
|
||||
{t("agent.pause.confirm")}
|
||||
@@ -218,7 +226,7 @@ function PausedBanner({ agentConsole }: { agentConsole: AgentConsole }) {
|
||||
<div
|
||||
role="status"
|
||||
data-testid="agent-paused-banner"
|
||||
className="flex shrink-0 flex-wrap items-center gap-3 rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-sm text-warning-text"
|
||||
className="flex shrink-0 flex-wrap items-center gap-3 rounded-xl bg-warning/10 px-3 py-2 text-sm text-warning-text"
|
||||
>
|
||||
<LuPause className="size-4 shrink-0" aria-hidden="true" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
@@ -234,9 +242,10 @@ function PausedBanner({ agentConsole }: { agentConsole: AgentConsole }) {
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
variant="soft"
|
||||
className="h-8 gap-1.5 rounded-lg text-xs"
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
data-testid="agent-resume"
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
@@ -342,6 +351,7 @@ export function AgentPage({
|
||||
>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
data-testid="agent-setup-sign-in"
|
||||
onClick={onOpenAccount}
|
||||
>
|
||||
@@ -359,7 +369,8 @@ export function AgentPage({
|
||||
<ProBadge />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
data-testid="agent-setup-plan-account"
|
||||
onClick={onOpenAccount}
|
||||
>
|
||||
@@ -378,6 +389,7 @@ export function AgentPage({
|
||||
>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
data-testid="agent-setup-turn-on"
|
||||
onClick={onConnectAgent}
|
||||
>
|
||||
@@ -394,27 +406,22 @@ export function AgentPage({
|
||||
body={t("agent.setup.waitingBody")}
|
||||
>
|
||||
<div className="flex w-full max-w-xl flex-col gap-2 text-left">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{t("agent.setup.starterLabel")}
|
||||
</span>
|
||||
<CopyToClipboard
|
||||
text={t("agent.setup.starter")}
|
||||
size="sm"
|
||||
successMessage={t("agent.setup.starterCopied")}
|
||||
className="h-7 w-7 p-0"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("agent.setup.starterLabel")}
|
||||
</span>
|
||||
<CodeSnippet
|
||||
multiline
|
||||
wrap
|
||||
data-testid="agent-starter-text"
|
||||
className="max-h-40 overflow-y-auto rounded-md border border-border bg-muted/40 p-3 text-xs break-words whitespace-pre-wrap text-foreground select-text"
|
||||
>
|
||||
{t("agent.setup.starter")}
|
||||
</p>
|
||||
code={t("agent.setup.starter")}
|
||||
copyLabel={t("agent.setup.starterLabel")}
|
||||
successMessage={t("agent.setup.starterCopied")}
|
||||
className="font-sans text-xs leading-relaxed"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5 self-center"
|
||||
variant="soft"
|
||||
className="mt-1 h-8 gap-1.5 self-center rounded-lg text-xs"
|
||||
data-testid="agent-setup-connect"
|
||||
onClick={onConnectAgent}
|
||||
>
|
||||
@@ -448,7 +455,7 @@ export function AgentPage({
|
||||
{remoteOff && (
|
||||
<div
|
||||
data-testid="agent-setup-remote-off"
|
||||
className="flex shrink-0 flex-wrap items-center gap-3 rounded-md border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground"
|
||||
className="flex shrink-0 flex-wrap items-center gap-3 rounded-xl bg-foreground/4 px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<LuPower className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1">
|
||||
@@ -456,7 +463,8 @@ export function AgentPage({
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
data-testid="agent-setup-turn-on"
|
||||
onClick={onConnectAgent}
|
||||
>
|
||||
@@ -489,148 +497,153 @@ export function AgentPage({
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="flex max-h-[85vh] max-w-[min(80rem,calc(100%-4rem))] flex-col">
|
||||
<div
|
||||
data-testid="agent-page"
|
||||
className="@container flex min-h-0 w-full flex-1 flex-col gap-3"
|
||||
>
|
||||
<div className="flex shrink-0 flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
{subPage ? (
|
||||
<>
|
||||
<h2 className="text-base leading-none font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</>
|
||||
<SoftFields>
|
||||
<div
|
||||
data-testid="agent-page"
|
||||
className="@container flex min-h-0 w-full flex-1 flex-col gap-3"
|
||||
>
|
||||
<div className="flex shrink-0 flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
{subPage ? (
|
||||
<>
|
||||
<h2 className="text-base leading-none font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{blocker === null && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{quota && (
|
||||
<span
|
||||
data-testid="agent-quota"
|
||||
className={cn(
|
||||
"text-xs tabular-nums",
|
||||
quota.left === 0
|
||||
? "text-warning-text"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t("agent.header.quota", {
|
||||
left: quota.left,
|
||||
limit: quota.limit,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<PauseControl agentConsole={agentConsole} />
|
||||
<RippleButton
|
||||
size="sm"
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg text-xs"
|
||||
data-testid="agent-connect"
|
||||
onClick={onConnectAgent}
|
||||
>
|
||||
<LuPlug className="size-3.5" aria-hidden="true" />
|
||||
{t("agent.header.connect")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{blocker === null && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{quota && (
|
||||
<span
|
||||
data-testid="agent-quota"
|
||||
className={cn(
|
||||
"text-xs tabular-nums",
|
||||
quota.left === 0
|
||||
? "text-warning-text"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
|
||||
{blocker === null && <PausedBanner agentConsole={agentConsole} />}
|
||||
|
||||
<AnimatedTabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => {
|
||||
changeTab(value as AgentTab);
|
||||
}}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<AnimatedTabsList className="shrink-0">
|
||||
{TABS.map((tab) => (
|
||||
<AnimatedTabsTrigger
|
||||
key={tab}
|
||||
value={tab}
|
||||
data-testid={`agent-tab-${tab}`}
|
||||
>
|
||||
{t("agent.header.quota", {
|
||||
left: quota.left,
|
||||
limit: quota.limit,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<PauseControl agentConsole={agentConsole} />
|
||||
<RippleButton
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
data-testid="agent-connect"
|
||||
onClick={onConnectAgent}
|
||||
>
|
||||
<LuPlug className="size-4" aria-hidden="true" />
|
||||
{t("agent.header.connect")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{blocker === null && <PausedBanner agentConsole={agentConsole} />}
|
||||
|
||||
<AnimatedTabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => {
|
||||
changeTab(value as AgentTab);
|
||||
}}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<AnimatedTabsList className="shrink-0">
|
||||
{TABS.map((tab) => (
|
||||
<AnimatedTabsTrigger
|
||||
key={tab}
|
||||
value={tab}
|
||||
data-testid={`agent-tab-${tab}`}
|
||||
>
|
||||
{t(`agent.tabs.${tab}`)}
|
||||
{tab === "conversation" &&
|
||||
agentConsole.openRequests.length > 0 && (
|
||||
<span className="rounded-full bg-warning/15 px-1.5 text-xs tabular-nums text-warning-text">
|
||||
{agentConsole.openRequests.length}
|
||||
</span>
|
||||
)}
|
||||
</AnimatedTabsTrigger>
|
||||
))}
|
||||
</AnimatedTabsList>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="conversation"
|
||||
className="mt-3 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
{conversationBody()}
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="profiles"
|
||||
className="mt-3 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
{blockerPanel ?? (
|
||||
<AgentProfiles
|
||||
agentConsole={agentConsole}
|
||||
profileNames={profileNames}
|
||||
now={now}
|
||||
onNote={openComposerFor}
|
||||
/>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="activity"
|
||||
className="mt-3 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
{blockerPanel ?? (
|
||||
<AgentActivityFeed
|
||||
agentConsole={agentConsole}
|
||||
profileNames={profileNames}
|
||||
now={now}
|
||||
/>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="recipes"
|
||||
className="mt-3 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
{blockerPanel ??
|
||||
(automation ? (
|
||||
<AgentRecipes profiles={profiles} />
|
||||
) : (
|
||||
<SetupPanel
|
||||
testId="agent-recipes-plan"
|
||||
icon={<LuLock />}
|
||||
title={t("agent.tabs.recipes")}
|
||||
body={t("agent.recipes.needsAutomation")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProBadge />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onOpenAccount}
|
||||
>
|
||||
{t("agent.setup.planAction")}
|
||||
</Button>
|
||||
</div>
|
||||
</SetupPanel>
|
||||
{t(`agent.tabs.${tab}`)}
|
||||
{tab === "conversation" &&
|
||||
agentConsole.openRequests.length > 0 && (
|
||||
<span className="rounded-full bg-warning/15 px-1.5 text-xs tabular-nums text-warning-text">
|
||||
{agentConsole.openRequests.length}
|
||||
</span>
|
||||
)}
|
||||
</AnimatedTabsTrigger>
|
||||
))}
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
</div>
|
||||
</AnimatedTabsList>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="conversation"
|
||||
className="mt-3 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
{conversationBody()}
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="profiles"
|
||||
className="mt-3 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
{blockerPanel ?? (
|
||||
<AgentProfiles
|
||||
agentConsole={agentConsole}
|
||||
profileNames={profileNames}
|
||||
now={now}
|
||||
onNote={openComposerFor}
|
||||
/>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="activity"
|
||||
className="mt-3 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
{blockerPanel ?? (
|
||||
<AgentActivityFeed
|
||||
agentConsole={agentConsole}
|
||||
profileNames={profileNames}
|
||||
now={now}
|
||||
/>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="recipes"
|
||||
className="mt-3 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
{blockerPanel ??
|
||||
(automation ? (
|
||||
<AgentRecipes profiles={profiles} />
|
||||
) : (
|
||||
<SetupPanel
|
||||
testId="agent-recipes-plan"
|
||||
icon={<LuLock />}
|
||||
title={t("agent.tabs.recipes")}
|
||||
body={t("agent.recipes.needsAutomation")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProBadge />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="soft"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
onClick={onOpenAccount}
|
||||
>
|
||||
{t("agent.setup.planAction")}
|
||||
</Button>
|
||||
</div>
|
||||
</SetupPanel>
|
||||
))}
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
</div>
|
||||
</SoftFields>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
LuAppWindow,
|
||||
LuHand,
|
||||
LuMessageSquareText,
|
||||
LuSearch,
|
||||
LuUndo2,
|
||||
} from "react-icons/lu";
|
||||
import {
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
NoteAction,
|
||||
useTimeFormat,
|
||||
} from "@/components/agent-console-parts";
|
||||
import { DATA_TABLE_CLASSES } from "@/components/table-controls";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -41,10 +43,10 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATE_CLASS: Record<AgentProfileState, string> = {
|
||||
held: "border-primary/40 bg-primary/10 text-primary-text",
|
||||
working: "border-success/40 bg-success/10 text-success-text",
|
||||
error: "border-destructive/40 bg-destructive/10 text-destructive-text",
|
||||
idle: "border-border text-muted-foreground",
|
||||
held: "bg-primary/10 text-primary-text",
|
||||
working: "bg-success/10 text-success-text",
|
||||
error: "bg-destructive/10 text-destructive-text",
|
||||
idle: "bg-foreground/6 text-muted-foreground",
|
||||
};
|
||||
|
||||
export function AgentProfiles({
|
||||
@@ -119,16 +121,20 @@ export function AgentProfiles({
|
||||
<Label htmlFor={searchId} className="sr-only">
|
||||
{t("agent.profiles.search")}
|
||||
</Label>
|
||||
<Input
|
||||
id={searchId}
|
||||
value={query}
|
||||
placeholder={t("agent.profiles.search")}
|
||||
className="h-8 w-56 text-sm"
|
||||
data-testid="agent-profiles-search"
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<div className="relative">
|
||||
<LuSearch className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id={searchId}
|
||||
variant="soft"
|
||||
value={query}
|
||||
placeholder={t("agent.profiles.search")}
|
||||
className="h-8 w-56 pl-8 text-xs md:text-xs"
|
||||
data-testid="agent-profiles-search"
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={errorsId}
|
||||
@@ -179,11 +185,11 @@ export function AgentProfiles({
|
||||
{t("agent.profiles.noMatches")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-md border border-border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8">
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<Table className="data-table">
|
||||
<TableHeader className={DATA_TABLE_CLASSES.header}>
|
||||
<TableRow className={DATA_TABLE_CLASSES.headerRow}>
|
||||
<TableHead className={cn(DATA_TABLE_CLASSES.head, "w-8")}>
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
aria-label={t("common.aria.selectAll")}
|
||||
@@ -196,19 +202,42 @@ export function AgentProfiles({
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t("agent.profiles.columns.profile")}</TableHead>
|
||||
<TableHead className="hidden @2xl:table-cell">
|
||||
<TableHead className={DATA_TABLE_CLASSES.head}>
|
||||
{t("agent.profiles.columns.profile")}
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={cn(
|
||||
DATA_TABLE_CLASSES.head,
|
||||
"hidden @2xl:table-cell",
|
||||
)}
|
||||
>
|
||||
{t("agent.profiles.columns.agent")}
|
||||
</TableHead>
|
||||
<TableHead>{t("agent.profiles.columns.lastAction")}</TableHead>
|
||||
<TableHead className="hidden @xl:table-cell">
|
||||
<TableHead className={DATA_TABLE_CLASSES.head}>
|
||||
{t("agent.profiles.columns.lastAction")}
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={cn(
|
||||
DATA_TABLE_CLASSES.head,
|
||||
"hidden @xl:table-cell",
|
||||
)}
|
||||
>
|
||||
{t("agent.profiles.columns.result")}
|
||||
</TableHead>
|
||||
<TableHead className="hidden @xl:table-cell">
|
||||
<TableHead
|
||||
className={cn(
|
||||
DATA_TABLE_CLASSES.head,
|
||||
"hidden @xl:table-cell",
|
||||
)}
|
||||
>
|
||||
{t("agent.profiles.columns.when")}
|
||||
</TableHead>
|
||||
<TableHead>{t("agent.profiles.columns.state")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<TableHead className={DATA_TABLE_CLASSES.head}>
|
||||
{t("agent.profiles.columns.state")}
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={cn(DATA_TABLE_CLASSES.head, "text-right")}
|
||||
>
|
||||
{t("common.labels.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
@@ -222,6 +251,8 @@ export function AgentProfiles({
|
||||
return (
|
||||
<TableRow
|
||||
key={row.profile_id}
|
||||
data-table-row={row.profile_id}
|
||||
className={DATA_TABLE_CLASSES.row}
|
||||
data-testid={`agent-profile-${row.profile_id}`}
|
||||
data-state={
|
||||
selected.has(row.profile_id) ? "selected" : undefined
|
||||
@@ -298,7 +329,7 @@ export function AgentProfiles({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
className={cn("font-normal", STATE_CLASS[row.state])}
|
||||
>
|
||||
{t(`agent.profiles.state.${row.state}`)}
|
||||
@@ -338,9 +369,9 @@ export function AgentProfiles({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
className="row-reveal size-8 rounded-lg"
|
||||
aria-label={t("agent.actions.showWindow")}
|
||||
data-testid="agent-profile-show-window"
|
||||
onClick={() => {
|
||||
@@ -358,9 +389,9 @@ export function AgentProfiles({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
className="row-reveal size-8 rounded-lg"
|
||||
aria-label={t("agent.actions.note")}
|
||||
data-testid="agent-profile-note"
|
||||
onClick={() => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { StatusLight } from "@/components/ui/settings-panel";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import {
|
||||
getRecipeRecording,
|
||||
@@ -123,7 +124,7 @@ export function RecipeRecorder({
|
||||
return (
|
||||
<div
|
||||
data-slot="agent-recipe-recorder"
|
||||
className="flex flex-col gap-3 rounded-md border border-border bg-card p-3"
|
||||
className="flex flex-col gap-3 rounded-xl bg-foreground/3 p-3"
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("agent.recipes.recording.description")}
|
||||
@@ -131,8 +132,8 @@ export function RecipeRecorder({
|
||||
|
||||
{isRecording ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<LuCircleDot className="size-3.5 text-destructive" />
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<StatusLight tone="destructive" live />
|
||||
{t("agent.recipes.recording.recording")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -141,8 +142,10 @@ export function RecipeRecorder({
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
className="h-8 gap-1.5 rounded-lg text-xs"
|
||||
disabled={isBusy}
|
||||
aria-busy={isBusy}
|
||||
onClick={() => {
|
||||
void stop();
|
||||
}}
|
||||
@@ -154,7 +157,10 @@ export function RecipeRecorder({
|
||||
) : (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex min-w-48 flex-col gap-1">
|
||||
<Label htmlFor="agent-recorder-profile" className="text-xs">
|
||||
<Label
|
||||
htmlFor="agent-recorder-profile"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{t("agent.recipes.recording.hint")}
|
||||
</Label>
|
||||
<Select
|
||||
@@ -164,7 +170,8 @@ export function RecipeRecorder({
|
||||
>
|
||||
<SelectTrigger
|
||||
id="agent-recorder-profile"
|
||||
className="h-8 text-xs"
|
||||
variant="soft"
|
||||
className="h-8 w-full text-xs"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -179,7 +186,9 @@ export function RecipeRecorder({
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 rounded-lg text-xs"
|
||||
disabled={isBusy || !profileId}
|
||||
aria-busy={isBusy}
|
||||
onClick={() => {
|
||||
void start();
|
||||
}}
|
||||
|
||||
@@ -139,7 +139,7 @@ export function RecipeStepsEditor({
|
||||
// Rows are reordered and removed, so the index is the identity here.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`step-${index}`}
|
||||
className="flex flex-col gap-2 rounded-md border border-border p-2"
|
||||
className="flex flex-col gap-2 rounded-lg bg-foreground/4 p-2"
|
||||
data-slot="agent-recipe-step"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -166,9 +166,10 @@ export function RecipeStepsEditor({
|
||||
</Select>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
type="button"
|
||||
className="size-8 rounded-lg"
|
||||
disabled={disabled || index === 0}
|
||||
aria-label={t("agent.recipes.moveStepUp")}
|
||||
onClick={() => {
|
||||
@@ -178,9 +179,10 @@ export function RecipeStepsEditor({
|
||||
<LuArrowUp className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
type="button"
|
||||
className="size-8 rounded-lg"
|
||||
disabled={disabled || index === steps.length - 1}
|
||||
aria-label={t("agent.recipes.moveStepDown")}
|
||||
onClick={() => {
|
||||
@@ -190,9 +192,10 @@ export function RecipeStepsEditor({
|
||||
<LuArrowDown className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
type="button"
|
||||
className="size-8 rounded-lg"
|
||||
disabled={disabled}
|
||||
aria-label={t("agent.recipes.removeStep")}
|
||||
onClick={() => {
|
||||
@@ -410,7 +413,7 @@ function Field({
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor={id} className="text-xs">
|
||||
<Label htmlFor={id} className="text-xs font-medium text-muted-foreground">
|
||||
{label}
|
||||
</Label>
|
||||
{cloneElement(children, { id })}
|
||||
|
||||
@@ -90,19 +90,19 @@ export function AgentRecipes({ profiles }: AgentRecipesProps) {
|
||||
</p>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg text-xs"
|
||||
disabled={editing !== null}
|
||||
onClick={() => {
|
||||
setEditing("new");
|
||||
}}
|
||||
>
|
||||
<GoPlus className="size-4" />
|
||||
<GoPlus className="size-3.5" />
|
||||
{t("agent.recipes.new")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
{error !== null && (
|
||||
<p className="shrink-0 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive-text">
|
||||
<p className="shrink-0 rounded-xl bg-destructive/10 p-3 text-sm text-destructive-text">
|
||||
{translateBackendError(t, error)}
|
||||
</p>
|
||||
)}
|
||||
@@ -152,7 +152,7 @@ export function AgentRecipes({ profiles }: AgentRecipesProps) {
|
||||
recipes.map((recipe) => (
|
||||
<div
|
||||
key={recipe.id}
|
||||
className="flex items-center gap-3 rounded-md border border-border bg-card p-3"
|
||||
className="flex items-center gap-3 rounded-xl bg-foreground/3 p-3 transition-colors duration-150 hover:bg-foreground/5"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
@@ -165,8 +165,9 @@ export function AgentRecipes({ profiles }: AgentRecipesProps) {
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
onClick={() => {
|
||||
setEditing(recipe);
|
||||
}}
|
||||
@@ -174,8 +175,9 @@ export function AgentRecipes({ profiles }: AgentRecipesProps) {
|
||||
{t("common.buttons.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
className="h-8 rounded-lg text-xs hover:bg-destructive/10 hover:text-destructive-text"
|
||||
onClick={() => {
|
||||
setPendingRemoval(recipe);
|
||||
}}
|
||||
@@ -262,14 +264,19 @@ function RecipeEditor({
|
||||
return (
|
||||
<form
|
||||
data-slot="agent-recipe-editor"
|
||||
className="flex shrink-0 flex-col gap-3 rounded-md border border-border bg-card p-3"
|
||||
className="flex shrink-0 flex-col gap-3 rounded-xl bg-foreground/3 p-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSave();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="agent-recipe-name">{t("agent.recipes.name")}</Label>
|
||||
<Label
|
||||
htmlFor="agent-recipe-name"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{t("agent.recipes.name")}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-recipe-name"
|
||||
value={name}
|
||||
@@ -280,7 +287,9 @@ function RecipeEditor({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>{t("agent.recipes.steps")}</Label>
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
{t("agent.recipes.steps")}
|
||||
</Label>
|
||||
<RecipeStepsEditor
|
||||
steps={steps}
|
||||
onChange={setSteps}
|
||||
@@ -291,9 +300,10 @@ function RecipeEditor({
|
||||
{t("agent.recipes.stepsHint")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
type="button"
|
||||
className="h-8 gap-1.5 rounded-lg text-xs"
|
||||
disabled={isSaving}
|
||||
onClick={() => {
|
||||
setSteps([...steps, emptyStep("navigate")]);
|
||||
@@ -305,10 +315,21 @@ function RecipeEditor({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onCancel} type="button">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
<RippleButton type="submit" size="sm" disabled={!canSave}>
|
||||
<RippleButton
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
disabled={!canSave}
|
||||
>
|
||||
{isSaving ? t("common.buttons.saving") : t("common.buttons.save")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { type ReactNode, useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AnimatedTabs,
|
||||
AnimatedTabsContent,
|
||||
AnimatedTabsList,
|
||||
AnimatedTabsTrigger,
|
||||
} from "@/components/ui/animated-tabs";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import { SECTION_LABEL_CLASS } from "@/components/ui/settings-panel";
|
||||
import { useInputModality } from "@/hooks/use-input-modality";
|
||||
import type { SnippetLanguage } from "@/lib/api-examples";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface CodeExample {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
method?: string;
|
||||
paid?: boolean;
|
||||
snippets: Partial<Record<SnippetLanguage, { display: string; copy: string }>>;
|
||||
response?: string;
|
||||
}
|
||||
|
||||
const LANGUAGE_ORDER: SnippetLanguage[] = [
|
||||
"curl",
|
||||
"javascript",
|
||||
"python",
|
||||
"json",
|
||||
];
|
||||
|
||||
function languagesOf(example: CodeExample) {
|
||||
return LANGUAGE_ORDER.filter((language) => example.snippets[language]);
|
||||
}
|
||||
|
||||
export function CodeExamplesDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
examples,
|
||||
highlights,
|
||||
note,
|
||||
copyMessage,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
examples: CodeExample[];
|
||||
highlights: readonly string[];
|
||||
note?: ReactNode;
|
||||
copyMessage: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const inputModality = useInputModality();
|
||||
const animate = !reduceMotion && inputModality === "pointer";
|
||||
const markerId = useId();
|
||||
const [selectedId, setSelectedId] = useState(examples[0]?.id ?? "");
|
||||
// The language is the reader's, not the example's: it stays as they move
|
||||
// between examples, and an example without it falls back to its own first.
|
||||
const [language, setLanguage] = useState<SnippetLanguage>("curl");
|
||||
|
||||
const selected =
|
||||
examples.find((example) => example.id === selectedId) ?? examples[0];
|
||||
if (!selected) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
data-testid="code-examples-dialog"
|
||||
className="flex max-h-[85vh] max-w-4xl flex-col gap-4"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="@container flex min-h-0 flex-1 flex-col">
|
||||
<TabsPrimitive.Root
|
||||
value={selected.id}
|
||||
onValueChange={setSelectedId}
|
||||
orientation="vertical"
|
||||
className="flex min-h-0 flex-1 flex-col gap-4 @2xl:flex-row"
|
||||
>
|
||||
<TabsPrimitive.List
|
||||
aria-label={title}
|
||||
className="isolate flex shrink-0 scrollbar-none gap-1 overflow-x-auto @2xl:w-52 @2xl:flex-col @2xl:overflow-visible"
|
||||
>
|
||||
{examples.map((example) => {
|
||||
const active = example.id === selected.id;
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
key={example.id}
|
||||
value={example.id}
|
||||
data-testid={`code-example-${example.id}`}
|
||||
className={cn(
|
||||
"relative flex shrink-0 cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs whitespace-nowrap transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-foreground/15",
|
||||
active
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground hover:bg-foreground/4 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId={
|
||||
animate ? `example-marker-${markerId}` : undefined
|
||||
}
|
||||
initial={false}
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 -z-10 rounded-lg bg-foreground/7"
|
||||
transition={
|
||||
animate
|
||||
? { type: "spring", stiffness: 420, damping: 36 }
|
||||
: { duration: 0 }
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{example.method && (
|
||||
<span className="w-9 shrink-0 font-mono text-[10px] text-muted-foreground">
|
||||
{example.method}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{example.title}
|
||||
</span>
|
||||
{example.paid && <ProBadge />}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
})}
|
||||
</TabsPrimitive.List>
|
||||
|
||||
{examples.map((example) => {
|
||||
const languages = languagesOf(example);
|
||||
const shown = languages.includes(language)
|
||||
? language
|
||||
: (languages[0] ?? "curl");
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
key={example.id}
|
||||
value={example.id}
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-col gap-3 overflow-y-auto outline-none"
|
||||
>
|
||||
<motion.div
|
||||
initial={animate ? { x: 6 } : false}
|
||||
animate={{ x: 0 }}
|
||||
transition={{ duration: 0.18, ease: MOTION_EASE_OUT }}
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||
{example.title}
|
||||
{example.paid && (
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{t("integrations.examples.paid")}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{example.description}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<AnimatedTabs
|
||||
value={shown}
|
||||
onValueChange={(value) => {
|
||||
setLanguage(value as SnippetLanguage);
|
||||
}}
|
||||
className="flex min-w-0 flex-col gap-2"
|
||||
>
|
||||
<AnimatedTabsList
|
||||
aria-label={t("integrations.examples.language")}
|
||||
>
|
||||
{languages.map((item) => (
|
||||
<AnimatedTabsTrigger
|
||||
key={item}
|
||||
value={item}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{t(`integrations.examples.languages.${item}`)}
|
||||
</AnimatedTabsTrigger>
|
||||
))}
|
||||
</AnimatedTabsList>
|
||||
{languages.map((item) => {
|
||||
const snippet = example.snippets[item];
|
||||
if (!snippet) return null;
|
||||
return (
|
||||
<AnimatedTabsContent key={item} value={item}>
|
||||
<motion.div
|
||||
initial={animate ? { y: 4 } : false}
|
||||
animate={{ y: 0 }}
|
||||
transition={{
|
||||
duration: 0.16,
|
||||
ease: MOTION_EASE_OUT,
|
||||
}}
|
||||
>
|
||||
<CodeSnippet
|
||||
multiline
|
||||
code={snippet.display}
|
||||
copyText={snippet.copy}
|
||||
highlights={highlights}
|
||||
successMessage={copyMessage}
|
||||
className="text-xs leading-relaxed"
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatedTabsContent>
|
||||
);
|
||||
})}
|
||||
</AnimatedTabs>
|
||||
|
||||
{example.response && (
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<p className={SECTION_LABEL_CLASS}>
|
||||
{t("integrations.examples.response")}
|
||||
</p>
|
||||
<CodeSnippet
|
||||
multiline
|
||||
copyable={false}
|
||||
code={example.response}
|
||||
className="text-xs leading-relaxed text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TabsPrimitive.Content>
|
||||
);
|
||||
})}
|
||||
</TabsPrimitive.Root>
|
||||
</div>
|
||||
|
||||
{note && (
|
||||
<p className="shrink-0 text-xs leading-relaxed text-muted-foreground">
|
||||
{note}
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,12 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LuActivity } from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { OperationFlow } from "@/components/ui/operation-flow";
|
||||
import { SettingsPanel } from "@/components/ui/settings-panel";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Diagnostic {
|
||||
configured: boolean;
|
||||
@@ -22,8 +25,10 @@ interface Diagnostic {
|
||||
*/
|
||||
export function IntegrationDiagnostics({
|
||||
target,
|
||||
className,
|
||||
}: {
|
||||
target: "api" | "remote";
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [result, setResult] = useState<Diagnostic | null>(null);
|
||||
@@ -52,23 +57,33 @@ export function IntegrationDiagnostics({
|
||||
: "appFeedback.unavailable",
|
||||
);
|
||||
return (
|
||||
<section
|
||||
<SettingsPanel
|
||||
data-slot="integration-diagnostics"
|
||||
className="space-y-3 rounded-md bg-muted/30 p-4"
|
||||
className={cn("space-y-4", className)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("appFeedback.connectionTest")}
|
||||
</h3>
|
||||
<Button
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("appFeedback.connectionTest")}
|
||||
</h3>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t(
|
||||
target === "api"
|
||||
? "appFeedback.apiProbeScope"
|
||||
: "appFeedback.remoteProbeScope",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
variant="soft"
|
||||
className="h-8 gap-1.5 rounded-lg text-xs"
|
||||
isLoading={busy}
|
||||
onClick={() => void check()}
|
||||
>
|
||||
{t(busy ? "appFeedback.checking" : "appFeedback.testConnection")}
|
||||
</Button>
|
||||
<LuActivity className="size-3.5" />
|
||||
{t("appFeedback.testConnection")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
<OperationFlow
|
||||
label={t("appFeedback.connectionTest")}
|
||||
@@ -113,7 +128,7 @@ export function IntegrationDiagnostics({
|
||||
/>
|
||||
<div
|
||||
role="status"
|
||||
className="text-xs leading-relaxed text-muted-foreground"
|
||||
className="text-xs leading-relaxed text-muted-foreground tabular-nums"
|
||||
>
|
||||
{busy
|
||||
? t("appFeedback.checking")
|
||||
@@ -123,21 +138,16 @@ export function IntegrationDiagnostics({
|
||||
})
|
||||
: t("appFeedback.notChecked")}
|
||||
{result?.http_status != null && (
|
||||
<span className="ml-2 font-mono">HTTP {result.http_status}</span>
|
||||
<span className="ml-2 rounded-md bg-foreground/5 px-1.5 py-0.5 font-mono">
|
||||
HTTP {result.http_status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t(
|
||||
target === "api"
|
||||
? "appFeedback.apiProbeScope"
|
||||
: "appFeedback.remoteProbeScope",
|
||||
)}
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="break-words text-xs text-destructive-text">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</SettingsPanel>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,25 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuCheck, LuCloud, LuPlug, LuTrash2 } from "react-icons/lu";
|
||||
import {
|
||||
LuArrowRight,
|
||||
LuCloud,
|
||||
LuEye,
|
||||
LuEyeOff,
|
||||
LuPlug,
|
||||
LuRefreshCw,
|
||||
LuTrash2,
|
||||
} from "react-icons/lu";
|
||||
import {
|
||||
type CodeExample,
|
||||
CodeExamplesDialog,
|
||||
} from "@/components/code-examples-dialog";
|
||||
import { IntegrationDiagnostics } from "@/components/integration-diagnostics";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { AgentIcon } from "@/components/mcp-agent-icon";
|
||||
import { AnimatedDisclosureContent } from "@/components/ui/animated-disclosure";
|
||||
import { AnimatedSwitch } from "@/components/ui/animated-switch";
|
||||
import {
|
||||
AnimatedTabs,
|
||||
@@ -16,6 +29,9 @@ import {
|
||||
AnimatedTabsTrigger,
|
||||
} from "@/components/ui/animated-tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet";
|
||||
import { ConfirmationMark } from "@/components/ui/confirmation-mark";
|
||||
import { CopyToClipboard } from "@/components/ui/copy-to-clipboard";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -25,8 +41,26 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { OperationFlow } from "@/components/ui/operation-flow";
|
||||
import {
|
||||
FeatureIcon,
|
||||
SECTION_LABEL_CLASS,
|
||||
SettingsPanel,
|
||||
SettingsRow,
|
||||
StatusLight,
|
||||
} from "@/components/ui/settings-panel";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||
import {
|
||||
curlSnippet,
|
||||
LOCAL_API_EXAMPLES,
|
||||
maskToken,
|
||||
mcpClientConfig,
|
||||
oneLine,
|
||||
PROFILE_ID_PLACEHOLDER,
|
||||
REMOTE_MCP_EXAMPLES,
|
||||
REQUEST_LANGUAGES,
|
||||
requestSnippet,
|
||||
} from "@/lib/api-examples";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { canUseRemoteControl } from "@/lib/entitlements";
|
||||
import {
|
||||
@@ -42,7 +76,6 @@ import {
|
||||
} from "@/lib/mcp";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CopyToClipboard } from "./ui/copy-to-clipboard";
|
||||
|
||||
interface AppSettings {
|
||||
api_enabled: boolean;
|
||||
@@ -103,6 +136,12 @@ export function IntegrationsDialog({
|
||||
null,
|
||||
);
|
||||
const [isRotatingCredential, setIsRotatingCredential] = useState(false);
|
||||
const [apiExamplesOpen, setApiExamplesOpen] = useState(false);
|
||||
const [remoteExamplesOpen, setRemoteExamplesOpen] = useState(false);
|
||||
const apiSwitchId = useId();
|
||||
const apiPortId = useId();
|
||||
const apiTokenId = useId();
|
||||
const remoteSwitchId = useId();
|
||||
const [activeTab, setActiveTab] = useState<IntegrationsTab>(initialTab);
|
||||
// Mod+I re-targets an open dialog through `initialTab`. The tabs are
|
||||
// controlled (see `shownTab`), so a remount would not adopt the new value;
|
||||
@@ -420,22 +459,103 @@ export function IntegrationsDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePort = async () => {
|
||||
const port = settings.api_port;
|
||||
if (port < 1 || port > 65535) {
|
||||
showErrorToast(t("integrations.apiInvalidPort"), {
|
||||
description: t("integrations.apiInvalidPortDescription"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsApiStarting(true);
|
||||
try {
|
||||
await invoke("stop_api_server");
|
||||
const next = await invoke<AppSettings>("save_app_settings", {
|
||||
settings,
|
||||
});
|
||||
setSettings(next);
|
||||
const actualPort = await invoke<number>("start_api_server", { port });
|
||||
setApiServerPort(actualPort);
|
||||
if (actualPort !== port) {
|
||||
showErrorToast(t("integrations.apiPortInUse", { port }), {
|
||||
description: t("integrations.apiFallbackPort", { port: actualPort }),
|
||||
});
|
||||
} else {
|
||||
showSuccessToast(t("integrations.apiRunning", { port: actualPort }));
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorToast(t("integrations.apiStartFailed"), {
|
||||
description: translateBackendError(t, e),
|
||||
});
|
||||
} finally {
|
||||
setIsApiStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const credentialPresent = credential?.present ?? false;
|
||||
const credentialPrefix = credentialPrefixOf(credential);
|
||||
// The example names the credential it expects by its visible prefix; the
|
||||
// dialog never has the plaintext on screen, so the line is a template either
|
||||
// way and copying it hands over exactly what is shown.
|
||||
// The examples name the credential they expect by its visible prefix; the
|
||||
// dialog never has the plaintext on screen, so the lines are templates
|
||||
// either way and copying one hands over exactly what is shown.
|
||||
const exampleBearer = credentialPrefix
|
||||
? t("integrations.remote.credentialPrefix", { prefix: credentialPrefix })
|
||||
: "${TOKEN}";
|
||||
const remoteExampleRequest = [
|
||||
`curl -X POST -H "Authorization: Bearer ${exampleBearer}" \\`,
|
||||
` -H "Content-Type: application/json" \\`,
|
||||
` -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \\`,
|
||||
` ${REMOTE_MCP_URL}`,
|
||||
].join("\n");
|
||||
const remoteTarget = { baseUrl: REMOTE_MCP_URL, token: exampleBearer };
|
||||
const remoteQuickStart = oneLine(
|
||||
curlSnippet(REMOTE_MCP_EXAMPLES[0].request, remoteTarget),
|
||||
);
|
||||
const fxLine = fxExportLine(settings.mcp_remote_key);
|
||||
|
||||
const apiBaseUrl = `http://127.0.0.1:${apiServerPort ?? settings.api_port}`;
|
||||
const apiToken = settings.api_token ?? "";
|
||||
const maskedApiToken = maskToken(apiToken);
|
||||
// The local token is this machine's own, so a copied example carries it
|
||||
// and runs as pasted; the page itself shows only its ends.
|
||||
const apiDisplayTarget = { baseUrl: apiBaseUrl, token: maskedApiToken };
|
||||
const apiCopyTarget = { baseUrl: apiBaseUrl, token: apiToken || "TOKEN" };
|
||||
const apiQuickStart = LOCAL_API_EXAMPLES[0].request;
|
||||
|
||||
const apiExamples: CodeExample[] = LOCAL_API_EXAMPLES.map((example) => ({
|
||||
id: example.id,
|
||||
title: t(`integrations.examples.items.${example.id}.title`),
|
||||
description: t(`integrations.examples.items.${example.id}.description`),
|
||||
method: example.request.method,
|
||||
paid: example.paid,
|
||||
response: example.response,
|
||||
snippets: Object.fromEntries(
|
||||
REQUEST_LANGUAGES.map((language) => [
|
||||
language,
|
||||
{
|
||||
display: requestSnippet(language, example.request, apiDisplayTarget),
|
||||
copy: requestSnippet(language, example.request, apiCopyTarget),
|
||||
},
|
||||
]),
|
||||
),
|
||||
}));
|
||||
|
||||
const remoteConfig = mcpClientConfig(REMOTE_MCP_URL, exampleBearer);
|
||||
const remoteExamples: CodeExample[] = [
|
||||
...REMOTE_MCP_EXAMPLES.map((example) => ({
|
||||
id: example.id,
|
||||
title: t(`integrations.examples.items.${example.id}.title`),
|
||||
description: t(`integrations.examples.items.${example.id}.description`),
|
||||
method: example.request.method,
|
||||
response: example.response,
|
||||
snippets: Object.fromEntries(
|
||||
REQUEST_LANGUAGES.map((language) => {
|
||||
const code = requestSnippet(language, example.request, remoteTarget);
|
||||
return [language, { display: code, copy: code }];
|
||||
}),
|
||||
),
|
||||
})),
|
||||
{
|
||||
id: "clientConfig",
|
||||
title: t("integrations.examples.items.clientConfig.title"),
|
||||
description: t("integrations.examples.items.clientConfig.description"),
|
||||
snippets: { json: { display: remoteConfig, copy: remoteConfig } },
|
||||
},
|
||||
];
|
||||
|
||||
// Remote control is not something a regular user is told about: the tab
|
||||
// exists only for an account entitled to it, or while the bridge is already
|
||||
// on so it can always be switched off. Nothing else on the page names it.
|
||||
@@ -447,17 +567,20 @@ export function IntegrationsDialog({
|
||||
activeTab === "remote" && !remoteTabShown ? "api" : activeTab;
|
||||
|
||||
const clientsGrid = (
|
||||
<div className="@container flex flex-col gap-3">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
<section className="flex flex-col gap-2">
|
||||
<h3 className={SECTION_LABEL_CLASS}>
|
||||
{t("integrations.mcp.clientsLabel")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-1 gap-3 @2xl:grid-cols-2">
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-2 @2xl:grid-cols-2">
|
||||
{agents.map((agent) => {
|
||||
const busy = busyAgentIds.has(agent.id);
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="flex flex-col justify-center gap-2 rounded-md border bg-card px-3 py-2.5"
|
||||
className={cn(
|
||||
"flex flex-col justify-center gap-2 rounded-xl px-3 py-2.5 transition-colors duration-200",
|
||||
agent.connected ? "bg-foreground/5" : "bg-foreground/3",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<AgentIcon id={agent.id} category={agent.category} />
|
||||
@@ -472,28 +595,30 @@ export function IntegrationsDialog({
|
||||
{agent.connected ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-foreground">
|
||||
<LuCheck className="size-3" />
|
||||
<ConfirmationMark />
|
||||
{t("appFeedback.configured")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-destructive-text"
|
||||
className="size-8 rounded-lg hover:text-destructive-text"
|
||||
disabled={busy}
|
||||
onClick={() => void handleRemoveAgent(agent)}
|
||||
aria-label={t("integrations.mcp.removeAriaLabel", {
|
||||
name: agent.display_name,
|
||||
})}
|
||||
>
|
||||
<LuTrash2 className="size-4" />
|
||||
<LuTrash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
onClick={() => void handleAddAgent(agent)}
|
||||
>
|
||||
{t("integrations.mcp.add")}
|
||||
@@ -502,7 +627,7 @@ export function IntegrationsDialog({
|
||||
</div>
|
||||
{agent.connected && (
|
||||
<details data-slot="client-route" className="text-xs">
|
||||
<summary className="cursor-pointer rounded-sm py-1 text-muted-foreground focus-visible:outline-2 focus-visible:outline-ring">
|
||||
<summary className="w-fit cursor-pointer rounded-sm py-1 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring">
|
||||
{t("appFeedback.routeDetails")}
|
||||
</summary>
|
||||
<OperationFlow
|
||||
@@ -544,9 +669,8 @@ export function IntegrationsDialog({
|
||||
</p>
|
||||
{fxLine && (
|
||||
<CopyToClipboard
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
variant="subtle"
|
||||
className="size-8 shrink-0 rounded-lg"
|
||||
text={fxLine}
|
||||
successMessage={t("integrations.remote.fxExportCopied")}
|
||||
/>
|
||||
@@ -557,7 +681,20 @@ export function IntegrationsDialog({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
const examplesButton = (onClick: () => void) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
className="group/examples h-7 gap-1 rounded-lg px-2 text-xs"
|
||||
onClick={onClick}
|
||||
>
|
||||
{t("integrations.examples.open")}
|
||||
<LuArrowRight className="size-3.5 transition-transform duration-150 group-hover/examples:translate-x-0.5 motion-reduce:transition-none" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -593,28 +730,25 @@ export function IntegrationsDialog({
|
||||
)}
|
||||
</AnimatedTabsList>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="api"
|
||||
className="@container mt-4 flex flex-col gap-4"
|
||||
>
|
||||
<IntegrationDiagnostics
|
||||
key={`${apiServerPort}:${settings.api_token ?? ""}`}
|
||||
target="api"
|
||||
/>
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<LuPlug className="mt-0.5 size-5 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.apiEnableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.apiEnableDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<AnimatedTabsContent value="api" className="@container mt-4">
|
||||
<SettingsPanel>
|
||||
<div className="flex items-start gap-3">
|
||||
<FeatureIcon active={apiServerPort !== null}>
|
||||
<LuPlug />
|
||||
</FeatureIcon>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<Label
|
||||
htmlFor={apiSwitchId}
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("integrations.apiEnableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("integrations.apiEnableDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
id={apiSwitchId}
|
||||
checked={apiServerPort !== null}
|
||||
disabled={isApiStarting}
|
||||
onCheckedChange={(checked) =>
|
||||
@@ -622,199 +756,175 @@ export function IntegrationsDialog({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{apiServerPort && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="size-1.5 rounded-full bg-success" />
|
||||
<AnimatedDisclosureContent open={apiServerPort !== null}>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-3 text-xs @md:pl-12">
|
||||
<StatusLight tone="success" live />
|
||||
<span className="text-muted-foreground">
|
||||
{t("integrations.apiRunningOn")}
|
||||
</span>
|
||||
<code className="rounded bg-muted px-2 py-1 font-mono text-[11px]">
|
||||
http://127.0.0.1:{apiServerPort}
|
||||
</code>
|
||||
<CodeSnippet
|
||||
code={`http://127.0.0.1:${apiServerPort ?? settings.api_port}`}
|
||||
className="w-fit"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AnimatedDisclosureContent>
|
||||
</SettingsPanel>
|
||||
|
||||
{settings.api_enabled && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiPortLabel")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={apiPortDraft}
|
||||
onChange={(e) => {
|
||||
setApiPortDraft(e.target.value);
|
||||
const val = Number.parseInt(e.target.value, 10);
|
||||
if (
|
||||
!Number.isNaN(val) &&
|
||||
val >= 1 &&
|
||||
val <= 65535
|
||||
) {
|
||||
setSettings({ ...settings, api_port: val });
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const val = Number.parseInt(apiPortDraft, 10);
|
||||
if (Number.isNaN(val) || val < 1 || val > 65535) {
|
||||
setApiPortDraft(String(settings.api_port));
|
||||
}
|
||||
}}
|
||||
className="w-24 font-mono"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
isApiStarting ||
|
||||
apiServerPort === settings.api_port
|
||||
<AnimatedDisclosureContent open={settings.api_enabled}>
|
||||
<div className="flex flex-col gap-4 pt-3">
|
||||
<SettingsPanel className="divide-y divide-foreground/6 py-3">
|
||||
<SettingsRow
|
||||
label={t("integrations.apiPortLabel")}
|
||||
description={t("integrations.apiPortDescription")}
|
||||
htmlFor={apiPortId}
|
||||
>
|
||||
<Input
|
||||
id={apiPortId}
|
||||
type="number"
|
||||
variant="soft"
|
||||
value={apiPortDraft}
|
||||
onChange={(e) => {
|
||||
setApiPortDraft(e.target.value);
|
||||
const val = Number.parseInt(e.target.value, 10);
|
||||
if (
|
||||
!Number.isNaN(val) &&
|
||||
val >= 1 &&
|
||||
val <= 65535
|
||||
) {
|
||||
setSettings({ ...settings, api_port: val });
|
||||
}
|
||||
onClick={async () => {
|
||||
const port = settings.api_port;
|
||||
if (port < 1 || port > 65535) {
|
||||
showErrorToast(
|
||||
t("integrations.apiInvalidPort"),
|
||||
{
|
||||
description: t(
|
||||
"integrations.apiInvalidPortDescription",
|
||||
),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
setIsApiStarting(true);
|
||||
try {
|
||||
await invoke("stop_api_server");
|
||||
const next = await invoke<AppSettings>(
|
||||
"save_app_settings",
|
||||
{ settings },
|
||||
);
|
||||
setSettings(next);
|
||||
const actualPort = await invoke<number>(
|
||||
"start_api_server",
|
||||
{ port },
|
||||
);
|
||||
setApiServerPort(actualPort);
|
||||
if (actualPort !== port) {
|
||||
showErrorToast(
|
||||
t("integrations.apiPortInUse", { port }),
|
||||
{
|
||||
description: t(
|
||||
"integrations.apiFallbackPort",
|
||||
{ port: actualPort },
|
||||
),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(
|
||||
t("integrations.apiRunning", {
|
||||
port: actualPort,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorToast(
|
||||
t("integrations.apiStartFailed"),
|
||||
{
|
||||
description: translateBackendError(t, e),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setIsApiStarting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiTokenLabel")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showApiToken ? "text" : "password"}
|
||||
value={settings.api_token ?? ""}
|
||||
readOnly
|
||||
className="pr-10 font-mono"
|
||||
/>
|
||||
}}
|
||||
onBlur={() => {
|
||||
const val = Number.parseInt(apiPortDraft, 10);
|
||||
if (Number.isNaN(val) || val < 1 || val > 65535) {
|
||||
setApiPortDraft(String(settings.api_port));
|
||||
}
|
||||
}}
|
||||
className="h-8 w-24 font-mono text-xs md:text-xs"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="soft"
|
||||
className="h-8 rounded-lg text-xs"
|
||||
isLoading={isApiStarting}
|
||||
disabled={apiServerPort === settings.api_port}
|
||||
onClick={() => void handleSavePort()}
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</LoadingButton>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t("integrations.apiTokenLabel")}
|
||||
description={t("integrations.apiTokenDescription")}
|
||||
htmlFor={apiTokenId}
|
||||
>
|
||||
<div className="relative w-full @xl:w-72">
|
||||
<Input
|
||||
id={apiTokenId}
|
||||
type={showApiToken ? "text" : "password"}
|
||||
variant="soft"
|
||||
value={apiToken}
|
||||
readOnly
|
||||
className="h-8 pr-16 font-mono text-xs md:text-xs"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-1 flex items-center gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-0 right-0 h-full px-3 hover:bg-transparent"
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
className="size-6 rounded-md"
|
||||
aria-label={
|
||||
showApiToken
|
||||
? t("common.aria.hideToken")
|
||||
: t("common.aria.showToken")
|
||||
}
|
||||
onClick={() => {
|
||||
setShowApiToken(!showApiToken);
|
||||
}}
|
||||
>
|
||||
{showApiToken ? (
|
||||
<EyeOff className="size-4" />
|
||||
<LuEyeOff className="size-3.5" />
|
||||
) : (
|
||||
<Eye className="size-4" />
|
||||
<LuEye className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<CopyToClipboard
|
||||
text={apiToken}
|
||||
variant="subtle"
|
||||
className="size-6 rounded-md"
|
||||
successMessage={t("integrations.tokenCopied")}
|
||||
/>
|
||||
</div>
|
||||
<CopyToClipboard
|
||||
text={settings.api_token ?? ""}
|
||||
successMessage={t("integrations.tokenCopied")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsPanel>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className={SECTION_LABEL_CLASS}>
|
||||
{t("integrations.apiExampleRequest")}
|
||||
</Label>
|
||||
<CopyToClipboard
|
||||
text={`curl -H "Authorization: Bearer ${settings.api_token ?? "${TOKEN}"}" \\\n http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
|
||||
successMessage={t("common.buttons.copied")}
|
||||
/>
|
||||
</h3>
|
||||
{examplesButton(() => {
|
||||
setApiExamplesOpen(true);
|
||||
})}
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded bg-background p-3 font-mono text-[11px] whitespace-pre">
|
||||
{`curl -H "Authorization: Bearer \${TOKEN}" \\
|
||||
http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<CodeSnippet
|
||||
prompt
|
||||
data-testid="integrations-api-example"
|
||||
code={oneLine(
|
||||
curlSnippet(apiQuickStart, apiDisplayTarget),
|
||||
)}
|
||||
copyText={oneLine(
|
||||
curlSnippet(apiQuickStart, apiCopyTarget),
|
||||
)}
|
||||
highlights={[maskedApiToken]}
|
||||
copyLabel={t("integrations.apiExampleRequest")}
|
||||
successMessage={t("common.buttons.copied")}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</AnimatedDisclosureContent>
|
||||
|
||||
<IntegrationDiagnostics
|
||||
key={`${apiServerPort}:${settings.api_token ?? ""}`}
|
||||
target="api"
|
||||
className="mt-4"
|
||||
/>
|
||||
|
||||
<CodeExamplesDialog
|
||||
open={apiExamplesOpen}
|
||||
onOpenChange={setApiExamplesOpen}
|
||||
title={t("integrations.examples.apiTitle")}
|
||||
description={t("integrations.examples.apiDescription")}
|
||||
examples={apiExamples}
|
||||
highlights={[maskedApiToken, PROFILE_ID_PLACEHOLDER]}
|
||||
note={t("integrations.examples.tokenIncluded")}
|
||||
copyMessage={t("common.buttons.copied")}
|
||||
/>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
{remoteTabShown && (
|
||||
<AnimatedTabsContent
|
||||
value="remote"
|
||||
className="mt-4 flex flex-col gap-5"
|
||||
>
|
||||
<IntegrationDiagnostics
|
||||
key={`${credentialPrefixOf(credential)}:${remote?.enabled}`}
|
||||
target="remote"
|
||||
/>
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<LuCloud className="mt-0.5 size-5 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.remote.enableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.remote.enableDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<AnimatedTabsContent value="remote" className="@container mt-4">
|
||||
<SettingsPanel>
|
||||
<div className="flex items-start gap-3">
|
||||
<FeatureIcon active={remote?.connected ?? false}>
|
||||
<LuCloud />
|
||||
</FeatureIcon>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<Label
|
||||
htmlFor={remoteSwitchId}
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("integrations.remote.enableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("integrations.remote.enableDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
id={remoteSwitchId}
|
||||
checked={remote?.enabled ?? false}
|
||||
disabled={
|
||||
!termsAccepted || !isLoggedIn || isRemoteStarting
|
||||
@@ -826,86 +936,83 @@ export function IntegrationsDialog({
|
||||
</div>
|
||||
|
||||
{!isLoggedIn && (
|
||||
<p className="text-xs text-warning-text">
|
||||
<p className="pt-3 text-xs text-warning-text @md:pl-12">
|
||||
{t("integrations.remote.signInRequired")}
|
||||
</p>
|
||||
)}
|
||||
{isLoggedIn && !termsAccepted && (
|
||||
<p className="text-xs text-warning-text">
|
||||
<p className="pt-3 text-xs text-warning-text @md:pl-12">
|
||||
{t("integrations.mcpAcceptTermsFirst")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{remote?.enabled && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
remote.connected
|
||||
? "bg-success"
|
||||
: "bg-muted-foreground",
|
||||
<AnimatedDisclosureContent open={remote?.enabled ?? false}>
|
||||
{remote && (
|
||||
<div className="flex flex-col gap-2 pt-3 @md:pl-12">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
{/* Three states, not two. A refusal the bridge
|
||||
will keep receiving (an unentitled plan, a taken
|
||||
slot, a dead credential) re-dials for ever
|
||||
without ever connecting, and "Connecting as"
|
||||
over the top of the error underneath it read as
|
||||
a hang rather than an answer. */}
|
||||
<StatusLight
|
||||
tone={
|
||||
remote.connected
|
||||
? "success"
|
||||
: remote.lastError
|
||||
? "destructive"
|
||||
: "muted"
|
||||
}
|
||||
live={remote.connected || !remote.lastError}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{remote.connected
|
||||
? t("integrations.remote.connected")
|
||||
: remote.lastError
|
||||
? t("integrations.remote.notConnected")
|
||||
: t("integrations.remote.connecting")}
|
||||
</span>
|
||||
{/* Only under a phrase that governs it. "Connected
|
||||
as" and "Connecting as" are open phrases; "Not
|
||||
connected" is closed, and the id dangling after
|
||||
it read as a sentence fragment in all ten
|
||||
locales. */}
|
||||
{!(remote.lastError && !remote.connected) && (
|
||||
<code className="rounded-md bg-foreground/5 px-2 py-0.5 font-mono text-[11px]">
|
||||
{remote.instanceId}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
{!remote.connected && remote.lastError && (
|
||||
<p className="text-xs text-destructive-text">
|
||||
{translateBackendError(t, remote.lastError)}
|
||||
</p>
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{/* Three states, not two. A refusal the bridge will
|
||||
keep receiving (an unentitled plan, a taken slot,
|
||||
a dead credential) re-dials for ever without ever
|
||||
connecting, and "Connecting as" over the top of the
|
||||
error underneath it read as a hang rather than an
|
||||
answer. */}
|
||||
{remote.connected
|
||||
? t("integrations.remote.connected")
|
||||
: remote.lastError
|
||||
? t("integrations.remote.notConnected")
|
||||
: t("integrations.remote.connecting")}
|
||||
</span>
|
||||
{/* Only under a phrase that governs it. "Connected as"
|
||||
and "Connecting as" are open phrases; "Not connected"
|
||||
is closed, and the id dangling after it read as a
|
||||
sentence fragment in all ten locales. */}
|
||||
{!(remote.lastError && !remote.connected) && (
|
||||
<code className="rounded bg-muted px-2 py-1 font-mono text-[11px]">
|
||||
{remote.instanceId}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remote?.enabled &&
|
||||
!remote.connected &&
|
||||
remote.lastError && (
|
||||
<p className="text-xs text-destructive-text">
|
||||
{translateBackendError(t, remote.lastError)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AnimatedDisclosureContent>
|
||||
</SettingsPanel>
|
||||
|
||||
{/* Gated on entitlement: handing an unentitled customer a
|
||||
URL, a credential or an "Add" button that can only answer
|
||||
402 is the same wrong-diagnosis trap as telling them their
|
||||
desktop is offline. */}
|
||||
{remote?.enabled &&
|
||||
(remoteEntitled || !remoteEntitlementKnown) && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.remote.credentialLabel")}
|
||||
</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isRotatingCredential}
|
||||
onClick={() => void handleRotateCredential()}
|
||||
>
|
||||
{credentialPresent
|
||||
? t("integrations.remote.credentialRotate")
|
||||
: t("integrations.remote.credentialCreate")}
|
||||
</Button>
|
||||
</div>
|
||||
<AnimatedDisclosureContent
|
||||
open={
|
||||
(remote?.enabled ?? false) &&
|
||||
(remoteEntitled || !remoteEntitlementKnown)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4 pt-3">
|
||||
<SettingsPanel className="divide-y divide-foreground/6 py-3">
|
||||
<SettingsRow
|
||||
label={t("integrations.remote.credentialLabel")}
|
||||
description={t("integrations.remote.credentialHint")}
|
||||
>
|
||||
{credentialPresent ? (
|
||||
credentialPrefix && (
|
||||
<code className="w-fit rounded bg-muted px-2 py-1 font-mono text-[11px]">
|
||||
<code className="rounded-md bg-foreground/5 px-2 py-1 font-mono text-[11px]">
|
||||
{t("integrations.remote.credentialPrefix", {
|
||||
prefix: credentialPrefix,
|
||||
})}
|
||||
@@ -916,51 +1023,76 @@ export function IntegrationsDialog({
|
||||
{t("integrations.remote.credentialNone")}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.remote.credentialHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.remote.endpointLabel")}
|
||||
</Label>
|
||||
<CopyToClipboard
|
||||
text={REMOTE_MCP_URL}
|
||||
successMessage={t(
|
||||
"integrations.remote.endpointCopied",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={REMOTE_MCP_URL}
|
||||
readOnly
|
||||
className="font-mono text-xs"
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="soft"
|
||||
className="h-8 shrink-0 gap-1.5 rounded-lg text-xs"
|
||||
isLoading={isRotatingCredential}
|
||||
onClick={() => void handleRotateCredential()}
|
||||
>
|
||||
{credentialPresent && (
|
||||
<LuRefreshCw className="size-3.5" />
|
||||
)}
|
||||
{credentialPresent
|
||||
? t("integrations.remote.credentialRotate")
|
||||
: t("integrations.remote.credentialCreate")}
|
||||
</LoadingButton>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t("integrations.remote.endpointLabel")}
|
||||
description={t(
|
||||
"integrations.remote.endpointDescription",
|
||||
)}
|
||||
>
|
||||
<CodeSnippet
|
||||
code={REMOTE_MCP_URL}
|
||||
copyLabel={t("integrations.remote.endpointLabel")}
|
||||
successMessage={t(
|
||||
"integrations.remote.endpointCopied",
|
||||
)}
|
||||
className="w-full @xl:w-auto"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.remote.endpointDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsPanel>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.remote.exampleRequest")}
|
||||
</Label>
|
||||
<CopyToClipboard
|
||||
text={remoteExampleRequest}
|
||||
successMessage={t("common.buttons.copied")}
|
||||
/>
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded bg-background p-3 font-mono text-[11px] whitespace-pre">
|
||||
{remoteExampleRequest}
|
||||
</pre>
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className={SECTION_LABEL_CLASS}>
|
||||
{t("integrations.remote.exampleRequest")}
|
||||
</h3>
|
||||
{examplesButton(() => {
|
||||
setRemoteExamplesOpen(true);
|
||||
})}
|
||||
</div>
|
||||
<CodeSnippet
|
||||
prompt
|
||||
code={remoteQuickStart}
|
||||
highlights={[exampleBearer]}
|
||||
copyLabel={t("integrations.remote.exampleRequest")}
|
||||
successMessage={t("common.buttons.copied")}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{clientsGrid}
|
||||
</>
|
||||
)}
|
||||
{clientsGrid}
|
||||
</div>
|
||||
</AnimatedDisclosureContent>
|
||||
|
||||
<IntegrationDiagnostics
|
||||
key={`${credentialPrefixOf(credential)}:${remote?.enabled}`}
|
||||
target="remote"
|
||||
className="mt-4"
|
||||
/>
|
||||
|
||||
<CodeExamplesDialog
|
||||
open={remoteExamplesOpen}
|
||||
onOpenChange={setRemoteExamplesOpen}
|
||||
title={t("integrations.examples.remoteTitle")}
|
||||
description={t("integrations.examples.remoteDescription")}
|
||||
examples={remoteExamples}
|
||||
highlights={[exampleBearer]}
|
||||
note={t("integrations.examples.credentialTemplate")}
|
||||
copyMessage={t("common.buttons.copied")}
|
||||
/>
|
||||
</AnimatedTabsContent>
|
||||
)}
|
||||
</AnimatedTabs>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { CopyToClipboard } from "@/components/ui/copy-to-clipboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function escapeRegExp(text: string) {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** Marks the parts of a snippet the reader has to swap for their own value. */
|
||||
function withHighlights(code: string, highlights: readonly string[]) {
|
||||
const tokens = [...new Set(highlights.filter(Boolean))];
|
||||
if (tokens.length === 0) return code;
|
||||
const parts = code.split(
|
||||
new RegExp(`(${tokens.map(escapeRegExp).join("|")})`, "g"),
|
||||
);
|
||||
const nodes: ReactNode[] = [];
|
||||
let offset = 0;
|
||||
parts.forEach((part, index) => {
|
||||
if (part) {
|
||||
nodes.push(
|
||||
index % 2 === 1 ? (
|
||||
<span
|
||||
key={offset}
|
||||
data-slot="code-placeholder"
|
||||
className="rounded-sm bg-primary/10 px-0.5 font-medium text-foreground"
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
),
|
||||
);
|
||||
}
|
||||
offset += part.length;
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
interface CodeSnippetProps {
|
||||
code: string;
|
||||
/** What the copy button writes, when it differs from what is shown. */
|
||||
copyText?: string;
|
||||
highlights?: readonly string[];
|
||||
/** Draws a shell prompt that is never copied. */
|
||||
prompt?: boolean;
|
||||
multiline?: boolean;
|
||||
/** Wraps long lines, for prose rather than code. */
|
||||
wrap?: boolean;
|
||||
copyable?: boolean;
|
||||
copyLabel?: string;
|
||||
successMessage?: string;
|
||||
className?: string;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export function CodeSnippet({
|
||||
code,
|
||||
copyText,
|
||||
highlights = [],
|
||||
prompt = false,
|
||||
multiline = false,
|
||||
wrap = false,
|
||||
copyable = true,
|
||||
copyLabel,
|
||||
successMessage,
|
||||
className,
|
||||
"data-testid": testId,
|
||||
}: CodeSnippetProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="code-snippet"
|
||||
className={cn(
|
||||
"group/snippet relative min-w-0 rounded-lg bg-foreground/4 font-mono text-[11px] leading-5 text-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<pre
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
"[font-family:inherit] select-text",
|
||||
wrap
|
||||
? "break-words whitespace-pre-wrap"
|
||||
: "scrollbar-none overflow-x-auto whitespace-pre",
|
||||
multiline ? "max-h-72 overflow-y-auto p-3" : "px-3 py-1.5",
|
||||
copyable && "pr-10",
|
||||
)}
|
||||
>
|
||||
{prompt && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground select-none"
|
||||
>
|
||||
${" "}
|
||||
</span>
|
||||
)}
|
||||
<code className="[font-family:inherit]">
|
||||
{withHighlights(code, highlights)}
|
||||
</code>
|
||||
</pre>
|
||||
{copyable && (
|
||||
<CopyToClipboard
|
||||
text={copyText ?? code}
|
||||
variant="subtle"
|
||||
aria-label={copyLabel}
|
||||
successMessage={successMessage}
|
||||
className={cn(
|
||||
"absolute right-1 size-7 rounded-md bg-background/60 opacity-0 backdrop-blur-sm transition-opacity duration-150 group-focus-within/snippet:opacity-100 group-hover/snippet:opacity-100 focus-visible:opacity-100 data-[copied=true]:opacity-100 [@media(hover:none)]:opacity-100",
|
||||
multiline ? "top-1.5" : "top-1/2 -translate-y-1/2",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { LuCheck, LuCopy } from "react-icons/lu";
|
||||
import { Button, type ButtonProps } from "@/components/ui/button";
|
||||
import { showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CopyToClipboardProps {
|
||||
text: string;
|
||||
@@ -12,6 +13,7 @@ interface CopyToClipboardProps {
|
||||
size?: "default" | "sm" | "lg" | "icon";
|
||||
className?: string;
|
||||
successMessage?: string;
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
export function CopyToClipboard({
|
||||
@@ -19,7 +21,8 @@ export function CopyToClipboard({
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
className,
|
||||
successMessage = "Copied to clipboard",
|
||||
successMessage,
|
||||
"aria-label": ariaLabel,
|
||||
}: CopyToClipboardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -28,35 +31,41 @@ export function CopyToClipboard({
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
showSuccessToast(successMessage);
|
||||
showSuccessToast(successMessage ?? t("toasts.success.copied"));
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy to clipboard:", error);
|
||||
}
|
||||
}, [text, successMessage]);
|
||||
}, [text, successMessage, t]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={`relative ${className ?? ""}`}
|
||||
// Lets a control that is only shown on hover stay up while it confirms.
|
||||
data-copied={copied}
|
||||
className={cn("relative", className)}
|
||||
onClick={copyToClipboard}
|
||||
aria-label={copied ? t("common.aria.copied") : t("common.aria.copy")}
|
||||
aria-label={
|
||||
copied ? t("common.aria.copied") : (ariaLabel ?? t("common.aria.copy"))
|
||||
}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{copied ? t("common.srOnly.copied") : t("common.srOnly.copy")}
|
||||
</span>
|
||||
<LuCopy
|
||||
className={`size-4 transition-all duration-150 ${
|
||||
copied ? "scale-0" : "scale-100"
|
||||
}`}
|
||||
className={cn(
|
||||
"size-4 transition-all duration-150",
|
||||
copied ? "scale-0" : "scale-100",
|
||||
)}
|
||||
/>
|
||||
<LuCheck
|
||||
className={`absolute inset-0 m-auto size-4 text-foreground transition-all duration-150 ${
|
||||
copied ? "scale-100" : "scale-0"
|
||||
}`}
|
||||
className={cn(
|
||||
"absolute inset-0 m-auto size-4 text-foreground transition-all duration-150",
|
||||
copied ? "scale-100" : "scale-0",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const SECTION_LABEL_CLASS =
|
||||
"text-[11px] font-medium tracking-wide text-muted-foreground uppercase";
|
||||
|
||||
/** One quiet surface for a group of related settings. */
|
||||
export function SettingsPanel({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"section">) {
|
||||
return (
|
||||
<section
|
||||
className={cn("rounded-xl bg-foreground/3 p-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A setting with its explanation on the left and its control on the right. */
|
||||
export function SettingsRow({
|
||||
label,
|
||||
description,
|
||||
htmlFor,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
htmlFor?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 py-3 first:pt-0 last:pb-0 @xl:flex-row @xl:items-center @xl:justify-between @xl:gap-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-0.5 @xl:max-w-[50%]">
|
||||
<Label htmlFor={htmlFor} className="text-sm font-medium">
|
||||
{label}
|
||||
</Label>
|
||||
{description && (
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2 @xl:justify-end">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The icon beside a feature's on/off switch; it lights up while the feature runs. */
|
||||
export function FeatureIcon({
|
||||
children,
|
||||
active,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
active: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"grid size-9 shrink-0 place-items-center rounded-lg transition-colors duration-200 [&_svg]:size-4.5",
|
||||
active
|
||||
? "bg-success/12 text-success-text"
|
||||
: "bg-foreground/6 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A status light. `live` pulses for a connection that is up right now. */
|
||||
export function StatusLight({
|
||||
tone,
|
||||
live = false,
|
||||
className,
|
||||
}: {
|
||||
tone: "success" | "warning" | "destructive" | "muted";
|
||||
live?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const color = {
|
||||
success: "bg-success",
|
||||
warning: "bg-warning",
|
||||
destructive: "bg-destructive",
|
||||
muted: "bg-muted-foreground",
|
||||
}[tone];
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("relative inline-flex size-2 shrink-0", className)}
|
||||
>
|
||||
{live && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inset-0 animate-ping rounded-full opacity-60 motion-reduce:hidden",
|
||||
color,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className={cn("relative size-2 rounded-full", color)} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { useFieldVariant } from "@/components/ui/field-variant";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
const textareaVariants = cva(
|
||||
"flex min-h-[80px] w-full px-3 py-2 text-sm transition-[color,background-color,border-color,box-shadow] placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"rounded-md border border-input bg-background ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
soft: "rounded-lg bg-foreground/5 hover:bg-foreground/7 focus-visible:bg-foreground/7 focus-visible:ring-2 focus-visible:ring-foreground/15 aria-invalid:bg-destructive/10 aria-invalid:ring-2 aria-invalid:ring-destructive/40",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement> &
|
||||
VariantProps<typeof textareaVariants>;
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
({ className, variant, ...props }, ref) => {
|
||||
const inherited = useFieldVariant();
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
textareaVariants({ variant: variant ?? inherited }),
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -855,7 +855,9 @@
|
||||
"apiEnableLabel": "Lokalen API-Server aktivieren",
|
||||
"apiEnableDescription": "Ermöglicht die Verwaltung von Profilen, Gruppen und Proxys über die REST API.",
|
||||
"apiPortLabel": "Port",
|
||||
"apiPortDescription": "Der Port, auf dem der Server lauscht. Nur dieser Computer kann sich verbinden.",
|
||||
"apiTokenLabel": "Authentifizierungstoken",
|
||||
"apiTokenDescription": "Bei jeder Anfrage als Bearer-Token mitsenden.",
|
||||
"apiInvalidPort": "Ungültiger Port",
|
||||
"apiInvalidPortDescription": "Der Port muss zwischen 1 und 65535 liegen",
|
||||
"apiPortInUse": "Port {{port}} wird bereits verwendet",
|
||||
@@ -870,6 +872,62 @@
|
||||
"openSettings": "Integrationseinstellungen öffnen",
|
||||
"apiRunningOn": "Läuft auf",
|
||||
"apiExampleRequest": "Beispielanfrage",
|
||||
"examples": {
|
||||
"open": "Weitere Beispiele",
|
||||
"apiTitle": "Beispiele für die lokale API",
|
||||
"apiDescription": "Kopiere eine Anfrage und führe sie im Terminal oder in einem Skript aus. Ersetze die markierten Werte durch deine eigenen.",
|
||||
"remoteTitle": "Remote-MCP-Beispiele",
|
||||
"remoteDescription": "Rufe den Remote-MCP-Endpunkt direkt auf oder füge ihn einem Client hinzu, der nicht in der Liste steht.",
|
||||
"response": "Antwort",
|
||||
"language": "Sprache",
|
||||
"paid": "Erfordert einen kostenpflichtigen Plan",
|
||||
"tokenIncluded": "Kopierter Code enthält dein vollständiges Token. Halte es geheim.",
|
||||
"credentialTemplate": "Es wird nur der Anfang der Zugangsdaten angezeigt. Ersetze ihn vor dem Ausführen durch die vollständigen Zugangsdaten.",
|
||||
"languages": {
|
||||
"curl": "cURL",
|
||||
"javascript": "JavaScript",
|
||||
"python": "Python",
|
||||
"json": "JSON"
|
||||
},
|
||||
"items": {
|
||||
"listProfiles": {
|
||||
"title": "Profile auflisten",
|
||||
"description": "Gibt jedes Profil mit ID, Browser, Proxy und Laufstatus zurück."
|
||||
},
|
||||
"runProfile": {
|
||||
"title": "Profil starten",
|
||||
"description": "Startet den Browser und gibt seinen Remote-Debugging-Port zurück. Verbinde Playwright, Puppeteer oder einen anderen CDP-Client mit diesem Port."
|
||||
},
|
||||
"openUrl": {
|
||||
"title": "URL öffnen",
|
||||
"description": "Öffnet eine Seite in einem Profil, das bereits läuft."
|
||||
},
|
||||
"killProfile": {
|
||||
"title": "Profil stoppen",
|
||||
"description": "Schließt den Browser eines laufenden Profils."
|
||||
},
|
||||
"createProxy": {
|
||||
"title": "Proxy hinzufügen",
|
||||
"description": "Speichert einen Proxy, den du Profilen zuweisen kannst."
|
||||
},
|
||||
"openApi": {
|
||||
"title": "API-Referenz",
|
||||
"description": "Das OpenAPI-Dokument für alle Endpunkte. Es braucht kein Token. Importiere es in Postman, Insomnia oder einen Client-Generator."
|
||||
},
|
||||
"listTools": {
|
||||
"title": "Tools auflisten",
|
||||
"description": "Gibt die Tools zurück, die ein Agent über den Endpunkt aufrufen kann."
|
||||
},
|
||||
"callTool": {
|
||||
"title": "Tool aufrufen",
|
||||
"description": "Führt ein Tool aus. Dieses Beispiel listet die Profile auf dem verbundenen Gerät auf."
|
||||
},
|
||||
"clientConfig": {
|
||||
"title": "Client-Konfiguration",
|
||||
"description": "Füge diesen Eintrag der MCP-Konfiguration eines Clients hinzu, der nicht in der Liste steht."
|
||||
}
|
||||
}
|
||||
},
|
||||
"tabRemote": "Remote-MCP",
|
||||
"remote": {
|
||||
"enableLabel": "Fernsteuerung",
|
||||
|
||||
@@ -855,7 +855,9 @@
|
||||
"apiEnableLabel": "Enable Local API Server",
|
||||
"apiEnableDescription": "Allow managing profiles, groups, and proxies via REST API.",
|
||||
"apiPortLabel": "Port",
|
||||
"apiPortDescription": "The port the server listens on. Only this computer can connect to it.",
|
||||
"apiTokenLabel": "Authentication Token",
|
||||
"apiTokenDescription": "Send it as a bearer token with every request.",
|
||||
"apiInvalidPort": "Invalid port",
|
||||
"apiInvalidPortDescription": "Port must be between 1 and 65535",
|
||||
"apiPortInUse": "Port {{port}} is already in use",
|
||||
@@ -870,6 +872,62 @@
|
||||
"openSettings": "Open Integrations Settings",
|
||||
"apiRunningOn": "Running on",
|
||||
"apiExampleRequest": "Example request",
|
||||
"examples": {
|
||||
"open": "More examples",
|
||||
"apiTitle": "Local API examples",
|
||||
"apiDescription": "Copy a request and run it from a terminal or a script. Replace the highlighted values with your own.",
|
||||
"remoteTitle": "Remote MCP examples",
|
||||
"remoteDescription": "Call the remote MCP endpoint directly, or add it to a client that is not in the list.",
|
||||
"response": "Response",
|
||||
"language": "Language",
|
||||
"paid": "Needs a paid plan",
|
||||
"tokenIncluded": "Copied code includes your full token. Keep it private.",
|
||||
"credentialTemplate": "Only the start of the credential is shown. Replace it with the full credential before you run the code.",
|
||||
"languages": {
|
||||
"curl": "cURL",
|
||||
"javascript": "JavaScript",
|
||||
"python": "Python",
|
||||
"json": "JSON"
|
||||
},
|
||||
"items": {
|
||||
"listProfiles": {
|
||||
"title": "List profiles",
|
||||
"description": "Returns every profile with its ID, browser, proxy and running state."
|
||||
},
|
||||
"runProfile": {
|
||||
"title": "Launch a profile",
|
||||
"description": "Starts the browser and returns its remote debugging port. Connect Playwright, Puppeteer or any CDP client to that port."
|
||||
},
|
||||
"openUrl": {
|
||||
"title": "Open a URL",
|
||||
"description": "Opens a page in a profile that is already running."
|
||||
},
|
||||
"killProfile": {
|
||||
"title": "Stop a profile",
|
||||
"description": "Closes the browser of a running profile."
|
||||
},
|
||||
"createProxy": {
|
||||
"title": "Add a proxy",
|
||||
"description": "Saves a proxy that you can assign to profiles."
|
||||
},
|
||||
"openApi": {
|
||||
"title": "API reference",
|
||||
"description": "The OpenAPI document for every endpoint. It needs no token. Import it into Postman, Insomnia or a client generator."
|
||||
},
|
||||
"listTools": {
|
||||
"title": "List tools",
|
||||
"description": "Returns the tools that an agent can call through the endpoint."
|
||||
},
|
||||
"callTool": {
|
||||
"title": "Call a tool",
|
||||
"description": "Runs one tool. This example lists the profiles on the connected device."
|
||||
},
|
||||
"clientConfig": {
|
||||
"title": "Client configuration",
|
||||
"description": "Add this entry to the MCP configuration of a client that is not in the list."
|
||||
}
|
||||
}
|
||||
},
|
||||
"tabRemote": "Remote MCP",
|
||||
"remote": {
|
||||
"enableLabel": "Remote control",
|
||||
|
||||
@@ -859,7 +859,9 @@
|
||||
"apiEnableLabel": "Activar servidor API local",
|
||||
"apiEnableDescription": "Permite gestionar perfiles, grupos y proxies vía API REST.",
|
||||
"apiPortLabel": "Puerto",
|
||||
"apiPortDescription": "El puerto en el que escucha el servidor. Solo este equipo puede conectarse.",
|
||||
"apiTokenLabel": "Token de autenticación",
|
||||
"apiTokenDescription": "Envíalo como token Bearer en cada solicitud.",
|
||||
"apiInvalidPort": "Puerto inválido",
|
||||
"apiInvalidPortDescription": "El puerto debe estar entre 1 y 65535",
|
||||
"apiPortInUse": "El puerto {{port}} ya está en uso",
|
||||
@@ -874,6 +876,62 @@
|
||||
"openSettings": "Abrir configuración de integraciones",
|
||||
"apiRunningOn": "Ejecutándose en",
|
||||
"apiExampleRequest": "Solicitud de ejemplo",
|
||||
"examples": {
|
||||
"open": "Más ejemplos",
|
||||
"apiTitle": "Ejemplos de la API local",
|
||||
"apiDescription": "Copia una solicitud y ejecútala desde una terminal o un script. Sustituye los valores resaltados por los tuyos.",
|
||||
"remoteTitle": "Ejemplos de MCP remoto",
|
||||
"remoteDescription": "Llama directamente al endpoint de MCP remoto o añádelo a un cliente que no está en la lista.",
|
||||
"response": "Respuesta",
|
||||
"language": "Lenguaje",
|
||||
"paid": "Requiere un plan de pago",
|
||||
"tokenIncluded": "El código copiado incluye tu token completo. Mantenlo en privado.",
|
||||
"credentialTemplate": "Solo se muestra el inicio de la credencial. Sustitúyelo por la credencial completa antes de ejecutar el código.",
|
||||
"languages": {
|
||||
"curl": "cURL",
|
||||
"javascript": "JavaScript",
|
||||
"python": "Python",
|
||||
"json": "JSON"
|
||||
},
|
||||
"items": {
|
||||
"listProfiles": {
|
||||
"title": "Listar perfiles",
|
||||
"description": "Devuelve todos los perfiles con su ID, navegador, proxy y estado de ejecución."
|
||||
},
|
||||
"runProfile": {
|
||||
"title": "Iniciar un perfil",
|
||||
"description": "Inicia el navegador y devuelve su puerto de depuración remota. Conecta Playwright, Puppeteer o cualquier cliente CDP a ese puerto."
|
||||
},
|
||||
"openUrl": {
|
||||
"title": "Abrir una URL",
|
||||
"description": "Abre una página en un perfil que ya está en ejecución."
|
||||
},
|
||||
"killProfile": {
|
||||
"title": "Detener un perfil",
|
||||
"description": "Cierra el navegador de un perfil en ejecución."
|
||||
},
|
||||
"createProxy": {
|
||||
"title": "Añadir un proxy",
|
||||
"description": "Guarda un proxy que puedes asignar a perfiles."
|
||||
},
|
||||
"openApi": {
|
||||
"title": "Referencia de la API",
|
||||
"description": "El documento OpenAPI de todos los endpoints. No necesita token. Impórtalo en Postman, Insomnia o un generador de clientes."
|
||||
},
|
||||
"listTools": {
|
||||
"title": "Listar herramientas",
|
||||
"description": "Devuelve las herramientas que un agente puede llamar a través del endpoint."
|
||||
},
|
||||
"callTool": {
|
||||
"title": "Llamar a una herramienta",
|
||||
"description": "Ejecuta una herramienta. Este ejemplo lista los perfiles del dispositivo conectado."
|
||||
},
|
||||
"clientConfig": {
|
||||
"title": "Configuración del cliente",
|
||||
"description": "Añade esta entrada a la configuración MCP de un cliente que no está en la lista."
|
||||
}
|
||||
}
|
||||
},
|
||||
"tabRemote": "MCP remoto",
|
||||
"remote": {
|
||||
"enableLabel": "Control remoto",
|
||||
|
||||
@@ -859,7 +859,9 @@
|
||||
"apiEnableLabel": "Activer le serveur API local",
|
||||
"apiEnableDescription": "Permet de gérer les profils, groupes et proxys via l'API REST.",
|
||||
"apiPortLabel": "Port",
|
||||
"apiPortDescription": "Le port sur lequel le serveur écoute. Seul cet ordinateur peut s'y connecter.",
|
||||
"apiTokenLabel": "Jeton d'authentification",
|
||||
"apiTokenDescription": "Envoyez-le comme jeton Bearer avec chaque requête.",
|
||||
"apiInvalidPort": "Port invalide",
|
||||
"apiInvalidPortDescription": "Le port doit être entre 1 et 65535",
|
||||
"apiPortInUse": "Le port {{port}} est déjà utilisé",
|
||||
@@ -874,6 +876,62 @@
|
||||
"openSettings": "Ouvrir les paramètres d'intégrations",
|
||||
"apiRunningOn": "En cours sur",
|
||||
"apiExampleRequest": "Exemple de requête",
|
||||
"examples": {
|
||||
"open": "Plus d'exemples",
|
||||
"apiTitle": "Exemples de l'API locale",
|
||||
"apiDescription": "Copiez une requête et exécutez-la depuis un terminal ou un script. Remplacez les valeurs surlignées par les vôtres.",
|
||||
"remoteTitle": "Exemples MCP distant",
|
||||
"remoteDescription": "Appelez directement le point de terminaison MCP distant, ou ajoutez-le à un client absent de la liste.",
|
||||
"response": "Réponse",
|
||||
"language": "Langage",
|
||||
"paid": "Nécessite une offre payante",
|
||||
"tokenIncluded": "Le code copié contient votre jeton complet. Gardez-le privé.",
|
||||
"credentialTemplate": "Seul le début de l'identifiant est affiché. Remplacez-le par l'identifiant complet avant d'exécuter le code.",
|
||||
"languages": {
|
||||
"curl": "cURL",
|
||||
"javascript": "JavaScript",
|
||||
"python": "Python",
|
||||
"json": "JSON"
|
||||
},
|
||||
"items": {
|
||||
"listProfiles": {
|
||||
"title": "Lister les profils",
|
||||
"description": "Renvoie chaque profil avec son ID, son navigateur, son proxy et son état d'exécution."
|
||||
},
|
||||
"runProfile": {
|
||||
"title": "Lancer un profil",
|
||||
"description": "Démarre le navigateur et renvoie son port de débogage distant. Connectez Playwright, Puppeteer ou tout client CDP à ce port."
|
||||
},
|
||||
"openUrl": {
|
||||
"title": "Ouvrir une URL",
|
||||
"description": "Ouvre une page dans un profil déjà en cours d'exécution."
|
||||
},
|
||||
"killProfile": {
|
||||
"title": "Arrêter un profil",
|
||||
"description": "Ferme le navigateur d'un profil en cours d'exécution."
|
||||
},
|
||||
"createProxy": {
|
||||
"title": "Ajouter un proxy",
|
||||
"description": "Enregistre un proxy que vous pouvez attribuer à des profils."
|
||||
},
|
||||
"openApi": {
|
||||
"title": "Référence de l'API",
|
||||
"description": "Le document OpenAPI de tous les points de terminaison. Aucun jeton n'est nécessaire. Importez-le dans Postman, Insomnia ou un générateur de clients."
|
||||
},
|
||||
"listTools": {
|
||||
"title": "Lister les outils",
|
||||
"description": "Renvoie les outils qu'un agent peut appeler via le point de terminaison."
|
||||
},
|
||||
"callTool": {
|
||||
"title": "Appeler un outil",
|
||||
"description": "Exécute un outil. Cet exemple liste les profils de l'appareil connecté."
|
||||
},
|
||||
"clientConfig": {
|
||||
"title": "Configuration du client",
|
||||
"description": "Ajoutez cette entrée à la configuration MCP d'un client absent de la liste."
|
||||
}
|
||||
}
|
||||
},
|
||||
"tabRemote": "MCP à distance",
|
||||
"remote": {
|
||||
"enableLabel": "Contrôle à distance",
|
||||
|
||||
@@ -855,7 +855,9 @@
|
||||
"apiEnableLabel": "ローカル API サーバーを有効にする",
|
||||
"apiEnableDescription": "REST API でプロファイル、グループ、プロキシを管理できます。",
|
||||
"apiPortLabel": "ポート",
|
||||
"apiPortDescription": "サーバーが待ち受けるポートです。接続できるのはこのコンピューターだけです。",
|
||||
"apiTokenLabel": "認証トークン",
|
||||
"apiTokenDescription": "すべてのリクエストで Bearer トークンとして送信します。",
|
||||
"apiInvalidPort": "無効なポート",
|
||||
"apiInvalidPortDescription": "ポートは 1 から 65535 の間で指定してください",
|
||||
"apiPortInUse": "ポート {{port}} は既に使用中です",
|
||||
@@ -870,6 +872,62 @@
|
||||
"openSettings": "統合設定を開く",
|
||||
"apiRunningOn": "実行中",
|
||||
"apiExampleRequest": "リクエスト例",
|
||||
"examples": {
|
||||
"open": "その他の例",
|
||||
"apiTitle": "ローカル API の例",
|
||||
"apiDescription": "リクエストをコピーして、ターミナルやスクリプトから実行します。強調表示された値は自分の値に置き換えてください。",
|
||||
"remoteTitle": "リモート MCP の例",
|
||||
"remoteDescription": "リモート MCP エンドポイントを直接呼び出すか、一覧にないクライアントに追加します。",
|
||||
"response": "レスポンス",
|
||||
"language": "言語",
|
||||
"paid": "有料プランが必要です",
|
||||
"tokenIncluded": "コピーしたコードにはトークン全体が含まれます。他人に共有しないでください。",
|
||||
"credentialTemplate": "認証情報は先頭部分のみ表示されています。実行する前に完全な認証情報に置き換えてください。",
|
||||
"languages": {
|
||||
"curl": "cURL",
|
||||
"javascript": "JavaScript",
|
||||
"python": "Python",
|
||||
"json": "JSON"
|
||||
},
|
||||
"items": {
|
||||
"listProfiles": {
|
||||
"title": "プロファイル一覧",
|
||||
"description": "すべてのプロファイルを ID、ブラウザー、プロキシ、実行状態とともに返します。"
|
||||
},
|
||||
"runProfile": {
|
||||
"title": "プロファイルを起動",
|
||||
"description": "ブラウザーを起動し、リモートデバッグポートを返します。Playwright、Puppeteer などの CDP クライアントをそのポートに接続します。"
|
||||
},
|
||||
"openUrl": {
|
||||
"title": "URL を開く",
|
||||
"description": "実行中のプロファイルでページを開きます。"
|
||||
},
|
||||
"killProfile": {
|
||||
"title": "プロファイルを停止",
|
||||
"description": "実行中のプロファイルのブラウザーを閉じます。"
|
||||
},
|
||||
"createProxy": {
|
||||
"title": "プロキシを追加",
|
||||
"description": "プロファイルに割り当てられるプロキシを保存します。"
|
||||
},
|
||||
"openApi": {
|
||||
"title": "API リファレンス",
|
||||
"description": "すべてのエンドポイントを記述した OpenAPI ドキュメントです。トークンは不要です。Postman、Insomnia、クライアント生成ツールにインポートできます。"
|
||||
},
|
||||
"listTools": {
|
||||
"title": "ツール一覧",
|
||||
"description": "エージェントがエンドポイント経由で呼び出せるツールを返します。"
|
||||
},
|
||||
"callTool": {
|
||||
"title": "ツールを呼び出す",
|
||||
"description": "ツールを 1 つ実行します。この例では接続中のデバイスのプロファイルを一覧表示します。"
|
||||
},
|
||||
"clientConfig": {
|
||||
"title": "クライアント設定",
|
||||
"description": "一覧にないクライアントの MCP 設定にこのエントリを追加します。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tabRemote": "リモート MCP",
|
||||
"remote": {
|
||||
"enableLabel": "リモート制御",
|
||||
|
||||
@@ -855,7 +855,9 @@
|
||||
"apiEnableLabel": "로컬 API 서버 활성화",
|
||||
"apiEnableDescription": "REST API로 프로필, 그룹 및 프록시를 관리할 수 있도록 허용합니다.",
|
||||
"apiPortLabel": "포트",
|
||||
"apiPortDescription": "서버가 수신 대기하는 포트입니다. 이 컴퓨터만 연결할 수 있습니다.",
|
||||
"apiTokenLabel": "인증 토큰",
|
||||
"apiTokenDescription": "모든 요청에 Bearer 토큰으로 보내세요.",
|
||||
"apiInvalidPort": "잘못된 포트",
|
||||
"apiInvalidPortDescription": "포트는 1에서 65535 사이여야 합니다",
|
||||
"apiPortInUse": "포트 {{port}}이(가) 이미 사용 중입니다",
|
||||
@@ -870,6 +872,62 @@
|
||||
"openSettings": "통합 설정 열기",
|
||||
"apiRunningOn": "실행 중",
|
||||
"apiExampleRequest": "예시 요청",
|
||||
"examples": {
|
||||
"open": "더 많은 예시",
|
||||
"apiTitle": "로컬 API 예시",
|
||||
"apiDescription": "요청을 복사해 터미널이나 스크립트에서 실행하세요. 강조 표시된 값은 직접 바꿔 주세요.",
|
||||
"remoteTitle": "원격 MCP 예시",
|
||||
"remoteDescription": "원격 MCP 엔드포인트를 직접 호출하거나 목록에 없는 클라이언트에 추가하세요.",
|
||||
"response": "응답",
|
||||
"language": "언어",
|
||||
"paid": "유료 요금제가 필요합니다",
|
||||
"tokenIncluded": "복사한 코드에는 전체 토큰이 포함됩니다. 다른 사람과 공유하지 마세요.",
|
||||
"credentialTemplate": "자격 증명은 앞부분만 표시됩니다. 코드를 실행하기 전에 전체 자격 증명으로 바꾸세요.",
|
||||
"languages": {
|
||||
"curl": "cURL",
|
||||
"javascript": "JavaScript",
|
||||
"python": "Python",
|
||||
"json": "JSON"
|
||||
},
|
||||
"items": {
|
||||
"listProfiles": {
|
||||
"title": "프로필 목록",
|
||||
"description": "모든 프로필을 ID, 브라우저, 프록시, 실행 상태와 함께 반환합니다."
|
||||
},
|
||||
"runProfile": {
|
||||
"title": "프로필 실행",
|
||||
"description": "브라우저를 시작하고 원격 디버깅 포트를 반환합니다. Playwright, Puppeteer 또는 다른 CDP 클라이언트를 이 포트에 연결하세요."
|
||||
},
|
||||
"openUrl": {
|
||||
"title": "URL 열기",
|
||||
"description": "이미 실행 중인 프로필에서 페이지를 엽니다."
|
||||
},
|
||||
"killProfile": {
|
||||
"title": "프로필 중지",
|
||||
"description": "실행 중인 프로필의 브라우저를 닫습니다."
|
||||
},
|
||||
"createProxy": {
|
||||
"title": "프록시 추가",
|
||||
"description": "프로필에 할당할 수 있는 프록시를 저장합니다."
|
||||
},
|
||||
"openApi": {
|
||||
"title": "API 레퍼런스",
|
||||
"description": "모든 엔드포인트를 설명하는 OpenAPI 문서입니다. 토큰이 필요 없습니다. Postman, Insomnia 또는 클라이언트 생성기로 가져오세요."
|
||||
},
|
||||
"listTools": {
|
||||
"title": "도구 목록",
|
||||
"description": "에이전트가 엔드포인트를 통해 호출할 수 있는 도구를 반환합니다."
|
||||
},
|
||||
"callTool": {
|
||||
"title": "도구 호출",
|
||||
"description": "도구 하나를 실행합니다. 이 예시는 연결된 기기의 프로필 목록을 가져옵니다."
|
||||
},
|
||||
"clientConfig": {
|
||||
"title": "클라이언트 구성",
|
||||
"description": "목록에 없는 클라이언트의 MCP 구성에 이 항목을 추가하세요."
|
||||
}
|
||||
}
|
||||
},
|
||||
"tabRemote": "원격 MCP",
|
||||
"remote": {
|
||||
"enableLabel": "원격 제어",
|
||||
|
||||
@@ -859,7 +859,9 @@
|
||||
"apiEnableLabel": "Ativar servidor API local",
|
||||
"apiEnableDescription": "Permite gerenciar perfis, grupos e proxies via API REST.",
|
||||
"apiPortLabel": "Porta",
|
||||
"apiPortDescription": "A porta em que o servidor escuta. Só este computador pode se conectar.",
|
||||
"apiTokenLabel": "Token de autenticação",
|
||||
"apiTokenDescription": "Envie-o como token Bearer em cada requisição.",
|
||||
"apiInvalidPort": "Porta inválida",
|
||||
"apiInvalidPortDescription": "A porta deve estar entre 1 e 65535",
|
||||
"apiPortInUse": "A porta {{port}} já está em uso",
|
||||
@@ -874,6 +876,62 @@
|
||||
"openSettings": "Abrir configurações de integrações",
|
||||
"apiRunningOn": "Em execução em",
|
||||
"apiExampleRequest": "Exemplo de solicitação",
|
||||
"examples": {
|
||||
"open": "Mais exemplos",
|
||||
"apiTitle": "Exemplos da API local",
|
||||
"apiDescription": "Copie uma requisição e execute-a em um terminal ou script. Substitua os valores destacados pelos seus.",
|
||||
"remoteTitle": "Exemplos do MCP remoto",
|
||||
"remoteDescription": "Chame o endpoint MCP remoto diretamente ou adicione-o a um cliente que não está na lista.",
|
||||
"response": "Resposta",
|
||||
"language": "Linguagem",
|
||||
"paid": "Requer um plano pago",
|
||||
"tokenIncluded": "O código copiado inclui seu token completo. Mantenha-o privado.",
|
||||
"credentialTemplate": "Só o início da credencial é exibido. Substitua-o pela credencial completa antes de executar o código.",
|
||||
"languages": {
|
||||
"curl": "cURL",
|
||||
"javascript": "JavaScript",
|
||||
"python": "Python",
|
||||
"json": "JSON"
|
||||
},
|
||||
"items": {
|
||||
"listProfiles": {
|
||||
"title": "Listar perfis",
|
||||
"description": "Retorna todos os perfis com ID, navegador, proxy e estado de execução."
|
||||
},
|
||||
"runProfile": {
|
||||
"title": "Iniciar um perfil",
|
||||
"description": "Inicia o navegador e retorna a porta de depuração remota. Conecte o Playwright, o Puppeteer ou qualquer cliente CDP a essa porta."
|
||||
},
|
||||
"openUrl": {
|
||||
"title": "Abrir uma URL",
|
||||
"description": "Abre uma página em um perfil que já está em execução."
|
||||
},
|
||||
"killProfile": {
|
||||
"title": "Parar um perfil",
|
||||
"description": "Fecha o navegador de um perfil em execução."
|
||||
},
|
||||
"createProxy": {
|
||||
"title": "Adicionar um proxy",
|
||||
"description": "Salva um proxy que você pode atribuir a perfis."
|
||||
},
|
||||
"openApi": {
|
||||
"title": "Referência da API",
|
||||
"description": "O documento OpenAPI de todos os endpoints. Não precisa de token. Importe-o no Postman, no Insomnia ou em um gerador de clientes."
|
||||
},
|
||||
"listTools": {
|
||||
"title": "Listar ferramentas",
|
||||
"description": "Retorna as ferramentas que um agente pode chamar pelo endpoint."
|
||||
},
|
||||
"callTool": {
|
||||
"title": "Chamar uma ferramenta",
|
||||
"description": "Executa uma ferramenta. Este exemplo lista os perfis do dispositivo conectado."
|
||||
},
|
||||
"clientConfig": {
|
||||
"title": "Configuração do cliente",
|
||||
"description": "Adicione esta entrada à configuração MCP de um cliente que não está na lista."
|
||||
}
|
||||
}
|
||||
},
|
||||
"tabRemote": "MCP remoto",
|
||||
"remote": {
|
||||
"enableLabel": "Controlo remoto",
|
||||
|
||||
@@ -863,7 +863,9 @@
|
||||
"apiEnableLabel": "Включить локальный API сервер",
|
||||
"apiEnableDescription": "Позволяет управлять профилями, группами и прокси через REST API.",
|
||||
"apiPortLabel": "Порт",
|
||||
"apiPortDescription": "Порт, на котором работает сервер. Подключиться может только этот компьютер.",
|
||||
"apiTokenLabel": "Токен аутентификации",
|
||||
"apiTokenDescription": "Передавайте его как Bearer-токен в каждом запросе.",
|
||||
"apiInvalidPort": "Неверный порт",
|
||||
"apiInvalidPortDescription": "Порт должен быть от 1 до 65535",
|
||||
"apiPortInUse": "Порт {{port}} уже используется",
|
||||
@@ -878,6 +880,62 @@
|
||||
"openSettings": "Открыть настройки интеграций",
|
||||
"apiRunningOn": "Запущен на",
|
||||
"apiExampleRequest": "Пример запроса",
|
||||
"examples": {
|
||||
"open": "Другие примеры",
|
||||
"apiTitle": "Примеры локального API",
|
||||
"apiDescription": "Скопируйте запрос и выполните его в терминале или скрипте. Замените выделенные значения своими.",
|
||||
"remoteTitle": "Примеры удалённого MCP",
|
||||
"remoteDescription": "Вызовите удалённый MCP-эндпоинт напрямую или добавьте его в клиент, которого нет в списке.",
|
||||
"response": "Ответ",
|
||||
"language": "Язык",
|
||||
"paid": "Нужен платный тариф",
|
||||
"tokenIncluded": "Скопированный код содержит ваш полный токен. Не передавайте его другим.",
|
||||
"credentialTemplate": "Показано только начало учётных данных. Перед запуском кода замените его полными учётными данными.",
|
||||
"languages": {
|
||||
"curl": "cURL",
|
||||
"javascript": "JavaScript",
|
||||
"python": "Python",
|
||||
"json": "JSON"
|
||||
},
|
||||
"items": {
|
||||
"listProfiles": {
|
||||
"title": "Список профилей",
|
||||
"description": "Возвращает все профили с ID, браузером, прокси и состоянием запуска."
|
||||
},
|
||||
"runProfile": {
|
||||
"title": "Запустить профиль",
|
||||
"description": "Запускает браузер и возвращает порт удалённой отладки. Подключите к этому порту Playwright, Puppeteer или любой CDP-клиент."
|
||||
},
|
||||
"openUrl": {
|
||||
"title": "Открыть URL",
|
||||
"description": "Открывает страницу в уже запущенном профиле."
|
||||
},
|
||||
"killProfile": {
|
||||
"title": "Остановить профиль",
|
||||
"description": "Закрывает браузер запущенного профиля."
|
||||
},
|
||||
"createProxy": {
|
||||
"title": "Добавить прокси",
|
||||
"description": "Сохраняет прокси, который можно назначить профилям."
|
||||
},
|
||||
"openApi": {
|
||||
"title": "Справочник API",
|
||||
"description": "Документ OpenAPI со всеми эндпоинтами. Токен не нужен. Импортируйте его в Postman, Insomnia или генератор клиентов."
|
||||
},
|
||||
"listTools": {
|
||||
"title": "Список инструментов",
|
||||
"description": "Возвращает инструменты, которые агент может вызвать через эндпоинт."
|
||||
},
|
||||
"callTool": {
|
||||
"title": "Вызвать инструмент",
|
||||
"description": "Запускает один инструмент. Этот пример выводит профили на подключённом устройстве."
|
||||
},
|
||||
"clientConfig": {
|
||||
"title": "Настройка клиента",
|
||||
"description": "Добавьте эту запись в MCP-конфигурацию клиента, которого нет в списке."
|
||||
}
|
||||
}
|
||||
},
|
||||
"tabRemote": "Удалённый MCP",
|
||||
"remote": {
|
||||
"enableLabel": "Удалённое управление",
|
||||
|
||||
@@ -855,7 +855,9 @@
|
||||
"apiEnableLabel": "Yerel API Sunucusunu Etkinleştir",
|
||||
"apiEnableDescription": "Profillerin, grupların ve proxy'lerin REST API üzerinden yönetilmesine izin verin.",
|
||||
"apiPortLabel": "Bağlantı Noktası",
|
||||
"apiPortDescription": "Sunucunun dinlediği port. Yalnızca bu bilgisayar bağlanabilir.",
|
||||
"apiTokenLabel": "Kimlik Doğrulama Jetonu",
|
||||
"apiTokenDescription": "Her istekte Bearer token olarak gönderin.",
|
||||
"apiInvalidPort": "Geçersiz bağlantı noktası",
|
||||
"apiInvalidPortDescription": "Bağlantı noktası 1 ile 65535 arasında olmalıdır",
|
||||
"apiPortInUse": "{{port}} bağlantı noktası zaten kullanımda",
|
||||
@@ -870,6 +872,62 @@
|
||||
"openSettings": "Entegrasyon Ayarlarını Aç",
|
||||
"apiRunningOn": "Çalıştığı adres",
|
||||
"apiExampleRequest": "Örnek istek",
|
||||
"examples": {
|
||||
"open": "Daha fazla örnek",
|
||||
"apiTitle": "Yerel API örnekleri",
|
||||
"apiDescription": "Bir isteği kopyalayıp terminalde veya bir betikte çalıştırın. Vurgulanan değerleri kendi değerlerinizle değiştirin.",
|
||||
"remoteTitle": "Uzak MCP örnekleri",
|
||||
"remoteDescription": "Uzak MCP uç noktasını doğrudan çağırın veya listede olmayan bir istemciye ekleyin.",
|
||||
"response": "Yanıt",
|
||||
"language": "Dil",
|
||||
"paid": "Ücretli plan gerektirir",
|
||||
"tokenIncluded": "Kopyalanan kod tam token'ınızı içerir. Gizli tutun.",
|
||||
"credentialTemplate": "Kimlik bilgisinin yalnızca başı gösterilir. Kodu çalıştırmadan önce tam kimlik bilgisiyle değiştirin.",
|
||||
"languages": {
|
||||
"curl": "cURL",
|
||||
"javascript": "JavaScript",
|
||||
"python": "Python",
|
||||
"json": "JSON"
|
||||
},
|
||||
"items": {
|
||||
"listProfiles": {
|
||||
"title": "Profilleri listele",
|
||||
"description": "Her profili kimliği, tarayıcısı, proxy'si ve çalışma durumuyla döndürür."
|
||||
},
|
||||
"runProfile": {
|
||||
"title": "Profil başlat",
|
||||
"description": "Tarayıcıyı başlatır ve uzaktan hata ayıklama portunu döndürür. Playwright, Puppeteer veya herhangi bir CDP istemcisini bu porta bağlayın."
|
||||
},
|
||||
"openUrl": {
|
||||
"title": "URL aç",
|
||||
"description": "Zaten çalışan bir profilde bir sayfa açar."
|
||||
},
|
||||
"killProfile": {
|
||||
"title": "Profili durdur",
|
||||
"description": "Çalışan bir profilin tarayıcısını kapatır."
|
||||
},
|
||||
"createProxy": {
|
||||
"title": "Proxy ekle",
|
||||
"description": "Profillere atayabileceğiniz bir proxy kaydeder."
|
||||
},
|
||||
"openApi": {
|
||||
"title": "API başvurusu",
|
||||
"description": "Tüm uç noktaları açıklayan OpenAPI belgesi. Token gerekmez. Postman, Insomnia veya bir istemci oluşturucuya aktarın."
|
||||
},
|
||||
"listTools": {
|
||||
"title": "Araçları listele",
|
||||
"description": "Bir ajanın uç nokta üzerinden çağırabileceği araçları döndürür."
|
||||
},
|
||||
"callTool": {
|
||||
"title": "Araç çağır",
|
||||
"description": "Bir aracı çalıştırır. Bu örnek bağlı cihazdaki profilleri listeler."
|
||||
},
|
||||
"clientConfig": {
|
||||
"title": "İstemci yapılandırması",
|
||||
"description": "Bu girdiyi listede olmayan bir istemcinin MCP yapılandırmasına ekleyin."
|
||||
}
|
||||
}
|
||||
},
|
||||
"tabRemote": "Uzak MCP",
|
||||
"remote": {
|
||||
"enableLabel": "Uzaktan kontrol",
|
||||
|
||||
@@ -855,7 +855,9 @@
|
||||
"apiEnableLabel": "Bật máy chủ API cục bộ",
|
||||
"apiEnableDescription": "Cho phép quản lý profile, nhóm và proxy qua REST API.",
|
||||
"apiPortLabel": "Cổng",
|
||||
"apiPortDescription": "Cổng mà máy chủ lắng nghe. Chỉ máy tính này có thể kết nối.",
|
||||
"apiTokenLabel": "Token xác thực",
|
||||
"apiTokenDescription": "Gửi nó dưới dạng Bearer token trong mỗi yêu cầu.",
|
||||
"apiInvalidPort": "Cổng không hợp lệ",
|
||||
"apiInvalidPortDescription": "Cổng phải từ 1 đến 65535",
|
||||
"apiPortInUse": "Cổng {{port}} đang được sử dụng",
|
||||
@@ -870,6 +872,62 @@
|
||||
"openSettings": "Mở cài đặt tích hợp",
|
||||
"apiRunningOn": "Đang chạy trên",
|
||||
"apiExampleRequest": "Ví dụ yêu cầu",
|
||||
"examples": {
|
||||
"open": "Thêm ví dụ",
|
||||
"apiTitle": "Ví dụ API cục bộ",
|
||||
"apiDescription": "Sao chép một yêu cầu và chạy nó trong terminal hoặc script. Thay các giá trị được đánh dấu bằng giá trị của bạn.",
|
||||
"remoteTitle": "Ví dụ MCP từ xa",
|
||||
"remoteDescription": "Gọi trực tiếp endpoint MCP từ xa, hoặc thêm nó vào một client không có trong danh sách.",
|
||||
"response": "Phản hồi",
|
||||
"language": "Ngôn ngữ",
|
||||
"paid": "Cần gói trả phí",
|
||||
"tokenIncluded": "Mã đã sao chép chứa toàn bộ token của bạn. Hãy giữ bí mật.",
|
||||
"credentialTemplate": "Chỉ phần đầu của thông tin xác thực được hiển thị. Hãy thay bằng thông tin xác thực đầy đủ trước khi chạy mã.",
|
||||
"languages": {
|
||||
"curl": "cURL",
|
||||
"javascript": "JavaScript",
|
||||
"python": "Python",
|
||||
"json": "JSON"
|
||||
},
|
||||
"items": {
|
||||
"listProfiles": {
|
||||
"title": "Liệt kê hồ sơ",
|
||||
"description": "Trả về mọi hồ sơ cùng ID, trình duyệt, proxy và trạng thái chạy."
|
||||
},
|
||||
"runProfile": {
|
||||
"title": "Khởi chạy hồ sơ",
|
||||
"description": "Khởi động trình duyệt và trả về cổng gỡ lỗi từ xa. Kết nối Playwright, Puppeteer hoặc bất kỳ client CDP nào vào cổng đó."
|
||||
},
|
||||
"openUrl": {
|
||||
"title": "Mở URL",
|
||||
"description": "Mở một trang trong hồ sơ đang chạy."
|
||||
},
|
||||
"killProfile": {
|
||||
"title": "Dừng hồ sơ",
|
||||
"description": "Đóng trình duyệt của một hồ sơ đang chạy."
|
||||
},
|
||||
"createProxy": {
|
||||
"title": "Thêm proxy",
|
||||
"description": "Lưu một proxy mà bạn có thể gán cho hồ sơ."
|
||||
},
|
||||
"openApi": {
|
||||
"title": "Tài liệu API",
|
||||
"description": "Tài liệu OpenAPI cho mọi endpoint. Không cần token. Nhập nó vào Postman, Insomnia hoặc trình tạo client."
|
||||
},
|
||||
"listTools": {
|
||||
"title": "Liệt kê công cụ",
|
||||
"description": "Trả về các công cụ mà agent có thể gọi qua endpoint."
|
||||
},
|
||||
"callTool": {
|
||||
"title": "Gọi công cụ",
|
||||
"description": "Chạy một công cụ. Ví dụ này liệt kê hồ sơ trên thiết bị đã kết nối."
|
||||
},
|
||||
"clientConfig": {
|
||||
"title": "Cấu hình client",
|
||||
"description": "Thêm mục này vào cấu hình MCP của một client không có trong danh sách."
|
||||
}
|
||||
}
|
||||
},
|
||||
"tabRemote": "MCP từ xa",
|
||||
"remote": {
|
||||
"enableLabel": "Điều khiển từ xa",
|
||||
|
||||
@@ -855,7 +855,9 @@
|
||||
"apiEnableLabel": "启用本地 API 服务器",
|
||||
"apiEnableDescription": "允许通过 REST API 管理配置文件、分组和代理。",
|
||||
"apiPortLabel": "端口",
|
||||
"apiPortDescription": "服务器监听的端口。只有这台电脑可以连接。",
|
||||
"apiTokenLabel": "认证令牌",
|
||||
"apiTokenDescription": "在每个请求中作为 Bearer 令牌发送。",
|
||||
"apiInvalidPort": "无效的端口",
|
||||
"apiInvalidPortDescription": "端口必须在 1 到 65535 之间",
|
||||
"apiPortInUse": "端口 {{port}} 已被占用",
|
||||
@@ -870,6 +872,62 @@
|
||||
"openSettings": "打开集成设置",
|
||||
"apiRunningOn": "运行于",
|
||||
"apiExampleRequest": "示例请求",
|
||||
"examples": {
|
||||
"open": "更多示例",
|
||||
"apiTitle": "本地 API 示例",
|
||||
"apiDescription": "复制请求并在终端或脚本中运行。将高亮的值替换为你自己的值。",
|
||||
"remoteTitle": "远程 MCP 示例",
|
||||
"remoteDescription": "直接调用远程 MCP 端点,或将其添加到列表中没有的客户端。",
|
||||
"response": "响应",
|
||||
"language": "语言",
|
||||
"paid": "需要付费套餐",
|
||||
"tokenIncluded": "复制的代码包含你的完整令牌。请妥善保管。",
|
||||
"credentialTemplate": "仅显示凭据的开头部分。运行代码前请将其替换为完整凭据。",
|
||||
"languages": {
|
||||
"curl": "cURL",
|
||||
"javascript": "JavaScript",
|
||||
"python": "Python",
|
||||
"json": "JSON"
|
||||
},
|
||||
"items": {
|
||||
"listProfiles": {
|
||||
"title": "列出配置文件",
|
||||
"description": "返回所有配置文件及其 ID、浏览器、代理和运行状态。"
|
||||
},
|
||||
"runProfile": {
|
||||
"title": "启动配置文件",
|
||||
"description": "启动浏览器并返回其远程调试端口。将 Playwright、Puppeteer 或任意 CDP 客户端连接到该端口。"
|
||||
},
|
||||
"openUrl": {
|
||||
"title": "打开 URL",
|
||||
"description": "在已运行的配置文件中打开页面。"
|
||||
},
|
||||
"killProfile": {
|
||||
"title": "停止配置文件",
|
||||
"description": "关闭正在运行的配置文件的浏览器。"
|
||||
},
|
||||
"createProxy": {
|
||||
"title": "添加代理",
|
||||
"description": "保存一个可分配给配置文件的代理。"
|
||||
},
|
||||
"openApi": {
|
||||
"title": "API 参考",
|
||||
"description": "描述所有端点的 OpenAPI 文档。无需令牌。可导入 Postman、Insomnia 或客户端生成器。"
|
||||
},
|
||||
"listTools": {
|
||||
"title": "列出工具",
|
||||
"description": "返回代理可通过该端点调用的工具。"
|
||||
},
|
||||
"callTool": {
|
||||
"title": "调用工具",
|
||||
"description": "运行一个工具。此示例列出已连接设备上的配置文件。"
|
||||
},
|
||||
"clientConfig": {
|
||||
"title": "客户端配置",
|
||||
"description": "将此条目添加到列表中没有的客户端的 MCP 配置中。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tabRemote": "远程 MCP",
|
||||
"remote": {
|
||||
"enableLabel": "远程控制",
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import {
|
||||
curlSnippet,
|
||||
javascriptSnippet,
|
||||
LOCAL_API_EXAMPLES,
|
||||
maskToken,
|
||||
mcpClientConfig,
|
||||
oneLine,
|
||||
PROFILE_ID_PLACEHOLDER,
|
||||
pythonLiteral,
|
||||
pythonSnippet,
|
||||
REMOTE_MCP_EXAMPLES,
|
||||
REQUEST_LANGUAGES,
|
||||
requestSnippet,
|
||||
} from "./api-examples.ts";
|
||||
|
||||
const target = { baseUrl: "http://127.0.0.1:10108", token: "tok" };
|
||||
const AsyncFunction = (async () => {}).constructor;
|
||||
|
||||
test("curl puts the method, the bearer and the body on their own lines", () => {
|
||||
assert.equal(
|
||||
curlSnippet({ method: "GET", path: "/v1/profiles" }, target),
|
||||
'curl http://127.0.0.1:10108/v1/profiles \\\n -H "Authorization: Bearer tok"',
|
||||
);
|
||||
assert.equal(
|
||||
curlSnippet(
|
||||
{ method: "POST", path: "/v1/x", body: { note: "it's" } },
|
||||
target,
|
||||
),
|
||||
[
|
||||
"curl -X POST http://127.0.0.1:10108/v1/x \\",
|
||||
' -H "Authorization: Bearer tok" \\',
|
||||
' -H "Content-Type: application/json" \\',
|
||||
` -d '{"note":"it'\\''s"}'`,
|
||||
].join("\n"),
|
||||
);
|
||||
assert.equal(
|
||||
curlSnippet({ method: "GET", path: "/openapi.json", auth: false }, target),
|
||||
"curl http://127.0.0.1:10108/openapi.json",
|
||||
);
|
||||
});
|
||||
|
||||
test("the JavaScript form is valid code and keeps the body's values", () => {
|
||||
for (const example of [...LOCAL_API_EXAMPLES, ...REMOTE_MCP_EXAMPLES]) {
|
||||
const code = javascriptSnippet(example.request, target);
|
||||
assert.doesNotThrow(() => new AsyncFunction(code), example.id);
|
||||
}
|
||||
const code = javascriptSnippet(
|
||||
{
|
||||
method: "POST",
|
||||
path: "/v1/x",
|
||||
body: { plain: 1, "needs-quotes": [true, null] },
|
||||
},
|
||||
target,
|
||||
);
|
||||
assert.match(code, /\bplain: 1,/);
|
||||
assert.match(code, /"needs-quotes": \[/);
|
||||
assert.equal(
|
||||
javascriptSnippet({ method: "GET", path: "/o", auth: false }, target),
|
||||
'const response = await fetch("http://127.0.0.1:10108/o");\nconsole.log(await response.json());',
|
||||
);
|
||||
assert.match(
|
||||
javascriptSnippet(
|
||||
{ method: "POST", path: "/k", returnsJson: false },
|
||||
target,
|
||||
),
|
||||
/console\.log\(response\.status\);$/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Python literals use Python's constants", () => {
|
||||
assert.equal(pythonLiteral(true), "True");
|
||||
assert.equal(pythonLiteral(false), "False");
|
||||
assert.equal(pythonLiteral(null), "None");
|
||||
assert.equal(
|
||||
pythonLiteral({ a: [1, false], b: {} }),
|
||||
'{\n "a": [\n 1,\n False,\n ],\n "b": {},\n}',
|
||||
);
|
||||
const code = pythonSnippet(
|
||||
{ method: "PUT", path: "/v1/y", body: { on: true }, returnsJson: false },
|
||||
target,
|
||||
);
|
||||
assert.match(code, /^import requests\n\nresponse = requests\.put\(/);
|
||||
assert.match(code, /json=\{\n {8}"on": True,\n {4}\},/);
|
||||
assert.match(code, /print\(response\.status_code\)$/);
|
||||
});
|
||||
|
||||
test("every request language renders every example", () => {
|
||||
assert.deepEqual(REQUEST_LANGUAGES, ["curl", "javascript", "python"]);
|
||||
for (const language of REQUEST_LANGUAGES) {
|
||||
for (const example of LOCAL_API_EXAMPLES) {
|
||||
const code = requestSnippet(language, example.request, target);
|
||||
assert.ok(code.includes(target.baseUrl), `${language} ${example.id}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("a one-line command is the same command", () => {
|
||||
const spec = LOCAL_API_EXAMPLES.find((e) => e.id === "runProfile").request;
|
||||
assert.equal(
|
||||
oneLine(curlSnippet(spec, target)),
|
||||
`curl -X POST http://127.0.0.1:10108/v1/profiles/PROFILE_ID/run -H "Authorization: Bearer tok" -H "Content-Type: application/json" -d '{"url":"https://example.com","headless":false}'`,
|
||||
);
|
||||
assert.equal(oneLine("echo 'a \\ b'"), "echo 'a \\ b'");
|
||||
});
|
||||
|
||||
test("the curl body round-trips to the request body", () => {
|
||||
for (const example of LOCAL_API_EXAMPLES.filter((e) => e.request.body)) {
|
||||
const code = curlSnippet(example.request, target);
|
||||
const body = code.match(/-d '(.*)'$/)[1];
|
||||
assert.deepEqual(JSON.parse(body), example.request.body, example.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("local examples only call routes the app publishes", () => {
|
||||
const published = new Set(
|
||||
JSON.parse(
|
||||
readFileSync(new URL("../../sdk/api-paths.json", import.meta.url)),
|
||||
).operations.map((operation) => `${operation.method} ${operation.path}`),
|
||||
);
|
||||
for (const example of LOCAL_API_EXAMPLES) {
|
||||
const { method, path, auth } = example.request;
|
||||
if (auth === false) {
|
||||
assert.equal(path, "/openapi.json", example.id);
|
||||
continue;
|
||||
}
|
||||
const route = `${method} ${path.replace(PROFILE_ID_PLACEHOLDER, "{id}")}`;
|
||||
assert.ok(published.has(route), `${example.id}: ${route}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("only the automation routes are marked paid", () => {
|
||||
assert.deepEqual(
|
||||
LOCAL_API_EXAMPLES.filter((example) => example.paid).map((e) => e.id),
|
||||
["runProfile", "openUrl", "killProfile"],
|
||||
);
|
||||
});
|
||||
|
||||
test("a token is shown by its ends, never whole", () => {
|
||||
assert.equal(maskToken(null), "TOKEN");
|
||||
assert.equal(maskToken("short"), "••••••••");
|
||||
assert.equal(maskToken("abcd0123456789wxyz"), "abcd…wxyz");
|
||||
});
|
||||
|
||||
test("the client config names the server and carries the bearer", () => {
|
||||
const config = JSON.parse(mcpClientConfig("https://h/api/mcp", "k…"));
|
||||
assert.deepEqual(config.mcpServers["donut-browser"], {
|
||||
type: "http",
|
||||
url: "https://h/api/mcp",
|
||||
headers: { Authorization: "Bearer k…" },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
export type RequestLanguage = "curl" | "javascript" | "python";
|
||||
|
||||
export type SnippetLanguage = RequestLanguage | "json";
|
||||
|
||||
export const REQUEST_LANGUAGES: readonly RequestLanguage[] = [
|
||||
"curl",
|
||||
"javascript",
|
||||
"python",
|
||||
];
|
||||
|
||||
export const PROFILE_ID_PLACEHOLDER = "PROFILE_ID";
|
||||
|
||||
type JsonValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
export interface RequestSpec {
|
||||
method: "GET" | "POST" | "PUT" | "DELETE";
|
||||
/** Joined onto the base URL as is, so it may be empty. */
|
||||
path: string;
|
||||
body?: { [key: string]: JsonValue };
|
||||
/** False for the few routes that answer without the bearer token. */
|
||||
auth?: boolean;
|
||||
/** False when the route answers with a status and no body. */
|
||||
returnsJson?: boolean;
|
||||
}
|
||||
|
||||
export interface RequestTarget {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
const INDENT = " ";
|
||||
const PY_INDENT = " ";
|
||||
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
||||
|
||||
function urlOf(spec: RequestSpec, target: RequestTarget): string {
|
||||
return `${target.baseUrl}${spec.path}`;
|
||||
}
|
||||
|
||||
function shellQuote(text: string): string {
|
||||
return `'${text.replaceAll("'", `'\\''`)}'`;
|
||||
}
|
||||
|
||||
export function curlSnippet(spec: RequestSpec, target: RequestTarget): string {
|
||||
const head =
|
||||
spec.method === "GET"
|
||||
? `curl ${urlOf(spec, target)}`
|
||||
: `curl -X ${spec.method} ${urlOf(spec, target)}`;
|
||||
const flags: string[] = [];
|
||||
if (spec.auth !== false) {
|
||||
flags.push(`-H "Authorization: Bearer ${target.token}"`);
|
||||
}
|
||||
if (spec.body) {
|
||||
flags.push(`-H "Content-Type: application/json"`);
|
||||
flags.push(`-d ${shellQuote(JSON.stringify(spec.body))}`);
|
||||
}
|
||||
return [head, ...flags.map((flag) => `${INDENT}${flag}`)].join(" \\\n");
|
||||
}
|
||||
|
||||
function jsLiteral(value: JsonValue, depth: number): string {
|
||||
if (value === null || typeof value !== "object") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
const pad = INDENT.repeat(depth + 1);
|
||||
const close = INDENT.repeat(depth);
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return "[]";
|
||||
return `[\n${value.map((item) => `${pad}${jsLiteral(item, depth + 1)},`).join("\n")}\n${close}]`;
|
||||
}
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 0) return "{}";
|
||||
return `{\n${entries
|
||||
.map(
|
||||
([key, item]) =>
|
||||
`${pad}${IDENTIFIER.test(key) ? key : JSON.stringify(key)}: ${jsLiteral(item, depth + 1)},`,
|
||||
)
|
||||
.join("\n")}\n${close}}`;
|
||||
}
|
||||
|
||||
export function javascriptSnippet(
|
||||
spec: RequestSpec,
|
||||
target: RequestTarget,
|
||||
): string {
|
||||
const options: string[] = [];
|
||||
if (spec.method !== "GET") options.push(`method: "${spec.method}",`);
|
||||
const headers: string[] = [];
|
||||
if (spec.auth !== false) {
|
||||
headers.push(`Authorization: "Bearer ${target.token}",`);
|
||||
}
|
||||
if (spec.body) headers.push(`"Content-Type": "application/json",`);
|
||||
if (headers.length > 0) {
|
||||
options.push(
|
||||
`headers: {\n${headers.map((line) => `${INDENT.repeat(2)}${line}`).join("\n")}\n${INDENT}},`,
|
||||
);
|
||||
}
|
||||
if (spec.body)
|
||||
options.push(`body: JSON.stringify(${jsLiteral(spec.body, 1)}),`);
|
||||
const url = JSON.stringify(urlOf(spec, target));
|
||||
const call =
|
||||
options.length === 0
|
||||
? `const response = await fetch(${url});`
|
||||
: `const response = await fetch(${url}, {\n${options.map((line) => `${INDENT}${line}`).join("\n")}\n});`;
|
||||
const result =
|
||||
spec.returnsJson === false
|
||||
? "console.log(response.status);"
|
||||
: "console.log(await response.json());";
|
||||
return `${call}\n${result}`;
|
||||
}
|
||||
|
||||
export function pythonLiteral(value: JsonValue, depth = 0): string {
|
||||
if (value === null) return "None";
|
||||
if (value === true) return "True";
|
||||
if (value === false) return "False";
|
||||
if (typeof value !== "object") return JSON.stringify(value);
|
||||
const pad = PY_INDENT.repeat(depth + 1);
|
||||
const close = PY_INDENT.repeat(depth);
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return "[]";
|
||||
return `[\n${value.map((item) => `${pad}${pythonLiteral(item, depth + 1)},`).join("\n")}\n${close}]`;
|
||||
}
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 0) return "{}";
|
||||
return `{\n${entries
|
||||
.map(
|
||||
([key, item]) =>
|
||||
`${pad}${JSON.stringify(key)}: ${pythonLiteral(item, depth + 1)},`,
|
||||
)
|
||||
.join("\n")}\n${close}}`;
|
||||
}
|
||||
|
||||
export function pythonSnippet(
|
||||
spec: RequestSpec,
|
||||
target: RequestTarget,
|
||||
): string {
|
||||
const args = [JSON.stringify(urlOf(spec, target))];
|
||||
if (spec.auth !== false) {
|
||||
args.push(`headers={"Authorization": "Bearer ${target.token}"}`);
|
||||
}
|
||||
if (spec.body) args.push(`json=${pythonLiteral(spec.body, 1)}`);
|
||||
const result =
|
||||
spec.returnsJson === false
|
||||
? "print(response.status_code)"
|
||||
: "print(response.json())";
|
||||
return [
|
||||
"import requests",
|
||||
"",
|
||||
`response = requests.${spec.method.toLowerCase()}(`,
|
||||
...args.map((arg) => `${PY_INDENT}${arg},`),
|
||||
")",
|
||||
result,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function requestSnippet(
|
||||
language: RequestLanguage,
|
||||
spec: RequestSpec,
|
||||
target: RequestTarget,
|
||||
): string {
|
||||
switch (language) {
|
||||
case "curl":
|
||||
return curlSnippet(spec, target);
|
||||
case "javascript":
|
||||
return javascriptSnippet(spec, target);
|
||||
case "python":
|
||||
return pythonSnippet(spec, target);
|
||||
}
|
||||
}
|
||||
|
||||
/** A continued shell command on one line, for places with room for one. */
|
||||
export function oneLine(command: string): string {
|
||||
return command.replace(/ \\\n\s*/g, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* A token is shown by its ends only. The copy carries the real value, so the
|
||||
* visible form only has to say whose token it is.
|
||||
*/
|
||||
export function maskToken(token: string | null | undefined): string {
|
||||
if (!token) return "TOKEN";
|
||||
if (token.length <= 12) return "•".repeat(8);
|
||||
return `${token.slice(0, 4)}…${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
export type LocalApiExampleId =
|
||||
| "listProfiles"
|
||||
| "runProfile"
|
||||
| "openUrl"
|
||||
| "killProfile"
|
||||
| "createProxy"
|
||||
| "openApi";
|
||||
|
||||
export interface ApiExample<Id extends string = string> {
|
||||
id: Id;
|
||||
request: RequestSpec;
|
||||
/** Needs a paid plan with browser automation; the route answers 402 otherwise. */
|
||||
paid?: boolean;
|
||||
response: string;
|
||||
}
|
||||
|
||||
export const LOCAL_API_EXAMPLES: readonly ApiExample<LocalApiExampleId>[] = [
|
||||
{
|
||||
id: "listProfiles",
|
||||
request: { method: "GET", path: "/v1/profiles" },
|
||||
response: JSON.stringify(
|
||||
{
|
||||
profiles: [
|
||||
{
|
||||
id: "6f1c…",
|
||||
name: "Shopping",
|
||||
browser: "wayfern",
|
||||
proxy_id: null,
|
||||
tags: ["eu"],
|
||||
is_running: false,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "runProfile",
|
||||
paid: true,
|
||||
request: {
|
||||
method: "POST",
|
||||
path: `/v1/profiles/${PROFILE_ID_PLACEHOLDER}/run`,
|
||||
body: { url: "https://example.com", headless: false },
|
||||
},
|
||||
response: JSON.stringify(
|
||||
{
|
||||
profile_id: "6f1c…",
|
||||
remote_debugging_port: 9222,
|
||||
headless: false,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "openUrl",
|
||||
paid: true,
|
||||
request: {
|
||||
method: "POST",
|
||||
path: `/v1/profiles/${PROFILE_ID_PLACEHOLDER}/open-url`,
|
||||
body: { url: "https://example.com/account" },
|
||||
returnsJson: false,
|
||||
},
|
||||
response: "200 OK",
|
||||
},
|
||||
{
|
||||
id: "killProfile",
|
||||
paid: true,
|
||||
request: {
|
||||
method: "POST",
|
||||
path: `/v1/profiles/${PROFILE_ID_PLACEHOLDER}/kill`,
|
||||
returnsJson: false,
|
||||
},
|
||||
response: "204 No Content",
|
||||
},
|
||||
{
|
||||
id: "createProxy",
|
||||
request: {
|
||||
method: "POST",
|
||||
path: "/v1/proxies",
|
||||
body: {
|
||||
name: "Residential EU",
|
||||
proxy_settings: {
|
||||
proxy_type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 8080,
|
||||
username: "user",
|
||||
password: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
response: JSON.stringify(
|
||||
{
|
||||
id: "a29d…",
|
||||
name: "Residential EU",
|
||||
proxy_settings: {
|
||||
proxy_type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 8080,
|
||||
username: "user",
|
||||
password: "secret",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "openApi",
|
||||
request: { method: "GET", path: "/openapi.json", auth: false },
|
||||
response: JSON.stringify(
|
||||
{
|
||||
openapi: "3.1.0",
|
||||
info: { title: "donutbrowser", version: "…" },
|
||||
paths: { "/v1/profiles": {} },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export type RemoteMcpExampleId = "listTools" | "callTool" | "clientConfig";
|
||||
|
||||
export const MCP_SERVER_NAME = "donut-browser";
|
||||
|
||||
export const REMOTE_MCP_EXAMPLES: readonly ApiExample<
|
||||
Exclude<RemoteMcpExampleId, "clientConfig">
|
||||
>[] = [
|
||||
{
|
||||
id: "listTools",
|
||||
request: {
|
||||
method: "POST",
|
||||
path: "",
|
||||
body: { jsonrpc: "2.0", id: 1, method: "tools/list" },
|
||||
},
|
||||
response: JSON.stringify(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: {
|
||||
tools: [
|
||||
{
|
||||
name: "list_profiles",
|
||||
description: "List all Wayfern browser profiles",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "callTool",
|
||||
request: {
|
||||
method: "POST",
|
||||
path: "",
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "tools/call",
|
||||
params: { name: "list_profiles", arguments: {} },
|
||||
},
|
||||
},
|
||||
response: JSON.stringify(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
result: { content: [{ type: "text", text: "[ … ]" }] },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/** The entry most MCP clients accept in their `mcpServers` map. */
|
||||
export function mcpClientConfig(url: string, token: string): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
[MCP_SERVER_NAME]: {
|
||||
type: "http",
|
||||
url,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user