This commit is contained in:
ZacharyZhang-NY committed 2026-04-21 12:44:33 -04:00
1 parent 740d0812ee
commit 97033dfabd
68 files changed
+2608 -328

No files matched your search

+38 -7
View File
@@ -2,7 +2,17 @@ import type { ReactNode } from "react";
import { Eyebrow } from "./ui";
/**
* Two-pane layout with a section header.
* Two-pane layout with an optional section header.
* - `split="vertical"` (default): stacks primary over secondary with rhythmic whitespace.
* - `split="horizontal"`: primary (register) on the left, secondary (focus) on the right.
* Use horizontal when the two panes share a single focus — selecting a row on the
* left updates the inspector on the right, and the page should present one idea.
*
* `action` docks a single control (e.g. a primary "+ Add" button) to the right of the
* header row. When a title/eyebrow is present the action sits on the header's baseline;
* when the header is absent, the action sits in its own right-aligned row above the body.
* Keep it to one element — this slot exists so the control has a home, not a stage.
*
* Use `Panel` (from ui/) for single-slot sections. Use this when rendering a primary + secondary pane.
*/
export function TwoPaneLayout({
@@ -10,19 +20,40 @@ export function TwoPaneLayout({
eyebrow,
primary,
secondary,
split = "vertical",
action,
}: {
title: string;
title?: string;
eyebrow?: string;
primary: ReactNode;
secondary: ReactNode;
split?: "vertical" | "horizontal";
action?: ReactNode;
}) {
const hasHeader = Boolean(title || eyebrow);
const body =
split === "horizontal" ? (
<div className="grid gap-8 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.4fr)] lg:items-start">
<div className="min-w-0">{primary}</div>
<div className="min-w-0">{secondary}</div>
</div>
) : (
<div className="space-y-14">{primary}{secondary}</div>
);
return (
<div className="space-y-14">
<div>
{eyebrow ? <Eyebrow className="mb-1">{eyebrow}</Eyebrow> : null}
<h2 className="text-[20px] font-bold tracking-[-0.025em] text-foreground">{title}</h2>
</div>
<div className="space-y-14">{primary}{secondary}</div>
{hasHeader ? (
<div className="flex items-end justify-between gap-4">
<div className="min-w-0">
{eyebrow ? <Eyebrow className="mb-1">{eyebrow}</Eyebrow> : null}
{title ? <h2 className="text-[15px] font-semibold tracking-[-0.01em] text-foreground">{title}</h2> : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
) : action ? (
<div className="flex justify-end">{action}</div>
) : null}
{body}
</div>
);
}
@@ -139,9 +139,6 @@ function RulesPanel({ rules, companyId, onRefresh, t }: { rules: AutomationRuleR
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center">
<Lightning size={36} className="mb-3 text-muted-foreground" />
<div className="text-[14px] font-medium text-muted-foreground">{t("auto.rules.noRules")}</div>
<div className="mx-auto mt-1 max-w-[42ch] text-[12px] leading-relaxed text-muted-foreground">
{t("auto.rules.noRulesDetail")}
</div>
<div className="mt-4">
<ActionButton label={t("auto.rules.createFirstRule")} onClick={() => setShowCreate(true)} icon={Lightning} tone="accent" />
</div>
@@ -221,9 +218,6 @@ function WorkflowsPanel({ workflows, companyId, onRefresh, t }: { workflows: Wor
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center">
<ArrowsClockwise size={36} className="mb-3 text-muted-foreground" />
<div className="text-[14px] font-medium text-muted-foreground">{t("auto.workflows.noWorkflows")}</div>
<div className="mx-auto mt-1 max-w-[42ch] text-[12px] leading-relaxed text-muted-foreground">
{t("auto.workflows.noWorkflowsDetail")}
</div>
<div className="mt-4">
<ActionButton label={t("auto.workflows.createFirstWorkflow")} onClick={() => setShowCreate(true)} icon={ArrowsClockwise} tone="accent" />
</div>
@@ -84,10 +84,7 @@ export function SearchResults({ results, query, loading, agents, onNavigateToRes
if (results.length === 0 && query.length > 0) {
return (
<div className="flex h-full items-center justify-center">
<EmptyState
title={t("comm.searchEmptyTitle")}
detail={t("comm.searchEmptyDetail")}
/>
<EmptyState title={t("comm.searchEmptyTitle")} />
</div>
)
}
@@ -54,7 +54,7 @@ export function ExecutionModeStep({ initialValue, onContinue, onBack }: Executio
<button
type="button"
onClick={() => onContinue(executionMode)}
className="flex items-center gap-2 rounded-md bg-[color:var(--primary)] px-4 py-2 text-[13px] font-medium text-white transition hover:opacity-90"
className="flex items-center gap-2 rounded-md bg-[color:var(--primary)] px-4 py-2 text-[13px] font-medium text-[color:var(--primary-foreground)] transition hover:opacity-90"
>
{t("onboarding.continue")}
<ArrowRight size={14} />
@@ -79,23 +79,18 @@ function WorkProductRow({ product, companyId }: { product: WorkProductRecord; co
// path as absolute.
const primaryAction = product.url || (product.provider === "filesystem" && product.externalId)
? () => { void window.agentCompany.openWorkProduct({ companyId, id: product.id }); }
: null;
const rowBody = (
<div className="min-w-0 flex-1 text-left">
<div className="flex items-center gap-2 text-[13px] font-medium text-foreground">
<FileText size={15} className="text-muted-foreground" />
<span className="truncate">{product.title}</span>
</div>
<div className="mt-1 text-[11px] text-muted-foreground">{subtitle}</div>
</div>
);
: undefined;
return (
<ListRow>
<ListRow onClick={primaryAction}>
<div className="flex items-start justify-between gap-3">
{primaryAction
? <button type="button" className="min-w-0 flex-1 text-left" onClick={primaryAction}>{rowBody}</button>
: rowBody}
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1 text-left">
<div className="flex items-center gap-2 text-[13px] font-medium text-foreground">
<FileText size={15} className="text-muted-foreground" />
<span className="truncate">{product.title}</span>
</div>
<div className="mt-1 text-[11px] text-muted-foreground">{subtitle}</div>
</div>
<div data-row-action className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<StatusPill status={product.status} />
</div>
</div>
@@ -128,7 +123,6 @@ export function DocumentCenter({ companyId, documents, workProducts, agents, pro
{isEmpty ? (
<EmptyState
title={t("ops.documentsEmptyTitle")}
detail={t("ops.documentsEmptyDetail")}
action={<ActionButton label={t("ops.documentsEmptyAction")} icon={Plus} tone="accent" onClick={() => setEditingId(NEW_ENTITY_ID)} />}
/>
) : null}
@@ -139,19 +133,19 @@ export function DocumentCenter({ companyId, documents, workProducts, agents, pro
</div>
<ListFrame>
{documents.map((document) => (
<ListRow key={document.id}>
<ListRow key={document.id} onClick={() => setEditingId(document.id)}>
<div className="flex items-start justify-between gap-3">
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setEditingId(document.id)}>
<div className="min-w-0 flex-1 text-left">
<div className="flex items-center gap-2 text-[13px] font-medium text-foreground">
<FileText size={15} className="text-primary" />
<span className="truncate">{document.title}</span>
</div>
<div className="mt-1 text-[11px] text-muted-foreground">{document.type.replaceAll("_", " ")} · v{document.version} · {document.updatedAt.slice(0, 10)}</div>
</button>
<div className="flex items-center gap-2">
</div>
<div data-row-action className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<StatusPill status={document.status} />
<button type="button" aria-label="Edit document" className="rounded p-1 text-muted-foreground hover:text-foreground" onClick={() => setEditingId(document.id)}><NotePencil size={14} /></button>
<button type="button" aria-label="Delete document" className="rounded p-1 text-muted-foreground hover:text-destructive" onClick={() => setDeleteId(document.id)}><Trash size={14} /></button>
<button type="button" aria-label="Edit document" className="rounded p-1 text-muted-foreground hover:text-foreground" onClick={(e) => { e.stopPropagation(); setEditingId(document.id); }}><NotePencil size={14} /></button>
<button type="button" aria-label="Delete document" className="rounded p-1 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation(); setDeleteId(document.id); }}><Trash size={14} /></button>
</div>
</div>
</ListRow>
@@ -13,7 +13,7 @@ export function SectionHeader({
return (
<div className="mb-4 flex items-center justify-between">
<div>
<div className="text-[16px] font-semibold text-foreground">{title}</div>
<div className="text-[15px] font-semibold text-foreground">{title}</div>
<div className="text-[12px] text-muted-foreground">{count} item{count === 1 ? "" : "s"}</div>
</div>
<ActionButton label="New" icon={Plus} tone="accent" onClick={action} />
@@ -51,7 +51,7 @@ export function ActivityFeed({ activity, agents, limit = 15 }: { activity: Activ
key={item.id}
initial={index === 0 ? { opacity: 0, y: -8 } : false}
animate={{ opacity: 1, y: 0 }}
className="flex items-start gap-3 rounded-lg px-3 py-2 transition hover:bg-muted"
className="flex items-start gap-3 rounded-lg px-3 py-2 transition hover:bg-muted/50"
>
<div className="mt-0.5 shrink-0">
<Icon size={14} className="text-muted-foreground" />
@@ -192,7 +192,7 @@ export function Dashboard(props: DashboardProps) {
key={run.id}
type="button"
onClick={() => onNavigate("runs", run.id)}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 px-4 py-3 text-left transition hover:bg-muted"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 px-4 py-3 text-left transition hover:bg-muted/50"
>
<div className="relative shrink-0">
<Robot size={16} className="text-success" />
@@ -50,7 +50,6 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps)
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center">
<Briefcase size={32} className="mb-3 text-muted-foreground" />
<div className="text-[14px] font-medium text-muted-foreground">{t("kanban.noTasks")}</div>
<div className="mt-1 text-[12px] text-muted-foreground">{t("kanban.noTasksDetail")}</div>
</div>
);
}
@@ -37,7 +37,7 @@ export function AgentWorkloadChart({ data, reducedMotion }: AgentWorkloadChartPr
axisLine={false}
tickLine={false}
/>
<Tooltip content={<ChartTooltip valueSuffix=" runs" />} />
<Tooltip content={<ChartTooltip valueSuffix=" runs" labelMode="plain" />} />
<Bar
dataKey="runs"
fill="var(--primary)"
@@ -1,36 +1,74 @@
import type { TooltipContentProps } from "recharts";
export type ChartTooltipLabelMode = "date" | "plain";
interface ChartTooltipExtraProps {
valuePrefix?: string;
valueSuffix?: string;
/**
* How to interpret the raw `label` coming from recharts.
* - "date" (default): parse `YYYY-MM-DD` strings into "Mon DD, YYYY".
* - "plain": pass through the raw label as-is (category axes, names, etc.).
* `labelFormatter`, if provided, still wins over both modes.
*/
labelMode?: ChartTooltipLabelMode;
labelFormatter?: (label: string | number | undefined) => string;
}
type Props = Partial<TooltipContentProps<number, string>> & ChartTooltipExtraProps;
/** Pure helper: format a raw label as a localized date, or return it unchanged on failure. */
export function formatDateLabel(label: string | number | undefined): string {
if (typeof label !== "string") return String(label);
const parsed = new Date(label + "T00:00:00");
if (Number.isNaN(parsed.getTime())) return label;
return parsed.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
/** Pure helper: pass-through formatter for category / name labels. */
export function formatPlainLabel(label: string | number | undefined): string {
if (label === undefined || label === null) return "";
return String(label);
}
/** Pure helper: decide whether the label row should render at all. */
export function shouldRenderLabel(label: string | number | undefined): boolean {
if (label === undefined || label === null) return false;
if (typeof label === "string" && label.length === 0) return false;
return true;
}
function pickDefaultFormatter(mode: ChartTooltipLabelMode) {
return mode === "plain" ? formatPlainLabel : formatDateLabel;
}
export function ChartTooltip({
active,
payload,
label,
valuePrefix = "",
valueSuffix = "",
labelMode = "date",
labelFormatter,
}: Props) {
if (!active || !payload?.length) return null;
const dateStr =
typeof label === "string"
? new Date(label + "T00:00:00").toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
: String(label);
const renderLabel = shouldRenderLabel(label);
const formatLabel = labelFormatter ?? pickDefaultFormatter(labelMode);
const labelText = renderLabel ? formatLabel(label) : "";
return (
<div
className="rounded-lg border border-border bg-card px-3 py-2 shadow-md"
style={{ maxWidth: 200, zIndex: 50 }}
>
<div className="text-[11px] text-muted-foreground">{dateStr}</div>
{renderLabel && labelText ? (
<div className="text-[11px] text-muted-foreground">{labelText}</div>
) : null}
{payload.map((entry, i) => (
<div key={i} className="mt-1 flex items-center gap-2">
<span
@@ -38,7 +38,7 @@ export function ActivityList({ activity }: { activity: ActivityRecord[] }) {
overscan: 15,
})
if (activity.length === 0) return <EmptyState title={t("exec.noActivityYet")} detail={t("exec.noActivityDetail")} />
if (activity.length === 0) return <EmptyState title={t("exec.noActivityYet")} />
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
@@ -17,7 +17,7 @@ export function ApprovalList({ approvals, selectedApprovalId, onSelect }: { appr
overscan: 10,
})
if (approvals.length === 0) return <EmptyState title={t("exec.noApprovals")} detail={t("exec.noApprovalsDetail")} />
if (approvals.length === 0) return <EmptyState title={t("exec.noApprovals")} />
return (
<div
ref={parentRef}
@@ -28,7 +28,7 @@ export function CostList({ costs, agents, runs }: { costs: ProfileSnapshot["cost
overscan: 10,
})
if (costs.length === 0) return <EmptyState title={t("exec.noCostData")} detail={t("exec.noCostDataDetail")} />
if (costs.length === 0) return <EmptyState title={t("exec.noCostData")} />
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
@@ -77,10 +77,7 @@ export function IncidentList({ companyId, agents, projects }: IncidentListProps)
<div className="flex-1 overflow-y-auto px-4 pb-4">
{incidents.length === 0 ? (
<EmptyState
title="No budget incidents"
detail={filter === "open" ? "No open incidents. All spending is within limits." : "No incidents recorded yet."}
/>
<EmptyState title={filter === "open" ? "No open incidents" : "No incidents yet"} />
) : (
<ListFrame>
{incidents.map((incident) => {
@@ -106,7 +106,7 @@ export function PendingReviewList({ tasks, agents, approvals }: PendingReviewLis
const t = useT()
const reviewTasks = tasks.filter((tk) => tk.status === "in_review")
if (reviewTasks.length === 0) {
return <EmptyState title={t("exec.noReviewsInProgress")} detail={t("exec.noReviewsDetail")} />
return <EmptyState title={t("exec.noReviewsInProgress")} />
}
const pendingApprovalTaskIds = new Set(
approvals
@@ -72,7 +72,6 @@ export function PolicyList({ companyId, agents, projects }: PolicyListProps) {
<div className="p-6">
<EmptyState
title="No budget policies"
detail="Add your first policy to track and enforce spending limits."
action={<ActionButton label="Add Policy" icon={Plus} onClick={handleAdd} />}
/>
<PolicyFormModal
@@ -1,99 +1,256 @@
import { useState, useMemo } from "react"
import { CheckCircle, ListChecks, Pencil, Play, ShieldCheck, Trash, XCircle, Warning, Robot, ArrowRight } from "../ui/icons"
import type { CommentRecord, GoalRecord, ProfileSnapshot, ProjectRecord, RunRecord, TaskRecord, TaskStatus, WorkspaceRecord } from "@shared/types"
import { useEffect, useMemo, useState, type ReactNode } from "react"
import type {
CommentRecord, GoalRecord, ProfileSnapshot, ProjectRecord, RunRecord,
TaskRecord, TaskStatus, WorkspaceRecord,
} from "@shared/types"
import { useT } from "../../i18n"
import { formatMoney, formatTime } from "../../lib/formatters"
import { unwrap } from "../../lib/desktop"
import { ActionButton, ConfirmDialog, Eyebrow, InlineNotice, Input, Select, StatusPill, TextArea } from "../ui"
import { describeActionError } from "./execution-utils"
import { TaskComments } from "./TaskComments"
import { ConfirmDialog, InlineNotice } from "../ui"
import { describeActionError, shouldAlertGovernance, type GovernanceAlert } from "./execution-utils"
import { TaskInspectorOverview } from "./TaskInspectorOverview"
import { TaskInspectorSubtasks } from "./TaskInspectorSubtasks"
import { TaskInspectorActivity } from "./TaskInspectorActivity"
import { TaskInspectorGovernance } from "./TaskInspectorGovernance"
import { TaskInspectorEditForm } from "./TaskInspectorEditForm"
import { useAppStore } from "../../state/store"
import type { TaskInspectorTab } from "../../state/slices/ui"
export function TaskInspector({ task, tasks, runs, agents, goals, projects, workspaces, companyId, onRun, onCancelRun, onCreateApproval, onOpenDiscussion, comments, onRefresh, onDeleted }: {
task: TaskRecord; tasks: TaskRecord[]; runs: RunRecord[]; agents: ProfileSnapshot["agents"]; goals: GoalRecord[]; projects: ProjectRecord[]; workspaces: WorkspaceRecord[]; companyId: string
onRun: (taskId: string) => Promise<void>; onCancelRun: (runId: string) => Promise<void>; onCreateApproval: () => Promise<void>; onOpenDiscussion: (taskId: string) => void
comments: CommentRecord[]; onRefresh: () => Promise<void>; onDeleted: () => Promise<void>
function alertTone(alerts: GovernanceAlert[]): "danger" | "warn" {
return alerts.some((a) => a.severity === "danger") ? "danger" : "warn"
}
function alertMessage(alerts: GovernanceAlert[], t: (key: string, fallback?: string) => string): string {
const parts = alerts.map((a) => {
if (a.kind === "blocked") return t("exec.alertBlocked", "Blocked — needs unblock approval")
if (a.kind === "budget_over") return t("exec.alertBudgetOver", "Assignee budget > 80%")
return t("exec.alertMissingReviewer", "In review without a reviewer")
})
return parts.join(" · ")
}
export function TaskInspector({
task, tasks, runs, agents, goals, projects, workspaces, companyId,
onRun, onCancelRun, onCreateApproval, onOpenDiscussion,
comments, onRefresh, onDeleted,
}: {
task: TaskRecord
tasks: TaskRecord[]
runs: RunRecord[]
agents: ProfileSnapshot["agents"]
goals: GoalRecord[]
projects: ProjectRecord[]
workspaces: WorkspaceRecord[]
companyId: string
onRun: (taskId: string) => Promise<void>
onCancelRun: (runId: string) => Promise<void>
onCreateApproval: () => Promise<void>
onOpenDiscussion: (taskId: string) => void
comments: CommentRecord[]
onRefresh: () => Promise<void>
onDeleted: () => Promise<void>
}) {
const t = useT()
const addToast = useAppStore((s) => s.addToast)
const [editing, setEditing] = useState(false); const [editTitle, setEditTitle] = useState(task.title); const [editDescription, setEditDescription] = useState(task.description)
const [editPriority, setEditPriority] = useState(task.priority); const [editStatus, setEditStatus] = useState<TaskStatus>(task.status); const [editAssignee, setEditAssignee] = useState(task.assigneeAgentId ?? "")
const [editWorkspace, setEditWorkspace] = useState(task.workspaceId ?? ""); const [editProject, setEditProject] = useState(task.projectId ?? ""); const [editGoal, setEditGoal] = useState(task.goalId ?? "")
const [confirmDelete, setConfirmDelete] = useState(false); const [actionError, setActionError] = useState<string | null>(null); const [actionNotice, setActionNotice] = useState<string | null>(null)
const [deleteError, setDeleteError] = useState<string | null>(null); const [savingChanges, setSavingChanges] = useState(false); const [updatingStatus, setUpdatingStatus] = useState<TaskStatus | null>(null); const [creatingApproval, setCreatingApproval] = useState(false)
const tab = useAppStore((s) => s.taskInspectorTab)
const setTab = useAppStore((s) => s.setTaskInspectorTab)
const [editing, setEditing] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const [actionError, setActionError] = useState<string | null>(null)
const [actionNotice, setActionNotice] = useState<string | null>(null)
const [deleteError, setDeleteError] = useState<string | null>(null)
const [updatingStatus, setUpdatingStatus] = useState<TaskStatus | null>(null)
const [creatingApproval, setCreatingApproval] = useState(false)
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
const assignee = agentMap.get(task.assigneeAgentId ?? "") ?? null; const project = projects.find((e) => e.id === task.projectId) ?? null
const goal = goals.find((e) => e.id === task.goalId) ?? null; const workspace = workspaces.find((e) => e.id === task.workspaceId) ?? null
const activeRun = runs.find((r) => r.id === task.activeRunId) ?? null; const reviewer = assignee?.reportsTo ? (agentMap.get(assignee.reportsTo) ?? null) : null
const needsGovernance = task.status === "blocked" || (task.status === "in_review" && !reviewer)
const budgetWarning = assignee && assignee.budgetMonthlyUsd > 0 ? assignee.spentMonthlyUsd / assignee.budgetMonthlyUsd : 0
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents])
const assignee = agentMap.get(task.assigneeAgentId ?? "") ?? null
const project = projects.find((p) => p.id === task.projectId) ?? null
const goal = goals.find((g) => g.id === task.goalId) ?? null
const workspace = workspaces.find((w) => w.id === task.workspaceId) ?? null
const activeRun = runs.find((r) => r.id === task.activeRunId) ?? null
const reviewer = assignee?.reportsTo ? agentMap.get(assignee.reportsTo) ?? null : null
function startEdit() { setActionError(null); setActionNotice(null); setEditTitle(task.title); setEditDescription(task.description); setEditPriority(task.priority); setEditStatus(task.status); setEditAssignee(task.assigneeAgentId ?? ""); setEditWorkspace(task.workspaceId ?? ""); setEditProject(task.projectId ?? ""); setEditGoal(task.goalId ?? ""); setEditing(true) }
async function saveChanges() { setActionError(null); setActionNotice(null); setSavingChanges(true); try { unwrap(await window.agentCompany.saveTask({ id: task.id, companyId: task.companyId, title: editTitle, description: editDescription, priority: editPriority as TaskRecord["priority"], status: editStatus, assigneeAgentId: editAssignee || null, workspaceId: editWorkspace || null, projectId: editProject || null, goalId: editGoal || null })); await onRefresh(); setEditing(false); setActionNotice("Task changes saved."); addToast("Task changes saved", "success") } catch (e) { setActionError(describeActionError(e)) } finally { setSavingChanges(false) } }
async function quickStatus(status: TaskStatus) { setActionError(null); setActionNotice(null); setUpdatingStatus(status); try { unwrap(await window.agentCompany.saveTask({ id: task.id, companyId: task.companyId, title: task.title, description: task.description, priority: task.priority, status, assigneeAgentId: task.assigneeAgentId, workspaceId: task.workspaceId, projectId: task.projectId, goalId: task.goalId })); await onRefresh(); setActionNotice(`Task moved to ${status.replaceAll("_", " ")}.`) } catch (e) { setActionError(describeActionError(e)) } finally { setUpdatingStatus(null) } }
async function handleCreateApproval() { setActionError(null); setActionNotice(null); setCreatingApproval(true); try { await onCreateApproval(); setActionNotice(task.status === "blocked" ? "Approval created and routed to leadership." : "Approval created."); addToast("Approval created and routed to leadership", "success") } catch (e) { setActionError(describeActionError(e)) } finally { setCreatingApproval(false) } }
async function handleDelete() { setDeleteError(null); try { unwrap(await window.agentCompany.deleteTask({ id: task.id, companyId })); setConfirmDelete(false); addToast("Task deleted", "success"); await onDeleted() } catch (e) { setDeleteError(describeActionError(e)) } }
const subtasksCount = tasks.filter((s) => s.parentId === task.id).length
const commentsCount = comments.length
const runsCount = runs.length
const alerts = useMemo(
() => shouldAlertGovernance(task, assignee, reviewer),
[task, assignee, reviewer],
)
// If the currently-selected tab disappears (e.g. Subtasks hidden because
// subtasksCount===0), fall back to Overview so the modal never shows a
// blank body. Keeps the persisted tab sticky in all other cases.
useEffect(() => {
if (tab === "subtasks" && subtasksCount === 0) setTab("overview")
}, [tab, subtasksCount, setTab])
async function quickStatus(status: TaskStatus) {
setActionError(null); setActionNotice(null); setUpdatingStatus(status)
try {
unwrap(await window.agentCompany.saveTask({
id: task.id, companyId: task.companyId, title: task.title, description: task.description,
priority: task.priority, status, assigneeAgentId: task.assigneeAgentId,
workspaceId: task.workspaceId, projectId: task.projectId, goalId: task.goalId,
}))
await onRefresh()
setActionNotice(`Task moved to ${status.replaceAll("_", " ")}.`)
} catch (e) {
setActionError(describeActionError(e))
} finally {
setUpdatingStatus(null)
}
}
async function handleCreateApproval() {
setActionError(null); setActionNotice(null); setCreatingApproval(true)
try {
await onCreateApproval()
setActionNotice(task.status === "blocked" ? "Approval created and routed to leadership." : "Approval created.")
addToast("Approval created and routed to leadership", "success")
} catch (e) {
setActionError(describeActionError(e))
} finally {
setCreatingApproval(false)
}
}
async function handleDelete() {
setDeleteError(null)
try {
unwrap(await window.agentCompany.deleteTask({ id: task.id, companyId }))
setConfirmDelete(false)
addToast("Task deleted", "success")
await onDeleted()
} catch (e) {
setDeleteError(describeActionError(e))
}
}
const tabBtn = (key: TaskInspectorTab, label: string, badge?: ReactNode) => {
const active = tab === key
return (
<button
type="button"
onClick={() => setTab(key)}
className={`relative flex items-center rounded-md px-3 py-1.5 text-[12px] font-semibold transition ${
active ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
{label}
{badge}
</button>
)
}
const countPill = (value: number) => (
<span className={`ml-1 rounded-full px-1.5 text-[10px] ${tab === "activity" ? "bg-primary-foreground/20" : "bg-muted-foreground/15"}`}>{value}</span>
)
// Activity badge is a single total. The comments/runs split lives inside
// the Activity tab as filter chips — one signal per surface.
const activityBadge = commentsCount + runsCount > 0 ? countPill(commentsCount + runsCount) : null
// Governance signal speaks once via the Overview banner. No tab-level dot —
// duplicating the alert adds noise without context.
return (
<div className="space-y-6">
<ConfirmDialog open={confirmDelete} title={t("exec.deleteTask")} message={`${t("exec.deleteTaskConfirm").replace("{0}", task.title)}`} errorMessage={deleteError} onConfirm={() => void handleDelete()} onCancel={() => { setDeleteError(null); setConfirmDelete(false) }} />
{budgetWarning > 0.8 && assignee ? <div className={`flex items-center gap-2 rounded-lg border px-4 py-3 text-[13px] font-medium ${budgetWarning > 1 ? "border-[color:var(--destructive)] bg-destructive-muted text-destructive" : "border-[color:var(--destructive)] bg-warning-muted text-warning"}`}><Warning size={16} weight="fill" />{assignee.name} {t("exec.budgetUsed").replace("{0}", String(Math.round(budgetWarning * 100)))} ({formatMoney(assignee.spentMonthlyUsd)} / {formatMoney(assignee.budgetMonthlyUsd)})</div> : null}
<div className="space-y-5">
<ConfirmDialog
open={confirmDelete}
title={t("exec.deleteTask")}
message={t("exec.deleteTaskConfirm").replace("{0}", task.title)}
errorMessage={deleteError}
onConfirm={() => void handleDelete()}
onCancel={() => { setDeleteError(null); setConfirmDelete(false) }}
/>
{actionError ? <InlineNotice message={actionError} /> : null}
{!actionError && actionNotice ? <InlineNotice message={actionNotice} tone="success" /> : null}
{editing ? (
<div className="grid min-w-0 gap-3 md:grid-cols-2">
<div className="md:col-span-2"><Input label={t("exec.title")} value={editTitle} onChange={setEditTitle} placeholder={t("exec.taskTitle")} /></div>
<div className="md:col-span-2"><TextArea label={t("exec.description")} value={editDescription} onChange={setEditDescription} placeholder={t("exec.description")} rows={4} /></div>
<Select label={t("exec.priority")} value={editPriority} onChange={v => setEditPriority(v as TaskRecord["priority"])} options={["critical","high","medium","low"].map(v => ({ value: v, label: v }))} />
<Select label={t("exec.status")} value={editStatus} onChange={v => setEditStatus(v as TaskStatus)} options={["backlog","todo","in_progress","in_review","done","blocked","cancelled"].map(v => ({ value: v, label: v.replaceAll("_"," ") }))} />
<Select label={t("exec.assignee")} value={editAssignee} onChange={setEditAssignee} options={[{ value: "", label: t("exec.unassigned") }, ...agents.map(a => ({ value: a.id, label: a.name }))]} />
<Select label={t("exec.workspace")} value={editWorkspace} onChange={setEditWorkspace} options={[{ value: "", label: t("exec.noWorkspace") }, ...workspaces.map(w => ({ value: w.id, label: w.name }))]} />
<Select label={t("exec.project")} value={editProject} onChange={setEditProject} options={[{ value: "", label: t("exec.noProject") }, ...projects.map(p => ({ value: p.id, label: p.name }))]} />
<Select label={t("exec.goal")} value={editGoal} onChange={setEditGoal} options={[{ value: "", label: t("exec.noGoal") }, ...goals.map(g => ({ value: g.id, label: g.title }))]} />
<div className="md:col-span-2 flex gap-3"><ActionButton label={t("exec.save")} tone="accent" onClick={() => void saveChanges()} icon={CheckCircle} loading={savingChanges} /><ActionButton label={t("exec.cancel")} onClick={() => setEditing(false)} icon={XCircle} /></div>
</div>
<TaskInspectorEditForm
task={task}
agents={agents}
goals={goals}
projects={projects}
workspaces={workspaces}
onDone={() => setEditing(false)}
onRefresh={onRefresh}
onError={setActionError}
onNotice={setActionNotice}
addToast={addToast}
/>
) : (
<>
<div className="grid gap-x-6 gap-y-2 text-[13px] md:grid-cols-2">
{assignee && <div className="text-muted-foreground">{t("exec.assigneeLabel")} <span className="text-foreground">{assignee.name}</span></div>}
{project && <div className="text-muted-foreground">{t("exec.projectLabel")} <span className="text-foreground">{project.name}</span></div>}
{goal && <div className="text-muted-foreground">{t("exec.goalLabel")} <span className="text-foreground">{goal.title}</span></div>}
{task.parentId && (() => { const parent = tasks.find((t) => t.id === task.parentId); return parent ? <div className="text-muted-foreground">{t("exec.parentLabel")} <span className="text-foreground">{parent.title}</span></div> : null })()}
{workspace && <div className="text-muted-foreground">{t("exec.workspaceLabel")} <span className="text-foreground">{workspace.name}</span></div>}
{task.costUsd > 0 && <div className="text-muted-foreground">{t("exec.costLabel")} <span className="font-mono tabular-nums text-foreground">{formatMoney(task.costUsd)}</span></div>}
</div>
{task.description ? <div className="text-[13px] leading-relaxed text-muted-foreground whitespace-pre-wrap">{task.description}</div> : null}
{task.status === "in_review" && reviewer ? <div className="rounded-lg border border-[color:var(--primary)] bg-accent px-4 py-3"><div className="flex items-center gap-2 text-[13px] font-semibold text-primary"><Robot size={16} weight="fill" />{t("exec.beingReviewedBy").replace("{0}", reviewer.name + (reviewer.title ? ` (${reviewer.title})` : ""))}</div><div className="mt-1 text-[13px] leading-relaxed text-primary">{t("exec.submittedForReview").replace("{0}", assignee?.name ?? "").replace("{1}", reviewer.name)}</div></div>
: needsGovernance ? <div className="rounded-lg border border-[color:var(--destructive)] bg-warning-muted px-4 py-3"><div className="flex items-center gap-2 text-[13px] font-semibold text-warning"><ShieldCheck size={16} weight="fill" />{task.status === "blocked" ? t("exec.governanceRequired") : t("exec.waitingOnReview")}</div><div className="mt-1 text-[13px] leading-relaxed text-muted-foreground">{task.status === "blocked" ? t("exec.blockedGovernance") : t("exec.reviewGovernance")}</div><div className="mt-3"><ActionButton label={task.status === "blocked" ? t("exec.createUnblockApproval") : t("exec.createReviewApproval")} onClick={() => void handleCreateApproval()} icon={ShieldCheck} loading={creatingApproval} /></div></div> : null}
<div className="flex items-center gap-3">
{activeRun ? <ActionButton label={t("exec.cancelRun")} onClick={() => void onCancelRun(activeRun.id)} icon={XCircle} tone="danger" /> : <>
{["todo","in_progress","blocked"].includes(task.status) && <ActionButton label={t("exec.run")} onClick={() => void onRun(task.id)} icon={Play} tone="accent" />}
{task.status === "in_review" && <ActionButton label={t("exec.done")} onClick={() => void quickStatus("done")} icon={CheckCircle} tone="accent" loading={updatingStatus === "done"} />}
{task.status === "blocked" && <ActionButton label={t("exec.unblock")} onClick={() => void quickStatus("in_progress")} icon={Play} loading={updatingStatus === "in_progress"} />}
</>}
<ActionButton label={t("exec.discussion")} onClick={() => onOpenDiscussion(task.id)} icon={ArrowRight} />
<ActionButton label={t("exec.edit")} onClick={startEdit} icon={Pencil} />
<ActionButton label={t("exec.delete")} onClick={() => setConfirmDelete(true)} icon={Trash} tone="danger" />
<div className="flex flex-wrap items-center gap-1 border-b border-border pb-2">
{tabBtn("overview", t("exec.tabOverview", "Overview"))}
{subtasksCount > 0 && tabBtn("subtasks", t("exec.tabSubtasks", "Subtasks"), countPill(subtasksCount))}
{tabBtn("activity", t("exec.tabActivity", "Activity"), activityBadge)}
{tabBtn("governance", t("exec.tabGovernance", "Governance"))}
</div>
{tab === "overview" && (
<>
{alerts.length > 0 ? (
<InlineNotice
tone={alertTone(alerts)}
message={alertMessage(alerts, t)}
action={
<button
type="button"
onClick={() => setTab("governance")}
className="shrink-0 rounded-md border border-current px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide"
>
{t("exec.openGovernance", "Open")}
</button>
}
/>
) : null}
<TaskInspectorOverview
task={task}
tasks={tasks}
activeRun={activeRun}
assignee={assignee}
project={project}
goal={goal}
workspace={workspace}
onRun={onRun}
onCancelRun={onCancelRun}
onQuickStatus={quickStatus}
onOpenDiscussion={onOpenDiscussion}
onEdit={() => { setActionError(null); setActionNotice(null); setEditing(true) }}
onDelete={() => setConfirmDelete(true)}
updatingStatus={updatingStatus}
/>
</>
)}
{tab === "subtasks" && subtasksCount > 0 && (
<TaskInspectorSubtasks task={task} tasks={tasks} agents={agents} />
)}
{tab === "activity" && (
<TaskInspectorActivity
taskId={task.id}
companyId={task.companyId}
agents={agents}
comments={comments}
runs={runs}
onRefresh={onRefresh}
/>
)}
{tab === "governance" && (
<TaskInspectorGovernance
task={task}
assignee={assignee}
reviewer={reviewer}
onCreateApproval={handleCreateApproval}
creatingApproval={creatingApproval}
/>
)}
</>
)}
{(() => { const subtasks = tasks.filter((t) => t.parentId === task.id); if (subtasks.length === 0) return null; return (
<div className="space-y-2">
<div className="flex items-center gap-2"><ListChecks size={14} className="text-muted-foreground" /><Eyebrow>{t("exec.subtasks")} ({subtasks.length})</Eyebrow><span className="text-[10px] text-success">{t("exec.doneCount").replace("{0}", String(subtasks.filter((st) => st.status === "done").length)).replace("{1}", String(subtasks.length))}</span></div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-success transition-all duration-300" style={{ width: `${subtasks.length > 0 ? (subtasks.filter((t) => t.status === "done").length / subtasks.length) * 100 : 0}%` }} /></div>
<div className="space-y-1">{subtasks.map((sub) => { const subAssignee = agentMap.get(sub.assigneeAgentId ?? ""); return (
<div key={sub.id} className="flex items-center gap-2 rounded-lg px-3 py-2 text-[12px] bg-muted">
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${sub.status === "done" ? "bg-success" : sub.status === "in_progress" ? "bg-primary" : sub.status === "blocked" ? "bg-destructive" : sub.status === "in_review" ? "bg-warning" : "bg-[color:var(--muted-foreground)]"}`} />
<span className={`flex-1 truncate ${sub.status === "done" ? "line-through text-muted-foreground" : "text-foreground"}`}>{sub.title}</span>
{subAssignee ? <span className="shrink-0 text-[10px] text-muted-foreground">{subAssignee.name}</span> : null}<StatusPill status={sub.status} />
</div>) })}</div></div>) })()}
<TaskComments taskId={task.id} companyId={task.companyId} agents={agents} comments={comments} onRefresh={onRefresh} />
{runs.length > 0 && <div className="space-y-2"><Eyebrow>{t("exec.runHistory")}</Eyebrow><div className="space-y-2">{runs.slice(0, 8).map((run) => { const runAgent = agentMap.get(run.agentId); const isActive = run.status === "running" || run.status === "queued"; return (
<div key={run.id} className={`rounded-lg border px-3 py-2.5 ${isActive ? "border-[color:var(--primary)] bg-success-muted" : run.status === "failed" || run.status === "timed_out" ? "border-[color:var(--destructive)] border-opacity-30 bg-destructive-muted" : "border-border bg-muted"}`}>
<div className="flex items-center justify-between gap-2"><div className="min-w-0 flex-1"><div className="flex items-center gap-2">{isActive ? <span className="h-2 w-2 animate-pulse rounded-full bg-success" /> : null}<span className="truncate text-[13px] font-medium text-foreground">{run.summary || (isActive ? t("exec.runningEllipsis") : t("exec.completedRun"))}</span></div><div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground">{runAgent ? <span>{runAgent.name}</span> : null}<span>{run.connectorId.replace("_local", "")}</span>{run.model ? <span className="font-mono">{run.model}</span> : null}<span>{formatTime(run.startedAt)}</span>{run.costUsd != null && run.costUsd > 0 ? <span className="font-mono tabular-nums">{formatMoney(run.costUsd)}</span> : null}</div></div><StatusPill status={run.status} /></div>
{run.errorMessage ? <div className="mt-1.5 rounded bg-destructive-muted px-2 py-1 text-[11px] text-destructive break-words">{run.errorMessage}</div> : null}
</div>) })}{runs.length > 8 && <div className="text-[11px] text-muted-foreground text-center">{t("exec.moreRuns").replace("{0}", String(runs.length - 8))}</div>}</div></div>}
</div>
)
}
@@ -0,0 +1,166 @@
import { useEffect, useMemo, useState } from "react"
import type { CommentRecord, ProfileSnapshot, RunRecord } from "@shared/types"
import { useT } from "../../i18n"
import { formatMoney, formatTime } from "../../lib/formatters"
import { unwrap } from "../../lib/desktop"
import { ActionButton, InlineNotice, StatusPill } from "../ui"
import { MarkdownBody } from "../ui/MarkdownBody"
import { ArrowRight } from "../ui/icons"
import { describeActionError } from "./execution-utils"
type ActivityFilter = "all" | "comments" | "runs"
export function TaskInspectorActivity({
taskId, companyId, agents, comments, runs, onRefresh,
}: {
taskId: string
companyId: string
agents: ProfileSnapshot["agents"]
comments: CommentRecord[]
runs: RunRecord[]
onRefresh: () => Promise<void>
}) {
const t = useT()
const [filter, setFilter] = useState<ActivityFilter>("all")
const [newComment, setNewComment] = useState("")
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents])
useEffect(() => {
const unsubscribe = window.agentCompany.subscribe((event) => {
if (event.type === "agent-message" && event.taskId === taskId) void onRefresh()
})
return unsubscribe
}, [onRefresh, taskId])
const handleSubmit = async () => {
if (!newComment.trim()) return
setSubmitError(null); setSubmitting(true)
try {
unwrap(await window.agentCompany.addComment({ companyId, taskId, authorName: "Board", body: newComment.trim() }))
setNewComment("")
await onRefresh()
} catch (e) {
setSubmitError(describeActionError(e))
} finally {
setSubmitting(false)
}
}
type FeedItem =
| { kind: "comment"; ts: number; data: CommentRecord }
| { kind: "run"; ts: number; data: RunRecord }
const feed: FeedItem[] = useMemo(() => {
const items: FeedItem[] = []
const toMs = (value: string | null) => (value ? Date.parse(value) : 0)
if (filter === "all" || filter === "comments") {
for (const c of comments) items.push({ kind: "comment", ts: toMs(c.createdAt), data: c })
}
if (filter === "all" || filter === "runs") {
for (const r of runs) items.push({ kind: "run", ts: toMs(r.startedAt ?? r.createdAt), data: r })
}
items.sort((a, b) => b.ts - a.ts)
return items
}, [comments, runs, filter])
const filterBtn = (key: ActivityFilter, label: string) => (
<button
type="button"
onClick={() => setFilter(key)}
className={`rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.08em] transition ${
filter === key ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:text-foreground"
}`}
>
{label}
</button>
)
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
{filterBtn("all", t("exec.activityAll", "All"))}
{filterBtn("comments", t("exec.communication"))}
{filterBtn("runs", t("exec.runHistory"))}
<span className="ml-auto text-[11px] text-muted-foreground">{feed.length}</span>
</div>
{feed.length === 0 ? (
<div className="text-[12px] text-muted-foreground">{t("exec.noMessagesYet")}</div>
) : (
<div className="max-h-[320px] space-y-2 overflow-y-auto rounded-lg border border-border p-3">
{feed.map((item) => {
if (item.kind === "comment") {
const c = item.data
const isAgent = Boolean(c.authorAgentId)
const agent = isAgent ? agentMap.get(c.authorAgentId ?? "") : null
return (
<div key={`c-${c.id}`} className="rounded-md bg-muted px-3 py-2">
<div className="flex items-center gap-2">
<span className={`text-[12px] font-semibold ${isAgent ? "text-primary" : "text-foreground"}`}>{c.authorName}</span>
{agent && <span className="text-[10px] text-muted-foreground">{agent.role}</span>}
<span className="text-[10px] text-muted-foreground">{formatTime(c.createdAt)}</span>
</div>
<div className="mt-1 text-[13px] text-foreground"><MarkdownBody body={c.body} /></div>
</div>
)
}
const run = item.data
const runAgent = agentMap.get(run.agentId)
const isActive = run.status === "running" || run.status === "queued"
const needsAttention = run.status === "failed" || run.status === "timed_out"
// Two tiers only: resting (default border + muted bg) vs attention
// (destructive border + muted bg). Active runs earn a pulsing dot,
// not a third border color — less rhythm, more signal.
return (
<div
key={`r-${run.id}`}
className={`rounded-lg border px-3 py-2.5 bg-muted ${
needsAttention ? "border-destructive" : "border-border"
}`}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{isActive ? <span className="h-2 w-2 animate-pulse rounded-full bg-success" /> : null}
<span className="truncate text-[13px] font-medium text-foreground">
{run.summary || (isActive ? t("exec.runningEllipsis") : t("exec.completedRun"))}
</span>
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground">
{runAgent ? <span>{runAgent.name}</span> : null}
<span>{run.connectorId.replace("_local", "")}</span>
{run.model ? <span className="font-mono">{run.model}</span> : null}
<span>{formatTime(run.startedAt)}</span>
{run.costUsd != null && run.costUsd > 0 ? <span className="font-mono tabular-nums">{formatMoney(run.costUsd)}</span> : null}
</div>
</div>
<StatusPill status={run.status} />
</div>
{run.errorMessage ? (
<div className="mt-1.5 rounded bg-destructive-muted px-2 py-1 text-[11px] text-destructive break-words">
{run.errorMessage}
</div>
) : null}
</div>
)
})}
</div>
)}
{submitError ? <InlineNotice message={submitError} /> : null}
<div className="flex gap-2">
<input
aria-label={t("exec.sendMessage")}
className="flex-1 rounded-md border border-border bg-card px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-[color:var(--primary)] focus:outline-none"
placeholder={t("exec.sendMessage")}
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void handleSubmit() } }}
/>
<ActionButton label={t("exec.send")} tone="accent" onClick={() => void handleSubmit()} icon={ArrowRight} loading={submitting} />
</div>
</div>
)
}
@@ -0,0 +1,76 @@
import { useState } from "react"
import { CheckCircle, XCircle } from "../ui/icons"
import type { GoalRecord, ProfileSnapshot, ProjectRecord, TaskRecord, TaskStatus, WorkspaceRecord } from "@shared/types"
import { useT } from "../../i18n"
import { unwrap } from "../../lib/desktop"
import { ActionButton, Input, Select, TextArea } from "../ui"
import { describeActionError } from "./execution-utils"
export function TaskInspectorEditForm({
task, agents, goals, projects, workspaces, onDone, onRefresh, onError, onNotice, addToast,
}: {
task: TaskRecord
agents: ProfileSnapshot["agents"]
goals: GoalRecord[]
projects: ProjectRecord[]
workspaces: WorkspaceRecord[]
onDone: () => void
onRefresh: () => Promise<void>
onError: (message: string | null) => void
onNotice: (message: string | null) => void
addToast: (message: string, tone?: "success" | "warn" | "danger") => void
}) {
const t = useT()
const [title, setTitle] = useState(task.title)
const [description, setDescription] = useState(task.description)
const [priority, setPriority] = useState(task.priority)
const [status, setStatus] = useState<TaskStatus>(task.status)
const [assignee, setAssignee] = useState(task.assigneeAgentId ?? "")
const [workspace, setWorkspace] = useState(task.workspaceId ?? "")
const [project, setProject] = useState(task.projectId ?? "")
const [goal, setGoal] = useState(task.goalId ?? "")
const [saving, setSaving] = useState(false)
const save = async () => {
onError(null); onNotice(null); setSaving(true)
try {
unwrap(await window.agentCompany.saveTask({
id: task.id,
companyId: task.companyId,
title,
description,
priority: priority as TaskRecord["priority"],
status,
assigneeAgentId: assignee || null,
workspaceId: workspace || null,
projectId: project || null,
goalId: goal || null,
}))
await onRefresh()
onDone()
onNotice("Task changes saved.")
addToast("Task changes saved", "success")
} catch (e) {
onError(describeActionError(e))
} finally {
setSaving(false)
}
}
return (
<div className="grid min-w-0 gap-3 md:grid-cols-2">
<div className="md:col-span-2"><Input label={t("exec.title")} value={title} onChange={setTitle} placeholder={t("exec.taskTitle")} /></div>
<div className="md:col-span-2"><TextArea label={t("exec.description")} value={description} onChange={setDescription} placeholder={t("exec.description")} rows={4} /></div>
<Select label={t("exec.priority")} value={priority} onChange={(v) => setPriority(v as TaskRecord["priority"])} options={["critical", "high", "medium", "low"].map((v) => ({ value: v, label: v }))} />
<Select label={t("exec.status")} value={status} onChange={(v) => setStatus(v as TaskStatus)} options={["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"].map((v) => ({ value: v, label: v.replaceAll("_", " ") }))} />
<Select label={t("exec.assignee")} value={assignee} onChange={setAssignee} options={[{ value: "", label: t("exec.unassigned") }, ...agents.map((a) => ({ value: a.id, label: a.name }))]} />
<Select label={t("exec.workspace")} value={workspace} onChange={setWorkspace} options={[{ value: "", label: t("exec.noWorkspace") }, ...workspaces.map((w) => ({ value: w.id, label: w.name }))]} />
<Select label={t("exec.project")} value={project} onChange={setProject} options={[{ value: "", label: t("exec.noProject") }, ...projects.map((p) => ({ value: p.id, label: p.name }))]} />
<Select label={t("exec.goal")} value={goal} onChange={setGoal} options={[{ value: "", label: t("exec.noGoal") }, ...goals.map((g) => ({ value: g.id, label: g.title }))]} />
<div className="md:col-span-2 flex gap-3">
<ActionButton label={t("exec.save")} tone="accent" onClick={() => void save()} icon={CheckCircle} loading={saving} />
<ActionButton label={t("exec.cancel")} onClick={onDone} icon={XCircle} />
</div>
</div>
)
}
@@ -0,0 +1,72 @@
import type { ProfileSnapshot, TaskRecord } from "@shared/types"
import { useT } from "../../i18n"
import { formatMoney } from "../../lib/formatters"
import { ActionButton } from "../ui"
import { Robot, ShieldCheck, Warning } from "../ui/icons"
export function TaskInspectorGovernance({
task, assignee, reviewer, onCreateApproval, creatingApproval,
}: {
task: TaskRecord
assignee: ProfileSnapshot["agents"][number] | null
reviewer: ProfileSnapshot["agents"][number] | null
onCreateApproval: () => Promise<void>
creatingApproval: boolean
}) {
const t = useT()
const budgetRatio = assignee && assignee.budgetMonthlyUsd > 0 ? assignee.spentMonthlyUsd / assignee.budgetMonthlyUsd : 0
const budgetWarn = budgetRatio > 0.8
const needsGovernance = task.status === "blocked" || (task.status === "in_review" && !reviewer)
const inReviewWithReviewer = task.status === "in_review" && reviewer
const anything = budgetWarn || needsGovernance || inReviewWithReviewer
if (!anything) {
return <div className="text-[13px] text-muted-foreground">{t("exec.noGovernanceSignals", "No governance signals.")}</div>
}
return (
<div className="space-y-3">
{budgetWarn && assignee ? (
<div className={`flex items-center gap-2 rounded-lg border px-4 py-3 text-[13px] font-medium ${
budgetRatio > 1 ? "border-[color:var(--destructive)] bg-destructive-muted text-destructive" : "border-[color:var(--destructive)] bg-warning-muted text-warning"
}`}>
<Warning size={16} weight="fill" />
{assignee.name} {t("exec.budgetUsed").replace("{0}", String(Math.round(budgetRatio * 100)))}
({formatMoney(assignee.spentMonthlyUsd)} / {formatMoney(assignee.budgetMonthlyUsd)})
</div>
) : null}
{inReviewWithReviewer ? (
<div className="rounded-lg border border-[color:var(--primary)] bg-accent px-4 py-3">
<div className="flex items-center gap-2 text-[13px] font-semibold text-primary">
<Robot size={16} weight="fill" />
{t("exec.beingReviewedBy").replace("{0}", reviewer.name + (reviewer.title ? ` (${reviewer.title})` : ""))}
</div>
<div className="mt-1 text-[13px] leading-relaxed text-primary">
{t("exec.submittedForReview").replace("{0}", assignee?.name ?? "").replace("{1}", reviewer.name)}
</div>
</div>
) : null}
{needsGovernance ? (
<div className="rounded-lg border border-[color:var(--destructive)] bg-warning-muted px-4 py-3">
<div className="flex items-center gap-2 text-[13px] font-semibold text-warning">
<ShieldCheck size={16} weight="fill" />
{task.status === "blocked" ? t("exec.governanceRequired") : t("exec.waitingOnReview")}
</div>
<div className="mt-1 text-[13px] leading-relaxed text-muted-foreground">
{task.status === "blocked" ? t("exec.blockedGovernance") : t("exec.reviewGovernance")}
</div>
<div className="mt-3">
<ActionButton
label={task.status === "blocked" ? t("exec.createUnblockApproval") : t("exec.createReviewApproval")}
onClick={() => void onCreateApproval()}
icon={ShieldCheck}
loading={creatingApproval}
/>
</div>
</div>
) : null}
</div>
)
}
@@ -0,0 +1,79 @@
import { CheckCircle, Pencil, Play, Trash, XCircle, ArrowRight } from "../ui/icons"
import type { GoalRecord, ProfileSnapshot, ProjectRecord, RunRecord, TaskRecord, TaskStatus, WorkspaceRecord } from "@shared/types"
import { useT } from "../../i18n"
import { formatMoney } from "../../lib/formatters"
import { ActionButton, StatusPill } from "../ui"
export function TaskInspectorOverview({
task, tasks, activeRun, assignee, project, goal, workspace,
onRun, onCancelRun, onQuickStatus, onOpenDiscussion, onEdit, onDelete, updatingStatus,
}: {
task: TaskRecord
tasks: TaskRecord[]
activeRun: RunRecord | null
assignee: ProfileSnapshot["agents"][number] | null
project: ProjectRecord | null
goal: GoalRecord | null
workspace: WorkspaceRecord | null
onRun: (taskId: string) => Promise<void>
onCancelRun: (runId: string) => Promise<void>
onQuickStatus: (status: TaskStatus) => Promise<void>
onOpenDiscussion: (taskId: string) => void
onEdit: () => void
onDelete: () => void
updatingStatus: TaskStatus | null
}) {
const t = useT()
const parent = task.parentId ? tasks.find((p) => p.id === task.parentId) ?? null : null
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<StatusPill status={task.status} />
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground">
{task.priority}
</span>
{assignee && <span className="text-[12px] text-muted-foreground">{t("exec.assigneeLabel")} <span className="text-foreground">{assignee.name}</span></span>}
</div>
<div className="grid gap-x-6 gap-y-1 text-[13px] md:grid-cols-2">
{project && <div className="text-muted-foreground">{t("exec.projectLabel")} <span className="text-foreground">{project.name}</span></div>}
{goal && <div className="text-muted-foreground">{t("exec.goalLabel")} <span className="text-foreground">{goal.title}</span></div>}
{parent && <div className="text-muted-foreground">{t("exec.parentLabel")} <span className="text-foreground">{parent.title}</span></div>}
{workspace && <div className="text-muted-foreground">{t("exec.workspaceLabel")} <span className="text-foreground">{workspace.name}</span></div>}
{task.costUsd > 0 && <div className="text-muted-foreground">{t("exec.costLabel")} <span className="font-mono tabular-nums text-foreground">{formatMoney(task.costUsd)}</span></div>}
</div>
{task.description ? <div className="text-[13px] leading-relaxed text-muted-foreground whitespace-pre-wrap">{task.description}</div> : null}
{/* Action row grouped by weight: primary (accent) · secondary (default) · destructive (danger).
gap-6 between groups, gap-2 within a group — spacing does the tiering. */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
<div className="flex flex-wrap items-center gap-2">
{activeRun ? (
<ActionButton label={t("exec.cancelRun")} onClick={() => void onCancelRun(activeRun.id)} icon={XCircle} tone="danger" />
) : (
<>
{["todo", "in_progress", "blocked"].includes(task.status) && (
<ActionButton label={t("exec.run")} onClick={() => void onRun(task.id)} icon={Play} tone="accent" />
)}
{task.status === "in_review" && (
<ActionButton label={t("exec.done")} onClick={() => void onQuickStatus("done")} icon={CheckCircle} tone="accent" loading={updatingStatus === "done"} />
)}
</>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{!activeRun && task.status === "blocked" && (
<ActionButton label={t("exec.unblock")} onClick={() => void onQuickStatus("in_progress")} icon={Play} loading={updatingStatus === "in_progress"} />
)}
<ActionButton label={t("exec.discussion")} onClick={() => onOpenDiscussion(task.id)} icon={ArrowRight} />
<ActionButton label={t("exec.edit")} onClick={onEdit} icon={Pencil} />
</div>
<div className="flex flex-wrap items-center gap-2">
<ActionButton label={t("exec.delete")} onClick={onDelete} icon={Trash} tone="danger" />
</div>
</div>
</div>
)
}
@@ -0,0 +1,47 @@
import type { ProfileSnapshot, TaskRecord } from "@shared/types"
import { useT } from "../../i18n"
import { Eyebrow, StatusPill } from "../ui"
import { ListChecks } from "../ui/icons"
export function TaskInspectorSubtasks({ task, tasks, agents }: {
task: TaskRecord; tasks: TaskRecord[]; agents: ProfileSnapshot["agents"]
}) {
const t = useT()
const subtasks = tasks.filter((s) => s.parentId === task.id)
const agentMap = new Map(agents.map((a) => [a.id, a]))
if (subtasks.length === 0) {
return <div className="text-[13px] text-muted-foreground">{t("exec.noSubtasks", "No subtasks.")}</div>
}
const done = subtasks.filter((s) => s.status === "done").length
const pct = (done / subtasks.length) * 100
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<ListChecks size={14} className="text-muted-foreground" />
<Eyebrow>{t("exec.subtasks")} ({subtasks.length})</Eyebrow>
<span className="text-[10px] text-success">
{t("exec.doneCount").replace("{0}", String(done)).replace("{1}", String(subtasks.length))}
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-success transition-all duration-300" style={{ width: `${pct}%` }} />
</div>
<div className="space-y-1">
{subtasks.map((sub) => {
const subAssignee = agentMap.get(sub.assigneeAgentId ?? "")
return (
<div key={sub.id} className="flex items-center gap-2 rounded-lg px-3 py-2 text-[12px] bg-muted">
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${sub.status === "done" ? "bg-success" : sub.status === "in_progress" ? "bg-primary" : sub.status === "blocked" ? "bg-destructive" : sub.status === "in_review" ? "bg-warning" : "bg-[color:var(--muted-foreground)]"}`} />
<span className={`flex-1 truncate ${sub.status === "done" ? "line-through text-muted-foreground" : "text-foreground"}`}>{sub.title}</span>
{subAssignee ? <span className="shrink-0 text-[10px] text-muted-foreground">{subAssignee.name}</span> : null}
<StatusPill status={sub.status} />
</div>
)
})}
</div>
</div>
)
}
@@ -18,7 +18,7 @@ export function TaskQueue({ tasks, runs, selectedTaskId, onSelect }: { tasks: Ta
overscan: 10,
})
if (tasks.length === 0) return <EmptyState title={t("exec.taskQueueEmpty")} detail={t("exec.taskQueueEmptyDetail")} />
if (tasks.length === 0) return <EmptyState title={t("exec.taskQueueEmpty")} />
return (
<div
ref={parentRef}
@@ -3,30 +3,102 @@ import type { AgentRecord, DocumentRecord, ProjectRecord, RunRecord, TaskRecord,
import { useT } from "../../i18n"
import { formatTime } from "../../lib/formatters"
import { ActionButton, EmptyState, StatusPill } from "../ui"
import { PriorityDot, WorkspaceStatePill } from "./execution-utils"
import { PriorityDot } from "./execution-utils"
export function WorkspaceInspector({ workspace, agents, tasks, runs, documents, project, onOpen }: {
export function WorkspaceInspector({ workspace, agents, tasks, runs, documents: _documents, project, onOpen }: {
workspace: WorkspaceRecord | null; agents: AgentRecord[]; tasks: TaskRecord[]; runs: RunRecord[]; documents: DocumentRecord[]; project: ProjectRecord | null; onOpen: (path: string) => Promise<void>
}) {
const t = useT()
if (!workspace) return <EmptyState title={t("exec.selectWorkspace")} detail={t("exec.selectWorkspaceDetail")} />
if (!workspace) return <EmptyState title={t("exec.selectWorkspace")} />
const wAgents = agents.filter((a) => a.workspaceId === workspace.id); const wTasks = tasks.filter((tk) => tk.workspaceId === workspace.id)
const wRuns = runs.filter((r) => r.workspaceId === workspace.id); const wDocs = documents.filter((d) => d.projectId && project && d.projectId === project.id)
const wRuns = runs.filter((r) => r.workspaceId === workspace.id)
const hasMeta = Boolean(project || workspace.repoUrl || workspace.repoRef)
return (
<div className="space-y-5">
<div className="rounded-lg border border-border bg-card p-4">
<div className="flex items-start justify-between gap-3"><div className="min-w-0"><div className="flex items-center gap-2"><div className="text-[15px] font-semibold text-foreground">{workspace.name}</div><WorkspaceStatePill state={workspace.state} /></div><div className="font-mono mt-1 text-[11px] text-muted-foreground">{workspace.localPath}</div><div className="mt-2 text-[12px] text-muted-foreground">{project ? <>Project: <span className="text-foreground">{project.name}</span></> : "No linked project"}{workspace.repoUrl ? <span> · Repo: <span className="text-foreground">{workspace.repoUrl}</span></span> : null}{workspace.repoRef ? <span> · Ref: <span className="text-foreground">{workspace.repoRef}</span></span> : null}</div></div><ActionButton label={t("exec.openFolder")} onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} /></div>
</div>
<div className="grid gap-3 md:grid-cols-4">
{[{ label: t("exec.agents"), val: wAgents.length }, { label: t("exec.tasks"), val: wTasks.length }, { label: t("exec.runs"), val: wRuns.length }, { label: t("exec.docs"), val: wDocs.length }].map((s) => (
<div key={s.label} className="rounded-lg border border-border bg-card p-4"><div className="text-[11px] uppercase tracking-[0.08em] text-muted-foreground">{s.label}</div><div className="mt-2 text-[24px] font-semibold text-foreground">{s.val}</div></div>
))}
</div>
<div className="grid gap-4 xl:grid-cols-3">
<div className="rounded-lg border border-border bg-card p-4"><div className="mb-3 text-[12px] font-semibold text-foreground">{t("exec.assignedAgents")}</div>{wAgents.length === 0 ? <div className="text-[12px] text-muted-foreground">{t("exec.noAgentsBound")}</div> : <div className="space-y-2">{wAgents.map((a) => <div key={a.id} className="flex items-center justify-between rounded-lg bg-muted px-3 py-2"><div><div className="text-[12px] font-medium text-foreground">{a.name}</div><div className="text-[11px] text-muted-foreground">{a.role}</div></div><StatusPill status={a.status} /></div>)}</div>}</div>
<div className="rounded-lg border border-border bg-card p-4"><div className="mb-3 text-[12px] font-semibold text-foreground">{t("exec.openTasks")}</div>{wTasks.length === 0 ? <div className="text-[12px] text-muted-foreground">{t("exec.noTasksTarget")}</div> : <div className="space-y-2">{wTasks.slice(0, 6).map((tk) => <div key={tk.id} className="rounded-lg bg-muted px-3 py-2"><div className="flex items-center gap-2"><PriorityDot priority={tk.priority} /><span className="text-[12px] font-medium text-foreground">{tk.title}</span></div><div className="mt-1 text-[11px] text-muted-foreground">{tk.status.replaceAll("_"," ")}</div></div>)}</div>}</div>
<div className="rounded-lg border border-border bg-card p-4"><div className="mb-3 text-[12px] font-semibold text-foreground">{t("exec.recentRuns")}</div>{wRuns.length === 0 ? <div className="text-[12px] text-muted-foreground">{t("exec.noRunsExecuted")}</div> : <div className="space-y-2">{wRuns.slice(0, 6).map((r) => <div key={r.id} className="rounded-lg bg-muted px-3 py-2"><div className="flex items-center justify-between gap-2"><span className="text-[12px] font-medium text-foreground">{r.summary || `Run ${r.id.slice(0, 8)}`}</span><StatusPill status={r.status} /></div><div className="mt-1 text-[11px] text-muted-foreground">{formatTime(r.startedAt)} → {formatTime(r.finishedAt)}</div></div>)}</div>}</div>
{hasMeta ? (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 text-[12px] text-muted-foreground">
{project ? <>Project: <span className="text-foreground">{project.name}</span></> : null}
{workspace.repoUrl ? <span>{project ? " · " : ""}Repo: <span className="text-foreground">{workspace.repoUrl}</span></span> : null}
{workspace.repoRef ? <span> · Ref: <span className="text-foreground">{workspace.repoRef}</span></span> : null}
</div>
<ActionButton label={t("exec.openFolder")} onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} />
</div>
) : (
<div className="flex items-start justify-end">
<ActionButton label={t("exec.openFolder")} onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} />
</div>
)}
<div className="grid gap-10 [grid-template-columns:repeat(auto-fit,minmax(240px,1fr))]">
<section>
<div className="mb-2 flex items-baseline gap-2 border-b border-border pb-1.5 text-[11px] uppercase tracking-[0.12em] text-muted-foreground">
<span>{t("exec.assignedAgents")}</span>
<span className="text-success tracking-normal">{wAgents.length}</span>
</div>
{wAgents.length === 0 ? (
<div className="py-2 pl-2 text-[12px] text-muted-foreground">{t("exec.noAgentsBound")}</div>
) : (
<div className="divide-y divide-border/60">
{wAgents.map((a) => (
<div key={a.id} className="group grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 border-l-2 border-transparent py-2 pl-2 pr-1 transition-colors hover:border-l-[color:var(--success)]">
<span aria-hidden="true" className="select-none text-muted-foreground transition-colors group-hover:text-success">›</span>
<div className="min-w-0">
<div className="truncate text-[12px] font-medium text-foreground">{a.name}</div>
<div className="truncate text-[11px] text-muted-foreground">{a.role}</div>
</div>
<StatusPill status={a.status} />
</div>
))}
</div>
)}
</section>
<section>
<div className="mb-2 flex items-baseline gap-2 border-b border-border pb-1.5 text-[11px] uppercase tracking-[0.12em] text-muted-foreground">
<span>{t("exec.openTasks")}</span>
<span className="text-success tracking-normal">{wTasks.length}</span>
</div>
{wTasks.length === 0 ? (
<div className="py-2 pl-2 text-[12px] text-muted-foreground">{t("exec.noTasksTarget")}</div>
) : (
<div className="divide-y divide-border/60">
{wTasks.slice(0, 6).map((tk) => (
<div key={tk.id} className="group grid grid-cols-[auto_minmax(0,1fr)] items-start gap-3 border-l-2 border-transparent py-2 pl-2 pr-1 transition-colors hover:border-l-[color:var(--success)]">
<span aria-hidden="true" className="select-none pt-[1px] text-muted-foreground transition-colors group-hover:text-success">›</span>
<div className="min-w-0">
<div className="flex items-center gap-2">
<PriorityDot priority={tk.priority} />
<span className="text-[12px] font-medium text-foreground">{tk.title}</span>
</div>
<div className="mt-1 text-[11px] text-muted-foreground">{tk.status.replaceAll("_", " ")}</div>
</div>
</div>
))}
</div>
)}
</section>
<section>
<div className="mb-2 flex items-baseline gap-2 border-b border-border pb-1.5 text-[11px] uppercase tracking-[0.12em] text-muted-foreground">
<span>{t("exec.recentRuns")}</span>
<span className="text-success tracking-normal">{wRuns.length}</span>
</div>
{wRuns.length === 0 ? (
<div className="py-2 pl-2 text-[12px] text-muted-foreground">{t("exec.noRunsExecuted")}</div>
) : (
<div className="divide-y divide-border/60">
{wRuns.slice(0, 6).map((r) => (
<div key={r.id} className="group grid grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-3 border-l-2 border-transparent py-2 pl-2 pr-1 transition-colors hover:border-l-[color:var(--success)]">
<span aria-hidden="true" className="select-none pt-[1px] text-muted-foreground transition-colors group-hover:text-success">›</span>
<div className="min-w-0">
<div className="truncate text-[12px] font-medium text-foreground">{r.summary || `Run ${r.id.slice(0, 8)}`}</div>
<div className="mt-1 text-[11px] text-muted-foreground">{formatTime(r.startedAt)} → {formatTime(r.finishedAt)}</div>
</div>
<StatusPill status={r.status} />
</div>
))}
</div>
)}
</section>
</div>
</div>
)
@@ -3,7 +3,7 @@ import { ArrowSquareOut, FolderOpen, Trash } from "../ui/icons"
import type { ProjectRecord, WorkspaceRecord } from "@shared/types"
import { useT } from "../../i18n"
import { unwrap } from "../../lib/desktop"
import { ActionButton, ConfirmDialog, EmptyState, ListFrame, ListRow, StatusPill } from "../ui"
import { ActionButton, ConfirmDialog, EmptyState, ListFrame, ListRow } from "../ui"
import { describeActionError, WorkspaceStatePill } from "./execution-utils"
export function WorkspaceList({ workspaces, projects, companyId, selectedWorkspaceId, onSelect, onOpen, onDeleted }: {
@@ -15,7 +15,7 @@ export function WorkspaceList({ workspaces, projects, companyId, selectedWorkspa
const toDelete = workspaces.find((w) => w.id === confirmDeleteId) ?? null
async function handleDelete(id: string) { setDeleteError(null); try { unwrap(await window.agentCompany.deleteWorkspace({ id, companyId })); setConfirmDeleteId(null); await onDeleted() } catch (e) { setDeleteError(describeActionError(e)) } }
if (workspaces.length === 0) return <EmptyState title={t("exec.noWorkspacesYet")} detail={t("exec.noWorkspacesDetail")} />
if (workspaces.length === 0) return <EmptyState title={t("exec.noWorkspacesYet")} />
return (
<>
<ConfirmDialog open={confirmDeleteId !== null} title={t("exec.deleteWorkspace")} message={t("exec.deleteWorkspaceConfirm").replace("{0}", toDelete?.name ?? "")} errorMessage={deleteError} onConfirm={() => confirmDeleteId && void handleDelete(confirmDeleteId)} onCancel={() => { setDeleteError(null); setConfirmDeleteId(null) }} />
@@ -24,21 +24,28 @@ export function WorkspaceList({ workspaces, projects, companyId, selectedWorkspa
const linkedProject = projects.find((p) => p.id === workspace.projectId) ?? null
return (
<ListRow key={workspace.id}>
<div className={`mb-2 rounded-lg p-1 ${selectedWorkspaceId === workspace.id ? "bg-accent" : ""}`}>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2"><div className="truncate text-[13px] text-foreground">{workspace.name}</div><WorkspaceStatePill state={workspace.state} /></div>
<div className="font-mono truncate text-[11px] text-muted-foreground">{workspace.localPath}</div>
{(() => {
const isSelected = selectedWorkspaceId === workspace.id;
return (
<div className={`group grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 border-l-2 py-2 pl-2 pr-1 transition-colors ${isSelected ? "border-l-[color:var(--success)]" : "border-l-transparent hover:border-l-border"}`}>
<span aria-hidden="true" className={`select-none transition-colors ${isSelected ? "text-success" : "text-muted-foreground group-hover:text-foreground"}`}>›</span>
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="truncate text-[13px] font-medium text-foreground">{workspace.name}</div>
<WorkspaceStatePill state={workspace.state} />
{workspace.isPrimary ? <span className="text-[11px] text-muted-foreground">· Primary</span> : null}
</div>
<div className="truncate font-mono text-[11px] text-muted-foreground">{workspace.localPath}</div>
<div className="truncate text-[11px] text-muted-foreground">{workspace.repoUrl || "No remote URL"}{workspace.repoRef ? ` · ${workspace.repoRef}` : ""}{linkedProject ? <span> · Project: <span className="text-foreground">{linkedProject.name}</span></span> : null}</div>
</div>
<div className="flex items-center gap-2">
<ActionButton label={t("exec.inspect")} onClick={() => onSelect(workspace.id)} icon={FolderOpen} />
<ActionButton label={t("exec.reveal")} onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} />
<ActionButton label={t("exec.delete")} onClick={() => setConfirmDeleteId(workspace.id)} icon={Trash} tone="danger" />
</div>
</div>
<div className="flex items-center gap-2">
{workspace.isPrimary ? <StatusPill status="ready" /> : null}
<ActionButton label={t("exec.inspect")} onClick={() => onSelect(workspace.id)} icon={FolderOpen} />
<ActionButton label={t("exec.reveal")} onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} />
<ActionButton label={t("exec.delete")} onClick={() => setConfirmDeleteId(workspace.id)} icon={Trash} tone="danger" />
</div>
</div>
<div className="truncate text-[11px] text-muted-foreground">{workspace.repoUrl || "No remote URL"} {workspace.repoRef ? `· ${workspace.repoRef}` : ""}{linkedProject ? <span> · Project: <span className="text-foreground">{linkedProject.name}</span></span> : null}</div>
</div>
);
})()}
</ListRow>
)
})}
@@ -1,10 +1,15 @@
import { useMemo, useState } from "react"
import { CheckCircle } from "../../ui/icons"
import type { ApprovalRecord, DocumentRecord } from "@shared/types"
import { extractApprovalDocumentReferences, type ApprovalDocumentReference } from "@shared/approval-documents"
import { CheckCircle, FileText } from "../../ui/icons"
import type { ApprovalRecord, DocumentRecord, WorkProductRecord } from "@shared/types"
import {
extractApprovalDocumentReferences,
extractApprovalWorkProductIds,
inspectApprovalPayload,
type ApprovalDocumentReference,
} from "@shared/approval-documents"
import { unwrap } from "../../../lib/desktop"
import { useAppStore } from "../../../state/store"
import { ActionButton, Card, Eyebrow, InlineNotice, Input, TextArea } from "../../ui"
import { ActionButton, Card, Eyebrow, InlineNotice, Input, StatusPill, TextArea } from "../../ui"
import { useT } from "../../../i18n"
import { describeActionError } from "../execution-utils"
@@ -81,22 +86,145 @@ function ApprovalDocumentEditor({
)
}
function ApprovalWorkProductRow({
product,
companyId,
}: {
product: WorkProductRecord
companyId: string
}) {
// Mirror DocumentCenter's WorkProductRow: route through openWorkProduct so
// main can join workspace-relative paths against the correct root. Only
// rows with a URL or a filesystem externalId are openable — for anything
// else we fall back to a non-interactive display (still lets the reviewer
// see what's attached even if it can't be opened right now).
const canOpen = Boolean(product.url || (product.provider === "filesystem" && product.externalId))
const subtitle = [
product.type.replaceAll("_", " "),
product.provider,
product.externalId ?? product.url ?? null,
product.updatedAt.slice(0, 10),
]
.filter((v): v is string => Boolean(v))
.join(" · ")
const rowBody = (
<div className="min-w-0 flex-1 text-left">
<div className="flex items-center gap-2 text-[13px] font-medium text-foreground">
<FileText size={15} className="text-muted-foreground" />
<span className="truncate">{product.title}</span>
</div>
<div className="mt-1 text-[11px] text-muted-foreground truncate">{subtitle}</div>
</div>
)
return (
<Card padding="sm">
<div className="flex items-start justify-between gap-3">
{canOpen ? (
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => {
void window.agentCompany.openWorkProduct({ companyId, id: product.id })
}}
>
{rowBody}
</button>
) : (
rowBody
)}
<StatusPill status={product.status} />
</div>
</Card>
)
}
// Shimmer skeleton for pending attachment resolution. A skeleton reads as
// "content is on its way" — swapping text between "loading" and "missing"
// made both states look like errors. Tailwind's animate-pulse is enough; no
// new deps.
function ApprovalAttachmentSkeleton() {
return (
<Card padding="sm" className="bg-muted">
<div className="animate-pulse space-y-2" aria-hidden="true">
<div className="h-3 w-1/3 rounded bg-[color:var(--muted-foreground)]/20" />
<div className="h-2 w-1/2 rounded bg-[color:var(--muted-foreground)]/15" />
</div>
</Card>
)
}
export function ApprovalDocumentsPanel({
approval,
documents,
workProducts,
companyId,
}: {
approval: ApprovalRecord
documents?: DocumentRecord[]
workProducts?: WorkProductRecord[]
companyId: string
}) {
const t = useT()
const inspection = useMemo(
() => inspectApprovalPayload(approval.payloadJson),
[approval.payloadJson],
)
const references = useMemo(
() => extractApprovalDocumentReferences(approval.payloadJson),
[approval.payloadJson],
)
const workProductIds = useMemo(
() => extractApprovalWorkProductIds(approval.payloadJson),
[approval.payloadJson],
)
if (references.length === 0) return null
// Undefined = parent hasn't delivered the snapshot yet (loading). An empty
// or populated array = snapshot is authoritative; any id that fails to
// resolve against it is genuinely missing (record deleted since the
// approval was filed). The two states render as different shells — a
// shimmer skeleton for loading, a muted card with a proper title for
// missing.
const workProductsLoading = workProducts === undefined
const documentsLoading = documents === undefined
const resolvedWorkProducts = useMemo(() => {
if (!workProducts || workProductIds.length === 0) return []
const byId = new Map(workProducts.map((wp) => [wp.id, wp] as const))
return workProductIds
.map((id) => byId.get(id))
.filter((wp): wp is WorkProductRecord => wp != null)
}, [workProducts, workProductIds])
// A malformed payloadJson survived into persistence (legacy row written
// before the write-time guard existed, or a direct DB edit). The write
// boundary now catches this, but the read side must still render a
// dignified card rather than silently showing nothing — a reviewer who
// expected attachments would otherwise stare at a blank modal and
// wonder if the approval was filed correctly.
if (inspection.kind === "malformed") {
return (
<div className="space-y-3">
<Eyebrow tone="primary">{t("approval.linkedDocuments")}</Eyebrow>
<Card padding="sm" className="bg-muted">
<div className="text-[13px] font-medium text-foreground">
{t("approval.malformedPayloadTitle")}
</div>
<div className="mt-2 text-[12px] text-muted-foreground">
{t("approval.malformedPayloadBody")}
</div>
<div className="mt-2 text-[11px] font-mono text-muted-foreground break-all">
{inspection.reason}
</div>
</Card>
</div>
)
}
if (references.length === 0 && resolvedWorkProducts.length === 0 && workProductIds.length === 0) {
return null
}
const fullDocs = documents
? references
@@ -110,32 +238,64 @@ export function ApprovalDocumentsPanel({
)
: []
const unresolvedWorkProductIds = workProductIds.filter(
(id) => !resolvedWorkProducts.some((wp) => wp.id === id),
)
// When documents snapshot is loaded and a reference id is not in it, the
// document has been deleted. When the snapshot is still loading, every
// reference is "pending resolution" — render the skeleton shell instead.
const unresolvedDocumentReferences = documentsLoading
? []
: references.filter((reference) => !fullDocs.some((entry) => entry.document.id === reference.id))
const loadingDocumentCount = documentsLoading ? references.length : 0
const loadingWorkProductCount = workProductsLoading ? workProductIds.length : 0
return (
<div className="space-y-3">
<Eyebrow tone="primary">{t("approval.linkedDocuments")}</Eyebrow>
{fullDocs.length > 0
? fullDocs.map(({ reference, document }) => (
<ApprovalDocumentEditor
key={document.id}
companyId={companyId}
document={document}
fallback={reference}
/>
))
: references.map((reference) => (
<Card key={reference.id} padding="sm" className="bg-muted">
<div className="text-[13px] font-medium text-foreground">{reference.title ?? reference.id}</div>
<div className="text-[11px] text-muted-foreground">
{reference.type?.replaceAll("_", " ") ?? "document"} · {reference.id}
{resolvedWorkProducts.map((product) => (
<ApprovalWorkProductRow key={product.id} product={product} companyId={companyId} />
))}
{Array.from({ length: loadingWorkProductCount }).map((_, index) => (
<ApprovalAttachmentSkeleton key={`wp-skeleton-${index}`} />
))}
{!workProductsLoading
? unresolvedWorkProductIds.map((id) => (
<Card key={id} padding="sm" className="bg-muted">
<div className="text-[13px] font-medium text-foreground">
{t("approval.missingWorkProductTitle")}
</div>
<div className="mt-1 text-[11px] font-mono text-muted-foreground break-all">{id}</div>
<div className="mt-2 text-[12px] text-muted-foreground">
{t("approval.documentNotLoaded")}
{t("approval.documentMissing")}
</div>
</Card>
))}
{approval.impactSummary ? (
<div className="text-[11px] text-muted-foreground">{approval.impactSummary}</div>
) : null}
))
: null}
{fullDocs.map(({ reference, document }) => (
<ApprovalDocumentEditor
key={document.id}
companyId={companyId}
document={document}
fallback={reference}
/>
))}
{Array.from({ length: loadingDocumentCount }).map((_, index) => (
<ApprovalAttachmentSkeleton key={`doc-skeleton-${index}`} />
))}
{unresolvedDocumentReferences.map((reference) => (
<Card key={reference.id} padding="sm" className="bg-muted">
<div className="text-[13px] font-medium text-foreground">
{reference.title ?? t("approval.missingDocumentTitle")}
</div>
<div className="mt-1 text-[11px] font-mono text-muted-foreground break-all">
{reference.type?.replaceAll("_", " ") ?? "document"} · {reference.id}
</div>
<div className="mt-2 text-[12px] text-muted-foreground">
{t("approval.documentMissing")}
</div>
</Card>
))}
</div>
)
}
@@ -1,6 +1,9 @@
import { useState } from "react"
import type { AgentRecord, ApprovalRecord, DocumentRecord, TaskRecord } from "@shared/types"
import { extractApprovalDocumentReferences } from "@shared/approval-documents"
import type { AgentRecord, ApprovalRecord, DocumentRecord, TaskRecord, WorkProductRecord } from "@shared/types"
import {
extractApprovalDocumentReferences,
extractApprovalWorkProductIds,
} from "@shared/approval-documents"
import { useT } from "../../../i18n"
import { unwrap } from "../../../lib/desktop"
import { Card, ConfirmDialog, Eyebrow, InlineNotice } from "../../ui"
@@ -20,6 +23,7 @@ export function ApprovalInspector({
agents,
tasks,
documents,
workProducts,
companyId,
onDecide,
onDeleted,
@@ -28,6 +32,7 @@ export function ApprovalInspector({
agents: AgentRecord[]
tasks: TaskRecord[]
documents?: DocumentRecord[]
workProducts?: WorkProductRecord[]
companyId: string
onDecide: (state: DecisionState, note?: string) => Promise<void>
onDeleted: () => Promise<void>
@@ -47,7 +52,9 @@ export function ApprovalInspector({
const isDecided = approval.state !== "pending"
const isSocialPost = approval.type === "social_post"
const socialPlatform = useSocialPlatform(approval)
const hasDocumentReferences = extractApprovalDocumentReferences(approval.payloadJson).length > 0
const hasDocumentReferences =
extractApprovalDocumentReferences(approval.payloadJson).length > 0 ||
extractApprovalWorkProductIds(approval.payloadJson).length > 0
async function handleDelete() {
setActionError(null)
@@ -114,6 +121,17 @@ export function ApprovalInspector({
<div className="border-y border-border py-4 space-y-4">
<ApprovalSummary approval={approval} requestingAgent={requestingAgent} relatedTask={relatedTask} />
{approval.type === "hire_agent" ? <ProposedHireCard approval={approval} agents={agents} /> : null}
{/* Documents/work-products promoted above the warning banner: whatever
the reviewer is approving, the artifacts themselves are the
primary evidence. Pushing them to the first screen means the
operator can open the PDF or read the doc before wading through
org-strategy boilerplate. */}
<ApprovalDocumentsPanel
approval={approval}
documents={documents}
workProducts={workProducts}
companyId={companyId}
/>
{approval.type === "approve_ceo_strategy" ? (
<Card
padding="sm"
@@ -127,7 +145,6 @@ export function ApprovalInspector({
</div>
</Card>
) : null}
<ApprovalDocumentsPanel approval={approval} documents={documents} companyId={companyId} />
{approval.type === "deliverable_review" && !hasDocumentReferences ? (
<DeliverableReviewPanel approval={approval} documents={documents} agents={agents} />
) : null}
@@ -1,3 +1,4 @@
import type { ProfileSnapshot, TaskRecord } from "@shared/types"
import {
Robot, CheckSquare, Play, ShieldCheck, Gear, FolderOpen, Key, Target, Briefcase, Buildings,
} from "../ui/icons"
@@ -6,6 +7,45 @@ export function describeActionError(error: unknown) {
return error instanceof Error ? error.message : "The action could not be completed."
}
export type GovernanceAlertKind = "blocked" | "budget_over" | "missing_reviewer"
export type GovernanceAlertSeverity = "warning" | "danger"
export interface GovernanceAlert {
kind: GovernanceAlertKind
severity: GovernanceAlertSeverity
}
type Agent = ProfileSnapshot["agents"][number]
/**
* Collect the governance signals the user should see the moment a task opens.
* Returned alerts drive the banner at the top of Overview and the badge on
* the Governance tab; the tab itself still renders the full narrative + action.
*/
export function shouldAlertGovernance(
task: TaskRecord,
assignee?: Agent | null,
reviewer?: Agent | null,
): GovernanceAlert[] {
const alerts: GovernanceAlert[] = []
if (task.status === "blocked") {
alerts.push({ kind: "blocked", severity: "danger" })
}
if (assignee && assignee.budgetMonthlyUsd > 0) {
const ratio = assignee.spentMonthlyUsd / assignee.budgetMonthlyUsd
if (ratio > 0.8) {
alerts.push({ kind: "budget_over", severity: ratio > 1 ? "danger" : "warning" })
}
}
if (task.status === "in_review" && !reviewer) {
alerts.push({ kind: "missing_reviewer", severity: "warning" })
}
return alerts
}
export const ENTITY_ICONS: Record<string, typeof Robot> = {
agent: Robot, task: CheckSquare, run: Play, approval: ShieldCheck, connector: Gear,
workspace: FolderOpen, secret: Key, goal: Target, project: Briefcase, company: Buildings,
@@ -33,7 +33,7 @@ export function AgentRoster({
})
if (agents.length === 0) {
return <EmptyState title={t("org.noAgentsYet")} detail={t("org.noAgentsDetail")} />
return <EmptyState title={t("org.noAgentsYet")} />
}
return (
@@ -16,7 +16,7 @@ export function GoalList({
}) {
const t = useT();
if (goals.length === 0) {
return <EmptyState title={t("org.noGoalsYet")} detail={t("org.noGoalsDetail")} />;
return <EmptyState title={t("org.noGoalsYet")} />;
}
return (
<ListFrame>
@@ -82,7 +82,7 @@ export function OrgChart({ agents, connectors, onSelectAgent }: { agents: AgentR
setZoom(nz);
}, [zoom, pan]);
if (agents.length === 0) return <EmptyState title={t("org.noAgentsToDisplay")} detail={t("org.noAgentsToDisplayDetail")} />;
if (agents.length === 0) return <EmptyState title={t("org.noAgentsToDisplay")} />;
const zoomIn = () => { const nz = Math.min(zoom * 1.2, 2); const c = containerRef.current; if (c) { const cx = c.clientWidth / 2; const cy = c.clientHeight / 2; const s = nz / zoom; setPan({ x: cx - s * (cx - pan.x), y: cy - s * (cy - pan.y) }); } setZoom(nz); };
const zoomOut = () => { const nz = Math.max(zoom * 0.8, 0.2); const c = containerRef.current; if (c) { const cx = c.clientWidth / 2; const cy = c.clientHeight / 2; const s = nz / zoom; setPan({ x: cx - s * (cx - pan.x), y: cy - s * (cy - pan.y) }); } setZoom(nz); };
@@ -20,7 +20,7 @@ export function ProjectList({
}) {
const t = useT();
if (projects.length === 0) {
return <EmptyState title={t("org.noProjectsYet")} detail={t("org.noProjectsDetail")} />;
return <EmptyState title={t("org.noProjectsYet")} />;
}
return (
<ListFrame>
+1 -4
View File
@@ -77,10 +77,7 @@ export function RunCenter({
if (runs.length === 0) {
return (
<EmptyState
title={t("runs.noRunsYet")}
detail={t("runs.noRunsDetail")}
/>
<EmptyState title={t("runs.noRunsYet")} />
);
}
+1 -4
View File
@@ -93,10 +93,7 @@ export function RunQueue({
})}
</ListFrame>
{filteredRuns.length === 0 ? (
<EmptyState
title={t("runs.noRunsMatch")}
detail={t("runs.noRunsMatchDetail")}
/>
<EmptyState title={t("runs.noRunsMatch")} />
) : null}
</Panel>
</div>
@@ -106,7 +106,6 @@ export function SkillsSection({
{skills.length === 0 ? (
<EmptyState
title={t("skills.emptyTitle")}
detail={t("skills.emptyDetail")}
action={
<ActionButton
label={t("skills.importButton")}
@@ -139,10 +138,7 @@ export function SkillsSection({
/>
) : (
<div className="flex flex-1 items-center justify-center">
<EmptyState
title={t("skills.selectToViewTitle")}
detail={t("skills.selectToViewDetail")}
/>
<EmptyState title={t("skills.selectToViewTitle")} />
</div>
)}
</div>
+10 -7
View File
@@ -8,22 +8,25 @@ export function Panel({
className = "",
tone = "plain",
}: {
title: string;
title?: string;
eyebrow?: string;
children: ReactNode;
action?: ReactNode;
className?: string;
tone?: "plain" | "surface";
}) {
const hasHeader = Boolean(title || eyebrow || action);
return (
<section className={`space-y-4 ${className}`}>
<div className={`flex items-end justify-between gap-4 ${tone === "surface" ? "border-b border-border pb-3" : ""}`}>
<div>
{eyebrow ? <div className="mb-1 text-[11px] font-semibold uppercase tracking-[0.08em] text-primary">{eyebrow}</div> : null}
<h2 className="text-[20px] font-bold tracking-[-0.025em] text-foreground">{title}</h2>
{hasHeader ? (
<div className={`flex items-end justify-between gap-4 ${tone === "surface" ? "border-b border-border pb-3" : ""}`}>
<div>
{eyebrow ? <div className="mb-1 text-[11px] font-semibold uppercase tracking-[0.08em] text-primary">{eyebrow}</div> : null}
{title ? <h2 className="text-[15px] font-semibold tracking-[-0.01em] text-foreground">{title}</h2> : null}
</div>
{action}
</div>
{action}
</div>
) : null}
<div>{children}</div>
</section>
);
+32 -4
View File
@@ -97,20 +97,48 @@ export function ListRow({
children,
className = "",
onClick,
ariaLabel,
}: {
children: ReactNode;
className?: string;
onClick?: () => void;
/**
* Optional accessible name for the row. Required whenever the row's visible
* children don't already include readable text (e.g. icon-only or graphic
* rows). Omit when children already provide a textual label.
*/
ariaLabel?: string;
}) {
if (onClick) {
// Use role="button" on a div (instead of a native <button>) so callers can
// nest real <button> elements inside for per-action controls (edit, delete,
// status pills). Nested <button> inside <button> is invalid HTML.
//
// `has-[[data-row-action]:hover]:bg-transparent` suppresses the row's
// hover bg when the pointer is actually over an inner action cluster
// (marked with `data-row-action`). This way hover tells the user where
// the click lands: on the row, or on the action — never both at once.
return (
<button
type="button"
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 block w-full border-b border-border px-5 py-4 text-left last:border-b-0 ${className}`}
<div
role="button"
tabIndex={0}
aria-label={ariaLabel}
className={`block w-full cursor-pointer border-b border-border px-5 py-4 text-left transition-colors last:border-b-0 hover:bg-muted/50 has-[[data-row-action]:hover]:bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset ${className}`}
onClick={onClick}
onKeyDown={(event) => {
// Only handle keys that originated on the row itself. Without this
// guard, Space/Enter dispatched from a nested <button> (e.g. an
// action pill) would bubble up, get preventDefault'd, and trigger
// the row's onClick — swallowing the inner button's own activation.
if (event.target !== event.currentTarget) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick();
}
}}
>
{children}
</button>
</div>
);
}
return <div className={`border-b border-border px-5 py-4 last:border-b-0 ${className}`}>{children}</div>;