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>;
+7 -24
View File
@@ -215,10 +215,8 @@ export const en: Record<string, string> = {
"skills.filterPlaceholder": "Filter skills...",
"skills.filterAria": "Filter skills",
"skills.emptyTitle": "No skills imported",
"skills.emptyDetail": "Import skills from GitHub, a local path, or skills.sh to get started.",
"skills.importButton": "Import Skill",
"skills.selectToViewTitle": "Select a skill to view details",
"skills.selectToViewDetail": "Choose a skill from the list to see its content, file tree, and configuration.",
"agent.pendingApproval": "Pending Approval",
"agent.pendingHireNote": "Pending hires must be approved or rejected from Hiring or Approvals.",
@@ -403,7 +401,6 @@ export const en: Record<string, string> = {
// ── Execution / Task Inspector ──
"exec.taskQueueEmpty": "All caught up",
"exec.taskQueueEmptyDetail": "New tasks show up here.",
"exec.deleteTask": "Delete task",
"exec.deleteTaskConfirm": "Permanently delete \"{0}\"? This cannot be undone.",
"exec.budgetUsed": "has used {0}% of their monthly budget",
@@ -458,7 +455,6 @@ export const en: Record<string, string> = {
"exec.createTask": "Create task",
"exec.taskTitle": "Task title",
"exec.noReviewsInProgress": "No reviews pending",
"exec.noReviewsDetail": "Review items show up here.",
"exec.awaitingReview": "awaiting review",
"exec.reviewedBy": "reviewed by",
"exec.reviewGroupBoard": "Board review",
@@ -467,7 +463,6 @@ export const en: Record<string, string> = {
"exec.reviewAwaitsBoard": "awaiting board approval",
"exec.reviewAwaitsUser": "awaiting your review",
"exec.noApprovals": "No pending approvals",
"exec.noApprovalsDetail": "Approvals show up here when agents need you.",
"exec.deleteApproval": "Delete approval",
"exec.deleteApprovalConfirm": "Permanently delete this approval? This cannot be undone.",
"exec.requestedBy": "Requested by:",
@@ -490,7 +485,6 @@ export const en: Record<string, string> = {
"exec.reject": "Reject",
"exec.lastNote": "Last note:",
"exec.noCostData": "No costs recorded yet",
"exec.noCostDataDetail": "Costs show up here after runs start.",
"exec.allConnectors": "All connectors",
"exec.runCost": "Run cost",
"exec.unknownAgent": "Unknown agent",
@@ -499,20 +493,17 @@ export const en: Record<string, string> = {
"exec.knownSpend": "Known spend",
"exec.unattributedEntries": "Unattributed entries",
"exec.noActivityYet": "No activity recorded yet",
"exec.noActivityDetail": "Task and system updates show here.",
"exec.searchActivity": "Search activity...",
"exec.allTypes": "All types",
"exec.noMatchingActivity": "No activity matches this filter",
"exec.loadMore": "Load more ({0} remaining)",
"exec.noWorkspacesYet": "No workspaces created yet",
"exec.noWorkspacesDetail": "Workspaces are created automatically.",
"exec.deleteWorkspace": "Delete workspace",
"exec.deleteWorkspaceConfirm": "Delete workspace \"{0}\"? Files on disk stay.",
"exec.inspect": "Inspect",
"exec.reveal": "Reveal",
"exec.noRemoteUrl": "No remote URL",
"exec.selectWorkspace": "Select a workspace",
"exec.selectWorkspaceDetail": "Pick a workspace to see its tasks and activity.",
"exec.selectWorkspace": "Select a workspace to inspect",
"exec.agents": "Agents",
"exec.tasks": "Tasks",
"exec.runs": "Runs",
@@ -648,10 +639,8 @@ export const en: Record<string, string> = {
"dash.poor": "Poor",
"dash.critical": "Critical",
"dash.noAgentsYet": "No agents hired yet",
"dash.noAgentsDetail": "Your CEO is hiring. Check back soon.",
"dash.createCeoAgent": "",
"dash.noActivityYet": "Waiting for activity",
"dash.noActivityDetail": "Live updates show here.",
"dash.liveRuns": "Live Runs",
"dash.running": "running",
"dash.allQuiet": "All quiet",
@@ -703,14 +692,10 @@ export const en: Record<string, string> = {
// ── Organization ──
"org.noAgentsYet": "No team members yet",
"org.noAgentsDetail": "Hired members show up here.",
"org.noTitle": "No title",
"org.noGoalsYet": "No goals defined yet",
"org.noGoalsDetail": "Goals set the team's priorities.",
"org.noProjectsYet": "No projects created yet",
"org.noProjectsDetail": "Projects show up here.",
"org.noAgentsToDisplay": "Organization chart is empty",
"org.noAgentsToDisplayDetail": "The org chart appears after hiring starts.",
"org.saveChanges": "Save",
"org.submitHireRequest": "Submit hire request",
"org.createGoal": "Create goal",
@@ -770,7 +755,6 @@ export const en: Record<string, string> = {
// ── Runs ──
"runs.noRunsYet": "No agent runs yet",
"runs.noRunsDetail": "Runs show up here with logs and status.",
"runs.activeLabel": "Active",
"runs.queuedOrRunning": "Queued or running right now",
"runs.needsAttention": "Needs attention",
@@ -780,7 +764,6 @@ export const en: Record<string, string> = {
"runs.runQueue": "Run queue",
"runs.searchRuns": "Search by task, agent, connector, workspace, or error",
"runs.noRunsMatch": "No runs match this filter",
"runs.noRunsMatchDetail": "Try another filter.",
"runs.runDetail": "Run detail",
"runs.cancelRun": "Cancel run",
"runs.noSummary": "No summary yet",
@@ -874,9 +857,7 @@ export const en: Record<string, string> = {
"ops.documentsProse": "Prose documents",
"ops.documentsWorkProducts": "Files & deliverables",
"ops.documentsEmptyTitle": "No documents yet",
"ops.documentsEmptyDetail": "Agents register files here automatically when they write to the workspace, and can publish prose documents (PRDs, specs, post-mortems) via POST /api/companies/{id}/documents.",
"ops.documentsEmptyAction": "Create the first document",
"ops.documentsSelectCompany": "Select a company from the workspace switcher to see its documents.",
"ops.knowledgeBase": "Knowledge Base",
"ops.meetings": "Meetings",
"ops.sprintBoard": "Sprint Board",
@@ -1007,7 +988,6 @@ export const en: Record<string, string> = {
"kanban.done": "Done",
"kanban.blocked": "Blocked",
"kanban.noTasks": "No tasks yet",
"kanban.noTasksDetail": "Tasks created by agents will appear here on the board",
"kanban.unassigned": "Unassigned",
"kanban.running": "Running",
"kanban.priority.critical": "Critical",
@@ -1096,7 +1076,6 @@ export const en: Record<string, string> = {
"auto.rules.filterPaused": "Paused",
"auto.rules.filterError": "Error",
"auto.rules.noRules": "No automation rules",
"auto.rules.noRulesDetail": "Create your first rule to make the company autonomous.",
"auto.rules.createFirstRule": "Create First Rule",
"auto.rules.noRulesFilter": "No rules match the current filter.",
"auto.rules.deleteRule": "Delete rule",
@@ -1108,7 +1087,6 @@ export const en: Record<string, string> = {
"auto.workflows.summaryOther": "{n} other",
"auto.workflows.newWorkflow": "New Workflow",
"auto.workflows.noWorkflows": "No workflow pipelines",
"auto.workflows.noWorkflowsDetail": "Workflows chain actions into multi-step pipelines.",
"auto.workflows.createFirstWorkflow": "Create First Workflow",
"auto.workflows.deleteWorkflow": "Delete workflow",
"auto.workflows.deleteWorkflowConfirm": "Are you sure you want to delete {name}? This action cannot be undone.",
@@ -1349,6 +1327,12 @@ export const en: Record<string, string> = {
"approval.proposedHire": "Proposed Hire",
"approval.orgStrategy": "Organization Strategy",
"approval.documentNotLoaded": "The linked document is not loaded in the current snapshot yet. Reopen the approval after the next refresh.",
"approval.documentLoading": "Loading linked attachment…",
"approval.documentMissing": "This attachment is no longer available. It may have been deleted since the approval was filed.",
"approval.missingWorkProductTitle": "Missing work product",
"approval.missingDocumentTitle": "Missing document",
"approval.malformedPayloadTitle": "This approval is malformed",
"approval.malformedPayloadBody": "The attachment metadata on this approval could not be parsed. Contact the agent that filed it to refile with a valid payload.",
"approval.documentTitleRequired": "Document title is required.",
"approval.documentSavedToast": "Saved document: {title}",
"approval.saving": "Saving...",
@@ -1433,7 +1417,6 @@ export const en: Record<string, string> = {
"comm.searchResultsAria": "Search results",
"comm.unknownAgent": "Unknown Agent",
"comm.searchEmptyTitle": "No messages matched your search",
"comm.searchEmptyDetail": "Try different keywords or search across all channels.",
"comm.resultCount": "{count} result",
"comm.resultCountPlural": "{count} results",
"comm.channelsTotal": "{count} channels",
+7 -24
View File
@@ -215,10 +215,8 @@ export const zh: Record<string, string> = {
"skills.filterPlaceholder": "过滤技能...",
"skills.filterAria": "过滤技能",
"skills.emptyTitle": "尚未导入技能",
"skills.emptyDetail": "从 GitHub、本地路径或 skills.sh 导入技能即可开始。",
"skills.importButton": "导入技能",
"skills.selectToViewTitle": "选择一个技能查看详情",
"skills.selectToViewDetail": "从列表中选择一项,查看其内容、文件树与配置。",
"agent.pendingApproval": "等待审批",
"agent.pendingHireNote": "待审批成员需在招聘或审批页面处理。",
@@ -403,7 +401,6 @@ export const zh: Record<string, string> = {
// ── 执行/任务检查器 ──
"exec.taskQueueEmpty": "暂无待办任务",
"exec.taskQueueEmptyDetail": "有新任务时会显示在这里。",
"exec.deleteTask": "删除任务",
"exec.deleteTaskConfirm": "永久删除「{0}」?此操作无法撤消。",
"exec.budgetUsed": "已使用 {0}% 的月度预算",
@@ -458,7 +455,6 @@ export const zh: Record<string, string> = {
"exec.createTask": "创建任务",
"exec.taskTitle": "任务标题",
"exec.noReviewsInProgress": "暂无待审核任务",
"exec.noReviewsDetail": "待审核任务会出现在这里。",
"exec.awaitingReview": "等待审核",
"exec.reviewedBy": "由...审核",
"exec.reviewGroupBoard": "董事会审核",
@@ -467,7 +463,6 @@ export const zh: Record<string, string> = {
"exec.reviewAwaitsBoard": "等待董事会批准",
"exec.reviewAwaitsUser": "等待你的审核",
"exec.noApprovals": "暂无待审批项",
"exec.noApprovalsDetail": "需要你批准的内容会出现在这里。",
"exec.deleteApproval": "删除审批",
"exec.deleteApprovalConfirm": "永久删除此审批?此操作无法撤消。",
"exec.requestedBy": "请求者:",
@@ -490,7 +485,6 @@ export const zh: Record<string, string> = {
"exec.reject": "拒绝",
"exec.lastNote": "最近备注:",
"exec.noCostData": "暂无费用记录",
"exec.noCostDataDetail": "开始运行后,这里会显示费用。",
"exec.allConnectors": "全部连接器",
"exec.runCost": "运行费用",
"exec.unknownAgent": "未知智能体",
@@ -499,20 +493,17 @@ export const zh: Record<string, string> = {
"exec.knownSpend": "已知支出",
"exec.unattributedEntries": "未归因条目",
"exec.noActivityYet": "暂无活动记录",
"exec.noActivityDetail": "任务和系统动态会显示在这里。",
"exec.searchActivity": "搜索活动...",
"exec.allTypes": "全部类型",
"exec.noMatchingActivity": "没有匹配此筛选条件的活动",
"exec.loadMore": "加载更多(还剩 {0} 条)",
"exec.noWorkspacesYet": "暂无工作区",
"exec.noWorkspacesDetail": "公司启动后会自动创建。",
"exec.deleteWorkspace": "删除工作区",
"exec.deleteWorkspaceConfirm": "删除工作区「{0}」?只会移出系统,不会删磁盘文件。",
"exec.inspect": "检查",
"exec.reveal": "在文件管理器中显示",
"exec.noRemoteUrl": "无远程地址",
"exec.selectWorkspace": "选择一个工作区",
"exec.selectWorkspaceDetail": "选一个工作区查看任务和动态。",
"exec.selectWorkspace": "选一个工作区查看",
"exec.agents": "智能体",
"exec.tasks": "任务",
"exec.runs": "运行",
@@ -648,10 +639,8 @@ export const zh: Record<string, string> = {
"dash.poor": "较差",
"dash.critical": "危急",
"dash.noAgentsYet": "尚未招聘智能体",
"dash.noAgentsDetail": "CEO 正在招人,稍后再看。",
"dash.createCeoAgent": "",
"dash.noActivityYet": "等待活动",
"dash.noActivityDetail": "动态会实时显示在这里。",
"dash.liveRuns": "实时运行",
"dash.running": "运行中",
"dash.allQuiet": "一切平静",
@@ -703,14 +692,10 @@ export const zh: Record<string, string> = {
// ── 组织 ──
"org.noAgentsYet": "还没有团队成员",
"org.noAgentsDetail": "招到人后会显示在这里。",
"org.noTitle": "无职位",
"org.noGoalsYet": "暂未设定目标",
"org.noGoalsDetail": "目标会决定团队优先级。",
"org.noProjectsYet": "暂无项目",
"org.noProjectsDetail": "项目会在这里出现。",
"org.noAgentsToDisplay": "组织架构图为空",
"org.noAgentsToDisplayDetail": "招到人后,这里会显示汇报关系。",
"org.saveChanges": "保存",
"org.submitHireRequest": "提交招聘请求",
"org.createGoal": "创建目标",
@@ -770,7 +755,6 @@ export const zh: Record<string, string> = {
// ── 运行 ──
"runs.noRunsYet": "暂无智能体运行记录",
"runs.noRunsDetail": "开始跑任务后,这里会显示日志和状态。",
"runs.activeLabel": "活跃",
"runs.queuedOrRunning": "正在排队或运行中",
"runs.needsAttention": "需要关注",
@@ -780,7 +764,6 @@ export const zh: Record<string, string> = {
"runs.runQueue": "运行队列",
"runs.searchRuns": "按任务、智能体、连接器、工作区或错误搜索",
"runs.noRunsMatch": "没有匹配此筛选条件的运行",
"runs.noRunsMatchDetail": "换个筛选试试。",
"runs.runDetail": "运行详情",
"runs.cancelRun": "取消运行",
"runs.noSummary": "暂无摘要",
@@ -874,9 +857,7 @@ export const zh: Record<string, string> = {
"ops.documentsProse": "文稿",
"ops.documentsWorkProducts": "文件与交付物",
"ops.documentsEmptyTitle": "还没有文档",
"ops.documentsEmptyDetail": "Agent 在 workspace 里写文件时会被自动登记为交付物;结构化文稿(PRD、技术规格、复盘)需要调用 POST /api/companies/{id}/documents 发布。",
"ops.documentsEmptyAction": "创建第一份文档",
"ops.documentsSelectCompany": "请先在工作区切换器中选择一家公司,才能查看它的文档。",
"ops.knowledgeBase": "知识库",
"ops.meetings": "会议",
"ops.sprintBoard": "迭代看板",
@@ -1007,7 +988,6 @@ export const zh: Record<string, string> = {
"kanban.done": "已完成",
"kanban.blocked": "阻塞",
"kanban.noTasks": "暂无任务",
"kanban.noTasksDetail": "智能体创建的任务会显示在看板上",
"kanban.unassigned": "未分配",
"kanban.running": "运行中",
"kanban.priority.critical": "紧急",
@@ -1096,7 +1076,6 @@ export const zh: Record<string, string> = {
"auto.rules.filterPaused": "已暂停",
"auto.rules.filterError": "出错",
"auto.rules.noRules": "暂无自动化规则",
"auto.rules.noRulesDetail": "创建第一条规则,让公司自主运转。",
"auto.rules.createFirstRule": "创建第一条规则",
"auto.rules.noRulesFilter": "没有符合当前筛选条件的规则。",
"auto.rules.deleteRule": "删除规则",
@@ -1108,7 +1087,6 @@ export const zh: Record<string, string> = {
"auto.workflows.summaryOther": "{n} 条其它",
"auto.workflows.newWorkflow": "新建工作流",
"auto.workflows.noWorkflows": "暂无工作流",
"auto.workflows.noWorkflowsDetail": "工作流把多个动作串成多步流水线。",
"auto.workflows.createFirstWorkflow": "创建第一个工作流",
"auto.workflows.deleteWorkflow": "删除工作流",
"auto.workflows.deleteWorkflowConfirm": "确定删除 {name} 吗?该操作不可恢复。",
@@ -1349,6 +1327,12 @@ export const zh: Record<string, string> = {
"approval.proposedHire": "拟招聘",
"approval.orgStrategy": "组织战略",
"approval.documentNotLoaded": "关联文档尚未加载到当前快照,下次刷新后重新打开此审批。",
"approval.documentLoading": "正在加载关联附件…",
"approval.documentMissing": "附件已不可用,可能在审批发起后被删除。",
"approval.missingWorkProductTitle": "交付物已缺失",
"approval.missingDocumentTitle": "文档已缺失",
"approval.malformedPayloadTitle": "审批数据异常",
"approval.malformedPayloadBody": "附件元数据无法解析,请联系发起该审批的代理重新提交有效负载。",
"approval.documentTitleRequired": "文档标题不能为空。",
"approval.documentSavedToast": "已保存文档:{title}",
"approval.saving": "保存中...",
@@ -1433,7 +1417,6 @@ export const zh: Record<string, string> = {
"comm.searchResultsAria": "搜索结果",
"comm.unknownAgent": "未知智能体",
"comm.searchEmptyTitle": "没有匹配的消息",
"comm.searchEmptyDetail": "换个关键词,或者在全部频道中搜索。",
"comm.resultCount": "{count} 条结果",
"comm.resultCountPlural": "{count} 条结果",
"comm.channelsTotal": "{count} 个频道",
+9
View File
@@ -11,12 +11,15 @@ export interface Toast {
function nextToastId() { return `t-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; }
export type TaskInspectorTab = "overview" | "subtasks" | "activity" | "governance";
export interface UiSlice {
locale: AppLocale;
showOnboarding: boolean;
initialOnboardingLocked: boolean;
confirmDeleteCompanyId: string | null;
taskViewMode: "list" | "kanban";
taskInspectorTab: TaskInspectorTab;
logRevision: number;
toasts: Toast[];
setLocale: (locale: AppLocale) => void;
@@ -24,6 +27,7 @@ export interface UiSlice {
setInitialOnboardingLocked: (locked: boolean) => void;
setConfirmDeleteCompanyId: (id: string | null) => void;
setTaskViewMode: (mode: "list" | "kanban") => void;
setTaskInspectorTab: (tab: TaskInspectorTab) => void;
incrementLogRevision: () => void;
addToast: (message: string, tone?: Toast["tone"]) => void;
dismissToast: (id: string) => void;
@@ -40,6 +44,7 @@ export const createUiSlice: StateCreator<
initialOnboardingLocked: false,
confirmDeleteCompanyId: null,
taskViewMode: "list",
taskInspectorTab: "overview",
logRevision: 0,
toasts: [],
setLocale: (locale) =>
@@ -62,6 +67,10 @@ export const createUiSlice: StateCreator<
set((state) => {
state.taskViewMode = mode;
}),
setTaskInspectorTab: (tab) =>
set((state) => {
state.taskInspectorTab = tab;
}),
incrementLogRevision: () =>
set((state) => {
state.logRevision += 1;
@@ -71,6 +71,7 @@ export function ExecutionSections({ section }: ExecutionSectionsProps) {
companyWorkspaces,
companyCosts,
companyDocuments,
companyWorkProducts,
filteredActivity,
selectedTask,
selectedConnector,
@@ -148,7 +149,7 @@ export function ExecutionSections({ section }: ExecutionSectionsProps) {
{section === "tasks" ? (
<>
<Panel title="Goals" action={<ActionButton label="New goal" icon={Target} tone="accent" onClick={() => setSelectedGoalId(NEW_ENTITY_ID)} />}>
<Panel action={<ActionButton label="New goal" icon={Target} tone="accent" onClick={() => setSelectedGoalId(NEW_ENTITY_ID)} />}>
<GoalList goals={companyGoals} agents={companyAgents} selectedGoalId={selectedGoalId} onSelect={setSelectedGoalId} />
</Panel>
<Modal open={selectedGoalId === NEW_ENTITY_ID && section === "tasks"} title="New goal" onClose={() => setSelectedGoalId(null)}>
@@ -169,7 +170,6 @@ export function ExecutionSections({ section }: ExecutionSectionsProps) {
</Modal>
<Panel
title="Task queue"
action={
<div className="flex items-center gap-2">
<div className="flex rounded-lg border border-border bg-card">
@@ -244,7 +244,7 @@ export function ExecutionSections({ section }: ExecutionSectionsProps) {
<Panel title="Agent reviews in progress">
<PendingReviewList tasks={tasks} agents={companyAgents} approvals={pendingApprovals} />
</Panel>
<Panel title="Pending approvals">
<Panel>
<ApprovalList approvals={pendingApprovals} selectedApprovalId={selectedApproval?.id ?? null} onSelect={setSelectedApprovalId} />
</Panel>
<Modal open={!!selectedApproval} title={selectedApproval?.payloadSummary ?? "Approval detail"} onClose={() => setSelectedApprovalId(null)}>
@@ -254,6 +254,7 @@ export function ExecutionSections({ section }: ExecutionSectionsProps) {
agents={companyAgents}
tasks={tasks}
documents={companyDocuments}
workProducts={companyWorkProducts}
companyId={currentCompany.id}
onDecide={async (state, note) => {
unwrap(await window.agentCompany.decideApproval({ companyId: currentCompany.id, approvalId: selectedApproval.id, state, decisionNote: note ?? `${state} from desktop control surface.` }));
@@ -286,8 +287,6 @@ export function ExecutionSections({ section }: ExecutionSectionsProps) {
</div>
{costsTab === "costs" ? (
<TwoPaneLayout
title="Costs"
eyebrow={undefined}
primary={
<Panel title="Recent cost entries">
<CostList costs={companyCosts} agents={companyAgents} runs={runs} />
@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import type { SectionId } from "@shared/types";
import { deriveInboxItems } from "@shared/inbox-items";
import { Inbox } from "../../components/inbox/Inbox";
@@ -18,7 +18,8 @@ import { DocumentCenter } from "../../components/company-operations/DocumentCent
import { KnowledgeBaseManager } from "../../components/company-operations/KnowledgeBaseManager";
import { MeetingsManager } from "../../components/company-operations/MeetingsManager";
import { SprintManager } from "../../components/company-operations/SprintManager";
import { EmptyState, Panel } from "../../components/ui";
import { ActionButton, EmptyState, Modal } from "../../components/ui";
import { Plus } from "../../components/ui/icons";
import { useT } from "../../i18n";
import { unwrap } from "../../lib/desktop";
import { useAppStore } from "../../state/store";
@@ -68,6 +69,8 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
const setSection = useAppStore((s) => s.setSection);
const navigateTo = useAppStore((s) => s.navigateTo);
const [workspaceCreatorOpen, setWorkspaceCreatorOpen] = useState(false);
if (!snapshot) return null;
return (
@@ -226,10 +229,7 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
onRefresh={refresh}
/>
) : (
<EmptyState
title={t("ops.documentsEmptyTitle")}
detail={t("ops.documentsSelectCompany")}
/>
<EmptyState title={t("ops.documentsEmptyTitle")} />
)
) : null}
@@ -246,11 +246,18 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
) : null}
{section === "workspaces" && currentCompany ? (
<TwoPaneLayout
title="Workspaces"
eyebrow={undefined}
primary={
<Panel title="Workspace registry">
<>
<TwoPaneLayout
split="vertical"
action={
<ActionButton
label={t("exec.addWorkspace")}
onClick={() => setWorkspaceCreatorOpen(true)}
icon={Plus}
tone="accent"
/>
}
primary={
<WorkspaceList
workspaces={companyWorkspaces}
projects={companyProjects}
@@ -260,27 +267,34 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
onOpen={openPathWithFeedback}
onDeleted={refresh}
/>
</Panel>
}
secondary={
<div className="space-y-6">
<Panel title="Workspace control center">
<WorkspaceInspector
workspace={selectedWorkspace}
agents={companyAgents}
tasks={tasks}
runs={runs}
documents={companyDocuments}
project={selectedWorkspace ? (companyProjects.find((project) => project.id === selectedWorkspace.projectId) ?? null) : null}
onOpen={openPathWithFeedback}
/>
</Panel>
<Panel title="Add workspace">
<WorkspaceCreator companyId={currentCompany.id} projects={companyProjects} onSaved={refresh} />
</Panel>
</div>
}
/>
}
secondary={
<WorkspaceInspector
workspace={selectedWorkspace}
agents={companyAgents}
tasks={tasks}
runs={runs}
documents={companyDocuments}
project={selectedWorkspace ? (companyProjects.find((project) => project.id === selectedWorkspace.projectId) ?? null) : null}
onOpen={openPathWithFeedback}
/>
}
/>
<Modal
open={workspaceCreatorOpen}
title={t("exec.addWorkspace")}
onClose={() => setWorkspaceCreatorOpen(false)}
>
<WorkspaceCreator
companyId={currentCompany.id}
projects={companyProjects}
onSaved={async () => {
await refresh();
setWorkspaceCreatorOpen(false);
}}
/>
</Modal>
</>
) : null}
{section === "settings" && currentCompany ? (