update
No files matched your search
|
After Width: | Height: | Size: 752 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 337 KiB |
|
After Width: | Height: | Size: 465 KiB |
|
After Width: | Height: | Size: 450 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 606 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -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 |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 739 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
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, {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} 个频道",
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||