large update

This commit is contained in:
ZacharyZhang-NY committed 2026-03-29 14:08:15 -04:00
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>
);
}
+65
View File
@@ -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>
);
}
+1 -1
View File
@@ -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>
)
}
+3 -10
View File
@@ -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>
);
}
+1
View File
@@ -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>
);
}
+11 -10
View File
@@ -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>