update
This commit is contained in:
1 parent
66158e222e
commit
67c757f481
314 files changed
+37945
-22962
No files matched your search
@@ -1,16 +1,16 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Buildings, FolderOpen, Lightning, UserCircleCheck } from "@phosphor-icons/react";
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
import { Buildings, FolderOpen } from "@phosphor-icons/react";
|
||||
import type { ConnectorId, ProfileSnapshot } from "@shared/types";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { ActionButton, Input, Select, TextArea } from "../ui";
|
||||
import { ActionButton, Input, TextArea, Select } from "../ui";
|
||||
|
||||
interface CompanyStepProps {
|
||||
companyName: string;
|
||||
setCompanyName: (v: string) => void;
|
||||
companyDescription: string;
|
||||
setCompanyDescription: (v: string) => void;
|
||||
selectedConnectorId: string;
|
||||
setSelectedConnectorId: (v: string) => void;
|
||||
selectedConnectorId: ConnectorId | "";
|
||||
setSelectedConnectorId: (v: ConnectorId | "") => void;
|
||||
selectedModel: string;
|
||||
setSelectedModel: (v: string) => void;
|
||||
executionReadyConnectors: ProfileSnapshot["connectors"];
|
||||
@@ -18,10 +18,6 @@ interface CompanyStepProps {
|
||||
loadingModels: boolean;
|
||||
workspacePath: string;
|
||||
setWorkspacePath: (v: string) => void;
|
||||
autoApproveHires: boolean;
|
||||
setAutoApproveHires: (v: boolean) => void;
|
||||
reviewDeliverables: boolean;
|
||||
setReviewDeliverables: (v: boolean) => void;
|
||||
onPickDirectory: () => void;
|
||||
onContinue: () => void;
|
||||
onBack: () => void;
|
||||
@@ -44,10 +40,6 @@ export function CompanyStep({
|
||||
loadingModels,
|
||||
workspacePath,
|
||||
setWorkspacePath,
|
||||
autoApproveHires,
|
||||
setAutoApproveHires,
|
||||
reviewDeliverables,
|
||||
setReviewDeliverables,
|
||||
onPickDirectory,
|
||||
onContinue,
|
||||
onBack,
|
||||
@@ -65,7 +57,7 @@ export function CompanyStep({
|
||||
className="bg-card border border-border shadow-sm w-full rounded-lg p-7"
|
||||
>
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{t("onboarding.stepLabel1")} 3 / 5
|
||||
{t("onboarding.stepLabelCompany")} 3 / 6
|
||||
</div>
|
||||
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-foreground">
|
||||
{t("onboarding.step1Title")}
|
||||
@@ -96,7 +88,7 @@ export function CompanyStep({
|
||||
<Select
|
||||
label={currentLocale === "zh" ? "提供商" : "Provider"}
|
||||
value={selectedConnectorId}
|
||||
onChange={setSelectedConnectorId}
|
||||
onChange={(value) => setSelectedConnectorId(value as ConnectorId | "")}
|
||||
options={executionReadyConnectors.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.id.startsWith("claude_compat") && c.notes ? `${c.notes}` : c.label,
|
||||
@@ -106,13 +98,16 @@ export function CompanyStep({
|
||||
label={currentLocale === "zh" ? "模型" : "Model"}
|
||||
value={selectedModel}
|
||||
onChange={setSelectedModel}
|
||||
disabled={loadingModels}
|
||||
disabled={loadingModels || modelOptions.length === 0}
|
||||
options={[
|
||||
{ value: "", label: loadingModels ? (currentLocale === "zh" ? "加载中..." : "Loading...") : (currentLocale === "zh" ? "默认" : "Default") },
|
||||
...modelOptions.map((m) => ({ value: m.id, label: m.label })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 rounded-lg border border-border bg-background/60 px-3 py-2.5 text-[11px] leading-relaxed text-muted-foreground">
|
||||
{t("onboarding.companyStepModelPoolHint")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -136,29 +131,6 @@ export function CompanyStep({
|
||||
{t("onboarding.workspaceHelp")}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-[12px] font-medium text-muted-foreground">{t("onboarding.automationMode")}</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setReviewDeliverables(false); setAutoApproveHires(true) }}
|
||||
className={`cursor-pointer rounded-lg border-2 px-4 py-3.5 text-left transition ${!reviewDeliverables ? "border-[color:var(--primary)] bg-accent" : "border-border bg-muted hover:border-[color:var(--muted-foreground)]"}`}
|
||||
>
|
||||
<Lightning size={20} weight={!reviewDeliverables ? "fill" : "regular"} className={!reviewDeliverables ? "text-primary" : "text-muted-foreground"} />
|
||||
<div className="mt-2 text-[13px] font-semibold text-foreground">{t("onboarding.modeFullAuto")}</div>
|
||||
<div className="mt-1 text-[11px] leading-relaxed text-muted-foreground">{t("onboarding.modeFullAutoDesc")}</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setReviewDeliverables(true); setAutoApproveHires(false) }}
|
||||
className={`cursor-pointer rounded-lg border-2 px-4 py-3.5 text-left transition ${reviewDeliverables ? "border-[color:var(--primary)] bg-accent" : "border-border bg-muted hover:border-[color:var(--muted-foreground)]"}`}
|
||||
>
|
||||
<UserCircleCheck size={20} weight={reviewDeliverables ? "fill" : "regular"} className={reviewDeliverables ? "text-primary" : "text-muted-foreground"} />
|
||||
<div className="mt-2 text-[13px] font-semibold text-foreground">{t("onboarding.modeHumanReview")}</div>
|
||||
<div className="mt-1 text-[11px] leading-relaxed text-muted-foreground">{t("onboarding.modeHumanReviewDesc")}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowRight, Brain, UserCircle, type Icon } from "@phosphor-icons/react";
|
||||
import type { ExecutionMode } from "@shared/types";
|
||||
|
||||
interface ExecutionModeStepProps {
|
||||
executionMode: ExecutionMode;
|
||||
setExecutionMode: (mode: ExecutionMode) => void;
|
||||
onContinue: () => void;
|
||||
onBack: () => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export function ExecutionModeStep({
|
||||
executionMode,
|
||||
setExecutionMode,
|
||||
onContinue,
|
||||
onBack,
|
||||
t,
|
||||
}: ExecutionModeStepProps) {
|
||||
return (
|
||||
<motion.div
|
||||
key="executionmode"
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -18 }}
|
||||
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="bg-card border border-border shadow-sm w-full rounded-lg p-7"
|
||||
>
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{t("onboarding.stepLabelMode")} 5 / 6
|
||||
</div>
|
||||
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-foreground">
|
||||
{t("onboarding.modeStepTitle")}
|
||||
</div>
|
||||
<div className="mb-6 h-px bg-[color:var(--border)]" />
|
||||
|
||||
<div className="space-y-3">
|
||||
<ModeCard
|
||||
selected={executionMode === "ai_ceo"}
|
||||
onSelect={() => setExecutionMode("ai_ceo")}
|
||||
icon={Brain}
|
||||
title={t("onboarding.modeAiCeoTitle")}
|
||||
subtitle={t("onboarding.modeAiCeoSubtitle")}
|
||||
description={t("onboarding.modeAiCeoDesc")}
|
||||
/>
|
||||
<ModeCard
|
||||
selected={executionMode === "ai_cofounder"}
|
||||
onSelect={() => setExecutionMode("ai_cofounder")}
|
||||
icon={UserCircle}
|
||||
title={t("onboarding.modeCofounderTitle")}
|
||||
subtitle={t("onboarding.modeCofounderSubtitle")}
|
||||
description={t("onboarding.modeCofounderDesc")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-[11px] text-muted-foreground">
|
||||
{t("onboarding.modeCanChange")}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-[12px] font-medium text-muted-foreground transition hover:text-foreground"
|
||||
>
|
||||
{t("onboarding.back")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
className="flex items-center gap-2 rounded-md bg-[color:var(--primary)] px-4 py-2 text-[13px] font-medium text-white transition hover:opacity-90"
|
||||
>
|
||||
{t("onboarding.continue")}
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeCard({
|
||||
selected,
|
||||
onSelect,
|
||||
icon: Icon,
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
}: {
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
icon: Icon;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={`w-full rounded-lg border px-4 py-4 text-left transition ${
|
||||
selected
|
||||
? "border-[color:var(--primary)] bg-[color:var(--primary)]/5 ring-1 ring-[color:var(--primary)]"
|
||||
: "border-border bg-background hover:border-muted-foreground/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`mt-0.5 rounded-md p-1.5 ${
|
||||
selected
|
||||
? "bg-[color:var(--primary)]/10 text-[color:var(--primary)]"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-[14px] font-semibold text-foreground">{title}</span>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.06em] ${
|
||||
selected
|
||||
? "bg-[color:var(--primary)]/10 text-[color:var(--primary)]"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{subtitle}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[12px] leading-relaxed text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<div
|
||||
className={`mt-1 h-4 w-4 shrink-0 rounded-full border-2 transition ${
|
||||
selected
|
||||
? "border-[color:var(--primary)] bg-[color:var(--primary)]"
|
||||
: "border-border bg-background"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export function MissionStep({
|
||||
className="bg-card border border-border shadow-sm w-full rounded-lg p-7"
|
||||
>
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{t("onboarding.stepLabel2")} 4 / 5
|
||||
{t("onboarding.stepLabelMission")} 6 / 6
|
||||
</div>
|
||||
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-foreground">
|
||||
{t("onboarding.step2Title")}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { motion } from "framer-motion";
|
||||
import type { ConnectorModelEntry } from "@shared/connector-models";
|
||||
import type { ConnectorId, ProfileSnapshot } from "@shared/types";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { ActionButton } from "../ui";
|
||||
import { ModelToggleList, StatusBadge } from "../execution/ModelManagementShared";
|
||||
|
||||
interface ModelPoolStepProps {
|
||||
currentLocale: AppLocale;
|
||||
connectorModelPools: Array<{
|
||||
connector: ProfileSnapshot["connectors"][number];
|
||||
models: ConnectorModelEntry[];
|
||||
}>;
|
||||
error: string | null;
|
||||
onBack: () => void;
|
||||
onContinue: () => void;
|
||||
onToggleConnectorModel: (connectorId: ConnectorId, modelId: string) => void;
|
||||
selectedConnectorId: ConnectorId | "";
|
||||
selectedConnectorEnabledModels: number;
|
||||
totalEnabledModels: number;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export function ModelPoolStep({
|
||||
currentLocale,
|
||||
connectorModelPools,
|
||||
error,
|
||||
onBack,
|
||||
onContinue,
|
||||
onToggleConnectorModel,
|
||||
selectedConnectorId,
|
||||
selectedConnectorEnabledModels,
|
||||
totalEnabledModels,
|
||||
t,
|
||||
}: ModelPoolStepProps) {
|
||||
const selectedConnectorIsRunnable = selectedConnectorEnabledModels > 0;
|
||||
const hasAnyEnabledModels = totalEnabledModels > 0;
|
||||
const canContinue = hasAnyEnabledModels && selectedConnectorIsRunnable;
|
||||
const readinessHint = !hasAnyEnabledModels
|
||||
? t("onboarding.modelPoolGlobalRequirement")
|
||||
: !selectedConnectorIsRunnable
|
||||
? t("onboarding.modelPoolSelectedConnectorRequirement")
|
||||
: t("onboarding.modelPoolContinueHint");
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key="modelpool"
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -18 }}
|
||||
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="w-full rounded-lg border border-border bg-card p-7 shadow-sm"
|
||||
>
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{t("onboarding.stepLabelModelPool")} 4 / 6
|
||||
</div>
|
||||
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-foreground">
|
||||
{t("onboarding.modelPoolStepTitle")}
|
||||
</div>
|
||||
<div className="mb-3 text-[13px] leading-relaxed text-muted-foreground">
|
||||
{t("onboarding.modelPoolStepSubtitle")}
|
||||
</div>
|
||||
<div className="mb-6 h-px bg-[color:var(--border)]" />
|
||||
|
||||
<div className="mb-4 rounded-lg border border-border bg-background/70 px-4 py-3 text-[12px] leading-relaxed text-muted-foreground">
|
||||
{t("onboarding.modelPoolStepDescription")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{connectorModelPools.map(({ connector, models }) => {
|
||||
const enabledCount = models.filter((model) => model.enabled).length;
|
||||
return (
|
||||
<div key={connector.id} className="rounded-lg border border-border bg-background/50 p-4">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-[13px] font-semibold text-foreground">
|
||||
{connector.id.startsWith("claude_compat") && connector.notes ? connector.notes : connector.label}
|
||||
</span>
|
||||
{connector.id === selectedConnectorId ? (
|
||||
<span className="rounded-full bg-accent px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-primary">
|
||||
{currentLocale === "zh" ? "CEO 当前运行时" : "CEO runtime"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{currentLocale === "zh"
|
||||
? `已启用 ${enabledCount} / ${models.length || 0} 个模型`
|
||||
: `${enabledCount} / ${models.length || 0} models enabled`}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={connector.status} zh={currentLocale === "zh"} />
|
||||
</div>
|
||||
<ModelToggleList
|
||||
models={models}
|
||||
onToggle={(modelId) => void onToggleConnectorModel(connector.id, modelId)}
|
||||
canRemove={false}
|
||||
zh={currentLocale === "zh"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3 text-[13px] text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-5 text-[12px] text-muted-foreground">{readinessHint}</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-[12px] font-medium text-muted-foreground transition hover:text-foreground"
|
||||
>
|
||||
{t("onboarding.back")}
|
||||
</button>
|
||||
<ActionButton
|
||||
label={t("onboarding.continue")}
|
||||
onClick={onContinue}
|
||||
tone="accent"
|
||||
disabled={!canContinue}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -5,22 +5,41 @@ import {
|
||||
HardDrive,
|
||||
Plugs,
|
||||
Rocket,
|
||||
SlidersHorizontal,
|
||||
Target,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { AsciiAnimation } from "./AsciiAnimation";
|
||||
|
||||
export type OnboardingStep = "welcome" | "nodecheck" | "providers" | "company" | "mission" | "launch";
|
||||
export type OnboardingStep =
|
||||
| "welcome"
|
||||
| "nodecheck"
|
||||
| "providers"
|
||||
| "company"
|
||||
| "modelpool"
|
||||
| "executionmode"
|
||||
| "mission"
|
||||
| "launch";
|
||||
|
||||
const STEP_LABELS_CONFIG = [
|
||||
{ key: "onboarding.stepLabelNodeCheck", icon: HardDrive, step: "nodecheck" as const },
|
||||
{ key: "onboarding.stepLabelProviders", icon: Plugs, step: "providers" as const },
|
||||
{ key: "onboarding.stepLabelCompany", icon: Buildings, step: "company" as const },
|
||||
{ key: "onboarding.stepLabelModelPool", icon: SlidersHorizontal, step: "modelpool" as const },
|
||||
{ key: "onboarding.stepLabelMode", icon: CheckCircle, step: "executionmode" as const },
|
||||
{ key: "onboarding.stepLabelMission", icon: Target, step: "mission" as const },
|
||||
{ key: "onboarding.stepLabelLaunch", icon: Rocket, step: "launch" as const },
|
||||
] as const;
|
||||
|
||||
const STEP_ORDER: OnboardingStep[] = ["nodecheck", "providers", "company", "mission", "launch"];
|
||||
const STEP_ORDER: OnboardingStep[] = [
|
||||
"nodecheck",
|
||||
"providers",
|
||||
"company",
|
||||
"modelpool",
|
||||
"executionmode",
|
||||
"mission",
|
||||
"launch",
|
||||
];
|
||||
|
||||
interface OnboardingHeroProps {
|
||||
step: OnboardingStep;
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import type { ConnectorId, OnboardingBootstrapResult, ProfileSnapshot } from "@shared/types";
|
||||
import type { ConnectorModelEntry } from "@shared/connector-models";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import type { TranslationFn } from "../../i18n";
|
||||
import { OnboardingHero, type OnboardingStep } from "./OnboardingHero";
|
||||
import { NodeCheckStep } from "./NodeCheckStep";
|
||||
import { ProvidersStep } from "./ProvidersStep";
|
||||
import { CompanyStep } from "./CompanyStep";
|
||||
import { ModelPoolStep } from "./ModelPoolStep";
|
||||
import { ExecutionModeStep } from "./ExecutionModeStep";
|
||||
import { MissionStep } from "./MissionStep";
|
||||
import { LaunchStep } from "./LaunchStep";
|
||||
|
||||
interface OnboardingStepPanelProps {
|
||||
step: OnboardingStep;
|
||||
currentLocale: AppLocale;
|
||||
t: TranslationFn;
|
||||
canCancel: boolean;
|
||||
onCancel?: () => void;
|
||||
isFirstLaunch: boolean;
|
||||
nodeStatus: { installed: boolean; version: string | null } | null;
|
||||
nodeChecking: boolean;
|
||||
onCheckNode: () => void;
|
||||
onInstallCommand: (title: string, command: string) => void;
|
||||
connectors: ProfileSnapshot["connectors"];
|
||||
hasAtLeastOneReady: boolean;
|
||||
onTestConnector: (connectorId: string) => void;
|
||||
onSaveConnector: (connectorId: string, envBindingText: string, model: string | null, notes?: string) => void;
|
||||
onLoginConnector: (connectorId: string) => void;
|
||||
error: string | null;
|
||||
companyName: string;
|
||||
setCompanyName: (value: string) => void;
|
||||
companyDescription: string;
|
||||
setCompanyDescription: (value: string) => void;
|
||||
selectedConnectorId: ConnectorId | "";
|
||||
setSelectedConnectorId: (value: ConnectorId | "") => void;
|
||||
selectedModel: string;
|
||||
setSelectedModel: (value: string) => void;
|
||||
executionReadyConnectors: ProfileSnapshot["connectors"];
|
||||
connectorModelPools: Array<{
|
||||
connector: ProfileSnapshot["connectors"][number];
|
||||
models: ConnectorModelEntry[];
|
||||
}>;
|
||||
modelOptions: Array<{ id: string; label: string }>;
|
||||
loadingModels: boolean;
|
||||
totalEnabledModels: number;
|
||||
selectedConnectorEnabledModels: number;
|
||||
onToggleConnectorModel: (connectorId: ConnectorId, modelId: string) => void;
|
||||
workspacePath: string;
|
||||
setWorkspacePath: (value: string) => void;
|
||||
onPickDirectory: () => void;
|
||||
executionMode: "ai_ceo" | "ai_cofounder";
|
||||
setExecutionMode: (value: "ai_ceo" | "ai_cofounder") => void;
|
||||
goalTitle: string;
|
||||
setGoalTitle: (value: string) => void;
|
||||
goalDescription: string;
|
||||
setGoalDescription: (value: string) => void;
|
||||
loading: boolean;
|
||||
launchSummary: OnboardingBootstrapResult | null;
|
||||
selectedConnector: ProfileSnapshot["connectors"][number] | null;
|
||||
onBack: (step: OnboardingStep) => void;
|
||||
onContinueFromNodeCheck: () => void;
|
||||
onContinueFromProviders: () => void;
|
||||
onContinueFromCompany: () => void;
|
||||
onContinueFromModelPool: () => void;
|
||||
onContinueFromExecutionMode: () => void;
|
||||
onLaunchCompany: () => void;
|
||||
onFinishLaunch: () => void;
|
||||
}
|
||||
|
||||
export function OnboardingStepPanel({
|
||||
step,
|
||||
currentLocale,
|
||||
t,
|
||||
canCancel,
|
||||
onCancel,
|
||||
isFirstLaunch,
|
||||
nodeStatus,
|
||||
nodeChecking,
|
||||
onCheckNode,
|
||||
onInstallCommand,
|
||||
connectors,
|
||||
hasAtLeastOneReady,
|
||||
onTestConnector,
|
||||
onSaveConnector,
|
||||
onLoginConnector,
|
||||
error,
|
||||
companyName,
|
||||
setCompanyName,
|
||||
companyDescription,
|
||||
setCompanyDescription,
|
||||
selectedConnectorId,
|
||||
setSelectedConnectorId,
|
||||
selectedModel,
|
||||
setSelectedModel,
|
||||
executionReadyConnectors,
|
||||
connectorModelPools,
|
||||
modelOptions,
|
||||
loadingModels,
|
||||
totalEnabledModels,
|
||||
selectedConnectorEnabledModels,
|
||||
onToggleConnectorModel,
|
||||
workspacePath,
|
||||
setWorkspacePath,
|
||||
onPickDirectory,
|
||||
executionMode,
|
||||
setExecutionMode,
|
||||
goalTitle,
|
||||
setGoalTitle,
|
||||
goalDescription,
|
||||
setGoalDescription,
|
||||
loading,
|
||||
launchSummary,
|
||||
selectedConnector,
|
||||
onBack,
|
||||
onContinueFromNodeCheck,
|
||||
onContinueFromProviders,
|
||||
onContinueFromCompany,
|
||||
onContinueFromModelPool,
|
||||
onContinueFromExecutionMode,
|
||||
onLaunchCompany,
|
||||
onFinishLaunch,
|
||||
}: OnboardingStepPanelProps) {
|
||||
return (
|
||||
<>
|
||||
<OnboardingHero
|
||||
step={step}
|
||||
currentLocale={currentLocale}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 overflow-y-auto border-l border-border bg-background">
|
||||
<div className="relative flex min-h-full items-center px-6 py-10 md:px-10">
|
||||
{canCancel && onCancel && !isFirstLaunch ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="absolute right-6 top-6 text-[12px] font-medium text-muted-foreground transition hover:text-foreground"
|
||||
>
|
||||
{t("onboarding.cancel")}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{step === "nodecheck" ? (
|
||||
<NodeCheckStep
|
||||
nodeStatus={nodeStatus}
|
||||
checking={nodeChecking}
|
||||
onCheck={onCheckNode}
|
||||
onInstall={(command) => onInstallCommand("Install Node.js", command)}
|
||||
onContinue={onContinueFromNodeCheck}
|
||||
onBack={() => onBack("welcome")}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === "providers" ? (
|
||||
<ProvidersStep
|
||||
connectors={connectors}
|
||||
onTestConnector={onTestConnector}
|
||||
onSaveConnector={onSaveConnector}
|
||||
onLoginConnector={onLoginConnector}
|
||||
onInstallCommand={onInstallCommand}
|
||||
hasAtLeastOneReady={hasAtLeastOneReady}
|
||||
onContinue={onContinueFromProviders}
|
||||
onBack={() => onBack("nodecheck")}
|
||||
error={error}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === "company" ? (
|
||||
<CompanyStep
|
||||
companyName={companyName}
|
||||
setCompanyName={setCompanyName}
|
||||
companyDescription={companyDescription}
|
||||
setCompanyDescription={setCompanyDescription}
|
||||
selectedConnectorId={selectedConnectorId}
|
||||
setSelectedConnectorId={setSelectedConnectorId}
|
||||
selectedModel={selectedModel}
|
||||
setSelectedModel={setSelectedModel}
|
||||
executionReadyConnectors={executionReadyConnectors}
|
||||
modelOptions={modelOptions}
|
||||
loadingModels={loadingModels}
|
||||
workspacePath={workspacePath}
|
||||
setWorkspacePath={setWorkspacePath}
|
||||
onPickDirectory={onPickDirectory}
|
||||
onContinue={onContinueFromCompany}
|
||||
onBack={() => onBack("providers")}
|
||||
error={error}
|
||||
currentLocale={currentLocale}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === "modelpool" ? (
|
||||
<ModelPoolStep
|
||||
currentLocale={currentLocale}
|
||||
connectorModelPools={connectorModelPools}
|
||||
error={error}
|
||||
onBack={() => onBack("company")}
|
||||
onContinue={onContinueFromModelPool}
|
||||
onToggleConnectorModel={onToggleConnectorModel}
|
||||
selectedConnectorId={selectedConnectorId}
|
||||
selectedConnectorEnabledModels={selectedConnectorEnabledModels}
|
||||
totalEnabledModels={totalEnabledModels}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === "executionmode" ? (
|
||||
<ExecutionModeStep
|
||||
executionMode={executionMode}
|
||||
setExecutionMode={setExecutionMode}
|
||||
onContinue={onContinueFromExecutionMode}
|
||||
onBack={() => onBack("modelpool")}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === "mission" ? (
|
||||
<MissionStep
|
||||
goalTitle={goalTitle}
|
||||
setGoalTitle={setGoalTitle}
|
||||
goalDescription={goalDescription}
|
||||
setGoalDescription={setGoalDescription}
|
||||
onLaunch={onLaunchCompany}
|
||||
onBack={() => onBack("executionmode")}
|
||||
loading={loading}
|
||||
error={error}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === "launch" && launchSummary ? (
|
||||
<LaunchStep
|
||||
launchSummary={launchSummary}
|
||||
selectedConnector={selectedConnector}
|
||||
onFinish={onFinishLaunch}
|
||||
loading={loading}
|
||||
error={error}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { isConnectorExecutionReady } from "@shared/connector-policy";
|
||||
import type { ConnectorId, OnboardingBootstrapResult, ProfileSnapshot } from "@shared/types";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { ConnectorId, ExecutionMode, OnboardingBootstrapResult, ProfileSnapshot } from "@shared/types";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { useT } from "../../i18n";
|
||||
import { unwrap } from "../../lib/desktop";
|
||||
import { OnboardingHero, type OnboardingStep } from "./OnboardingHero";
|
||||
import type { OnboardingStep } from "./OnboardingHero";
|
||||
import { WelcomeStep } from "./WelcomeStep";
|
||||
import { NodeCheckStep } from "./NodeCheckStep";
|
||||
import { ProvidersStep } from "./ProvidersStep";
|
||||
import { CompanyStep } from "./CompanyStep";
|
||||
import { MissionStep } from "./MissionStep";
|
||||
import { LaunchStep } from "./LaunchStep";
|
||||
import { OnboardingStepPanel } from "./OnboardingStepPanel";
|
||||
import { useConnectorCallbacks } from "./useConnectorCallbacks";
|
||||
import { useConnectorModelSelection } from "./useConnectorModelSelection";
|
||||
|
||||
export function CompanyOnboarding({
|
||||
connectors,
|
||||
@@ -32,16 +28,7 @@ export function CompanyOnboarding({
|
||||
}) {
|
||||
const t = useT();
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Step navigation */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const [step, setStep] = useState<OnboardingStep>("welcome");
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Node.js check state */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const [nodeStatus, setNodeStatus] = useState<{ installed: boolean; version: string | null } | null>(null);
|
||||
const [nodeChecking, setNodeChecking] = useState(false);
|
||||
|
||||
@@ -64,155 +51,32 @@ export function CompanyOnboarding({
|
||||
}
|
||||
}, [step, nodeStatus, checkNode]);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Provider / connector state */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const executionReadyConnectors = useMemo(
|
||||
() => connectors.filter((c) => isConnectorExecutionReady(c)),
|
||||
[connectors],
|
||||
);
|
||||
const hasAtLeastOneReady = executionReadyConnectors.length > 0;
|
||||
|
||||
const [selectedConnectorId, setSelectedConnectorId] = useState<string>(
|
||||
executionReadyConnectors[0]?.id ?? "",
|
||||
);
|
||||
|
||||
// Keep selected connector in sync with readiness changes
|
||||
useEffect(() => {
|
||||
if (!selectedConnectorId && executionReadyConnectors[0]) {
|
||||
setSelectedConnectorId(executionReadyConnectors[0].id);
|
||||
return;
|
||||
}
|
||||
if (selectedConnectorId && !executionReadyConnectors.some((c) => c.id === selectedConnectorId)) {
|
||||
setSelectedConnectorId(executionReadyConnectors[0]?.id ?? "");
|
||||
}
|
||||
}, [executionReadyConnectors, selectedConnectorId]);
|
||||
|
||||
const selectedConnector =
|
||||
executionReadyConnectors.find((c) => c.id === selectedConnectorId) ??
|
||||
executionReadyConnectors[0] ?? null;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* CEO model selection */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [modelOptions, setModelOptions] = useState<Array<{ id: string; label: string }>>([]);
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
|
||||
// Load models when the selected connector changes
|
||||
useEffect(() => {
|
||||
if (!selectedConnectorId) { setModelOptions([]); return; }
|
||||
|
||||
// For claude_compatible, parse user-configured models (comma-separated in connector.model)
|
||||
if (selectedConnectorId.startsWith("claude_compat")) {
|
||||
const compat = connectors.find((c) => c.id === selectedConnectorId);
|
||||
const raw = compat?.model ?? "";
|
||||
const models = raw.split(",").map((m) => m.trim()).filter(Boolean).map((m) => ({ id: m, label: m }));
|
||||
setModelOptions(models);
|
||||
if (models.length > 0 && !models.some((m) => m.id === selectedModel)) {
|
||||
setSelectedModel(models[0].id);
|
||||
}
|
||||
setLoadingModels(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoadingModels(true);
|
||||
window.agentCompany.listConnectorModels(selectedConnectorId)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result.ok) setModelOptions(result.data);
|
||||
else setModelOptions([]);
|
||||
})
|
||||
.catch(() => { if (!cancelled) setModelOptions([]); })
|
||||
.finally(() => { if (!cancelled) setLoadingModels(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedConnectorId, connectors]);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Provider callbacks (for ProvidersStep) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const handleTestConnector = useCallback(async (connectorId: string) => {
|
||||
try {
|
||||
unwrap(await window.agentCompany.testConnector(connectorId as ConnectorId));
|
||||
} catch {
|
||||
// Health check result is stored in DB and reflected via domain-changed
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSaveConnector = useCallback(async (connectorId: string, envBindingText: string, model: string | null, notes?: string) => {
|
||||
const connector = connectors.find((c) => c.id === connectorId);
|
||||
if (!connector) return;
|
||||
try {
|
||||
unwrap(await window.agentCompany.saveConnector({
|
||||
id: connectorId as ConnectorId,
|
||||
command: connector.command,
|
||||
model,
|
||||
envBindingText,
|
||||
notes: notes ?? connector.notes,
|
||||
}));
|
||||
// Run health check AFTER save completes so the probe uses the new config
|
||||
unwrap(await window.agentCompany.testConnector(connectorId as ConnectorId));
|
||||
} catch {
|
||||
// Error is non-fatal; result reflected via domain-changed
|
||||
}
|
||||
}, [connectors]);
|
||||
|
||||
const handleLoginConnector = useCallback(async (connectorId: string) => {
|
||||
try {
|
||||
unwrap(await window.agentCompany.openConnectorAuthTerminal(connectorId as ConnectorId));
|
||||
} catch {
|
||||
// Terminal open failure is non-fatal
|
||||
return;
|
||||
}
|
||||
// Poll connector health after login terminal opens (same pattern as ConnectorEditor)
|
||||
let attempts = 0;
|
||||
const poll = setInterval(async () => {
|
||||
attempts++;
|
||||
if (attempts > 12) { clearInterval(poll); return; }
|
||||
try {
|
||||
unwrap(await window.agentCompany.testConnector(connectorId as ConnectorId));
|
||||
} catch { /* health check result reflected via domain-changed */ }
|
||||
}, 5000);
|
||||
}, []);
|
||||
|
||||
const handleInstallCommand = useCallback(async (title: string, command: string) => {
|
||||
try {
|
||||
unwrap(await window.agentCompany.openTerminalCommand({ title, command }));
|
||||
} catch {
|
||||
// Terminal open failure is non-fatal
|
||||
return;
|
||||
}
|
||||
// Poll all connectors after install (install may make a new CLI available)
|
||||
let attempts = 0;
|
||||
const poll = setInterval(async () => {
|
||||
attempts++;
|
||||
if (attempts > 12) { clearInterval(poll); return; }
|
||||
for (const c of connectors) {
|
||||
if (c.status !== "ready") {
|
||||
try { unwrap(await window.agentCompany.testConnector(c.id as ConnectorId)); } catch { /* reflected via domain-changed */ }
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
}, [connectors]);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Company / mission state */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const { handleTestConnector, handleSaveConnector, handleLoginConnector, handleInstallCommand } =
|
||||
useConnectorCallbacks(connectors);
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [companyDescription, setCompanyDescription] = useState("");
|
||||
const [workspacePath, setWorkspacePath] = useState("");
|
||||
const [autoApproveHires, setAutoApproveHires] = useState(true);
|
||||
const [reviewDeliverables, setReviewDeliverables] = useState(false);
|
||||
const [executionMode, setExecutionMode] = useState<ExecutionMode>("ai_ceo");
|
||||
const [goalTitle, setGoalTitle] = useState("");
|
||||
const [goalDescription, setGoalDescription] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [launchSummary, setLaunchSummary] = useState<OnboardingBootstrapResult | null>(null);
|
||||
const {
|
||||
connectorModelPools,
|
||||
executionReadyConnectors,
|
||||
hasAtLeastOneReady,
|
||||
loadingModels,
|
||||
modelOptions,
|
||||
selectedConnector,
|
||||
selectedConnectorId,
|
||||
selectedModel,
|
||||
setSelectedConnectorId,
|
||||
setSelectedModel,
|
||||
selectedConnectorEnabledModels,
|
||||
totalEnabledModels,
|
||||
toggleConnectorModel,
|
||||
} = useConnectorModelSelection(connectors, currentLocale, t, setError);
|
||||
|
||||
const pickDirectory = useCallback(async () => {
|
||||
setError(null);
|
||||
@@ -231,18 +95,18 @@ export function CompanyOnboarding({
|
||||
companyName: companyName.trim(),
|
||||
companyDescription: companyDescription.trim(),
|
||||
workspacePath: workspacePath.trim(),
|
||||
autoApproveHires,
|
||||
reviewDeliverables,
|
||||
autoApproveHires: executionMode === "ai_ceo",
|
||||
reviewDeliverables: executionMode === "ai_cofounder",
|
||||
executionMode,
|
||||
goalTitle: goalTitle.trim(),
|
||||
goalDescription: goalDescription.trim(),
|
||||
connectorId: selectedConnector?.id,
|
||||
connectorId: selectedConnector?.id as ConnectorId | undefined,
|
||||
connectorModel: selectedModel.trim(),
|
||||
}));
|
||||
setLaunchSummary(result);
|
||||
setStep("launch");
|
||||
}, [
|
||||
autoApproveHires,
|
||||
reviewDeliverables,
|
||||
executionMode,
|
||||
companyDescription,
|
||||
companyName,
|
||||
goalDescription,
|
||||
@@ -259,7 +123,7 @@ export function CompanyOnboarding({
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setStep("mission");
|
||||
setStep("modelpool");
|
||||
}, [companyName, loading, t]);
|
||||
|
||||
const launchCompany = useCallback(async () => {
|
||||
@@ -292,10 +156,6 @@ export function CompanyOnboarding({
|
||||
}
|
||||
}, [launchSummary, onDone, t]);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Render */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const isWelcome = step === "welcome";
|
||||
const heroStep: OnboardingStep = step as OnboardingStep;
|
||||
|
||||
@@ -319,107 +179,59 @@ export function CompanyOnboarding({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<OnboardingHero
|
||||
<OnboardingStepPanel
|
||||
step={heroStep}
|
||||
currentLocale={currentLocale}
|
||||
t={t}
|
||||
canCancel={canCancel}
|
||||
onCancel={onCancel}
|
||||
isFirstLaunch={isFirstLaunch}
|
||||
nodeStatus={nodeStatus}
|
||||
nodeChecking={nodeChecking}
|
||||
onCheckNode={() => void checkNode()}
|
||||
onInstallCommand={(title, command) => void handleInstallCommand(title, command)}
|
||||
connectors={connectors}
|
||||
hasAtLeastOneReady={hasAtLeastOneReady}
|
||||
onTestConnector={(id) => void handleTestConnector(id)}
|
||||
onSaveConnector={(id, env, model, notes) => void handleSaveConnector(id, env, model, notes)}
|
||||
onLoginConnector={(id) => void handleLoginConnector(id)}
|
||||
error={error}
|
||||
companyName={companyName}
|
||||
setCompanyName={setCompanyName}
|
||||
companyDescription={companyDescription}
|
||||
setCompanyDescription={setCompanyDescription}
|
||||
selectedConnectorId={selectedConnectorId}
|
||||
setSelectedConnectorId={setSelectedConnectorId}
|
||||
selectedModel={selectedModel}
|
||||
setSelectedModel={setSelectedModel}
|
||||
executionReadyConnectors={executionReadyConnectors}
|
||||
connectorModelPools={connectorModelPools}
|
||||
modelOptions={modelOptions}
|
||||
loadingModels={loadingModels}
|
||||
totalEnabledModels={totalEnabledModels}
|
||||
selectedConnectorEnabledModels={selectedConnectorEnabledModels}
|
||||
onToggleConnectorModel={(connectorId, modelId) => void toggleConnectorModel(connectorId, modelId)}
|
||||
workspacePath={workspacePath}
|
||||
setWorkspacePath={setWorkspacePath}
|
||||
onPickDirectory={() => void pickDirectory()}
|
||||
executionMode={executionMode}
|
||||
setExecutionMode={setExecutionMode}
|
||||
goalTitle={goalTitle}
|
||||
setGoalTitle={setGoalTitle}
|
||||
goalDescription={goalDescription}
|
||||
setGoalDescription={setGoalDescription}
|
||||
loading={loading}
|
||||
launchSummary={launchSummary}
|
||||
selectedConnector={selectedConnector}
|
||||
onBack={setStep}
|
||||
onContinueFromNodeCheck={() => { setError(null); setStep("providers"); }}
|
||||
onContinueFromProviders={() => { setError(null); setStep("company"); }}
|
||||
onContinueFromCompany={() => void continueToMission()}
|
||||
onContinueFromModelPool={() => { setError(null); setStep("executionmode"); }}
|
||||
onContinueFromExecutionMode={() => { setError(null); setStep("mission"); }}
|
||||
onLaunchCompany={() => void launchCompany()}
|
||||
onFinishLaunch={() => void finishLaunch()}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 overflow-y-auto border-l border-border bg-background">
|
||||
<div className="relative flex min-h-full items-center px-6 py-10 md:px-10">
|
||||
{canCancel && onCancel && !isFirstLaunch ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="absolute right-6 top-6 text-[12px] font-medium text-muted-foreground transition hover:text-foreground"
|
||||
>
|
||||
{t("onboarding.cancel")}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{step === "nodecheck" ? (
|
||||
<NodeCheckStep
|
||||
nodeStatus={nodeStatus}
|
||||
checking={nodeChecking}
|
||||
onCheck={() => void checkNode()}
|
||||
onInstall={(command) => void handleInstallCommand("Install Node.js", command)}
|
||||
onContinue={() => { setError(null); setStep("providers"); }}
|
||||
onBack={() => setStep("welcome")}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === "providers" ? (
|
||||
<ProvidersStep
|
||||
connectors={connectors}
|
||||
onTestConnector={(id) => void handleTestConnector(id)}
|
||||
onSaveConnector={(id, env, model, notes) => void handleSaveConnector(id, env, model, notes)}
|
||||
onLoginConnector={(id) => void handleLoginConnector(id)}
|
||||
onInstallCommand={(title, cmd) => void handleInstallCommand(title, cmd)}
|
||||
hasAtLeastOneReady={hasAtLeastOneReady}
|
||||
onContinue={() => { setError(null); setStep("company"); }}
|
||||
onBack={() => setStep("nodecheck")}
|
||||
error={error}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === "company" ? (
|
||||
<CompanyStep
|
||||
companyName={companyName}
|
||||
setCompanyName={setCompanyName}
|
||||
companyDescription={companyDescription}
|
||||
setCompanyDescription={setCompanyDescription}
|
||||
selectedConnectorId={selectedConnectorId}
|
||||
setSelectedConnectorId={setSelectedConnectorId}
|
||||
selectedModel={selectedModel}
|
||||
setSelectedModel={setSelectedModel}
|
||||
executionReadyConnectors={executionReadyConnectors}
|
||||
modelOptions={modelOptions}
|
||||
loadingModels={loadingModels}
|
||||
workspacePath={workspacePath}
|
||||
setWorkspacePath={setWorkspacePath}
|
||||
autoApproveHires={autoApproveHires}
|
||||
setAutoApproveHires={setAutoApproveHires}
|
||||
reviewDeliverables={reviewDeliverables}
|
||||
setReviewDeliverables={setReviewDeliverables}
|
||||
onPickDirectory={() => void pickDirectory()}
|
||||
onContinue={() => void continueToMission()}
|
||||
onBack={() => setStep("providers")}
|
||||
error={error}
|
||||
currentLocale={currentLocale}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === "mission" ? (
|
||||
<MissionStep
|
||||
goalTitle={goalTitle}
|
||||
setGoalTitle={setGoalTitle}
|
||||
goalDescription={goalDescription}
|
||||
setGoalDescription={setGoalDescription}
|
||||
onLaunch={() => void launchCompany()}
|
||||
onBack={() => setStep("company")}
|
||||
loading={loading}
|
||||
error={error}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === "launch" && launchSummary ? (
|
||||
<LaunchStep
|
||||
launchSummary={launchSummary}
|
||||
selectedConnector={selectedConnector}
|
||||
onFinish={() => void finishLaunch()}
|
||||
loading={loading}
|
||||
error={error}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,39 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
ArrowRight,
|
||||
CaretDown,
|
||||
CaretRight,
|
||||
ArrowSquareOut,
|
||||
FloppyDisk,
|
||||
SignIn,
|
||||
Terminal,
|
||||
} from "@phosphor-icons/react";
|
||||
import { ArrowRight, FloppyDisk, SignIn } from "@phosphor-icons/react";
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
import { ActionButton } from "../ui";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Compatible-API provider presets */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const COMPATIBLE_PROVIDERS = [
|
||||
{ id: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
|
||||
{ id: "minimax_cn", label: "MiniMax (CN)", baseUrl: "https://api.minimaxi.com/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
|
||||
{ id: "kimi", label: "Kimi", baseUrl: "https://api.kimi.com/coding/", docUrl: "https://www.kimi.com/code/docs/en/more/third-party-agents.html" },
|
||||
{ id: "kimi_cn", label: "Kimi CN", baseUrl: "https://api.moonshot.cn/anthropic", docUrl: "https://platform.moonshot.cn/docs/guide/agent-support#macos-%E5%92%8C-linux-1" },
|
||||
{ id: "glm", label: "GLM (ZhiPu)", baseUrl: "https://api.z.ai/api/anthropic", docUrl: "https://docs.z.ai/devpack/tool/claude" },
|
||||
{ id: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api", docUrl: "https://openrouter.ai/docs/guides/coding-agents/claude-code-integration" },
|
||||
{ id: "tencent", label: "Tencent Coding Plan", baseUrl: "https://api.lkeap.cloud.tencent.com/coding/anthropic", docUrl: "https://cloud.tencent.com/document/product/1772/128951" },
|
||||
{ id: "poe", label: "POE", baseUrl: "https://api.poe.com", docUrl: "https://creator.poe.com/docs/external-applications/claude-code" },
|
||||
{ id: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/anthropic", docUrl: "https://api-docs.deepseek.com/guides/anthropic_api" },
|
||||
{ id: "alibaba", label: "Alibaba Coding Plan", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan" },
|
||||
{ id: "alibaba_cn", label: "Alibaba Coding Plan (CN)", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://help.aliyun.com/zh/model-studio/claude-code-coding-plan" },
|
||||
{ id: "bailing", label: "BaiLing", baseUrl: "https://api.tbox.cn/api/anthropic", docUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/best_practices_claudecode" },
|
||||
{ id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", docUrl: "https://docs.nano-gpt.com/integrations/claude-code#manual-configuration-all-platforms" },
|
||||
{ id: "zhipu_cn", label: "ZhiPu (CN)", baseUrl: "https://open.bigmodel.cn/api/anthropic", docUrl: "https://docs.bigmodel.cn/cn/coding-plan/tool/claude" },
|
||||
{ id: "ollama", label: "Ollama (Local)", baseUrl: "http://localhost:11434", docUrl: "https://docs.ollama.com/integrations/claude-code" },
|
||||
{ id: "custom", label: "Custom", baseUrl: "", docUrl: "" },
|
||||
] as const;
|
||||
import {
|
||||
CompatProviderCard,
|
||||
InstallBlock,
|
||||
ProviderCard,
|
||||
StatusBadge,
|
||||
INPUT_CLS,
|
||||
} from "./ProvidersStepShared";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Props */
|
||||
@@ -52,315 +28,7 @@ interface ProvidersStepProps {
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Status badge */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type ConnectorStatus = ProfileSnapshot["connectors"][number]["status"];
|
||||
|
||||
function StatusBadge({ status, t }: { status: ConnectorStatus; t: (key: string) => string }) {
|
||||
if (status === "ready") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-success-muted px-2 py-0.5 text-[11px] font-medium text-success">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-success" />
|
||||
{t("onboarding.providerReady")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "not_installed") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground" />
|
||||
{t("onboarding.providerNotInstalled")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "auth_required") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-warning-muted px-2 py-0.5 text-[11px] font-medium text-warning">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
|
||||
{t("onboarding.providerAuthRequired")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "detected" || status === "checking" as string) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-muted-foreground border-t-primary" />
|
||||
{t("onboarding.providerChecking")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-caution-muted px-2 py-0.5 text-[11px] font-medium text-caution">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-caution" />
|
||||
{status.replaceAll("_", " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared input class */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const INPUT_CLS =
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Provider card wrapper */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function ProviderCard({
|
||||
title,
|
||||
badge,
|
||||
expanded,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
badge: React.ReactNode;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex w-full cursor-pointer items-center justify-between px-4 py-3 transition hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{expanded ? <CaretDown size={14} className="text-muted-foreground" /> : <CaretRight size={14} className="text-muted-foreground" />}
|
||||
<span className="text-[13px] font-semibold text-foreground">{title}</span>
|
||||
</div>
|
||||
{badge}
|
||||
</button>
|
||||
{expanded && <div className="space-y-3 px-4 pb-4">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Install snippet */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function InstallBlock({
|
||||
macLinuxCmd,
|
||||
windowsCmd,
|
||||
onInstall,
|
||||
title,
|
||||
t,
|
||||
}: {
|
||||
macLinuxCmd: string;
|
||||
windowsCmd: string | null;
|
||||
onInstall: (title: string, command: string) => void;
|
||||
title: string;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const platform = window.agentCompany?.platform ?? "linux";
|
||||
const isWindows = platform === "win32";
|
||||
const cmd = isWindows && windowsCmd ? windowsCmd : macLinuxCmd;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[12px] font-medium text-muted-foreground">{t("onboarding.providerInstall")}</div>
|
||||
{!isWindows && (
|
||||
<div className="rounded-lg border border-border bg-muted px-3 py-2 font-mono text-[12px] text-foreground">
|
||||
{macLinuxCmd}
|
||||
</div>
|
||||
)}
|
||||
{isWindows && windowsCmd && (
|
||||
<div className="rounded-lg border border-border bg-muted px-3 py-2 font-mono text-[12px] text-foreground">
|
||||
{windowsCmd}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onInstall(title, cmd)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
|
||||
>
|
||||
<Terminal size={14} />
|
||||
{t("onboarding.providerInstall")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Compatible provider card (one per slot) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function CompatProviderCard({
|
||||
slotId,
|
||||
connector,
|
||||
claudeInstalled,
|
||||
onSaveConnector,
|
||||
onInstallCommand,
|
||||
expanded,
|
||||
onToggle,
|
||||
t,
|
||||
}: {
|
||||
slotId: string;
|
||||
connector: ProfileSnapshot["connectors"][number] | null;
|
||||
claudeInstalled: boolean;
|
||||
onSaveConnector: ProvidersStepProps["onSaveConnector"];
|
||||
onInstallCommand: ProvidersStepProps["onInstallCommand"];
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
function parseEnv(text: string, key: string): string {
|
||||
const line = text.split(/\r?\n/).find((l) => l.startsWith(`${key}=`));
|
||||
return line ? line.slice(key.length + 1) : "";
|
||||
}
|
||||
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>(() => {
|
||||
const saved = connector?.notes ?? "";
|
||||
const match = COMPATIBLE_PROVIDERS.find((p) => p.label === saved);
|
||||
return match?.id ?? COMPATIBLE_PROVIDERS[0].id;
|
||||
});
|
||||
const [apiKey, setApiKey] = useState(() => parseEnv(connector?.envBindingText ?? "", "ANTHROPIC_API_KEY"));
|
||||
const [baseUrl, setBaseUrl] = useState<string>(() =>
|
||||
parseEnv(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL") || COMPATIBLE_PROVIDERS[0].baseUrl,
|
||||
);
|
||||
const [models, setModels] = useState<string[]>(() => {
|
||||
const raw = connector?.model ?? "";
|
||||
return raw.split(",").map((m) => m.trim()).filter(Boolean);
|
||||
});
|
||||
const [newModel, setNewModel] = useState("");
|
||||
|
||||
const status = connector?.status ?? "not_installed";
|
||||
const providerLabel = COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ?? t("onboarding.claudeCompatibleLabel");
|
||||
const displayTitle = connector?.notes || providerLabel;
|
||||
|
||||
function handleProviderChange(providerId: string) {
|
||||
setSelectedProvider(providerId);
|
||||
const provider = COMPATIBLE_PROVIDERS.find((p) => p.id === providerId);
|
||||
if (provider) setBaseUrl(provider.baseUrl);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const lines: string[] = [];
|
||||
if (apiKey.trim()) lines.push(`ANTHROPIC_API_KEY=${apiKey.trim()}`);
|
||||
if (baseUrl.trim()) lines.push(`ANTHROPIC_BASE_URL=${baseUrl.trim()}`);
|
||||
const label = COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ?? "";
|
||||
const allModels = [...models];
|
||||
const pending = newModel.trim();
|
||||
if (pending && !allModels.includes(pending)) {
|
||||
allModels.push(pending);
|
||||
setModels(allModels);
|
||||
setNewModel("");
|
||||
}
|
||||
const modelsCsv = allModels.join(",") || null;
|
||||
onSaveConnector(slotId, lines.join("\n"), modelsCsv, label);
|
||||
}
|
||||
|
||||
function addModel() {
|
||||
const trimmed = newModel.trim();
|
||||
if (!trimmed) return;
|
||||
setModels((prev) => {
|
||||
if (prev.includes(trimmed)) return prev;
|
||||
return [...prev, trimmed];
|
||||
});
|
||||
setNewModel("");
|
||||
}
|
||||
|
||||
const selectedProviderData = COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider);
|
||||
|
||||
return (
|
||||
<ProviderCard
|
||||
title={displayTitle}
|
||||
badge={<StatusBadge status={status} t={t} />}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
{!claudeInstalled && (
|
||||
<InstallBlock
|
||||
macLinuxCmd="curl -fsSL https://claude.ai/install.sh | bash"
|
||||
windowsCmd="irm https://claude.ai/install.ps1 | iex"
|
||||
onInstall={onInstallCommand}
|
||||
title="Install Claude Code"
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{t("onboarding.providerSelectProvider")}
|
||||
</label>
|
||||
<select
|
||||
className={INPUT_CLS}
|
||||
value={selectedProvider}
|
||||
onChange={(e) => handleProviderChange(e.target.value)}
|
||||
>
|
||||
{COMPATIBLE_PROVIDERS.map((p) => (
|
||||
<option key={p.id} value={p.id} className="bg-card text-foreground">{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedProviderData?.docUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.open(selectedProviderData.docUrl, "_blank")}
|
||||
className="inline-flex items-center gap-1 text-[12px] text-primary transition hover:underline"
|
||||
>
|
||||
<ArrowSquareOut size={12} />
|
||||
{t("onboarding.providerViewDocs")}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("onboarding.providerApiKey")}</label>
|
||||
<input type="password" className={INPUT_CLS} value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-..." />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("onboarding.providerBaseUrl")}</label>
|
||||
<input className={INPUT_CLS} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.example.com/v1" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("onboarding.providerModel")}</label>
|
||||
{models.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{models.map((m) => (
|
||||
<span key={m} className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-[12px] text-foreground">
|
||||
{m}
|
||||
<button type="button" onClick={() => setModels(models.filter((x) => x !== m))} className="ml-0.5 text-muted-foreground hover:text-destructive">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className={INPUT_CLS}
|
||||
value={newModel}
|
||||
onChange={(e) => setNewModel(e.target.value)}
|
||||
placeholder="model-name"
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addModel(); } }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addModel}
|
||||
disabled={!newModel.trim()}
|
||||
className="shrink-0 rounded-lg border border-border bg-card px-3 py-2 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
|
||||
>
|
||||
<FloppyDisk size={14} />
|
||||
{t("onboarding.providerSaveCheck")}
|
||||
</button>
|
||||
</ProviderCard>
|
||||
);
|
||||
}
|
||||
const COMPAT_SLOTS = ["claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5", "claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10"] as const;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main component */
|
||||
@@ -378,41 +46,30 @@ export function ProvidersStep({
|
||||
error,
|
||||
t,
|
||||
}: ProvidersStepProps) {
|
||||
// Expansion state
|
||||
const [expandedCard, setExpandedCard] = useState<string | null>("claude");
|
||||
|
||||
// Connector lookups
|
||||
const claudeConnector = connectors.find((c) => c.id === "claude_local");
|
||||
const codexConnector = connectors.find((c) => c.id === "codex_local");
|
||||
const geminiConnector = connectors.find((c) => c.id === "gemini_local");
|
||||
const compatConnector = connectors.find((c) => c.id === "claude_compatible");
|
||||
|
||||
// Parse env bindings from connector data for form initialization
|
||||
function parseEnvValue(envText: string, key: string): string {
|
||||
const line = envText.split(/\r?\n/).find((l) => l.startsWith(`${key}=`));
|
||||
return line ? line.slice(key.length + 1) : "";
|
||||
}
|
||||
|
||||
// Claude Code (Official) form — init from saved connector data
|
||||
const [claudeApiKey, setClaudeApiKey] = useState(() =>
|
||||
parseEnvValue(claudeConnector?.envBindingText ?? "", "ANTHROPIC_API_KEY"),
|
||||
);
|
||||
|
||||
// Codex form
|
||||
const [codexApiKey, setCodexApiKey] = useState(() =>
|
||||
parseEnvValue(codexConnector?.envBindingText ?? "", "OPENAI_API_KEY"),
|
||||
);
|
||||
|
||||
// Compatible API slots
|
||||
const COMPAT_SLOTS = ["claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5", "claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10"] as const;
|
||||
const compatSlots = COMPAT_SLOTS.map((id) => connectors.find((c) => c.id === id));
|
||||
// A slot is "active" if it has been configured (has ANTHROPIC_BASE_URL)
|
||||
const activeCompatSlots = COMPAT_SLOTS.filter((id) => {
|
||||
const c = connectors.find((conn) => conn.id === id);
|
||||
return c && c.envBindingText.includes("ANTHROPIC_BASE_URL");
|
||||
});
|
||||
const nextAvailableSlot = COMPAT_SLOTS.find((id) => !activeCompatSlots.includes(id)) ?? null;
|
||||
// Always show active slots + the first empty slot for adding
|
||||
const visibleCompatSlots = [...activeCompatSlots];
|
||||
if (nextAvailableSlot && !visibleCompatSlots.includes(nextAvailableSlot)) {
|
||||
visibleCompatSlots.push(nextAvailableSlot);
|
||||
@@ -424,8 +81,6 @@ export function ProvidersStep({
|
||||
|
||||
const toggle = (card: string) => setExpandedCard((prev) => (prev === card ? null : card));
|
||||
|
||||
/* ------ save helpers ------ */
|
||||
|
||||
function handleClaudeSave() {
|
||||
const lines: string[] = [];
|
||||
if (claudeApiKey.trim()) lines.push(`ANTHROPIC_API_KEY=${claudeApiKey.trim()}`);
|
||||
@@ -438,10 +93,6 @@ export function ProvidersStep({
|
||||
onSaveConnector("codex_local", lines.join("\n"), null);
|
||||
}
|
||||
|
||||
function openExternalDoc(url: string) {
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key="providers"
|
||||
@@ -451,9 +102,8 @@ export function ProvidersStep({
|
||||
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="w-full rounded-lg border border-border bg-card p-7 shadow-sm"
|
||||
>
|
||||
{/* Step label */}
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{t("onboarding.stepLabel1")} 2 / 5 — {t("onboarding.providersStep")}
|
||||
{t("onboarding.stepLabelProviders")} 2 / 6
|
||||
</div>
|
||||
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-foreground">
|
||||
{t("onboarding.providersTitle")}
|
||||
@@ -463,9 +113,7 @@ export function ProvidersStep({
|
||||
</div>
|
||||
<div className="mb-6 h-px bg-[color:var(--border)]" />
|
||||
|
||||
{/* Provider cards */}
|
||||
<div className="space-y-3">
|
||||
|
||||
{/* ---- Card 1: Claude Code (Official) ---- */}
|
||||
<ProviderCard
|
||||
title={t("onboarding.claudeCodeLabel")}
|
||||
@@ -482,7 +130,6 @@ export function ProvidersStep({
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLoginConnector("claude_local")}
|
||||
@@ -491,7 +138,6 @@ export function ProvidersStep({
|
||||
<SignIn size={14} />
|
||||
{t("onboarding.providerLogin")}
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{t("onboarding.providerApiKey")} <span className="font-normal">(optional)</span>
|
||||
@@ -504,7 +150,6 @@ export function ProvidersStep({
|
||||
placeholder="sk-ant-..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClaudeSave}
|
||||
@@ -531,7 +176,6 @@ export function ProvidersStep({
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLoginConnector("codex_local")}
|
||||
@@ -540,7 +184,6 @@ export function ProvidersStep({
|
||||
<SignIn size={14} />
|
||||
{t("onboarding.providerLogin")}
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{t("onboarding.providerApiKey")} (OPENAI_API_KEY)
|
||||
@@ -553,7 +196,6 @@ export function ProvidersStep({
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCodexSave}
|
||||
@@ -580,7 +222,6 @@ export function ProvidersStep({
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLoginConnector("gemini_local")}
|
||||
@@ -589,7 +230,6 @@ export function ProvidersStep({
|
||||
<SignIn size={14} />
|
||||
{t("onboarding.providerLogin")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTestConnector("gemini_local")}
|
||||
@@ -600,7 +240,7 @@ export function ProvidersStep({
|
||||
</button>
|
||||
</ProviderCard>
|
||||
|
||||
{/* ---- Compatible API providers (up to 3 slots) ---- */}
|
||||
{/* ---- Compatible API providers ---- */}
|
||||
{visibleCompatSlots.map((slotId) => (
|
||||
<CompatProviderCard
|
||||
key={slotId}
|
||||
@@ -616,14 +256,12 @@ export function ProvidersStep({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error ? (
|
||||
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3 text-[13px] text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
@@ -632,7 +270,6 @@ export function ProvidersStep({
|
||||
>
|
||||
{t("onboarding.back")}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{!hasAtLeastOneReady && (
|
||||
<span className="text-[12px] text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ArrowSquareOut,
|
||||
CaretDown,
|
||||
CaretRight,
|
||||
FloppyDisk,
|
||||
Terminal,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Compatible-API provider presets */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const COMPATIBLE_PROVIDERS = [
|
||||
{ id: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
|
||||
{ id: "minimax_cn", label: "MiniMax (CN)", baseUrl: "https://api.minimaxi.com/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
|
||||
{ id: "kimi", label: "Kimi", baseUrl: "https://api.kimi.com/coding/", docUrl: "https://www.kimi.com/code/docs/en/more/third-party-agents.html" },
|
||||
{ id: "kimi_cn", label: "Kimi CN", baseUrl: "https://api.moonshot.cn/anthropic", docUrl: "https://platform.moonshot.cn/docs/guide/agent-support#macos-%E5%92%8C-linux-1" },
|
||||
{ id: "glm", label: "GLM (ZhiPu)", baseUrl: "https://api.z.ai/api/anthropic", docUrl: "https://docs.z.ai/devpack/tool/claude" },
|
||||
{ id: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api", docUrl: "https://openrouter.ai/docs/guides/coding-agents/claude-code-integration" },
|
||||
{ id: "tencent", label: "Tencent Coding Plan", baseUrl: "https://api.lkeap.cloud.tencent.com/coding/anthropic", docUrl: "https://cloud.tencent.com/document/product/1772/128951" },
|
||||
{ id: "poe", label: "POE", baseUrl: "https://api.poe.com", docUrl: "https://creator.poe.com/docs/external-applications/claude-code" },
|
||||
{ id: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/anthropic", docUrl: "https://api-docs.deepseek.com/guides/anthropic_api" },
|
||||
{ id: "alibaba", label: "Alibaba Coding Plan", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan" },
|
||||
{ id: "alibaba_cn", label: "Alibaba Coding Plan (CN)", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://help.aliyun.com/zh/model-studio/claude-code-coding-plan" },
|
||||
{ id: "bailing", label: "BaiLing", baseUrl: "https://api.tbox.cn/api/anthropic", docUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/best_practices_claudecode" },
|
||||
{ id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", docUrl: "https://docs.nano-gpt.com/integrations/claude-code#manual-configuration-all-platforms" },
|
||||
{ id: "zhipu_cn", label: "ZhiPu (CN)", baseUrl: "https://open.bigmodel.cn/api/anthropic", docUrl: "https://docs.bigmodel.cn/cn/coding-plan/tool/claude" },
|
||||
{ id: "ollama", label: "Ollama (Local)", baseUrl: "http://localhost:11434", docUrl: "https://docs.ollama.com/integrations/claude-code" },
|
||||
{ id: "custom", label: "Custom", baseUrl: "", docUrl: "" },
|
||||
] as const;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared input class */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const INPUT_CLS =
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type ConnectorStatus = ProfileSnapshot["connectors"][number]["status"];
|
||||
|
||||
export interface ProvidersStepSaveConnector {
|
||||
(connectorId: string, envBindingText: string, model: string | null, notes?: string): void;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Status badge */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function StatusBadge({ status, t }: { status: ConnectorStatus; t: (key: string) => string }) {
|
||||
if (status === "ready") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-success-muted px-2 py-0.5 text-[11px] font-medium text-success">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-success" />
|
||||
{t("onboarding.providerReady")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "not_installed") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground" />
|
||||
{t("onboarding.providerNotInstalled")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "auth_required") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-warning-muted px-2 py-0.5 text-[11px] font-medium text-warning">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
|
||||
{t("onboarding.providerAuthRequired")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "detected" || status === "checking" as string) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-muted-foreground border-t-primary" />
|
||||
{t("onboarding.providerChecking")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-caution-muted px-2 py-0.5 text-[11px] font-medium text-caution">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-caution" />
|
||||
{status.replaceAll("_", " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Provider card wrapper */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function ProviderCard({
|
||||
title,
|
||||
badge,
|
||||
expanded,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
badge: React.ReactNode;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex w-full cursor-pointer items-center justify-between px-4 py-3 transition hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{expanded ? <CaretDown size={14} className="text-muted-foreground" /> : <CaretRight size={14} className="text-muted-foreground" />}
|
||||
<span className="text-[13px] font-semibold text-foreground">{title}</span>
|
||||
</div>
|
||||
{badge}
|
||||
</button>
|
||||
{expanded && <div className="space-y-3 px-4 pb-4">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Install snippet */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function InstallBlock({
|
||||
macLinuxCmd,
|
||||
windowsCmd,
|
||||
onInstall,
|
||||
title,
|
||||
t,
|
||||
}: {
|
||||
macLinuxCmd: string;
|
||||
windowsCmd: string | null;
|
||||
onInstall: (title: string, command: string) => void;
|
||||
title: string;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const platform = window.agentCompany?.platform ?? "linux";
|
||||
const isWindows = platform === "win32";
|
||||
const cmd = isWindows && windowsCmd ? windowsCmd : macLinuxCmd;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[12px] font-medium text-muted-foreground">{t("onboarding.providerInstall")}</div>
|
||||
{!isWindows && (
|
||||
<div className="rounded-lg border border-border bg-muted px-3 py-2 font-mono text-[12px] text-foreground">
|
||||
{macLinuxCmd}
|
||||
</div>
|
||||
)}
|
||||
{isWindows && windowsCmd && (
|
||||
<div className="rounded-lg border border-border bg-muted px-3 py-2 font-mono text-[12px] text-foreground">
|
||||
{windowsCmd}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onInstall(title, cmd)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
|
||||
>
|
||||
<Terminal size={14} />
|
||||
{t("onboarding.providerInstall")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Compatible provider card (one per slot) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function CompatProviderCard({
|
||||
slotId,
|
||||
connector,
|
||||
claudeInstalled,
|
||||
onSaveConnector,
|
||||
onInstallCommand,
|
||||
expanded,
|
||||
onToggle,
|
||||
t,
|
||||
}: {
|
||||
slotId: string;
|
||||
connector: ProfileSnapshot["connectors"][number] | null;
|
||||
claudeInstalled: boolean;
|
||||
onSaveConnector: ProvidersStepSaveConnector;
|
||||
onInstallCommand: (title: string, command: string) => void;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
function parseEnv(text: string, key: string): string {
|
||||
const line = text.split(/\r?\n/).find((l) => l.startsWith(`${key}=`));
|
||||
return line ? line.slice(key.length + 1) : "";
|
||||
}
|
||||
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>(() => {
|
||||
const saved = connector?.notes ?? "";
|
||||
const match = COMPATIBLE_PROVIDERS.find((p) => p.label === saved);
|
||||
return match?.id ?? COMPATIBLE_PROVIDERS[0].id;
|
||||
});
|
||||
const [apiKey, setApiKey] = useState(() => parseEnv(connector?.envBindingText ?? "", "ANTHROPIC_API_KEY"));
|
||||
const [baseUrl, setBaseUrl] = useState<string>(() =>
|
||||
parseEnv(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL") || COMPATIBLE_PROVIDERS[0].baseUrl,
|
||||
);
|
||||
const [models, setModels] = useState<string[]>(() => {
|
||||
const raw = connector?.model ?? "";
|
||||
return raw.split(",").map((m) => m.trim()).filter(Boolean);
|
||||
});
|
||||
const [newModel, setNewModel] = useState("");
|
||||
|
||||
const status = connector?.status ?? "not_installed";
|
||||
const providerLabel = COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ?? t("onboarding.claudeCompatibleLabel");
|
||||
const displayTitle = connector?.notes || providerLabel;
|
||||
|
||||
function handleProviderChange(providerId: string) {
|
||||
setSelectedProvider(providerId);
|
||||
const provider = COMPATIBLE_PROVIDERS.find((p) => p.id === providerId);
|
||||
if (provider) setBaseUrl(provider.baseUrl);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const lines: string[] = [];
|
||||
if (apiKey.trim()) lines.push(`ANTHROPIC_API_KEY=${apiKey.trim()}`);
|
||||
if (baseUrl.trim()) lines.push(`ANTHROPIC_BASE_URL=${baseUrl.trim()}`);
|
||||
const label = COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ?? "";
|
||||
const allModels = [...models];
|
||||
const pending = newModel.trim();
|
||||
if (pending && !allModels.includes(pending)) {
|
||||
allModels.push(pending);
|
||||
setModels(allModels);
|
||||
setNewModel("");
|
||||
}
|
||||
const modelsCsv = allModels.join(",") || null;
|
||||
onSaveConnector(slotId, lines.join("\n"), modelsCsv, label);
|
||||
}
|
||||
|
||||
function addModel() {
|
||||
const trimmed = newModel.trim();
|
||||
if (!trimmed) return;
|
||||
setModels((prev) => {
|
||||
if (prev.includes(trimmed)) return prev;
|
||||
return [...prev, trimmed];
|
||||
});
|
||||
setNewModel("");
|
||||
}
|
||||
|
||||
const selectedProviderData = COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider);
|
||||
|
||||
return (
|
||||
<ProviderCard
|
||||
title={displayTitle}
|
||||
badge={<StatusBadge status={status} t={t} />}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
{!claudeInstalled && (
|
||||
<InstallBlock
|
||||
macLinuxCmd="curl -fsSL https://claude.ai/install.sh | bash"
|
||||
windowsCmd="irm https://claude.ai/install.ps1 | iex"
|
||||
onInstall={onInstallCommand}
|
||||
title="Install Claude Code"
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{t("onboarding.providerSelectProvider")}
|
||||
</label>
|
||||
<select
|
||||
className={INPUT_CLS}
|
||||
value={selectedProvider}
|
||||
onChange={(e) => handleProviderChange(e.target.value)}
|
||||
>
|
||||
{COMPATIBLE_PROVIDERS.map((p) => (
|
||||
<option key={p.id} value={p.id} className="bg-card text-foreground">{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedProviderData?.docUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.open(selectedProviderData.docUrl, "_blank")}
|
||||
className="inline-flex items-center gap-1 text-[12px] text-primary transition hover:underline"
|
||||
>
|
||||
<ArrowSquareOut size={12} />
|
||||
{t("onboarding.providerViewDocs")}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("onboarding.providerApiKey")}</label>
|
||||
<input type="password" className={INPUT_CLS} value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-..." />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("onboarding.providerBaseUrl")}</label>
|
||||
<input className={INPUT_CLS} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.example.com/v1" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("onboarding.providerModel")}</label>
|
||||
{models.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{models.map((m) => (
|
||||
<span key={m} className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-[12px] text-foreground">
|
||||
{m}
|
||||
<button type="button" onClick={() => setModels(models.filter((x) => x !== m))} className="ml-0.5 text-muted-foreground hover:text-destructive">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className={INPUT_CLS}
|
||||
value={newModel}
|
||||
onChange={(e) => setNewModel(e.target.value)}
|
||||
placeholder="model-name"
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addModel(); } }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addModel}
|
||||
disabled={!newModel.trim()}
|
||||
className="shrink-0 rounded-lg border border-border bg-card px-3 py-2 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
|
||||
>
|
||||
<FloppyDisk size={14} />
|
||||
{t("onboarding.providerSaveCheck")}
|
||||
</button>
|
||||
</ProviderCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useCallback } from "react";
|
||||
import type { ConnectorId } from "@shared/types";
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
import { unwrap } from "../../lib/desktop";
|
||||
|
||||
export function useConnectorCallbacks(connectors: ProfileSnapshot["connectors"]) {
|
||||
const handleTestConnector = useCallback(async (connectorId: string) => {
|
||||
try {
|
||||
unwrap(await window.agentCompany.testConnector(connectorId as ConnectorId));
|
||||
} catch {
|
||||
// Health check result is stored in DB and reflected via domain-changed
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSaveConnector = useCallback(async (connectorId: string, envBindingText: string, model: string | null, notes?: string) => {
|
||||
const connector = connectors.find((c) => c.id === connectorId);
|
||||
if (!connector) return;
|
||||
try {
|
||||
unwrap(await window.agentCompany.saveConnector({
|
||||
id: connectorId as ConnectorId,
|
||||
command: connector.command,
|
||||
model,
|
||||
envBindingText,
|
||||
notes: notes ?? connector.notes,
|
||||
}));
|
||||
// Run health check AFTER save completes so the probe uses the new config
|
||||
unwrap(await window.agentCompany.testConnector(connectorId as ConnectorId));
|
||||
} catch {
|
||||
// Error is non-fatal; result reflected via domain-changed
|
||||
}
|
||||
}, [connectors]);
|
||||
|
||||
const handleLoginConnector = useCallback(async (connectorId: string) => {
|
||||
try {
|
||||
unwrap(await window.agentCompany.openConnectorAuthTerminal(connectorId as ConnectorId));
|
||||
} catch {
|
||||
// Terminal open failure is non-fatal
|
||||
return;
|
||||
}
|
||||
// Poll connector health after login terminal opens (same pattern as ConnectorEditor)
|
||||
let attempts = 0;
|
||||
const poll = setInterval(async () => {
|
||||
attempts++;
|
||||
if (attempts > 12) { clearInterval(poll); return; }
|
||||
try {
|
||||
unwrap(await window.agentCompany.testConnector(connectorId as ConnectorId));
|
||||
} catch { /* health check result reflected via domain-changed */ }
|
||||
}, 5000);
|
||||
}, []);
|
||||
|
||||
const handleInstallCommand = useCallback(async (title: string, command: string) => {
|
||||
try {
|
||||
unwrap(await window.agentCompany.openTerminalCommand({ title, command }));
|
||||
} catch {
|
||||
// Terminal open failure is non-fatal
|
||||
return;
|
||||
}
|
||||
// Poll all connectors after install (install may make a new CLI available)
|
||||
let attempts = 0;
|
||||
const poll = setInterval(async () => {
|
||||
attempts++;
|
||||
if (attempts > 12) { clearInterval(poll); return; }
|
||||
for (const c of connectors) {
|
||||
if (c.status !== "ready") {
|
||||
try { unwrap(await window.agentCompany.testConnector(c.id as ConnectorId)); } catch { /* reflected via domain-changed */ }
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
}, [connectors]);
|
||||
|
||||
return { handleTestConnector, handleSaveConnector, handleLoginConnector, handleInstallCommand };
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
mergeConnectorModelEntries,
|
||||
type ConnectorModelEntry,
|
||||
} from "@shared/connector-models";
|
||||
import { isConnectorExecutionReady } from "@shared/connector-policy";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import type { ConnectorId, ProfileSnapshot } from "@shared/types";
|
||||
import { unwrap } from "../../lib/desktop";
|
||||
import type { TranslationFn } from "../../i18n";
|
||||
|
||||
interface UseConnectorModelSelectionResult {
|
||||
executionReadyConnectors: ProfileSnapshot["connectors"];
|
||||
hasAtLeastOneReady: boolean;
|
||||
selectedConnectorId: ConnectorId | "";
|
||||
setSelectedConnectorId: (value: ConnectorId | "") => void;
|
||||
selectedConnector: ProfileSnapshot["connectors"][number] | null;
|
||||
selectedModel: string;
|
||||
setSelectedModel: (value: string) => void;
|
||||
connectorModels: ConnectorModelEntry[];
|
||||
connectorModelPools: Array<{
|
||||
connector: ProfileSnapshot["connectors"][number];
|
||||
models: ConnectorModelEntry[];
|
||||
}>;
|
||||
modelOptions: Array<{ id: string; label: string }>;
|
||||
loadingModels: boolean;
|
||||
totalEnabledModels: number;
|
||||
selectedConnectorEnabledModels: number;
|
||||
toggleConnectorModel: (connectorId: ConnectorId, modelId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useConnectorModelSelection(
|
||||
connectors: ProfileSnapshot["connectors"],
|
||||
currentLocale: AppLocale,
|
||||
t: TranslationFn,
|
||||
setError: (error: string | null) => void,
|
||||
): UseConnectorModelSelectionResult {
|
||||
const executionReadyConnectors = useMemo(
|
||||
() => connectors.filter((connector) => isConnectorExecutionReady(connector)),
|
||||
[connectors],
|
||||
);
|
||||
const hasAtLeastOneReady = executionReadyConnectors.length > 0;
|
||||
|
||||
const [selectedConnectorId, setSelectedConnectorId] = useState<ConnectorId | "">(
|
||||
executionReadyConnectors[0]?.id ?? "",
|
||||
);
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [connectorModelsById, setConnectorModelsById] = useState<Record<string, ConnectorModelEntry[]>>({});
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedConnectorId && executionReadyConnectors[0]) {
|
||||
setSelectedConnectorId(executionReadyConnectors[0].id);
|
||||
return;
|
||||
}
|
||||
if (selectedConnectorId && !executionReadyConnectors.some((connector) => connector.id === selectedConnectorId)) {
|
||||
setSelectedConnectorId(executionReadyConnectors[0]?.id ?? "");
|
||||
}
|
||||
}, [executionReadyConnectors, selectedConnectorId]);
|
||||
|
||||
const selectedConnector =
|
||||
executionReadyConnectors.find((connector) => connector.id === selectedConnectorId) ??
|
||||
executionReadyConnectors[0] ??
|
||||
null;
|
||||
|
||||
const loadConnectorModels = useCallback(async (connector: ProfileSnapshot["connectors"][number]) => {
|
||||
if (connector.id.startsWith("claude_compat")) {
|
||||
const configuredModels = (connector.model ?? "")
|
||||
.split(",")
|
||||
.map((model) => model.trim())
|
||||
.filter(Boolean)
|
||||
.map((model) => ({ id: model, label: model }));
|
||||
const disabledResult = await window.agentCompany.getConnectorDisabledModels(connector.id);
|
||||
const disabledModels = disabledResult.ok ? disabledResult.data : [];
|
||||
return mergeConnectorModelEntries(configuredModels, disabledModels);
|
||||
}
|
||||
|
||||
const [enabledResult, disabledResult] = await Promise.all([
|
||||
window.agentCompany.listConnectorModels(connector.id),
|
||||
window.agentCompany.getConnectorDisabledModels(connector.id),
|
||||
]);
|
||||
const enabledModels = enabledResult.ok ? enabledResult.data : [];
|
||||
const disabledModels = disabledResult.ok ? disabledResult.data : [];
|
||||
return mergeConnectorModelEntries(enabledModels, disabledModels);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAtLeastOneReady) {
|
||||
setConnectorModelsById({});
|
||||
setSelectedModel("");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoadingModels(true);
|
||||
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const entries = await Promise.all(
|
||||
executionReadyConnectors.map(async (connector) => [connector.id, await loadConnectorModels(connector)] as const),
|
||||
);
|
||||
if (cancelled) return;
|
||||
setConnectorModelsById(Object.fromEntries(entries));
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setConnectorModelsById({});
|
||||
setSelectedModel("");
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadModels();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [executionReadyConnectors, hasAtLeastOneReady, loadConnectorModels]);
|
||||
|
||||
const connectorModels = useMemo(
|
||||
() => connectorModelsById[selectedConnectorId] ?? [],
|
||||
[connectorModelsById, selectedConnectorId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedConnectorId) {
|
||||
setSelectedModel("");
|
||||
return;
|
||||
}
|
||||
setSelectedModel((currentModel) =>
|
||||
currentModel && connectorModels.some((entry) => entry.enabled && entry.id === currentModel)
|
||||
? currentModel
|
||||
: "",
|
||||
);
|
||||
}, [connectorModels, selectedConnectorId]);
|
||||
|
||||
const modelOptions = useMemo(
|
||||
() => connectorModels
|
||||
.filter((model) => model.enabled)
|
||||
.map((model) => ({ id: model.id, label: model.label })),
|
||||
[connectorModels],
|
||||
);
|
||||
|
||||
const connectorModelPools = useMemo(
|
||||
() =>
|
||||
executionReadyConnectors.map((connector) => ({
|
||||
connector,
|
||||
models: connectorModelsById[connector.id] ?? [],
|
||||
})),
|
||||
[connectorModelsById, executionReadyConnectors],
|
||||
);
|
||||
|
||||
const totalEnabledModels = useMemo(
|
||||
() =>
|
||||
Object.values(connectorModelsById).reduce(
|
||||
(count, models) => count + models.filter((model) => model.enabled).length,
|
||||
0,
|
||||
),
|
||||
[connectorModelsById],
|
||||
);
|
||||
|
||||
const selectedConnectorEnabledModels = useMemo(
|
||||
() => connectorModels.filter((model) => model.enabled).length,
|
||||
[connectorModels],
|
||||
);
|
||||
|
||||
const toggleConnectorModel = useCallback(async (connectorId: ConnectorId, modelId: string) => {
|
||||
const connectorModels = connectorModelsById[connectorId];
|
||||
if (!connectorModels) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentEntry = connectorModels.find((model) => model.id === modelId);
|
||||
if (!currentEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedModels = connectorModels.map((model) =>
|
||||
model.id === modelId ? { ...model, enabled: !model.enabled } : model,
|
||||
);
|
||||
const updatedModelMap = {
|
||||
...connectorModelsById,
|
||||
[connectorId]: updatedModels,
|
||||
};
|
||||
const nextEnabledCount = Object.values(updatedModelMap).reduce(
|
||||
(count, models) => count + models.filter((model) => model.enabled).length,
|
||||
0,
|
||||
);
|
||||
|
||||
if (currentEntry.enabled && nextEnabledCount === 0) {
|
||||
setError(
|
||||
currentLocale === "zh"
|
||||
? "至少保留一个全局启用模型,CEO 才能继续为团队分配工作。"
|
||||
: "Keep at least one enabled model across all providers so the CEO can keep staffing the team.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const previousModels = connectorModelsById;
|
||||
setConnectorModelsById(updatedModelMap);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
unwrap(
|
||||
await window.agentCompany.setConnectorDisabledModels({
|
||||
connectorId,
|
||||
disabledModels: updatedModels
|
||||
.filter((model) => !model.enabled)
|
||||
.map((model) => ({ id: model.id, label: model.label })),
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
setConnectorModelsById(previousModels);
|
||||
setError(error instanceof Error ? error.message : t("onboarding.unableToBootstrap"));
|
||||
}
|
||||
}, [connectorModelsById, currentLocale, setError, t]);
|
||||
|
||||
return {
|
||||
executionReadyConnectors,
|
||||
hasAtLeastOneReady,
|
||||
selectedConnectorId,
|
||||
setSelectedConnectorId,
|
||||
selectedConnector,
|
||||
selectedModel,
|
||||
setSelectedModel,
|
||||
connectorModels,
|
||||
connectorModelPools,
|
||||
modelOptions,
|
||||
loadingModels,
|
||||
totalEnabledModels,
|
||||
selectedConnectorEnabledModels,
|
||||
toggleConnectorModel,
|
||||
};
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import { ArrowRight, ArrowsClockwise, Lightning, Plus, Robot, Trash, X } from "@phosphor-icons/react"
|
||||
import type { AutomationAction, AutomationRuleRecord, AutomationStatus, AutomationTrigger, WorkflowPipelineRecord } from "@shared/types"
|
||||
import { ActionButton, Select } from "../ui"
|
||||
import { ActionButton } from "../ui"
|
||||
import { useT } from "../../i18n"
|
||||
import { ACCENT, LINE, triggerConfig, actionConfig, statusColors, type AutomationLogEntry } from "./automation-config"
|
||||
import { ACCENT, LINE, triggerConfig, actionConfig, type AutomationLogEntry } from "./automation-config"
|
||||
import { timeAgo } from "../../lib/formatters"
|
||||
import { RuleCard } from "./RuleCard"
|
||||
import { RuleFormModal } from "./RuleEditor"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import { ArrowsClockwise, CaretRight, Gear, Lightning, Play, Trash, X } from "@phosphor-icons/react"
|
||||
import { ArrowsClockwise, CaretRight, Gear, Lightning, Play, Trash } from "@phosphor-icons/react"
|
||||
import type { WorkflowPipelineRecord, WorkflowStep } from "@shared/types"
|
||||
import { timeAgo } from "../../lib/formatters"
|
||||
import { ActionButton } from "../ui"
|
||||
import { ACCENT, LINE, triggerConfig, actionConfig, workflowStatusColors, stepStatusConfig, safeJsonParse } from "./automation-config"
|
||||
|
||||
function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
|
||||
|
||||
@@ -37,10 +37,10 @@ export function ChannelHeader({
|
||||
activeChannel,
|
||||
showSidebar,
|
||||
onShowSidebar,
|
||||
channelMessages,
|
||||
taskDiscussionComments,
|
||||
channelMessages: _channelMessages,
|
||||
taskDiscussionComments: _taskDiscussionComments,
|
||||
activeTask,
|
||||
unreadByChannel,
|
||||
unreadByChannel: _unreadByChannel,
|
||||
onNavigateToTask,
|
||||
filterBar,
|
||||
}: ChannelHeaderProps) {
|
||||
|
||||
@@ -98,7 +98,7 @@ export function ChannelSidebar({
|
||||
unreadByChannel,
|
||||
agents,
|
||||
totalChannels,
|
||||
onHideSidebar,
|
||||
onHideSidebar: _onHideSidebar,
|
||||
}: ChannelSidebarProps) {
|
||||
const t = useT()
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import type { AgentMessageRecord, AgentRecord, CommentRecord, MessageChannel, ProjectRecord, TaskRecord } from "@shared/types"
|
||||
import type { AgentMessageRecord, AgentRecord, CommentRecord, DocumentRecord, MessageChannel, ProjectRecord, TaskRecord } from "@shared/types"
|
||||
import { useAppStore } from "../../state/store"
|
||||
import { unwrap } from "../../lib/desktop"
|
||||
import { useT } from "../../i18n"
|
||||
import { ChannelSidebar, FilterBar, type FilterState } from "./ChannelSidebar"
|
||||
import type { ChannelDescriptor, TaskDiscussionDescriptor, AnyChannel } from "./ChannelHeader"
|
||||
import type { ChannelDescriptor, AnyChannel } from "./ChannelHeader"
|
||||
import { ChannelHeader } from "./ChannelHeader"
|
||||
import { MessageList } from "./MessageList"
|
||||
import { MessageComposer, TaskDiscussionComposer, NoChannelComposer } from "./MessageComposer"
|
||||
import { SearchResults } from "./SearchResults"
|
||||
import ThreadPanel from "./ThreadPanel"
|
||||
import { deriveChannels, deriveTaskDiscussionChannels, getChannelMessages } from "./channel-utils"
|
||||
|
||||
|
||||
interface CommunicationHubProps {
|
||||
messages: AgentMessageRecord[]; comments: CommentRecord[]; tasks: TaskRecord[]; agents: AgentRecord[]
|
||||
projects?: ProjectRecord[]; companyId: string; selectedEntityId?: string | null
|
||||
projects?: ProjectRecord[]; documents?: DocumentRecord[]; companyId: string; selectedEntityId?: string | null
|
||||
onEntityConsumed?: () => void; onNavigateToTask: (taskId: string) => void; onRefresh: () => void
|
||||
}
|
||||
|
||||
@@ -58,8 +58,7 @@ function SearchScopeToggle({ scope, onChangeScope }: { scope: "global" | "channe
|
||||
)
|
||||
}
|
||||
|
||||
export function CommunicationHub({ messages, comments, tasks, agents, projects, companyId, selectedEntityId, onEntityConsumed, onNavigateToTask, onRefresh }: CommunicationHubProps) {
|
||||
const t = useT()
|
||||
export function CommunicationHub({ messages, comments, tasks, agents, projects, documents, companyId, selectedEntityId, onEntityConsumed, onNavigateToTask, onRefresh }: CommunicationHubProps) {
|
||||
const [selectedChannelKey, setSelectedChannelKey] = useState<string | null>(null)
|
||||
const [showSidebar, setShowSidebar] = useState(true)
|
||||
const [replyTo, setReplyTo] = useState<AgentMessageRecord | null>(null)
|
||||
@@ -67,6 +66,10 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
|
||||
const pendingReadIdsRef = useRef<Set<string>>(new Set())
|
||||
const [filters, setFilters] = useState<FilterState>({ search: "", channelType: "all", priority: "all", unreadOnly: false })
|
||||
|
||||
// Thread panel state from Zustand store
|
||||
const activeThreadId = useAppStore((s) => s.activeThreadId)
|
||||
const setActiveThreadId = useAppStore((s) => s.setActiveThreadId)
|
||||
|
||||
// Search state from Zustand store
|
||||
const searchQuery = useAppStore((s) => s.searchQuery)
|
||||
const searchScope = useAppStore((s) => s.searchScope)
|
||||
@@ -269,8 +272,19 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{activeChannel ? (activeChannel.kind === "message" ? <MessageComposer agents={agents} companyId={companyId} channel={activeChannel.channel} channelTargetId={activeChannel.channel === "direct" ? null : activeChannel.targetId} directParticipantIds={activeChannel.channel === "direct" ? activeChannel.participantIds : []} replyTo={replyTo} onSent={() => { setReplyTo(null); onRefresh() }} onCancelReply={() => setReplyTo(null)} /> : <TaskDiscussionComposer companyId={companyId} taskId={activeChannel.taskId} onSent={onRefresh} />) : <NoChannelComposer />}
|
||||
{activeChannel ? (activeChannel.kind === "message" ? <MessageComposer agents={agents} companyId={companyId} channel={activeChannel.channel} channelTargetId={activeChannel.channel === "direct" ? null : activeChannel.targetId} directParticipantIds={activeChannel.channel === "direct" ? activeChannel.participantIds : []} replyTo={replyTo} onSent={() => { setReplyTo(null); onRefresh() }} onCancelReply={() => setReplyTo(null)} documents={documents} /> : <TaskDiscussionComposer companyId={companyId} taskId={activeChannel.taskId} onSent={onRefresh} />) : <NoChannelComposer />}
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{activeThreadId && (
|
||||
<ThreadPanel
|
||||
threadId={activeThreadId}
|
||||
companyId={companyId}
|
||||
agents={agents}
|
||||
documents={documents ?? []}
|
||||
onClose={() => setActiveThreadId(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import { ArrowBendUpLeft, PaperPlaneTilt, X, ChatCircle } from "@phosphor-icons/react"
|
||||
import type { AgentMessageRecord, AgentRecord, MessageChannel } from "@shared/types"
|
||||
import { ArrowBendUpLeft, PaperPlaneTilt, X, ChatCircle, UserCircle, Paperclip, FileText } from "@phosphor-icons/react"
|
||||
import type { AgentMessageRecord, AgentRecord, DocumentRecord, MessageChannel } from "@shared/types"
|
||||
import { unwrap } from "../../lib/desktop"
|
||||
import { useAppStore } from "../../state/store"
|
||||
import { InlineNotice } from "../ui"
|
||||
import { AgentAvatar } from "./MessageItem"
|
||||
import { useT } from "../../i18n"
|
||||
|
||||
const HUMAN_SENDER_ID = "__board__";
|
||||
|
||||
function SendButton({ sending, disabled, onClick, label }: { sending: boolean; disabled: boolean; onClick: () => void; label: string }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} disabled={disabled || sending} className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground transition hover:brightness-90 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none" aria-label={label}>
|
||||
@@ -15,41 +17,265 @@ function SendButton({ sending, disabled, onClick, label }: { sending: boolean; d
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Attachment Picker ──────────────────────────────────────────────────────
|
||||
|
||||
interface AttachmentItem { type: string; id: string; title: string }
|
||||
|
||||
function AttachmentPicker({ documents, attachments, onAdd, onRemove }: {
|
||||
documents: DocumentRecord[];
|
||||
attachments: AttachmentItem[];
|
||||
onAdd: (doc: DocumentRecord) => void;
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [filter, setFilter] = useState("")
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const attachedIds = useMemo(() => new Set(attachments.map((a) => a.id)), [attachments])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = filter.toLowerCase()
|
||||
return documents.filter((d) => !attachedIds.has(d.id) && (d.title.toLowerCase().includes(q) || d.type.toLowerCase().includes(q)))
|
||||
}, [documents, attachedIds, filter])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener("mousedown", handler)
|
||||
return () => document.removeEventListener("mousedown", handler)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
||||
aria-label="Attach document"
|
||||
>
|
||||
<Paperclip size={16} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute bottom-full left-0 z-50 mb-1 w-72 rounded-xl border border-border bg-card shadow-xl">
|
||||
<div className="border-b border-border px-3 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Search documents..."
|
||||
className="w-full rounded-lg border border-border bg-muted px-2.5 py-1.5 text-[12px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-[12px] text-muted-foreground">
|
||||
{documents.length === 0 ? "No documents in this company" : "No matching documents"}
|
||||
</div>
|
||||
) : (
|
||||
filtered.slice(0, 20).map((doc) => (
|
||||
<button
|
||||
key={doc.id}
|
||||
type="button"
|
||||
onClick={() => { onAdd(doc); setFilter(""); setOpen(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left transition hover:bg-muted"
|
||||
>
|
||||
<FileText size={14} className="shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[12px] font-medium text-foreground">{doc.title}</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">{doc.type.replaceAll("_", " ")} · {doc.status}</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="absolute bottom-full left-10 mb-1 flex flex-wrap gap-1">
|
||||
{attachments.map((att) => (
|
||||
<span key={att.id} className="inline-flex items-center gap-1 rounded-lg bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary">
|
||||
<FileText size={10} />
|
||||
<span className="max-w-[120px] truncate">{att.title}</span>
|
||||
<button type="button" onClick={() => onRemove(att.id)} className="ml-0.5 rounded-sm p-0.5 transition hover:bg-primary/20"><X size={8} /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── @Mention Autocomplete ──────────────────────────────────────────────────
|
||||
|
||||
function MentionDropdown({ agents, query, onSelect, position }: {
|
||||
agents: AgentRecord[];
|
||||
query: string;
|
||||
onSelect: (agent: AgentRecord) => void;
|
||||
position: { top: number; left: number };
|
||||
}) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.toLowerCase()
|
||||
return agents.filter((a) => a.status !== "terminated" && (
|
||||
a.name.toLowerCase().includes(q) ||
|
||||
(a.title?.toLowerCase().includes(q)) ||
|
||||
(a.department?.toLowerCase().includes(q))
|
||||
)).slice(0, 8)
|
||||
}, [agents, query])
|
||||
|
||||
useEffect(() => { setSelectedIndex(0) }, [query])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1)) }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIndex((i) => Math.max(i - 1, 0)) }
|
||||
else if (e.key === "Enter" || e.key === "Tab") {
|
||||
if (filtered[selectedIndex]) { e.preventDefault(); onSelect(filtered[selectedIndex]) }
|
||||
}
|
||||
else if (e.key === "Escape") { onSelect(null as unknown as AgentRecord) }
|
||||
}
|
||||
document.addEventListener("keydown", handler, true)
|
||||
return () => document.removeEventListener("keydown", handler, true)
|
||||
}, [filtered, selectedIndex, onSelect])
|
||||
|
||||
if (filtered.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-50 w-64 rounded-xl border border-border bg-card shadow-xl"
|
||||
style={{ bottom: position.top, left: position.left }}
|
||||
>
|
||||
{filtered.map((agent, i) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(agent)}
|
||||
className={`flex w-full items-center gap-2.5 px-3 py-2 text-left transition ${
|
||||
i === selectedIndex ? "bg-primary/10" : "hover:bg-muted"
|
||||
} ${i === 0 ? "rounded-t-xl" : ""} ${i === filtered.length - 1 ? "rounded-b-xl" : ""}`}
|
||||
>
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-bold text-muted-foreground">
|
||||
{agent.name.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[12px] font-semibold text-foreground">{agent.name}</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">{agent.title}{agent.department ? ` · ${agent.department}` : ""}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Composer ──────────────────────────────────────────────────────────
|
||||
|
||||
interface MessageComposerProps {
|
||||
agents: AgentRecord[]; companyId: string; channel: MessageChannel; channelTargetId: string | null
|
||||
directParticipantIds?: string[]; replyTo: AgentMessageRecord | null; onSent: () => void; onCancelReply: () => void
|
||||
documents?: DocumentRecord[]
|
||||
/** Thread ID for precise checkpoint resolution. */
|
||||
threadId?: string | null
|
||||
/** Wait state ID for precise checkpoint resolution. */
|
||||
waitStateId?: string | null
|
||||
}
|
||||
|
||||
export function MessageComposer({ agents, companyId, channel, channelTargetId, directParticipantIds = [], replyTo, onSent, onCancelReply }: MessageComposerProps) {
|
||||
const [fromAgentId, setFromAgentId] = useState(agents[0]?.id ?? "")
|
||||
export function MessageComposer({ agents, companyId, channel, channelTargetId, directParticipantIds = [], replyTo, onSent, onCancelReply, documents = [], threadId = null, waitStateId = null }: MessageComposerProps) {
|
||||
const [body, setBody] = useState(""); const [subject, setSubject] = useState("")
|
||||
const [sending, setSending] = useState(false); const [sendError, setSendError] = useState<string | null>(null)
|
||||
const [attachments, setAttachments] = useState<AttachmentItem[]>([])
|
||||
const [mentionState, setMentionState] = useState<{ active: boolean; query: string; startIndex: number }>({ active: false, query: "", startIndex: -1 })
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const addToast = useAppStore((s) => s.addToast)
|
||||
|
||||
useEffect(() => { if (replyTo) textareaRef.current?.focus() }, [replyTo])
|
||||
useEffect(() => { if (agents.length === 0) { if (fromAgentId) setFromAgentId(""); return }; if (!agents.some((a) => a.id === fromAgentId)) setFromAgentId(agents[0]?.id ?? "") }, [agents, fromAgentId])
|
||||
|
||||
const directRecipientId = useMemo(() => {
|
||||
if (channel !== "direct") return null
|
||||
if (replyTo?.fromAgentId) return replyTo.fromAgentId
|
||||
if (directParticipantIds.length === 0) return null
|
||||
if (directParticipantIds.length === 1) return directParticipantIds[0] ?? null
|
||||
if (fromAgentId && directParticipantIds.includes(fromAgentId)) return directParticipantIds.find((id) => id !== fromAgentId) ?? null
|
||||
return directParticipantIds[0] ?? null
|
||||
}, [channel, replyTo, directParticipantIds, fromAgentId])
|
||||
const nonBoardParticipant = directParticipantIds.find((id) => id !== HUMAN_SENDER_ID)
|
||||
return nonBoardParticipant ?? directParticipantIds[0] ?? null
|
||||
}, [channel, replyTo, directParticipantIds])
|
||||
|
||||
const handleAddAttachment = useCallback((doc: DocumentRecord) => {
|
||||
setAttachments((prev) => [...prev, { type: "document", id: doc.id, title: doc.title }])
|
||||
}, [])
|
||||
|
||||
const handleRemoveAttachment = useCallback((id: string) => {
|
||||
setAttachments((prev) => prev.filter((a) => a.id !== id))
|
||||
}, [])
|
||||
|
||||
const handleMentionSelect = useCallback((agent: AgentRecord | null) => {
|
||||
if (!agent) { setMentionState({ active: false, query: "", startIndex: -1 }); return }
|
||||
setBody((prev) => {
|
||||
const before = prev.slice(0, mentionState.startIndex)
|
||||
const after = prev.slice(mentionState.startIndex + mentionState.query.length + 1)
|
||||
return `${before}@${agent.name} ${after}`
|
||||
})
|
||||
setMentionState({ active: false, query: "", startIndex: -1 })
|
||||
textareaRef.current?.focus()
|
||||
}, [mentionState])
|
||||
|
||||
const handleBodyChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value
|
||||
setBody(value)
|
||||
|
||||
const cursorPos = e.target.selectionStart
|
||||
const textBeforeCursor = value.slice(0, cursorPos)
|
||||
const atIndex = textBeforeCursor.lastIndexOf("@")
|
||||
|
||||
if (atIndex >= 0) {
|
||||
const charBefore = atIndex > 0 ? textBeforeCursor[atIndex - 1] : " "
|
||||
if (charBefore === " " || charBefore === "\n" || atIndex === 0) {
|
||||
const query = textBeforeCursor.slice(atIndex + 1)
|
||||
if (!query.includes(" ") && !query.includes("\n") && query.length <= 30) {
|
||||
setMentionState({ active: true, query, startIndex: atIndex })
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
setMentionState({ active: false, query: "", startIndex: -1 })
|
||||
}, [])
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!body.trim() || !fromAgentId) return; if (channel === "direct" && !directRecipientId) return
|
||||
if (!agents.some((a) => a.id === fromAgentId)) { setSendError("Select a valid sender."); return }
|
||||
if (!body.trim()) return; if (channel === "direct" && !directRecipientId) return
|
||||
setSending(true); setSendError(null)
|
||||
try { unwrap(await window.agentCompany.sendAgentMessage({ companyId, fromAgentId, toAgentId: channel === "direct" ? directRecipientId : null, channel, channelTargetId, subject: subject.trim() || (replyTo ? `Re: ${replyTo.subject}` : ""), body: body.trim(), priority: "normal", parentMessageId: replyTo?.id ?? null, attachmentsJson: "[]" })); setBody(""); setSubject(""); onSent() } catch (e) { setSendError(e instanceof Error ? e.message : "Failed to send message.") } finally { setSending(false) }
|
||||
}, [agents, body, channel, channelTargetId, companyId, directRecipientId, fromAgentId, onSent, replyTo, subject])
|
||||
const attachmentsJson = attachments.length > 0 ? JSON.stringify(attachments) : "[]"
|
||||
try {
|
||||
const result = unwrap(await window.agentCompany.sendAgentMessage({
|
||||
companyId, fromAgentId: HUMAN_SENDER_ID,
|
||||
toAgentId: channel === "direct" ? directRecipientId : null,
|
||||
channel, channelTargetId,
|
||||
subject: subject.trim() || (replyTo ? `Re: ${replyTo.subject}` : ""),
|
||||
body: body.trim(), priority: "normal",
|
||||
parentMessageId: replyTo?.id ?? null, attachmentsJson,
|
||||
threadId: threadId ?? undefined,
|
||||
waitStateId: waitStateId ?? undefined,
|
||||
}))
|
||||
if (result.mentionResults && result.mentionResults.length > 0) {
|
||||
const woken = result.mentionResults.filter((m) => m.outcome === "woken")
|
||||
const queued = result.mentionResults.filter((m) => m.outcome === "queued")
|
||||
const busy = result.mentionResults.filter((m) => m.outcome === "busy")
|
||||
if (woken.length > 0) addToast(`Notified: ${woken.map((m) => m.agentName).join(", ")}`, "success")
|
||||
if (queued.length > 0) addToast(`Queued: ${queued.map((m) => m.agentName).join(", ")}`, "warn")
|
||||
if (busy.length > 0) addToast(`Busy: ${busy.map((m) => m.agentName).join(", ")}`, "warn")
|
||||
}
|
||||
setBody(""); setSubject(""); setAttachments([]); onSent()
|
||||
} catch (e) { setSendError(e instanceof Error ? e.message : "Failed to send message.") }
|
||||
finally { setSending(false) }
|
||||
}, [body, channel, channelTargetId, companyId, directRecipientId, onSent, replyTo, subject, attachments, threadId, waitStateId, addToast])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); void handleSend() } }, [handleSend])
|
||||
const fromAgent = agents.find((a) => a.id === fromAgentId)
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (mentionState.active) return // let MentionDropdown handle keys
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); void handleSend() }
|
||||
}, [handleSend, mentionState.active])
|
||||
|
||||
return (
|
||||
<div className="border-t border-border bg-card px-4 py-3">
|
||||
<div className="relative border-t border-border bg-card px-4 py-3">
|
||||
{sendError ? <div className="mb-3"><InlineNotice message={sendError} /></div> : null}
|
||||
{channel === "direct" && !directRecipientId ? <div className="mb-2"><InlineNotice message="Select a direct conversation partner before sending a DM." /></div> : null}
|
||||
<AnimatePresence>
|
||||
@@ -62,16 +288,38 @@ export function MessageComposer({ agents, companyId, channel, channelTargetId, d
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<AgentAvatar agent={fromAgent} size="sm" />
|
||||
<select value={fromAgentId} onChange={(e) => setFromAgentId(e.target.value)} className="min-w-0 rounded-lg border border-border bg-card px-2 py-1 text-[11px] font-medium text-foreground">
|
||||
{agents.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.role})</option>)}
|
||||
</select>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary"><UserCircle size={16} weight="fill" /></div>
|
||||
<span className="text-[11px] font-medium text-foreground">You (Board)</span>
|
||||
{!replyTo ? <input type="text" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject (optional)" aria-label="Message subject" className="min-w-0 flex-1 rounded-lg border border-border bg-card px-2.5 py-1 text-[11px] text-foreground placeholder:text-muted-foreground" /> : null}
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea ref={textareaRef} value={body} onChange={(e) => setBody(e.target.value)} onKeyDown={handleKeyDown} placeholder="Type a message..." aria-label="Message body" rows={2} className="min-h-[40px] min-w-0 flex-1 resize-none rounded-lg border border-border bg-muted px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-transparent focus:outline-none focus:ring-2" style={{ focusRingColor: "var(--primary)" } as React.CSSProperties} />
|
||||
<SendButton sending={sending} disabled={!body.trim() || !fromAgentId || (channel === "direct" && !directRecipientId)} onClick={() => void handleSend()} label="Send message" />
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{attachments.map((att) => (
|
||||
<span key={att.id} className="inline-flex items-center gap-1 rounded-lg bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary">
|
||||
<FileText size={10} />
|
||||
<span className="max-w-[160px] truncate">{att.title}</span>
|
||||
<button type="button" onClick={() => handleRemoveAttachment(att.id)} className="ml-0.5 rounded-sm p-0.5 transition hover:bg-primary/20"><X size={8} /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex items-end gap-2">
|
||||
<AttachmentPicker documents={documents} attachments={attachments} onAdd={handleAddAttachment} onRemove={handleRemoveAttachment} />
|
||||
<textarea ref={textareaRef} value={body} onChange={handleBodyChange} onKeyDown={handleKeyDown} placeholder="Type a message... (use @ to mention agents)" aria-label="Message body" rows={2} className="min-h-[40px] min-w-0 flex-1 resize-none rounded-lg border border-border bg-muted px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-transparent focus:outline-none focus:ring-2" style={{ focusRingColor: "var(--primary)" } as React.CSSProperties} />
|
||||
<SendButton sending={sending} disabled={!body.trim() || (channel === "direct" && !directRecipientId)} onClick={() => void handleSend()} label="Send message" />
|
||||
</div>
|
||||
|
||||
{mentionState.active && (
|
||||
<MentionDropdown
|
||||
agents={agents}
|
||||
query={mentionState.query}
|
||||
onSelect={handleMentionSelect}
|
||||
position={{ top: 60, left: 48 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">Press {navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"}+Enter to send</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -7,11 +7,14 @@ import {
|
||||
CheckCircle,
|
||||
WarningCircle,
|
||||
ArrowBendUpLeft,
|
||||
UserCircle,
|
||||
} from "@phosphor-icons/react"
|
||||
import type { AgentMessageRecord, AgentRecord, CommentRecord, DepartmentId } from "@shared/types"
|
||||
import { getAgentInitials, formatMessageTime } from "../../lib/formatters"
|
||||
import { DEPARTMENT_COLORS } from "@shared/constants"
|
||||
|
||||
const HUMAN_SENDER_ID = "__board__";
|
||||
|
||||
|
||||
const priorityConfig: Record<string, { label: string; className: string }> = {
|
||||
urgent: { label: "Urgent", className: "bg-destructive-muted text-destructive" },
|
||||
@@ -59,8 +62,10 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
onReply: () => void
|
||||
}) {
|
||||
const isUnread = !message.readAt
|
||||
const isUrgent = message.priority === "urgent"
|
||||
const isHuman = message.fromAgentId === HUMAN_SENDER_ID
|
||||
const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted-foreground)"
|
||||
const senderColor = isHuman ? "var(--primary)" : deptColor
|
||||
const senderName = isHuman ? "You" : (agent?.name ?? "Unknown Agent")
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -75,12 +80,14 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
: "hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<AgentAvatar agent={agent} />
|
||||
{isHuman
|
||||
? <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary"><UserCircle size={20} weight="fill" /></div>
|
||||
: <AgentAvatar agent={agent} />}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-[13px] font-semibold" style={{ color: deptColor }}>
|
||||
{agent?.name ?? "Unknown Agent"}
|
||||
<span className="truncate text-[13px] font-semibold" style={{ color: senderColor }}>
|
||||
{senderName}
|
||||
</span>
|
||||
{agent?.title ? (
|
||||
<span className="truncate text-[11px] text-muted-foreground">{agent.title}</span>
|
||||
|
||||
@@ -30,7 +30,7 @@ export function MessageList({
|
||||
activeChannel,
|
||||
filteredMessages,
|
||||
taskDiscussionComments,
|
||||
activeTask,
|
||||
activeTask: _activeTask,
|
||||
agentMap,
|
||||
filters,
|
||||
onReply,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Thread Panel
|
||||
*
|
||||
* Renders a thread event timeline with wait-state indicators and a
|
||||
* message composer for scoped replies. Designed to slide in as a
|
||||
* right-side panel alongside the main message area.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import { X, ChatCircleDots, ArrowBendUpLeft } from "@phosphor-icons/react"
|
||||
import { motion } from "framer-motion"
|
||||
import type { AgentRecord, ConversationEventRecord, ThreadWaitStateRecord, ConversationThreadRecord, DocumentRecord } from "@shared/types"
|
||||
import WaitStateIndicator, { type WaitStateInfo } from "./WaitStateIndicator"
|
||||
import { MessageComposer } from "./MessageComposer"
|
||||
|
||||
interface ThreadPanelProps {
|
||||
threadId: string
|
||||
companyId: string
|
||||
agents: AgentRecord[]
|
||||
documents: DocumentRecord[]
|
||||
onClose: () => void
|
||||
onNavigateApproval?: (approvalId: string) => void
|
||||
}
|
||||
|
||||
function EventItem({ event, agents }: { event: ConversationEventRecord; agents: AgentRecord[] }) {
|
||||
const actorName = event.actorType === "user"
|
||||
? "You"
|
||||
: agents.find((a) => a.id === event.actorId)?.name ?? event.actorId
|
||||
|
||||
const time = new Date(event.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
|
||||
const iconClass = event.eventType === "status_change"
|
||||
? "text-blue-500"
|
||||
: event.eventType === "approval_request"
|
||||
? "text-amber-500"
|
||||
: event.eventType === "approval_decision"
|
||||
? "text-emerald-500"
|
||||
: "text-muted-foreground"
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 px-3 py-2">
|
||||
<div className={`mt-0.5 shrink-0 ${iconClass}`}>
|
||||
{event.actorType === "user"
|
||||
? <ArrowBendUpLeft size={14} />
|
||||
: <ChatCircleDots size={14} />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-medium text-foreground">{actorName}</span>
|
||||
<span className="text-xs text-muted-foreground">{time}</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground/80">{event.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ThreadPanel({ threadId, companyId, agents, documents, onClose, onNavigateApproval }: ThreadPanelProps) {
|
||||
const [thread, setThread] = useState<ConversationThreadRecord | null>(null)
|
||||
const [events, setEvents] = useState<ConversationEventRecord[]>([])
|
||||
const [waitStates, setWaitStates] = useState<ThreadWaitStateRecord[]>([])
|
||||
const [replyWaitStateId, setReplyWaitStateId] = useState<string | null>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const loadThread = useCallback(async () => {
|
||||
const threadResult = await window.agentCompany.getThread({ threadId, companyId })
|
||||
if (threadResult.ok) setThread(threadResult.data)
|
||||
|
||||
const eventsResult = await window.agentCompany.listThreadEvents({ threadId, companyId, limit: 100, offset: 0 })
|
||||
if (eventsResult.ok) setEvents(eventsResult.data)
|
||||
|
||||
const wsResult = await window.agentCompany.listThreadWaitStates({ companyId, resolvedOnly: false })
|
||||
if (wsResult.ok) {
|
||||
setWaitStates(wsResult.data.filter((ws) => ws.threadId === threadId && !ws.resolvedAt))
|
||||
}
|
||||
}, [threadId, companyId])
|
||||
|
||||
useEffect(() => { void loadThread() }, [loadThread])
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [events])
|
||||
|
||||
const handleReply = useCallback((waitStateId: string, _threadId: string) => {
|
||||
setReplyWaitStateId(waitStateId)
|
||||
}, [])
|
||||
|
||||
const handleSent = useCallback(() => {
|
||||
setReplyWaitStateId(null)
|
||||
void loadThread()
|
||||
}, [loadThread])
|
||||
|
||||
const activeReplyWaitState = waitStates.find((waitState) => waitState.waitingFor !== "user_approval") ?? null
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ x: 380, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: 380, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="flex h-full w-[380px] shrink-0 flex-col border-l border-border bg-background"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<ChatCircleDots size={18} className="text-muted-foreground" />
|
||||
<h3 className="flex-1 truncate text-sm font-medium text-foreground">
|
||||
{thread?.title ?? "Thread"}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Close thread"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Wait State Indicators */}
|
||||
{waitStates.length > 0 && (
|
||||
<div className="space-y-1 border-b border-border p-2">
|
||||
{waitStates.map((ws) => (
|
||||
<WaitStateIndicator
|
||||
key={ws.id}
|
||||
waitState={ws as WaitStateInfo}
|
||||
agents={agents}
|
||||
onReply={handleReply}
|
||||
onNavigateApproval={onNavigateApproval}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event Timeline */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
{events.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">
|
||||
No events yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50 py-1">
|
||||
{events.map((event) => (
|
||||
<EventItem key={event.id} event={event} agents={agents} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Composer */}
|
||||
<div className="border-t border-border">
|
||||
<MessageComposer
|
||||
agents={agents}
|
||||
companyId={companyId}
|
||||
channel="direct"
|
||||
channelTargetId={null}
|
||||
replyTo={null}
|
||||
onSent={handleSent}
|
||||
onCancelReply={() => setReplyWaitStateId(null)}
|
||||
documents={documents}
|
||||
threadId={threadId}
|
||||
waitStateId={replyWaitStateId ?? activeReplyWaitState?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Wait State Indicator
|
||||
*
|
||||
* Renders an inline banner within a thread or channel when an agent
|
||||
* is waiting for human input (reply, review, or approval).
|
||||
* Provides a primary action button to reply or navigate to the approval.
|
||||
*/
|
||||
|
||||
import { Hourglass, ChatCircle, CheckCircle } from "@phosphor-icons/react"
|
||||
import type { AgentRecord } from "@shared/types"
|
||||
|
||||
export interface WaitStateInfo {
|
||||
id: string
|
||||
threadId: string
|
||||
companyId: string
|
||||
waitingFor: "user_reply" | "user_review" | "user_approval"
|
||||
reason: string
|
||||
createdAt: string
|
||||
relatedApprovalId: string | null
|
||||
}
|
||||
|
||||
interface WaitStateIndicatorProps {
|
||||
waitState: WaitStateInfo
|
||||
agents: AgentRecord[]
|
||||
onReply: (waitStateId: string, threadId: string) => void
|
||||
onNavigateApproval?: (approvalId: string) => void
|
||||
}
|
||||
|
||||
function formatWaitDuration(createdAt: string): string {
|
||||
const diff = Date.now() - new Date(createdAt).getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return "just now"
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
export default function WaitStateIndicator({ waitState, onReply, onNavigateApproval }: WaitStateIndicatorProps) {
|
||||
const isApproval = waitState.waitingFor === "user_approval" && waitState.relatedApprovalId
|
||||
const isReview = waitState.waitingFor === "user_review"
|
||||
const duration = formatWaitDuration(waitState.createdAt)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border-l-2 border-amber-500 bg-amber-50 px-3 py-2 dark:border-amber-400 dark:bg-amber-950/30">
|
||||
<Hourglass size={18} weight="fill" className="shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">{waitState.reason}</p>
|
||||
<p className="text-xs text-muted-foreground">Waiting since {duration}</p>
|
||||
</div>
|
||||
{isApproval && onNavigateApproval ? (
|
||||
<button
|
||||
onClick={() => onNavigateApproval(waitState.relatedApprovalId!)}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600"
|
||||
>
|
||||
<CheckCircle size={14} />
|
||||
Review
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onReply(waitState.id, waitState.threadId)}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600"
|
||||
>
|
||||
<ChatCircle size={14} />
|
||||
{isReview ? "Review" : "Reply"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { AgentMessageRecord, AgentRecord, CommentRecord, MessageChannel, ProjectRecord, TaskRecord } from "@shared/types"
|
||||
import type { ChannelDescriptor, TaskDiscussionDescriptor } from "./ChannelHeader"
|
||||
|
||||
const BOARD_ID = "__board__"
|
||||
|
||||
function labelForParticipant(id: string | undefined, agentMap: Map<string, AgentRecord>): string {
|
||||
if (!id) return "Unknown"
|
||||
if (id === BOARD_ID) return "Board"
|
||||
return agentMap.get(id)?.name ?? id.slice(0, 8)
|
||||
}
|
||||
|
||||
export function deriveChannels(messages: AgentMessageRecord[], agents: AgentRecord[], projects?: ProjectRecord[]): ChannelDescriptor[] {
|
||||
const channelMap = new Map<string, ChannelDescriptor>()
|
||||
const agentMap = new Map(agents.map((a) => [a.id, a]))
|
||||
@@ -21,7 +29,12 @@ export function deriveChannels(messages: AgentMessageRecord[], agents: AgentReco
|
||||
case "direct": {
|
||||
const otherId = msg.toAgentId
|
||||
if (!otherId) { key = "direct-broadcast"; label = "Broadcast DMs" }
|
||||
else { const ids = [msg.fromAgentId, otherId].sort(); key = `direct-${ids.join("-")}`; const f = agentMap.get(ids[0] ?? ""); const s = agentMap.get(ids[1] ?? ""); label = `${f?.name ?? (ids[0] ? ids[0].slice(0, 8) : "Unknown")} \u2194 ${s?.name ?? (ids[1] ? ids[1].slice(0, 8) : "Unknown")}`; agentId = ids[1] ?? null }
|
||||
else {
|
||||
const ids = [msg.fromAgentId, otherId].sort()
|
||||
key = `direct-${ids.join("-")}`
|
||||
label = `${labelForParticipant(ids[0], agentMap)} \u2194 ${labelForParticipant(ids[1], agentMap)}`
|
||||
agentId = ids.find((id) => id !== BOARD_ID) ?? ids[1] ?? null
|
||||
}
|
||||
break }
|
||||
case "department": { const d = msg.channelTargetId ?? "general"; key = `department-${d}`; label = `#${d.replace(/_/g, "-")}`; break }
|
||||
case "company": { key = "company-all"; label = "#company-wide"; break }
|
||||
|
||||
@@ -135,7 +135,7 @@ export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRe
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{submitError ? <InlineNotice message={submitError} /> : null}
|
||||
{needsLogin ? (
|
||||
{needsLogin && authActionCopy ? (
|
||||
<div className="rounded-lg border border-[color:var(--destructive)] bg-warning-muted px-4 py-4 text-[12px] text-warning">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em]">{authActionCopy.title}</div>
|
||||
<div className="mt-1.5 leading-relaxed">{authActionCopy.message}</div>
|
||||
|
||||
@@ -29,6 +29,8 @@ export function ActivityFeed({ activity, agents, limit = 15 }: { activity: Activ
|
||||
.slice(0, limit);
|
||||
}, [activity, limit]);
|
||||
|
||||
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
|
||||
|
||||
if (recentActivity.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center py-8 text-center">
|
||||
@@ -37,8 +39,6 @@ export function ActivityFeed({ activity, agents, limit = 15 }: { activity: Activ
|
||||
);
|
||||
}
|
||||
|
||||
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{recentActivity.map((item, index) => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { Briefcase, CheckCircle, Clock, ClockCounterClockwise, Robot, Trash, XCircle } from "@phosphor-icons/react"
|
||||
import { CaretDown, CaretUp } from "@phosphor-icons/react"
|
||||
import type { AgentRecord, ApprovalRecord, DocumentRecord, TaskRecord } from "@shared/types"
|
||||
import { extractApprovalDocumentReferences, type ApprovalDocumentReference } from "@shared/approval-documents"
|
||||
import { useT } from "../../i18n"
|
||||
import { formatMoney, formatTime } from "../../lib/formatters"
|
||||
import { unwrap } from "../../lib/desktop"
|
||||
import { ActionButton, ConfirmDialog, InlineNotice, StatusPill, TextArea } from "../ui"
|
||||
import { ActionButton, ConfirmDialog, InlineNotice, Input, StatusPill, TextArea } from "../ui"
|
||||
import { describeActionError, parseApprovalPayloadJson } from "./execution-utils"
|
||||
import { SocialDraftPreview } from "./SocialDraftPreview"
|
||||
import { useAppStore } from "../../state/store"
|
||||
@@ -19,6 +20,115 @@ function getTypeBadgeClass(type: string): string {
|
||||
return "bg-muted text-muted-foreground"
|
||||
}
|
||||
|
||||
function ApprovalDocumentEditor({
|
||||
companyId,
|
||||
document,
|
||||
fallback,
|
||||
}: {
|
||||
companyId: string
|
||||
document: DocumentRecord
|
||||
fallback?: ApprovalDocumentReference
|
||||
}) {
|
||||
const addToast = useAppStore((s) => s.addToast)
|
||||
const [title, setTitle] = useState(document.title)
|
||||
const [content, setContent] = useState(document.content ?? "")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
|
||||
async function handleSave() {
|
||||
if (!title.trim()) {
|
||||
setSaveError("Document title is required.")
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
try {
|
||||
unwrap(await window.agentCompany.saveDocument({
|
||||
id: document.id,
|
||||
companyId,
|
||||
type: document.type,
|
||||
title: title.trim(),
|
||||
content,
|
||||
authorAgentId: document.authorAgentId ?? null,
|
||||
reviewerAgentId: document.reviewerAgentId ?? null,
|
||||
projectId: document.projectId ?? null,
|
||||
goalId: document.goalId ?? null,
|
||||
parentDocId: document.parentDocId ?? null,
|
||||
version: document.version,
|
||||
status: document.status,
|
||||
tagsJson: document.tagsJson,
|
||||
}))
|
||||
addToast(`Saved document: ${title.trim()}`, "success")
|
||||
} catch (error) {
|
||||
setSaveError(describeActionError(error))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-border px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13px] font-medium text-foreground">{fallback?.title ?? document.title}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{(fallback?.type ?? document.type).replaceAll("_", " ")} · v{document.version} · {document.status}</div>
|
||||
</div>
|
||||
<ActionButton label={saving ? "Saving..." : "Save changes"} icon={CheckCircle} tone="accent" onClick={() => void handleSave()} loading={saving} />
|
||||
</div>
|
||||
{saveError ? <InlineNotice message={saveError} /> : null}
|
||||
<Input label="Title" value={title} onChange={setTitle} placeholder="Document title" />
|
||||
<TextArea label="Content" value={content} onChange={setContent} rows={12} placeholder="Document content" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ApprovalDocumentsPanel({
|
||||
approval,
|
||||
documents,
|
||||
companyId,
|
||||
}: {
|
||||
approval: ApprovalRecord
|
||||
documents?: DocumentRecord[]
|
||||
companyId: string
|
||||
}) {
|
||||
const references = useMemo(
|
||||
() => extractApprovalDocumentReferences(approval.payloadJson),
|
||||
[approval.payloadJson],
|
||||
)
|
||||
|
||||
if (references.length === 0) return null
|
||||
|
||||
const fullDocs = documents
|
||||
? references
|
||||
.map((reference) => ({
|
||||
reference,
|
||||
document: documents.find((entry) => entry.id === reference.id) ?? null,
|
||||
}))
|
||||
.filter((entry) => entry.document)
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-primary">Linked Documents</div>
|
||||
{fullDocs.length > 0 ? fullDocs.map(({ reference, document }) => (
|
||||
<ApprovalDocumentEditor
|
||||
key={document!.id}
|
||||
companyId={companyId}
|
||||
document={document!}
|
||||
fallback={reference}
|
||||
/>
|
||||
)) : references.map((reference) => (
|
||||
<div key={reference.id} className="rounded-lg border border-border bg-muted px-4 py-3">
|
||||
<div className="text-[13px] font-medium text-foreground">{reference.title ?? reference.id}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{reference.type?.replaceAll("_", " ") ?? "document"} · {reference.id}</div>
|
||||
<div className="mt-2 text-[12px] text-muted-foreground">The linked document is not loaded in the current snapshot yet. Reopen the approval after the next refresh.</div>
|
||||
</div>
|
||||
))}
|
||||
{approval.impactSummary ? <div className="text-[11px] text-muted-foreground">{approval.impactSummary}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApprovalInspector({ approval, agents, tasks, documents, companyId, onDecide, onDeleted }: {
|
||||
approval: ApprovalRecord; agents: AgentRecord[]; tasks: TaskRecord[]; documents?: DocumentRecord[]; companyId: string
|
||||
onDecide: (state: "approved" | "rejected" | "revision_requested", note?: string) => Promise<void>; onDeleted: () => Promise<void>
|
||||
@@ -114,7 +224,8 @@ export function ApprovalInspector({ approval, agents, tasks, documents, companyI
|
||||
{mgr ? <div><span className="text-muted-foreground">Reports to:</span> <span className="text-foreground">{mgr.name}</span></div> : null}
|
||||
</div></div>) })() : null}
|
||||
{approval.type === "approve_ceo_strategy" ? <div className="rounded-lg border border-[color:var(--destructive)] border-opacity-30 bg-warning-muted px-4 py-3"><div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-warning">Organization Strategy</div><div className="whitespace-pre-wrap text-[12px] leading-relaxed text-foreground">{approval.payloadSummary}</div></div> : null}
|
||||
{approval.type === "deliverable_review" ? <DeliverableReviewPanel approval={approval} documents={documents} agents={agents} /> : null}
|
||||
<ApprovalDocumentsPanel approval={approval} documents={documents} companyId={companyId} />
|
||||
{approval.type === "deliverable_review" && !extractApprovalDocumentReferences(approval.payloadJson).length ? <DeliverableReviewPanel approval={approval} documents={documents} agents={agents} /> : null}
|
||||
{isSocialPost && approval.payloadJson ? (
|
||||
<SocialDraftPreview
|
||||
payloadJson={approval.payloadJson}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ArrowSquareOut, FloppyDisk, Plus, Trash } from "@phosphor-icons/react";
|
||||
import type { ConnectorRecord } from "@shared/types";
|
||||
import { unwrap } from "../../lib/desktop";
|
||||
import {
|
||||
COMPATIBLE_PROVIDERS,
|
||||
INPUT_CLS,
|
||||
ModelToggleList,
|
||||
ProviderCard,
|
||||
StatusBadge,
|
||||
parseEnvValue,
|
||||
type ModelEntry,
|
||||
} from "./ModelManagementShared";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Compatible provider card */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function CompatProviderCard({
|
||||
slotId,
|
||||
connector,
|
||||
zh,
|
||||
onSaved,
|
||||
expanded,
|
||||
onToggle,
|
||||
onCleared,
|
||||
}: {
|
||||
slotId: string;
|
||||
connector: ConnectorRecord | null;
|
||||
zh: boolean;
|
||||
onSaved: () => Promise<void>;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onCleared?: () => void;
|
||||
}) {
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>(() => {
|
||||
const saved = connector?.notes ?? "";
|
||||
const match = COMPATIBLE_PROVIDERS.find((p) => p.label === saved);
|
||||
return match?.id ?? COMPATIBLE_PROVIDERS[0].id;
|
||||
});
|
||||
|
||||
const [apiKey, setApiKey] = useState(() =>
|
||||
parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_API_KEY"),
|
||||
);
|
||||
const [baseUrl, setBaseUrl] = useState<string>(
|
||||
() =>
|
||||
parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL") ||
|
||||
COMPATIBLE_PROVIDERS[0].baseUrl,
|
||||
);
|
||||
const [models, setModels] = useState<ModelEntry[]>([]);
|
||||
const [newModel, setNewModel] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const status = connector?.status ?? "not_installed";
|
||||
const providerLabel =
|
||||
COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ??
|
||||
"Compatible API";
|
||||
const displayTitle = connector?.notes || providerLabel;
|
||||
|
||||
// Sync form state when connector changes externally (e.g. after save + refresh)
|
||||
useEffect(() => {
|
||||
setApiKey(parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_API_KEY"));
|
||||
const savedBase = parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL");
|
||||
if (savedBase) setBaseUrl(savedBase);
|
||||
const saved = connector?.notes ?? "";
|
||||
const match = COMPATIBLE_PROVIDERS.find((p) => p.label === saved);
|
||||
if (match) setSelectedProvider(match.id);
|
||||
}, [connector?.envBindingText, connector?.notes]);
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
if (!connector) {
|
||||
setModels([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const raw = connector.model ?? "";
|
||||
const allModelIds = raw
|
||||
.split(",")
|
||||
.map((m) => m.trim())
|
||||
.filter(Boolean);
|
||||
const disabledResult = await window.agentCompany.getConnectorDisabledModels(
|
||||
connector.id,
|
||||
);
|
||||
const disabledSet = new Set(
|
||||
(disabledResult.ok ? disabledResult.data : []).map((m) => m.id),
|
||||
);
|
||||
setModels(
|
||||
allModelIds.map((id) => ({
|
||||
id,
|
||||
label: id,
|
||||
enabled: !disabledSet.has(id),
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}, [connector]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadModels();
|
||||
}, [loadModels]);
|
||||
|
||||
function handleProviderChange(providerId: string) {
|
||||
setSelectedProvider(providerId);
|
||||
const provider = COMPATIBLE_PROVIDERS.find((p) => p.id === providerId);
|
||||
if (provider) setBaseUrl(provider.baseUrl);
|
||||
}
|
||||
|
||||
function addModel() {
|
||||
const trimmed = newModel.trim();
|
||||
if (!trimmed || models.some((m) => m.id === trimmed)) return;
|
||||
setModels((prev) => [...prev, { id: trimmed, label: trimmed, enabled: true }]);
|
||||
setNewModel("");
|
||||
}
|
||||
|
||||
function removeModel(modelId: string) {
|
||||
setModels((prev) => prev.filter((m) => m.id !== modelId));
|
||||
}
|
||||
|
||||
async function handleToggleModel(modelId: string) {
|
||||
if (!connector) return;
|
||||
const prev = models;
|
||||
const updated = models.map((m) =>
|
||||
m.id === modelId ? { ...m, enabled: !m.enabled } : m,
|
||||
);
|
||||
setModels(updated);
|
||||
const newDisabled = updated
|
||||
.filter((m) => !m.enabled)
|
||||
.map((m) => ({ id: m.id, label: m.label }));
|
||||
try {
|
||||
unwrap(
|
||||
await window.agentCompany.setConnectorDisabledModels({
|
||||
connectorId: connector.id,
|
||||
disabledModels: newDisabled,
|
||||
}),
|
||||
);
|
||||
await onSaved();
|
||||
} catch {
|
||||
setModels(prev);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const lines: string[] = [];
|
||||
if (apiKey.trim()) lines.push(`ANTHROPIC_API_KEY=${apiKey.trim()}`);
|
||||
if (baseUrl.trim()) lines.push(`ANTHROPIC_BASE_URL=${baseUrl.trim()}`);
|
||||
const label =
|
||||
COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ?? "";
|
||||
const modelsCsv =
|
||||
models.map((m) => m.id).join(",") || null;
|
||||
unwrap(
|
||||
await window.agentCompany.saveConnector({
|
||||
id: slotId as ConnectorRecord["id"],
|
||||
command: connector?.command ?? "claude",
|
||||
model: modelsCsv,
|
||||
envBindingText: lines.join("\n"),
|
||||
notes: label,
|
||||
}),
|
||||
);
|
||||
await window.agentCompany.testConnector(slotId as ConnectorRecord["id"]);
|
||||
await onSaved();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
if (!connector) return;
|
||||
try {
|
||||
unwrap(
|
||||
await window.agentCompany.saveConnector({
|
||||
id: connector.id,
|
||||
command: connector.command,
|
||||
model: null,
|
||||
envBindingText: "",
|
||||
notes: "",
|
||||
}),
|
||||
);
|
||||
unwrap(
|
||||
await window.agentCompany.setConnectorDisabledModels({
|
||||
connectorId: connector.id,
|
||||
disabledModels: [],
|
||||
}),
|
||||
);
|
||||
await onSaved();
|
||||
onCleared?.();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
const selectedProviderData = COMPATIBLE_PROVIDERS.find(
|
||||
(p) => p.id === selectedProvider,
|
||||
);
|
||||
const isConfigured =
|
||||
connector &&
|
||||
(connector.envBindingText.includes("ANTHROPIC_BASE_URL") ||
|
||||
connector.envBindingText.includes("ANTHROPIC_API_KEY"));
|
||||
|
||||
return (
|
||||
<ProviderCard
|
||||
title={displayTitle}
|
||||
badge={<StatusBadge status={status} zh={zh} />}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{zh ? "选择提供商" : "Select provider"}
|
||||
</label>
|
||||
<select
|
||||
className={INPUT_CLS}
|
||||
value={selectedProvider}
|
||||
onChange={(e) => handleProviderChange(e.target.value)}
|
||||
>
|
||||
{COMPATIBLE_PROVIDERS.map((p) => (
|
||||
<option key={p.id} value={p.id} className="bg-card text-foreground">
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedProviderData?.docUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.open(selectedProviderData.docUrl, "_blank")}
|
||||
className="inline-flex items-center gap-1 text-[12px] text-primary transition hover:underline"
|
||||
>
|
||||
<ArrowSquareOut size={12} />
|
||||
{zh ? "查看文档" : "View docs"}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
ANTHROPIC_API_KEY
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className={INPUT_CLS}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
ANTHROPIC_BASE_URL
|
||||
</label>
|
||||
<input
|
||||
className={INPUT_CLS}
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModelToggleList
|
||||
models={models}
|
||||
onToggle={(id) => void handleToggleModel(id)}
|
||||
onRemove={removeModel}
|
||||
canRemove
|
||||
zh={zh}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{zh ? "添加模型" : "Add model"}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className={INPUT_CLS}
|
||||
value={newModel}
|
||||
onChange={(e) => setNewModel(e.target.value)}
|
||||
placeholder={zh ? "输入模型名称" : "model-name"}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addModel();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addModel}
|
||||
disabled={!newModel.trim()}
|
||||
className="shrink-0 rounded-lg border border-border bg-card px-3 py-2 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
|
||||
aria-label={zh ? "添加模型" : "Add model"}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
|
||||
>
|
||||
<FloppyDisk size={14} />
|
||||
{saving
|
||||
? zh
|
||||
? "保存中..."
|
||||
: "Saving..."
|
||||
: zh
|
||||
? "保存并检查"
|
||||
: "Save & Check"}
|
||||
</button>
|
||||
{isConfigured ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleClear()}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-destructive transition hover:bg-muted"
|
||||
>
|
||||
<Trash size={14} />
|
||||
{zh ? "清除配置" : "Clear"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</ProviderCard>
|
||||
);
|
||||
}
|
||||
@@ -1,717 +1,20 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
CaretDown,
|
||||
CaretRight,
|
||||
FloppyDisk,
|
||||
Plus,
|
||||
SignIn,
|
||||
Trash,
|
||||
ArrowSquareOut,
|
||||
X as XIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { ConnectorRecord, ProfileSnapshot } from "@shared/types";
|
||||
import { useState } from "react";
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { unwrap } from "../../lib/desktop";
|
||||
import { COMPAT_SLOTS } from "./ModelManagementShared";
|
||||
import { NativeProviderCard } from "./NativeProviderCard";
|
||||
import { CompatProviderCard } from "./CompatProviderCard";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Compatible-API provider presets (mirrors ProvidersStep) */
|
||||
/* Main component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const COMPATIBLE_PROVIDERS = [
|
||||
{ id: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
|
||||
{ id: "minimax_cn", label: "MiniMax (CN)", baseUrl: "https://api.minimaxi.com/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
|
||||
{ id: "kimi", label: "Kimi", baseUrl: "https://api.kimi.com/coding/", docUrl: "https://www.kimi.com/code/docs/en/more/third-party-agents.html" },
|
||||
{ id: "kimi_cn", label: "Kimi CN", baseUrl: "https://api.moonshot.cn/anthropic", docUrl: "https://platform.moonshot.cn/docs/guide/agent-support" },
|
||||
{ id: "glm", label: "GLM (ZhiPu)", baseUrl: "https://api.z.ai/api/anthropic", docUrl: "https://docs.z.ai/devpack/tool/claude" },
|
||||
{ id: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api", docUrl: "https://openrouter.ai/docs/guides/coding-agents/claude-code-integration" },
|
||||
{ id: "tencent", label: "Tencent Coding Plan", baseUrl: "https://api.lkeap.cloud.tencent.com/coding/anthropic", docUrl: "https://cloud.tencent.com/document/product/1772/128951" },
|
||||
{ id: "poe", label: "POE", baseUrl: "https://api.poe.com", docUrl: "https://creator.poe.com/docs/external-applications/claude-code" },
|
||||
{ id: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/anthropic", docUrl: "https://api-docs.deepseek.com/guides/anthropic_api" },
|
||||
{ id: "alibaba", label: "Alibaba Coding Plan", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan" },
|
||||
{ id: "alibaba_cn", label: "Alibaba Coding Plan (CN)", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://help.aliyun.com/zh/model-studio/claude-code-coding-plan" },
|
||||
{ id: "bailing", label: "BaiLing", baseUrl: "https://api.tbox.cn/api/anthropic", docUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/best_practices_claudecode" },
|
||||
{ id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", docUrl: "https://docs.nano-gpt.com/integrations/claude-code#manual-configuration-all-platforms" },
|
||||
{ id: "zhipu_cn", label: "ZhiPu (CN)", baseUrl: "https://open.bigmodel.cn/api/anthropic", docUrl: "https://docs.bigmodel.cn/cn/coding-plan/tool/claude" },
|
||||
{ id: "ollama", label: "Ollama (Local)", baseUrl: "http://localhost:11434", docUrl: "https://docs.ollama.com/integrations/claude-code" },
|
||||
{ id: "custom", label: "Custom", baseUrl: "", docUrl: "" },
|
||||
] as const;
|
||||
|
||||
const COMPAT_SLOTS = [
|
||||
"claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5",
|
||||
"claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10",
|
||||
] as const;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const INPUT_CLS =
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground";
|
||||
|
||||
interface ModelManagementProps {
|
||||
connectors: ProfileSnapshot["connectors"];
|
||||
locale: AppLocale;
|
||||
onSaved: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface ModelEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
type ConnectorStatus = ProfileSnapshot["connectors"][number]["status"];
|
||||
|
||||
function parseEnvValue(text: string, key: string): string {
|
||||
const line = text.split(/\r?\n/).find((l) => l.startsWith(`${key}=`));
|
||||
return line ? line.slice(key.length + 1) : "";
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* StatusBadge */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function StatusBadge({ status, zh }: { status: ConnectorStatus; zh: boolean }) {
|
||||
if (status === "ready") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-success-muted px-2 py-0.5 text-[11px] font-medium text-success">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-success" />
|
||||
{zh ? "就绪" : "Ready"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "not_installed") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground" />
|
||||
{zh ? "未安装" : "Not installed"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "auth_required") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-warning-muted px-2 py-0.5 text-[11px] font-medium text-warning">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
|
||||
{zh ? "需登录" : "Auth required"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "detected" || (status as string) === "checking") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-muted-foreground border-t-primary" />
|
||||
{zh ? "检测中" : "Checking"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-caution-muted px-2 py-0.5 text-[11px] font-medium text-caution">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-caution" />
|
||||
{status.replaceAll("_", " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* ProviderCard */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function ProviderCard({
|
||||
title,
|
||||
badge,
|
||||
expanded,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
badge: React.ReactNode;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex w-full cursor-pointer items-center justify-between px-4 py-3 transition hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{expanded ? (
|
||||
<CaretDown size={14} className="text-muted-foreground" />
|
||||
) : (
|
||||
<CaretRight size={14} className="text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-[13px] font-semibold text-foreground">{title}</span>
|
||||
</div>
|
||||
{badge}
|
||||
</button>
|
||||
{expanded && <div className="space-y-3 px-4 pb-4">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Toggle switch */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function ToggleSwitch({ checked, onChange, label }: { checked: boolean; onChange: () => void; label: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChange}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${checked ? "bg-success" : "bg-muted-foreground/30"}`}
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-[18px]" : "translate-x-[3px]"}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Model toggle list */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function ModelToggleList({
|
||||
models,
|
||||
onToggle,
|
||||
onRemove,
|
||||
canRemove,
|
||||
zh,
|
||||
}: {
|
||||
models: ModelEntry[];
|
||||
onToggle: (id: string) => void;
|
||||
onRemove?: (id: string) => void;
|
||||
canRemove: boolean;
|
||||
zh: boolean;
|
||||
}) {
|
||||
if (models.length === 0) {
|
||||
return (
|
||||
<div className="text-[12px] text-muted-foreground">
|
||||
{zh ? "暂无模型" : "No models available"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const enabledCount = models.filter((m) => m.enabled).length;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">
|
||||
{zh ? "模型" : "Models"} ({enabledCount}/{models.length} {zh ? "已启用" : "enabled"})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{models.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ToggleSwitch
|
||||
checked={m.enabled}
|
||||
onChange={() => onToggle(m.id)}
|
||||
label={`${m.enabled ? "Disable" : "Enable"} ${m.label}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-[12px] ${m.enabled ? "text-foreground" : "text-muted-foreground line-through"}`}
|
||||
>
|
||||
{m.label}
|
||||
</span>
|
||||
</div>
|
||||
{canRemove && onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(m.id)}
|
||||
className="text-muted-foreground transition hover:text-destructive"
|
||||
aria-label={`Remove ${m.label}`}
|
||||
>
|
||||
<XIcon size={12} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Native provider card (Claude Code / Codex / Gemini) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function NativeProviderCard({
|
||||
connector,
|
||||
zh,
|
||||
onSaved,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
connector: ConnectorRecord;
|
||||
zh: boolean;
|
||||
onSaved: () => Promise<void>;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const [models, setModels] = useState<ModelEntry[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const envKeyName = connector.id === "codex_local" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
|
||||
const [apiKey, setApiKey] = useState(() => parseEnvValue(connector.envBindingText, envKeyName));
|
||||
|
||||
useEffect(() => {
|
||||
setApiKey(parseEnvValue(connector.envBindingText, envKeyName));
|
||||
}, [connector.envBindingText, envKeyName]);
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
try {
|
||||
const [enabledResult, disabledResult] = await Promise.all([
|
||||
window.agentCompany.listConnectorModels(connector.id),
|
||||
window.agentCompany.getConnectorDisabledModels(connector.id),
|
||||
]);
|
||||
const enabled = enabledResult.ok ? enabledResult.data : [];
|
||||
const disabled = disabledResult.ok ? disabledResult.data : [];
|
||||
setModels([
|
||||
...enabled.map((m) => ({ ...m, enabled: true })),
|
||||
...disabled.map((m) => ({ ...m, enabled: false })),
|
||||
]);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}, [connector.id]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadModels();
|
||||
}, [loadModels]);
|
||||
|
||||
async function handleToggleModel(modelId: string) {
|
||||
const prev = models;
|
||||
const updated = models.map((m) =>
|
||||
m.id === modelId ? { ...m, enabled: !m.enabled } : m,
|
||||
);
|
||||
setModels(updated);
|
||||
const newDisabled = updated
|
||||
.filter((m) => !m.enabled)
|
||||
.map((m) => ({ id: m.id, label: m.label }));
|
||||
try {
|
||||
unwrap(
|
||||
await window.agentCompany.setConnectorDisabledModels({
|
||||
connectorId: connector.id,
|
||||
disabledModels: newDisabled,
|
||||
}),
|
||||
);
|
||||
await onSaved();
|
||||
} catch {
|
||||
setModels(prev);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const lines: string[] = [];
|
||||
if (apiKey.trim()) lines.push(`${envKeyName}=${apiKey.trim()}`);
|
||||
unwrap(
|
||||
await window.agentCompany.saveConnector({
|
||||
id: connector.id,
|
||||
command: connector.command,
|
||||
model: connector.model,
|
||||
envBindingText: lines.join("\n"),
|
||||
notes: connector.notes,
|
||||
}),
|
||||
);
|
||||
await window.agentCompany.testConnector(connector.id);
|
||||
await onSaved();
|
||||
void loadModels();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
await window.agentCompany.openConnectorAuthTerminal(connector.id);
|
||||
}
|
||||
|
||||
const showApiKey = connector.id === "claude_local" || connector.id === "codex_local";
|
||||
|
||||
return (
|
||||
<ProviderCard
|
||||
title={connector.label}
|
||||
badge={<StatusBadge status={connector.status} zh={zh} />}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleLogin()}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
|
||||
>
|
||||
<SignIn size={14} />
|
||||
{zh ? "登录" : "Login"}
|
||||
</button>
|
||||
|
||||
{showApiKey ? (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{envKeyName}{" "}
|
||||
<span className="font-normal">({zh ? "可选" : "optional"})</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className={INPUT_CLS}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={connector.id === "codex_local" ? "sk-..." : "sk-ant-..."}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ModelToggleList
|
||||
models={models}
|
||||
onToggle={(id) => void handleToggleModel(id)}
|
||||
canRemove={false}
|
||||
zh={zh}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
|
||||
>
|
||||
<FloppyDisk size={14} />
|
||||
{saving
|
||||
? zh
|
||||
? "保存中..."
|
||||
: "Saving..."
|
||||
: zh
|
||||
? "保存并检查"
|
||||
: "Save & Check"}
|
||||
</button>
|
||||
</ProviderCard>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Compatible provider card */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function CompatProviderCard({
|
||||
slotId,
|
||||
connector,
|
||||
zh,
|
||||
onSaved,
|
||||
expanded,
|
||||
onToggle,
|
||||
onCleared,
|
||||
}: {
|
||||
slotId: string;
|
||||
connector: ConnectorRecord | null;
|
||||
zh: boolean;
|
||||
onSaved: () => Promise<void>;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onCleared?: () => void;
|
||||
}) {
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>(() => {
|
||||
const saved = connector?.notes ?? "";
|
||||
const match = COMPATIBLE_PROVIDERS.find((p) => p.label === saved);
|
||||
return match?.id ?? COMPATIBLE_PROVIDERS[0].id;
|
||||
});
|
||||
|
||||
const [apiKey, setApiKey] = useState(() =>
|
||||
parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_API_KEY"),
|
||||
);
|
||||
const [baseUrl, setBaseUrl] = useState<string>(
|
||||
() =>
|
||||
parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL") ||
|
||||
COMPATIBLE_PROVIDERS[0].baseUrl,
|
||||
);
|
||||
const [models, setModels] = useState<ModelEntry[]>([]);
|
||||
const [newModel, setNewModel] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const status = connector?.status ?? "not_installed";
|
||||
const providerLabel =
|
||||
COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ??
|
||||
"Compatible API";
|
||||
const displayTitle = connector?.notes || providerLabel;
|
||||
|
||||
// Sync form state when connector changes externally (e.g. after save + refresh)
|
||||
useEffect(() => {
|
||||
setApiKey(parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_API_KEY"));
|
||||
const savedBase = parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL");
|
||||
if (savedBase) setBaseUrl(savedBase);
|
||||
const saved = connector?.notes ?? "";
|
||||
const match = COMPATIBLE_PROVIDERS.find((p) => p.label === saved);
|
||||
if (match) setSelectedProvider(match.id);
|
||||
}, [connector?.envBindingText, connector?.notes]);
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
if (!connector) {
|
||||
setModels([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const raw = connector.model ?? "";
|
||||
const allModelIds = raw
|
||||
.split(",")
|
||||
.map((m) => m.trim())
|
||||
.filter(Boolean);
|
||||
const disabledResult = await window.agentCompany.getConnectorDisabledModels(
|
||||
connector.id,
|
||||
);
|
||||
const disabledSet = new Set(
|
||||
(disabledResult.ok ? disabledResult.data : []).map((m) => m.id),
|
||||
);
|
||||
setModels(
|
||||
allModelIds.map((id) => ({
|
||||
id,
|
||||
label: id,
|
||||
enabled: !disabledSet.has(id),
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}, [connector?.id, connector?.model]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadModels();
|
||||
}, [loadModels]);
|
||||
|
||||
function handleProviderChange(providerId: string) {
|
||||
setSelectedProvider(providerId);
|
||||
const provider = COMPATIBLE_PROVIDERS.find((p) => p.id === providerId);
|
||||
if (provider) setBaseUrl(provider.baseUrl);
|
||||
}
|
||||
|
||||
function addModel() {
|
||||
const trimmed = newModel.trim();
|
||||
if (!trimmed || models.some((m) => m.id === trimmed)) return;
|
||||
setModels((prev) => [...prev, { id: trimmed, label: trimmed, enabled: true }]);
|
||||
setNewModel("");
|
||||
}
|
||||
|
||||
function removeModel(modelId: string) {
|
||||
setModels((prev) => prev.filter((m) => m.id !== modelId));
|
||||
}
|
||||
|
||||
async function handleToggleModel(modelId: string) {
|
||||
if (!connector) return;
|
||||
const prev = models;
|
||||
const updated = models.map((m) =>
|
||||
m.id === modelId ? { ...m, enabled: !m.enabled } : m,
|
||||
);
|
||||
setModels(updated);
|
||||
const newDisabled = updated
|
||||
.filter((m) => !m.enabled)
|
||||
.map((m) => ({ id: m.id, label: m.label }));
|
||||
try {
|
||||
unwrap(
|
||||
await window.agentCompany.setConnectorDisabledModels({
|
||||
connectorId: connector.id,
|
||||
disabledModels: newDisabled,
|
||||
}),
|
||||
);
|
||||
await onSaved();
|
||||
} catch {
|
||||
setModels(prev);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const lines: string[] = [];
|
||||
if (apiKey.trim()) lines.push(`ANTHROPIC_API_KEY=${apiKey.trim()}`);
|
||||
if (baseUrl.trim()) lines.push(`ANTHROPIC_BASE_URL=${baseUrl.trim()}`);
|
||||
const label =
|
||||
COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ?? "";
|
||||
const modelsCsv =
|
||||
models.map((m) => m.id).join(",") || null;
|
||||
unwrap(
|
||||
await window.agentCompany.saveConnector({
|
||||
id: slotId as ConnectorRecord["id"],
|
||||
command: connector?.command ?? "claude",
|
||||
model: modelsCsv,
|
||||
envBindingText: lines.join("\n"),
|
||||
notes: label,
|
||||
}),
|
||||
);
|
||||
await window.agentCompany.testConnector(slotId);
|
||||
await onSaved();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
if (!connector) return;
|
||||
try {
|
||||
unwrap(
|
||||
await window.agentCompany.saveConnector({
|
||||
id: connector.id,
|
||||
command: connector.command,
|
||||
model: null,
|
||||
envBindingText: "",
|
||||
notes: "",
|
||||
}),
|
||||
);
|
||||
unwrap(
|
||||
await window.agentCompany.setConnectorDisabledModels({
|
||||
connectorId: connector.id,
|
||||
disabledModels: [],
|
||||
}),
|
||||
);
|
||||
await onSaved();
|
||||
onCleared?.();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
const selectedProviderData = COMPATIBLE_PROVIDERS.find(
|
||||
(p) => p.id === selectedProvider,
|
||||
);
|
||||
const isConfigured =
|
||||
connector &&
|
||||
(connector.envBindingText.includes("ANTHROPIC_BASE_URL") ||
|
||||
connector.envBindingText.includes("ANTHROPIC_API_KEY"));
|
||||
|
||||
return (
|
||||
<ProviderCard
|
||||
title={displayTitle}
|
||||
badge={<StatusBadge status={status} zh={zh} />}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{zh ? "选择提供商" : "Select provider"}
|
||||
</label>
|
||||
<select
|
||||
className={INPUT_CLS}
|
||||
value={selectedProvider}
|
||||
onChange={(e) => handleProviderChange(e.target.value)}
|
||||
>
|
||||
{COMPATIBLE_PROVIDERS.map((p) => (
|
||||
<option key={p.id} value={p.id} className="bg-card text-foreground">
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedProviderData?.docUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.open(selectedProviderData.docUrl, "_blank")}
|
||||
className="inline-flex items-center gap-1 text-[12px] text-primary transition hover:underline"
|
||||
>
|
||||
<ArrowSquareOut size={12} />
|
||||
{zh ? "查看文档" : "View docs"}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
ANTHROPIC_API_KEY
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className={INPUT_CLS}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
ANTHROPIC_BASE_URL
|
||||
</label>
|
||||
<input
|
||||
className={INPUT_CLS}
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModelToggleList
|
||||
models={models}
|
||||
onToggle={(id) => void handleToggleModel(id)}
|
||||
onRemove={removeModel}
|
||||
canRemove
|
||||
zh={zh}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{zh ? "添加模型" : "Add model"}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className={INPUT_CLS}
|
||||
value={newModel}
|
||||
onChange={(e) => setNewModel(e.target.value)}
|
||||
placeholder={zh ? "输入模型名称" : "model-name"}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addModel();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addModel}
|
||||
disabled={!newModel.trim()}
|
||||
className="shrink-0 rounded-lg border border-border bg-card px-3 py-2 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
|
||||
aria-label={zh ? "添加模型" : "Add model"}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
|
||||
>
|
||||
<FloppyDisk size={14} />
|
||||
{saving
|
||||
? zh
|
||||
? "保存中..."
|
||||
: "Saving..."
|
||||
: zh
|
||||
? "保存并检查"
|
||||
: "Save & Check"}
|
||||
</button>
|
||||
{isConfigured ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleClear()}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-destructive transition hover:bg-muted"
|
||||
>
|
||||
<Trash size={14} />
|
||||
{zh ? "清除配置" : "Clear"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</ProviderCard>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function ModelManagement({
|
||||
connectors,
|
||||
locale,
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { CaretDown, CaretRight } from "@phosphor-icons/react";
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Compatible-API provider presets (mirrors ProvidersStep) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const COMPATIBLE_PROVIDERS = [
|
||||
{ id: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
|
||||
{ id: "minimax_cn", label: "MiniMax (CN)", baseUrl: "https://api.minimaxi.com/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
|
||||
{ id: "kimi", label: "Kimi", baseUrl: "https://api.kimi.com/coding/", docUrl: "https://www.kimi.com/code/docs/en/more/third-party-agents.html" },
|
||||
{ id: "kimi_cn", label: "Kimi CN", baseUrl: "https://api.moonshot.cn/anthropic", docUrl: "https://platform.moonshot.cn/docs/guide/agent-support" },
|
||||
{ id: "glm", label: "GLM (ZhiPu)", baseUrl: "https://api.z.ai/api/anthropic", docUrl: "https://docs.z.ai/devpack/tool/claude" },
|
||||
{ id: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api", docUrl: "https://openrouter.ai/docs/guides/coding-agents/claude-code-integration" },
|
||||
{ id: "tencent", label: "Tencent Coding Plan", baseUrl: "https://api.lkeap.cloud.tencent.com/coding/anthropic", docUrl: "https://cloud.tencent.com/document/product/1772/128951" },
|
||||
{ id: "poe", label: "POE", baseUrl: "https://api.poe.com", docUrl: "https://creator.poe.com/docs/external-applications/claude-code" },
|
||||
{ id: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/anthropic", docUrl: "https://api-docs.deepseek.com/guides/anthropic_api" },
|
||||
{ id: "alibaba", label: "Alibaba Coding Plan", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan" },
|
||||
{ id: "alibaba_cn", label: "Alibaba Coding Plan (CN)", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://help.aliyun.com/zh/model-studio/claude-code-coding-plan" },
|
||||
{ id: "bailing", label: "BaiLing", baseUrl: "https://api.tbox.cn/api/anthropic", docUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/best_practices_claudecode" },
|
||||
{ id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", docUrl: "https://docs.nano-gpt.com/integrations/claude-code#manual-configuration-all-platforms" },
|
||||
{ id: "zhipu_cn", label: "ZhiPu (CN)", baseUrl: "https://open.bigmodel.cn/api/anthropic", docUrl: "https://docs.bigmodel.cn/cn/coding-plan/tool/claude" },
|
||||
{ id: "ollama", label: "Ollama (Local)", baseUrl: "http://localhost:11434", docUrl: "https://docs.ollama.com/integrations/claude-code" },
|
||||
{ id: "custom", label: "Custom", baseUrl: "", docUrl: "" },
|
||||
] as const;
|
||||
|
||||
export const COMPAT_SLOTS = [
|
||||
"claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5",
|
||||
"claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10",
|
||||
] as const;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const INPUT_CLS =
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground";
|
||||
|
||||
export interface ModelEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export type ConnectorStatus = ProfileSnapshot["connectors"][number]["status"];
|
||||
|
||||
export function parseEnvValue(text: string, key: string): string {
|
||||
const line = text.split(/\r?\n/).find((l) => l.startsWith(`${key}=`));
|
||||
return line ? line.slice(key.length + 1) : "";
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* StatusBadge */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function StatusBadge({ status, zh }: { status: ConnectorStatus; zh: boolean }) {
|
||||
if (status === "ready") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-success-muted px-2 py-0.5 text-[11px] font-medium text-success">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-success" />
|
||||
{zh ? "就绪" : "Ready"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "not_installed") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground" />
|
||||
{zh ? "未安装" : "Not installed"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "auth_required") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-warning-muted px-2 py-0.5 text-[11px] font-medium text-warning">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
|
||||
{zh ? "需登录" : "Auth required"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "detected" || (status as string) === "checking") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-muted-foreground border-t-primary" />
|
||||
{zh ? "检测中" : "Checking"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-caution-muted px-2 py-0.5 text-[11px] font-medium text-caution">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-caution" />
|
||||
{status.replaceAll("_", " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* ProviderCard */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function ProviderCard({
|
||||
title,
|
||||
badge,
|
||||
expanded,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
badge: React.ReactNode;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex w-full cursor-pointer items-center justify-between px-4 py-3 transition hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{expanded ? (
|
||||
<CaretDown size={14} className="text-muted-foreground" />
|
||||
) : (
|
||||
<CaretRight size={14} className="text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-[13px] font-semibold text-foreground">{title}</span>
|
||||
</div>
|
||||
{badge}
|
||||
</button>
|
||||
{expanded && <div className="space-y-3 px-4 pb-4">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Toggle switch */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function ToggleSwitch({ checked, onChange, label }: { checked: boolean; onChange: () => void; label: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChange}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${checked ? "bg-success" : "bg-muted-foreground/30"}`}
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-[18px]" : "translate-x-[3px]"}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Model toggle list */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
import { X as XIcon } from "@phosphor-icons/react";
|
||||
|
||||
export function ModelToggleList({
|
||||
models,
|
||||
onToggle,
|
||||
onRemove,
|
||||
canRemove,
|
||||
zh,
|
||||
}: {
|
||||
models: ModelEntry[];
|
||||
onToggle: (id: string) => void;
|
||||
onRemove?: (id: string) => void;
|
||||
canRemove: boolean;
|
||||
zh: boolean;
|
||||
}) {
|
||||
if (models.length === 0) {
|
||||
return (
|
||||
<div className="text-[12px] text-muted-foreground">
|
||||
{zh ? "暂无模型" : "No models available"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const enabledCount = models.filter((m) => m.enabled).length;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">
|
||||
{zh ? "模型" : "Models"} ({enabledCount}/{models.length} {zh ? "已启用" : "enabled"})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{models.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ToggleSwitch
|
||||
checked={m.enabled}
|
||||
onChange={() => onToggle(m.id)}
|
||||
label={`${m.enabled ? "Disable" : "Enable"} ${m.label}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-[12px] ${m.enabled ? "text-foreground" : "text-muted-foreground line-through"}`}
|
||||
>
|
||||
{m.label}
|
||||
</span>
|
||||
</div>
|
||||
{canRemove && onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(m.id)}
|
||||
className="text-muted-foreground transition hover:text-destructive"
|
||||
aria-label={`Remove ${m.label}`}
|
||||
>
|
||||
<XIcon size={12} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { FloppyDisk, SignIn } from "@phosphor-icons/react";
|
||||
import type { ConnectorRecord } from "@shared/types";
|
||||
import { unwrap } from "../../lib/desktop";
|
||||
import {
|
||||
INPUT_CLS,
|
||||
ModelToggleList,
|
||||
ProviderCard,
|
||||
StatusBadge,
|
||||
parseEnvValue,
|
||||
type ModelEntry,
|
||||
} from "./ModelManagementShared";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Native provider card (Claude Code / Codex / Gemini) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function NativeProviderCard({
|
||||
connector,
|
||||
zh,
|
||||
onSaved,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
connector: ConnectorRecord;
|
||||
zh: boolean;
|
||||
onSaved: () => Promise<void>;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const [models, setModels] = useState<ModelEntry[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const envKeyName = connector.id === "codex_local" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
|
||||
const [apiKey, setApiKey] = useState(() => parseEnvValue(connector.envBindingText, envKeyName));
|
||||
|
||||
useEffect(() => {
|
||||
setApiKey(parseEnvValue(connector.envBindingText, envKeyName));
|
||||
}, [connector.envBindingText, envKeyName]);
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
try {
|
||||
const [enabledResult, disabledResult] = await Promise.all([
|
||||
window.agentCompany.listConnectorModels(connector.id),
|
||||
window.agentCompany.getConnectorDisabledModels(connector.id),
|
||||
]);
|
||||
const enabled = enabledResult.ok ? enabledResult.data : [];
|
||||
const disabled = disabledResult.ok ? disabledResult.data : [];
|
||||
setModels([
|
||||
...enabled.map((m) => ({ ...m, enabled: true })),
|
||||
...disabled.map((m) => ({ ...m, enabled: false })),
|
||||
]);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}, [connector.id]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadModels();
|
||||
}, [loadModels]);
|
||||
|
||||
async function handleToggleModel(modelId: string) {
|
||||
const prev = models;
|
||||
const updated = models.map((m) =>
|
||||
m.id === modelId ? { ...m, enabled: !m.enabled } : m,
|
||||
);
|
||||
setModels(updated);
|
||||
const newDisabled = updated
|
||||
.filter((m) => !m.enabled)
|
||||
.map((m) => ({ id: m.id, label: m.label }));
|
||||
try {
|
||||
unwrap(
|
||||
await window.agentCompany.setConnectorDisabledModels({
|
||||
connectorId: connector.id,
|
||||
disabledModels: newDisabled,
|
||||
}),
|
||||
);
|
||||
await onSaved();
|
||||
} catch {
|
||||
setModels(prev);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const lines: string[] = [];
|
||||
if (apiKey.trim()) lines.push(`${envKeyName}=${apiKey.trim()}`);
|
||||
unwrap(
|
||||
await window.agentCompany.saveConnector({
|
||||
id: connector.id,
|
||||
command: connector.command,
|
||||
model: connector.model,
|
||||
envBindingText: lines.join("\n"),
|
||||
notes: connector.notes,
|
||||
}),
|
||||
);
|
||||
await window.agentCompany.testConnector(connector.id);
|
||||
await onSaved();
|
||||
void loadModels();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
await window.agentCompany.openConnectorAuthTerminal(connector.id);
|
||||
}
|
||||
|
||||
const showApiKey = connector.id === "claude_local" || connector.id === "codex_local";
|
||||
|
||||
return (
|
||||
<ProviderCard
|
||||
title={connector.label}
|
||||
badge={<StatusBadge status={connector.status} zh={zh} />}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleLogin()}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
|
||||
>
|
||||
<SignIn size={14} />
|
||||
{zh ? "登录" : "Login"}
|
||||
</button>
|
||||
|
||||
{showApiKey ? (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{envKeyName}{" "}
|
||||
<span className="font-normal">({zh ? "可选" : "optional"})</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className={INPUT_CLS}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={connector.id === "codex_local" ? "sk-..." : "sk-ant-..."}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ModelToggleList
|
||||
models={models}
|
||||
onToggle={(id) => void handleToggleModel(id)}
|
||||
canRemove={false}
|
||||
zh={zh}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
|
||||
>
|
||||
<FloppyDisk size={14} />
|
||||
{saving
|
||||
? zh
|
||||
? "保存中..."
|
||||
: "Saving..."
|
||||
: zh
|
||||
? "保存并检查"
|
||||
: "Save & Check"}
|
||||
</button>
|
||||
</ProviderCard>
|
||||
);
|
||||
}
|
||||
@@ -35,11 +35,17 @@ export function SettingsPane({ snapshot, secrets, companyId, onSaved }: { snapsh
|
||||
<div className="mt-3"><Select label={t("settings.language")} value={selectedLocale} onChange={(v) => setSelectedLocale(v as AppLocale)} options={APP_LOCALES.map((loc) => ({ value: loc, label: LOCALE_LABELS[loc] }))} /></div>
|
||||
<div className="border-y border-border py-4 mt-3"><label className="flex items-center justify-between text-[13px] text-muted-foreground"><span>{t("exec.enableUpdater")}</span><input type="checkbox" checked={updatesEnabled} onChange={(e) => setUpdatesEnabled(e.target.checked)} /></label></div>
|
||||
</div>
|
||||
{(() => { const company = snapshot.companies.find((c) => c.id === companyId); if (!company) return null; return (
|
||||
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.companyAutonomy")}</div>
|
||||
{(() => { const company = snapshot.companies.find((c) => c.id === companyId); if (!company) return null; const isCeo = company.executionMode !== "ai_cofounder"; return (
|
||||
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.executionMode")}</div>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-border px-4 py-3"><label className="flex items-center gap-3"><input type="checkbox" checked={company.autoApproveHires} onChange={async (e) => { setActionError(null); try { unwrap(await window.agentCompany.saveCompany({ id: company.id, name: company.name, description: company.description, status: company.status, autoApproveHires: e.target.checked, reviewDeliverables: company.reviewDeliverables })); await onSaved() } catch (err) { setActionError(describeActionError(err)) } }} className="h-4 w-4 rounded accent-[color:var(--primary)]" /><div><div className="text-[13px] font-medium text-foreground">{t("exec.autoApproveLabel")}</div><div className="mt-0.5 text-[11px] text-muted-foreground">{t("exec.autoApproveDesc")}</div></div></label></div>
|
||||
<div className="rounded-lg border border-border px-4 py-3"><label className="flex items-center gap-3"><input type="checkbox" checked={!company.reviewDeliverables} onChange={async (e) => { setActionError(null); try { unwrap(await window.agentCompany.saveCompany({ id: company.id, name: company.name, description: company.description, status: company.status, autoApproveHires: company.autoApproveHires, reviewDeliverables: !e.target.checked })); await onSaved() } catch (err) { setActionError(describeActionError(err)) } }} className="h-4 w-4 rounded accent-[color:var(--primary)]" /><div><div className="text-[13px] font-medium text-foreground">{t("exec.reviewDeliverablesLabel")}</div><div className="mt-0.5 text-[11px] text-muted-foreground">{t("exec.reviewDeliverablesDesc")}</div></div></label></div>
|
||||
<button type="button" onClick={async () => { if (isCeo) return; setActionError(null); try { unwrap(await window.agentCompany.saveCompany({ id: company.id, name: company.name, description: company.description, status: company.status, autoApproveHires: true, reviewDeliverables: false, executionMode: "ai_ceo" })); await onSaved(); addToast(t("exec.modeSwitched"), "success") } catch (err) { setActionError(describeActionError(err)) } }} className={`w-full rounded-lg border-2 px-4 py-3 text-left transition ${isCeo ? "border-[color:var(--primary)] bg-accent" : "border-border bg-muted hover:border-[color:var(--muted-foreground)] cursor-pointer"}`}>
|
||||
<div className="text-[13px] font-semibold text-foreground">AI CEO <span className="ml-1 text-[10px] font-normal text-muted-foreground">{t("exec.modeCeoTag")}</span></div>
|
||||
<div className="mt-1 text-[11px] leading-relaxed text-muted-foreground">{t("exec.modeCeoDesc")}</div>
|
||||
</button>
|
||||
<button type="button" onClick={async () => { if (!isCeo) return; setActionError(null); try { unwrap(await window.agentCompany.saveCompany({ id: company.id, name: company.name, description: company.description, status: company.status, autoApproveHires: false, reviewDeliverables: true, executionMode: "ai_cofounder" })); await onSaved(); addToast(t("exec.modeSwitched"), "success") } catch (err) { setActionError(describeActionError(err)) } }} className={`w-full rounded-lg border-2 px-4 py-3 text-left transition ${!isCeo ? "border-[color:var(--primary)] bg-accent" : "border-border bg-muted hover:border-[color:var(--muted-foreground)] cursor-pointer"}`}>
|
||||
<div className="text-[13px] font-semibold text-foreground">AI Co-Founder <span className="ml-1 text-[10px] font-normal text-muted-foreground">{t("exec.modeCofounderTag")}</span></div>
|
||||
<div className="mt-1 text-[11px] leading-relaxed text-muted-foreground">{t("exec.modeCofounderDesc")}</div>
|
||||
</button>
|
||||
</div></div>) })()}
|
||||
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Keyboard Shortcuts</div>
|
||||
<div className="space-y-1 border-y border-border py-3">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useCallback, useEffect, useState, useMemo } from "react"
|
||||
import { useState, useMemo } from "react"
|
||||
import { CheckCircle, ListChecks, Pencil, Play, ShieldCheck, Trash, XCircle, Warning, Robot, ArrowRight } from "@phosphor-icons/react"
|
||||
import type { AgentRecord, CommentRecord, GoalRecord, ProfileSnapshot, ProjectRecord, RunRecord, TaskRecord, TaskStatus, WorkspaceRecord } from "@shared/types"
|
||||
import type { CommentRecord, GoalRecord, ProfileSnapshot, ProjectRecord, RunRecord, TaskRecord, TaskStatus, WorkspaceRecord } from "@shared/types"
|
||||
import { useT } from "../../i18n"
|
||||
import { formatMoney, formatTime } from "../../lib/formatters"
|
||||
import { unwrap } from "../../lib/desktop"
|
||||
import { ActionButton, ConfirmDialog, InlineNotice, Input, Select, StatusPill, TextArea } from "../ui"
|
||||
import { describeActionError, PriorityDot } from "./execution-utils"
|
||||
import { describeActionError } from "./execution-utils"
|
||||
import { TaskComments } from "./TaskComments"
|
||||
import { useAppStore } from "../../state/store"
|
||||
|
||||
|
||||
@@ -121,6 +121,55 @@ export function AgentEditor({
|
||||
|
||||
const managerCandidates = agents.filter((a) => a.id !== agent.id);
|
||||
|
||||
async function persistAgentDraft() {
|
||||
const meta = { ...existingMeta };
|
||||
if (thinkingEffort) meta.thinkingEffort = thinkingEffort;
|
||||
else delete meta.thinkingEffort;
|
||||
|
||||
const budgetDollars = Number(budget || 0);
|
||||
unwrap(await window.agentCompany.saveAgent({
|
||||
id: agent.id,
|
||||
companyId,
|
||||
name,
|
||||
role,
|
||||
title,
|
||||
status,
|
||||
connectorId,
|
||||
workspaceId: workspaceId || null,
|
||||
reportsTo: reportsTo || null,
|
||||
model: model || null,
|
||||
metadataJson: JSON.stringify(meta),
|
||||
budgetMonthlyUsd: budgetDollars,
|
||||
capabilities,
|
||||
}));
|
||||
|
||||
if (budgetDollars > 0 && agent.id) {
|
||||
await window.agentCompany.saveBudgetPolicy({
|
||||
companyId,
|
||||
scopeType: "agent",
|
||||
scopeId: agent.id,
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: Math.round(budgetDollars * 100),
|
||||
warnPercent: agentPolicy?.warnPercent ?? 80,
|
||||
hardStopEnabled: agentPolicy?.hardStopEnabled ?? true,
|
||||
notifyEnabled: agentPolicy?.notifyEnabled ?? true,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWakeNow() {
|
||||
setSaveError(null);
|
||||
try {
|
||||
await persistAgentDraft();
|
||||
unwrap(await window.agentCompany.triggerHeartbeat({ agentId: agent.id, companyId, trigger: "manual" }));
|
||||
await onSaved();
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : "Failed to wake agent.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 gap-4">
|
||||
<Input label={t("agent.name")} value={name} onChange={setName} placeholder={t("agent.name")} readOnly={pendingApproval} disabled={pendingApproval} />
|
||||
@@ -205,7 +254,14 @@ export function AgentEditor({
|
||||
<Input label={t("agent.budget")} value={budget} onChange={setBudget} placeholder="120" type="number" readOnly={pendingApproval} disabled={pendingApproval} />
|
||||
<TextArea label={t("agent.capabilities")} value={capabilities} onChange={setCapabilities} placeholder={t("agent.capabilities")} readOnly={pendingApproval} disabled={pendingApproval} />
|
||||
|
||||
<HeartbeatSection agent={agent} companyId={companyId} pendingApproval={pendingApproval} persistedConnectorIssue={persistedConnectorIssue} onSaved={onSaved} />
|
||||
<HeartbeatSection
|
||||
agent={agent}
|
||||
companyId={companyId}
|
||||
pendingApproval={pendingApproval}
|
||||
persistedConnectorIssue={selectedConnectorIssue ?? persistedConnectorIssue}
|
||||
onSaved={onSaved}
|
||||
onWakeNow={() => void handleWakeNow()}
|
||||
/>
|
||||
|
||||
{/* Skills section (D-04 dual access) */}
|
||||
<div className="mt-6 border-t border-border pt-4">
|
||||
@@ -222,41 +278,8 @@ export function AgentEditor({
|
||||
onClick={() =>
|
||||
void (async () => {
|
||||
setSaveError(null);
|
||||
const meta = { ...existingMeta };
|
||||
if (thinkingEffort) meta.thinkingEffort = thinkingEffort;
|
||||
else delete meta.thinkingEffort;
|
||||
try {
|
||||
const budgetDollars = Number(budget || 0);
|
||||
unwrap(await window.agentCompany.saveAgent({
|
||||
id: agent.id,
|
||||
companyId,
|
||||
name,
|
||||
role,
|
||||
title,
|
||||
status,
|
||||
connectorId,
|
||||
workspaceId: workspaceId || null,
|
||||
reportsTo: reportsTo || null,
|
||||
model: model || null,
|
||||
metadataJson: JSON.stringify(meta),
|
||||
budgetMonthlyUsd: budgetDollars,
|
||||
capabilities,
|
||||
}));
|
||||
// D-11: Also write to budget_policies for policy-based enforcement
|
||||
if (budgetDollars > 0 && agent.id) {
|
||||
await window.agentCompany.saveBudgetPolicy({
|
||||
companyId,
|
||||
scopeType: "agent",
|
||||
scopeId: agent.id,
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: Math.round(budgetDollars * 100),
|
||||
warnPercent: agentPolicy?.warnPercent ?? 80,
|
||||
hardStopEnabled: agentPolicy?.hardStopEnabled ?? true,
|
||||
notifyEnabled: agentPolicy?.notifyEnabled ?? true,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
await persistAgentDraft();
|
||||
await onSaved();
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : "Failed to save agent.");
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Heartbeat, Lightning, Trash, XCircle } from "@phosphor-icons/react";
|
||||
import type { AgentRecord } from "@shared/types";
|
||||
import { ActionButton, Select } from "../ui";
|
||||
import { formatMoney, formatTime } from "../../lib/formatters";
|
||||
import { unwrap } from "../../lib/desktop";
|
||||
|
||||
export function BudgetBar({ spent, budget }: { spent: number; budget: number }) {
|
||||
const pct = budget > 0 ? Math.min((spent / budget) * 100, 100) : 0;
|
||||
@@ -55,12 +56,14 @@ export function HeartbeatSection({
|
||||
pendingApproval,
|
||||
persistedConnectorIssue,
|
||||
onSaved,
|
||||
onWakeNow,
|
||||
}: {
|
||||
agent: AgentRecord;
|
||||
companyId: string;
|
||||
pendingApproval: boolean;
|
||||
persistedConnectorIssue: string | null;
|
||||
onSaved: () => Promise<void>;
|
||||
onWakeNow?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-3">
|
||||
@@ -112,7 +115,11 @@ export function HeartbeatSection({
|
||||
disabled={pendingApproval || !!persistedConnectorIssue}
|
||||
onClick={() =>
|
||||
void (async () => {
|
||||
await window.agentCompany.triggerHeartbeat({ agentId: agent.id, companyId, trigger: "manual" });
|
||||
if (onWakeNow) {
|
||||
onWakeNow();
|
||||
return;
|
||||
}
|
||||
unwrap(await window.agentCompany.triggerHeartbeat({ agentId: agent.id, companyId, trigger: "manual" }));
|
||||
await onSaved();
|
||||
})()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ type SkillAgentTogglesProps =
|
||||
|
||||
export function SkillAgentToggles(props: SkillAgentTogglesProps) {
|
||||
const { companyId, mode } = props;
|
||||
const skillId = mode === "skill" ? props.skillId : "";
|
||||
const agentId = mode === "agent" ? props.agentId : "";
|
||||
const addToast = useAppStore((s) => s.addToast);
|
||||
const [assignedIds, setAssignedIds] = useState<Set<string>>(new Set());
|
||||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||
@@ -16,27 +18,27 @@ export function SkillAgentToggles(props: SkillAgentTogglesProps) {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
if (mode === "skill") {
|
||||
const result = await window.agentCompany.listSkillAgents(companyId, props.skillId);
|
||||
const result = await window.agentCompany.listSkillAgents(companyId, skillId);
|
||||
if (!cancelled && result.ok) setAssignedIds(new Set(result.data));
|
||||
} else {
|
||||
const result = await window.agentCompany.listAgentSkills(companyId, props.agentId);
|
||||
const result = await window.agentCompany.listAgentSkills(companyId, agentId);
|
||||
if (!cancelled && result.ok) setAssignedIds(new Set(result.data));
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => { cancelled = true; };
|
||||
}, [mode, companyId, mode === "skill" ? props.skillId : props.agentId]);
|
||||
}, [mode, companyId, skillId, agentId]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (targetId: string) => {
|
||||
setTogglingId(targetId);
|
||||
const isAssigned = assignedIds.has(targetId);
|
||||
const agentId = mode === "skill" ? targetId : props.agentId;
|
||||
const skillId = mode === "skill" ? props.skillId : targetId;
|
||||
const resolvedAgentId = mode === "skill" ? targetId : agentId;
|
||||
const resolvedSkillId = mode === "skill" ? skillId : targetId;
|
||||
|
||||
const result = isAssigned
|
||||
? await window.agentCompany.removeSkillFromAgent(companyId, agentId, skillId)
|
||||
: await window.agentCompany.assignSkillToAgent(companyId, agentId, skillId);
|
||||
? await window.agentCompany.removeSkillFromAgent(companyId, resolvedAgentId, resolvedSkillId)
|
||||
: await window.agentCompany.assignSkillToAgent(companyId, resolvedAgentId, resolvedSkillId);
|
||||
|
||||
if (result.ok) {
|
||||
setAssignedIds((prev) => {
|
||||
@@ -50,9 +52,7 @@ export function SkillAgentToggles(props: SkillAgentTogglesProps) {
|
||||
}
|
||||
setTogglingId(null);
|
||||
},
|
||||
[assignedIds, mode, companyId, addToast,
|
||||
mode === "skill" ? props.skillId : "",
|
||||
mode === "agent" ? props.agentId : ""],
|
||||
[assignedIds, mode, companyId, addToast, skillId, agentId],
|
||||
);
|
||||
|
||||
const items: Array<{ id: string; name: string; detail?: string }> =
|
||||
|
||||
@@ -60,7 +60,7 @@ export function SkillDetail({
|
||||
skillId,
|
||||
agents,
|
||||
onDeleted,
|
||||
onRefresh,
|
||||
onRefresh: _onRefresh,
|
||||
}: {
|
||||
companyId: string;
|
||||
skillId: string;
|
||||
|
||||
@@ -1,600 +1,5 @@
|
||||
import { Component, useEffect, useRef, type ElementType, type ErrorInfo, type ReactNode } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
const EASE = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
export function Badge({ count, tone = "default" }: { count: number; tone?: "default" | "warn" | "danger" }) {
|
||||
if (count <= 0) return null;
|
||||
const toneClass = tone === "danger" ? "bg-destructive text-primary-foreground" : tone === "warn" ? "bg-warning text-primary-foreground" : "bg-[color:var(--muted-foreground)] text-primary-foreground";
|
||||
return (
|
||||
<span className={`ml-auto inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full px-1.5 text-[10px] font-bold ${toneClass}`}>
|
||||
{count > 99 ? "99+" : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionButton({
|
||||
active,
|
||||
label,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
badge,
|
||||
badgeTone,
|
||||
collapsed = false,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
icon: ElementType;
|
||||
onClick: () => void;
|
||||
badge?: number;
|
||||
badgeTone?: "default" | "warn" | "danger";
|
||||
collapsed?: boolean;
|
||||
}) {
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
aria-current={active ? "page" : undefined}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 relative flex w-full items-center justify-center rounded-lg p-2 transition duration-150 ${
|
||||
active
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon size={18} weight={active ? "fill" : "regular"} className={active ? "text-primary" : ""} />
|
||||
{badge ? (
|
||||
<span className={`absolute right-1 top-1 h-[6px] w-[6px] rounded-full ${
|
||||
badgeTone === "danger" ? "bg-destructive" : badgeTone === "warn" ? "bg-warning" : "bg-[color:var(--muted-foreground)]"
|
||||
}`} />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 relative flex w-full items-center gap-2.5 rounded-lg px-3 py-[7px] text-left transition duration-150 ${
|
||||
active
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className={`absolute left-0 top-1/2 h-4 w-[3px] -translate-y-1/2 rounded-r-full bg-primary transition-all duration-200 ${active ? "opacity-100 scale-y-100" : "opacity-0 scale-y-0"}`} aria-hidden="true" />
|
||||
<Icon size={17} weight={active ? "fill" : "regular"} className={active ? "text-primary" : ""} />
|
||||
<span className={`text-[13px] tracking-[0.005em] ${active ? "font-bold" : "font-medium"}`}>{label}</span>
|
||||
{badge ? <Badge count={badge} tone={badgeTone} /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Panel({
|
||||
title,
|
||||
eyebrow,
|
||||
children,
|
||||
action,
|
||||
className = "",
|
||||
tone = "plain",
|
||||
}: {
|
||||
title: string;
|
||||
eyebrow?: string;
|
||||
children: ReactNode;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
tone?: "plain" | "surface";
|
||||
}) {
|
||||
return (
|
||||
<section className={`space-y-4 ${className}`}>
|
||||
<div className={`flex items-end justify-between gap-4 ${tone === "surface" ? "border-b border-border pb-3" : ""}`}>
|
||||
<div>
|
||||
{eyebrow ? <div className="mb-1 text-[11px] font-semibold uppercase tracking-[0.08em] text-primary">{eyebrow}</div> : null}
|
||||
<h2 className="text-[20px] font-bold tracking-[-0.025em] text-foreground">{title}</h2>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
width = "640px",
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
width?: string;
|
||||
}) {
|
||||
const trapRef = useFocusTrap(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease: EASE }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-[color:var(--foreground)]/45"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
ref={trapRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
initial={{ scale: 0.96, opacity: 0, y: 8 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.96, opacity: 0, y: 8 }}
|
||||
transition={{ duration: 0.22, ease: EASE }}
|
||||
className="flex w-full flex-col rounded-lg border border-border bg-card shadow-xl"
|
||||
style={{ maxWidth: width, maxHeight: "85vh" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-5 py-3.5">
|
||||
<h2 className="min-w-0 truncate text-[16px] font-bold tracking-[-0.015em] text-foreground">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-3 flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 14 14" fill="none"><path d="M1 1l12 12M13 1L1 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 overflow-y-auto overflow-x-auto px-5 py-4" style={{ overflowWrap: "break-word", wordBreak: "break-word" }}>{children}</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">{label}</div>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Input({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = "text",
|
||||
readOnly = false,
|
||||
disabled = false,
|
||||
label,
|
||||
hint,
|
||||
id,
|
||||
ariaLabel,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
type?: string;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
id?: string;
|
||||
ariaLabel?: string;
|
||||
}) {
|
||||
const input = (
|
||||
<input
|
||||
id={id}
|
||||
aria-label={ariaLabel}
|
||||
type={type}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card px-3.5 py-2.5 text-[14px] text-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-60"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
);
|
||||
if (label) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
{hint ? <span title={hint} className="cursor-help text-[10px] text-muted-foreground/60 hover:text-muted-foreground">(?)</span> : null}
|
||||
</div>
|
||||
{input}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export function TextArea({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rows = 4,
|
||||
label,
|
||||
hint,
|
||||
readOnly = false,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const textarea = (
|
||||
<textarea
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full max-w-full rounded-lg border border-border bg-card px-3.5 py-2.5 text-[14px] text-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-60 break-words"
|
||||
style={{ overflowWrap: "anywhere", wordBreak: "break-word", whiteSpace: "pre-wrap" }}
|
||||
value={value}
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
);
|
||||
if (label) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
{hint ? <span title={hint} className="cursor-help text-[10px] text-muted-foreground/60 hover:text-muted-foreground">(?)</span> : null}
|
||||
</div>
|
||||
{textarea}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return textarea;
|
||||
}
|
||||
|
||||
export function Select({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
compact = false,
|
||||
label,
|
||||
hint,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
compact?: boolean;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const select = (
|
||||
<select
|
||||
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full max-w-full border bg-card text-foreground ${
|
||||
compact
|
||||
? "rounded-lg border-border px-2.5 py-1.5 text-[13px] font-medium"
|
||||
: "rounded-lg border-border px-3.5 py-2.5 text-[14px]"
|
||||
} disabled:cursor-not-allowed disabled:opacity-60 truncate`}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-card text-foreground">
|
||||
{option.label.length > 80 ? option.label.slice(0, 77) + "..." : option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
if (label) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
{hint ? <span title={hint} className="cursor-help text-[10px] text-muted-foreground/60 hover:text-muted-foreground">(?)</span> : null}
|
||||
</div>
|
||||
{select}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return select;
|
||||
}
|
||||
|
||||
export function ActionButton({
|
||||
label,
|
||||
onClick,
|
||||
icon: Icon,
|
||||
tone = "default",
|
||||
loading = false,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon?: ElementType;
|
||||
tone?: "default" | "accent" | "danger";
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const isDisabled = loading || disabled;
|
||||
const toneClass =
|
||||
tone === "accent"
|
||||
? "border-transparent bg-primary text-primary-foreground shadow-[0_1px_3px_rgba(74,93,122,0.35)] hover:bg-primary/90"
|
||||
: tone === "danger"
|
||||
? "border-transparent bg-destructive text-primary-foreground shadow-[0_1px_3px_rgba(220,53,69,0.3)] hover:brightness-90"
|
||||
: "border-border bg-card text-foreground hover:bg-muted";
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 inline-flex items-center gap-2 rounded-lg border px-4 py-2.5 text-[12px] font-bold transition-all duration-150 active:scale-[0.97] active:translate-y-px ${toneClass} ${isDisabled ? "pointer-events-none opacity-60" : ""} ${loading ? "scale-[0.98]" : ""}`}
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
aria-busy={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<svg className="h-4 w-4 animate-spin" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="2" opacity="0.3" />
|
||||
<path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
) : Icon ? (
|
||||
<Icon size={16} />
|
||||
) : null}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusPill({ status }: { status: string }) {
|
||||
const positive = ["ready", "active", "running", "succeeded", "approved", "achieved", "done"].includes(status);
|
||||
const negative = ["error", "failed", "cancelled", "blocked", "rejected", "interrupted"].includes(status);
|
||||
const warning = ["auth_required", "degraded", "pending", "in_review", "todo", "queued", "not_installed"].includes(status);
|
||||
const checking = status === "detected";
|
||||
const toneClass = positive
|
||||
? "text-success"
|
||||
: negative
|
||||
? "text-destructive"
|
||||
: warning
|
||||
? "text-warning"
|
||||
: "text-muted-foreground";
|
||||
const dotClass = positive
|
||||
? "bg-success"
|
||||
: negative
|
||||
? "bg-destructive"
|
||||
: warning
|
||||
? "bg-warning"
|
||||
: "bg-[color:var(--muted-foreground)]";
|
||||
|
||||
const displayLabel = checking ? "checking" : status.replaceAll("_", " ");
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-2 text-[11px] font-bold uppercase tracking-[0.12em] ${toneClass}`}>
|
||||
{checking ? (
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[color:var(--muted-foreground)]" aria-hidden="true" />
|
||||
) : (
|
||||
<span className={`h-2 w-2 rounded-full ${dotClass}`} aria-hidden="true" />
|
||||
)}
|
||||
{displayLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function InlineNotice({
|
||||
message,
|
||||
tone = "danger",
|
||||
action,
|
||||
}: {
|
||||
message: string;
|
||||
tone?: "danger" | "success" | "warn";
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
const toneClass = tone === "success"
|
||||
? "border-[color:var(--primary)] bg-success-muted text-success"
|
||||
: tone === "warn"
|
||||
? "border-[color:var(--warning)] bg-warning-muted text-warning"
|
||||
: "border-[color:var(--destructive)] bg-destructive-muted text-destructive";
|
||||
return (
|
||||
<div className={`rounded-lg border px-4 py-3 text-[13px] ${toneClass}`} role={tone === "danger" ? "alert" : "status"}>
|
||||
{action ? (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span>{message}</span>
|
||||
{action}
|
||||
</div>
|
||||
) : message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmDialog({ open, title, message, errorMessage, onConfirm, onCancel }: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
errorMessage?: string | null;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const trapRef = useFocusTrap(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onCancel();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onCancel]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15, ease: EASE }} className="fixed inset-0 z-50 flex items-center justify-center bg-[color:var(--foreground)]/45" onClick={onCancel}>
|
||||
<motion.div
|
||||
ref={trapRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
initial={{ scale: 0.96, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.96, opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease: EASE }}
|
||||
className="w-full max-w-[400px] rounded-lg bg-card border border-border p-6 shadow-lg"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div id="confirm-dialog-title" className="text-[16px] font-semibold text-foreground mb-2">{title}</div>
|
||||
<div className="text-[13px] text-muted-foreground mb-6">{message}</div>
|
||||
{errorMessage ? <div className="mb-4"><InlineNotice message={errorMessage} /></div> : null}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<ActionButton label="Cancel" onClick={onCancel} />
|
||||
<ActionButton label="Confirm" tone="danger" onClick={onConfirm} />
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
detail,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
detail?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.22, ease: EASE }} className="px-1 py-8 text-center">
|
||||
<div className="mb-2 text-[15px] font-semibold text-foreground">{title}</div>
|
||||
{detail ? <div className="mx-auto max-w-[48ch] text-[13px] leading-relaxed text-muted-foreground">{detail}</div> : null}
|
||||
{action ? <div className="mt-5">{action}</div> : null}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListFrame({
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={`border-y border-border ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
export function ListRow({
|
||||
children,
|
||||
className = "",
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 block w-full border-b border-border px-5 py-4 text-left last:border-b-0 ${className}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return <div className={`border-b border-border px-5 py-4 last:border-b-0 ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
export function useFocusTrap(active: boolean) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !ref.current) return;
|
||||
|
||||
const container = ref.current;
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
|
||||
const focusable = container.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
focusable[0]?.focus();
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key !== "Tab") return;
|
||||
const focusableNow = container.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
if (focusableNow.length === 0) return;
|
||||
|
||||
const first = focusableNow[0];
|
||||
const last = focusableNow[focusableNow.length - 1];
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
container.removeEventListener("keydown", handleKeyDown);
|
||||
previouslyFocused?.focus();
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
export function useReducedMotion() {
|
||||
const query = typeof window !== "undefined" ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
|
||||
// Return static value — Electron doesn't change this mid-session
|
||||
return query?.matches ?? false;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<{ children: ReactNode; fallback?: ReactNode }, { hasError: boolean; error: Error | null }> {
|
||||
constructor(props: { children: ReactNode; fallback?: ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("[ErrorBoundary]", error, info);
|
||||
}
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback ?? (
|
||||
<div className="p-8 text-center" role="alert">
|
||||
<div className="text-[15px] font-semibold text-destructive mb-2">Something went wrong</div>
|
||||
<ActionButton label="Reload page" onClick={() => window.location.reload()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
// Barrel re-export — all consumers of "../components/ui" continue to work unchanged.
|
||||
export { Badge, SectionButton, Panel, Field, Input, TextArea, Select, ActionButton } from "./ui/primitives";
|
||||
export { StatusPill, InlineNotice, ConfirmDialog, EmptyState } from "./ui/feedback";
|
||||
export { Modal, ListFrame, ListRow } from "./ui/layout";
|
||||
export { useFocusTrap, useReducedMotion, ErrorBoundary } from "./ui/hooks";
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ActionButton } from "./primitives";
|
||||
import { useFocusTrap } from "./hooks";
|
||||
|
||||
const EASE = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* StatusPill */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function StatusPill({ status }: { status: string }) {
|
||||
const positive = ["ready", "active", "running", "succeeded", "approved", "achieved", "done"].includes(status);
|
||||
const negative = ["error", "failed", "cancelled", "blocked", "rejected", "interrupted"].includes(status);
|
||||
const warning = ["auth_required", "degraded", "pending", "in_review", "todo", "queued", "not_installed"].includes(status);
|
||||
const checking = status === "detected";
|
||||
const toneClass = positive
|
||||
? "text-success"
|
||||
: negative
|
||||
? "text-destructive"
|
||||
: warning
|
||||
? "text-warning"
|
||||
: "text-muted-foreground";
|
||||
const dotClass = positive
|
||||
? "bg-success"
|
||||
: negative
|
||||
? "bg-destructive"
|
||||
: warning
|
||||
? "bg-warning"
|
||||
: "bg-[color:var(--muted-foreground)]";
|
||||
|
||||
const displayLabel = checking ? "checking" : status.replaceAll("_", " ");
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-2 text-[11px] font-bold uppercase tracking-[0.12em] ${toneClass}`}>
|
||||
{checking ? (
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[color:var(--muted-foreground)]" aria-hidden="true" />
|
||||
) : (
|
||||
<span className={`h-2 w-2 rounded-full ${dotClass}`} aria-hidden="true" />
|
||||
)}
|
||||
{displayLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* InlineNotice */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function InlineNotice({
|
||||
message,
|
||||
tone = "danger",
|
||||
action,
|
||||
}: {
|
||||
message: string;
|
||||
tone?: "danger" | "success" | "warn";
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
const toneClass = tone === "success"
|
||||
? "border-[color:var(--primary)] bg-success-muted text-success"
|
||||
: tone === "warn"
|
||||
? "border-[color:var(--warning)] bg-warning-muted text-warning"
|
||||
: "border-[color:var(--destructive)] bg-destructive-muted text-destructive";
|
||||
return (
|
||||
<div className={`rounded-lg border px-4 py-3 text-[13px] ${toneClass}`} role={tone === "danger" ? "alert" : "status"}>
|
||||
{action ? (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span>{message}</span>
|
||||
{action}
|
||||
</div>
|
||||
) : message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* ConfirmDialog */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function ConfirmDialog({ open, title, message, errorMessage, onConfirm, onCancel }: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
errorMessage?: string | null;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const trapRef = useFocusTrap(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onCancel();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onCancel]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15, ease: EASE }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-[color:var(--foreground)]/45"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<motion.div
|
||||
ref={trapRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
initial={{ scale: 0.96, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.96, opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease: EASE }}
|
||||
className="w-full max-w-[400px] rounded-lg bg-card border border-border p-6 shadow-lg"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div id="confirm-dialog-title" className="text-[16px] font-semibold text-foreground mb-2">{title}</div>
|
||||
<div className="text-[13px] text-muted-foreground mb-6">{message}</div>
|
||||
{errorMessage ? <div className="mb-4"><InlineNotice message={errorMessage} /></div> : null}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<ActionButton label="Cancel" onClick={onCancel} />
|
||||
<ActionButton label="Confirm" tone="danger" onClick={onConfirm} />
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* EmptyState */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
detail,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
detail?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.22, ease: EASE }}
|
||||
className="px-1 py-8 text-center"
|
||||
>
|
||||
<div className="mb-2 text-[15px] font-semibold text-foreground">{title}</div>
|
||||
{detail ? <div className="mx-auto max-w-[48ch] text-[13px] leading-relaxed text-muted-foreground">{detail}</div> : null}
|
||||
{action ? <div className="mt-5">{action}</div> : null}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Component, useEffect, useRef, type ErrorInfo, type ReactNode } from "react";
|
||||
import { ActionButton } from "./primitives";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* useFocusTrap */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function useFocusTrap(active: boolean) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !ref.current) return;
|
||||
|
||||
const container = ref.current;
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
|
||||
const focusable = container.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
focusable[0]?.focus();
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key !== "Tab") return;
|
||||
const focusableNow = container.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
if (focusableNow.length === 0) return;
|
||||
|
||||
const first = focusableNow[0];
|
||||
const last = focusableNow[focusableNow.length - 1];
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
container.removeEventListener("keydown", handleKeyDown);
|
||||
previouslyFocused?.focus();
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* useReducedMotion */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function useReducedMotion() {
|
||||
const query = typeof window !== "undefined" ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
|
||||
// Return static value — Electron doesn't change this mid-session
|
||||
return query?.matches ?? false;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* ErrorBoundary */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export class ErrorBoundary extends Component<{ children: ReactNode; fallback?: ReactNode }, { hasError: boolean; error: Error | null }> {
|
||||
constructor(props: { children: ReactNode; fallback?: ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("[ErrorBoundary]", error, info);
|
||||
}
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback ?? (
|
||||
<div className="p-8 text-center" role="alert">
|
||||
<div className="text-[15px] font-semibold text-destructive mb-2">Something went wrong</div>
|
||||
<ActionButton label="Reload page" onClick={() => window.location.reload()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { Badge, SectionButton, Panel, Field, Input, TextArea, Select, ActionButton } from "./primitives";
|
||||
export { StatusPill, InlineNotice, ConfirmDialog, EmptyState } from "./feedback";
|
||||
export { Modal, ListFrame, ListRow } from "./layout";
|
||||
export { useFocusTrap, useReducedMotion, ErrorBoundary } from "./hooks";
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useFocusTrap } from "./hooks";
|
||||
|
||||
const EASE = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Modal */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
width = "640px",
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
width?: string;
|
||||
}) {
|
||||
const trapRef = useFocusTrap(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease: EASE }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-[color:var(--foreground)]/45"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
ref={trapRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
initial={{ scale: 0.96, opacity: 0, y: 8 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.96, opacity: 0, y: 8 }}
|
||||
transition={{ duration: 0.22, ease: EASE }}
|
||||
className="flex w-full flex-col rounded-lg border border-border bg-card shadow-xl"
|
||||
style={{ maxWidth: width, maxHeight: "85vh" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-5 py-3.5">
|
||||
<h2 className="min-w-0 truncate text-[16px] font-bold tracking-[-0.015em] text-foreground">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-3 flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 14 14" fill="none"><path d="M1 1l12 12M13 1L1 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 overflow-y-auto overflow-x-auto px-5 py-4" style={{ overflowWrap: "break-word", wordBreak: "break-word" }}>{children}</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* ListFrame */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function ListFrame({
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={`border-y border-border ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* ListRow */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function ListRow({
|
||||
children,
|
||||
className = "",
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 block w-full border-b border-border px-5 py-4 text-left last:border-b-0 ${className}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return <div className={`border-b border-border px-5 py-4 last:border-b-0 ${className}`}>{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { type ElementType } from "react";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Badge */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function Badge({ count, tone = "default" }: { count: number; tone?: "default" | "warn" | "danger" }) {
|
||||
if (count <= 0) return null;
|
||||
const toneClass = tone === "danger" ? "bg-destructive text-primary-foreground" : tone === "warn" ? "bg-warning text-primary-foreground" : "bg-[color:var(--muted-foreground)] text-primary-foreground";
|
||||
return (
|
||||
<span className={`ml-auto inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full px-1.5 text-[10px] font-bold ${toneClass}`}>
|
||||
{count > 99 ? "99+" : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* SectionButton */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function SectionButton({
|
||||
active,
|
||||
label,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
badge,
|
||||
badgeTone,
|
||||
collapsed = false,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
icon: ElementType;
|
||||
onClick: () => void;
|
||||
badge?: number;
|
||||
badgeTone?: "default" | "warn" | "danger";
|
||||
collapsed?: boolean;
|
||||
}) {
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
aria-current={active ? "page" : undefined}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 relative flex w-full items-center justify-center rounded-lg p-2 transition duration-150 ${
|
||||
active
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon size={18} weight={active ? "fill" : "regular"} className={active ? "text-primary" : ""} />
|
||||
{badge ? (
|
||||
<span className={`absolute right-1 top-1 h-[6px] w-[6px] rounded-full ${
|
||||
badgeTone === "danger" ? "bg-destructive" : badgeTone === "warn" ? "bg-warning" : "bg-[color:var(--muted-foreground)]"
|
||||
}`} />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 relative flex w-full items-center gap-2.5 rounded-lg px-3 py-[7px] text-left transition duration-150 ${
|
||||
active
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className={`absolute left-0 top-1/2 h-4 w-[3px] -translate-y-1/2 rounded-r-full bg-primary transition-all duration-200 ${active ? "opacity-100 scale-y-100" : "opacity-0 scale-y-0"}`} aria-hidden="true" />
|
||||
<Icon size={17} weight={active ? "fill" : "regular"} className={active ? "text-primary" : ""} />
|
||||
<span className={`text-[13px] tracking-[0.005em] ${active ? "font-bold" : "font-medium"}`}>{label}</span>
|
||||
{badge ? <Badge count={badge} tone={badgeTone} /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Panel */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
export function Panel({
|
||||
title,
|
||||
eyebrow,
|
||||
children,
|
||||
action,
|
||||
className = "",
|
||||
tone = "plain",
|
||||
}: {
|
||||
title: string;
|
||||
eyebrow?: string;
|
||||
children: ReactNode;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
tone?: "plain" | "surface";
|
||||
}) {
|
||||
return (
|
||||
<section className={`space-y-4 ${className}`}>
|
||||
<div className={`flex items-end justify-between gap-4 ${tone === "surface" ? "border-b border-border pb-3" : ""}`}>
|
||||
<div>
|
||||
{eyebrow ? <div className="mb-1 text-[11px] font-semibold uppercase tracking-[0.08em] text-primary">{eyebrow}</div> : null}
|
||||
<h2 className="text-[20px] font-bold tracking-[-0.025em] text-foreground">{title}</h2>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Field */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">{label}</div>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Input */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function Input({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = "text",
|
||||
readOnly = false,
|
||||
disabled = false,
|
||||
label,
|
||||
hint,
|
||||
id,
|
||||
ariaLabel,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
type?: string;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
id?: string;
|
||||
ariaLabel?: string;
|
||||
}) {
|
||||
const input = (
|
||||
<input
|
||||
id={id}
|
||||
aria-label={ariaLabel}
|
||||
type={type}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card px-3.5 py-2.5 text-[14px] text-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-60"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
);
|
||||
if (label) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
{hint ? <span title={hint} className="cursor-help text-[10px] text-muted-foreground/60 hover:text-muted-foreground">(?)</span> : null}
|
||||
</div>
|
||||
{input}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* TextArea */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function TextArea({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rows = 4,
|
||||
label,
|
||||
hint,
|
||||
readOnly = false,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const textarea = (
|
||||
<textarea
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full max-w-full rounded-lg border border-border bg-card px-3.5 py-2.5 text-[14px] text-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-60 break-words"
|
||||
style={{ overflowWrap: "anywhere", wordBreak: "break-word", whiteSpace: "pre-wrap" }}
|
||||
value={value}
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
);
|
||||
if (label) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
{hint ? <span title={hint} className="cursor-help text-[10px] text-muted-foreground/60 hover:text-muted-foreground">(?)</span> : null}
|
||||
</div>
|
||||
{textarea}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return textarea;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Select */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function Select({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
compact = false,
|
||||
label,
|
||||
hint,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
compact?: boolean;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const select = (
|
||||
<select
|
||||
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full max-w-full border bg-card text-foreground ${
|
||||
compact
|
||||
? "rounded-lg border-border px-2.5 py-1.5 text-[13px] font-medium"
|
||||
: "rounded-lg border-border px-3.5 py-2.5 text-[14px]"
|
||||
} disabled:cursor-not-allowed disabled:opacity-60 truncate`}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-card text-foreground">
|
||||
{option.label.length > 80 ? option.label.slice(0, 77) + "..." : option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
if (label) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
{hint ? <span title={hint} className="cursor-help text-[10px] text-muted-foreground/60 hover:text-muted-foreground">(?)</span> : null}
|
||||
</div>
|
||||
{select}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return select;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* ActionButton */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function ActionButton({
|
||||
label,
|
||||
onClick,
|
||||
icon: Icon,
|
||||
tone = "default",
|
||||
loading = false,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon?: ElementType;
|
||||
tone?: "default" | "accent" | "danger";
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const isDisabled = loading || disabled;
|
||||
const toneClass =
|
||||
tone === "accent"
|
||||
? "border-transparent bg-primary text-primary-foreground shadow-[0_1px_3px_rgba(74,93,122,0.35)] hover:bg-primary/90"
|
||||
: tone === "danger"
|
||||
? "border-transparent bg-destructive text-primary-foreground shadow-[0_1px_3px_rgba(220,53,69,0.3)] hover:brightness-90"
|
||||
: "border-border bg-card text-foreground hover:bg-muted";
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 inline-flex items-center gap-2 rounded-lg border px-4 py-2.5 text-[12px] font-bold transition-all duration-150 active:scale-[0.97] active:translate-y-px ${toneClass} ${isDisabled ? "pointer-events-none opacity-60" : ""} ${loading ? "scale-[0.98]" : ""}`}
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
aria-busy={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<svg className="h-4 w-4 animate-spin" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="2" opacity="0.3" />
|
||||
<path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
) : Icon ? (
|
||||
<Icon size={16} />
|
||||
) : null}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user