This commit is contained in:
ZacharyZhang-NY committed 2026-03-30 00:15:21 -04:00
1 parent 65e85135c1
commit 6f1de8047e
64 files changed
+1269 -515

No files matched your search

@@ -44,7 +44,7 @@ export function ActivityList({ activity }: { activity: ActivityRecord[] }) {
<div className="flex items-center gap-3">
<div className="relative flex-1">
<MagnifyingGlass size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input type="text" 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 py-2.5 pl-8 pr-3.5 text-[13px] text-foreground placeholder:text-muted-foreground" value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t("exec.searchActivity")} />
<input type="text" aria-label={t("exec.searchActivity")} 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 py-2.5 pl-8 pr-3.5 text-[13px] text-foreground placeholder:text-muted-foreground" value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t("exec.searchActivity")} />
</div>
<Select value={entityFilter} onChange={setEntityFilter} options={ENTITY_TYPE_OPTIONS.map((v) => ({ value: v, label: v === "all" ? t("exec.allTypes") : v }))} />
</div>
@@ -12,8 +12,8 @@ import { useAppStore } from "../../state/store"
function getTypeBadgeClass(type: string): string {
if (type === "hire_agent") return "bg-accent text-primary"
if (type === "approve_ceo_strategy") return "bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400"
if (type === "secret_access") return "bg-red-100 dark:bg-red-900/50 text-destructive"
if (type === "approve_ceo_strategy") return "bg-warning-muted text-warning"
if (type === "secret_access") return "bg-destructive-muted text-destructive"
if (type === "social_post") return "bg-accent text-primary"
if (type === "deliverable_review") return "bg-accent text-primary"
return "bg-muted text-muted-foreground"
@@ -113,7 +113,7 @@ export function ApprovalInspector({ approval, agents, tasks, documents, companyI
<div><span className="text-muted-foreground">Budget:</span> <span className="font-mono text-foreground">{formatMoney(proposedAgent?.budgetMonthlyUsd ?? (typeof reqSnap?.budgetMonthlyUsd === "number" ? reqSnap.budgetMonthlyUsd : 0))}/mo</span></div>
{mgr ? <div><span className="text-muted-foreground">Reports to:</span> <span className="text-foreground">{mgr.name}</span></div> : null}
</div></div>) })() : null}
{approval.type === "approve_ceo_strategy" ? <div className="rounded-lg border border-[color:var(--destructive)] border-opacity-30 bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3"><div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-yellow-600 dark:text-yellow-400">Organization Strategy</div><div className="whitespace-pre-wrap text-[12px] leading-relaxed text-foreground">{approval.payloadSummary}</div></div> : null}
{approval.type === "approve_ceo_strategy" ? <div className="rounded-lg border border-[color:var(--destructive)] border-opacity-30 bg-warning-muted px-4 py-3"><div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-warning">Organization Strategy</div><div className="whitespace-pre-wrap text-[12px] leading-relaxed text-foreground">{approval.payloadSummary}</div></div> : null}
{approval.type === "deliverable_review" ? <DeliverableReviewPanel approval={approval} documents={documents} agents={agents} /> : null}
{isSocialPost && approval.payloadJson ? (
<SocialDraftPreview
@@ -53,11 +53,11 @@ export function ApprovalList({ approvals, selectedApprovalId, onSelect }: { appr
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 outline-none ${virtualRow.index === focusedIndex ? "ring-1 ring-[color:var(--primary)] ring-inset" : ""}`}
>
<button
className={`relative w-full border-b border-border px-4 py-3 text-left transition duration-150 last:border-b-0 ${selectedApprovalId === approval.id ? "bg-yellow-100 dark:bg-yellow-900/50" : "bg-transparent hover:bg-muted"}`}
className={`relative w-full border-b border-border px-4 py-3 text-left transition duration-150 last:border-b-0 ${selectedApprovalId === approval.id ? "bg-warning-muted" : "bg-transparent hover:bg-muted"}`}
onClick={() => onSelect(approval.id)}
tabIndex={-1}
>
{selectedApprovalId === approval.id ? <span className="absolute inset-y-0 left-0 w-[3px] bg-yellow-600 dark:bg-yellow-400" /> : null}
{selectedApprovalId === approval.id ? <span className="absolute inset-y-0 left-0 w-[3px] bg-warning" /> : null}
<div className="flex items-center justify-between gap-4">
<div className={`truncate text-[13px] text-foreground ${selectedApprovalId === approval.id ? "font-semibold" : "font-medium"}`}>{approval.payloadSummary}</div>
<StatusPill status={approval.state} />
@@ -45,8 +45,8 @@ export function BudgetSummary({ agents, costs, companyId }: BudgetSummaryProps)
const pct = status.amount > 0 ? (status.spent / status.amount) * 100 : 0
const isWarning = pct > 80 && pct <= 100
const isDanger = pct > 100
const barColor = isDanger ? "bg-destructive" : isWarning ? "bg-yellow-600 dark:bg-yellow-400" : "bg-green-600 dark:bg-green-400"
const nameColor = isDanger ? "text-destructive" : isWarning ? "text-yellow-600 dark:text-yellow-400" : "text-foreground"
const barColor = isDanger ? "bg-destructive" : isWarning ? "bg-warning" : "bg-success"
const nameColor = isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-foreground"
const scopeLabel = status.scopeType === "company"
? "Company"
@@ -62,11 +62,11 @@ export function BudgetSummary({ agents, costs, companyId }: BudgetSummaryProps)
{status.scopeType}
</span>
<div className={`text-[14px] font-medium ${nameColor}`}>{scopeLabel}</div>
{status.hasOpenIncident ? <Warning size={14} weight="fill" className="text-yellow-600 dark:text-yellow-400" /> : null}
{status.hasOpenIncident ? <Warning size={14} weight="fill" className="text-warning" /> : null}
</div>
<div className="flex items-center gap-2">
<div className="font-mono text-[12px] text-foreground">{formatMoney(status.spent / 100)} / {formatMoney(status.amount / 100)}</div>
<span className={`font-mono text-[11px] font-semibold ${isDanger ? "text-destructive" : isWarning ? "text-yellow-600 dark:text-yellow-400" : "text-muted-foreground"}`}>{Math.round(pct)}%</span>
<span className={`font-mono text-[11px] font-semibold ${isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-muted-foreground"}`}>{Math.round(pct)}%</span>
</div>
</div>
<div className="h-2 rounded-full bg-[color:var(--border)]"><div className={`h-2 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} /></div>
@@ -79,19 +79,19 @@ export function BudgetSummary({ agents, costs, companyId }: BudgetSummaryProps)
{agents.map((agent) => {
const pct = agent.budgetMonthlyUsd > 0 ? (agent.spentMonthlyUsd / agent.budgetMonthlyUsd) * 100 : 0
const isWarning = pct > 80 && pct <= 100; const isDanger = pct > 100
const barColor = isDanger ? "bg-destructive" : isWarning ? "bg-yellow-600 dark:bg-yellow-400" : "bg-green-600 dark:bg-green-400"
const nameColor = isDanger ? "text-destructive" : isWarning ? "text-yellow-600 dark:text-yellow-400" : "text-foreground"
const barColor = isDanger ? "bg-destructive" : isWarning ? "bg-warning" : "bg-success"
const nameColor = isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-foreground"
return (
<ListRow key={agent.id}>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`text-[14px] font-medium ${nameColor}`}>{agent.name}</div>
{isDanger ? <Warning size={14} weight="fill" className="text-destructive" /> : null}
{isWarning ? <Warning size={14} weight="fill" className="text-yellow-600 dark:text-yellow-400" /> : null}
{isWarning ? <Warning size={14} weight="fill" className="text-warning" /> : null}
</div>
<div className="flex items-center gap-2">
<div className="font-mono text-[12px] text-foreground">{formatMoney(agent.spentMonthlyUsd)} / {formatMoney(agent.budgetMonthlyUsd)}</div>
<span className={`font-mono text-[11px] font-semibold ${isDanger ? "text-destructive" : isWarning ? "text-yellow-600 dark:text-yellow-400" : "text-muted-foreground"}`}>{Math.round(pct)}%</span>
<span className={`font-mono text-[11px] font-semibold ${isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-muted-foreground"}`}>{Math.round(pct)}%</span>
</div>
</div>
<div className="h-2 rounded-full bg-[color:var(--border)]"><div className={`h-2 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} /></div>
@@ -100,7 +100,7 @@ export function CostList({ costs, agents, runs }: { costs: ProfileSnapshot["cost
</div>
<div className="shrink-0 text-right">
<div className="font-mono tabular-nums text-[13px] text-foreground">{formatMoney(entry.amountUsd)}</div>
{entry.unattributed ? <div className="text-[11px] text-yellow-600 dark:text-yellow-400">unattributed</div> : null}
{entry.unattributed ? <div className="text-[11px] text-warning">unattributed</div> : null}
</div>
</div>
</div>
@@ -11,8 +11,8 @@ interface IncidentListProps {
}
const STATUS_STYLES: Record<string, string> = {
open: "text-destructive bg-red-100 dark:bg-red-900/50",
resolved: "text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/50",
open: "text-destructive bg-destructive-muted",
resolved: "text-success bg-success-muted",
auto_resolved_limit_increase: "text-primary bg-accent",
auto_closed: "text-muted-foreground bg-muted",
};
@@ -93,8 +93,8 @@ export function IncidentList({ companyId, agents, projects }: IncidentListProps)
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] ${
isWarn
? "text-yellow-600 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/50"
: "text-destructive bg-red-100 dark:bg-red-900/50"
? "text-warning bg-warning-muted"
: "text-destructive bg-destructive-muted"
}`}
>
{isWarn ? <Warning size={10} weight="bold" /> : null}
@@ -1,9 +1,52 @@
import { useCallback, useEffect, useState } from "react";
import { FloppyDisk, Plus, X as XIcon } from "@phosphor-icons/react";
import {
CaretDown,
CaretRight,
FloppyDisk,
Plus,
SignIn,
Trash,
ArrowSquareOut,
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";
/* ------------------------------------------------------------------ */
/* Compatible-API provider presets (mirrors ProvidersStep) */
/* ------------------------------------------------------------------ */
const COMPATIBLE_PROVIDERS = [
{ id: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
{ id: "minimax_cn", label: "MiniMax (CN)", baseUrl: "https://api.minimaxi.com/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
{ id: "kimi", label: "Kimi", baseUrl: "https://api.kimi.com/coding/", docUrl: "https://www.kimi.com/code/docs/en/more/third-party-agents.html" },
{ id: "kimi_cn", label: "Kimi CN", baseUrl: "https://api.moonshot.cn/anthropic", docUrl: "https://platform.moonshot.cn/docs/guide/agent-support" },
{ id: "glm", label: "GLM (ZhiPu)", baseUrl: "https://api.z.ai/api/anthropic", docUrl: "https://docs.z.ai/devpack/tool/claude" },
{ id: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api", docUrl: "https://openrouter.ai/docs/guides/coding-agents/claude-code-integration" },
{ id: "tencent", label: "Tencent Coding Plan", baseUrl: "https://api.lkeap.cloud.tencent.com/coding/anthropic", docUrl: "https://cloud.tencent.com/document/product/1772/128951" },
{ id: "poe", label: "POE", baseUrl: "https://api.poe.com", docUrl: "https://creator.poe.com/docs/external-applications/claude-code" },
{ id: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/anthropic", docUrl: "https://api-docs.deepseek.com/guides/anthropic_api" },
{ id: "alibaba", label: "Alibaba Coding Plan", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan" },
{ id: "alibaba_cn", label: "Alibaba Coding Plan (CN)", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://help.aliyun.com/zh/model-studio/claude-code-coding-plan" },
{ id: "bailing", label: "BaiLing", baseUrl: "https://api.tbox.cn/api/anthropic", docUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/best_practices_claudecode" },
{ id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", docUrl: "https://docs.nano-gpt.com/integrations/claude-code#manual-configuration-all-platforms" },
{ id: "zhipu_cn", label: "ZhiPu (CN)", baseUrl: "https://open.bigmodel.cn/api/anthropic", docUrl: "https://docs.bigmodel.cn/cn/coding-plan/tool/claude" },
{ id: "ollama", label: "Ollama (Local)", baseUrl: "http://localhost:11434", docUrl: "https://docs.ollama.com/integrations/claude-code" },
{ id: "custom", label: "Custom", baseUrl: "", docUrl: "" },
] as const;
const COMPAT_SLOTS = [
"claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5",
"claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10",
] as const;
/* ------------------------------------------------------------------ */
/* Shared */
/* ------------------------------------------------------------------ */
const INPUT_CLS =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground";
interface ModelManagementProps {
connectors: ProfileSnapshot["connectors"];
@@ -11,194 +54,738 @@ interface ModelManagementProps {
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");
interface ModelEntry {
id: string;
label: string;
enabled: boolean;
}
function getDisplayName(connector: ConnectorRecord): string {
if (connector.id.startsWith("claude_compat") && connector.notes) return connector.notes;
return connector.label;
type ConnectorStatus = ProfileSnapshot["connectors"][number]["status"];
function parseEnvValue(text: string, key: string): string {
const line = text.split(/\r?\n/).find((l) => l.startsWith(`${key}=`));
return line ? line.slice(key.length + 1) : "";
}
function CompatModelEditor({
/* ------------------------------------------------------------------ */
/* StatusBadge */
/* ------------------------------------------------------------------ */
function StatusBadge({ status, zh }: { status: ConnectorStatus; zh: boolean }) {
if (status === "ready") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-success-muted px-2 py-0.5 text-[11px] font-medium text-success">
<span className="h-1.5 w-1.5 rounded-full bg-success" />
{zh ? "就绪" : "Ready"}
</span>
);
}
if (status === "not_installed") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground" />
{zh ? "未安装" : "Not installed"}
</span>
);
}
if (status === "auth_required") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-warning-muted px-2 py-0.5 text-[11px] font-medium text-warning">
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
{zh ? "需登录" : "Auth required"}
</span>
);
}
if (status === "detected" || (status as string) === "checking") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
<span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-muted-foreground border-t-primary" />
{zh ? "检测中" : "Checking"}
</span>
);
}
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-caution-muted px-2 py-0.5 text-[11px] font-medium text-caution">
<span className="h-1.5 w-1.5 rounded-full bg-caution" />
{status.replaceAll("_", " ")}
</span>
);
}
/* ------------------------------------------------------------------ */
/* ProviderCard */
/* ------------------------------------------------------------------ */
function ProviderCard({
title,
badge,
expanded,
onToggle,
children,
}: {
title: string;
badge: React.ReactNode;
expanded: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<button
type="button"
onClick={onToggle}
className="flex w-full cursor-pointer items-center justify-between px-4 py-3 transition hover:bg-muted/50"
>
<div className="flex items-center gap-2.5">
{expanded ? (
<CaretDown size={14} className="text-muted-foreground" />
) : (
<CaretRight size={14} className="text-muted-foreground" />
)}
<span className="text-[13px] font-semibold text-foreground">{title}</span>
</div>
{badge}
</button>
{expanded && <div className="space-y-3 px-4 pb-4">{children}</div>}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Toggle switch */
/* ------------------------------------------------------------------ */
function ToggleSwitch({ checked, onChange, label }: { checked: boolean; onChange: () => void; label: string }) {
return (
<button
type="button"
onClick={onChange}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${checked ? "bg-success" : "bg-muted-foreground/30"}`}
role="switch"
aria-checked={checked}
aria-label={label}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-[18px]" : "translate-x-[3px]"}`}
/>
</button>
);
}
/* ------------------------------------------------------------------ */
/* Model toggle list */
/* ------------------------------------------------------------------ */
function ModelToggleList({
models,
onToggle,
onRemove,
canRemove,
zh,
}: {
models: ModelEntry[];
onToggle: (id: string) => void;
onRemove?: (id: string) => void;
canRemove: boolean;
zh: boolean;
}) {
if (models.length === 0) {
return (
<div className="text-[12px] text-muted-foreground">
{zh ? "暂无模型" : "No models available"}
</div>
);
}
const enabledCount = models.filter((m) => m.enabled).length;
return (
<div>
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">
{zh ? "模型" : "Models"} ({enabledCount}/{models.length} {zh ? "已启用" : "enabled"})
</div>
<div className="space-y-1">
{models.map((m) => (
<div
key={m.id}
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
>
<div className="flex items-center gap-2.5">
<ToggleSwitch
checked={m.enabled}
onChange={() => onToggle(m.id)}
label={`${m.enabled ? "Disable" : "Enable"} ${m.label}`}
/>
<span
className={`text-[12px] ${m.enabled ? "text-foreground" : "text-muted-foreground line-through"}`}
>
{m.label}
</span>
</div>
{canRemove && onRemove ? (
<button
type="button"
onClick={() => onRemove(m.id)}
className="text-muted-foreground transition hover:text-destructive"
aria-label={`Remove ${m.label}`}
>
<XIcon size={12} />
</button>
) : null}
</div>
))}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Native provider card (Claude Code / Codex / Gemini) */
/* ------------------------------------------------------------------ */
function NativeProviderCard({
connector,
locale,
zh,
onSaved,
expanded,
onToggle,
}: {
connector: ConnectorRecord;
locale: AppLocale;
zh: boolean;
onSaved: () => Promise<void>;
expanded: boolean;
onToggle: () => void;
}) {
const [models, setModels] = useState<string[]>(() => {
const raw = connector.model ?? "";
return raw.split(",").map((m) => m.trim()).filter(Boolean);
});
const [newModel, setNewModel] = useState("");
const [models, setModels] = useState<ModelEntry[]>([]);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const envKeyName = connector.id === "codex_local" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
const [apiKey, setApiKey] = useState(() => parseEnvValue(connector.envBindingText, envKeyName));
useEffect(() => {
const raw = connector.model ?? "";
setModels(raw.split(",").map((m) => m.trim()).filter(Boolean));
setDirty(false);
}, [connector.model]);
setApiKey(parseEnvValue(connector.envBindingText, envKeyName));
}, [connector.envBindingText, envKeyName]);
function addModel() {
const trimmed = newModel.trim();
if (!trimmed || models.includes(trimmed)) return;
setModels((prev) => [...prev, trimmed]);
setNewModel("");
setDirty(true);
}
const loadModels = useCallback(async () => {
try {
const [enabledResult, disabledResult] = await Promise.all([
window.agentCompany.listConnectorModels(connector.id),
window.agentCompany.getConnectorDisabledModels(connector.id),
]);
const enabled = enabledResult.ok ? enabledResult.data : [];
const disabled = disabledResult.ok ? disabledResult.data : [];
setModels([
...enabled.map((m) => ({ ...m, enabled: true })),
...disabled.map((m) => ({ ...m, enabled: false })),
]);
} catch {
/* non-fatal */
}
}, [connector.id]);
function removeModel(m: string) {
setModels((prev) => prev.filter((x) => x !== m));
setDirty(true);
useEffect(() => {
void loadModels();
}, [loadModels]);
async function handleToggleModel(modelId: string) {
const prev = models;
const updated = models.map((m) =>
m.id === modelId ? { ...m, enabled: !m.enabled } : m,
);
setModels(updated);
const newDisabled = updated
.filter((m) => !m.enabled)
.map((m) => ({ id: m.id, label: m.label }));
try {
unwrap(
await window.agentCompany.setConnectorDisabledModels({
connectorId: connector.id,
disabledModels: newDisabled,
}),
);
await onSaved();
} catch {
setModels(prev);
}
}
async function handleSave() {
setSaving(true);
try {
unwrap(await window.agentCompany.saveConnector({
id: connector.id,
command: connector.command,
model: models.join(",") || null,
envBindingText: connector.envBindingText,
notes: connector.notes,
}));
setDirty(false);
const lines: string[] = [];
if (apiKey.trim()) lines.push(`${envKeyName}=${apiKey.trim()}`);
unwrap(
await window.agentCompany.saveConnector({
id: connector.id,
command: connector.command,
model: connector.model,
envBindingText: lines.join("\n"),
notes: connector.notes,
}),
);
await window.agentCompany.testConnector(connector.id);
await onSaved();
void loadModels();
} finally {
setSaving(false);
}
}
async function handleLogin() {
await window.agentCompany.openConnectorAuthTerminal(connector.id);
}
const showApiKey = connector.id === "claude_local" || connector.id === "codex_local";
return (
<ProviderCard
title={connector.label}
badge={<StatusBadge status={connector.status} zh={zh} />}
expanded={expanded}
onToggle={onToggle}
>
<button
type="button"
onClick={() => void handleLogin()}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
>
<SignIn size={14} />
{zh ? "登录" : "Login"}
</button>
{showApiKey ? (
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{envKeyName}{" "}
<span className="font-normal">({zh ? "可选" : "optional"})</span>
</label>
<input
type="password"
className={INPUT_CLS}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={connector.id === "codex_local" ? "sk-..." : "sk-ant-..."}
/>
</div>
) : null}
<ModelToggleList
models={models}
onToggle={(id) => void handleToggleModel(id)}
canRemove={false}
zh={zh}
/>
<button
type="button"
onClick={() => void handleSave()}
disabled={saving}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
>
<FloppyDisk size={14} />
{saving
? zh
? "保存中..."
: "Saving..."
: zh
? "保存并检查"
: "Save & Check"}
</button>
</ProviderCard>
);
}
/* ------------------------------------------------------------------ */
/* Compatible provider card */
/* ------------------------------------------------------------------ */
function CompatProviderCard({
slotId,
connector,
zh,
onSaved,
expanded,
onToggle,
onCleared,
}: {
slotId: string;
connector: ConnectorRecord | null;
zh: boolean;
onSaved: () => Promise<void>;
expanded: boolean;
onToggle: () => void;
onCleared?: () => void;
}) {
const [selectedProvider, setSelectedProvider] = useState<string>(() => {
const saved = connector?.notes ?? "";
const match = COMPATIBLE_PROVIDERS.find((p) => p.label === saved);
return match?.id ?? COMPATIBLE_PROVIDERS[0].id;
});
const [apiKey, setApiKey] = useState(() =>
parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_API_KEY"),
);
const [baseUrl, setBaseUrl] = useState<string>(
() =>
parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL") ||
COMPATIBLE_PROVIDERS[0].baseUrl,
);
const [models, setModels] = useState<ModelEntry[]>([]);
const [newModel, setNewModel] = useState("");
const [saving, setSaving] = useState(false);
const status = connector?.status ?? "not_installed";
const providerLabel =
COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ??
"Compatible API";
const displayTitle = connector?.notes || providerLabel;
// Sync form state when connector changes externally (e.g. after save + refresh)
useEffect(() => {
setApiKey(parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_API_KEY"));
const savedBase = parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL");
if (savedBase) setBaseUrl(savedBase);
const saved = connector?.notes ?? "";
const match = COMPATIBLE_PROVIDERS.find((p) => p.label === saved);
if (match) setSelectedProvider(match.id);
}, [connector?.envBindingText, connector?.notes]);
const loadModels = useCallback(async () => {
if (!connector) {
setModels([]);
return;
}
try {
const raw = connector.model ?? "";
const allModelIds = raw
.split(",")
.map((m) => m.trim())
.filter(Boolean);
const disabledResult = await window.agentCompany.getConnectorDisabledModels(
connector.id,
);
const disabledSet = new Set(
(disabledResult.ok ? disabledResult.data : []).map((m) => m.id),
);
setModels(
allModelIds.map((id) => ({
id,
label: id,
enabled: !disabledSet.has(id),
})),
);
} catch {
/* non-fatal */
}
}, [connector?.id, connector?.model]);
useEffect(() => {
void loadModels();
}, [loadModels]);
function handleProviderChange(providerId: string) {
setSelectedProvider(providerId);
const provider = COMPATIBLE_PROVIDERS.find((p) => p.id === providerId);
if (provider) setBaseUrl(provider.baseUrl);
}
function addModel() {
const trimmed = newModel.trim();
if (!trimmed || models.some((m) => m.id === trimmed)) return;
setModels((prev) => [...prev, { id: trimmed, label: trimmed, enabled: true }]);
setNewModel("");
}
function removeModel(modelId: string) {
setModels((prev) => prev.filter((m) => m.id !== modelId));
}
async function handleToggleModel(modelId: string) {
if (!connector) return;
const prev = models;
const updated = models.map((m) =>
m.id === modelId ? { ...m, enabled: !m.enabled } : m,
);
setModels(updated);
const newDisabled = updated
.filter((m) => !m.enabled)
.map((m) => ({ id: m.id, label: m.label }));
try {
unwrap(
await window.agentCompany.setConnectorDisabledModels({
connectorId: connector.id,
disabledModels: newDisabled,
}),
);
await onSaved();
} catch {
setModels(prev);
}
}
async function handleSave() {
setSaving(true);
try {
const lines: string[] = [];
if (apiKey.trim()) lines.push(`ANTHROPIC_API_KEY=${apiKey.trim()}`);
if (baseUrl.trim()) lines.push(`ANTHROPIC_BASE_URL=${baseUrl.trim()}`);
const label =
COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ?? "";
const modelsCsv =
models.map((m) => m.id).join(",") || null;
unwrap(
await window.agentCompany.saveConnector({
id: slotId as ConnectorRecord["id"],
command: connector?.command ?? "claude",
model: modelsCsv,
envBindingText: lines.join("\n"),
notes: label,
}),
);
await window.agentCompany.testConnector(slotId);
await onSaved();
} finally {
setSaving(false);
}
}
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 () => {
async function handleClear() {
if (!connector) return;
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>
);
unwrap(
await window.agentCompany.saveConnector({
id: connector.id,
command: connector.command,
model: null,
envBindingText: "",
notes: "",
}),
);
unwrap(
await window.agentCompany.setConnectorDisabledModels({
connectorId: connector.id,
disabledModels: [],
}),
);
await onSaved();
onCleared?.();
} catch {
/* non-fatal */
}
}
const selectedProviderData = COMPATIBLE_PROVIDERS.find(
(p) => p.id === selectedProvider,
);
const isConfigured =
connector &&
(connector.envBindingText.includes("ANTHROPIC_BASE_URL") ||
connector.envBindingText.includes("ANTHROPIC_API_KEY"));
return (
<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} />
),
)}
<ProviderCard
title={displayTitle}
badge={<StatusBadge status={status} zh={zh} />}
expanded={expanded}
onToggle={onToggle}
>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{zh ? "选择提供商" : "Select provider"}
</label>
<select
className={INPUT_CLS}
value={selectedProvider}
onChange={(e) => handleProviderChange(e.target.value)}
>
{COMPATIBLE_PROVIDERS.map((p) => (
<option key={p.id} value={p.id} className="bg-card text-foreground">
{p.label}
</option>
))}
</select>
</div>
{selectedProviderData?.docUrl ? (
<button
type="button"
onClick={() => window.open(selectedProviderData.docUrl, "_blank")}
className="inline-flex items-center gap-1 text-[12px] text-primary transition hover:underline"
>
<ArrowSquareOut size={12} />
{zh ? "查看文档" : "View docs"}
</button>
) : null}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
ANTHROPIC_API_KEY
</label>
<input
type="password"
className={INPUT_CLS}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
/>
</div>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
ANTHROPIC_BASE_URL
</label>
<input
className={INPUT_CLS}
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://api.example.com/v1"
/>
</div>
<ModelToggleList
models={models}
onToggle={(id) => void handleToggleModel(id)}
onRemove={removeModel}
canRemove
zh={zh}
/>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{zh ? "添加模型" : "Add model"}
</label>
<div className="flex gap-2">
<input
className={INPUT_CLS}
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
placeholder={zh ? "输入模型名称" : "model-name"}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addModel();
}
}}
/>
<button
type="button"
onClick={addModel}
disabled={!newModel.trim()}
className="shrink-0 rounded-lg border border-border bg-card px-3 py-2 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
aria-label={zh ? "添加模型" : "Add model"}
>
<Plus size={14} />
</button>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void handleSave()}
disabled={saving}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
>
<FloppyDisk size={14} />
{saving
? zh
? "保存中..."
: "Saving..."
: zh
? "保存并检查"
: "Save & Check"}
</button>
{isConfigured ? (
<button
type="button"
onClick={() => void handleClear()}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-destructive transition hover:bg-muted"
>
<Trash size={14} />
{zh ? "清除配置" : "Clear"}
</button>
) : null}
</div>
</ProviderCard>
);
}
/* ------------------------------------------------------------------ */
/* Main component */
/* ------------------------------------------------------------------ */
export function ModelManagement({
connectors,
locale,
onSaved,
}: ModelManagementProps) {
const zh = locale === "zh";
const [expandedCard, setExpandedCard] = useState<string | null>(null);
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 activeCompatSlots = COMPAT_SLOTS.filter((id) => {
const c = connectors.find((conn) => conn.id === id);
return (
c &&
(c.envBindingText.includes("ANTHROPIC_BASE_URL") ||
c.envBindingText.includes("ANTHROPIC_API_KEY"))
);
});
const nextAvailableSlot =
COMPAT_SLOTS.find((id) => !activeCompatSlots.includes(id)) ?? null;
const visibleCompatSlots = [...activeCompatSlots];
if (nextAvailableSlot && !visibleCompatSlots.includes(nextAvailableSlot)) {
visibleCompatSlots.push(nextAvailableSlot);
}
const toggle = (card: string) =>
setExpandedCard((prev) => (prev === card ? null : card));
return (
<div className="space-y-3">
{claudeConnector ? (
<NativeProviderCard
connector={claudeConnector}
zh={zh}
onSaved={onSaved}
expanded={expandedCard === "claude_local"}
onToggle={() => toggle("claude_local")}
/>
) : null}
{codexConnector ? (
<NativeProviderCard
connector={codexConnector}
zh={zh}
onSaved={onSaved}
expanded={expandedCard === "codex_local"}
onToggle={() => toggle("codex_local")}
/>
) : null}
{geminiConnector ? (
<NativeProviderCard
connector={geminiConnector}
zh={zh}
onSaved={onSaved}
expanded={expandedCard === "gemini_local"}
onToggle={() => toggle("gemini_local")}
/>
) : null}
{visibleCompatSlots.map((slotId) => (
<CompatProviderCard
key={slotId}
slotId={slotId}
connector={connectors.find((c) => c.id === slotId) ?? null}
zh={zh}
onSaved={onSaved}
expanded={expandedCard === slotId}
onToggle={() => toggle(slotId)}
onCleared={() => setExpandedCard(null)}
/>
))}
</div>
);
}
@@ -99,7 +99,7 @@ export function PolicyList({ companyId, agents, projects }: PolicyListProps) {
{policies.map((policy) => {
const util = getUtilization(policy.id);
const pct = util?.pct ?? 0;
const barColor = pct > 100 ? "bg-destructive" : pct > 80 ? "bg-yellow-600 dark:bg-yellow-400" : "bg-green-600 dark:bg-green-400";
const barColor = pct > 100 ? "bg-destructive" : pct > 80 ? "bg-warning" : "bg-success";
return (
<ListRow key={policy.id} onClick={() => handleEdit(policy)}>
<div className="flex items-center justify-between gap-3">
@@ -110,7 +110,7 @@ export function PolicyList({ companyId, agents, projects }: PolicyListProps) {
</span>
<span className="truncate text-[13px] font-medium text-foreground">{getScopeName(policy)}</span>
{policy.hardStopEnabled ? <ShieldCheck size={14} className="shrink-0 text-primary" /> : null}
{util?.hasIncident ? <Warning size={14} weight="fill" className="shrink-0 text-yellow-600 dark:text-yellow-400" /> : null}
{util?.hasIncident ? <Warning size={14} weight="fill" className="shrink-0 text-warning" /> : null}
</div>
<div className="mt-1 flex items-center gap-3 text-[11px] text-muted-foreground">
<span className="font-[family-name:var(--font-mono)]" style={{ fontVariantNumeric: "tabular-nums" }}>
@@ -41,6 +41,23 @@ export function SettingsPane({ snapshot, secrets, companyId, onSaved }: { snapsh
<div className="rounded-lg border border-border px-4 py-3"><label className="flex items-center gap-3"><input type="checkbox" checked={company.autoApproveHires} onChange={async (e) => { setActionError(null); try { unwrap(await window.agentCompany.saveCompany({ id: company.id, name: company.name, description: company.description, status: company.status, autoApproveHires: e.target.checked, reviewDeliverables: company.reviewDeliverables })); await onSaved() } catch (err) { setActionError(describeActionError(err)) } }} className="h-4 w-4 rounded accent-[color:var(--primary)]" /><div><div className="text-[13px] font-medium text-foreground">{t("exec.autoApproveLabel")}</div><div className="mt-0.5 text-[11px] text-muted-foreground">{t("exec.autoApproveDesc")}</div></div></label></div>
<div className="rounded-lg border border-border px-4 py-3"><label className="flex items-center gap-3"><input type="checkbox" checked={!company.reviewDeliverables} onChange={async (e) => { setActionError(null); try { unwrap(await window.agentCompany.saveCompany({ id: company.id, name: company.name, description: company.description, status: company.status, autoApproveHires: company.autoApproveHires, reviewDeliverables: !e.target.checked })); await onSaved() } catch (err) { setActionError(describeActionError(err)) } }} className="h-4 w-4 rounded accent-[color:var(--primary)]" /><div><div className="text-[13px] font-medium text-foreground">{t("exec.reviewDeliverablesLabel")}</div><div className="mt-0.5 text-[11px] text-muted-foreground">{t("exec.reviewDeliverablesDesc")}</div></div></label></div>
</div></div>) })()}
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Keyboard Shortcuts</div>
<div className="space-y-1 border-y border-border py-3">
{([
[navigator.platform.includes("Mac") ? "⌘K" : "Ctrl+K", "Open command palette"],
[navigator.platform.includes("Mac") ? "⌘Enter" : "Ctrl+Enter", "Send message"],
["Escape", "Close modal / palette"],
["↑ / ↓", "Navigate palette results"],
["Enter", "Select palette result"],
["Tab", "Move focus between elements"],
] as const).map(([key, desc]) => (
<div key={key} className="flex items-center justify-between px-3 py-1.5 text-[12px]">
<span className="text-muted-foreground">{desc}</span>
<kbd className="rounded border border-border bg-muted px-2 py-0.5 font-mono text-[11px] text-foreground">{key}</kbd>
</div>
))}
</div>
</div>
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.actions")}</div><div className="grid gap-3 md:grid-cols-2"><ActionButton label={t("exec.saveSettings")} tone="accent" onClick={() => void handleSaveSettings()} icon={CheckCircle} loading={loadingAction === "save"} /><ActionButton label={t("exec.checkForUpdates")} onClick={() => void handleCheckForUpdates()} icon={Download} loading={loadingAction === "check"} /><ActionButton label={t("exec.backupProfile")} onClick={() => void handleBackupProfile()} icon={ClockCounterClockwise} loading={loadingAction === "backup"} /><ActionButton label={t("exec.restoreProfile")} onClick={() => void handleRestoreProfile()} icon={ArrowClockwise} loading={loadingAction === "restore"} /></div></div>
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretBackend")}</div><div className="border-y border-border py-3 text-[13px] text-muted-foreground">{snapshot.backend.secretBackend}</div></div>
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secrets")} ({secrets.length})</div>{secrets.length === 0 ? <EmptyState title={t("exec.noSecretsStored")} /> : <div className="border-y border-border overflow-x-auto"><table className="w-full text-left text-[13px]"><thead><tr className="border-b border-border"><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretName")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretDescription")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretBackendCol")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretUpdated")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground" /></tr></thead><tbody>{secrets.map((s) => <tr key={s.id} className="border-b border-border last:border-b-0"><td className="px-4 py-3 font-medium text-foreground">{s.name}</td><td className="px-4 py-3 text-muted-foreground">{s.description || "\u2014"}</td><td className="px-4 py-3 font-mono text-muted-foreground">{s.backend}</td><td className="px-4 py-3 font-mono text-muted-foreground">{formatTime(s.updatedAt)}</td><td className="px-4 py-3"><ActionButton label="Delete" onClick={() => setConfirmDeleteSecretId(s.id)} icon={Trash} tone="danger" /></td></tr>)}</tbody></table></div>}</div>
@@ -121,7 +121,7 @@ export function SocialDraftPreview({ payloadJson, platform, isPending: _isPendin
) : null}
{draft.scheduledAt ? (
<div className="flex items-center gap-1.5 text-[13px] font-[family-name:var(--font-mono)] text-yellow-600 dark:text-yellow-400">
<div className="flex items-center gap-1.5 text-[13px] font-[family-name:var(--font-mono)] text-warning">
<Clock size={14} />
Scheduled: {formatScheduledDate(draft.scheduledAt)}
</div>
@@ -52,7 +52,7 @@ export function TaskComments({ taskId, companyId, agents, comments, onRefresh }:
)}
{submitError ? <InlineNotice message={submitError} /> : null}
<div className="flex gap-2">
<input className="flex-1 rounded-md border border-border bg-card px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-[color:var(--primary)] focus:outline-none" placeholder={t("exec.sendMessage")} value={newComment} onChange={(e) => setNewComment(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void handleSubmit() } }} />
<input aria-label={t("exec.sendMessage")} className="flex-1 rounded-md border border-border bg-card px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-[color:var(--primary)] focus:outline-none" placeholder={t("exec.sendMessage")} value={newComment} onChange={(e) => setNewComment(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void handleSubmit() } }} />
<ActionButton label={t("exec.send")} tone="accent" onClick={() => void handleSubmit()} icon={ArrowRight} loading={submitting} />
</div>
</div>
@@ -1,4 +1,5 @@
import { useState } from "react"
import { AnimatePresence, motion } from "framer-motion"
import { ListChecks } from "@phosphor-icons/react"
import type { GoalRecord, ProfileSnapshot, ProjectRecord, WorkspaceRecord, TaskRecord } from "@shared/types"
import { useT } from "../../i18n"
@@ -20,15 +21,29 @@ export function TaskCreator({ companyId, goals, projects, agents, workspaces, on
try { unwrap(await window.agentCompany.saveTask({ companyId, projectId: projectId || null, goalId: goalId || null, title, description, assigneeAgentId: assigneeAgentId || null, workspaceId: workspaceId || null, priority: priority as TaskRecord["priority"], status: "todo" })); setTitle(""); setDescription(""); await onSaved() } catch (e) { setSubmitError(describeActionError(e)) } finally { setSubmitting(false) }
}
const [showAdvanced, setShowAdvanced] = useState(false)
return (
<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>
<Select label={t("exec.goal")} value={goalId} onChange={setGoalId} options={[{ value: "", label: t("exec.noGoal") }, ...goals.map((g) => ({ value: g.id, label: g.title }))]} />
<Select label={t("exec.project")} value={projectId} onChange={setProjectId} options={[{ value: "", label: t("exec.noProject") }, ...projects.map((p) => ({ value: p.id, label: p.name }))]} />
<Select label={t("exec.assignee")} value={assigneeAgentId} onChange={setAssigneeAgentId} options={[{ value: "", label: t("exec.unassigned") }, ...agents.map((a) => ({ value: a.id, label: a.name }))]} />
<Select label={t("exec.workspace")} value={workspaceId} onChange={setWorkspaceId} options={[{ value: "", label: t("exec.noWorkspace") }, ...workspaces.map((w) => ({ value: w.id, label: w.name }))]} />
<div className="md:col-span-2">
<button type="button" onClick={() => setShowAdvanced(!showAdvanced)} className="flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground transition hover:text-foreground">
<svg width="10" height="10" viewBox="0 0 10 10" className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}><path d="M3 1l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" /></svg>
{t("exec.goal")}, {t("exec.project")}, {t("exec.assignee")}, {t("exec.workspace")}
</button>
</div>
<AnimatePresence>
{showAdvanced ? (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }} className="grid gap-4 overflow-hidden md:col-span-2 md:grid-cols-2">
<Select label={t("exec.goal")} value={goalId} onChange={setGoalId} options={[{ value: "", label: t("exec.noGoal") }, ...goals.map((g) => ({ value: g.id, label: g.title }))]} />
<Select label={t("exec.project")} value={projectId} onChange={setProjectId} options={[{ value: "", label: t("exec.noProject") }, ...projects.map((p) => ({ value: p.id, label: p.name }))]} />
<Select label={t("exec.assignee")} value={assigneeAgentId} onChange={setAssigneeAgentId} options={[{ value: "", label: t("exec.unassigned") }, ...agents.map((a) => ({ value: a.id, label: a.name }))]} />
<Select label={t("exec.workspace")} value={workspaceId} onChange={setWorkspaceId} options={[{ value: "", label: t("exec.noWorkspace") }, ...workspaces.map((w) => ({ value: w.id, label: w.name }))]} />
</motion.div>
) : null}
</AnimatePresence>
{submitError ? <div className="md:col-span-2"><InlineNotice message={submitError} /></div> : null}
<div className="md:col-span-2"><ActionButton label={t("exec.createTask")} tone="accent" onClick={() => void handleCreateTask()} icon={ListChecks} loading={submitting} /></div>
</div>
@@ -38,7 +38,7 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work
return (
<div className="space-y-6">
<ConfirmDialog open={confirmDelete} title={t("exec.deleteTask")} message={`${t("exec.deleteTaskConfirm").replace("{0}", task.title)}`} errorMessage={deleteError} onConfirm={() => void handleDelete()} onCancel={() => { setDeleteError(null); setConfirmDelete(false) }} />
{budgetWarning > 0.8 && assignee ? <div className={`flex items-center gap-2 rounded-lg border px-4 py-3 text-[13px] font-medium ${budgetWarning > 1 ? "border-[color:var(--destructive)] bg-red-100 dark:bg-red-900/50 text-destructive" : "border-[color:var(--destructive)] bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400"}`}><Warning size={16} weight="fill" />{assignee.name} has used {Math.round(budgetWarning * 100)}% of their monthly budget ({formatMoney(assignee.spentMonthlyUsd)} / {formatMoney(assignee.budgetMonthlyUsd)})</div> : null}
{budgetWarning > 0.8 && assignee ? <div className={`flex items-center gap-2 rounded-lg border px-4 py-3 text-[13px] font-medium ${budgetWarning > 1 ? "border-[color:var(--destructive)] bg-destructive-muted text-destructive" : "border-[color:var(--destructive)] bg-warning-muted text-warning"}`}><Warning size={16} weight="fill" />{assignee.name} has used {Math.round(budgetWarning * 100)}% of their monthly budget ({formatMoney(assignee.spentMonthlyUsd)} / {formatMoney(assignee.budgetMonthlyUsd)})</div> : null}
{actionError ? <InlineNotice message={actionError} /> : null}
{!actionError && actionNotice ? <InlineNotice message={actionNotice} tone="success" /> : null}
{editing ? (
@@ -65,7 +65,7 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work
</div>
{task.description ? <div className="text-[13px] leading-relaxed text-muted-foreground whitespace-pre-wrap">{task.description}</div> : null}
{task.status === "in_review" && reviewer ? <div className="rounded-lg border border-[color:var(--primary)] bg-accent px-4 py-3"><div className="flex items-center gap-2 text-[13px] font-semibold text-primary"><Robot size={16} weight="fill" />Being reviewed by {reviewer.name}{reviewer.title ? ` (${reviewer.title})` : ""}</div><div className="mt-1 text-[13px] leading-relaxed text-primary">{assignee?.name} submitted this task for review. {reviewer.name} will approve, request revisions, or escalate.</div></div>
: needsGovernance ? <div className="rounded-lg border border-[color:var(--destructive)] bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3"><div className="flex items-center gap-2 text-[13px] font-semibold text-yellow-600 dark:text-yellow-400"><ShieldCheck size={16} weight="fill" />{task.status === "blocked" ? t("exec.governanceRequired") : t("exec.waitingOnReview")}</div><div className="mt-1 text-[13px] leading-relaxed text-muted-foreground">{task.status === "blocked" ? t("exec.blockedGovernance") : t("exec.reviewGovernance")}</div><div className="mt-3"><ActionButton label={task.status === "blocked" ? t("exec.createUnblockApproval") : t("exec.createReviewApproval")} onClick={() => void handleCreateApproval()} icon={ShieldCheck} loading={creatingApproval} /></div></div> : null}
: needsGovernance ? <div className="rounded-lg border border-[color:var(--destructive)] bg-warning-muted px-4 py-3"><div className="flex items-center gap-2 text-[13px] font-semibold text-warning"><ShieldCheck size={16} weight="fill" />{task.status === "blocked" ? t("exec.governanceRequired") : t("exec.waitingOnReview")}</div><div className="mt-1 text-[13px] leading-relaxed text-muted-foreground">{task.status === "blocked" ? t("exec.blockedGovernance") : t("exec.reviewGovernance")}</div><div className="mt-3"><ActionButton label={task.status === "blocked" ? t("exec.createUnblockApproval") : t("exec.createReviewApproval")} onClick={() => void handleCreateApproval()} icon={ShieldCheck} loading={creatingApproval} /></div></div> : null}
<div className="flex items-center gap-3">
{activeRun ? <ActionButton label={t("exec.cancelRun")} onClick={() => void onCancelRun(activeRun.id)} icon={XCircle} tone="danger" /> : <>
{["todo","in_progress","blocked"].includes(task.status) && <ActionButton label={t("exec.run")} onClick={() => void onRun(task.id)} icon={Play} tone="accent" />}
@@ -80,19 +80,19 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work
)}
{(() => { const subtasks = tasks.filter((t) => t.parentId === task.id); if (subtasks.length === 0) return null; return (
<div className="space-y-2">
<div className="flex items-center gap-2"><ListChecks size={14} className="text-muted-foreground" /><span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.subtasks")} ({subtasks.length})</span><span className="text-[10px] text-green-600 dark:text-green-400">{subtasks.filter((t) => t.status === "done").length}/{subtasks.length} done</span></div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-green-600 dark:bg-green-400 transition-all duration-300" style={{ width: `${subtasks.length > 0 ? (subtasks.filter((t) => t.status === "done").length / subtasks.length) * 100 : 0}%` }} /></div>
<div className="flex items-center gap-2"><ListChecks size={14} className="text-muted-foreground" /><span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.subtasks")} ({subtasks.length})</span><span className="text-[10px] text-success">{subtasks.filter((t) => t.status === "done").length}/{subtasks.length} done</span></div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-success transition-all duration-300" style={{ width: `${subtasks.length > 0 ? (subtasks.filter((t) => t.status === "done").length / subtasks.length) * 100 : 0}%` }} /></div>
<div className="space-y-1">{subtasks.map((sub) => { const subAssignee = agentMap.get(sub.assigneeAgentId ?? ""); return (
<div key={sub.id} className="flex items-center gap-2 rounded-lg px-3 py-2 text-[12px] bg-muted">
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${sub.status === "done" ? "bg-green-600 dark:bg-green-400" : sub.status === "in_progress" ? "bg-primary" : sub.status === "blocked" ? "bg-destructive" : sub.status === "in_review" ? "bg-yellow-600 dark:bg-yellow-400" : "bg-[color:var(--muted-foreground)]"}`} />
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${sub.status === "done" ? "bg-success" : sub.status === "in_progress" ? "bg-primary" : sub.status === "blocked" ? "bg-destructive" : sub.status === "in_review" ? "bg-warning" : "bg-[color:var(--muted-foreground)]"}`} />
<span className={`flex-1 truncate ${sub.status === "done" ? "line-through text-muted-foreground" : "text-foreground"}`}>{sub.title}</span>
{subAssignee ? <span className="shrink-0 text-[10px] text-muted-foreground">{subAssignee.name}</span> : null}<StatusPill status={sub.status} />
</div>) })}</div></div>) })()}
<TaskComments taskId={task.id} companyId={task.companyId} agents={agents} comments={comments} onRefresh={onRefresh} />
{runs.length > 0 && <div className="space-y-2"><div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.runHistory")}</div><div className="space-y-2">{runs.slice(0, 8).map((run) => { const runAgent = agentMap.get(run.agentId); const isActive = run.status === "running" || run.status === "queued"; return (
<div key={run.id} className={`rounded-lg border px-3 py-2.5 ${isActive ? "border-[color:var(--primary)] bg-green-100 dark:bg-green-900/50" : run.status === "failed" || run.status === "timed_out" ? "border-[color:var(--destructive)] border-opacity-30 bg-red-100 dark:bg-red-900/50" : "border-border bg-muted"}`}>
<div className="flex items-center justify-between gap-2"><div className="min-w-0 flex-1"><div className="flex items-center gap-2">{isActive ? <span className="h-2 w-2 animate-pulse rounded-full bg-green-600 dark:bg-green-400" /> : null}<span className="truncate text-[13px] font-medium text-foreground">{run.summary || (isActive ? "Running..." : "Completed run")}</span></div><div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground">{runAgent ? <span>{runAgent.name}</span> : null}<span>{run.connectorId.replace("_local", "")}</span>{run.model ? <span className="font-mono">{run.model}</span> : null}<span>{formatTime(run.startedAt)}</span>{run.costUsd != null && run.costUsd > 0 ? <span className="font-mono tabular-nums">{formatMoney(run.costUsd)}</span> : null}</div></div><StatusPill status={run.status} /></div>
{run.errorMessage ? <div className="mt-1.5 rounded bg-red-100 dark:bg-red-900/50 px-2 py-1 text-[11px] text-destructive break-words">{run.errorMessage}</div> : null}
<div key={run.id} className={`rounded-lg border px-3 py-2.5 ${isActive ? "border-[color:var(--primary)] bg-success-muted" : run.status === "failed" || run.status === "timed_out" ? "border-[color:var(--destructive)] border-opacity-30 bg-destructive-muted" : "border-border bg-muted"}`}>
<div className="flex items-center justify-between gap-2"><div className="min-w-0 flex-1"><div className="flex items-center gap-2">{isActive ? <span className="h-2 w-2 animate-pulse rounded-full bg-success" /> : null}<span className="truncate text-[13px] font-medium text-foreground">{run.summary || (isActive ? "Running..." : "Completed run")}</span></div><div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground">{runAgent ? <span>{runAgent.name}</span> : null}<span>{run.connectorId.replace("_local", "")}</span>{run.model ? <span className="font-mono">{run.model}</span> : null}<span>{formatTime(run.startedAt)}</span>{run.costUsd != null && run.costUsd > 0 ? <span className="font-mono tabular-nums">{formatMoney(run.costUsd)}</span> : null}</div></div><StatusPill status={run.status} /></div>
{run.errorMessage ? <div className="mt-1.5 rounded bg-destructive-muted px-2 py-1 text-[11px] text-destructive break-words">{run.errorMessage}</div> : null}
</div>) })}{runs.length > 8 && <div className="text-[11px] text-muted-foreground text-center">+{runs.length - 8} more runs</div>}</div></div>}
</div>
)
@@ -27,11 +27,11 @@ export const ENTITY_TYPE_OPTIONS = [
export function WorkspaceStatePill({ state }: { state: string }) {
const colorMap: Record<string, string> = {
ready: "text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/50",
busy: "text-yellow-600 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/50",
blocked: "text-destructive bg-red-100 dark:bg-red-900/50",
ready: "text-success bg-success-muted",
busy: "text-warning bg-warning-muted",
blocked: "text-destructive bg-destructive-muted",
missing: "text-muted-foreground bg-muted",
error: "text-destructive bg-red-100 dark:bg-red-900/50",
error: "text-destructive bg-destructive-muted",
}
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] ${colorMap[state] ?? colorMap.missing}`}>
@@ -41,6 +41,6 @@ export function WorkspaceStatePill({ state }: { state: string }) {
}
export function PriorityDot({ priority }: { priority: string }) {
const color = priority === "critical" ? "bg-destructive" : priority === "high" ? "bg-yellow-600 dark:bg-yellow-400" : priority === "low" ? "bg-[color:var(--muted-foreground)]" : "bg-[color:var(--muted-foreground)]"
const color = priority === "critical" ? "bg-destructive" : priority === "high" ? "bg-warning" : priority === "low" ? "bg-[color:var(--muted-foreground)]" : "bg-[color:var(--muted-foreground)]"
return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${color}`} aria-label={`${priority} priority`} />
}