Promote AgentCompany-New to the repository root and retire the old app

The legacy application - its src, tests, landing site, promo assets, docs,
release tooling, CI workflows, and root configs - leaves the tree; it stays
reachable in history. Everything under AgentCompany-New moves up unchanged:
same package name, so the production userData/database path is untouched,
and e2e/build scripts already resolved paths relative to the project. The
developer cred.txt stays gitignored at its new location. Full verify:ci
passes from the new root.
This commit is contained in:
ZacharyZhang-NY committed 2026-07-07 00:04:13 -04:00
1 parent 2350f744e3
commit 07d0b4587d
1904 files changed
+1133 -282075

No files matched your search

-249
View File
@@ -1,249 +0,0 @@
import { useCallback, useMemo, type ReactNode } from "react";
import { CircleNotch } from "./components/ui/icons";
import { LocaleContext, TranslationContext, type TranslationFn } from "./i18n";
import type { AppLocale } from "@shared/locale";
import { CompanyOnboarding } from "./components/company-onboarding/OnboardingWizard";
import { OAuthPromptDialog } from "./components/settings/dialogs/OAuthPromptDialog";
import TitleBar from "./components/TitleBar";
import ToastContainer from "./components/Toast";
import { SidebarView } from "./views/SidebarView";
import { SectionContentView } from "./views/SectionContentView";
import { CommandPaletteView } from "./views/CommandPaletteView";
import { useAppStore } from "./state/store";
import { useUIController } from "./hooks/useUIController";
import { useCompanyController } from "./hooks/useCompanyController";
import { useCommandPaletteHotkeys } from "./hooks/useCommandPaletteHotkeys";
import { AppActionsContext, type AppActions } from "./hooks/useAppActions";
import { SectionContext } from "./hooks/useSectionContext";
import { createAppErrorNotice, describeDesktopError, formatDesktopResultNotice } from "./lib/desktop";
function Shell({
locale,
t,
showCustomTitleBar,
className,
children,
}: {
locale: AppLocale;
t: TranslationFn;
showCustomTitleBar: boolean;
className?: string;
children: ReactNode;
}) {
return (
<LocaleContext.Provider value={locale}>
<TranslationContext.Provider value={t}>
<div className={`flex h-[100dvh] flex-col overflow-hidden${className ? " " + className : ""}`}>
{showCustomTitleBar && <TitleBar />}
{children}
</div>
</TranslationContext.Provider>
</LocaleContext.Provider>
);
}
export function App() {
const ui = useUIController();
const company = useCompanyController();
const error = useAppStore((s) => s.error);
const setError = useAppStore((s) => s.setError);
const setSelectedRunId = useAppStore((s) => s.setSelectedRunId);
const navigateTo = useAppStore((s) => s.navigateTo);
const showCustomTitleBar = window.agentCompany.platform !== "darwin";
const isMac = !showCustomTitleBar;
const currentCompany = company.currentCompany;
const companyRuns = company.runs;
const refreshCompany = company.refresh;
const openPathWithFeedback = company.openPathWithFeedback;
const startRun = useCallback(async (taskId: string) => {
if (!currentCompany) {
setError(createAppErrorNotice(
"Select a company before starting a run.",
"COMPANY_REQUIRED",
"Select a company in the sidebar, then start the run.",
));
return;
}
const result = await window.agentCompany.startTaskRun({ companyId: currentCompany.id, taskId });
if (!result.ok) { setError(formatDesktopResultNotice(result.error)); return; }
setSelectedRunId(result.data);
await refreshCompany();
}, [currentCompany, refreshCompany, setError, setSelectedRunId]);
const cancelRun = useCallback(async (runId: string) => {
try {
const run = companyRuns.find((entry) => entry.id === runId);
if (!run) throw new Error("Selected run is no longer available.");
const result = await window.agentCompany.cancelRun({ companyId: run.companyId, runId });
if (!result.ok) { setError(formatDesktopResultNotice(result.error)); return; }
await refreshCompany();
setError(null);
} catch (err) { setError(describeDesktopError(err)); }
}, [companyRuns, refreshCompany, setError]);
const { filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects, hasEntityResults } =
useMemo(() => ui.buildPaletteResults(
ui.navItems,
company.companyAgents,
company.tasks,
company.runs,
company.companyGoals,
company.companyProjects,
), [
ui,
company.companyAgents,
company.tasks,
company.runs,
company.companyGoals,
company.companyProjects,
]);
const paletteActions = useMemo(
() => ui.buildPaletteActions(filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects),
[ui, filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects],
);
const paletteActionIndex = useMemo(
() => new Map(paletteActions.map((action, index) => [action.key, index])),
[paletteActions],
);
useCommandPaletteHotkeys({
commandPaletteOpen: ui.commandPaletteOpen,
setCommandPaletteOpen: ui.setCommandPaletteOpen,
setCommandPaletteSearch: ui.setCommandPaletteSearch,
setSelectedPaletteIndex: ui.setSelectedPaletteIndex,
paletteActions,
navigateTo,
commandPaletteSearch: ui.commandPaletteSearch,
});
const appActions = useMemo<AppActions>(() => ({
refresh: refreshCompany,
setError,
openPathWithFeedback,
startRun,
cancelRun,
}), [refreshCompany, openPathWithFeedback, startRun, cancelRun, setError]);
if (!company.snapshot) {
return (
<Shell locale={ui.locale} t={ui.t} showCustomTitleBar={showCustomTitleBar}>
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3">
<CircleNotch size={24} className="animate-spin text-primary" />
<span className="text-[13px] text-muted-foreground">{ui.t("app.loading")}</span>
</div>
</Shell>
);
}
if (company.snapshot.companies.length === 0 || ui.showOnboarding || ui.initialOnboardingLocked) {
return (
<Shell locale={ui.locale} t={ui.t} showCustomTitleBar={showCustomTitleBar}>
<div className="min-h-0 flex-1 overflow-auto">
<CompanyOnboarding
connectors={company.snapshot.connectors}
onDone={company.handleOnboardingDone}
canCancel={company.snapshot.companies.length > 0 && !ui.initialOnboardingLocked}
onCancel={() => {
ui.setShowOnboarding(false);
ui.setInitialOnboardingLocked(false);
setError(null);
}}
currentLocale={ui.locale}
onLocaleChange={ui.handleLocaleChange}
isFirstLaunch={company.snapshot.companies.length === 0}
/>
</div>
{/* Mount here so onboarding OAuth flows that need a prompt (e.g. */}
{/* GitHub Copilot's enterprise-domain step) can surface a modal. */}
<OAuthPromptDialog />
</Shell>
);
}
return (
<LocaleContext.Provider value={ui.locale}>
<TranslationContext.Provider value={ui.t}>
<AppActionsContext.Provider value={appActions}>
<SectionContext.Provider value={company.sectionContextValue}>
<div className="flex h-[100dvh] flex-col overflow-hidden bg-background">
{showCustomTitleBar && <TitleBar />}
<div className={`grid min-h-0 flex-1 overflow-hidden transition-[grid-template-columns] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] ${ui.sidebarCollapsed ? "grid-cols-[56px_minmax(0,1fr)]" : "grid-cols-[220px_minmax(0,1fr)]"}`}>
<SidebarView
isMac={isMac}
sidebarCollapsed={ui.sidebarCollapsed}
setSidebarCollapsed={ui.setSidebarCollapsed}
section={ui.section}
setSection={ui.setSection}
sidebarGroups={ui.sidebarGroups}
navBadges={company.navBadges}
companySwitcherOpen={ui.companySwitcherOpen}
setCompanySwitcherOpen={ui.setCompanySwitcherOpen}
confirmDeleteCompanyId={ui.confirmDeleteCompanyId}
setConfirmDeleteCompanyId={ui.setConfirmDeleteCompanyId}
currentCompany={company.currentCompany}
snapshot={company.snapshot}
setCompany={company.setCompany}
deleteCompany={company.deleteCompany}
setCommandPaletteOpen={ui.setCommandPaletteOpen}
t={ui.t}
/>
<main aria-label="Content" className="flex min-w-0 flex-col overflow-hidden">
<header className="sidebar-drag shrink-0 border-b border-border bg-background">
<div className="mx-auto flex w-full max-w-[1040px] items-start justify-between gap-4 px-6 pb-5 pt-6 lg:gap-6 lg:px-10 lg:pt-8">
<h1 className="text-[28px] font-bold tracking-[-0.02em] text-foreground">
{ui.navItems.find((item) => item.id === ui.section)?.label ?? ui.t(`nav.${ui.section}`)}
</h1>
</div>
</header>
<SectionContentView
section={ui.section}
error={error}
pageTransition={ui.pageTransition}
/>
</main>
<CommandPaletteView
commandPaletteOpen={ui.commandPaletteOpen}
setCommandPaletteOpen={ui.setCommandPaletteOpen}
commandPaletteSearch={ui.commandPaletteSearch}
setCommandPaletteSearch={ui.setCommandPaletteSearch}
selectedPaletteIndex={ui.selectedPaletteIndex}
setSelectedPaletteIndex={ui.setSelectedPaletteIndex}
paletteTrapRef={ui.paletteTrapRef}
filteredNavItems={filteredNavItems}
matchedAgents={matchedAgents}
matchedTasks={matchedTasks}
matchedRuns={matchedRuns}
matchedGoals={matchedGoals}
matchedProjects={matchedProjects}
hasEntityResults={hasEntityResults}
paletteActions={paletteActions}
paletteActionIndex={paletteActionIndex}
runs={company.runs}
navigateTo={navigateTo}
panelTransition={ui.panelTransition}
t={ui.t}
/>
<ToastContainer />
{/* App-root mount so any section that kicks off an OAuth login — */}
{/* not just Settings — sees prompt/manual-code events. */}
<OAuthPromptDialog />
</div>
</div>
</SectionContext.Provider>
</AppActionsContext.Provider>
</TranslationContext.Provider>
</LocaleContext.Provider>
);
}
export default App;
-62
View File
@@ -1,62 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { Copy, Minus, Square, X } from "./ui/icons";
import { useT } from "../i18n";
export default function TitleBar() {
const t = useT();
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={t("titlebar.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 ? t("titlebar.restore") : t("titlebar.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().then(refreshMaximized);
}}
>
{maximized ? (
<Copy size={12} weight="bold" />
) : (
<Square size={12} weight="bold" />
)}
</button>
<button
type="button"
aria-label={t("titlebar.close")}
className="titlebar-button flex h-full w-[46px] items-center justify-center text-muted-foreground transition hover:bg-destructive hover:text-primary-foreground"
onClick={() => void window.agentCompany.windowClose()}
>
<X size={14} weight="bold" />
</button>
</div>
</div>
);
}
-75
View File
@@ -1,75 +0,0 @@
import { AnimatePresence, motion } from "framer-motion"
import { CheckCircle, Warning, X, XCircle } from "./ui/icons"
import { useAppStore } from "../state/store"
import { useReducedMotion } from "./ui"
import type { Toast } from "../state/slices/ui"
const TONE_CONFIG = {
success: { icon: CheckCircle, className: "bg-success-muted border-success text-success" },
danger: { icon: XCircle, className: "bg-destructive-muted border-destructive text-destructive" },
warn: { icon: Warning, className: "bg-warning-muted border-warning text-warning" },
} as const
export default function ToastContainer() {
const toasts = useAppStore((s) => s.toasts)
const dismiss = useAppStore((s) => s.dismissToast)
const reduced = useReducedMotion()
if (toasts.length === 0) return null
return (
<div className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2">
<AnimatePresence>
{toasts.map((toast) => {
const cfg = TONE_CONFIG[toast.tone]
const Icon = cfg.icon
return (
<motion.div
key={toast.id}
role={toast.tone === "danger" ? "alert" : "status"}
aria-live={toast.tone === "danger" ? "assertive" : "polite"}
initial={reduced ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, y: -8, scale: 0.96 }}
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
className={`flex w-[320px] items-start gap-2.5 rounded-lg border px-4 py-3 shadow-lg ${cfg.className}`}
>
<Icon size={16} weight="fill" className="mt-px shrink-0" />
<ToastMessage message={toast.message} />
<button
type="button"
onClick={() => dismiss(toast.id)}
className="shrink-0 rounded-[4px] p-0.5 opacity-60 transition hover:opacity-100"
aria-label="Dismiss"
>
<X size={12} />
</button>
</motion.div>
)
})}
</AnimatePresence>
</div>
)
}
function ToastMessage({ message }: { message: Toast["message"] }) {
if (typeof message === "string") {
return (
<span className="max-h-[6.5rem] min-w-0 flex-1 overflow-y-auto break-words text-[13px] font-medium leading-relaxed">
{message}
</span>
)
}
return (
<div className="max-h-[7.5rem] min-w-0 flex-1 overflow-y-auto">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="rounded bg-background/80 px-1.5 py-0.5 font-mono text-[10px] font-semibold uppercase tracking-[0.08em]">
{message.code}
</span>
<span className="min-w-0 break-words text-[12px] font-semibold leading-5">{message.actionHint}</span>
</div>
<div className="mt-1 break-words text-[12px] leading-5 opacity-80">{message.message}</div>
</div>
)
}
-59
View File
@@ -1,59 +0,0 @@
import type { ReactNode } from "react";
import { Eyebrow } from "./ui";
/**
* Two-pane layout with an optional section header.
* - `split="vertical"` (default): stacks primary over secondary with rhythmic whitespace.
* - `split="horizontal"`: primary (register) on the left, secondary (focus) on the right.
* Use horizontal when the two panes share a single focus — selecting a row on the
* left updates the inspector on the right, and the page should present one idea.
*
* `action` docks a single control (e.g. a primary "+ Add" button) to the right of the
* header row. When a title/eyebrow is present the action sits on the header's baseline;
* when the header is absent, the action sits in its own right-aligned row above the body.
* Keep it to one element — this slot exists so the control has a home, not a stage.
*
* Use `Panel` (from ui/) for single-slot sections. Use this when rendering a primary + secondary pane.
*/
export function TwoPaneLayout({
title,
eyebrow,
primary,
secondary,
split = "vertical",
action,
}: {
title?: string;
eyebrow?: string;
primary: ReactNode;
secondary: ReactNode;
split?: "vertical" | "horizontal";
action?: ReactNode;
}) {
const hasHeader = Boolean(title || eyebrow);
const body =
split === "horizontal" ? (
<div className="grid gap-8 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.4fr)] lg:items-start">
<div className="min-w-0">{primary}</div>
<div className="min-w-0">{secondary}</div>
</div>
) : (
<div className="space-y-14">{primary}{secondary}</div>
);
return (
<div className="space-y-14">
{hasHeader ? (
<div className="flex items-end justify-between gap-4">
<div className="min-w-0">
{eyebrow ? <Eyebrow className="mb-1">{eyebrow}</Eyebrow> : null}
{title ? <h2 className="text-[15px] font-semibold tracking-[-0.01em] text-foreground">{title}</h2> : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
) : action ? (
<div className="flex justify-end">{action}</div>
) : null}
{body}
</div>
);
}
@@ -1,312 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { AnimatePresence, motion } from "framer-motion"
import { ArrowRight, ArrowsClockwise, Lightning, Plus, Robot, X } from "../ui/icons"
import type { AutomationAction, AutomationRuleRecord, AutomationStatus, AutomationTrigger, WorkflowPipelineRecord } from "@shared/types"
import { ActionButton, ConfirmDialog, Card, Eyebrow } from "../ui"
import { useT, type TranslationFn } from "../../i18n"
import { triggerConfig, actionConfig, type AutomationLogEntry } from "./automation-config"
import { timeAgo } from "../../lib/formatters"
import { RuleCard } from "./RuleCard"
import { RuleFormModal } from "./RuleEditor"
import { WorkflowCard } from "./WorkflowList"
import { WorkflowFormModal } from "./WorkflowEditor"
function AutomationActivityLog({ entries, t }: { entries: AutomationLogEntry[]; t: TranslationFn }) {
if (entries.length === 0) {
return <div className="py-6 text-center text-[13px] text-muted-foreground">{t("auto.activity.noActivity")}</div>
}
return (
<div className="space-y-1">
{entries.map((entry, idx) => {
const ti = triggerConfig[entry.trigger as AutomationTrigger]
const ai = actionConfig[entry.action as AutomationAction]
const TIcon = ti?.icon ?? Lightning
return (
<motion.div
key={`${entry.ruleId}-${entry.executedAt}-${idx}`}
initial={{ opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.03 }}
className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-2.5 text-[12px]"
>
<TIcon size={14} className="text-primary" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-foreground">{entry.ruleName}</span>
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span>{ti?.label ?? entry.trigger}</span>
<ArrowRight size={8} />
<span>{ai?.label ?? entry.action}</span>
</div>
</div>
<span className="shrink-0 text-[10px] text-muted-foreground">{timeAgo(entry.executedAt)}</span>
</motion.div>
)
})}
</div>
)
}
function RulesPanel({ rules, companyId, onRefresh, t }: { rules: AutomationRuleRecord[]; companyId: string; onRefresh: () => void; t: TranslationFn }) {
const [showCreate, setShowCreate] = useState(false); const [editRule, setEditRule] = useState<AutomationRuleRecord | null>(null); const [delTarget, setDelTarget] = useState<AutomationRuleRecord | null>(null)
const [actLog, setActLog] = useState<AutomationLogEntry[]>([]); const [activeTab, setActiveTab] = useState<"rules"|"activity">("rules"); const [filterStatus, setFilterStatus] = useState<AutomationStatus|"all">("all")
const [actionError, setActionError] = useState<string | null>(null)
useEffect(() => { void window.agentCompany.getAutomationLog(companyId).then((r) => { if (r.ok) setActLog(r.data) }) }, [companyId, rules])
const filtered = useMemo(() => filterStatus === "all" ? rules : rules.filter((r) => r.status === filterStatus), [rules, filterStatus])
const counts = useMemo(() => { let active = 0, paused = 0, error = 0; for (const r of rules) { if (r.status === "active") active++; else if (r.status === "paused") paused++; else if (r.status === "error") error++; } return { total: rules.length, active, paused, error } }, [rules])
const handleToggle = useCallback(async (rule: AutomationRuleRecord) => { setActionError(null); try { const r = await window.agentCompany.toggleAutomationRule({ id: rule.id, companyId, status: (rule.status === "active" ? "paused" : "active") as "active"|"paused" }); if (!r.ok) { setActionError(r.error.message); return }; onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : t("auto.failedAction")) } }, [companyId, onRefresh, t])
const handleDelete = useCallback(async () => { if (!delTarget) return; setActionError(null); try { const r = await window.agentCompany.deleteAutomationRule({ id: delTarget.id, companyId }); if (!r.ok) { setActionError(r.error.message); setDelTarget(null); return }; setDelTarget(null); onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : t("auto.failedAction")); setDelTarget(null) } }, [delTarget, companyId, onRefresh, t])
return (
<div className="space-y-5">
{actionError ? (
<div className="flex items-center justify-between rounded-lg bg-destructive-muted px-3.5 py-2.5 text-[12px] font-medium text-destructive">
<span>{actionError}</span>
<button type="button" onClick={() => setActionError(null)} className="ml-2 rounded p-0.5 hover:bg-destructive hover:text-primary-foreground">
<X size={12} />
</button>
</div>
) : null}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Lightning size={20} weight="fill" />
</div>
<div>
<h2 className="text-[16px] font-bold text-foreground">{t("auto.rules.title")}</h2>
<p className="text-[12px] text-muted-foreground">
{t("auto.rules.summaryActive").replace("{n}", String(counts.active))},{t("auto.rules.summaryPaused").replace("{n}", String(counts.paused))}{counts.error > 0 ? `,${t("auto.rules.summaryErrored").replace("{n}", String(counts.error))}` : ""}
</p>
</div>
</div>
<ActionButton label={t("auto.rules.newRule")} onClick={() => setShowCreate(true)} icon={Plus} tone="accent" />
</div>
{rules.length > 0 ? (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{[
{ l: t("auto.rules.totalRules"), v: counts.total, accent: false },
{ l: t("auto.rules.totalExecutions"), v: rules.reduce((s, r) => s + r.executionCount, 0), accent: true },
{ l: t("auto.rules.recentActivity"), v: actLog.length, accent: true },
].map((s) => (
<Card key={s.l} padding="sm" className="!py-3.5">
<Eyebrow>{s.l}</Eyebrow>
<div className={`text-[26px] font-bold tabular-nums ${s.accent ? "text-primary" : ""}`}>{s.v}</div>
</Card>
))}
</div>
) : null}
<div className="flex gap-1 rounded-lg bg-muted p-1">
<button
type="button"
onClick={() => setActiveTab("rules")}
className={`flex-1 rounded-lg px-3 py-1.5 text-[12px] font-medium transition ${activeTab === "rules" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
>
{t("auto.rules.tabRules").replace("{n}", String(rules.length))}
</button>
<button
type="button"
onClick={() => setActiveTab("activity")}
className={`flex-1 rounded-lg px-3 py-1.5 text-[12px] font-medium transition ${activeTab === "activity" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
>
{t("auto.rules.tabActivity").replace("{n}", String(actLog.length))}
</button>
</div>
{activeTab === "rules" ? (
<>
{rules.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
{(["all", "active", "paused", "error"] as const).map((s) => {
const filterLabel = s === "all" ? t("auto.rules.filterAll")
: s === "active" ? t("auto.rules.filterActive")
: s === "paused" ? t("auto.rules.filterPaused")
: t("auto.rules.filterError");
return (
<button
key={s}
type="button"
onClick={() => setFilterStatus(s)}
className={`rounded-full px-3 py-1.5 text-[11px] font-medium transition ${filterStatus === s ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-[color:var(--border)]"}`}
>
{filterLabel}
</button>
);
})}
</div>
) : null}
{filtered.length === 0 && rules.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center">
<Lightning size={36} className="mb-3 text-muted-foreground" />
<div className="text-[14px] font-medium text-muted-foreground">{t("auto.rules.noRules")}</div>
<div className="mt-4">
<ActionButton label={t("auto.rules.createFirstRule")} onClick={() => setShowCreate(true)} icon={Lightning} tone="accent" />
</div>
</div>
) : filtered.length === 0 ? (
<div className="py-8 text-center text-[13px] text-muted-foreground">{t("auto.rules.noRulesFilter")}</div>
) : (
<div className="space-y-2">
<AnimatePresence>
{filtered.map((r) => (
<RuleCard
key={r.id}
rule={r}
onToggle={() => void handleToggle(r)}
onDelete={() => setDelTarget(r)}
onEdit={() => setEditRule(r)}
/>
))}
</AnimatePresence>
</div>
)}
</>
) : (
<AutomationActivityLog entries={actLog} t={t} />
)}
<AnimatePresence>
{showCreate ? (
<RuleFormModal open={showCreate} editingRule={null} companyId={companyId} onClose={() => setShowCreate(false)} onSaved={onRefresh} />
) : null}
{editRule ? (
<RuleFormModal open={!!editRule} editingRule={editRule} companyId={companyId} onClose={() => setEditRule(null)} onSaved={onRefresh} />
) : null}
</AnimatePresence>
<ConfirmDialog
open={!!delTarget}
title={t("auto.rules.deleteRule")}
message={delTarget ? t("auto.rules.deleteRuleConfirm").replace("{name}", delTarget.name) : ""}
onConfirm={() => void handleDelete()}
onCancel={() => setDelTarget(null)}
/>
</div>
)
}
function WorkflowsPanel({ workflows, companyId, onRefresh, t }: { workflows: WorkflowPipelineRecord[]; companyId: string; onRefresh: () => void; t: TranslationFn }) {
const [showCreate, setShowCreate] = useState(false); const [editWf, setEditWf] = useState<WorkflowPipelineRecord | null>(null); const [delTarget, setDelTarget] = useState<WorkflowPipelineRecord | null>(null)
const [actionError, setActionError] = useState<string | null>(null); const [runningId, setRunningId] = useState<string | null>(null)
const handleDelete = useCallback(async () => { if (!delTarget) return; setActionError(null); try { const r = await window.agentCompany.deleteWorkflow({ id: delTarget.id, companyId }); if (!r.ok) { setActionError(r.error.message); setDelTarget(null); return }; setDelTarget(null); onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : t("auto.failedAction")); setDelTarget(null) } }, [delTarget, companyId, onRefresh, t])
const handleRun = useCallback(async (wf: WorkflowPipelineRecord) => { setActionError(null); setRunningId(wf.id); try { const r = await window.agentCompany.runWorkflow({ id: wf.id, companyId }); if (!r.ok) { setActionError(r.error.message); return }; onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : t("auto.failedAction")) } finally { setRunningId(null) } }, [companyId, onRefresh, t])
const counts = useMemo(() => ({ total: workflows.length, active: workflows.filter((w) => w.status === "active").length, draft: workflows.filter((w) => w.status === "draft").length }), [workflows])
return (
<div className="space-y-5">
{actionError ? (
<div className="flex items-center justify-between rounded-lg bg-destructive-muted px-3.5 py-2.5 text-[12px] font-medium text-destructive">
<span>{actionError}</span>
<button type="button" onClick={() => setActionError(null)} className="ml-2 rounded p-0.5 hover:bg-destructive hover:text-primary-foreground">
<X size={12} />
</button>
</div>
) : null}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<ArrowsClockwise size={20} weight="fill" />
</div>
<div>
<h2 className="text-[16px] font-bold text-foreground">{t("auto.workflows.title")}</h2>
<p className="text-[12px] text-muted-foreground">
{t("auto.workflows.summaryActive").replace("{n}", String(counts.active))},{t("auto.workflows.summaryDraft").replace("{n}", String(counts.draft))}
{counts.total > counts.active + counts.draft ? `,${t("auto.workflows.summaryOther").replace("{n}", String(counts.total - counts.active - counts.draft))}` : ""}
</p>
</div>
</div>
<ActionButton label={t("auto.workflows.newWorkflow")} onClick={() => setShowCreate(true)} icon={Plus} tone="accent" />
</div>
{workflows.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center">
<ArrowsClockwise size={36} className="mb-3 text-muted-foreground" />
<div className="text-[14px] font-medium text-muted-foreground">{t("auto.workflows.noWorkflows")}</div>
<div className="mt-4">
<ActionButton label={t("auto.workflows.createFirstWorkflow")} onClick={() => setShowCreate(true)} icon={ArrowsClockwise} tone="accent" />
</div>
</div>
) : (
<div className="space-y-2">
<AnimatePresence>
{workflows.map((wf) => (
<WorkflowCard
key={wf.id}
workflow={wf}
isRunning={runningId === wf.id}
onDelete={() => setDelTarget(wf)}
onEdit={() => setEditWf(wf)}
onRun={() => void handleRun(wf)}
/>
))}
</AnimatePresence>
</div>
)}
<AnimatePresence>
{showCreate ? (
<WorkflowFormModal open={showCreate} editingWorkflow={null} companyId={companyId} onClose={() => setShowCreate(false)} onSaved={onRefresh} />
) : null}
{editWf ? (
<WorkflowFormModal open={!!editWf} editingWorkflow={editWf} companyId={companyId} onClose={() => setEditWf(null)} onSaved={onRefresh} />
) : null}
</AnimatePresence>
<ConfirmDialog
open={!!delTarget}
title={t("auto.workflows.deleteWorkflow")}
message={delTarget ? t("auto.workflows.deleteWorkflowConfirm").replace("{name}", delTarget.name) : ""}
onConfirm={() => void handleDelete()}
onCancel={() => setDelTarget(null)}
/>
</div>
)
}
export function AutomationPanel({ rules, workflows, companyId, onRefresh }: { rules: AutomationRuleRecord[]; workflows: WorkflowPipelineRecord[]; companyId: string; onRefresh: () => void }) {
const t = useT(); const [activeSection, setActiveSection] = useState<"rules"|"workflows">("rules")
return (
<div className="space-y-6">
<div className="rounded-lg border border-primary/20 bg-gradient-to-br from-primary/5 to-primary/[0.02] px-5 py-4">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Robot size={20} weight="fill" />
</div>
<div>
<div className="text-[13px] font-semibold text-foreground">{t("auto.automationEngine")}</div>
<div className="mt-0.5 text-[11px] leading-relaxed text-muted-foreground">
{t("auto.engineDescription")}
</div>
</div>
</div>
</div>
<div className="flex gap-1 rounded-lg bg-muted p-1">
<button
type="button"
onClick={() => setActiveSection("rules")}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2 text-[13px] font-medium transition ${activeSection === "rules" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
>
<Lightning size={15} weight={activeSection === "rules" ? "fill" : "regular"} className={activeSection === "rules" ? "text-primary" : ""} />
{t("auto.tabRules").replace("{n}", String(rules.length))}
</button>
<button
type="button"
onClick={() => setActiveSection("workflows")}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2 text-[13px] font-medium transition ${activeSection === "workflows" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
>
<ArrowsClockwise size={15} weight={activeSection === "workflows" ? "fill" : "regular"} className={activeSection === "workflows" ? "text-primary" : ""} />
{t("auto.tabWorkflows").replace("{n}", String(workflows.length))}
</button>
</div>
<AnimatePresence mode="wait">
<motion.div
key={activeSection}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
>
{activeSection === "rules" ? (
<RulesPanel rules={rules} companyId={companyId} onRefresh={onRefresh} t={t} />
) : (
<WorkflowsPanel workflows={workflows} companyId={companyId} onRefresh={onRefresh} t={t} />
)}
</motion.div>
</AnimatePresence>
</div>
)
}
@@ -1,35 +0,0 @@
import { motion } from "framer-motion"
import { ArrowRight, CaretRight, Gear, Lightning, Pause, Play, Trash } from "../ui/icons"
import type { AutomationRuleRecord } from "@shared/types"
import { timeAgo } from "../../lib/formatters"
import { triggerConfig, actionConfig, statusColors } from "./automation-config"
export function RuleCard({ rule, onToggle, onDelete, onEdit }: { rule: AutomationRuleRecord; onToggle: () => void; onDelete: () => void; onEdit: () => void }) {
const triggerInfo = triggerConfig[rule.trigger]; const actionInfo = actionConfig[rule.action]
const TriggerIcon = triggerInfo?.icon ?? Lightning; const colors = statusColors[rule.status]
return (
<motion.div layout initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, x: 20 }} className="group rounded-lg border border-border bg-card transition duration-150 hover:shadow-md hover:-translate-y-px">
<div className="flex items-center gap-3 px-4 py-3.5">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"><TriggerIcon size={20} weight="fill" /></div>
<button type="button" onClick={onEdit} className="min-w-0 flex-1 text-left">
<div className="flex items-center gap-2">
<span className="truncate text-[13px] font-bold text-foreground">{rule.name}</span>
<span className="flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]" style={{ backgroundColor: colors.bg, color: colors.text }}><span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: colors.dot }} aria-hidden="true" />{rule.status}</span>
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1 rounded-[4px] bg-muted px-1.5 py-0.5 font-medium text-muted-foreground"><Lightning size={10} weight="fill" />{triggerInfo?.label ?? rule.trigger}</span>
<ArrowRight size={10} className="text-muted-foreground" />
<span className="inline-flex items-center gap-1 rounded-[4px] bg-muted px-1.5 py-0.5 font-medium text-muted-foreground"><Gear size={10} />{actionInfo?.label ?? rule.action}</span>
{rule.sourceDepartment || rule.targetDepartment ? <><span className="text-[color:var(--border)]">|</span><span className="inline-flex items-center gap-1"><span className="font-medium text-muted-foreground">{rule.sourceDepartment ?? "Any"}</span><CaretRight size={8} /><span className="font-medium text-muted-foreground">{rule.targetDepartment ?? "Same"}</span></span></> : null}
</div>
<div className="mt-1 flex items-center gap-3 text-[10px] text-muted-foreground"><span className="tabular-nums">{rule.executionCount} execution{rule.executionCount !== 1 ? "s" : ""}</span><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted-foreground)]" /><span>Last run {timeAgo(rule.lastExecutedAt)}</span><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted-foreground)]" /><span className="tabular-nums">Priority {rule.priority}</span></div>
</button>
<div className="flex shrink-0 items-center gap-1">
<button type="button" onClick={onToggle} aria-label={rule.status === "active" ? "Pause rule" : "Activate rule"} title={rule.status === "active" ? "Pause rule" : "Activate rule"} className="rounded-lg p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground">{rule.status === "active" ? <Pause size={15} /> : <Play size={15} />}</button>
<button type="button" onClick={onDelete} aria-label="Delete rule" title="Delete rule" className="rounded-lg p-2 text-muted-foreground opacity-0 transition hover:bg-destructive-muted hover:text-destructive group-hover:opacity-100"><Trash size={15} /></button>
</div>
</div>
</motion.div>
)
}
@@ -1,45 +0,0 @@
import { useCallback, useEffect, useState } from "react"
import { Gear, Lightning } from "../ui/icons"
import type { AutomationAction, AutomationRuleRecord, AutomationTrigger, DepartmentId } from "@shared/types"
import { ActionButton, Modal, Select } from "../ui"
import { triggerConfig, actionConfig, departmentOptions, safeJsonStringify } from "./automation-config"
import { useT } from "../../i18n"
interface RuleFormState { name: string; description: string; trigger: AutomationTrigger; conditionsJson: string; action: AutomationAction; actionConfigJson: string; sourceDepartment: string; targetDepartment: string; priority: number }
const defaultRuleForm: RuleFormState = { name: "", description: "", trigger: "task_status_changed", conditionsJson: "{}", action: "assign_task", actionConfigJson: "{}", sourceDepartment: "", targetDepartment: "", priority: 50 }
function ruleToFormState(rule: AutomationRuleRecord): RuleFormState { return { name: rule.name, description: rule.description, trigger: rule.trigger, conditionsJson: safeJsonStringify(rule.conditionsJson), action: rule.action, actionConfigJson: safeJsonStringify(rule.actionConfigJson), sourceDepartment: rule.sourceDepartment ?? "", targetDepartment: rule.targetDepartment ?? "", priority: rule.priority } }
export function RuleFormModal({ open, editingRule, companyId, onClose, onSaved }: { open: boolean; editingRule: AutomationRuleRecord | null; companyId: string; onClose: () => void; onSaved: () => void }) {
const t = useT()
const isEditing = editingRule !== null; const [form, setForm] = useState<RuleFormState>(defaultRuleForm); const [saving, setSaving] = useState(false); const [error, setError] = useState<string | null>(null)
useEffect(() => { if (open) { setForm(editingRule ? ruleToFormState(editingRule) : defaultRuleForm); setError(null) } }, [open, editingRule])
const updateField = useCallback(<K extends keyof RuleFormState>(key: K, value: RuleFormState[K]) => { setForm((prev) => ({ ...prev, [key]: value })) }, [])
const handleSave = useCallback(async () => {
if (!form.name.trim()) { setError(t("automation.ruleEditor.errNameRequired")); return }
try { JSON.parse(form.conditionsJson) } catch { setError(t("automation.ruleEditor.errConditionsJson")); return }
try { JSON.parse(form.actionConfigJson) } catch { setError(t("automation.ruleEditor.errActionConfigJson")); return }
setSaving(true); setError(null)
try { const result = await window.agentCompany.saveAutomationRule({ id: editingRule?.id ?? null, companyId, name: form.name.trim(), description: form.description.trim(), trigger: form.trigger, conditionsJson: form.conditionsJson, action: form.action, actionConfigJson: form.actionConfigJson, sourceDepartment: (form.sourceDepartment || null) as DepartmentId | null, targetDepartment: (form.targetDepartment || null) as DepartmentId | null, priority: form.priority, status: editingRule?.status ?? "active" }); if (!result.ok) { setError(result.error.message); return }; onSaved(); onClose() } catch (err) { setError(err instanceof Error ? err.message : t("automation.ruleEditor.errSaveFailed")) } finally { setSaving(false) }
}, [form, editingRule, companyId, onSaved, onClose, t])
const triggerOptions = Object.entries(triggerConfig).map(([key, c]) => ({ value: key, label: c.label })); const actionOptions = Object.entries(actionConfig).map(([key, c]) => ({ value: key, label: c.label }))
const selectedTriggerInfo = triggerConfig[form.trigger]; const selectedActionInfo = actionConfig[form.action]
return (
<Modal open={open} onClose={onClose} title={isEditing ? t("automation.ruleEditor.editTitle") : t("automation.ruleEditor.newTitle")} width="580px">
<div className="space-y-5">
{error ? <div className="rounded-lg bg-destructive-muted px-3.5 py-2.5 text-[12px] font-medium text-destructive">{error}</div> : null}
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("automation.ruleEditor.nameLabel")}</label><input type="text" value={form.name} onChange={(e) => updateField("name", e.target.value)} placeholder={t("automation.ruleEditor.namePlaceholder")} 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" /></div>
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("automation.ruleEditor.descriptionLabel")}</label><textarea value={form.description} onChange={(e) => updateField("description", e.target.value)} placeholder={t("automation.ruleEditor.descriptionPlaceholder")} rows={2} 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" /></div>
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("automation.ruleEditor.triggerLabel")}</label><Select value={form.trigger} onChange={(v) => updateField("trigger", v as AutomationTrigger)} options={triggerOptions} />{selectedTriggerInfo ? <div className="mt-1.5 text-[11px] text-muted-foreground">{selectedTriggerInfo.description}</div> : null}</div>
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("automation.ruleEditor.conditionsLabel")}</label><textarea value={form.conditionsJson} onChange={(e) => updateField("conditionsJson", e.target.value)} rows={3} spellCheck={false} 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 font-mono text-[12px] text-foreground" placeholder='{"status": "blocked"}' /></div>
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("automation.ruleEditor.actionLabel")}</label><Select value={form.action} onChange={(v) => updateField("action", v as AutomationAction)} options={actionOptions} />{selectedActionInfo ? <div className="mt-1.5 text-[11px] text-muted-foreground">{selectedActionInfo.description}</div> : null}</div>
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("automation.ruleEditor.actionConfigLabel")}</label><textarea value={form.actionConfigJson} onChange={(e) => updateField("actionConfigJson", e.target.value)} rows={3} spellCheck={false} 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 font-mono text-[12px] text-foreground" placeholder='{"targetAgentId": "..."}' /></div>
<div className="grid grid-cols-2 gap-3"><div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("automation.ruleEditor.sourceDepartmentLabel")}</label><Select value={form.sourceDepartment} onChange={(v) => updateField("sourceDepartment", v)} options={departmentOptions} /></div><div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("automation.ruleEditor.targetDepartmentLabel")}</label><Select value={form.targetDepartment} onChange={(v) => updateField("targetDepartment", v)} options={departmentOptions} /></div></div>
<div><div className="mb-1.5 flex items-center justify-between"><label className="text-[12px] font-medium text-muted-foreground">{t("automation.ruleEditor.priorityLabel")}</label><span className="text-[12px] font-semibold text-primary">{form.priority}</span></div><input type="range" min={0} max={100} value={form.priority} onChange={(e) => updateField("priority", parseInt(e.target.value, 10))} className="w-full accent-[color:var(--primary)]" /><div className="mt-1 flex justify-between text-[10px] text-muted-foreground"><span>{t("automation.ruleEditor.priorityLow")}</span><span>{t("automation.ruleEditor.priorityHigh")}</span></div></div>
</div>
<div className="mt-6 flex justify-end gap-3 border-t border-border pt-4">
<ActionButton label={t("automation.ruleEditor.cancel")} onClick={onClose} />
<ActionButton label={isEditing ? t("automation.ruleEditor.save") : t("automation.ruleEditor.create")} onClick={() => void handleSave()} icon={isEditing ? Gear : Lightning} tone="accent" loading={saving} disabled={!form.name.trim()} />
</div>
</Modal>
)
}
@@ -1,42 +0,0 @@
import { useCallback, useEffect, useState } from "react"
import { ArrowsClockwise, Gear } from "../ui/icons"
import type { AutomationTrigger, WorkflowPipelineRecord, WorkflowStatus } from "@shared/types"
import { ActionButton, Modal, Select } from "../ui"
import { triggerConfig, safeJsonStringify } from "./automation-config"
import { useT } from "../../i18n"
interface WorkflowFormState { name: string; description: string; triggerType: AutomationTrigger; triggerConfigJson: string; status: WorkflowStatus; stepsJson: string }
const defaultWorkflowForm: WorkflowFormState = { name: "", description: "", triggerType: "task_created", triggerConfigJson: "{}", status: "draft", stepsJson: "[]" }
function workflowToFormState(wf: WorkflowPipelineRecord): WorkflowFormState { return { name: wf.name, description: wf.description, triggerType: wf.triggerType, triggerConfigJson: safeJsonStringify(wf.triggerConfigJson), status: wf.status, stepsJson: safeJsonStringify(wf.stepsJson) } }
export function WorkflowFormModal({ open, editingWorkflow, companyId, onClose, onSaved }: { open: boolean; editingWorkflow: WorkflowPipelineRecord | null; companyId: string; onClose: () => void; onSaved: () => void }) {
const t = useT()
const isEditing = editingWorkflow !== null; const [form, setForm] = useState<WorkflowFormState>(defaultWorkflowForm); const [saving, setSaving] = useState(false); const [error, setError] = useState<string | null>(null)
useEffect(() => { if (open) { setForm(editingWorkflow ? workflowToFormState(editingWorkflow) : defaultWorkflowForm); setError(null) } }, [open, editingWorkflow])
const updateField = useCallback(<K extends keyof WorkflowFormState>(key: K, value: WorkflowFormState[K]) => { setForm((p) => ({ ...p, [key]: value })) }, [])
const handleSave = useCallback(async () => {
if (!form.name.trim()) { setError(t("automation.workflowEditor.errNameRequired")); return }
try { JSON.parse(form.triggerConfigJson) } catch { setError(t("automation.workflowEditor.errTriggerJson")); return }
try { JSON.parse(form.stepsJson) } catch { setError(t("automation.workflowEditor.errStepsJson")); return }
setSaving(true); setError(null)
try { const result = await window.agentCompany.saveWorkflow({ id: editingWorkflow?.id ?? null, companyId, name: form.name.trim(), description: form.description.trim(), triggerType: form.triggerType, triggerConfigJson: form.triggerConfigJson, status: form.status, stepsJson: form.stepsJson }); if (!result.ok) { setError(result.error.message); return }; onSaved(); onClose() } catch (err) { setError(err instanceof Error ? err.message : t("automation.workflowEditor.errSaveFailed")) } finally { setSaving(false) }
}, [form, editingWorkflow, companyId, onSaved, onClose, t])
const triggerOptions = Object.entries(triggerConfig).map(([key, c]) => ({ value: key, label: c.label }))
const statusOptions = [{ value: "draft", label: "Draft" }, { value: "active", label: "Active" }, { value: "paused", label: "Paused" }]
return (
<Modal open={open} onClose={onClose} title={isEditing ? t("automation.workflowEditor.editTitle") : t("automation.workflowEditor.newTitle")} width="540px">
<div className="space-y-5">
{error ? <div className="rounded-lg bg-destructive-muted px-3.5 py-2.5 text-[12px] font-medium text-destructive">{error}</div> : null}
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("automation.workflowEditor.nameLabel")}</label><input type="text" value={form.name} onChange={(e) => updateField("name", e.target.value)} placeholder={t("automation.workflowEditor.namePlaceholder")} 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" /></div>
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("automation.workflowEditor.descriptionLabel")}</label><textarea value={form.description} onChange={(e) => updateField("description", e.target.value)} placeholder={t("automation.workflowEditor.descriptionPlaceholder")} rows={2} 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" /></div>
<div className="grid grid-cols-2 gap-3"><div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("automation.ruleEditor.triggerLabel")}</label><Select value={form.triggerType} onChange={(v) => updateField("triggerType", v as AutomationTrigger)} options={triggerOptions} /></div><div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Status</label><Select value={form.status} onChange={(v) => updateField("status", v as WorkflowStatus)} options={statusOptions} /></div></div>
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("automation.workflowEditor.triggerLabel")}</label><textarea value={form.triggerConfigJson} onChange={(e) => updateField("triggerConfigJson", e.target.value)} rows={2} spellCheck={false} 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 font-mono text-[12px] text-foreground" placeholder="{}" /></div>
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("automation.workflowEditor.stepsLabel")}</label><textarea value={form.stepsJson} onChange={(e) => updateField("stepsJson", e.target.value)} rows={6} spellCheck={false} 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 font-mono text-[12px] text-foreground" placeholder='[{"id":"step-1","name":"Assign","action":"assign_task","configJson":"{}","assigneeDepartment":"engineering","assigneeAgentId":null,"requiresApproval":false,"timeoutSec":0,"status":"pending"}]' /></div>
</div>
<div className="mt-6 flex justify-end gap-3 border-t border-border pt-4">
<ActionButton label={t("automation.workflowEditor.cancel")} onClick={onClose} />
<ActionButton label={isEditing ? t("automation.workflowEditor.save") : t("automation.workflowEditor.create")} onClick={() => void handleSave()} icon={isEditing ? Gear : ArrowsClockwise} tone="accent" loading={saving} disabled={!form.name.trim()} />
</div>
</Modal>
)
}
@@ -1,58 +0,0 @@
import { useMemo, useState } from "react"
import { AnimatePresence, motion } from "framer-motion"
import { ArrowsClockwise, CaretRight, Gear, Lightning, Play, Trash } from "../ui/icons"
import type { WorkflowPipelineRecord, WorkflowStep } from "@shared/types"
import { timeAgo } from "../../lib/formatters"
import { triggerConfig, actionConfig, workflowStatusColors, stepStatusConfig, safeJsonParse } from "./automation-config"
function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
if (steps.length === 0) return <div className="py-4 text-center text-[11px] text-muted-foreground">No steps defined</div>
return (
<div className="relative pl-5">
<div className="absolute left-[9px] top-2 w-px" style={{ height: `calc(100% - 16px)`, backgroundColor: "var(--border)" }} />
<div className="space-y-3">
{steps.map((step, idx) => {
const colors = stepStatusConfig[step.status]; const info = actionConfig[step.action]
return (
<div key={step.id} className="relative flex items-start gap-3">
<div className="absolute -left-5 top-1.5 z-10 h-[10px] w-[10px] rounded-full border-2" style={{ backgroundColor: colors.bg, borderColor: colors.border }} />
<div className={`flex-1 rounded-lg border px-3 py-2.5 ${step.status === "running" ? "border-primary" : "border-border"}`}>
<div className="flex items-center justify-between"><div className="flex items-center gap-2"><span className="text-[11px] font-bold" style={{ color: colors.text }}>{idx + 1}</span><span className="text-[12px] font-medium text-foreground">{step.name}</span></div><span className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]" style={{ backgroundColor: colors.bg, color: colors.text }}>{step.status}</span></div>
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground"><span>{info?.label ?? step.action}</span>{step.assigneeDepartment ? <><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted-foreground)]" /><span>{step.assigneeDepartment}</span></> : null}{step.requiresApproval ? <><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted-foreground)]" /><span className="text-warning">Requires approval</span></> : null}{step.timeoutSec > 0 ? <><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted-foreground)]" /><span>{step.timeoutSec}s timeout</span></> : null}</div>
</div>
</div>
)
})}
</div>
</div>
)
}
function WorkflowCard({ workflow, isRunning, onDelete, onEdit, onRun }: { workflow: WorkflowPipelineRecord; isRunning: boolean; onDelete: () => void; onEdit: () => void; onRun: () => void }) {
const [expanded, setExpanded] = useState(false); const colors = workflowStatusColors[workflow.status]
const triggerInfo = triggerConfig[workflow.triggerType]; const TriggerIcon = triggerInfo?.icon ?? Lightning
const steps: WorkflowStep[] = useMemo(() => { const parsed = safeJsonParse(workflow.stepsJson); return Array.isArray(parsed) ? (parsed as WorkflowStep[]) : [] }, [workflow.stepsJson])
return (
<motion.div layout initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, x: 20 }} className="group rounded-lg border border-border bg-card transition hover:shadow-sm">
<div className="flex items-center gap-3 px-4 py-3.5">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"><TriggerIcon size={20} weight="fill" /></div>
<button type="button" onClick={() => setExpanded((p) => !p)} className="min-w-0 flex-1 text-left">
<div className="flex items-center gap-2"><span className="truncate text-[13px] font-semibold text-foreground">{workflow.name}</span><span className="flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]" style={{ backgroundColor: colors.bg, color: colors.text }}><span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: colors.dot }} />{workflow.status}</span></div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-muted-foreground"><span className="inline-flex items-center gap-1 rounded-[4px] bg-muted px-1.5 py-0.5 font-medium text-muted-foreground"><Lightning size={10} weight="fill" />{triggerInfo?.label ?? workflow.triggerType}</span><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted-foreground)]" /><span>{steps.length} step{steps.length !== 1 ? "s" : ""}</span><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted-foreground)]" /><span>{workflow.runCount} run{workflow.runCount !== 1 ? "s" : ""}</span>{workflow.lastRunAt ? <><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted-foreground)]" /><span>Last run {timeAgo(workflow.lastRunAt)}</span></> : null}</div>
</button>
<div className="flex shrink-0 items-center gap-1">
<motion.div animate={{ rotate: expanded ? 90 : 0 }} transition={{ duration: 0.15 }}><button type="button" onClick={() => setExpanded((p) => !p)} aria-label={expanded ? "Collapse" : "Expand"} className="rounded-lg p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground"><CaretRight size={14} /></button></motion.div>
<button type="button" onClick={onRun} aria-label="Run workflow" disabled={isRunning} className="rounded-lg p-2 text-muted-foreground transition hover:bg-success-muted hover:text-success disabled:pointer-events-none disabled:opacity-50">{isRunning ? <ArrowsClockwise size={15} className="animate-spin" /> : <Play size={15} />}</button>
<button type="button" onClick={onEdit} aria-label="Edit workflow" className="rounded-lg p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground"><Gear size={15} /></button>
<button type="button" onClick={onDelete} aria-label="Delete workflow" className="rounded-lg p-2 text-muted-foreground opacity-0 transition hover:bg-destructive-muted hover:text-destructive group-hover:opacity-100"><Trash size={15} /></button>
</div>
</div>
<AnimatePresence>
{expanded ? <motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }} className="overflow-hidden"><div className="border-t border-border px-4 py-4">{workflow.description ? <p className="mb-3 text-[12px] leading-relaxed text-muted-foreground">{workflow.description}</p> : null}<StepTimeline steps={steps} /></div></motion.div> : null}
</AnimatePresence>
</motion.div>
)
}
export { WorkflowCard, StepTimeline }
@@ -1,69 +0,0 @@
import type { AutomationAction, AutomationStatus, AutomationTrigger, WorkflowStatus, WorkflowStep } from "@shared/types"
import { ArrowsClockwise, Eye, Funnel, Lightning, Pause, Play, Plus, Robot, Timer, Trash } from "../ui/icons"
import { DEPARTMENT_OPTIONS } from "@shared/constants"
export const triggerConfig: Record<AutomationTrigger, { label: string; description: string; icon: typeof Lightning }> = {
task_status_changed: { label: "Task Status Changed", description: "When a task transitions to a new status", icon: ArrowsClockwise },
task_created: { label: "Task Created", description: "When a new task is added to the system", icon: Plus },
task_blocked: { label: "Task Blocked", description: "When a task becomes blocked by a dependency", icon: Pause },
task_reassigned: { label: "Task Reassigned", description: "When a task is reassigned to a different agent", icon: ArrowsClockwise },
run_completed: { label: "Run Completed", description: "When an agent run finishes successfully", icon: Play },
run_failed: { label: "Run Failed", description: "When an agent run encounters an error", icon: Trash },
approval_created: { label: "Approval Created", description: "When a new approval request needs review", icon: Eye },
approval_resolved: { label: "Approval Resolved", description: "When a pending approval is decided", icon: Eye },
budget_threshold: { label: "Budget Threshold", description: "When spending exceeds a configured limit", icon: Funnel },
agent_idle: { label: "Agent Idle", description: "When an agent has no tasks and becomes idle", icon: Robot },
comment_posted: { label: "Comment Posted", description: "When a new comment is added to a task", icon: Lightning },
document_created: { label: "Document Created", description: "When a new document is generated", icon: Lightning },
schedule: { label: "Scheduled", description: "Runs on a cron-like schedule automatically", icon: Timer },
hire_approved: { label: "Hire Approved", description: "When a new agent hire is approved", icon: Robot },
sprint_started: { label: "Sprint Started", description: "When a sprint transitions to active", icon: Play },
sprint_ended: { label: "Sprint Ended", description: "When a sprint reaches completion", icon: ArrowsClockwise },
}
export const actionConfig: Record<AutomationAction, { label: string; description: string }> = {
assign_task: { label: "Assign Task", description: "Auto-assign a task to the best available agent" },
create_task: { label: "Create Task", description: "Generate a new task from a template" },
notify_agent: { label: "Notify Agent", description: "Send a notification to a specific agent" },
send_message: { label: "Send Message", description: "Deliver a message via inter-agent messaging" },
trigger_heartbeat: { label: "Trigger Heartbeat", description: "Wake an agent and trigger their heartbeat loop" },
create_approval: { label: "Create Approval", description: "Request human approval before proceeding" },
update_task_status: { label: "Update Task Status", description: "Move a task to a new status column" },
escalate_to_manager: { label: "Escalate to Manager", description: "Route to the responsible manager for review" },
cross_department_notify: { label: "Cross-Dept Notify", description: "Alert another department about this event" },
schedule_meeting: { label: "Schedule Meeting", description: "Create a meeting with relevant participants" },
create_document: { label: "Create Document", description: "Generate a document from a template" },
auto_approve: { label: "Auto Approve", description: "Automatically approve matching requests" },
reassign_task: { label: "Reassign Task", description: "Move task to a different agent" },
run_task: { label: "Run Task", description: "Immediately execute the task with an agent" },
}
export const departmentOptions = DEPARTMENT_OPTIONS
export const statusColors: Record<AutomationStatus, { bg: string; text: string; dot: string }> = {
active: { bg: "var(--muted-foreground)", text: "var(--primary)", dot: "var(--primary)" },
paused: { bg: "var(--muted-foreground)", text: "var(--destructive)", dot: "var(--destructive)" },
disabled: { bg: "var(--muted-foreground)", text: "var(--muted-foreground)", dot: "var(--muted-foreground)" },
error: { bg: "var(--destructive)", text: "var(--destructive)", dot: "var(--destructive)" },
}
export const workflowStatusColors: Record<WorkflowStatus, { bg: string; text: string; dot: string }> = {
draft: { bg: "var(--muted-foreground)", text: "var(--muted-foreground)", dot: "var(--muted-foreground)" },
active: { bg: "var(--muted-foreground)", text: "var(--primary)", dot: "var(--primary)" },
paused: { bg: "var(--muted-foreground)", text: "var(--destructive)", dot: "var(--destructive)" },
completed: { bg: "var(--muted-foreground)", text: "var(--primary)", dot: "var(--primary)" },
failed: { bg: "var(--destructive)", text: "var(--destructive)", dot: "var(--destructive)" },
}
export const stepStatusConfig: Record<WorkflowStep["status"], { bg: string; border: string; text: string }> = {
pending: { bg: "var(--muted-foreground)", border: "var(--border)", text: "var(--muted-foreground)" },
running: { bg: "var(--primary)", border: "var(--primary)", text: "var(--primary)" },
completed: { bg: "var(--muted-foreground)", border: "var(--primary)", text: "var(--primary)" },
failed: { bg: "var(--destructive)", border: "var(--destructive)", text: "var(--destructive)" },
skipped: { bg: "var(--muted-foreground)", border: "var(--border)", text: "var(--muted-foreground)" },
}
export interface AutomationLogEntry { ruleId: string; ruleName: string; trigger: string; action: string; executedAt: string }
export function safeJsonParse(json: string): unknown { try { return JSON.parse(json) } catch { return {} } }
export function safeJsonStringify(value: string): string { try { const parsed = JSON.parse(value); return JSON.stringify(parsed, null, 2) } catch { return value } }
@@ -1,80 +0,0 @@
import type { AgentMessageRecord, CommentRecord, TaskRecord, MessageChannel } from "@shared/types"
import { CaretDown } from "../ui/icons"
import { useT } from "../../i18n"
export interface ChannelDescriptor {
key: string
kind: "message"
channel: MessageChannel
label: string
targetId: string | null
agentId: string | null
participantIds: string[]
}
export interface TaskDiscussionDescriptor {
key: string
kind: "task"
taskId: string
label: string
}
export type AnyChannel = ChannelDescriptor | TaskDiscussionDescriptor
interface ChannelHeaderProps {
activeChannel: AnyChannel | null
showSidebar: boolean
onShowSidebar: () => void
channelMessages: AgentMessageRecord[]
taskDiscussionComments: CommentRecord[]
activeTask: TaskRecord | null
unreadByChannel: Map<string, { unread: number; hasUrgent: boolean }>
onNavigateToTask: (taskId: string) => void
filterBar: React.ReactNode
}
export function ChannelHeader({
activeChannel,
showSidebar,
onShowSidebar,
channelMessages: _channelMessages,
taskDiscussionComments: _taskDiscussionComments,
activeTask,
unreadByChannel: _unreadByChannel,
onNavigateToTask,
filterBar,
}: ChannelHeaderProps) {
const t = useT()
return (
<div className="flex items-center gap-3 border-b border-border bg-background px-4 py-2.5">
{!showSidebar ? (
<button
type="button"
onClick={onShowSidebar}
className="rounded-[4px] p-1 text-muted-foreground transition hover:text-foreground"
aria-label={t("comm.showSidebar")}
>
<CaretDown size={12} className="rotate-90" />
</button>
) : null}
{activeChannel ? (
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-semibold text-foreground">{activeChannel.label}</div>
{activeChannel.kind === "task" && activeTask ? (
<button type="button" onClick={() => onNavigateToTask(activeTask.id)} className="mt-0.5 text-[11px] font-medium text-primary transition hover:underline">
{t("comm.openTask")}
</button>
) : null}
</div>
) : (
<span className="text-[13px] text-muted-foreground">{t("comm.selectChannel")}</span>
)}
<div className="ml-auto w-full max-w-[320px]">
{filterBar}
</div>
</div>
)
}
@@ -1,252 +0,0 @@
import { useState } from "react"
import { AnimatePresence, motion } from "framer-motion"
import { EnvelopeSimple, Hash, Buildings, Megaphone, Fire, MagnifyingGlass, Funnel } from "../ui/icons"
import type { AgentRecord, MessageChannel } from "@shared/types"
import { AgentAvatar } from "./MessageItem"
import type { ChannelDescriptor, TaskDiscussionDescriptor } from "./ChannelHeader"
import type { MessageFilters } from "./message-filters"
import { DEFAULT_MESSAGE_FILTERS, hasActiveMessageFilters } from "./message-filters"
import { useT } from "../../i18n"
import { Eyebrow, Select } from "../ui"
type ChannelConfig = { labelKey: string; icon: React.ElementType }
const channelConfig: Record<MessageChannel, ChannelConfig> = {
direct: { labelKey: "comm.channelLabel.direct", icon: EnvelopeSimple },
department: { labelKey: "comm.channelLabel.department", icon: Buildings },
company: { labelKey: "comm.channelLabel.company", icon: Megaphone },
project: { labelKey: "comm.channelLabel.project", icon: Hash },
incident: { labelKey: "comm.channelLabel.incident", icon: Fire },
}
function ChannelItem({
descriptor,
active,
unreadCount,
hasUrgent,
agents,
onClick,
}: {
descriptor: ChannelDescriptor
active: boolean
unreadCount: number
hasUrgent: boolean
agents: AgentRecord[]
onClick: () => void
}) {
const config = channelConfig[descriptor.channel]
const Icon = config.icon
const agent = descriptor.agentId ? agents.find((a) => a.id === descriptor.agentId) : undefined
return (
<button
type="button"
onClick={onClick}
aria-current={active ? "page" : undefined}
className={`group relative flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left transition duration-150 ${
active ? "bg-accent" : "hover:bg-muted/50"
}`}
>
{descriptor.channel === "direct" && agent ? (
<AgentAvatar agent={agent} size="sm" />
) : (
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-muted">
<Icon size={14} weight={active ? "fill" : "regular"} className="text-muted-foreground" />
</div>
)}
<span
className={`min-w-0 flex-1 truncate text-[13px] ${
active
? "font-semibold text-foreground"
: unreadCount > 0
? "font-semibold text-foreground"
: "font-medium text-muted-foreground"
}`}
>
{descriptor.label}
</span>
{hasUrgent ? (
<span className="h-2 w-2 shrink-0 rounded-full bg-destructive" />
) : unreadCount > 0 ? (
<span className="flex h-[18px] min-w-[18px] shrink-0 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold tabular-nums text-primary-foreground">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
) : null}
</button>
)
}
interface ChannelSidebarProps {
channelGroups: Array<{ type: MessageChannel; label: string; items: ChannelDescriptor[] }>
taskDiscussionChannels: TaskDiscussionDescriptor[]
selectedChannelKey: string | null
onSelectChannel: (key: string) => void
unreadByChannel: Map<string, { unread: number; hasUrgent: boolean }>
agents: AgentRecord[]
totalChannels: number
onHideSidebar: () => void
}
export function ChannelSidebar({
channelGroups,
taskDiscussionChannels,
selectedChannelKey,
onSelectChannel,
unreadByChannel,
agents,
totalChannels,
onHideSidebar: _onHideSidebar,
}: ChannelSidebarProps) {
const t = useT()
return (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: 260, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="flex h-full shrink-0 flex-col overflow-hidden border-r border-border bg-background"
style={{ width: 260 }}
>
<div className="flex-1 overflow-y-auto px-2 py-3">
{channelGroups.map((group) => (
<div key={group.type} className="mb-3">
<Eyebrow className="mb-1 px-2">{group.label}</Eyebrow>
{group.items.map((ch) => {
const stats = unreadByChannel.get(ch.key)
return (
<ChannelItem
key={ch.key}
descriptor={ch}
active={selectedChannelKey === ch.key}
unreadCount={stats?.unread ?? 0}
hasUrgent={stats?.hasUrgent ?? false}
agents={agents}
onClick={() => onSelectChannel(ch.key)}
/>
)
})}
</div>
))}
{taskDiscussionChannels.length > 0 ? (
<div className="mb-3">
<Eyebrow className="mb-1 px-2">{t("comm.taskDiscussions")}</Eyebrow>
{taskDiscussionChannels.map((discussion) => (
<button
key={discussion.key}
type="button"
onClick={() => onSelectChannel(discussion.key)}
className={`group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left transition duration-150 ${
selectedChannelKey === discussion.key ? "bg-accent" : "hover:bg-muted/50"
}`}
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-muted">
<Hash size={14} weight={selectedChannelKey === discussion.key ? "fill" : "regular"} className="text-muted-foreground" />
</div>
<span className={`min-w-0 flex-1 truncate text-[13px] ${selectedChannelKey === discussion.key ? "font-semibold text-foreground" : "font-medium text-muted-foreground"}`}>
{discussion.label}
</span>
</button>
))}
</div>
) : null}
{totalChannels === 0 ? (
<div className="px-3 py-6 text-center text-[11px] text-muted-foreground">
{t("comm.noActiveDepartments")}
</div>
) : null}
</div>
<div className="border-t border-border px-4 py-2">
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>{t("comm.channelsTotal").replace("{count}", String(totalChannels))}</span>
<span>{t("comm.agentsTotal").replace("{count}", String(agents.length))}</span>
</div>
</div>
</motion.div>
)
}
export type FilterState = MessageFilters
export function FilterBar({
filters,
onChangeFilters,
scopeToggle,
onSearchKeyDown,
}: {
filters: FilterState
onChangeFilters: (next: FilterState) => void
scopeToggle?: React.ReactNode
onSearchKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
}) {
const t = useT()
const [expanded, setExpanded] = useState(false)
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="relative min-w-0 flex-1">
<MagnifyingGlass size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
value={filters.search}
onChange={(e) => onChangeFilters({ ...filters, search: e.target.value })}
onKeyDown={onSearchKeyDown}
placeholder={t("comm.searchMessages")}
className="w-full rounded-lg border border-border bg-card py-1.5 pl-8 pr-3 text-[11px] text-foreground placeholder:text-muted-foreground focus:border-[color:var(--primary)] focus:outline-none focus:ring-1 focus:ring-[color:var(--primary)]"
/>
</div>
{scopeToggle}
<button
type="button"
onClick={() => setExpanded(!expanded)}
className={`flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-[11px] font-medium transition ${
expanded || hasActiveMessageFilters(filters)
? "border-transparent bg-primary text-primary-foreground"
: "border-border text-muted-foreground hover:border-border"
}`}
>
<Funnel size={12} />
{t("comm.filters")}
</button>
</div>
<AnimatePresence>
{expanded ? (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="flex flex-wrap items-center gap-2 overflow-hidden"
>
<Select
compact
value={filters.priority}
onChange={(v) => onChangeFilters({ ...filters, priority: v })}
options={[
{ value: "all", label: t("comm.allPriorities") },
{ value: "urgent", label: t("comm.urgent") },
{ value: "normal", label: t("comm.normal") },
{ value: "low", label: t("comm.low") },
]}
/>
{filters.priority !== "all" ? (
<button
type="button"
onClick={() => onChangeFilters({ ...DEFAULT_MESSAGE_FILTERS, search: filters.search })}
className="text-[11px] font-medium text-muted-foreground underline transition hover:text-foreground"
>
{t("comm.clearFilters")}
</button>
) : null}
</motion.div>
) : null}
</AnimatePresence>
</div>
)
}
@@ -1,327 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { AnimatePresence, motion } from "framer-motion"
import type { AgentMessageRecord, AgentRecord, CommentRecord, DocumentRecord, MessageChannel, ProjectRecord, TaskRecord } from "@shared/types"
import { useAppStore } from "../../state/store"
import { unwrap } from "../../lib/desktop"
import { ChannelSidebar, FilterBar, type FilterState } from "./ChannelSidebar"
import type { ChannelDescriptor, AnyChannel } from "./ChannelHeader"
import { ChannelHeader } from "./ChannelHeader"
import { MessageList } from "./MessageList"
import { MessageComposer, TaskDiscussionComposer, NoChannelComposer } from "./MessageComposer"
import { SearchResults } from "./SearchResults"
import ThreadPanel from "./ThreadPanel"
import { deriveChannels, deriveTaskDiscussionChannels, getChannelMessages } from "./channel-utils"
import { DEFAULT_MESSAGE_FILTERS, filterChannelMessages } from "./message-filters"
import { useT, type TranslationFn } from "../../i18n"
interface CommunicationHubProps {
messages: AgentMessageRecord[]; comments: CommentRecord[]; tasks: TaskRecord[]; agents: AgentRecord[]
projects?: ProjectRecord[]; documents?: DocumentRecord[]; companyId: string; selectedEntityId?: string | null
onEntityConsumed?: () => void; onNavigateToTask: (taskId: string) => void; onRefresh: () => void
}
function SearchScopeToggle({ scope, onChangeScope, t }: { scope: "global" | "channel"; onChangeScope: (scope: "global" | "channel") => void; t: TranslationFn }) {
return (
<div
className="inline-flex items-center gap-0.5 rounded-full bg-card p-0.5"
role="radiogroup"
aria-label={t("comm.searchScope")}
>
<button
type="button"
role="radio"
aria-checked={scope === "global"}
onClick={() => onChangeScope("global")}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onChangeScope("global") } }}
className={`rounded-full px-2.5 py-1.5 text-[11px] font-semibold transition-colors duration-150 ${
scope === "global"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{t("comm.allChannels")}
</button>
<button
type="button"
role="radio"
aria-checked={scope === "channel"}
onClick={() => onChangeScope("channel")}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onChangeScope("channel") } }}
className={`rounded-full px-2.5 py-1.5 text-[11px] font-semibold transition-colors duration-150 ${
scope === "channel"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{t("comm.thisChannel")}
</button>
</div>
)
}
export function CommunicationHub({ messages, comments, tasks, agents, projects, documents, companyId, selectedEntityId, onEntityConsumed, onNavigateToTask, onRefresh }: CommunicationHubProps) {
const t = useT()
const [selectedChannelKey, setSelectedChannelKey] = useState<string | null>(null)
const [showSidebar, setShowSidebar] = useState(true)
const [replyTo, setReplyTo] = useState<AgentMessageRecord | null>(null)
const [optimisticReadIds, setOptimisticReadIds] = useState<Set<string>>(new Set())
const pendingReadIdsRef = useRef<Set<string>>(new Set())
const [filters, setFilters] = useState<FilterState>(DEFAULT_MESSAGE_FILTERS)
// Thread panel state from Zustand store
const activeThreadId = useAppStore((s) => s.activeThreadId)
const setActiveThreadId = useAppStore((s) => s.setActiveThreadId)
// Search state from Zustand store
const searchQuery = useAppStore((s) => s.searchQuery)
const searchScope = useAppStore((s) => s.searchScope)
const searchResults = useAppStore((s) => s.searchResults)
const searchLoading = useAppStore((s) => s.searchLoading)
const searchRequestId = useAppStore((s) => s.searchRequestId)
const setSearchQuery = useAppStore((s) => s.setSearchQuery)
const setSearchScope = useAppStore((s) => s.setSearchScope)
const setSearchResults = useAppStore((s) => s.setSearchResults)
const setSearchLoading = useAppStore((s) => s.setSearchLoading)
const clearSearch = useAppStore((s) => s.clearSearch)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents])
const effectiveMessages = useMemo(() => messages.map((m) => optimisticReadIds.has(m.id) ? { ...m, readAt: m.readAt ?? new Date().toISOString() } : m), [messages, optimisticReadIds])
useEffect(() => { setOptimisticReadIds((cur) => { if (cur.size === 0) return cur; const next = new Set(Array.from(cur).filter((id) => effectiveMessages.some((m) => m.id === id))); return next.size === cur.size ? cur : next }) }, [effectiveMessages])
const messageChannels = useMemo(() => deriveChannels(effectiveMessages, agents, projects), [effectiveMessages, agents, projects])
const forcedTaskId = useMemo(() => (selectedEntityId && tasks.some((t) => t.id === selectedEntityId) ? selectedEntityId : null), [selectedEntityId, tasks])
const taskDiscussionChannels = useMemo(() => deriveTaskDiscussionChannels(comments, tasks, forcedTaskId), [comments, forcedTaskId, tasks])
const channels: AnyChannel[] = useMemo(() => [...messageChannels, ...taskDiscussionChannels], [messageChannels, taskDiscussionChannels])
const handleSelectChannel = useCallback((key: string) => {
setSelectedChannelKey(key)
setReplyTo(null)
setFilters(DEFAULT_MESSAGE_FILTERS)
clearSearch()
}, [clearSearch])
useEffect(() => { if (channels.length === 0) { if (selectedChannelKey) setSelectedChannelKey(null); return }; if (!selectedChannelKey || !channels.some((c) => c.key === selectedChannelKey)) handleSelectChannel(channels[0].key) }, [channels, selectedChannelKey, handleSelectChannel])
useEffect(() => {
if (!selectedEntityId) return
const matchMsg = effectiveMessages.find((m) => m.id === selectedEntityId)
if (matchMsg) {
const ch = messageChannels.find((ch) => getChannelMessages(effectiveMessages, ch).some((m) => m.id === selectedEntityId))
if (ch) handleSelectChannel(ch.key)
onEntityConsumed?.()
return
}
const matchCom = comments.find((c) => c.id === selectedEntityId)
if (matchCom) {
handleSelectChannel(`task-${matchCom.taskId}`)
onEntityConsumed?.()
return
}
const matchTask = taskDiscussionChannels.find((d) => d.taskId === selectedEntityId)
if (matchTask) {
handleSelectChannel(matchTask.key)
onEntityConsumed?.()
return
}
// Entity id is stale or points outside this view (e.g. the message
// was deleted between navigation and render). Consume it anyway so
// the parent store clears the pointer — leaving it set would make
// the effect refire on every snapshot and block the user from
// navigating manually inside the communication hub.
onEntityConsumed?.()
}, [selectedEntityId, effectiveMessages, comments, messageChannels, onEntityConsumed, taskDiscussionChannels, handleSelectChannel])
const activeChannel = useMemo(() => channels.find((c) => c.key === selectedChannelKey) ?? null, [channels, selectedChannelKey])
const channelMessages = useMemo(() => { if (!activeChannel || activeChannel.kind !== "message") return []; return getChannelMessages(effectiveMessages, activeChannel).sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) }, [effectiveMessages, activeChannel])
const taskComments = useMemo(() => { if (!activeChannel || activeChannel.kind !== "task") return []; return comments.filter((c) => c.taskId === activeChannel.taskId).sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) }, [comments, activeChannel])
const activeTask = useMemo(() => { if (!activeChannel || activeChannel.kind !== "task") return null; return tasks.find((t) => t.id === activeChannel.taskId) ?? null }, [tasks, activeChannel])
const filteredMessages = useMemo(() => filterChannelMessages(channelMessages, filters, agentMap, searchQuery), [channelMessages, filters, agentMap, searchQuery])
// Debounced search via IPC
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
const query = filters.search
if (!query || query.length < 1) {
if (searchQuery) clearSearch()
return
}
setSearchQuery(query)
setSearchLoading(true)
const currentRequestId = searchRequestId + 1
debounceRef.current = setTimeout(async () => {
try {
const channelParam = searchScope === "channel" && activeChannel?.kind === "message"
? (activeChannel as ChannelDescriptor).channel
: undefined
const channelTargetParam = searchScope === "channel" && activeChannel?.kind === "message"
? (activeChannel as ChannelDescriptor).targetId
: undefined
const result = await window.agentCompany.searchMessages({
companyId,
query,
channel: channelParam,
channelTargetId: channelTargetParam,
limit: 50,
})
if (result.ok) {
setSearchResults(result.data, currentRequestId)
} else {
setSearchResults([], currentRequestId)
}
} catch (error) {
console.warn(
`[communication] searchMessages failed: ${error instanceof Error ? error.message : String(error)}`,
)
setSearchResults([], currentRequestId)
}
}, 300)
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters.search, searchScope, activeChannel, companyId])
// Navigate to the channel containing a search result
const handleNavigateToResult = useCallback((message: AgentMessageRecord) => {
// Find which channel this message belongs to
const matchingChannel = messageChannels.find((ch) => {
const channelMsgs = getChannelMessages(effectiveMessages, ch)
return channelMsgs.some((m) => m.id === message.id)
})
if (matchingChannel) {
handleSelectChannel(matchingChannel.key)
}
// Clear search to return to normal view
setFilters((prev) => ({ ...prev, search: "" }))
clearSearch()
}, [messageChannels, effectiveMessages, handleSelectChannel, clearSearch])
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
e.preventDefault()
setFilters((prev) => ({ ...prev, search: "" }))
clearSearch()
}
}, [clearSearch])
const isSearchActive = filters.search.length >= 1
useEffect(() => {
if (!activeChannel || activeChannel.kind !== "message") return
// Search no longer blocks read marking — the original guard existed
// to keep unread badges stable while the user was scanning the
// results list, but the search panel renders inside the message
// list pane (isSearchActive swaps the rendered list) so messages
// shown through search are the same messages the user is actually
// viewing. Blocking reads here left unreads stuck until the user
// cleared search and re-opened the channel.
const ids = filteredMessages.filter((m) => !m.readAt && !pendingReadIdsRef.current.has(m.id)).map((m) => m.id)
if (ids.length === 0) return
let cancelled = false
void (async () => {
for (const id of ids) pendingReadIdsRef.current.add(id)
setOptimisticReadIds((c) => { const n = new Set(c); for (const id of ids) n.add(id); return n })
const results = await Promise.all(ids.map(async (id) => { try { unwrap(await window.agentCompany.markMessageRead({ messageId: id, companyId })); return { id, ok: true as const } } catch (error) { console.warn(`[communication] markMessageRead(${id}) failed: ${error instanceof Error ? error.message : String(error)}`); return { id, ok: false as const } } }))
const failed = results.filter((r) => !r.ok).map((r) => r.id)
for (const id of ids) pendingReadIdsRef.current.delete(id)
if (!cancelled && failed.length > 0) setOptimisticReadIds((c) => { const n = new Set(c); for (const id of failed) n.delete(id); return n })
})()
return () => { cancelled = true }
}, [activeChannel, companyId, filteredMessages])
const unreadByChannel = useMemo(() => {
const counts = new Map<string, { unread: number; hasUrgent: boolean }>()
for (const ch of channels) { if (ch.kind !== "message") { counts.set(ch.key, { unread: 0, hasUrgent: false }); continue }; const msgs = getChannelMessages(effectiveMessages, ch); counts.set(ch.key, { unread: msgs.filter((m) => !m.readAt).length, hasUrgent: msgs.some((m) => !m.readAt && m.priority === "urgent") }) }
return counts
}, [channels, effectiveMessages])
const channelGroups = useMemo(() => {
const groups: Array<{ type: MessageChannel; label: string; items: ChannelDescriptor[] }> = []
const cfgs: Record<MessageChannel, string> = {
company: t("comm.channelGroup.company"),
department: t("comm.channelGroup.department"),
project: t("comm.channelGroup.project"),
incident: t("comm.channelGroup.incident"),
direct: t("comm.channelGroup.direct"),
}
for (const type of (["company", "department", "project", "incident", "direct"] as MessageChannel[])) {
const items = channels.filter((c): c is ChannelDescriptor => c.kind === "message" && c.channel === type)
if (items.length > 0) groups.push({ type, label: cfgs[type], items })
}
return groups
}, [channels, t])
const scopeToggleElement = isSearchActive ? (
<SearchScopeToggle scope={searchScope} onChangeScope={setSearchScope} t={t} />
) : null
return (
<div className="flex h-full min-h-0 overflow-hidden">
<AnimatePresence initial={false}>
{showSidebar ? <ChannelSidebar channelGroups={channelGroups} taskDiscussionChannels={taskDiscussionChannels} selectedChannelKey={selectedChannelKey} onSelectChannel={handleSelectChannel} unreadByChannel={unreadByChannel} agents={agents} totalChannels={channels.length} onHideSidebar={() => setShowSidebar(false)} /> : null}
</AnimatePresence>
<div className="flex min-w-0 flex-1 flex-col">
<ChannelHeader activeChannel={activeChannel} showSidebar={showSidebar} onShowSidebar={() => setShowSidebar(true)} channelMessages={channelMessages} taskDiscussionComments={taskComments} activeTask={activeTask} unreadByChannel={unreadByChannel} onNavigateToTask={onNavigateToTask} filterBar={<FilterBar filters={filters} onChangeFilters={setFilters} scopeToggle={scopeToggleElement} onSearchKeyDown={handleSearchKeyDown} />} />
<div className="flex-1 overflow-y-auto px-2 py-3">
<AnimatePresence mode="wait">
{isSearchActive ? (
<motion.div
key="search-results"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, ease: "easeInOut" }}
className="h-full"
>
<SearchResults
results={searchResults}
query={searchQuery}
loading={searchLoading}
agents={agents}
onNavigateToResult={handleNavigateToResult}
/>
</motion.div>
) : (
<motion.div
key="message-list"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, ease: "easeInOut" }}
className="h-full"
>
<MessageList activeChannel={activeChannel} filteredMessages={filteredMessages} taskDiscussionComments={taskComments} activeTask={activeTask} agentMap={agentMap} filters={filters} onReply={setReplyTo} messageCount={effectiveMessages.length} selectedChannelKey={selectedChannelKey} />
</motion.div>
)}
</AnimatePresence>
</div>
{activeChannel ? (activeChannel.kind === "message" ? <MessageComposer agents={agents} companyId={companyId} channel={activeChannel.channel} channelTargetId={activeChannel.channel === "direct" ? null : activeChannel.targetId} directParticipantIds={activeChannel.channel === "direct" ? activeChannel.participantIds : []} replyTo={replyTo} onSent={() => { setReplyTo(null); onRefresh() }} onCancelReply={() => setReplyTo(null)} documents={documents} /> : <TaskDiscussionComposer companyId={companyId} taskId={activeChannel.taskId} onSent={onRefresh} />) : <NoChannelComposer />}
</div>
<AnimatePresence>
{activeThreadId && (
<ThreadPanel
threadId={activeThreadId}
companyId={companyId}
agents={agents}
documents={documents ?? []}
onClose={() => setActiveThreadId(null)}
/>
)}
</AnimatePresence>
</div>
)
}
@@ -1,161 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
AgentMessageRecord,
AgentRecord,
DocumentRecord,
MessageChannel,
} from "@shared/types";
import { cmdOrCtrlLabel } from "../../lib/platform";
import { InlineNotice } from "../ui";
import { useT } from "../../i18n";
import { ComposerShell } from "./composer-shell/ComposerShell";
import {
AttachmentChips,
ComposerAttachments,
type AttachmentItem,
} from "./composer/ComposerAttachments";
import { ComposerMentionDropdown } from "./composer/ComposerMentionDropdown";
import { ComposerReplyBanner } from "./composer/ComposerReplyBanner";
import { ComposerSubjectRow } from "./composer/ComposerSubjectRow";
import { useMentionState } from "./composer/useMentionState";
import { useMessageSend } from "./composer/useMessageSend";
export { TaskDiscussionComposer } from "./composer/TaskDiscussionComposer";
export { NoChannelComposer } from "./composer/NoChannelComposer";
const HUMAN_SENDER_ID = "__board__";
interface MessageComposerProps {
agents: AgentRecord[];
companyId: string;
channel: MessageChannel;
channelTargetId: string | null;
directParticipantIds?: string[];
replyTo: AgentMessageRecord | null;
onSent: () => void;
onCancelReply: () => void;
documents?: DocumentRecord[];
/** Thread ID for precise checkpoint resolution. */
threadId?: string | null;
/** Wait state ID for precise checkpoint resolution. */
waitStateId?: string | null;
}
export function MessageComposer({
agents,
companyId,
channel,
channelTargetId,
directParticipantIds = [],
replyTo,
onSent,
onCancelReply,
documents = [],
threadId = null,
waitStateId = null,
}: MessageComposerProps) {
const t = useT();
const [body, setBody] = useState("");
const [subject, setSubject] = useState("");
const [attachments, setAttachments] = useState<AttachmentItem[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (replyTo) textareaRef.current?.focus();
}, [replyTo]);
const directRecipientId = useMemo(() => {
if (channel !== "direct") return null;
if (replyTo?.fromAgentId) return replyTo.fromAgentId;
if (directParticipantIds.length === 0) return null;
const nonBoard = directParticipantIds.find((id) => id !== HUMAN_SENDER_ID);
return nonBoard ?? directParticipantIds[0] ?? null;
}, [channel, replyTo, directParticipantIds]);
const onCleared = useCallback(() => {
setBody("");
setSubject("");
setAttachments([]);
onSent();
}, [onSent]);
const { send, sending, sendError } = useMessageSend({
body, subject, attachments, channel, channelTargetId, companyId,
directRecipientId, replyTo, threadId, waitStateId, onCleared,
});
const { mentionState, detect, selectAgent } = useMentionState({
setBody,
focusTextarea: () => textareaRef.current?.focus(),
});
const handleBodyChange = useCallback(
(value: string, e: React.ChangeEvent<HTMLTextAreaElement>) => {
setBody(value);
detect(value, e.target);
},
[detect],
);
const addAttachment = useCallback((doc: DocumentRecord) => {
setAttachments((prev) => [...prev, { type: "document", id: doc.id, title: doc.title }]);
}, []);
const removeAttachment = useCallback((id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id));
}, []);
return (
<div className="relative border-t border-border bg-card px-4 py-3">
{channel === "direct" && !directRecipientId ? (
<div className="mb-2">
<InlineNotice message={t("composer.directMissingNotice")} />
</div>
) : null}
<ComposerShell
ref={textareaRef}
value={body}
onChange={handleBodyChange}
onSend={() => void send()}
placeholder={t("composer.typeMessage")}
sending={sending}
error={sendError}
shortcutHint={t("comm.sendShortcut").replace("{0}", cmdOrCtrlLabel())}
sendLabel={t("composer.sendMessage")}
sendDisabled={!body.trim() || (channel === "direct" && !directRecipientId)}
leading={
<ComposerAttachments
documents={documents}
attachments={attachments}
onAdd={addAttachment}
onRemove={removeAttachment}
/>
}
belowHeader={
<>
<ComposerReplyBanner replyTo={replyTo} agents={agents} onCancel={onCancelReply} />
<ComposerSubjectRow
subject={subject}
onSubjectChange={setSubject}
showSubjectInput={!replyTo}
/>
<AttachmentChips attachments={attachments} onRemove={removeAttachment} />
</>
}
onKeyDownExtra={() => mentionState.active}
textareaAriaLabel={t("composer.messageBody")}
/>
{mentionState.active && textareaRef.current ? (
<ComposerMentionDropdown
agents={agents}
query={mentionState.query}
onSelect={selectAgent}
position={{
top: textareaRef.current.offsetTop + mentionState.caretTop,
left: textareaRef.current.offsetLeft + mentionState.caretLeft,
}}
/>
) : null}
</div>
);
}
@@ -1,208 +0,0 @@
import { memo } from "react"
import { motion } from "framer-motion"
import {
ChatCircle,
At,
Circle,
WarningCircle,
ArrowBendUpLeft,
UserCircle,
} from "../ui/icons"
import type { AgentMessageRecord, AgentRecord, CommentRecord, DepartmentId } from "@shared/types"
import { getAgentInitials, formatMessageTime } from "../../lib/formatters"
import { safeJson } from "../../lib/safe-json"
import { DEPARTMENT_COLORS } from "@shared/constants"
import { useT, type TranslationFn } from "../../i18n"
import { MarkdownBody } from "../ui/MarkdownBody"
const HUMAN_SENDER_ID = "__board__";
function priorityConfig(t: TranslationFn): Record<string, { label: string; className: string }> {
return {
urgent: { label: t("msg.priority.urgent"), className: "bg-destructive-muted text-destructive" },
normal: { label: t("msg.priority.normal"), className: "bg-muted text-muted-foreground" },
low: { label: t("msg.priority.low"), className: "bg-muted text-muted-foreground" },
}
}
function getDepartmentColor(department: DepartmentId | null | undefined): string {
if (!department) return "var(--muted-foreground)"
return DEPARTMENT_COLORS[department] ?? "var(--muted-foreground)"
}
export const AgentAvatar = memo(function AgentAvatar({
agent,
size = "md",
}: {
agent: AgentRecord | undefined
size?: "sm" | "md" | "lg"
}) {
const sizeClass = size === "sm" ? "h-7 w-7 text-[10px]" : size === "lg" ? "h-11 w-11 text-[14px]" : "h-9 w-9 text-[11px]"
const color = agent ? getDepartmentColor(agent.department) : "var(--muted-foreground)"
const initials = agent ? getAgentInitials(agent.name) : "?"
return (
<div
className={`flex shrink-0 items-center justify-center rounded-full font-bold ${sizeClass}`}
style={{ backgroundColor: `color-mix(in oklab, ${color}, transparent 91%)`, color }}
>
{initials}
</div>
)
})
export const MessageBubble = memo(function MessageBubble({
message,
agent,
isThread,
replyCount,
onReply,
}: {
message: AgentMessageRecord
agent: AgentRecord | undefined
isThread: boolean
replyCount: number
onReply: () => void
}) {
const t = useT()
const priorityMap = priorityConfig(t)
const isUnread = !message.readAt
const isHuman = message.fromAgentId === HUMAN_SENDER_ID
const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted-foreground)"
const senderColor = isHuman ? "var(--primary)" : deptColor
const senderName = isHuman ? t("msg.you") : (agent?.name ?? t("msg.unknownAgent"))
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className={`group relative flex gap-3 rounded-lg px-4 py-3 transition ${
isThread ? "ml-11" : ""
} ${
isUnread
? "bg-card"
: "hover:bg-muted/40"
}`}
>
{isHuman
? <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary"><UserCircle size={20} weight="fill" /></div>
: <AgentAvatar agent={agent} />}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-[13px] font-semibold" style={{ color: senderColor }}>
{senderName}
</span>
{agent?.title ? (
<span className="truncate text-[11px] text-muted-foreground">{agent.title}</span>
) : null}
<span className="whitespace-nowrap shrink-0 text-[11px] text-muted-foreground">
{formatMessageTime(message.createdAt)}
</span>
{message.priority !== "normal" ? (
<span
className={`flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-[0.04em] ${priorityMap[message.priority]?.className ?? ""}`}
>
{message.priority === "urgent" ? <WarningCircle size={10} weight="fill" /> : null}
{priorityMap[message.priority]?.label}
</span>
) : null}
{isUnread ? (
<Circle size={8} weight="fill" className="shrink-0" style={{ color: "var(--primary)" }} aria-label={t("msg.unread")} />
) : null}
</div>
{message.subject ? (
<div className="mt-0.5 break-words text-[13px] font-medium text-foreground">
{message.subject}
</div>
) : null}
<div className="mt-1 break-words text-[13px] leading-relaxed text-muted-foreground">
<MarkdownBody body={message.body} />
</div>
{message.attachmentsJson && message.attachmentsJson !== "[]" ? (
<AttachmentList json={message.attachmentsJson} />
) : null}
<div className="mt-2 flex items-center gap-3">
{replyCount > 0 && !isThread ? (
<button
type="button"
onClick={onReply}
className="flex items-center gap-1.5 text-[11px] font-medium transition hover:opacity-80"
style={{ color: "var(--primary)" }}
>
<ChatCircle size={12} />
{replyCount} {t("msg.replies")}
</button>
) : null}
<button
type="button"
onClick={onReply}
className="flex items-center gap-1 rounded-lg px-2 py-0.5 text-[11px] font-medium text-muted-foreground opacity-0 transition hover:bg-muted hover:text-foreground group-hover:opacity-100"
>
<ArrowBendUpLeft size={10} />
{t("msg.reply")}
</button>
</div>
</div>
</motion.div>
)
})
export function TaskCommentBubble({
comment,
agent,
}: {
comment: CommentRecord
agent: AgentRecord | undefined
}) {
const isBoard = !comment.authorAgentId
const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted-foreground)"
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18 }}
className="group relative flex gap-3 rounded-lg bg-card px-4 py-3"
>
<AgentAvatar agent={agent} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-[13px] font-semibold" style={{ color: isBoard ? "var(--primary)" : deptColor }}>
{comment.authorName}
</span>
{agent?.title ? <span className="truncate text-[11px] text-muted-foreground">{agent.title}</span> : null}
<span className="whitespace-nowrap shrink-0 text-[11px] text-muted-foreground">{formatMessageTime(comment.createdAt)}</span>
</div>
<div className="mt-1 break-words text-[13px] leading-relaxed text-muted-foreground">
<MarkdownBody body={comment.body} />
</div>
</div>
</motion.div>
)
}
function AttachmentList({ json }: { json: string }) {
const items = safeJson<Array<{ type: string; id: string; title: string }>>(json, [])
if (!items.length) return null
return (
<div className="mt-2 flex flex-wrap gap-1.5">
{items.map((item, i) => (
<span
key={`${item.id}-${i}`}
className="inline-flex items-center gap-1 rounded-lg bg-muted px-2 py-1 text-[11px] font-medium text-muted-foreground"
>
<At size={10} className="text-muted-foreground" />
{item.title || `${item.type}:${item.id.slice(0, 8)}`}
</span>
))}
</div>
)
}
@@ -1,194 +0,0 @@
import { useRef, useEffect, useMemo } from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import { ChatCircle, Tray } from "../ui/icons"
import type { AgentMessageRecord, AgentRecord, CommentRecord, TaskRecord } from "@shared/types"
import { MessageBubble, TaskCommentBubble } from "./MessageItem"
import type { AnyChannel } from "./ChannelHeader"
import { useT } from "../../i18n"
import { useRovingTabIndex } from "../../lib/keyboard"
interface FilterState {
search: string
priority: string
}
interface MessageListProps {
activeChannel: AnyChannel | null
filteredMessages: AgentMessageRecord[]
taskDiscussionComments: CommentRecord[]
activeTask: TaskRecord | null
agentMap: Map<string, AgentRecord>
filters: FilterState
onReply: (message: AgentMessageRecord) => void
messageCount: number
selectedChannelKey: string | null
}
export function MessageList({
activeChannel,
filteredMessages,
taskDiscussionComments,
activeTask: _activeTask,
agentMap,
filters,
onReply,
messageCount,
selectedChannelKey,
}: MessageListProps) {
const t = useT()
const parentRef = useRef<HTMLDivElement>(null)
const threadedMessages = useMemo(() => {
// A message is rendered at the top level when it either has no
// parent, or its parent is outside the current filtered view
// (another channel, beyond the 1000-row snapshot cap, or simply
// not loaded yet). Without this second condition, a DM channel
// whose earliest message has scrolled off the snapshot would
// display as completely empty even though replies for it are
// present — the exact "clicked the channel, got a blank pane"
// symptom.
const idSet = new Set(filteredMessages.map((m) => m.id))
const topLevel: AgentMessageRecord[] = []
const replyMap = new Map<string, AgentMessageRecord[]>()
for (const m of filteredMessages) {
const parentInView = m.parentMessageId ? idSet.has(m.parentMessageId) : false
if (!m.parentMessageId || !parentInView) {
topLevel.push(m)
} else {
const existing = replyMap.get(m.parentMessageId) ?? []
existing.push(m)
replyMap.set(m.parentMessageId, existing)
}
}
return { topLevel, replyMap }
}, [filteredMessages])
// Flatten for virtualization: each top-level message + its replies
const flatItems = useMemo(() => {
const items: Array<{ type: "message"; message: AgentMessageRecord; isThread: boolean; replyCount: number } | { type: "comment"; comment: CommentRecord }> = []
if (activeChannel?.kind === "task") {
for (const comment of taskDiscussionComments) {
items.push({ type: "comment", comment })
}
} else {
for (const msg of threadedMessages.topLevel) {
const replies = threadedMessages.replyMap.get(msg.id) ?? []
items.push({ type: "message", message: msg, isThread: false, replyCount: replies.length })
for (const reply of replies) {
items.push({ type: "message", message: reply, isThread: true, replyCount: 0 })
}
}
}
return items
}, [activeChannel?.kind, taskDiscussionComments, threadedMessages])
const { handleKeyDown, getTabIndex, focusedIndex } = useRovingTabIndex(flatItems.length)
const virtualizer = useVirtualizer({
count: flatItems.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
overscan: 10,
})
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
if (flatItems.length > 0) {
virtualizer.scrollToIndex(flatItems.length - 1, { align: "end" })
}
}, [selectedChannelKey, messageCount, flatItems.length, virtualizer])
if (!activeChannel) {
return (
<div className="flex h-full flex-col items-center justify-center text-center">
<ChatCircle size={40} className="mb-3 text-muted-foreground" />
<div className="text-[14px] font-medium text-muted-foreground">
{t("comm.selectChannelView")}
</div>
</div>
)
}
if (activeChannel.kind === "task" && taskDiscussionComments.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center text-center">
<Tray size={36} className="mb-3 text-muted-foreground" />
<div className="text-[14px] font-medium text-muted-foreground">
{t("comm.noDiscussionYet")}
</div>
</div>
)
}
if (activeChannel.kind !== "task" && filteredMessages.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center text-center">
<Tray size={36} className="mb-3 text-muted-foreground" />
<div className="text-[14px] font-medium text-muted-foreground">
{filters.search || filters.priority !== "all"
? t("comm.noMatchFilters")
: t("comm.noMessagesChannel")}
</div>
{(filters.search || filters.priority !== "all") ? (
<div className="mt-1 text-[13px] text-muted-foreground">{t("comm.adjustFilters")}</div>
) : null}
</div>
)
}
return (
<div
ref={parentRef}
className="h-full overflow-auto"
role="listbox"
aria-label={t("comm.messagesAria")}
onKeyDown={handleKeyDown}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const item = flatItems[virtualRow.index]
return (
<div
key={virtualRow.index}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
tabIndex={getTabIndex(virtualRow.index)}
role="option"
aria-selected={false}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
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" : ""}`}
>
{item.type === "comment" ? (
<TaskCommentBubble
comment={item.comment}
agent={item.comment.authorAgentId ? (agentMap.get(item.comment.authorAgentId) ?? undefined) : undefined}
/>
) : (
<MessageBubble
message={item.message}
agent={agentMap.get(item.message.fromAgentId)}
isThread={item.isThread}
replyCount={item.replyCount}
onReply={() => onReply(item.message)}
/>
)}
</div>
)
})}
</div>
</div>
)
}
@@ -1,191 +0,0 @@
import { useRef, useCallback } from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import { motion } from "framer-motion"
import { EnvelopeSimple, Buildings, Megaphone, Hash, Fire } from "../ui/icons"
import type { AgentMessageRecord, AgentRecord, MessageChannel } from "@shared/types"
import { AgentAvatar } from "./MessageItem"
import { formatMessageTime } from "../../lib/formatters"
import { EmptyState, useReducedMotion } from "../ui"
import { useRovingTabIndex } from "../../lib/keyboard"
import { useT } from "../../i18n"
import { normalizeMarkdownText } from "@shared/markdown-text"
type ChannelConfig = { labelKey: string; icon: React.ElementType; color: string }
const channelConfig: Record<MessageChannel, ChannelConfig> = {
direct: { labelKey: "comm.channelLabel.directShort", icon: EnvelopeSimple, color: "var(--primary)" },
department: { labelKey: "comm.channelLabel.department", icon: Buildings, color: "var(--muted-foreground)" },
company: { labelKey: "comm.channelLabel.companyShort", icon: Megaphone, color: "var(--primary)" },
project: { labelKey: "comm.channelLabel.project", icon: Hash, color: "var(--primary)" },
incident: { labelKey: "comm.channelLabel.incident", icon: Fire, color: "var(--destructive)" },
}
function highlightMatch(text: string, query: string): React.ReactNode {
if (!query) return text
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const regex = new RegExp(`(${escaped})`, "gi")
const parts = text.split(regex)
return parts.map((part, i) =>
regex.test(part)
? <mark key={i} className="rounded-[2px] bg-warning-muted px-0.5 text-foreground">{part}</mark>
: part
)
}
function SkeletonRow() {
return (
<div className="flex gap-3 px-3 py-3" style={{ height: 72 }}>
<div className="h-7 w-7 shrink-0 animate-pulse rounded-full bg-muted" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="h-3 w-32 animate-pulse rounded bg-muted" />
<div className="h-3 w-48 animate-pulse rounded bg-muted" />
<div className="h-3 w-64 animate-pulse rounded bg-muted" />
</div>
</div>
)
}
interface SearchResultsProps {
results: AgentMessageRecord[]
query: string
loading: boolean
agents: AgentRecord[]
onNavigateToResult: (message: AgentMessageRecord) => void
}
export function SearchResults({ results, query, loading, agents, onNavigateToResult }: SearchResultsProps) {
const t = useT()
const reducedMotion = useReducedMotion()
const parentRef = useRef<HTMLDivElement>(null)
const agentMap = new Map(agents.map((a) => [a.id, a]))
const { handleKeyDown, getTabIndex, focusedIndex } = useRovingTabIndex(results.length)
const virtualizer = useVirtualizer({
count: results.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
overscan: 8,
})
const handleResultClick = useCallback((message: AgentMessageRecord) => {
onNavigateToResult(message)
}, [onNavigateToResult])
if (loading) {
return (
<div className="flex h-full flex-col">
<SkeletonRow />
<SkeletonRow />
<SkeletonRow />
</div>
)
}
if (results.length === 0 && query.length > 0) {
return (
<div className="flex h-full items-center justify-center">
<EmptyState title={t("comm.searchEmptyTitle")} />
</div>
)
}
if (results.length === 0) return null
const animationProps = reducedMotion
? {}
: { initial: { opacity: 0, y: 8 }, animate: { opacity: 1, y: 0 }, transition: { duration: 0.2, ease: "easeOut" as const } }
return (
<motion.div className="flex h-full flex-col" {...animationProps}>
<div className="shrink-0 px-3 py-2">
<span className="text-[11px] font-semibold text-muted-foreground">
{(results.length === 1 ? t("comm.resultCount") : t("comm.resultCountPlural")).replace("{count}", String(results.length))}
</span>
</div>
<div
ref={parentRef}
className="flex-1 overflow-auto"
role="listbox"
aria-label={t("comm.searchResultsAria")}
onKeyDown={handleKeyDown}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const message = results[virtualRow.index]
const agent = agentMap.get(message.fromAgentId)
const config = channelConfig[message.channel]
const normalizedBody = normalizeMarkdownText(message.body)
const ChannelIcon = config.icon
return (
<div
key={virtualRow.index}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
tabIndex={getTabIndex(virtualRow.index)}
role="option"
aria-selected={false}
onClick={() => handleResultClick(message)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
handleResultClick(message)
}
}}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
className={`cursor-pointer px-3 py-2 transition-colors duration-100 hover:bg-muted focus:outline-none ${virtualRow.index === focusedIndex ? "ring-1 ring-inset ring-[color:var(--primary)]" : ""}`}
>
<div className="flex items-start gap-2.5">
<AgentAvatar agent={agent} size="sm" />
<div className="min-w-0 flex-1">
{/* Row 1: Agent name, channel badge, timestamp */}
<div className="flex items-center gap-2">
<span className="truncate text-[11px] font-semibold text-foreground">
{agent?.name ?? t("comm.unknownAgent")}
</span>
<span
className="inline-flex shrink-0 items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] font-medium"
style={{ backgroundColor: `color-mix(in oklab, ${config.color}, transparent 91%)`, color: config.color }}
>
<ChannelIcon size={10} weight="bold" />
{t(config.labelKey)}
</span>
<span className="ml-auto whitespace-nowrap shrink-0 font-mono text-[11px] text-muted-foreground">
{formatMessageTime(message.createdAt)}
</span>
</div>
{/* Row 2: Subject */}
<div className="mt-0.5 truncate text-[13px] font-semibold text-foreground">
{highlightMatch(message.subject, query)}
</div>
{/* Row 3: Body snippet */}
<div className="mt-0.5 line-clamp-2 break-words text-[13px] leading-relaxed text-muted-foreground">
{highlightMatch(normalizedBody, query)}
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
</motion.div>
)
}
@@ -1,181 +0,0 @@
/**
* Thread Panel
*
* Renders a thread event timeline with wait-state indicators and a
* message composer for scoped replies. Designed to slide in as a
* right-side panel alongside the main message area.
*/
import { useState, useEffect, useCallback, useRef } from "react"
import { X, ChatCircleDots, ArrowBendUpLeft } from "../ui/icons"
import { motion } from "framer-motion"
import type { AgentRecord, ConversationEventRecord, ConversationParticipantRecord, ThreadWaitStateRecord, ConversationThreadRecord, DocumentRecord } from "@shared/types"
import WaitStateIndicator, { type WaitStateInfo } from "./WaitStateIndicator"
import { MessageComposer } from "./MessageComposer"
import { formatMessageTime } from "../../lib/formatters"
import { useT, type TranslationFn } from "../../i18n"
import { MarkdownBody } from "../ui/MarkdownBody"
interface ThreadPanelProps {
threadId: string
companyId: string
agents: AgentRecord[]
documents: DocumentRecord[]
onClose: () => void
onNavigateApproval?: (approvalId: string) => void
}
function EventItem({ event, agents, t }: { event: ConversationEventRecord; agents: AgentRecord[]; t: TranslationFn }) {
const actorName = event.actorType === "user"
? t("thread.actor.you")
: agents.find((a) => a.id === event.actorId)?.name ?? event.actorId
const time = formatMessageTime(event.createdAt)
const iconClass = event.eventType === "status_change"
? "text-primary"
: event.eventType === "approval_request"
? "text-warning"
: event.eventType === "approval_decision"
? "text-success"
: "text-muted-foreground"
return (
<div className="flex gap-2 px-3 py-2">
<div className={`mt-0.5 shrink-0 ${iconClass}`}>
{event.actorType === "user"
? <ArrowBendUpLeft size={14} />
: <ChatCircleDots size={14} />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-xs font-medium text-foreground">{actorName}</span>
<span className="text-xs text-muted-foreground">{time}</span>
</div>
<div className="text-sm text-foreground/80">
<MarkdownBody body={event.body} />
</div>
</div>
</div>
)
}
export default function ThreadPanel({ threadId, companyId, agents, documents, onClose, onNavigateApproval }: ThreadPanelProps) {
const t = useT()
const [thread, setThread] = useState<ConversationThreadRecord | null>(null)
const [events, setEvents] = useState<ConversationEventRecord[]>([])
const [waitStates, setWaitStates] = useState<ThreadWaitStateRecord[]>([])
const [participants, setParticipants] = useState<ConversationParticipantRecord[]>([])
const [replyWaitStateId, setReplyWaitStateId] = useState<string | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const loadThread = useCallback(async () => {
const threadResult = await window.agentCompany.getThread({ threadId, companyId })
if (threadResult.ok) setThread(threadResult.data)
const eventsResult = await window.agentCompany.listThreadEvents({ threadId, companyId, limit: 100, offset: 0 })
if (eventsResult.ok) setEvents(eventsResult.data)
const wsResult = await window.agentCompany.listThreadWaitStates({ companyId, resolvedOnly: false })
if (wsResult.ok) {
setWaitStates(wsResult.data.filter((ws) => ws.threadId === threadId && !ws.resolvedAt))
}
const participantsResult = await window.agentCompany.listThreadParticipants({ threadId, companyId })
if (participantsResult.ok) setParticipants(participantsResult.data)
}, [threadId, companyId])
useEffect(() => { void loadThread() }, [loadThread])
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [events])
const handleReply = useCallback((waitStateId: string, _threadId: string) => {
setReplyWaitStateId(waitStateId)
}, [])
const handleSent = useCallback(() => {
setReplyWaitStateId(null)
void loadThread()
}, [loadThread])
const activeReplyWaitState = waitStates.find((waitState) => waitState.waitingFor !== "user_approval") ?? null
const agentParticipantIds = participants
.filter((p) => p.participantType === "agent")
.map((p) => p.participantId)
return (
<motion.div
initial={{ x: 380, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 380, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
className="flex h-full w-[380px] shrink-0 flex-col border-l border-border bg-background"
>
{/* Header */}
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<ChatCircleDots size={18} className="text-muted-foreground" />
<h3 className="flex-1 truncate text-sm font-medium text-foreground">
{thread?.title ?? t("thread.title")}
</h3>
<button
onClick={onClose}
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={t("thread.close")}
>
<X size={16} />
</button>
</div>
{/* Wait State Indicators */}
{waitStates.length > 0 && (
<div className="space-y-1 border-b border-border p-2">
{waitStates.map((ws) => (
<WaitStateIndicator
key={ws.id}
waitState={ws as WaitStateInfo}
agents={agents}
onReply={handleReply}
onNavigateApproval={onNavigateApproval}
/>
))}
</div>
)}
{/* Event Timeline */}
<div ref={scrollRef} className="flex-1 overflow-y-auto">
{events.length === 0 ? (
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">
{t("thread.noEvents")}
</div>
) : (
<div className="divide-y divide-border/50 py-1">
{events.map((event) => (
<EventItem key={event.id} event={event} agents={agents} t={t} />
))}
</div>
)}
</div>
{/* Composer */}
<div className="border-t border-border">
<MessageComposer
agents={agents}
companyId={companyId}
channel="direct"
channelTargetId={null}
directParticipantIds={agentParticipantIds}
replyTo={null}
onSent={handleSent}
onCancelReply={() => setReplyWaitStateId(null)}
documents={documents}
threadId={threadId}
waitStateId={replyWaitStateId ?? activeReplyWaitState?.id ?? null}
/>
</div>
</motion.div>
)
}
@@ -1,63 +0,0 @@
/**
* Wait State Indicator
*
* Renders an inline banner within a thread or channel when an agent
* is waiting for human input (reply, review, or approval).
* Provides a primary action button to reply or navigate to the approval.
*/
import { Hourglass, ChatCircle, CheckCircle } from "../ui/icons"
import type { AgentRecord } from "@shared/types"
import { useT } from "../../i18n"
import { timeAgo } from "../../lib/formatters"
export interface WaitStateInfo {
id: string
threadId: string
companyId: string
waitingFor: "user_reply" | "user_review" | "user_approval"
reason: string
createdAt: string
relatedApprovalId: string | null
}
interface WaitStateIndicatorProps {
waitState: WaitStateInfo
agents: AgentRecord[]
onReply: (waitStateId: string, threadId: string) => void
onNavigateApproval?: (approvalId: string) => void
}
export default function WaitStateIndicator({ waitState, onReply, onNavigateApproval }: WaitStateIndicatorProps) {
const t = useT()
const isApproval = waitState.waitingFor === "user_approval" && waitState.relatedApprovalId
const isReview = waitState.waitingFor === "user_review"
const duration = timeAgo(waitState.createdAt)
return (
<div className="flex items-center gap-3 rounded-md border-l-2 border-primary bg-accent px-3 py-2">
<Hourglass size={18} weight="fill" className="shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">{waitState.reason}</p>
<p className="text-xs text-muted-foreground">{t("wait.waitingSince")} {duration}</p>
</div>
{isApproval && onNavigateApproval ? (
<button
onClick={() => { const id = waitState.relatedApprovalId; if (id) onNavigateApproval(id); }}
className="flex shrink-0 items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:brightness-95"
>
<CheckCircle size={14} />
{t("wait.review")}
</button>
) : (
<button
onClick={() => onReply(waitState.id, waitState.threadId)}
className="flex shrink-0 items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:brightness-95"
>
<ChatCircle size={14} />
{isReview ? t("wait.review") : t("wait.reply")}
</button>
)}
</div>
)
}
@@ -1,123 +0,0 @@
import type { AgentMessageRecord, AgentRecord, CommentRecord, MessageChannel, ProjectRecord, TaskRecord } from "@shared/types"
import type { ChannelDescriptor, TaskDiscussionDescriptor } from "./ChannelHeader"
const BOARD_ID = "__board__"
function labelForParticipant(id: string | undefined, agentMap: Map<string, AgentRecord>): string {
if (!id) return "Unknown"
if (id === BOARD_ID) return "Board"
return agentMap.get(id)?.name ?? id.slice(0, 8)
}
/**
* Resolve the participant-pair key that a direct message belongs to.
*
* A chat is a conversation thread, not a one-hop DM pair. When the user
* writes "@CEO 更新一下进度" inside their chat with 张工程师, CEO's
* reply targets `__board__` (the human reader) via
* `toAgentId=__board__, parentMessageId=<original>`. Keying that reply
* by `sort(fromAgent, toAgent)` alone would split it into a separate
* Board↔CEO pane and strand the user in two half-conversations. By
* walking `parentMessageId` back to the root of the thread and using
* the root's participant pair, every reply in the chain lands in the
* same chat surface the sender originally chose.
*
* Cycle guard: `parentMessageId` is user-supplied in some replies, so a
* malformed cycle must not wedge the UI. We bail out once the visit set
* sees an id twice and treat the current message as its own root.
*/
function resolveDirectThreadPair(
msg: AgentMessageRecord,
byId: Map<string, AgentMessageRecord>,
): [string, string] | null {
const visited = new Set<string>()
let cursor: AgentMessageRecord | undefined = msg
while (cursor && cursor.parentMessageId && !visited.has(cursor.id)) {
visited.add(cursor.id)
const parent = byId.get(cursor.parentMessageId)
if (!parent || parent.channel !== "direct" || !parent.toAgentId) break
cursor = parent
}
if (!cursor || !cursor.toAgentId) return null
const ids = [cursor.fromAgentId, cursor.toAgentId].sort()
return [ids[0]!, ids[1]!]
}
export function deriveChannels(messages: AgentMessageRecord[], agents: AgentRecord[], projects?: ProjectRecord[]): ChannelDescriptor[] {
const channelMap = new Map<string, ChannelDescriptor>()
const agentMap = new Map(agents.map((a) => [a.id, a]))
const projectMap = new Map((projects ?? []).map((p) => [p.id, p]))
const byId = new Map(messages.map((m) => [m.id, m]))
channelMap.set("company-all", { key: "company-all", kind: "message", channel: "company", label: "#company-wide", targetId: null, agentId: null, participantIds: [] })
const activeDepts = new Set<string>()
for (const agent of agents) { if (agent.department && agent.status !== "terminated") activeDepts.add(agent.department) }
for (const dept of activeDepts) {
const key = `department-${dept}`
channelMap.set(key, { key, kind: "message", channel: "department", label: `#${dept.replace(/_/g, "-")}`, targetId: dept, agentId: null, participantIds: [] })
}
for (const msg of messages) {
let key: string; let label: string; let agentId: string | null = null
let participantIds: string[] = []
switch (msg.channel) {
case "direct": {
const pair = resolveDirectThreadPair(msg, byId)
if (pair && pair[0] === pair[1]) continue
if (!pair) { key = "direct-broadcast"; label = "Broadcast DMs" }
else {
key = `direct-${pair.join("-")}`
label = `${labelForParticipant(pair[0], agentMap)} \u2194 ${labelForParticipant(pair[1], agentMap)}`
agentId = pair.find((id) => id !== BOARD_ID) ?? pair[1] ?? null
participantIds = pair
}
break }
case "department": { const d = msg.channelTargetId ?? "general"; key = `department-${d}`; label = `#${d.replace(/_/g, "-")}`; break }
case "company": { key = "company-all"; label = "#company-wide"; break }
case "project": { const pid = msg.channelTargetId ?? "general"; key = `project-${pid}`; label = projectMap.get(pid)?.name ?? `#project-${pid.slice(0, 8)}`; break }
case "incident": { const iid = msg.channelTargetId ?? "room"; key = `incident-${iid}`; label = iid === "room" ? "#incident-room" : `#incident-${iid.slice(0, 8)}`; break }
}
if (!channelMap.has(key)) {
channelMap.set(key, { key, kind: "message", channel: msg.channel, label, targetId: msg.channelTargetId, agentId, participantIds })
}
}
const order: MessageChannel[] = ["company", "department", "project", "incident", "direct"]
return Array.from(channelMap.values()).sort((a, b) => { const ai = order.indexOf(a.channel); const bi = order.indexOf(b.channel); return ai !== bi ? ai - bi : a.label.localeCompare(b.label) })
}
export function deriveTaskDiscussionChannels(comments: CommentRecord[], tasks: TaskRecord[], forcedTaskId?: string | null): TaskDiscussionDescriptor[] {
const taskMap = new Map(tasks.map((t) => [t.id, t]))
const activeIds = tasks.filter((t) => ["todo", "in_progress", "in_review", "blocked"].includes(t.status)).map((t) => t.id)
const ids = new Set([...comments.map((c) => c.taskId), ...activeIds, ...(forcedTaskId ? [forcedTaskId] : [])])
return Array.from(ids).map((taskId) => {
const task = taskMap.get(taskId)
return { key: `task-${taskId}`, kind: "task" as const, taskId, label: task ? `${task.title}${task.status === "blocked" ? " \u00b7 blocked" : task.status === "in_review" ? " \u00b7 review" : ""}` : `Task ${taskId.slice(0, 8)}` }
}).sort((l, r) => {
const lt = taskMap.get(l.taskId); const rt = taskMap.get(r.taskId)
const pw: Record<string, number> = { critical: 4, high: 3, medium: 2, low: 1 }
const lw = lt ? pw[lt.priority] ?? 0 : 0; const rw = rt ? pw[rt.priority] ?? 0 : 0
return lw !== rw ? rw - lw : l.label.localeCompare(r.label)
})
}
export function getChannelMessages(messages: AgentMessageRecord[], channel: ChannelDescriptor): AgentMessageRecord[] {
const byId = new Map(messages.map((m) => [m.id, m]))
return messages.filter((msg) => {
if (channel.channel === "company" && msg.channel === "company") return true
if (channel.channel === "department" && msg.channel === "department") return msg.channelTargetId === channel.targetId
if (channel.channel === "project" && msg.channel === "project") return msg.channelTargetId === channel.targetId
if (channel.channel === "incident" && msg.channel === "incident") return msg.channelTargetId === channel.targetId
if (channel.channel === "direct" && msg.channel === "direct") {
if (channel.key === "direct-broadcast") return !msg.toAgentId
if (!msg.toAgentId || channel.participantIds.length < 2) return false
const pair = resolveDirectThreadPair(msg, byId)
if (!pair) return false
if (pair[0] === pair[1]) return false
return pair[0] === channel.participantIds[0] && pair[1] === channel.participantIds[1]
}
return false
})
}
@@ -1,96 +0,0 @@
import { forwardRef, useCallback, type ReactNode, type KeyboardEvent, type ChangeEvent } from "react";
import { PaperPlaneTilt } from "../../ui/icons";
import { ActionButton, InlineNotice } from "../../ui";
export interface ComposerShellProps {
value: string;
onChange: (value: string, event: ChangeEvent<HTMLTextAreaElement>) => void;
onSend: () => void;
placeholder: string;
sending: boolean;
error?: string | null;
shortcutHint?: string;
sendLabel?: string;
sendDisabled?: boolean;
leading?: ReactNode;
belowHeader?: ReactNode;
onKeyDownExtra?: (event: KeyboardEvent<HTMLTextAreaElement>) => boolean;
textareaAriaLabel?: string;
rows?: number;
}
function SendButton({ sending, disabled, onClick, label }: { sending: boolean; disabled: boolean; onClick: () => void; label: string }) {
return (
<ActionButton
label={label}
icon={PaperPlaneTilt}
tone="accent"
iconOnly
loading={sending}
disabled={disabled}
onClick={onClick}
/>
);
}
export const ComposerShell = forwardRef<HTMLTextAreaElement, ComposerShellProps>(function ComposerShell(
{
value,
onChange,
onSend,
placeholder,
sending,
error = null,
shortcutHint,
sendLabel = "Send message",
sendDisabled,
leading,
belowHeader,
onKeyDownExtra,
textareaAriaLabel,
rows = 2,
},
ref,
) {
const handleChange = useCallback(
(event: ChangeEvent<HTMLTextAreaElement>) => {
onChange(event.target.value, event);
},
[onChange],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (onKeyDownExtra && onKeyDownExtra(event)) return;
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
onSend();
}
},
[onKeyDownExtra, onSend],
);
const disabled = sendDisabled ?? !value.trim();
return (
<>
{error ? <div className="mb-3"><InlineNotice message={error} /></div> : null}
{belowHeader}
<div className="relative flex items-end gap-2">
{leading}
<textarea
ref={ref}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
aria-label={textareaAriaLabel ?? placeholder}
rows={rows}
className="min-h-[40px] min-w-0 flex-1 resize-none rounded-lg border border-border bg-muted px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-transparent focus:outline-none focus:ring-2 focus:ring-ring"
/>
<SendButton sending={sending} disabled={disabled} onClick={onSend} label={sendLabel} />
</div>
{shortcutHint ? <div className="mt-1 text-[11px] text-muted-foreground">{shortcutHint}</div> : null}
</>
);
});
@@ -1,160 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { FileText, Paperclip, X } from "../../ui/icons";
import type { DocumentRecord } from "@shared/types";
import { useT } from "../../../i18n";
export interface AttachmentItem {
type: string;
id: string;
title: string;
}
interface ComposerAttachmentsProps {
documents: DocumentRecord[];
attachments: AttachmentItem[];
onAdd: (doc: DocumentRecord) => void;
onRemove: (id: string) => void;
}
export function ComposerAttachments({
documents,
attachments,
onAdd,
onRemove,
}: ComposerAttachmentsProps) {
const t = useT();
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const attachedIds = useMemo(() => new Set(attachments.map((a) => a.id)), [attachments]);
const filtered = useMemo(() => {
const q = filter.toLowerCase();
return documents.filter(
(d) =>
!attachedIds.has(d.id) &&
(d.title.toLowerCase().includes(q) || d.type.toLowerCase().includes(q)),
);
}, [documents, attachedIds, filter]);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node))
setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground"
aria-label={t("composer.attachDocument")}
>
<Paperclip size={16} />
</button>
{open && (
<div className="absolute bottom-full left-0 z-50 mb-1 w-72 rounded-xl border border-border bg-card shadow-xl">
<div className="border-b border-border px-3 py-2">
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={t("composer.searchDocuments")}
className="w-full rounded-lg border border-border bg-muted px-2.5 py-1.5 text-[12px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1"
autoFocus
/>
</div>
<div className="max-h-48 overflow-y-auto">
{filtered.length === 0 ? (
<div className="px-3 py-4 text-center text-[12px] text-muted-foreground">
{documents.length === 0
? t("composer.noDocuments")
: t("composer.noMatchingDocuments")}
</div>
) : (
filtered.slice(0, 20).map((doc) => (
<button
key={doc.id}
type="button"
onClick={() => {
onAdd(doc);
setFilter("");
setOpen(false);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition hover:bg-muted"
>
<FileText size={14} className="shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-medium text-foreground">
{doc.title}
</div>
<div className="truncate text-[10px] text-muted-foreground">
{doc.type.replaceAll("_", " ")} · {doc.status}
</div>
</div>
</button>
))
)}
</div>
</div>
)}
{attachments.length > 0 && (
<div className="absolute bottom-full left-10 mb-1 flex flex-wrap gap-1">
{attachments.map((att) => (
<span
key={att.id}
className="inline-flex items-center gap-1 rounded-lg bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary"
>
<FileText size={10} />
<span className="max-w-[120px] truncate">{att.title}</span>
<button
type="button"
onClick={() => onRemove(att.id)}
className="ml-0.5 rounded-sm p-0.5 transition hover:bg-primary/20"
>
<X size={8} />
</button>
</span>
))}
</div>
)}
</div>
);
}
export function AttachmentChips({
attachments,
onRemove,
}: {
attachments: AttachmentItem[];
onRemove: (id: string) => void;
}) {
if (attachments.length === 0) return null;
return (
<div className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((att) => (
<span
key={att.id}
className="inline-flex items-center gap-1 rounded-lg bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary"
>
<FileText size={10} />
<span className="max-w-[160px] truncate">{att.title}</span>
<button
type="button"
onClick={() => onRemove(att.id)}
className="ml-0.5 rounded-sm p-0.5 transition hover:bg-primary/20"
>
<X size={8} />
</button>
</span>
))}
</div>
);
}
@@ -1,89 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import type { AgentRecord } from "@shared/types";
import { getAgentInitials } from "../../../lib/formatters";
interface ComposerMentionDropdownProps {
agents: AgentRecord[];
query: string;
onSelect: (agent: AgentRecord | null) => void;
position: { top: number; left: number };
}
export function ComposerMentionDropdown({
agents,
query,
onSelect,
position,
}: ComposerMentionDropdownProps) {
const [selectedIndex, setSelectedIndex] = useState(0);
const filtered = useMemo(() => {
const q = query.toLowerCase();
return agents
.filter(
(a) =>
a.status !== "terminated" &&
(a.name.toLowerCase().includes(q) ||
a.title?.toLowerCase().includes(q) ||
a.department?.toLowerCase().includes(q)),
)
.slice(0, 8);
}, [agents, query]);
useEffect(() => {
setSelectedIndex(0);
}, [query]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" || e.key === "Tab") {
if (filtered[selectedIndex]) {
e.preventDefault();
onSelect(filtered[selectedIndex]);
}
} else if (e.key === "Escape") {
onSelect(null);
}
};
document.addEventListener("keydown", handler, true);
return () => document.removeEventListener("keydown", handler, true);
}, [filtered, selectedIndex, onSelect]);
if (filtered.length === 0) return null;
return (
<div
className="absolute z-50 w-64 rounded-xl border border-border bg-card shadow-xl"
style={{ top: position.top, left: position.left }}
>
{filtered.map((agent, i) => (
<button
key={agent.id}
type="button"
onClick={() => onSelect(agent)}
className={`flex w-full items-center gap-2.5 px-3 py-2 text-left transition ${
i === selectedIndex ? "bg-primary/10" : "hover:bg-muted"
} ${i === 0 ? "rounded-t-xl" : ""} ${i === filtered.length - 1 ? "rounded-b-xl" : ""}`}
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-bold text-muted-foreground">
{getAgentInitials(agent.name)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-semibold text-foreground">
{agent.name}
</div>
<div className="truncate text-[10px] text-muted-foreground">
{agent.title}
{agent.department ? ` · ${agent.department}` : ""}
</div>
</div>
</button>
))}
</div>
);
}
@@ -1,42 +0,0 @@
import { AnimatePresence, motion } from "framer-motion";
import { ArrowBendUpLeft, X } from "../../ui/icons";
import type { AgentMessageRecord, AgentRecord } from "@shared/types";
import { useT } from "../../../i18n";
interface ComposerReplyBannerProps {
replyTo: AgentMessageRecord | null;
agents: AgentRecord[];
onCancel: () => void;
}
export function ComposerReplyBanner({ replyTo, agents, onCancel }: ComposerReplyBannerProps) {
const t = useT();
return (
<AnimatePresence>
{replyTo ? (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="mb-2 flex items-center gap-2 overflow-hidden rounded-lg bg-muted px-3 py-2"
>
<ArrowBendUpLeft size={12} className="text-primary" />
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground">
{t("composer.replyingTo")}{" "}
<span className="font-semibold">
{agents.find((a) => a.id === replyTo.fromAgentId)?.name ?? t("composer.replyingToAgent")}
</span>
{replyTo.subject ? `: ${replyTo.subject}` : ""}
</span>
<button
type="button"
onClick={onCancel}
className="shrink-0 rounded-[4px] p-0.5 text-muted-foreground transition hover:text-foreground"
>
<X size={12} />
</button>
</motion.div>
) : null}
</AnimatePresence>
);
}
@@ -1,34 +0,0 @@
import { UserCircle } from "../../ui/icons";
import { useT } from "../../../i18n";
interface ComposerSubjectRowProps {
subject: string;
onSubjectChange: (value: string) => void;
showSubjectInput: boolean;
}
export function ComposerSubjectRow({
subject,
onSubjectChange,
showSubjectInput,
}: ComposerSubjectRowProps) {
const t = useT();
return (
<div className="mb-2 flex items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary">
<UserCircle size={16} weight="fill" />
</div>
<span className="text-[11px] font-medium text-foreground">{t("composer.youBoard")}</span>
{showSubjectInput ? (
<input
type="text"
value={subject}
onChange={(e) => onSubjectChange(e.target.value)}
placeholder={t("composer.subjectOptional")}
aria-label={t("composer.messageSubject")}
className="min-w-0 flex-1 rounded-lg border border-border bg-card px-2.5 py-1 text-[11px] text-foreground placeholder:text-muted-foreground"
/>
) : null}
</div>
);
}
@@ -1,14 +0,0 @@
import { ChatCircle } from "../../ui/icons";
import { useT } from "../../../i18n";
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>
);
}
@@ -1,56 +0,0 @@
import { useCallback, useState } from "react";
import { unwrap } from "../../../lib/desktop";
import { ComposerShell } from "../composer-shell/ComposerShell";
import { useT } from "../../../i18n";
export function TaskDiscussionComposer({
companyId,
taskId,
onSent,
}: {
companyId: string;
taskId: string;
onSent: () => void;
}) {
const t = useT();
const [body, setBody] = useState("");
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const handleSend = useCallback(async () => {
if (!body.trim()) return;
setSending(true);
setSendError(null);
try {
unwrap(
await window.agentCompany.addComment({
companyId,
taskId,
authorName: "Board",
body: body.trim(),
}),
);
setBody("");
onSent();
} catch (e) {
setSendError(e instanceof Error ? e.message : t("composer.sendFailedShort"));
} finally {
setSending(false);
}
}, [body, companyId, taskId, onSent, t]);
return (
<div className="border-t border-border bg-card px-4 py-3">
<ComposerShell
value={body}
onChange={(v) => setBody(v)}
onSend={() => void handleSend()}
placeholder={t("composer.taskDiscussionPlaceholder")}
sending={sending}
error={sendError}
sendLabel={t("composer.sendTaskComment")}
textareaAriaLabel={t("composer.taskDiscussionReply")}
/>
</div>
);
}
@@ -1,79 +0,0 @@
/**
* Compute pixel coordinates of the caret inside a textarea relative to the
* textarea's own top-left corner. Uses a hidden mirror div that replicates
* the textarea's type/font metrics so the span we drop at the caret offset
* lays out in the same position the real caret does.
*/
export function getTextareaCaretCoords(
textarea: HTMLTextAreaElement,
caretIndex: number,
): { top: number; left: number } {
const style = window.getComputedStyle(textarea);
const mirror = document.createElement("div");
const mirrorStyle = mirror.style;
const copyProps = [
"boxSizing",
"width",
"height",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"borderTopWidth",
"borderRightWidth",
"borderBottomWidth",
"borderLeftWidth",
"fontFamily",
"fontSize",
"fontWeight",
"fontStyle",
"fontVariant",
"lineHeight",
"letterSpacing",
"wordSpacing",
"textTransform",
"textIndent",
"whiteSpace",
"wordWrap",
] as const;
for (const prop of copyProps) {
mirrorStyle[prop as never] = style[prop as never];
}
mirrorStyle.position = "absolute";
mirrorStyle.visibility = "hidden";
mirrorStyle.overflow = "hidden";
mirrorStyle.whiteSpace = "pre-wrap";
mirrorStyle.wordWrap = "break-word";
mirrorStyle.top = "0";
mirrorStyle.left = "-9999px";
mirror.textContent = textarea.value.slice(0, caretIndex);
const marker = document.createElement("span");
marker.textContent = "\u200b";
mirror.appendChild(marker);
document.body.appendChild(mirror);
const coords = {
top: marker.offsetTop - textarea.scrollTop,
left: marker.offsetLeft - textarea.scrollLeft,
};
document.body.removeChild(mirror);
return coords;
}
export interface CaretMeasurement {
top: number;
left: number;
lineHeight: number;
}
export function measureCaret(
textarea: HTMLTextAreaElement,
caretIndex: number,
): CaretMeasurement {
const coords = getTextareaCaretCoords(textarea, caretIndex);
const style = window.getComputedStyle(textarea);
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.3;
return { top: coords.top, left: coords.left, lineHeight };
}
@@ -1,76 +0,0 @@
import { useCallback, useState } from "react";
import type { AgentRecord } from "@shared/types";
import { measureCaret } from "./useCaretMeasurement";
export interface MentionState {
active: boolean;
query: string;
startIndex: number;
caretTop: number;
caretLeft: number;
}
const INITIAL_MENTION: MentionState = {
active: false,
query: "",
startIndex: -1,
caretTop: 0,
caretLeft: 0,
};
export function useMentionState({
setBody,
focusTextarea,
}: {
setBody: (update: (prev: string) => string) => void;
focusTextarea: () => void;
}) {
const [mentionState, setMentionState] = useState<MentionState>(INITIAL_MENTION);
const dismiss = useCallback(() => {
setMentionState((s) => ({ ...s, active: false, query: "", startIndex: -1 }));
}, []);
const detect = useCallback(
(value: string, textarea: HTMLTextAreaElement) => {
const cursorPos = textarea.selectionStart;
const windowStart = Math.max(0, cursorPos - 50);
const windowText = value.slice(windowStart, cursorPos);
const match = windowText.match(/(?:^|\s)@(\w*)$/);
if (match) {
const query = match[1];
const atIndex = windowStart + match.index! + (match[0].startsWith("@") ? 0 : 1);
const { top, left, lineHeight } = measureCaret(textarea, atIndex);
setMentionState({
active: true,
query,
startIndex: atIndex,
caretTop: top + lineHeight,
caretLeft: left,
});
return;
}
dismiss();
},
[dismiss],
);
const selectAgent = useCallback(
(agent: AgentRecord | null) => {
if (!agent) {
dismiss();
return;
}
setBody((prev) => {
const before = prev.slice(0, mentionState.startIndex);
const after = prev.slice(mentionState.startIndex + mentionState.query.length + 1);
return `${before}@${agent.name} ${after}`;
});
dismiss();
focusTextarea();
},
[mentionState, setBody, focusTextarea, dismiss],
);
return { mentionState, detect, selectAgent };
}
@@ -1,99 +0,0 @@
import { useCallback, useState } from "react";
import type { AgentMessageRecord, MessageChannel } from "@shared/types";
import { unwrap } from "../../../lib/desktop";
import { useAppStore } from "../../../state/store";
import { useT } from "../../../i18n";
import type { AttachmentItem } from "./ComposerAttachments";
const HUMAN_SENDER_ID = "__board__";
interface UseMessageSendArgs {
body: string;
subject: string;
attachments: AttachmentItem[];
channel: MessageChannel;
channelTargetId: string | null;
companyId: string;
directRecipientId: string | null;
replyTo: AgentMessageRecord | null;
threadId?: string | null;
waitStateId?: string | null;
onCleared: () => void;
}
export function useMessageSend({
body,
subject,
attachments,
channel,
channelTargetId,
companyId,
directRecipientId,
replyTo,
threadId = null,
waitStateId = null,
onCleared,
}: UseMessageSendArgs) {
const t = useT();
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const addToast = useAppStore((s) => s.addToast);
const send = useCallback(async () => {
if (!body.trim()) return;
if (channel === "direct" && !directRecipientId) return;
setSending(true);
setSendError(null);
const attachmentsJson = attachments.length > 0 ? JSON.stringify(attachments) : "[]";
try {
const result = unwrap(
await window.agentCompany.sendAgentMessage({
companyId,
fromAgentId: HUMAN_SENDER_ID,
toAgentId: channel === "direct" ? directRecipientId : null,
channel,
channelTargetId,
subject: subject.trim() || (replyTo ? `Re: ${replyTo.subject}` : ""),
body: body.trim(),
priority: "normal",
parentMessageId: replyTo?.id ?? null,
attachmentsJson,
threadId: threadId ?? undefined,
waitStateId: waitStateId ?? undefined,
}),
);
if (result.mentionResults && result.mentionResults.length > 0) {
const woken = result.mentionResults.filter((m) => m.outcome === "woken");
const queued = result.mentionResults.filter((m) => m.outcome === "queued");
const busy = result.mentionResults.filter((m) => m.outcome === "busy");
if (woken.length > 0)
addToast(`${t("composer.notified")}: ${woken.map((m) => m.agentName).join(", ")}`, "success");
if (queued.length > 0)
addToast(`${t("composer.queued")}: ${queued.map((m) => m.agentName).join(", ")}`, "warn");
if (busy.length > 0)
addToast(`${t("composer.busy")}: ${busy.map((m) => m.agentName).join(", ")}`, "warn");
}
onCleared();
} catch (e) {
setSendError(e instanceof Error ? e.message : t("composer.sendFailed"));
} finally {
setSending(false);
}
}, [
body,
channel,
channelTargetId,
companyId,
directRecipientId,
onCleared,
replyTo,
subject,
attachments,
threadId,
waitStateId,
addToast,
t,
]);
return { send, sending, sendError };
}
@@ -1,36 +0,0 @@
import type { AgentMessageRecord, AgentRecord } from "@shared/types"
export interface MessageFilters {
search: string
priority: string
}
export const DEFAULT_MESSAGE_FILTERS: MessageFilters = {
search: "",
priority: "all",
}
export function hasActiveMessageFilters(filters: MessageFilters): boolean {
return filters.search.length > 0 || filters.priority !== "all"
}
export function filterChannelMessages(
messages: AgentMessageRecord[],
filters: MessageFilters,
agentMap: Map<string, AgentRecord>,
searchQuery: string,
): AgentMessageRecord[] {
let result = messages
if (filters.search && !searchQuery) {
const query = filters.search.toLowerCase()
result = result.filter((message) =>
message.subject.toLowerCase().includes(query)
|| message.body.toLowerCase().includes(query)
|| (agentMap.get(message.fromAgentId)?.name ?? "").toLowerCase().includes(query)
)
}
if (filters.priority !== "all") {
result = result.filter((message) => message.priority === filters.priority)
}
return result
}
@@ -1,165 +0,0 @@
import { useCallback, useState } from "react";
import { Buildings, FolderOpen } from "../ui/icons";
import type { ConnectorId } from "@shared/types";
import { ActionButton, Input, TextArea, Select } from "../ui";
import { OnboardingStep } from "./OnboardingStep";
import {
useOnboardingConnectors,
useOnboardingWizard,
} from "./OnboardingContext";
export interface CompanyStepValues {
companyName: string;
companyDescription: string;
workspacePath: string;
}
interface CompanyStepProps {
initialValues: CompanyStepValues;
onPickDirectory: () => Promise<string | null>;
onContinue: (values: CompanyStepValues) => void;
onBack: () => void;
}
export function CompanyStep({
initialValues,
onPickDirectory,
onContinue,
onBack,
}: CompanyStepProps) {
const { currentLocale, t, error } = useOnboardingWizard();
const {
selectedConnectorId,
setSelectedConnectorId,
selectedModel,
setSelectedModel,
executionReadyConnectors,
modelOptions,
loadingModels,
} = useOnboardingConnectors();
const [companyName, setCompanyName] = useState(initialValues.companyName);
const [companyDescription, setCompanyDescription] = useState(initialValues.companyDescription);
const [workspacePath, setWorkspacePath] = useState(initialValues.workspacePath);
const handlePick = useCallback(async () => {
const picked = await onPickDirectory();
if (picked) setWorkspacePath(picked);
}, [onPickDirectory]);
const handleContinue = useCallback(() => {
onContinue({ companyName, companyDescription, workspacePath });
}, [companyName, companyDescription, workspacePath, onContinue]);
return (
<OnboardingStep
step="company"
eyebrow={t("onboarding.stepLabelCompany")}
title={t("onboarding.step1Title")}
>
<div className="space-y-4">
<Input
label={t("onboarding.companyName")}
value={companyName}
onChange={setCompanyName}
placeholder={t("onboarding.companyNamePlaceholder")}
/>
<TextArea
label={t("onboarding.companyDescription")}
value={companyDescription}
onChange={setCompanyDescription}
placeholder={t("onboarding.companyDescriptionPlaceholder")}
rows={3}
/>
{/* CEO Provider + Model — stacked so long names (`Fireworks AI`,
`Kimi K2.5 Turbo Preview 0911`) are readable without the
two-column ellipsis truncation that the 540-px panel forced. */}
<div>
<div className="mb-2 text-[12px] font-medium text-muted-foreground">
{currentLocale === "zh" ? "CEO 运行时" : "CEO Runtime"}
</div>
<div className="space-y-3">
<Select
label={currentLocale === "zh" ? "提供商" : "Provider"}
value={selectedConnectorId}
onChange={(value) => setSelectedConnectorId(value as ConnectorId | "")}
options={executionReadyConnectors.map((c) => ({
value: c.id,
label: c.id.startsWith("claude_compat") && c.notes ? `${c.notes}` : c.label,
}))}
/>
<Select
label={currentLocale === "zh" ? "模型" : "Model"}
value={selectedModel}
onChange={setSelectedModel}
disabled={loadingModels || modelOptions.length === 0}
options={
loadingModels
? [{ value: "", label: currentLocale === "zh" ? "加载中..." : "Loading..." }]
: modelOptions.map((m) => ({ value: m.id, label: m.label }))
}
/>
</div>
<div className="mt-3 rounded-lg border border-border bg-background/60 px-3 py-2.5 text-[11px] leading-relaxed text-muted-foreground">
{t("onboarding.companyStepModelPoolHint")}
</div>
</div>
<div>
<label
htmlFor="onboarding-workspace-path"
className="mb-1.5 block text-[12px] font-medium text-muted-foreground"
>
{t("onboarding.workspaceFolder")}{" "}
<span className="font-normal text-muted-foreground">
{t("onboarding.workspaceFolderOptional")}
</span>
</label>
<div className="flex items-stretch gap-3">
<div className="min-w-0 flex-1">
<Input
id="onboarding-workspace-path"
ariaLabel={t("onboarding.workspaceFolder")}
value={workspacePath}
onChange={setWorkspacePath}
placeholder={t("onboarding.workspaceFolderPlaceholder")}
/>
</div>
<ActionButton
label={currentLocale === "zh" ? "选择" : "Choose"}
onClick={() => void handlePick()}
icon={FolderOpen}
/>
</div>
<div className="mt-1.5 text-[11px] text-muted-foreground">
{t("onboarding.workspaceHelp")}
</div>
</div>
</div>
{error ? (
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3 text-[13px] text-destructive">
{error}
</div>
) : null}
<div className="mt-8 flex items-center justify-between">
<button
type="button"
onClick={onBack}
className="text-[12px] font-medium text-muted-foreground transition hover:text-foreground"
>
{t("onboarding.back")}
</button>
<ActionButton
label={t("onboarding.continue")}
onClick={handleContinue}
icon={Buildings}
tone="accent"
disabled={!companyName.trim() || !selectedConnectorId}
/>
</div>
</OnboardingStep>
);
}
@@ -1,127 +0,0 @@
import { useState } from "react";
import { ArrowRight, Brain, UserCircle, type Icon } from "../ui/icons";
import type { ExecutionMode } from "@shared/types";
import { OnboardingStep } from "./OnboardingStep";
import { useOnboardingWizard } from "./OnboardingContext";
interface ExecutionModeStepProps {
initialValue: ExecutionMode;
onContinue: (mode: ExecutionMode) => void;
onBack: () => void;
}
export function ExecutionModeStep({ initialValue, onContinue, onBack }: ExecutionModeStepProps) {
const { t } = useOnboardingWizard();
const [executionMode, setExecutionMode] = useState<ExecutionMode>(initialValue);
return (
<OnboardingStep
step="executionmode"
eyebrow={t("onboarding.stepLabelMode")}
title={t("onboarding.modeStepTitle")}
>
<div className="space-y-3">
<ModeCard
selected={executionMode === "ai_ceo"}
onSelect={() => setExecutionMode("ai_ceo")}
icon={Brain}
title={t("onboarding.modeAiCeoTitle")}
subtitle={t("onboarding.modeAiCeoSubtitle")}
description={t("onboarding.modeAiCeoDesc")}
/>
<ModeCard
selected={executionMode === "ai_cofounder"}
onSelect={() => setExecutionMode("ai_cofounder")}
icon={UserCircle}
title={t("onboarding.modeCofounderTitle")}
subtitle={t("onboarding.modeCofounderSubtitle")}
description={t("onboarding.modeCofounderDesc")}
/>
</div>
<p className="mt-4 text-[11px] text-muted-foreground">
{t("onboarding.modeCanChange")}
</p>
<div className="mt-8 flex items-center justify-between">
<button
type="button"
onClick={onBack}
className="text-[12px] font-medium text-muted-foreground transition hover:text-foreground"
>
{t("onboarding.back")}
</button>
<button
type="button"
onClick={() => onContinue(executionMode)}
className="flex items-center gap-2 rounded-md bg-[color:var(--primary)] px-4 py-2 text-[13px] font-medium text-[color:var(--primary-foreground)] transition hover:opacity-90"
>
{t("onboarding.continue")}
<ArrowRight size={14} />
</button>
</div>
</OnboardingStep>
);
}
function ModeCard({
selected,
onSelect,
icon: Icon,
title,
subtitle,
description,
}: {
selected: boolean;
onSelect: () => void;
icon: Icon;
title: string;
subtitle: string;
description: string;
}) {
return (
<button
type="button"
onClick={onSelect}
className={`w-full rounded-lg border px-4 py-4 text-left transition ${
selected
? "border-[color:var(--primary)] bg-[color:var(--primary)]/5 ring-1 ring-[color:var(--primary)]"
: "border-border bg-background hover:border-muted-foreground/40"
}`}
>
<div className="flex items-start gap-3">
<div
className={`mt-0.5 rounded-md p-1.5 ${
selected
? "bg-[color:var(--primary)]/10 text-[color:var(--primary)]"
: "bg-muted text-muted-foreground"
}`}
>
<Icon size={16} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[14px] font-semibold text-foreground">{title}</span>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.06em] ${
selected
? "bg-[color:var(--primary)]/10 text-[color:var(--primary)]"
: "bg-muted text-muted-foreground"
}`}
>
{subtitle}
</span>
</div>
<p className="text-[12px] leading-relaxed text-muted-foreground">{description}</p>
</div>
<div
className={`mt-1 h-4 w-4 shrink-0 rounded-full border-2 transition ${
selected
? "border-[color:var(--primary)] bg-[color:var(--primary)]"
: "border-border bg-background"
}`}
/>
</div>
</button>
);
}
@@ -1,87 +0,0 @@
import { motion } from "framer-motion";
import { CheckCircle, Rocket } from "../ui/icons";
import { ActionButton } from "../ui";
import {
useOnboardingConnectors,
useOnboardingWizard,
} from "./OnboardingContext";
interface LaunchStepProps {
onFinish: () => void;
}
export function LaunchStep({ onFinish }: LaunchStepProps) {
const { launchSummary, loading, error, t } = useOnboardingWizard();
const { selectedConnector } = useOnboardingConnectors();
if (!launchSummary) return null;
return (
<motion.div
key="launch"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.36, ease: [0.16, 1, 0.3, 1] }}
className="bg-card border border-border shadow-sm w-full rounded-lg p-7"
>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-success-muted text-success">
<CheckCircle size={24} weight="fill" />
</div>
<h2 className="mt-5 text-[28px] tracking-[-0.03em] text-foreground">
{launchSummary.companyName} {t("onboarding.isLive")}
</h2>
<p className="mt-3 text-[14px] leading-relaxed text-muted-foreground">
{launchSummary.runId ? t("onboarding.launchDescription") : t("onboarding.launchDescriptionDeferred")}
</p>
<div className="mt-6 grid gap-3 md:grid-cols-2">
<div className="rounded-lg border border-border bg-muted px-4 py-3">
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("onboarding.connector")}</div>
<div className="mt-1 text-[13px] font-medium text-foreground">
{selectedConnector?.label ?? t("onboarding.unavailable")}
</div>
</div>
<div className="rounded-lg border border-border bg-muted px-4 py-3">
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("onboarding.leadAgent")}</div>
<div className="mt-1 text-[13px] font-medium text-foreground">
{launchSummary.leadAgentId ? t("onboarding.ceoAgentCreated") : t("onboarding.leadAgentUnavailable")}
</div>
</div>
<div className="rounded-lg border border-border bg-muted px-4 py-3">
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("onboarding.primaryWorkspace")}</div>
<div className="mt-1 text-[13px] font-medium text-foreground">
{launchSummary.workspaceId ? t("onboarding.registered") : t("onboarding.notBound")}
</div>
</div>
<div className="rounded-lg border border-border bg-muted px-4 py-3">
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("onboarding.kickoffProject")}</div>
<div className="mt-1 text-[13px] font-medium text-foreground">
{launchSummary.projectId ? t("onboarding.created") : t("onboarding.unavailable")}
</div>
</div>
<div className="rounded-lg border border-border bg-muted px-4 py-3">
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("onboarding.initialRun")}</div>
<div className="mt-1 text-[13px] font-medium text-foreground">
{launchSummary.runId ? t("onboarding.queuedInOrchestration") : t("onboarding.deferredUntilReady")}
</div>
</div>
</div>
{error ? (
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3 text-[13px] text-destructive">
{error}
</div>
) : null}
<div className="mt-8 flex items-center justify-end">
<ActionButton
label={loading ? t("onboarding.opening") : t("onboarding.openControlCenter")}
onClick={onFinish}
icon={Rocket}
tone="accent"
loading={loading}
/>
</div>
</motion.div>
);
}
@@ -1,97 +0,0 @@
import { useCallback, useState } from "react";
import { motion } from "framer-motion";
import { Cpu, Lightning } from "../ui/icons";
import { ActionButton, Input, TextArea } from "../ui";
import { getStepNumber, TOTAL_STEPS } from "./OnboardingHero";
import { useOnboardingWizard } from "./OnboardingContext";
export interface MissionStepValues {
goalTitle: string;
goalDescription: string;
}
interface MissionStepProps {
initialValues: MissionStepValues;
onLaunch: (values: MissionStepValues) => void;
onBack: () => void;
}
export function MissionStep({ initialValues, onLaunch, onBack }: MissionStepProps) {
const { loading, error, t } = useOnboardingWizard();
const [goalTitle, setGoalTitle] = useState(initialValues.goalTitle);
const [goalDescription, setGoalDescription] = useState(initialValues.goalDescription);
const handleLaunch = useCallback(() => {
onLaunch({ goalTitle, goalDescription });
}, [goalTitle, goalDescription, onLaunch]);
return (
<motion.div
key="mission"
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.stepLabelMission")} {getStepNumber("mission")} / {TOTAL_STEPS}
</div>
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-foreground">
{t("onboarding.step2Title")}
</div>
<div className="mb-6 h-px bg-[color:var(--border)]" />
<div className="space-y-4">
<Input
label={t("onboarding.goal")}
value={goalTitle}
onChange={setGoalTitle}
placeholder={t("onboarding.goalPlaceholder")}
/>
<TextArea
label={t("onboarding.acceptanceCriteria")}
value={goalDescription}
onChange={setGoalDescription}
placeholder={t("onboarding.acceptanceCriteriaPlaceholder")}
rows={5}
/>
<div className="rounded-lg border border-border bg-muted px-4 py-3 text-[12px] text-muted-foreground">
<div className="mb-2 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground">
<Cpu size={12} />
{t("onboarding.executionHandoff")}
</div>
<div className="space-y-2">
<div>{t("onboarding.executionHandoffLine1")}</div>
<div>{t("onboarding.executionHandoffLine2")}</div>
<div>{t("onboarding.executionHandoffLine3")}</div>
</div>
</div>
</div>
{error ? (
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3 text-[13px] text-destructive">
{error}
</div>
) : null}
<div className="mt-8 flex items-center justify-between">
<button
type="button"
onClick={onBack}
className="text-[12px] font-medium text-muted-foreground transition hover:text-foreground"
>
{t("onboarding.back")}
</button>
<ActionButton
label={loading ? t("onboarding.launching") : t("onboarding.launchCompany")}
onClick={handleLaunch}
icon={Lightning}
tone="accent"
loading={loading}
disabled={!goalTitle.trim()}
/>
</div>
</motion.div>
);
}
@@ -1,126 +0,0 @@
import { ActionButton } from "../ui";
import { ModelToggleList, StatusBadge } from "../execution/ModelManagementShared";
import { OnboardingStep } from "./OnboardingStep";
import {
useOnboardingConnectors,
useOnboardingWizard,
} from "./OnboardingContext";
interface ModelPoolStepProps {
onBack: () => void;
onContinue: () => void;
}
export function ModelPoolStep({ onBack, onContinue }: ModelPoolStepProps) {
const { error, t } = useOnboardingWizard();
const {
connectorModelPools,
onToggleConnectorModel,
onSetAllConnectorModels,
selectedConnectorId,
selectedConnectorEnabledModels,
totalEnabledModels,
} = useOnboardingConnectors();
const selectedConnectorIsRunnable = selectedConnectorEnabledModels > 0;
const hasAnyEnabledModels = totalEnabledModels > 0;
const canContinue = hasAnyEnabledModels && selectedConnectorIsRunnable;
const readinessHint = !hasAnyEnabledModels
? t("onboarding.modelPoolGlobalRequirement")
: !selectedConnectorIsRunnable
? t("onboarding.modelPoolSelectedConnectorRequirement")
: t("onboarding.modelPoolContinueHint");
return (
<OnboardingStep
step="modelpool"
eyebrow={t("onboarding.stepLabelModelPool")}
title={t("onboarding.modelPoolStepTitle")}
subtitle={t("onboarding.modelPoolStepSubtitle")}
>
<div className="mb-4 rounded-lg border border-border bg-background/70 px-4 py-3 text-[12px] leading-relaxed text-muted-foreground">
{t("onboarding.modelPoolStepDescription")}
</div>
<div className="space-y-3">
{connectorModelPools.map(({ connector, models }) => {
const enabledCount = models.filter((model) => model.enabled).length;
const allEnabled = models.length > 0 && enabledCount === models.length;
// One action, state-aware: when everything is on the next useful
// move is "disable all"; otherwise the user almost always wants
// "enable all". Two buttons would compete for the user's decision
// for no real benefit.
const bulkAction = allEnabled
? { label: t("models.disableAll"), target: false }
: { label: t("models.enableAll"), target: true };
return (
<div key={connector.id} className="rounded-lg border border-border bg-background/50 p-4">
{/* Row 1 — identity + health. Title has room to breathe. */}
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-[13px] font-semibold text-foreground">
{connector.id.startsWith("claude_compat") && connector.notes ? connector.notes : connector.label}
</span>
{connector.id === selectedConnectorId ? (
<span className="shrink-0 whitespace-nowrap rounded-full bg-accent px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-primary">
{t("models.ceoRuntime")}
</span>
) : null}
</div>
<StatusBadge status={connector.status} />
</div>
{/* Row 2 — stats + bulk action. Only rendered when there is */}
{/* more than one model (single-model providers have nothing */}
{/* meaningful to bulk-toggle). */}
<div className="mt-2 flex items-center justify-between gap-3 text-[11px] text-muted-foreground">
<span>
{t("models.poolSummary").replace("{enabled}", String(enabledCount)).replace("{total}", String(models.length || 0))}
</span>
{models.length > 1 ? (
<button
type="button"
onClick={() => onSetAllConnectorModels(connector.id, bulkAction.target)}
className="whitespace-nowrap rounded-md border border-border bg-background/60 px-2 py-1 text-[11px] text-muted-foreground transition hover:border-foreground/40 hover:text-foreground"
>
{bulkAction.label}
</button>
) : null}
</div>
<div className="mt-3">
<ModelToggleList
models={models}
onToggle={(modelId) => void onToggleConnectorModel(connector.id, modelId)}
canRemove={false}
/>
</div>
</div>
);
})}
</div>
{error ? (
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3 text-[13px] text-destructive">
{error}
</div>
) : null}
<div className="mt-5 text-[12px] text-muted-foreground">{readinessHint}</div>
<div className="mt-8 flex items-center justify-between">
<button
type="button"
onClick={onBack}
className="text-[12px] font-medium text-muted-foreground transition hover:text-foreground"
>
{t("onboarding.back")}
</button>
<ActionButton
label={t("onboarding.continue")}
onClick={onContinue}
tone="accent"
disabled={!canContinue}
/>
</div>
</OnboardingStep>
);
}
@@ -1,125 +0,0 @@
import {
CheckCircle,
XCircle,
ArrowRight,
ArrowCounterClockwise,
Terminal,
} from "../ui/icons";
import { ActionButton } from "../ui";
import type { TerminalCommandKey } from "@shared/contracts";
import { OnboardingStep } from "./OnboardingStep";
import { useOnboardingWizard } from "./OnboardingContext";
interface NodeCheckStepProps {
onContinue: () => void;
onBack: () => void;
}
const INSTALL_COMMANDS: Record<string, { label: string; code: string; commandKey: TerminalCommandKey }> = {
darwin: {
label: "macOS",
code: "brew install node@22",
commandKey: "install_node_darwin",
},
win32: {
label: "Windows",
code: "winget install -e --id OpenJS.NodeJS.LTS",
commandKey: "install_node_win32",
},
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`,
commandKey: "install_node_linux",
},
};
export function NodeCheckStep({ onContinue, onBack }: NodeCheckStepProps) {
const { nodeStatus, nodeChecking, onCheckNode, onInstallCommand, t } = useOnboardingWizard();
const platform = window.agentCompany.platform;
const platformInfo = INSTALL_COMMANDS[platform] ?? INSTALL_COMMANDS.linux;
return (
<OnboardingStep
step="nodecheck"
eyebrow={`${t("onboarding.stepLabel1")} — ${t("onboarding.nodeCheckStep")}`}
title={t("onboarding.nodeCheckTitle")}
>
{/* Status */}
<div className="mb-5 flex items-center gap-2.5 text-[13px]">
{nodeChecking ? (
<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-success" />
) : nodeStatus ? (
<XCircle size={18} weight="fill" className="shrink-0 text-destructive" />
) : null}
{nodeChecking ? (
<span className="text-muted-foreground">{t("onboarding.providerChecking")}...</span>
) : nodeStatus?.installed ? (
<span className="font-medium text-success">
{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={() => onInstallCommand(platformInfo.commandKey)}
icon={Terminal}
/>
</div>
)}
{/* Check Again */}
<div className="mb-6">
<ActionButton
label={t("onboarding.checkAgain")}
onClick={onCheckNode}
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>
</OnboardingStep>
);
}
@@ -1,106 +0,0 @@
import { createContext, useContext, type ReactNode } from "react";
import type { AppLocale } from "@shared/locale";
import type {
ConnectorId,
OnboardingBootstrapResult,
ProfileSnapshot,
} from "@shared/types";
import type { ConnectorModelEntry } from "@shared/connector-models";
import type { TerminalCommandKey } from "@shared/contracts";
import type { TranslationFn } from "../../i18n";
/**
* Wizard-level shell state: locale, translation, load/error, finalized
* launch snapshot. Narrow on purpose so that typing in a step does not
* invalidate every consumer.
*/
export interface OnboardingWizardContextValue {
currentLocale: AppLocale;
t: TranslationFn;
error: string | null;
loading: boolean;
launchSummary: OnboardingBootstrapResult | null;
nodeStatus: { installed: boolean; version: string | null } | null;
nodeChecking: boolean;
onCheckNode: () => void;
onInstallCommand: (commandKey: TerminalCommandKey) => void;
}
const OnboardingWizardContext = createContext<OnboardingWizardContextValue | null>(null);
export function OnboardingWizardProvider({
value,
children,
}: {
value: OnboardingWizardContextValue;
children: ReactNode;
}) {
return <OnboardingWizardContext.Provider value={value}>{children}</OnboardingWizardContext.Provider>;
}
export function useOnboardingWizard(): OnboardingWizardContextValue {
const ctx = useContext(OnboardingWizardContext);
if (!ctx) {
throw new Error("useOnboardingWizard must be used within an OnboardingWizardProvider");
}
return ctx;
}
/**
* Connector/model slice. Shared by CompanyStep, ModelPoolStep, LaunchStep.
*/
export interface OnboardingConnectorsContextValue {
connectors: ProfileSnapshot["connectors"];
hasAtLeastOneReady: boolean;
onTestConnector: (connectorId: string) => void;
onSaveConnector: (
connectorId: string,
envBindingText: string,
model: string | null,
notes?: string,
) => void;
selectedConnectorId: ConnectorId | "";
setSelectedConnectorId: (value: ConnectorId | "") => void;
selectedModel: string;
setSelectedModel: (value: string) => void;
selectedConnector: ProfileSnapshot["connectors"][number] | null;
executionReadyConnectors: ProfileSnapshot["connectors"];
connectorModelPools: Array<{
connector: ProfileSnapshot["connectors"][number];
models: ConnectorModelEntry[];
}>;
modelOptions: Array<{ id: string; label: string }>;
loadingModels: boolean;
totalEnabledModels: number;
selectedConnectorEnabledModels: number;
onToggleConnectorModel: (connectorId: ConnectorId, modelId: string) => void;
onSetAllConnectorModels: (connectorId: ConnectorId, enabled: boolean) => void;
}
const OnboardingConnectorsContext = createContext<OnboardingConnectorsContextValue | null>(null);
export function OnboardingConnectorsProvider({
value,
children,
}: {
value: OnboardingConnectorsContextValue;
children: ReactNode;
}) {
return (
<OnboardingConnectorsContext.Provider value={value}>
{children}
</OnboardingConnectorsContext.Provider>
);
}
export function useOnboardingConnectors(): OnboardingConnectorsContextValue {
const ctx = useContext(OnboardingConnectorsContext);
if (!ctx) {
throw new Error(
"useOnboardingConnectors must be used within an OnboardingConnectorsProvider",
);
}
return ctx;
}
@@ -1,163 +0,0 @@
import { motion } from "framer-motion";
import {
Buildings,
CheckCircle,
HardDrive,
Plugs,
Rocket,
SlidersHorizontal,
Target,
} from "../ui/icons";
import type { AppLocale } from "@shared/locale";
export type OnboardingStep =
| "welcome"
| "nodecheck"
| "providers"
| "company"
| "modelpool"
| "executionmode"
| "mission"
| "launch";
const STEP_LABELS_CONFIG = [
{ key: "onboarding.stepLabelNodeCheck", icon: HardDrive, step: "nodecheck" as const },
{ key: "onboarding.stepLabelProviders", icon: Plugs, step: "providers" as const },
{ key: "onboarding.stepLabelCompany", icon: Buildings, step: "company" as const },
{ key: "onboarding.stepLabelModelPool", icon: SlidersHorizontal, step: "modelpool" as const },
{ key: "onboarding.stepLabelMode", icon: CheckCircle, step: "executionmode" as const },
{ key: "onboarding.stepLabelMission", icon: Target, step: "mission" as const },
{ key: "onboarding.stepLabelLaunch", icon: Rocket, step: "launch" as const },
] as const;
export const STEP_ORDER = [
"nodecheck",
"providers",
"company",
"modelpool",
"executionmode",
"mission",
"launch",
] as const satisfies readonly OnboardingStep[];
export type OnboardingStepName = (typeof STEP_ORDER)[number];
export const TOTAL_STEPS = STEP_ORDER.length;
export function getStepNumber(step: OnboardingStepName): number {
return STEP_ORDER.indexOf(step) + 1;
}
interface OnboardingHeroProps {
step: OnboardingStep;
currentLocale: AppLocale;
t: (key: string) => string;
}
export function OnboardingHero({
step,
currentLocale: _currentLocale,
t,
}: OnboardingHeroProps) {
const stepIndex = (STEP_ORDER as readonly OnboardingStep[]).indexOf(step);
const stepLabels = STEP_LABELS_CONFIG.map((cfg) => ({
label: t(cfg.key),
icon: cfg.icon,
}));
return (
<div className="relative flex min-h-0 items-center justify-center overflow-hidden px-8 py-12 md:px-16">
{/* Ambient gradient */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.035]"
style={{
background: "radial-gradient(ellipse 80% 60% at 30% 20%, var(--primary) 0%, transparent 70%)",
}}
/>
<div
className="pointer-events-none absolute inset-0 opacity-[0.025]"
style={{
background: "radial-gradient(ellipse 60% 80% at 70% 80%, var(--primary) 0%, transparent 70%)",
animation: "hero-drift 12s ease-in-out infinite alternate",
}}
/>
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.42, ease: [0.16, 1, 0.3, 1] }}
className="relative z-10 mx-auto w-full max-w-[min(460px,90vw)]"
>
{/* Signature line */}
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.1, ease: [0.16, 1, 0.3, 1] }}
className="mb-6 flex items-center gap-3"
>
<div
className="h-px flex-1 max-w-[32px]"
style={{
background: "linear-gradient(90deg, var(--primary), transparent)",
}}
/>
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{t("onboarding.subtitle")}
</span>
</motion.div>
{/* Title */}
<motion.h1
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.15, ease: [0.16, 1, 0.3, 1] }}
className="text-[36px] font-bold leading-[1.05] tracking-[-0.03em] text-foreground md:text-[44px]"
>
{t("onboarding.title").split("\n").map((line, i) => (
<span key={i}>{line}{i === 0 ? <br /> : null}</span>
))}
</motion.h1>
{/* Description */}
<motion.p
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.25, ease: [0.16, 1, 0.3, 1] }}
className="mt-4 max-w-[400px] text-[14px] leading-relaxed text-muted-foreground"
>
{t("onboarding.description")}
</motion.p>
{/* Step progress */}
<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-success-muted text-success"
: 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>
</div>
))}
</motion.div>
</motion.div>
</div>
);
}
@@ -1,36 +0,0 @@
import { motion } from "framer-motion";
import type { ReactNode } from "react";
import { getStepNumber, TOTAL_STEPS, type OnboardingStepName } from "./OnboardingHero";
interface OnboardingStepProps {
step: OnboardingStepName;
eyebrow: string;
title: string;
subtitle?: ReactNode;
children: ReactNode;
}
export function OnboardingStep({ step, eyebrow, title, subtitle, children }: OnboardingStepProps) {
return (
<motion.div
key={step}
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">
{eyebrow} {getStepNumber(step)} / {TOTAL_STEPS}
</div>
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-foreground">
{title}
</div>
{subtitle ? (
<div className="mb-4 text-[13px] leading-relaxed text-muted-foreground">{subtitle}</div>
) : null}
<div className="mb-6 h-px bg-[color:var(--border)]" />
{children}
</motion.div>
);
}
@@ -1,221 +0,0 @@
import { AnimatePresence } from "framer-motion";
import type { ExecutionMode } from "@shared/types";
import {
Buildings,
CheckCircle,
HardDrive,
Plugs,
SlidersHorizontal,
Target,
} from "../ui/icons";
import { OnboardingHero, STEP_ORDER, type OnboardingStep, type OnboardingStepName } from "./OnboardingHero";
import { NodeCheckStep } from "./NodeCheckStep";
import { ProvidersStep } from "./ProvidersStep";
import { CompanyStep, type CompanyStepValues } from "./CompanyStep";
import { ModelPoolStep } from "./ModelPoolStep";
import { ExecutionModeStep } from "./ExecutionModeStep";
import { MissionStep, type MissionStepValues } from "./MissionStep";
import { LaunchStep } from "./LaunchStep";
import { useOnboardingWizard } from "./OnboardingContext";
/**
* Completion flag per tab. Computed in OnboardingWizard from the
* wizard's finalized values + connector slice, threaded down here so
* the tab bar can show a tick next to finished sections and the user
* can tell at a glance what is still blocking launch. `launch` is
* implied complete once all six earlier tabs are true; it has no
* independent field.
*/
export interface OnboardingTabCompletion {
nodecheck: boolean;
providers: boolean;
company: boolean;
modelpool: boolean;
executionmode: boolean;
mission: boolean;
}
interface OnboardingStepPanelProps {
step: OnboardingStep;
canCancel: boolean;
onCancel?: () => void;
isFirstLaunch: boolean;
onBack: (step: OnboardingStep) => void;
/**
* Jump directly to an arbitrary setup tab. Called by the tab bar;
* unlike `onBack` it is symmetric (can move forward or backward).
*/
onNavigateToStep: (step: OnboardingStepName) => void;
tabCompletion: OnboardingTabCompletion;
onContinueFromNodeCheck: () => void;
onContinueFromProviders: () => void;
onContinueFromCompany: (values: CompanyStepValues) => void;
onContinueFromModelPool: () => void;
onContinueFromExecutionMode: (mode: ExecutionMode) => void;
onLaunchCompany: (values: MissionStepValues) => void;
onFinishLaunch: () => void;
onPickDirectory: () => Promise<string | null>;
companyValues: CompanyStepValues;
missionValues: MissionStepValues;
executionMode: ExecutionMode;
}
type SetupTabId = Exclude<OnboardingStepName, "launch">;
const SETUP_TABS: ReadonlyArray<{
id: SetupTabId;
icon: typeof HardDrive;
labelKey: string;
}> = [
{ id: "nodecheck", icon: HardDrive, labelKey: "onboarding.stepLabelNodeCheck" },
{ id: "providers", icon: Plugs, labelKey: "onboarding.stepLabelProviders" },
{ id: "company", icon: Buildings, labelKey: "onboarding.stepLabelCompany" },
{ id: "modelpool", icon: SlidersHorizontal, labelKey: "onboarding.stepLabelModelPool" },
{ id: "executionmode", icon: CheckCircle, labelKey: "onboarding.stepLabelMode" },
{ id: "mission", icon: Target, labelKey: "onboarding.stepLabelMission" },
];
function isSetupTabId(step: OnboardingStep): step is SetupTabId {
return (STEP_ORDER as readonly OnboardingStep[]).includes(step) && step !== "launch";
}
export function OnboardingStepPanel({
step,
canCancel,
onCancel,
isFirstLaunch,
onBack,
onNavigateToStep,
tabCompletion,
onContinueFromNodeCheck,
onContinueFromProviders,
onContinueFromCompany,
onContinueFromModelPool,
onContinueFromExecutionMode,
onLaunchCompany,
onFinishLaunch,
onPickDirectory,
companyValues,
missionValues,
executionMode,
}: OnboardingStepPanelProps) {
const { currentLocale, t, launchSummary } = useOnboardingWizard();
const showTabs = isSetupTabId(step);
return (
<>
<OnboardingHero step={step} currentLocale={currentLocale} t={t} />
<div className="min-h-0 overflow-y-auto border-l border-border bg-background">
<div className="relative flex min-h-full flex-col 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}
{showTabs ? (
<nav
role="tablist"
aria-label={t("onboarding.stepsAria")}
className="mb-6 flex flex-wrap gap-1 border-b border-border"
>
{SETUP_TABS.map((tab) => {
const active = step === tab.id;
const done = tabCompletion[tab.id];
const Icon = done ? CheckCircle : tab.icon;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => onNavigateToStep(tab.id)}
className={`relative flex items-center gap-1.5 whitespace-nowrap px-3 py-2 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
active
? "text-foreground"
: done
? "text-success hover:text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Icon
size={13}
weight={done ? "fill" : "regular"}
className={done && !active ? "text-success" : undefined}
/>
<span>{t(tab.labelKey)}</span>
{active ? (
<span
aria-hidden="true"
className="absolute inset-x-2 bottom-[-1px] h-[2px] bg-primary"
/>
) : null}
</button>
);
})}
</nav>
) : null}
<div className="flex flex-1 items-center">
<AnimatePresence mode="wait">
{step === "nodecheck" ? (
<NodeCheckStep
onContinue={onContinueFromNodeCheck}
onBack={() => onBack("welcome")}
/>
) : null}
{step === "providers" ? (
<ProvidersStep
onContinue={onContinueFromProviders}
onBack={() => onBack("nodecheck")}
/>
) : null}
{step === "company" ? (
<CompanyStep
initialValues={companyValues}
onPickDirectory={onPickDirectory}
onContinue={onContinueFromCompany}
onBack={() => onBack("providers")}
/>
) : null}
{step === "modelpool" ? (
<ModelPoolStep
onBack={() => onBack("company")}
onContinue={onContinueFromModelPool}
/>
) : null}
{step === "executionmode" ? (
<ExecutionModeStep
initialValue={executionMode}
onContinue={onContinueFromExecutionMode}
onBack={() => onBack("modelpool")}
/>
) : null}
{step === "mission" ? (
<MissionStep
initialValues={missionValues}
onLaunch={onLaunchCompany}
onBack={() => onBack("executionmode")}
/>
) : null}
{step === "launch" && launchSummary ? (
<LaunchStep onFinish={onFinishLaunch} />
) : null}
</AnimatePresence>
</div>
</div>
</div>
</>
);
}
@@ -1,336 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type {
ConnectorId,
ExecutionMode,
OnboardingBootstrapResult,
ProfileSnapshot,
} from "@shared/types";
import type { AppLocale } from "@shared/locale";
import { useT } from "../../i18n";
import { unwrap } from "../../lib/desktop";
import type { OnboardingStep, OnboardingStepName } from "./OnboardingHero";
import { WelcomeStep } from "./WelcomeStep";
import { OnboardingStepPanel, type OnboardingTabCompletion } from "./OnboardingStepPanel";
import { useConnectorCallbacks } from "./useConnectorCallbacks";
import { useConnectorModelSelection } from "./useConnectorModelSelection";
import {
OnboardingConnectorsProvider,
OnboardingWizardProvider,
type OnboardingConnectorsContextValue,
type OnboardingWizardContextValue,
} from "./OnboardingContext";
import type { CompanyStepValues } from "./CompanyStep";
import type { MissionStepValues } from "./MissionStep";
const EMPTY_COMPANY: CompanyStepValues = {
companyName: "",
companyDescription: "",
workspacePath: "",
};
const EMPTY_MISSION: MissionStepValues = { goalTitle: "", goalDescription: "" };
export function CompanyOnboarding({
connectors,
onDone,
canCancel = false,
onCancel,
currentLocale,
onLocaleChange,
isFirstLaunch = false,
}: {
connectors: ProfileSnapshot["connectors"];
onDone: (summary: OnboardingBootstrapResult) => Promise<void>;
canCancel?: boolean;
onCancel?: () => void;
currentLocale: AppLocale;
onLocaleChange: (locale: AppLocale) => void;
isFirstLaunch?: boolean;
}) {
const t = useT();
const [step, setStep] = useState<OnboardingStep>("welcome");
const [nodeStatus, setNodeStatus] = useState<{ installed: boolean; version: string | null } | null>(null);
const [nodeChecking, setNodeChecking] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [launchSummary, setLaunchSummary] = useState<OnboardingBootstrapResult | null>(null);
// Finalized step-local snapshots. Written only on step "commit" (continue).
const [companyValues, setCompanyValues] = useState<CompanyStepValues>(EMPTY_COMPANY);
const [executionMode, setExecutionMode] = useState<ExecutionMode>("ai_ceo");
const [missionValues, setMissionValues] = useState<MissionStepValues>(EMPTY_MISSION);
const checkNode = useCallback(async () => {
setNodeChecking(true);
try {
const result = unwrap(await window.agentCompany.checkNodeInstalled());
setNodeStatus(result);
} catch (error) {
console.warn(
`[onboarding/OnboardingWizard] checkNodeInstalled failed; assuming uninstalled: ${error instanceof Error ? error.message : String(error)}`,
);
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]);
const { handleTestConnector, handleSaveConnector, handleInstallCommand } =
useConnectorCallbacks(connectors);
const {
connectorModelPools,
executionReadyConnectors,
hasAtLeastOneReady,
loadingModels,
modelOptions,
selectedConnector,
selectedConnectorId,
selectedModel,
setSelectedConnectorId,
setSelectedModel,
selectedConnectorEnabledModels,
totalEnabledModels,
toggleConnectorModel,
setAllConnectorModels,
poolSelections,
} = useConnectorModelSelection(connectors, currentLocale, t, setError);
const pickDirectory = useCallback(async (): Promise<string | null> => {
setError(null);
const result = await window.agentCompany.pickDirectory();
if (!result.ok) {
setError(result.error.message);
return null;
}
return result.data ?? null;
}, []);
const handleContinueFromCompany = useCallback(
(values: CompanyStepValues) => {
if (loading) return;
if (!values.companyName.trim()) {
setError(t("onboarding.enterCompanyName"));
return;
}
setCompanyValues(values);
setError(null);
setStep("modelpool");
},
[loading, t],
);
const handleContinueFromExecutionMode = useCallback((mode: ExecutionMode) => {
setExecutionMode(mode);
setError(null);
setStep("mission");
}, []);
const launchCompany = useCallback(
async (values: MissionStepValues) => {
if (loading) return;
if (!values.goalTitle.trim()) {
setError(t("onboarding.describeFirstGoal"));
return;
}
setMissionValues(values);
setLoading(true);
setError(null);
try {
const result = unwrap(
await window.agentCompany.bootstrapOnboarding({
companyName: companyValues.companyName.trim(),
companyDescription: companyValues.companyDescription.trim(),
workspacePath: companyValues.workspacePath.trim(),
autoApproveHires: executionMode === "ai_ceo",
reviewDeliverables: executionMode === "ai_cofounder",
executionMode,
goalTitle: values.goalTitle.trim(),
goalDescription: values.goalDescription.trim(),
connectorId: selectedConnector?.id as ConnectorId | undefined,
connectorModel: selectedModel.trim(),
connectorPoolSelections: poolSelections,
}),
);
setLaunchSummary(result);
setStep("launch");
} catch (submitError) {
setError(
submitError instanceof Error ? submitError.message : t("onboarding.unableToBootstrap"),
);
} finally {
setLoading(false);
}
},
[companyValues, executionMode, loading, poolSelections, selectedConnector?.id, selectedModel, t],
);
const finishLaunch = useCallback(async () => {
if (!launchSummary) return;
setLoading(true);
setError(null);
try {
await onDone(launchSummary);
} catch (submitError) {
setError(
submitError instanceof Error
? submitError.message
: t("onboarding.unableToOpenControlCenter"),
);
} finally {
setLoading(false);
}
}, [launchSummary, onDone, t]);
const wizardValue = useMemo<OnboardingWizardContextValue>(
() => ({
currentLocale,
t,
error,
loading,
launchSummary,
nodeStatus,
nodeChecking,
onCheckNode: () => void checkNode(),
onInstallCommand: (commandKey) => void handleInstallCommand(commandKey),
}),
[currentLocale, t, error, loading, launchSummary, nodeStatus, nodeChecking, checkNode, handleInstallCommand],
);
const connectorsValue = useMemo<OnboardingConnectorsContextValue>(
() => ({
connectors,
hasAtLeastOneReady,
onTestConnector: (id) => void handleTestConnector(id),
onSaveConnector: (id, env, model, notes) => void handleSaveConnector(id, env, model, notes),
selectedConnectorId,
setSelectedConnectorId,
selectedModel,
setSelectedModel,
selectedConnector,
executionReadyConnectors,
connectorModelPools,
modelOptions,
loadingModels,
totalEnabledModels,
selectedConnectorEnabledModels,
onToggleConnectorModel: (connectorId, modelId) =>
void toggleConnectorModel(connectorId, modelId),
onSetAllConnectorModels: (connectorId, enabled) =>
void setAllConnectorModels(connectorId, enabled),
}),
[
connectors,
hasAtLeastOneReady,
handleTestConnector,
handleSaveConnector,
selectedConnectorId,
setSelectedConnectorId,
selectedModel,
setSelectedModel,
selectedConnector,
executionReadyConnectors,
connectorModelPools,
modelOptions,
loadingModels,
totalEnabledModels,
selectedConnectorEnabledModels,
toggleConnectorModel,
setAllConnectorModels,
],
);
const isWelcome = step === "welcome";
const heroStep: OnboardingStep = step as OnboardingStep;
// Completion flags drive the tab ticks. Each rule mirrors the
// validation that the in-step "Continue" button enforces, so a user
// who navigates tabs freely cannot land on "mission" with a green
// "company" tick unless company data is actually valid. `launch`
// does not get a tab (it has no independent field; it just consumes
// the others).
const navigateToStep = useCallback((target: OnboardingStepName) => {
setError(null);
setStep(target);
}, []);
const tabCompletion: OnboardingTabCompletion = useMemo(
() => ({
nodecheck: Boolean(nodeStatus?.installed),
providers: hasAtLeastOneReady,
company: companyValues.companyName.trim().length > 0 && selectedConnectorId !== "",
modelpool: totalEnabledModels > 0,
executionmode: true, // has a default ("ai_ceo")
mission: missionValues.goalTitle.trim().length > 0,
}),
[
nodeStatus?.installed,
hasAtLeastOneReady,
companyValues.companyName,
selectedConnectorId,
totalEnabledModels,
missionValues.goalTitle,
],
);
return (
<div
className={`relative h-[100dvh] overflow-hidden bg-background ${
isWelcome ? "flex items-stretch" : "grid md:grid-cols-[minmax(0,1fr)_540px]"
}`}
>
<div className="sidebar-drag absolute inset-x-0 top-0 z-10 h-[38px]" />
{isWelcome ? (
<WelcomeStep
onContinue={() => setStep("nodecheck")}
currentLocale={currentLocale}
onLocaleChange={onLocaleChange}
t={t}
/>
) : (
<OnboardingWizardProvider value={wizardValue}>
<OnboardingConnectorsProvider value={connectorsValue}>
<OnboardingStepPanel
step={heroStep}
canCancel={canCancel}
onCancel={onCancel}
isFirstLaunch={isFirstLaunch}
onBack={setStep}
onNavigateToStep={navigateToStep}
tabCompletion={tabCompletion}
onContinueFromNodeCheck={() => {
setError(null);
setStep("providers");
}}
onContinueFromProviders={() => {
setError(null);
setStep("company");
}}
onContinueFromCompany={handleContinueFromCompany}
onContinueFromModelPool={() => {
setError(null);
setStep("executionmode");
}}
onContinueFromExecutionMode={handleContinueFromExecutionMode}
onLaunchCompany={(values) => void launchCompany(values)}
onFinishLaunch={() => void finishLaunch()}
onPickDirectory={pickDirectory}
companyValues={companyValues}
missionValues={missionValues}
executionMode={executionMode}
/>
</OnboardingConnectorsProvider>
</OnboardingWizardProvider>
)}
</div>
);
}
@@ -1,158 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { ArrowRight, CaretDown, CaretRight } from "../ui/icons";
import { ActionButton } from "../ui";
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
import {
CONNECTOR_CATALOG,
listFeaturedOnboardingConnectors,
} from "@shared/connector-catalog";
import { OnboardingStep } from "./OnboardingStep";
import { useOnboardingWizard } from "./OnboardingContext";
import { OnboardingProviderDispatch } from "./provider-cards/ProviderCard";
interface ProvidersStepProps {
onContinue: () => void;
onBack: () => void;
}
const FEATURED = listFeaturedOnboardingConnectors();
const FEATURED_IDS = new Set(FEATURED.map((entry) => entry.id));
const ADVANCED = CONNECTOR_CATALOG.filter((entry) => !FEATURED_IDS.has(entry.id));
export function ProvidersStep({ onContinue, onBack }: ProvidersStepProps) {
const { t, error } = useOnboardingWizard();
const [credentialsMap, setCredentialsMap] = useState<Map<string, CredentialSummary>>(
new Map(),
);
const [expandedId, setExpandedId] = useState<string | null>(FEATURED[0]?.id ?? null);
const [advancedOpen, setAdvancedOpen] = useState(false);
useEffect(() => {
const desktop = window.agentCompany;
if (!desktop) return;
let cancelled = false;
const refresh = async () => {
const result = await desktop.listCredentials();
if (cancelled || !result.ok) return;
setCredentialsMap(new Map(result.data.map((row) => [row.connectorId, row])));
};
void refresh();
const unsubscribe = desktop.subscribe((event) => {
if (event.type !== "credentials-changed") return;
void refresh();
});
return () => {
cancelled = true;
unsubscribe();
};
}, []);
const readyCount = useMemo(
() => Array.from(credentialsMap.values()).filter((c) => c.status === "active").length,
[credentialsMap],
);
const canContinue = readyCount > 0;
const toggle = (id: string) =>
setExpandedId((current) => (current === id ? null : id));
return (
<OnboardingStep
step="providers"
eyebrow={t("onboarding.stepLabelProviders")}
title={t("onboarding.providers.title")}
subtitle={t("onboarding.providers.subtitle")}
>
<section aria-label={t("onboarding.providers.featuredHeading")} className="space-y-3">
<div className="flex items-baseline justify-between">
<h3 className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{t("onboarding.providers.featuredHeading")}
</h3>
<span className="text-[11px] text-muted-foreground/70">
{readyCount > 0
? `${readyCount} ${t("onboarding.providers.ready")}`
: t("onboarding.providers.noneConfigured")}
</span>
</div>
{FEATURED.map((entry) => (
<OnboardingProviderDispatch
key={entry.id}
entry={entry}
credential={credentialsMap.get(entry.id) ?? null}
expanded={expandedId === entry.id}
onToggle={() => toggle(entry.id)}
t={t}
/>
))}
</section>
<section aria-label={t("onboarding.providers.advancedHeading")} className="mt-6">
<button
type="button"
onClick={() => setAdvancedOpen((value) => !value)}
className="flex w-full items-center justify-between rounded-md border border-border bg-card px-4 py-2.5 text-left transition hover:bg-muted/40"
>
<span className="flex items-center gap-2">
{advancedOpen ? (
<CaretDown size={13} className="text-muted-foreground" />
) : (
<CaretRight size={13} className="text-muted-foreground" />
)}
<span className="text-[12px] font-semibold text-foreground">
{t("onboarding.providers.advancedHeading")}
</span>
</span>
<span className="text-[11px] text-muted-foreground/70">
{`${ADVANCED.length} ${t("onboarding.providers.providersLabel")}`}
</span>
</button>
{advancedOpen ? (
<div className="mt-3 space-y-3">
{ADVANCED.map((entry) => (
<OnboardingProviderDispatch
key={entry.id}
entry={entry}
credential={credentialsMap.get(entry.id) ?? null}
expanded={expandedId === entry.id}
onToggle={() => toggle(entry.id)}
t={t}
/>
))}
</div>
) : null}
</section>
{error ? (
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3 text-[13px] text-destructive">
{error}
</div>
) : null}
<div className="mt-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">
{!canContinue ? (
<span className="text-[12px] text-muted-foreground">
{t("onboarding.providers.atLeastOne")}
</span>
) : null}
<ActionButton
label={t("onboarding.continue")}
onClick={onContinue}
icon={ArrowRight}
tone="accent"
disabled={!canContinue}
/>
</div>
</div>
</OnboardingStep>
);
}
@@ -1,73 +0,0 @@
import { motion } from "framer-motion";
import { GlobeSimple, Rocket } from "../ui/icons";
import type { AppLocale } from "@shared/locale";
import { APP_LOCALES, LOCALE_LABELS } from "@shared/locale";
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">
<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>
</motion.div>
</div>
);
}
@@ -1,38 +0,0 @@
import type { ConnectorCatalogEntry } from "@shared/connector-catalog";
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
import type { TranslationFn } from "../../../i18n";
import { OAuthProviderCard } from "./oauth-card";
import { ApiKeyProviderCard } from "./api-key-card";
import { CloudProviderCard } from "./cloud-card";
import { LocalProviderCard } from "./local-card";
export interface ProviderCardProps {
entry: ConnectorCatalogEntry;
credential: CredentialSummary | null;
expanded: boolean;
onToggle: () => void;
onOpenSettings?: () => void;
t: TranslationFn;
}
/**
* Dispatch based on the catalog auth descriptor. Six auth kinds live behind
* four dedicated cards — cloud variants share a card because their onboarding
* affordance is identical (deep link to Settings). A universal form generator
* is intentionally avoided so each auth flow stays legible end-to-end.
*/
export function OnboardingProviderDispatch(props: ProviderCardProps) {
const { entry } = props;
switch (entry.auth.type) {
case "oauth":
return <OAuthProviderCard {...props} />;
case "api_key":
return <ApiKeyProviderCard {...props} />;
case "aws":
case "azure":
case "gcp_adc":
return <CloudProviderCard {...props} />;
case "local":
return <LocalProviderCard {...props} />;
}
}
@@ -1,134 +0,0 @@
import { useState } from "react";
import { FloppyDisk } from "../../ui/icons";
import type { ConnectorCatalogEntry } from "@shared/connector-catalog";
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
import type { TranslationFn } from "../../../i18n";
import {
InlineFeedback,
OnboardingProviderCard,
OnboardingStatusBadge,
type OnboardingCardStatus,
} from "./shared";
import { Input } from "../../ui";
interface ApiKeyCardProps {
entry: ConnectorCatalogEntry;
credential: CredentialSummary | null;
expanded: boolean;
onToggle: () => void;
t: TranslationFn;
}
export function ApiKeyProviderCard({
entry,
credential,
expanded,
onToggle,
t,
}: ApiKeyCardProps) {
if (entry.auth.type !== "api_key") {
throw new Error(`ApiKeyProviderCard received non-api_key connector: ${entry.id}`);
}
const envKey = entry.auth.envKey;
const [value, setValue] = useState("");
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
const status: OnboardingCardStatus = busy
? "checking"
: credential?.status ?? "not_configured";
const connected = credential?.status === "active";
const handleSave = async () => {
if (!window.agentCompany) return;
const trimmed = value.trim();
if (trimmed.length === 0) {
setFeedback(t("onboarding.providers.apiKeyRequired"));
return;
}
setBusy(true);
setFeedback(null);
const result = await window.agentCompany.saveCredential({
connectorId: entry.id,
payload: {
kind: "api_key",
value: { source: "literal", value: trimmed },
},
});
setBusy(false);
if (!result.ok) {
setFeedback(result.error.message);
return;
}
setValue("");
setFeedback(t("onboarding.providers.saved"));
};
const handleRemove = async () => {
if (!window.agentCompany) return;
setBusy(true);
setFeedback(null);
const result = await window.agentCompany.removeCredential({ connectorId: entry.id });
setBusy(false);
if (!result.ok) setFeedback(result.error.message);
};
return (
<OnboardingProviderCard
label={entry.label}
description={entry.description}
badge={<OnboardingStatusBadge status={status} t={t} />}
expanded={expanded}
onToggle={onToggle}
>
{entry.notes ? (
<div className="text-[12px] leading-relaxed text-muted-foreground">{entry.notes}</div>
) : null}
<form
onSubmit={(event) => {
event.preventDefault();
void handleSave();
}}
className="space-y-3"
>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{t("onboarding.providers.apiKeyLabel")}{" "}
<span className="font-mono text-[11px] text-muted-foreground/70">({envKey})</span>
</label>
<Input
type="password"
value={value}
onChange={setValue}
placeholder={connected ? t("onboarding.providers.apiKeyReplacePlaceholder") : ""}
disabled={busy}
/>
</div>
<div className="flex items-center gap-2">
<button
type="submit"
disabled={busy || value.trim().length === 0}
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} />
{busy ? t("onboarding.providers.saving") : t("onboarding.providers.save")}
</button>
{connected ? (
<button
type="button"
onClick={() => void handleRemove()}
disabled={busy}
className="text-[12px] text-muted-foreground transition hover:text-destructive disabled:opacity-40"
>
{t("onboarding.providers.disconnect")}
</button>
) : null}
</div>
</form>
{credential?.displayHint ? (
<div className="text-[11px] text-muted-foreground">{credential.displayHint}</div>
) : null}
<InlineFeedback message={feedback} />
</OnboardingProviderCard>
);
}
@@ -1,66 +0,0 @@
import { ArrowSquareOut } from "../../ui/icons";
import type { ConnectorCatalogEntry } from "@shared/connector-catalog";
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
import type { TranslationFn } from "../../../i18n";
import {
InlineFeedback,
OnboardingProviderCard,
OnboardingStatusBadge,
type OnboardingCardStatus,
} from "./shared";
interface CloudCardProps {
entry: ConnectorCatalogEntry;
credential: CredentialSummary | null;
expanded: boolean;
onToggle: () => void;
onOpenSettings?: () => void;
t: TranslationFn;
}
/**
* Cloud connectors (AWS / Azure / GCP ADC) need multi-field forms. During
* onboarding we show a short primer and route the user to Settings where the
* dedicated forms live. Status reflects an existing credential if one was
* already set up outside onboarding.
*/
export function CloudProviderCard({
entry,
credential,
expanded,
onToggle,
onOpenSettings,
t,
}: CloudCardProps) {
const status: OnboardingCardStatus = credential?.status ?? "not_configured";
return (
<OnboardingProviderCard
label={entry.label}
description={entry.description}
badge={<OnboardingStatusBadge status={status} t={t} />}
expanded={expanded}
onToggle={onToggle}
>
{entry.notes ? (
<div className="text-[12px] leading-relaxed text-muted-foreground">{entry.notes}</div>
) : null}
<div className="rounded-md border border-dashed border-border bg-muted/30 px-3 py-2.5 text-[12px] leading-relaxed text-muted-foreground">
{t("onboarding.providers.cloudPrimer")}
</div>
<button
type="button"
onClick={onOpenSettings}
disabled={!onOpenSettings}
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"
>
<ArrowSquareOut size={14} />
{t("onboarding.providers.configureInSettings")}
</button>
{credential?.displayHint ? (
<div className="text-[11px] text-muted-foreground">{credential.displayHint}</div>
) : null}
<InlineFeedback message={null} />
</OnboardingProviderCard>
);
}
@@ -1,153 +0,0 @@
import { useEffect, useState } from "react";
import { FloppyDisk } from "../../ui/icons";
import type { ConnectorCatalogEntry } from "@shared/connector-catalog";
import { isLocalConnectorBaseUrl, type CredentialSummary } from "@shared/contracts/schemas-credentials";
import type { TranslationFn } from "../../../i18n";
import {
InlineFeedback,
OnboardingProviderCard,
OnboardingStatusBadge,
type OnboardingCardStatus,
} from "./shared";
import { Input } from "../../ui";
interface LocalProviderCardProps {
entry: ConnectorCatalogEntry;
credential: CredentialSummary | null;
expanded: boolean;
onToggle: () => void;
t: TranslationFn;
}
/**
* Onboarding card for local OpenAI-compatible servers (Ollama, LM Studio).
*
* The catalog supplies a sensible default URL per entry; the user can
* override it to point at a private LAN server or a non-default port.
* The card never asks for an API key — local servers
* accept any bearer value, so the credential resolver supplies the
* connector id as a non-empty placeholder for the OpenAI SDK.
*/
export function LocalProviderCard({
entry,
credential,
expanded,
onToggle,
t,
}: LocalProviderCardProps) {
if (entry.auth.type !== "local") {
throw new Error(`LocalProviderCard received non-local connector: ${entry.id}`);
}
const defaultBaseUrl = entry.auth.defaultBaseUrl;
const initialBaseUrl = credential?.displayHint?.startsWith("local:")
? credential.displayHint.slice("local:".length)
: defaultBaseUrl;
const [baseUrl, setBaseUrl] = useState(initialBaseUrl);
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
useEffect(() => {
const next = credential?.displayHint?.startsWith("local:")
? credential.displayHint.slice("local:".length)
: defaultBaseUrl;
setBaseUrl(next);
}, [credential?.displayHint, defaultBaseUrl]);
const status: OnboardingCardStatus = busy
? "checking"
: credential?.status ?? "not_configured";
const connected = credential?.status === "active";
const handleSave = async () => {
if (!window.agentCompany) return;
const trimmed = baseUrl.trim();
if (trimmed.length === 0) {
setFeedback(t("onboarding.providers.baseUrlRequired"));
return;
}
if (!isLocalConnectorBaseUrl(trimmed)) {
setFeedback(t("onboarding.providers.baseUrlInvalid"));
return;
}
setBusy(true);
setFeedback(null);
const result = await window.agentCompany.saveCredential({
connectorId: entry.id,
payload: {
kind: "local",
value: { baseUrl: trimmed },
},
});
setBusy(false);
if (!result.ok) {
setFeedback(result.error.message);
return;
}
setFeedback(t("onboarding.providers.saved"));
};
const handleRemove = async () => {
if (!window.agentCompany) return;
setBusy(true);
setFeedback(null);
const result = await window.agentCompany.removeCredential({ connectorId: entry.id });
setBusy(false);
if (!result.ok) setFeedback(result.error.message);
};
return (
<OnboardingProviderCard
label={entry.label}
description={entry.description}
badge={<OnboardingStatusBadge status={status} t={t} />}
expanded={expanded}
onToggle={onToggle}
>
{entry.notes ? (
<div className="text-[12px] leading-relaxed text-muted-foreground">{entry.notes}</div>
) : null}
<form
onSubmit={(event) => {
event.preventDefault();
void handleSave();
}}
className="space-y-3"
>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{t("onboarding.providers.baseUrlLabel")}{" "}
<span className="font-mono text-[11px] text-muted-foreground/70">({defaultBaseUrl})</span>
</label>
<Input
type="text"
value={baseUrl}
onChange={setBaseUrl}
placeholder={defaultBaseUrl}
disabled={busy}
/>
</div>
<div className="flex items-center gap-2">
<button
type="submit"
disabled={busy || baseUrl.trim().length === 0}
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} />
{busy ? t("onboarding.providers.saving") : t("onboarding.providers.save")}
</button>
{connected ? (
<button
type="button"
onClick={() => void handleRemove()}
disabled={busy}
className="text-[12px] text-muted-foreground transition hover:text-destructive disabled:opacity-40"
>
{t("onboarding.providers.disconnect")}
</button>
) : null}
</div>
</form>
<InlineFeedback message={feedback} />
</OnboardingProviderCard>
);
}
@@ -1,96 +0,0 @@
import { useState } from "react";
import { SignIn } from "../../ui/icons";
import type { ConnectorCatalogEntry } from "@shared/connector-catalog";
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
import type { TranslationFn } from "../../../i18n";
import {
InlineFeedback,
OnboardingProviderCard,
OnboardingStatusBadge,
type OnboardingCardStatus,
} from "./shared";
interface OAuthCardProps {
entry: ConnectorCatalogEntry;
credential: CredentialSummary | null;
expanded: boolean;
onToggle: () => void;
t: TranslationFn;
}
export function OAuthProviderCard({
entry,
credential,
expanded,
onToggle,
t,
}: OAuthCardProps) {
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
const status: OnboardingCardStatus = busy
? "checking"
: credential?.status ?? "not_configured";
const connected = credential?.status === "active";
const handleLogin = async () => {
if (!window.agentCompany) return;
setBusy(true);
setFeedback(null);
const result = await window.agentCompany.startOAuth({ connectorId: entry.id });
setBusy(false);
if (!result.ok) setFeedback(result.error.message);
};
const handleRemove = async () => {
if (!window.agentCompany) return;
setBusy(true);
setFeedback(null);
const result = await window.agentCompany.removeCredential({ connectorId: entry.id });
setBusy(false);
if (!result.ok) setFeedback(result.error.message);
};
return (
<OnboardingProviderCard
label={entry.label}
description={entry.description}
badge={<OnboardingStatusBadge status={status} t={t} />}
expanded={expanded}
onToggle={onToggle}
>
{entry.notes ? (
<div className="text-[12px] leading-relaxed text-muted-foreground">{entry.notes}</div>
) : null}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void handleLogin()}
disabled={busy}
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"
>
<SignIn size={14} />
{busy
? t("onboarding.providers.signingIn")
: connected
? t("onboarding.providers.reconnect")
: t("onboarding.providers.signIn")}
</button>
{connected ? (
<button
type="button"
onClick={() => void handleRemove()}
disabled={busy}
className="text-[12px] text-muted-foreground transition hover:text-destructive disabled:opacity-40"
>
{t("onboarding.providers.disconnect")}
</button>
) : null}
</div>
{credential?.displayHint ? (
<div className="text-[11px] text-muted-foreground">{credential.displayHint}</div>
) : null}
<InlineFeedback message={feedback} />
</OnboardingProviderCard>
);
}
@@ -1,101 +0,0 @@
import { CaretDown, CaretRight } from "../../ui/icons";
import type { ReactNode } from "react";
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
import type { TranslationFn } from "../../../i18n";
import { Card } from "../../ui";
export type OnboardingCardStatus = CredentialSummary["status"] | "checking" | "not_configured";
export function OnboardingProviderCard({
label,
description,
badge,
expanded,
onToggle,
children,
}: {
label: string;
description: string;
badge: ReactNode;
expanded: boolean;
onToggle: () => void;
children: ReactNode;
}) {
return (
<Card padding="none" className="overflow-hidden">
<button
type="button"
onClick={onToggle}
className="flex w-full cursor-pointer items-start justify-between gap-3 px-4 py-3 text-left transition hover:bg-muted/40"
>
<div className="flex min-w-0 items-start gap-2.5">
{expanded ? (
<CaretDown size={14} className="mt-1 flex-shrink-0 text-muted-foreground" />
) : (
<CaretRight size={14} className="mt-1 flex-shrink-0 text-muted-foreground" />
)}
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold text-foreground">{label}</div>
<div className="mt-0.5 text-[12px] leading-snug text-muted-foreground line-clamp-2">
{description}
</div>
</div>
</div>
<div className="flex-shrink-0 pt-0.5">{badge}</div>
</button>
{expanded ? <div className="space-y-3 px-4 pb-4">{children}</div> : null}
</Card>
);
}
export function OnboardingStatusBadge({
status,
t,
}: {
status: OnboardingCardStatus;
t: TranslationFn;
}) {
if (status === "active") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-success-muted px-2 py-0.5 text-[11px] font-medium text-success">
<span className="h-1.5 w-1.5 rounded-full bg-success" />
{t("onboarding.providers.statusReady")}
</span>
);
}
if (status === "expired") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-warning-muted px-2 py-0.5 text-[11px] font-medium text-warning">
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
{t("onboarding.providers.statusExpired")}
</span>
);
}
if (status === "error" || status === "revoked") {
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" />
{t("onboarding.providers.statusError")}
</span>
);
}
if (status === "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" />
{t("onboarding.providers.statusChecking")}
</span>
);
}
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.providers.statusNotConfigured")}
</span>
);
}
export function InlineFeedback({ message }: { message: string | null }) {
if (!message) return null;
return <div className="pt-1 text-[11px] leading-relaxed text-muted-foreground">{message}</div>;
}
@@ -1,67 +0,0 @@
import { useCallback } from "react";
import type { ConnectorId } from "@shared/types";
import type { ProfileSnapshot } from "@shared/types";
import type { openTerminalCommandSchema } from "@shared/contracts";
import type { z } from "zod";
import { unwrap } from "../../lib/desktop";
export function useConnectorCallbacks(connectors: ProfileSnapshot["connectors"]) {
const handleTestConnector = useCallback(async (connectorId: string) => {
try {
unwrap(await window.agentCompany.testConnector(connectorId as ConnectorId));
} catch (error) {
console.warn(
`[onboarding/useConnectorCallbacks] testConnector(${connectorId}) failed (result reflected via domain-changed): ${error instanceof Error ? error.message : String(error)}`,
);
}
}, []);
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,
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) {
console.warn(
`[onboarding/useConnectorCallbacks] saveConnector+testConnector(${connectorId}) failed (result reflected via domain-changed): ${error instanceof Error ? error.message : String(error)}`,
);
}
}, [connectors]);
const handleInstallCommand = useCallback(async (commandKey: z.infer<typeof openTerminalCommandSchema>["commandKey"]) => {
try {
unwrap(await window.agentCompany.openTerminalCommand({ commandKey }));
} catch (error) {
console.warn(
`[onboarding/useConnectorCallbacks] openTerminalCommand(${commandKey}) failed: ${error instanceof Error ? error.message : String(error)}`,
);
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 (error) {
console.warn(
`[onboarding/useConnectorCallbacks] post-install testConnector(${c.id}) failed (attempt ${attempts}): ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
}, 5000);
}, [connectors]);
return { handleTestConnector, handleSaveConnector, handleInstallCommand };
}
@@ -1,310 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
mergeConnectorModelEntries,
type ConnectorModelEntry,
} from "@shared/connector-models";
import { isConnectorExecutionReady } from "@shared/connector-policy";
import type { AppLocale } from "@shared/locale";
import type { ConnectorId, ProfileSnapshot } from "@shared/types";
import type { TranslationFn } from "../../i18n";
/**
* Structural equality over the per-connector model catalog. Returns true
* when the two maps hold the same connector ids, each list in the same
* order, and each entry has the same id/label/enabled flag. Used to keep
* React state references stable across snapshot refreshes so an open
* native <select> dropdown is not torn down mid-interaction.
*/
function connectorCatalogsEqual(
a: Record<string, ConnectorModelEntry[]>,
b: Record<string, ConnectorModelEntry[]>,
): boolean {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
const listA = a[key];
const listB = b[key];
if (!listB || listA.length !== listB.length) return false;
for (let i = 0; i < listA.length; i++) {
const x = listA[i];
const y = listB[i];
if (x.id !== y.id || x.label !== y.label || x.enabled !== y.enabled) return false;
}
}
return true;
}
/**
* Per-connector disabled-model list, shaped to feed straight into
* `bootstrapOnboarding`'s input. Empty array means "every model enabled."
*/
export interface ConnectorPoolSelection {
connectorId: ConnectorId;
disabledModels: Array<{ id: string; label: string }>;
}
interface UseConnectorModelSelectionResult {
executionReadyConnectors: ProfileSnapshot["connectors"];
hasAtLeastOneReady: boolean;
selectedConnectorId: ConnectorId | "";
setSelectedConnectorId: (value: ConnectorId | "") => void;
selectedConnector: ProfileSnapshot["connectors"][number] | null;
selectedModel: string;
setSelectedModel: (value: string) => void;
connectorModels: ConnectorModelEntry[];
connectorModelPools: Array<{
connector: ProfileSnapshot["connectors"][number];
models: ConnectorModelEntry[];
}>;
modelOptions: Array<{ id: string; label: string }>;
loadingModels: boolean;
totalEnabledModels: number;
selectedConnectorEnabledModels: number;
toggleConnectorModel: (connectorId: ConnectorId, modelId: string) => void;
setAllConnectorModels: (connectorId: ConnectorId, enabled: boolean) => void;
/**
* Snapshot of the user's per-connector disabled selections, ready
* to be committed with the company creation in `bootstrapOnboarding`.
*/
poolSelections: ConnectorPoolSelection[];
}
export function useConnectorModelSelection(
connectors: ProfileSnapshot["connectors"],
currentLocale: AppLocale,
t: TranslationFn,
setError: (error: string | null) => void,
): UseConnectorModelSelectionResult {
const executionReadyConnectors = useMemo(
() => connectors.filter((connector) => isConnectorExecutionReady(connector)),
[connectors],
);
const hasAtLeastOneReady = executionReadyConnectors.length > 0;
const [selectedConnectorId, setSelectedConnectorId] = useState<ConnectorId | "">(
executionReadyConnectors[0]?.id ?? "",
);
const [selectedModel, setSelectedModel] = useState("");
const [connectorModelsById, setConnectorModelsById] = useState<Record<string, ConnectorModelEntry[]>>({});
const [loadingModels, setLoadingModels] = useState(false);
useEffect(() => {
if (!selectedConnectorId && executionReadyConnectors[0]) {
setSelectedConnectorId(executionReadyConnectors[0].id);
return;
}
if (selectedConnectorId && !executionReadyConnectors.some((connector) => connector.id === selectedConnectorId)) {
setSelectedConnectorId(executionReadyConnectors[0]?.id ?? "");
}
}, [executionReadyConnectors, selectedConnectorId]);
const selectedConnector =
executionReadyConnectors.find((connector) => connector.id === selectedConnectorId) ??
executionReadyConnectors[0] ??
null;
// Onboarding is a pre-commit phase — the company does not exist in
// the DB yet, so there is no persisted disabled-models list to read.
// Initial state is "every model enabled"; user toggles mutate local
// state only, and `bootstrapOnboarding` persists the final selection
// when the company is created. This removes the global-leak bug
// where a deleted company's disabled list inherited into the next.
const loadConnectorModels = useCallback(async (connector: ProfileSnapshot["connectors"][number]) => {
if (connector.id.startsWith("claude_compat")) {
const configuredModels = (connector.model ?? "")
.split(",")
.map((model) => model.trim())
.filter(Boolean)
.map((model) => ({ id: model, label: model }));
return mergeConnectorModelEntries(configuredModels, []);
}
const enabledResult = await window.agentCompany.listConnectorModels(connector.id);
const enabledModels = enabledResult.ok ? enabledResult.data : [];
return mergeConnectorModelEntries(enabledModels, []);
}, []);
useEffect(() => {
if (!hasAtLeastOneReady) {
setConnectorModelsById({});
setSelectedModel("");
return;
}
let cancelled = false;
setLoadingModels(true);
const loadModels = async () => {
try {
const entries = await Promise.all(
executionReadyConnectors.map(async (connector) => [connector.id, await loadConnectorModels(connector)] as const),
);
if (cancelled) return;
const next = Object.fromEntries(entries);
// Guard against no-op state updates: snapshot refreshes re-run this
// effect with the same CONNECTORS data, and returning a new object
// reference here cascades through `connectorModels` → `modelOptions`
// and collapses any open native <select> dropdown. Only call
// setConnectorModelsById when the catalog actually changed.
setConnectorModelsById((previous) =>
connectorCatalogsEqual(previous, next) ? previous : next,
);
} catch (error) {
if (cancelled) return;
console.warn(
`[onboarding/useConnectorModelSelection] connector model fetch failed; clearing catalog: ${error instanceof Error ? error.message : String(error)}`,
);
setConnectorModelsById((previous) => (Object.keys(previous).length === 0 ? previous : {}));
setSelectedModel("");
} finally {
if (!cancelled) {
setLoadingModels(false);
}
}
};
void loadModels();
return () => {
cancelled = true;
};
}, [executionReadyConnectors, hasAtLeastOneReady, loadConnectorModels]);
const connectorModels = useMemo(
() => connectorModelsById[selectedConnectorId] ?? [],
[connectorModelsById, selectedConnectorId],
);
useEffect(() => {
if (!selectedConnectorId) {
setSelectedModel("");
return;
}
const enabledModels = connectorModels.filter((entry) => entry.enabled);
if (enabledModels.length === 0) {
setSelectedModel("");
return;
}
setSelectedModel((currentModel) => {
if (currentModel && enabledModels.some((entry) => entry.id === currentModel)) {
return currentModel;
}
const connectorDefinition = executionReadyConnectors.find((c) => c.id === selectedConnectorId);
const preferred = connectorDefinition?.model;
if (preferred && enabledModels.some((entry) => entry.id === preferred)) {
return preferred;
}
return enabledModels[0]?.id ?? "";
});
}, [connectorModels, selectedConnectorId, executionReadyConnectors]);
const modelOptions = useMemo(
() => connectorModels
.filter((model) => model.enabled)
.map((model) => ({ id: model.id, label: model.label })),
[connectorModels],
);
const connectorModelPools = useMemo(
() =>
executionReadyConnectors.map((connector) => ({
connector,
models: connectorModelsById[connector.id] ?? [],
})),
[connectorModelsById, executionReadyConnectors],
);
const totalEnabledModels = useMemo(
() =>
Object.values(connectorModelsById).reduce(
(count, models) => count + models.filter((model) => model.enabled).length,
0,
),
[connectorModelsById],
);
const selectedConnectorEnabledModels = useMemo(
() => connectorModels.filter((model) => model.enabled).length,
[connectorModels],
);
const toggleConnectorModel = useCallback((connectorId: ConnectorId, modelId: string) => {
const connectorModels = connectorModelsById[connectorId];
if (!connectorModels) return;
const currentEntry = connectorModels.find((model) => model.id === modelId);
if (!currentEntry) return;
const updatedModels = connectorModels.map((model) =>
model.id === modelId ? { ...model, enabled: !model.enabled } : model,
);
const updatedModelMap = { ...connectorModelsById, [connectorId]: updatedModels };
const nextEnabledCount = Object.values(updatedModelMap).reduce(
(count, models) => count + models.filter((model) => model.enabled).length,
0,
);
if (currentEntry.enabled && nextEnabledCount === 0) {
setError(
currentLocale === "zh"
? "至少保留一个全局启用模型,CEO 才能继续为团队分配工作。"
: "Keep at least one enabled model across all providers so the CEO can keep staffing the team.",
);
return;
}
setConnectorModelsById(updatedModelMap);
setError(null);
// Unused dependency: t is left in the closure list below for
// consistency with the error-copy callbacks; avoids a hook
// re-identity when the translator ref swaps locales.
void t;
}, [connectorModelsById, currentLocale, setError, t]);
/**
* Bulk-flip every model of one connector on or off. Pure local state
* during onboarding; persistence happens at `bootstrapOnboarding`.
* Disabling-all is allowed even if it zeroes the global enabled
* count — the continue gate at the wizard level catches that.
*/
const setAllConnectorModels = useCallback(
(connectorId: ConnectorId, enabled: boolean) => {
const connectorModels = connectorModelsById[connectorId];
if (!connectorModels || connectorModels.length === 0) return;
const updatedModels = connectorModels.map((model) => ({ ...model, enabled }));
setConnectorModelsById({ ...connectorModelsById, [connectorId]: updatedModels });
setError(null);
},
[connectorModelsById, setError],
);
const poolSelections = useMemo<ConnectorPoolSelection[]>(
() =>
Object.entries(connectorModelsById).map(([connectorId, models]) => ({
connectorId: connectorId as ConnectorId,
disabledModels: models
.filter((model) => !model.enabled)
.map((model) => ({ id: model.id, label: model.label })),
})),
[connectorModelsById],
);
return {
executionReadyConnectors,
hasAtLeastOneReady,
selectedConnectorId,
setSelectedConnectorId,
selectedConnector,
selectedModel,
setSelectedModel,
connectorModels,
connectorModelPools,
modelOptions,
loadingModels,
totalEnabledModels,
selectedConnectorEnabledModels,
toggleConnectorModel,
setAllConnectorModels,
poolSelections,
};
}
@@ -1,183 +0,0 @@
import { useMemo, useState } from "react";
import { FileText, FloppyDisk, NotePencil, Plus, Trash } from "../ui/icons";
import type { AgentRecord, DocumentRecord, GoalRecord, ProjectRecord, WorkProductRecord } from "@shared/types";
import { ActionButton, ConfirmDialog, EmptyState, InlineNotice, Input, ListFrame, ListRow, Modal, Select, StatusPill, TextArea } from "../ui";
import { useT } from "../../i18n";
import { useAppStore } from "../../state/store";
import { documentTypeOptions, documentStatusOptions, splitTags, parseTags } from "./helpers";
import { SectionHeader } from "./SectionHeader";
import { NEW_ENTITY_ID } from "../../lib/constants";
function DocumentEditor({ companyId, agents, projects, goals, document, onSaved }: {
companyId: string; agents: AgentRecord[]; projects: ProjectRecord[]; goals: GoalRecord[];
document: DocumentRecord | null; onSaved: () => Promise<void>;
}) {
const [title, setTitle] = useState(document?.title ?? "");
const [type, setType] = useState(document?.type ?? "technical_spec");
const [content, setContent] = useState(document?.content ?? "");
const [status, setStatus] = useState(document?.status ?? "draft");
const [reviewerAgentId, setReviewerAgentId] = useState(document?.reviewerAgentId ?? "");
const [projectId, setProjectId] = useState(document?.projectId ?? "");
const [goalId, setGoalId] = useState(document?.goalId ?? "");
const [tags, setTags] = useState(parseTags(document?.tagsJson ?? "[]"));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
if (!title.trim()) { setError("Document title is required."); return; }
setSaving(true); setError(null);
try {
const result = await window.agentCompany.saveDocument({
id: document?.id ?? null, companyId,
type: type as Parameters<typeof window.agentCompany.saveDocument>[0]["type"],
title: title.trim(), content,
authorAgentId: document?.authorAgentId ?? null, reviewerAgentId: reviewerAgentId || null,
projectId: projectId || null, goalId: goalId || null,
parentDocId: document?.parentDocId ?? null, version: document?.version ?? 1,
status: status as Parameters<typeof window.agentCompany.saveDocument>[0]["status"],
tagsJson: splitTags(tags),
});
if (!result.ok) { setError(result.error.message); return; }
await onSaved();
} finally { setSaving(false); }
}
return (
<div className="space-y-4">
{error ? <InlineNotice message={error} /> : null}
<Input label="Title" value={title} onChange={setTitle} placeholder="Architecture review summary" />
<div className="grid gap-4 md:grid-cols-2">
<Select label="Type" value={type} onChange={(value) => setType(value as typeof type)} options={documentTypeOptions} />
<Select label="Status" value={status} onChange={(value) => setStatus(value as typeof status)} options={documentStatusOptions} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Select label="Reviewer" value={reviewerAgentId} onChange={setReviewerAgentId} options={[{ value: "", label: "None" }, ...agents.map((agent) => ({ value: agent.id, label: `${agent.name} (${agent.role})` }))]} />
<Input label="Tags" value={tags} onChange={setTags} placeholder="architecture, security, release" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Select label="Project" value={projectId} onChange={setProjectId} options={[{ value: "", label: "None" }, ...projects.map((project) => ({ value: project.id, label: project.name }))]} />
<Select label="Goal" value={goalId} onChange={setGoalId} options={[{ value: "", label: "None" }, ...goals.map((goal) => ({ value: goal.id, label: goal.title }))]} />
</div>
<TextArea label="Content" value={content} onChange={setContent} rows={12} placeholder="Write the document content here." />
<div className="flex justify-end">
<ActionButton label={saving ? "Saving..." : document ? "Save document" : "Create document"} icon={FloppyDisk} tone="accent" onClick={() => void save()} loading={saving} />
</div>
</div>
);
}
function WorkProductRow({ product, companyId }: { product: WorkProductRecord; companyId: string }) {
const addToast = useAppStore((s) => s.addToast);
const subtitle = [
product.type.replaceAll("_", " "),
product.provider,
product.externalId ?? product.url ?? null,
product.updatedAt.slice(0, 10),
].filter((v): v is string => Boolean(v)).join(" · ");
// externalId is workspace-relative; route through openWorkProduct so
// the main process can join it against the work product's workspace
// root before handing off to the OS shell. Direct `openPath(externalId)`
// used to flash a failure when the renderer tried to treat the relative
// path as absolute. On failure show a single user-facing line — never
// leak the backend reason code or the resolver's null-chain detail to
// the operator, those belong in a main-process log, not the UI.
const primaryAction = product.url || (product.provider === "filesystem" && product.externalId)
? async () => {
const result = await window.agentCompany.openWorkProduct({ companyId, id: product.id });
if (!result.ok) {
addToast(`Couldn't open ${product.title}`, "danger");
}
}
: undefined;
return (
<ListRow onClick={primaryAction}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 text-left">
<div className="flex items-center gap-2 text-[13px] font-medium text-foreground">
<FileText size={15} className="text-muted-foreground" />
<span className="truncate">{product.title}</span>
</div>
<div className="mt-1 text-[11px] text-muted-foreground">{subtitle}</div>
</div>
<div data-row-action className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<StatusPill status={product.status} />
</div>
</div>
</ListRow>
);
}
interface DocumentCenterProps {
companyId: string;
documents: DocumentRecord[];
workProducts: WorkProductRecord[];
agents: AgentRecord[];
projects: ProjectRecord[];
goals: GoalRecord[];
onRefresh: () => Promise<void>;
}
export function DocumentCenter({ companyId, documents, workProducts, agents, projects, goals, onRefresh }: DocumentCenterProps) {
const t = useT();
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const activeDocument = useMemo(() => documents.find((document) => document.id === editingId) ?? null, [documents, editingId]);
const totalCount = documents.length + workProducts.length;
const isEmpty = totalCount === 0;
return (
<div>
<SectionHeader title={t("ops.documentCenter")} count={totalCount} action={() => setEditingId(NEW_ENTITY_ID)} />
{isEmpty ? (
<EmptyState
title={t("ops.documentsEmptyTitle")}
action={<ActionButton label={t("ops.documentsEmptyAction")} icon={Plus} tone="accent" onClick={() => setEditingId(NEW_ENTITY_ID)} />}
/>
) : null}
{documents.length > 0 ? (
<div>
<div className="px-4 pt-3 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{t("ops.documentsProse")}
</div>
<ListFrame>
{documents.map((document) => (
<ListRow key={document.id} onClick={() => setEditingId(document.id)}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 text-left">
<div className="flex items-center gap-2 text-[13px] font-medium text-foreground">
<FileText size={15} className="text-primary" />
<span className="truncate">{document.title}</span>
</div>
<div className="mt-1 text-[11px] text-muted-foreground">{document.type.replaceAll("_", " ")} · v{document.version} · {document.updatedAt.slice(0, 10)}</div>
</div>
<div data-row-action className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<StatusPill status={document.status} />
<button type="button" aria-label="Edit document" className="rounded p-1 text-muted-foreground hover:text-foreground" onClick={(e) => { e.stopPropagation(); setEditingId(document.id); }}><NotePencil size={14} /></button>
<button type="button" aria-label="Delete document" className="rounded p-1 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation(); setDeleteId(document.id); }}><Trash size={14} /></button>
</div>
</div>
</ListRow>
))}
</ListFrame>
</div>
) : null}
{workProducts.length > 0 ? (
<div>
<div className="px-4 pt-4 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{t("ops.documentsWorkProducts")}
</div>
<ListFrame>
{workProducts.map((product) => (
<WorkProductRow key={product.id} product={product} companyId={companyId} />
))}
</ListFrame>
</div>
) : null}
<Modal open={editingId !== null} title={activeDocument ? activeDocument.title : "New document"} onClose={() => setEditingId(null)} width="760px">
<DocumentEditor companyId={companyId} agents={agents} projects={projects} goals={goals} document={editingId === NEW_ENTITY_ID ? null : activeDocument} onSaved={async () => { setEditingId(null); await onRefresh(); }} />
</Modal>
<ConfirmDialog open={deleteId !== null} title="Delete document" message="This removes the document from the company knowledge base." onCancel={() => setDeleteId(null)} onConfirm={async () => { if (!deleteId) return; const result = await window.agentCompany.deleteDocument({ id: deleteId, companyId }); setDeleteId(null); if (result.ok) await onRefresh(); }} />
</div>
);
}
@@ -1,94 +0,0 @@
import { useMemo, useState } from "react";
import { FloppyDisk, Lightbulb, NotePencil, Trash } from "../ui/icons";
import type { KnowledgeEntryRecord } from "@shared/types";
import { ActionButton, ConfirmDialog, InlineNotice, Input, ListFrame, ListRow, Modal, Select, StatusPill, TextArea } from "../ui";
import { useT } from "../../i18n";
import { knowledgeCategoryOptions, importanceOptions, splitTags, parseTags } from "./helpers";
import { SectionHeader } from "./SectionHeader";
import { NEW_ENTITY_ID } from "../../lib/constants";
function KnowledgeEditor({ companyId, entry, onSaved }: {
companyId: string; entry: KnowledgeEntryRecord | null; onSaved: () => Promise<void>;
}) {
const [topic, setTopic] = useState(entry?.topic ?? "");
const [category, setCategory] = useState(entry?.category ?? "technical");
const [importance, setImportance] = useState(entry?.importance ?? "medium");
const [content, setContent] = useState(entry?.content ?? "");
const [tags, setTags] = useState(parseTags(entry?.tagsJson ?? "[]"));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
if (!topic.trim() || !content.trim()) { setError("Topic and content are required."); return; }
setSaving(true); setError(null);
try {
const result = await window.agentCompany.saveKnowledgeEntry({
id: entry?.id ?? null, companyId,
category: category as Parameters<typeof window.agentCompany.saveKnowledgeEntry>[0]["category"],
topic: topic.trim(), content: content.trim(),
authorAgentId: entry?.authorAgentId ?? null,
importance: importance as Parameters<typeof window.agentCompany.saveKnowledgeEntry>[0]["importance"],
tagsJson: splitTags(tags),
referencedEntityType: entry?.referencedEntityType ?? null,
referencedEntityId: entry?.referencedEntityId ?? null,
});
if (!result.ok) { setError(result.error.message); return; }
await onSaved();
} finally { setSaving(false); }
}
return (
<div className="space-y-4">
{error ? <InlineNotice message={error} /> : null}
<Input label="Topic" value={topic} onChange={setTopic} placeholder="What did the team learn?" />
<div className="grid gap-4 md:grid-cols-2">
<Select label="Category" value={category} onChange={(value) => setCategory(value as typeof category)} options={knowledgeCategoryOptions} />
<Select label="Importance" value={importance} onChange={(value) => setImportance(value as typeof importance)} options={importanceOptions} />
</div>
<Input label="Tags" value={tags} onChange={setTags} placeholder="incident, auth, onboarding" />
<TextArea label="Content" value={content} onChange={setContent} rows={10} placeholder="Enter the knowledge content" />
<div className="flex justify-end">
<ActionButton label={saving ? "Saving..." : entry ? "Save entry" : "Create entry"} icon={FloppyDisk} tone="accent" onClick={() => void save()} loading={saving} />
</div>
</div>
);
}
export function KnowledgeBaseManager({ companyId, entries, onRefresh }: {
companyId: string; entries: KnowledgeEntryRecord[]; onRefresh: () => Promise<void>;
}) {
const t = useT();
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const activeEntry = useMemo(() => entries.find((entry) => entry.id === editingId) ?? null, [entries, editingId]);
return (
<div>
<SectionHeader title={t("ops.knowledgeBase")} count={entries.length} action={() => setEditingId(NEW_ENTITY_ID)} />
<ListFrame>
{entries.map((entry) => (
<ListRow key={entry.id}>
<div className="flex items-start justify-between gap-3">
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setEditingId(entry.id)}>
<div className="flex items-center gap-2 text-[13px] font-medium text-foreground">
<Lightbulb size={15} className="text-primary" />
<span className="truncate">{entry.topic}</span>
</div>
<div className="mt-1 text-[11px] text-muted-foreground">{entry.category.replaceAll("_", " ")} · {entry.updatedAt.slice(0, 10)}</div>
</button>
<div className="flex items-center gap-2">
<StatusPill status={entry.importance} />
<button type="button" aria-label="Edit entry" className="rounded p-1 text-muted-foreground hover:text-foreground" onClick={() => setEditingId(entry.id)}><NotePencil size={14} /></button>
<button type="button" aria-label="Delete entry" className="rounded p-1 text-muted-foreground hover:text-destructive" onClick={() => setDeleteId(entry.id)}><Trash size={14} /></button>
</div>
</div>
</ListRow>
))}
</ListFrame>
<Modal open={editingId !== null} title={activeEntry ? activeEntry.topic : "New knowledge entry"} onClose={() => setEditingId(null)} width="760px">
<KnowledgeEditor companyId={companyId} entry={editingId === NEW_ENTITY_ID ? null : activeEntry} onSaved={async () => { setEditingId(null); await onRefresh(); }} />
</Modal>
<ConfirmDialog open={deleteId !== null} title="Delete knowledge entry" message="This will permanently delete this entry." onCancel={() => setDeleteId(null)} onConfirm={async () => { if (!deleteId) return; const result = await window.agentCompany.deleteKnowledgeEntry({ id: deleteId, companyId }); setDeleteId(null); if (result.ok) await onRefresh(); }} />
</div>
);
}
@@ -1,108 +0,0 @@
import { useMemo, useState } from "react";
import { CalendarBlank, FloppyDisk, NotePencil, Trash } from "../ui/icons";
import type { AgentRecord, MeetingRecord } from "@shared/types";
import { ActionButton, ConfirmDialog, InlineNotice, Input, ListFrame, ListRow, Modal, Select, StatusPill } from "../ui";
import { useT } from "../../i18n";
import { meetingTypeOptions, meetingStatusOptions, splitTags, parseTags, parseParticipantIds, formatParticipantIds } from "./helpers";
import { SectionHeader } from "./SectionHeader";
import { NEW_ENTITY_ID } from "../../lib/constants";
function MeetingEditor({ companyId, agents, meeting, onSaved }: {
companyId: string; agents: AgentRecord[]; meeting: MeetingRecord | null; onSaved: () => Promise<void>;
}) {
const [title, setTitle] = useState(meeting?.title ?? "");
const [type, setType] = useState(meeting?.type ?? "department_sync");
const [status, setStatus] = useState(meeting?.status ?? "scheduled");
const [scheduledAt, setScheduledAt] = useState(meeting?.scheduledAt?.slice(0, 16) ?? new Date().toISOString().slice(0, 16));
const [durationMinutes, setDurationMinutes] = useState(String(meeting?.durationMinutes ?? 30));
const [agenda, setAgenda] = useState(parseTags(meeting?.agendaJson ?? "[]"));
const [notes, setNotes] = useState(parseTags(meeting?.notesJson ?? "[]"));
const [decisions, setDecisions] = useState(parseTags(meeting?.decisionsJson ?? "[]"));
const [participants, setParticipants] = useState(formatParticipantIds(meeting?.participantAgentIds ?? "[]", agents));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
if (!title.trim()) { setError("Meeting title is required."); return; }
setSaving(true); setError(null);
try {
const participantIds = parseParticipantIds(participants)
.map((value) => agents.find((agent) => agent.name === value || agent.id === value)?.id ?? value)
.filter(Boolean);
const result = await window.agentCompany.saveMeeting({
id: meeting?.id ?? null, companyId,
type: type as Parameters<typeof window.agentCompany.saveMeeting>[0]["type"],
title: title.trim(), organizerAgentId: meeting?.organizerAgentId ?? null,
participantAgentIds: JSON.stringify(participantIds),
scheduledAt: new Date(scheduledAt).toISOString(),
durationMinutes: Number.parseInt(durationMinutes, 10) || 30,
agendaJson: splitTags(agenda), notesJson: splitTags(notes),
decisionsJson: splitTags(decisions), actionItemsJson: meeting?.actionItemsJson ?? "[]",
status: status as Parameters<typeof window.agentCompany.saveMeeting>[0]["status"],
});
if (!result.ok) { setError(result.error.message); return; }
await onSaved();
} finally { setSaving(false); }
}
return (
<div className="space-y-4">
{error ? <InlineNotice message={error} /> : null}
<Input label="Title" value={title} onChange={setTitle} placeholder="Sprint review" />
<div className="grid gap-4 md:grid-cols-2">
<Select label="Type" value={type} onChange={(value) => setType(value as typeof type)} options={meetingTypeOptions} />
<Select label="Status" value={status} onChange={(value) => setStatus(value as typeof status)} options={meetingStatusOptions} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Input label="Scheduled At" value={scheduledAt} onChange={setScheduledAt} type="datetime-local" />
<Input label="Duration (minutes)" value={durationMinutes} onChange={setDurationMinutes} type="number" />
</div>
<Input label="Participants" value={participants} onChange={setParticipants} placeholder="Comma-separated agent names or ids" />
<Input label="Agenda" value={agenda} onChange={setAgenda} placeholder="Planning, blockers, decisions" />
<Input label="Notes" value={notes} onChange={setNotes} placeholder="Key notes" />
<Input label="Decisions" value={decisions} onChange={setDecisions} placeholder="Decision 1, Decision 2" />
<div className="flex justify-end">
<ActionButton label={saving ? "Saving..." : meeting ? "Save meeting" : "Create meeting"} icon={FloppyDisk} tone="accent" onClick={() => void save()} loading={saving} />
</div>
</div>
);
}
export function MeetingsManager({ companyId, meetings, agents, onRefresh }: {
companyId: string; meetings: MeetingRecord[]; agents: AgentRecord[]; onRefresh: () => Promise<void>;
}) {
const t = useT();
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const activeMeeting = useMemo(() => meetings.find((meeting) => meeting.id === editingId) ?? null, [meetings, editingId]);
return (
<div>
<SectionHeader title={t("ops.meetings")} count={meetings.length} action={() => setEditingId(NEW_ENTITY_ID)} />
<ListFrame>
{meetings.map((meeting) => (
<ListRow key={meeting.id}>
<div className="flex items-start justify-between gap-3">
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setEditingId(meeting.id)}>
<div className="flex items-center gap-2 text-[13px] font-medium text-foreground">
<CalendarBlank size={15} className="text-primary" />
<span className="truncate">{meeting.title}</span>
</div>
<div className="mt-1 text-[11px] text-muted-foreground">{meeting.type.replaceAll("_", " ")} · {new Date(meeting.scheduledAt).toLocaleString()} · {meeting.durationMinutes} min</div>
</button>
<div className="flex items-center gap-2">
<StatusPill status={meeting.status} />
<button type="button" className="rounded p-1 text-muted-foreground hover:text-foreground" onClick={() => setEditingId(meeting.id)}><NotePencil size={14} /></button>
<button type="button" className="rounded p-1 text-muted-foreground hover:text-destructive" onClick={() => setDeleteId(meeting.id)}><Trash size={14} /></button>
</div>
</div>
</ListRow>
))}
</ListFrame>
<Modal open={editingId !== null} title={activeMeeting ? activeMeeting.title : "New meeting"} onClose={() => setEditingId(null)} width="760px">
<MeetingEditor companyId={companyId} agents={agents} meeting={editingId === NEW_ENTITY_ID ? null : activeMeeting} onSaved={async () => { setEditingId(null); await onRefresh(); }} />
</Modal>
<ConfirmDialog open={deleteId !== null} title="Delete meeting" message="This removes the meeting and its operational context from the company timeline." onCancel={() => setDeleteId(null)} onConfirm={async () => { if (!deleteId) return; const result = await window.agentCompany.deleteMeeting({ id: deleteId, companyId }); setDeleteId(null); if (result.ok) await onRefresh(); }} />
</div>
);
}
@@ -1,22 +0,0 @@
import { Plus } from "../ui/icons";
import { ActionButton } from "../ui";
export function SectionHeader({
title,
count,
action,
}: {
title: string;
count: number;
action: () => void;
}) {
return (
<div className="mb-4 flex items-center justify-between">
<div>
<div className="text-[15px] font-semibold text-foreground">{title}</div>
<div className="text-[12px] text-muted-foreground">{count} item{count === 1 ? "" : "s"}</div>
</div>
<ActionButton label="New" icon={Plus} tone="accent" onClick={action} />
</div>
);
}
@@ -1,100 +0,0 @@
import { useMemo, useState } from "react";
import { FloppyDisk, NotePencil, Rows, Trash } from "../ui/icons";
import type { SprintRecord } from "@shared/types";
import { ActionButton, ConfirmDialog, InlineNotice, Input, ListFrame, ListRow, Modal, Select, StatusPill, TextArea } from "../ui";
import { useT } from "../../i18n";
import { sprintStatusOptions } from "./helpers";
import { SectionHeader } from "./SectionHeader";
import { NEW_ENTITY_ID } from "../../lib/constants";
function SprintEditor({ companyId, sprint, onSaved }: {
companyId: string; sprint: SprintRecord | null; onSaved: () => Promise<void>;
}) {
const [name, setName] = useState(sprint?.name ?? "");
const [goal, setGoal] = useState(sprint?.goal ?? "");
const [startDate, setStartDate] = useState(sprint?.startDate ?? new Date().toISOString().slice(0, 10));
const [endDate, setEndDate] = useState(sprint?.endDate ?? new Date(Date.now() + 14 * 86_400_000).toISOString().slice(0, 10));
const [status, setStatus] = useState(sprint?.status ?? "planning");
const [velocityPoints, setVelocityPoints] = useState(String(sprint?.velocityPoints ?? 20));
const [completedPoints, setCompletedPoints] = useState(String(sprint?.completedPoints ?? 0));
const [retrospectiveNotes, setRetrospectiveNotes] = useState(sprint?.retrospectiveNotes ?? "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
if (!name.trim()) { setError("Sprint name is required."); return; }
setSaving(true); setError(null);
try {
const result = await window.agentCompany.saveSprint({
id: sprint?.id ?? null, companyId, name: name.trim(), goal: goal.trim(),
startDate, endDate,
status: status as Parameters<typeof window.agentCompany.saveSprint>[0]["status"],
retrospectiveNotes,
velocityPoints: Number.parseInt(velocityPoints, 10) || 0,
completedPoints: Number.parseInt(completedPoints, 10) || 0,
});
if (!result.ok) { setError(result.error.message); return; }
await onSaved();
} finally { setSaving(false); }
}
return (
<div className="space-y-4">
{error ? <InlineNotice message={error} /> : null}
<Input label="Sprint name" value={name} onChange={setName} placeholder="Sprint 12" />
<TextArea label="Goal" value={goal} onChange={setGoal} rows={3} placeholder="What this sprint is meant to achieve." />
<div className="grid gap-4 md:grid-cols-2">
<Input label="Start date" value={startDate} onChange={setStartDate} type="date" />
<Input label="End date" value={endDate} onChange={setEndDate} type="date" />
</div>
<div className="grid gap-4 md:grid-cols-3">
<Select label="Status" value={status} onChange={(value) => setStatus(value as typeof status)} options={sprintStatusOptions} />
<Input label="Velocity points" value={velocityPoints} onChange={setVelocityPoints} type="number" />
<Input label="Completed points" value={completedPoints} onChange={setCompletedPoints} type="number" />
</div>
<TextArea label="Retrospective notes" value={retrospectiveNotes} onChange={setRetrospectiveNotes} rows={5} placeholder="What should the team retain, change, or stop?" />
<div className="flex justify-end">
<ActionButton label={saving ? "Saving..." : sprint ? "Save sprint" : "Create sprint"} icon={FloppyDisk} tone="accent" onClick={() => void save()} loading={saving} />
</div>
</div>
);
}
export function SprintManager({ companyId, sprints, onRefresh }: {
companyId: string; sprints: SprintRecord[]; onRefresh: () => Promise<void>;
}) {
const t = useT();
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const activeSprint = useMemo(() => sprints.find((sprint) => sprint.id === editingId) ?? null, [sprints, editingId]);
return (
<div>
<SectionHeader title={t("ops.sprintBoard")} count={sprints.length} action={() => setEditingId(NEW_ENTITY_ID)} />
<ListFrame>
{sprints.map((sprint) => (
<ListRow key={sprint.id}>
<div className="flex items-start justify-between gap-3">
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setEditingId(sprint.id)}>
<div className="flex items-center gap-2 text-[13px] font-medium text-foreground">
<Rows size={15} className="text-primary" />
<span className="truncate">{sprint.name}</span>
</div>
<div className="mt-1 text-[11px] text-muted-foreground">{sprint.startDate} → {sprint.endDate} · {sprint.completedPoints}/{sprint.velocityPoints} pts</div>
</button>
<div className="flex items-center gap-2">
<StatusPill status={sprint.status} />
<button type="button" className="rounded p-1 text-muted-foreground hover:text-foreground" onClick={() => setEditingId(sprint.id)}><NotePencil size={14} /></button>
<button type="button" className="rounded p-1 text-muted-foreground hover:text-destructive" onClick={() => setDeleteId(sprint.id)}><Trash size={14} /></button>
</div>
</div>
</ListRow>
))}
</ListFrame>
<Modal open={editingId !== null} title={activeSprint ? activeSprint.name : "New sprint"} onClose={() => setEditingId(null)} width="720px">
<SprintEditor companyId={companyId} sprint={editingId === NEW_ENTITY_ID ? null : activeSprint} onSaved={async () => { setEditingId(null); await onRefresh(); }} />
</Modal>
<ConfirmDialog open={deleteId !== null} title="Delete sprint" message="This removes the sprint board entry. Historical run and task records remain." onCancel={() => setDeleteId(null)} onConfirm={async () => { if (!deleteId) return; const result = await window.agentCompany.deleteSprint({ id: deleteId, companyId }); setDeleteId(null); if (result.ok) await onRefresh(); }} />
</div>
);
}
@@ -1,82 +0,0 @@
export const documentTypeOptions = [
"prd",
"technical_spec",
"design_doc",
"test_plan",
"post_mortem",
"meeting_notes",
"budget_proposal",
"hiring_requisition",
"performance_review_doc",
"sop",
"knowledge_article",
"project_brief",
"sprint_report",
"incident_report",
"onboarding_guide",
"architecture_decision",
"status_report",
"contract",
"proposal",
].map((value) => ({ value, label: value.replaceAll("_", " ") }));
export const documentStatusOptions = ["draft", "in_review", "approved", "archived", "superseded"].map((value) => ({
value,
label: value.replaceAll("_", " "),
}));
export const knowledgeCategoryOptions = [
"lesson_learned",
"best_practice",
"decision",
"process",
"technical",
"business",
"onboarding",
"incident",
].map((value) => ({ value, label: value.replaceAll("_", " ") }));
export const importanceOptions = ["critical", "high", "medium", "low"].map((value) => ({ value, label: value }));
export const meetingTypeOptions = [
"standup",
"sprint_planning",
"sprint_review",
"retrospective",
"one_on_one",
"all_hands",
"department_sync",
"incident_review",
"hiring_committee",
"budget_review",
"design_review",
"architecture_review",
].map((value) => ({ value, label: value.replaceAll("_", " ") }));
export const meetingStatusOptions = ["scheduled", "in_progress", "completed", "cancelled"].map((value) => ({ value, label: value.replaceAll("_", " ") }));
export const sprintStatusOptions = ["planning", "active", "review", "completed", "cancelled"].map((value) => ({ value, label: value }));
export function splitTags(value: string) {
return JSON.stringify(value.split(",").map((item) => item.trim()).filter(Boolean));
}
import { safeJson } from "../../lib/safe-json";
export function parseTags(value: string) {
return safeJson<string[]>(value, []).join(", ");
}
export function parseParticipantIds(value: string) {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
import type { AgentRecord } from "@shared/types";
export function formatParticipantIds(raw: string, agents: AgentRecord[]) {
return safeJson<string[]>(raw, [])
.map((id) => agents.find((agent) => agent.id === id)?.name ?? id.slice(0, 8))
.join(", ");
}
-103
View File
@@ -1,103 +0,0 @@
import { useEffect, useState } from "react";
import type { ConnectorRecord } from "@shared/types";
import { findConnectorCatalogEntry } from "@shared/connector-catalog";
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
import { InlineNotice, StatusPill } from "./ui";
const AUTH_KIND_LABEL: Record<string, string> = {
oauth: "OAuth",
api_key: "API key",
aws: "AWS",
azure: "Azure",
gcp_adc: "Google Vertex (ADC)",
};
/**
* Legacy connector detail view. Real configuration happens in
* Settings → Providers. Here we only surface catalog metadata and the current
* credential status pulled from the pi-mono credential store.
*/
export function ConnectorEditor({
connector,
}: {
connector: ConnectorRecord;
onSaved: () => Promise<void>;
}) {
const [credential, setCredential] = useState<CredentialSummary | null>(null);
const [error, setError] = useState<string | null>(null);
const catalog = findConnectorCatalogEntry(connector.id);
useEffect(() => {
const desktop = window.agentCompany;
if (!desktop) return;
let cancelled = false;
const refresh = async () => {
const result = await desktop.listCredentials();
if (cancelled) return;
if (!result.ok) {
setError(result.error.message);
return;
}
setCredential(result.data.find((row) => row.connectorId === connector.id) ?? null);
};
void refresh();
const unsubscribe = desktop.subscribe((event) => {
if (event.type !== "credentials-changed") return;
void refresh();
});
return () => {
cancelled = true;
unsubscribe();
};
}, [connector.id]);
return (
<div className="grid gap-4">
{error ? <InlineNotice message={error} /> : null}
<div className="grid gap-4 md:grid-cols-2">
<div>
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">Status</div>
<div className="flex items-center gap-3">
<StatusPill status={connector.status} />
<span className="text-[12px] text-muted-foreground">
{credential ? credential.status : "not configured"}
</span>
</div>
</div>
<div>
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">Auth</div>
<div className="text-[13px] text-foreground">
{catalog ? AUTH_KIND_LABEL[catalog.auth.type] ?? catalog.auth.type : "Unknown"}
</div>
</div>
</div>
<div>
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">Provider id</div>
<div className="font-mono text-[12px] text-foreground">{catalog?.provider ?? connector.id}</div>
</div>
{catalog?.description ? (
<div>
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">Description</div>
<div className="text-[12px] leading-relaxed text-muted-foreground">{catalog.description}</div>
</div>
) : null}
{catalog?.notes ? (
<div>
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">Notes</div>
<div className="text-[12px] leading-relaxed text-muted-foreground">{catalog.notes}</div>
</div>
) : null}
<div className="rounded-lg border border-border bg-muted px-4 py-3 text-[12px] leading-relaxed text-muted-foreground">
Manage credentials in Settings → Providers. Status and OAuth flows are handled by the pi-mono credential store.
</div>
{credential?.lastError ? (
<div className="rounded-lg border border-border bg-muted px-4 py-3 text-[12px] leading-relaxed text-muted-foreground">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
Latest credential error
</div>
<div>{credential.lastError}</div>
</div>
) : null}
</div>
);
}
@@ -1,77 +0,0 @@
import { useMemo } from "react";
import { motion } from "framer-motion";
import {
CheckCircle,
Heartbeat,
Lightning,
ShieldCheck,
Warning,
XCircle,
} from "../ui/icons";
import type { ActivityRecord, AgentRecord } from "@shared/types";
import { timeAgo } from "../../lib/formatters";
const actionIcons: Record<string, React.ElementType> = {
created: CheckCircle,
updated: Lightning,
deleted: XCircle,
started: Heartbeat,
completed: CheckCircle,
failed: Warning,
approved: ShieldCheck,
rejected: XCircle,
};
export function ActivityFeed({ activity, agents, limit = 15 }: { activity: ActivityRecord[]; agents: AgentRecord[]; limit?: number }) {
const recentActivity = useMemo(() => {
return [...activity]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, limit);
}, [activity, limit]);
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
if (recentActivity.length === 0) {
return (
<div className="flex h-full items-center justify-center py-8 text-center">
<div className="text-[13px] text-muted-foreground">No activity yet</div>
</div>
);
}
return (
<div className="space-y-0.5">
{recentActivity.map((item, index) => {
const Icon = actionIcons[item.action] ?? Lightning;
const agent = agentMap.get(item.actor);
const actorName = agent?.name ?? (item.actor === "board" ? "Board" : item.actor.slice(0, 8));
return (
<motion.div
key={item.id}
initial={index === 0 ? { opacity: 0, y: -8 } : false}
animate={{ opacity: 1, y: 0 }}
className="flex items-start gap-3 rounded-lg px-3 py-2 transition hover:bg-muted/50"
>
<div className="mt-0.5 shrink-0">
<Icon size={14} className="text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[12px] leading-relaxed text-foreground">
<span className="font-medium">{actorName}</span>
{" "}
<span className="text-muted-foreground">{item.action}</span>
{" "}
<span className="text-muted-foreground">{item.entityType}</span>
{item.detail ? (
<span className="text-muted-foreground"> — {item.detail.slice(0, 60)}{item.detail.length > 60 ? "..." : ""}</span>
) : null}
</div>
<div className="mt-0.5 whitespace-nowrap text-[10px] text-muted-foreground">{timeAgo(item.createdAt)}</div>
</div>
</motion.div>
);
})}
</div>
);
}
@@ -1,111 +0,0 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { Warning, X } from "../ui/icons";
import { AnimatePresence, motion } from "framer-motion";
import type { AgentRecord, BudgetIncidentRecord, SectionId } from "@shared/types";
import { useReducedMotion } from "../ui";
import { computeBudgetAlerts, type BudgetAlert } from "../../lib/budget-alerts";
export interface BudgetAlertBannerProps {
agents: AgentRecord[];
onNavigate: (section: SectionId, entityId?: string) => void;
companyId?: string;
}
export function BudgetAlertBanner({ agents, onNavigate, companyId }: BudgetAlertBannerProps) {
const reducedMotion = useReducedMotion();
const [dismissed, setDismissed] = useState(false);
const [incidents, setIncidents] = useState<BudgetIncidentRecord[]>([]);
useEffect(() => {
if (!companyId) return;
void (async () => {
const result = await window.agentCompany.listBudgetIncidents({ companyId, status: "open" });
if (result.ok) setIncidents(result.data);
})();
}, [companyId]);
const alerts = useMemo<BudgetAlert[]>(
() => computeBudgetAlerts(incidents, agents),
[incidents, agents],
);
const alertKey = useMemo(
() => alerts.map((a) => a.id).sort().join(","),
[alerts],
);
useEffect(() => {
setDismissed(false);
}, [alertKey]);
const handleDismiss = useCallback(() => setDismissed(true), []);
const handleViewCosts = useCallback(() => onNavigate("costs"), [onNavigate]);
if (alerts.length === 0) return null;
if (dismissed) return null;
const content = (
<div className="space-y-2">
{alerts.map((alert) => {
const isDanger = alert.severity === "danger";
const bgColor = isDanger ? "bg-destructive-muted" : "bg-warning-muted";
const textColor = isDanger ? "text-destructive" : "text-warning";
return (
<div
key={alert.id}
role="alert"
aria-live="assertive"
className={`rounded-lg border border-[color:var(--destructive)] border-opacity-30 ${bgColor} px-4 py-3`}
>
<div className="flex items-start gap-3">
<div className="mt-0.5 shrink-0">
<Warning size={18} weight="bold" className={textColor} />
</div>
<div className="min-w-0 flex-1">
<div className={`text-[13px] font-semibold ${textColor}`}>{alert.title}</div>
<div className="mt-0.5 text-[13px] text-muted-foreground">
{alert.scopeLabel} — ${alert.observedUsd.toFixed(2)} / ${alert.limitUsd.toFixed(2)} ({alert.pct}%)
</div>
<button
type="button"
onClick={handleViewCosts}
className="mt-1.5 text-[11px] font-medium text-primary hover:underline cursor-pointer"
>
View Costs
</button>
</div>
</div>
</div>
);
})}
<div className="flex justify-end">
<button
type="button"
onClick={handleDismiss}
title="Dismiss budget alerts"
aria-label="Dismiss budget alerts"
className="shrink-0 rounded p-0.5 text-muted-foreground transition hover:text-foreground"
>
<X size={14} />
</button>
</div>
</div>
);
if (reducedMotion) {
return content;
}
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
{content}
</motion.div>
</AnimatePresence>
);
}
@@ -1,97 +0,0 @@
import { useMemo } from "react";
import { motion } from "framer-motion";
import type { RunRecord, CostRecord, AgentRecord } from "@shared/types";
import { Eyebrow, useReducedMotion } from "../ui";
import { useT } from "../../i18n";
import {
aggregateRunsByDay,
aggregateCostsByDay,
aggregateAgentWorkload,
} from "./charts/chart-utils";
import { ChartCard } from "./charts/ChartCard";
import { RunActivityChart } from "./charts/RunActivityChart";
import { SuccessRateChart } from "./charts/SuccessRateChart";
import { CostTrendChart } from "./charts/CostTrendChart";
import { AgentWorkloadChart } from "./charts/AgentWorkloadChart";
import { BudgetUtilization } from "./charts/BudgetUtilization";
interface ChartSectionProps {
runs: RunRecord[];
costs: CostRecord[];
agents: AgentRecord[];
companyId: string;
}
export function ChartSection({ runs, costs, agents, companyId }: ChartSectionProps) {
const t = useT();
const reducedMotion = useReducedMotion();
const dailyRuns = useMemo(() => aggregateRunsByDay(runs, 14), [runs]);
const dailyCosts = useMemo(() => aggregateCostsByDay(costs, 14), [costs]);
const agentWorkload = useMemo(
() => aggregateAgentWorkload(runs, agents),
[runs, agents],
);
const hasRunData = dailyRuns.some((d) => d.total > 0);
const hasCostData = dailyCosts.some((d) => d.total > 0);
const totalRuns = dailyRuns.reduce((s, d) => s + d.total, 0);
const content = (
<div className="space-y-6">
{/* Section header */}
<Eyebrow>{t("chart.performance")}</Eyebrow>
{/* 2-column chart grid */}
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<ChartCard
title={t("chart.runActivity")}
ariaLabel={t("chart.runActivityAria").replace("{count}", String(totalRuns))}
isEmpty={!hasRunData}
>
<RunActivityChart data={dailyRuns} reducedMotion={reducedMotion} />
</ChartCard>
<ChartCard
title={t("chart.successRate")}
ariaLabel={t("chart.successRateAria")}
isEmpty={!hasRunData}
>
<SuccessRateChart data={dailyRuns} reducedMotion={reducedMotion} />
</ChartCard>
<ChartCard
title={t("chart.costTrend")}
ariaLabel={t("chart.costTrendAria")}
isEmpty={!hasCostData}
>
<CostTrendChart data={dailyCosts} reducedMotion={reducedMotion} />
</ChartCard>
<ChartCard
title={t("chart.agentWorkload")}
ariaLabel={t("chart.agentWorkloadAria")}
isEmpty={agentWorkload.length === 0}
>
<AgentWorkloadChart data={agentWorkload} reducedMotion={reducedMotion} />
</ChartCard>
</div>
{/* Budget Utilization section */}
<Eyebrow>{t("chart.budgetUtilization")}</Eyebrow>
<BudgetUtilization companyId={companyId} agents={agents} />
</div>
);
if (reducedMotion) {
return content;
}
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
>
{content}
</motion.div>
);
}
@@ -1,232 +0,0 @@
import { useMemo } from "react";
import {
ArrowRight,
Briefcase,
CurrencyCircleDollar,
Robot,
ShieldCheck,
Warning,
XCircle,
} from "../ui/icons";
import type {
ActivityRecord,
AgentRecord,
AgentMessageRecord,
ApprovalRecord,
CostRecord,
GoalRecord,
ProjectRecord,
RunRecord,
SectionId,
TaskRecord,
} from "@shared/types";
import { formatMoney } from "../../lib/formatters";
import { useT } from "../../i18n";
import { MetricCard } from "./MetricsGrid";
import { ActivityFeed } from "./ActivityFeed";
import { RecoveryBanner } from "./RecoveryBanner";
import { BudgetAlertBanner } from "./BudgetAlertBanner";
import { ChartSection } from "./ChartSection";
import { AttentionRow, Eyebrow } from "../ui";
export interface DashboardProps {
companyId?: string;
agents: AgentRecord[];
tasks: TaskRecord[];
runs: RunRecord[];
approvals: ApprovalRecord[];
goals: GoalRecord[];
projects: ProjectRecord[];
activity: ActivityRecord[];
costs: CostRecord[];
messages?: AgentMessageRecord[];
onNavigate: (section: SectionId, entityId?: string) => void;
}
export function Dashboard(props: DashboardProps) {
const t = useT();
const { companyId, agents, tasks, runs, approvals, activity, costs, messages = [], onNavigate } = props;
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
const taskMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]);
const activeAgents = useMemo(() => agents.filter((a) => a.status !== "terminated" && a.status !== "pending_approval"), [agents]);
const activeTasks = useMemo(() => tasks.filter((t) => t.status === "in_progress" || t.status === "in_review"), [tasks]);
const pendingApprovals = useMemo(() => approvals.filter((a) => a.state === "pending"), [approvals]);
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" && 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;
// Active work — agents currently running tasks
const activeWork = useMemo(() => {
return runs
.filter((r) => r.status === "running" || r.status === "queued")
.map((run) => {
const agent = agentMap.get(run.agentId);
const task = run.taskId ? taskMap.get(run.taskId) : undefined;
return { run, agent, task };
})
.slice(0, 8);
}, [runs, agentMap, taskMap]);
return (
<div className="space-y-6">
{/* Conditional banners */}
<RecoveryBanner />
<BudgetAlertBanner agents={agents} onNavigate={onNavigate} companyId={companyId} />
{/* Key Metrics — 4 cards */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
<MetricCard
label={t("dash.activeTasks")}
value={activeTasks.length}
icon={Briefcase}
onClick={() => onNavigate("tasks")}
subtitle={`${tasks.filter((t) => t.status === "todo").length} ${t("dash.queued")}`}
/>
<MetricCard
label={t("dash.approvals")}
value={pendingApprovals.length}
icon={ShieldCheck}
onClick={() => onNavigate("approvals")}
subtitle={pendingApprovals.length > 0 ? t("dash.needsAttention") : t("dash.allClear")}
/>
<MetricCard
label={t("common.agents")}
value={activeAgents.length}
icon={Robot}
onClick={() => onNavigate("agents")}
subtitle={`${activeAgents.filter((a) => a.status === "running").length} ${t("dash.running")}`}
/>
<MetricCard
label={t("dash.totalSpend")}
value={formatMoney(totalSpend)}
icon={CurrencyCircleDollar}
onClick={() => onNavigate("settings")}
/>
</div>
{/* Performance Charts */}
{companyId ? <ChartSection runs={runs} costs={costs} agents={agents} companyId={companyId} /> : null}
{/* Needs Attention — moved to top, only shows when items exist */}
{hasAttentionItems ? (
<section>
<Eyebrow tone="warning" className="mb-3 flex items-center gap-2">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-warning" />
{t("dash.needsAttentionSection")}
</Eyebrow>
<div className="space-y-1.5">
{pendingApprovals.slice(0, 5).map((approval, i) => {
const requester = approval.requestedByAgentId ? agentMap.get(approval.requestedByAgentId) : undefined;
return (
<AttentionRow
key={approval.id}
icon={ShieldCheck}
tone="warn"
index={i}
title={approval.payloadSummary}
subtitle={
<span className="flex items-center gap-2">
<span className="uppercase tracking-[0.1em]">{approval.type.replaceAll("_", " ")}</span>
{requester ? <span>by {requester.name}</span> : null}
</span>
}
onClick={() => onNavigate("approvals", approval.id)}
/>
);
})}
{failedRuns.slice(0, 3).map((run, i) => {
const agent = agentMap.get(run.agentId);
return (
<AttentionRow
key={run.id}
icon={XCircle}
tone="danger"
index={pendingApprovals.length + i}
title={run.summary || "Run failed"}
subtitle={`${agent?.name ?? ""} — ${run.errorMessage?.slice(0, 80) ?? "Unknown error"}`}
onClick={() => onNavigate("runs", run.id)}
/>
);
})}
{urgentMessages.slice(0, 3).map((msg, i) => {
const sender = agentMap.get(msg.fromAgentId);
return (
<AttentionRow
key={msg.id}
icon={Warning}
tone="warn"
index={pendingApprovals.length + failedRuns.length + i}
title={msg.subject}
subtitle={`${sender?.name ?? "Unknown"} — ${msg.channel}${msg.priority === "urgent" ? " · urgent" : ""}`}
onClick={() => onNavigate("communication", msg.id)}
/>
);
})}
</div>
</section>
) : null}
{/* Active Work — what's running right now */}
<section>
<div className="mb-3 flex items-center justify-between">
<Eyebrow>{t("dash.activeWork")}</Eyebrow>
<button type="button" onClick={() => onNavigate("runs")} className="flex items-center gap-1 text-[11px] font-medium text-primary transition hover:underline">
{t("dash.allRuns")} <ArrowRight size={12} />
</button>
</div>
{activeWork.length > 0 ? (
<div className="rounded-lg border border-border bg-card divide-y divide-[color:var(--border)]">
{activeWork.map(({ run, agent, task }) => (
<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 px-4 py-3 text-left transition hover:bg-muted/50"
>
<div className="relative shrink-0">
<Robot size={16} className="text-success" />
{run.status === "running" ? (
<span className="absolute -right-0.5 -top-0.5 h-2 w-2">
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-success" />
</span>
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">
{task?.title ?? run.summary ?? `Run ${run.id.slice(0, 8)}`}
</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">
{agent?.name ?? "Unknown agent"} · {run.status}
</div>
</div>
</button>
))}
</div>
) : (
<div className="rounded-lg border border-dashed border-border py-8 text-center">
<div className="text-[13px] text-muted-foreground">{t("dash.allQuiet")}</div>
<div className="mt-1 text-[11px] text-muted-foreground">{t("dash.noTasksRunning")}</div>
</div>
)}
</section>
{/* Recent Activity */}
<section>
<div className="mb-3 flex items-center justify-between">
<Eyebrow>{t("dash.recentActivity")}</Eyebrow>
</div>
<div className="max-h-[360px] overflow-y-auto rounded-lg border border-border bg-card py-2">
<ActivityFeed activity={activity} agents={agents} limit={10} />
</div>
</section>
</div>
);
}
@@ -1,105 +0,0 @@
import { useMemo } from "react";
import { Briefcase, Robot } from "../ui/icons";
import type { AgentRecord, RunRecord, TaskRecord } from "@shared/types";
import { useT } from "../../i18n";
import { Eyebrow } from "../ui";
interface KanbanProps {
tasks: TaskRecord[];
agents: AgentRecord[];
runs: RunRecord[];
onSelectTask: (taskId: string) => void;
}
const KANBAN_COLUMNS: Array<{ status: TaskRecord["status"]; labelKey: string }> = [
{ status: "backlog", labelKey: "kanban.backlog" },
{ status: "todo", labelKey: "kanban.todo" },
{ status: "in_progress", labelKey: "kanban.inProgress" },
{ status: "in_review", labelKey: "kanban.inReview" },
{ status: "done", labelKey: "kanban.done" },
{ status: "blocked", labelKey: "kanban.blocked" },
];
export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps) {
const t = useT();
const tasksByStatus = useMemo(() => {
const grouped: Record<string, TaskRecord[]> = {};
for (const col of KANBAN_COLUMNS) grouped[col.status] = [];
for (const task of tasks) {
if (task.status === "cancelled") continue;
const bucket = grouped[task.status];
if (bucket) bucket.push(task);
else grouped.backlog?.push(task);
}
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
for (const col of Object.values(grouped)) {
col.sort((a, b) => (priorityOrder[a.priority] ?? 9) - (priorityOrder[b.priority] ?? 9));
}
return grouped;
}, [tasks]);
if (tasks.length === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center">
<Briefcase size={32} className="mb-3 text-muted-foreground" />
<div className="text-[14px] font-medium text-muted-foreground">{t("kanban.noTasks")}</div>
</div>
);
}
return (
<div className="flex gap-3 overflow-x-auto pb-2">
{KANBAN_COLUMNS.map((col) => {
const colTasks = tasksByStatus[col.status] ?? [];
return (
<div key={col.status} className="flex w-[220px] min-w-[160px] shrink-0 flex-col lg:w-[240px]">
<div className="mb-2 flex items-center gap-2 px-1">
<Eyebrow>{t(col.labelKey)}</Eyebrow>
<span className="ml-auto rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold tabular-nums text-muted-foreground">
{colTasks.length}
</span>
</div>
<div className="flex flex-1 flex-col gap-1.5 rounded-lg bg-muted p-1.5" style={{ minHeight: 120 }}>
{colTasks.map((task) => {
const assignee = agents.find((a) => a.id === task.assigneeAgentId);
const activeRun = runs.find((r) => r.id === task.activeRunId);
const isRunning = activeRun?.status === "running";
return (
<button
key={task.id}
type="button"
onClick={() => onSelectTask(task.id)}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-lg border border-border bg-card px-3 py-2.5 text-left transition duration-150 hover:bg-muted/40"
>
<div className="truncate text-[12px] font-medium leading-snug text-foreground">{task.title}</div>
<div className="mt-1.5 flex items-center gap-2">
{assignee ? (
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Robot size={10} />
{assignee.name}
</span>
) : (
<span className="text-[10px] italic text-muted-foreground">{t("kanban.unassigned")}</span>
)}
{isRunning ? (
<span className="flex items-center gap-1 whitespace-nowrap text-[10px] text-primary">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary" aria-hidden="true" />
{t("kanban.running")}
</span>
) : null}
</div>
{task.priority === "critical" || task.priority === "high" ? (
<div className="mt-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-primary">
{task.priority === "critical" ? t("kanban.priority.critical") : t("kanban.priority.high")}
</div>
) : null}
</button>
);
})}
</div>
</div>
);
})}
</div>
);
}
@@ -1,33 +0,0 @@
import type React from "react";
import { Eyebrow } from "../ui";
export function MetricCard({
label,
value,
icon: Icon,
onClick,
subtitle,
}: {
label: string;
value: string | number;
icon: React.ElementType;
onClick?: () => void;
subtitle?: string;
}) {
return (
<button
type="button"
onClick={onClick}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex items-center gap-4 rounded-lg border border-border bg-card px-4 py-4 text-left transition duration-200 hover:bg-muted/40"
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-accent">
<Icon size={24} weight="fill" className="text-primary" />
</div>
<div className="min-w-0 flex-1">
<Eyebrow>{label}</Eyebrow>
<div className="text-[28px] font-semibold tracking-[-0.02em] leading-none text-foreground" style={{ fontVariantNumeric: "tabular-nums" }}>{value}</div>
{subtitle ? <div className="mt-1.5 truncate text-[11px] font-medium text-muted-foreground">{subtitle}</div> : null}
</div>
</button>
);
}
@@ -1,82 +0,0 @@
import { useState, useEffect } from "react";
import { Warning, ArrowClockwise, X, Trash } from "../ui/icons";
import type { DesktopEvent } from "@shared/types/events";
interface InterruptedRun {
runId: string;
taskId: string;
taskTitle: string;
agentId: string;
agentName: string;
companyId: string;
connectorId: string;
interruptedAt: string;
}
export function RecoveryBanner() {
const [runs, setRuns] = useState<InterruptedRun[]>([]);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
const unsubscribe = window.agentCompany?.subscribe?.((event: DesktopEvent) => {
if (event.type === "recovery-needed" && event.interruptedRuns) {
setRuns(event.interruptedRuns);
}
});
return () => {
unsubscribe?.();
};
}, []);
if (runs.length === 0 || dismissed) return null;
const handleRetry = async (run: InterruptedRun) => {
await window.agentCompany?.recoveryRetry(run.taskId, run.agentId, run.companyId);
setRuns(prev => prev.filter(r => r.runId !== run.runId));
};
const handleFail = async (run: InterruptedRun) => {
await window.agentCompany?.recoveryFail(run.taskId, run.companyId);
setRuns(prev => prev.filter(r => r.runId !== run.runId));
};
const handleDismissAll = () => setDismissed(true);
return (
<div className="bg-warning-muted border border-[color:var(--destructive)]/30 rounded-lg px-4 py-4 mb-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-warning font-medium">
<Warning size={18} weight="bold" />
<span>{runs.length} interrupted task(s) detected from previous session</span>
</div>
<button onClick={handleDismissAll} className="text-muted-foreground hover:text-foreground transition-colors" aria-label="Dismiss recovery banner">
<X size={16} />
</button>
</div>
<div className="space-y-2">
{runs.map(run => (
<div key={run.runId} className="flex items-center justify-between bg-muted rounded-[4px] px-3 py-2 text-[13px]">
<div className="min-w-0 flex-1">
<span className="truncate text-foreground">{run.taskTitle}</span>
<span className="whitespace-nowrap text-muted-foreground ml-2">({run.agentName})</span>
</div>
<div className="flex gap-2">
<button
onClick={() => handleRetry(run)}
className="flex items-center gap-1 text-success hover:text-success text-xs transition-colors"
>
<ArrowClockwise size={14} /> Retry
</button>
<button
onClick={() => handleFail(run)}
className="flex items-center gap-1 text-destructive hover:text-destructive text-xs transition-colors"
>
<Trash size={14} /> Cancel
</button>
</div>
</div>
))}
</div>
</div>
);
}
@@ -1,52 +0,0 @@
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import type { AgentWorkloadData } from "./chart-utils";
import { ChartTooltip } from "./ChartTooltip";
interface AgentWorkloadChartProps {
data: AgentWorkloadData[];
reducedMotion: boolean;
}
export function AgentWorkloadChart({ data, reducedMotion }: AgentWorkloadChartProps) {
return (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={data}>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
horizontal={true}
vertical={false}
/>
<XAxis
dataKey="name"
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
axisLine={{ stroke: "var(--border)" }}
tickLine={false}
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
axisLine={false}
tickLine={false}
/>
<Tooltip content={<ChartTooltip valueSuffix=" runs" labelMode="plain" />} />
<Bar
dataKey="runs"
fill="var(--primary)"
fillOpacity={0.85}
radius={[4, 4, 0, 0]}
maxBarSize={40}
isAnimationActive={!reducedMotion}
/>
</BarChart>
</ResponsiveContainer>
);
}
@@ -1,131 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import type { AgentRecord, BudgetStatusSummary } from "@shared/types";
interface BudgetUtilizationProps {
companyId: string;
agents: AgentRecord[];
}
interface BudgetRow {
id: string;
name: string;
scopeType: string;
spent: number;
budget: number;
pct: number;
}
function getBarColor(pct: number): string {
if (pct >= 100) return "bg-destructive";
if (pct >= 80) return "bg-warning";
return "bg-success";
}
function scopeLabel(scopeType: string): string {
if (scopeType === "company") return "Company";
if (scopeType === "project") return "Project";
return "Agent";
}
export function BudgetUtilization({ companyId, agents }: BudgetUtilizationProps) {
const [budgetStatus, setBudgetStatus] = useState<BudgetStatusSummary | null>(null);
useEffect(() => {
window.agentCompany.getBudgetStatus(companyId).then((result) => {
if (result.ok) setBudgetStatus(result.data);
});
}, [companyId]);
const rows = useMemo<BudgetRow[]>(() => {
// Build rows from budget policies (company/agent/project scopes)
const policyRows: BudgetRow[] = (budgetStatus?.policies ?? [])
.filter((p) => p.amount > 0)
.map((p) => {
const agent = p.scopeType === "agent" ? agents.find((a) => a.id === p.scopeId) : undefined;
const name = agent ? agent.name : `${scopeLabel(p.scopeType)} budget`;
return {
id: p.policyId,
name,
scopeType: p.scopeType,
spent: p.spent,
budget: p.amount,
pct: Math.round((p.spent / p.amount) * 100),
};
});
// Fallback: if no policies, use agent-level budgets from AgentRecord
if (policyRows.length === 0) {
return agents
.filter((a) => a.budgetMonthlyUsd > 0)
.map((a) => ({
id: a.id,
name: a.name,
scopeType: "agent",
spent: a.spentMonthlyUsd,
budget: a.budgetMonthlyUsd,
pct: Math.round((a.spentMonthlyUsd / a.budgetMonthlyUsd) * 100),
}))
.sort((a, b) => a.name.localeCompare(b.name));
}
// Sort: company first, then project, then agent
const order: Record<string, number> = { company: 0, project: 1, agent: 2 };
return policyRows.sort(
(a, b) => (order[a.scopeType] ?? 3) - (order[b.scopeType] ?? 3) || a.name.localeCompare(b.name),
);
}, [budgetStatus, agents]);
if (rows.length === 0) {
return (
<div className="rounded-lg border border-border bg-card px-4 py-4">
<div className="flex h-[120px] flex-col items-center justify-center gap-2">
<span className="text-[13px] font-semibold text-muted-foreground">
No budget policies configured
</span>
<span className="max-w-[200px] text-center text-[11px] text-muted-foreground">
Set budget policies in Settings to track utilization here.
</span>
</div>
</div>
);
}
return (
<div className="rounded-lg border border-border bg-card px-4 py-4">
<div className="space-y-3">
{rows.map((row) => (
<div key={row.id}>
<div className="mb-1 flex items-center justify-between">
<span className="flex items-center gap-2 text-[13px] font-semibold text-foreground">
{row.name}
<span className="text-[11px] font-normal text-muted-foreground">
{scopeLabel(row.scopeType)}
</span>
</span>
<span
className="text-[13px] text-muted-foreground"
style={{
fontFamily: "var(--font-mono)",
fontVariantNumeric: "tabular-nums",
}}
>
{row.pct}%
</span>
</div>
<div className="relative h-1.5 rounded-full bg-muted">
<div
className={`h-1.5 rounded-full transition-all duration-300 ${getBarColor(row.pct)}`}
style={{ width: `${Math.min(row.pct, 100)}%` }}
/>
{/* 80% warning marker */}
<div
className="absolute top-0 h-full w-px bg-warning opacity-50"
style={{ left: "80%" }}
/>
</div>
</div>
))}
</div>
</div>
);
}
@@ -1,58 +0,0 @@
import { ChartLine } from "../../ui/icons";
import { useT } from "../../../i18n";
interface ChartCardProps {
title: string;
period?: string;
isEmpty?: boolean;
emptyHeading?: string;
emptyBody?: string;
ariaLabel?: string;
children: React.ReactNode;
}
export function ChartCard({
title,
period = "14d",
isEmpty = false,
emptyHeading,
emptyBody,
ariaLabel,
children,
}: ChartCardProps) {
const t = useT();
const resolvedHeading = emptyHeading ?? t("chart.emptyHeading");
const resolvedBody = emptyBody ?? t("chart.emptyBody");
return (
<div
className="rounded-lg border border-border bg-card px-4 py-4"
role="img"
aria-label={ariaLabel}
>
<div className="mb-3 flex items-center justify-between">
<span className="text-[13px] font-semibold text-foreground">
{title}
</span>
<span className="text-[11px] text-muted-foreground">
{period}
</span>
</div>
{isEmpty ? (
<div className="flex h-[220px] flex-col items-center justify-center gap-2">
<ChartLine
size={32}
className="text-muted-foreground opacity-50"
/>
<span className="text-[13px] font-semibold text-muted-foreground">
{resolvedHeading}
</span>
<span className="max-w-[200px] text-center text-[11px] text-muted-foreground">
{resolvedBody}
</span>
</div>
) : (
<div style={{ height: 220 }}>{children}</div>
)}
</div>
);
}
@@ -1,95 +0,0 @@
import type { TooltipContentProps } from "recharts";
export type ChartTooltipLabelMode = "date" | "plain";
interface ChartTooltipExtraProps {
valuePrefix?: string;
valueSuffix?: string;
/**
* How to interpret the raw `label` coming from recharts.
* - "date" (default): parse `YYYY-MM-DD` strings into "Mon DD, YYYY".
* - "plain": pass through the raw label as-is (category axes, names, etc.).
* `labelFormatter`, if provided, still wins over both modes.
*/
labelMode?: ChartTooltipLabelMode;
labelFormatter?: (label: string | number | undefined) => string;
}
type Props = Partial<TooltipContentProps<number, string>> & ChartTooltipExtraProps;
/** Pure helper: format a raw label as a localized date, or return it unchanged on failure. */
export function formatDateLabel(label: string | number | undefined): string {
if (typeof label !== "string") return String(label);
const parsed = new Date(label + "T00:00:00");
if (Number.isNaN(parsed.getTime())) return label;
return parsed.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
/** Pure helper: pass-through formatter for category / name labels. */
export function formatPlainLabel(label: string | number | undefined): string {
if (label === undefined || label === null) return "";
return String(label);
}
/** Pure helper: decide whether the label row should render at all. */
export function shouldRenderLabel(label: string | number | undefined): boolean {
if (label === undefined || label === null) return false;
if (typeof label === "string" && label.length === 0) return false;
return true;
}
function pickDefaultFormatter(mode: ChartTooltipLabelMode) {
return mode === "plain" ? formatPlainLabel : formatDateLabel;
}
export function ChartTooltip({
active,
payload,
label,
valuePrefix = "",
valueSuffix = "",
labelMode = "date",
labelFormatter,
}: Props) {
if (!active || !payload?.length) return null;
const renderLabel = shouldRenderLabel(label);
const formatLabel = labelFormatter ?? pickDefaultFormatter(labelMode);
const labelText = renderLabel ? formatLabel(label) : "";
return (
<div
className="rounded-lg border border-border bg-card px-3 py-2 shadow-md"
style={{ maxWidth: 200, zIndex: 50 }}
>
{renderLabel && labelText ? (
<div className="text-[11px] text-muted-foreground">{labelText}</div>
) : null}
{payload.map((entry, i) => (
<div key={i} className="mt-1 flex items-center gap-2">
<span
className="inline-block h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span
className="text-[13px] font-semibold text-foreground"
style={{
fontFamily: "var(--font-mono)",
fontVariantNumeric: "tabular-nums",
}}
>
{valuePrefix}
{typeof entry.value === "number"
? entry.value.toLocaleString()
: entry.value}
{valueSuffix}
</span>
</div>
))}
</div>
);
}
@@ -1,57 +0,0 @@
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import type { DailyCostData } from "./chart-utils";
import { formatShortDate } from "./chart-utils";
import { ChartTooltip } from "./ChartTooltip";
interface CostTrendChartProps {
data: DailyCostData[];
reducedMotion: boolean;
}
export function CostTrendChart({ data, reducedMotion }: CostTrendChartProps) {
return (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={data}>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
horizontal={true}
vertical={false}
/>
<XAxis
dataKey="date"
tickFormatter={formatShortDate}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
axisLine={{ stroke: "var(--border)" }}
tickLine={false}
/>
<YAxis
tickFormatter={(val: number) => "$" + val.toFixed(0)}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
axisLine={false}
tickLine={false}
/>
<Tooltip content={<ChartTooltip valuePrefix="$" />} />
<Area
type="monotone"
dataKey="total"
stroke="var(--primary)"
fill="var(--primary)"
fillOpacity={0.15}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, fill: "var(--primary)" }}
isAnimationActive={!reducedMotion}
/>
</AreaChart>
</ResponsiveContainer>
);
}
@@ -1,57 +0,0 @@
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import type { DailyRunData } from "./chart-utils";
import { formatShortDate } from "./chart-utils";
import { ChartTooltip } from "./ChartTooltip";
interface RunActivityChartProps {
data: DailyRunData[];
reducedMotion: boolean;
}
export function RunActivityChart({ data, reducedMotion }: RunActivityChartProps) {
return (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={data}>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
horizontal={true}
vertical={false}
/>
<XAxis
dataKey="date"
tickFormatter={formatShortDate}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
axisLine={{ stroke: "var(--border)" }}
tickLine={false}
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
axisLine={false}
tickLine={false}
/>
<Tooltip content={<ChartTooltip valueSuffix=" runs" />} />
<Area
type="monotone"
dataKey="total"
stroke="var(--primary)"
fill="var(--primary)"
fillOpacity={0.3}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, fill: "var(--primary)" }}
isAnimationActive={!reducedMotion}
/>
</AreaChart>
</ResponsiveContainer>
);
}
@@ -1,66 +0,0 @@
import { useMemo } from "react";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import type { DailyRunData } from "./chart-utils";
import { formatShortDate } from "./chart-utils";
import { ChartTooltip } from "./ChartTooltip";
interface SuccessRateChartProps {
data: DailyRunData[];
reducedMotion: boolean;
}
export function SuccessRateChart({ data, reducedMotion }: SuccessRateChartProps) {
const rateData = useMemo(
() =>
data.map((d) => ({
date: d.date,
rate: d.total > 0 ? Math.round((d.succeeded / d.total) * 100) : 0,
})),
[data],
);
return (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={rateData}>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
horizontal={true}
vertical={false}
/>
<XAxis
dataKey="date"
tickFormatter={formatShortDate}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
axisLine={{ stroke: "var(--border)" }}
tickLine={false}
/>
<YAxis
domain={[0, 100]}
tickFormatter={(val: number) => `${val}%`}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
axisLine={false}
tickLine={false}
/>
<Tooltip content={<ChartTooltip valueSuffix="%" />} />
<Line
type="monotone"
dataKey="rate"
stroke="var(--primary)"
strokeWidth={2}
dot={false}
activeDot={{ r: 4, fill: "var(--primary)" }}
isAnimationActive={!reducedMotion}
/>
</LineChart>
</ResponsiveContainer>
);
}
@@ -1,142 +0,0 @@
import type { RunRecord, CostRecord, AgentRecord } from "@shared/types";
export interface DailyRunData {
date: string; // "YYYY-MM-DD"
total: number;
succeeded: number;
failed: number;
}
export interface DailyCostData {
date: string; // "YYYY-MM-DD"
total: number; // USD amount (NOT cents)
}
export interface AgentWorkloadData {
name: string; // agent name, truncated to 10 chars
runs: number;
}
/**
* Aggregate runs into daily buckets for chart consumption.
* Returns `days` entries sorted ascending by date (oldest first).
* Days with no runs have {total:0, succeeded:0, failed:0}.
*/
export function aggregateRunsByDay(runs: RunRecord[], days: number): DailyRunData[] {
const now = new Date();
const buckets = new Map<string, { total: number; succeeded: number; failed: number }>();
// Initialize all day buckets with zero values
for (let i = 0; i < days; i++) {
const date = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
const key = date.toISOString().slice(0, 10);
buckets.set(key, { total: 0, succeeded: 0, failed: 0 });
}
// Fill buckets from actual data
for (const run of runs) {
const day = run.createdAt.slice(0, 10);
const bucket = buckets.get(day);
if (!bucket) continue;
bucket.total++;
if (run.status === "succeeded") bucket.succeeded++;
if (run.status === "failed" || run.status === "timed_out" || run.status === "interrupted") {
bucket.failed++;
}
}
// Return sorted ascending by date
return Array.from(buckets.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, data]) => ({ date, ...data }));
}
/**
* Aggregate costs into daily buckets for chart consumption.
* Returns `days` entries sorted ascending by date.
* Days with no costs have {total:0}. Null amountUsd is treated as 0.
*/
export function aggregateCostsByDay(costs: CostRecord[], days: number): DailyCostData[] {
const now = new Date();
const buckets = new Map<string, number>();
// Initialize all day buckets with zero
for (let i = 0; i < days; i++) {
const date = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
const key = date.toISOString().slice(0, 10);
buckets.set(key, 0);
}
// Sum costs per day
for (const cost of costs) {
const day = cost.createdAt.slice(0, 10);
const current = buckets.get(day);
if (current === undefined) continue;
buckets.set(day, current + (cost.amountUsd ?? 0));
}
// Return sorted ascending by date
return Array.from(buckets.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, total]) => ({ date, total }));
}
/**
* Group runs by agent and return workload data sorted by run count descending.
* If more than maxAgents, keep top maxAgents and aggregate rest into "Others".
* Agent names longer than 10 chars are truncated with ellipsis.
*/
export function aggregateAgentWorkload(
runs: RunRecord[],
agents: AgentRecord[],
maxAgents: number = 8,
): AgentWorkloadData[] {
// Build name lookup from agents array
const nameMap = new Map<string, string>();
for (const agent of agents) {
nameMap.set(agent.id, agent.name);
}
// Count runs per agent
const counts = new Map<string, number>();
for (const run of runs) {
counts.set(run.agentId, (counts.get(run.agentId) ?? 0) + 1);
}
// Sort by count descending
const sorted = Array.from(counts.entries())
.sort(([, a], [, b]) => b - a);
const truncateName = (name: string): string =>
name.length > 10 ? name.slice(0, 10) + "..." : name;
// If within limit, return all
if (sorted.length <= maxAgents) {
return sorted.map(([agentId, runCount]) => ({
name: truncateName(nameMap.get(agentId) ?? agentId),
runs: runCount,
}));
}
// Split into top agents and "Others"
const top = sorted.slice(0, maxAgents);
const rest = sorted.slice(maxAgents);
const othersRuns = rest.reduce((sum, [, count]) => sum + count, 0);
const result: AgentWorkloadData[] = top.map(([agentId, runCount]) => ({
name: truncateName(nameMap.get(agentId) ?? agentId),
runs: runCount,
}));
result.push({ name: "Others", runs: othersRuns });
return result;
}
/**
* Format a YYYY-MM-DD date string as "MMM dd" (e.g., "Mar 15").
* Uses T00:00:00 suffix to avoid timezone shift.
*/
export function formatShortDate(dateStr: string): string {
const date = new Date(dateStr + "T00:00:00");
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
@@ -1,106 +0,0 @@
import { useMemo, useRef, useState } from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import { Lightning, MagnifyingGlass } from "../ui/icons"
import type { ActivityRecord } from "@shared/types"
import { useT } from "../../i18n"
import { formatTime } from "../../lib/formatters"
import { useRovingTabIndex } from "../../lib/keyboard"
import { EmptyState, Select } from "../ui"
import { ENTITY_ICONS, ENTITY_TYPE_OPTIONS } from "./execution-utils"
export function ActivityList({ activity }: { activity: ActivityRecord[] }) {
const t = useT()
const [search, setSearch] = useState("")
const [entityFilter, setEntityFilter] = useState<string>("all")
const parentRef = useRef<HTMLDivElement>(null)
const filtered = useMemo(() => {
let result = activity
if (entityFilter !== "all") result = result.filter((e) => e.entityType === entityFilter)
if (search.trim()) {
const q = search.toLowerCase()
result = result.filter((e) =>
e.action.toLowerCase().includes(q) ||
e.actor.toLowerCase().includes(q) ||
e.detail.toLowerCase().includes(q) ||
e.entityType.toLowerCase().includes(q),
)
}
return result
}, [activity, entityFilter, search])
const { handleKeyDown, getTabIndex, focusedIndex } = useRovingTabIndex(filtered.length)
const virtualizer = useVirtualizer({
count: filtered.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
overscan: 15,
})
if (activity.length === 0) return <EmptyState title={t("exec.noActivityYet")} />
return (
<div className="space-y-3">
<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" 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>
{filtered.length === 0 ? <EmptyState title={t("exec.noMatchingActivity")} /> : (
<div
ref={parentRef}
className="h-full max-h-[600px] overflow-auto border-y border-border"
role="listbox"
aria-label="Activity log"
onKeyDown={handleKeyDown}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const entry = filtered[virtualRow.index]
const EntityIcon = ENTITY_ICONS[entry.entityType] ?? Lightning
return (
<div
key={entry.id}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
tabIndex={getTabIndex(virtualRow.index)}
role="option"
aria-selected={false}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 outline-none border-b border-border px-4 py-3 last:border-b-0 ${virtualRow.index === focusedIndex ? "ring-1 ring-[color:var(--primary)] ring-inset" : ""}`}
>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<EntityIcon size={14} weight="duotone" className="shrink-0 text-muted-foreground" />
<div className="truncate text-[13px] text-foreground">{entry.action}</div>
</div>
<div className="font-mono text-[11px] text-muted-foreground">{formatTime(entry.createdAt)}</div>
</div>
<div className="flex items-center gap-2 text-[13px] text-muted-foreground">
<span className="font-medium text-muted-foreground">{entry.actor}</span>
<span>·</span>
<span className="truncate">{entry.detail || `${entry.entityType}:${entry.entityId}`}</span>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
)
}
@@ -1,72 +0,0 @@
import { useRef } from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import type { ApprovalRecord } from "@shared/types"
import { useT } from "../../i18n"
import { useRovingTabIndex } from "../../lib/keyboard"
import { EmptyState, StatusPill } from "../ui"
export function ApprovalList({ approvals, selectedApprovalId, onSelect }: { approvals: ApprovalRecord[]; selectedApprovalId: string | null; onSelect: (id: string) => void }) {
const t = useT()
const parentRef = useRef<HTMLDivElement>(null)
const { handleKeyDown, getTabIndex, focusedIndex } = useRovingTabIndex(approvals.length)
const virtualizer = useVirtualizer({
count: approvals.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 52,
overscan: 10,
})
if (approvals.length === 0) return <EmptyState title={t("exec.noApprovals")} />
return (
<div
ref={parentRef}
className="h-full overflow-auto border-y border-border"
role="listbox"
aria-label="Pending approvals"
onKeyDown={handleKeyDown}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const approval = approvals[virtualRow.index]
return (
<div
key={approval.id}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
tabIndex={getTabIndex(virtualRow.index)}
role="option"
aria-selected={approval.id === selectedApprovalId}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
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-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-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} />
</div>
</button>
</div>
)
})}
</div>
</div>
)
}
@@ -1,105 +0,0 @@
import { useEffect, useState } from "react"
import { Warning } from "../ui/icons"
import type { BudgetStatusSummary, ProfileSnapshot } from "@shared/types"
import { formatMoney } from "../../lib/formatters"
import { ListFrame, ListRow } from "../ui"
interface BudgetSummaryProps {
agents: ProfileSnapshot["agents"]
costs: ProfileSnapshot["costs"]
companyId?: string
}
export function BudgetSummary({ agents, costs, companyId }: BudgetSummaryProps) {
const [budgetStatus, setBudgetStatus] = useState<BudgetStatusSummary | null>(null)
useEffect(() => {
if (!companyId) return
void (async () => {
const result = await window.agentCompany.getBudgetStatus(companyId)
if (result.ok) setBudgetStatus(result.data)
})()
}, [companyId])
const knownSpend = costs.reduce((sum, entry) => sum + (entry.amountUsd ?? 0), 0)
const unattributedCount = costs.filter((entry) => entry.unattributed).length
const hasPolicies = budgetStatus && budgetStatus.policies.length > 0
return (
<div className="space-y-4">
<div className="border-y border-border">
<div className="flex items-end justify-between gap-4 py-3">
<div className="text-[12px] text-muted-foreground">Known spend</div>
<div className="font-mono text-[22px] tracking-[-0.03em] text-foreground">{formatMoney(knownSpend)}</div>
</div>
<div className="flex items-end justify-between gap-4 border-t border-border py-3">
<div className="text-[12px] text-muted-foreground">Unattributed entries</div>
<div className="font-mono text-[18px] text-foreground">{String(unattributedCount)}</div>
</div>
</div>
{hasPolicies ? (
<ListFrame>
{budgetStatus.policies.map((status) => {
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-warning" : "bg-success"
const nameColor = isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-foreground"
const scopeLabel = status.scopeType === "company"
? "Company"
: status.scopeType === "agent"
? (agents.find((a) => a.id === status.scopeId)?.name ?? status.scopeId.slice(0, 8))
: status.scopeId.slice(0, 8)
return (
<ListRow key={status.policyId}>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground">
{status.scopeType}
</span>
<div className={`text-[14px] font-medium ${nameColor}`}>{scopeLabel}</div>
{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-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>
</ListRow>
)
})}
</ListFrame>
) : (
<ListFrame>
{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-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-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-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>
</ListRow>
)
})}
</ListFrame>
)}
</div>
)
}
@@ -1,113 +0,0 @@
import { useMemo, useRef, useState } from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import type { ProfileSnapshot } from "@shared/types"
import { useT } from "../../i18n"
import { formatMoney } from "../../lib/formatters"
import { useRovingTabIndex } from "../../lib/keyboard"
import { EmptyState, Select } from "../ui"
export function CostList({ costs, agents, runs }: { costs: ProfileSnapshot["costs"]; agents: ProfileSnapshot["agents"]; runs: ProfileSnapshot["runs"] }) {
const t = useT()
const [connectorFilter, setConnectorFilter] = useState("all")
const parentRef = useRef<HTMLDivElement>(null)
const connectorIds = useMemo(() => {
const ids = new Set(costs.map((c) => c.connectorId))
return Array.from(ids).sort()
}, [costs])
const filtered = useMemo(() => connectorFilter === "all" ? costs : costs.filter((c) => c.connectorId === connectorFilter), [costs, connectorFilter])
const total = useMemo(() => filtered.reduce((sum, c) => sum + (c.amountUsd ?? 0), 0), [filtered])
// +1 for the total row at the bottom
const { handleKeyDown, getTabIndex, focusedIndex } = useRovingTabIndex(filtered.length + 1)
const virtualizer = useVirtualizer({
count: filtered.length + 1, // +1 for total row
getScrollElement: () => parentRef.current,
estimateSize: () => 44,
overscan: 10,
})
if (costs.length === 0) return <EmptyState title={t("exec.noCostData")} />
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<Select value={connectorFilter} onChange={setConnectorFilter} options={[{ value: "all", label: t("exec.allConnectors") }, ...connectorIds.map((id) => ({ value: id, label: id }))]} />
</div>
<div
ref={parentRef}
className="h-full max-h-[600px] overflow-auto border-y border-border"
role="listbox"
aria-label="Cost entries"
onKeyDown={handleKeyDown}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const isTotal = virtualRow.index === filtered.length
if (isTotal) {
return (
<div
key="__total__"
data-index={virtualRow.index}
ref={virtualizer.measureElement}
tabIndex={getTabIndex(virtualRow.index)}
role="option"
aria-selected={false}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 outline-none border-b border-border px-4 py-3 last:border-b-0 ${virtualRow.index === focusedIndex ? "ring-1 ring-[color:var(--primary)] ring-inset" : ""}`}
>
<div className="flex items-center justify-between">
<div className="text-[13px] font-semibold text-foreground">Total ({filtered.length} entries)</div>
<div className="font-mono tabular-nums text-[13px] font-bold tracking-[-0.02em] text-foreground">{formatMoney(total)}</div>
</div>
</div>
)
}
const entry = filtered[virtualRow.index]
return (
<div
key={entry.id}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
tabIndex={getTabIndex(virtualRow.index)}
role="option"
aria-selected={false}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 outline-none border-b border-border px-4 py-3 last:border-b-0 ${virtualRow.index === focusedIndex ? "ring-1 ring-[color:var(--primary)] ring-inset" : ""}`}
>
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] text-foreground">{runs.find((run) => run.id === entry.runId)?.summary || "Run cost"}</div>
<div className="text-[11px] text-muted-foreground">{agents.find((a) => a.id === entry.agentId)?.name ?? "Unknown agent"} · {entry.connectorId}</div>
</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-warning">unattributed</div> : null}
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
@@ -1,135 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { CheckCircle, Warning } from "../ui/icons";
import type { BudgetIncidentRecord } from "@shared/types";
import { ActionButton, EmptyState, ListFrame, ListRow } from "../ui";
import { formatTime } from "../../lib/formatters";
interface IncidentListProps {
companyId: string;
agents: Array<{ id: string; name: string }>;
projects: Array<{ id: string; name: string }>;
}
const STATUS_STYLES: Record<string, string> = {
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",
};
const STATUS_LABELS: Record<string, string> = {
open: "Open",
resolved: "Resolved",
auto_resolved_limit_increase: "Auto-resolved",
auto_closed: "Auto-closed",
};
export function IncidentList({ companyId, agents, projects }: IncidentListProps) {
const [incidents, setIncidents] = useState<BudgetIncidentRecord[]>([]);
const [filter, setFilter] = useState<"all" | "open">("open");
const loadData = useCallback(async () => {
const result = await window.agentCompany.listBudgetIncidents({ companyId, status: filter });
if (result.ok) setIncidents(result.data);
}, [companyId, filter]);
useEffect(() => { void loadData(); }, [loadData]);
function getScopeName(scopeType: string, scopeId: string): string {
if (scopeType === "company") return "Company";
if (scopeType === "agent") {
const agent = agents.find((a) => a.id === scopeId);
return agent ? agent.name : scopeId.slice(0, 8);
}
if (scopeType === "project") {
const project = projects.find((p) => p.id === scopeId);
return project ? project.name : scopeId.slice(0, 8);
}
return scopeId.slice(0, 8);
}
async function handleResolve(incidentId: string) {
const result = await window.agentCompany.resolveBudgetIncident({ companyId, incidentId });
if (result.ok) void loadData();
}
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center justify-between px-4 py-3">
<h3 className="text-[15px] font-semibold text-foreground">Budget Incidents</h3>
<div className="flex items-center gap-1 rounded-lg bg-muted p-0.5">
{(["open", "all"] as const).map((f) => (
<button
key={f}
type="button"
onClick={() => setFilter(f)}
className={`rounded-[5px] px-3 py-1 text-[12px] font-medium transition ${
filter === f
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{f === "open" ? "Open" : "All"}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 pb-4">
{incidents.length === 0 ? (
<EmptyState title={filter === "open" ? "No open incidents" : "No incidents yet"} />
) : (
<ListFrame>
{incidents.map((incident) => {
const isWarn = incident.thresholdType === "warn_threshold";
return (
<ListRow key={incident.id}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<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-warning bg-warning-muted"
: "text-destructive bg-destructive-muted"
}`}
>
{isWarn ? <Warning size={10} weight="bold" /> : null}
{isWarn ? "Warning" : "Hard Stop"}
</span>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] ${STATUS_STYLES[incident.status] ?? STATUS_STYLES.open}`}>
{STATUS_LABELS[incident.status] ?? incident.status}
</span>
</div>
<div className="mt-1.5 flex items-center gap-2 text-[12px] text-muted-foreground">
<span className="capitalize">{incident.scopeType}</span>
<span className="text-muted-foreground">&middot;</span>
<span className="font-medium text-foreground">{getScopeName(incident.scopeType, incident.scopeId)}</span>
</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" }}>
${(incident.amountObserved / 100).toFixed(2)} / ${(incident.amountLimit / 100).toFixed(2)}
</span>
<span>{formatTime(incident.windowStart)} - {formatTime(incident.windowEnd)}</span>
</div>
</div>
{incident.status === "open" ? (
<ActionButton
label="Resolve"
icon={CheckCircle}
onClick={() => void handleResolve(incident.id)}
/>
) : null}
</div>
</ListRow>
);
})}
</ListFrame>
)}
</div>
</div>
);
}
@@ -1,146 +0,0 @@
import type { ProfileSnapshot } from "@shared/types";
import { Input } from "../ui";
import { useT } from "../../i18n";
/* ------------------------------------------------------------------ */
/* Shared */
/* ------------------------------------------------------------------ */
export { Input };
export interface ModelEntry {
id: string;
label: string;
enabled: boolean;
}
export type ConnectorStatus = ProfileSnapshot["connectors"][number]["status"];
/* ------------------------------------------------------------------ */
/* StatusBadge */
/* ------------------------------------------------------------------ */
export function StatusBadge({ status }: { status: ConnectorStatus }) {
const t = useT();
if (status === "ready") {
return (
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-success-muted px-2 py-0.5 text-[11px] font-medium text-success">
<span className="h-1.5 w-1.5 rounded-full bg-success" />
{t("models.ready")}
</span>
);
}
if (status === "not_installed") {
return (
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap 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("models.notInstalled")}
</span>
);
}
if (status === "auth_required") {
return (
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-warning-muted px-2 py-0.5 text-[11px] font-medium text-warning">
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
{t("models.authRequired")}
</span>
);
}
if (status === "detected" || (status as string) === "checking") {
return (
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap 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("models.checking")}
</span>
);
}
return (
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap 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>
);
}
/* ------------------------------------------------------------------ */
/* Toggle switch */
/* ------------------------------------------------------------------ */
export function ToggleSwitch({ checked, onChange, label }: { checked: boolean; onChange: () => void; label: string }) {
return (
<button
type="button"
onClick={onChange}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${checked ? "bg-success" : "bg-muted-foreground/30"}`}
role="switch"
aria-checked={checked}
aria-label={label}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-background shadow-sm transition-transform ${checked ? "translate-x-[18px]" : "translate-x-[3px]"}`}
/>
</button>
);
}
/* ------------------------------------------------------------------ */
/* Model toggle list */
/* ------------------------------------------------------------------ */
import { X as XIcon } from "../ui/icons";
export function ModelToggleList({
models,
onToggle,
onRemove,
canRemove,
}: {
models: ModelEntry[];
onToggle: (id: string) => void;
onRemove?: (id: string) => void;
canRemove: boolean;
}) {
const t = useT();
if (models.length === 0) {
return (
<div className="text-[12px] text-muted-foreground">
{t("models.noModels")}
</div>
);
}
return (
<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 ? t("models.disableToggle") : t("models.enableToggle")).replace("{name}", 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={t("models.removeModel").replace("{name}", m.label)}
>
<XIcon size={12} />
</button>
) : null}
</div>
))}
</div>
</div>
);
}
@@ -1,130 +0,0 @@
import { Robot } from "../ui/icons"
import type { AgentRecord, ApprovalRecord, TaskRecord } from "@shared/types"
import { useT, type TranslationFn } from "../../i18n"
import { EmptyState, ListFrame } from "../ui"
interface PendingReviewListProps {
tasks: TaskRecord[]
agents: AgentRecord[]
approvals: ApprovalRecord[]
}
type ReviewKind = "board" | "user" | "manager"
interface ReviewRow {
task: TaskRecord
assignee: AgentRecord | null
reviewer: AgentRecord | null
kind: ReviewKind
}
function classifyReview(
task: TaskRecord,
agents: AgentRecord[],
pendingApprovalTaskIds: Set<string>,
): ReviewRow {
const assignee = agents.find((a) => a.id === task.assigneeAgentId) ?? null
if (pendingApprovalTaskIds.has(task.id)) {
return { task, assignee, reviewer: null, kind: "board" }
}
if (task.requiresUserReview) {
return { task, assignee, reviewer: null, kind: "user" }
}
if (!assignee) {
return { task, assignee: null, reviewer: null, kind: "user" }
}
if (!assignee.reportsTo) {
return { task, assignee, reviewer: null, kind: "user" }
}
const reviewer = agents.find((a) => a.id === assignee.reportsTo) ?? null
return { task, assignee, reviewer, kind: "manager" }
}
function groupTitle(kind: ReviewKind, t: TranslationFn): string {
if (kind === "board") return t("exec.reviewGroupBoard")
if (kind === "user") return t("exec.reviewGroupUser")
return t("exec.reviewGroupManager")
}
function renderReviewerLine(row: ReviewRow, t: TranslationFn): React.ReactNode {
const assigneeName = row.assignee?.name ?? "Unassigned"
if (row.kind === "board") {
return (
<>
{assigneeName} · <span className="text-warning">{t("exec.reviewAwaitsBoard")}</span>
</>
)
}
if (row.kind === "user") {
return (
<>
{assigneeName} · <span className="text-warning">{t("exec.reviewAwaitsUser")}</span>
</>
)
}
const reviewer = row.reviewer
if (!reviewer) {
return <>{assigneeName} · {t("exec.awaitingReview")}</>
}
return (
<>
{assigneeName} &rarr; {t("exec.reviewedBy")}{" "}
<span className="text-muted-foreground">
{reviewer.name}
{reviewer.title ? ` (${reviewer.title})` : ""}
</span>
</>
)
}
function ReviewGroup({ title, rows, t }: { title: string; rows: ReviewRow[]; t: TranslationFn }) {
return (
<div>
<div className="px-4 pt-3 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{title}
</div>
{rows.map((row) => (
<div key={row.task.id} className="flex items-start gap-3 border-b border-border px-4 py-3 last:border-b-0">
<Robot size={16} weight="fill" className="mt-0.5 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">{row.task.title}</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">{renderReviewerLine(row, t)}</div>
</div>
</div>
))}
</div>
)
}
/**
* Pending reviews separated into three visually distinct groups so
* Board / Human / Manager review semantics do not collapse into one
* faceless list. Priority order (board > user > manager) lines up
* with who is actually being asked to act next.
*/
export function PendingReviewList({ tasks, agents, approvals }: PendingReviewListProps) {
const t = useT()
const reviewTasks = tasks.filter((tk) => tk.status === "in_review")
if (reviewTasks.length === 0) {
return <EmptyState title={t("exec.noReviewsInProgress")} />
}
const pendingApprovalTaskIds = new Set(
approvals
.filter((approval) => approval.state === "pending" || approval.state === "revision_requested")
.map((approval) => approval.relatedTaskId)
.filter((taskId): taskId is string => Boolean(taskId)),
)
const rows = reviewTasks.map((task) => classifyReview(task, agents, pendingApprovalTaskIds))
const boardRows = rows.filter((r) => r.kind === "board")
const userRows = rows.filter((r) => r.kind === "user")
const managerRows = rows.filter((r) => r.kind === "manager")
return (
<ListFrame>
{boardRows.length > 0 ? <ReviewGroup title={t("exec.reviewGroupBoard")} rows={boardRows} t={t} /> : null}
{userRows.length > 0 ? <ReviewGroup title={groupTitle("user", t)} rows={userRows} t={t} /> : null}
{managerRows.length > 0 ? <ReviewGroup title={groupTitle("manager", t)} rows={managerRows} t={t} /> : null}
</ListFrame>
)
}
@@ -1,164 +0,0 @@
import { useEffect, useState } from "react";
import type { BudgetPolicyRecord } from "@shared/types";
import { ActionButton, Input, Modal, Select } from "../ui";
import { buildBudgetPolicyInput, getBudgetPolicyAmountDollars } from "../../lib/budget-policy-input";
import { useT } from "../../i18n";
interface PolicyFormModalProps {
open: boolean;
onClose: () => void;
onSaved: () => void;
companyId: string;
editingPolicy: BudgetPolicyRecord | null;
agents: Array<{ id: string; name: string }>;
projects: Array<{ id: string; name: string }>;
}
export function PolicyFormModal({ open, onClose, onSaved, companyId, editingPolicy, agents, projects }: PolicyFormModalProps) {
const t = useT();
const [scopeType, setScopeType] = useState<"company" | "agent" | "project">("company");
const [scopeId, setScopeId] = useState(companyId);
const [amountDollars, setAmountDollars] = useState("100");
const [warnPercent, setWarnPercent] = useState("80");
const [hardStopEnabled, setHardStopEnabled] = useState(true);
const [notifyEnabled, setNotifyEnabled] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (editingPolicy) {
setScopeType(editingPolicy.scopeType);
setScopeId(editingPolicy.scopeId);
setAmountDollars(getBudgetPolicyAmountDollars(editingPolicy));
setWarnPercent(String(editingPolicy.warnPercent));
setHardStopEnabled(editingPolicy.hardStopEnabled);
setNotifyEnabled(editingPolicy.notifyEnabled);
} else {
setScopeType("company");
setScopeId(companyId);
setAmountDollars("100");
setWarnPercent("80");
setHardStopEnabled(true);
setNotifyEnabled(true);
}
}, [editingPolicy, companyId]);
useEffect(() => {
if (scopeType === "company") {
setScopeId(companyId);
} else if (scopeType === "agent" && agents.length > 0) {
setScopeId(agents[0].id);
} else if (scopeType === "project" && projects.length > 0) {
setScopeId(projects[0].id);
}
}, [scopeType, companyId, agents, projects]);
async function handleSave() {
setSaving(true);
try {
const payload = buildBudgetPolicyInput({
policyId: editingPolicy?.id ?? null,
companyId,
scopeType,
scopeId,
amountDollars,
warnPercent,
hardStopEnabled,
notifyEnabled,
});
if (!payload) return;
const result = await window.agentCompany.saveBudgetPolicy(payload);
if (result.ok) {
onSaved();
onClose();
}
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
title={editingPolicy ? t("policy.editTitle") : t("policy.addTitle")}
>
<div className="grid gap-4">
<Select
label={t("policy.scope")}
value={scopeType}
onChange={(v) => setScopeType(v as "company" | "agent" | "project")}
options={[
{ value: "company", label: t("policy.scopeCompany") },
{ value: "agent", label: t("policy.scopeAgent") },
{ value: "project", label: t("policy.scopeProject") },
]}
/>
{scopeType === "agent" ? (
<Select
label={t("policy.scopeAgent")}
value={scopeId}
onChange={setScopeId}
options={agents.map((a) => ({ value: a.id, label: a.name }))}
/>
) : null}
{scopeType === "project" ? (
<Select
label={t("policy.scopeProject")}
value={scopeId}
onChange={setScopeId}
options={projects.map((p) => ({ value: p.id, label: p.name }))}
/>
) : null}
<Input
label={t("policy.monthlyBudget")}
value={amountDollars}
onChange={setAmountDollars}
type="number"
placeholder="100.00"
/>
<Input
label={t("policy.warningThreshold")}
value={warnPercent}
onChange={setWarnPercent}
type="number"
placeholder="80"
/>
<label className="flex items-center gap-2.5">
<input
type="checkbox"
checked={hardStopEnabled}
onChange={(e) => setHardStopEnabled(e.target.checked)}
className="h-4 w-4 rounded border-border accent-[color:var(--primary)]"
/>
<span className="text-[13px] text-foreground">{t("policy.hardStop")}</span>
</label>
<label className="flex items-center gap-2.5">
<input
type="checkbox"
checked={notifyEnabled}
onChange={(e) => setNotifyEnabled(e.target.checked)}
className="h-4 w-4 rounded border-border accent-[color:var(--primary)]"
/>
<span className="text-[13px] text-foreground">{t("policy.sendNotifications")}</span>
</label>
<div className="flex items-center justify-end gap-3 pt-2">
<ActionButton label={t("policy.cancel")} onClick={onClose} />
<ActionButton
label={saving ? t("policy.saving") : t("policy.savePolicy")}
tone="accent"
disabled={saving}
onClick={() => void handleSave()}
/>
</div>
</div>
</Modal>
);
}
@@ -1,160 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { Plus, ShieldCheck, Trash, Warning } from "../ui/icons";
import type { BudgetPolicyRecord, BudgetStatusSummary } from "@shared/types";
import { ActionButton, EmptyState, ListFrame, ListRow } from "../ui";
import { PolicyFormModal } from "./PolicyFormModal";
interface PolicyListProps {
companyId: string;
agents: Array<{ id: string; name: string }>;
projects: Array<{ id: string; name: string }>;
}
export function PolicyList({ companyId, agents, projects }: PolicyListProps) {
const [policies, setPolicies] = useState<BudgetPolicyRecord[]>([]);
const [budgetStatus, setBudgetStatus] = useState<BudgetStatusSummary | null>(null);
const [showForm, setShowForm] = useState(false);
const [editingPolicy, setEditingPolicy] = useState<BudgetPolicyRecord | null>(null);
const loadData = useCallback(async () => {
const [polRes, statusRes] = await Promise.all([
window.agentCompany.listBudgetPolicies(companyId),
window.agentCompany.getBudgetStatus(companyId),
]);
if (polRes.ok) setPolicies(polRes.data);
if (statusRes.ok) setBudgetStatus(statusRes.data);
}, [companyId]);
useEffect(() => { void loadData(); }, [loadData]);
function getScopeName(policy: BudgetPolicyRecord): string {
if (policy.scopeType === "company") return "Company";
if (policy.scopeType === "agent") {
const agent = agents.find((a) => a.id === policy.scopeId);
return agent ? agent.name : policy.scopeId.slice(0, 8);
}
if (policy.scopeType === "project") {
const project = projects.find((p) => p.id === policy.scopeId);
return project ? project.name : policy.scopeId.slice(0, 8);
}
return policy.scopeId.slice(0, 8);
}
function getUtilization(policyId: string): { spent: number; amount: number; pct: number; hasIncident: boolean } | null {
if (!budgetStatus) return null;
const entry = budgetStatus.policies.find((p) => p.policyId === policyId);
if (!entry) return null;
const pct = entry.amount > 0 ? (entry.spent / entry.amount) * 100 : 0;
return { spent: entry.spent, amount: entry.amount, pct, hasIncident: entry.hasOpenIncident };
}
async function handleDelete(policyId: string) {
const result = await window.agentCompany.deleteBudgetPolicy({ companyId, policyId });
if (result.ok) void loadData();
}
function handleEdit(policy: BudgetPolicyRecord) {
setEditingPolicy(policy);
setShowForm(true);
}
function handleAdd() {
setEditingPolicy(null);
setShowForm(true);
}
function handleSaved() {
void loadData();
}
if (policies.length === 0 && !showForm) {
return (
<div className="p-6">
<EmptyState
title="No budget policies"
action={<ActionButton label="Add Policy" icon={Plus} onClick={handleAdd} />}
/>
<PolicyFormModal
open={showForm}
onClose={() => setShowForm(false)}
onSaved={handleSaved}
companyId={companyId}
editingPolicy={editingPolicy}
agents={agents}
projects={projects}
/>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center justify-between px-4 py-3">
<h3 className="text-[15px] font-semibold text-foreground">Budget Policies</h3>
<ActionButton label="Add Policy" icon={Plus} onClick={handleAdd} />
</div>
<div className="flex-1 overflow-y-auto px-4 pb-4">
<ListFrame>
{policies.map((policy) => {
const util = getUtilization(policy.id);
const pct = util?.pct ?? 0;
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">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground">
{policy.scopeType}
</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-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" }}>
${(policy.amount / 100).toFixed(2)}/mo
</span>
<span>Warn at {policy.warnPercent}%</span>
</div>
{util ? (
<div className="mt-1.5 flex items-center gap-2">
<div className="h-1.5 flex-1 rounded-full bg-[color:var(--border)]">
<div className={`h-1.5 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} />
</div>
<span className="text-[10px] font-[family-name:var(--font-mono)] text-muted-foreground" style={{ fontVariantNumeric: "tabular-nums" }}>
{Math.round(pct)}%
</span>
</div>
) : null}
</div>
<button
type="button"
className="shrink-0 rounded p-1 text-muted-foreground transition hover:text-destructive"
title="Delete policy"
aria-label="Delete policy"
onClick={(e) => {
e.stopPropagation();
void handleDelete(policy.id);
}}
>
<Trash size={14} />
</button>
</div>
</ListRow>
);
})}
</ListFrame>
</div>
<PolicyFormModal
open={showForm}
onClose={() => { setShowForm(false); setEditingPolicy(null); }}
onSaved={handleSaved}
companyId={companyId}
editingPolicy={editingPolicy}
agents={agents}
projects={projects}
/>
</div>
);
}
@@ -1,120 +0,0 @@
import { useState, useMemo } from "react";
import type { ProfileSnapshot, SecretRecord } from "@shared/types";
import type { AppLocale } from "@shared/locale";
import { useEffect } from "react";
import { useLocale, useT } from "../../i18n";
import { GeneralSection } from "./settings-sections/GeneralSection";
import { ExecutionModeSection } from "./settings-sections/ExecutionModeSection";
import { ShortcutsSection } from "./settings-sections/ShortcutsSection";
import { DataSection } from "./settings-sections/DataSection";
import { SecretsSection } from "./settings-sections/SecretsSection";
import { ProvidersPanel } from "../settings/providers/ProvidersPanel";
type SettingsTab = "general" | "mode" | "providers" | "shortcuts" | "data" | "secrets";
interface SettingsShellProps {
snapshot: ProfileSnapshot;
secrets: SecretRecord[];
companyId: string;
onSaved: () => Promise<void>;
}
/**
* Settings page with top-row tab navigation. Replaces the previous
* single-scroll layout that stacked seven unrelated concerns
* (preferences, execution mode, providers, keyboard reference, data
* ops, secrets) in one column forcing users to scroll past everything
* they didn't need.
*
* Tabs live as local state — settings are transient; there is no
* benefit to persisting the active tab across company switches or
* app restarts. Default is "general" because first-time users most
* often land here for theme / language.
*/
export function SettingsShell({ snapshot, secrets, companyId, onSaved }: SettingsShellProps) {
const t = useT();
const currentLocale = useLocale();
const [activeTab, setActiveTab] = useState<SettingsTab>("general");
const [theme, setTheme] = useState(snapshot.theme);
const [selectedLocale, setSelectedLocale] = useState<AppLocale>(snapshot.locale ?? currentLocale);
const [updatesEnabled, setUpdatesEnabled] = useState(snapshot.updatesEnabled);
useEffect(() => {
setTheme(snapshot.theme);
setUpdatesEnabled(snapshot.updatesEnabled);
if (snapshot.locale) setSelectedLocale(snapshot.locale);
}, [snapshot.theme, snapshot.updatesEnabled, snapshot.locale]);
const company = useMemo(
() => snapshot.companies.find((entry) => entry.id === companyId) ?? null,
[snapshot.companies, companyId],
);
const tabs: Array<{ id: SettingsTab; label: string }> = [
{ id: "general", label: t("settings.tabs.general") },
{ id: "mode", label: t("settings.tabs.mode") },
{ id: "providers", label: t("settings.tabs.providers") },
{ id: "shortcuts", label: t("settings.tabs.shortcuts") },
{ id: "data", label: t("settings.tabs.data") },
{ id: "secrets", label: t("settings.tabs.secrets") },
];
return (
<div className="space-y-6">
<nav
role="tablist"
aria-label={t("settings.tabsAria")}
className="flex flex-wrap gap-1 border-b border-border"
>
{tabs.map((tab) => {
const active = activeTab === tab.id;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => setActiveTab(tab.id)}
className={`relative px-4 py-2 text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
active
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab.label}
{active ? (
<span
aria-hidden="true"
className="absolute inset-x-2 bottom-[-1px] h-[2px] bg-primary"
/>
) : null}
</button>
);
})}
</nav>
<div>
{activeTab === "general" ? (
<GeneralSection
theme={theme}
setTheme={setTheme}
selectedLocale={selectedLocale}
setSelectedLocale={setSelectedLocale}
updatesEnabled={updatesEnabled}
setUpdatesEnabled={setUpdatesEnabled}
onSaved={onSaved}
/>
) : null}
{activeTab === "mode" && company ? (
<ExecutionModeSection company={company} onSaved={onSaved} />
) : null}
{activeTab === "providers" ? <ProvidersPanel /> : null}
{activeTab === "shortcuts" ? <ShortcutsSection /> : null}
{activeTab === "data" ? <DataSection snapshot={snapshot} onSaved={onSaved} /> : null}
{activeTab === "secrets" ? (
<SecretsSection secrets={secrets} companyId={companyId} onSaved={onSaved} />
) : null}
</div>
</div>
);
}
@@ -1,129 +0,0 @@
import { Clock, Link as LinkIcon } from "../ui/icons"
import { platformConfig } from "../social/social-config"
import type { SocialPlatform } from "@shared/types"
import { safeJson } from "../../lib/safe-json"
const PLATFORM_CHAR_LIMITS: Record<string, number> = {
twitter: 280,
linkedin: 3000,
reddit: 40000,
instagram: 2200,
}
const DEFAULT_CHAR_LIMIT = 5000
interface SocialDraftPreviewProps {
payloadJson: string
platform: string
isPending: boolean
onScheduleChange?: (scheduledAt: string | null) => void
}
interface DraftPayload {
content: string
mediaUrls: string[]
scheduledAt: string | null
socialAccountId: string
platform: string
}
function parseDraft(payloadJson: string): DraftPayload | null {
const parsed = safeJson<unknown>(payloadJson, null)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null
const obj = parsed as Record<string, unknown>
return {
content: typeof obj.content === "string" ? obj.content : "",
mediaUrls: Array.isArray(obj.mediaUrls) ? (obj.mediaUrls as string[]) : [],
scheduledAt: typeof obj.scheduledAt === "string" ? obj.scheduledAt : null,
socialAccountId: typeof obj.socialAccountId === "string" ? obj.socialAccountId : "",
platform: typeof obj.platform === "string" ? obj.platform : "other",
}
}
function formatScheduledDate(isoDate: string): string {
try {
const d = new Date(isoDate)
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
})
} catch {
return isoDate
}
}
export function SocialDraftPreview({ payloadJson, platform, isPending: _isPending }: SocialDraftPreviewProps) {
const draft = parseDraft(payloadJson)
if (!draft) return null
const platformKey = (draft.platform || platform || "other") as SocialPlatform
const config = platformConfig[platformKey] ?? platformConfig.other
const PlatformIcon = config.icon
const platformColor = config.color
const charLimit = PLATFORM_CHAR_LIMITS[platformKey] ?? DEFAULT_CHAR_LIMIT
const charCount = draft.content.length
const isOverLimit = charCount > charLimit
return (
<div
role="region"
aria-label="Social post draft preview"
className="rounded-lg border px-4 py-3"
style={{ borderColor: `${platformColor}4D` }}
>
<div className="mb-2 flex items-center gap-2">
<span
className="text-[11px] font-semibold uppercase tracking-[0.06em]"
style={{ color: platformColor }}
>
SOCIAL POST DRAFT
</span>
</div>
<div className="mb-3 flex items-center gap-2">
<PlatformIcon size={16} style={{ color: platformColor }} />
<span className="text-[13px] font-medium text-foreground">
{config.label}
</span>
{draft.socialAccountId ? (
<span className="text-[13px] font-[family-name:var(--font-mono)] text-muted-foreground">
@{draft.socialAccountId.slice(0, 8)}
</span>
) : null}
</div>
<div className="mb-2 max-h-[200px] overflow-y-auto whitespace-pre-wrap text-[13px] leading-relaxed text-foreground">
{draft.content}
</div>
<div
className="mb-2 text-[10px] font-[family-name:var(--font-mono)]"
style={{ color: isOverLimit ? "var(--destructive)" : "var(--muted-foreground)" }}
aria-live="polite"
>
{charCount} / {charLimit}{isOverLimit ? " -- over limit" : ""}
</div>
{draft.mediaUrls.length > 0 ? (
<div className="mb-2 space-y-1">
{draft.mediaUrls.map((url, idx) => (
<div key={idx} className="flex items-center gap-1.5 text-[11px] text-primary">
<LinkIcon size={12} />
<span className="truncate max-w-[300px]">{url}</span>
</div>
))}
</div>
) : null}
{draft.scheduledAt ? (
<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>
) : null}
</div>
)
}
@@ -1,63 +0,0 @@
import { useEffect, useState } from "react"
import { Robot, ArrowRight } from "../ui/icons"
import type { CommentRecord, ProfileSnapshot } from "@shared/types"
import { useT } from "../../i18n"
import { formatTime } from "../../lib/formatters"
import { unwrap } from "../../lib/desktop"
import { ActionButton, InlineNotice } from "../ui"
import { MarkdownBody } from "../ui/MarkdownBody"
import { describeActionError } from "./execution-utils"
export function TaskComments({ taskId, companyId, agents, comments, onRefresh }: {
taskId: string; companyId: string; agents: ProfileSnapshot["agents"]; comments: CommentRecord[]; onRefresh: () => Promise<void>
}) {
const t = useT()
const [newComment, setNewComment] = useState(""); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState<string | null>(null)
useEffect(() => {
const unsubscribe = window.agentCompany.subscribe((event) => { if (event.type === "agent-message" && event.taskId === taskId) void onRefresh() })
return unsubscribe
}, [onRefresh, taskId])
const handleSubmit = async () => {
if (!newComment.trim()) return; setSubmitError(null); setSubmitting(true)
try { unwrap(await window.agentCompany.addComment({ companyId, taskId, authorName: "Board", body: newComment.trim() })); setNewComment(""); await onRefresh() } catch (e) { setSubmitError(describeActionError(e)) } finally { setSubmitting(false) }
}
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Robot size={16} className="text-muted-foreground" />
<span className="text-[13px] font-semibold text-foreground">{t("exec.communication")}</span>
<span className="text-[11px] text-muted-foreground">({comments.length})</span>
</div>
{comments.length === 0 ? (
<div className="text-[12px] text-muted-foreground">{t("exec.noMessagesYet")}</div>
) : (
<div className="max-h-[240px] space-y-2 overflow-y-auto rounded-lg border border-border p-3">
{comments.map((comment) => {
const isAgent = Boolean(comment.authorAgentId)
const agent = isAgent ? agents.find((a) => a.id === comment.authorAgentId) : null
return (
<div key={comment.id} className="rounded-md bg-muted px-3 py-2">
<div className="flex items-center gap-2">
<span className={`text-[12px] font-semibold ${isAgent ? "text-primary" : "text-foreground"}`}>{comment.authorName}</span>
{agent && <span className="text-[10px] text-muted-foreground">{agent.role}</span>}
<span className="text-[10px] text-muted-foreground">{formatTime(comment.createdAt)}</span>
</div>
<div className="mt-1 text-[13px] text-foreground">
<MarkdownBody body={comment.body} />
</div>
</div>
)
})}
</div>
)}
{submitError ? <InlineNotice message={submitError} /> : null}
<div className="flex gap-2">
<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,61 +0,0 @@
import { useState } from "react"
import { AnimatePresence, motion } from "framer-motion"
import { ListChecks } from "../ui/icons"
import type { GoalRecord, ProfileSnapshot, ProjectRecord, WorkspaceRecord, TaskRecord } from "@shared/types"
import { useT } from "../../i18n"
import { unwrap } from "../../lib/desktop"
import { ActionButton, InlineNotice, Input, Select, TextArea } from "../ui"
import { describeActionError } from "./execution-utils"
export function TaskCreator({ companyId, goals, projects, agents, workspaces, onSaved }: {
companyId: string; goals: GoalRecord[]; projects: ProjectRecord[]; agents: ProfileSnapshot["agents"]; workspaces: WorkspaceRecord[]; onSaved: () => Promise<void>
}) {
const t = useT()
const [title, setTitle] = useState(""); const [description, setDescription] = useState("")
const [goalId, setGoalId] = useState(goals[0]?.id ?? ""); const [projectId, setProjectId] = useState(projects[0]?.id ?? "")
const [assigneeAgentId, setAssigneeAgentId] = useState(agents[0]?.id ?? ""); const [workspaceId, setWorkspaceId] = useState(workspaces[0]?.id ?? "")
const [requiresFilesystem, setRequiresFilesystem] = useState(false)
const [priority, setPriority] = useState("medium"); const [submitError, setSubmitError] = useState<string | null>(null); const [submitting, setSubmitting] = useState(false)
async function handleCreateTask() {
setSubmitError(null); setSubmitting(true)
try { unwrap(await window.agentCompany.saveTask({ companyId, projectId: projectId || null, goalId: goalId || null, title, description, assigneeAgentId: assigneeAgentId || null, workspaceId: workspaceId || null, requiresFilesystem, priority: priority as TaskRecord["priority"], status: "todo" })); setTitle(""); setDescription(""); setRequiresFilesystem(false); 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={t("exec.taskDescription")} value={description} onChange={setDescription} placeholder={t("exec.describeTask")} rows={5} /></div>
<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 }))]} />
<label className="flex items-center gap-2.5 md:col-span-2">
<input
type="checkbox"
checked={requiresFilesystem}
onChange={(event) => setRequiresFilesystem(event.target.checked)}
className="h-4 w-4 rounded border-border accent-[color:var(--primary)]"
/>
<span className="text-[13px] text-foreground">{t("exec.requiresFilesystem")}</span>
</label>
</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>
)
}
@@ -1,257 +0,0 @@
import { useEffect, useMemo, useState, type ReactNode } from "react"
import type {
CommentRecord, GoalRecord, ProfileSnapshot, ProjectRecord, RunRecord,
TaskRecord, TaskStatus, WorkspaceRecord,
} from "@shared/types"
import { useT } from "../../i18n"
import { unwrap } from "../../lib/desktop"
import { ConfirmDialog, InlineNotice } from "../ui"
import { describeActionError, shouldAlertGovernance, type GovernanceAlert } from "./execution-utils"
import { TaskInspectorOverview } from "./TaskInspectorOverview"
import { TaskInspectorSubtasks } from "./TaskInspectorSubtasks"
import { TaskInspectorActivity } from "./TaskInspectorActivity"
import { TaskInspectorGovernance } from "./TaskInspectorGovernance"
import { TaskInspectorEditForm } from "./TaskInspectorEditForm"
import { useAppStore } from "../../state/store"
import type { TaskInspectorTab } from "../../state/slices/ui"
function alertTone(alerts: GovernanceAlert[]): "danger" | "warn" {
return alerts.some((a) => a.severity === "danger") ? "danger" : "warn"
}
function alertMessage(alerts: GovernanceAlert[], t: (key: string, fallback?: string) => string): string {
const parts = alerts.map((a) => {
if (a.kind === "blocked") return t("exec.alertBlocked", "Blocked — needs unblock approval")
if (a.kind === "budget_over") return t("exec.alertBudgetOver", "Assignee budget > 80%")
return t("exec.alertMissingReviewer", "In review without a reviewer")
})
return parts.join(" · ")
}
export function TaskInspector({
task, tasks, runs, agents, goals, projects, workspaces, companyId,
onRun, onCancelRun, onCreateApproval, onOpenDiscussion,
comments, onRefresh, onDeleted,
}: {
task: TaskRecord
tasks: TaskRecord[]
runs: RunRecord[]
agents: ProfileSnapshot["agents"]
goals: GoalRecord[]
projects: ProjectRecord[]
workspaces: WorkspaceRecord[]
companyId: string
onRun: (taskId: string) => Promise<void>
onCancelRun: (runId: string) => Promise<void>
onCreateApproval: () => Promise<void>
onOpenDiscussion: (taskId: string) => void
comments: CommentRecord[]
onRefresh: () => Promise<void>
onDeleted: () => Promise<void>
}) {
const t = useT()
const addToast = useAppStore((s) => s.addToast)
const tab = useAppStore((s) => s.taskInspectorTab)
const setTab = useAppStore((s) => s.setTaskInspectorTab)
const [editing, setEditing] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const [actionError, setActionError] = useState<string | null>(null)
const [actionNotice, setActionNotice] = useState<string | null>(null)
const [deleteError, setDeleteError] = useState<string | null>(null)
const [updatingStatus, setUpdatingStatus] = useState<TaskStatus | null>(null)
const [creatingApproval, setCreatingApproval] = useState(false)
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents])
const assignee = agentMap.get(task.assigneeAgentId ?? "") ?? null
const project = projects.find((p) => p.id === task.projectId) ?? null
const goal = goals.find((g) => g.id === task.goalId) ?? null
const workspace = workspaces.find((w) => w.id === task.workspaceId) ?? null
const activeRun = runs.find((r) => r.id === task.activeRunId) ?? null
const reviewer = assignee?.reportsTo ? agentMap.get(assignee.reportsTo) ?? null : null
const subtasksCount = tasks.filter((s) => s.parentId === task.id).length
const commentsCount = comments.length
const runsCount = runs.length
const alerts = useMemo(
() => shouldAlertGovernance(task, assignee, reviewer),
[task, assignee, reviewer],
)
// If the currently-selected tab disappears (e.g. Subtasks hidden because
// subtasksCount===0), fall back to Overview so the modal never shows a
// blank body. Keeps the persisted tab sticky in all other cases.
useEffect(() => {
if (tab === "subtasks" && subtasksCount === 0) setTab("overview")
}, [tab, subtasksCount, setTab])
async function quickStatus(status: TaskStatus) {
setActionError(null); setActionNotice(null); setUpdatingStatus(status)
try {
unwrap(await window.agentCompany.saveTask({
id: task.id, companyId: task.companyId, title: task.title, description: task.description,
priority: task.priority, status, assigneeAgentId: task.assigneeAgentId,
workspaceId: task.workspaceId, requiresFilesystem: task.requiresFilesystem,
projectId: task.projectId, goalId: task.goalId,
}))
await onRefresh()
setActionNotice(`Task moved to ${status.replaceAll("_", " ")}.`)
} catch (e) {
setActionError(describeActionError(e))
} finally {
setUpdatingStatus(null)
}
}
async function handleCreateApproval() {
setActionError(null); setActionNotice(null); setCreatingApproval(true)
try {
await onCreateApproval()
setActionNotice(task.status === "blocked" ? "Approval created and routed to leadership." : "Approval created.")
addToast("Approval created and routed to leadership", "success")
} catch (e) {
setActionError(describeActionError(e))
} finally {
setCreatingApproval(false)
}
}
async function handleDelete() {
setDeleteError(null)
try {
unwrap(await window.agentCompany.deleteTask({ id: task.id, companyId }))
setConfirmDelete(false)
addToast("Task deleted", "success")
await onDeleted()
} catch (e) {
setDeleteError(describeActionError(e))
}
}
const tabBtn = (key: TaskInspectorTab, label: string, badge?: ReactNode) => {
const active = tab === key
return (
<button
type="button"
onClick={() => setTab(key)}
className={`relative flex items-center rounded-md px-3 py-1.5 text-[12px] font-semibold transition ${
active ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
{label}
{badge}
</button>
)
}
const countPill = (value: number) => (
<span className={`ml-1 rounded-full px-1.5 text-[10px] ${tab === "activity" ? "bg-primary-foreground/20" : "bg-muted-foreground/15"}`}>{value}</span>
)
// Activity badge is a single total. The comments/runs split lives inside
// the Activity tab as filter chips — one signal per surface.
const activityBadge = commentsCount + runsCount > 0 ? countPill(commentsCount + runsCount) : null
// Governance signal speaks once via the Overview banner. No tab-level dot —
// duplicating the alert adds noise without context.
return (
<div className="space-y-5">
<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) }}
/>
{actionError ? <InlineNotice message={actionError} /> : null}
{!actionError && actionNotice ? <InlineNotice message={actionNotice} tone="success" /> : null}
{editing ? (
<TaskInspectorEditForm
task={task}
agents={agents}
goals={goals}
projects={projects}
workspaces={workspaces}
onDone={() => setEditing(false)}
onRefresh={onRefresh}
onError={setActionError}
onNotice={setActionNotice}
addToast={addToast}
/>
) : (
<>
<div className="flex flex-wrap items-center gap-1 border-b border-border pb-2">
{tabBtn("overview", t("exec.tabOverview", "Overview"))}
{subtasksCount > 0 && tabBtn("subtasks", t("exec.tabSubtasks", "Subtasks"), countPill(subtasksCount))}
{tabBtn("activity", t("exec.tabActivity", "Activity"), activityBadge)}
{tabBtn("governance", t("exec.tabGovernance", "Governance"))}
</div>
{tab === "overview" && (
<>
{alerts.length > 0 ? (
<InlineNotice
tone={alertTone(alerts)}
message={alertMessage(alerts, t)}
action={
<button
type="button"
onClick={() => setTab("governance")}
className="shrink-0 rounded-md border border-current px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide"
>
{t("exec.openGovernance", "Open")}
</button>
}
/>
) : null}
<TaskInspectorOverview
task={task}
tasks={tasks}
activeRun={activeRun}
assignee={assignee}
project={project}
goal={goal}
workspace={workspace}
onRun={onRun}
onCancelRun={onCancelRun}
onQuickStatus={quickStatus}
onOpenDiscussion={onOpenDiscussion}
onEdit={() => { setActionError(null); setActionNotice(null); setEditing(true) }}
onDelete={() => setConfirmDelete(true)}
updatingStatus={updatingStatus}
/>
</>
)}
{tab === "subtasks" && subtasksCount > 0 && (
<TaskInspectorSubtasks task={task} tasks={tasks} agents={agents} />
)}
{tab === "activity" && (
<TaskInspectorActivity
taskId={task.id}
companyId={task.companyId}
agents={agents}
comments={comments}
runs={runs}
onRefresh={onRefresh}
/>
)}
{tab === "governance" && (
<TaskInspectorGovernance
task={task}
assignee={assignee}
reviewer={reviewer}
onCreateApproval={handleCreateApproval}
creatingApproval={creatingApproval}
/>
)}
</>
)}
</div>
)
}
@@ -1,166 +0,0 @@
import { useEffect, useMemo, useState } from "react"
import type { CommentRecord, ProfileSnapshot, RunRecord } from "@shared/types"
import { useT } from "../../i18n"
import { formatMoney, formatTime } from "../../lib/formatters"
import { unwrap } from "../../lib/desktop"
import { ActionButton, InlineNotice, StatusPill } from "../ui"
import { MarkdownBody } from "../ui/MarkdownBody"
import { ArrowRight } from "../ui/icons"
import { describeActionError } from "./execution-utils"
type ActivityFilter = "all" | "comments" | "runs"
export function TaskInspectorActivity({
taskId, companyId, agents, comments, runs, onRefresh,
}: {
taskId: string
companyId: string
agents: ProfileSnapshot["agents"]
comments: CommentRecord[]
runs: RunRecord[]
onRefresh: () => Promise<void>
}) {
const t = useT()
const [filter, setFilter] = useState<ActivityFilter>("all")
const [newComment, setNewComment] = useState("")
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents])
useEffect(() => {
const unsubscribe = window.agentCompany.subscribe((event) => {
if (event.type === "agent-message" && event.taskId === taskId) void onRefresh()
})
return unsubscribe
}, [onRefresh, taskId])
const handleSubmit = async () => {
if (!newComment.trim()) return
setSubmitError(null); setSubmitting(true)
try {
unwrap(await window.agentCompany.addComment({ companyId, taskId, authorName: "Board", body: newComment.trim() }))
setNewComment("")
await onRefresh()
} catch (e) {
setSubmitError(describeActionError(e))
} finally {
setSubmitting(false)
}
}
type FeedItem =
| { kind: "comment"; ts: number; data: CommentRecord }
| { kind: "run"; ts: number; data: RunRecord }
const feed: FeedItem[] = useMemo(() => {
const items: FeedItem[] = []
const toMs = (value: string | null) => (value ? Date.parse(value) : 0)
if (filter === "all" || filter === "comments") {
for (const c of comments) items.push({ kind: "comment", ts: toMs(c.createdAt), data: c })
}
if (filter === "all" || filter === "runs") {
for (const r of runs) items.push({ kind: "run", ts: toMs(r.startedAt ?? r.createdAt), data: r })
}
items.sort((a, b) => b.ts - a.ts)
return items
}, [comments, runs, filter])
const filterBtn = (key: ActivityFilter, label: string) => (
<button
type="button"
onClick={() => setFilter(key)}
className={`rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.08em] transition ${
filter === key ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:text-foreground"
}`}
>
{label}
</button>
)
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
{filterBtn("all", t("exec.activityAll", "All"))}
{filterBtn("comments", t("exec.communication"))}
{filterBtn("runs", t("exec.runHistory"))}
<span className="ml-auto text-[11px] text-muted-foreground">{feed.length}</span>
</div>
{feed.length === 0 ? (
<div className="text-[12px] text-muted-foreground">{t("exec.noMessagesYet")}</div>
) : (
<div className="max-h-[320px] space-y-2 overflow-y-auto rounded-lg border border-border p-3">
{feed.map((item) => {
if (item.kind === "comment") {
const c = item.data
const isAgent = Boolean(c.authorAgentId)
const agent = isAgent ? agentMap.get(c.authorAgentId ?? "") : null
return (
<div key={`c-${c.id}`} className="rounded-md bg-muted px-3 py-2">
<div className="flex items-center gap-2">
<span className={`text-[12px] font-semibold ${isAgent ? "text-primary" : "text-foreground"}`}>{c.authorName}</span>
{agent && <span className="text-[10px] text-muted-foreground">{agent.role}</span>}
<span className="text-[10px] text-muted-foreground">{formatTime(c.createdAt)}</span>
</div>
<div className="mt-1 text-[13px] text-foreground"><MarkdownBody body={c.body} /></div>
</div>
)
}
const run = item.data
const runAgent = agentMap.get(run.agentId)
const isActive = run.status === "running" || run.status === "queued"
const needsAttention = run.status === "failed" || run.status === "timed_out"
// Two tiers only: resting (default border + muted bg) vs attention
// (destructive border + muted bg). Active runs earn a pulsing dot,
// not a third border color — less rhythm, more signal.
return (
<div
key={`r-${run.id}`}
className={`rounded-lg border px-3 py-2.5 bg-muted ${
needsAttention ? "border-destructive" : "border-border"
}`}
>
<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 ? t("exec.runningEllipsis") : t("exec.completedRun"))}
</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>
)
})}
</div>
)}
{submitError ? <InlineNotice message={submitError} /> : null}
<div className="flex gap-2">
<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,87 +0,0 @@
import { useState } from "react"
import { CheckCircle, XCircle } from "../ui/icons"
import type { GoalRecord, ProfileSnapshot, ProjectRecord, TaskRecord, TaskStatus, WorkspaceRecord } from "@shared/types"
import { useT } from "../../i18n"
import { unwrap } from "../../lib/desktop"
import { ActionButton, Input, Select, TextArea } from "../ui"
import { describeActionError } from "./execution-utils"
export function TaskInspectorEditForm({
task, agents, goals, projects, workspaces, onDone, onRefresh, onError, onNotice, addToast,
}: {
task: TaskRecord
agents: ProfileSnapshot["agents"]
goals: GoalRecord[]
projects: ProjectRecord[]
workspaces: WorkspaceRecord[]
onDone: () => void
onRefresh: () => Promise<void>
onError: (message: string | null) => void
onNotice: (message: string | null) => void
addToast: (message: string, tone?: "success" | "warn" | "danger") => void
}) {
const t = useT()
const [title, setTitle] = useState(task.title)
const [description, setDescription] = useState(task.description)
const [priority, setPriority] = useState(task.priority)
const [status, setStatus] = useState<TaskStatus>(task.status)
const [assignee, setAssignee] = useState(task.assigneeAgentId ?? "")
const [workspace, setWorkspace] = useState(task.workspaceId ?? "")
const [requiresFilesystem, setRequiresFilesystem] = useState(task.requiresFilesystem)
const [project, setProject] = useState(task.projectId ?? "")
const [goal, setGoal] = useState(task.goalId ?? "")
const [saving, setSaving] = useState(false)
const save = async () => {
onError(null); onNotice(null); setSaving(true)
try {
unwrap(await window.agentCompany.saveTask({
id: task.id,
companyId: task.companyId,
title,
description,
priority: priority as TaskRecord["priority"],
status,
assigneeAgentId: assignee || null,
workspaceId: workspace || null,
requiresFilesystem,
projectId: project || null,
goalId: goal || null,
}))
await onRefresh()
onDone()
onNotice("Task changes saved.")
addToast("Task changes saved", "success")
} catch (e) {
onError(describeActionError(e))
} finally {
setSaving(false)
}
}
return (
<div className="grid min-w-0 gap-3 md:grid-cols-2">
<div className="md:col-span-2"><Input label={t("exec.title")} value={title} onChange={setTitle} placeholder={t("exec.taskTitle")} /></div>
<div className="md:col-span-2"><TextArea label={t("exec.description")} value={description} onChange={setDescription} placeholder={t("exec.description")} rows={4} /></div>
<Select label={t("exec.priority")} value={priority} onChange={(v) => setPriority(v as TaskRecord["priority"])} options={["critical", "high", "medium", "low"].map((v) => ({ value: v, label: v }))} />
<Select label={t("exec.status")} value={status} onChange={(v) => setStatus(v as TaskStatus)} options={["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"].map((v) => ({ value: v, label: v.replaceAll("_", " ") }))} />
<Select label={t("exec.assignee")} value={assignee} onChange={setAssignee} options={[{ value: "", label: t("exec.unassigned") }, ...agents.map((a) => ({ value: a.id, label: a.name }))]} />
<Select label={t("exec.workspace")} value={workspace} onChange={setWorkspace} options={[{ value: "", label: t("exec.noWorkspace") }, ...workspaces.map((w) => ({ value: w.id, label: w.name }))]} />
<label className="flex items-center gap-2.5 md:col-span-2">
<input
type="checkbox"
checked={requiresFilesystem}
onChange={(event) => setRequiresFilesystem(event.target.checked)}
className="h-4 w-4 rounded border-border accent-[color:var(--primary)]"
/>
<span className="text-[13px] text-foreground">{t("exec.requiresFilesystem")}</span>
</label>
<Select label={t("exec.project")} value={project} onChange={setProject} options={[{ value: "", label: t("exec.noProject") }, ...projects.map((p) => ({ value: p.id, label: p.name }))]} />
<Select label={t("exec.goal")} value={goal} onChange={setGoal} options={[{ value: "", label: t("exec.noGoal") }, ...goals.map((g) => ({ value: g.id, label: g.title }))]} />
<div className="md:col-span-2 flex gap-3">
<ActionButton label={t("exec.save")} tone="accent" onClick={() => void save()} icon={CheckCircle} loading={saving} />
<ActionButton label={t("exec.cancel")} onClick={onDone} icon={XCircle} />
</div>
</div>
)
}
@@ -1,72 +0,0 @@
import type { ProfileSnapshot, TaskRecord } from "@shared/types"
import { useT } from "../../i18n"
import { formatMoney } from "../../lib/formatters"
import { ActionButton } from "../ui"
import { Robot, ShieldCheck, Warning } from "../ui/icons"
export function TaskInspectorGovernance({
task, assignee, reviewer, onCreateApproval, creatingApproval,
}: {
task: TaskRecord
assignee: ProfileSnapshot["agents"][number] | null
reviewer: ProfileSnapshot["agents"][number] | null
onCreateApproval: () => Promise<void>
creatingApproval: boolean
}) {
const t = useT()
const budgetRatio = assignee && assignee.budgetMonthlyUsd > 0 ? assignee.spentMonthlyUsd / assignee.budgetMonthlyUsd : 0
const budgetWarn = budgetRatio > 0.8
const needsGovernance = task.status === "blocked" || (task.status === "in_review" && !reviewer)
const inReviewWithReviewer = task.status === "in_review" && reviewer
const anything = budgetWarn || needsGovernance || inReviewWithReviewer
if (!anything) {
return <div className="text-[13px] text-muted-foreground">{t("exec.noGovernanceSignals", "No governance signals.")}</div>
}
return (
<div className="space-y-3">
{budgetWarn && assignee ? (
<div className={`flex items-center gap-2 rounded-lg border px-4 py-3 text-[13px] font-medium ${
budgetRatio > 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} {t("exec.budgetUsed").replace("{0}", String(Math.round(budgetRatio * 100)))}
({formatMoney(assignee.spentMonthlyUsd)} / {formatMoney(assignee.budgetMonthlyUsd)})
</div>
) : null}
{inReviewWithReviewer ? (
<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" />
{t("exec.beingReviewedBy").replace("{0}", reviewer.name + (reviewer.title ? ` (${reviewer.title})` : ""))}
</div>
<div className="mt-1 text-[13px] leading-relaxed text-primary">
{t("exec.submittedForReview").replace("{0}", assignee?.name ?? "").replace("{1}", reviewer.name)}
</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 onCreateApproval()}
icon={ShieldCheck}
loading={creatingApproval}
/>
</div>
</div>
) : null}
</div>
)
}
@@ -1,79 +0,0 @@
import { CheckCircle, Pencil, Play, Trash, XCircle, ArrowRight } from "../ui/icons"
import type { GoalRecord, ProfileSnapshot, ProjectRecord, RunRecord, TaskRecord, TaskStatus, WorkspaceRecord } from "@shared/types"
import { useT } from "../../i18n"
import { formatMoney } from "../../lib/formatters"
import { ActionButton, StatusPill } from "../ui"
export function TaskInspectorOverview({
task, tasks, activeRun, assignee, project, goal, workspace,
onRun, onCancelRun, onQuickStatus, onOpenDiscussion, onEdit, onDelete, updatingStatus,
}: {
task: TaskRecord
tasks: TaskRecord[]
activeRun: RunRecord | null
assignee: ProfileSnapshot["agents"][number] | null
project: ProjectRecord | null
goal: GoalRecord | null
workspace: WorkspaceRecord | null
onRun: (taskId: string) => Promise<void>
onCancelRun: (runId: string) => Promise<void>
onQuickStatus: (status: TaskStatus) => Promise<void>
onOpenDiscussion: (taskId: string) => void
onEdit: () => void
onDelete: () => void
updatingStatus: TaskStatus | null
}) {
const t = useT()
const parent = task.parentId ? tasks.find((p) => p.id === task.parentId) ?? null : null
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<StatusPill status={task.status} />
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground">
{task.priority}
</span>
{assignee && <span className="text-[12px] text-muted-foreground">{t("exec.assigneeLabel")} <span className="text-foreground">{assignee.name}</span></span>}
</div>
<div className="grid gap-x-6 gap-y-1 text-[13px] md:grid-cols-2">
{project && <div className="text-muted-foreground">{t("exec.projectLabel")} <span className="text-foreground">{project.name}</span></div>}
{goal && <div className="text-muted-foreground">{t("exec.goalLabel")} <span className="text-foreground">{goal.title}</span></div>}
{parent && <div className="text-muted-foreground">{t("exec.parentLabel")} <span className="text-foreground">{parent.title}</span></div>}
{workspace && <div className="text-muted-foreground">{t("exec.workspaceLabel")} <span className="text-foreground">{workspace.name}</span></div>}
{task.costUsd > 0 && <div className="text-muted-foreground">{t("exec.costLabel")} <span className="font-mono tabular-nums text-foreground">{formatMoney(task.costUsd)}</span></div>}
</div>
{task.description ? <div className="text-[13px] leading-relaxed text-muted-foreground whitespace-pre-wrap">{task.description}</div> : null}
{/* Action row grouped by weight: primary (accent) · secondary (default) · destructive (danger).
gap-6 between groups, gap-2 within a group — spacing does the tiering. */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
<div className="flex flex-wrap items-center gap-2">
{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" />
)}
{task.status === "in_review" && (
<ActionButton label={t("exec.done")} onClick={() => void onQuickStatus("done")} icon={CheckCircle} tone="accent" loading={updatingStatus === "done"} />
)}
</>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{!activeRun && task.status === "blocked" && (
<ActionButton label={t("exec.unblock")} onClick={() => void onQuickStatus("in_progress")} icon={Play} loading={updatingStatus === "in_progress"} />
)}
<ActionButton label={t("exec.discussion")} onClick={() => onOpenDiscussion(task.id)} icon={ArrowRight} />
<ActionButton label={t("exec.edit")} onClick={onEdit} icon={Pencil} />
</div>
<div className="flex flex-wrap items-center gap-2">
<ActionButton label={t("exec.delete")} onClick={onDelete} icon={Trash} tone="danger" />
</div>
</div>
</div>
)
}
@@ -1,47 +0,0 @@
import type { ProfileSnapshot, TaskRecord } from "@shared/types"
import { useT } from "../../i18n"
import { Eyebrow, StatusPill } from "../ui"
import { ListChecks } from "../ui/icons"
export function TaskInspectorSubtasks({ task, tasks, agents }: {
task: TaskRecord; tasks: TaskRecord[]; agents: ProfileSnapshot["agents"]
}) {
const t = useT()
const subtasks = tasks.filter((s) => s.parentId === task.id)
const agentMap = new Map(agents.map((a) => [a.id, a]))
if (subtasks.length === 0) {
return <div className="text-[13px] text-muted-foreground">{t("exec.noSubtasks", "No subtasks.")}</div>
}
const done = subtasks.filter((s) => s.status === "done").length
const pct = (done / subtasks.length) * 100
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<ListChecks size={14} className="text-muted-foreground" />
<Eyebrow>{t("exec.subtasks")} ({subtasks.length})</Eyebrow>
<span className="text-[10px] text-success">
{t("exec.doneCount").replace("{0}", String(done)).replace("{1}", String(subtasks.length))}
</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: `${pct}%` }} />
</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-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>
)
}
@@ -1,77 +0,0 @@
import { useRef } from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import type { RunRecord, TaskRecord } from "@shared/types"
import { useT } from "../../i18n"
import { useRovingTabIndex } from "../../lib/keyboard"
import { EmptyState, StatusPill } from "../ui"
import { PriorityDot } from "./execution-utils"
export function TaskQueue({ tasks, runs, selectedTaskId, onSelect }: { tasks: TaskRecord[]; runs: RunRecord[]; selectedTaskId: string | null; onSelect: (taskId: string) => void }) {
const t = useT()
const parentRef = useRef<HTMLDivElement>(null)
const { handleKeyDown, getTabIndex, focusedIndex } = useRovingTabIndex(tasks.length)
const virtualizer = useVirtualizer({
count: tasks.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 52,
overscan: 10,
})
if (tasks.length === 0) return <EmptyState title={t("exec.taskQueueEmpty")} />
return (
<div
ref={parentRef}
className="h-full overflow-auto border-y border-border"
role="listbox"
aria-label="Task queue"
onKeyDown={handleKeyDown}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const task = tasks[virtualRow.index]
const activeRun = runs.find((run) => run.id === task.activeRunId) ?? null
return (
<div
key={task.id}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
tabIndex={getTabIndex(virtualRow.index)}
role="option"
aria-selected={task.id === selectedTaskId}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
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 ${selectedTaskId === task.id ? "bg-accent border-l-[3px] border-l-[color:var(--primary)]" : "bg-transparent hover:bg-muted"}`}
onClick={() => onSelect(task.id)}
tabIndex={-1}
>
{selectedTaskId === task.id ? <span className="absolute inset-y-3 left-0 w-0.5 rounded-full bg-primary" /> : null}
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-2.5">
<PriorityDot priority={task.priority} />
<div className={`truncate text-[13px] text-foreground ${selectedTaskId === task.id ? "font-semibold" : "font-medium"}`}>{task.title}</div>
</div>
<StatusPill status={activeRun?.status ?? task.status} />
</div>
</button>
</div>
)
})}
</div>
</div>
)
}
@@ -1,28 +0,0 @@
import { useState } from "react"
import { FolderOpen } from "../ui/icons"
import type { ProjectRecord } from "@shared/types"
import { useT } from "../../i18n"
import { unwrap } from "../../lib/desktop"
import { ActionButton, Field, InlineNotice, Input, Select } from "../ui"
import { describeActionError } from "./execution-utils"
export function WorkspaceCreator({ companyId, projects, onSaved }: { companyId: string; projects: ProjectRecord[]; onSaved: () => Promise<void> }) {
const t = useT()
const [name, setName] = useState(""); const [localPath, setLocalPath] = useState(""); const [repoUrl, setRepoUrl] = useState(""); const [repoRef, setRepoRef] = useState("")
const [projectId, setProjectId] = useState(projects[0]?.id ?? ""); const [submitError, setSubmitError] = useState<string | null>(null); const [submitting, setSubmitting] = useState(false)
async function handleChooseDirectory() { setSubmitError(null); const result = await window.agentCompany.pickDirectory(); if (!result.ok) { setSubmitError(result.error.message); return }; if (result.data) setLocalPath(result.data) }
async function handleSaveWorkspace() { setSubmitError(null); setSubmitting(true); try { unwrap(await window.agentCompany.saveWorkspace({ companyId, name, localPath, repoUrl, repoRef, projectId: projectId || null, isPrimary: false })); setName(""); setLocalPath(""); setRepoUrl(""); setRepoRef(""); await onSaved() } catch (e) { setSubmitError(describeActionError(e)) } finally { setSubmitting(false) } }
return (
<div className="grid gap-4">
<Input label={t("exec.workspaceName")} value={name} onChange={setName} placeholder={t("exec.workspaceName")} />
<Field label={t("exec.localPath")}><div className="flex gap-3"><Input value={localPath} onChange={setLocalPath} placeholder="/Users/you/project" /><ActionButton label={t("exec.choose")} onClick={() => void handleChooseDirectory()} icon={FolderOpen} /></div></Field>
<Input label={t("exec.repoUrl")} value={repoUrl} onChange={setRepoUrl} placeholder="https://github.com/org/repo" />
<Input label={t("exec.branchRef")} value={repoRef} onChange={setRepoRef} placeholder="main" />
<Select label={t("exec.project")} value={projectId} onChange={setProjectId} options={[{ value: "", label: t("exec.noProjectBinding") }, ...projects.map((p) => ({ value: p.id, label: p.name }))]} />
{submitError ? <InlineNotice message={submitError} /> : null}
<ActionButton label={t("exec.addWorkspace")} tone="accent" onClick={() => void handleSaveWorkspace()} icon={FolderOpen} loading={submitting} />
</div>
)
}
@@ -1,105 +0,0 @@
import { ArrowSquareOut } from "../ui/icons"
import type { AgentRecord, DocumentRecord, ProjectRecord, RunRecord, TaskRecord, WorkspaceRecord } from "@shared/types"
import { useT } from "../../i18n"
import { formatTime } from "../../lib/formatters"
import { ActionButton, EmptyState, StatusPill } from "../ui"
import { PriorityDot } from "./execution-utils"
export function WorkspaceInspector({ workspace, agents, tasks, runs, documents: _documents, project, onOpen }: {
workspace: WorkspaceRecord | null; agents: AgentRecord[]; tasks: TaskRecord[]; runs: RunRecord[]; documents: DocumentRecord[]; project: ProjectRecord | null; onOpen: (path: string) => Promise<void>
}) {
const t = useT()
if (!workspace) return <EmptyState title={t("exec.selectWorkspace")} />
const wAgents = agents.filter((a) => a.workspaceId === workspace.id); const wTasks = tasks.filter((tk) => tk.workspaceId === workspace.id)
const wRuns = runs.filter((r) => r.workspaceId === workspace.id)
const hasMeta = Boolean(project || workspace.repoUrl || workspace.repoRef)
return (
<div className="space-y-5">
{hasMeta ? (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 text-[12px] text-muted-foreground">
{project ? <>Project: <span className="text-foreground">{project.name}</span></> : null}
{workspace.repoUrl ? <span>{project ? " · " : ""}Repo: <span className="text-foreground">{workspace.repoUrl}</span></span> : null}
{workspace.repoRef ? <span> · Ref: <span className="text-foreground">{workspace.repoRef}</span></span> : null}
</div>
<ActionButton label={t("exec.openFolder")} onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} />
</div>
) : (
<div className="flex items-start justify-end">
<ActionButton label={t("exec.openFolder")} onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} />
</div>
)}
<div className="grid gap-10 [grid-template-columns:repeat(auto-fit,minmax(240px,1fr))]">
<section>
<div className="mb-2 flex items-baseline gap-2 border-b border-border pb-1.5 text-[11px] uppercase tracking-[0.12em] text-muted-foreground">
<span>{t("exec.assignedAgents")}</span>
<span className="text-primary tracking-normal">{wAgents.length}</span>
</div>
{wAgents.length === 0 ? (
<div className="py-2 pl-2 text-[12px] text-muted-foreground">{t("exec.noAgentsBound")}</div>
) : (
<div className="divide-y divide-border/60">
{wAgents.map((a) => (
<div key={a.id} className="group grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 border-l-2 border-transparent py-2 pl-2 pr-1 transition-colors hover:border-l-[color:var(--primary)]">
<span aria-hidden="true" className="select-none text-muted-foreground transition-colors group-hover:text-primary">›</span>
<div className="min-w-0">
<div className="truncate text-[12px] font-medium text-foreground">{a.name}</div>
<div className="truncate text-[11px] text-muted-foreground">{a.role}</div>
</div>
<StatusPill status={a.status} />
</div>
))}
</div>
)}
</section>
<section>
<div className="mb-2 flex items-baseline gap-2 border-b border-border pb-1.5 text-[11px] uppercase tracking-[0.12em] text-muted-foreground">
<span>{t("exec.openTasks")}</span>
<span className="text-primary tracking-normal">{wTasks.length}</span>
</div>
{wTasks.length === 0 ? (
<div className="py-2 pl-2 text-[12px] text-muted-foreground">{t("exec.noTasksTarget")}</div>
) : (
<div className="divide-y divide-border/60">
{wTasks.slice(0, 6).map((tk) => (
<div key={tk.id} className="group grid grid-cols-[auto_minmax(0,1fr)] items-start gap-3 border-l-2 border-transparent py-2 pl-2 pr-1 transition-colors hover:border-l-[color:var(--primary)]">
<span aria-hidden="true" className="select-none pt-[1px] text-muted-foreground transition-colors group-hover:text-primary">›</span>
<div className="min-w-0">
<div className="flex items-center gap-2">
<PriorityDot priority={tk.priority} />
<span className="text-[12px] font-medium text-foreground">{tk.title}</span>
</div>
<div className="mt-1 text-[11px] text-muted-foreground">{tk.status.replaceAll("_", " ")}</div>
</div>
</div>
))}
</div>
)}
</section>
<section>
<div className="mb-2 flex items-baseline gap-2 border-b border-border pb-1.5 text-[11px] uppercase tracking-[0.12em] text-muted-foreground">
<span>{t("exec.recentRuns")}</span>
<span className="text-primary tracking-normal">{wRuns.length}</span>
</div>
{wRuns.length === 0 ? (
<div className="py-2 pl-2 text-[12px] text-muted-foreground">{t("exec.noRunsExecuted")}</div>
) : (
<div className="divide-y divide-border/60">
{wRuns.slice(0, 6).map((r) => (
<div key={r.id} className="group grid grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-3 border-l-2 border-transparent py-2 pl-2 pr-1 transition-colors hover:border-l-[color:var(--primary)]">
<span aria-hidden="true" className="select-none pt-[1px] text-muted-foreground transition-colors group-hover:text-primary">›</span>
<div className="min-w-0">
<div className="truncate text-[12px] font-medium text-foreground">{r.summary || `Run ${r.id.slice(0, 8)}`}</div>
<div className="mt-1 text-[11px] text-muted-foreground">{formatTime(r.startedAt)} → {formatTime(r.finishedAt)}</div>
</div>
<StatusPill status={r.status} />
</div>
))}
</div>
)}
</section>
</div>
</div>
)
}
@@ -1,55 +0,0 @@
import { useState } from "react"
import { ArrowSquareOut, FolderOpen, Trash } from "../ui/icons"
import type { ProjectRecord, WorkspaceRecord } from "@shared/types"
import { useT } from "../../i18n"
import { unwrap } from "../../lib/desktop"
import { ActionButton, ConfirmDialog, EmptyState, ListFrame, ListRow } from "../ui"
import { describeActionError, WorkspaceStatePill } from "./execution-utils"
export function WorkspaceList({ workspaces, projects, companyId, selectedWorkspaceId, onSelect, onOpen, onDeleted }: {
workspaces: WorkspaceRecord[]; projects: ProjectRecord[]; companyId: string; selectedWorkspaceId: string | null
onSelect: (workspaceId: string) => void; onOpen: (path: string) => Promise<void>; onDeleted: () => Promise<void>
}) {
const t = useT()
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null)
const toDelete = workspaces.find((w) => w.id === confirmDeleteId) ?? null
async function handleDelete(id: string) { setDeleteError(null); try { unwrap(await window.agentCompany.deleteWorkspace({ id, companyId })); setConfirmDeleteId(null); await onDeleted() } catch (e) { setDeleteError(describeActionError(e)) } }
if (workspaces.length === 0) return <EmptyState title={t("exec.noWorkspacesYet")} />
return (
<>
<ConfirmDialog open={confirmDeleteId !== null} title={t("exec.deleteWorkspace")} message={t("exec.deleteWorkspaceConfirm").replace("{0}", toDelete?.name ?? "")} errorMessage={deleteError} onConfirm={() => confirmDeleteId && void handleDelete(confirmDeleteId)} onCancel={() => { setDeleteError(null); setConfirmDeleteId(null) }} />
<ListFrame>
{workspaces.map((workspace) => {
const linkedProject = projects.find((p) => p.id === workspace.projectId) ?? null
return (
<ListRow key={workspace.id}>
{(() => {
const isSelected = selectedWorkspaceId === workspace.id;
return (
<div className={`group grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 border-l-2 py-2 pl-2 pr-1 transition-colors ${isSelected ? "border-l-[color:var(--primary)]" : "border-l-transparent hover:border-l-border"}`}>
<span aria-hidden="true" className={`select-none transition-colors ${isSelected ? "text-primary" : "text-muted-foreground group-hover:text-foreground"}`}>›</span>
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="truncate text-[13px] font-medium text-foreground">{workspace.name}</div>
<WorkspaceStatePill state={workspace.state} />
{workspace.isPrimary ? <span className="text-[11px] text-muted-foreground">· Primary</span> : null}
</div>
<div className="truncate font-mono text-[11px] text-muted-foreground">{workspace.localPath}</div>
<div className="truncate text-[11px] text-muted-foreground">{workspace.repoUrl || "No remote URL"}{workspace.repoRef ? ` · ${workspace.repoRef}` : ""}{linkedProject ? <span> · Project: <span className="text-foreground">{linkedProject.name}</span></span> : null}</div>
</div>
<div className="flex items-center gap-2">
<ActionButton label={t("exec.inspect")} onClick={() => onSelect(workspace.id)} icon={FolderOpen} />
<ActionButton label={t("exec.reveal")} onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} />
<ActionButton label={t("exec.delete")} onClick={() => setConfirmDeleteId(workspace.id)} icon={Trash} tone="danger" />
</div>
</div>
);
})()}
</ListRow>
)
})}
</ListFrame>
</>
)
}
@@ -1,301 +0,0 @@
import { useMemo, useState } from "react"
import { CheckCircle, FileText } from "../../ui/icons"
import type { ApprovalRecord, DocumentRecord, WorkProductRecord } from "@shared/types"
import {
extractApprovalDocumentReferences,
extractApprovalWorkProductIds,
inspectApprovalPayload,
type ApprovalDocumentReference,
} from "@shared/approval-documents"
import { unwrap } from "../../../lib/desktop"
import { useAppStore } from "../../../state/store"
import { ActionButton, Card, Eyebrow, InlineNotice, Input, StatusPill, TextArea } from "../../ui"
import { useT } from "../../../i18n"
import { describeActionError } from "../execution-utils"
function ApprovalDocumentEditor({
companyId,
document,
fallback,
}: {
companyId: string
document: DocumentRecord
fallback?: ApprovalDocumentReference
}) {
const t = useT()
const addToast = useAppStore((s) => s.addToast)
const [title, setTitle] = useState(document.title)
const [content, setContent] = useState(document.content ?? "")
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
async function handleSave() {
if (!title.trim()) {
setSaveError(t("approval.documentTitleRequired"))
return
}
setSaving(true)
setSaveError(null)
try {
unwrap(
await window.agentCompany.saveDocument({
id: document.id,
companyId,
type: document.type,
title: title.trim(),
content,
authorAgentId: document.authorAgentId ?? null,
reviewerAgentId: document.reviewerAgentId ?? null,
projectId: document.projectId ?? null,
goalId: document.goalId ?? null,
parentDocId: document.parentDocId ?? null,
version: document.version,
status: document.status,
tagsJson: document.tagsJson,
}),
)
addToast(t("approval.documentSavedToast").replace("{title}", title.trim()), "success")
} catch (error) {
setSaveError(describeActionError(error))
} finally {
setSaving(false)
}
}
return (
<Card padding="sm" className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-[13px] font-medium text-foreground">{fallback?.title ?? document.title}</div>
<div className="text-[11px] text-muted-foreground">
{(fallback?.type ?? document.type).replaceAll("_", " ")} · v{document.version} · {document.status}
</div>
</div>
<ActionButton
label={saving ? t("approval.saving") : t("approval.saveChanges")}
icon={CheckCircle}
tone="accent"
onClick={() => void handleSave()}
loading={saving}
/>
</div>
{saveError ? <InlineNotice message={saveError} /> : null}
<Input label={t("approval.docTitle")} value={title} onChange={setTitle} placeholder={t("approval.docTitlePlaceholder")} />
<TextArea label={t("approval.docContent")} value={content} onChange={setContent} rows={12} placeholder={t("approval.docContentPlaceholder")} />
</Card>
)
}
function ApprovalWorkProductRow({
product,
companyId,
}: {
product: WorkProductRecord
companyId: string
}) {
// Mirror DocumentCenter's WorkProductRow: route through openWorkProduct so
// main can join workspace-relative paths against the correct root. Only
// rows with a URL or a filesystem externalId are openable — for anything
// else we fall back to a non-interactive display (still lets the reviewer
// see what's attached even if it can't be opened right now).
const canOpen = Boolean(product.url || (product.provider === "filesystem" && product.externalId))
const subtitle = [
product.type.replaceAll("_", " "),
product.provider,
product.externalId ?? product.url ?? null,
product.updatedAt.slice(0, 10),
]
.filter((v): v is string => Boolean(v))
.join(" · ")
const rowBody = (
<div className="min-w-0 flex-1 text-left">
<div className="flex items-center gap-2 text-[13px] font-medium text-foreground">
<FileText size={15} className="text-muted-foreground" />
<span className="truncate">{product.title}</span>
</div>
<div className="mt-1 text-[11px] text-muted-foreground truncate">{subtitle}</div>
</div>
)
return (
<Card padding="sm">
<div className="flex items-start justify-between gap-3">
{canOpen ? (
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => {
void window.agentCompany.openWorkProduct({ companyId, id: product.id })
}}
>
{rowBody}
</button>
) : (
rowBody
)}
<StatusPill status={product.status} />
</div>
</Card>
)
}
// Shimmer skeleton for pending attachment resolution. A skeleton reads as
// "content is on its way" — swapping text between "loading" and "missing"
// made both states look like errors. Tailwind's animate-pulse is enough; no
// new deps.
function ApprovalAttachmentSkeleton() {
return (
<Card padding="sm" className="bg-muted">
<div className="animate-pulse space-y-2" aria-hidden="true">
<div className="h-3 w-1/3 rounded bg-[color:var(--muted-foreground)]/20" />
<div className="h-2 w-1/2 rounded bg-[color:var(--muted-foreground)]/15" />
</div>
</Card>
)
}
export function ApprovalDocumentsPanel({
approval,
documents,
workProducts,
companyId,
}: {
approval: ApprovalRecord
documents?: DocumentRecord[]
workProducts?: WorkProductRecord[]
companyId: string
}) {
const t = useT()
const inspection = useMemo(
() => inspectApprovalPayload(approval.payloadJson),
[approval.payloadJson],
)
const references = useMemo(
() => extractApprovalDocumentReferences(approval.payloadJson),
[approval.payloadJson],
)
const workProductIds = useMemo(
() => extractApprovalWorkProductIds(approval.payloadJson),
[approval.payloadJson],
)
// Undefined = parent hasn't delivered the snapshot yet (loading). An empty
// or populated array = snapshot is authoritative; any id that fails to
// resolve against it is genuinely missing (record deleted since the
// approval was filed). The two states render as different shells — a
// shimmer skeleton for loading, a muted card with a proper title for
// missing.
const workProductsLoading = workProducts === undefined
const documentsLoading = documents === undefined
const resolvedWorkProducts = useMemo(() => {
if (!workProducts || workProductIds.length === 0) return []
const byId = new Map(workProducts.map((wp) => [wp.id, wp] as const))
return workProductIds
.map((id) => byId.get(id))
.filter((wp): wp is WorkProductRecord => wp != null)
}, [workProducts, workProductIds])
// A malformed payloadJson survived into persistence (legacy row written
// before the write-time guard existed, or a direct DB edit). The write
// boundary now catches this, but the read side must still render a
// dignified card rather than silently showing nothing — a reviewer who
// expected attachments would otherwise stare at a blank modal and
// wonder if the approval was filed correctly.
if (inspection.kind === "malformed") {
return (
<div className="space-y-3">
<Eyebrow tone="primary">{t("approval.linkedDocuments")}</Eyebrow>
<Card padding="sm" className="bg-muted">
<div className="text-[13px] font-medium text-foreground">
{t("approval.malformedPayloadTitle")}
</div>
<div className="mt-2 text-[12px] text-muted-foreground">
{t("approval.malformedPayloadBody")}
</div>
<div className="mt-2 text-[11px] font-mono text-muted-foreground break-all">
{inspection.reason}
</div>
</Card>
</div>
)
}
if (references.length === 0 && resolvedWorkProducts.length === 0 && workProductIds.length === 0) {
return null
}
const fullDocs = documents
? references
.map((reference) => ({
reference,
document: documents.find((entry) => entry.id === reference.id) ?? null,
}))
.filter(
(entry): entry is typeof entry & { document: NonNullable<typeof entry.document> } =>
entry.document != null,
)
: []
const unresolvedWorkProductIds = workProductIds.filter(
(id) => !resolvedWorkProducts.some((wp) => wp.id === id),
)
// When documents snapshot is loaded and a reference id is not in it, the
// document has been deleted. When the snapshot is still loading, every
// reference is "pending resolution" — render the skeleton shell instead.
const unresolvedDocumentReferences = documentsLoading
? []
: references.filter((reference) => !fullDocs.some((entry) => entry.document.id === reference.id))
const loadingDocumentCount = documentsLoading ? references.length : 0
const loadingWorkProductCount = workProductsLoading ? workProductIds.length : 0
return (
<div className="space-y-3">
<Eyebrow tone="primary">{t("approval.linkedDocuments")}</Eyebrow>
{resolvedWorkProducts.map((product) => (
<ApprovalWorkProductRow key={product.id} product={product} companyId={companyId} />
))}
{Array.from({ length: loadingWorkProductCount }).map((_, index) => (
<ApprovalAttachmentSkeleton key={`wp-skeleton-${index}`} />
))}
{!workProductsLoading
? unresolvedWorkProductIds.map((id) => (
<Card key={id} padding="sm" className="bg-muted">
<div className="text-[13px] font-medium text-foreground">
{t("approval.missingWorkProductTitle")}
</div>
<div className="mt-1 text-[11px] font-mono text-muted-foreground break-all">{id}</div>
<div className="mt-2 text-[12px] text-muted-foreground">
{t("approval.documentMissing")}
</div>
</Card>
))
: null}
{fullDocs.map(({ reference, document }) => (
<ApprovalDocumentEditor
key={document.id}
companyId={companyId}
document={document}
fallback={reference}
/>
))}
{Array.from({ length: loadingDocumentCount }).map((_, index) => (
<ApprovalAttachmentSkeleton key={`doc-skeleton-${index}`} />
))}
{unresolvedDocumentReferences.map((reference) => (
<Card key={reference.id} padding="sm" className="bg-muted">
<div className="text-[13px] font-medium text-foreground">
{reference.title ?? t("approval.missingDocumentTitle")}
</div>
<div className="mt-1 text-[11px] font-mono text-muted-foreground break-all">
{reference.type?.replaceAll("_", " ") ?? "document"} · {reference.id}
</div>
<div className="mt-2 text-[12px] text-muted-foreground">
{t("approval.documentMissing")}
</div>
</Card>
))}
</div>
)
}
@@ -1,188 +0,0 @@
import { useState } from "react"
import type { AgentRecord, ApprovalRecord, DocumentRecord, TaskRecord, WorkProductRecord } from "@shared/types"
import {
extractApprovalDocumentReferences,
extractApprovalWorkProductIds,
} from "@shared/approval-documents"
import { useT } from "../../../i18n"
import { unwrap } from "../../../lib/desktop"
import { Card, ConfirmDialog, Eyebrow, InlineNotice } from "../../ui"
import { useAppStore } from "../../../state/store"
import { describeActionError } from "../execution-utils"
import { SocialDraftPreview } from "../SocialDraftPreview"
import { ApprovalSummary } from "./ApprovalSummary"
import { ProposedHireCard } from "./ProposedHireCard"
import { ApprovalDocumentsPanel } from "./ApprovalDocumentsPanel"
import { DeliverableReviewPanel } from "./DeliverableReviewPanel"
import { SchedulePicker, type ScheduleMode } from "./SchedulePicker"
import { DecisionForm, type ActingState, type DecisionState } from "./DecisionForm"
import { useSocialPlatform } from "./useApprovalPayload"
export function ApprovalInspector({
approval,
agents,
tasks,
documents,
workProducts,
companyId,
onDecide,
onDeleted,
}: {
approval: ApprovalRecord
agents: AgentRecord[]
tasks: TaskRecord[]
documents?: DocumentRecord[]
workProducts?: WorkProductRecord[]
companyId: string
onDecide: (state: DecisionState, note?: string) => Promise<void>
onDeleted: () => Promise<void>
}) {
const t = useT()
const addToast = useAppStore((s) => s.addToast)
const [decisionNote, setDecisionNote] = useState("")
const [confirmDelete, setConfirmDelete] = useState(false)
const [actionError, setActionError] = useState<string | null>(null)
const [acting, setActing] = useState<ActingState>(null)
const [scheduleMode, setScheduleMode] = useState<ScheduleMode>("immediate")
const [scheduledAt, setScheduledAt] = useState("")
const [scheduleError, setScheduleError] = useState<string | null>(null)
const requestingAgent = agents.find((a) => a.id === approval.requestedByAgentId) ?? null
const relatedTask = tasks.find((task) => task.id === approval.relatedTaskId) ?? null
const isDecided = approval.state !== "pending"
const isSocialPost = approval.type === "social_post"
const socialPlatform = useSocialPlatform(approval)
const hasDocumentReferences =
extractApprovalDocumentReferences(approval.payloadJson).length > 0 ||
extractApprovalWorkProductIds(approval.payloadJson).length > 0
async function handleDelete() {
setActionError(null)
setActing("delete")
try {
unwrap(await window.agentCompany.deleteApproval({ id: approval.id, companyId }))
setConfirmDelete(false)
await onDeleted()
} catch (error) {
setActionError(describeActionError(error))
} finally {
setActing(null)
}
}
async function handleDecision(state: DecisionState) {
setActionError(null)
setScheduleError(null)
if (isSocialPost && state === "approved" && scheduleMode === "scheduled") {
if (!scheduledAt) {
setScheduleError(t("approval.scheduleSelectTime"))
return
}
const chosenTime = new Date(scheduledAt).getTime()
if (chosenTime <= Date.now()) {
setScheduleError(t("approval.scheduleFuture"))
return
}
}
setActing(state)
try {
let noteForDecision = decisionNote || undefined
if (isSocialPost && state === "approved" && scheduleMode === "scheduled" && scheduledAt) {
const scheduleInfo = `[scheduled:${new Date(scheduledAt).toISOString()}]`
noteForDecision = noteForDecision ? `${noteForDecision} ${scheduleInfo}` : scheduleInfo
}
await onDecide(state, noteForDecision)
if (state === "approved") addToast(t("approval.granted"), "success")
else if (state === "rejected") addToast(t("approval.rejectedToast"), "warn")
else if (state === "revision_requested") addToast(t("approval.revisionRequested"), "warn")
} catch (error) {
setActionError(describeActionError(error))
} finally {
setActing(null)
}
}
return (
<div className="space-y-6">
<ConfirmDialog
open={confirmDelete}
title={t("exec.deleteApproval")}
message={t("exec.deleteApprovalConfirm")}
errorMessage={actionError}
onConfirm={() => void handleDelete()}
onCancel={() => {
setActionError(null)
setConfirmDelete(false)
}}
/>
{actionError && !confirmDelete ? <InlineNotice message={actionError} /> : null}
<div className="border-y border-border py-4 space-y-4">
<ApprovalSummary approval={approval} requestingAgent={requestingAgent} relatedTask={relatedTask} />
{approval.type === "hire_agent" ? <ProposedHireCard approval={approval} agents={agents} /> : null}
{/* Documents/work-products promoted above the warning banner: whatever
the reviewer is approving, the artifacts themselves are the
primary evidence. Pushing them to the first screen means the
operator can open the PDF or read the doc before wading through
org-strategy boilerplate. */}
<ApprovalDocumentsPanel
approval={approval}
documents={documents}
workProducts={workProducts}
companyId={companyId}
/>
{approval.type === "approve_ceo_strategy" ? (
<Card
padding="sm"
className="border-[color:var(--destructive)] border-opacity-30 bg-warning-muted"
>
<Eyebrow tone="warning" className="mb-2">
{t("approval.orgStrategy")}
</Eyebrow>
<div className="whitespace-pre-wrap text-[12px] leading-relaxed text-foreground">
{approval.payloadSummary}
</div>
</Card>
) : null}
{approval.type === "deliverable_review" && !hasDocumentReferences ? (
<DeliverableReviewPanel approval={approval} documents={documents} agents={agents} />
) : null}
{isSocialPost && approval.payloadJson ? (
<SocialDraftPreview
payloadJson={approval.payloadJson}
platform={socialPlatform}
isPending={approval.state === "pending"}
/>
) : null}
</div>
{isSocialPost && !isDecided ? (
<SchedulePicker
mode={scheduleMode}
scheduledAt={scheduledAt}
error={scheduleError}
onModeChange={setScheduleMode}
onScheduledAtChange={setScheduledAt}
onClearError={() => setScheduleError(null)}
/>
) : null}
<DecisionForm
isDecided={isDecided}
isSocialPost={isSocialPost}
decisionNote={decisionNote}
acting={acting}
onDecisionNoteChange={setDecisionNote}
onDecide={(state) => void handleDecision(state)}
onRequestDelete={() => setConfirmDelete(true)}
/>
{approval.decisionNote ? (
<div className="border-t border-border pt-4 text-[13px] text-muted-foreground">
{t("approval.lastNote")} {approval.decisionNote}
</div>
) : null}
</div>
)
}
@@ -1,54 +0,0 @@
import { Briefcase, Robot } from "../../ui/icons"
import type { AgentRecord, ApprovalRecord, TaskRecord } from "@shared/types"
import { formatTime } from "../../../lib/formatters"
import { StatusPill } from "../../ui"
import { useT } from "../../../i18n"
import { TypeBadge } from "./TypeBadge"
export function ApprovalSummary({
approval,
requestingAgent,
relatedTask,
}: {
approval: ApprovalRecord
requestingAgent: AgentRecord | null
relatedTask: TaskRecord | null
}) {
const t = useT()
return (
<>
<div className="flex items-center gap-3">
<TypeBadge type={approval.type} />
<StatusPill status={approval.state} />
</div>
<div className="text-[13px] font-medium text-foreground">{approval.payloadSummary}</div>
{approval.impactSummary ? (
<div className="text-[13px] text-muted-foreground">{approval.impactSummary}</div>
) : null}
<div className="grid gap-2 text-[13px] md:grid-cols-2">
<div className="flex items-center gap-2 text-muted-foreground">
<Robot size={14} />
{t("approval.requestedBy")}{" "}
<span className="font-medium text-foreground">{requestingAgent?.name ?? t("approval.unknownRequester")}</span>
{requestingAgent?.role ? (
<span className="text-[10px] text-muted-foreground">({requestingAgent.role})</span>
) : null}
</div>
{relatedTask ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Briefcase size={14} />
{t("approval.task")} <span className="font-medium text-foreground">{relatedTask.title}</span>
</div>
) : null}
<div className="text-muted-foreground">
{t("approval.created")} <span className="font-mono text-foreground">{formatTime(approval.createdAt)}</span>
</div>
{approval.updatedAt !== approval.createdAt ? (
<div className="text-muted-foreground">
{t("approval.updated")} <span className="font-mono text-foreground">{formatTime(approval.updatedAt)}</span>
</div>
) : null}
</div>
</>
)
}
Loaded 100 of 1904 files, more files were not shown because too many files have changed in this diff. Show more