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:
1 parent
2350f744e3
commit
07d0b4587d
1904 files changed
+1133
-282075
No files matched your search
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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(", ");
|
||||
}
|
||||
@@ -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">·</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} → {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
Reference in new issue
Block a user