This commit is contained in:
ZacharyZhang-NY committed 2026-04-04 12:03:50 -04:00
1 parent 66158e222e
commit 67c757f481
314 files changed
+37945 -22962

No files matched your search

+149 -1734
View File
File diff suppressed because it is too large. Load diff
@@ -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 }
+1 -1
View File
@@ -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;
+5 -600
View File
@@ -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";
+161
View File
@@ -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>
);
}
+91
View File
@@ -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;
}
}
+4
View File
@@ -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";
+117
View File
@@ -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>;
}
+327
View File
@@ -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>
);
}
+389
View File
@@ -0,0 +1,389 @@
import { useCallback, useDeferredValue, useEffect, useMemo, useRef } from "react";
import type {
CompanyRecord,
OnboardingBootstrapResult,
SectionId,
} from "@shared/types";
import type { AppLocale } from "@shared/locale";
import { createTranslator } from "../i18n";
import { useAppStore } from "../state/store";
import { unwrap } from "../lib/desktop";
import { useFocusTrap, useReducedMotion } from "../components/ui";
import {
BookOpen,
Gear,
Tray,
SquaresFour,
UsersThree,
ListChecks,
ChatCircle,
PuzzlePiece,
FileText,
ShieldCheck,
Megaphone,
FolderOpen,
} from "@phosphor-icons/react";
import { useDesktopEvents } from "./useDesktopEvents";
import { useDataLoader } from "./useDataLoader";
import { useCompanyData } from "./useCompanyData";
import { useCommandPaletteHotkeys } from "./useCommandPaletteHotkeys";
type NavItemDef = { id: SectionId; labelKey: string; icon: typeof SquaresFour };
export type NavItem = { id: SectionId; label: string; icon: typeof SquaresFour };
export type PaletteAction = { key: string; section: SectionId; entityId?: string };
const sidebarNavDefs: NavItemDef[] = [
{ id: "overview", labelKey: "nav.overview", icon: SquaresFour },
{ id: "inbox", labelKey: "nav.inbox", icon: Tray },
{ id: "communication", labelKey: "nav.communication", icon: ChatCircle },
{ id: "tasks", labelKey: "nav.work", icon: ListChecks },
{ id: "approvals", labelKey: "nav.approvals", icon: ShieldCheck },
{ id: "agents", labelKey: "nav.agents", icon: UsersThree },
{ id: "workspaces", labelKey: "nav.workspaces", icon: FolderOpen },
{ id: "knowledge", labelKey: "nav.knowledge", icon: BookOpen },
{ id: "documents", labelKey: "nav.documents", icon: FileText },
];
const paletteOnlyNavDefs: NavItemDef[] = [
{ id: "skills", labelKey: "nav.skills", icon: PuzzlePiece },
{ id: "social", labelKey: "nav.social", icon: Megaphone },
];
export function useAppController() {
const reducedMotion = useReducedMotion();
const noMotion = { duration: 0 };
const pageTransition = reducedMotion ? noMotion : { duration: 0.32, ease: [0.16, 1, 0.3, 1] as const };
const panelTransition = reducedMotion ? noMotion : { duration: 0.28, ease: [0.16, 1, 0.3, 1] as const };
const locale = useAppStore((s) => s.locale);
const setLocale = useAppStore((s) => s.setLocale);
const t = useMemo(() => createTranslator(locale), [locale]);
const sidebarNav: NavItem[] = useMemo(() => sidebarNavDefs.map((item) => ({ id: item.id, label: t(item.labelKey), icon: item.icon })), [t]);
const navItems: NavItem[] = useMemo(
() =>
sidebarNav
.concat(
paletteOnlyNavDefs.map((item) => ({
id: item.id,
label: t(item.labelKey),
icon: item.icon,
})),
)
.concat({ id: "settings", label: t("nav.settings"), icon: Gear }),
[sidebarNav, t],
);
const snapshot = useAppStore((s) => s.snapshot);
const setSnapshot = useAppStore((s) => s.setSnapshot);
const section = useAppStore((s) => s.section);
const setSection = useAppStore((s) => s.setSection);
const error = useAppStore((s) => s.error);
const setError = useAppStore((s) => s.setError);
const commandPaletteOpen = useAppStore((s) => s.commandPaletteOpen);
const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen);
const consoleOpen = useAppStore((s) => s.consoleOpen);
const setConsoleOpen = useAppStore((s) => s.setConsoleOpen);
const sidebarCollapsed = useAppStore((s) => s.sidebarCollapsed);
const setSidebarCollapsed = useAppStore((s) => s.setSidebarCollapsed);
const companySwitcherOpen = useAppStore((s) => s.companySwitcherOpen);
const setCompanySwitcherOpen = useAppStore((s) => s.setCompanySwitcherOpen);
const confirmDeleteCompanyId = useAppStore((s) => s.confirmDeleteCompanyId);
const setConfirmDeleteCompanyId = useAppStore((s) => s.setConfirmDeleteCompanyId);
const selectedTaskId = useAppStore((s) => s.selectedTaskId);
const setSelectedTaskId = useAppStore((s) => s.setSelectedTaskId);
const selectedConnectorId = useAppStore((s) => s.selectedConnectorId);
const setSelectedConnectorId = useAppStore((s) => s.setSelectedConnectorId);
const selectedApprovalId = useAppStore((s) => s.selectedApprovalId);
const setSelectedApprovalId = useAppStore((s) => s.setSelectedApprovalId);
const selectedRunId = useAppStore((s) => s.selectedRunId);
const setSelectedRunId = useAppStore((s) => s.setSelectedRunId);
const selectedAgentId = useAppStore((s) => s.selectedAgentId);
const setSelectedAgentId = useAppStore((s) => s.setSelectedAgentId);
const selectedGoalId = useAppStore((s) => s.selectedGoalId);
const setSelectedGoalId = useAppStore((s) => s.setSelectedGoalId);
const selectedProjectId = useAppStore((s) => s.selectedProjectId);
const setSelectedProjectId = useAppStore((s) => s.setSelectedProjectId);
const setSelectedWorkspaceId = useAppStore((s) => s.setSelectedWorkspaceId);
const selectedCommunicationEntityId = useAppStore((s) => s.selectedCommunicationEntityId);
const setSelectedCommunicationEntityId = useAppStore((s) => s.setSelectedCommunicationEntityId);
const taskViewMode = useAppStore((s) => s.taskViewMode);
const setTaskViewMode = useAppStore((s) => s.setTaskViewMode);
const logBufferRef = useRef<Record<string, string>>({});
const incrementLogRevision = useAppStore((s) => s.incrementLogRevision);
const commandPaletteSearch = useAppStore((s) => s.commandPaletteSearch);
const setCommandPaletteSearch = useAppStore((s) => s.setCommandPaletteSearch);
const selectedPaletteIndex = useAppStore((s) => s.selectedPaletteIndex);
const setSelectedPaletteIndex = useAppStore((s) => s.setSelectedPaletteIndex);
const showOnboarding = useAppStore((s) => s.showOnboarding);
const setShowOnboarding = useAppStore((s) => s.setShowOnboarding);
const initialOnboardingLocked = useAppStore((s) => s.initialOnboardingLocked);
const setInitialOnboardingLocked = useAppStore((s) => s.setInitialOnboardingLocked);
const inboxItems = useAppStore((s) => s.inboxItems);
const setInboxItems = useAppStore((s) => s.setInboxItems);
const companyMetrics = useAppStore((s) => s.companyMetrics);
const setCompanyMetrics = useAppStore((s) => s.setCompanyMetrics);
const standupReport = useAppStore((s) => s.standupReport);
const setStandupReport = useAppStore((s) => s.setStandupReport);
const socialAccounts = useAppStore((s) => s.socialAccounts);
const setSocialAccounts = useAppStore((s) => s.setSocialAccounts);
const browserActions = useAppStore((s) => s.browserActions);
const setBrowserActions = useAppStore((s) => s.setBrowserActions);
const setBadges = useAppStore((s) => s.setBadges);
const currentCompany = useMemo<CompanyRecord | null>(() => {
if (!snapshot) return null;
return snapshot.companies.find((company) => company.id === snapshot.currentCompanyId) ?? snapshot.companies[0] ?? null;
}, [snapshot]);
const companyData = useCompanyData(snapshot, currentCompany);
const { tasks, runs, approvals, pendingApprovals, companyAgents, companyGoals, companyProjects,
companyWorkspaces, companyCosts, filteredActivity, companySecrets, companyDocuments,
companyKnowledge, companyMeetings, companySprints, selectedTask, selectedApproval,
selectedRun, selectedAgent, selectedGoal, selectedProject, selectedWorkspace, navBadges } = companyData;
const selectedConnector = snapshot?.connectors.find((connector) => connector.id === selectedConnectorId) ?? snapshot?.connectors[0] ?? null;
const clearCompanyScopedUiState = useCallback(() => {
const store = useAppStore.getState();
store.clearCompanyScopedSelections();
store.setInboxItems([]);
store.setCompanyMetrics(null);
store.setStandupReport(null);
store.setSocialAccounts([]);
store.setBrowserActions([]);
store.setBadges(null);
store.setSkills([]);
store.setSelectedSkillId(null);
store.setSelectedSkillFilePath("SKILL.md");
store.setSkillImportModalOpen(false);
}, []);
const { refresh } = useDataLoader(
snapshot, section, selectedConnectorId, selectedRunId,
setSnapshot, setError, setInitialOnboardingLocked,
setSelectedConnectorId, setSelectedRunId,
setBadges, setInboxItems, setSocialAccounts, setBrowserActions,
setCompanyMetrics, setStandupReport,
);
// Stable ref so the subscribe callback always calls the latest refresh
// without tearing down the subscription when refresh identity changes.
const refreshRef = useRef(refresh);
useEffect(() => { refreshRef.current = refresh; }, [refresh]);
useDesktopEvents(refreshRef, logBufferRef, incrementLogRevision);
useEffect(() => {
if (!snapshot) return;
const root = document.documentElement;
const applyTheme = () => {
const isDark = snapshot.theme === "dark" ||
(snapshot.theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
root.classList.toggle("dark", isDark);
root.style.colorScheme = isDark ? "dark" : "light";
};
applyTheme();
if (snapshot.locale && snapshot.locale !== locale) setLocale(snapshot.locale);
if (snapshot.theme === "system") {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", applyTheme);
return () => mq.removeEventListener("change", applyTheme);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- setLocale is a stable Zustand setter
}, [snapshot, locale]);
useEffect(() => {
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
}, [locale]);
useEffect(() => {
if (selectedRunId && !runs.some((run) => run.id === selectedRunId)) {
setSelectedRunId(runs[0]?.id ?? null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- setSelectedRunId is a stable Zustand setter
}, [runs, selectedRunId]);
const activeRunId = selectedRun?.id ?? null;
useEffect(() => {
if (!activeRunId) return;
void (async () => {
const activeRun = runs.find((run) => run.id === activeRunId);
if (!activeRun) return;
const result = await window.agentCompany.getRunLog({ companyId: activeRun.companyId, runId: activeRunId });
if (result.ok) {
logBufferRef.current = { ...logBufferRef.current, [activeRunId]: result.data };
incrementLogRevision();
return;
}
setError(result.error.message);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps -- incrementLogRevision and setError are stable Zustand setters
}, [activeRunId, runs]);
const handleLocaleChange = useCallback(async (newLocale: AppLocale) => {
setLocale(newLocale);
try { await window.agentCompany.updateSettings({ locale: newLocale }); } catch { /* local state already updated */ }
// eslint-disable-next-line react-hooks/exhaustive-deps -- setLocale is a stable Zustand setter
}, []);
const paletteTrapRef = useFocusTrap(commandPaletteOpen);
const paletteQuery = useDeferredValue(commandPaletteSearch.trim().toLowerCase());
const paletteResults = useMemo(() => {
const filteredNavItems = paletteQuery
? navItems.filter((item) => item.label.toLowerCase().includes(paletteQuery) || item.id.toLowerCase().includes(paletteQuery))
: navItems;
const matchedAgents = paletteQuery ? companyAgents.filter((a) => a.name.toLowerCase().includes(paletteQuery) || a.role.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
const matchedTasks = paletteQuery ? tasks.filter((t) => t.title.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
const matchedRuns = paletteQuery ? runs.filter((run) => {
const matchingTask = tasks.find((task) => task.id === run.taskId);
return [run.id, run.summary ?? "", run.errorMessage ?? "", matchingTask?.title ?? "", run.connectorId]
.some((value) => value.toLowerCase().includes(paletteQuery));
}).slice(0, 5) : [];
const matchedGoals = paletteQuery ? companyGoals.filter((g) => g.title.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
const matchedProjects = paletteQuery ? companyProjects.filter((p) => p.name.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
const hasEntityResults = matchedAgents.length > 0 || matchedTasks.length > 0 || matchedRuns.length > 0 || matchedGoals.length > 0 || matchedProjects.length > 0;
return { filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects, hasEntityResults };
}, [paletteQuery, companyAgents, companyGoals, companyProjects, runs, tasks, navItems]);
const { filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects, hasEntityResults } = paletteResults;
const paletteActions = useMemo<PaletteAction[]>(() => ([
...filteredNavItems.map((item) => ({ key: `section:${item.id}`, section: item.id })),
...matchedAgents.map((agent) => ({ key: `agent:${agent.id}`, section: "agents" as const, entityId: agent.id })),
...matchedTasks.map((task) => ({ key: `task:${task.id}`, section: "tasks" as const, entityId: task.id })),
...matchedRuns.map((run) => ({ key: `run:${run.id}`, section: "runs" as const, entityId: run.id })),
...matchedGoals.map((goal) => ({ key: `goal:${goal.id}`, section: "goals" as const, entityId: goal.id })),
...matchedProjects.map((project) => ({ key: `project:${project.id}`, section: "projects" as const, entityId: project.id })),
]), [filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects]);
const paletteActionIndex = useMemo(() => new Map(paletteActions.map((action, index) => [action.key, index])), [paletteActions]);
const navigateTo = useCallback((targetSection: SectionId, entityId?: string) => {
setSection(targetSection);
if (entityId) {
if (targetSection === "agents") setSelectedAgentId(entityId);
else if (targetSection === "connectors") setSelectedConnectorId(entityId);
else if (targetSection === "runs") { setSelectedRunId(entityId); setConsoleOpen(true); }
else if (targetSection === "tasks") {
const matchingTask = tasks.find((task) => task.id === entityId) ?? null;
if (matchingTask) { setSelectedTaskId(matchingTask.id); }
else {
const matchingRun = runs.find((run) => run.id === entityId) ?? null;
if (matchingRun) { setSelectedRunId(matchingRun.id); setSelectedTaskId(matchingRun.taskId); setConsoleOpen(true); }
}
}
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
else if (targetSection === "goals") setSelectedGoalId(entityId);
else if (targetSection === "projects") setSelectedProjectId(entityId);
else if (targetSection === "communication") setSelectedCommunicationEntityId(entityId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Zustand setters are stable references
}, [runs, tasks]);
useCommandPaletteHotkeys({
commandPaletteOpen,
setCommandPaletteOpen,
setCommandPaletteSearch,
setSelectedPaletteIndex,
paletteActions,
selectedPaletteIndex,
navigateTo,
commandPaletteSearch,
});
const setCompany = useCallback(async (companyId: string) => {
if (companyId === "__new__") { setShowOnboarding(true); return; }
try {
unwrap(await window.agentCompany.setCurrentCompany(companyId));
clearCompanyScopedUiState();
await refresh();
setError(null);
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
// eslint-disable-next-line react-hooks/exhaustive-deps -- setError and setShowOnboarding are stable Zustand setters
}, [clearCompanyScopedUiState, refresh]);
const deleteCompany = useCallback(async (companyId: string) => {
try {
unwrap(await window.agentCompany.deleteCompany(companyId));
setConfirmDeleteCompanyId(null);
setCompanySwitcherOpen(false);
await refresh();
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
// eslint-disable-next-line react-hooks/exhaustive-deps -- Zustand setters are stable references
}, [refresh]);
const startRun = useCallback(async (taskId: string) => {
if (!currentCompany) { setError("Select a company before starting a run."); return; }
const result = await window.agentCompany.startTaskRun({ companyId: currentCompany.id, taskId });
if (!result.ok) { setError(result.error.message); return; }
setSelectedRunId(result.data);
setConsoleOpen(true);
await refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps -- Zustand setters are stable references
}, [currentCompany, refresh]);
const cancelRun = useCallback(async (runId: string) => {
try {
const run = runs.find((entry) => entry.id === runId);
if (!run) throw new Error("Selected run is no longer available.");
unwrap(await window.agentCompany.cancelRun({ companyId: run.companyId, runId }));
await refresh();
setError(null);
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
// eslint-disable-next-line react-hooks/exhaustive-deps -- setError is a stable Zustand setter
}, [refresh, runs]);
const handleOnboardingDone = useCallback(async (summary: OnboardingBootstrapResult) => {
setShowOnboarding(false);
setInitialOnboardingLocked(false);
await refresh();
setError(null);
setSection("overview");
if (summary.runId) setSelectedRunId(summary.runId);
if (summary.leadAgentId) setSelectedAgentId(summary.leadAgentId);
// eslint-disable-next-line react-hooks/exhaustive-deps -- Zustand setters are stable references
}, [refresh]);
const openPathWithFeedback = useCallback(async (path: string) => {
try { unwrap(await window.agentCompany.openPath(path)); setError(null); }
catch (err) { setError(err instanceof Error ? err.message : String(err)); }
// eslint-disable-next-line react-hooks/exhaustive-deps -- setError is a stable Zustand setter
}, []);
return {
pageTransition, panelTransition,
locale, t, handleLocaleChange,
sidebarNav, navItems, navBadges,
snapshot, currentCompany,
showOnboarding, initialOnboardingLocked, handleOnboardingDone, setShowOnboarding, setInitialOnboardingLocked, setError,
sidebarCollapsed, setSidebarCollapsed,
companySwitcherOpen, setCompanySwitcherOpen,
confirmDeleteCompanyId, setConfirmDeleteCompanyId,
setCompany, deleteCompany,
section, setSection, error, consoleOpen, setConsoleOpen,
tasks, runs, approvals, pendingApprovals,
companyAgents, companyGoals, companyProjects, companyWorkspaces,
companyCosts, filteredActivity, companySecrets, companyDocuments,
companyKnowledge, companyMeetings, companySprints,
socialAccounts, browserActions, inboxItems, companyMetrics, standupReport,
selectedTask, selectedTaskId, setSelectedTaskId,
selectedConnector, setSelectedConnectorId,
selectedApproval, selectedApprovalId, setSelectedApprovalId,
selectedAgent, selectedAgentId, setSelectedAgentId,
selectedGoal, selectedGoalId, setSelectedGoalId,
selectedProject, selectedProjectId, setSelectedProjectId,
selectedWorkspace, setSelectedWorkspaceId,
selectedCommunicationEntityId, setSelectedCommunicationEntityId,
selectedRun, selectedRunId, setSelectedRunId,
taskViewMode, setTaskViewMode,
logBufferRef,
navigateTo, refresh, startRun, cancelRun, openPathWithFeedback,
commandPaletteOpen, setCommandPaletteOpen,
commandPaletteSearch, setCommandPaletteSearch,
selectedPaletteIndex, setSelectedPaletteIndex,
paletteTrapRef,
filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects, hasEntityResults,
paletteActions, paletteActionIndex,
};
}
@@ -0,0 +1,65 @@
import { useEffect } from "react";
import { useAppStore } from "../state/store";
import type { PaletteAction } from "./useAppController";
interface CommandPaletteHotkeysOptions {
commandPaletteOpen: boolean;
setCommandPaletteOpen: (open: boolean) => void;
setCommandPaletteSearch: (value: string) => void;
setSelectedPaletteIndex: (index: number) => void;
paletteActions: PaletteAction[];
selectedPaletteIndex: number;
navigateTo: (section: PaletteAction["section"], entityId?: string) => void;
commandPaletteSearch: string;
}
export function useCommandPaletteHotkeys({
commandPaletteOpen,
setCommandPaletteOpen,
setCommandPaletteSearch,
setSelectedPaletteIndex,
paletteActions,
selectedPaletteIndex,
navigateTo,
commandPaletteSearch,
}: CommandPaletteHotkeysOptions) {
useEffect(() => {
function onGlobalKey(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
event.preventDefault();
setCommandPaletteOpen(!commandPaletteOpen);
if (!commandPaletteOpen) {
setCommandPaletteSearch("");
setSelectedPaletteIndex(0);
}
}
}
document.addEventListener("keydown", onGlobalKey);
return () => document.removeEventListener("keydown", onGlobalKey);
}, [commandPaletteOpen, setCommandPaletteOpen, setCommandPaletteSearch, setSelectedPaletteIndex]);
useEffect(() => {
if (!commandPaletteOpen) return;
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") { setCommandPaletteOpen(false); setCommandPaletteSearch(""); setSelectedPaletteIndex(0); return; }
if (paletteActions.length === 0) return;
if (event.key === "ArrowDown") { event.preventDefault(); setSelectedPaletteIndex((useAppStore.getState().selectedPaletteIndex + 1) % paletteActions.length); return; }
if (event.key === "ArrowUp") { event.preventDefault(); setSelectedPaletteIndex((useAppStore.getState().selectedPaletteIndex - 1 + paletteActions.length) % paletteActions.length); return; }
if (event.key !== "Enter") return;
event.preventDefault();
const selectedAction = paletteActions[Math.min(selectedPaletteIndex, paletteActions.length - 1)];
if (!selectedAction) return;
navigateTo(selectedAction.section, selectedAction.entityId);
setCommandPaletteOpen(false);
setCommandPaletteSearch("");
setSelectedPaletteIndex(0);
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [commandPaletteOpen, navigateTo, paletteActions, selectedPaletteIndex, setCommandPaletteOpen, setCommandPaletteSearch, setSelectedPaletteIndex]);
useEffect(() => {
if (!commandPaletteOpen) return;
setSelectedPaletteIndex(0);
}, [commandPaletteOpen, commandPaletteSearch, setSelectedPaletteIndex]);
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Company Context Provider
*
* Provides shared company infrastructure to section components, eliminating
* the need to thread 80+ props through SectionContentView. Components can
* import useCompanyContext() instead of receiving props from the root.
*
* This context holds stable references to company data and actions that
* nearly every section needs: companyId, snapshot, refresh, navigation, etc.
*/
import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { CompanyRecord, ProfileSnapshot, SectionId } from "@shared/types";
export interface CompanyContextValue {
companyId: string | null;
currentCompany: CompanyRecord | null;
snapshot: ProfileSnapshot | null;
refresh: () => void;
navigateTo: (section: SectionId, entityId?: string) => void;
setSection: (section: SectionId) => void;
setError: (error: string | null) => void;
openPath: (targetPath: string) => Promise<void>;
}
const CompanyContext = createContext<CompanyContextValue | null>(null);
interface CompanyContextProviderProps {
companyId: string | null;
currentCompany: CompanyRecord | null;
snapshot: ProfileSnapshot | null;
refresh: () => void;
navigateTo: (section: SectionId, entityId?: string) => void;
setSection: (section: SectionId) => void;
setError: (error: string | null) => void;
openPath: (targetPath: string) => Promise<void>;
children: ReactNode;
}
export function CompanyContextProvider({
companyId, currentCompany, snapshot, refresh,
navigateTo, setSection, setError, openPath, children,
}: CompanyContextProviderProps) {
const value = useMemo<CompanyContextValue>(() => ({
companyId, currentCompany, snapshot, refresh,
navigateTo, setSection, setError, openPath,
}), [companyId, currentCompany, snapshot, refresh, navigateTo, setSection, setError, openPath]);
return <CompanyContext.Provider value={value}>{children}</CompanyContext.Provider>;
}
export function useCompanyContext(): CompanyContextValue {
const ctx = useContext(CompanyContext);
if (!ctx) throw new Error("useCompanyContext must be used within CompanyContextProvider");
return ctx;
}
+79
View File
@@ -0,0 +1,79 @@
import { useMemo } from "react";
import type { CompanyRecord, ProfileSnapshot, SectionId } from "@shared/types";
import { useAppStore } from "../state/store";
export function useCompanyData(
snapshot: ProfileSnapshot | null,
currentCompany: CompanyRecord | null,
) {
const selectedRunId = useAppStore((s) => s.selectedRunId);
const selectedTaskId = useAppStore((s) => s.selectedTaskId);
const selectedApprovalId = useAppStore((s) => s.selectedApprovalId);
const selectedAgentId = useAppStore((s) => s.selectedAgentId);
const selectedGoalId = useAppStore((s) => s.selectedGoalId);
const selectedProjectId = useAppStore((s) => s.selectedProjectId);
const selectedWorkspaceId = useAppStore((s) => s.selectedWorkspaceId);
const tasks = useMemo(() => snapshot?.tasks.filter((task) => !currentCompany || task.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const approvals = useMemo(() => snapshot?.approvals.filter((approval) => !currentCompany || approval.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const pendingApprovals = useMemo(() => approvals.filter((approval) => approval.state === "pending"), [approvals]);
const runs = useMemo(() => snapshot?.runs.filter((run) => !currentCompany || run.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const companyAgents = useMemo(() => snapshot?.agents.filter((a) => !currentCompany || a.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const companyGoals = useMemo(() => snapshot?.goals.filter((g) => !currentCompany || g.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const companyProjects = useMemo(() => snapshot?.projects.filter((p) => !currentCompany || p.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const companyWorkspaces = useMemo(() => snapshot?.workspaces.filter((workspace) => !currentCompany || workspace.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const companyCosts = useMemo(() => snapshot?.costs.filter((entry) => !currentCompany || entry.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const filteredActivity = useMemo(() => snapshot?.activity.filter((entry) => !currentCompany || entry.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const companySecrets = useMemo(() => snapshot?.secrets.filter((secret) => !currentCompany || secret.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const companyDocuments = useMemo(() => snapshot?.documents?.filter((d) => !currentCompany || d.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const companyKnowledge = useMemo(() => snapshot?.knowledgeBase?.filter((k) => !currentCompany || k.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const companyMeetings = useMemo(() => snapshot?.meetings?.filter((m) => !currentCompany || m.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const companySprints = useMemo(() => snapshot?.sprints?.filter((s) => !currentCompany || s.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const selectedTask = selectedTaskId ? (tasks.find((task) => task.id === selectedTaskId) ?? null) : null;
const selectedApproval = selectedApprovalId ? (approvals.find((approval) => approval.id === selectedApprovalId) ?? null) : null;
const selectedRun = runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null;
const selectedAgent = companyAgents.find((a) => a.id === selectedAgentId) ?? null;
const selectedGoal = companyGoals.find((g) => g.id === selectedGoalId) ?? null;
const selectedProject = companyProjects.find((p) => p.id === selectedProjectId) ?? null;
const selectedWorkspace = companyWorkspaces.find((workspace) => workspace.id === selectedWorkspaceId) ?? companyWorkspaces[0] ?? null;
const navBadgesSource = useAppStore((s) => s.badges);
const navBadges = useMemo<Partial<Record<SectionId, { count: number; tone: "default" | "warn" | "danger" }>>>(() => navBadgesSource ? {
runs:
navBadgesSource.failedRuns > 0
? { count: navBadgesSource.failedRuns, tone: "danger" }
: navBadgesSource.activeRuns > 0
? { count: navBadgesSource.activeRuns, tone: "default" }
: undefined,
tasks: navBadgesSource.todoTasks > 0 ? { count: navBadgesSource.todoTasks, tone: "default" } : undefined,
approvals: navBadgesSource.pendingApprovals > 0 ? { count: navBadgesSource.pendingApprovals, tone: "warn" } : undefined,
communication: navBadgesSource.unreadMessages > 0 ? { count: navBadgesSource.unreadMessages, tone: "warn" } : undefined,
} : {}, [navBadgesSource]);
return {
tasks,
approvals,
pendingApprovals,
runs,
companyAgents,
companyGoals,
companyProjects,
companyWorkspaces,
companyCosts,
filteredActivity,
companySecrets,
companyDocuments,
companyKnowledge,
companyMeetings,
companySprints,
selectedTask,
selectedApproval,
selectedRun,
selectedAgent,
selectedGoal,
selectedProject,
selectedWorkspace,
navBadges,
};
}
+85
View File
@@ -0,0 +1,85 @@
import { useCallback, useEffect } from "react";
import type { ProfileSnapshot, SectionId } from "@shared/types";
import { unwrap } from "../lib/desktop";
import { useAppStore } from "../state/store";
export function useDataLoader(
snapshot: ProfileSnapshot | null,
section: SectionId,
selectedConnectorId: string | null,
selectedRunId: string | null,
setSnapshot: (snapshot: ProfileSnapshot) => void,
setError: (error: string | null) => void,
setInitialOnboardingLocked: (locked: boolean) => void,
setSelectedConnectorId: (id: string | null) => void,
setSelectedRunId: (id: string | null) => void,
setBadges: (badges: Parameters<ReturnType<typeof useAppStore.getState>["setBadges"]>[0]) => void,
setInboxItems: (items: Parameters<ReturnType<typeof useAppStore.getState>["setInboxItems"]>[0]) => void,
setSocialAccounts: (accounts: Parameters<ReturnType<typeof useAppStore.getState>["setSocialAccounts"]>[0]) => void,
setBrowserActions: (actions: Parameters<ReturnType<typeof useAppStore.getState>["setBrowserActions"]>[0]) => void,
setCompanyMetrics: (metrics: Parameters<ReturnType<typeof useAppStore.getState>["setCompanyMetrics"]>[0]) => void,
setStandupReport: (report: Parameters<ReturnType<typeof useAppStore.getState>["setStandupReport"]>[0]) => void,
) {
const refresh = useCallback(async () => {
try {
const next = unwrap<ProfileSnapshot>(await window.agentCompany.loadSnapshot());
setSnapshot(next);
if (next.companies.length === 0) {
setInitialOnboardingLocked(true);
}
if (!selectedConnectorId && next.connectors[0]) setSelectedConnectorId(next.connectors[0].id);
if (!selectedRunId && next.runs[0]) setSelectedRunId(next.runs[0].id);
if (next.currentCompanyId) {
const badgeResult = await window.agentCompany.getSidebarBadges(next.currentCompanyId);
if (badgeResult.ok) setBadges(badgeResult.data);
const inboxResult = await window.agentCompany.getInbox(next.currentCompanyId);
if (inboxResult.ok) setInboxItems(inboxResult.data);
setSocialAccounts(next.socialAccounts?.filter(a => a.companyId === next.currentCompanyId) ?? []);
const browserActionResult = await window.agentCompany.listBrowserActions({ companyId: next.currentCompanyId, limit: 50 });
if (browserActionResult.ok) {
setBrowserActions(browserActionResult.data);
}
}
setError(null);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : String(nextError));
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Zustand setters are stable references
}, [selectedConnectorId, selectedRunId]);
// Section-based data loading
useEffect(() => {
if (!snapshot?.currentCompanyId) return;
const companyId = snapshot.currentCompanyId;
let cancelled = false;
if (section === "inbox") {
void window.agentCompany.getInbox(companyId).then((result) => {
if (!cancelled && result.ok) setInboxItems(result.data);
});
}
if (section === "performance") {
void window.agentCompany.getCompanyMetrics({ companyId, days: 30 }).then((result) => {
if (!cancelled && result.ok) setCompanyMetrics(result.data);
});
}
if (section === "standup") {
void window.agentCompany.getStandupReport(companyId).then((result) => {
if (!cancelled && result.ok) setStandupReport(result.data);
});
}
if (section === "social") {
setSocialAccounts(snapshot.socialAccounts?.filter((a) => a.companyId === companyId) ?? []);
}
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- Zustand setters are stable references
}, [section, snapshot]);
return { refresh };
}
+152
View File
@@ -0,0 +1,152 @@
import { useEffect, type MutableRefObject } from "react";
import type { DesktopEvent } from "@shared/types";
import { createDomainEventBatcher, type GranularEventEntry } from "../lib/ipc-batcher";
import { useAppStore } from "../state/store";
export function useDesktopEvents(
refreshRef: MutableRefObject<() => Promise<void>>,
logBufferRef: MutableRefObject<Record<string, string>>,
incrementLogRevision: () => void,
) {
useEffect(() => {
void refreshRef.current();
const store = useAppStore;
async function handleGranularBatch(events: GranularEventEntry[]) {
const companyId = store.getState().currentCompanyId;
if (!companyId) {
void refreshRef.current();
return;
}
let needsFullRefresh = false;
for (const entry of events) {
// run-updated:completed touches too many entity types -- full refresh
if (entry.type === "run-updated" && entry.action === "completed") {
needsFullRefresh = true;
break;
}
try {
switch (entry.type) {
case "task-updated": {
if (entry.action === "deleted") {
store.getState().removeTask(entry.entityId);
} else {
const result = await window.agentCompany.getTask({ companyId, id: entry.entityId });
if (result.ok) {
store.getState().patchTask(result.data);
} else {
store.getState().removeTask(entry.entityId);
}
}
break;
}
case "run-updated": {
if (entry.action === "deleted") {
store.getState().removeRun(entry.entityId);
} else {
const result = await window.agentCompany.getRun({ companyId, id: entry.entityId });
if (result.ok) {
store.getState().patchRun(result.data);
} else {
store.getState().removeRun(entry.entityId);
}
}
break;
}
case "approval-updated": {
if (entry.action === "deleted") {
store.getState().removeApproval(entry.entityId);
} else {
const result = await window.agentCompany.getApproval({ companyId, id: entry.entityId });
if (result.ok) {
store.getState().patchApproval(result.data);
} else {
store.getState().removeApproval(entry.entityId);
}
}
break;
}
case "agent-updated": {
const result = await window.agentCompany.getAgent({ companyId, id: entry.entityId });
if (result.ok) {
store.getState().patchAgent(result.data);
} else {
needsFullRefresh = true;
}
break;
}
case "budget-changed": {
// Budget is a computed aggregate -- full refresh is needed
needsFullRefresh = true;
break;
}
default: {
// Unknown granular event type -- fall back (per D-05 / PERF-03)
needsFullRefresh = true;
break;
}
}
} catch {
// IPC error during entity fetch -- fall back to full refresh (per pitfall 1)
needsFullRefresh = true;
break;
}
if (needsFullRefresh) break;
}
if (needsFullRefresh) {
void refreshRef.current();
} else {
// Refresh sidebar badges after granular patches (per pitfall 3)
void store.getState().refreshBadges();
}
}
const batcher = createDomainEventBatcher(
() => { void refreshRef.current(); },
(events) => { void handleGranularBatch(events); },
50,
);
const unsubscribe = window.agentCompany.subscribe((event: DesktopEvent) => {
if (event.type === "run-log") {
const MAX_LOG_BYTES_PER_RUN = 512_000;
const MAX_TRACKED_RUNS = 50;
const existing = logBufferRef.current[event.runId] ?? "";
const updated = existing.length < MAX_LOG_BYTES_PER_RUN
? existing + event.chunk
: existing.slice(-MAX_LOG_BYTES_PER_RUN / 2) + event.chunk;
logBufferRef.current[event.runId] = updated;
const runIds = Object.keys(logBufferRef.current);
if (runIds.length > MAX_TRACKED_RUNS) {
for (const oldId of runIds.slice(0, runIds.length - MAX_TRACKED_RUNS)) {
delete logBufferRef.current[oldId];
}
}
incrementLogRevision();
return;
}
if (event.type === "shortcut") {
if (event.action === "toggle-command-palette") store.getState().toggleCommandPalette();
if (event.action === "toggle-console") store.getState().toggleConsole();
if (event.action === "refresh") void refreshRef.current();
return;
}
if (event.type === "notification-click") {
store.getState().navigateTo(event.section, event.entityId);
return;
}
// All data-mutating events go through the enhanced batcher
batcher.push(event);
});
return () => {
unsubscribe();
batcher.dispose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- refreshRef and logBufferRef are stable refs; incrementLogRevision is a stable Zustand setter
}, []);
}
+84 -66
View File
@@ -51,46 +51,46 @@ export const en: Record<string, string> = {
"app.search": "Search sections, agents, tasks, goals, projects...",
// ── Onboarding flow ──
"onboarding.title": "Start a real local-first\nagent company.",
"onboarding.title": "Start your\nAI company.",
"onboarding.subtitle": "AgentCompany Desktop",
"onboarding.description": "Set up your company, connect an AI runtime, and launch your first task.",
"onboarding.step1Title": "Create the operating boundary",
"onboarding.step2Title": "Define the first real goal",
"onboarding.step3Title": "Launch summary",
"onboarding.description": "Name it, connect a runtime, and start work.",
"onboarding.step1Title": "Company info",
"onboarding.step2Title": "First goal",
"onboarding.step3Title": "Ready to launch",
"onboarding.stepLabel1": "Company",
"onboarding.stepLabel2": "Mission",
"onboarding.stepLabel3": "Launch",
"onboarding.companyName": "Company name",
"onboarding.companyNamePlaceholder": "e.g. Northline Systems",
"onboarding.companyDescription": "Company description",
"onboarding.companyDescriptionPlaceholder": "Optional context for the company operating model.",
"onboarding.companyDescriptionPlaceholder": "What does this company do?",
"onboarding.primaryConnector": "Primary connector",
"onboarding.connectorReadiness": "Connector readiness",
"onboarding.connectorInfo": "If this list is empty, check connector health in Settings.",
"onboarding.connectorInfo": "If this is empty, check Settings.",
"onboarding.workspaceFolder": "Workspace folder",
"onboarding.workspaceFolderOptional": "(optional)",
"onboarding.workspaceFolderPlaceholder": "/Users/you/project",
"onboarding.workspaceHelp": "First task will use this as the workspace.",
"onboarding.workspaceHelp": "The first task starts here.",
"onboarding.autoApprove": "Auto-approve hire and strategy approvals",
"onboarding.autoApproveLabel": "Auto-approve hire and strategy approvals",
"onboarding.autoApproveHelp": "Skips board review for hiring and strategy changes.",
"onboarding.autoApproveHelp": "Hiring and strategy changes auto-approve.",
"onboarding.reviewDeliverablesLabel": "Review important deliverables",
"onboarding.reviewDeliverablesHelp": "Pause workflow for your review on PRDs, design docs, and key documents.",
"onboarding.reviewDeliverablesHelp": "Important deliverables wait for your review.",
"onboarding.automationMode": "Automation mode",
"onboarding.modeFullAuto": "Full automation",
"onboarding.modeFullAutoDesc": "Agents run end-to-end without pausing. Best for hands-off operation.",
"onboarding.modeFullAutoDesc": "Runs on its own.",
"onboarding.modeHumanReview": "Human in the loop",
"onboarding.modeHumanReviewDesc": "Pause for your review on PRDs, design docs, and other key deliverables.",
"onboarding.modeHumanReviewDesc": "Stops at key points for review.",
"onboarding.continue": "Continue",
"onboarding.back": "Back",
"onboarding.goal": "Goal",
"onboarding.goalPlaceholder": "e.g. Ship the first customer-ready desktop demo",
"onboarding.acceptanceCriteria": "Acceptance criteria",
"onboarding.acceptanceCriteriaPlaceholder": "What does success look like?",
"onboarding.acceptanceCriteriaPlaceholder": "What counts as done?",
"onboarding.executionHandoff": "Execution handoff",
"onboarding.executionHandoffLine1": "The selected connector runs the CEO agent.",
"onboarding.executionHandoffLine2": "Creates the company, workspace, CEO agent, project, and first task.",
"onboarding.executionHandoffLine3": "Execution starts immediately or when the runtime is available.",
"onboarding.executionHandoffLine1": "The selected runtime powers the CEO.",
"onboarding.executionHandoffLine2": "We create the company, workspace, and first task.",
"onboarding.executionHandoffLine3": "Work starts as soon as the runtime is ready.",
"onboarding.launching": "Launching...",
"onboarding.launchCompany": "Launch Company",
"onboarding.isLive": "is live.",
@@ -114,29 +114,46 @@ export const en: Record<string, string> = {
"onboarding.cancel": "Cancel",
"onboarding.enterCompanyName": "Enter a company name.",
"onboarding.connectorRequired": "A ready connector is required to continue.",
"onboarding.describeFirstGoal": "Describe the first real company goal.",
"onboarding.unableToBootstrap": "Unable to launch the company. Check your connector authentication and try again.",
"onboarding.describeFirstGoal": "Add the first goal.",
"onboarding.unableToBootstrap": "Launch failed. Check the connector and try again.",
"onboarding.unableToOpenControlCenter": "Unable to open the control center.",
"onboarding.connectorNotInstalled": "No CLI runtime detected. Install and authenticate a supported runtime to continue.",
"onboarding.connectorWarning": "Install and authenticate a CLI runtime first.",
"onboarding.connectorNotInstalled": "No CLI runtime found. Install and sign in first.",
"onboarding.connectorWarning": "Install and sign in to a runtime first.",
"onboarding.languageSelection": "Language",
"onboarding.selectLanguage": "Select language",
"onboarding.welcomeSubtitle": "Your local-first AI agent company starts here.",
"onboarding.welcomeSubtitle": "Build your AI company here.",
"onboarding.getStarted": "Get Started",
"onboarding.stepLabelNodeCheck": "Requirements",
"onboarding.stepLabelProviders": "Providers",
"onboarding.stepLabelCompany": "Company",
"onboarding.stepLabelModelPool": "Team Models",
"onboarding.stepLabelMode": "Mode",
"onboarding.stepLabelMission": "Mission",
"onboarding.stepLabelLaunch": "Launch",
"onboarding.companyStepModelPoolHint": "You'll choose team models next.",
"onboarding.modelPoolStepTitle": "Choose team models",
"onboarding.modelPoolStepSubtitle": "These are the models your team can use.",
"onboarding.modelPoolStepDescription": "The CEO only assigns models that are enabled here.",
"onboarding.modelPoolGlobalRequirement": "Turn on at least one model to continue.",
"onboarding.modelPoolSelectedConnectorRequirement": "Turn on one model for the CEO runtime, or pick another provider.",
"onboarding.modelPoolContinueHint": "You can change this later in Settings.",
"onboarding.modeStepTitle": "How should it run?",
"onboarding.modeAiCeoTitle": "AI CEO",
"onboarding.modeAiCeoSubtitle": "Fully Autonomous",
"onboarding.modeAiCeoDesc": "AI handles the work end to end. Best for 0→1.",
"onboarding.modeCofounderTitle": "AI Co-Founder",
"onboarding.modeCofounderSubtitle": "Human-Reviewed",
"onboarding.modeCofounderDesc": "Key decisions wait for you. Best for research or growth.",
"onboarding.modeCanChange": "You can change this later.",
"onboarding.nodeCheckTitle": "Check Node.js",
"onboarding.nodeCheckStep": "SYSTEM REQUIREMENTS",
"onboarding.nodeInstalled": "Node.js {version} installed",
"onboarding.nodeNotInstalled": "Node.js is not installed",
"onboarding.nodeRequired": "Node.js is required to run CLI-based agent connectors.",
"onboarding.nodeRequired": "CLI runtimes need Node.js.",
"onboarding.checkAgain": "Check Again",
"onboarding.installNodeTerminal": "Open Terminal & Install",
"onboarding.providersTitle": "Configure AI Providers",
"onboarding.providersSubtitle": "Set up at least one provider to power your agents. You can configure more later in Settings.",
"onboarding.providersTitle": "Set up runtimes",
"onboarding.providersSubtitle": "You need one working runtime. Add more later if needed.",
"onboarding.providersStep": "AI PROVIDERS",
"onboarding.providerReady": "Ready",
"onboarding.providerNotInstalled": "Not installed",
@@ -151,8 +168,8 @@ export const en: Record<string, string> = {
"onboarding.providerModel": "Model",
"onboarding.providerSelectProvider": "Select a provider...",
"onboarding.providerViewDocs": "View docs",
"onboarding.providerCompatibleNote": "This overrides Claude Code Official API key settings.",
"onboarding.providerAtLeastOne": "Configure at least one provider to continue.",
"onboarding.providerCompatibleNote": "This overrides the Claude Code API setup.",
"onboarding.providerAtLeastOne": "Set up one runtime to continue.",
"onboarding.claudeCodeLabel": "Claude Code",
"onboarding.codexLabel": "Codex",
"onboarding.geminiLabel": "Gemini CLI",
@@ -357,13 +374,13 @@ export const en: Record<string, string> = {
// ── Execution / Task Inspector ──
"exec.taskQueueEmpty": "All caught up",
"exec.taskQueueEmptyDetail": "New tasks will appear here as agents plan and break down work.",
"exec.taskQueueEmptyDetail": "New tasks show up here.",
"exec.deleteTask": "Delete task",
"exec.deleteTaskConfirm": "Permanently delete \"{0}\"? This cannot be undone.",
"exec.budgetUsed": "has used {0}% of their monthly budget",
"exec.taskChangesSaved": "Task changes saved.",
"exec.taskMovedTo": "Task moved to {0}.",
"exec.approvalCreatedBlocked": "Approval created and routed to leadership.",
"exec.approvalCreatedBlocked": "Approval sent to leadership.",
"exec.approvalCreated": "Approval created.",
"exec.title": "Title",
"exec.description": "Description",
@@ -385,11 +402,11 @@ export const en: Record<string, string> = {
"exec.workspaceLabel": "Workspace:",
"exec.costLabel": "Cost:",
"exec.beingReviewedBy": "Being reviewed by {0}",
"exec.submittedForReview": "{0} submitted this task for review by {1}.",
"exec.submittedForReview": "{0} sent this task to {1} for review.",
"exec.governanceRequired": "Governance action required",
"exec.waitingOnReview": "Task is waiting on review",
"exec.blockedGovernance": "Create an approval to unblock this task.",
"exec.reviewGovernance": "Request a decision before proceeding.",
"exec.blockedGovernance": "Create an approval to continue.",
"exec.reviewGovernance": "Get a decision before continuing.",
"exec.createUnblockApproval": "Create unblock approval",
"exec.createReviewApproval": "Create review approval",
"exec.cancelRun": "Cancel run",
@@ -412,11 +429,11 @@ export const en: Record<string, string> = {
"exec.createTask": "Create task",
"exec.taskTitle": "Task title",
"exec.noReviewsInProgress": "No reviews pending",
"exec.noReviewsDetail": "Tasks waiting for agent review will appear here.",
"exec.noReviewsDetail": "Review items show up here.",
"exec.awaitingReview": "awaiting review",
"exec.reviewedBy": "reviewed by",
"exec.noApprovals": "No pending approvals",
"exec.noApprovalsDetail": "Approval requests from agents will appear here when they need your sign-off.",
"exec.noApprovalsDetail": "Approvals show up here when agents need you.",
"exec.deleteApproval": "Delete approval",
"exec.deleteApprovalConfirm": "Permanently delete this approval? This cannot be undone.",
"exec.requestedBy": "Requested by:",
@@ -433,13 +450,13 @@ export const en: Record<string, string> = {
"exec.reportsTo": "Reports to:",
"exec.orgStrategy": "Organization Strategy",
"exec.decisionNote": "Decision note",
"exec.decisionNotePlaceholder": "Optional note to accompany your decision...",
"exec.decisionNotePlaceholder": "Optional note...",
"exec.approve": "Approve",
"exec.requestRevision": "Request revision",
"exec.reject": "Reject",
"exec.lastNote": "Last note:",
"exec.noCostData": "No costs recorded yet",
"exec.noCostDataDetail": "API usage and token costs will be tracked here once agents start running.",
"exec.noCostDataDetail": "Costs show up here after runs start.",
"exec.allConnectors": "All connectors",
"exec.runCost": "Run cost",
"exec.unknownAgent": "Unknown agent",
@@ -448,30 +465,30 @@ export const en: Record<string, string> = {
"exec.knownSpend": "Known spend",
"exec.unattributedEntries": "Unattributed entries",
"exec.noActivityYet": "No activity recorded yet",
"exec.noActivityDetail": "Agent actions, task updates, and system events will stream here.",
"exec.noActivityDetail": "Task and system updates show here.",
"exec.searchActivity": "Search activity...",
"exec.allTypes": "All types",
"exec.noMatchingActivity": "No activity matches this filter",
"exec.loadMore": "Load more ({0} remaining)",
"exec.noWorkspacesYet": "No workspaces created yet",
"exec.noWorkspacesDetail": "Workspaces are created automatically when your company launches.",
"exec.noWorkspacesDetail": "Workspaces are created automatically.",
"exec.deleteWorkspace": "Delete workspace",
"exec.deleteWorkspaceConfirm": "Delete workspace \"{0}\"? This only removes it from the company registry; files on disk are not affected.",
"exec.deleteWorkspaceConfirm": "Delete workspace \"{0}\"? Files on disk stay.",
"exec.inspect": "Inspect",
"exec.reveal": "Reveal",
"exec.noRemoteUrl": "No remote URL",
"exec.selectWorkspace": "Select a workspace",
"exec.selectWorkspaceDetail": "Choose a workspace from the list to view agents, tasks, and activity.",
"exec.selectWorkspaceDetail": "Pick a workspace to see its tasks and activity.",
"exec.agents": "Agents",
"exec.tasks": "Tasks",
"exec.runs": "Runs",
"exec.docs": "Docs",
"exec.assignedAgents": "Assigned Agents",
"exec.noAgentsBound": "No agents are currently bound to this workspace.",
"exec.noAgentsBound": "No agents use this workspace yet.",
"exec.openTasks": "Open Tasks",
"exec.noTasksTarget": "No tasks target this workspace yet.",
"exec.noTasksTarget": "No tasks here yet.",
"exec.recentRuns": "Recent Runs",
"exec.noRunsExecuted": "No runs have executed in this workspace.",
"exec.noRunsExecuted": "No runs here yet.",
"exec.openFolder": "Open folder",
"exec.noLinkedProject": "No linked project",
"exec.workspaceName": "Workspace name",
@@ -485,11 +502,12 @@ export const en: Record<string, string> = {
"exec.profilePath": "Profile path",
"exec.preferences": "Preferences",
"exec.enableUpdater": "Enable updater checks",
"exec.companyAutonomy": "Company autonomy",
"exec.autoApproveLabel": "Auto-approve hire and strategy approvals",
"exec.autoApproveDesc": "Auto-approve hiring and CEO strategy decisions without board review.",
"exec.reviewDeliverablesLabel": "Auto-approve important deliverables",
"exec.reviewDeliverablesDesc": "Skip board review when agents produce PRDs, design docs, and other key documents.",
"exec.executionMode": "Execution Mode",
"exec.modeCeoTag": "Fully Autonomous",
"exec.modeCofounderTag": "Human-Reviewed",
"exec.modeCeoDesc": "AI runs on its own. Best for 0→1.",
"exec.modeCofounderDesc": "Key decisions wait for you. Best for research or growth.",
"exec.modeSwitched": "Execution mode switched",
"exec.actions": "Actions",
"exec.saveSettings": "Save settings",
"exec.checkForUpdates": "Check for updates",
@@ -526,20 +544,20 @@ export const en: Record<string, string> = {
"comm.newMessage": "New message",
"comm.channels": "Channels",
"comm.taskDiscussions": "Task Discussions",
"comm.noActiveDepartments": "No active departments yet.",
"comm.noActiveDepartments": "No departments yet.",
"comm.channelsCount": "{0} channels",
"comm.agentsCount": "{0} agents",
"comm.selectChannel": "Select a channel",
"comm.selectChannelView": "Select a channel to view messages",
"comm.selectChannelView": "Pick a channel to read messages",
"comm.monitorComms": "",
"comm.noDiscussionYet": "No discussion yet",
"comm.commentsAppearHere": "No comments yet",
"comm.noMatchFilters": "No messages match your filters",
"comm.noMessagesChannel": "No messages in this channel yet",
"comm.adjustFilters": "Try adjusting your search or filters",
"comm.noMatchFilters": "No messages match",
"comm.noMessagesChannel": "No messages yet",
"comm.adjustFilters": "Try another search or filter",
"comm.messagesAppear": "No messages yet",
"comm.openTask": "Open task",
"comm.selectChannelSend": "Select a channel to read and send messages.",
"comm.selectChannelSend": "Pick a channel first.",
"comm.replyInDiscussion": "Reply in task discussion...",
"comm.typeMessage": "Type a message...",
"comm.sendShortcut": "Press {0}+Enter to send",
@@ -578,7 +596,7 @@ export const en: Record<string, string> = {
"comm.writeMessage": "Write your message...",
"comm.sendMessage": "Send Message",
"comm.subjectOptional": "Subject (optional)",
"comm.selectConversation": "Select a direct conversation partner before sending a DM.",
"comm.selectConversation": "Pick someone first.",
"comm.directMessages": "Direct Messages",
"comm.companyWide": "Company-wide",
"comm.unread": "unread",
@@ -595,10 +613,10 @@ export const en: Record<string, string> = {
"dash.poor": "Poor",
"dash.critical": "Critical",
"dash.noAgentsYet": "No agents hired yet",
"dash.noAgentsDetail": "Your CEO is working on hiring. Check back shortly.",
"dash.noAgentsDetail": "Your CEO is hiring. Check back soon.",
"dash.createCeoAgent": "",
"dash.noActivityYet": "Waiting for activity",
"dash.noActivityDetail": "Agent actions and events will stream here in real time.",
"dash.noActivityDetail": "Live updates show here.",
"dash.liveRuns": "Live Runs",
"dash.running": "running",
"dash.allQuiet": "All quiet",
@@ -650,15 +668,15 @@ export const en: Record<string, string> = {
// ── Organization ──
"org.noAgentsYet": "No agents hired yet",
"org.noAgentsDetail": "Agents join your company through the CEO\u2019s hiring decisions or your direct approval.",
"org.noAgentsDetail": "Hired agents show up here.",
"org.noTitle": "No title",
"org.noGoalsYet": "No goals defined yet",
"org.noGoalsDetail": "Goals are set during company creation and drive how agents prioritize work.",
"org.noGoalsDetail": "Goals set the team's priorities.",
"org.noProjectsYet": "No projects created yet",
"org.noProjectsDetail": "The CEO organizes work into projects as the company scales.",
"org.noProjectsDetail": "Projects show up here.",
"org.noAgentsToDisplay": "Organization chart is empty",
"org.noAgentsToDisplayDetail": "Agents will appear here once hired, showing reporting structure and status.",
"org.saveChanges": "Save changes",
"org.noAgentsToDisplayDetail": "The org chart appears after hiring starts.",
"org.saveChanges": "Save",
"org.submitHireRequest": "Submit hire request",
"org.createGoal": "Create goal",
"org.createProject": "Create project",
@@ -688,7 +706,7 @@ export const en: Record<string, string> = {
// ── Runs ──
"runs.noRunsYet": "No agent runs yet",
"runs.noRunsDetail": "Runs are created when agents execute tasks. They\u2019ll appear here with logs and status.",
"runs.noRunsDetail": "Runs show up here with logs and status.",
"runs.activeLabel": "Active",
"runs.queuedOrRunning": "Queued or running right now",
"runs.needsAttention": "Needs attention",
@@ -698,7 +716,7 @@ export const en: Record<string, string> = {
"runs.runQueue": "Run queue",
"runs.searchRuns": "Search by task, agent, connector, workspace, or error",
"runs.noRunsMatch": "No runs match this filter",
"runs.noRunsMatchDetail": "Try broadening your filter or clearing it to see all runs.",
"runs.noRunsMatchDetail": "Try another filter.",
"runs.runDetail": "Run detail",
"runs.openConsole": "Open console",
"runs.cancelRun": "Cancel run",
@@ -812,7 +830,7 @@ export const en: Record<string, string> = {
// ── Inbox / Standup / Performance ──
"inbox.title": "Inbox",
"inbox.itemsNeedAttention": "{0} item{1} need{2} your attention",
"inbox.itemsNeedAttention": "{0} item{1} need{2} you",
"inbox.allClear": "All clear!",
"inbox.noItemsNow": "No items need your attention right now",
"inbox.all": "All",
@@ -831,12 +849,12 @@ export const en: Record<string, string> = {
"standup.highlights": "Highlights",
"standup.agentStatus": "Agent Status",
"standup.noData": "No standup data",
"standup.noDataDesc": "Appears once agents are active",
"standup.noDataDesc": "Shows up after agents start working",
"standup.inProgress": "in progress",
"standup.doneLabel": "done",
"standup.blockedLabel": "blocked",
"perf.noMetrics": "No metrics yet",
"perf.noMetricsDesc": "Metrics will appear after agents start working",
"perf.noMetricsDesc": "Shows up after agents start working",
"perf.successRate": "Success Rate",
"perf.tasksDone": "Tasks Done",
"perf.throughput": "Throughput",
+84 -66
View File
@@ -51,46 +51,46 @@ export const zh: Record<string, string> = {
"app.search": "搜索版块、智能体、任务、目标、项目...",
// ── 引导流程 ──
"onboarding.title": "创建一个本地优先的\n智能体公司。",
"onboarding.title": "创建你的\nAI 公司。",
"onboarding.subtitle": "AgentCompany 桌面端",
"onboarding.description": "创建公司、连接 AI 运行时、启动第一个任务。",
"onboarding.step1Title": "建立运营边界",
"onboarding.step2Title": "定义第一个目标",
"onboarding.step3Title": "启动概要",
"onboarding.description": "填好公司信息,连接运行时,然后开工。",
"onboarding.step1Title": "公司信息",
"onboarding.step2Title": "第一个目标",
"onboarding.step3Title": "准备完成",
"onboarding.stepLabel1": "公司",
"onboarding.stepLabel2": "使命",
"onboarding.stepLabel3": "启动",
"onboarding.companyName": "公司名称",
"onboarding.companyNamePlaceholder": "例如:北线系统",
"onboarding.companyDescription": "公司描述",
"onboarding.companyDescriptionPlaceholder": "可选的公司运营模式说明。",
"onboarding.companyDescriptionPlaceholder": "简单说一下这家公司做什么。",
"onboarding.primaryConnector": "主连接器",
"onboarding.connectorReadiness": "连接器就绪状态",
"onboarding.connectorInfo": "列表为空时,请在设置中检查连接器健康状态。",
"onboarding.connectorInfo": "如果这里是空的,去设置检查连接器。",
"onboarding.workspaceFolder": "工作区目录",
"onboarding.workspaceFolderOptional": "(可选)",
"onboarding.workspaceFolderPlaceholder": "/Users/you/project",
"onboarding.workspaceHelp": "首个任务将使用此路径作为工作区。",
"onboarding.workspaceHelp": "第一个任务会从这里开始。",
"onboarding.autoApprove": "自动批准招聘和战略审批",
"onboarding.autoApproveLabel": "自动批准招聘和战略审批",
"onboarding.autoApproveHelp": "跳过招聘和战略变更的董事会审批。",
"onboarding.autoApproveHelp": "招聘和战略变更自动通过。",
"onboarding.reviewDeliverablesLabel": "重要产出物人工审核",
"onboarding.reviewDeliverablesHelp": "当 Agent 产出 PRD、设计文档等重要产出物时暂停流程,等待您审核。",
"onboarding.reviewDeliverablesHelp": "重要交付物会停下来等你确认。",
"onboarding.automationMode": "自动化模式",
"onboarding.modeFullAuto": "全自动",
"onboarding.modeFullAutoDesc": "Agent 全程自动运行,无需人工干预。适合放手运营。",
"onboarding.modeFullAutoDesc": "全程自动跑。",
"onboarding.modeHumanReview": "人工审核",
"onboarding.modeHumanReviewDesc": "当产出 PRD、设计文档等重要产出物时暂停,等待您审核后继续。",
"onboarding.modeHumanReviewDesc": "关键节点会停下来等你确认。",
"onboarding.continue": "继续",
"onboarding.back": "返回",
"onboarding.goal": "目标",
"onboarding.goalPlaceholder": "例如:交付第一个面向客户的桌面演示",
"onboarding.acceptanceCriteria": "验收标准",
"onboarding.acceptanceCriteriaPlaceholder": "成功的标准是什么?",
"onboarding.acceptanceCriteriaPlaceholder": "怎么才算做成了?",
"onboarding.executionHandoff": "执行交接",
"onboarding.executionHandoffLine1": "所选连接器运行 CEO 智能体。",
"onboarding.executionHandoffLine2": "创建公司、工作区、CEO 智能体、项目和首个任务。",
"onboarding.executionHandoffLine3": "立即执行,或在运行时可用后执行。",
"onboarding.executionHandoffLine1": "所选运行时会驱动 CEO。",
"onboarding.executionHandoffLine2": "系统会创建公司、工作区和第一个任务。",
"onboarding.executionHandoffLine3": "运行时一就绪就开跑。",
"onboarding.launching": "正在启动...",
"onboarding.launchCompany": "启动公司",
"onboarding.isLive": "已上线。",
@@ -114,29 +114,46 @@ export const zh: Record<string, string> = {
"onboarding.cancel": "取消",
"onboarding.enterCompanyName": "请输入公司名称。",
"onboarding.connectorRequired": "需要一个就绪的连接器才能继续。",
"onboarding.describeFirstGoal": "请描述公司的第一个目标。",
"onboarding.unableToBootstrap": "无法启动公司。请检查连接器认证后重试。",
"onboarding.describeFirstGoal": "先写下第一个目标。",
"onboarding.unableToBootstrap": "启动失败。检查连接器后重试。",
"onboarding.unableToOpenControlCenter": "无法打开控制中心。",
"onboarding.connectorNotInstalled": "未检测到 CLI 运行时。请安装并认证一个受支持的运行时以继续。",
"onboarding.connectorWarning": "请先安装并认证一个 CLI 运行时。",
"onboarding.connectorNotInstalled": "没找到可用的 CLI 运行时。先安装并登录一个。",
"onboarding.connectorWarning": "先装好并登录一个运行时。",
"onboarding.languageSelection": "语言",
"onboarding.selectLanguage": "选择语言",
"onboarding.welcomeSubtitle": "本地优先的 AI 智能体公司,从这里开始。",
"onboarding.welcomeSubtitle": "从这里开始搭你的 AI 公司。",
"onboarding.getStarted": "开始设置",
"onboarding.stepLabelNodeCheck": "环境检查",
"onboarding.stepLabelProviders": "AI 提供商",
"onboarding.stepLabelCompany": "公司",
"onboarding.stepLabelModelPool": "团队模型",
"onboarding.stepLabelMode": "模式",
"onboarding.stepLabelMission": "目标",
"onboarding.stepLabelLaunch": "启动",
"onboarding.companyStepModelPoolHint": "下一步再选团队可用模型。",
"onboarding.modelPoolStepTitle": "选择团队模型",
"onboarding.modelPoolStepSubtitle": "这里决定团队能用哪些模型。",
"onboarding.modelPoolStepDescription": "CEO 只会给员工分配这里启用的模型。",
"onboarding.modelPoolGlobalRequirement": "至少启用一个模型后才能继续。",
"onboarding.modelPoolSelectedConnectorRequirement": "请给当前 CEO 运行时启用一个模型,或返回更换 provider。",
"onboarding.modelPoolContinueHint": "之后也可以去设置里修改。",
"onboarding.modeStepTitle": "这家公司怎么跑?",
"onboarding.modeAiCeoTitle": "AI CEO",
"onboarding.modeAiCeoSubtitle": "完全自主",
"onboarding.modeAiCeoDesc": "AI 自己推进任务、审批和交付。适合 0→1。",
"onboarding.modeCofounderTitle": "AI 联合创始人",
"onboarding.modeCofounderSubtitle": "人工审核",
"onboarding.modeCofounderDesc": "关键节点等你拍板。适合科研或增长。",
"onboarding.modeCanChange": "之后随时都能改。",
"onboarding.nodeCheckTitle": "检查 Node.js",
"onboarding.nodeCheckStep": "系统环境",
"onboarding.nodeInstalled": "Node.js {version} 已安装",
"onboarding.nodeNotInstalled": "Node.js 未安装",
"onboarding.nodeRequired": "运行 CLI 智能体连接器需要 Node.js。",
"onboarding.nodeRequired": "CLI 运行时需要 Node.js。",
"onboarding.checkAgain": "重新检查",
"onboarding.installNodeTerminal": "打开终端安装",
"onboarding.providersTitle": "配置 AI 提供商",
"onboarding.providersSubtitle": "至少配置一个提供商来驱动智能体。后续可在设置中添加更多。",
"onboarding.providersTitle": "配置运行时",
"onboarding.providersSubtitle": "至少准备一个可用运行时,之后还能再加。",
"onboarding.providersStep": "AI 提供商",
"onboarding.providerReady": "就绪",
"onboarding.providerNotInstalled": "未安装",
@@ -151,8 +168,8 @@ export const zh: Record<string, string> = {
"onboarding.providerModel": "模型",
"onboarding.providerSelectProvider": "选择一个提供商...",
"onboarding.providerViewDocs": "查看文档",
"onboarding.providerCompatibleNote": "此设置会覆盖 Claude Code 官方 API 密钥配置。",
"onboarding.providerAtLeastOne": "至少配置一个提供商才能继续。",
"onboarding.providerCompatibleNote": "这会覆盖 Claude Code 官方 API 配置。",
"onboarding.providerAtLeastOne": "至少准备一个可用运行时。",
"onboarding.claudeCodeLabel": "Claude Code",
"onboarding.codexLabel": "Codex",
"onboarding.geminiLabel": "Gemini CLI",
@@ -357,13 +374,13 @@ export const zh: Record<string, string> = {
// ── 执行/任务检查器 ──
"exec.taskQueueEmpty": "暂无待办任务",
"exec.taskQueueEmptyDetail": "智能体规划和分解工作时,新任务将出现在这里。",
"exec.taskQueueEmptyDetail": "有新任务时会显示在这里。",
"exec.deleteTask": "删除任务",
"exec.deleteTaskConfirm": "永久删除「{0}」?此操作无法撤消。",
"exec.budgetUsed": "已使用 {0}% 的月度预算",
"exec.taskChangesSaved": "任务更改已保存。",
"exec.taskMovedTo": "任务已移至{0}。",
"exec.approvalCreatedBlocked": "审批已创建并转交管理层。",
"exec.approvalCreatedBlocked": "审批已提交给上级。",
"exec.approvalCreated": "审批已创建。",
"exec.title": "标题",
"exec.description": "描述",
@@ -385,11 +402,11 @@ export const zh: Record<string, string> = {
"exec.workspaceLabel": "工作区:",
"exec.costLabel": "费用:",
"exec.beingReviewedBy": "正在由 {0} 审核",
"exec.submittedForReview": "{0} 已提交此任务由 {1} 审核。",
"exec.submittedForReview": "{0} 已提交给 {1} 审核。",
"exec.governanceRequired": "需要治理操作",
"exec.waitingOnReview": "任务等待审核中",
"exec.blockedGovernance": "创建审批以解除此任务的阻塞。",
"exec.reviewGovernance": "继续前请求一个决策。",
"exec.blockedGovernance": "先发起审批,再继续。",
"exec.reviewGovernance": "继续前先拿到决定。",
"exec.createUnblockApproval": "创建解除阻塞审批",
"exec.createReviewApproval": "创建审核审批",
"exec.cancelRun": "取消运行",
@@ -412,11 +429,11 @@ export const zh: Record<string, string> = {
"exec.createTask": "创建任务",
"exec.taskTitle": "任务标题",
"exec.noReviewsInProgress": "暂无待审核任务",
"exec.noReviewsDetail": "等待智能体审核的任务将出现在这里。",
"exec.noReviewsDetail": "待审核任务会出现在这里。",
"exec.awaitingReview": "等待审核",
"exec.reviewedBy": "由...审核",
"exec.noApprovals": "暂无待审批项",
"exec.noApprovalsDetail": "智能体需要您批准时,审批请求将出现在这里。",
"exec.noApprovalsDetail": "需要你批准的内容会出现在这里。",
"exec.deleteApproval": "删除审批",
"exec.deleteApprovalConfirm": "永久删除此审批?此操作无法撤消。",
"exec.requestedBy": "请求者:",
@@ -433,13 +450,13 @@ export const zh: Record<string, string> = {
"exec.reportsTo": "汇报给:",
"exec.orgStrategy": "组织战略",
"exec.decisionNote": "决策备注",
"exec.decisionNotePlaceholder": "附加决策的可选备注...",
"exec.decisionNotePlaceholder": "可选备注...",
"exec.approve": "批准",
"exec.requestRevision": "要求修改",
"exec.reject": "拒绝",
"exec.lastNote": "最近备注:",
"exec.noCostData": "暂无费用记录",
"exec.noCostDataDetail": "智能体开始运行后,API 使用和令牌费用将在此处跟踪。",
"exec.noCostDataDetail": "开始运行后,这里会显示费用。",
"exec.allConnectors": "全部连接器",
"exec.runCost": "运行费用",
"exec.unknownAgent": "未知智能体",
@@ -448,30 +465,30 @@ export const zh: Record<string, string> = {
"exec.knownSpend": "已知支出",
"exec.unattributedEntries": "未归因条目",
"exec.noActivityYet": "暂无活动记录",
"exec.noActivityDetail": "智能体操作、任务更新和系统事件将在此处实时显示。",
"exec.noActivityDetail": "任务和系统动态会显示在这里。",
"exec.searchActivity": "搜索活动...",
"exec.allTypes": "全部类型",
"exec.noMatchingActivity": "没有匹配此筛选条件的活动",
"exec.loadMore": "加载更多(还剩 {0} 条)",
"exec.noWorkspacesYet": "暂无工作区",
"exec.noWorkspacesDetail": "公司启动时将自动创建工作区。",
"exec.noWorkspacesDetail": "公司启动后会自动创建。",
"exec.deleteWorkspace": "删除工作区",
"exec.deleteWorkspaceConfirm": "删除工作区「{0}」?这只会从公司注册表中移除,不会影响磁盘上的文件。",
"exec.deleteWorkspaceConfirm": "删除工作区「{0}」?只会移出系统,不会删磁盘文件。",
"exec.inspect": "检查",
"exec.reveal": "在文件管理器中显示",
"exec.noRemoteUrl": "无远程地址",
"exec.selectWorkspace": "选择一个工作区",
"exec.selectWorkspaceDetail": "从列表中选择一个工作区,查看其智能体、任务和活动。",
"exec.selectWorkspaceDetail": "选一个工作区查看任务和动态。",
"exec.agents": "智能体",
"exec.tasks": "任务",
"exec.runs": "运行",
"exec.docs": "文档",
"exec.assignedAgents": "分配的智能体",
"exec.noAgentsBound": "当前没有智能体绑定到此工作区。",
"exec.noAgentsBound": "这个工作区还没绑定智能体。",
"exec.openTasks": "进行中的任务",
"exec.noTasksTarget": "尚无任务指向此工作区。",
"exec.noTasksTarget": "这个工作区还没有任务。",
"exec.recentRuns": "最近运行",
"exec.noRunsExecuted": "此工作区尚未执行过运行。",
"exec.noRunsExecuted": "这个工作区还没跑过任务。",
"exec.openFolder": "打开文件夹",
"exec.noLinkedProject": "无关联项目",
"exec.workspaceName": "工作区名称",
@@ -485,11 +502,12 @@ export const zh: Record<string, string> = {
"exec.profilePath": "配置文件路径",
"exec.preferences": "偏好设置",
"exec.enableUpdater": "启用更新检查",
"exec.companyAutonomy": "公司自治",
"exec.autoApproveLabel": "自动批准招聘和战略审批",
"exec.autoApproveDesc": "自动批准招聘和 CEO 战略决策,无需董事会审批。",
"exec.reviewDeliverablesLabel": "自动通过重要产出物",
"exec.reviewDeliverablesDesc": "自动通过 Agent 产出的 PRD、设计文档等重要产出物,无需人工审核。",
"exec.executionMode": "运营模式",
"exec.modeCeoTag": "完全自主",
"exec.modeCofounderTag": "人工审核",
"exec.modeCeoDesc": "AI 全程自动推进。适合 0→1。",
"exec.modeCofounderDesc": "关键节点等你确认。适合科研或增长。",
"exec.modeSwitched": "运营模式已切换",
"exec.actions": "操作",
"exec.saveSettings": "保存设置",
"exec.checkForUpdates": "检查更新",
@@ -526,20 +544,20 @@ export const zh: Record<string, string> = {
"comm.newMessage": "新消息",
"comm.channels": "频道",
"comm.taskDiscussions": "任务讨论",
"comm.noActiveDepartments": "尚无活跃部门。",
"comm.noActiveDepartments": "还没有部门。",
"comm.channelsCount": "{0} 个频道",
"comm.agentsCount": "{0} 个智能体",
"comm.selectChannel": "选择一个频道",
"comm.selectChannelView": "选择一个频道以查看消息",
"comm.selectChannelView": "选个频道看消息",
"comm.monitorComms": "",
"comm.noDiscussionYet": "暂无讨论",
"comm.commentsAppearHere": "暂无评论",
"comm.noMatchFilters": "没有匹配筛选条件的消息",
"comm.noMessagesChannel": "此频道暂无消息",
"comm.adjustFilters": "尝试调整搜索条件或筛选器",
"comm.noMatchFilters": "没有符合条件的消息",
"comm.noMessagesChannel": "这个频道还没有消息",
"comm.adjustFilters": "换个搜索或筛选试试",
"comm.messagesAppear": "暂无消息",
"comm.openTask": "打开任务",
"comm.selectChannelSend": "选择一个频道以读取和发送消息。",
"comm.selectChannelSend": "先选一个频道。",
"comm.replyInDiscussion": "在任务讨论中回复...",
"comm.typeMessage": "输入消息...",
"comm.sendShortcut": "按 {0}+Enter 发送",
@@ -578,7 +596,7 @@ export const zh: Record<string, string> = {
"comm.writeMessage": "编写消息内容...",
"comm.sendMessage": "发送消息",
"comm.subjectOptional": "主题(可选)",
"comm.selectConversation": "请先选择一个私信对象再发送消息。",
"comm.selectConversation": "先选一个私信对象。",
"comm.directMessages": "私信",
"comm.companyWide": "全公司",
"comm.unread": "未读",
@@ -595,10 +613,10 @@ export const zh: Record<string, string> = {
"dash.poor": "较差",
"dash.critical": "危急",
"dash.noAgentsYet": "尚未招聘智能体",
"dash.noAgentsDetail": "您的 CEO 正在招聘中,请稍后查看。",
"dash.noAgentsDetail": "CEO 正在招人,稍后再看。",
"dash.createCeoAgent": "",
"dash.noActivityYet": "等待活动",
"dash.noActivityDetail": "智能体的操作和事件将在此实时显示。",
"dash.noActivityDetail": "动态会实时显示在这里。",
"dash.liveRuns": "实时运行",
"dash.running": "运行中",
"dash.allQuiet": "一切平静",
@@ -650,15 +668,15 @@ export const zh: Record<string, string> = {
// ── 组织 ──
"org.noAgentsYet": "暂无已招聘的智能体",
"org.noAgentsDetail": "智能体将通过 CEO 的招聘决策或您的直接批准加入公司。",
"org.noAgentsDetail": "招到人后会显示在这里。",
"org.noTitle": "无职位",
"org.noGoalsYet": "暂未设定目标",
"org.noGoalsDetail": "目标在创建公司时设定,驱动智能体的工作优先级。",
"org.noGoalsDetail": "目标会决定团队优先级。",
"org.noProjectsYet": "暂无项目",
"org.noProjectsDetail": "CEO 将在公司扩展时组织项目。",
"org.noProjectsDetail": "项目会在这里出现。",
"org.noAgentsToDisplay": "组织架构图为空",
"org.noAgentsToDisplayDetail": "智能体招聘后将在此显示,展示汇报结构和状态。",
"org.saveChanges": "保存更改",
"org.noAgentsToDisplayDetail": "招到人后,这里会显示汇报关系。",
"org.saveChanges": "保存",
"org.submitHireRequest": "提交招聘请求",
"org.createGoal": "创建目标",
"org.createProject": "创建项目",
@@ -688,7 +706,7 @@ export const zh: Record<string, string> = {
// ── 运行 ──
"runs.noRunsYet": "暂无智能体运行记录",
"runs.noRunsDetail": "智能体执行任务时将创建运行记录,包含日志和状态信息。",
"runs.noRunsDetail": "开始跑任务后,这里会显示日志和状态。",
"runs.activeLabel": "活跃",
"runs.queuedOrRunning": "正在排队或运行中",
"runs.needsAttention": "需要关注",
@@ -698,7 +716,7 @@ export const zh: Record<string, string> = {
"runs.runQueue": "运行队列",
"runs.searchRuns": "按任务、智能体、连接器、工作区或错误搜索",
"runs.noRunsMatch": "没有匹配此筛选条件的运行",
"runs.noRunsMatchDetail": "尝试放宽筛选条件或清除筛选以查看所有运行。",
"runs.noRunsMatchDetail": "换个筛选试试。",
"runs.runDetail": "运行详情",
"runs.openConsole": "打开控制台",
"runs.cancelRun": "取消运行",
@@ -812,7 +830,7 @@ export const zh: Record<string, string> = {
// ── 收件箱/站会/绩效 ──
"inbox.title": "收件箱",
"inbox.itemsNeedAttention": "{0} 个事项需要您关注",
"inbox.itemsNeedAttention": "有 {0} 个事项等你处理",
"inbox.allClear": "一切正常!",
"inbox.noItemsNow": "目前没有需要您关注的事项",
"inbox.all": "全部",
@@ -831,12 +849,12 @@ export const zh: Record<string, string> = {
"standup.highlights": "亮点",
"standup.agentStatus": "智能体状态",
"standup.noData": "暂无站会数据",
"standup.noDataDesc": "智能体活跃后将显示",
"standup.noDataDesc": "开始工作后会显示。",
"standup.inProgress": "进行中",
"standup.doneLabel": "已完成",
"standup.blockedLabel": "已阻塞",
"perf.noMetrics": "暂无指标",
"perf.noMetricsDesc": "智能体开始工作后将显示指标",
"perf.noMetricsDesc": "开始工作后会显示。",
"perf.successRate": "成功率",
"perf.tasksDone": "完成任务",
"perf.throughput": "吞吐量",
@@ -8,11 +8,13 @@ export interface CommunicationSlice {
searchResults: AgentMessageRecord[];
searchLoading: boolean;
searchRequestId: number;
activeThreadId: string | null;
setSearchQuery: (query: string) => void;
setSearchScope: (scope: "global" | "channel") => void;
setSearchResults: (results: AgentMessageRecord[], requestId: number) => void;
setSearchLoading: (loading: boolean) => void;
clearSearch: () => void;
setActiveThreadId: (id: string | null) => void;
}
export const createCommunicationSlice: StateCreator<
@@ -26,6 +28,7 @@ export const createCommunicationSlice: StateCreator<
searchResults: [],
searchLoading: false,
searchRequestId: 0,
activeThreadId: null,
setSearchQuery: (query) =>
set((state) => {
state.searchQuery = query;
@@ -52,4 +55,8 @@ export const createCommunicationSlice: StateCreator<
state.searchResults = [];
state.searchLoading = false;
}),
setActiveThreadId: (id) =>
set((state) => {
state.activeThreadId = id;
}),
});
+292
View File
@@ -0,0 +1,292 @@
import { AnimatePresence, motion } from "framer-motion";
import {
Kanban,
ListChecks,
MagnifyingGlass,
Target,
TerminalWindow,
UsersThree,
} from "@phosphor-icons/react";
import type { AgentRecord, GoalRecord, ProjectRecord, RunRecord, SectionId, TaskRecord } from "@shared/types";
import { ListFrame, ListRow, StatusPill } from "../components/ui";
type NavItem = { id: SectionId; label: string; icon: typeof MagnifyingGlass };
type PaletteAction = {
key: string;
section: SectionId;
entityId?: string;
};
interface CommandPaletteViewProps {
commandPaletteOpen: boolean;
setCommandPaletteOpen: (value: boolean) => void;
commandPaletteSearch: string;
setCommandPaletteSearch: (value: string) => void;
selectedPaletteIndex: number;
setSelectedPaletteIndex: (value: number) => void;
paletteTrapRef: React.RefObject<HTMLDivElement | null>;
filteredNavItems: NavItem[];
matchedAgents: AgentRecord[];
matchedTasks: TaskRecord[];
matchedRuns: RunRecord[];
matchedGoals: GoalRecord[];
matchedProjects: ProjectRecord[];
hasEntityResults: boolean;
paletteActions: PaletteAction[];
paletteActionIndex: Map<string, number>;
runs: RunRecord[];
navigateTo: (section: SectionId, entityId?: string) => void;
panelTransition: { duration: number; ease?: readonly [number, number, number, number] } | { duration: number };
t: (key: string) => string;
}
export function CommandPaletteView({
commandPaletteOpen,
setCommandPaletteOpen,
commandPaletteSearch,
setCommandPaletteSearch,
selectedPaletteIndex,
setSelectedPaletteIndex,
paletteTrapRef,
filteredNavItems,
matchedAgents,
matchedTasks,
matchedRuns,
matchedGoals,
matchedProjects,
hasEntityResults,
paletteActionIndex,
runs,
navigateTo,
panelTransition,
t,
}: CommandPaletteViewProps) {
const paletteQuery = commandPaletteSearch.trim().toLowerCase();
return (
<AnimatePresence>
{commandPaletteOpen ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-start justify-center bg-background/72 px-4 pt-24 backdrop-blur-sm"
onClick={() => { setCommandPaletteOpen(false); setCommandPaletteSearch(""); }}
>
<motion.div
ref={paletteTrapRef}
role="dialog"
aria-modal="true"
aria-label="Command palette"
initial={{ opacity: 0, y: 14, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.98 }}
transition={panelTransition}
className="w-full max-w-[560px] rounded-lg border border-border bg-card px-5 py-4 shadow-[0_18px_40px_rgba(15,23,42,0.08)]"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-3 flex items-center gap-3">
<MagnifyingGlass size={18} className="text-primary" />
<input
type="text"
aria-label="Search"
className="flex-1 bg-transparent text-[14px] text-foreground placeholder:text-muted-foreground outline-none"
placeholder={t("app.search")}
value={commandPaletteSearch}
onChange={(e) => setCommandPaletteSearch(e.target.value)}
autoFocus
/>
{commandPaletteSearch && (
<button
className="text-[12px] text-muted-foreground hover:text-foreground"
onClick={() => setCommandPaletteSearch("")}
>
{t("app.clear")}
</button>
)}
</div>
{filteredNavItems.length > 0 ? (
<>
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("entity.sections")}</div>
<ListFrame>
{filteredNavItems.map((item) => (
<ListRow
key={item.id}
className={`py-3 ${paletteActionIndex.get(`section:${item.id}`) === selectedPaletteIndex ? "bg-muted" : ""}`}
onClick={() => {
navigateTo(item.id);
setCommandPaletteOpen(false);
setCommandPaletteSearch("");
setSelectedPaletteIndex(0);
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-foreground">
<item.icon size={16} />
<span className="text-[13px] tracking-[0.005em]">{item.label}</span>
</div>
<span className="font-mono text-[11px] text-muted-foreground">{item.id}</span>
</div>
</ListRow>
))}
</ListFrame>
</>
) : null}
{hasEntityResults ? (
<div className="mt-3 space-y-3">
{matchedAgents.length > 0 ? (
<>
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("entity.agents")}</div>
<ListFrame>
{matchedAgents.map((agent) => (
<ListRow
key={agent.id}
className={`py-3 ${paletteActionIndex.get(`agent:${agent.id}`) === selectedPaletteIndex ? "bg-muted" : ""}`}
onClick={() => {
navigateTo("agents", agent.id);
setCommandPaletteOpen(false);
setCommandPaletteSearch("");
setSelectedPaletteIndex(0);
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-foreground">
<UsersThree size={16} />
<span className="text-[13px] tracking-[0.005em]">{agent.name}</span>
<span className="text-[11px] text-muted-foreground">{agent.role}</span>
</div>
<StatusPill status={agent.status} />
</div>
</ListRow>
))}
</ListFrame>
</>
) : null}
{matchedTasks.length > 0 ? (
<>
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("entity.tasks")}</div>
<ListFrame>
{matchedTasks.map((task) => (
<ListRow
key={task.id}
className={`py-3 ${paletteActionIndex.get(`task:${task.id}`) === selectedPaletteIndex ? "bg-muted" : ""}`}
onClick={() => {
navigateTo("tasks", task.id);
setCommandPaletteOpen(false);
setCommandPaletteSearch("");
setSelectedPaletteIndex(0);
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-foreground">
<ListChecks size={16} />
<span className="text-[13px] tracking-[0.005em]">{task.title}</span>
</div>
<StatusPill status={task.status} />
</div>
</ListRow>
))}
</ListFrame>
</>
) : null}
{matchedRuns.length > 0 ? (
<>
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("entity.runs")}</div>
<ListFrame>
{matchedRuns.map((run) => (
<ListRow
key={run.id}
className={`py-3 ${paletteActionIndex.get(`run:${run.id}`) === selectedPaletteIndex ? "bg-muted" : ""}`}
onClick={() => {
navigateTo("runs", run.id);
setCommandPaletteOpen(false);
setCommandPaletteSearch("");
setSelectedPaletteIndex(0);
}}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex items-center gap-3 text-foreground">
<TerminalWindow size={16} />
<span className="truncate text-[13px] tracking-[0.005em]">
{run.summary || runs.find((entry) => entry.id === run.id)?.id.slice(0, 8) || run.id.slice(0, 8)}
</span>
</div>
<StatusPill status={run.status} />
</div>
</ListRow>
))}
</ListFrame>
</>
) : null}
{matchedGoals.length > 0 ? (
<>
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("entity.goals")}</div>
<ListFrame>
{matchedGoals.map((goal) => (
<ListRow
key={goal.id}
className={`py-3 ${paletteActionIndex.get(`goal:${goal.id}`) === selectedPaletteIndex ? "bg-muted" : ""}`}
onClick={() => {
navigateTo("goals", goal.id);
setCommandPaletteOpen(false);
setCommandPaletteSearch("");
setSelectedPaletteIndex(0);
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-foreground">
<Target size={16} />
<span className="text-[13px] tracking-[0.005em]">{goal.title}</span>
</div>
<StatusPill status={goal.status} />
</div>
</ListRow>
))}
</ListFrame>
</>
) : null}
{matchedProjects.length > 0 ? (
<>
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("entity.projects")}</div>
<ListFrame>
{matchedProjects.map((project) => (
<ListRow
key={project.id}
className={`py-3 ${paletteActionIndex.get(`project:${project.id}`) === selectedPaletteIndex ? "bg-muted" : ""}`}
onClick={() => {
navigateTo("projects", project.id);
setCommandPaletteOpen(false);
setCommandPaletteSearch("");
setSelectedPaletteIndex(0);
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-foreground">
<Kanban size={16} />
<span className="text-[13px] tracking-[0.005em]">{project.name}</span>
</div>
<StatusPill status={project.status} />
</div>
</ListRow>
))}
</ListFrame>
</>
) : null}
</div>
) : null}
{paletteQuery && filteredNavItems.length === 0 && !hasEntityResults ? (
<div className="py-6 text-center text-[13px] text-muted-foreground">{t("app.noResults")} "{commandPaletteSearch}"</div>
) : null}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { AnimatePresence, motion } from "framer-motion";
import { FolderOpen, TerminalWindow, X } from "@phosphor-icons/react";
import type { RunRecord } from "@shared/types";
import { ActionButton, Select, StatusPill } from "../components/ui";
import { formatMoney, formatTime } from "../lib/formatters";
interface ConsoleViewProps {
consoleOpen: boolean;
setConsoleOpen: (value: boolean) => void;
selectedRun: RunRecord | null;
setSelectedRunId: (id: string | null) => void;
runs: RunRecord[];
logBufferRef: React.RefObject<Record<string, string>>;
openPathWithFeedback: (path: string) => Promise<void>;
panelTransition: { duration: number; ease?: readonly [number, number, number, number] } | { duration: number };
t: (key: string) => string;
}
export function ConsoleView({
consoleOpen,
setConsoleOpen,
selectedRun,
setSelectedRunId,
runs,
logBufferRef,
openPathWithFeedback,
panelTransition,
t,
}: ConsoleViewProps) {
return (
<AnimatePresence>
{consoleOpen ? (
<motion.section
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={panelTransition}
className="shrink-0 overflow-hidden border-t border-border bg-card"
>
<div className="flex items-center justify-between px-5 py-2.5">
<div className="flex items-center gap-3">
<TerminalWindow size={15} className="text-muted-foreground" />
<span className="text-[13px] font-semibold text-foreground">{t("console.title")}</span>
</div>
<div className="flex items-center gap-3">
<Select
compact
value={selectedRun?.id ?? ""}
onChange={setSelectedRunId}
options={(() => {
const activeRuns = runs.filter((r) => r.summary !== "Application shut down." && r.summary !== "Worker process crashed.");
return activeRuns.length > 0
? activeRuns.map((run) => ({ value: run.id, label: run.summary || `Run ${run.id.slice(0, 8)}` }))
: [{ value: "", label: t("console.noRuns") }];
})()}
/>
<button
type="button"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-lg p-1 text-muted-foreground transition hover:text-foreground"
onClick={() => setConsoleOpen(false)}
aria-label="Close console"
>
<X size={14} />
</button>
</div>
</div>
<div className="grid border-t border-border xl:grid-cols-[240px_minmax(0,1fr)]">
<div className="border-b border-border px-5 py-3 xl:border-b-0 xl:border-r">
{selectedRun ? (
<div className="space-y-2.5 text-[12px] text-muted-foreground">
<div className="flex items-center justify-between">
<span>{t("common.status")}</span>
<StatusPill status={selectedRun.status} />
</div>
<div className="flex items-center justify-between">
<span>{t("common.started")}</span>
<span className="font-mono text-[11px]">{formatTime(selectedRun.startedAt)}</span>
</div>
<div className="flex items-center justify-between">
<span>{t("common.finished")}</span>
<span className="font-mono text-[11px]">{formatTime(selectedRun.finishedAt)}</span>
</div>
<div className="flex items-center justify-between">
<span>{t("common.cost")}</span>
<span className="font-mono text-[11px]">{formatMoney(selectedRun.costUsd)}</span>
</div>
{selectedRun.logPath ? (
<div className="pt-1">
<ActionButton label={t("common.openLog")} onClick={() => void openPathWithFeedback(selectedRun.logPath!)} icon={FolderOpen} />
</div>
) : null}
</div>
) : (
<div className="py-4 text-center text-[12px] text-muted-foreground">{t("common.noRunSelected")}</div>
)}
</div>
<div className="h-[200px] overflow-auto px-5 py-3">
<pre className="font-mono whitespace-pre-wrap break-all text-[12px] leading-relaxed text-muted-foreground">
{selectedRun ? logBufferRef.current[selectedRun.id] ?? t("common.waitingForLog") : t("common.noRunSelected")}
</pre>
</div>
</div>
</motion.section>
) : null}
</AnimatePresence>
);
}
+280
View File
@@ -0,0 +1,280 @@
import { AnimatePresence, motion } from "framer-motion";
import { WarningCircle } from "@phosphor-icons/react";
import type {
AgentRecord,
ApprovalRecord,
BrowserActionRecord,
CompanyRecord,
CostRecord,
DocumentRecord,
GoalRecord,
InboxItem,
CompanyMetrics,
KnowledgeEntryRecord,
MeetingRecord,
ProfileSnapshot,
ProjectRecord,
RunRecord,
SectionId,
SocialAccountRecord,
SprintRecord,
StandupReport,
TaskRecord,
WorkspaceRecord,
} from "@shared/types";
import type { AppLocale } from "@shared/locale";
import { ErrorBoundary } from "../components/ui";
import { OrganizationSections } from "./sections/OrganizationSections";
import { ExecutionSections } from "./sections/ExecutionSections";
import { OperationsSections } from "./sections/OperationsSections";
interface SectionContentViewProps {
section: SectionId;
error: string | null;
snapshot: ProfileSnapshot;
currentCompany: CompanyRecord | null;
locale: AppLocale;
// data collections
tasks: TaskRecord[];
runs: RunRecord[];
approvals: ApprovalRecord[];
pendingApprovals: ApprovalRecord[];
companyAgents: AgentRecord[];
companyGoals: GoalRecord[];
companyProjects: ProjectRecord[];
companyWorkspaces: WorkspaceRecord[];
companyCosts: CostRecord[];
filteredActivity: ReturnType<ProfileSnapshot["activity"]["filter"]>;
companySecrets: ProfileSnapshot["secrets"];
companyDocuments: DocumentRecord[];
companyKnowledge: KnowledgeEntryRecord[];
companyMeetings: MeetingRecord[];
companySprints: SprintRecord[];
socialAccounts: SocialAccountRecord[];
browserActions: BrowserActionRecord[];
inboxItems: InboxItem[];
companyMetrics: CompanyMetrics | null;
standupReport: StandupReport | null;
selectedRunId: string | null;
// selections
selectedTask: TaskRecord | null;
selectedTaskId: string | null;
setSelectedTaskId: (id: string | null) => void;
selectedConnector: ProfileSnapshot["connectors"][number] | null;
setSelectedConnectorId: (id: string | null) => void;
selectedApproval: ApprovalRecord | null;
selectedApprovalId: string | null;
setSelectedApprovalId: (id: string | null) => void;
selectedAgent: AgentRecord | null;
selectedAgentId: string | null;
setSelectedAgentId: (id: string | null) => void;
selectedGoal: GoalRecord | null;
selectedGoalId: string | null;
setSelectedGoalId: (id: string | null) => void;
selectedProject: ProjectRecord | null;
selectedProjectId: string | null;
setSelectedProjectId: (id: string | null) => void;
selectedWorkspace: WorkspaceRecord | null;
setSelectedWorkspaceId: (id: string | null) => void;
selectedCommunicationEntityId: string | null;
setSelectedCommunicationEntityId: (id: string | null) => void;
taskViewMode: "list" | "kanban";
setTaskViewMode: (mode: "list" | "kanban") => void;
// actions
navigateTo: (section: SectionId, entityId?: string) => void;
setSection: (section: SectionId) => void;
setConsoleOpen: (value: boolean) => void;
setSelectedRunId: (id: string | null) => void;
refresh: () => Promise<void>;
startRun: (taskId: string) => Promise<void>;
cancelRun: (runId: string) => Promise<void>;
openPathWithFeedback: (path: string) => Promise<void>;
setError: (error: string | null) => void;
// transitions
pageTransition: { duration: number; ease?: readonly [number, number, number, number] } | { duration: number };
}
export function SectionContentView({
section,
error,
snapshot,
currentCompany,
locale,
tasks,
runs,
approvals,
pendingApprovals,
companyAgents,
companyGoals,
companyProjects,
companyWorkspaces,
companyCosts,
filteredActivity,
companySecrets,
companyDocuments,
companyKnowledge,
companyMeetings,
companySprints,
socialAccounts,
browserActions,
inboxItems,
companyMetrics,
standupReport,
selectedTask,
selectedTaskId,
setSelectedTaskId,
selectedConnector,
setSelectedConnectorId,
selectedApproval,
selectedApprovalId,
setSelectedApprovalId,
selectedAgent: _selectedAgent,
selectedAgentId,
setSelectedAgentId,
selectedGoal: _selectedGoal,
selectedGoalId,
setSelectedGoalId,
selectedProject: _selectedProject,
selectedProjectId,
setSelectedProjectId,
selectedWorkspace,
setSelectedWorkspaceId,
selectedCommunicationEntityId,
setSelectedCommunicationEntityId,
taskViewMode,
setTaskViewMode,
selectedRunId,
navigateTo,
setSection,
setConsoleOpen,
setSelectedRunId,
refresh,
startRun,
cancelRun,
openPathWithFeedback,
setError,
pageTransition,
}: SectionContentViewProps) {
return (
<div className={`flex-1 ${section === "communication" ? "flex flex-col overflow-hidden" : "overflow-auto"}`}>
<div className={section === "communication" ? "flex min-h-0 flex-1 flex-col" : "mx-auto flex w-full max-w-[1040px] flex-col px-6 py-6 lg:px-10 lg:py-8"}>
{error ? (
<div className={`flex items-center gap-3 rounded-lg bg-destructive-muted px-4 py-3 text-[13px] text-destructive ${section === "communication" ? "mx-6 mt-4" : "mb-6"}`}>
<WarningCircle size={18} />
<span>{error}</span>
</div>
) : null}
<div className={section === "communication" ? "flex min-h-0 flex-1 flex-col" : "min-h-0 flex-1"}>
<ErrorBoundary>
<AnimatePresence mode="wait">
<motion.div
key={section}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={pageTransition}
className={section === "communication" ? "flex min-h-0 flex-1 flex-col" : "space-y-14"}
style={section === "communication" ? { height: "100%" } : undefined}
>
<OrganizationSections
section={section}
snapshot={snapshot}
currentCompany={currentCompany}
companyAgents={companyAgents}
companyGoals={companyGoals}
companyProjects={companyProjects}
companyWorkspaces={companyWorkspaces}
selectedAgentId={selectedAgentId}
setSelectedAgentId={setSelectedAgentId}
selectedGoalId={selectedGoalId}
setSelectedGoalId={setSelectedGoalId}
selectedProjectId={selectedProjectId}
setSelectedProjectId={setSelectedProjectId}
setSection={setSection}
refresh={refresh}
/>
<ExecutionSections
section={section}
snapshot={snapshot}
currentCompany={currentCompany}
tasks={tasks}
runs={runs}
approvals={approvals}
pendingApprovals={pendingApprovals}
companyAgents={companyAgents}
companyGoals={companyGoals}
companyProjects={companyProjects}
companyWorkspaces={companyWorkspaces}
companyCosts={companyCosts}
companyDocuments={companyDocuments}
filteredActivity={filteredActivity}
selectedRunId={selectedRunId}
selectedTask={selectedTask}
selectedTaskId={selectedTaskId}
setSelectedTaskId={setSelectedTaskId}
selectedConnector={selectedConnector}
setSelectedConnectorId={setSelectedConnectorId}
selectedApproval={selectedApproval}
selectedApprovalId={selectedApprovalId}
setSelectedApprovalId={setSelectedApprovalId}
selectedGoalId={selectedGoalId}
setSelectedGoalId={setSelectedGoalId}
taskViewMode={taskViewMode}
setTaskViewMode={setTaskViewMode}
setSection={setSection}
setConsoleOpen={setConsoleOpen}
setSelectedRunId={setSelectedRunId}
setSelectedCommunicationEntityId={setSelectedCommunicationEntityId}
refresh={refresh}
startRun={startRun}
cancelRun={cancelRun}
openPathWithFeedback={openPathWithFeedback}
setError={setError}
/>
<OperationsSections
section={section}
snapshot={snapshot}
currentCompany={currentCompany}
locale={locale}
tasks={tasks}
runs={runs}
companyAgents={companyAgents}
companyProjects={companyProjects}
companyWorkspaces={companyWorkspaces}
companyDocuments={companyDocuments}
companyKnowledge={companyKnowledge}
companyMeetings={companyMeetings}
companySprints={companySprints}
socialAccounts={socialAccounts}
browserActions={browserActions}
inboxItems={inboxItems}
companyMetrics={companyMetrics}
standupReport={standupReport}
companySecrets={companySecrets}
selectedWorkspace={selectedWorkspace}
setSelectedWorkspaceId={setSelectedWorkspaceId}
selectedCommunicationEntityId={selectedCommunicationEntityId}
setSelectedCommunicationEntityId={setSelectedCommunicationEntityId}
setSelectedTaskId={setSelectedTaskId}
setSelectedApprovalId={setSelectedApprovalId}
setSection={setSection}
navigateTo={navigateTo}
refresh={refresh}
openPathWithFeedback={openPathWithFeedback}
setError={setError}
filteredActivity={filteredActivity}
companyCosts={companyCosts}
companyGoals={companyGoals}
/>
</motion.div>
</AnimatePresence>
</ErrorBoundary>
</div>
</div>
</div>
);
}
+188
View File
@@ -0,0 +1,188 @@
import {
CaretUpDown,
Check,
Command,
Gear,
Plus,
SidebarSimple,
Trash,
} from "@phosphor-icons/react";
import type { CompanyRecord, ProfileSnapshot, SectionId } from "@shared/types";
import { SectionButton } from "../components/ui";
type NavItem = { id: SectionId; label: string; icon: typeof Gear };
interface SidebarViewProps {
isMac: boolean;
sidebarCollapsed: boolean;
setSidebarCollapsed: (value: boolean) => void;
section: SectionId;
setSection: (section: SectionId) => void;
sidebarNav: NavItem[];
navBadges: Partial<Record<SectionId, { count: number; tone: "default" | "warn" | "danger" }>>;
companySwitcherOpen: boolean;
setCompanySwitcherOpen: (value: boolean) => void;
confirmDeleteCompanyId: string | null;
setConfirmDeleteCompanyId: (id: string | null) => void;
currentCompany: CompanyRecord | null;
snapshot: ProfileSnapshot;
setCompany: (companyId: string) => void;
deleteCompany: (companyId: string) => void;
setCommandPaletteOpen: (value: boolean) => void;
t: (key: string) => string;
}
export function SidebarView({
isMac,
sidebarCollapsed,
setSidebarCollapsed,
section,
setSection,
sidebarNav,
navBadges,
companySwitcherOpen,
setCompanySwitcherOpen,
confirmDeleteCompanyId,
setConfirmDeleteCompanyId,
currentCompany,
snapshot,
setCompany,
deleteCompany,
setCommandPaletteOpen,
t,
}: SidebarViewProps) {
return (
<aside className="flex h-full flex-col overflow-hidden border-r border-border bg-card">
{sidebarCollapsed ? (
<div className={`sidebar-drag flex items-center justify-center px-2 pb-3 ${isMac ? "pt-[38px]" : "pt-3"}`}>
<button
aria-label="Open command palette"
title={`Command palette (${isMac ? "⌘" : "Ctrl+"}K)`}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border border-border text-muted-foreground transition hover:bg-muted hover:text-foreground"
onClick={() => setCommandPaletteOpen(true)}
>
<Command size={14} />
</button>
</div>
) : (
<div className={`sidebar-drag flex items-center gap-2.5 px-4 pb-4 ${isMac ? "pt-[38px]" : "pt-3"}`}>
<div className="relative min-w-0 flex-1">
<button
type="button"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-2 rounded-lg border border-border bg-card px-2.5 py-1.5 text-left text-[13px] font-medium text-foreground transition hover:bg-muted"
onClick={() => setCompanySwitcherOpen(!companySwitcherOpen)}
>
<span className="min-w-0 flex-1 truncate">{currentCompany?.name ?? t("app.selectCompany")}</span>
<CaretUpDown size={14} className="shrink-0 text-muted-foreground" />
</button>
{companySwitcherOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => { setCompanySwitcherOpen(false); setConfirmDeleteCompanyId(null); }} />
<div className="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-lg border border-border bg-card shadow-lg">
<div className="max-h-[240px] overflow-y-auto py-1">
{snapshot.companies.map((company) => (
<div key={company.id} className="group flex items-center">
{confirmDeleteCompanyId === company.id ? (
<div className="flex w-full items-center gap-2 px-3 py-2">
<span className="flex-1 truncate text-[12px] text-destructive">Delete {company.name}?</span>
<button
type="button"
className="rounded px-2 py-0.5 text-[11px] font-semibold text-destructive transition hover:bg-destructive hover:text-primary-foreground"
onClick={() => void deleteCompany(company.id)}
>
Yes
</button>
<button
type="button"
className="rounded px-2 py-0.5 text-[11px] font-semibold text-muted-foreground transition hover:bg-muted"
onClick={() => setConfirmDeleteCompanyId(null)}
>
No
</button>
</div>
) : (
<>
<button
type="button"
className={`flex min-w-0 flex-1 items-center gap-2.5 px-3 py-2 text-left text-[13px] transition hover:bg-muted ${
company.id === currentCompany?.id ? "font-semibold text-foreground" : "text-muted-foreground"
}`}
onClick={() => { void setCompany(company.id); setCompanySwitcherOpen(false); }}
>
<span className="w-4 shrink-0">{company.id === currentCompany?.id ? <Check size={14} weight="bold" className="text-primary" /> : null}</span>
<span className="min-w-0 flex-1 truncate">{company.name}</span>
</button>
<button
type="button"
className="mr-2 rounded p-1 text-muted-foreground opacity-0 transition hover:bg-destructive hover:text-primary-foreground group-hover:opacity-100"
title={`Delete ${company.name}`}
onClick={() => setConfirmDeleteCompanyId(company.id)}
>
<Trash size={13} />
</button>
</>
)}
</div>
))}
</div>
<div className="border-t border-border">
<button
type="button"
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-[13px] text-muted-foreground transition hover:bg-muted hover:text-foreground"
onClick={() => { void setCompany("__new__"); setCompanySwitcherOpen(false); }}
>
<Plus size={14} />
<span>{t("app.newCompany")}</span>
</button>
</div>
</div>
</>
)}
</div>
<button
aria-label={`Open command palette (${isMac ? "⌘" : "Ctrl+"}K)`}
title={`Command palette (${isMac ? "⌘" : "Ctrl+"}K)`}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex h-[30px] shrink-0 items-center justify-center gap-1 rounded-lg border border-border px-2 text-muted-foreground transition hover:bg-muted hover:text-foreground"
onClick={() => setCommandPaletteOpen(true)}
>
<Command size={14} />
<kbd className="text-[10px] font-medium text-muted-foreground">{isMac ? "⌘K" : "^K"}</kbd>
</button>
</div>
)}
<nav aria-label="Main navigation" className={`flex-1 overflow-y-auto pb-3 ${sidebarCollapsed ? "px-1.5 pt-2" : "px-3 pt-2"}`}>
{/* Core: Overview, Team, Work */}
<div className="space-y-0.5">
{sidebarNav.slice(0, 3).map((item) => (
<SectionButton key={item.id} active={section === item.id} label={item.label} icon={item.icon} onClick={() => setSection(item.id)} badge={navBadges[item.id]?.count} badgeTone={navBadges[item.id]?.tone} collapsed={sidebarCollapsed} />
))}
</div>
<div className={`my-2 border-t border-border ${sidebarCollapsed ? "mx-1" : "mx-2"}`} />
{/* Collaborate & Extend: Communication, Skills, Documents, Approvals, Social */}
<div className="space-y-0.5">
{sidebarNav.slice(3).map((item) => (
<SectionButton key={item.id} active={section === item.id} label={item.label} icon={item.icon} onClick={() => setSection(item.id)} badge={navBadges[item.id]?.count} badgeTone={navBadges[item.id]?.tone} collapsed={sidebarCollapsed} />
))}
</div>
</nav>
<div className={`border-t border-border py-2 ${sidebarCollapsed ? "px-1.5" : "px-3"}`}>
<SectionButton
active={section === "settings"}
label="Settings"
icon={Gear}
onClick={() => setSection("settings")}
collapsed={sidebarCollapsed}
/>
<button
aria-label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 mt-1 flex w-full items-center rounded-lg py-[7px] text-muted-foreground transition duration-150 hover:bg-muted hover:text-foreground ${sidebarCollapsed ? "justify-center p-2" : "gap-2.5 px-3"}`}
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
>
<SidebarSimple size={sidebarCollapsed ? 18 : 17} />
{!sidebarCollapsed ? <span className="text-[13px] font-medium tracking-[0.005em]">{t("app.collapse")}</span> : null}
</button>
</div>
</aside>
);
}
@@ -0,0 +1,362 @@
import { useState } from "react";
import type {
AgentRecord,
ApprovalRecord,
CompanyRecord,
CostRecord,
DocumentRecord,
GoalRecord,
ProjectRecord,
RunRecord,
SectionId,
TaskRecord,
WorkspaceRecord,
ProfileSnapshot,
} from "@shared/types";
import {
Kanban,
ListChecks,
Target,
} from "@phosphor-icons/react";
import { ConnectorEditor } from "../../components/connectors";
import { KanbanBoard } from "../../components/dashboard";
import { EntityWorkspace } from "../../components/EntityWorkspace";
import { RunCenter } from "../../components/runs";
import {
ActivityList,
ApprovalInspector,
ApprovalList,
BudgetSummary,
CostList,
PendingReviewList,
TaskCreator,
TaskInspector,
TaskQueue,
} from "../../components/execution";
import { PolicyList } from "../../components/execution/PolicyList";
import { IncidentList } from "../../components/execution/IncidentList";
import {
GoalCreator,
GoalEditor,
GoalList,
} from "../../components/organization";
import { ActionButton, ListFrame, ListRow, Modal, Panel, StatusPill } from "../../components/ui";
import { unwrap } from "../../lib/desktop";
interface ExecutionSectionsProps {
section: SectionId;
snapshot: ProfileSnapshot;
currentCompany: CompanyRecord | null;
tasks: TaskRecord[];
runs: RunRecord[];
approvals: ApprovalRecord[];
pendingApprovals: ApprovalRecord[];
companyAgents: AgentRecord[];
companyGoals: GoalRecord[];
companyProjects: ProjectRecord[];
companyWorkspaces: WorkspaceRecord[];
companyCosts: CostRecord[];
companyDocuments: DocumentRecord[];
filteredActivity: ReturnType<ProfileSnapshot["activity"]["filter"]>;
selectedRunId: string | null;
selectedTask: TaskRecord | null;
selectedTaskId: string | null;
setSelectedTaskId: (id: string | null) => void;
selectedConnector: ProfileSnapshot["connectors"][number] | null;
setSelectedConnectorId: (id: string | null) => void;
selectedApproval: ApprovalRecord | null;
selectedApprovalId: string | null;
setSelectedApprovalId: (id: string | null) => void;
selectedGoalId: string | null;
setSelectedGoalId: (id: string | null) => void;
taskViewMode: "list" | "kanban";
setTaskViewMode: (mode: "list" | "kanban") => void;
setSection: (section: SectionId) => void;
setConsoleOpen: (value: boolean) => void;
setSelectedRunId: (id: string | null) => void;
setSelectedCommunicationEntityId: (id: string | null) => void;
refresh: () => Promise<void>;
startRun: (taskId: string) => Promise<void>;
cancelRun: (runId: string) => Promise<void>;
openPathWithFeedback: (path: string) => Promise<void>;
setError: (error: string | null) => void;
}
export function ExecutionSections({
section,
snapshot,
currentCompany,
tasks,
runs,
approvals: _approvals,
pendingApprovals,
companyAgents,
companyGoals,
companyProjects,
companyWorkspaces,
companyCosts,
companyDocuments,
filteredActivity,
selectedRunId,
selectedTask,
selectedTaskId,
setSelectedTaskId,
selectedConnector,
setSelectedConnectorId,
selectedApproval,
selectedApprovalId: _selectedApprovalId,
setSelectedApprovalId,
selectedGoalId,
setSelectedGoalId,
taskViewMode,
setTaskViewMode,
setSection,
setConsoleOpen,
setSelectedRunId,
setSelectedCommunicationEntityId,
refresh,
startRun,
cancelRun,
openPathWithFeedback,
setError: _setError,
}: ExecutionSectionsProps) {
const [costsTab, setCostsTab] = useState<"costs" | "policies" | "incidents">("costs");
const selectedGoal = companyGoals.find((g) => g.id === selectedGoalId) ?? null;
return (
<>
{section === "runs" ? (
<RunCenter
runs={runs}
tasks={tasks}
agents={companyAgents}
workspaces={companyWorkspaces}
selectedRunId={selectedRunId}
onSelectRun={(runId: string) => {
setSelectedRunId(runId);
}}
onOpenLogPath={async (path: string) => {
await openPathWithFeedback(path);
}}
onCancelRun={cancelRun}
onOpenConsole={(runId: string) => {
setSelectedRunId(runId);
setConsoleOpen(true);
}}
/>
) : null}
{section === "connectors" && selectedConnector ? (
<div className="space-y-10">
<Panel title="Connector registry">
<ListFrame>
{snapshot.connectors.map((connector) => (
<ListRow
key={connector.id}
onClick={() => setSelectedConnectorId(connector.id)}
className={`cursor-pointer transition-colors duration-100 ${
selectedConnector.id === connector.id
? "bg-accent"
: "hover:bg-muted"
}`}
>
<div className="flex items-center justify-between">
<div className="min-w-0">
<div className="text-[14px] font-medium text-foreground">{connector.label}</div>
<div className="mt-1 truncate text-[11px] uppercase tracking-[0.12em] text-muted-foreground">{connector.command}</div>
</div>
<StatusPill status={connector.status} />
</div>
</ListRow>
))}
</ListFrame>
</Panel>
<Panel title={selectedConnector.label}>
<ConnectorEditor connector={selectedConnector} onSaved={refresh} />
</Panel>
</div>
) : null}
{section === "tasks" ? (
<>
<Panel title="Goals" action={<ActionButton label="New goal" icon={Target} tone="accent" onClick={() => setSelectedGoalId("__new__")} />}>
<GoalList goals={companyGoals} agents={companyAgents} selectedGoalId={selectedGoalId} onSelect={setSelectedGoalId} />
</Panel>
<Modal open={selectedGoalId === "__new__" && section === "tasks"} title="New goal" onClose={() => setSelectedGoalId(null)}>
<GoalCreator companyId={currentCompany!.id} agents={companyAgents} goals={companyGoals} onSaved={async () => { setSelectedGoalId(null); await refresh(); }} />
</Modal>
<Modal open={!!selectedGoal && section === "tasks"} title={selectedGoal ? `Edit: ${selectedGoal.title}` : ""} onClose={() => setSelectedGoalId(null)}>
{selectedGoal ? (
<GoalEditor
key={selectedGoalId}
goal={selectedGoal}
goals={companyGoals}
agents={companyAgents}
companyId={currentCompany!.id}
onSaved={async () => { setSelectedGoalId(null); await refresh(); }}
onDeleted={async () => { setSelectedGoalId(null); await refresh(); }}
/>
) : null}
</Modal>
<Panel
title="Task queue"
action={
<div className="flex items-center gap-2">
<div className="flex rounded-lg border border-border bg-card">
<button
type="button"
onClick={() => setTaskViewMode("list")}
className={`px-2.5 py-1.5 text-[11px] font-medium transition ${taskViewMode === "list" ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground"}`}
>
<ListChecks size={14} />
</button>
<button
type="button"
onClick={() => setTaskViewMode("kanban")}
className={`px-2.5 py-1.5 text-[11px] font-medium transition ${taskViewMode === "kanban" ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground"}`}
>
<Kanban size={14} />
</button>
</div>
<ActionButton label="New task" icon={ListChecks} tone="accent" onClick={() => setSelectedTaskId("__new__")} />
</div>
}
>
{taskViewMode === "kanban" ? (
<KanbanBoard tasks={tasks} agents={companyAgents} runs={runs} onSelectTask={setSelectedTaskId} />
) : (
<TaskQueue tasks={tasks} runs={runs} onSelect={setSelectedTaskId} selectedTaskId={selectedTask?.id ?? null} />
)}
</Panel>
<Modal open={selectedTaskId === "__new__"} title="New task" onClose={() => setSelectedTaskId(null)}>
<TaskCreator companyId={currentCompany!.id} goals={companyGoals} projects={companyProjects} agents={companyAgents} workspaces={companyWorkspaces} onSaved={async () => { setSelectedTaskId(null); await refresh(); }} />
</Modal>
<Modal open={!!selectedTask} title={selectedTask?.title ?? ""} onClose={() => setSelectedTaskId(null)} width="720px">
{selectedTask ? (
<TaskInspector
task={selectedTask}
tasks={tasks}
runs={runs.filter((run) => run.taskId === selectedTask.id)}
agents={companyAgents}
goals={companyGoals}
projects={companyProjects}
workspaces={companyWorkspaces}
companyId={currentCompany!.id}
comments={(snapshot.comments ?? []).filter((comment) => comment.taskId === selectedTask.id)}
onRefresh={refresh}
onRun={startRun}
onCancelRun={cancelRun}
onCreateApproval={async () => {
const isBlocked = selectedTask.status === "blocked";
const approvalId = unwrap(await window.agentCompany.requestApproval({
companyId: currentCompany!.id,
relatedTaskId: selectedTask.id,
requestedByAgentId: selectedTask.assigneeAgentId,
type: isBlocked ? "secret_access" : "approve_ceo_strategy",
payloadSummary: isBlocked
? `Blocker: ${selectedTask.title} — needs operator assistance`
: `Review needed: ${selectedTask.title}`,
impactSummary: isBlocked
? "Agent is blocked and needs operator input (credentials, access, or decision)."
: "No automated reviewer available. Operator review required.",
}));
setSelectedApprovalId(approvalId);
await refresh();
}}
onOpenDiscussion={(taskId) => {
setSelectedTaskId(null);
setSection("communication");
setSelectedCommunicationEntityId(taskId);
}}
onDeleted={async () => { setSelectedTaskId(null); await refresh(); }}
/>
) : null}
</Modal>
</>
) : null}
{section === "approvals" ? (
<>
<Panel title="Agent reviews in progress">
<PendingReviewList tasks={tasks} agents={companyAgents} />
</Panel>
<Panel title="Pending approvals">
<ApprovalList approvals={pendingApprovals} selectedApprovalId={selectedApproval?.id ?? null} onSelect={setSelectedApprovalId} />
</Panel>
<Modal open={!!selectedApproval} title={selectedApproval?.payloadSummary ?? "Approval detail"} onClose={() => setSelectedApprovalId(null)}>
{selectedApproval ? (
<ApprovalInspector
approval={selectedApproval}
agents={companyAgents}
tasks={tasks}
documents={companyDocuments}
companyId={currentCompany!.id}
onDecide={async (state, note) => {
unwrap(await window.agentCompany.decideApproval({ companyId: currentCompany!.id, approvalId: selectedApproval.id, state, decisionNote: note ?? `${state} from desktop control surface.` }));
await refresh();
}}
onDeleted={async () => { setSelectedApprovalId(null); await refresh(); }}
/>
) : null}
</Modal>
</>
) : null}
{section === "costs" ? (
<div className="flex h-full flex-col">
<div className="flex items-center gap-1 border-b border-border px-4 pt-2">
{(["costs", "policies", "incidents"] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setCostsTab(tab)}
className={`rounded-t px-3 py-1.5 text-[13px] font-medium transition-colors ${
costsTab === tab
? "border-b-2 border-[color:var(--primary)] text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab === "costs" ? "Cost Entries" : tab === "policies" ? "Policies" : "Incidents"}
</button>
))}
</div>
{costsTab === "costs" ? (
<EntityWorkspace
title="Costs"
eyebrow={undefined}
primary={
<Panel title="Recent cost entries">
<CostList costs={companyCosts} agents={companyAgents} runs={runs} />
</Panel>
}
secondary={
<Panel title="Budget posture">
<BudgetSummary agents={companyAgents} costs={companyCosts} companyId={currentCompany?.id} />
</Panel>
}
/>
) : costsTab === "policies" ? (
<PolicyList
companyId={currentCompany!.id}
agents={companyAgents.map((a) => ({ id: a.id, name: a.name }))}
projects={companyProjects.map((p) => ({ id: p.id, name: p.name }))}
/>
) : (
<IncidentList
companyId={currentCompany!.id}
agents={companyAgents.map((a) => ({ id: a.id, name: a.name }))}
projects={companyProjects.map((p) => ({ id: p.id, name: p.name }))}
/>
)}
</div>
) : null}
{section === "activity" ? (
<Panel title="Activity timeline">
<ActivityList activity={filteredActivity} />
</Panel>
) : null}
</>
);
}
@@ -0,0 +1,326 @@
import type {
AgentRecord,
BrowserActionRecord,
CompanyRecord,
DocumentRecord,
InboxItem,
CompanyMetrics,
KnowledgeEntryRecord,
MeetingRecord,
ProjectRecord,
RunRecord,
SectionId,
SocialAccountRecord,
SprintRecord,
StandupReport,
TaskRecord,
WorkspaceRecord,
ProfileSnapshot,
} from "@shared/types";
import type { AppLocale } from "@shared/locale";
import { Inbox, StandupView, PerformanceView } from "../../components/inbox";
import { Dashboard } from "../../components/dashboard";
import { EntityWorkspace } from "../../components/EntityWorkspace";
import {
SettingsPane,
ModelManagement,
WorkspaceCreator,
WorkspaceInspector,
WorkspaceList,
} from "../../components/execution";
import { SocialAccountsPanel } from "../../components/social";
import { AutomationPanel } from "../../components/automation";
import { CommunicationHub } from "../../components/communication";
import { SkillsSection } from "../../components/skills";
import { DocumentCenter, KnowledgeBaseManager, MeetingsManager, SprintManager } from "../../components/company-operations";
import { Panel } from "../../components/ui";
import { unwrap } from "../../lib/desktop";
interface OperationsSectionsProps {
section: SectionId;
snapshot: ProfileSnapshot;
currentCompany: CompanyRecord | null;
locale: AppLocale;
tasks: TaskRecord[];
runs: RunRecord[];
companyAgents: AgentRecord[];
companyProjects: ProjectRecord[];
companyWorkspaces: WorkspaceRecord[];
companyDocuments: DocumentRecord[];
companyKnowledge: KnowledgeEntryRecord[];
companyMeetings: MeetingRecord[];
companySprints: SprintRecord[];
socialAccounts: SocialAccountRecord[];
browserActions: BrowserActionRecord[];
inboxItems: InboxItem[];
companyMetrics: CompanyMetrics | null;
standupReport: StandupReport | null;
companySecrets: ProfileSnapshot["secrets"];
selectedWorkspace: WorkspaceRecord | null;
setSelectedWorkspaceId: (id: string | null) => void;
selectedCommunicationEntityId: string | null;
setSelectedCommunicationEntityId: (id: string | null) => void;
setSelectedTaskId: (id: string | null) => void;
setSelectedApprovalId: (id: string | null) => void;
setSection: (section: SectionId) => void;
navigateTo: (section: SectionId, entityId?: string) => void;
refresh: () => Promise<void>;
openPathWithFeedback: (path: string) => Promise<void>;
setError: (error: string | null) => void;
filteredActivity: ReturnType<ProfileSnapshot["activity"]["filter"]>;
companyCosts: ReturnType<ProfileSnapshot["costs"]["filter"]>;
companyGoals: ReturnType<NonNullable<ProfileSnapshot["goals"]>["filter"]>;
}
export function OperationsSections({
section,
snapshot,
currentCompany,
locale,
tasks,
runs,
companyAgents,
companyProjects,
companyWorkspaces,
companyDocuments,
companyKnowledge,
companyMeetings,
companySprints,
socialAccounts,
browserActions,
inboxItems,
companyMetrics,
standupReport,
companySecrets,
selectedWorkspace,
setSelectedWorkspaceId,
selectedCommunicationEntityId,
setSelectedCommunicationEntityId,
setSelectedTaskId,
setSelectedApprovalId,
setSection,
navigateTo,
refresh,
openPathWithFeedback,
setError,
filteredActivity,
companyCosts,
companyGoals,
}: OperationsSectionsProps) {
return (
<>
{section === "overview" ? (
<Dashboard
companyName={currentCompany?.name}
companyId={currentCompany?.id}
agents={companyAgents}
tasks={tasks}
runs={runs}
approvals={snapshot.approvals?.filter((a) => !currentCompany || a.companyId === currentCompany.id) ?? []}
goals={companyGoals}
projects={companyProjects}
activity={filteredActivity}
costs={companyCosts}
messages={snapshot.agentMessages?.filter((message) => !currentCompany || message.companyId === currentCompany.id) ?? []}
onNavigate={navigateTo}
/>
) : null}
{section === "inbox" ? (
<Inbox
items={inboxItems}
onNavigate={navigateTo}
/>
) : null}
{section === "standup" ? (
<StandupView
report={standupReport}
onNavigate={navigateTo}
/>
) : null}
{section === "performance" ? (
<PerformanceView
metrics={companyMetrics}
onNavigate={navigateTo}
/>
) : null}
{section === "social" && snapshot?.currentCompanyId && (
<SocialAccountsPanel
accounts={socialAccounts}
actions={browserActions}
agents={companyAgents.map(a => ({ id: a.id, name: a.name }))}
companyId={snapshot.currentCompanyId}
onSave={async (input) => {
try {
unwrap(await window.agentCompany.saveSocialAccount({
companyId: input.companyId,
platform: input.platform,
accountName: input.accountName,
displayName: input.displayName,
requireApproval: input.requireApproval,
profileUrl: "",
status: "login_required",
metadataJson: "{}",
}));
await refresh();
setError(null);
} catch (error) {
setError(error instanceof Error ? error.message : String(error));
}
}}
onDelete={async (id) => {
if (!snapshot?.currentCompanyId) return;
try {
unwrap(await window.agentCompany.deleteSocialAccount({ id, companyId: snapshot.currentCompanyId }));
await refresh();
setError(null);
} catch (error) {
setError(error instanceof Error ? error.message : String(error));
}
}}
onLogin={async (accountId) => {
if (!snapshot?.currentCompanyId) return;
try {
unwrap(await window.agentCompany.triggerBrowserLogin({ companyId: snapshot.currentCompanyId, socialAccountId: accountId }));
await refresh();
setError(null);
} catch (error) {
setError(error instanceof Error ? error.message : String(error));
}
}}
onOpenPath={async (path) => {
try {
unwrap(await window.agentCompany.openPath(path));
setError(null);
} catch (error) {
setError(error instanceof Error ? error.message : String(error));
}
}}
onCancelAction={async (actionId) => {
if (!snapshot?.currentCompanyId) return;
try {
unwrap(await window.agentCompany.cancelBrowserAction({ actionId, companyId: snapshot.currentCompanyId }));
await refresh();
setError(null);
} catch (error) {
setError(error instanceof Error ? error.message : String(error));
}
}}
onNavigate={(sectionName, entityId) => {
setSection(sectionName);
if (sectionName === "tasks") setSelectedTaskId(entityId);
if (sectionName === "approvals") setSelectedApprovalId(entityId);
}}
/>
)}
{section === "skills" && currentCompany ? (
<SkillsSection companyId={currentCompany.id} agents={companyAgents} onRefresh={refresh} />
) : null}
{section === "automation" && currentCompany ? (
<AutomationPanel
key={currentCompany.id}
rules={snapshot.automationRules?.filter(r => r.companyId === currentCompany.id) ?? []}
workflows={snapshot.workflows?.filter(w => w.companyId === currentCompany.id) ?? []}
companyId={currentCompany.id}
onRefresh={refresh}
/>
) : null}
{section === "communication" && currentCompany ? (
<CommunicationHub
messages={snapshot.agentMessages?.filter(m => m.companyId === currentCompany.id) ?? []}
comments={snapshot.comments?.filter((comment) => comment.companyId === currentCompany.id) ?? []}
tasks={tasks}
agents={companyAgents}
projects={companyProjects}
documents={companyDocuments}
companyId={currentCompany.id}
selectedEntityId={selectedCommunicationEntityId}
onEntityConsumed={() => setSelectedCommunicationEntityId(null)}
onNavigateToTask={(taskId) => {
setSection("tasks");
setSelectedTaskId(taskId);
}}
onRefresh={refresh}
/>
) : null}
{section === "documents" && currentCompany ? (
<DocumentCenter
companyId={currentCompany.id}
documents={companyDocuments}
agents={companyAgents}
projects={companyProjects}
goals={companyGoals}
onRefresh={refresh}
/>
) : null}
{section === "knowledge" && currentCompany ? (
<KnowledgeBaseManager companyId={currentCompany.id} entries={companyKnowledge} onRefresh={refresh} />
) : null}
{section === "meetings" && currentCompany ? (
<MeetingsManager companyId={currentCompany.id} meetings={companyMeetings} agents={companyAgents} onRefresh={refresh} />
) : null}
{section === "sprints" && currentCompany ? (
<SprintManager companyId={currentCompany.id} sprints={companySprints} onRefresh={refresh} />
) : null}
{section === "workspaces" && currentCompany ? (
<EntityWorkspace
title="Workspaces"
eyebrow={undefined}
primary={
<Panel title="Workspace registry">
<WorkspaceList
workspaces={companyWorkspaces}
projects={companyProjects}
companyId={currentCompany!.id}
selectedWorkspaceId={selectedWorkspace?.id ?? null}
onSelect={setSelectedWorkspaceId}
onOpen={openPathWithFeedback}
onDeleted={refresh}
/>
</Panel>
}
secondary={
<div className="space-y-6">
<Panel title="Workspace control center">
<WorkspaceInspector
workspace={selectedWorkspace}
agents={companyAgents}
tasks={tasks}
runs={runs}
documents={companyDocuments}
project={selectedWorkspace ? (companyProjects.find((project) => project.id === selectedWorkspace.projectId) ?? null) : null}
onOpen={openPathWithFeedback}
/>
</Panel>
<Panel title="Add workspace">
<WorkspaceCreator companyId={currentCompany!.id} projects={companyProjects} onSaved={refresh} />
</Panel>
</div>
}
/>
) : null}
{section === "settings" && currentCompany ? (
<div className="space-y-10">
<Panel title={locale === "zh" ? "配置" : "Profile and release"}>
<SettingsPane snapshot={snapshot} secrets={companySecrets} companyId={currentCompany!.id} onSaved={refresh} />
</Panel>
<Panel title={locale === "zh" ? "模型管理" : "Model Management"}>
<ModelManagement connectors={snapshot.connectors} locale={locale} onSaved={refresh} />
</Panel>
</div>
) : null}
</>
);
}
@@ -0,0 +1,175 @@
import type {
AgentRecord,
CompanyRecord,
GoalRecord,
ProjectRecord,
WorkspaceRecord,
ProfileSnapshot,
SectionId,
} from "@shared/types";
import {
Target,
UserPlus,
Kanban,
} from "@phosphor-icons/react";
import {
AgentCreator,
AgentEditor,
AgentRoster,
GoalCreator,
GoalEditor,
GoalList,
OrgChart,
ProjectCreator,
ProjectEditor,
ProjectList,
} from "../../components/organization";
import { HiringPipeline } from "../../components/hiring";
import { ActionButton, Modal, Panel } from "../../components/ui";
interface OrganizationSectionsProps {
section: SectionId;
snapshot: ProfileSnapshot;
currentCompany: CompanyRecord | null;
companyAgents: AgentRecord[];
companyGoals: GoalRecord[];
companyProjects: ProjectRecord[];
companyWorkspaces: WorkspaceRecord[];
selectedAgentId: string | null;
setSelectedAgentId: (id: string | null) => void;
selectedGoalId: string | null;
setSelectedGoalId: (id: string | null) => void;
selectedProjectId: string | null;
setSelectedProjectId: (id: string | null) => void;
setSection: (section: SectionId) => void;
refresh: () => Promise<void>;
}
export function OrganizationSections({
section,
snapshot,
currentCompany,
companyAgents,
companyGoals,
companyProjects,
companyWorkspaces,
selectedAgentId,
setSelectedAgentId,
selectedGoalId,
setSelectedGoalId,
selectedProjectId,
setSelectedProjectId,
setSection,
refresh,
}: OrganizationSectionsProps) {
const selectedAgent = companyAgents.find((a) => a.id === selectedAgentId) ?? null;
const selectedGoal = companyGoals.find((g) => g.id === selectedGoalId) ?? null;
const selectedProject = companyProjects.find((p) => p.id === selectedProjectId) ?? null;
return (
<>
{section === "agents" ? (
<>
<OrgChart agents={companyAgents} connectors={snapshot.connectors} onSelectAgent={(id) => setSelectedAgentId(id)} />
<Panel title="Agent roster" action={<ActionButton label="New hire request" icon={UserPlus} tone="accent" onClick={() => setSelectedAgentId("__new__")} />}>
<AgentRoster
agents={companyAgents}
connectors={snapshot.connectors}
workspaces={companyWorkspaces}
selectedAgentId={selectedAgentId}
onSelect={setSelectedAgentId}
/>
</Panel>
<Modal open={selectedAgentId === "__new__"} title="Request new hire" onClose={() => setSelectedAgentId(null)}>
<AgentCreator
companyId={currentCompany!.id}
connectors={snapshot.connectors}
workspaces={companyWorkspaces}
agents={companyAgents}
onSaved={async () => {
setSelectedAgentId(null);
setSection("hiring");
await refresh();
}}
/>
</Modal>
<Modal open={!!selectedAgent} title={selectedAgent ? `Edit: ${selectedAgent.name}` : ""} onClose={() => setSelectedAgentId(null)}>
{selectedAgent ? (
<AgentEditor
key={selectedAgentId}
agent={selectedAgent}
connectors={snapshot.connectors}
workspaces={companyWorkspaces}
agents={companyAgents}
companyId={currentCompany!.id}
onSaved={refresh}
onDeleted={async () => { setSelectedAgentId(null); await refresh(); }}
/>
) : null}
</Modal>
</>
) : null}
{section === "orgchart" ? (
<OrgChart agents={companyAgents} connectors={snapshot.connectors} onSelectAgent={(id) => { setSelectedAgentId(id); setSection("agents"); }} />
) : null}
{section === "hiring" && currentCompany ? (
<HiringPipeline
agents={companyAgents}
approvals={(snapshot?.approvals ?? []).filter(a => a.companyId === currentCompany!.id)}
onRefresh={refresh}
onNavigateToAgent={(id) => { setSelectedAgentId(id); setSection("agents"); }}
/>
) : null}
{section === "goals" ? (
<>
<Panel title="Goal stack" action={<ActionButton label="New goal" icon={Target} tone="accent" onClick={() => setSelectedGoalId("__new__")} />}>
<GoalList goals={companyGoals} agents={companyAgents} selectedGoalId={selectedGoalId} onSelect={setSelectedGoalId} />
</Panel>
<Modal open={selectedGoalId === "__new__"} title="New goal" onClose={() => setSelectedGoalId(null)}>
<GoalCreator companyId={currentCompany!.id} agents={companyAgents} goals={companyGoals} onSaved={async () => { setSelectedGoalId(null); await refresh(); }} />
</Modal>
<Modal open={!!selectedGoal} title={selectedGoal ? `Edit: ${selectedGoal.title}` : ""} onClose={() => setSelectedGoalId(null)}>
{selectedGoal ? (
<GoalEditor
key={selectedGoalId}
goal={selectedGoal}
goals={companyGoals}
agents={companyAgents}
companyId={currentCompany!.id}
onSaved={async () => { setSelectedGoalId(null); await refresh(); }}
onDeleted={async () => { setSelectedGoalId(null); await refresh(); }}
/>
) : null}
</Modal>
</>
) : null}
{section === "projects" ? (
<>
<Panel title="Projects" action={<ActionButton label="New project" icon={Kanban} tone="accent" onClick={() => setSelectedProjectId("__new__")} />}>
<ProjectList projects={companyProjects} goals={companyGoals} agents={companyAgents} workspaces={companyWorkspaces} selectedProjectId={selectedProjectId} onSelect={setSelectedProjectId} />
</Panel>
<Modal open={selectedProjectId === "__new__"} title="New project" onClose={() => setSelectedProjectId(null)}>
<ProjectCreator companyId={currentCompany!.id} goals={companyGoals} agents={companyAgents} onSaved={async () => { setSelectedProjectId(null); await refresh(); }} />
</Modal>
<Modal open={!!selectedProject} title={selectedProject ? `Edit: ${selectedProject.name}` : ""} onClose={() => setSelectedProjectId(null)}>
{selectedProject ? (
<ProjectEditor
key={selectedProjectId}
project={selectedProject}
goals={companyGoals}
agents={companyAgents}
companyId={currentCompany!.id}
onSaved={async () => { setSelectedProjectId(null); await refresh(); }}
onDeleted={async () => { setSelectedProjectId(null); await refresh(); }}
/>
) : null}
</Modal>
</>
) : null}
</>
);
}