源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export * from "./workspaceWorkflowRuntime";
|
||||
export * from "./tuttiModePlan";
|
||||
@@ -0,0 +1,258 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
TuttiModePlanPanel,
|
||||
type TuttiModePlanPanelLabels
|
||||
} from "./TuttiModePlanPanel";
|
||||
import type { TuttiModePlanPanelViewModel } from "./tuttiModePlanPanelProjection";
|
||||
import type { TuttiModePlanAssignmentCatalog } from "./useTuttiModePlanPanels";
|
||||
|
||||
const labels: TuttiModePlanPanelLabels = {
|
||||
tasks: "Tasks",
|
||||
priority: "Priority",
|
||||
priorityHigh: "High",
|
||||
priorityMedium: "Medium",
|
||||
priorityLow: "Low",
|
||||
agentTarget: "Agent",
|
||||
model: "Model",
|
||||
permissionMode: "Permission mode",
|
||||
reasoningEffort: "Reasoning effort",
|
||||
parallelizable: "Parallel",
|
||||
autoAccept: "Auto-accept",
|
||||
notSpecified: "Not specified",
|
||||
assignmentOptionsLoading: "Loading options..."
|
||||
};
|
||||
|
||||
const panel: TuttiModePlanPanelViewModel = {
|
||||
id: "workflow-1:checkpoint-1",
|
||||
workflowId: "workflow-1",
|
||||
workspaceId: "workspace-1",
|
||||
sourceSessionId: "session-1",
|
||||
sourceTurnId: "turn-1",
|
||||
sourceToolCallId: "tool-call-1",
|
||||
reviewKind: "task_review",
|
||||
state: "pending",
|
||||
actionable: true,
|
||||
title: "Ship the durable workflow",
|
||||
topicId: "topic-1",
|
||||
markdownBody: "## Goal\n\nShip safely.",
|
||||
revision: {
|
||||
id: "revision-1",
|
||||
sequence: 1,
|
||||
schemaVersion: "tutti-mode-plan/v1",
|
||||
documentPath: `tutti-mode-plans/workflow-1/revisions/${"a".repeat(64)}.md`,
|
||||
sha256: "a".repeat(64),
|
||||
producedByTurnId: "turn-1",
|
||||
createdAtUnixMs: 100
|
||||
},
|
||||
checkpoint: {
|
||||
id: "checkpoint-1",
|
||||
status: "pending",
|
||||
decidedBy: null,
|
||||
decisionReason: null,
|
||||
decidedAtUnixMs: null,
|
||||
createdAtUnixMs: 110,
|
||||
updatedAtUnixMs: 110
|
||||
},
|
||||
execution: {
|
||||
mode: "sequential",
|
||||
reasoningIntensity: 70,
|
||||
orchestrationIntensity: 60
|
||||
},
|
||||
budget: {
|
||||
mode: "fixed",
|
||||
tokenLimit: 12_000,
|
||||
quotaWaterlinePercent: 15
|
||||
},
|
||||
tasks: [
|
||||
{
|
||||
ordinal: 1,
|
||||
id: "implement",
|
||||
title: "Implement",
|
||||
content: "Build the workflow",
|
||||
priority: "high",
|
||||
agentTargetId: "codex-agent",
|
||||
modelPlanId: "model-plan-pro",
|
||||
model: "gpt-5.6-sol",
|
||||
permissionModeId: "acceptEdits",
|
||||
reasoningEffort: "high",
|
||||
executionDirectory: "/workspace/implement",
|
||||
dependsOn: ["foundation", "contract-tests"],
|
||||
parallelizable: true,
|
||||
autoAccept: true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
function assignmentCatalog(
|
||||
overrides: Partial<TuttiModePlanAssignmentCatalog> = {}
|
||||
): TuttiModePlanAssignmentCatalog {
|
||||
return {
|
||||
agents: [
|
||||
{ agentTargetId: "codex-agent", label: "Codex" },
|
||||
{ agentTargetId: "claude-agent", label: "Claude Code" }
|
||||
],
|
||||
optionsByAgentId: {
|
||||
"codex-agent": {
|
||||
models: [
|
||||
{ value: "gpt-5.6-sol", label: "GPT-5.6 Sol" },
|
||||
{
|
||||
value: "composer-2.5[fast=true]",
|
||||
label: "composer-2.5"
|
||||
}
|
||||
],
|
||||
modelPlans: [
|
||||
{
|
||||
modelPlanId: "model-plan-pro",
|
||||
label: "Pro plan",
|
||||
models: [{ value: "gpt-5.6-sol", label: "GPT-5.6 Sol" }]
|
||||
}
|
||||
],
|
||||
permissionModes: [{ id: "acceptEdits", label: "Accept edits" }],
|
||||
reasoningEfforts: [
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "high", label: "High" }
|
||||
]
|
||||
}
|
||||
},
|
||||
loadAgentOptions: vi.fn(),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe("TuttiModePlanPanel", () => {
|
||||
it("renders the plan without duplicating workflow phase badges", () => {
|
||||
render(
|
||||
<TuttiModePlanPanel labels={labels} panel={panel} submitting={false} />
|
||||
);
|
||||
|
||||
expect(screen.getByText("Ship the durable workflow")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Tutti mode plan")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Plan review")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Needs review")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides plan internals: execution/budget cards, task metadata, decisions", () => {
|
||||
render(
|
||||
<TuttiModePlanPanel labels={labels} panel={panel} submitting={false} />
|
||||
);
|
||||
|
||||
// Execution / budget tuning happens by re-planning, not on the card.
|
||||
expect(screen.queryByText("Execution")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Budget")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Sequential")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Quota waterline")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("15%")).not.toBeInTheDocument();
|
||||
// Task ID / execution directory / dependencies stay out of the card.
|
||||
expect(screen.queryByText("/workspace/implement")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("foundation")).not.toBeInTheDocument();
|
||||
// Decisions live in the composer now — no footer buttons.
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Accept" })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows specified assignments read-only without an assignment catalog", () => {
|
||||
render(
|
||||
<TuttiModePlanPanel labels={labels} panel={panel} submitting={false} />
|
||||
);
|
||||
|
||||
expect(screen.getByText("codex-agent")).toBeInTheDocument();
|
||||
expect(screen.getByText("gpt-5.6-sol")).toBeInTheDocument();
|
||||
expect(screen.getByText("acceptEdits")).toBeInTheDocument();
|
||||
expect(screen.getByText("high")).toBeInTheDocument();
|
||||
expect(screen.getByText("Parallel")).toBeInTheDocument();
|
||||
// Model plans never surface on tasks — plans enter via agent config.
|
||||
expect(screen.queryByText("model-plan-pro")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders per-task assignment selectors without a model plan select", () => {
|
||||
render(
|
||||
<TuttiModePlanPanel
|
||||
assignmentCatalog={assignmentCatalog()}
|
||||
assignmentDrafts={{}}
|
||||
labels={labels}
|
||||
panel={panel}
|
||||
submitting={false}
|
||||
onAssignmentDraftChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("tutti-plan-task-assignment-implement")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Agent")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Model")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Permission mode")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Reasoning effort")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Model plan")).not.toBeInTheDocument();
|
||||
// The parallel opt-in renders as a checked switch seeded from the plan.
|
||||
const parallelSwitch = screen.getByTestId(
|
||||
"tutti-plan-task-parallel-toggle-implement"
|
||||
);
|
||||
expect(parallelSwitch).toHaveAttribute("role", "switch");
|
||||
expect(parallelSwitch).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("renders the canonical model label instead of the provider-native value", () => {
|
||||
render(
|
||||
<TuttiModePlanPanel
|
||||
assignmentCatalog={assignmentCatalog()}
|
||||
assignmentDrafts={{}}
|
||||
labels={labels}
|
||||
panel={{
|
||||
...panel,
|
||||
tasks: [
|
||||
{
|
||||
...panel.tasks[0]!,
|
||||
model: "composer-2.5[fast=true]"
|
||||
}
|
||||
]
|
||||
}}
|
||||
submitting={false}
|
||||
onAssignmentDraftChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Model")).toHaveTextContent("composer-2.5");
|
||||
expect(screen.getByLabelText("Model")).not.toHaveTextContent("[fast=true]");
|
||||
});
|
||||
|
||||
it("stays read-only without a host-owned draft store", () => {
|
||||
render(
|
||||
<TuttiModePlanPanel
|
||||
assignmentCatalog={assignmentCatalog()}
|
||||
labels={labels}
|
||||
panel={panel}
|
||||
submitting={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("tutti-plan-task-assignment-implement")
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays read-only for non-actionable panels even with a catalog", () => {
|
||||
render(
|
||||
<TuttiModePlanPanel
|
||||
assignmentCatalog={assignmentCatalog()}
|
||||
assignmentDrafts={{}}
|
||||
labels={labels}
|
||||
panel={{ ...panel, state: "accepted", actionable: false }}
|
||||
submitting={false}
|
||||
onAssignmentDraftChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("tutti-plan-task-assignment-implement")
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTitle("Model")).toHaveTextContent("GPT-5.6 Sol");
|
||||
expect(screen.getByTitle("Permission mode")).toHaveTextContent(
|
||||
"Accept edits"
|
||||
);
|
||||
expect(screen.getByTitle("Reasoning effort")).toHaveTextContent("High");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
cn
|
||||
} from "@tutti-os/ui-system";
|
||||
import composerStyles from "../../agent-gui/agentGuiNode/AgentGUINode.styles";
|
||||
import type {
|
||||
TuttiModePlanPanelTaskViewModel,
|
||||
TuttiModePlanPanelViewModel
|
||||
} from "./tuttiModePlanPanelProjection";
|
||||
import type { TuttiModePlanAssignmentCatalog } from "./useTuttiModePlanPanels";
|
||||
import {
|
||||
assignmentOptionLabel,
|
||||
TuttiModePlanTaskAssignmentEditor,
|
||||
permissionModeAssignmentTone
|
||||
} from "./TuttiModePlanTaskAssignmentEditor";
|
||||
import type {
|
||||
TuttiModePlanTaskAssignmentDraft,
|
||||
TuttiModePlanTaskAssignmentDrafts
|
||||
} from "./tuttiModePlanTaskAssignments";
|
||||
|
||||
export interface TuttiModePlanPanelLabels {
|
||||
tasks: string;
|
||||
priority: string;
|
||||
priorityHigh: string;
|
||||
priorityMedium: string;
|
||||
priorityLow: string;
|
||||
agentTarget: string;
|
||||
model: string;
|
||||
permissionMode: string;
|
||||
reasoningEffort: string;
|
||||
parallelizable: string;
|
||||
autoAccept: string;
|
||||
notSpecified: string;
|
||||
assignmentOptionsLoading: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only review card for a pending Tutti mode plan. Decisions live in the
|
||||
* composer (empty send accepts, typed feedback rejects, the review banner
|
||||
* cancels), so the card carries no action buttons of its own; the only
|
||||
* interactive piece is the per-task assignment row, whose drafts the host
|
||||
* owns so an accept from the composer can include them.
|
||||
*/
|
||||
export function TuttiModePlanPanel({
|
||||
assignmentCatalog,
|
||||
assignmentDrafts,
|
||||
embedded = false,
|
||||
labels,
|
||||
panel,
|
||||
submitting,
|
||||
onAssignmentDraftChange
|
||||
}: {
|
||||
assignmentCatalog?: TuttiModePlanAssignmentCatalog | null;
|
||||
assignmentDrafts?: TuttiModePlanTaskAssignmentDrafts;
|
||||
embedded?: boolean;
|
||||
labels: TuttiModePlanPanelLabels;
|
||||
panel: TuttiModePlanPanelViewModel;
|
||||
submitting: boolean;
|
||||
onAssignmentDraftChange?(
|
||||
taskId: string,
|
||||
patch: TuttiModePlanTaskAssignmentDraft
|
||||
): void;
|
||||
}): React.JSX.Element {
|
||||
// Editing needs the loaded agent directory plus a host-owned draft store;
|
||||
// before that (or without a host catalog at all) tasks stay read-only.
|
||||
const editable =
|
||||
assignmentCatalog?.agents != null &&
|
||||
panel.actionable &&
|
||||
onAssignmentDraftChange !== undefined;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"w-full",
|
||||
embedded
|
||||
? "border-0 bg-transparent shadow-none"
|
||||
: "mx-auto max-w-[860px]"
|
||||
)}
|
||||
data-testid="tutti-mode-plan-panel"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>{panel.title}</CardTitle>
|
||||
<CardDescription>{panel.topicId}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="prose prose-sm max-w-none text-foreground dark:prose-invert">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
>
|
||||
{panel.markdownBody}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
{panel.tasks.length > 0 ? (
|
||||
<section className="grid gap-2">
|
||||
<h4 className="text-xs font-medium text-foreground">
|
||||
{labels.tasks}
|
||||
</h4>
|
||||
<ol className="grid gap-2">
|
||||
{panel.tasks.map((task) => (
|
||||
<li
|
||||
key={task.id}
|
||||
className="rounded-lg border border-[var(--line-2)] bg-[var(--background-board-card)] px-4 pt-3 pb-2 text-sm"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="font-medium text-foreground">
|
||||
{task.ordinal}. {task.title}
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{priorityLabel(labels, task.priority)}
|
||||
</Badge>
|
||||
</div>
|
||||
{task.content ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{task.content}
|
||||
</p>
|
||||
) : null}
|
||||
{editable && assignmentCatalog ? (
|
||||
<TuttiModePlanTaskAssignmentEditor
|
||||
catalog={assignmentCatalog}
|
||||
disabled={submitting}
|
||||
draft={assignmentDrafts?.[task.id] ?? {}}
|
||||
labels={labels}
|
||||
task={task}
|
||||
onEdit={(patch) =>
|
||||
onAssignmentDraftChange?.(task.id, patch)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<TaskAssignmentSummary
|
||||
assignmentCatalog={assignmentCatalog}
|
||||
labels={labels}
|
||||
task={task}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only counterpart of the assignment editor row: the same composer-styled
|
||||
* single line, showing only the assignments the plan actually specifies.
|
||||
*/
|
||||
function TaskAssignmentSummary({
|
||||
assignmentCatalog,
|
||||
labels,
|
||||
task
|
||||
}: {
|
||||
assignmentCatalog?: TuttiModePlanAssignmentCatalog | null;
|
||||
labels: TuttiModePlanPanelLabels;
|
||||
task: TuttiModePlanPanelTaskViewModel;
|
||||
}): React.JSX.Element | null {
|
||||
const agentDetail = task.agentTargetId
|
||||
? assignmentCatalog?.optionsByAgentId[task.agentTargetId]
|
||||
: null;
|
||||
const modelOptions = [
|
||||
...(agentDetail?.models ?? []),
|
||||
...(agentDetail?.modelPlans.flatMap((plan) => plan.models) ?? [])
|
||||
];
|
||||
const chips: {
|
||||
label: string;
|
||||
tone?: "accent" | "success" | "warning" | undefined;
|
||||
value: string | null;
|
||||
}[] = [
|
||||
{
|
||||
label: labels.agentTarget,
|
||||
value: task.agentTargetId
|
||||
? (assignmentCatalog?.agents?.find(
|
||||
(agent) => agent.agentTargetId === task.agentTargetId
|
||||
)?.label ?? task.agentTargetId)
|
||||
: null
|
||||
},
|
||||
{
|
||||
label: labels.model,
|
||||
value: assignmentOptionLabel(modelOptions, task.model)
|
||||
},
|
||||
{
|
||||
label: labels.permissionMode,
|
||||
tone: permissionModeAssignmentTone(task.permissionModeId),
|
||||
value: assignmentOptionLabel(
|
||||
(agentDetail?.permissionModes ?? []).map((mode) => ({
|
||||
label: mode.label,
|
||||
value: mode.id
|
||||
})),
|
||||
task.permissionModeId
|
||||
)
|
||||
},
|
||||
{
|
||||
label: labels.reasoningEffort,
|
||||
value: assignmentOptionLabel(
|
||||
agentDetail?.reasoningEfforts ?? [],
|
||||
task.reasoningEffort
|
||||
)
|
||||
},
|
||||
{
|
||||
label: labels.parallelizable,
|
||||
tone: "accent" as const,
|
||||
value: task.parallelizable ? labels.parallelizable : null
|
||||
},
|
||||
{
|
||||
label: labels.autoAccept,
|
||||
tone: "success" as const,
|
||||
value: task.autoAccept ? labels.autoAccept : null
|
||||
}
|
||||
].filter((chip) => chip.value !== null);
|
||||
if (chips.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="tutti-plan-task-assignment-row mt-3 flex flex-wrap items-center gap-3 border-t border-[var(--line-2)] pt-2">
|
||||
{chips.map((chip) => (
|
||||
<span
|
||||
key={chip.label}
|
||||
title={chip.label}
|
||||
data-permission-tone={chip.tone}
|
||||
className={cn(
|
||||
"w-auto max-w-full",
|
||||
composerStyles.composerMenuTrigger
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{chip.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function priorityLabel(
|
||||
labels: TuttiModePlanPanelLabels,
|
||||
priority: TuttiModePlanPanelViewModel["tasks"][number]["priority"]
|
||||
): string {
|
||||
switch (priority) {
|
||||
case "high":
|
||||
return labels.priorityHigh;
|
||||
case "medium":
|
||||
return labels.priorityMedium;
|
||||
case "low":
|
||||
return labels.priorityLow;
|
||||
}
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
import { CheckCheck, Split } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
Switch,
|
||||
cn
|
||||
} from "@tutti-os/ui-system";
|
||||
import composerStyles from "../../agent-gui/agentGuiNode/AgentGUINode.styles";
|
||||
import type { TuttiModePlanAssignmentCatalog } from "./useTuttiModePlanPanels";
|
||||
import type { TuttiModePlanPanelTaskViewModel } from "./tuttiModePlanPanelProjection";
|
||||
import {
|
||||
effectiveTaskAssignmentValue,
|
||||
effectiveTaskFlag,
|
||||
type TuttiModePlanTaskAssignmentDraft
|
||||
} from "./tuttiModePlanTaskAssignments";
|
||||
|
||||
export interface TuttiModePlanTaskAssignmentEditorLabels {
|
||||
agentTarget: string;
|
||||
model: string;
|
||||
permissionMode: string;
|
||||
reasoningEffort: string;
|
||||
parallelizable: string;
|
||||
autoAccept: string;
|
||||
notSpecified: string;
|
||||
assignmentOptionsLoading: string;
|
||||
}
|
||||
|
||||
const CLEAR_VALUE = "__tutti_plan_assignment_clear__";
|
||||
|
||||
function toSelectValue(value: string): string {
|
||||
return value === "" ? CLEAR_VALUE : value;
|
||||
}
|
||||
|
||||
function fromSelectValue(value: string): string {
|
||||
return value === CLEAR_VALUE ? "" : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the composer permission-mode trigger tones so the plan panel's
|
||||
* permission controls pick up the same accent/success/warning colors.
|
||||
*/
|
||||
export function permissionModeAssignmentTone(
|
||||
value: string | null | undefined
|
||||
): "accent" | "success" | "warning" | undefined {
|
||||
switch (value?.trim().toLowerCase().replace(/\s+/g, "-") || undefined) {
|
||||
case "read-only":
|
||||
case "readonly":
|
||||
case "ask-for-approval":
|
||||
return "success";
|
||||
case "auto":
|
||||
case "default":
|
||||
case "accept-edits":
|
||||
case "acceptedits":
|
||||
return "accent";
|
||||
case "full-access":
|
||||
case "bypasspermissions":
|
||||
return "warning";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function assignmentOptionLabel(
|
||||
options: readonly { label: string; value: string }[],
|
||||
value: string | null | undefined
|
||||
): string | null {
|
||||
const normalizedValue = value?.trim() ?? "";
|
||||
if (!normalizedValue) return null;
|
||||
return (
|
||||
options.find((option) => option.value === normalizedValue)?.label ??
|
||||
normalizedValue
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-task assignment selectors for the single review checkpoint, rendered as
|
||||
* one composer-styled row. Option catalogs are agent-scoped and supplied by
|
||||
* the host; the editor only chooses among them and reports draft edits upward.
|
||||
*
|
||||
* Model plans are deliberately not exposed here: a plan enters orchestration
|
||||
* by configuring an agent target bound to it, so the row offers only agent /
|
||||
* model / permission mode / reasoning effort. An explicit model choice clears
|
||||
* any document-stamped plan pin so the two can never contradict.
|
||||
*/
|
||||
export function TuttiModePlanTaskAssignmentEditor({
|
||||
catalog,
|
||||
disabled,
|
||||
draft,
|
||||
labels,
|
||||
onEdit,
|
||||
task
|
||||
}: {
|
||||
catalog: TuttiModePlanAssignmentCatalog;
|
||||
disabled: boolean;
|
||||
draft: TuttiModePlanTaskAssignmentDraft;
|
||||
labels: TuttiModePlanTaskAssignmentEditorLabels;
|
||||
onEdit(patch: TuttiModePlanTaskAssignmentDraft): void;
|
||||
task: TuttiModePlanPanelTaskViewModel;
|
||||
}): React.JSX.Element {
|
||||
const agentValue = effectiveTaskAssignmentValue(
|
||||
draft.agentTargetId,
|
||||
task.agentTargetId
|
||||
);
|
||||
const modelValue = effectiveTaskAssignmentValue(draft.model, task.model);
|
||||
const permissionValue = effectiveTaskAssignmentValue(
|
||||
draft.permissionModeId,
|
||||
task.permissionModeId
|
||||
);
|
||||
const effortValue = effectiveTaskAssignmentValue(
|
||||
draft.reasoningEffort,
|
||||
task.reasoningEffort
|
||||
);
|
||||
const parallelizable = effectiveTaskFlag(
|
||||
draft.parallelizable,
|
||||
task.parallelizable
|
||||
);
|
||||
const autoAccept = effectiveTaskFlag(draft.autoAccept, task.autoAccept);
|
||||
// Options for document-referenced agents are preloaded by the panels hook
|
||||
// alongside the snapshot refresh; user-driven agent changes trigger the
|
||||
// (deduplicated) load from the change handler, so no component effect is
|
||||
// needed here.
|
||||
const agentDetail = agentValue
|
||||
? (catalog.optionsByAgentId[agentValue] ?? null)
|
||||
: null;
|
||||
const modelOptions = agentDetail?.models ?? [];
|
||||
const detailPending = Boolean(agentValue) && !agentDetail;
|
||||
const agentLabel = agentValue
|
||||
? ((catalog.agents ?? []).find(
|
||||
(agent) => agent.agentTargetId === agentValue
|
||||
)?.label ?? agentValue)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="tutti-plan-task-assignment-row mt-3 flex flex-wrap items-center gap-3 border-t border-[var(--line-2)] pt-2"
|
||||
data-testid={`tutti-plan-task-assignment-${task.id}`}
|
||||
>
|
||||
<Select
|
||||
disabled={disabled}
|
||||
value={toSelectValue(agentValue)}
|
||||
onValueChange={(value) => {
|
||||
const nextAgent = fromSelectValue(value);
|
||||
if (nextAgent) catalog.loadAgentOptions(nextAgent);
|
||||
onEdit({ agentTargetId: nextAgent });
|
||||
}}
|
||||
>
|
||||
<AssignmentSelectTrigger
|
||||
fieldLabel={labels.agentTarget}
|
||||
value={agentLabel}
|
||||
/>
|
||||
<AssignmentSelectContent>
|
||||
<AssignmentSelectItem value={CLEAR_VALUE}>
|
||||
{labels.notSpecified}
|
||||
</AssignmentSelectItem>
|
||||
{(catalog.agents ?? []).map((agent) => (
|
||||
<AssignmentSelectItem
|
||||
key={agent.agentTargetId}
|
||||
value={agent.agentTargetId}
|
||||
>
|
||||
{agent.label}
|
||||
</AssignmentSelectItem>
|
||||
))}
|
||||
{agentValue &&
|
||||
!(catalog.agents ?? []).some(
|
||||
(agent) => agent.agentTargetId === agentValue
|
||||
) ? (
|
||||
<AssignmentSelectItem value={agentValue}>
|
||||
{agentValue}
|
||||
</AssignmentSelectItem>
|
||||
) : null}
|
||||
</AssignmentSelectContent>
|
||||
</Select>
|
||||
<AssignmentValueSelect
|
||||
disabled={disabled || !agentValue || detailPending}
|
||||
fieldLabel={labels.model}
|
||||
notSpecifiedLabel={labels.notSpecified}
|
||||
options={modelOptions}
|
||||
pending={detailPending}
|
||||
pendingLabel={labels.assignmentOptionsLoading}
|
||||
value={modelValue}
|
||||
onChange={(value) => onEdit({ model: value, modelPlanId: "" })}
|
||||
/>
|
||||
<AssignmentValueSelect
|
||||
disabled={disabled || !agentValue || detailPending}
|
||||
fieldLabel={labels.permissionMode}
|
||||
notSpecifiedLabel={labels.notSpecified}
|
||||
options={(agentDetail?.permissionModes ?? []).map((mode) => ({
|
||||
label: mode.label,
|
||||
value: mode.id
|
||||
}))}
|
||||
pending={detailPending}
|
||||
pendingLabel={labels.assignmentOptionsLoading}
|
||||
tone={permissionModeAssignmentTone(permissionValue)}
|
||||
value={permissionValue}
|
||||
onChange={(value) => onEdit({ permissionModeId: value })}
|
||||
/>
|
||||
<AssignmentValueSelect
|
||||
disabled={disabled || !agentValue || detailPending}
|
||||
fieldLabel={labels.reasoningEffort}
|
||||
notSpecifiedLabel={labels.notSpecified}
|
||||
options={agentDetail?.reasoningEfforts ?? []}
|
||||
pending={detailPending}
|
||||
pendingLabel={labels.assignmentOptionsLoading}
|
||||
value={effortValue}
|
||||
onChange={(value) => onEdit({ reasoningEffort: value })}
|
||||
/>
|
||||
<label
|
||||
className={cn(
|
||||
"flex min-h-[28px] w-auto shrink-0 items-center gap-1.5 text-[13px]",
|
||||
disabled
|
||||
? "cursor-not-allowed text-[var(--agent-gui-text-tertiary)]"
|
||||
: "cursor-pointer text-[var(--agent-gui-text-secondary)]"
|
||||
)}
|
||||
htmlFor={`tutti-plan-task-parallel-${task.id}`}
|
||||
title={labels.parallelizable}
|
||||
>
|
||||
<Split aria-hidden className="size-3.5 shrink-0" />
|
||||
<span className="min-w-0 truncate">{labels.parallelizable}</span>
|
||||
<Switch
|
||||
id={`tutti-plan-task-parallel-${task.id}`}
|
||||
size="sm"
|
||||
checked={parallelizable}
|
||||
disabled={disabled}
|
||||
aria-label={labels.parallelizable}
|
||||
data-testid={`tutti-plan-task-parallel-toggle-${task.id}`}
|
||||
onCheckedChange={(checked) => onEdit({ parallelizable: checked })}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
className={cn(
|
||||
"flex min-h-[28px] w-auto shrink-0 items-center gap-1.5 text-[13px]",
|
||||
disabled
|
||||
? "cursor-not-allowed text-[var(--agent-gui-text-tertiary)]"
|
||||
: "cursor-pointer text-[var(--agent-gui-text-secondary)]"
|
||||
)}
|
||||
htmlFor={`tutti-plan-task-auto-accept-${task.id}`}
|
||||
title={labels.autoAccept}
|
||||
>
|
||||
<CheckCheck aria-hidden className="size-3.5 shrink-0" />
|
||||
<span className="min-w-0 truncate">{labels.autoAccept}</span>
|
||||
<Switch
|
||||
id={`tutti-plan-task-auto-accept-${task.id}`}
|
||||
size="sm"
|
||||
checked={autoAccept}
|
||||
disabled={disabled}
|
||||
aria-label={labels.autoAccept}
|
||||
data-testid={`tutti-plan-task-auto-accept-toggle-${task.id}`}
|
||||
onCheckedChange={(checked) => onEdit({ autoAccept: checked })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentSelectTrigger({
|
||||
fieldLabel,
|
||||
pending = false,
|
||||
pendingLabel,
|
||||
tone,
|
||||
value
|
||||
}: {
|
||||
fieldLabel: string;
|
||||
pending?: boolean;
|
||||
pendingLabel?: string;
|
||||
tone?: "accent" | "success" | "warning" | undefined;
|
||||
value: string | null;
|
||||
}): React.JSX.Element {
|
||||
const placeholder = pending && pendingLabel ? pendingLabel : fieldLabel;
|
||||
return (
|
||||
<SelectTrigger
|
||||
aria-label={fieldLabel}
|
||||
title={fieldLabel}
|
||||
data-permission-tone={value !== null ? tone : undefined}
|
||||
className={cn(
|
||||
"w-auto max-w-full",
|
||||
composerStyles.composerMenuTrigger,
|
||||
pending && "animate-pulse"
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center">
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
value === null && "text-[var(--agent-gui-text-secondary)]"
|
||||
)}
|
||||
>
|
||||
{value ?? placeholder}
|
||||
</span>
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentSelectContent({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<SelectContent
|
||||
className={cn(
|
||||
"nodrag",
|
||||
composerStyles.composerMenuContent,
|
||||
"min-w-[190px]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectContent>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentSelectItem({
|
||||
children,
|
||||
value
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<SelectItem className={composerStyles.composerMenuItem} value={value}>
|
||||
{children}
|
||||
</SelectItem>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentValueSelect({
|
||||
disabled,
|
||||
fieldLabel,
|
||||
notSpecifiedLabel,
|
||||
onChange,
|
||||
options,
|
||||
pending,
|
||||
pendingLabel,
|
||||
tone,
|
||||
value
|
||||
}: {
|
||||
disabled: boolean;
|
||||
fieldLabel: string;
|
||||
notSpecifiedLabel: string;
|
||||
onChange(value: string): void;
|
||||
options: readonly { label: string; value: string }[];
|
||||
pending: boolean;
|
||||
pendingLabel: string;
|
||||
tone?: "accent" | "success" | "warning" | undefined;
|
||||
value: string;
|
||||
}): React.JSX.Element {
|
||||
const known = options.some((option) => option.value === value);
|
||||
const selectedLabel = assignmentOptionLabel(options, value);
|
||||
return (
|
||||
<Select
|
||||
disabled={disabled}
|
||||
value={toSelectValue(value)}
|
||||
onValueChange={(next) => onChange(fromSelectValue(next))}
|
||||
>
|
||||
<AssignmentSelectTrigger
|
||||
fieldLabel={fieldLabel}
|
||||
pending={pending}
|
||||
pendingLabel={pendingLabel}
|
||||
tone={tone}
|
||||
value={selectedLabel}
|
||||
/>
|
||||
<AssignmentSelectContent>
|
||||
<AssignmentSelectItem value={CLEAR_VALUE}>
|
||||
{notSpecifiedLabel}
|
||||
</AssignmentSelectItem>
|
||||
{options.map((option) => (
|
||||
<AssignmentSelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</AssignmentSelectItem>
|
||||
))}
|
||||
{value && !known ? (
|
||||
<AssignmentSelectItem value={value}>{value}</AssignmentSelectItem>
|
||||
) : null}
|
||||
</AssignmentSelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
TuttiPlanIssuePanel,
|
||||
groupTuttiPlanIssueTasksIntoStages,
|
||||
type TuttiPlanIssuePanelLabels
|
||||
} from "./TuttiPlanIssuePanel";
|
||||
import type { TuttiPlanIssueSnapshot } from "../workspaceWorkflowRuntime";
|
||||
|
||||
const labels: TuttiPlanIssuePanelLabels = {
|
||||
openIssue: "Open Issue",
|
||||
listView: "List",
|
||||
boardView: "Board",
|
||||
parallelizable: "Parallel",
|
||||
autoAccept: "Auto-accept",
|
||||
accept: "Accept",
|
||||
rework: "Rework",
|
||||
dependencies: "Depends",
|
||||
stageParallel: (index, count) => `Stage ${index} · parallel ×${count}`,
|
||||
stageSequential: (index) => `Stage ${index} · sequential`,
|
||||
summary: (done, total, running) =>
|
||||
`${done}/${total} done · ${running} running`,
|
||||
statusNotStarted: "Todo",
|
||||
statusRunning: "Running",
|
||||
statusPendingAcceptance: "In review",
|
||||
statusCompleted: "Done",
|
||||
statusFailed: "Failed",
|
||||
statusCanceled: "Canceled"
|
||||
};
|
||||
|
||||
const issue: TuttiPlanIssueSnapshot = {
|
||||
workflowId: "wf-1",
|
||||
sourceTurnId: "turn-1",
|
||||
issueId: "tutti-mode-plan-wf-1",
|
||||
topicId: "default",
|
||||
title: "Neon chase MVP",
|
||||
dispatchPaused: false,
|
||||
tasks: [
|
||||
{
|
||||
taskId: "p1",
|
||||
title: "Build the stage",
|
||||
content: "Foundations",
|
||||
status: "completed",
|
||||
sortIndex: 1,
|
||||
parallelizable: true,
|
||||
autoAccept: false,
|
||||
dependencyTaskIds: []
|
||||
},
|
||||
{
|
||||
taskId: "p2",
|
||||
title: "Core gameplay",
|
||||
content: "WASD",
|
||||
status: "running",
|
||||
sortIndex: 2,
|
||||
parallelizable: true,
|
||||
autoAccept: true,
|
||||
dependencyTaskIds: []
|
||||
},
|
||||
{
|
||||
taskId: "s3",
|
||||
title: "Polish and ship",
|
||||
content: "QA",
|
||||
status: "not_started",
|
||||
sortIndex: 3,
|
||||
parallelizable: false,
|
||||
autoAccept: false,
|
||||
dependencyTaskIds: ["p1", "p2"]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
describe("groupTuttiPlanIssueTasksIntoStages", () => {
|
||||
it("splits chained parallelizable tasks into separate stages", () => {
|
||||
// A parallelizable task that depends on a member of the running stage can
|
||||
// never actually run alongside it, so the display must not pretend it can.
|
||||
const chained: TuttiPlanIssueSnapshot["tasks"] = [
|
||||
{
|
||||
taskId: "c1",
|
||||
title: "First",
|
||||
content: "",
|
||||
status: "not_started",
|
||||
sortIndex: 1,
|
||||
parallelizable: true,
|
||||
autoAccept: false,
|
||||
dependencyTaskIds: []
|
||||
},
|
||||
{
|
||||
taskId: "c2",
|
||||
title: "Second",
|
||||
content: "",
|
||||
status: "not_started",
|
||||
sortIndex: 2,
|
||||
parallelizable: true,
|
||||
autoAccept: false,
|
||||
dependencyTaskIds: ["c1"]
|
||||
},
|
||||
{
|
||||
taskId: "c3",
|
||||
title: "Third",
|
||||
content: "",
|
||||
status: "not_started",
|
||||
sortIndex: 3,
|
||||
parallelizable: true,
|
||||
autoAccept: false,
|
||||
dependencyTaskIds: []
|
||||
}
|
||||
];
|
||||
const stages = groupTuttiPlanIssueTasksIntoStages(chained);
|
||||
expect(
|
||||
stages.map((stage) => stage.tasks.map((task) => task.taskId))
|
||||
).toEqual([["c1"], ["c2", "c3"]]);
|
||||
});
|
||||
|
||||
it("mirrors dispatcher semantics: parallel batch then exclusive stage", () => {
|
||||
const stages = groupTuttiPlanIssueTasksIntoStages(issue.tasks);
|
||||
expect(stages.map((stage) => stage.kind)).toEqual([
|
||||
"parallel",
|
||||
"sequential"
|
||||
]);
|
||||
expect(stages[0]?.tasks.map((task) => task.taskId)).toEqual(["p1", "p2"]);
|
||||
expect(stages[1]?.tasks.map((task) => task.taskId)).toEqual(["s3"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TuttiPlanIssuePanel", () => {
|
||||
it("defaults to the board with status columns and structure chips", () => {
|
||||
render(
|
||||
<TuttiPlanIssuePanel
|
||||
issue={issue}
|
||||
labels={labels}
|
||||
onOpenIssue={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Neon chase MVP")).toBeInTheDocument();
|
||||
expect(screen.getByText("1/3 done · 1 running")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId("tutti-plan-issue-column-running")
|
||||
).toHaveTextContent("Core gameplay");
|
||||
expect(
|
||||
screen.getByTestId("tutti-plan-issue-column-completed")
|
||||
).toHaveTextContent("Build the stage");
|
||||
// No failed/canceled tasks: those columns stay hidden.
|
||||
expect(
|
||||
screen.queryByTestId("tutti-plan-issue-column-failed")
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText("Parallel").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("switches to the stage-grouped list view", () => {
|
||||
render(
|
||||
<TuttiPlanIssuePanel
|
||||
issue={issue}
|
||||
labels={labels}
|
||||
onOpenIssue={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("tutti-plan-issue-view-list"));
|
||||
expect(
|
||||
screen.getByTestId("tutti-plan-issue-stage-parallel")
|
||||
).toHaveTextContent("Stage 1 · parallel ×2");
|
||||
expect(
|
||||
screen.getByTestId("tutti-plan-issue-stage-sequential")
|
||||
).toHaveTextContent("Stage 2 · sequential");
|
||||
expect(screen.getByText("Depends: p1, p2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("jumps to the full issue surface", () => {
|
||||
const onOpenIssue = vi.fn();
|
||||
render(
|
||||
<TuttiPlanIssuePanel
|
||||
issue={issue}
|
||||
labels={labels}
|
||||
onOpenIssue={onOpenIssue}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("tutti-plan-issue-open"));
|
||||
expect(onOpenIssue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not expose a panel-local stop while runs are live", () => {
|
||||
const liveTask = {
|
||||
taskId: "live",
|
||||
title: "Working",
|
||||
content: "",
|
||||
status: "running",
|
||||
sortIndex: 1,
|
||||
parallelizable: false,
|
||||
autoAccept: false,
|
||||
dependencyTaskIds: []
|
||||
};
|
||||
const runningIssue: TuttiPlanIssueSnapshot = {
|
||||
...issue,
|
||||
tasks: [liveTask]
|
||||
};
|
||||
const { rerender } = render(
|
||||
<TuttiPlanIssuePanel issue={runningIssue} labels={labels} />
|
||||
);
|
||||
expect(screen.queryByTestId("tutti-plan-issue-stop")).toBeNull();
|
||||
rerender(
|
||||
<TuttiPlanIssuePanel
|
||||
issue={{
|
||||
...runningIssue,
|
||||
tasks: [{ ...liveTask, status: "canceled" }]
|
||||
}}
|
||||
labels={labels}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId("tutti-plan-issue-stop")).toBeNull();
|
||||
});
|
||||
|
||||
it("jumps into the delegate conversation from a launched task card", () => {
|
||||
const launchedIssue: TuttiPlanIssueSnapshot = {
|
||||
...issue,
|
||||
tasks: [
|
||||
{
|
||||
taskId: "live",
|
||||
title: "Working",
|
||||
content: "",
|
||||
status: "running",
|
||||
sortIndex: 1,
|
||||
parallelizable: false,
|
||||
autoAccept: false,
|
||||
dependencyTaskIds: []
|
||||
},
|
||||
{
|
||||
taskId: "gate",
|
||||
title: "Review me",
|
||||
content: "",
|
||||
status: "pending_acceptance",
|
||||
sortIndex: 2,
|
||||
parallelizable: false,
|
||||
autoAccept: false,
|
||||
dependencyTaskIds: []
|
||||
},
|
||||
{
|
||||
taskId: "later",
|
||||
title: "Not launched",
|
||||
content: "",
|
||||
status: "not_started",
|
||||
sortIndex: 3,
|
||||
parallelizable: false,
|
||||
autoAccept: false,
|
||||
dependencyTaskIds: []
|
||||
}
|
||||
]
|
||||
};
|
||||
const onOpenTask = vi.fn();
|
||||
const onTaskAction = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<TuttiPlanIssuePanel
|
||||
issue={launchedIssue}
|
||||
labels={labels}
|
||||
onOpenTask={onOpenTask}
|
||||
onTaskAction={onTaskAction}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("tutti-plan-issue-task-live"));
|
||||
expect(onOpenTask).toHaveBeenCalledWith("live");
|
||||
// A task that never launched has no conversation to jump into.
|
||||
fireEvent.click(screen.getByTestId("tutti-plan-issue-task-later"));
|
||||
expect(onOpenTask).toHaveBeenCalledTimes(1);
|
||||
// Prompt actions must not also open the delegate task conversation.
|
||||
fireEvent.click(screen.getByTestId("tutti-plan-issue-accept-gate"));
|
||||
expect(onTaskAction).toHaveBeenCalledWith("gate", "accept");
|
||||
expect(onOpenTask).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows the auto-accept chip on flagged tasks", () => {
|
||||
render(
|
||||
<TuttiPlanIssuePanel
|
||||
issue={issue}
|
||||
labels={labels}
|
||||
onOpenIssue={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByText("Auto-accept").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("requests source-Agent prompts for pending task actions", async () => {
|
||||
const pendingIssue: TuttiPlanIssueSnapshot = {
|
||||
...issue,
|
||||
tasks: [
|
||||
{
|
||||
taskId: "gate",
|
||||
title: "Review me",
|
||||
content: "",
|
||||
status: "pending_acceptance",
|
||||
sortIndex: 1,
|
||||
parallelizable: false,
|
||||
autoAccept: false,
|
||||
dependencyTaskIds: []
|
||||
}
|
||||
]
|
||||
};
|
||||
const onTaskAction = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<TuttiPlanIssuePanel
|
||||
issue={pendingIssue}
|
||||
labels={labels}
|
||||
onTaskAction={onTaskAction}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("tutti-plan-issue-accept-gate"));
|
||||
expect(onTaskAction).toHaveBeenCalledWith("gate", "accept");
|
||||
// In-flight prompt preparation disables both buttons until it settles.
|
||||
expect(screen.getByTestId("tutti-plan-issue-rework-gate")).toBeDisabled();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("tutti-plan-issue-rework-gate")).toBeEnabled()
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("tutti-plan-issue-rework-gate"));
|
||||
expect(onTaskAction).toHaveBeenCalledWith("gate", "rework");
|
||||
});
|
||||
|
||||
it("offers only a rework prompt action on a failed task", () => {
|
||||
const failedIssue: TuttiPlanIssueSnapshot = {
|
||||
...issue,
|
||||
tasks: [
|
||||
{
|
||||
taskId: "boom",
|
||||
title: "Broke",
|
||||
content: "",
|
||||
status: "failed",
|
||||
sortIndex: 1,
|
||||
parallelizable: false,
|
||||
autoAccept: false,
|
||||
dependencyTaskIds: []
|
||||
}
|
||||
]
|
||||
};
|
||||
const onTaskAction = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<TuttiPlanIssuePanel
|
||||
issue={failedIssue}
|
||||
labels={labels}
|
||||
onTaskAction={onTaskAction}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId("tutti-plan-issue-accept-boom")
|
||||
).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("tutti-plan-issue-rework-boom"));
|
||||
expect(onTaskAction).toHaveBeenCalledWith("boom", "rework");
|
||||
});
|
||||
|
||||
it("keeps pending tasks display-only without a prompt callback", () => {
|
||||
const pendingIssue: TuttiPlanIssueSnapshot = {
|
||||
...issue,
|
||||
tasks: [
|
||||
{
|
||||
taskId: "gate",
|
||||
title: "Review me",
|
||||
content: "",
|
||||
status: "pending_acceptance",
|
||||
sortIndex: 1,
|
||||
parallelizable: false,
|
||||
autoAccept: false,
|
||||
dependencyTaskIds: []
|
||||
}
|
||||
]
|
||||
};
|
||||
render(<TuttiPlanIssuePanel issue={pendingIssue} labels={labels} />);
|
||||
expect(
|
||||
screen.queryByTestId("tutti-plan-issue-accept-gate")
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,577 @@
|
||||
import { useState } from "react";
|
||||
import { ExternalLink, ListChecks, RotateCcw } from "lucide-react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
SegmentBar,
|
||||
cn
|
||||
} from "@tutti-os/ui-system";
|
||||
import type {
|
||||
TuttiPlanIssueSnapshot,
|
||||
TuttiPlanIssueTaskSnapshot
|
||||
} from "../workspaceWorkflowRuntime";
|
||||
import type { TuttiModePlanTaskPromptAction } from "./tuttiModePlanPromptActions";
|
||||
|
||||
export interface TuttiPlanIssuePanelLabels {
|
||||
openIssue: string;
|
||||
listView: string;
|
||||
boardView: string;
|
||||
parallelizable: string;
|
||||
autoAccept: string;
|
||||
accept: string;
|
||||
rework: string;
|
||||
dependencies: string;
|
||||
stageParallel: (index: string, count: string) => string;
|
||||
stageSequential: (index: string) => string;
|
||||
summary: (done: string, total: string, running: string) => string;
|
||||
statusNotStarted: string;
|
||||
statusRunning: string;
|
||||
statusPendingAcceptance: string;
|
||||
statusCompleted: string;
|
||||
statusFailed: string;
|
||||
statusCanceled: string;
|
||||
}
|
||||
|
||||
type TuttiPlanIssueViewMode = "list" | "board";
|
||||
|
||||
const BOARD_STATUS_ORDER = [
|
||||
"not_started",
|
||||
"running",
|
||||
"pending_acceptance",
|
||||
"completed",
|
||||
"failed",
|
||||
"canceled"
|
||||
] as const;
|
||||
|
||||
type BoardStatus = (typeof BOARD_STATUS_ORDER)[number];
|
||||
|
||||
function boardStatusOf(task: TuttiPlanIssueTaskSnapshot): BoardStatus {
|
||||
return (BOARD_STATUS_ORDER as readonly string[]).includes(task.status)
|
||||
? (task.status as BoardStatus)
|
||||
: "not_started";
|
||||
}
|
||||
|
||||
function statusLabel(
|
||||
labels: TuttiPlanIssuePanelLabels,
|
||||
status: BoardStatus
|
||||
): string {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return labels.statusRunning;
|
||||
case "pending_acceptance":
|
||||
return labels.statusPendingAcceptance;
|
||||
case "completed":
|
||||
return labels.statusCompleted;
|
||||
case "failed":
|
||||
return labels.statusFailed;
|
||||
case "canceled":
|
||||
return labels.statusCanceled;
|
||||
default:
|
||||
return labels.statusNotStarted;
|
||||
}
|
||||
}
|
||||
|
||||
function statusDotClassName(status: BoardStatus): string {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "bg-[var(--status-running)]";
|
||||
case "pending_acceptance":
|
||||
return "bg-[var(--tutti-purple)]";
|
||||
case "completed":
|
||||
return "bg-[var(--state-success)]";
|
||||
case "failed":
|
||||
return "bg-[var(--state-danger)]";
|
||||
case "canceled":
|
||||
return "bg-[var(--text-tertiary)]";
|
||||
default:
|
||||
return "bg-[var(--text-secondary)]";
|
||||
}
|
||||
}
|
||||
|
||||
type TuttiPlanIssueStage = {
|
||||
kind: "parallel" | "sequential";
|
||||
tasks: TuttiPlanIssueTaskSnapshot[];
|
||||
};
|
||||
|
||||
/** Mirrors the dispatcher grouping: consecutive parallelizable tasks share a
|
||||
* stage only while they stay independent of each other — a task that depends
|
||||
* on a member of the running stage can never actually run alongside it
|
||||
* (dependencies outrank the flag at dispatch), so it starts a new stage. */
|
||||
export function groupTuttiPlanIssueTasksIntoStages(
|
||||
tasks: readonly TuttiPlanIssueTaskSnapshot[]
|
||||
): TuttiPlanIssueStage[] {
|
||||
const ordered = [...tasks].sort(
|
||||
(left, right) => left.sortIndex - right.sortIndex
|
||||
);
|
||||
const stages: TuttiPlanIssueStage[] = [];
|
||||
for (const task of ordered) {
|
||||
const previous = stages.at(-1);
|
||||
if (
|
||||
task.parallelizable &&
|
||||
previous?.kind === "parallel" &&
|
||||
!task.dependencyTaskIds.some((dependencyId) =>
|
||||
previous.tasks.some((member) => member.taskId === dependencyId)
|
||||
)
|
||||
) {
|
||||
previous.tasks.push(task);
|
||||
continue;
|
||||
}
|
||||
stages.push({
|
||||
kind: task.parallelizable ? "parallel" : "sequential",
|
||||
tasks: [task]
|
||||
});
|
||||
}
|
||||
return stages;
|
||||
}
|
||||
|
||||
/** A task action that the source conversation turns into an Agent draft. */
|
||||
export type TuttiPlanIssueTaskAction = TuttiModePlanTaskPromptAction;
|
||||
|
||||
/** A task that has launched has a delegate conversation to jump into. */
|
||||
function taskHasConversation(task: TuttiPlanIssueTaskSnapshot): boolean {
|
||||
return task.status !== "not_started";
|
||||
}
|
||||
|
||||
/**
|
||||
* Embedded "issue panel view" for the source conversation: once the accepted
|
||||
* plan materialized an Issue, the conversation shows its subtasks as a live
|
||||
* board/list. Pending and failed tasks can prepare accept/rework instructions
|
||||
* in the source Agent composer; the panel itself does not mutate the managed
|
||||
* Issue. A jump into the full Issue surface remains one click away.
|
||||
*/
|
||||
export function TuttiPlanIssuePanel({
|
||||
embedded = false,
|
||||
issue,
|
||||
labels,
|
||||
onOpenIssue,
|
||||
onTaskAction,
|
||||
onOpenTask
|
||||
}: {
|
||||
embedded?: boolean;
|
||||
issue: TuttiPlanIssueSnapshot;
|
||||
labels: TuttiPlanIssuePanelLabels;
|
||||
onOpenIssue?: () => void;
|
||||
onTaskAction?: (
|
||||
taskId: string,
|
||||
action: TuttiPlanIssueTaskAction
|
||||
) => Promise<void>;
|
||||
/** Jump into the delegate conversation of a task that has launched. */
|
||||
onOpenTask?: (taskId: string) => void | Promise<void>;
|
||||
}): React.JSX.Element {
|
||||
const [viewMode, setViewMode] = useState<TuttiPlanIssueViewMode>("board");
|
||||
const [decidingTaskIds, setDecidingTaskIds] = useState<readonly string[]>([]);
|
||||
const requestTaskAction = onTaskAction
|
||||
? (taskId: string, action: TuttiPlanIssueTaskAction): void => {
|
||||
setDecidingTaskIds((current) =>
|
||||
current.includes(taskId) ? current : [...current, taskId]
|
||||
);
|
||||
void onTaskAction(taskId, action)
|
||||
.catch((error: unknown) => {
|
||||
// Keep prompt preparation failures observable; otherwise a rejected
|
||||
// host callback would read as a dead button.
|
||||
console.error("tutti plan issue task prompt action failed", error);
|
||||
})
|
||||
.finally(() => {
|
||||
setDecidingTaskIds((current) =>
|
||||
current.filter((id) => id !== taskId)
|
||||
);
|
||||
});
|
||||
}
|
||||
: undefined;
|
||||
const done = issue.tasks.filter((task) => task.status === "completed").length;
|
||||
const running = issue.tasks.filter(
|
||||
(task) => task.status === "running"
|
||||
).length;
|
||||
return (
|
||||
<Card
|
||||
size="sm"
|
||||
className={cn(
|
||||
"w-full pt-2!",
|
||||
embedded && "border-0 bg-transparent shadow-none"
|
||||
)}
|
||||
data-testid="tutti-plan-issue-panel"
|
||||
>
|
||||
<CardHeader className="gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ListChecks
|
||||
aria-hidden
|
||||
className="size-4 shrink-0 text-[var(--tutti-purple)]"
|
||||
/>
|
||||
<span className="min-w-0 truncate text-sm font-medium text-foreground">
|
||||
{issue.title}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{labels.summary(
|
||||
String(done),
|
||||
String(issue.tasks.length),
|
||||
String(running)
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<SegmentBar
|
||||
segments={[
|
||||
{
|
||||
label: labels.listView,
|
||||
testId: "tutti-plan-issue-view-list",
|
||||
value: "list"
|
||||
},
|
||||
{
|
||||
label: labels.boardView,
|
||||
testId: "tutti-plan-issue-view-board",
|
||||
value: "board"
|
||||
}
|
||||
]}
|
||||
value={viewMode}
|
||||
onValueChange={setViewMode}
|
||||
/>
|
||||
{onOpenIssue ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
data-testid="tutti-plan-issue-open"
|
||||
onClick={onOpenIssue}
|
||||
>
|
||||
<ExternalLink aria-hidden className="size-3.5" />
|
||||
{labels.openIssue}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{viewMode === "board" ? (
|
||||
<TuttiPlanIssueBoard
|
||||
issue={issue}
|
||||
labels={labels}
|
||||
requestTaskAction={requestTaskAction}
|
||||
decidingTaskIds={decidingTaskIds}
|
||||
openTask={onOpenTask}
|
||||
/>
|
||||
) : (
|
||||
<TuttiPlanIssueList
|
||||
issue={issue}
|
||||
labels={labels}
|
||||
requestTaskAction={requestTaskAction}
|
||||
decidingTaskIds={decidingTaskIds}
|
||||
openTask={onOpenTask}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskStructureChips({
|
||||
labels,
|
||||
task
|
||||
}: {
|
||||
labels: TuttiPlanIssuePanelLabels;
|
||||
task: TuttiPlanIssueTaskSnapshot;
|
||||
}): React.JSX.Element | null {
|
||||
const dependencies = task.dependencyTaskIds.filter((id) => id.trim() !== "");
|
||||
if (!task.parallelizable && !task.autoAccept && dependencies.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-1">
|
||||
{task.parallelizable ? (
|
||||
<Badge variant="accent" size="sm">
|
||||
{labels.parallelizable}
|
||||
</Badge>
|
||||
) : null}
|
||||
{task.autoAccept ? (
|
||||
<Badge variant="success" size="sm">
|
||||
{labels.autoAccept}
|
||||
</Badge>
|
||||
) : null}
|
||||
{dependencies.length > 0 ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="min-w-0 truncate"
|
||||
title={dependencies.join(", ")}
|
||||
>
|
||||
{labels.dependencies}: {dependencies.join(", ")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskPromptActions({
|
||||
labels,
|
||||
task,
|
||||
requestTaskAction,
|
||||
deciding
|
||||
}: {
|
||||
labels: TuttiPlanIssuePanelLabels;
|
||||
task: TuttiPlanIssueTaskSnapshot;
|
||||
requestTaskAction?: (
|
||||
taskId: string,
|
||||
action: TuttiPlanIssueTaskAction
|
||||
) => void;
|
||||
deciding: boolean;
|
||||
}): React.JSX.Element | null {
|
||||
// These buttons only prepare source-Agent instructions. The Agent remains
|
||||
// responsible for inspecting the authoritative execution before acting.
|
||||
const canAccept = task.status === "pending_acceptance";
|
||||
const canRework = canAccept || task.status === "failed";
|
||||
if (!requestTaskAction || !canRework) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{canAccept ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-6 bg-[var(--tutti-purple)] px-2 text-[11px] text-white hover:bg-[color-mix(in_srgb,var(--tutti-purple)_85%,black)]"
|
||||
disabled={deciding}
|
||||
data-testid={`tutti-plan-issue-accept-${task.taskId}`}
|
||||
onClick={(event) => {
|
||||
// The card itself jumps into the delegate conversation; preparing
|
||||
// a source-Agent instruction must not trigger that navigation.
|
||||
event.stopPropagation();
|
||||
requestTaskAction(task.taskId, "accept");
|
||||
}}
|
||||
>
|
||||
{labels.accept}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-6 px-2 text-[11px]"
|
||||
disabled={deciding}
|
||||
data-testid={`tutti-plan-issue-rework-${task.taskId}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
requestTaskAction(task.taskId, "rework");
|
||||
}}
|
||||
>
|
||||
<RotateCcw aria-hidden className="size-3.5" />
|
||||
{labels.rework}
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TuttiPlanIssueBoard({
|
||||
issue,
|
||||
labels,
|
||||
requestTaskAction,
|
||||
decidingTaskIds,
|
||||
openTask
|
||||
}: {
|
||||
issue: TuttiPlanIssueSnapshot;
|
||||
labels: TuttiPlanIssuePanelLabels;
|
||||
requestTaskAction?: (
|
||||
taskId: string,
|
||||
action: TuttiPlanIssueTaskAction
|
||||
) => void;
|
||||
decidingTaskIds: readonly string[];
|
||||
openTask?: (taskId: string) => void | Promise<void>;
|
||||
}): React.JSX.Element {
|
||||
const groups = new Map<BoardStatus, TuttiPlanIssueTaskSnapshot[]>();
|
||||
for (const task of issue.tasks) {
|
||||
const status = boardStatusOf(task);
|
||||
groups.set(status, [...(groups.get(status) ?? []), task]);
|
||||
}
|
||||
const columns = BOARD_STATUS_ORDER.filter((status) =>
|
||||
status === "failed" || status === "canceled"
|
||||
? (groups.get(status)?.length ?? 0) > 0
|
||||
: true
|
||||
);
|
||||
return (
|
||||
<div className="board-scroll min-w-0 overflow-x-auto pb-1">
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columns.length}, minmax(280px, 1fr))`
|
||||
}}
|
||||
>
|
||||
{columns.map((status) => {
|
||||
const tasks = groups.get(status) ?? [];
|
||||
return (
|
||||
<div
|
||||
key={status}
|
||||
className="min-h-[220px] min-w-0 rounded-lg border border-[var(--line-2)] bg-muted/30 px-2 py-2"
|
||||
data-testid={`tutti-plan-issue-column-${status}`}
|
||||
>
|
||||
<div className="mx-1 mb-1.5 flex items-center justify-between gap-2">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
statusDotClassName(status)
|
||||
)}
|
||||
/>
|
||||
<span className="truncate text-[11px] font-medium text-foreground">
|
||||
{statusLabel(labels, status)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
{tasks.map((task) => {
|
||||
const openable = openTask && taskHasConversation(task);
|
||||
return (
|
||||
<div
|
||||
key={task.taskId}
|
||||
role={openable ? "button" : undefined}
|
||||
tabIndex={openable ? 0 : undefined}
|
||||
data-testid={`tutti-plan-issue-task-${task.taskId}`}
|
||||
className={cn(
|
||||
"min-w-0 overflow-hidden rounded-md bg-[var(--background-board-card)] p-3",
|
||||
openable && "cursor-pointer"
|
||||
)}
|
||||
onClick={
|
||||
openable ? () => void openTask(task.taskId) : undefined
|
||||
}
|
||||
onKeyDown={
|
||||
openable
|
||||
? (event) => {
|
||||
if (event.key === "Enter") {
|
||||
void openTask(task.taskId);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className="line-clamp-2 text-[13px] font-medium text-foreground">
|
||||
{task.title}
|
||||
</span>
|
||||
<span className="mt-2 block empty:hidden">
|
||||
<TaskStructureChips labels={labels} task={task} />
|
||||
</span>
|
||||
<span className="mt-1.5 block empty:hidden">
|
||||
<TaskPromptActions
|
||||
labels={labels}
|
||||
task={task}
|
||||
requestTaskAction={requestTaskAction}
|
||||
deciding={decidingTaskIds.includes(task.taskId)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TuttiPlanIssueList({
|
||||
issue,
|
||||
labels,
|
||||
requestTaskAction,
|
||||
decidingTaskIds,
|
||||
openTask
|
||||
}: {
|
||||
issue: TuttiPlanIssueSnapshot;
|
||||
labels: TuttiPlanIssuePanelLabels;
|
||||
requestTaskAction?: (
|
||||
taskId: string,
|
||||
action: TuttiPlanIssueTaskAction
|
||||
) => void;
|
||||
decidingTaskIds: readonly string[];
|
||||
openTask?: (taskId: string) => void | Promise<void>;
|
||||
}): React.JSX.Element {
|
||||
const showStages = issue.tasks.some((task) => task.parallelizable);
|
||||
const stages = showStages
|
||||
? groupTuttiPlanIssueTasksIntoStages(issue.tasks)
|
||||
: [{ kind: "sequential" as const, tasks: [...issue.tasks] }];
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-[var(--line-2)] bg-[var(--background-board-card)]">
|
||||
{stages.map((stage, index) => (
|
||||
<div key={`stage-${index}`}>
|
||||
{showStages ? (
|
||||
<div
|
||||
className="border-b border-[var(--line-2)] bg-muted/40 px-3 py-1 text-[10px] font-medium text-muted-foreground"
|
||||
data-testid={`tutti-plan-issue-stage-${stage.kind}`}
|
||||
>
|
||||
{stage.kind === "parallel"
|
||||
? labels.stageParallel(
|
||||
String(index + 1),
|
||||
String(stage.tasks.length)
|
||||
)
|
||||
: labels.stageSequential(String(index + 1))}
|
||||
</div>
|
||||
) : null}
|
||||
{stage.tasks.map((task) => {
|
||||
const status = boardStatusOf(task);
|
||||
const openable = openTask && taskHasConversation(task);
|
||||
return (
|
||||
<div
|
||||
key={task.taskId}
|
||||
role={openable ? "button" : undefined}
|
||||
tabIndex={openable ? 0 : undefined}
|
||||
data-testid={`tutti-plan-issue-row-${task.taskId}`}
|
||||
className={cn(
|
||||
"flex items-start justify-between gap-3 border-b border-[var(--line-2)] px-3 py-2 last:border-b-0",
|
||||
openable && "cursor-pointer"
|
||||
)}
|
||||
onClick={
|
||||
openable ? () => void openTask(task.taskId) : undefined
|
||||
}
|
||||
onKeyDown={
|
||||
openable
|
||||
? (event) => {
|
||||
if (event.key === "Enter") {
|
||||
void openTask(task.taskId);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span className="truncate text-[13px] font-medium text-foreground">
|
||||
{task.title}
|
||||
</span>
|
||||
<TaskStructureChips labels={labels} task={task} />
|
||||
</div>
|
||||
{task.content ? (
|
||||
<p className="mt-1 line-clamp-2 text-[11px] text-muted-foreground">
|
||||
{task.content}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<TaskPromptActions
|
||||
labels={labels}
|
||||
task={task}
|
||||
requestTaskAction={requestTaskAction}
|
||||
deciding={decidingTaskIds.includes(task.taskId)}
|
||||
/>
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
statusDotClassName(status)
|
||||
)}
|
||||
/>
|
||||
{statusLabel(labels, status)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from "./tuttiModePlanPanelProjection";
|
||||
export * from "./tuttiModePlanTaskAssignments";
|
||||
export * from "./tuttiModePlanPromptActions";
|
||||
export * from "./TuttiModePlanPanel";
|
||||
export * from "./TuttiModePlanTaskAssignmentEditor";
|
||||
export * from "./TuttiPlanIssuePanel";
|
||||
export * from "./useTuttiModePlanPanels";
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
projectTuttiModePlanPanel,
|
||||
type TuttiModePlanReviewSnapshot
|
||||
} from "./tuttiModePlanPanelProjection";
|
||||
|
||||
function planSnapshot(
|
||||
overrides: Partial<TuttiModePlanReviewSnapshot> = {}
|
||||
): TuttiModePlanReviewSnapshot {
|
||||
return {
|
||||
workflow: {
|
||||
id: "workflow-1",
|
||||
workspaceId: "workspace-1",
|
||||
type: "tutti_mode_plan",
|
||||
owner: "tutti",
|
||||
triggerKind: "agent_cli",
|
||||
sourceSessionId: "session-1",
|
||||
sourceTurnId: "turn-1",
|
||||
sourceToolCallId: "tool-call-1",
|
||||
status: "pending_review",
|
||||
currentRevisionId: "revision-1"
|
||||
},
|
||||
revisions: [
|
||||
{
|
||||
id: "revision-1",
|
||||
workflowId: "workflow-1",
|
||||
sequence: 1,
|
||||
schemaVersion: "tutti-mode-plan/v1",
|
||||
documentPath: `tutti-mode-plans/workflow-1/revisions/${"a".repeat(64)}.md`,
|
||||
sha256:
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
producedByTurnId: "turn-1",
|
||||
createdAtUnixMs: 110,
|
||||
document: {
|
||||
schema: "tutti-mode-plan/v1",
|
||||
phase: "task_graph",
|
||||
title: "Ship the workflow",
|
||||
topicId: "topic-1",
|
||||
markdownBody: "## Goal\n\nShip the durable workflow safely.",
|
||||
execution: {
|
||||
mode: "sequential",
|
||||
reasoningIntensity: 70,
|
||||
orchestrationIntensity: 60
|
||||
},
|
||||
budget: {
|
||||
mode: "fixed",
|
||||
tokenLimit: 120_000,
|
||||
quotaWaterlinePercent: 15
|
||||
},
|
||||
tasks: [
|
||||
{
|
||||
id: "design",
|
||||
title: "Design",
|
||||
content: "Define contracts",
|
||||
priority: "high",
|
||||
agentTargetId: "local:codex",
|
||||
modelPlanId: "model-plan-1",
|
||||
model: "gpt-5.4",
|
||||
permissionModeId: "acceptEdits",
|
||||
reasoningEffort: "high",
|
||||
executionDirectory: "packages/agent/gui",
|
||||
dependsOn: [],
|
||||
parallelizable: true,
|
||||
autoAccept: true
|
||||
},
|
||||
{
|
||||
id: "build",
|
||||
title: "Build",
|
||||
content: "Implement the contracts",
|
||||
priority: "medium",
|
||||
agentTargetId: null,
|
||||
modelPlanId: null,
|
||||
model: null,
|
||||
permissionModeId: null,
|
||||
reasoningEffort: null,
|
||||
executionDirectory: null,
|
||||
dependsOn: ["design"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
checkpoints: [
|
||||
{
|
||||
id: "checkpoint-1",
|
||||
workflowId: "workflow-1",
|
||||
kind: "task_review",
|
||||
revisionId: "revision-1",
|
||||
status: "pending",
|
||||
decidedBy: null,
|
||||
decisionReason: null,
|
||||
createdAtUnixMs: 120,
|
||||
updatedAtUnixMs: 120,
|
||||
decidedAtUnixMs: null
|
||||
}
|
||||
],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe("projectTuttiModePlanPanel", () => {
|
||||
it("projects the single pending plan review from durable workflow state", () => {
|
||||
expect(projectTuttiModePlanPanel(planSnapshot())).toEqual({
|
||||
id: "workflow-1:checkpoint-1",
|
||||
workflowId: "workflow-1",
|
||||
workspaceId: "workspace-1",
|
||||
sourceSessionId: "session-1",
|
||||
sourceTurnId: "turn-1",
|
||||
sourceToolCallId: "tool-call-1",
|
||||
reviewKind: "task_review",
|
||||
state: "pending",
|
||||
actionable: true,
|
||||
title: "Ship the workflow",
|
||||
topicId: "topic-1",
|
||||
markdownBody: "## Goal\n\nShip the durable workflow safely.",
|
||||
revision: {
|
||||
id: "revision-1",
|
||||
sequence: 1,
|
||||
schemaVersion: "tutti-mode-plan/v1",
|
||||
documentPath: `tutti-mode-plans/workflow-1/revisions/${"a".repeat(64)}.md`,
|
||||
sha256:
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
producedByTurnId: "turn-1",
|
||||
createdAtUnixMs: 110
|
||||
},
|
||||
checkpoint: {
|
||||
id: "checkpoint-1",
|
||||
status: "pending",
|
||||
decidedBy: null,
|
||||
decisionReason: null,
|
||||
decidedAtUnixMs: null,
|
||||
createdAtUnixMs: 120,
|
||||
updatedAtUnixMs: 120
|
||||
},
|
||||
execution: {
|
||||
mode: "sequential",
|
||||
reasoningIntensity: 70,
|
||||
orchestrationIntensity: 60
|
||||
},
|
||||
budget: {
|
||||
mode: "fixed",
|
||||
tokenLimit: 120_000,
|
||||
quotaWaterlinePercent: 15
|
||||
},
|
||||
tasks: [
|
||||
{
|
||||
ordinal: 1,
|
||||
id: "design",
|
||||
title: "Design",
|
||||
content: "Define contracts",
|
||||
priority: "high",
|
||||
agentTargetId: "local:codex",
|
||||
modelPlanId: "model-plan-1",
|
||||
model: "gpt-5.4",
|
||||
permissionModeId: "acceptEdits",
|
||||
reasoningEffort: "high",
|
||||
executionDirectory: "packages/agent/gui",
|
||||
dependsOn: [],
|
||||
parallelizable: true,
|
||||
autoAccept: true
|
||||
},
|
||||
{
|
||||
ordinal: 2,
|
||||
id: "build",
|
||||
title: "Build",
|
||||
content: "Implement the contracts",
|
||||
priority: "medium",
|
||||
agentTargetId: null,
|
||||
modelPlanId: null,
|
||||
model: null,
|
||||
permissionModeId: null,
|
||||
reasoningEffort: null,
|
||||
executionDirectory: null,
|
||||
dependsOn: ["design"],
|
||||
parallelizable: false,
|
||||
autoAccept: false
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it("never projects a legacy configuration review", () => {
|
||||
const legacy = planSnapshot();
|
||||
legacy.revisions[0]!.document.phase = "configuration";
|
||||
legacy.revisions[0]!.document.tasks = [];
|
||||
legacy.checkpoints[0] = {
|
||||
...legacy.checkpoints[0]!,
|
||||
kind: "configuration_review"
|
||||
};
|
||||
|
||||
expect(projectTuttiModePlanPanel(legacy)).toBeNull();
|
||||
});
|
||||
|
||||
it("projects only the current revision", () => {
|
||||
const snapshot = planSnapshot();
|
||||
snapshot.workflow.currentRevisionId = "revision-2";
|
||||
snapshot.revisions = [
|
||||
...snapshot.revisions,
|
||||
{
|
||||
...snapshot.revisions[0]!,
|
||||
id: "revision-2",
|
||||
sequence: 2,
|
||||
producedByTurnId: "turn-2",
|
||||
createdAtUnixMs: 210,
|
||||
document: {
|
||||
...snapshot.revisions[0]!.document,
|
||||
markdownBody: "## Tasks\n\nImplement and verify the workflow."
|
||||
}
|
||||
}
|
||||
];
|
||||
snapshot.checkpoints = [
|
||||
{
|
||||
...snapshot.checkpoints[0]!,
|
||||
status: "superseded",
|
||||
updatedAtUnixMs: 205
|
||||
},
|
||||
{
|
||||
id: "checkpoint-2",
|
||||
workflowId: "workflow-1",
|
||||
kind: "task_review",
|
||||
revisionId: "revision-2",
|
||||
status: "accepted",
|
||||
decidedBy: "user-1",
|
||||
decisionReason: "Ready to execute",
|
||||
createdAtUnixMs: 220,
|
||||
updatedAtUnixMs: 230,
|
||||
decidedAtUnixMs: 230
|
||||
}
|
||||
];
|
||||
|
||||
const panel = projectTuttiModePlanPanel(snapshot);
|
||||
|
||||
expect(panel).toMatchObject({
|
||||
id: "workflow-1:checkpoint-2",
|
||||
reviewKind: "task_review",
|
||||
state: "accepted",
|
||||
actionable: false,
|
||||
revision: { id: "revision-2", sequence: 2 },
|
||||
checkpoint: {
|
||||
id: "checkpoint-2",
|
||||
decisionReason: "Ready to execute"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["pending", "pending", true],
|
||||
["accepted", "accepted", false],
|
||||
["rejected", "rejected", false],
|
||||
["canceled", "canceled", false],
|
||||
["superseded", "expired", false]
|
||||
] as const)(
|
||||
"maps checkpoint status %s to panel state %s",
|
||||
(checkpointStatus, panelState, actionable) => {
|
||||
const snapshot = planSnapshot();
|
||||
snapshot.checkpoints[0] = {
|
||||
...snapshot.checkpoints[0]!,
|
||||
status: checkpointStatus
|
||||
};
|
||||
|
||||
expect(projectTuttiModePlanPanel(snapshot)).toMatchObject({
|
||||
state: panelState,
|
||||
actionable
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it("uses the newest checkpoint for the current immutable revision", () => {
|
||||
const snapshot = planSnapshot();
|
||||
snapshot.checkpoints.push({
|
||||
...snapshot.checkpoints[0]!,
|
||||
id: "checkpoint-2",
|
||||
status: "rejected",
|
||||
decisionReason: "Revise the budget",
|
||||
updatedAtUnixMs: 150
|
||||
});
|
||||
|
||||
expect(projectTuttiModePlanPanel(snapshot)).toMatchObject({
|
||||
id: "workflow-1:checkpoint-2",
|
||||
state: "rejected",
|
||||
checkpoint: {
|
||||
id: "checkpoint-2",
|
||||
decisionReason: "Revise the budget"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when the current revision or its matching review is missing", () => {
|
||||
const missingRevision = planSnapshot();
|
||||
missingRevision.workflow.currentRevisionId = "revision-missing";
|
||||
expect(projectTuttiModePlanPanel(missingRevision)).toBeNull();
|
||||
|
||||
const missingCheckpoint = planSnapshot();
|
||||
missingCheckpoint.checkpoints = [];
|
||||
expect(projectTuttiModePlanPanel(missingCheckpoint)).toBeNull();
|
||||
});
|
||||
|
||||
it("fails closed when durable checkpoint kind and parsed Markdown phase disagree", () => {
|
||||
const snapshot = planSnapshot();
|
||||
snapshot.revisions[0]!.document.phase = "configuration";
|
||||
snapshot.revisions[0]!.document.tasks = [];
|
||||
|
||||
expect(projectTuttiModePlanPanel(snapshot)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not derive a panel from transcript markers or agent interactions", () => {
|
||||
const nonTuttiSnapshot = {
|
||||
...planSnapshot(),
|
||||
workflow: {
|
||||
...planSnapshot().workflow,
|
||||
type: "provider_plan"
|
||||
},
|
||||
transcript: "<!-- legacy-provider-plan -->",
|
||||
pendingInteractions: [{ kind: "exit_plan" }]
|
||||
} as unknown as TuttiModePlanReviewSnapshot;
|
||||
|
||||
expect(projectTuttiModePlanPanel(nonTuttiSnapshot)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not expose mutable arrays from the source snapshot", () => {
|
||||
const snapshot = planSnapshot();
|
||||
const panel = projectTuttiModePlanPanel(snapshot);
|
||||
expect(panel).not.toBeNull();
|
||||
|
||||
panel?.tasks.push({
|
||||
ordinal: 3,
|
||||
id: "local-only",
|
||||
title: "Local only",
|
||||
content: "",
|
||||
priority: "low",
|
||||
agentTargetId: null,
|
||||
modelPlanId: null,
|
||||
model: null,
|
||||
permissionModeId: null,
|
||||
reasoningEffort: null,
|
||||
executionDirectory: null,
|
||||
dependsOn: [],
|
||||
parallelizable: false,
|
||||
autoAccept: false
|
||||
});
|
||||
|
||||
expect(snapshot.revisions[0]?.document.tasks).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
export type TuttiModePlanWorkflowStatus =
|
||||
| "pending_review"
|
||||
| "in_progress"
|
||||
| "accepted"
|
||||
| "rejected"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "canceled";
|
||||
|
||||
export type TuttiModePlanCheckpointKind =
|
||||
| "configuration_review"
|
||||
| "task_review";
|
||||
|
||||
export type TuttiModePlanCheckpointStatus =
|
||||
| "pending"
|
||||
| "accepted"
|
||||
| "rejected"
|
||||
| "superseded"
|
||||
| "canceled";
|
||||
|
||||
// UI-facing review snapshot. It deliberately excludes workflow operations,
|
||||
// turn links, actionable items, and unrelated timestamps from the daemon DTO.
|
||||
// Desktop must explicitly map the transport contract into this narrow seam.
|
||||
export interface TuttiModePlanReviewSnapshot {
|
||||
workflow: TuttiModePlanWorkflow;
|
||||
revisions: TuttiModePlanRevision[];
|
||||
checkpoints: TuttiModePlanCheckpoint[];
|
||||
}
|
||||
|
||||
export interface TuttiModePlanWorkflow {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
type: string;
|
||||
owner: string;
|
||||
triggerKind: string;
|
||||
sourceSessionId: string;
|
||||
sourceTurnId?: string | null;
|
||||
sourceToolCallId?: string | null;
|
||||
status: TuttiModePlanWorkflowStatus;
|
||||
currentRevisionId: string;
|
||||
}
|
||||
|
||||
export interface TuttiModePlanRevision {
|
||||
id: string;
|
||||
workflowId: string;
|
||||
sequence: number;
|
||||
schemaVersion: string;
|
||||
documentPath: string;
|
||||
sha256: string;
|
||||
producedByTurnId?: string | null;
|
||||
createdAtUnixMs: number;
|
||||
document: TuttiModePlanDocument;
|
||||
}
|
||||
|
||||
export interface TuttiModePlanDocument {
|
||||
schema: string;
|
||||
phase: "configuration" | "task_graph";
|
||||
title: string;
|
||||
topicId: string;
|
||||
markdownBody: string;
|
||||
execution: TuttiModePlanExecution;
|
||||
budget: TuttiModePlanBudget;
|
||||
tasks: TuttiModePlanTask[];
|
||||
}
|
||||
|
||||
export interface TuttiModePlanExecution {
|
||||
mode: "sequential" | "parallel";
|
||||
reasoningIntensity: number;
|
||||
orchestrationIntensity: number;
|
||||
effect?: number | null;
|
||||
speed?: number | null;
|
||||
}
|
||||
|
||||
export interface TuttiModePlanBudget {
|
||||
mode: "auto" | "fixed";
|
||||
tokenLimit: number;
|
||||
quotaWaterlinePercent: number;
|
||||
}
|
||||
|
||||
export interface TuttiModePlanTask {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
priority: "high" | "medium" | "low";
|
||||
agentTargetId?: string | null;
|
||||
modelPlanId?: string | null;
|
||||
model?: string | null;
|
||||
permissionModeId?: string | null;
|
||||
reasoningEffort?: string | null;
|
||||
executionDirectory?: string | null;
|
||||
dependsOn: string[];
|
||||
parallelizable?: boolean;
|
||||
autoAccept?: boolean;
|
||||
}
|
||||
|
||||
export interface TuttiModePlanCheckpoint {
|
||||
id: string;
|
||||
workflowId: string;
|
||||
kind: TuttiModePlanCheckpointKind;
|
||||
revisionId: string;
|
||||
status: TuttiModePlanCheckpointStatus;
|
||||
decidedBy?: string | null;
|
||||
decisionReason?: string | null;
|
||||
createdAtUnixMs: number;
|
||||
updatedAtUnixMs: number;
|
||||
decidedAtUnixMs?: number | null;
|
||||
}
|
||||
|
||||
export type TuttiModePlanPanelState =
|
||||
| "pending"
|
||||
| "accepted"
|
||||
| "rejected"
|
||||
| "canceled"
|
||||
| "expired";
|
||||
|
||||
export interface TuttiModePlanPanelRevisionViewModel {
|
||||
id: string;
|
||||
sequence: number;
|
||||
schemaVersion: string;
|
||||
documentPath: string;
|
||||
sha256: string;
|
||||
producedByTurnId: string | null;
|
||||
createdAtUnixMs: number;
|
||||
}
|
||||
|
||||
export interface TuttiModePlanPanelCheckpointViewModel {
|
||||
id: string;
|
||||
status: TuttiModePlanCheckpointStatus;
|
||||
decidedBy: string | null;
|
||||
decisionReason: string | null;
|
||||
decidedAtUnixMs: number | null;
|
||||
createdAtUnixMs: number;
|
||||
updatedAtUnixMs: number;
|
||||
}
|
||||
|
||||
export interface TuttiModePlanPanelTaskViewModel {
|
||||
ordinal: number;
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
priority: "high" | "medium" | "low";
|
||||
agentTargetId: string | null;
|
||||
modelPlanId: string | null;
|
||||
model: string | null;
|
||||
permissionModeId: string | null;
|
||||
reasoningEffort: string | null;
|
||||
executionDirectory: string | null;
|
||||
dependsOn: string[];
|
||||
parallelizable: boolean;
|
||||
autoAccept: boolean;
|
||||
}
|
||||
|
||||
export interface TuttiModePlanPanelViewModel {
|
||||
id: string;
|
||||
workflowId: string;
|
||||
workspaceId: string;
|
||||
sourceSessionId: string;
|
||||
sourceTurnId: string | null;
|
||||
sourceToolCallId: string | null;
|
||||
reviewKind: TuttiModePlanCheckpointKind;
|
||||
state: TuttiModePlanPanelState;
|
||||
actionable: boolean;
|
||||
title: string;
|
||||
topicId: string;
|
||||
markdownBody: string;
|
||||
revision: TuttiModePlanPanelRevisionViewModel;
|
||||
checkpoint: TuttiModePlanPanelCheckpointViewModel;
|
||||
execution: TuttiModePlanExecution;
|
||||
budget: TuttiModePlanBudget;
|
||||
tasks: TuttiModePlanPanelTaskViewModel[];
|
||||
}
|
||||
|
||||
function nullableString(value: string | null | undefined): string | null {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function checkpointSortValue(
|
||||
left: TuttiModePlanCheckpoint,
|
||||
right: TuttiModePlanCheckpoint
|
||||
): number {
|
||||
return (
|
||||
left.updatedAtUnixMs - right.updatedAtUnixMs ||
|
||||
left.createdAtUnixMs - right.createdAtUnixMs ||
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
}
|
||||
|
||||
function currentCheckpoint(
|
||||
snapshot: TuttiModePlanReviewSnapshot,
|
||||
revision: TuttiModePlanRevision
|
||||
): TuttiModePlanCheckpoint | null {
|
||||
const checkpoints = snapshot.checkpoints
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate.workflowId === snapshot.workflow.id &&
|
||||
candidate.revisionId === revision.id
|
||||
)
|
||||
.sort(checkpointSortValue);
|
||||
return checkpoints.at(-1) ?? null;
|
||||
}
|
||||
|
||||
// The single-review flow renders only the task review over a complete plan
|
||||
// document. Legacy two-phase configuration checkpoints are retired by the
|
||||
// daemon at startup and never projected.
|
||||
function reviewKindMatchesDocument(
|
||||
kind: TuttiModePlanCheckpointKind,
|
||||
phase: TuttiModePlanDocument["phase"]
|
||||
): boolean {
|
||||
return kind === "task_review" && phase === "task_graph";
|
||||
}
|
||||
|
||||
function panelState(
|
||||
workflowStatus: TuttiModePlanWorkflowStatus,
|
||||
checkpointStatus: TuttiModePlanCheckpointStatus
|
||||
): TuttiModePlanPanelState {
|
||||
switch (checkpointStatus) {
|
||||
case "accepted":
|
||||
case "rejected":
|
||||
case "canceled":
|
||||
return checkpointStatus;
|
||||
case "superseded":
|
||||
return "expired";
|
||||
case "pending":
|
||||
if (workflowStatus === "canceled") return "canceled";
|
||||
if (workflowStatus === "rejected") return "rejected";
|
||||
if (workflowStatus === "failed") return "expired";
|
||||
if (workflowStatus === "accepted" || workflowStatus === "completed") {
|
||||
return "accepted";
|
||||
}
|
||||
return "pending";
|
||||
}
|
||||
}
|
||||
|
||||
function projectTask(
|
||||
task: TuttiModePlanTask,
|
||||
index: number
|
||||
): TuttiModePlanPanelTaskViewModel {
|
||||
return {
|
||||
ordinal: index + 1,
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
content: task.content,
|
||||
priority: task.priority,
|
||||
agentTargetId: nullableString(task.agentTargetId),
|
||||
modelPlanId: nullableString(task.modelPlanId),
|
||||
model: nullableString(task.model),
|
||||
permissionModeId: nullableString(task.permissionModeId),
|
||||
reasoningEffort: nullableString(task.reasoningEffort),
|
||||
executionDirectory: nullableString(task.executionDirectory),
|
||||
dependsOn: [...task.dependsOn],
|
||||
parallelizable: task.parallelizable === true,
|
||||
autoAccept: task.autoAccept === true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects an authoritative, normalized workflow snapshot into the Tutti Mode
|
||||
* Plan review panel. The input is supplied by the workflow host boundary; this
|
||||
* function never reads transcript messages, provider interactions, or revision
|
||||
* files.
|
||||
*/
|
||||
export function projectTuttiModePlanPanel(
|
||||
snapshot: TuttiModePlanReviewSnapshot
|
||||
): TuttiModePlanPanelViewModel | null {
|
||||
const workflow = snapshot.workflow;
|
||||
if (
|
||||
workflow.type !== "tutti_mode_plan" ||
|
||||
workflow.owner !== "tutti" ||
|
||||
workflow.triggerKind !== "agent_cli"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const revision = snapshot.revisions.find(
|
||||
(candidate) =>
|
||||
candidate.id === workflow.currentRevisionId &&
|
||||
candidate.workflowId === workflow.id
|
||||
);
|
||||
if (!revision || revision.document.schema !== revision.schemaVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const checkpoint = currentCheckpoint(snapshot, revision);
|
||||
if (
|
||||
!checkpoint ||
|
||||
!reviewKindMatchesDocument(checkpoint.kind, revision.document.phase)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = panelState(workflow.status, checkpoint.status);
|
||||
return {
|
||||
id: `${workflow.id}:${checkpoint.id}`,
|
||||
workflowId: workflow.id,
|
||||
workspaceId: workflow.workspaceId,
|
||||
sourceSessionId: workflow.sourceSessionId,
|
||||
sourceTurnId: nullableString(workflow.sourceTurnId),
|
||||
sourceToolCallId: nullableString(workflow.sourceToolCallId),
|
||||
reviewKind: checkpoint.kind,
|
||||
state,
|
||||
actionable: state === "pending",
|
||||
title: revision.document.title,
|
||||
topicId: revision.document.topicId,
|
||||
markdownBody: revision.document.markdownBody,
|
||||
revision: {
|
||||
id: revision.id,
|
||||
sequence: revision.sequence,
|
||||
schemaVersion: revision.schemaVersion,
|
||||
documentPath: revision.documentPath,
|
||||
sha256: revision.sha256,
|
||||
producedByTurnId: nullableString(revision.producedByTurnId),
|
||||
createdAtUnixMs: revision.createdAtUnixMs
|
||||
},
|
||||
checkpoint: {
|
||||
id: checkpoint.id,
|
||||
status: checkpoint.status,
|
||||
decidedBy: nullableString(checkpoint.decidedBy),
|
||||
decisionReason: nullableString(checkpoint.decisionReason),
|
||||
decidedAtUnixMs: checkpoint.decidedAtUnixMs ?? null,
|
||||
createdAtUnixMs: checkpoint.createdAtUnixMs,
|
||||
updatedAtUnixMs: checkpoint.updatedAtUnixMs
|
||||
},
|
||||
execution: { ...revision.document.execution },
|
||||
budget: { ...revision.document.budget },
|
||||
tasks: revision.document.tasks.map(projectTask)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TuttiPlanIssueSnapshot } from "../workspaceWorkflowRuntime";
|
||||
import { tuttiModePlanTaskActionPrompt } from "./tuttiModePlanPromptActions";
|
||||
|
||||
const issue: TuttiPlanIssueSnapshot = {
|
||||
workflowId: "workflow-1",
|
||||
sourceTurnId: "turn-1",
|
||||
issueId: "issue-1",
|
||||
topicId: "topic/one",
|
||||
title: "Plan",
|
||||
dispatchPaused: false,
|
||||
tasks: [
|
||||
{
|
||||
taskId: "task-1",
|
||||
title: "Review [result]",
|
||||
content: "",
|
||||
status: "failed",
|
||||
sortIndex: 1,
|
||||
parallelizable: false,
|
||||
autoAccept: false,
|
||||
dependencyTaskIds: []
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const labels = {
|
||||
accept: (reference: string) => `Accept ${reference}`,
|
||||
rework: (reference: string) => `Rework ${reference}`
|
||||
};
|
||||
|
||||
describe("tuttiModePlanTaskActionPrompt", () => {
|
||||
it.each([
|
||||
["accept", "Accept"],
|
||||
["rework", "Rework"]
|
||||
] as const)(
|
||||
"builds an exact source-Agent %s draft with a task mention",
|
||||
(action, prefix) => {
|
||||
expect(
|
||||
tuttiModePlanTaskActionPrompt({
|
||||
action,
|
||||
issue,
|
||||
labels,
|
||||
taskId: "task-1",
|
||||
workspaceId: "workspace-1"
|
||||
})
|
||||
).toBe(
|
||||
`${prefix} [Review \\[result\\]](mention://workspace-issue/issue-1?workspaceId=workspace-1&topicId=topic%2Fone&taskId=task-1)`
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it("fails closed when the task is no longer in the current projection", () => {
|
||||
expect(
|
||||
tuttiModePlanTaskActionPrompt({
|
||||
action: "rework",
|
||||
issue,
|
||||
labels,
|
||||
taskId: "missing",
|
||||
workspaceId: "workspace-1"
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { buildWorkspaceIssueMentionHref } from "@tutti-os/workspace-issue-manager/core";
|
||||
import type {
|
||||
TuttiPlanIssueSnapshot,
|
||||
TuttiPlanIssueTaskSnapshot
|
||||
} from "../workspaceWorkflowRuntime";
|
||||
|
||||
export type TuttiModePlanTaskPromptAction = "accept" | "rework";
|
||||
|
||||
export interface TuttiModePlanTaskPromptLabels {
|
||||
accept(reference: string): string;
|
||||
rework(reference: string): string;
|
||||
}
|
||||
|
||||
function escapeMarkdownLabel(label: string): string {
|
||||
return label
|
||||
.replaceAll("\\", "\\\\")
|
||||
.replaceAll("[", "\\[")
|
||||
.replaceAll("]", "\\]");
|
||||
}
|
||||
|
||||
function taskReference(input: {
|
||||
issue: TuttiPlanIssueSnapshot;
|
||||
task: TuttiPlanIssueTaskSnapshot;
|
||||
workspaceId: string;
|
||||
}): string {
|
||||
const href = buildWorkspaceIssueMentionHref({
|
||||
issueId: input.issue.issueId,
|
||||
taskId: input.task.taskId,
|
||||
topicId: input.issue.topicId,
|
||||
workspaceId: input.workspaceId
|
||||
});
|
||||
const label = input.task.title.trim() || input.task.taskId;
|
||||
return href
|
||||
? `[${escapeMarkdownLabel(label)}](${href})`
|
||||
: escapeMarkdownLabel(label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a plan-panel user action into a source-Agent draft prompt.
|
||||
*
|
||||
* This is intentionally presentation-only: it does not claim Tutti execution
|
||||
* authority or mutate a managed Issue. Sending the resulting draft lets the
|
||||
* source Agent inspect the canonical execution and use its fenced commands.
|
||||
*/
|
||||
export function tuttiModePlanTaskActionPrompt(input: {
|
||||
action: TuttiModePlanTaskPromptAction;
|
||||
issue: TuttiPlanIssueSnapshot;
|
||||
labels: TuttiModePlanTaskPromptLabels;
|
||||
taskId: string;
|
||||
workspaceId: string;
|
||||
}): string | null {
|
||||
const task = input.issue.tasks.find(
|
||||
(candidate) => candidate.taskId === input.taskId
|
||||
);
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
const reference = taskReference({
|
||||
issue: input.issue,
|
||||
task,
|
||||
workspaceId: input.workspaceId
|
||||
});
|
||||
return input.labels[input.action](reference).trim() || null;
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
effectiveTaskAssignmentValue,
|
||||
effectiveTaskFlag,
|
||||
mergeTaskAssignmentDraft,
|
||||
taskAssignmentInputsFromDrafts
|
||||
} from "./tuttiModePlanTaskAssignments";
|
||||
import type { TuttiModePlanPanelTaskViewModel } from "./tuttiModePlanPanelProjection";
|
||||
|
||||
function taskViewModel(
|
||||
overrides: Partial<TuttiModePlanPanelTaskViewModel> & { id: string }
|
||||
): TuttiModePlanPanelTaskViewModel {
|
||||
return {
|
||||
ordinal: 1,
|
||||
title: "Task",
|
||||
content: "",
|
||||
priority: "medium",
|
||||
agentTargetId: null,
|
||||
modelPlanId: null,
|
||||
model: null,
|
||||
permissionModeId: null,
|
||||
reasoningEffort: null,
|
||||
executionDirectory: null,
|
||||
dependsOn: [],
|
||||
parallelizable: false,
|
||||
autoAccept: false,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe("mergeTaskAssignmentDraft", () => {
|
||||
it("stores field edits per task", () => {
|
||||
const drafts = mergeTaskAssignmentDraft({}, "task-1", {
|
||||
reasoningEffort: "high"
|
||||
});
|
||||
expect(drafts["task-1"]).toEqual({ reasoningEffort: "high" });
|
||||
});
|
||||
|
||||
it("resets dependent selections when the agent changes", () => {
|
||||
let drafts = mergeTaskAssignmentDraft({}, "task-1", {
|
||||
modelPlanId: "plan-1",
|
||||
model: "model-1"
|
||||
});
|
||||
drafts = mergeTaskAssignmentDraft(drafts, "task-1", {
|
||||
agentTargetId: "agent-2"
|
||||
});
|
||||
expect(drafts["task-1"]).toEqual({
|
||||
agentTargetId: "agent-2",
|
||||
modelPlanId: "",
|
||||
model: "",
|
||||
permissionModeId: "",
|
||||
reasoningEffort: ""
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the agent-independent toggles across agent changes", () => {
|
||||
let drafts = mergeTaskAssignmentDraft({}, "task-1", {
|
||||
parallelizable: true
|
||||
});
|
||||
drafts = mergeTaskAssignmentDraft(drafts, "task-1", { autoAccept: true });
|
||||
drafts = mergeTaskAssignmentDraft(drafts, "task-1", {
|
||||
agentTargetId: "agent-2"
|
||||
});
|
||||
expect(drafts["task-1"]).toEqual({
|
||||
agentTargetId: "agent-2",
|
||||
modelPlanId: "",
|
||||
model: "",
|
||||
permissionModeId: "",
|
||||
reasoningEffort: "",
|
||||
parallelizable: true,
|
||||
autoAccept: true
|
||||
});
|
||||
expect(effectiveTaskFlag(undefined, true)).toBe(true);
|
||||
expect(effectiveTaskFlag(false, true)).toBe(false);
|
||||
const inputs = taskAssignmentInputsFromDrafts(drafts, [
|
||||
taskViewModel({ id: "task-1" })
|
||||
]);
|
||||
expect(inputs).toEqual([
|
||||
{
|
||||
taskId: "task-1",
|
||||
agentTargetId: "agent-2",
|
||||
modelPlanId: "",
|
||||
model: "",
|
||||
permissionModeId: "",
|
||||
reasoningEffort: "",
|
||||
parallelizable: true,
|
||||
autoAccept: true
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps other fields when the same agent is re-selected", () => {
|
||||
let drafts = mergeTaskAssignmentDraft({}, "task-1", {
|
||||
agentTargetId: "agent-1"
|
||||
});
|
||||
drafts = mergeTaskAssignmentDraft(drafts, "task-1", { model: "model-9" });
|
||||
drafts = mergeTaskAssignmentDraft(drafts, "task-1", {
|
||||
agentTargetId: "agent-1"
|
||||
});
|
||||
expect(drafts["task-1"]?.model).toBe("model-9");
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveTaskAssignmentValue", () => {
|
||||
it("prefers the draft edit, including an explicit clear", () => {
|
||||
expect(effectiveTaskAssignmentValue(undefined, "doc-value")).toBe(
|
||||
"doc-value"
|
||||
);
|
||||
expect(effectiveTaskAssignmentValue("edit", "doc-value")).toBe("edit");
|
||||
expect(effectiveTaskAssignmentValue("", "doc-value")).toBe("");
|
||||
expect(effectiveTaskAssignmentValue(undefined, null)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("taskAssignmentInputsFromDrafts", () => {
|
||||
it("sends only touched tasks and touched fields", () => {
|
||||
const tasks = [
|
||||
taskViewModel({ id: "task-1" }),
|
||||
taskViewModel({ id: "task-2", ordinal: 2 })
|
||||
];
|
||||
const drafts = mergeTaskAssignmentDraft({}, "task-2", {
|
||||
reasoningEffort: "high"
|
||||
});
|
||||
const inputs = taskAssignmentInputsFromDrafts(drafts, tasks);
|
||||
expect(inputs).toEqual([{ taskId: "task-2", reasoningEffort: "high" }]);
|
||||
expect("model" in inputs[0]!).toBe(false);
|
||||
});
|
||||
|
||||
it("carries the dependent clears produced by an agent change", () => {
|
||||
const tasks = [taskViewModel({ id: "task-1" })];
|
||||
const drafts = mergeTaskAssignmentDraft({}, "task-1", {
|
||||
agentTargetId: "agent-1"
|
||||
});
|
||||
expect(taskAssignmentInputsFromDrafts(drafts, tasks)).toEqual([
|
||||
{
|
||||
taskId: "task-1",
|
||||
agentTargetId: "agent-1",
|
||||
modelPlanId: "",
|
||||
model: "",
|
||||
permissionModeId: "",
|
||||
reasoningEffort: ""
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops drafts for tasks missing from the current revision", () => {
|
||||
const drafts = mergeTaskAssignmentDraft({}, "task-gone", {
|
||||
model: "model-1"
|
||||
});
|
||||
expect(
|
||||
taskAssignmentInputsFromDrafts(drafts, [taskViewModel({ id: "task-1" })])
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns an empty payload for untouched drafts", () => {
|
||||
expect(
|
||||
taskAssignmentInputsFromDrafts({}, [taskViewModel({ id: "task-1" })])
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { TuttiModePlanTaskAssignmentInput } from "../workspaceWorkflowRuntime";
|
||||
import type { TuttiModePlanPanelTaskViewModel } from "./tuttiModePlanPanelProjection";
|
||||
|
||||
/**
|
||||
* One task's in-panel assignment edits. `undefined` means "untouched — keep
|
||||
* the plan document value"; an empty string is an explicit clear that the
|
||||
* daemon persists as such.
|
||||
*/
|
||||
export interface TuttiModePlanTaskAssignmentDraft {
|
||||
agentTargetId?: string;
|
||||
modelPlanId?: string;
|
||||
model?: string;
|
||||
permissionModeId?: string;
|
||||
reasoningEffort?: string;
|
||||
/** Per-task parallel opt-in; undefined keeps the plan document value. */
|
||||
parallelizable?: boolean;
|
||||
/** Per-task acceptance bypass; undefined keeps the plan document value. */
|
||||
autoAccept?: boolean;
|
||||
}
|
||||
|
||||
export type TuttiModePlanTaskAssignmentDrafts = Readonly<
|
||||
Record<string, TuttiModePlanTaskAssignmentDraft>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Applies one field edit. Changing the Agent invalidates the dependent
|
||||
* selections (plan, model, permission mode, reasoning effort) because their
|
||||
* option catalogs are agent-scoped; they reset to an explicit clear so an
|
||||
* incompatible document value can never ride along silently. The parallel
|
||||
* opt-in is agent-independent and survives an agent change.
|
||||
*/
|
||||
export function mergeTaskAssignmentDraft(
|
||||
drafts: TuttiModePlanTaskAssignmentDrafts,
|
||||
taskId: string,
|
||||
patch: TuttiModePlanTaskAssignmentDraft
|
||||
): TuttiModePlanTaskAssignmentDrafts {
|
||||
const current = drafts[taskId] ?? {};
|
||||
const next: TuttiModePlanTaskAssignmentDraft =
|
||||
patch.agentTargetId !== undefined &&
|
||||
patch.agentTargetId !== current.agentTargetId
|
||||
? {
|
||||
agentTargetId: patch.agentTargetId,
|
||||
modelPlanId: "",
|
||||
model: "",
|
||||
permissionModeId: "",
|
||||
reasoningEffort: "",
|
||||
...(current.parallelizable !== undefined
|
||||
? { parallelizable: current.parallelizable }
|
||||
: {}),
|
||||
...(current.autoAccept !== undefined
|
||||
? { autoAccept: current.autoAccept }
|
||||
: {})
|
||||
}
|
||||
: { ...current, ...patch };
|
||||
return { ...drafts, [taskId]: next };
|
||||
}
|
||||
|
||||
/** The state a boolean toggle (parallel, auto-accept) displays: draft wins. */
|
||||
export function effectiveTaskFlag(
|
||||
draftValue: boolean | undefined,
|
||||
documentValue: boolean
|
||||
): boolean {
|
||||
return draftValue ?? documentValue;
|
||||
}
|
||||
|
||||
/** The value a selector should display: draft edit wins over document value. */
|
||||
export function effectiveTaskAssignmentValue(
|
||||
draftValue: string | undefined,
|
||||
documentValue: string | null
|
||||
): string {
|
||||
if (draftValue !== undefined) return draftValue;
|
||||
return documentValue ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the decision payload. Only touched tasks are included, and only
|
||||
* touched fields are sent; untouched fields stay null-equivalent (omitted) so
|
||||
* the daemon keeps the plan document value.
|
||||
*/
|
||||
export function taskAssignmentInputsFromDrafts(
|
||||
drafts: TuttiModePlanTaskAssignmentDrafts,
|
||||
tasks: readonly TuttiModePlanPanelTaskViewModel[]
|
||||
): TuttiModePlanTaskAssignmentInput[] {
|
||||
const knownTaskIds = new Set(tasks.map((task) => task.id));
|
||||
const inputs: TuttiModePlanTaskAssignmentInput[] = [];
|
||||
for (const task of tasks) {
|
||||
const draft = drafts[task.id];
|
||||
if (!draft) continue;
|
||||
const input: TuttiModePlanTaskAssignmentInput = { taskId: task.id };
|
||||
let touched = false;
|
||||
if (draft.agentTargetId !== undefined) {
|
||||
input.agentTargetId = draft.agentTargetId;
|
||||
touched = true;
|
||||
}
|
||||
if (draft.modelPlanId !== undefined) {
|
||||
input.modelPlanId = draft.modelPlanId;
|
||||
touched = true;
|
||||
}
|
||||
if (draft.model !== undefined) {
|
||||
input.model = draft.model;
|
||||
touched = true;
|
||||
}
|
||||
if (draft.permissionModeId !== undefined) {
|
||||
input.permissionModeId = draft.permissionModeId;
|
||||
touched = true;
|
||||
}
|
||||
if (draft.reasoningEffort !== undefined) {
|
||||
input.reasoningEffort = draft.reasoningEffort;
|
||||
touched = true;
|
||||
}
|
||||
if (draft.parallelizable !== undefined) {
|
||||
input.parallelizable = draft.parallelizable;
|
||||
touched = true;
|
||||
}
|
||||
if (draft.autoAccept !== undefined) {
|
||||
input.autoAccept = draft.autoAccept;
|
||||
touched = true;
|
||||
}
|
||||
if (touched) inputs.push(input);
|
||||
}
|
||||
// Drafts for tasks that vanished from the current revision are dropped.
|
||||
return inputs.filter((input) => knownTaskIds.has(input.taskId));
|
||||
}
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { StrictMode } from "react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
tuttiPlanIssueExecutionIsActive,
|
||||
useTuttiModePlanPanels
|
||||
} from "./useTuttiModePlanPanels";
|
||||
import {
|
||||
TuttiModePlanReviewRuntimeProvider,
|
||||
type TuttiModePlanReviewRuntime,
|
||||
type TuttiPlanIssueQueryResult,
|
||||
type TuttiPlanIssueSnapshot,
|
||||
type TuttiPlanIssueSource
|
||||
} from "../workspaceWorkflowRuntime";
|
||||
|
||||
function snapshotFor(sessionId: string): TuttiPlanIssueSnapshot {
|
||||
return {
|
||||
workflowId: `workflow-${sessionId}`,
|
||||
sourceTurnId: `turn-${sessionId}`,
|
||||
issueId: `tutti-mode-plan-${sessionId}`,
|
||||
topicId: "default",
|
||||
title: `Issue for ${sessionId}`,
|
||||
dispatchPaused: false,
|
||||
tasks: [
|
||||
{
|
||||
taskId: "task-1",
|
||||
title: "Only",
|
||||
content: "",
|
||||
status: "running",
|
||||
sortIndex: 1,
|
||||
parallelizable: false,
|
||||
autoAccept: false,
|
||||
dependencyTaskIds: []
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
describe("tuttiPlanIssueExecutionIsActive", () => {
|
||||
it.each(["not_started", "running", "pending_acceptance"])(
|
||||
"treats %s work as active while dispatch is open",
|
||||
(status) => {
|
||||
const issue = snapshotFor("session-1");
|
||||
expect(
|
||||
tuttiPlanIssueExecutionIsActive({
|
||||
...issue,
|
||||
tasks: [{ ...issue.tasks[0]!, status }]
|
||||
})
|
||||
).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it("ends aggregate work when dispatch is paused or every task is terminal", () => {
|
||||
const issue = snapshotFor("session-1");
|
||||
expect(
|
||||
tuttiPlanIssueExecutionIsActive({ ...issue, dispatchPaused: true })
|
||||
).toBe(false);
|
||||
expect(
|
||||
tuttiPlanIssueExecutionIsActive({
|
||||
...issue,
|
||||
tasks: ["completed", "failed", "canceled"].map((status, index) => ({
|
||||
...issue.tasks[0]!,
|
||||
taskId: `task-${index}`,
|
||||
status
|
||||
}))
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
interface DeferredLoad {
|
||||
sessionId: string;
|
||||
resolve: (result: TuttiPlanIssueQueryResult) => void;
|
||||
}
|
||||
|
||||
function createHarness(): {
|
||||
runtime: TuttiModePlanReviewRuntime;
|
||||
loads: DeferredLoad[];
|
||||
emitIssueUpdate: (issueId: string) => void;
|
||||
emitWorkflowUpdate: (sourceSessionId: string) => void;
|
||||
emitSessionSettled: (sourceSessionId: string) => void;
|
||||
} {
|
||||
const loads: DeferredLoad[] = [];
|
||||
const listeners = new Set<(update: { issueId: string }) => void>();
|
||||
const workflowListeners = new Set<
|
||||
Parameters<TuttiModePlanReviewRuntime["subscribe"]>[1]
|
||||
>();
|
||||
const source: TuttiPlanIssueSource = {
|
||||
getSessionPlanIssue({ sourceSessionId }) {
|
||||
return new Promise((resolve) => {
|
||||
loads.push({ sessionId: sourceSessionId, resolve });
|
||||
});
|
||||
},
|
||||
subscribeIssueUpdates(_workspaceId, listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
cancelExecution: vi.fn().mockResolvedValue(undefined),
|
||||
resolveTaskSession: vi.fn().mockResolvedValue(null)
|
||||
};
|
||||
const runtime: TuttiModePlanReviewRuntime = {
|
||||
listPending: vi.fn().mockResolvedValue([]),
|
||||
decide: vi.fn().mockResolvedValue(undefined),
|
||||
subscribe: (_workspaceId, listener) => {
|
||||
workflowListeners.add(listener);
|
||||
return () => workflowListeners.delete(listener);
|
||||
},
|
||||
planIssues: source
|
||||
};
|
||||
return {
|
||||
runtime,
|
||||
loads,
|
||||
emitIssueUpdate: (issueId) => {
|
||||
for (const listener of listeners) listener({ issueId });
|
||||
},
|
||||
emitWorkflowUpdate: (sourceSessionId) => {
|
||||
for (const listener of workflowListeners) {
|
||||
listener({
|
||||
kind: "workflow_updated",
|
||||
workspaceId: "workspace-1",
|
||||
workflowId: "workflow-1",
|
||||
sourceSessionId,
|
||||
checkpointId: "checkpoint-1",
|
||||
changeKind: "operation_updated"
|
||||
});
|
||||
}
|
||||
},
|
||||
emitSessionSettled: (sourceSessionId) => {
|
||||
for (const listener of workflowListeners) {
|
||||
listener({
|
||||
kind: "session_settled",
|
||||
workspaceId: "workspace-1",
|
||||
sourceSessionId
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function Probe({ sessionId }: { sessionId: string }): React.JSX.Element {
|
||||
const { planIssue } = useTuttiModePlanPanels({
|
||||
decidedBy: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
sourceSessionId: sessionId
|
||||
});
|
||||
return (
|
||||
<div data-testid="probe">{planIssue ? planIssue.issueId : "none"}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The plan-issue embed rides the review panels hook lifecycle (one scope, one
|
||||
// effect); these probes pin the load/refresh semantics the embedded panel
|
||||
// depends on.
|
||||
describe("useTuttiModePlanPanels plan issue embed", () => {
|
||||
it("surfaces the loaded issue under StrictMode double-mount", async () => {
|
||||
const harness = createHarness();
|
||||
render(
|
||||
<StrictMode>
|
||||
<TuttiModePlanReviewRuntimeProvider runtime={harness.runtime}>
|
||||
<Probe sessionId="session-a" />
|
||||
</TuttiModePlanReviewRuntimeProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
await waitFor(() => expect(harness.loads.length).toBeGreaterThan(0));
|
||||
for (const load of [...harness.loads]) {
|
||||
load.resolve({ kind: "issue", issue: snapshotFor(load.sessionId) });
|
||||
}
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("probe")).toHaveTextContent(
|
||||
"tutti-mode-plan-session-a"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("re-reads the plan issue on workflow updates for this session", async () => {
|
||||
// workspace.issue.updated fires during materialization, before the
|
||||
// create_issue operation records its outcome; the workflow's
|
||||
// operation_updated event is the authoritative post-completion signal.
|
||||
// Without this re-read, an accepted plan's issue panel stays invisible
|
||||
// until a remount.
|
||||
const harness = createHarness();
|
||||
render(
|
||||
<TuttiModePlanReviewRuntimeProvider runtime={harness.runtime}>
|
||||
<Probe sessionId="session-a" />
|
||||
</TuttiModePlanReviewRuntimeProvider>
|
||||
);
|
||||
await waitFor(() => expect(harness.loads.length).toBe(1));
|
||||
harness.loads[0]!.resolve(null);
|
||||
harness.emitWorkflowUpdate("session-a");
|
||||
await waitFor(() => expect(harness.loads.length).toBe(2));
|
||||
harness.loads[1]!.resolve({
|
||||
kind: "issue",
|
||||
issue: snapshotFor("session-a")
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("probe")).toHaveTextContent(
|
||||
"tutti-mode-plan-session-a"
|
||||
)
|
||||
);
|
||||
|
||||
// Another session's workflow event must not trigger a read for this one.
|
||||
harness.emitWorkflowUpdate("session-b");
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(harness.loads.length).toBe(2);
|
||||
});
|
||||
|
||||
it("re-reads the plan issue when this session's turn settles", async () => {
|
||||
// A plan proposed mid-turn announces itself through one workflow event;
|
||||
// if that single event is dropped, the host-relayed session_settled
|
||||
// invalidation is the read-repair point.
|
||||
const harness = createHarness();
|
||||
render(
|
||||
<TuttiModePlanReviewRuntimeProvider runtime={harness.runtime}>
|
||||
<Probe sessionId="session-a" />
|
||||
</TuttiModePlanReviewRuntimeProvider>
|
||||
);
|
||||
await waitFor(() => expect(harness.loads.length).toBe(1));
|
||||
harness.loads[0]!.resolve(null);
|
||||
harness.emitSessionSettled("session-a");
|
||||
await waitFor(() => expect(harness.loads.length).toBe(2));
|
||||
harness.loads[1]!.resolve({
|
||||
kind: "issue",
|
||||
issue: snapshotFor("session-a")
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("probe")).toHaveTextContent(
|
||||
"tutti-mode-plan-session-a"
|
||||
)
|
||||
);
|
||||
|
||||
// Another session's settle must not trigger a read for this one.
|
||||
harness.emitSessionSettled("session-b");
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(harness.loads.length).toBe(2);
|
||||
});
|
||||
|
||||
it("loads the new scope when the conversation flips mid-flight", async () => {
|
||||
const harness = createHarness();
|
||||
const view = render(
|
||||
<StrictMode>
|
||||
<TuttiModePlanReviewRuntimeProvider runtime={harness.runtime}>
|
||||
<Probe sessionId="session-a" />
|
||||
</TuttiModePlanReviewRuntimeProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
await waitFor(() => expect(harness.loads.length).toBeGreaterThan(0));
|
||||
const pendingA = [...harness.loads];
|
||||
|
||||
view.rerender(
|
||||
<StrictMode>
|
||||
<TuttiModePlanReviewRuntimeProvider runtime={harness.runtime}>
|
||||
<Probe sessionId="session-b" />
|
||||
</TuttiModePlanReviewRuntimeProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
// The stale loads resolve late and must be discarded.
|
||||
for (const load of pendingA) {
|
||||
load.resolve({ kind: "issue", issue: snapshotFor(load.sessionId) });
|
||||
}
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
harness.loads.filter((load) => load.sessionId === "session-b").length
|
||||
).toBeGreaterThan(0)
|
||||
);
|
||||
for (const load of harness.loads.filter(
|
||||
(load) => load.sessionId === "session-b"
|
||||
)) {
|
||||
load.resolve({ kind: "issue", issue: snapshotFor(load.sessionId) });
|
||||
}
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("probe")).toHaveTextContent(
|
||||
"tutti-mode-plan-session-b"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("retries via issue updates after an initially empty load", async () => {
|
||||
const harness = createHarness();
|
||||
render(
|
||||
<StrictMode>
|
||||
<TuttiModePlanReviewRuntimeProvider runtime={harness.runtime}>
|
||||
<Probe sessionId="session-a" />
|
||||
</TuttiModePlanReviewRuntimeProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
await waitFor(() => expect(harness.loads.length).toBeGreaterThan(0));
|
||||
for (const load of [...harness.loads]) {
|
||||
load.resolve(null);
|
||||
}
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("probe")).toHaveTextContent("none")
|
||||
);
|
||||
|
||||
const before = harness.loads.length;
|
||||
harness.emitIssueUpdate("tutti-mode-plan-session-a");
|
||||
await waitFor(() => expect(harness.loads.length).toBeGreaterThan(before));
|
||||
for (const load of harness.loads.slice(before)) {
|
||||
load.resolve({ kind: "issue", issue: snapshotFor(load.sessionId) });
|
||||
}
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("probe")).toHaveTextContent(
|
||||
"tutti-mode-plan-session-a"
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,434 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { StrictMode, type ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
TuttiModePlanReviewRuntimeProvider,
|
||||
type TuttiModePlanReviewRuntime
|
||||
} from "../workspaceWorkflowRuntime";
|
||||
import type { TuttiModePlanReviewSnapshot } from "./tuttiModePlanPanelProjection";
|
||||
import { useTuttiModePlanPanels } from "./useTuttiModePlanPanels";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, reject, resolve };
|
||||
}
|
||||
|
||||
function snapshot(sourceSessionId: string): TuttiModePlanReviewSnapshot {
|
||||
const suffix = sourceSessionId.replace("session-", "");
|
||||
const workflowId = `workflow-${suffix}`;
|
||||
const revisionId = `revision-${suffix}`;
|
||||
return {
|
||||
workflow: {
|
||||
id: workflowId,
|
||||
workspaceId: "workspace-1",
|
||||
type: "tutti_mode_plan",
|
||||
owner: "tutti",
|
||||
triggerKind: "agent_cli",
|
||||
sourceSessionId,
|
||||
status: "pending_review",
|
||||
currentRevisionId: revisionId
|
||||
},
|
||||
revisions: [
|
||||
{
|
||||
id: revisionId,
|
||||
workflowId,
|
||||
sequence: 1,
|
||||
schemaVersion: "tutti-mode-plan/v1",
|
||||
documentPath: `tutti-mode-plans/${workflowId}/revisions/${"a".repeat(64)}.md`,
|
||||
sha256: "a".repeat(64),
|
||||
createdAtUnixMs: 100,
|
||||
document: {
|
||||
schema: "tutti-mode-plan/v1",
|
||||
phase: "task_graph",
|
||||
title: `Plan ${suffix}`,
|
||||
topicId: "topic-1",
|
||||
markdownBody: "## Goal\n\nShip safely",
|
||||
execution: {
|
||||
mode: "sequential",
|
||||
reasoningIntensity: 50,
|
||||
orchestrationIntensity: 50
|
||||
},
|
||||
budget: {
|
||||
mode: "auto",
|
||||
tokenLimit: 80_000,
|
||||
quotaWaterlinePercent: 10
|
||||
},
|
||||
tasks: [
|
||||
{
|
||||
id: `task-${suffix}`,
|
||||
title: "Implement",
|
||||
content: "",
|
||||
priority: "medium",
|
||||
agentTargetId: "codex",
|
||||
dependsOn: []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
checkpoints: [
|
||||
{
|
||||
id: `checkpoint-${suffix}`,
|
||||
workflowId,
|
||||
kind: "task_review",
|
||||
revisionId,
|
||||
status: "pending",
|
||||
createdAtUnixMs: 110,
|
||||
updatedAtUnixMs: 110
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanels(runtime: TuttiModePlanReviewRuntime) {
|
||||
return renderHook(
|
||||
({ sourceSessionId }: { sourceSessionId: string | null }) =>
|
||||
useTuttiModePlanPanels({
|
||||
decidedBy: "user-1",
|
||||
sourceSessionId,
|
||||
workspaceId: "workspace-1"
|
||||
}),
|
||||
{
|
||||
initialProps: { sourceSessionId: "session-a" as string | null },
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<StrictMode>
|
||||
<TuttiModePlanReviewRuntimeProvider runtime={runtime}>
|
||||
{children}
|
||||
</TuttiModePlanReviewRuntimeProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
describe("useTuttiModePlanPanels", () => {
|
||||
it("keeps disabled preview scopes transport-free", async () => {
|
||||
const runtime: TuttiModePlanReviewRuntime = {
|
||||
listPending: vi.fn(() => Promise.resolve([snapshot("session-a")])),
|
||||
decide: vi.fn(),
|
||||
subscribe: vi.fn(() => () => undefined)
|
||||
};
|
||||
const rendered = renderHook(
|
||||
() =>
|
||||
useTuttiModePlanPanels({
|
||||
decidedBy: "user-1",
|
||||
enabled: false,
|
||||
sourceSessionId: "session-a",
|
||||
workspaceId: "workspace-1"
|
||||
}),
|
||||
{
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<StrictMode>
|
||||
<TuttiModePlanReviewRuntimeProvider runtime={runtime}>
|
||||
{children}
|
||||
</TuttiModePlanReviewRuntimeProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
await act(async () => undefined);
|
||||
|
||||
expect(runtime.listPending).not.toHaveBeenCalled();
|
||||
expect(runtime.subscribe).not.toHaveBeenCalled();
|
||||
expect(rendered.result.current.panels).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears session A panels immediately when switching to session B", async () => {
|
||||
const sessionB = deferred<readonly TuttiModePlanReviewSnapshot[]>();
|
||||
const runtime: TuttiModePlanReviewRuntime = {
|
||||
listPending: vi.fn(({ sourceSessionId }) =>
|
||||
sourceSessionId === "session-a"
|
||||
? Promise.resolve([snapshot("session-a")])
|
||||
: sessionB.promise
|
||||
),
|
||||
decide: vi.fn(),
|
||||
subscribe: vi.fn(() => () => undefined)
|
||||
};
|
||||
const rendered = renderPanels(runtime);
|
||||
await act(async () => undefined);
|
||||
expect(rendered.result.current.panels[0]?.sourceSessionId).toBe(
|
||||
"session-a"
|
||||
);
|
||||
|
||||
rendered.rerender({ sourceSessionId: "session-b" });
|
||||
|
||||
expect(rendered.result.current.panels).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores session A results across an empty scope before session B", async () => {
|
||||
const sessionA = deferred<readonly TuttiModePlanReviewSnapshot[]>();
|
||||
const sessionB = deferred<readonly TuttiModePlanReviewSnapshot[]>();
|
||||
const runtime: TuttiModePlanReviewRuntime = {
|
||||
listPending: vi.fn(({ sourceSessionId }) =>
|
||||
sourceSessionId === "session-a" ? sessionA.promise : sessionB.promise
|
||||
),
|
||||
decide: vi.fn(),
|
||||
subscribe: vi.fn(() => () => undefined)
|
||||
};
|
||||
const rendered = renderPanels(runtime);
|
||||
rendered.rerender({ sourceSessionId: null });
|
||||
rendered.rerender({ sourceSessionId: "session-b" });
|
||||
|
||||
await act(async () => sessionA.resolve([snapshot("session-a")]));
|
||||
expect(rendered.result.current.panels).toEqual([]);
|
||||
|
||||
await act(async () => sessionB.resolve([snapshot("session-b")]));
|
||||
expect(rendered.result.current.panels[0]?.sourceSessionId).toBe(
|
||||
"session-b"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not let an in-flight session A decision mutate session B state", async () => {
|
||||
const decision = deferred<void>();
|
||||
const runtime: TuttiModePlanReviewRuntime = {
|
||||
listPending: vi.fn(({ sourceSessionId }) =>
|
||||
Promise.resolve([snapshot(sourceSessionId)])
|
||||
),
|
||||
decide: vi.fn(() => decision.promise),
|
||||
subscribe: vi.fn(() => () => undefined)
|
||||
};
|
||||
const rendered = renderPanels(runtime);
|
||||
await act(async () => undefined);
|
||||
let decisionPromise!: Promise<void>;
|
||||
act(() => {
|
||||
decisionPromise = rendered.result.current.decide({
|
||||
checkpointId: "checkpoint-a",
|
||||
decision: "accepted",
|
||||
workflowId: "workflow-a"
|
||||
});
|
||||
});
|
||||
expect(rendered.result.current.submittingCheckpointId).toBe("checkpoint-a");
|
||||
|
||||
rendered.rerender({ sourceSessionId: "session-b" });
|
||||
await act(async () => undefined);
|
||||
expect(rendered.result.current.panels[0]?.sourceSessionId).toBe(
|
||||
"session-b"
|
||||
);
|
||||
expect(rendered.result.current.submittingCheckpointId).toBeNull();
|
||||
|
||||
await act(async () => decision.resolve());
|
||||
await decisionPromise;
|
||||
expect(rendered.result.current.panels[0]?.sourceSessionId).toBe(
|
||||
"session-b"
|
||||
);
|
||||
expect(rendered.result.current.submittingCheckpointId).toBeNull();
|
||||
});
|
||||
|
||||
it("reconciles the active scope after reconnect and ignores the older in-flight result", async () => {
|
||||
const beforeReconnect = deferred<readonly TuttiModePlanReviewSnapshot[]>();
|
||||
const afterReconnect = deferred<readonly TuttiModePlanReviewSnapshot[]>();
|
||||
let reconnected = false;
|
||||
let invalidationListener:
|
||||
| Parameters<TuttiModePlanReviewRuntime["subscribe"]>[1]
|
||||
| undefined;
|
||||
const runtime: TuttiModePlanReviewRuntime = {
|
||||
listPending: vi.fn(() =>
|
||||
reconnected ? afterReconnect.promise : beforeReconnect.promise
|
||||
),
|
||||
decide: vi.fn(),
|
||||
subscribe: vi.fn((_workspaceId, listener) => {
|
||||
invalidationListener = listener;
|
||||
return () => undefined;
|
||||
})
|
||||
};
|
||||
const rendered = renderPanels(runtime);
|
||||
await act(async () => undefined);
|
||||
|
||||
reconnected = true;
|
||||
act(() => {
|
||||
invalidationListener?.({
|
||||
kind: "connection_restored",
|
||||
workspaceId: "workspace-1"
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => beforeReconnect.resolve([snapshot("session-a")]));
|
||||
expect(rendered.result.current.panels).toEqual([]);
|
||||
|
||||
await act(async () => afterReconnect.resolve([snapshot("session-a")]));
|
||||
expect(rendered.result.current.panels[0]?.workflowId).toBe("workflow-a");
|
||||
expect(runtime.listPending).toHaveBeenCalledWith({
|
||||
sourceSessionId: "session-a",
|
||||
workspaceId: "workspace-1"
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the assignment catalog across a revision_created refresh", async () => {
|
||||
let invalidationListener:
|
||||
| Parameters<TuttiModePlanReviewRuntime["subscribe"]>[1]
|
||||
| undefined;
|
||||
const listAgents = vi.fn(() =>
|
||||
Promise.resolve([{ agentTargetId: "codex", label: "Codex" }])
|
||||
);
|
||||
const loadAgentOptions = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
models: [{ value: "gpt-5.4", label: "GPT-5.4" }],
|
||||
modelPlans: [],
|
||||
permissionModes: [{ id: "auto", label: "Auto" }],
|
||||
reasoningEfforts: [{ value: "high", label: "High" }]
|
||||
})
|
||||
);
|
||||
const runtime: TuttiModePlanReviewRuntime = {
|
||||
listPending: vi.fn(() => Promise.resolve([snapshot("session-a")])),
|
||||
decide: vi.fn(),
|
||||
subscribe: vi.fn((_workspaceId, listener) => {
|
||||
invalidationListener = listener;
|
||||
return () => undefined;
|
||||
}),
|
||||
assignmentOptions: { listAgents, loadAgentOptions }
|
||||
};
|
||||
const rendered = renderPanels(runtime);
|
||||
await act(async () => undefined);
|
||||
expect(rendered.result.current.assignmentCatalog.agents).toEqual([
|
||||
{ agentTargetId: "codex", label: "Codex" }
|
||||
]);
|
||||
expect(
|
||||
rendered.result.current.assignmentCatalog.optionsByAgentId["codex"]
|
||||
?.models
|
||||
).toEqual([{ value: "gpt-5.4", label: "GPT-5.4" }]);
|
||||
|
||||
// "Request changes" -> agent revises -> revision_created invalidation.
|
||||
await act(async () => {
|
||||
invalidationListener?.({
|
||||
kind: "workflow_updated",
|
||||
workspaceId: "workspace-1",
|
||||
workflowId: "workflow-a",
|
||||
sourceSessionId: "session-a",
|
||||
checkpointId: "checkpoint-a2",
|
||||
changeKind: "revision_created"
|
||||
});
|
||||
});
|
||||
await act(async () => undefined);
|
||||
|
||||
// The new revision panel must keep a usable catalog: agents stay loaded
|
||||
// and the directory is not refetched redundantly.
|
||||
expect(rendered.result.current.assignmentCatalog.agents).toEqual([
|
||||
{ agentTargetId: "codex", label: "Codex" }
|
||||
]);
|
||||
expect(listAgents).toHaveBeenCalledTimes(1);
|
||||
expect(loadAgentOptions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps stale assignment options visible while an invalidated target refreshes", async () => {
|
||||
let invalidationListener:
|
||||
| Parameters<TuttiModePlanReviewRuntime["subscribe"]>[1]
|
||||
| undefined;
|
||||
const refreshed = deferred<{
|
||||
models: { value: string; label: string }[];
|
||||
modelPlans: never[];
|
||||
permissionModes: { id: string; label: string }[];
|
||||
reasoningEfforts: { value: string; label: string }[];
|
||||
}>();
|
||||
const stale = {
|
||||
models: [{ value: "gpt-5.4", label: "GPT-5.4" }],
|
||||
modelPlans: [],
|
||||
permissionModes: [{ id: "auto", label: "Auto" }],
|
||||
reasoningEfforts: [{ value: "high", label: "High" }]
|
||||
};
|
||||
const loadAgentOptions = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(stale)
|
||||
.mockImplementationOnce(() => refreshed.promise);
|
||||
const runtime: TuttiModePlanReviewRuntime = {
|
||||
listPending: vi.fn(() => Promise.resolve([snapshot("session-a")])),
|
||||
decide: vi.fn(),
|
||||
subscribe: vi.fn((_workspaceId, listener) => {
|
||||
invalidationListener = listener;
|
||||
return () => undefined;
|
||||
}),
|
||||
assignmentOptions: {
|
||||
readAgentOptions: vi.fn(() => stale),
|
||||
listAgents: vi.fn(() =>
|
||||
Promise.resolve([{ agentTargetId: "codex", label: "Codex" }])
|
||||
),
|
||||
loadAgentOptions
|
||||
}
|
||||
};
|
||||
const rendered = renderPanels(runtime);
|
||||
await act(async () => undefined);
|
||||
expect(
|
||||
rendered.result.current.assignmentCatalog.optionsByAgentId["codex"]
|
||||
?.models
|
||||
).toEqual([{ value: "gpt-5.4", label: "GPT-5.4" }]);
|
||||
|
||||
act(() => {
|
||||
invalidationListener?.({
|
||||
kind: "assignment_options_invalidated",
|
||||
workspaceId: "workspace-1",
|
||||
agentTargetIds: ["codex"]
|
||||
});
|
||||
});
|
||||
expect(loadAgentOptions).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
rendered.result.current.assignmentCatalog.optionsByAgentId["codex"]
|
||||
?.models
|
||||
).toEqual([{ value: "gpt-5.4", label: "GPT-5.4" }]);
|
||||
|
||||
await act(async () =>
|
||||
refreshed.resolve({
|
||||
models: [{ value: "gpt-5.5", label: "GPT-5.5" }],
|
||||
modelPlans: [],
|
||||
permissionModes: [{ id: "auto", label: "Auto" }],
|
||||
reasoningEfforts: [{ value: "xhigh", label: "Extra high" }]
|
||||
})
|
||||
);
|
||||
expect(
|
||||
rendered.result.current.assignmentCatalog.optionsByAgentId["codex"]
|
||||
?.models
|
||||
).toEqual([{ value: "gpt-5.5", label: "GPT-5.5" }]);
|
||||
});
|
||||
|
||||
it("retries a failed assignment directory load on the next refresh", async () => {
|
||||
let invalidationListener:
|
||||
| Parameters<TuttiModePlanReviewRuntime["subscribe"]>[1]
|
||||
| undefined;
|
||||
const listAgents = vi
|
||||
.fn<() => Promise<readonly { agentTargetId: string; label: string }[]>>()
|
||||
.mockRejectedValueOnce(new Error("directory unavailable"))
|
||||
.mockResolvedValue([{ agentTargetId: "codex", label: "Codex" }]);
|
||||
const runtime: TuttiModePlanReviewRuntime = {
|
||||
listPending: vi.fn(() => Promise.resolve([snapshot("session-a")])),
|
||||
decide: vi.fn(),
|
||||
subscribe: vi.fn((_workspaceId, listener) => {
|
||||
invalidationListener = listener;
|
||||
return () => undefined;
|
||||
}),
|
||||
assignmentOptions: {
|
||||
listAgents,
|
||||
loadAgentOptions: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
models: [],
|
||||
modelPlans: [],
|
||||
permissionModes: [],
|
||||
reasoningEfforts: []
|
||||
})
|
||||
)
|
||||
}
|
||||
};
|
||||
const rendered = renderPanels(runtime);
|
||||
await act(async () => undefined);
|
||||
expect(rendered.result.current.assignmentCatalog.agents).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
invalidationListener?.({
|
||||
kind: "workflow_updated",
|
||||
workspaceId: "workspace-1",
|
||||
workflowId: "workflow-a",
|
||||
sourceSessionId: "session-a",
|
||||
checkpointId: "checkpoint-a2",
|
||||
changeKind: "revision_created"
|
||||
});
|
||||
});
|
||||
await act(async () => undefined);
|
||||
|
||||
expect(rendered.result.current.assignmentCatalog.agents).toEqual([
|
||||
{ agentTargetId: "codex", label: "Codex" }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,585 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useOptionalTuttiModePlanReviewRuntime } from "../workspaceWorkflowRuntime";
|
||||
import type {
|
||||
TuttiModePlanAssignmentAgentDetail,
|
||||
TuttiModePlanAssignmentAgentOption,
|
||||
TuttiModePlanTaskAssignmentInput
|
||||
} from "../workspaceWorkflowRuntime";
|
||||
import type {
|
||||
TuttiPlanIssueMaterializationFailure,
|
||||
TuttiPlanIssueQueryResult,
|
||||
TuttiPlanIssueSnapshot
|
||||
} from "../workspaceWorkflowRuntime";
|
||||
import {
|
||||
projectTuttiModePlanPanel,
|
||||
type TuttiModePlanPanelViewModel,
|
||||
type TuttiModePlanReviewSnapshot
|
||||
} from "./tuttiModePlanPanelProjection";
|
||||
|
||||
interface PanelState {
|
||||
error: unknown;
|
||||
loading: boolean;
|
||||
scopeKey: string;
|
||||
snapshots: readonly TuttiModePlanReviewSnapshot[];
|
||||
submittingCheckpointId: string | null;
|
||||
}
|
||||
|
||||
interface PlanIssueState {
|
||||
result: TuttiPlanIssueQueryResult;
|
||||
scopeKey: string;
|
||||
}
|
||||
|
||||
interface AssignmentCatalogState {
|
||||
agents: readonly TuttiModePlanAssignmentAgentOption[] | null;
|
||||
optionsByAgentId: Readonly<
|
||||
Record<string, TuttiModePlanAssignmentAgentDetail>
|
||||
>;
|
||||
scopeKey: string;
|
||||
}
|
||||
|
||||
export interface TuttiModePlanAssignmentCatalog {
|
||||
/** Null until loaded; empty array is a real "no agents" answer. */
|
||||
agents: readonly TuttiModePlanAssignmentAgentOption[] | null;
|
||||
optionsByAgentId: Readonly<
|
||||
Record<string, TuttiModePlanAssignmentAgentDetail>
|
||||
>;
|
||||
loadAgentOptions(agentTargetId: string): void;
|
||||
}
|
||||
|
||||
function emptyState(scopeKey: string): PanelState {
|
||||
return {
|
||||
error: null,
|
||||
loading: false,
|
||||
scopeKey,
|
||||
snapshots: [],
|
||||
submittingCheckpointId: null
|
||||
};
|
||||
}
|
||||
|
||||
function emptyAssignmentState(scopeKey: string): AssignmentCatalogState {
|
||||
return { agents: null, optionsByAgentId: {}, scopeKey };
|
||||
}
|
||||
|
||||
const EMPTY_AGENT_DETAIL: TuttiModePlanAssignmentAgentDetail = {
|
||||
models: [],
|
||||
modelPlans: [],
|
||||
permissionModes: [],
|
||||
reasoningEfforts: []
|
||||
};
|
||||
|
||||
const TERMINAL_PLAN_ISSUE_TASK_STATUSES = new Set([
|
||||
"completed",
|
||||
"failed",
|
||||
"canceled"
|
||||
]);
|
||||
|
||||
export function tuttiPlanIssueExecutionIsActive(
|
||||
issue: TuttiPlanIssueSnapshot | null
|
||||
): boolean {
|
||||
return Boolean(
|
||||
issue &&
|
||||
!issue.dispatchPaused &&
|
||||
issue.tasks.some(
|
||||
(task) =>
|
||||
!TERMINAL_PLAN_ISSUE_TASK_STATUSES.has(task.status.trim().toLowerCase())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function useTuttiModePlanPanels(input: {
|
||||
decidedBy: string;
|
||||
/** Passive previews keep the hook mounted without starting transport work. */
|
||||
enabled?: boolean;
|
||||
sourceSessionId: string | null;
|
||||
workspaceId: string;
|
||||
}): {
|
||||
assignmentCatalog: TuttiModePlanAssignmentCatalog;
|
||||
decide(input: {
|
||||
checkpointId: string;
|
||||
decision: "accepted" | "rejected" | "canceled";
|
||||
reason?: string | null;
|
||||
taskAssignments?: readonly TuttiModePlanTaskAssignmentInput[];
|
||||
workflowId: string;
|
||||
}): Promise<void>;
|
||||
error: unknown;
|
||||
loading: boolean;
|
||||
panels: readonly TuttiModePlanPanelViewModel[];
|
||||
/** Live snapshot of the Issue this session's accepted plan materialized. */
|
||||
planIssue: TuttiPlanIssueSnapshot | null;
|
||||
/** The accepted plan's durable create_issue failure, when no Issue exists. */
|
||||
planIssueMaterializationFailure: TuttiPlanIssueMaterializationFailure | null;
|
||||
/** Stop the plan issue's execution (pause + cancel runs); null until loaded. */
|
||||
cancelPlanIssueExecution: (() => Promise<void>) | null;
|
||||
/** Resolve a task's delegate session for jump-to-conversation; null until loaded. */
|
||||
resolvePlanIssueTaskSession:
|
||||
| ((taskId: string) => Promise<{ agentSessionId: string } | null>)
|
||||
| null;
|
||||
retry(): void;
|
||||
submittingCheckpointId: string | null;
|
||||
} {
|
||||
const runtime = useOptionalTuttiModePlanReviewRuntime();
|
||||
const [state, setState] = useState<PanelState>(() => emptyState(""));
|
||||
const [assignmentState, setAssignmentState] =
|
||||
useState<AssignmentCatalogState>(() => emptyAssignmentState(""));
|
||||
const [planIssueState, setPlanIssueState] = useState<PlanIssueState>({
|
||||
result: null,
|
||||
scopeKey: ""
|
||||
});
|
||||
const requestSequenceRef = useRef(0);
|
||||
const [retrySequence, setRetrySequence] = useState(0);
|
||||
const enabled = input.enabled ?? true;
|
||||
const workspaceId = input.workspaceId.trim();
|
||||
const sourceSessionId = input.sourceSessionId?.trim() ?? "";
|
||||
const scopeKey =
|
||||
enabled && runtime && workspaceId && sourceSessionId
|
||||
? JSON.stringify([workspaceId, sourceSessionId])
|
||||
: "";
|
||||
const activeScopeRef = useRef("");
|
||||
const assignmentRequestsRef = useRef(new Set<string>());
|
||||
const planIssueRefreshRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const assignmentSource = runtime?.assignmentOptions ?? null;
|
||||
|
||||
const loadAgentOptions = useCallback(
|
||||
(agentTargetId: string): void => {
|
||||
const capturedScope = scopeKey;
|
||||
const trimmed = agentTargetId.trim();
|
||||
if (
|
||||
!capturedScope ||
|
||||
!assignmentSource ||
|
||||
!trimmed ||
|
||||
assignmentRequestsRef.current.has(trimmed)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
assignmentRequestsRef.current.add(trimmed);
|
||||
const cached = assignmentSource.readAgentOptions?.({
|
||||
workspaceId,
|
||||
agentTargetId: trimmed
|
||||
});
|
||||
if (cached) {
|
||||
setAssignmentState((current) =>
|
||||
current.scopeKey === capturedScope
|
||||
? {
|
||||
...current,
|
||||
optionsByAgentId: {
|
||||
...current.optionsByAgentId,
|
||||
[trimmed]: cached
|
||||
}
|
||||
}
|
||||
: current
|
||||
);
|
||||
}
|
||||
void assignmentSource
|
||||
.loadAgentOptions({ workspaceId, agentTargetId: trimmed })
|
||||
.then((options) => {
|
||||
if (activeScopeRef.current !== capturedScope) return;
|
||||
setAssignmentState((current) =>
|
||||
current.scopeKey === capturedScope
|
||||
? {
|
||||
...current,
|
||||
optionsByAgentId: {
|
||||
...current.optionsByAgentId,
|
||||
[trimmed]: options
|
||||
}
|
||||
}
|
||||
: current
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// Allow a later explicit agent re-selection to retry the load, but
|
||||
// settle the entry with an empty catalog so selectors degrade to
|
||||
// the current values instead of wedging on the loading placeholder.
|
||||
assignmentRequestsRef.current.delete(trimmed);
|
||||
if (activeScopeRef.current !== capturedScope) return;
|
||||
setAssignmentState((current) =>
|
||||
current.scopeKey === capturedScope &&
|
||||
current.optionsByAgentId[trimmed] === undefined
|
||||
? {
|
||||
...current,
|
||||
optionsByAgentId: {
|
||||
...current.optionsByAgentId,
|
||||
[trimmed]: EMPTY_AGENT_DETAIL
|
||||
}
|
||||
}
|
||||
: current
|
||||
);
|
||||
});
|
||||
},
|
||||
[assignmentSource, scopeKey, workspaceId]
|
||||
);
|
||||
|
||||
// Catalog loading piggybacks on snapshot refreshes instead of adding a
|
||||
// component effect: every successful listPending with pending work triggers
|
||||
// the (deduplicated) agent-directory load plus a preload of the option
|
||||
// catalogs for agents already referenced by the plan document.
|
||||
const ensureAssignmentCatalog = useCallback(
|
||||
(
|
||||
capturedScope: string,
|
||||
snapshots: readonly TuttiModePlanReviewSnapshot[]
|
||||
): void => {
|
||||
if (!capturedScope || !assignmentSource || snapshots.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!assignmentRequestsRef.current.has("__agents__")) {
|
||||
assignmentRequestsRef.current.add("__agents__");
|
||||
const cached = assignmentSource.readAgents?.({ workspaceId });
|
||||
if (cached) {
|
||||
setAssignmentState((current) =>
|
||||
current.scopeKey === capturedScope
|
||||
? { ...current, agents: [...cached] }
|
||||
: current
|
||||
);
|
||||
}
|
||||
void assignmentSource
|
||||
.listAgents({ workspaceId })
|
||||
.then((agents) => {
|
||||
if (activeScopeRef.current !== capturedScope) return;
|
||||
setAssignmentState((current) =>
|
||||
current.scopeKey === capturedScope
|
||||
? { ...current, agents: [...agents] }
|
||||
: current
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
assignmentRequestsRef.current.delete("__agents__");
|
||||
});
|
||||
}
|
||||
for (const snapshot of snapshots) {
|
||||
const revision = snapshot.revisions.find(
|
||||
(candidate) => candidate.id === snapshot.workflow.currentRevisionId
|
||||
);
|
||||
for (const task of revision?.document.tasks ?? []) {
|
||||
if (task.agentTargetId?.trim()) {
|
||||
loadAgentOptions(task.agentTargetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[assignmentSource, loadAgentOptions, workspaceId]
|
||||
);
|
||||
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
const capturedScope = scopeKey;
|
||||
if (activeScopeRef.current !== capturedScope) return;
|
||||
if (!enabled || !runtime || !workspaceId || !sourceSessionId) {
|
||||
requestSequenceRef.current += 1;
|
||||
setState(emptyState(capturedScope));
|
||||
return;
|
||||
}
|
||||
const sequence = ++requestSequenceRef.current;
|
||||
setState((current) =>
|
||||
current.scopeKey === capturedScope
|
||||
? { ...current, error: null, loading: true }
|
||||
: { ...emptyState(capturedScope), loading: true }
|
||||
);
|
||||
try {
|
||||
const snapshots = await runtime.listPending({
|
||||
workspaceId,
|
||||
sourceSessionId
|
||||
});
|
||||
if (
|
||||
requestSequenceRef.current !== sequence ||
|
||||
activeScopeRef.current !== capturedScope
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setState((current) => ({
|
||||
...current,
|
||||
error: null,
|
||||
loading: false,
|
||||
scopeKey: capturedScope,
|
||||
snapshots: [...snapshots]
|
||||
}));
|
||||
ensureAssignmentCatalog(capturedScope, snapshots);
|
||||
} catch (error) {
|
||||
if (
|
||||
requestSequenceRef.current !== sequence ||
|
||||
activeScopeRef.current !== capturedScope
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setState((current) => ({
|
||||
...current,
|
||||
error,
|
||||
loading: false,
|
||||
scopeKey: capturedScope,
|
||||
snapshots: []
|
||||
}));
|
||||
}
|
||||
}, [
|
||||
enabled,
|
||||
ensureAssignmentCatalog,
|
||||
runtime,
|
||||
scopeKey,
|
||||
sourceSessionId,
|
||||
workspaceId
|
||||
]);
|
||||
|
||||
const planIssueSource = runtime?.planIssues ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
requestSequenceRef.current += 1;
|
||||
activeScopeRef.current = scopeKey;
|
||||
assignmentRequestsRef.current = new Set<string>();
|
||||
setAssignmentState(emptyAssignmentState(scopeKey));
|
||||
setPlanIssueState({ result: null, scopeKey });
|
||||
void refresh();
|
||||
const capturedScope = scopeKey;
|
||||
// The materialized plan Issue shares this scope's lifecycle: load with the
|
||||
// review snapshots and refresh on workspace issue events, with a trailing
|
||||
// re-read so the view never settles on a snapshot older than the last one.
|
||||
let planIssueInFlight = false;
|
||||
let planIssueTrailing = false;
|
||||
const refreshPlanIssue = (): void => {
|
||||
if (!capturedScope || !planIssueSource) return;
|
||||
if (planIssueInFlight) {
|
||||
planIssueTrailing = true;
|
||||
return;
|
||||
}
|
||||
planIssueInFlight = true;
|
||||
void planIssueSource
|
||||
.getSessionPlanIssue({ workspaceId, sourceSessionId })
|
||||
.then((result) => {
|
||||
if (activeScopeRef.current !== capturedScope) return;
|
||||
setPlanIssueState((current) =>
|
||||
current.scopeKey === capturedScope
|
||||
? { result, scopeKey: capturedScope }
|
||||
: current
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort read model; a later update event retries.
|
||||
})
|
||||
.finally(() => {
|
||||
planIssueInFlight = false;
|
||||
if (planIssueTrailing && activeScopeRef.current === capturedScope) {
|
||||
planIssueTrailing = false;
|
||||
refreshPlanIssue();
|
||||
}
|
||||
});
|
||||
};
|
||||
refreshPlanIssue();
|
||||
planIssueRefreshRef.current = refreshPlanIssue;
|
||||
const unsubscribe =
|
||||
enabled && runtime && workspaceId && sourceSessionId
|
||||
? runtime.subscribe(workspaceId, (update) => {
|
||||
if (update.kind === "assignment_options_invalidated") {
|
||||
for (const agentTargetId of update.agentTargetIds) {
|
||||
const normalizedAgentTargetId = agentTargetId.trim();
|
||||
if (
|
||||
normalizedAgentTargetId &&
|
||||
assignmentRequestsRef.current.delete(normalizedAgentTargetId)
|
||||
) {
|
||||
loadAgentOptions(normalizedAgentTargetId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
update.kind === "connection_restored" ||
|
||||
update.sourceSessionId === sourceSessionId
|
||||
) {
|
||||
void refresh();
|
||||
// The workflow's operation_updated event is the authoritative
|
||||
// "Issue materialized" signal: workspace.issue.updated fires
|
||||
// during materialization, before the create_issue operation
|
||||
// records its outcome, so a re-read triggered only by the issue
|
||||
// event reliably finds no succeeded operation and leaves the
|
||||
// panel invisible until a remount.
|
||||
refreshPlanIssue();
|
||||
}
|
||||
})
|
||||
: undefined;
|
||||
const unsubscribePlanIssue =
|
||||
capturedScope && planIssueSource
|
||||
? planIssueSource.subscribeIssueUpdates(workspaceId, () => {
|
||||
refreshPlanIssue();
|
||||
})
|
||||
: undefined;
|
||||
return () => {
|
||||
planIssueRefreshRef.current = null;
|
||||
unsubscribe?.();
|
||||
unsubscribePlanIssue?.();
|
||||
if (activeScopeRef.current === scopeKey) {
|
||||
activeScopeRef.current = "";
|
||||
}
|
||||
requestSequenceRef.current += 1;
|
||||
};
|
||||
}, [
|
||||
enabled,
|
||||
planIssueSource,
|
||||
refresh,
|
||||
retrySequence,
|
||||
runtime,
|
||||
scopeKey,
|
||||
sourceSessionId,
|
||||
workspaceId
|
||||
]);
|
||||
|
||||
const decide = useCallback(
|
||||
async (decision: {
|
||||
checkpointId: string;
|
||||
decision: "accepted" | "rejected" | "canceled";
|
||||
reason?: string | null;
|
||||
taskAssignments?: readonly TuttiModePlanTaskAssignmentInput[];
|
||||
workflowId: string;
|
||||
}): Promise<void> => {
|
||||
const capturedScope = scopeKey;
|
||||
if (
|
||||
!runtime ||
|
||||
!capturedScope ||
|
||||
activeScopeRef.current !== capturedScope
|
||||
)
|
||||
return;
|
||||
setState((current) => ({
|
||||
...(current.scopeKey === capturedScope
|
||||
? current
|
||||
: emptyState(capturedScope)),
|
||||
error: null,
|
||||
submittingCheckpointId: decision.checkpointId,
|
||||
scopeKey: capturedScope
|
||||
}));
|
||||
try {
|
||||
await runtime.decide({
|
||||
workspaceId,
|
||||
workflowId: decision.workflowId,
|
||||
checkpointId: decision.checkpointId,
|
||||
decision: decision.decision,
|
||||
decidedBy: input.decidedBy,
|
||||
reason: decision.reason,
|
||||
taskAssignments: decision.taskAssignments
|
||||
});
|
||||
if (activeScopeRef.current !== capturedScope) return;
|
||||
await refresh();
|
||||
// The decide response returns after the daemon completed the
|
||||
// create_issue operation, so this read observes the materialized
|
||||
// Issue immediately — the accepted review panel hands over to the
|
||||
// issue panel in place without waiting for an event round-trip.
|
||||
planIssueRefreshRef.current?.();
|
||||
} catch (error) {
|
||||
if (activeScopeRef.current === capturedScope) {
|
||||
setState((current) => ({ ...current, error }));
|
||||
}
|
||||
} finally {
|
||||
if (activeScopeRef.current === capturedScope) {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
submittingCheckpointId: null
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
[input.decidedBy, refresh, runtime, scopeKey, workspaceId]
|
||||
);
|
||||
|
||||
const visibleState =
|
||||
state.scopeKey === scopeKey ? state : emptyState(scopeKey);
|
||||
|
||||
const panels = useMemo(
|
||||
() =>
|
||||
visibleState.snapshots
|
||||
.map(projectTuttiModePlanPanel)
|
||||
.filter((panel): panel is TuttiModePlanPanelViewModel => panel !== null)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.checkpoint.createdAtUnixMs - right.checkpoint.createdAtUnixMs
|
||||
),
|
||||
[visibleState.snapshots]
|
||||
);
|
||||
|
||||
const visiblePlanIssueResult =
|
||||
planIssueState.scopeKey === scopeKey ? planIssueState.result : null;
|
||||
const visiblePlanIssue =
|
||||
visiblePlanIssueResult?.kind === "issue"
|
||||
? visiblePlanIssueResult.issue
|
||||
: null;
|
||||
const visiblePlanIssueFailure = useMemo(
|
||||
() =>
|
||||
visiblePlanIssueResult?.kind === "materialization_failed"
|
||||
? {
|
||||
workflowId: visiblePlanIssueResult.workflowId,
|
||||
sourceTurnId: visiblePlanIssueResult.sourceTurnId,
|
||||
errorMessage: visiblePlanIssueResult.errorMessage
|
||||
}
|
||||
: null,
|
||||
[visiblePlanIssueResult]
|
||||
);
|
||||
const planIssueId = visiblePlanIssue?.issueId ?? "";
|
||||
const cancelPlanIssueExecutionAction =
|
||||
useCallback(async (): Promise<void> => {
|
||||
if (!planIssueSource || !planIssueId) return;
|
||||
// Stop is one daemon-owned cascade: pause dispatch, cancel every running
|
||||
// run. The issue-updated event refreshes the embed.
|
||||
await planIssueSource.cancelExecution({
|
||||
workspaceId,
|
||||
issueId: planIssueId
|
||||
});
|
||||
}, [planIssueId, planIssueSource, workspaceId]);
|
||||
const resolvePlanIssueTaskSessionAction = useCallback(
|
||||
(taskId: string): Promise<{ agentSessionId: string } | null> => {
|
||||
if (!planIssueSource || !planIssueId) return Promise.resolve(null);
|
||||
return planIssueSource.resolveTaskSession({
|
||||
workspaceId,
|
||||
issueId: planIssueId,
|
||||
taskId
|
||||
});
|
||||
},
|
||||
[planIssueId, planIssueSource, workspaceId]
|
||||
);
|
||||
const cancelPlanIssueExecution =
|
||||
planIssueSource && planIssueId ? cancelPlanIssueExecutionAction : null;
|
||||
const resolvePlanIssueTaskSession =
|
||||
planIssueSource && planIssueId ? resolvePlanIssueTaskSessionAction : null;
|
||||
|
||||
const visibleAssignmentState =
|
||||
assignmentState.scopeKey === scopeKey
|
||||
? assignmentState
|
||||
: emptyAssignmentState(scopeKey);
|
||||
|
||||
const assignmentCatalog = useMemo(
|
||||
() => ({
|
||||
agents: assignmentSource ? visibleAssignmentState.agents : null,
|
||||
optionsByAgentId: visibleAssignmentState.optionsByAgentId,
|
||||
loadAgentOptions
|
||||
}),
|
||||
[
|
||||
assignmentSource,
|
||||
loadAgentOptions,
|
||||
visibleAssignmentState.agents,
|
||||
visibleAssignmentState.optionsByAgentId
|
||||
]
|
||||
);
|
||||
const retry = useCallback(
|
||||
() => setRetrySequence((current) => current + 1),
|
||||
[]
|
||||
);
|
||||
return useMemo(
|
||||
() => ({
|
||||
assignmentCatalog,
|
||||
decide,
|
||||
error: visibleState.error,
|
||||
loading: visibleState.loading,
|
||||
panels,
|
||||
planIssue: visiblePlanIssue,
|
||||
planIssueMaterializationFailure: visiblePlanIssueFailure,
|
||||
cancelPlanIssueExecution,
|
||||
resolvePlanIssueTaskSession,
|
||||
retry,
|
||||
submittingCheckpointId: visibleState.submittingCheckpointId
|
||||
}),
|
||||
[
|
||||
assignmentCatalog,
|
||||
cancelPlanIssueExecution,
|
||||
decide,
|
||||
panels,
|
||||
resolvePlanIssueTaskSession,
|
||||
retry,
|
||||
visiblePlanIssue,
|
||||
visiblePlanIssueFailure,
|
||||
visibleState.error,
|
||||
visibleState.loading,
|
||||
visibleState.submittingCheckpointId
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { createContext, useContext, type JSX, type ReactNode } from "react";
|
||||
import type { AgentActivityComposerSettingOption } from "@tutti-os/agent-activity-core";
|
||||
import type { TuttiModePlanReviewSnapshot } from "./tuttiModePlan/tuttiModePlanPanelProjection";
|
||||
|
||||
export interface TuttiModePlanReviewUpdate {
|
||||
kind: "workflow_updated";
|
||||
workspaceId: string;
|
||||
workflowId: string;
|
||||
sourceSessionId: string;
|
||||
checkpointId: string;
|
||||
changeKind:
|
||||
| "proposal_created"
|
||||
| "revision_created"
|
||||
| "checkpoint_decided"
|
||||
| "operation_updated";
|
||||
}
|
||||
|
||||
export interface TuttiModePlanReviewConnectionRestored {
|
||||
kind: "connection_restored";
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A root turn of the source session settled. The host derives this from the
|
||||
* daemon's canonical turn fan-out and relays it as a read-repair trigger: a
|
||||
* plan proposed mid-turn announces itself through one workflow event, and if
|
||||
* that single event is lost no later workflow signal re-reads review state.
|
||||
*/
|
||||
export interface TuttiModePlanReviewSessionSettled {
|
||||
kind: "session_settled";
|
||||
workspaceId: string;
|
||||
sourceSessionId: string;
|
||||
}
|
||||
|
||||
/** Cached assignment catalogs changed for exact Agent Targets. */
|
||||
export interface TuttiModePlanAssignmentOptionsInvalidated {
|
||||
kind: "assignment_options_invalidated";
|
||||
workspaceId: string;
|
||||
agentTargetIds: readonly string[];
|
||||
}
|
||||
|
||||
export type TuttiModePlanReviewInvalidation =
|
||||
| TuttiModePlanReviewUpdate
|
||||
| TuttiModePlanReviewConnectionRestored
|
||||
| TuttiModePlanReviewSessionSettled
|
||||
| TuttiModePlanAssignmentOptionsInvalidated;
|
||||
|
||||
export interface TuttiModePlanTaskAssignmentInput {
|
||||
taskId: string;
|
||||
agentTargetId?: string | null;
|
||||
modelPlanId?: string | null;
|
||||
model?: string | null;
|
||||
permissionModeId?: string | null;
|
||||
reasoningEffort?: string | null;
|
||||
parallelizable?: boolean | null;
|
||||
autoAccept?: boolean | null;
|
||||
}
|
||||
|
||||
export interface TuttiModePlanReviewDecisionInput {
|
||||
workspaceId: string;
|
||||
workflowId: string;
|
||||
checkpointId: string;
|
||||
decision: "accepted" | "rejected" | "canceled";
|
||||
decidedBy: string;
|
||||
reason?: string | null;
|
||||
/** Per-task overrides; only meaningful with an accepted task review. */
|
||||
taskAssignments?: readonly TuttiModePlanTaskAssignmentInput[];
|
||||
}
|
||||
|
||||
export interface TuttiModePlanAssignmentAgentOption {
|
||||
agentTargetId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface TuttiModePlanAssignmentAgentDetail {
|
||||
/** Provider-native models usable without a model plan. */
|
||||
models: readonly AgentActivityComposerSettingOption[];
|
||||
modelPlans: readonly {
|
||||
modelPlanId: string;
|
||||
label: string;
|
||||
models: readonly AgentActivityComposerSettingOption[];
|
||||
}[];
|
||||
permissionModes: readonly { id: string; label: string }[];
|
||||
reasoningEfforts: readonly AgentActivityComposerSettingOption[];
|
||||
}
|
||||
|
||||
export interface TuttiPlanIssueTaskSnapshot {
|
||||
taskId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
status: string;
|
||||
sortIndex: number;
|
||||
parallelizable: boolean;
|
||||
autoAccept: boolean;
|
||||
dependencyTaskIds: string[];
|
||||
}
|
||||
|
||||
/** Read-only snapshot of the Issue a session's accepted plan materialized. */
|
||||
export interface TuttiPlanIssueSnapshot {
|
||||
workflowId: string;
|
||||
sourceTurnId: string | null;
|
||||
issueId: string;
|
||||
topicId: string;
|
||||
title: string;
|
||||
/** Durable Issue execution gate; paused graphs are not active work. */
|
||||
dispatchPaused: boolean;
|
||||
tasks: TuttiPlanIssueTaskSnapshot[];
|
||||
}
|
||||
|
||||
/**
|
||||
* An accepted plan whose create_issue operation durably failed. The
|
||||
* conversation must surface this instead of rendering nothing: there is no
|
||||
* pending checkpoint (the review panel is gone) and no Issue (the issue panel
|
||||
* never appears), so silence would hide the failure entirely.
|
||||
*/
|
||||
export interface TuttiPlanIssueMaterializationFailure {
|
||||
workflowId: string;
|
||||
sourceTurnId: string | null;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export type TuttiPlanIssueQueryResult =
|
||||
| { kind: "issue"; issue: TuttiPlanIssueSnapshot }
|
||||
| ({ kind: "materialization_failed" } & TuttiPlanIssueMaterializationFailure)
|
||||
| null;
|
||||
|
||||
/**
|
||||
* Source for the embedded plan-issue panel in the conversation.
|
||||
* The host resolves "the Issue this session's accepted plan created", relays
|
||||
* live issue updates, and exposes only daemon-owned product commands. Task
|
||||
* accept/rework actions remain source-Agent prompts rather than generic Issue
|
||||
* mutations.
|
||||
*/
|
||||
export interface TuttiPlanIssueSource {
|
||||
getSessionPlanIssue(input: {
|
||||
workspaceId: string;
|
||||
sourceSessionId: string;
|
||||
}): Promise<TuttiPlanIssueQueryResult>;
|
||||
subscribeIssueUpdates(
|
||||
workspaceId: string,
|
||||
listener: (update: { issueId: string }) => void
|
||||
): () => void;
|
||||
/**
|
||||
* Stops the Issue's execution: pauses future dispatch and cancels every
|
||||
* running task run. Idempotent; the daemon owns the cascade.
|
||||
*/
|
||||
cancelExecution(input: {
|
||||
workspaceId: string;
|
||||
issueId: string;
|
||||
}): Promise<void>;
|
||||
/**
|
||||
* Resolves the delegate agent session that ran (or is running) a task's
|
||||
* latest run, so a task card can jump straight into that conversation.
|
||||
* Null when the task has not launched yet.
|
||||
*/
|
||||
resolveTaskSession(input: {
|
||||
workspaceId: string;
|
||||
issueId: string;
|
||||
taskId: string;
|
||||
}): Promise<{ agentSessionId: string } | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Option catalogs for per-task assignment editing. The desktop host reuses
|
||||
* its agent directory and composer capability catalogs; the panel never
|
||||
* hardcodes providers or modes.
|
||||
*/
|
||||
export interface TuttiModePlanAssignmentOptionsSource {
|
||||
/**
|
||||
* Returns the last successful workspace directory, including stale data.
|
||||
* Hosts may omit this when they do not retain a query cache.
|
||||
*/
|
||||
readAgents?(input: {
|
||||
workspaceId: string;
|
||||
}): readonly TuttiModePlanAssignmentAgentOption[] | null;
|
||||
listAgents(input: {
|
||||
workspaceId: string;
|
||||
}): Promise<readonly TuttiModePlanAssignmentAgentOption[]>;
|
||||
/**
|
||||
* Returns the last successful target catalog, including stale data, so a
|
||||
* refresh never replaces usable selectors with a loading placeholder.
|
||||
*/
|
||||
readAgentOptions?(input: {
|
||||
workspaceId: string;
|
||||
agentTargetId: string;
|
||||
}): TuttiModePlanAssignmentAgentDetail | null;
|
||||
loadAgentOptions(input: {
|
||||
workspaceId: string;
|
||||
agentTargetId: string;
|
||||
}): Promise<TuttiModePlanAssignmentAgentDetail>;
|
||||
}
|
||||
|
||||
export interface TuttiModePlanReviewRuntime {
|
||||
listPending(input: {
|
||||
workspaceId: string;
|
||||
sourceSessionId: string;
|
||||
}): Promise<readonly TuttiModePlanReviewSnapshot[]>;
|
||||
decide(input: TuttiModePlanReviewDecisionInput): Promise<void>;
|
||||
subscribe(
|
||||
workspaceId: string,
|
||||
listener: (update: TuttiModePlanReviewInvalidation) => void
|
||||
): () => void;
|
||||
/** Optional; the panel degrades to read-only assignment display without it. */
|
||||
assignmentOptions?: TuttiModePlanAssignmentOptionsSource;
|
||||
/** Optional; without it the embedded plan-issue panel never renders. */
|
||||
planIssues?: TuttiPlanIssueSource;
|
||||
}
|
||||
|
||||
const TuttiModePlanReviewRuntimeContext =
|
||||
createContext<TuttiModePlanReviewRuntime | null>(null);
|
||||
|
||||
export function TuttiModePlanReviewRuntimeProvider({
|
||||
children,
|
||||
runtime
|
||||
}: {
|
||||
children: ReactNode;
|
||||
runtime?: TuttiModePlanReviewRuntime | null;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<TuttiModePlanReviewRuntimeContext.Provider value={runtime ?? null}>
|
||||
{children}
|
||||
</TuttiModePlanReviewRuntimeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useOptionalTuttiModePlanReviewRuntime(): TuttiModePlanReviewRuntime | null {
|
||||
return useContext(TuttiModePlanReviewRuntimeContext);
|
||||
}
|
||||
Reference in New Issue
Block a user