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

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="none">
<circle cx="512" cy="512" r="480" fill="#191919"/>
<g transform="translate(268 255) scale(4)">
<path fill="#FBFAF5" d="M 76.604813 111.661926 C 65.070183 111.662621 53.987053 111.662621 42.757744 111.662621 C 45.2239 101.998177 45.297546 101.949265 53.830105 102.036659 C 57.279823 102.071999 60.730202 102.04248 64.259201 102.04248 C 62.159458 93.316338 61.799103 93.009819 51.340881 91.341682 C 59.312901 73.847687 67.193756 56.553749 75.587578 38.134155 C 86.933441 62.965954 97.804642 86.758881 108.711754 110.630394 C 100.707413 111.871689 92.355965 107.246559 88.107536 98.852119 C 84.207748 91.146538 80.800591 83.191696 77.172813 75.348343 C 76.340195 73.54818 75.492958 71.754791 74.614624 69.877441 C 69.107384 75.691788 68.008286 81.475128 71.005882 88.088753 C 74.413452 95.606903 77.813919 103.128281 81.452705 111.167984 C 79.55114 111.381325 78.303734 111.521271 76.604813 111.661926 Z"/>
<path fill="#FBFAF5" d="M 26.805992 87.743256 C 38.616821 61.830872 50.261688 36.260269 62.470131 9.452141 C 65.732315 17.818909 71.145126 23.524567 66.258224 33.086182 C 55.518253 54.099815 46.339973 75.911873 36.533691 97.402336 C 32.699284 105.805443 26.495394 110.823158 16.012421 111.410957 C 19.72117 103.270836 23.180601 95.67794 26.805992 87.743256 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

@@ -14,7 +14,7 @@
*/
import type { AppDatabase } from "@main/database";
import { approvalInputSchema } from "@shared/contracts";
import { approvalInputSchema, mergeApprovalAttachments } from "@shared/contracts";
import { resolveApprovalDecision } from "@main/approval-policy";
import { shouldAutoApprove } from "@main/execution-policy";
import { UserInputError, UpstreamError, classifyUnknownError } from "@main/errors";
@@ -90,6 +90,25 @@ export function handleFileApprovalToolCall(
}
}
// Fold schema-level documentIds / workProductIds into payloadJson so the
// desktop inspector has a single source of truth. payloadJson remains the
// escape hatch for type-specific structured fields. Malformed payloadJson
// (non-JSON or non-object) is a producer bug — surface it to the agent as
// UserInputError so the tool call can be corrected, not silently stashed.
let mergedPayloadJson: string | null;
try {
mergedPayloadJson = mergeApprovalAttachments(
input.payloadJson,
input.documentIds,
input.workProductIds,
);
} catch (error) {
throw new UserInputError({
code: "file_approval.payload_json.invalid",
message: error instanceof Error ? error.message : "payloadJson must be a JSON object or null.",
});
}
let approvalId: string;
try {
approvalId = db.approvalsMod.requestApproval({
@@ -100,7 +119,7 @@ export function handleFileApprovalToolCall(
relatedAgentId: input.relatedAgentId ?? null,
payloadSummary: input.payloadSummary,
impactSummary: input.impactSummary,
payloadJson: input.payloadJson ?? null,
payloadJson: mergedPayloadJson,
});
} catch (error) {
throw classifyUnknownError(error, {
+23 -2
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
import log from "electron-log/main.js";
import { approvalInputSchema } from "@shared/contracts";
import { approvalInputSchema, mergeApprovalAttachments } from "@shared/contracts";
import { resolveApprovalDecision } from "../approval-policy";
import { shouldAutoApprove } from "../execution-policy";
import { incrementDeprecationCall } from "./deprecation-counter";
@@ -94,6 +94,27 @@ export function registerApprovalRoutes(server: AgentApiServer) {
}
}
// Fold the schema-level documentIds / workProductIds into payloadJson so
// the ApprovalInspector and extractApprovalDocumentReferences can read a
// single source of truth. Legacy callers that stuffed documentIds inside
// payloadJson keep working unchanged. Malformed payloadJson (non-JSON
// or non-object) surfaces as 400 at the API boundary — no silent
// rawPayload stashing, because that hid producer bugs from the operator.
let mergedPayloadJson: string | null;
try {
mergedPayloadJson = mergeApprovalAttachments(
input.payloadJson,
input.documentIds,
input.workProductIds,
);
} catch (error) {
server.json(res, 400, {
error: error instanceof Error ? error.message : "Invalid approval payloadJson",
code: "INVALID_PAYLOAD_JSON",
});
return;
}
let id: string;
try {
id = db.approvalsMod.requestApproval({
@@ -104,7 +125,7 @@ export function registerApprovalRoutes(server: AgentApiServer) {
relatedAgentId: input.relatedAgentId ?? null,
payloadSummary: input.payloadSummary,
impactSummary: input.impactSummary,
payloadJson: input.payloadJson ?? null,
payloadJson: mergedPayloadJson,
});
} catch (error) {
server.json(res, 422, { error: error instanceof Error ? error.message : "Approval could not be created" });
@@ -5,7 +5,9 @@ Body: {
"relatedTaskId": "{taskId — what task is this for}",
"requestedByAgentId": "{{agentId}}",
"payloadSummary": "Clear description of what you need and why",
"impactSummary": "What happens if this is approved vs denied"
"impactSummary": "What happens if this is approved vs denied",
"documentIds": ["{docUuid1}", "{docUuid2}"],
"workProductIds": ["{workProductUuid}"]
}
Approval types:
- secret_access: Need API keys, credentials, or account access
@@ -13,6 +15,30 @@ Approval types:
- approve_ceo_strategy: CEO submitting org strategy for Board review
- dangerous_command: Irreversible action that needs human sign-off
Attaching artifacts:
- `documentIds` — UUIDs of existing company documents. The board inspector
renders each as a clickable row on the first screen of the approval modal,
so reviewers can open and edit inline.
- `workProductIds` — UUIDs of existing work products (files on disk,
external URLs). The inspector surfaces each row with a click-to-open
action that hands off to the operator's default application.
- Prefer these schema-typed arrays over stuffing `{documentIds:[...]}` into
`payloadJson`. Agents using the `file_approval` internal tool pass the
same fields directly as arguments.
Example — deliverable_review that asks the CEO to sign off on a spec and an
exported PDF:
```
{
"type": "deliverable_review",
"relatedTaskId": "8f7a...",
"payloadSummary": "Q2 launch plan ready for CEO review",
"impactSummary": "Unblocks the kickoff email if approved; revision_requested loops back to me.",
"documentIds": ["2c9d...e41a"],
"workProductIds": ["55ab...90ff"]
}
```
### Check Approval Status
GET /api/approvals/{approvalId}
@@ -76,7 +76,19 @@ export const fileApprovalSchema = Type.Object({
payloadJson: Type.Optional(
Type.String({
description:
"Optional JSON string carrying structured approval payload. Must be valid JSON if provided.",
"Optional JSON string carrying type-specific structured payload (hire proposal, social draft, etc.). Must be valid JSON if provided. For attaching existing documents or work products, use documentIds / workProductIds — the board UI renders those directly on the first screen.",
}),
),
documentIds: Type.Optional(
Type.Array(Type.String(), {
description:
"UUIDs of existing company documents to attach to this approval. The board inspector surfaces each attachment on the first screen of the approval modal. Prefer this over jamming {documentIds:[...]} into payloadJson.",
}),
),
workProductIds: Type.Optional(
Type.Array(Type.String(), {
description:
"UUIDs of existing work products (files on disk, external URLs) to attach to this approval. The board inspector renders each row as a clickable link that opens in the operator's default application.",
}),
),
relatedTaskId: Type.Optional(
@@ -104,7 +116,7 @@ export function createFileApprovalTool(rpc: WorkerRpcClient, runId: string): Age
name: FILE_APPROVAL_TOOL_NAME,
label: "file approval",
description:
"File an approval request with the company board. Use for 'approve_ceo_strategy' after filing initial hires, or for any other decision that requires board sign-off. The request is auto-approved when the company is in hands-off mode; otherwise it waits for the human operator.",
"File an approval request with the company board. Use for 'approve_ceo_strategy' after filing initial hires, or for any other decision that requires board sign-off. When the approval references existing documents or work products (e.g. deliverable_review, document_review), pass their UUIDs via documentIds / workProductIds so the board inspector can render them as clickable attachments on the first screen — do not bury IDs inside payloadJson. The request is auto-approved when the company is in hands-off mode; otherwise it waits for the human operator.",
parameters: fileApprovalSchema,
async execute(toolCallId, params, _signal?, _onUpdate?) {
const payload: InvokeInternalToolRpcPayload = {
+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 ? (
+93 -7
View File
@@ -4,6 +4,38 @@ export interface ApprovalDocumentReference {
type?: string
}
export type ApprovalPayloadInspection =
| { kind: "empty" }
| { kind: "ok"; payload: Record<string, unknown> }
| { kind: "malformed"; reason: string }
/**
* Inspect an approval payloadJson and classify it. Unlike the tolerant
* extractors (which collapse malformed rows to "no attachments"), this
* returns an explicit "malformed" signal so the inspector can surface a
* dignified error card instead of rendering as if nothing was attached.
*
* The HTTP route + file_approval tool both reject malformed payloads at
* write time via mergeApprovalAttachments. This detector exists for the
* read path, where a legacy row with a broken payload could otherwise
* silently render as empty.
*/
export function inspectApprovalPayload(payloadJson: string | null | undefined): ApprovalPayloadInspection {
if (payloadJson == null || payloadJson.length === 0) return { kind: "empty" }
let parsed: unknown
try {
parsed = JSON.parse(payloadJson)
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
return { kind: "malformed", reason: `payloadJson is not valid JSON (${detail})` }
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
const actual = Array.isArray(parsed) ? "array" : parsed === null ? "null" : typeof parsed
return { kind: "malformed", reason: `payloadJson must be a JSON object (got ${actual})` }
}
return { kind: "ok", payload: parsed as Record<string, unknown> }
}
function parsePayload(payloadJson: string | null): Record<string, unknown> | null {
if (!payloadJson) return null
try {
@@ -16,33 +48,55 @@ function parsePayload(payloadJson: string | null): Record<string, unknown> | nul
}
}
/**
* Extract document references from an approval's persisted payloadJson.
*
* Priority order (canonical → legacy), with later sources providing
* enrichment only for ids introduced by earlier sources:
* 1. payload.documentIds — canonical schema top-level field (written by
* mergeApprovalAttachments). This is the authoritative id set.
* 2. payload.documentId / documentTitle / documentType — singular legacy
* shape; kept for back-compat with old rows.
* 3. payload.documents[] — legacy rich shape; used to enrich title/type
* metadata on ids already present from the canonical list, and to
* contribute ids that pre-date the canonical field.
*/
export function extractApprovalDocumentReferences(payloadJson: string | null): ApprovalDocumentReference[] {
const payload = parsePayload(payloadJson)
if (!payload) return []
const refs = new Map<string, ApprovalDocumentReference>()
const addRef = (id: unknown, title?: unknown, type?: unknown) => {
const addId = (id: unknown) => {
if (typeof id !== "string" || id.length === 0) return
if (!refs.has(id)) refs.set(id, { id })
}
const enrich = (id: unknown, title?: unknown, type?: unknown) => {
if (typeof id !== "string" || id.length === 0) return
const prior = refs.get(id) ?? { id }
refs.set(id, {
id,
title: typeof title === "string" && title.length > 0 ? title : undefined,
type: typeof type === "string" && type.length > 0 ? type : undefined,
title: prior.title ?? (typeof title === "string" && title.length > 0 ? title : undefined),
type: prior.type ?? (typeof type === "string" && type.length > 0 ? type : undefined),
})
}
addRef(payload.documentId, payload.documentTitle, payload.documentType)
// 1. Canonical top-level list first — establishes the authoritative ids.
const documentIds = payload.documentIds
if (Array.isArray(documentIds)) {
for (const entry of documentIds) addRef(entry)
for (const entry of documentIds) addId(entry)
}
// 2. Legacy singular shape.
enrich(payload.documentId, payload.documentTitle, payload.documentType)
// 3. Legacy rich shape — enriches metadata on canonical ids and adds
// ids that only ever lived in the legacy form.
const documents = payload.documents
if (Array.isArray(documents)) {
for (const entry of documents) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue
const record = entry as Record<string, unknown>
addRef(record.id, record.title, record.type)
enrich(record.id, record.title, record.type)
}
}
@@ -52,3 +106,35 @@ export function extractApprovalDocumentReferences(payloadJson: string | null): A
export function extractApprovalDocumentIds(payloadJson: string | null): string[] {
return extractApprovalDocumentReferences(payloadJson).map((entry) => entry.id)
}
/**
* Mirror of extractApprovalDocumentReferences for work products. Work products
* are filesystem-backed (or URL-backed) artifacts — the board inspector opens
* them via the OS default handler rather than editing them inline.
*
* Canonical source is payload.workProductIds (schema top-level, folded in by
* mergeApprovalAttachments). Legacy payload.workProducts[] is still read for
* back-compat but new callers must not write it.
*/
export function extractApprovalWorkProductIds(payloadJson: string | null): string[] {
const payload = parsePayload(payloadJson)
if (!payload) return []
const out = new Set<string>()
// Canonical first.
const workProductIds = payload.workProductIds
if (Array.isArray(workProductIds)) {
for (const entry of workProductIds) {
if (typeof entry === "string" && entry.length > 0) out.add(entry)
}
}
// Legacy rich shape; back-compat only.
const workProducts = payload.workProducts
if (Array.isArray(workProducts)) {
for (const entry of workProducts) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue
const record = entry as Record<string, unknown>
if (typeof record.id === "string" && record.id.length > 0) out.add(record.id)
}
}
return [...out]
}
+65
View File
@@ -1,6 +1,14 @@
import { z } from "zod";
import { optionalId } from "./helpers";
// Schema-typed attachment pointers. Agents used to jam everything into the
// free-form `payloadJson` string, which meant the board UI had to parse JSON
// blind and hope the keys matched. documentIds / workProductIds make the
// attachment contract schema-visible: validators see them, the HTTP route
// folds them back into payloadJson for persistence, and the inspector renders
// them deterministically in the "linked documents" panel on the first screen.
// payloadJson remains as the escape hatch for type-specific structured
// fields (social post drafts, hire proposals, etc.).
export const approvalInputSchema = z.object({
companyId: z.string().uuid(),
relatedTaskId: optionalId,
@@ -9,9 +17,66 @@ export const approvalInputSchema = z.object({
type: z.enum(["hire_agent", "approve_ceo_strategy", "dangerous_command", "secret_access", "budget_request", "promotion", "termination", "vendor_approval", "document_review", "production_deploy", "incident_escalation", "social_post", "deliverable_review", "budget_override", "plan_review", "scope_review", "draft_review", "strategy_review", "exception_review"]),
payloadSummary: z.string().min(2).max(280),
impactSummary: z.string().min(2).max(400),
// payloadJson: type-specific structured fields only (social draft, hire
// proposal, etc). DEPRECATED: embedding `documentIds` / `workProductIds` /
// `documents[]` / `workProducts[]` here. Use the top-level schema fields
// below — they are the canonical source of truth for attachment pointers.
// Legacy embedded mirrors are still read for back-compat but new callers
// must not write them.
payloadJson: z.string().nullable().optional(),
// CANONICAL attachment pointers. mergeApprovalAttachments folds these into
// payloadJson at persist time so on-disk rows have a single readable shape,
// but the schema top-level is the authoritative contract.
documentIds: z.array(z.string().uuid()).optional(),
workProductIds: z.array(z.string().uuid()).optional(),
});
/**
* Merge canonical documentIds / workProductIds (schema top-level) into
* payloadJson so downstream readers (ApprovalDocumentsPanel,
* extractApprovalDocumentReferences) see a single source of truth.
*
* Throws if payloadJson is non-empty but not valid JSON-object: the previous
* behavior stuffed the opaque string under `rawPayload` which hid producer
* bugs (an agent passing a stringified-twice JSON, or a literal "undefined")
* and silently broke attachment rendering. Let it crash at the boundary.
*/
export function mergeApprovalAttachments(
payloadJson: string | null | undefined,
documentIds: string[] | undefined,
workProductIds: string[] | undefined,
): string | null {
const hasDocs = Array.isArray(documentIds) && documentIds.length > 0;
const hasProducts = Array.isArray(workProductIds) && workProductIds.length > 0;
if (!hasDocs && !hasProducts) return payloadJson ?? null;
let base: Record<string, unknown> = {};
if (payloadJson) {
let parsed: unknown;
try {
parsed = JSON.parse(payloadJson);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(
`mergeApprovalAttachments: payloadJson is not valid JSON (${detail}). ` +
`payloadJson must be a JSON object or null; free-text payloads are not supported.`,
);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(
`mergeApprovalAttachments: payloadJson must be a JSON object (got ${
Array.isArray(parsed) ? "array" : parsed === null ? "null" : typeof parsed
}).`,
);
}
base = parsed as Record<string, unknown>;
}
if (hasDocs) base.documentIds = documentIds;
if (hasProducts) base.workProductIds = workProductIds;
return JSON.stringify(base);
}
export const approvalDecisionSchema = z.object({
companyId: z.string().uuid(),
approvalId: z.string().uuid(),
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect } from "vitest";
import {
formatDateLabel,
formatPlainLabel,
shouldRenderLabel,
} from "../../src/renderer/components/dashboard/charts/ChartTooltip";
// The <ChartTooltip /> component itself is a thin shell around three pure
// helpers. Testing the helpers covers every branch of the rendered output:
//
// formatDateLabel → "date" mode default formatter
// formatPlainLabel → "plain" mode default formatter
// shouldRenderLabel → whether the top label row is rendered at all
//
// Rendering JSX here would require adding @testing-library/react + jsdom just
// to re-check what the helpers already decide. The vitest config also only
// picks up *.test.ts (not .tsx), so keeping the test purely functional avoids
// expanding the harness for one component.
describe("formatDateLabel (date mode)", () => {
it("formats a valid ISO date string into Mon DD, YYYY", () => {
expect(formatDateLabel("2026-03-15")).toBe("Mar 15, 2026");
});
it("falls back to the original string on an Invalid Date", () => {
// "not-a-date" cannot be parsed; formatter must return the input verbatim
// rather than "Invalid Date" leaking to the UI.
expect(formatDateLabel("not-a-date")).toBe("not-a-date");
});
it("stringifies non-string labels (numbers) instead of trying to parse them", () => {
expect(formatDateLabel(42)).toBe("42");
});
});
describe("formatPlainLabel (plain mode)", () => {
it("passes a regular string through unchanged", () => {
expect(formatPlainLabel("Alice")).toBe("Alice");
});
it("stringifies numbers", () => {
expect(formatPlainLabel(7)).toBe("7");
});
it("returns empty string for null/undefined instead of 'null'/'undefined'", () => {
expect(formatPlainLabel(undefined)).toBe("");
// @ts-expect-error — runtime behavior for null is the contract we care about
expect(formatPlainLabel(null)).toBe("");
});
});
describe("shouldRenderLabel (empty label suppression)", () => {
it("returns false for undefined", () => {
expect(shouldRenderLabel(undefined)).toBe(false);
});
it("returns false for null", () => {
// @ts-expect-error — recharts can hand us null at runtime
expect(shouldRenderLabel(null)).toBe(false);
});
it("returns false for empty string", () => {
expect(shouldRenderLabel("")).toBe(false);
});
it("returns true for a non-empty string", () => {
expect(shouldRenderLabel("2026-03-15")).toBe(true);
});
it("returns true for a number (including 0)", () => {
expect(shouldRenderLabel(0)).toBe(true);
expect(shouldRenderLabel(42)).toBe(true);
});
});