diff --git a/promo-video/agentcompany-promo.mp4 b/promo-video/agentcompany-promo.mp4 new file mode 100644 index 00000000..2794d8df Binary files /dev/null and b/promo-video/agentcompany-promo.mp4 differ diff --git a/promo-video/assets/AI-Internal-Realtime-Message.jpeg b/promo-video/assets/AI-Internal-Realtime-Message.jpeg new file mode 100644 index 00000000..bd8235e9 Binary files /dev/null and b/promo-video/assets/AI-Internal-Realtime-Message.jpeg differ diff --git a/promo-video/assets/Approval-by-Human.jpeg b/promo-video/assets/Approval-by-Human.jpeg new file mode 100644 index 00000000..d5e459ff Binary files /dev/null and b/promo-video/assets/Approval-by-Human.jpeg differ diff --git a/promo-video/assets/Auto-OrgChart-Team.jpeg b/promo-video/assets/Auto-OrgChart-Team.jpeg new file mode 100644 index 00000000..156ed2ad Binary files /dev/null and b/promo-video/assets/Auto-OrgChart-Team.jpeg differ diff --git a/promo-video/assets/Auto-Task.jpeg b/promo-video/assets/Auto-Task.jpeg new file mode 100644 index 00000000..df5096cd Binary files /dev/null and b/promo-video/assets/Auto-Task.jpeg differ diff --git a/promo-video/assets/Dashboard.jpeg b/promo-video/assets/Dashboard.jpeg new file mode 100644 index 00000000..cf4f53e0 Binary files /dev/null and b/promo-video/assets/Dashboard.jpeg differ diff --git a/promo-video/assets/Document-Center.jpeg b/promo-video/assets/Document-Center.jpeg new file mode 100644 index 00000000..a40bfb89 Binary files /dev/null and b/promo-video/assets/Document-Center.jpeg differ diff --git a/promo-video/assets/Onboarding.jpeg b/promo-video/assets/Onboarding.jpeg new file mode 100644 index 00000000..d80dee0d Binary files /dev/null and b/promo-video/assets/Onboarding.jpeg differ diff --git a/promo-video/assets/icon.png b/promo-video/assets/icon.png new file mode 100644 index 00000000..5888660f Binary files /dev/null and b/promo-video/assets/icon.png differ diff --git a/promo-video/assets/icon.svg b/promo-video/assets/icon.svg new file mode 100644 index 00000000..dd6927c4 --- /dev/null +++ b/promo-video/assets/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/promo-video/index.html b/promo-video/index.html new file mode 100644 index 00000000..2b3bda26 --- /dev/null +++ b/promo-video/index.html @@ -0,0 +1,1019 @@ + + + + + AgentCompany — Promo + + + + +
+ +
+
+
+
+ + +
+
FOR AUTONOMOUS AI TEAMS
+
+ Launch an
AI company. +
+
+ Describe your mission. A CEO agent recruits the team, + delegates work, and delivers real results. +
+
+ +
+
ENTERPRISE · LOCAL-FIRST · 26 PROVIDERS
+
agent-company.dev→
+
+
+
+ + +
+
+
01 · DESCRIBE
+
+ Describe your mission. +
+ +
+
+
COMPANY MISSION · PLAIN ENGLISH
+
+ Build a marketing agency that ships weekly content, tracks KPIs, and auto-reports to stakeholders. +
+
+
⌘ + ↵ TO SUBMIT
+
Create Company
+
+
+
+
+
+ + +
+
+
02 · ASSEMBLE
+
+ A CEO agent recruits the team. +
+ +
+ + + + + + + + + +
+
CEO
+
+
Chief Executive
+
AUTONOMOUS · CLAUDE OPUS
+
+
+
+
MK
+
+
Marketing Lead
+
HIRED · 00:03
+
+
+
+
CW
+
+
Content Writer
+
HIRED · 00:05
+
+
+
+
DA
+
+
Data Analyst
+
HIRED · 00:07
+
+
+
+
OP
+
+
Ops Manager
+
HIRED · 00:09
+
+
+
+
+
+ + +
+
+
+
+
03 · EXECUTE
+
+ A team that actually works. +
+ +
+
+
+
COMMAND DECK
+
Live company dashboard
+
+
+
+
COLLABORATION
+
Real-time agent messages
+
+
+
+
ORCHESTRATION
+
Self-assigning task queue
+
+
+
+
DELIVERABLES
+
AI-generated document center
+
+
+
+
+ + +
+
+
+
04 · APPROVE
+
+ You stay in control. +
+
+ Gate sensitive moves behind human approval. Hiring, budget, + outbound messages — every critical decision waits for your sign-off. +
+
+
AI CEO · full autonomy
+
AI CO-FOUNDER · ask first
+
AUDIT TRAIL · every decision
+
+
+
+
+ +
+
+ ✓ + Approved by founder +
+
+
+
+ + +
+
+
+
READY TO LAUNCH?
+
+ Start your
AI company today. +
+
+
+
26
+
AI PROVIDERS
+
+
+
+
13,674+
+
DOWNLOADS
+
+
+
+
100%
+
LOCAL-FIRST
+
+
+
agent-company.dev →
+
+
+ +
AgentCompany
+
+
+ + +
+
+
+ + + + diff --git a/promo-video/snapshots/frame-00-at-0.5s.png b/promo-video/snapshots/frame-00-at-0.5s.png new file mode 100644 index 00000000..8425d7cb Binary files /dev/null and b/promo-video/snapshots/frame-00-at-0.5s.png differ diff --git a/promo-video/snapshots/frame-01-at-5.0s.png b/promo-video/snapshots/frame-01-at-5.0s.png new file mode 100644 index 00000000..dbafe92b Binary files /dev/null and b/promo-video/snapshots/frame-01-at-5.0s.png differ diff --git a/promo-video/snapshots/frame-02-at-7.0s.png b/promo-video/snapshots/frame-02-at-7.0s.png new file mode 100644 index 00000000..ccc0bc94 Binary files /dev/null and b/promo-video/snapshots/frame-02-at-7.0s.png differ diff --git a/promo-video/snapshots/frame-03-at-11.5s.png b/promo-video/snapshots/frame-03-at-11.5s.png new file mode 100644 index 00000000..ef31f11e Binary files /dev/null and b/promo-video/snapshots/frame-03-at-11.5s.png differ diff --git a/promo-video/snapshots/frame-04-at-17.0s.png b/promo-video/snapshots/frame-04-at-17.0s.png new file mode 100644 index 00000000..f3d01637 Binary files /dev/null and b/promo-video/snapshots/frame-04-at-17.0s.png differ diff --git a/promo-video/snapshots/frame-05-at-22.5s.png b/promo-video/snapshots/frame-05-at-22.5s.png new file mode 100644 index 00000000..c027054c Binary files /dev/null and b/promo-video/snapshots/frame-05-at-22.5s.png differ diff --git a/promo-video/snapshots/frame-06-at-28.5s.png b/promo-video/snapshots/frame-06-at-28.5s.png new file mode 100644 index 00000000..1c54481e Binary files /dev/null and b/promo-video/snapshots/frame-06-at-28.5s.png differ diff --git a/src/main/agent-runtime/internal-tools/file-approval-handler.ts b/src/main/agent-runtime/internal-tools/file-approval-handler.ts index e32d9427..c7d4f08d 100644 --- a/src/main/agent-runtime/internal-tools/file-approval-handler.ts +++ b/src/main/agent-runtime/internal-tools/file-approval-handler.ts @@ -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, { diff --git a/src/main/api-server/routes-approvals.ts b/src/main/api-server/routes-approvals.ts index eab189e5..ab541de5 100644 --- a/src/main/api-server/routes-approvals.ts +++ b/src/main/api-server/routes-approvals.ts @@ -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" }); diff --git a/src/main/prompt-builder/api-docs/approvals.en.md b/src/main/prompt-builder/api-docs/approvals.en.md index 03c45c7d..bc74059c 100644 --- a/src/main/prompt-builder/api-docs/approvals.en.md +++ b/src/main/prompt-builder/api-docs/approvals.en.md @@ -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} diff --git a/src/main/worker/runtime/internal-tools/file-approval-tool.ts b/src/main/worker/runtime/internal-tools/file-approval-tool.ts index f19f996c..0cc0a5c7 100644 --- a/src/main/worker/runtime/internal-tools/file-approval-tool.ts +++ b/src/main/worker/runtime/internal-tools/file-approval-tool.ts @@ -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 = { diff --git a/src/renderer/components/TwoPaneLayout.tsx b/src/renderer/components/TwoPaneLayout.tsx index c6119e30..8775313c 100644 --- a/src/renderer/components/TwoPaneLayout.tsx +++ b/src/renderer/components/TwoPaneLayout.tsx @@ -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" ? ( +
+
{primary}
+
{secondary}
+
+ ) : ( +
{primary}{secondary}
+ ); return (
-
- {eyebrow ? {eyebrow} : null} -

{title}

-
-
{primary}{secondary}
+ {hasHeader ? ( +
+
+ {eyebrow ? {eyebrow} : null} + {title ?

{title}

: null} +
+ {action ?
{action}
: null} +
+ ) : action ? ( +
{action}
+ ) : null} + {body}
); } diff --git a/src/renderer/components/automation/AutomationPanel.tsx b/src/renderer/components/automation/AutomationPanel.tsx index 58f14e08..c77b242a 100644 --- a/src/renderer/components/automation/AutomationPanel.tsx +++ b/src/renderer/components/automation/AutomationPanel.tsx @@ -139,9 +139,6 @@ function RulesPanel({ rules, companyId, onRefresh, t }: { rules: AutomationRuleR
{t("auto.rules.noRules")}
-
- {t("auto.rules.noRulesDetail")} -
setShowCreate(true)} icon={Lightning} tone="accent" />
@@ -221,9 +218,6 @@ function WorkflowsPanel({ workflows, companyId, onRefresh, t }: { workflows: Wor
{t("auto.workflows.noWorkflows")}
-
- {t("auto.workflows.noWorkflowsDetail")} -
setShowCreate(true)} icon={ArrowsClockwise} tone="accent" />
diff --git a/src/renderer/components/communication/SearchResults.tsx b/src/renderer/components/communication/SearchResults.tsx index 921c75fb..06babb30 100644 --- a/src/renderer/components/communication/SearchResults.tsx +++ b/src/renderer/components/communication/SearchResults.tsx @@ -84,10 +84,7 @@ export function SearchResults({ results, query, loading, agents, onNavigateToRes if (results.length === 0 && query.length > 0) { return (
- +
) } diff --git a/src/renderer/components/company-onboarding/ExecutionModeStep.tsx b/src/renderer/components/company-onboarding/ExecutionModeStep.tsx index 7bf1723b..3a58d5d8 100644 --- a/src/renderer/components/company-onboarding/ExecutionModeStep.tsx +++ b/src/renderer/components/company-onboarding/ExecutionModeStep.tsx @@ -54,7 +54,7 @@ export function ExecutionModeStep({ initialValue, onContinue, onBack }: Executio - : rowBody} -
+
+
+ + {product.title} +
+
{subtitle}
+
+
e.stopPropagation()}>
@@ -128,7 +123,6 @@ export function DocumentCenter({ companyId, documents, workProducts, agents, pro {isEmpty ? ( setEditingId(NEW_ENTITY_ID)} />} /> ) : null} @@ -139,19 +133,19 @@ export function DocumentCenter({ companyId, documents, workProducts, agents, pro
{documents.map((document) => ( - + setEditingId(document.id)}>
- -
+
+
e.stopPropagation()}> - - + +
diff --git a/src/renderer/components/company-operations/SectionHeader.tsx b/src/renderer/components/company-operations/SectionHeader.tsx index 36a64c84..83dfb379 100644 --- a/src/renderer/components/company-operations/SectionHeader.tsx +++ b/src/renderer/components/company-operations/SectionHeader.tsx @@ -13,7 +13,7 @@ export function SectionHeader({ return (
-
{title}
+
{title}
{count} item{count === 1 ? "" : "s"}
diff --git a/src/renderer/components/dashboard/ActivityFeed.tsx b/src/renderer/components/dashboard/ActivityFeed.tsx index 00d3a139..9702ae0e 100644 --- a/src/renderer/components/dashboard/ActivityFeed.tsx +++ b/src/renderer/components/dashboard/ActivityFeed.tsx @@ -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" >
diff --git a/src/renderer/components/dashboard/Dashboard.tsx b/src/renderer/components/dashboard/Dashboard.tsx index f6318b73..b115b6e1 100644 --- a/src/renderer/components/dashboard/Dashboard.tsx +++ b/src/renderer/components/dashboard/Dashboard.tsx @@ -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" >
diff --git a/src/renderer/components/dashboard/KanbanBoard.tsx b/src/renderer/components/dashboard/KanbanBoard.tsx index 3fbfcb15..6b5d381c 100644 --- a/src/renderer/components/dashboard/KanbanBoard.tsx +++ b/src/renderer/components/dashboard/KanbanBoard.tsx @@ -50,7 +50,6 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps)
{t("kanban.noTasks")}
-
{t("kanban.noTasksDetail")}
); } diff --git a/src/renderer/components/dashboard/charts/AgentWorkloadChart.tsx b/src/renderer/components/dashboard/charts/AgentWorkloadChart.tsx index fa0a7c26..f755b826 100644 --- a/src/renderer/components/dashboard/charts/AgentWorkloadChart.tsx +++ b/src/renderer/components/dashboard/charts/AgentWorkloadChart.tsx @@ -37,7 +37,7 @@ export function AgentWorkloadChart({ data, reducedMotion }: AgentWorkloadChartPr axisLine={false} tickLine={false} /> - } /> + } /> string; } type Props = Partial> & 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 (
-
{dateStr}
+ {renderLabel && labelText ? ( +
{labelText}
+ ) : null} {payload.map((entry, i) => (
+ if (activity.length === 0) return return (
diff --git a/src/renderer/components/execution/ApprovalList.tsx b/src/renderer/components/execution/ApprovalList.tsx index f3950110..3f006e8d 100644 --- a/src/renderer/components/execution/ApprovalList.tsx +++ b/src/renderer/components/execution/ApprovalList.tsx @@ -17,7 +17,7 @@ export function ApprovalList({ approvals, selectedApprovalId, onSelect }: { appr overscan: 10, }) - if (approvals.length === 0) return + if (approvals.length === 0) return return (
+ if (costs.length === 0) return return (
diff --git a/src/renderer/components/execution/IncidentList.tsx b/src/renderer/components/execution/IncidentList.tsx index e9050ed7..0b4af7ea 100644 --- a/src/renderer/components/execution/IncidentList.tsx +++ b/src/renderer/components/execution/IncidentList.tsx @@ -77,10 +77,7 @@ export function IncidentList({ companyId, agents, projects }: IncidentListProps)
{incidents.length === 0 ? ( - + ) : ( {incidents.map((incident) => { diff --git a/src/renderer/components/execution/PendingReviewList.tsx b/src/renderer/components/execution/PendingReviewList.tsx index cad0ebf4..0c90b615 100644 --- a/src/renderer/components/execution/PendingReviewList.tsx +++ b/src/renderer/components/execution/PendingReviewList.tsx @@ -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 + return } const pendingApprovalTaskIds = new Set( approvals diff --git a/src/renderer/components/execution/PolicyList.tsx b/src/renderer/components/execution/PolicyList.tsx index 6b564d48..bccaaa93 100644 --- a/src/renderer/components/execution/PolicyList.tsx +++ b/src/renderer/components/execution/PolicyList.tsx @@ -72,7 +72,6 @@ export function PolicyList({ companyId, agents, projects }: PolicyListProps) {
} /> Promise; onCancelRun: (runId: string) => Promise; onCreateApproval: () => Promise; onOpenDiscussion: (taskId: string) => void - comments: CommentRecord[]; onRefresh: () => Promise; onDeleted: () => Promise +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 + onCancelRun: (runId: string) => Promise + onCreateApproval: () => Promise + onOpenDiscussion: (taskId: string) => void + comments: CommentRecord[] + onRefresh: () => Promise + onDeleted: () => Promise }) { 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(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(null); const [actionNotice, setActionNotice] = useState(null) - const [deleteError, setDeleteError] = useState(null); const [savingChanges, setSavingChanges] = useState(false); const [updatingStatus, setUpdatingStatus] = useState(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(null) + const [actionNotice, setActionNotice] = useState(null) + const [deleteError, setDeleteError] = useState(null) + const [updatingStatus, setUpdatingStatus] = useState(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 ( + + ) + } + + const countPill = (value: number) => ( + {value} + ) + + // 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 ( -
- void handleDelete()} onCancel={() => { setDeleteError(null); setConfirmDelete(false) }} /> - {budgetWarning > 0.8 && assignee ?
1 ? "border-[color:var(--destructive)] bg-destructive-muted text-destructive" : "border-[color:var(--destructive)] bg-warning-muted text-warning"}`}>{assignee.name} {t("exec.budgetUsed").replace("{0}", String(Math.round(budgetWarning * 100)))} ({formatMoney(assignee.spentMonthlyUsd)} / {formatMoney(assignee.budgetMonthlyUsd)})
: null} +
+ void handleDelete()} + onCancel={() => { setDeleteError(null); setConfirmDelete(false) }} + /> + {actionError ? : null} {!actionError && actionNotice ? : null} + {editing ? ( -
-
-