update
This commit is contained in:
1 parent
7a3db27ffa
commit
1ba3147ed2
96 files changed
+9400
-6269
No files matched your search
+346
-93
@@ -34,7 +34,6 @@ import {
|
||||
SidebarSimple,
|
||||
TerminalWindow,
|
||||
WarningCircle,
|
||||
Wrench,
|
||||
X,
|
||||
} from "@phosphor-icons/react";
|
||||
import type {
|
||||
@@ -42,6 +41,7 @@ import type {
|
||||
CompanyMetrics,
|
||||
DesktopEvent,
|
||||
InboxItem,
|
||||
OnboardingBootstrapResult,
|
||||
ProfileSnapshot,
|
||||
RunRecord,
|
||||
SectionId,
|
||||
@@ -55,6 +55,7 @@ import { Inbox, StandupView, PerformanceView } from "./components/inbox";
|
||||
import { ConnectorEditor } from "./components/connectors";
|
||||
import { Dashboard, KanbanBoard } from "./components/dashboard";
|
||||
import { EntityWorkspace } from "./components/EntityWorkspace";
|
||||
import { RunCenter } from "./components/runs";
|
||||
import {
|
||||
ActivityList,
|
||||
ApprovalInspector,
|
||||
@@ -94,6 +95,11 @@ import { formatMoney, formatTime } from "./lib/formatters";
|
||||
|
||||
type NavItem = { id: SectionId; label: string; icon: typeof SquaresFour };
|
||||
type NavGroup = { label: string; items: NavItem[] };
|
||||
type PaletteAction = {
|
||||
key: string;
|
||||
section: SectionId;
|
||||
entityId?: string;
|
||||
};
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
@@ -103,6 +109,7 @@ const navGroups: NavGroup[] = [
|
||||
{ id: "inbox", label: "Inbox", icon: Bell },
|
||||
{ id: "standup", label: "Standup", icon: Heartbeat },
|
||||
{ id: "performance", label: "Performance", icon: ChartBar },
|
||||
{ id: "runs", label: "Runs", icon: TerminalWindow },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -180,6 +187,7 @@ export function App() {
|
||||
const [, setLogRevision] = useState(0);
|
||||
const [badges, setBadges] = useState<SidebarBadges | null>(null);
|
||||
const [commandPaletteSearch, setCommandPaletteSearch] = useState("");
|
||||
const [selectedPaletteIndex, setSelectedPaletteIndex] = useState(0);
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
const [initialOnboardingLocked, setInitialOnboardingLocked] = useState(false);
|
||||
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
|
||||
@@ -188,6 +196,23 @@ export function App() {
|
||||
const [socialAccounts, setSocialAccounts] = useState<SocialAccountRecord[]>([]);
|
||||
const [browserActions, setBrowserActions] = useState<BrowserActionRecord[]>([]);
|
||||
|
||||
const clearCompanyScopedUiState = useCallback(() => {
|
||||
setSelectedTaskId(null);
|
||||
setSelectedApprovalId(null);
|
||||
setSelectedRunId(null);
|
||||
setSelectedAgentId(null);
|
||||
setSelectedGoalId(null);
|
||||
setSelectedProjectId(null);
|
||||
setSelectedWorkspaceId(null);
|
||||
setSelectedCommunicationEntityId(null);
|
||||
setInboxItems([]);
|
||||
setCompanyMetrics(null);
|
||||
setStandupReport(null);
|
||||
setSocialAccounts([]);
|
||||
setBrowserActions([]);
|
||||
setBadges(null);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const next = unwrap<ProfileSnapshot>(await window.agentCompany.loadSnapshot());
|
||||
@@ -200,6 +225,8 @@ export function App() {
|
||||
if (next.currentCompanyId) {
|
||||
const badgeResult = await window.agentCompany.getSidebarBadges(next.currentCompanyId);
|
||||
if (badgeResult.ok) setBadges(badgeResult.data);
|
||||
const inboxResult = await window.agentCompany.getInbox(next.currentCompanyId);
|
||||
if (inboxResult.ok) setInboxItems(inboxResult.data);
|
||||
setSocialAccounts(next.socialAccounts?.filter(a => a.companyId === next.currentCompanyId) ?? []);
|
||||
const browserActionResult = await window.agentCompany.listBrowserActions({ companyId: next.currentCompanyId, limit: 50 });
|
||||
if (browserActionResult.ok) {
|
||||
@@ -234,10 +261,19 @@ export function App() {
|
||||
void refreshRef.current();
|
||||
const unsubscribe = window.agentCompany.subscribe((event: DesktopEvent) => {
|
||||
if (event.type === "run-log") {
|
||||
logBufferRef.current = {
|
||||
...logBufferRef.current,
|
||||
[event.runId]: `${logBufferRef.current[event.runId] ?? ""}${event.chunk}`,
|
||||
};
|
||||
const MAX_LOG_BYTES_PER_RUN = 512_000;
|
||||
const MAX_TRACKED_RUNS = 50;
|
||||
const existing = logBufferRef.current[event.runId] ?? "";
|
||||
const updated = existing.length < MAX_LOG_BYTES_PER_RUN
|
||||
? existing + event.chunk
|
||||
: existing.slice(-MAX_LOG_BYTES_PER_RUN / 2) + event.chunk;
|
||||
logBufferRef.current[event.runId] = updated;
|
||||
const runIds = Object.keys(logBufferRef.current);
|
||||
if (runIds.length > MAX_TRACKED_RUNS) {
|
||||
for (const oldId of runIds.slice(0, runIds.length - MAX_TRACKED_RUNS)) {
|
||||
delete logBufferRef.current[oldId];
|
||||
}
|
||||
}
|
||||
setLogRevision((r) => r + 1);
|
||||
return;
|
||||
}
|
||||
@@ -257,19 +293,6 @@ export function App() {
|
||||
};
|
||||
}, [debouncedRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRunId) return;
|
||||
void (async () => {
|
||||
const result = await window.agentCompany.getRunLog({ runId: selectedRunId });
|
||||
if (result.ok) {
|
||||
logBufferRef.current = { ...logBufferRef.current, [selectedRunId]: result.data };
|
||||
setLogRevision((r) => r + 1);
|
||||
return;
|
||||
}
|
||||
setError(result.error.message);
|
||||
})();
|
||||
}, [selectedRunId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!snapshot) return;
|
||||
const root = document.documentElement;
|
||||
@@ -280,41 +303,50 @@ export function App() {
|
||||
useEffect(() => {
|
||||
if (!snapshot?.currentCompanyId) return;
|
||||
const companyId = snapshot.currentCompanyId;
|
||||
let cancelled = false;
|
||||
|
||||
if (section === "inbox") {
|
||||
setInboxItems([]);
|
||||
}
|
||||
|
||||
if (section === "performance") {
|
||||
setCompanyMetrics(null);
|
||||
}
|
||||
|
||||
if (section === "standup") {
|
||||
setStandupReport(null);
|
||||
}
|
||||
|
||||
if (section === "social") {
|
||||
setSocialAccounts([]);
|
||||
}
|
||||
|
||||
if (section === "inbox") {
|
||||
void window.agentCompany.getInbox(companyId).then((result) => {
|
||||
if (result.ok) setInboxItems(result.data);
|
||||
if (!cancelled && result.ok) setInboxItems(result.data);
|
||||
});
|
||||
}
|
||||
|
||||
if (section === "performance") {
|
||||
void window.agentCompany.getCompanyMetrics({ companyId, days: 30 }).then((result) => {
|
||||
if (result.ok) setCompanyMetrics(result.data);
|
||||
if (!cancelled && result.ok) setCompanyMetrics(result.data);
|
||||
});
|
||||
}
|
||||
|
||||
if (section === "standup") {
|
||||
void window.agentCompany.getStandupReport(companyId).then((result) => {
|
||||
if (result.ok) setStandupReport(result.data);
|
||||
if (!cancelled && result.ok) setStandupReport(result.data);
|
||||
});
|
||||
}
|
||||
|
||||
if (section === "social") {
|
||||
setSocialAccounts(snapshot.socialAccounts?.filter(a => a.companyId === companyId) ?? []);
|
||||
setSocialAccounts(snapshot.socialAccounts?.filter((a) => a.companyId === companyId) ?? []);
|
||||
}
|
||||
}, [section, snapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!commandPaletteOpen) return;
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [commandPaletteOpen]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [section, snapshot]);
|
||||
|
||||
const paletteTrapRef = useFocusTrap(commandPaletteOpen);
|
||||
|
||||
@@ -327,10 +359,36 @@ export function App() {
|
||||
const selectedTask = selectedTaskId ? (tasks.find((task) => task.id === selectedTaskId) ?? null) : null;
|
||||
const selectedConnector = snapshot?.connectors.find((connector) => connector.id === selectedConnectorId) ?? snapshot?.connectors[0] ?? null;
|
||||
const approvals = useMemo(() => snapshot?.approvals.filter((approval) => !currentCompany || approval.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
const pendingApprovals = useMemo(() => approvals.filter((approval) => approval.state === "pending"), [approvals]);
|
||||
const selectedApproval = selectedApprovalId ? (approvals.find((approval) => approval.id === selectedApprovalId) ?? null) : null;
|
||||
const runs = useMemo(() => snapshot?.runs.filter((run) => !currentCompany || run.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
const selectedRun = runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRunId && !runs.some((run) => run.id === selectedRunId)) {
|
||||
setSelectedRunId(runs[0]?.id ?? null);
|
||||
}
|
||||
}, [runs, selectedRunId]);
|
||||
|
||||
const activeRunId = selectedRun?.id ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeRunId) return;
|
||||
void (async () => {
|
||||
const activeRun = runs.find((run) => run.id === activeRunId);
|
||||
if (!activeRun) {
|
||||
return;
|
||||
}
|
||||
const result = await window.agentCompany.getRunLog({ companyId: activeRun.companyId, runId: activeRunId });
|
||||
if (result.ok) {
|
||||
logBufferRef.current = { ...logBufferRef.current, [activeRunId]: result.data };
|
||||
setLogRevision((r) => r + 1);
|
||||
return;
|
||||
}
|
||||
setError(result.error.message);
|
||||
})();
|
||||
}, [activeRunId, runs]);
|
||||
|
||||
const companyAgents = useMemo(() => snapshot?.agents.filter((a) => !currentCompany || a.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
const selectedAgent = companyAgents.find((a) => a.id === selectedAgentId) ?? null;
|
||||
const companyGoals = useMemo(() => snapshot?.goals.filter((g) => !currentCompany || g.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
@@ -341,6 +399,12 @@ export function App() {
|
||||
const activeInboxCount = inboxItems.length;
|
||||
const navBadges = useMemo<Partial<Record<SectionId, { count: number; tone: "default" | "warn" | "danger" }>>>(() => badges ? {
|
||||
inbox: activeInboxCount > 0 ? { count: activeInboxCount, tone: "warn" } : undefined,
|
||||
runs:
|
||||
badges.failedRuns > 0
|
||||
? { count: badges.failedRuns, tone: "danger" }
|
||||
: badges.activeRuns > 0
|
||||
? { count: badges.activeRuns, tone: "default" }
|
||||
: undefined,
|
||||
tasks: badges.todoTasks > 0 ? { count: badges.todoTasks, tone: "default" } : undefined,
|
||||
approvals: badges.pendingApprovals > 0 ? { count: badges.pendingApprovals, tone: "warn" } : undefined,
|
||||
costs: badges.budgetWarnings > 0 ? { count: badges.budgetWarnings, tone: "danger" } : undefined,
|
||||
@@ -363,24 +427,99 @@ export function App() {
|
||||
: navItems;
|
||||
const matchedAgents = paletteQuery ? companyAgents.filter((a) => a.name.toLowerCase().includes(paletteQuery) || a.role.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
|
||||
const matchedTasks = paletteQuery ? tasks.filter((t) => t.title.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
|
||||
const matchedRuns = paletteQuery ? runs.filter((run) => {
|
||||
const matchingTask = tasks.find((task) => task.id === run.taskId);
|
||||
return [
|
||||
run.id,
|
||||
run.summary ?? "",
|
||||
run.errorMessage ?? "",
|
||||
matchingTask?.title ?? "",
|
||||
run.connectorId,
|
||||
].some((value) => value.toLowerCase().includes(paletteQuery));
|
||||
}).slice(0, 5) : [];
|
||||
const matchedGoals = paletteQuery ? companyGoals.filter((g) => g.title.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
|
||||
const matchedProjects = paletteQuery ? companyProjects.filter((p) => p.name.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
|
||||
const hasEntityResults = matchedAgents.length > 0 || matchedTasks.length > 0 || matchedGoals.length > 0 || matchedProjects.length > 0;
|
||||
return { filteredNavItems, matchedAgents, matchedTasks, matchedGoals, matchedProjects, hasEntityResults };
|
||||
}, [paletteQuery, companyAgents, tasks, companyGoals, companyProjects]);
|
||||
const { filteredNavItems, matchedAgents, matchedTasks, matchedGoals, matchedProjects, hasEntityResults } = paletteResults;
|
||||
const hasEntityResults = matchedAgents.length > 0 || matchedTasks.length > 0 || matchedRuns.length > 0 || matchedGoals.length > 0 || matchedProjects.length > 0;
|
||||
return { filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects, hasEntityResults };
|
||||
}, [paletteQuery, companyAgents, companyGoals, companyProjects, runs, tasks]);
|
||||
const { filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects, hasEntityResults } = paletteResults;
|
||||
const paletteActions = useMemo<PaletteAction[]>(() => ([
|
||||
...filteredNavItems.map((item) => ({ key: `section:${item.id}`, section: item.id })),
|
||||
...matchedAgents.map((agent) => ({ key: `agent:${agent.id}`, section: "agents" as const, entityId: agent.id })),
|
||||
...matchedTasks.map((task) => ({ key: `task:${task.id}`, section: "tasks" as const, entityId: task.id })),
|
||||
...matchedRuns.map((run) => ({ key: `run:${run.id}`, section: "runs" as const, entityId: run.id })),
|
||||
...matchedGoals.map((goal) => ({ key: `goal:${goal.id}`, section: "goals" as const, entityId: goal.id })),
|
||||
...matchedProjects.map((project) => ({ key: `project:${project.id}`, section: "projects" as const, entityId: project.id })),
|
||||
]), [filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects]);
|
||||
const paletteActionIndex = useMemo(() => new Map(paletteActions.map((action, index) => [action.key, index])), [paletteActions]);
|
||||
|
||||
const navigateTo = useCallback((targetSection: SectionId, entityId?: string) => {
|
||||
setSection(targetSection);
|
||||
if (entityId) {
|
||||
if (targetSection === "agents") setSelectedAgentId(entityId);
|
||||
else if (targetSection === "tasks") setSelectedTaskId(entityId);
|
||||
else if (targetSection === "connectors") setSelectedConnectorId(entityId);
|
||||
else if (targetSection === "runs") {
|
||||
setSelectedRunId(entityId);
|
||||
setConsoleOpen(true);
|
||||
}
|
||||
else if (targetSection === "tasks") {
|
||||
const matchingTask = tasks.find((task) => task.id === entityId) ?? null;
|
||||
if (matchingTask) {
|
||||
setSelectedTaskId(matchingTask.id);
|
||||
} else {
|
||||
const matchingRun = runs.find((run) => run.id === entityId) ?? null;
|
||||
if (matchingRun) {
|
||||
setSelectedRunId(matchingRun.id);
|
||||
setSelectedTaskId(matchingRun.taskId);
|
||||
setConsoleOpen(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
|
||||
else if (targetSection === "goals") setSelectedGoalId(entityId);
|
||||
else if (targetSection === "projects") setSelectedProjectId(entityId);
|
||||
else if (targetSection === "communication") setSelectedCommunicationEntityId(entityId);
|
||||
}
|
||||
}, []);
|
||||
}, [runs, tasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!commandPaletteOpen) return;
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
return;
|
||||
}
|
||||
if (paletteActions.length === 0) return;
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setSelectedPaletteIndex((current) => (current + 1) % paletteActions.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setSelectedPaletteIndex((current) => (current - 1 + paletteActions.length) % paletteActions.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const selectedAction = paletteActions[Math.min(selectedPaletteIndex, paletteActions.length - 1)];
|
||||
if (!selectedAction) return;
|
||||
navigateTo(selectedAction.section, selectedAction.entityId);
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [commandPaletteOpen, navigateTo, paletteActions, selectedPaletteIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!commandPaletteOpen) return;
|
||||
setSelectedPaletteIndex(0);
|
||||
}, [commandPaletteOpen, commandPaletteSearch]);
|
||||
|
||||
const setCompany = useCallback(async (companyId: string) => {
|
||||
if (companyId === "__new__") {
|
||||
@@ -389,12 +528,13 @@ export function App() {
|
||||
}
|
||||
try {
|
||||
unwrap(await window.agentCompany.setCurrentCompany(companyId));
|
||||
clearCompanyScopedUiState();
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, [refresh]);
|
||||
}, [clearCompanyScopedUiState, refresh]);
|
||||
|
||||
const deleteCompany = useCallback(async (companyId: string) => {
|
||||
try {
|
||||
@@ -408,7 +548,11 @@ export function App() {
|
||||
}, [refresh]);
|
||||
|
||||
const startRun = useCallback(async (taskId: string) => {
|
||||
const result = await window.agentCompany.startTaskRun({ taskId });
|
||||
if (!currentCompany) {
|
||||
setError("Select a company before starting a run.");
|
||||
return;
|
||||
}
|
||||
const result = await window.agentCompany.startTaskRun({ companyId: currentCompany.id, taskId });
|
||||
if (!result.ok) {
|
||||
setError(result.error.message);
|
||||
return;
|
||||
@@ -416,29 +560,49 @@ export function App() {
|
||||
setSelectedRunId(result.data);
|
||||
setConsoleOpen(true);
|
||||
await refresh();
|
||||
}, [refresh]);
|
||||
}, [currentCompany, refresh]);
|
||||
|
||||
const cancelRun = useCallback(async (runId: string) => {
|
||||
try {
|
||||
unwrap(await window.agentCompany.cancelRun({ runId }));
|
||||
const run = runs.find((entry) => entry.id === runId);
|
||||
if (!run) {
|
||||
throw new Error("Selected run is no longer available.");
|
||||
}
|
||||
unwrap(await window.agentCompany.cancelRun({ companyId: run.companyId, runId }));
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, [refresh, runs]);
|
||||
|
||||
const handleOnboardingDone = useCallback(async (summary: OnboardingBootstrapResult) => {
|
||||
setShowOnboarding(false);
|
||||
setInitialOnboardingLocked(false);
|
||||
await refresh();
|
||||
setError(null);
|
||||
if (summary.runId) {
|
||||
setSection("runs");
|
||||
setSelectedRunId(summary.runId);
|
||||
setConsoleOpen(true);
|
||||
return;
|
||||
}
|
||||
if (summary.taskId) {
|
||||
setSection("tasks");
|
||||
setSelectedTaskId(summary.taskId);
|
||||
return;
|
||||
}
|
||||
if (summary.projectId) {
|
||||
setSection("projects");
|
||||
setSelectedProjectId(summary.projectId);
|
||||
return;
|
||||
}
|
||||
if (summary.leadAgentId) {
|
||||
setSection("agents");
|
||||
setSelectedAgentId(summary.leadAgentId);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
const quickConnectorCheck = useCallback(async () => {
|
||||
if (!selectedConnector) return;
|
||||
try {
|
||||
unwrap(await window.agentCompany.testConnector(selectedConnector.id));
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, [selectedConnector, refresh]);
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] items-center justify-center text-[13px] text-[color:var(--muted)]">
|
||||
@@ -448,10 +612,10 @@ export function App() {
|
||||
}
|
||||
|
||||
if (snapshot.companies.length === 0 || showOnboarding || initialOnboardingLocked) {
|
||||
return <CompanyOnboarding connectors={snapshot.connectors} onDone={async () => {
|
||||
return <CompanyOnboarding connectors={snapshot.connectors} onDone={handleOnboardingDone} canCancel={snapshot.companies.length > 0 && !initialOnboardingLocked} onCancel={() => {
|
||||
setShowOnboarding(false);
|
||||
setInitialOnboardingLocked(false);
|
||||
await refresh();
|
||||
setError(null);
|
||||
}} />;
|
||||
}
|
||||
|
||||
@@ -688,6 +852,27 @@ export function App() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{section === "runs" ? (
|
||||
<RunCenter
|
||||
runs={runs}
|
||||
tasks={tasks}
|
||||
agents={companyAgents}
|
||||
workspaces={companyWorkspaces}
|
||||
selectedRunId={selectedRun?.id ?? null}
|
||||
onSelectRun={(runId: string) => {
|
||||
setSelectedRunId(runId);
|
||||
}}
|
||||
onOpenLogPath={async (path: string) => {
|
||||
unwrap(await window.agentCompany.openPath(path));
|
||||
}}
|
||||
onCancelRun={cancelRun}
|
||||
onOpenConsole={(runId: string) => {
|
||||
setSelectedRunId(runId);
|
||||
setConsoleOpen(true);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{section === "connectors" && selectedConnector ? (
|
||||
<div className="space-y-10">
|
||||
<Panel title="Connector registry">
|
||||
@@ -715,8 +900,6 @@ export function App() {
|
||||
</Panel>
|
||||
<Panel
|
||||
title={selectedConnector.label}
|
||||
|
||||
action={<ActionButton label="Run health check" onClick={() => void quickConnectorCheck()} icon={Wrench} tone="accent" />}
|
||||
>
|
||||
<ConnectorEditor connector={selectedConnector} onSaved={refresh} />
|
||||
</Panel>
|
||||
@@ -746,7 +929,7 @@ export function App() {
|
||||
workspaces={companyWorkspaces}
|
||||
agents={companyAgents}
|
||||
companyId={currentCompany!.id}
|
||||
onSaved={async () => { setSelectedAgentId(null); await refresh(); }}
|
||||
onSaved={refresh}
|
||||
onDeleted={async () => { setSelectedAgentId(null); await refresh(); }}
|
||||
/>
|
||||
) : null}
|
||||
@@ -861,6 +1044,8 @@ export function App() {
|
||||
projects={companyProjects}
|
||||
workspaces={companyWorkspaces}
|
||||
companyId={currentCompany!.id}
|
||||
comments={(snapshot.comments ?? []).filter((comment) => comment.taskId === selectedTask.id)}
|
||||
onRefresh={refresh}
|
||||
onRun={startRun}
|
||||
onCancelRun={cancelRun}
|
||||
onCreateApproval={async () => {
|
||||
@@ -880,6 +1065,11 @@ export function App() {
|
||||
setSelectedApprovalId(approvalId);
|
||||
await refresh();
|
||||
}}
|
||||
onOpenDiscussion={(taskId) => {
|
||||
setSelectedTaskId(null);
|
||||
setSection("communication");
|
||||
setSelectedCommunicationEntityId(taskId);
|
||||
}}
|
||||
onDeleted={async () => { setSelectedTaskId(null); await refresh(); }}
|
||||
/>
|
||||
) : null}
|
||||
@@ -893,7 +1083,7 @@ export function App() {
|
||||
<PendingReviewList tasks={tasks} agents={companyAgents} />
|
||||
</Panel>
|
||||
<Panel title="Pending approvals">
|
||||
<ApprovalList approvals={approvals} selectedApprovalId={selectedApproval?.id ?? null} onSelect={setSelectedApprovalId} />
|
||||
<ApprovalList approvals={pendingApprovals} selectedApprovalId={selectedApproval?.id ?? null} onSelect={setSelectedApprovalId} />
|
||||
</Panel>
|
||||
<Modal open={!!selectedApproval} title={selectedApproval?.payloadSummary ?? "Approval detail"} onClose={() => setSelectedApprovalId(null)}>
|
||||
{selectedApproval ? (
|
||||
@@ -943,33 +1133,60 @@ export function App() {
|
||||
agents={companyAgents.map(a => ({ id: a.id, name: a.name }))}
|
||||
companyId={snapshot.currentCompanyId}
|
||||
onSave={async (input) => {
|
||||
await window.agentCompany.saveSocialAccount({
|
||||
companyId: input.companyId,
|
||||
platform: input.platform,
|
||||
accountName: input.accountName,
|
||||
displayName: input.displayName,
|
||||
requireApproval: input.requireApproval,
|
||||
profileUrl: "",
|
||||
status: "login_required",
|
||||
metadataJson: "{}",
|
||||
});
|
||||
await refresh();
|
||||
try {
|
||||
unwrap(await window.agentCompany.saveSocialAccount({
|
||||
companyId: input.companyId,
|
||||
platform: input.platform,
|
||||
accountName: input.accountName,
|
||||
displayName: input.displayName,
|
||||
requireApproval: input.requireApproval,
|
||||
profileUrl: "",
|
||||
status: "login_required",
|
||||
metadataJson: "{}",
|
||||
}));
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}}
|
||||
onDelete={async (id) => {
|
||||
if (!snapshot?.currentCompanyId) return;
|
||||
await window.agentCompany.deleteSocialAccount({ id, companyId: snapshot.currentCompanyId });
|
||||
await refresh();
|
||||
try {
|
||||
unwrap(await window.agentCompany.deleteSocialAccount({ id, companyId: snapshot.currentCompanyId }));
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}}
|
||||
onLogin={async (accountId) => {
|
||||
await window.agentCompany.triggerBrowserLogin(accountId);
|
||||
await refresh();
|
||||
if (!snapshot?.currentCompanyId) return;
|
||||
try {
|
||||
unwrap(await window.agentCompany.triggerBrowserLogin({ companyId: snapshot.currentCompanyId, socialAccountId: accountId }));
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}}
|
||||
onOpenPath={async (path) => {
|
||||
await window.agentCompany.openPath(path);
|
||||
try {
|
||||
unwrap(await window.agentCompany.openPath(path));
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}}
|
||||
onCancelAction={async (actionId) => {
|
||||
await window.agentCompany.cancelBrowserAction({ runId: actionId });
|
||||
await refresh();
|
||||
if (!snapshot?.currentCompanyId) return;
|
||||
try {
|
||||
unwrap(await window.agentCompany.cancelBrowserAction({ actionId, companyId: snapshot.currentCompanyId }));
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}}
|
||||
onNavigate={(sectionName, entityId) => {
|
||||
setSection(sectionName);
|
||||
@@ -996,6 +1213,11 @@ export function App() {
|
||||
agents={companyAgents}
|
||||
companyId={currentCompany.id}
|
||||
selectedEntityId={selectedCommunicationEntityId}
|
||||
onEntityConsumed={() => setSelectedCommunicationEntityId(null)}
|
||||
onNavigateToTask={(taskId) => {
|
||||
setSection("tasks");
|
||||
setSelectedTaskId(taskId);
|
||||
}}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1202,11 +1424,12 @@ export function App() {
|
||||
{filteredNavItems.map((item) => (
|
||||
<ListRow
|
||||
key={item.id}
|
||||
className="py-3"
|
||||
className={`py-3 ${paletteActionIndex.get(`section:${item.id}`) === selectedPaletteIndex ? "bg-[color:var(--panel-soft)]" : ""}`}
|
||||
onClick={() => {
|
||||
setSection(item.id);
|
||||
navigateTo(item.id);
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1231,12 +1454,12 @@ export function App() {
|
||||
{matchedAgents.map((agent) => (
|
||||
<ListRow
|
||||
key={agent.id}
|
||||
className="py-3"
|
||||
className={`py-3 ${paletteActionIndex.get(`agent:${agent.id}`) === selectedPaletteIndex ? "bg-[color:var(--panel-soft)]" : ""}`}
|
||||
onClick={() => {
|
||||
setSection("agents");
|
||||
setSelectedAgentId(agent.id);
|
||||
navigateTo("agents", agent.id);
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1260,12 +1483,12 @@ export function App() {
|
||||
{matchedTasks.map((task) => (
|
||||
<ListRow
|
||||
key={task.id}
|
||||
className="py-3"
|
||||
className={`py-3 ${paletteActionIndex.get(`task:${task.id}`) === selectedPaletteIndex ? "bg-[color:var(--panel-soft)]" : ""}`}
|
||||
onClick={() => {
|
||||
setSection("tasks");
|
||||
setSelectedTaskId(task.id);
|
||||
navigateTo("tasks", task.id);
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1281,6 +1504,36 @@ export function App() {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{matchedRuns.length > 0 ? (
|
||||
<>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Runs</div>
|
||||
<ListFrame>
|
||||
{matchedRuns.map((run) => (
|
||||
<ListRow
|
||||
key={run.id}
|
||||
className={`py-3 ${paletteActionIndex.get(`run:${run.id}`) === selectedPaletteIndex ? "bg-[color:var(--panel-soft)]" : ""}`}
|
||||
onClick={() => {
|
||||
navigateTo("runs", run.id);
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex items-center gap-3 text-[color:var(--text)]">
|
||||
<TerminalWindow size={16} />
|
||||
<span className="truncate text-[13px] tracking-[0.005em]">
|
||||
{run.summary || runs.find((entry) => entry.id === run.id)?.id.slice(0, 8) || run.id.slice(0, 8)}
|
||||
</span>
|
||||
</div>
|
||||
<StatusPill status={run.status} />
|
||||
</div>
|
||||
</ListRow>
|
||||
))}
|
||||
</ListFrame>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{matchedGoals.length > 0 ? (
|
||||
<>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Goals</div>
|
||||
@@ -1288,12 +1541,12 @@ export function App() {
|
||||
{matchedGoals.map((goal) => (
|
||||
<ListRow
|
||||
key={goal.id}
|
||||
className="py-3"
|
||||
className={`py-3 ${paletteActionIndex.get(`goal:${goal.id}`) === selectedPaletteIndex ? "bg-[color:var(--panel-soft)]" : ""}`}
|
||||
onClick={() => {
|
||||
setSection("goals");
|
||||
setSelectedGoalId(goal.id);
|
||||
navigateTo("goals", goal.id);
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1316,12 +1569,12 @@ export function App() {
|
||||
{matchedProjects.map((project) => (
|
||||
<ListRow
|
||||
key={project.id}
|
||||
className="py-3"
|
||||
className={`py-3 ${paletteActionIndex.get(`project:${project.id}`) === selectedPaletteIndex ? "bg-[color:var(--panel-soft)]" : ""}`}
|
||||
onClick={() => {
|
||||
setSection("projects");
|
||||
setSelectedProjectId(project.id);
|
||||
navigateTo("projects", project.id);
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -31,7 +31,8 @@ import type {
|
||||
} from "@shared/types"
|
||||
import { getAgentInitials, formatMessageTime } from "../lib/formatters"
|
||||
import { DEPARTMENT_COLORS } from "@shared/constants"
|
||||
import { Modal, Select, Input, TextArea } from "./ui"
|
||||
import { unwrap } from "../lib/desktop"
|
||||
import { InlineNotice, Modal, Select, Input, TextArea } from "./ui"
|
||||
|
||||
const TERRACOTTA = "#d97745"
|
||||
const TERRACOTTA_SOFT = "#d9774518"
|
||||
@@ -181,18 +182,32 @@ function deriveTaskDiscussionChannels(
|
||||
tasks: TaskRecord[],
|
||||
): TaskDiscussionDescriptor[] {
|
||||
const taskMap = new Map(tasks.map((task) => [task.id, task]))
|
||||
const discussedTaskIds = new Set(comments.map((comment) => comment.taskId))
|
||||
return Array.from(discussedTaskIds)
|
||||
const activeTaskIds = tasks
|
||||
.filter((task) => ["todo", "in_progress", "in_review", "blocked"].includes(task.status))
|
||||
.map((task) => task.id)
|
||||
const discussionTaskIds = new Set([...comments.map((comment) => comment.taskId), ...activeTaskIds])
|
||||
|
||||
return Array.from(discussionTaskIds)
|
||||
.map((taskId) => {
|
||||
const task = taskMap.get(taskId)
|
||||
return {
|
||||
key: `task-${taskId}`,
|
||||
kind: "task" as const,
|
||||
taskId,
|
||||
label: task?.title ?? `Task ${taskId.slice(0, 8)}`,
|
||||
label: task
|
||||
? `${task.title}${task.status === "blocked" ? " · blocked" : task.status === "in_review" ? " · review" : ""}`
|
||||
: `Task ${taskId.slice(0, 8)}`,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
.sort((left, right) => {
|
||||
const leftTask = taskMap.get(left.taskId)
|
||||
const rightTask = taskMap.get(right.taskId)
|
||||
const priorityWeight: Record<string, number> = { critical: 4, high: 3, medium: 2, low: 1 }
|
||||
const leftWeight = leftTask ? priorityWeight[leftTask.priority] ?? 0 : 0
|
||||
const rightWeight = rightTask ? priorityWeight[rightTask.priority] ?? 0 : 0
|
||||
if (leftWeight !== rightWeight) return rightWeight - leftWeight
|
||||
return left.label.localeCompare(right.label)
|
||||
})
|
||||
}
|
||||
|
||||
function getChannelMessages(
|
||||
@@ -509,19 +524,23 @@ function TaskDiscussionComposer({
|
||||
}) {
|
||||
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 {
|
||||
await window.agentCompany.addComment({
|
||||
unwrap(await window.agentCompany.addComment({
|
||||
companyId,
|
||||
taskId,
|
||||
authorName: "Board",
|
||||
body: body.trim(),
|
||||
})
|
||||
}))
|
||||
setBody("")
|
||||
onSent()
|
||||
} catch (error) {
|
||||
setSendError(error instanceof Error ? error.message : "Failed to send task discussion reply.")
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -529,6 +548,7 @@ function TaskDiscussionComposer({
|
||||
|
||||
return (
|
||||
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
|
||||
{sendError ? <div className="mb-3"><InlineNotice message={sendError} /></div> : null}
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={body}
|
||||
@@ -609,6 +629,7 @@ function MessageComposer({
|
||||
const [body, setBody] = useState("")
|
||||
const [subject, setSubject] = useState("")
|
||||
const [sending, setSending] = useState(false)
|
||||
const [sendError, setSendError] = useState<string | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -620,8 +641,9 @@ function MessageComposer({
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!body.trim() || !fromAgentId) return
|
||||
setSending(true)
|
||||
setSendError(null)
|
||||
try {
|
||||
await window.agentCompany.sendAgentMessage({
|
||||
unwrap(await window.agentCompany.sendAgentMessage({
|
||||
companyId,
|
||||
fromAgentId,
|
||||
toAgentId: replyTo ? replyTo.fromAgentId : null,
|
||||
@@ -632,12 +654,12 @@ function MessageComposer({
|
||||
priority: "normal",
|
||||
parentMessageId: replyTo?.id ?? null,
|
||||
attachmentsJson: "[]",
|
||||
})
|
||||
}))
|
||||
setBody("")
|
||||
setSubject("")
|
||||
onSent()
|
||||
} catch (err) {
|
||||
void err
|
||||
} catch (error) {
|
||||
setSendError(error instanceof Error ? error.message : "Failed to send message.")
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -657,6 +679,7 @@ function MessageComposer({
|
||||
|
||||
return (
|
||||
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
|
||||
{sendError ? <div className="mb-3"><InlineNotice message={sendError} /></div> : null}
|
||||
<AnimatePresence>
|
||||
{replyTo ? (
|
||||
<motion.div
|
||||
@@ -758,6 +781,7 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
const [body, setBody] = useState("")
|
||||
const [priority, setPriority] = useState<MessagePriority>("normal")
|
||||
const [sending, setSending] = useState(false)
|
||||
const [sendError, setSendError] = useState<string | null>(null)
|
||||
|
||||
const departmentOptions = useMemo(() => {
|
||||
const depts = new Set<string>()
|
||||
@@ -768,10 +792,11 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
}, [agents])
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!body.trim() || !fromAgentId || !subject.trim()) return
|
||||
if (!body.trim() || !fromAgentId || !subject.trim() || (channel === "direct" && !toAgentId)) return
|
||||
setSending(true)
|
||||
setSendError(null)
|
||||
try {
|
||||
await window.agentCompany.sendAgentMessage({
|
||||
unwrap(await window.agentCompany.sendAgentMessage({
|
||||
companyId,
|
||||
fromAgentId,
|
||||
toAgentId: channel === "direct" && toAgentId ? toAgentId : null,
|
||||
@@ -782,14 +807,14 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
priority,
|
||||
parentMessageId: null,
|
||||
attachmentsJson: "[]",
|
||||
})
|
||||
}))
|
||||
setSubject("")
|
||||
setBody("")
|
||||
setPriority("normal")
|
||||
onSent()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
void err
|
||||
} catch (error) {
|
||||
setSendError(error instanceof Error ? error.message : "Failed to send message.")
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -798,6 +823,7 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
return (
|
||||
<Modal open={open} title="Compose Message" onClose={onClose} width="560px">
|
||||
<div className="space-y-4">
|
||||
{sendError ? <InlineNotice message={sendError} /> : null}
|
||||
<Select
|
||||
label="From Agent"
|
||||
value={fromAgentId}
|
||||
@@ -827,7 +853,7 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
value={toAgentId}
|
||||
onChange={setToAgentId}
|
||||
options={[
|
||||
{ value: "", label: "Broadcast to all" },
|
||||
{ value: "", label: "Select a recipient..." },
|
||||
...agents
|
||||
.filter((a) => a.id !== fromAgentId)
|
||||
.map((a) => ({
|
||||
@@ -895,7 +921,7 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={!body.trim() || !subject.trim() || !fromAgentId || sending}
|
||||
disabled={!body.trim() || !subject.trim() || !fromAgentId || (channel === "direct" && !toAgentId) || sending}
|
||||
className="inline-flex items-center gap-2 rounded-[8px] px-4 py-2 text-[12px] font-semibold text-white transition hover:brightness-90 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none"
|
||||
style={{ backgroundColor: TERRACOTTA }}
|
||||
>
|
||||
@@ -1060,6 +1086,8 @@ interface CommunicationHubProps {
|
||||
agents: AgentRecord[]
|
||||
companyId: string
|
||||
selectedEntityId?: string | null
|
||||
onEntityConsumed?: () => void
|
||||
onNavigateToTask: (taskId: string) => void
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
@@ -1070,12 +1098,16 @@ export function CommunicationHub({
|
||||
agents,
|
||||
companyId,
|
||||
selectedEntityId,
|
||||
onEntityConsumed,
|
||||
onNavigateToTask,
|
||||
onRefresh,
|
||||
}: CommunicationHubProps) {
|
||||
const [selectedChannelKey, setSelectedChannelKey] = useState<string | null>(null)
|
||||
const [showSidebar, setShowSidebar] = useState(true)
|
||||
const [showCompose, setShowCompose] = useState(false)
|
||||
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>({
|
||||
search: "",
|
||||
channelType: "all",
|
||||
@@ -1085,39 +1117,65 @@ export function CommunicationHub({
|
||||
|
||||
const messageEndRef = useRef<HTMLDivElement>(null)
|
||||
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents])
|
||||
const effectiveMessages = useMemo(
|
||||
() =>
|
||||
messages.map((message) =>
|
||||
optimisticReadIds.has(message.id)
|
||||
? { ...message, readAt: message.readAt ?? new Date().toISOString() }
|
||||
: message,
|
||||
),
|
||||
[messages, optimisticReadIds],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setOptimisticReadIds((current) => {
|
||||
if (current.size === 0) return current
|
||||
const next = new Set(Array.from(current).filter((messageId) => effectiveMessages.some((message) => message.id === messageId)))
|
||||
return next.size === current.size ? current : next
|
||||
})
|
||||
}, [effectiveMessages])
|
||||
|
||||
// Derive channels from messages
|
||||
const messageChannels = useMemo(() => deriveChannels(messages, agents), [messages, agents])
|
||||
const messageChannels = useMemo(() => deriveChannels(effectiveMessages, agents), [effectiveMessages, agents])
|
||||
const taskDiscussionChannels = useMemo(() => deriveTaskDiscussionChannels(comments, tasks), [comments, tasks])
|
||||
const channels = useMemo(() => [...messageChannels, ...taskDiscussionChannels], [messageChannels, taskDiscussionChannels])
|
||||
|
||||
// Auto-select first channel
|
||||
// Auto-select first valid channel
|
||||
useEffect(() => {
|
||||
if (!selectedChannelKey && channels.length > 0) {
|
||||
if (channels.length === 0) {
|
||||
if (selectedChannelKey) {
|
||||
setSelectedChannelKey(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!selectedChannelKey || !channels.some((channel) => channel.key === selectedChannelKey)) {
|
||||
setSelectedChannelKey(channels[0].key)
|
||||
}
|
||||
}, [channels, selectedChannelKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedEntityId) return
|
||||
const matchingMessage = messages.find((message) => message.id === selectedEntityId)
|
||||
const matchingMessage = effectiveMessages.find((message) => message.id === selectedEntityId)
|
||||
if (matchingMessage) {
|
||||
const matchingChannel = messageChannels.find((channel) => getChannelMessages(messages, channel).some((message) => message.id === selectedEntityId))
|
||||
const matchingChannel = messageChannels.find((channel) => getChannelMessages(effectiveMessages, channel).some((message) => message.id === selectedEntityId))
|
||||
if (matchingChannel) {
|
||||
setSelectedChannelKey(matchingChannel.key)
|
||||
onEntityConsumed?.()
|
||||
}
|
||||
return
|
||||
}
|
||||
const matchingComment = comments.find((comment) => comment.id === selectedEntityId)
|
||||
if (matchingComment) {
|
||||
setSelectedChannelKey(`task-${matchingComment.taskId}`)
|
||||
onEntityConsumed?.()
|
||||
return
|
||||
}
|
||||
const matchingTaskDiscussion = taskDiscussionChannels.find((discussion) => discussion.taskId === selectedEntityId)
|
||||
if (matchingTaskDiscussion) {
|
||||
setSelectedChannelKey(matchingTaskDiscussion.key)
|
||||
onEntityConsumed?.()
|
||||
}
|
||||
}, [selectedEntityId, messages, comments, messageChannels, taskDiscussionChannels])
|
||||
}, [selectedEntityId, effectiveMessages, comments, messageChannels, onEntityConsumed, taskDiscussionChannels])
|
||||
|
||||
const activeChannel = useMemo(
|
||||
() => channels.find((c) => c.key === selectedChannelKey) ?? null,
|
||||
@@ -1127,10 +1185,10 @@ export function CommunicationHub({
|
||||
// Messages for the selected channel
|
||||
const channelMessages = useMemo(() => {
|
||||
if (!activeChannel || activeChannel.kind !== "message") return []
|
||||
return getChannelMessages(messages, activeChannel).sort(
|
||||
return getChannelMessages(effectiveMessages, activeChannel).sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
)
|
||||
}, [messages, activeChannel])
|
||||
}, [effectiveMessages, activeChannel])
|
||||
|
||||
const taskDiscussionComments = useMemo(() => {
|
||||
if (!activeChannel || activeChannel.kind !== "task") return []
|
||||
@@ -1173,6 +1231,58 @@ export function CommunicationHub({
|
||||
return result
|
||||
}, [channelMessages, filters, agentMap])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeChannel || activeChannel.kind !== "message") return
|
||||
const unreadMessageIds = filteredMessages
|
||||
.filter((message) => !message.readAt && !pendingReadIdsRef.current.has(message.id))
|
||||
.map((message) => message.id)
|
||||
if (unreadMessageIds.length === 0) return
|
||||
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
for (const messageId of unreadMessageIds) {
|
||||
pendingReadIdsRef.current.add(messageId)
|
||||
}
|
||||
setOptimisticReadIds((current) => {
|
||||
const next = new Set(current)
|
||||
for (const messageId of unreadMessageIds) {
|
||||
next.add(messageId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
const results = await Promise.all(
|
||||
unreadMessageIds.map(async (messageId) => {
|
||||
try {
|
||||
unwrap(await window.agentCompany.markMessageRead({ messageId, companyId }))
|
||||
return { messageId, ok: true as const }
|
||||
} catch {
|
||||
return { messageId, ok: false as const }
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const failedIds = results.filter((result) => !result.ok).map((result) => result.messageId)
|
||||
for (const messageId of unreadMessageIds) {
|
||||
pendingReadIdsRef.current.delete(messageId)
|
||||
}
|
||||
|
||||
if (!cancelled && failedIds.length > 0) {
|
||||
setOptimisticReadIds((current) => {
|
||||
const next = new Set(current)
|
||||
for (const messageId of failedIds) {
|
||||
next.delete(messageId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeChannel, companyId, filteredMessages])
|
||||
|
||||
// Build thread structure: top-level messages + their replies
|
||||
const threadedMessages = useMemo(() => {
|
||||
const topLevel = filteredMessages.filter((m) => !m.parentMessageId)
|
||||
@@ -1195,22 +1305,23 @@ export function CommunicationHub({
|
||||
counts.set(ch.key, { unread: 0, hasUrgent: false })
|
||||
continue
|
||||
}
|
||||
const msgs = getChannelMessages(messages, ch)
|
||||
const unread = msgs.filter((m) => !m.readAt).length
|
||||
const hasUrgent = msgs.some((m) => !m.readAt && m.priority === "urgent")
|
||||
const visibleMessages = getChannelMessages(effectiveMessages, ch)
|
||||
const unread = visibleMessages.filter((m) => !m.readAt).length
|
||||
const hasUrgent = visibleMessages.some((m) => !m.readAt && m.priority === "urgent")
|
||||
counts.set(ch.key, { unread, hasUrgent })
|
||||
}
|
||||
return counts
|
||||
}, [channels, messages])
|
||||
}, [channels, effectiveMessages])
|
||||
|
||||
const totalUnread = useMemo(() => {
|
||||
return messages.filter((m) => !m.readAt).length
|
||||
}, [messages])
|
||||
return effectiveMessages.filter((m) => !m.readAt).length
|
||||
}, [effectiveMessages])
|
||||
|
||||
// Scroll to bottom when channel changes
|
||||
// Scroll to bottom when channel changes or new messages arrive
|
||||
const messageCount = useMemo(() => effectiveMessages.length, [effectiveMessages])
|
||||
useEffect(() => {
|
||||
messageEndRef.current?.scrollIntoView({ behavior: "auto" })
|
||||
}, [selectedChannelKey])
|
||||
}, [selectedChannelKey, messageCount])
|
||||
|
||||
// Group channels by type for sidebar sections
|
||||
const channelGroups = useMemo(() => {
|
||||
@@ -1405,6 +1516,15 @@ export function CommunicationHub({
|
||||
: `${taskDiscussionComments.length} comment${taskDiscussionComments.length !== 1 ? "s" : ""}${activeTask ? ` · ${activeTask.status.replaceAll("_", " ")}` : ""}`}
|
||||
</div>
|
||||
</div>
|
||||
{activeChannel.kind === "task" && activeTask ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigateToTask(activeTask.id)}
|
||||
className="rounded-[6px] border border-[color:var(--line)] px-2.5 py-1 text-[11px] font-medium text-[color:var(--muted-strong)] transition hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)]"
|
||||
>
|
||||
Open task
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[13px] text-[color:var(--muted)]">
|
||||
|
||||
@@ -1,21 +1,176 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { CheckCircle } from "@phosphor-icons/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CheckCircle, SignIn } from "@phosphor-icons/react";
|
||||
import type { ConnectorRecord } from "@shared/types";
|
||||
import { ActionButton, Input, StatusPill, TextArea } from "./ui";
|
||||
import { unwrap } from "../lib/desktop";
|
||||
import { ActionButton, InlineNotice, Input, Select, StatusPill, TextArea } from "./ui";
|
||||
|
||||
function getAuthActionCopy(connector: ConnectorRecord) {
|
||||
switch (connector.id) {
|
||||
case "codex_local":
|
||||
return {
|
||||
title: "Codex login required",
|
||||
message: "Codex is installed, but the CLI needs an authenticated session before AgentCompany can dispatch runs.",
|
||||
buttonLabel: "Open Terminal To Sign In",
|
||||
};
|
||||
case "claude_local":
|
||||
return {
|
||||
title: "Claude Code login required",
|
||||
message: "Claude Code is installed, but the local CLI is not authenticated for this desktop runtime.",
|
||||
buttonLabel: "Open Terminal To Sign In",
|
||||
};
|
||||
case "opencode_local":
|
||||
return {
|
||||
title: "OpenCode provider login required",
|
||||
message: "OpenCode is installed, but the provider login must be completed before runs can start.",
|
||||
buttonLabel: "Open Terminal To Sign In",
|
||||
};
|
||||
case "gemini_local":
|
||||
return {
|
||||
title: "Gemini authentication required",
|
||||
message: "Gemini CLI is available, but the runtime still needs an authenticated session. AgentCompany will open Gemini in a terminal and you can complete its interactive auth flow there.",
|
||||
buttonLabel: "Open Terminal",
|
||||
};
|
||||
case "kimi_local":
|
||||
return {
|
||||
title: "Kimi interactive login required",
|
||||
message: "Kimi Code is available in generic mode. AgentCompany can open the CLI in a terminal so you can complete the interactive login flow there if prompted.",
|
||||
buttonLabel: "Open Terminal",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRecord; onSaved: () => Promise<void> }) {
|
||||
const [model, setModel] = useState(connector.model ?? "");
|
||||
const [envBindingText, setEnvBindingText] = useState(connector.envBindingText);
|
||||
const [notes, setNotes] = useState(connector.notes);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [openingAuthTerminal, setOpeningAuthTerminal] = useState(false);
|
||||
const [autoChecking, setAutoChecking] = useState(false);
|
||||
const [modelOptions, setModelOptions] = useState<Array<{ id: string; label: string }>>([]);
|
||||
const [loadingModelOptions, setLoadingModelOptions] = useState(false);
|
||||
const [modelOptionsError, setModelOptionsError] = useState<string | null>(null);
|
||||
const authPollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const authActionCopy = getAuthActionCopy(connector);
|
||||
const needsLogin = connector.authState === "required" || connector.status === "auth_required";
|
||||
const requiresExplicitModel = connector.id === "opencode_local";
|
||||
const shouldLoadModelOptions = connector.capabilityMatrix.modelDiscovery || connector.id === "opencode_local";
|
||||
|
||||
useEffect(() => {
|
||||
setModel(connector.model ?? "");
|
||||
setEnvBindingText(connector.envBindingText);
|
||||
setNotes(connector.notes);
|
||||
setSubmitError(null);
|
||||
}, [connector]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (authPollTimeoutRef.current) {
|
||||
clearTimeout(authPollTimeoutRef.current);
|
||||
authPollTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!shouldLoadModelOptions) {
|
||||
setModelOptions([]);
|
||||
setModelOptionsError(null);
|
||||
setLoadingModelOptions(false);
|
||||
return;
|
||||
}
|
||||
setLoadingModelOptions(true);
|
||||
setModelOptionsError(null);
|
||||
void window.agentCompany.listConnectorModels(connector.id).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (!result.ok) {
|
||||
setModelOptions([]);
|
||||
setModelOptionsError(result.error.message);
|
||||
setLoadingModelOptions(false);
|
||||
return;
|
||||
}
|
||||
setModelOptions(result.data);
|
||||
setLoadingModelOptions(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connector.id, shouldLoadModelOptions]);
|
||||
|
||||
const modelSelectOptions = useMemo(() => {
|
||||
const options = [{ value: "", label: requiresExplicitModel ? "Please select a model..." : "Use connector default" }];
|
||||
const hasCurrentModel = modelOptions.some((option) => option.id === model);
|
||||
if (model && !hasCurrentModel) {
|
||||
options.push({ value: model, label: `${model} (saved)` });
|
||||
}
|
||||
options.push(...modelOptions.map((option) => ({ value: option.id, label: option.label || option.id })));
|
||||
return options;
|
||||
}, [model, modelOptions, requiresExplicitModel]);
|
||||
|
||||
const runHealthCheck = useCallback(async (quiet = false) => {
|
||||
setAutoChecking(true);
|
||||
try {
|
||||
unwrap(await window.agentCompany.testConnector(connector.id));
|
||||
await onSaved();
|
||||
if (!quiet) {
|
||||
setSubmitError(null);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!quiet) {
|
||||
setSubmitError(error instanceof Error ? error.message : "Automatic connector health check failed.");
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setAutoChecking(false);
|
||||
}
|
||||
}, [connector.id, onSaved]);
|
||||
|
||||
const scheduleAuthPolling = useCallback((remainingAttempts: number) => {
|
||||
if (authPollTimeoutRef.current) {
|
||||
clearTimeout(authPollTimeoutRef.current);
|
||||
authPollTimeoutRef.current = null;
|
||||
}
|
||||
if (remainingAttempts <= 0) {
|
||||
return;
|
||||
}
|
||||
authPollTimeoutRef.current = setTimeout(() => {
|
||||
void (async () => {
|
||||
const ok = await runHealthCheck(true);
|
||||
if (!ok) {
|
||||
scheduleAuthPolling(remainingAttempts - 1);
|
||||
}
|
||||
})();
|
||||
}, 5000);
|
||||
}, [runHealthCheck]);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{submitError ? <InlineNotice message={submitError} /> : null}
|
||||
{needsLogin ? (
|
||||
<div className="rounded-[10px] border border-[color:var(--warn)] bg-[color:var(--warn-soft)] px-4 py-4 text-[12px] text-[color:var(--warn)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em]">{authActionCopy.title}</div>
|
||||
<div className="mt-1.5 leading-relaxed">{authActionCopy.message}</div>
|
||||
<div className="mt-3">
|
||||
<ActionButton
|
||||
label={authActionCopy.buttonLabel}
|
||||
icon={SignIn}
|
||||
onClick={() => void (async () => {
|
||||
setOpeningAuthTerminal(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
unwrap(await window.agentCompany.openConnectorAuthTerminal(connector.id));
|
||||
scheduleAuthPolling(12);
|
||||
} catch (error) {
|
||||
setSubmitError(error instanceof Error ? error.message : "Could not open a terminal for connector login.");
|
||||
} finally {
|
||||
setOpeningAuthTerminal(false);
|
||||
}
|
||||
})()}
|
||||
loading={openingAuthTerminal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1.5 text-[12px] font-medium text-[color:var(--muted-strong)]">Status</div>
|
||||
@@ -41,29 +196,72 @@ export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRe
|
||||
AgentCompany pins connector execution to the audited binary for this runtime. Command overrides are not accepted from the renderer.
|
||||
</div>
|
||||
</div>
|
||||
<Input label="Model" value={model} onChange={setModel} placeholder="Leave empty to use connector default" />
|
||||
{shouldLoadModelOptions ? (
|
||||
<div className="grid gap-2">
|
||||
<Select
|
||||
label={requiresExplicitModel ? "Model (required)" : "Model"}
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
options={modelSelectOptions}
|
||||
/>
|
||||
{loadingModelOptions ? (
|
||||
<div className="text-[12px] text-[color:var(--muted)]">Loading available models...</div>
|
||||
) : null}
|
||||
{modelOptionsError ? (
|
||||
<InlineNotice message={modelOptionsError} />
|
||||
) : null}
|
||||
{requiresExplicitModel && !model.trim() ? (
|
||||
<div className="rounded-[8px] border border-[color:var(--warn)] bg-[color:var(--warn-soft)] px-4 py-3 text-[12px] text-[color:var(--warn)]">
|
||||
OpenCode is detected, but it still needs an explicit provider/model selection before AgentCompany can mark it ready.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<Input label="Model" value={model} onChange={setModel} placeholder="Leave empty to use connector default" />
|
||||
)}
|
||||
<TextArea label="Secret bindings" value={envBindingText} onChange={setEnvBindingText} rows={4} placeholder={"OPENAI_API_KEY=my-vault-key\nANTHROPIC_API_KEY=my-anthropic-key"} />
|
||||
<TextArea label="Operational notes" value={notes} onChange={setNotes} rows={4} />
|
||||
<div className="grid gap-3 md:grid-cols-[auto_1fr]">
|
||||
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3 text-[12px] text-[color:var(--muted-strong)]">
|
||||
Connector health checks run automatically on app launch, after save, and after terminal login is opened.
|
||||
{autoChecking ? " Refreshing connector state..." : ""}
|
||||
</div>
|
||||
{connector.lastError ? (
|
||||
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3 text-[12px] leading-relaxed text-[color:var(--muted-strong)]">
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Latest health-check detail</div>
|
||||
<div>{connector.lastError}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex gap-3">
|
||||
<ActionButton
|
||||
label="Save connector"
|
||||
tone="accent"
|
||||
onClick={() => void (async () => {
|
||||
await window.agentCompany.saveConnector({
|
||||
id: connector.id,
|
||||
command: connector.command,
|
||||
model: model || null,
|
||||
envBindingText,
|
||||
notes,
|
||||
});
|
||||
await onSaved();
|
||||
setSaving(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
unwrap(await window.agentCompany.saveConnector({
|
||||
id: connector.id,
|
||||
command: connector.command,
|
||||
model: model || null,
|
||||
envBindingText,
|
||||
notes,
|
||||
}));
|
||||
await onSaved();
|
||||
await runHealthCheck(true);
|
||||
} catch (error) {
|
||||
setSubmitError(error instanceof Error ? error.message : "Connector could not be saved.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})()}
|
||||
icon={CheckCircle}
|
||||
loading={saving}
|
||||
disabled={requiresExplicitModel && !model.trim()}
|
||||
/>
|
||||
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-4 text-[12px] leading-relaxed text-[color:var(--muted)]">
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Configuration doc</div>
|
||||
<pre className="mono whitespace-pre-wrap">{connector.configurationDoc}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-4 text-[12px] leading-relaxed text-[color:var(--muted)]">
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Configuration doc</div>
|
||||
<pre className="mono whitespace-pre-wrap">{connector.configurationDoc}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -819,7 +819,7 @@ export function Dashboard(props: DashboardProps) {
|
||||
<button
|
||||
key={connector.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate("connectors")}
|
||||
onClick={() => onNavigate("connectors", connector.id)}
|
||||
className={`focus-ring flex items-center gap-2.5 rounded-[8px] border px-3 py-2.5 text-left transition duration-150 hover:bg-[color:var(--panel-soft)] ${
|
||||
isError ? "border-[color:var(--danger)] border-opacity-30" : "border-[color:var(--line)]"
|
||||
}`}
|
||||
@@ -870,7 +870,7 @@ export function Dashboard(props: DashboardProps) {
|
||||
<button
|
||||
key={run.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate("tasks")}
|
||||
onClick={() => onNavigate("runs", run.id)}
|
||||
className="focus-ring flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--danger)] border-opacity-20 bg-[color:var(--danger-soft)] px-4 py-3 text-left transition hover:border-opacity-40"
|
||||
>
|
||||
<XCircle size={18} className="shrink-0 text-[color:var(--danger)]" />
|
||||
@@ -890,7 +890,7 @@ export function Dashboard(props: DashboardProps) {
|
||||
<button
|
||||
key={message.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate("communication")}
|
||||
onClick={() => onNavigate("communication", message.id)}
|
||||
className="focus-ring flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--warn)] border-opacity-20 bg-[color:var(--warn-soft)] px-4 py-3 text-left transition hover:border-opacity-40"
|
||||
>
|
||||
<Users size={18} className="shrink-0 text-[color:var(--warn)]" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState, useMemo } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import {
|
||||
ArrowClockwise,
|
||||
ArrowSquareOut,
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
ActivityRecord,
|
||||
AgentRecord,
|
||||
ApprovalRecord,
|
||||
CommentRecord,
|
||||
DocumentRecord,
|
||||
GoalRecord,
|
||||
ProfileSnapshot,
|
||||
@@ -46,6 +47,21 @@ function describeActionError(error: unknown) {
|
||||
return error instanceof Error ? error.message : "The action could not be completed.";
|
||||
}
|
||||
|
||||
function parseApprovalPayloadJson(approval: ApprovalRecord): Record<string, unknown> | null {
|
||||
if (!approval.payloadJson) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(approval.payloadJson);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const ENTITY_ICONS: Record<string, typeof Robot> = {
|
||||
agent: Robot,
|
||||
@@ -151,6 +167,9 @@ export function TaskInspector({
|
||||
onRun,
|
||||
onCancelRun,
|
||||
onCreateApproval,
|
||||
onOpenDiscussion,
|
||||
comments,
|
||||
onRefresh,
|
||||
onDeleted,
|
||||
}: {
|
||||
task: TaskRecord;
|
||||
@@ -164,6 +183,9 @@ export function TaskInspector({
|
||||
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 [editing, setEditing] = useState(false);
|
||||
@@ -226,6 +248,7 @@ export function TaskInspector({
|
||||
projectId: editProject || null,
|
||||
goalId: editGoal || null,
|
||||
}));
|
||||
await onRefresh();
|
||||
setEditing(false);
|
||||
setActionNotice("Task changes saved.");
|
||||
} catch (error) {
|
||||
@@ -252,6 +275,7 @@ export function TaskInspector({
|
||||
projectId: task.projectId,
|
||||
goalId: task.goalId,
|
||||
}));
|
||||
await onRefresh();
|
||||
setActionNotice(`Task moved to ${status.replaceAll("_", " ")}.`);
|
||||
} catch (error) {
|
||||
setActionError(describeActionError(error));
|
||||
@@ -400,6 +424,7 @@ export function TaskInspector({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ActionButton label="Discussion" onClick={() => onOpenDiscussion(task.id)} icon={ArrowRight} />
|
||||
<ActionButton label="Edit" onClick={startEdit} icon={Pencil} />
|
||||
<ActionButton label="Delete" onClick={() => setConfirmDelete(true)} icon={Trash} tone="danger" />
|
||||
</div>
|
||||
@@ -454,7 +479,13 @@ export function TaskInspector({
|
||||
})()}
|
||||
|
||||
{/* Communication */}
|
||||
<TaskComments taskId={task.id} companyId={task.companyId} agents={agents} />
|
||||
<TaskComments
|
||||
taskId={task.id}
|
||||
companyId={task.companyId}
|
||||
agents={agents}
|
||||
comments={comments}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
|
||||
{/* Run history — only if there are runs */}
|
||||
{runs.length > 0 && (
|
||||
@@ -509,35 +540,32 @@ function TaskComments({
|
||||
taskId,
|
||||
companyId,
|
||||
agents,
|
||||
comments,
|
||||
onRefresh,
|
||||
}: {
|
||||
taskId: string;
|
||||
companyId: string;
|
||||
agents: ProfileSnapshot["agents"];
|
||||
comments: CommentRecord[];
|
||||
onRefresh: () => Promise<void>;
|
||||
}) {
|
||||
const [comments, setComments] = useState<Array<{ id: string; authorAgentId: string | null; authorName: string; body: string; createdAt: string }>>([]);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const loadComments = useCallback(async () => {
|
||||
const result = await window.agentCompany.listComments({ taskId });
|
||||
if (result.ok) setComments(result.data);
|
||||
setLoading(false);
|
||||
}, [taskId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadComments();
|
||||
const unsubscribe = window.agentCompany.subscribe((event) => {
|
||||
if (event.type === "agent-message" && event.taskId === taskId) {
|
||||
setComments((prev) => [...prev, event.comment]);
|
||||
void onRefresh();
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [taskId, loadComments]);
|
||||
}, [onRefresh, taskId]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!newComment.trim()) return;
|
||||
setSubmitError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
unwrap(await window.agentCompany.addComment({
|
||||
companyId,
|
||||
@@ -546,9 +574,11 @@ function TaskComments({
|
||||
body: newComment.trim(),
|
||||
}));
|
||||
setNewComment("");
|
||||
await loadComments();
|
||||
await onRefresh();
|
||||
} catch (error) {
|
||||
setSubmitError(describeActionError(error));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -559,9 +589,7 @@ function TaskComments({
|
||||
<span className="text-[13px] font-semibold text-[color:var(--text)]">Communication</span>
|
||||
<span className="text-[11px] text-[color:var(--muted)]">({comments.length})</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="text-[12px] text-[color:var(--muted)]">Loading...</div>
|
||||
) : comments.length === 0 ? (
|
||||
{comments.length === 0 ? (
|
||||
<div className="text-[12px] text-[color:var(--muted)]">No messages yet. Agents will post updates here during runs.</div>
|
||||
) : (
|
||||
<div className="max-h-[240px] space-y-2 overflow-y-auto rounded-lg border border-[color:var(--line)] p-3">
|
||||
@@ -592,7 +620,7 @@ function TaskComments({
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void handleSubmit(); } }}
|
||||
/>
|
||||
<ActionButton label="Send" tone="accent" onClick={() => void handleSubmit()} icon={ArrowRight} />
|
||||
<ActionButton label="Send" tone="accent" onClick={() => void handleSubmit()} icon={ArrowRight} loading={submitting} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -843,26 +871,36 @@ export function ApprovalInspector({
|
||||
|
||||
{/* Hire agent payload details */}
|
||||
{approval.type === "hire_agent" ? (() => {
|
||||
const proposedAgent = agents.find(
|
||||
(a) => a.status === "pending_approval" && a.reportsTo === approval.requestedByAgentId
|
||||
) ?? agents.find(
|
||||
(a) => a.name.toLowerCase().includes("pending") || a.status === "pending_approval"
|
||||
);
|
||||
if (!proposedAgent) return null;
|
||||
const payload = parseApprovalPayloadJson(approval);
|
||||
const payloadAgentId = typeof payload?.agentId === "string" ? payload.agentId : null;
|
||||
const requestedSnapshot =
|
||||
payload?.requestedConfigurationSnapshot &&
|
||||
typeof payload.requestedConfigurationSnapshot === "object" &&
|
||||
!Array.isArray(payload.requestedConfigurationSnapshot)
|
||||
? payload.requestedConfigurationSnapshot as Record<string, unknown>
|
||||
: null;
|
||||
const proposedAgent =
|
||||
(approval.relatedAgentId ? (agents.find((agent) => agent.id === approval.relatedAgentId) ?? null) : null) ??
|
||||
(payloadAgentId ? (agents.find((agent) => agent.id === payloadAgentId) ?? null) : null);
|
||||
const proposedManagerId =
|
||||
proposedAgent?.reportsTo ??
|
||||
(typeof requestedSnapshot?.reportsTo === "string" ? requestedSnapshot.reportsTo : null);
|
||||
const proposedManager = proposedManagerId
|
||||
? (agents.find((agent) => agent.id === proposedManagerId) ?? null)
|
||||
: null;
|
||||
|
||||
if (!proposedAgent && !requestedSnapshot) return null;
|
||||
return (
|
||||
<div className="rounded-[8px] border border-[color:var(--accent)] border-opacity-30 bg-[color:var(--accent-soft)] px-4 py-3">
|
||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--accent)]">Proposed Hire</div>
|
||||
<div className="grid gap-1.5 text-[12px]">
|
||||
<div><span className="text-[color:var(--muted)]">Name:</span> <span className="font-medium text-[color:var(--text)]">{proposedAgent.name}</span></div>
|
||||
<div><span className="text-[color:var(--muted)]">Role:</span> <span className="text-[color:var(--text)]">{proposedAgent.role}</span></div>
|
||||
{proposedAgent.title ? <div><span className="text-[color:var(--muted)]">Title:</span> <span className="text-[color:var(--text)]">{proposedAgent.title}</span></div> : null}
|
||||
{proposedAgent.capabilities ? <div><span className="text-[color:var(--muted)]">Capabilities:</span> <span className="text-[color:var(--text)]">{proposedAgent.capabilities}</span></div> : null}
|
||||
<div><span className="text-[color:var(--muted)]">Connector:</span> <span className="mono text-[color:var(--text)]">{proposedAgent.connectorId}</span></div>
|
||||
<div><span className="text-[color:var(--muted)]">Budget:</span> <span className="mono text-[color:var(--text)]">{formatMoney(proposedAgent.budgetMonthlyUsd)}/mo</span></div>
|
||||
{proposedAgent.reportsTo ? (() => {
|
||||
const manager = agents.find((a) => a.id === proposedAgent.reportsTo);
|
||||
return manager ? <div><span className="text-[color:var(--muted)]">Reports to:</span> <span className="text-[color:var(--text)]">{manager.name}</span></div> : null;
|
||||
})() : null}
|
||||
<div><span className="text-[color:var(--muted)]">Name:</span> <span className="font-medium text-[color:var(--text)]">{proposedAgent?.name ?? approval.payloadSummary.replace(/^Hire\s+/i, "").split(/\s+as\s+/i)[0] ?? "Pending hire"}</span></div>
|
||||
<div><span className="text-[color:var(--muted)]">Role:</span> <span className="text-[color:var(--text)]">{proposedAgent?.role ?? (typeof requestedSnapshot?.role === "string" ? requestedSnapshot.role : "Unspecified")}</span></div>
|
||||
{(proposedAgent?.title || typeof requestedSnapshot?.title === "string") ? <div><span className="text-[color:var(--muted)]">Title:</span> <span className="text-[color:var(--text)]">{proposedAgent?.title || requestedSnapshot?.title as string}</span></div> : null}
|
||||
{(proposedAgent?.capabilities || typeof requestedSnapshot?.capabilities === "string") ? <div><span className="text-[color:var(--muted)]">Capabilities:</span> <span className="text-[color:var(--text)]">{proposedAgent?.capabilities || requestedSnapshot?.capabilities as string}</span></div> : null}
|
||||
<div><span className="text-[color:var(--muted)]">Connector:</span> <span className="mono text-[color:var(--text)]">{proposedAgent?.connectorId ?? (typeof requestedSnapshot?.connectorId === "string" ? requestedSnapshot.connectorId : "Unknown")}</span></div>
|
||||
<div><span className="text-[color:var(--muted)]">Budget:</span> <span className="mono text-[color:var(--text)]">{formatMoney(proposedAgent?.budgetMonthlyUsd ?? (typeof requestedSnapshot?.budgetMonthlyUsd === "number" ? requestedSnapshot.budgetMonthlyUsd : 0))}/mo</span></div>
|
||||
{proposedManager ? <div><span className="text-[color:var(--muted)]">Reports to:</span> <span className="text-[color:var(--text)]">{proposedManager.name}</span></div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1236,7 +1274,7 @@ export function WorkspaceInspector({
|
||||
{workspace.repoRef ? <span> · Ref: <span className="text-[color:var(--text)]">{workspace.repoRef}</span></span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton label="Reveal in Finder" onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} />
|
||||
<ActionButton label="Open folder" onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1542,6 +1580,7 @@ export function SettingsPane({
|
||||
type="checkbox"
|
||||
checked={company.autoApproveHires}
|
||||
onChange={async (e) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
unwrap(await window.agentCompany.saveCompany({
|
||||
id: company.id,
|
||||
@@ -1551,14 +1590,16 @@ export function SettingsPane({
|
||||
autoApproveHires: e.target.checked,
|
||||
}));
|
||||
await onSaved();
|
||||
} catch { /* ignore */ }
|
||||
} catch (error) {
|
||||
setActionError(describeActionError(error));
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 rounded accent-[color:var(--accent)]"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-[13px] font-medium text-[color:var(--text)]">Full Autonomy Mode</div>
|
||||
<div className="text-[13px] font-medium text-[color:var(--text)]">Auto-approve hire and strategy approvals</div>
|
||||
<div className="mt-0.5 text-[11px] text-[color:var(--muted)]">
|
||||
Auto-approve agent hires and CEO strategies. Agents will build the team and start operations without Board approval.
|
||||
Automatically approve agent hiring requests and CEO strategy approvals so the company can continue operating without waiting on the board for those specific approval types.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -61,7 +61,7 @@ export function Inbox({ items, onNavigate }: InboxProps) {
|
||||
const handleItemClick = (item: InboxItem) => {
|
||||
const sectionMap: Record<string, SectionId> = {
|
||||
approval: "approvals",
|
||||
run: "tasks",
|
||||
run: "runs",
|
||||
agent: "agents",
|
||||
task: "tasks",
|
||||
message: "communication",
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
ArrowClockwise,
|
||||
FolderOpen,
|
||||
Lightning,
|
||||
ListChecks,
|
||||
PlayCircle,
|
||||
WarningCircle,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { AgentRecord, RunRecord, TaskRecord, WorkspaceRecord } from "@shared/types";
|
||||
import { formatMoney, formatTime, timeAgo } from "../lib/formatters";
|
||||
import { ActionButton, EmptyState, Input, ListFrame, ListRow, Panel, Select, StatusPill } from "./ui";
|
||||
|
||||
const LOG_CHUNK_BYTES = 65_536;
|
||||
|
||||
interface RunCenterProps {
|
||||
runs: RunRecord[];
|
||||
tasks: TaskRecord[];
|
||||
agents: AgentRecord[];
|
||||
workspaces: WorkspaceRecord[];
|
||||
selectedRunId: string | null;
|
||||
onSelectRun: (runId: string) => void;
|
||||
onCancelRun: (runId: string) => Promise<void> | void;
|
||||
onOpenLogPath: (path: string) => Promise<void> | void;
|
||||
onOpenConsole: (runId: string) => void;
|
||||
}
|
||||
|
||||
interface RunLogState {
|
||||
content: string;
|
||||
offset: number;
|
||||
nextOffset: number;
|
||||
totalBytes: number;
|
||||
eof: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialLogState: RunLogState = {
|
||||
content: "",
|
||||
offset: 0,
|
||||
nextOffset: 0,
|
||||
totalBytes: 0,
|
||||
eof: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function isActiveStatus(status: RunRecord["status"]) {
|
||||
return status === "queued" || status === "running";
|
||||
}
|
||||
|
||||
function isAttentionStatus(status: RunRecord["status"]) {
|
||||
return status === "failed" || status === "timed_out" || status === "interrupted";
|
||||
}
|
||||
|
||||
export function RunCenter({
|
||||
runs,
|
||||
tasks,
|
||||
agents,
|
||||
workspaces,
|
||||
selectedRunId,
|
||||
onSelectRun,
|
||||
onCancelRun,
|
||||
onOpenLogPath,
|
||||
onOpenConsole,
|
||||
}: RunCenterProps) {
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | RunRecord["status"]>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [logState, setLogState] = useState<RunLogState>(initialLogState);
|
||||
const [logReloadKey, setLogReloadKey] = useState(0);
|
||||
|
||||
const agentMap = useMemo(() => new Map(agents.map((agent) => [agent.id, agent])), [agents]);
|
||||
const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
|
||||
const workspaceMap = useMemo(() => new Map(workspaces.map((workspace) => [workspace.id, workspace])), [workspaces]);
|
||||
|
||||
const selectedRun = useMemo(
|
||||
() => runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null,
|
||||
[runs, selectedRunId],
|
||||
);
|
||||
|
||||
const filteredRuns = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return runs.filter((run) => {
|
||||
if (statusFilter !== "all" && run.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
const task = taskMap.get(run.taskId);
|
||||
const agent = agentMap.get(run.agentId);
|
||||
const workspace = run.workspaceId ? workspaceMap.get(run.workspaceId) : null;
|
||||
return [
|
||||
task?.title ?? "",
|
||||
agent?.name ?? "",
|
||||
workspace?.name ?? "",
|
||||
run.summary ?? "",
|
||||
run.errorMessage ?? "",
|
||||
run.connectorId,
|
||||
run.id,
|
||||
].some((value) => value.toLowerCase().includes(query));
|
||||
});
|
||||
}, [agentMap, runs, search, statusFilter, taskMap, workspaceMap]);
|
||||
|
||||
const groupedCounts = useMemo(() => {
|
||||
let active = 0;
|
||||
let attention = 0;
|
||||
let completed = 0;
|
||||
|
||||
for (const run of runs) {
|
||||
if (isActiveStatus(run.status)) {
|
||||
active += 1;
|
||||
} else if (isAttentionStatus(run.status)) {
|
||||
attention += 1;
|
||||
} else {
|
||||
completed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { active, attention, completed };
|
||||
}, [runs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRunId && !runs.some((run) => run.id === selectedRunId)) {
|
||||
const fallbackRun = runs[0];
|
||||
if (fallbackRun) {
|
||||
onSelectRun(fallbackRun.id);
|
||||
}
|
||||
}
|
||||
}, [onSelectRun, runs, selectedRunId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadInitialChunk() {
|
||||
if (!selectedRun) {
|
||||
setLogState(initialLogState);
|
||||
return;
|
||||
}
|
||||
|
||||
setLogState((current) => ({ ...current, loading: true, error: null }));
|
||||
|
||||
const metaResult = await window.agentCompany.getRunLogChunk({
|
||||
companyId: selectedRun.companyId,
|
||||
runId: selectedRun.id,
|
||||
offset: 0,
|
||||
limit: 1024,
|
||||
});
|
||||
|
||||
if (!metaResult.ok) {
|
||||
if (!cancelled) {
|
||||
setLogState((current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
error: metaResult.error.message,
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const totalBytes = metaResult.data.totalBytes;
|
||||
const startOffset = Math.max(0, totalBytes - LOG_CHUNK_BYTES);
|
||||
const chunkResult =
|
||||
startOffset === 0 && totalBytes <= LOG_CHUNK_BYTES
|
||||
? metaResult
|
||||
: await window.agentCompany.getRunLogChunk({
|
||||
companyId: selectedRun.companyId,
|
||||
runId: selectedRun.id,
|
||||
offset: startOffset,
|
||||
limit: LOG_CHUNK_BYTES,
|
||||
});
|
||||
|
||||
if (!chunkResult.ok) {
|
||||
if (!cancelled) {
|
||||
setLogState((current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
error: chunkResult.error.message,
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setLogState({
|
||||
...chunkResult.data,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void loadInitialChunk();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [logReloadKey, selectedRun]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRun || !isActiveStatus(selectedRun.status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const intervalId = window.setInterval(async () => {
|
||||
const latest = await window.agentCompany.getRunLogChunk({
|
||||
companyId: selectedRun.companyId,
|
||||
runId: selectedRun.id,
|
||||
offset: Math.max(0, logState.totalBytes - LOG_CHUNK_BYTES),
|
||||
limit: LOG_CHUNK_BYTES,
|
||||
});
|
||||
|
||||
if (!latest.ok || cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLogState({
|
||||
...latest.data,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
}, 2500);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [logState.totalBytes, selectedRun]);
|
||||
|
||||
async function loadOlderLogs() {
|
||||
if (!selectedRun || logState.offset <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextOffset = Math.max(0, logState.offset - LOG_CHUNK_BYTES);
|
||||
setLogState((current) => ({ ...current, loading: true, error: null }));
|
||||
|
||||
const result = await window.agentCompany.getRunLogChunk({
|
||||
companyId: selectedRun.companyId,
|
||||
runId: selectedRun.id,
|
||||
offset: nextOffset,
|
||||
limit: logState.offset - nextOffset,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
setLogState((current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
error: result.error.message,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setLogState({
|
||||
...result.data,
|
||||
content: `${result.data.content}${logState.content}`,
|
||||
nextOffset: logState.nextOffset,
|
||||
eof: logState.eof,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (runs.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No runs yet"
|
||||
detail="Start a task run from the task queue or onboarding handoff. This surface becomes the operator console for queue state, failures, and live log tails."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<RunSummaryCard
|
||||
label="Active"
|
||||
value={groupedCounts.active}
|
||||
detail="Queued or running right now"
|
||||
icon={PlayCircle}
|
||||
/>
|
||||
<RunSummaryCard
|
||||
label="Needs attention"
|
||||
value={groupedCounts.attention}
|
||||
detail="Failed, timed out, or interrupted"
|
||||
icon={WarningCircle}
|
||||
tone="danger"
|
||||
/>
|
||||
<RunSummaryCard
|
||||
label="Completed"
|
||||
value={groupedCounts.completed}
|
||||
detail="Finished without current intervention"
|
||||
icon={ListChecks}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)]">
|
||||
<div className="space-y-5">
|
||||
<Panel
|
||||
title="Run queue"
|
||||
action={(
|
||||
<Select
|
||||
compact
|
||||
value={statusFilter}
|
||||
onChange={(value) => setStatusFilter(value as "all" | RunRecord["status"])}
|
||||
options={[
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "queued", label: "Queued" },
|
||||
{ value: "running", label: "Running" },
|
||||
{ value: "succeeded", label: "Succeeded" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
{ value: "timed_out", label: "Timed out" },
|
||||
{ value: "cancelled", label: "Cancelled" },
|
||||
{ value: "interrupted", label: "Interrupted" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
ariaLabel="Search runs"
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by task, agent, connector, workspace, or error"
|
||||
/>
|
||||
</div>
|
||||
<ListFrame>
|
||||
{filteredRuns.map((run) => {
|
||||
const task = taskMap.get(run.taskId);
|
||||
const agent = agentMap.get(run.agentId);
|
||||
const workspace = run.workspaceId ? workspaceMap.get(run.workspaceId) : null;
|
||||
const title = task?.title ?? (run.summary || `Run ${run.id.slice(0, 8)}`);
|
||||
const supportingText = run.errorMessage || run.summary || "No summary yet";
|
||||
return (
|
||||
<ListRow
|
||||
key={run.id}
|
||||
onClick={() => onSelectRun(run.id)}
|
||||
className={selectedRun?.id === run.id ? "bg-[color:var(--accent-soft)]" : "hover:bg-[color:var(--panel-soft)]"}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13px] font-semibold text-[color:var(--text)]">{title}</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-[color:var(--muted)]">
|
||||
{agent?.name ?? "Unknown agent"} via {run.connectorId} {workspace ? `· ${workspace.name}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill status={run.status} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 text-[11px] text-[color:var(--muted)]">
|
||||
<span className="truncate">{supportingText}</span>
|
||||
<span className="shrink-0">{timeAgo(run.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ListRow>
|
||||
);
|
||||
})}
|
||||
</ListFrame>
|
||||
{filteredRuns.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No runs match this filter"
|
||||
detail="Widen the status filter or clear the search query to inspect the full run history."
|
||||
/>
|
||||
) : null}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{selectedRun ? (
|
||||
<>
|
||||
<Panel
|
||||
title="Run detail"
|
||||
action={(
|
||||
<div className="flex items-center gap-2">
|
||||
<ActionButton
|
||||
label="Open console"
|
||||
icon={Lightning}
|
||||
onClick={() => onOpenConsole(selectedRun.id)}
|
||||
/>
|
||||
{isActiveStatus(selectedRun.status) ? (
|
||||
<ActionButton
|
||||
label="Cancel run"
|
||||
tone="danger"
|
||||
icon={WarningCircle}
|
||||
onClick={() => void onCancelRun(selectedRun.id)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<RunMeta label="Status" value={<StatusPill status={selectedRun.status} />} />
|
||||
<RunMeta label="Connector" value={selectedRun.connectorId} />
|
||||
<RunMeta label="Task" value={taskMap.get(selectedRun.taskId)?.title ?? selectedRun.taskId} />
|
||||
<RunMeta label="Agent" value={agentMap.get(selectedRun.agentId)?.name ?? selectedRun.agentId} />
|
||||
<RunMeta label="Workspace" value={selectedRun.workspaceId ? (workspaceMap.get(selectedRun.workspaceId)?.name ?? selectedRun.workspaceId) : "No exclusive workspace lock"} />
|
||||
<RunMeta label="Model" value={selectedRun.model ?? "Connector default"} />
|
||||
<RunMeta label="Started" value={formatTime(selectedRun.startedAt)} />
|
||||
<RunMeta label="Finished" value={formatTime(selectedRun.finishedAt)} />
|
||||
<RunMeta label="Cost" value={formatMoney(selectedRun.costUsd)} />
|
||||
<RunMeta label="Log bytes" value={logState.totalBytes.toLocaleString()} />
|
||||
</div>
|
||||
{selectedRun.errorMessage ? (
|
||||
<div className="mt-5 rounded-[10px] border border-[color:var(--danger)] bg-[color:var(--danger-soft)] px-4 py-3 text-[13px] text-[color:var(--danger)]">
|
||||
{selectedRun.errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedRun.logPath ? (
|
||||
<div className="mt-5">
|
||||
<ActionButton
|
||||
label="Open log file"
|
||||
icon={FolderOpen}
|
||||
onClick={() => void onOpenLogPath(selectedRun.logPath!)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
title="Log tail"
|
||||
eyebrow="Incremental reader"
|
||||
action={(
|
||||
<div className="flex items-center gap-2">
|
||||
<ActionButton
|
||||
label="Reload tail"
|
||||
icon={ArrowClockwise}
|
||||
onClick={() => setLogReloadKey((current) => current + 1)}
|
||||
/>
|
||||
{logState.offset > 0 ? (
|
||||
<ActionButton
|
||||
label="Load older"
|
||||
icon={ListChecks}
|
||||
onClick={() => void loadOlderLogs()}
|
||||
disabled={logState.loading}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{logState.error ? (
|
||||
<div className="rounded-[10px] border border-[color:var(--danger)] bg-[color:var(--danger-soft)] px-4 py-3 text-[13px] text-[color:var(--danger)]">
|
||||
{logState.error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mb-3 flex items-center justify-between text-[11px] text-[color:var(--muted)]">
|
||||
<span>
|
||||
Showing bytes {logState.offset.toLocaleString()} to {logState.nextOffset.toLocaleString()} of {logState.totalBytes.toLocaleString()}
|
||||
</span>
|
||||
<span>{logState.loading ? "Refreshing…" : logState.eof ? "At latest tail" : "Tail window loaded"}</span>
|
||||
</div>
|
||||
<div className="max-h-[420px] overflow-auto rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
|
||||
<pre className="mono whitespace-pre-wrap text-[12px] leading-relaxed text-[color:var(--muted-strong)]">
|
||||
{logState.content || "No log output captured yet."}
|
||||
</pre>
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunSummaryCard({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
icon: Icon,
|
||||
tone = "default",
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
detail: string;
|
||||
icon: React.ElementType;
|
||||
tone?: "default" | "danger";
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)]">{label}</div>
|
||||
<div className={`mt-1 text-[28px] font-semibold tracking-[-0.03em] ${tone === "danger" ? "text-[color:var(--danger)]" : "text-[color:var(--text)]"}`}>
|
||||
{value}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-[color:var(--muted)]">{detail}</div>
|
||||
</div>
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-[10px] ${tone === "danger" ? "bg-[color:var(--danger-soft)] text-[color:var(--danger)]" : "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)]"}`}>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunMeta({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-3.5 py-3">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{label}</div>
|
||||
<div className="mt-1 text-[13px] text-[color:var(--text)]">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { type ElementType, useCallback, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Browser,
|
||||
@@ -18,22 +18,30 @@ import {
|
||||
SignIn,
|
||||
Trash,
|
||||
UserPlus,
|
||||
FacebookLogo,
|
||||
GlobeHemisphereWest,
|
||||
InstagramLogo,
|
||||
LinkedinLogo,
|
||||
RedditLogo,
|
||||
TiktokLogo,
|
||||
XLogo,
|
||||
X,
|
||||
YoutubeLogo,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { BrowserActionRecord, SocialAccountRecord, SocialPlatform } from "@shared/types";
|
||||
import { timeAgo } from "../lib/formatters";
|
||||
import { ActionButton, Input } from "./ui";
|
||||
|
||||
const platformConfig: Record<SocialPlatform, { label: string; color: string; bgColor: string; icon: string }> = {
|
||||
twitter: { label: "Twitter / X", color: "#1DA1F2", bgColor: "#1DA1F210", icon: "𝕏" },
|
||||
linkedin: { label: "LinkedIn", color: "#0A66C2", bgColor: "#0A66C210", icon: "in" },
|
||||
instagram: { label: "Instagram", color: "#E4405F", bgColor: "#E4405F10", icon: "IG" },
|
||||
facebook: { label: "Facebook", color: "#1877F2", bgColor: "#1877F210", icon: "fb" },
|
||||
reddit: { label: "Reddit", color: "#FF4500", bgColor: "#FF450010", icon: "r/" },
|
||||
youtube: { label: "YouTube", color: "#FF0000", bgColor: "#FF000010", icon: "YT" },
|
||||
tiktok: { label: "TikTok", color: "#000000", bgColor: "#00000010", icon: "TT" },
|
||||
bluesky: { label: "Bluesky", color: "#0085FF", bgColor: "#0085FF10", icon: "🦋" },
|
||||
other: { label: "Other", color: "#6B7280", bgColor: "#6B728010", icon: "🌐" },
|
||||
const platformConfig: Record<SocialPlatform, { label: string; color: string; bgColor: string; icon: ElementType }> = {
|
||||
twitter: { label: "Twitter / X", color: "#1D9BF0", bgColor: "#1D9BF014", icon: XLogo },
|
||||
linkedin: { label: "LinkedIn", color: "#0A66C2", bgColor: "#0A66C214", icon: LinkedinLogo },
|
||||
instagram: { label: "Instagram", color: "#E4405F", bgColor: "#E4405F14", icon: InstagramLogo },
|
||||
facebook: { label: "Facebook", color: "#1877F2", bgColor: "#1877F214", icon: FacebookLogo },
|
||||
reddit: { label: "Reddit", color: "#FF4500", bgColor: "#FF450014", icon: RedditLogo },
|
||||
youtube: { label: "YouTube", color: "#FF0000", bgColor: "#FF000014", icon: YoutubeLogo },
|
||||
tiktok: { label: "TikTok", color: "#111315", bgColor: "#11131514", icon: TiktokLogo },
|
||||
bluesky: { label: "Bluesky", color: "#0085FF", bgColor: "#0085FF14", icon: GlobeHemisphereWest },
|
||||
other: { label: "Other", color: "#6B7280", bgColor: "#6B728014", icon: GlobeHemisphereWest },
|
||||
};
|
||||
|
||||
const actionTypeLabels: Record<string, { label: string; icon: React.ElementType }> = {
|
||||
@@ -58,41 +66,51 @@ function AccountCard({
|
||||
account: SocialAccountRecord;
|
||||
onLogin: () => void;
|
||||
onDelete: () => void;
|
||||
onClick: () => void;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const platform = platformConfig[account.platform] ?? platformConfig.other;
|
||||
const isLoggedIn = account.status === "logged_in" || account.status === "active";
|
||||
const PlatformIcon = platform.icon;
|
||||
const cardContent = (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-[color:var(--text)]">{account.displayName || account.accountName}</span>
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]"
|
||||
style={{ backgroundColor: platform.bgColor, color: platform.color }}
|
||||
>
|
||||
{platform.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-[color:var(--muted)]">
|
||||
<span>@{account.accountName}</span>
|
||||
{account.requireApproval ? (
|
||||
<span className="flex items-center gap-1 text-[color:var(--warn)]">
|
||||
<ShieldCheck size={10} />
|
||||
Approval required
|
||||
</span>
|
||||
) : null}
|
||||
{account.lastUsedAt ? <span>Last used {timeAgo(account.lastUsedAt)}</span> : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-3 rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3 transition hover:border-[color:var(--line-strong)] hover:shadow-sm">
|
||||
<div
|
||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[10px] text-[14px] font-bold"
|
||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[10px]"
|
||||
style={{ backgroundColor: platform.bgColor, color: platform.color }}
|
||||
>
|
||||
{platform.icon}
|
||||
<PlatformIcon size={18} weight="fill" />
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={onClick} className="min-w-0 flex-1 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-[color:var(--text)]">{account.displayName || account.accountName}</span>
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]"
|
||||
style={{ backgroundColor: platform.bgColor, color: platform.color }}
|
||||
>
|
||||
{platform.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-[color:var(--muted)]">
|
||||
<span>@{account.accountName}</span>
|
||||
{account.requireApproval ? (
|
||||
<span className="flex items-center gap-1 text-[color:var(--warn)]">
|
||||
<ShieldCheck size={10} />
|
||||
Approval required
|
||||
</span>
|
||||
) : null}
|
||||
{account.lastUsedAt ? <span>Last used {timeAgo(account.lastUsedAt)}</span> : null}
|
||||
</div>
|
||||
</button>
|
||||
{onClick ? (
|
||||
<button type="button" onClick={onClick} className="min-w-0 flex-1 text-left">
|
||||
{cardContent}
|
||||
</button>
|
||||
) : (
|
||||
<div className="min-w-0 flex-1 text-left">{cardContent}</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-semibold ${
|
||||
@@ -185,7 +203,9 @@ function AddAccountDialog({
|
||||
: "border-[color:var(--line)] text-[color:var(--muted)] hover:border-[color:var(--line-strong)]"
|
||||
}`}
|
||||
>
|
||||
<span style={{ color: config.color }}>{config.icon}</span>
|
||||
<span style={{ color: config.color }}>
|
||||
<config.icon size={14} />
|
||||
</span>
|
||||
{config.label}
|
||||
</button>
|
||||
))}
|
||||
@@ -492,7 +512,6 @@ export function SocialAccountsPanel({
|
||||
account={account}
|
||||
onLogin={() => void onLogin(account.id)}
|
||||
onDelete={() => void onDelete(account.id)}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -322,8 +322,8 @@ export function ActionButton({
|
||||
export function StatusPill({ status }: { status: string }) {
|
||||
const positive = ["ready", "active", "running", "succeeded", "approved", "achieved", "done"].includes(status);
|
||||
const negative = ["error", "failed", "cancelled", "blocked", "rejected", "interrupted"].includes(status);
|
||||
const warning = ["auth_required", "degraded", "pending", "in_review", "todo", "queued"].includes(status);
|
||||
const checking = status === "detected" || status === "not_installed";
|
||||
const warning = ["auth_required", "degraded", "pending", "in_review", "todo", "queued", "not_installed"].includes(status);
|
||||
const checking = status === "detected";
|
||||
const toneClass = positive
|
||||
? "text-[color:var(--success)]"
|
||||
: negative
|
||||
|
||||
Reference in new issue
Block a user