large update
This commit is contained in:
1 parent
2ed952f19c
commit
65e85135c1
111 files changed
+3936
-2933
No files matched your search
@@ -11,7 +11,11 @@ interface CompanyStepProps {
|
||||
setCompanyDescription: (v: string) => void;
|
||||
selectedConnectorId: string;
|
||||
setSelectedConnectorId: (v: string) => void;
|
||||
selectedModel: string;
|
||||
setSelectedModel: (v: string) => void;
|
||||
executionReadyConnectors: ProfileSnapshot["connectors"];
|
||||
modelOptions: Array<{ id: string; label: string }>;
|
||||
loadingModels: boolean;
|
||||
workspacePath: string;
|
||||
setWorkspacePath: (v: string) => void;
|
||||
autoApproveHires: boolean;
|
||||
@@ -22,8 +26,6 @@ interface CompanyStepProps {
|
||||
onContinue: () => void;
|
||||
onBack: () => void;
|
||||
error: string | null;
|
||||
isFirstLaunch: boolean;
|
||||
selectedConnector: ProfileSnapshot["connectors"][number] | null;
|
||||
currentLocale: AppLocale;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
@@ -33,9 +35,13 @@ export function CompanyStep({
|
||||
setCompanyName,
|
||||
companyDescription,
|
||||
setCompanyDescription,
|
||||
selectedConnectorId: _selectedConnectorId,
|
||||
selectedConnectorId,
|
||||
setSelectedConnectorId,
|
||||
selectedModel,
|
||||
setSelectedModel,
|
||||
executionReadyConnectors,
|
||||
modelOptions,
|
||||
loadingModels,
|
||||
workspacePath,
|
||||
setWorkspacePath,
|
||||
autoApproveHires,
|
||||
@@ -46,8 +52,6 @@ export function CompanyStep({
|
||||
onContinue,
|
||||
onBack,
|
||||
error,
|
||||
isFirstLaunch,
|
||||
selectedConnector,
|
||||
currentLocale,
|
||||
t,
|
||||
}: CompanyStepProps) {
|
||||
@@ -61,7 +65,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")} 1 / 3
|
||||
{t("onboarding.stepLabel1")} 3 / 5
|
||||
</div>
|
||||
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-foreground">
|
||||
{t("onboarding.step1Title")}
|
||||
@@ -82,18 +86,35 @@ export function CompanyStep({
|
||||
placeholder={t("onboarding.companyDescriptionPlaceholder")}
|
||||
rows={3}
|
||||
/>
|
||||
<Select
|
||||
label={t("onboarding.primaryConnector")}
|
||||
value={selectedConnector?.id ?? ""}
|
||||
onChange={setSelectedConnectorId}
|
||||
options={executionReadyConnectors.map((connector) => ({
|
||||
value: connector.id,
|
||||
label: connector.label,
|
||||
}))}
|
||||
/>
|
||||
<div className="rounded-lg border border-border bg-muted px-4 py-3 text-[12px] leading-relaxed text-muted-foreground">
|
||||
{t("onboarding.connectorInfo")}
|
||||
|
||||
{/* CEO Provider + Model */}
|
||||
<div>
|
||||
<div className="mb-2 text-[12px] font-medium text-muted-foreground">
|
||||
{currentLocale === "zh" ? "CEO 运行时" : "CEO Runtime"}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Select
|
||||
label={currentLocale === "zh" ? "提供商" : "Provider"}
|
||||
value={selectedConnectorId}
|
||||
onChange={setSelectedConnectorId}
|
||||
options={executionReadyConnectors.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.id.startsWith("claude_compat") && c.notes ? `${c.notes}` : c.label,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
label={currentLocale === "zh" ? "模型" : "Model"}
|
||||
value={selectedModel}
|
||||
onChange={setSelectedModel}
|
||||
disabled={loadingModels}
|
||||
options={[
|
||||
{ value: "", label: loadingModels ? (currentLocale === "zh" ? "加载中..." : "Loading...") : (currentLocale === "zh" ? "默认" : "Default") },
|
||||
...modelOptions.map((m) => ({ value: m.id, label: m.label })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="onboarding-workspace-path"
|
||||
@@ -147,21 +168,19 @@ export function CompanyStep({
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
{isFirstLaunch ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-[12px] font-medium text-muted-foreground transition hover:text-foreground"
|
||||
>
|
||||
{t("onboarding.back")}
|
||||
</button>
|
||||
) : <div />}
|
||||
<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}
|
||||
icon={Buildings}
|
||||
tone="accent"
|
||||
disabled={!companyName.trim() || !selectedConnector}
|
||||
disabled={!companyName.trim() || !selectedConnectorId}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -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")} 2 / 3
|
||||
{t("onboarding.stepLabel2")} 4 / 5
|
||||
</div>
|
||||
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-foreground">
|
||||
{t("onboarding.step2Title")}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
ArrowRight,
|
||||
ArrowCounterClockwise,
|
||||
Terminal,
|
||||
} from "@phosphor-icons/react";
|
||||
import { ActionButton } from "../ui";
|
||||
|
||||
interface NodeCheckStepProps {
|
||||
nodeStatus: { installed: boolean; version: string | null } | null;
|
||||
checking: boolean;
|
||||
onCheck: () => void;
|
||||
onInstall: (command: string) => void;
|
||||
onContinue: () => void;
|
||||
onBack: () => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const INSTALL_COMMANDS: Record<string, { label: string; code: string; command: string }> = {
|
||||
darwin: {
|
||||
label: "macOS",
|
||||
code: "brew install node@22",
|
||||
command: "brew install node@22",
|
||||
},
|
||||
win32: {
|
||||
label: "Windows",
|
||||
code: "winget install -e --id OpenJS.NodeJS.LTS",
|
||||
command: "winget install -e --id OpenJS.NodeJS.LTS",
|
||||
},
|
||||
linux: {
|
||||
label: "Linux",
|
||||
code: `# Download and install nvm:
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
|
||||
|
||||
# in lieu of restarting the shell
|
||||
\\. "$HOME/.nvm/nvm.sh"
|
||||
|
||||
# Download and install Node.js:
|
||||
nvm install 22
|
||||
|
||||
# Verify the Node.js version:
|
||||
node -v`,
|
||||
command: `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash && \\. "$HOME/.nvm/nvm.sh" && nvm install 22`,
|
||||
},
|
||||
};
|
||||
|
||||
export function NodeCheckStep({
|
||||
nodeStatus,
|
||||
checking,
|
||||
onCheck,
|
||||
onInstall,
|
||||
onContinue,
|
||||
onBack,
|
||||
t,
|
||||
}: NodeCheckStepProps) {
|
||||
const platform = window.agentCompany.platform;
|
||||
const platformInfo = INSTALL_COMMANDS[platform] ?? INSTALL_COMMANDS.linux;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key="nodecheck"
|
||||
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.stepLabel1")} 1 / 5 — {t("onboarding.nodeCheckStep")}
|
||||
</div>
|
||||
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-foreground">
|
||||
{t("onboarding.nodeCheckTitle")}
|
||||
</div>
|
||||
<div className="mb-6 h-px bg-[color:var(--border)]" />
|
||||
|
||||
{/* Status */}
|
||||
<div className="mb-5 flex items-center gap-2.5 text-[13px]">
|
||||
{checking ? (
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-primary" />
|
||||
) : nodeStatus?.installed ? (
|
||||
<CheckCircle size={18} weight="fill" className="shrink-0 text-green-600 dark:text-green-400" />
|
||||
) : nodeStatus ? (
|
||||
<XCircle size={18} weight="fill" className="shrink-0 text-destructive" />
|
||||
) : null}
|
||||
|
||||
{checking ? (
|
||||
<span className="text-muted-foreground">{t("onboarding.providerChecking")}...</span>
|
||||
) : nodeStatus?.installed ? (
|
||||
<span className="font-medium text-green-600 dark:text-green-400">
|
||||
{t("onboarding.nodeInstalled").replace("{version}", nodeStatus.version ?? "")}
|
||||
</span>
|
||||
) : nodeStatus ? (
|
||||
<span className="font-medium text-destructive">
|
||||
{t("onboarding.nodeNotInstalled")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Platform-specific install instructions (shown when not installed) */}
|
||||
{nodeStatus && !nodeStatus.installed && (
|
||||
<div className="mb-5 space-y-3">
|
||||
<div className="text-[12px] font-medium text-muted-foreground">
|
||||
{platformInfo.label}
|
||||
</div>
|
||||
<pre className="bg-muted rounded-lg border border-border px-4 py-3 font-mono text-[12px] leading-relaxed text-foreground whitespace-pre-wrap">
|
||||
{platformInfo.code}
|
||||
</pre>
|
||||
<ActionButton
|
||||
label={t("onboarding.installNodeTerminal")}
|
||||
onClick={() => onInstall(platformInfo.command)}
|
||||
icon={Terminal}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Check Again */}
|
||||
<div className="mb-6">
|
||||
<ActionButton
|
||||
label={t("onboarding.checkAgain")}
|
||||
onClick={onCheck}
|
||||
icon={ArrowCounterClockwise}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="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}
|
||||
icon={ArrowRight}
|
||||
tone="accent"
|
||||
disabled={!nodeStatus?.installed}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -2,40 +2,39 @@ import { motion } from "framer-motion";
|
||||
import {
|
||||
Buildings,
|
||||
CheckCircle,
|
||||
HardDrive,
|
||||
Plugs,
|
||||
Rocket,
|
||||
Target,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { ConnectorReadiness } from "./ConnectorReadiness";
|
||||
import { AsciiAnimation } from "./AsciiAnimation";
|
||||
|
||||
type Step = "language" | "company" | "mission" | "launch";
|
||||
export type OnboardingStep = "welcome" | "nodecheck" | "providers" | "company" | "mission" | "launch";
|
||||
|
||||
const STEP_LABELS_CONFIG = [
|
||||
{ key: "onboarding.stepLabel1", icon: Buildings },
|
||||
{ key: "onboarding.stepLabel2", icon: Target },
|
||||
{ key: "onboarding.stepLabel3", icon: Rocket },
|
||||
{ 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.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"];
|
||||
|
||||
interface OnboardingHeroProps {
|
||||
step: Step;
|
||||
connectors: ProfileSnapshot["connectors"];
|
||||
step: OnboardingStep;
|
||||
currentLocale: AppLocale;
|
||||
hasRunnableConnector: boolean;
|
||||
onOpenClaudeSettings: () => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export function OnboardingHero({
|
||||
step,
|
||||
connectors,
|
||||
currentLocale,
|
||||
hasRunnableConnector,
|
||||
onOpenClaudeSettings,
|
||||
currentLocale: _currentLocale,
|
||||
t,
|
||||
}: OnboardingHeroProps) {
|
||||
const stepIndex = step === "language" ? -1 : step === "company" ? 0 : step === "mission" ? 1 : 2;
|
||||
const stepIndex = STEP_ORDER.indexOf(step);
|
||||
|
||||
const stepLabels = STEP_LABELS_CONFIG.map((cfg) => ({
|
||||
label: t(cfg.key),
|
||||
icon: cfg.icon,
|
||||
@@ -43,7 +42,7 @@ export function OnboardingHero({
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 items-center justify-center overflow-hidden px-8 py-12 md:px-16">
|
||||
{/* Ambient gradient - subtle animated accent wash */}
|
||||
{/* Ambient gradient */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.035]"
|
||||
style={{
|
||||
@@ -58,7 +57,6 @@ export function OnboardingHero({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ASCII agent activity backdrop */}
|
||||
<AsciiAnimation />
|
||||
|
||||
<motion.div
|
||||
@@ -108,43 +106,32 @@ export function OnboardingHero({
|
||||
</motion.p>
|
||||
|
||||
{/* Step progress */}
|
||||
{step !== "language" ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.35, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="mt-10 flex items-center gap-3"
|
||||
>
|
||||
{stepLabels.map((entry, index) => (
|
||||
<div key={entry.label} className="flex items-center gap-2">
|
||||
{index > 0 ? (
|
||||
<div className={`h-px w-6 transition-colors duration-300 ${index <= stepIndex ? "bg-primary" : "bg-[color:var(--border)]"}`} />
|
||||
) : null}
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-full px-3 py-1.5 text-[11px] font-medium transition-colors duration-300 ${
|
||||
index < stepIndex
|
||||
? "bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400"
|
||||
: index === stepIndex
|
||||
? "bg-accent text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{index < stepIndex ? <CheckCircle size={14} weight="fill" /> : <entry.icon size={14} />}
|
||||
{entry.label}
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.35, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="mt-10 flex flex-wrap items-center gap-2"
|
||||
>
|
||||
{stepLabels.map((entry, index) => (
|
||||
<div key={entry.label} className="flex items-center gap-1.5">
|
||||
{index > 0 ? (
|
||||
<div className={`h-px w-4 transition-colors duration-300 ${index <= stepIndex ? "bg-primary" : "bg-[color:var(--border)]"}`} />
|
||||
) : null}
|
||||
<div
|
||||
className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-medium transition-colors duration-300 ${
|
||||
index < stepIndex
|
||||
? "bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400"
|
||||
: index === stepIndex
|
||||
? "bg-accent text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{index < stepIndex ? <CheckCircle size={12} weight="fill" /> : <entry.icon size={12} />}
|
||||
{entry.label}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
) : null}
|
||||
|
||||
{/* Connector readiness */}
|
||||
<ConnectorReadiness
|
||||
connectors={connectors}
|
||||
currentLocale={currentLocale}
|
||||
hasRunnableConnector={hasRunnableConnector}
|
||||
onOpenClaudeSettings={onOpenClaudeSettings}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* ELYDORA signature */}
|
||||
<motion.div
|
||||
@@ -173,7 +160,6 @@ export function OnboardingHero({
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Inline keyframe for ambient drift */}
|
||||
<style>{`
|
||||
@keyframes hero-drift {
|
||||
0% { transform: translate(0, 0) scale(1); }
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { isConnectorExecutionReady } from "@shared/connector-policy";
|
||||
import type { OnboardingBootstrapResult, ProfileSnapshot } from "@shared/types";
|
||||
import type { ConnectorId, OnboardingBootstrapResult, ProfileSnapshot } from "@shared/types";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { useT } from "../../i18n";
|
||||
import { unwrap } from "../../lib/desktop";
|
||||
import { OnboardingHero } from "./OnboardingHero";
|
||||
import { LanguageStep } from "./LanguageStep";
|
||||
import { OnboardingHero, 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 { ClaudeSettingsDialog } from "./ClaudeSettingsDialog";
|
||||
|
||||
type Step = "language" | "company" | "mission" | "launch";
|
||||
|
||||
export function CompanyOnboarding({
|
||||
connectors,
|
||||
@@ -32,7 +31,178 @@ export function CompanyOnboarding({
|
||||
isFirstLaunch?: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [step, setStep] = useState<Step>(isFirstLaunch ? "language" : "company");
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 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);
|
||||
|
||||
const checkNode = useCallback(async () => {
|
||||
setNodeChecking(true);
|
||||
try {
|
||||
const result = unwrap(await window.agentCompany.checkNodeInstalled());
|
||||
setNodeStatus(result);
|
||||
} catch {
|
||||
setNodeStatus({ installed: false, version: null });
|
||||
} finally {
|
||||
setNodeChecking(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-check node when entering the nodecheck step
|
||||
useEffect(() => {
|
||||
if (step === "nodecheck" && nodeStatus === null) {
|
||||
void checkNode();
|
||||
}
|
||||
}, [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]);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 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 [companyName, setCompanyName] = useState("");
|
||||
const [companyDescription, setCompanyDescription] = useState("");
|
||||
const [workspacePath, setWorkspacePath] = useState("");
|
||||
@@ -43,68 +213,6 @@ export function CompanyOnboarding({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [launchSummary, setLaunchSummary] = useState<OnboardingBootstrapResult | null>(null);
|
||||
const [claudeSettingsOpen, setClaudeSettingsOpen] = useState(false);
|
||||
const [claudeApiKey, setClaudeApiKey] = useState("");
|
||||
const [claudeBaseUrl, setClaudeBaseUrl] = useState("");
|
||||
const [claudeModel, setClaudeModel] = useState("");
|
||||
const [claudeSettingsSaving, setClaudeSettingsSaving] = useState(false);
|
||||
|
||||
const executionReadyConnectors = useMemo(
|
||||
() => connectors.filter((connector) => isConnectorExecutionReady(connector)),
|
||||
[connectors],
|
||||
);
|
||||
const [selectedConnectorId, setSelectedConnectorId] = useState<string>(executionReadyConnectors[0]?.id ?? "");
|
||||
const hasRunnableConnector = executionReadyConnectors.length > 0;
|
||||
const selectedConnector =
|
||||
executionReadyConnectors.find((connector) => connector.id === selectedConnectorId) ?? executionReadyConnectors[0] ?? null;
|
||||
|
||||
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 openClaudeSettings = useCallback(() => {
|
||||
const claude = connectors.find((c) => c.id === "claude_local");
|
||||
if (!claude) return;
|
||||
const lines = claude.envBindingText.split(/\r?\n/);
|
||||
setClaudeApiKey(lines.find((l) => l.startsWith("ANTHROPIC_API_KEY="))?.split("=").slice(1).join("=") ?? "");
|
||||
setClaudeBaseUrl(lines.find((l) => l.startsWith("ANTHROPIC_BASE_URL="))?.split("=").slice(1).join("=") ?? "");
|
||||
setClaudeModel(claude.model ?? "");
|
||||
setClaudeSettingsOpen(true);
|
||||
}, [connectors]);
|
||||
|
||||
const saveClaudeSettings = useCallback(async () => {
|
||||
setClaudeSettingsSaving(true);
|
||||
try {
|
||||
const envLines: string[] = [];
|
||||
if (claudeApiKey.trim()) envLines.push(`ANTHROPIC_API_KEY=${claudeApiKey.trim()}`);
|
||||
if (claudeBaseUrl.trim()) envLines.push(`ANTHROPIC_BASE_URL=${claudeBaseUrl.trim()}`);
|
||||
const claude = connectors.find((c) => c.id === "claude_local");
|
||||
if (!claude) return;
|
||||
const existingLines = claude.envBindingText.split(/\r?\n/).filter((l) => {
|
||||
const key = l.split("=")[0]?.trim();
|
||||
return key && key !== "ANTHROPIC_API_KEY" && key !== "ANTHROPIC_BASE_URL" && l.trim();
|
||||
});
|
||||
const newEnvText = [...envLines, ...existingLines].join("\n");
|
||||
unwrap(await window.agentCompany.saveConnector({
|
||||
id: "claude_local",
|
||||
command: claude.command,
|
||||
model: claudeModel.trim() || null,
|
||||
envBindingText: newEnvText,
|
||||
notes: claude.notes,
|
||||
}));
|
||||
setClaudeSettingsOpen(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setClaudeSettingsSaving(false);
|
||||
}
|
||||
}, [claudeApiKey, claudeBaseUrl, claudeModel, connectors]);
|
||||
|
||||
const pickDirectory = useCallback(async () => {
|
||||
setError(null);
|
||||
@@ -128,6 +236,7 @@ export function CompanyOnboarding({
|
||||
goalTitle: goalTitle.trim(),
|
||||
goalDescription: goalDescription.trim(),
|
||||
connectorId: selectedConnector?.id,
|
||||
connectorModel: selectedModel.trim(),
|
||||
}));
|
||||
setLaunchSummary(result);
|
||||
setStep("launch");
|
||||
@@ -139,22 +248,19 @@ export function CompanyOnboarding({
|
||||
goalDescription,
|
||||
goalTitle,
|
||||
selectedConnector?.id,
|
||||
selectedModel,
|
||||
workspacePath,
|
||||
]);
|
||||
|
||||
const continueToMission = useCallback(async () => {
|
||||
const continueToMission = useCallback(() => {
|
||||
if (loading) return;
|
||||
if (!companyName.trim()) {
|
||||
setError(t("onboarding.enterCompanyName"));
|
||||
return;
|
||||
}
|
||||
if (!selectedConnector) {
|
||||
setError(t("onboarding.connectorRequired"));
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setStep("mission");
|
||||
}, [companyName, loading, selectedConnector, t]);
|
||||
}, [companyName, loading, t]);
|
||||
|
||||
const launchCompany = useCallback(async () => {
|
||||
if (loading) return;
|
||||
@@ -186,109 +292,136 @@ export function CompanyOnboarding({
|
||||
}
|
||||
}, [launchSummary, onDone, t]);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Render */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const isWelcome = step === "welcome";
|
||||
const heroStep: OnboardingStep = step as OnboardingStep;
|
||||
|
||||
return (
|
||||
<div className="relative grid h-[100dvh] overflow-hidden bg-background md:grid-cols-[minmax(0,1fr)_540px]">
|
||||
{/* Drag region for macOS frameless title bar */}
|
||||
<div
|
||||
className={`relative h-[100dvh] overflow-hidden bg-background ${
|
||||
isWelcome
|
||||
? "flex items-stretch"
|
||||
: "grid md:grid-cols-[minmax(0,1fr)_540px]"
|
||||
}`}
|
||||
>
|
||||
{/* Drag region for frameless title bar */}
|
||||
<div className="sidebar-drag absolute inset-x-0 top-0 z-10 h-[38px]" />
|
||||
<OnboardingHero
|
||||
step={step}
|
||||
connectors={connectors}
|
||||
currentLocale={currentLocale}
|
||||
hasRunnableConnector={hasRunnableConnector}
|
||||
onOpenClaudeSettings={openClaudeSettings}
|
||||
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 === "language" ? (
|
||||
<LanguageStep
|
||||
currentLocale={currentLocale}
|
||||
onLocaleChange={onLocaleChange}
|
||||
onContinue={() => setStep("company")}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === "company" ? (
|
||||
<CompanyStep
|
||||
companyName={companyName}
|
||||
setCompanyName={setCompanyName}
|
||||
companyDescription={companyDescription}
|
||||
setCompanyDescription={setCompanyDescription}
|
||||
selectedConnectorId={selectedConnectorId}
|
||||
setSelectedConnectorId={setSelectedConnectorId}
|
||||
executionReadyConnectors={executionReadyConnectors}
|
||||
workspacePath={workspacePath}
|
||||
setWorkspacePath={setWorkspacePath}
|
||||
autoApproveHires={autoApproveHires}
|
||||
setAutoApproveHires={setAutoApproveHires}
|
||||
reviewDeliverables={reviewDeliverables}
|
||||
setReviewDeliverables={setReviewDeliverables}
|
||||
onPickDirectory={() => void pickDirectory()}
|
||||
onContinue={() => void continueToMission()}
|
||||
onBack={() => setStep("language")}
|
||||
error={error}
|
||||
isFirstLaunch={isFirstLaunch}
|
||||
selectedConnector={selectedConnector}
|
||||
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>
|
||||
|
||||
{claudeSettingsOpen ? (
|
||||
<ClaudeSettingsDialog
|
||||
claudeApiKey={claudeApiKey}
|
||||
setClaudeApiKey={setClaudeApiKey}
|
||||
claudeBaseUrl={claudeBaseUrl}
|
||||
setClaudeBaseUrl={setClaudeBaseUrl}
|
||||
claudeModel={claudeModel}
|
||||
setClaudeModel={setClaudeModel}
|
||||
onSave={() => void saveClaudeSettings()}
|
||||
onClose={() => setClaudeSettingsOpen(false)}
|
||||
saving={claudeSettingsSaving}
|
||||
{isWelcome ? (
|
||||
<WelcomeStep
|
||||
onContinue={() => setStep("nodecheck")}
|
||||
currentLocale={currentLocale}
|
||||
onLocaleChange={onLocaleChange}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
) : (
|
||||
<>
|
||||
<OnboardingHero
|
||||
step={heroStep}
|
||||
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={() => 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
ArrowRight,
|
||||
CaretDown,
|
||||
CaretRight,
|
||||
ArrowSquareOut,
|
||||
FloppyDisk,
|
||||
SignIn,
|
||||
Terminal,
|
||||
} 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;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Props */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface ProvidersStepProps {
|
||||
connectors: ProfileSnapshot["connectors"];
|
||||
onTestConnector: (connectorId: string) => void;
|
||||
onSaveConnector: (connectorId: string, envBindingText: string, model: string | null, notes?: string) => void;
|
||||
onLoginConnector: (connectorId: string) => void;
|
||||
onInstallCommand: (title: string, command: string) => void;
|
||||
hasAtLeastOneReady: boolean;
|
||||
onContinue: () => void;
|
||||
onBack: () => void;
|
||||
error: string | null;
|
||||
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-green-100 px-2 py-0.5 text-[11px] font-medium text-green-700 dark:bg-green-900/40 dark:text-green-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-600 dark:bg-green-400" />
|
||||
{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-yellow-100 px-2 py-0.5 text-[11px] font-medium text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-yellow-600 dark:bg-yellow-400" />
|
||||
{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-orange-100 px-2 py-0.5 text-[11px] font-medium text-orange-700 dark:bg-orange-900/40 dark:text-orange-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-orange-600 dark:bg-orange-400" />
|
||||
{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 modelsCsv = models.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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function ProvidersStep({
|
||||
connectors,
|
||||
onTestConnector,
|
||||
onSaveConnector,
|
||||
onLoginConnector,
|
||||
onInstallCommand,
|
||||
hasAtLeastOneReady,
|
||||
onContinue,
|
||||
onBack,
|
||||
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);
|
||||
}
|
||||
|
||||
const claudeStatus = claudeConnector?.status ?? "not_installed";
|
||||
const codexStatus = codexConnector?.status ?? "not_installed";
|
||||
const geminiStatus = geminiConnector?.status ?? "not_installed";
|
||||
|
||||
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()}`);
|
||||
onSaveConnector("claude_local", lines.join("\n"), null);
|
||||
}
|
||||
|
||||
function handleCodexSave() {
|
||||
const lines: string[] = [];
|
||||
if (codexApiKey.trim()) lines.push(`OPENAI_API_KEY=${codexApiKey.trim()}`);
|
||||
onSaveConnector("codex_local", lines.join("\n"), null);
|
||||
}
|
||||
|
||||
function openExternalDoc(url: string) {
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key="providers"
|
||||
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"
|
||||
>
|
||||
{/* 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")}
|
||||
</div>
|
||||
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-foreground">
|
||||
{t("onboarding.providersTitle")}
|
||||
</div>
|
||||
<div className="mb-4 text-[13px] leading-relaxed text-muted-foreground">
|
||||
{t("onboarding.providersSubtitle")}
|
||||
</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")}
|
||||
badge={<StatusBadge status={claudeStatus} t={t} />}
|
||||
expanded={expandedCard === "claude"}
|
||||
onToggle={() => toggle("claude")}
|
||||
>
|
||||
{claudeStatus === "not_installed" && (
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLoginConnector("claude_local")}
|
||||
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} />
|
||||
{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>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className={INPUT_CLS}
|
||||
value={claudeApiKey}
|
||||
onChange={(e) => setClaudeApiKey(e.target.value)}
|
||||
placeholder="sk-ant-..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClaudeSave}
|
||||
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>
|
||||
|
||||
{/* ---- Card 2: Codex (Official) ---- */}
|
||||
<ProviderCard
|
||||
title={t("onboarding.codexLabel")}
|
||||
badge={<StatusBadge status={codexStatus} t={t} />}
|
||||
expanded={expandedCard === "codex"}
|
||||
onToggle={() => toggle("codex")}
|
||||
>
|
||||
{codexStatus === "not_installed" && (
|
||||
<InstallBlock
|
||||
macLinuxCmd="npm i -g @openai/codex"
|
||||
windowsCmd={null}
|
||||
onInstall={onInstallCommand}
|
||||
title="Install Codex"
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLoginConnector("codex_local")}
|
||||
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} />
|
||||
{t("onboarding.providerLogin")}
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{t("onboarding.providerApiKey")} (OPENAI_API_KEY)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className={INPUT_CLS}
|
||||
value={codexApiKey}
|
||||
onChange={(e) => setCodexApiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCodexSave}
|
||||
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>
|
||||
|
||||
{/* ---- Card 3: Gemini CLI (Official) ---- */}
|
||||
<ProviderCard
|
||||
title={t("onboarding.geminiLabel")}
|
||||
badge={<StatusBadge status={geminiStatus} t={t} />}
|
||||
expanded={expandedCard === "gemini"}
|
||||
onToggle={() => toggle("gemini")}
|
||||
>
|
||||
{geminiStatus === "not_installed" && (
|
||||
<InstallBlock
|
||||
macLinuxCmd="npm install -g @google/gemini-cli"
|
||||
windowsCmd={null}
|
||||
onInstall={onInstallCommand}
|
||||
title="Install Gemini CLI"
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLoginConnector("gemini_local")}
|
||||
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} />
|
||||
{t("onboarding.providerLogin")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTestConnector("gemini_local")}
|
||||
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.providerCheck")}
|
||||
</button>
|
||||
</ProviderCard>
|
||||
|
||||
{/* ---- Compatible API providers (up to 3 slots) ---- */}
|
||||
{visibleCompatSlots.map((slotId) => (
|
||||
<CompatProviderCard
|
||||
key={slotId}
|
||||
slotId={slotId}
|
||||
connector={compatSlots[COMPAT_SLOTS.indexOf(slotId)] ?? null}
|
||||
claudeInstalled={claudeStatus !== "not_installed"}
|
||||
onSaveConnector={onSaveConnector}
|
||||
onInstallCommand={onInstallCommand}
|
||||
expanded={expandedCard === slotId}
|
||||
onToggle={() => toggle(slotId)}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error ? (
|
||||
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-red-100 px-4 py-3 text-[13px] text-destructive dark:bg-red-900/50">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Bottom bar */}
|
||||
<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>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{!hasAtLeastOneReady && (
|
||||
<span className="text-[12px] text-muted-foreground">
|
||||
{t("onboarding.providerAtLeastOne")}
|
||||
</span>
|
||||
)}
|
||||
<ActionButton
|
||||
label={t("onboarding.continue")}
|
||||
onClick={onContinue}
|
||||
icon={ArrowRight}
|
||||
tone="accent"
|
||||
disabled={!hasAtLeastOneReady}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { GlobeSimple, Rocket } from "@phosphor-icons/react";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { APP_LOCALES, LOCALE_LABELS } from "@shared/locale";
|
||||
import { AsciiAnimation } from "./AsciiAnimation";
|
||||
import { ActionButton } from "../ui";
|
||||
|
||||
interface WelcomeStepProps {
|
||||
onContinue: () => void;
|
||||
currentLocale: AppLocale;
|
||||
onLocaleChange: (locale: AppLocale) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export function WelcomeStep({ onContinue, currentLocale, onLocaleChange, t }: WelcomeStepProps) {
|
||||
return (
|
||||
<div className="relative flex w-full min-h-[100dvh] flex-col items-center justify-center overflow-hidden bg-background">
|
||||
<AsciiAnimation />
|
||||
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-10 h-40"
|
||||
style={{ background: "linear-gradient(to bottom, var(--background), transparent)" }}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-40"
|
||||
style={{ background: "linear-gradient(to top, var(--background), transparent)" }}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="relative z-20 flex flex-col items-center text-center px-8"
|
||||
>
|
||||
<h1 className="text-[48px] font-bold leading-[1.05] tracking-[-0.03em] text-foreground md:text-[64px]">
|
||||
AgentCompany
|
||||
</h1>
|
||||
|
||||
<p className="mt-4 max-w-[480px] text-[16px] leading-relaxed text-muted-foreground">
|
||||
{t("onboarding.welcomeSubtitle")}
|
||||
</p>
|
||||
|
||||
<div className="mt-10">
|
||||
<ActionButton
|
||||
label={t("onboarding.getStarted")}
|
||||
icon={Rocket}
|
||||
tone="accent"
|
||||
onClick={onContinue}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Inline language selector */}
|
||||
<div className="mt-6 flex items-center gap-2">
|
||||
<GlobeSimple size={14} className="text-muted-foreground" />
|
||||
{APP_LOCALES.map((locale, i) => (
|
||||
<span key={locale} className="flex items-center gap-2">
|
||||
{i > 0 && <span className="text-[12px] text-muted-foreground">/</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLocaleChange(locale)}
|
||||
className={`text-[13px] transition ${
|
||||
currentLocale === locale
|
||||
? "font-semibold text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{LOCALE_LABELS[locale]}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ELYDORA signature */}
|
||||
<div className="mt-12 flex items-center gap-3">
|
||||
<div
|
||||
className="h-px w-8"
|
||||
style={{ background: "linear-gradient(90deg, var(--primary), transparent)" }}
|
||||
/>
|
||||
<span
|
||||
className="select-none text-[10px] font-semibold uppercase tracking-[0.2em]"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, var(--primary) 0%, var(--muted-foreground) 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
backgroundClip: "text",
|
||||
}}
|
||||
>
|
||||
by ELYDORA
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Minus, Square, X } from "@phosphor-icons/react";
|
||||
|
||||
export default function TitleBar() {
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
|
||||
const refreshMaximized = useCallback(() => {
|
||||
void window.agentCompany.windowIsMaximized().then(setMaximized);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshMaximized();
|
||||
const onResize = () => refreshMaximized();
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [refreshMaximized]);
|
||||
|
||||
return (
|
||||
<div className="titlebar-drag flex h-[32px] shrink-0 select-none items-center justify-between bg-card border-b border-border">
|
||||
<div className="flex items-center gap-0.5 pl-3">
|
||||
<span className="text-[12px] font-semibold tracking-[-0.01em] text-muted-foreground">
|
||||
AgentCompany
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Minimize"
|
||||
className="titlebar-button flex h-full w-[46px] items-center justify-center text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
||||
onClick={() => void window.agentCompany.windowMinimize()}
|
||||
>
|
||||
<Minus size={14} weight="bold" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={maximized ? "Restore" : "Maximize"}
|
||||
className="titlebar-button flex h-full w-[46px] items-center justify-center text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
||||
onClick={() => {
|
||||
void window.agentCompany.windowMaximize();
|
||||
setTimeout(refreshMaximized, 50);
|
||||
}}
|
||||
>
|
||||
{maximized ? (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2">
|
||||
<rect x="2" y="0" width="8" height="8" rx="1" />
|
||||
<rect x="0" y="2" width="8" height="8" rx="1" fill="var(--card)" />
|
||||
<rect x="0" y="2" width="8" height="8" rx="1" />
|
||||
</svg>
|
||||
) : (
|
||||
<Square size={12} weight="bold" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
className="titlebar-button flex h-full w-[46px] items-center justify-center text-muted-foreground transition hover:bg-red-500 hover:text-white"
|
||||
onClick={() => void window.agentCompany.windowClose()}
|
||||
>
|
||||
<X size={14} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ export { CommunicationHub } from "./communication/CommunicationHub"
|
||||
export { ChannelSidebar, FilterBar } from "./communication/ChannelSidebar"
|
||||
export { MessageList } from "./communication/MessageList"
|
||||
export { MessageBubble, TaskCommentBubble, AgentAvatar } from "./communication/MessageItem"
|
||||
export { MessageComposer, TaskDiscussionComposer, ComposeModal, NoChannelComposer } from "./communication/MessageComposer"
|
||||
export { MessageComposer, TaskDiscussionComposer, NoChannelComposer } from "./communication/MessageComposer"
|
||||
export { ChannelHeader } from "./communication/ChannelHeader"
|
||||
export type { ChannelDescriptor, TaskDiscussionDescriptor, AnyChannel } from "./communication/ChannelHeader"
|
||||
export type { FilterState } from "./communication/ChannelSidebar"
|
||||
@@ -1,9 +1,9 @@
|
||||
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, MessagePriority, ProjectRecord } from "@shared/types"
|
||||
import type { AgentMessageRecord, AgentRecord, MessageChannel } from "@shared/types"
|
||||
import { unwrap } from "../../lib/desktop"
|
||||
import { InlineNotice, Modal, Select, Input, TextArea } from "../ui"
|
||||
import { InlineNotice } from "../ui"
|
||||
import { AgentAvatar } from "./MessageItem"
|
||||
import { useT } from "../../i18n"
|
||||
|
||||
@@ -98,44 +98,3 @@ export function NoChannelComposer() {
|
||||
const t = useT()
|
||||
return <div className="border-t border-border bg-card px-4 py-2.5"><div className="flex items-center gap-2 text-[11px] text-muted-foreground"><ChatCircle size={14} /><span>{t("comm.selectChannelSend")}</span></div></div>
|
||||
}
|
||||
|
||||
export function ComposeModal({ open, onClose, agents, projects, companyId, onSent }: { open: boolean; onClose: () => void; agents: AgentRecord[]; projects?: ProjectRecord[]; companyId: string; onSent: () => void }) {
|
||||
const t = useT()
|
||||
const [fromAgentId, setFromAgentId] = useState(agents[0]?.id ?? ""); const [toAgentId, setToAgentId] = useState(""); const [channel, setChannel] = useState<MessageChannel>("company")
|
||||
const [channelTargetId, setChannelTargetId] = useState(""); const [subject, setSubject] = useState(""); const [body, setBody] = useState("")
|
||||
const [priority, setPriority] = useState<MessagePriority>("normal"); const [sending, setSending] = useState(false); const [sendError, setSendError] = useState<string | null>(null)
|
||||
|
||||
const deptOpts = useMemo(() => { const d = new Set<string>(); for (const a of agents) { if (a.department) d.add(a.department) }; return Array.from(d).sort() }, [agents])
|
||||
useEffect(() => { if (channel !== "direct") setToAgentId(""); if (channel !== "department" && channel !== "project") setChannelTargetId("") }, [channel])
|
||||
useEffect(() => { if (agents.length === 0) { if (fromAgentId) setFromAgentId(""); return }; if (!agents.some((a) => a.id === fromAgentId)) setFromAgentId(agents[0]?.id ?? "") }, [agents, fromAgentId])
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const reqTarget = channel === "department" || channel === "project"
|
||||
if (!body.trim() || !fromAgentId || !subject.trim() || (channel === "direct" && !toAgentId) || (reqTarget && !channelTargetId)) return
|
||||
if (!agents.some((a) => a.id === fromAgentId)) { setSendError("Select a valid sender."); return }
|
||||
setSending(true); setSendError(null)
|
||||
try { unwrap(await window.agentCompany.sendAgentMessage({ companyId, fromAgentId, toAgentId: channel === "direct" && toAgentId ? toAgentId : null, channel, channelTargetId: reqTarget ? channelTargetId || null : null, subject: subject.trim(), body: body.trim(), priority, parentMessageId: null, attachmentsJson: "[]" })); setSubject(""); setBody(""); setPriority("normal"); onSent(); onClose() } catch (e) { setSendError(e instanceof Error ? e.message : "Failed to send.") } finally { setSending(false) }
|
||||
}, [agents, body, channel, channelTargetId, companyId, fromAgentId, onClose, onSent, priority, subject, toAgentId])
|
||||
|
||||
return (
|
||||
<Modal open={open} title={t("comm.composeMessage")} onClose={onClose} width="560px">
|
||||
<div className="space-y-4">
|
||||
{sendError ? <InlineNotice message={sendError} /> : null}
|
||||
<Select label={t("comm.fromAgent")} value={fromAgentId} onChange={setFromAgentId} options={agents.map((a) => ({ value: a.id, label: `${a.name} (${a.role})` }))} />
|
||||
<Select label={t("comm.channel")} value={channel} onChange={(v) => setChannel(v as MessageChannel)} options={[{ value: "company", label: t("comm.companyBroadcast") }, { value: "department", label: t("comm.departmentChannel") }, { value: "project", label: t("comm.projectChannel") }, { value: "incident", label: t("comm.incidentChannel") }, { value: "direct", label: t("comm.directMessage") }]} />
|
||||
{channel === "direct" ? <Select label="To Agent" value={toAgentId} onChange={setToAgentId} options={[{ value: "", label: "Select a recipient..." }, ...agents.filter((a) => a.id !== fromAgentId).map((a) => ({ value: a.id, label: `${a.name} (${a.role})` }))]} />
|
||||
: channel === "department" ? <Select label="Department" value={channelTargetId} onChange={setChannelTargetId} options={[{ value: "", label: "Select department..." }, ...deptOpts.map((d) => ({ value: d, label: d.replace("_", " ").replace(/\b\w/g, (c) => c.toUpperCase()) }))]} />
|
||||
: channel === "project" ? <Select label="Project" value={channelTargetId} onChange={setChannelTargetId} options={[{ value: "", label: "Select project..." }, ...(projects ?? []).map((p) => ({ value: p.id, label: p.name }))]} /> : null}
|
||||
<Input label={t("comm.subject")} value={subject} onChange={setSubject} placeholder={t("comm.messageSubject")} />
|
||||
<TextArea label={t("comm.message")} value={body} onChange={setBody} placeholder={t("comm.writeMessage")} rows={5} />
|
||||
<Select label="Priority" value={priority} onChange={(v) => setPriority(v as MessagePriority)} options={[{ value: "low", label: "Low" }, { value: "normal", label: "Normal" }, { value: "urgent", label: "Urgent" }]} />
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} className="rounded-lg px-3 py-2 text-[13px] font-medium text-muted-foreground transition hover:bg-muted">{t("common.cancel")}</button>
|
||||
<button type="button" onClick={() => void handleSend()} disabled={!body.trim() || !subject.trim() || !fromAgentId || (channel === "direct" && !toAgentId) || ((channel === "department" || channel === "project") && !channelTargetId) || sending} className="inline-flex items-center gap-2 rounded-lg px-3 py-2 text-[13px] font-semibold bg-primary text-primary-foreground transition hover:brightness-90 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none">
|
||||
{sending ? <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> : <PaperPlaneTilt size={14} weight="fill" />}{t("comm.sendMessage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -43,7 +43,6 @@ export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRe
|
||||
const authPollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const authActionCopy = getAuthActionCopy(connector);
|
||||
const needsLogin = connector.authState === "required" || connector.status === "auth_required";
|
||||
const requiresExplicitModel = false;
|
||||
const shouldLoadModelOptions = connector.capabilityMatrix.modelDiscovery;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -87,14 +86,14 @@ export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRe
|
||||
}, [connector.id, shouldLoadModelOptions]);
|
||||
|
||||
const modelSelectOptions = useMemo(() => {
|
||||
const options = [{ value: "", label: requiresExplicitModel ? "Please select a model..." : "Use connector default" }];
|
||||
const options = [{ value: "", label: "Use connector default" }];
|
||||
const hasCurrentModel = modelOptions.some((option) => option.id === model);
|
||||
if (model && !hasCurrentModel) {
|
||||
options.push({ value: model, label: `${model} (saved)` });
|
||||
}
|
||||
options.push(...modelOptions.map((option) => ({ value: option.id, label: option.label || option.id })));
|
||||
return options;
|
||||
}, [model, modelOptions, requiresExplicitModel]);
|
||||
}, [model, modelOptions]);
|
||||
|
||||
const runHealthCheck = useCallback(async (quiet = false) => {
|
||||
setAutoChecking(true);
|
||||
@@ -189,7 +188,7 @@ export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRe
|
||||
{shouldLoadModelOptions ? (
|
||||
<div className="grid gap-2">
|
||||
<Select
|
||||
label={requiresExplicitModel ? "Model (required)" : "Model"}
|
||||
label="Model"
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
options={modelSelectOptions}
|
||||
@@ -200,11 +199,6 @@ export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRe
|
||||
{modelOptionsError ? (
|
||||
<InlineNotice message={modelOptionsError} />
|
||||
) : null}
|
||||
{requiresExplicitModel && !model.trim() ? (
|
||||
<div className="rounded-lg border border-[color:var(--destructive)] bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3 text-[12px] text-yellow-600 dark:text-yellow-400">
|
||||
OpenCode is detected, but it still needs an explicit provider/model selection before AgentCompany can mark it ready.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<Input label="Model" value={model} onChange={setModel} placeholder="Connector default" />
|
||||
@@ -246,7 +240,6 @@ export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRe
|
||||
})()}
|
||||
icon={CheckCircle}
|
||||
loading={saving}
|
||||
disabled={requiresExplicitModel && !model.trim()}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-muted px-4 py-4 text-[12px] leading-relaxed text-muted-foreground">
|
||||
|
||||
@@ -57,7 +57,10 @@ export function Dashboard(props: DashboardProps) {
|
||||
const totalSpend = useMemo(() => costs.reduce((sum, c) => sum + (c.amountUsd ?? 0), 0), [costs]);
|
||||
|
||||
// Attention items
|
||||
const failedRuns = useMemo(() => runs.filter((r) => r.status === "failed" || r.status === "timed_out" || r.status === "interrupted"), [runs]);
|
||||
const failedRuns = useMemo(() => runs.filter((r) =>
|
||||
(r.status === "failed" || r.status === "timed_out") ||
|
||||
(r.status === "interrupted" && r.summary !== "Application shut down." && r.summary !== "Worker process crashed.")
|
||||
), [runs]);
|
||||
const urgentMessages = useMemo(() => messages.filter((m) => !m.readAt && (m.priority === "urgent" || m.channel === "incident")), [messages]);
|
||||
const hasAttentionItems = pendingApprovals.length > 0 || failedRuns.length > 0 || urgentMessages.length > 0;
|
||||
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Buildings,
|
||||
ChartLineUp,
|
||||
Cpu,
|
||||
CurrencyCircleDollar,
|
||||
Heartbeat,
|
||||
Lightning,
|
||||
Rocket,
|
||||
ShieldCheck,
|
||||
Target,
|
||||
TrendUp,
|
||||
Users,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { AgentRecord, RunRecord, SectionId, TaskRecord } from "@shared/types";
|
||||
|
||||
const DEPARTMENT_INFO: Record<string, { label: string; icon: React.ElementType; color: string }> = {
|
||||
executive: { label: "Executive", icon: Buildings, color: "var(--primary)" },
|
||||
engineering: { label: "Engineering", icon: Cpu, color: "#6366f1" },
|
||||
product: { label: "Product", icon: Rocket, color: "#8b5cf6" },
|
||||
design: { label: "Design", icon: Target, color: "#ec4899" },
|
||||
marketing: { label: "Marketing", icon: TrendUp, color: "#0ea5e9" },
|
||||
sales: { label: "Sales", icon: ChartLineUp, color: "#10b981" },
|
||||
hr: { label: "People", icon: Users, color: "#06b6d4" },
|
||||
finance: { label: "Finance", icon: CurrencyCircleDollar, color: "#14b8a6" },
|
||||
legal: { label: "Legal", icon: ShieldCheck, color: "#64748b" },
|
||||
operations: { label: "Operations", icon: Lightning, color: "#475569" },
|
||||
customer_support: { label: "Support", icon: Heartbeat, color: "#3b82f6" },
|
||||
research: { label: "Research", icon: Target, color: "#a855f7" },
|
||||
};
|
||||
|
||||
export function DepartmentOverview({
|
||||
agents,
|
||||
tasks,
|
||||
runs,
|
||||
onNavigate,
|
||||
}: {
|
||||
agents: AgentRecord[];
|
||||
tasks: TaskRecord[];
|
||||
runs: RunRecord[];
|
||||
onNavigate: (section: SectionId) => void;
|
||||
}) {
|
||||
const departments = useMemo(() => {
|
||||
const depts = new Map<string, { agents: AgentRecord[]; tasks: TaskRecord[]; activeRuns: number }>();
|
||||
for (const agent of agents) {
|
||||
if (agent.status === "terminated") continue;
|
||||
const dept = agent.department ?? "unassigned";
|
||||
if (!depts.has(dept)) depts.set(dept, { agents: [], tasks: [], activeRuns: 0 });
|
||||
depts.get(dept)!.agents.push(agent);
|
||||
}
|
||||
for (const task of tasks) {
|
||||
if (task.assigneeAgentId) {
|
||||
const agent = agents.find((a) => a.id === task.assigneeAgentId);
|
||||
if (agent?.department) {
|
||||
const dept = depts.get(agent.department);
|
||||
if (dept) dept.tasks.push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const run of runs) {
|
||||
if (run.status === "running") {
|
||||
const agent = agents.find((a) => a.id === run.agentId);
|
||||
if (agent?.department) {
|
||||
const dept = depts.get(agent.department);
|
||||
if (dept) dept.activeRuns++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(depts.entries())
|
||||
.filter(([key]) => key !== "unassigned")
|
||||
.sort((a, b) => b[1].agents.length - a[1].agents.length);
|
||||
}, [agents, tasks, runs]);
|
||||
|
||||
if (departments.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
|
||||
{departments.map(([deptId, data]) => {
|
||||
const info = DEPARTMENT_INFO[deptId] ?? { label: deptId, icon: Buildings, color: "var(--muted-foreground)" };
|
||||
const Icon = info.icon;
|
||||
const activeTasks = data.tasks.filter((t) => t.status === "in_progress" || t.status === "in_review").length;
|
||||
const doneTasks = data.tasks.filter((t) => t.status === "done").length;
|
||||
const blockedTasks = data.tasks.filter((t) => t.status === "blocked").length;
|
||||
const runningAgents = data.agents.filter((a) => a.status === "running").length;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={deptId}
|
||||
type="button"
|
||||
onClick={() => onNavigate("agents")}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 group relative overflow-hidden rounded-lg border border-border bg-card p-3.5 text-left transition duration-150 hover:border-border hover:shadow-sm"
|
||||
>
|
||||
<div className="absolute left-0 top-0 h-full w-[3px]" style={{ backgroundColor: info.color }} />
|
||||
<div className="flex items-center gap-2 pl-1">
|
||||
<Icon size={15} weight="fill" style={{ color: info.color }} />
|
||||
<span className="truncate text-[11px] font-medium text-foreground">{info.label}</span>
|
||||
{runningAgents > 0 && (
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-green-600 dark:bg-green-400" />
|
||||
<span className="text-[9px] text-green-600 dark:text-green-400">{runningAgents}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-3 gap-1 pl-1">
|
||||
<div>
|
||||
<div className="text-[16px] font-bold tabular-nums text-foreground">{data.agents.length}</div>
|
||||
<div className="text-[9px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">People</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[16px] font-bold tabular-nums text-primary">{activeTasks}</div>
|
||||
<div className="text-[9px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Active</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-[16px] font-bold tabular-nums ${blockedTasks > 0 ? "text-destructive" : "text-green-600 dark:text-green-400"}`}>
|
||||
{blockedTasks > 0 ? blockedTasks : doneTasks}
|
||||
</div>
|
||||
<div className="text-[9px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{blockedTasks > 0 ? "Blocked" : "Done"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { Target } from "@phosphor-icons/react";
|
||||
import type { GoalRecord, SectionId, TaskRecord } from "@shared/types";
|
||||
|
||||
export function GoalProgress({ goals, tasks, onNavigate }: { goals: GoalRecord[]; tasks: TaskRecord[]; onNavigate: (section: SectionId) => void }) {
|
||||
const activeGoals = useMemo(() => goals.filter((g) => g.status === "active"), [goals]);
|
||||
|
||||
if (activeGoals.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{activeGoals.slice(0, 5).map((goal) => {
|
||||
const goalTasks = tasks.filter((t) => t.goalId === goal.id);
|
||||
const done = goalTasks.filter((t) => t.status === "done").length;
|
||||
const total = goalTasks.length;
|
||||
const pct = total === 0 ? 0 : Math.round((done / total) * 100);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={goal.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate("goals")}
|
||||
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-4 py-3.5 text-left transition hover:border-border hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Target size={15} weight="fill" className="shrink-0 text-primary" />
|
||||
<span className="truncate text-[13px] font-medium text-foreground">{goal.title}</span>
|
||||
</div>
|
||||
<span className="shrink-0 whitespace-nowrap text-[13px] font-bold tabular-nums text-primary">{pct}%</span>
|
||||
</div>
|
||||
<div className="mt-2.5 h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-700 ease-out"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[11px] text-muted-foreground" style={{ fontVariantNumeric: "tabular-nums" }}>{done}/{total} tasks complete</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import type {
|
||||
AgentRecord,
|
||||
AgentMessageRecord,
|
||||
ApprovalRecord,
|
||||
BrowserActionRecord,
|
||||
ConnectorRecord,
|
||||
RunRecord,
|
||||
TaskRecord,
|
||||
WorkflowPipelineRecord,
|
||||
} from "@shared/types";
|
||||
import { isConnectorExecutionReady } from "@shared/connector-policy";
|
||||
|
||||
interface HealthInputProps {
|
||||
agents: AgentRecord[];
|
||||
tasks: TaskRecord[];
|
||||
runs: RunRecord[];
|
||||
approvals: ApprovalRecord[];
|
||||
connectors: ConnectorRecord[];
|
||||
messages?: AgentMessageRecord[];
|
||||
browserActions?: BrowserActionRecord[];
|
||||
workflows?: WorkflowPipelineRecord[];
|
||||
}
|
||||
|
||||
export function computeHealthScore(props: HealthInputProps): number {
|
||||
let score = 100;
|
||||
const { agents, tasks, runs, approvals, connectors, messages = [], browserActions = [], workflows = [] } = props;
|
||||
|
||||
const failedRuns = runs.filter((r) => r.status === "failed" || r.status === "timed_out");
|
||||
score -= Math.min(failedRuns.length * 5, 25);
|
||||
|
||||
const blockedTasks = tasks.filter((t) => t.status === "blocked");
|
||||
score -= Math.min(blockedTasks.length * 8, 20);
|
||||
|
||||
const pendingApprovals = approvals.filter((a) => a.state === "pending");
|
||||
score -= Math.min(pendingApprovals.length * 5, 15);
|
||||
|
||||
const errorAgents = agents.filter((a) => a.status === "error");
|
||||
score -= Math.min(errorAgents.length * 10, 20);
|
||||
|
||||
const readyConnectors = connectors.filter((connector) => isConnectorExecutionReady(connector));
|
||||
if (readyConnectors.length === 0 && connectors.length > 0) score -= 15;
|
||||
|
||||
const overBudget = agents.filter((a) => a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd >= a.budgetMonthlyUsd);
|
||||
score -= Math.min(overBudget.length * 5, 10);
|
||||
|
||||
const urgentMessages = messages.filter((message) => !message.readAt && (message.priority === "urgent" || message.channel === "incident"));
|
||||
score -= Math.min(urgentMessages.length * 4, 12);
|
||||
|
||||
const blockedBrowserActions = browserActions.filter((action) => action.status === "approval_required" || action.status === "failed");
|
||||
score -= Math.min(blockedBrowserActions.length * 4, 12);
|
||||
|
||||
const stalledWorkflows = workflows.filter((workflow) => workflow.status === "paused" || workflow.status === "failed");
|
||||
score -= Math.min(stalledWorkflows.length * 5, 15);
|
||||
|
||||
return Math.max(0, Math.min(100, score));
|
||||
}
|
||||
|
||||
function healthLabel(score: number): { label: string; tone: string } {
|
||||
if (score >= 90) return { label: "Excellent", tone: "text-green-600 dark:text-green-400" };
|
||||
if (score >= 70) return { label: "Good", tone: "text-green-600 dark:text-green-400" };
|
||||
if (score >= 50) return { label: "Fair", tone: "text-yellow-600 dark:text-yellow-400" };
|
||||
if (score >= 30) return { label: "Poor", tone: "text-yellow-600 dark:text-yellow-400" };
|
||||
return { label: "Critical", tone: "text-destructive" };
|
||||
}
|
||||
|
||||
export function HealthRing({ score }: { score: number }) {
|
||||
const radius = 38;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (score / 100) * circumference;
|
||||
const { label, tone } = healthLabel(score);
|
||||
|
||||
const strokeColor = score >= 70
|
||||
? "var(--primary)"
|
||||
: score >= 50
|
||||
? "var(--destructive)"
|
||||
: "var(--destructive)";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="relative h-[90px] w-[90px] shrink-0">
|
||||
<svg viewBox="0 0 88 88" className="h-full w-full -rotate-90">
|
||||
<circle cx="44" cy="44" r={radius} fill="none" stroke="var(--border)" strokeWidth="6" />
|
||||
<motion.circle
|
||||
cx="44"
|
||||
cy="44"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
initial={{ strokeDashoffset: circumference }}
|
||||
animate={{ strokeDashoffset: offset }}
|
||||
transition={{ duration: 1.2, ease: "easeOut" }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className={`text-[22px] font-bold ${tone}`}>{score}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Company Health</div>
|
||||
<div className={`text-[16px] font-semibold ${tone}`}>{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
Cpu,
|
||||
Lightning,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
XCircle,
|
||||
} from "@phosphor-icons/react";
|
||||
import type {
|
||||
AgentRecord,
|
||||
AgentMessageRecord,
|
||||
ApprovalRecord,
|
||||
BrowserActionRecord,
|
||||
RunRecord,
|
||||
SectionId,
|
||||
WorkflowPipelineRecord,
|
||||
} from "@shared/types";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
export function NeedsAttention({
|
||||
agents,
|
||||
approvals,
|
||||
runs,
|
||||
messages,
|
||||
browserActions,
|
||||
workflows,
|
||||
onNavigate,
|
||||
}: {
|
||||
agents: AgentRecord[];
|
||||
approvals: ApprovalRecord[];
|
||||
runs: RunRecord[];
|
||||
messages: AgentMessageRecord[];
|
||||
browserActions: BrowserActionRecord[];
|
||||
workflows: WorkflowPipelineRecord[];
|
||||
onNavigate: (section: SectionId, entityId?: string) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const pendingApprovals = useMemo(() => approvals.filter((a) => a.state === "pending"), [approvals]);
|
||||
const failedRuns = useMemo(() => runs.filter((r) => r.status === "failed" || r.status === "timed_out" || r.status === "interrupted"), [runs]);
|
||||
const urgentMessages = useMemo(() => messages.filter((message) => !message.readAt && (message.priority === "urgent" || message.channel === "incident")), [messages]);
|
||||
const blockedBrowserActions = useMemo(() => browserActions.filter((action) => action.status === "approval_required" || action.status === "failed"), [browserActions]);
|
||||
const stalledWorkflows = useMemo(() => workflows.filter((workflow) => workflow.status === "paused" || workflow.status === "failed"), [workflows]);
|
||||
|
||||
if (pendingApprovals.length === 0 && failedRuns.length === 0 && urgentMessages.length === 0 && blockedBrowserActions.length === 0 && stalledWorkflows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-yellow-600 dark:text-yellow-400">{t("dash.needsAttentionSection")}</div>
|
||||
<div className="space-y-1.5">
|
||||
{pendingApprovals.slice(0, 5).map((approval) => {
|
||||
const requester = agents.find((a) => a.id === approval.requestedByAgentId);
|
||||
return (
|
||||
<button
|
||||
key={approval.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate("approvals", approval.id)}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3 text-left transition hover:border-opacity-40"
|
||||
>
|
||||
<ShieldCheck size={18} className="shrink-0 text-yellow-600 dark:text-yellow-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium text-foreground">{approval.payloadSummary}</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="uppercase tracking-[0.1em]">{approval.type.replaceAll("_", " ")}</span>
|
||||
{requester ? <span>by {requester.name}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{failedRuns.slice(0, 3).map((run) => {
|
||||
const agent = agents.find((a) => a.id === run.agentId);
|
||||
return (
|
||||
<button
|
||||
key={run.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate("runs", run.id)}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 bg-red-100 dark:bg-red-900/50 px-4 py-3 text-left transition hover:border-opacity-40"
|
||||
>
|
||||
<XCircle size={18} className="shrink-0 text-destructive" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium text-foreground">{run.summary || "Run failed"}</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
{agent?.name} — {run.errorMessage?.slice(0, 80) ?? "Unknown error"}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{urgentMessages.slice(0, 3).map((message) => {
|
||||
const sender = agents.find((agent) => agent.id === message.fromAgentId);
|
||||
return (
|
||||
<button
|
||||
key={message.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate("communication", message.id)}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3 text-left transition hover:border-opacity-40"
|
||||
>
|
||||
<Users size={18} className="shrink-0 text-yellow-600 dark:text-yellow-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium text-foreground">{message.subject}</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
{sender?.name ?? "Unknown"} — {message.channel} {message.priority === "urgent" ? "· urgent" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{blockedBrowserActions.slice(0, 3).map((action) => (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate("social")}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3 text-left transition hover:border-opacity-40"
|
||||
>
|
||||
<Cpu size={18} className="shrink-0 text-yellow-600 dark:text-yellow-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium text-foreground">{action.resultSummary || action.actionType}</div>
|
||||
<div className="mt-0.5 whitespace-nowrap text-[11px] text-muted-foreground">Browser action {action.status.replaceAll("_", " ")}</div>
|
||||
</div>
|
||||
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
{stalledWorkflows.slice(0, 3).map((workflow) => (
|
||||
<button
|
||||
key={workflow.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate("automation")}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3 text-left transition hover:border-opacity-40"
|
||||
>
|
||||
<Lightning size={18} className="shrink-0 text-yellow-600 dark:text-yellow-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium text-foreground">{workflow.name}</div>
|
||||
<div className="mt-0.5 whitespace-nowrap text-[11px] text-muted-foreground">Workflow {workflow.status}</div>
|
||||
</div>
|
||||
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { Robot } from "@phosphor-icons/react";
|
||||
import type { AgentRecord } from "@shared/types";
|
||||
import { getAgentInitials } from "../../lib/formatters";
|
||||
|
||||
const statusGlow: Record<string, string> = {
|
||||
running: "ring-2 ring-[color:var(--primary)] ring-opacity-50",
|
||||
active: "ring-1 ring-[color:var(--primary)] ring-opacity-30",
|
||||
idle: "",
|
||||
paused: "opacity-60",
|
||||
error: "ring-2 ring-[color:var(--destructive)] ring-opacity-50",
|
||||
pending_approval: "ring-1 ring-[color:var(--destructive)] ring-opacity-40",
|
||||
terminated: "opacity-40",
|
||||
};
|
||||
|
||||
function AgentCard({ agent, onClick }: { agent: AgentRecord; onClick: () => void }) {
|
||||
const initials = getAgentInitials(agent.name);
|
||||
const isActive = agent.status === "running" || agent.status === "active";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 group relative flex flex-col items-center gap-2 rounded-lg border border-border bg-card px-3 py-3.5 text-center transition duration-150 hover:border-border hover:shadow-sm ${statusGlow[agent.status] ?? ""}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-full text-[15px] font-bold ${
|
||||
isActive ? "bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400" : "bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
{initials}
|
||||
</div>
|
||||
{agent.status === "running" ? (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-600 dark:bg-green-400 opacity-40" style={{ willChange: "transform" }} />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-green-600 dark:bg-green-400" />
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-[color:var(--card)]"
|
||||
style={{
|
||||
backgroundColor: agent.status === "error"
|
||||
? "var(--destructive)"
|
||||
: agent.status === "active"
|
||||
? "var(--primary)"
|
||||
: agent.status === "pending_approval"
|
||||
? "var(--destructive)"
|
||||
: agent.status === "paused" || agent.status === "terminated"
|
||||
? "var(--muted-foreground)"
|
||||
: "var(--muted-foreground)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full min-w-0">
|
||||
<div className="truncate text-[12px] font-semibold text-foreground">{agent.name}</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">{agent.title || agent.role}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkforceGrid({ agents, onSelectAgent }: { agents: AgentRecord[]; onSelectAgent: (id: string) => void }) {
|
||||
if (agents.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-10 text-center">
|
||||
<Robot size={32} className="mb-3 text-muted-foreground" />
|
||||
<div className="text-[14px] font-medium text-muted-foreground">No agents hired yet</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{agents.map((agent) => (
|
||||
<AgentCard key={agent.id} agent={agent} onClick={() => onSelectAgent(agent.id)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { CalendarCheck, Clock } from "@phosphor-icons/react";
|
||||
import type { SprintRecord, TaskRecord } from "@shared/types";
|
||||
import { StatusPill } from "../ui";
|
||||
|
||||
export function SprintProgress({ sprint, tasks }: { sprint: SprintRecord; tasks: TaskRecord[] }) {
|
||||
const [nowMs, setNowMs] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setNowMs(Date.now());
|
||||
}, []);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
if (sprint.velocityPoints === 0) return 0;
|
||||
return Math.min(100, Math.round((sprint.completedPoints / sprint.velocityPoints) * 100));
|
||||
}, [sprint]);
|
||||
|
||||
const daysLeft = useMemo(() => {
|
||||
const end = new Date(sprint.endDate).getTime();
|
||||
const now = nowMs ?? end;
|
||||
return Math.max(0, Math.ceil((end - now) / (1000 * 60 * 60 * 24)));
|
||||
}, [sprint, nowMs]);
|
||||
|
||||
const sprintTasks = useMemo(() => {
|
||||
const done = tasks.filter((t) => t.status === "done").length;
|
||||
const active = tasks.filter((t) => t.status === "in_progress" || t.status === "in_review").length;
|
||||
const total = tasks.length;
|
||||
return { done, active, total };
|
||||
}, [tasks]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarCheck size={16} className="text-primary" />
|
||||
<span className="truncate text-[12px] font-semibold text-foreground">{sprint.name}</span>
|
||||
<StatusPill status={sprint.status} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Clock size={12} />
|
||||
{daysLeft} days left
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-primary"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span style={{ fontVariantNumeric: "tabular-nums" }}>{sprint.completedPoints}/{sprint.velocityPoints} points ({progress}%)</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="whitespace-nowrap text-green-600 dark:text-green-400">{sprintTasks.done} done</span>
|
||||
<span className="whitespace-nowrap text-primary">{sprintTasks.active} active</span>
|
||||
<span className="whitespace-nowrap">{sprintTasks.total} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sprint.goal && (
|
||||
<div className="mt-2 text-[11px] italic text-muted-foreground">"{sprint.goal}"</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { ArrowRight } from "@phosphor-icons/react";
|
||||
import type { TaskRecord } from "@shared/types";
|
||||
|
||||
export function TaskPipeline({ tasks }: { tasks: TaskRecord[] }) {
|
||||
const stages = useMemo(() => {
|
||||
const counts = { backlog: 0, todo: 0, in_progress: 0, in_review: 0, done: 0, blocked: 0 };
|
||||
for (const t of tasks) {
|
||||
if (t.status in counts) counts[t.status as keyof typeof counts]++;
|
||||
}
|
||||
return [
|
||||
{ label: "Backlog", count: counts.backlog, tone: "text-muted-foreground", bg: "bg-muted" },
|
||||
{ label: "Todo", count: counts.todo, tone: "text-yellow-600 dark:text-yellow-400", bg: "bg-yellow-100 dark:bg-yellow-900/50" },
|
||||
{ label: "In Progress", count: counts.in_progress, tone: "text-primary", bg: "bg-accent" },
|
||||
{ label: "In Review", count: counts.in_review, tone: "text-yellow-600 dark:text-yellow-400", bg: "bg-yellow-100 dark:bg-yellow-900/50" },
|
||||
{ label: "Done", count: counts.done, tone: "text-green-600 dark:text-green-400", bg: "bg-green-100 dark:bg-green-900/50" },
|
||||
{ label: "Blocked", count: counts.blocked, tone: "text-destructive", bg: "bg-red-100 dark:bg-red-900/50" },
|
||||
];
|
||||
}, [tasks]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{stages.map((stage, i) => (
|
||||
<div key={stage.label} className="flex items-center gap-1.5">
|
||||
<div className={`flex items-center gap-2 rounded-lg px-3 py-2 ${stage.bg}`}>
|
||||
<span className={`text-[18px] font-bold ${stage.tone}`} style={{ fontVariantNumeric: "tabular-nums" }}>{stage.count}</span>
|
||||
<span className="text-[10px] font-medium uppercase tracking-[0.06em] text-muted-foreground">{stage.label}</span>
|
||||
</div>
|
||||
{i < stages.length - 1 ? (
|
||||
<ArrowRight size={12} className="text-muted-foreground" />
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export { WorkspaceInspector } from "./execution/WorkspaceInspector"
|
||||
export { WorkspaceCreator } from "./execution/WorkspaceCreator"
|
||||
export { SecretCreator } from "./execution/SecretCreator"
|
||||
export { SettingsPane } from "./execution/SettingsPane"
|
||||
export { ModelManagement } from "./execution/ModelManagement"
|
||||
export { PendingReviewList } from "./execution/PendingReviewList"
|
||||
export { PolicyList } from "./execution/PolicyList"
|
||||
export { PolicyFormModal } from "./execution/PolicyFormModal"
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { FloppyDisk, Plus, X as XIcon } from "@phosphor-icons/react";
|
||||
import type { ConnectorRecord, ProfileSnapshot } from "@shared/types";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { unwrap } from "../../lib/desktop";
|
||||
import { ActionButton } from "../ui";
|
||||
|
||||
interface ModelManagementProps {
|
||||
connectors: ProfileSnapshot["connectors"];
|
||||
locale: AppLocale;
|
||||
onSaved: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Check if a claude_compat connector has been configured (has env bindings). */
|
||||
function isConfigured(c: ConnectorRecord): boolean {
|
||||
return c.envBindingText.includes("ANTHROPIC_BASE_URL") || c.envBindingText.includes("ANTHROPIC_API_KEY");
|
||||
}
|
||||
|
||||
function getDisplayName(connector: ConnectorRecord): string {
|
||||
if (connector.id.startsWith("claude_compat") && connector.notes) return connector.notes;
|
||||
return connector.label;
|
||||
}
|
||||
|
||||
function CompatModelEditor({
|
||||
connector,
|
||||
locale,
|
||||
onSaved,
|
||||
}: {
|
||||
connector: ConnectorRecord;
|
||||
locale: AppLocale;
|
||||
onSaved: () => Promise<void>;
|
||||
}) {
|
||||
const [models, setModels] = useState<string[]>(() => {
|
||||
const raw = connector.model ?? "";
|
||||
return raw.split(",").map((m) => m.trim()).filter(Boolean);
|
||||
});
|
||||
const [newModel, setNewModel] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const raw = connector.model ?? "";
|
||||
setModels(raw.split(",").map((m) => m.trim()).filter(Boolean));
|
||||
setDirty(false);
|
||||
}, [connector.model]);
|
||||
|
||||
function addModel() {
|
||||
const trimmed = newModel.trim();
|
||||
if (!trimmed || models.includes(trimmed)) return;
|
||||
setModels((prev) => [...prev, trimmed]);
|
||||
setNewModel("");
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
function removeModel(m: string) {
|
||||
setModels((prev) => prev.filter((x) => x !== m));
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
unwrap(await window.agentCompany.saveConnector({
|
||||
id: connector.id,
|
||||
command: connector.command,
|
||||
model: models.join(",") || null,
|
||||
envBindingText: connector.envBindingText,
|
||||
notes: connector.notes,
|
||||
}));
|
||||
setDirty(false);
|
||||
await onSaved();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 text-[13px] font-semibold text-foreground">{getDisplayName(connector)}</div>
|
||||
{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 border border-border bg-muted px-2.5 py-1 text-[12px] text-foreground"
|
||||
>
|
||||
{m}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeModel(m)}
|
||||
className="ml-0.5 text-muted-foreground transition hover:text-destructive"
|
||||
aria-label={`Remove ${m}`}
|
||||
>
|
||||
<XIcon size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-2 text-[12px] text-muted-foreground">
|
||||
{locale === "zh" ? "未配置模型" : "No models configured"}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="flex-1 rounded-lg border border-border bg-card px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
value={newModel}
|
||||
onChange={(e) => setNewModel(e.target.value)}
|
||||
placeholder={locale === "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 p-2 text-foreground transition hover:bg-muted disabled:opacity-40"
|
||||
aria-label="Add model"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
{dirty ? (
|
||||
<ActionButton
|
||||
label={locale === "zh" ? "保存" : "Save"}
|
||||
tone="accent"
|
||||
icon={FloppyDisk}
|
||||
onClick={() => void handleSave()}
|
||||
loading={saving}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NativeModelList({
|
||||
connector,
|
||||
locale,
|
||||
}: {
|
||||
connector: ConnectorRecord;
|
||||
locale: AppLocale;
|
||||
}) {
|
||||
const [models, setModels] = useState<Array<{ id: string; label: string }>>([]);
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.agentCompany.listConnectorModels(connector.id);
|
||||
if (result.ok && result.data.length > 0) {
|
||||
setModels(result.data);
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
}, [connector.id]);
|
||||
|
||||
useEffect(() => { void loadModels(); }, [loadModels]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 text-[13px] font-semibold text-foreground">{getDisplayName(connector)}</div>
|
||||
{models.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{models.map((m) => (
|
||||
<span
|
||||
key={m.id}
|
||||
className="inline-block rounded-md border border-border bg-muted px-2.5 py-1 text-[12px] text-foreground"
|
||||
>
|
||||
{m.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[12px] text-muted-foreground">
|
||||
{locale === "zh" ? "使用默认模型" : "Using default model"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelManagement({ connectors, locale, onSaved }: ModelManagementProps) {
|
||||
// Show native connectors that are ready + claude_compat connectors that are configured
|
||||
const visibleConnectors = connectors.filter((c) => {
|
||||
if (c.id.startsWith("claude_compat")) return isConfigured(c);
|
||||
return c.status === "ready";
|
||||
});
|
||||
|
||||
if (visibleConnectors.length === 0) {
|
||||
return (
|
||||
<div className="py-6 text-center text-[13px] text-muted-foreground">
|
||||
{locale === "zh" ? "暂无就绪的提供商" : "No ready providers"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{visibleConnectors.map((connector) =>
|
||||
connector.id.startsWith("claude_compat") ? (
|
||||
<CompatModelEditor key={connector.id} connector={connector} locale={locale} onSaved={onSaved} />
|
||||
) : (
|
||||
<NativeModelList key={connector.id} connector={connector} locale={locale} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export function TaskCreator({ companyId, goals, projects, agents, workspaces, on
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid min-w-0 gap-4 md:grid-cols-2">
|
||||
<Input label={t("exec.title")} value={title} onChange={setTitle} placeholder={t("exec.taskTitle")} />
|
||||
<Select label={t("exec.priority")} value={priority} onChange={setPriority} options={["critical","high","medium","low"].map((v) => ({ value: v, label: v }))} />
|
||||
<div className="md:col-span-2"><TextArea label="Description" value={description} onChange={setDescription} placeholder="Describe the task" rows={5} /></div>
|
||||
|
||||
@@ -42,7 +42,7 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work
|
||||
{actionError ? <InlineNotice message={actionError} /> : null}
|
||||
{!actionError && actionNotice ? <InlineNotice message={actionNotice} tone="success" /> : null}
|
||||
{editing ? (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="grid min-w-0 gap-3 md:grid-cols-2">
|
||||
<div className="md:col-span-2"><Input label={t("exec.title")} value={editTitle} onChange={setEditTitle} placeholder={t("exec.taskTitle")} /></div>
|
||||
<div className="md:col-span-2"><TextArea label={t("exec.description")} value={editDescription} onChange={setEditDescription} placeholder={t("exec.description")} rows={4} /></div>
|
||||
<Select label={t("exec.priority")} value={editPriority} onChange={v => setEditPriority(v as TaskRecord["priority"])} options={["critical","high","medium","low"].map(v => ({ value: v, label: v }))} />
|
||||
|
||||
@@ -66,7 +66,7 @@ export function AgentCreator({
|
||||
}, [executionReadyConnectors, connectorId]);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid min-w-0 gap-4">
|
||||
<Input label="Agent name" value={name} onChange={setName} placeholder="Agent name" />
|
||||
<Input label="Role" value={role} onChange={setRole} placeholder="e.g. Engineer, PM, CEO" />
|
||||
<Input label="Title" value={title} onChange={setTitle} placeholder="Title" />
|
||||
@@ -74,7 +74,7 @@ export function AgentCreator({
|
||||
label="Connector"
|
||||
value={connectorId}
|
||||
onChange={(value) => setConnectorId(value as ConnectorRecord["id"])}
|
||||
options={executionReadyConnectors.map((connector) => ({ value: connector.id, label: connector.label }))}
|
||||
options={executionReadyConnectors.map((connector) => ({ value: connector.id, label: connector.id.startsWith("claude_compat") && connector.notes ? connector.notes : connector.label }))}
|
||||
/>
|
||||
{executionReadyConnectors.length === 0 ? (
|
||||
<p className="text-[12px] text-yellow-600 dark:text-yellow-400">
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AgentRecord, BudgetPolicyRecord, ConnectorRecord, WorkspaceRecord
|
||||
import { getConnectorExecutionReadinessIssue, isConnectorExecutionReady } from "@shared/connector-policy";
|
||||
import { unwrap } from "../../lib/desktop";
|
||||
import { useAppStore } from "../../state/store";
|
||||
import { useT } from "../../i18n";
|
||||
import { ActionButton, Input, Select, TextArea } from "../ui";
|
||||
import { SkillAgentToggles } from "../skills/SkillAgentToggles";
|
||||
import { ConfirmDelete, HeartbeatSection } from "./helpers";
|
||||
@@ -25,6 +26,7 @@ export function AgentEditor({
|
||||
onSaved: () => Promise<void>;
|
||||
onDeleted: () => Promise<void>;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [name, setName] = useState(agent.name);
|
||||
const [role, setRole] = useState(agent.role);
|
||||
const [title, setTitle] = useState(agent.title);
|
||||
@@ -51,30 +53,43 @@ export function AgentEditor({
|
||||
const persistedConnectorRecord = connectors.find((connector) => connector.id === agent.connectorId) ?? null;
|
||||
const persistedConnectorIssue = persistedConnectorRecord ? getConnectorExecutionReadinessIssue(persistedConnectorRecord) : "Current connector is unavailable.";
|
||||
const connectorOptions = useMemo(() => {
|
||||
const readyOptions = executionReadyConnectors.map((connector) => ({ value: connector.id, label: connector.label }));
|
||||
const getDisplayLabel = (c: typeof connectors[number]) =>
|
||||
c.id.startsWith("claude_compat") && c.notes ? c.notes : c.label;
|
||||
const readyOptions = executionReadyConnectors.map((connector) => ({ value: connector.id, label: getDisplayLabel(connector) }));
|
||||
if (!selectedConnectorRecord || isConnectorExecutionReady(selectedConnectorRecord)) {
|
||||
return readyOptions;
|
||||
}
|
||||
return [
|
||||
{ value: selectedConnectorRecord.id, label: `${selectedConnectorRecord.label} (current, blocked)` },
|
||||
{ value: selectedConnectorRecord.id, label: `${getDisplayLabel(selectedConnectorRecord)} (current, blocked)` },
|
||||
...readyOptions.filter((option) => option.value !== selectedConnectorRecord.id),
|
||||
];
|
||||
}, [executionReadyConnectors, selectedConnectorRecord]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const result = await window.agentCompany.listConnectorModels(connectorId);
|
||||
if (result.ok) {
|
||||
setAvailableModels(result.data);
|
||||
if (connectorId !== agent.connectorId) {
|
||||
setModel(result.data[0]?.id ?? "");
|
||||
}
|
||||
if (connectorId.startsWith("claude_compat")) {
|
||||
// Parse comma-separated user-configured models
|
||||
const compat = connectors.find((c) => c.id === connectorId);
|
||||
const raw = compat?.model ?? "";
|
||||
const models = raw.split(",").map((m) => m.trim()).filter(Boolean).map((m) => ({ id: m, label: m }));
|
||||
setAvailableModels(models);
|
||||
if (connectorId !== agent.connectorId && models.length > 0) {
|
||||
setModel(models[0].id);
|
||||
}
|
||||
})();
|
||||
if (connectorId !== "claude_local" && connectorId !== "codex_local") {
|
||||
} else {
|
||||
void (async () => {
|
||||
const result = await window.agentCompany.listConnectorModels(connectorId);
|
||||
if (result.ok) {
|
||||
setAvailableModels(result.data);
|
||||
if (connectorId !== agent.connectorId) {
|
||||
setModel(result.data[0]?.id ?? "");
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
if (connectorId !== "claude_local" && !connectorId.startsWith("claude_compat") && connectorId !== "codex_local") {
|
||||
setThinkingEffort("");
|
||||
}
|
||||
}, [connectorId, agent.connectorId]);
|
||||
}, [connectorId, agent.connectorId, connectors]);
|
||||
|
||||
const skills = useAppStore((s) => s.skills);
|
||||
const setSkills = useAppStore((s) => s.setSkills);
|
||||
@@ -107,17 +122,17 @@ export function AgentEditor({
|
||||
const managerCandidates = agents.filter((a) => a.id !== agent.id);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Input label="Agent name" value={name} onChange={setName} placeholder="Agent name" readOnly={pendingApproval} disabled={pendingApproval} />
|
||||
<Input label="Role" value={role} onChange={setRole} placeholder="Role" readOnly={pendingApproval} disabled={pendingApproval} />
|
||||
<Input label="Title" value={title} onChange={setTitle} placeholder="Title" readOnly={pendingApproval} disabled={pendingApproval} />
|
||||
<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} />
|
||||
<Input label={t("agent.role")} value={role} onChange={setRole} placeholder={t("agent.role")} readOnly={pendingApproval} disabled={pendingApproval} />
|
||||
<Input label={t("agent.title")} value={title} onChange={setTitle} placeholder={t("agent.title")} readOnly={pendingApproval} disabled={pendingApproval} />
|
||||
<Select
|
||||
label="Status"
|
||||
label={t("agent.status")}
|
||||
value={status}
|
||||
onChange={(v) => setStatus(v as AgentRecord["status"])}
|
||||
disabled={pendingApproval}
|
||||
options={pendingApproval
|
||||
? [{ value: "pending_approval", label: "Pending Approval" }]
|
||||
? [{ value: "pending_approval", label: t("agent.pendingApproval") }]
|
||||
: [
|
||||
{ value: "idle", label: "Idle" },
|
||||
{ value: "active", label: "Active" },
|
||||
@@ -129,11 +144,11 @@ export function AgentEditor({
|
||||
/>
|
||||
{pendingApproval ? (
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
Pending hires must be approved or rejected from Hiring or Approvals. Status changes are intentionally locked here.
|
||||
{t("agent.pendingHireNote")}
|
||||
</p>
|
||||
) : null}
|
||||
<Select
|
||||
label="Connector"
|
||||
label={t("agent.connector")}
|
||||
value={connectorId}
|
||||
onChange={(v) => setConnectorId(v as ConnectorRecord["id"])}
|
||||
disabled={pendingApproval}
|
||||
@@ -146,7 +161,7 @@ export function AgentEditor({
|
||||
) : null}
|
||||
{availableModels.length > 0 ? (
|
||||
<Select
|
||||
label="Model"
|
||||
label={t("agent.model")}
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
disabled={pendingApproval}
|
||||
@@ -155,7 +170,7 @@ export function AgentEditor({
|
||||
) : null}
|
||||
{supportsEffort ? (
|
||||
<Select
|
||||
label="Thinking effort"
|
||||
label={t("agent.thinkingEffort")}
|
||||
value={thinkingEffort}
|
||||
onChange={setThinkingEffort}
|
||||
disabled={pendingApproval}
|
||||
@@ -168,39 +183,39 @@ export function AgentEditor({
|
||||
/>
|
||||
) : null}
|
||||
<Select
|
||||
label="Workspace"
|
||||
label={t("agent.workspace")}
|
||||
value={workspaceId}
|
||||
onChange={setWorkspaceId}
|
||||
disabled={pendingApproval}
|
||||
options={[
|
||||
{ value: "", label: "No workspace" },
|
||||
{ value: "", label: t("agent.noWorkspace") },
|
||||
...workspaces.map((w) => ({ value: w.id, label: w.name })),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Reports to"
|
||||
label={t("agent.reportsTo")}
|
||||
value={reportsTo}
|
||||
onChange={setReportsTo}
|
||||
disabled={pendingApproval}
|
||||
options={[
|
||||
{ value: "", label: "No manager" },
|
||||
{ value: "", label: t("agent.noManager") },
|
||||
...managerCandidates.map((a) => ({ value: a.id, label: a.name })),
|
||||
]}
|
||||
/>
|
||||
<Input label="Monthly budget (USD)" value={budget} onChange={setBudget} placeholder="120" type="number" readOnly={pendingApproval} disabled={pendingApproval} />
|
||||
<TextArea label="Capabilities" value={capabilities} onChange={setCapabilities} placeholder="Capabilities" readOnly={pendingApproval} disabled={pendingApproval} />
|
||||
<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} />
|
||||
|
||||
{/* Skills section (D-04 dual access) */}
|
||||
<div className="mt-6 border-t border-border pt-4">
|
||||
<h3 className="text-[13px] font-semibold uppercase tracking-[0.08em] text-primary mb-3">Skills</h3>
|
||||
<h3 className="text-[13px] font-semibold uppercase tracking-[0.08em] text-primary mb-3">{t("agent.skills")}</h3>
|
||||
<SkillAgentToggles mode="agent" companyId={companyId} agentId={agent.id} skills={skills} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ActionButton
|
||||
label="Save changes"
|
||||
label={t("agent.saveChanges")}
|
||||
tone="accent"
|
||||
icon={Pencil}
|
||||
disabled={pendingApproval || !!selectedConnectorIssue}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function AgentRoster({
|
||||
<StatusPill status={agent.status} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>Connector: {connector?.label ?? agent.connectorId}</span>
|
||||
<span>Connector: {connector?.id.startsWith("claude_compat") && connector.notes ? connector.notes : connector?.label ?? agent.connectorId}</span>
|
||||
{workspace && <span>Workspace: {workspace.name}</span>}
|
||||
</div>
|
||||
<div className="mt-1.5">
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Heartbeat } from "@phosphor-icons/react";
|
||||
import type { AgentRecord } from "@shared/types";
|
||||
import type { AgentRecord, ConnectorRecord } from "@shared/types";
|
||||
import { getAgentInitials } from "../../lib/formatters";
|
||||
import { EmptyState } from "../ui";
|
||||
import { useT } from "../../i18n";
|
||||
import { buildOrgTree, layoutForest, flattenLayout, collectEdges, statusDotColor, adapterLabels, CARD_W, CARD_H, CHART_PADDING } from "./orgTreeLayout";
|
||||
|
||||
export function OrgChart({ agents, onSelectAgent }: { agents: AgentRecord[]; onSelectAgent?: (agentId: string) => void }) {
|
||||
export function OrgChart({ agents, connectors, onSelectAgent }: { agents: AgentRecord[]; connectors?: ConnectorRecord[]; onSelectAgent?: (agentId: string) => void }) {
|
||||
const t = useT();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
@@ -92,7 +92,10 @@ export function OrgChart({ agents, onSelectAgent }: { agents: AgentRecord[]; onS
|
||||
<div className="absolute inset-0" style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, transformOrigin: "0 0" }}>
|
||||
{allNodes.map((node) => {
|
||||
const dotColor = statusDotColor[node.agent.status] ?? "#a3a3a3";
|
||||
const connectorLabel = adapterLabels[node.agent.connectorId] ?? node.agent.connectorId;
|
||||
const connectorRecord = connectors?.find((c) => c.id === node.agent.connectorId);
|
||||
const connectorLabel = (connectorRecord?.id.startsWith("claude_compat") && connectorRecord.notes)
|
||||
? connectorRecord.notes
|
||||
: adapterLabels[node.agent.connectorId] ?? node.agent.connectorId;
|
||||
const initials = getAgentInitials(node.agent.name);
|
||||
const isRunning = node.agent.status === "running";
|
||||
const isActive = node.agent.status === "active" || isRunning;
|
||||
|
||||
@@ -97,6 +97,16 @@ export const statusDotColor: Record<string, string> = {
|
||||
|
||||
export const adapterLabels: Record<string, string> = {
|
||||
claude_local: "Claude",
|
||||
claude_compatible: "Compatible",
|
||||
claude_compat_2: "Compatible 2",
|
||||
claude_compat_3: "Compatible 3",
|
||||
claude_compat_4: "Compatible 4",
|
||||
claude_compat_5: "Compatible 5",
|
||||
claude_compat_6: "Compatible 6",
|
||||
claude_compat_7: "Compatible 7",
|
||||
claude_compat_8: "Compatible 8",
|
||||
claude_compat_9: "Compatible 9",
|
||||
claude_compat_10: "Compatible 10",
|
||||
codex_local: "Codex",
|
||||
gemini_local: "Gemini",
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { RunRecord } from "@shared/types";
|
||||
|
||||
export const LOG_CHUNK_BYTES = 65_536;
|
||||
@@ -27,46 +26,3 @@ export function isActiveStatus(status: RunRecord["status"]) {
|
||||
return status === "queued" || status === "running";
|
||||
}
|
||||
|
||||
export function isAttentionStatus(status: RunRecord["status"]) {
|
||||
return status === "failed" || status === "timed_out" || status === "interrupted";
|
||||
}
|
||||
|
||||
export function RunSummaryCard({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
icon: Icon,
|
||||
tone = "default",
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
detail: string;
|
||||
icon: React.ElementType;
|
||||
tone?: "default" | "danger";
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground">{label}</div>
|
||||
<div className={`mt-1 text-[28px] font-semibold tracking-[-0.03em] ${tone === "danger" ? "text-destructive" : "text-foreground"}`}>
|
||||
{value}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-muted-foreground">{detail}</div>
|
||||
</div>
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${tone === "danger" ? "bg-red-100 dark:bg-red-900/50 text-destructive" : "bg-muted text-muted-foreground"}`}>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RunMeta({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-muted px-3.5 py-3">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-[13px] text-foreground">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -129,21 +129,21 @@ export function Modal({
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className="flex w-full flex-col rounded-lg border border-border bg-card shadow-xl"
|
||||
style={{ maxWidth: width, maxHeight: "80vh" }}
|
||||
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="text-[16px] font-bold tracking-[-0.015em] text-foreground">{title}</h2>
|
||||
<h2 className="min-w-0 truncate text-[16px] font-bold tracking-[-0.015em] text-foreground">{title}</h2>
|
||||
<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={onClose}
|
||||
className="ml-3 shrink-0 cursor-pointer rounded-lg p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M1 1l12 12M13 1L1 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>
|
||||
<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="overflow-y-auto px-5 py-4">{children}</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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -222,7 +222,8 @@ export function TextArea({
|
||||
}) {
|
||||
const textarea = (
|
||||
<textarea
|
||||
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"
|
||||
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}
|
||||
@@ -259,18 +260,18 @@ export function Select({
|
||||
}) {
|
||||
const select = (
|
||||
<select
|
||||
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full border bg-card text-foreground ${
|
||||
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`}
|
||||
} 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}
|
||||
{option.label.length > 80 ? option.label.slice(0, 77) + "..." : option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
Reference in new issue
Block a user