mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Bunch of runtime fixes for MegaTask and other issues
This commit is contained in:
@@ -4,10 +4,12 @@ import { useEffect, useRef } from "react";
|
||||
import { Loader2, Sparkles, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { usePrompter } from "@/hooks/use-prompter";
|
||||
import { Team } from "@/types";
|
||||
import {
|
||||
ChatMessages,
|
||||
ChatComposer,
|
||||
SuccessCard,
|
||||
BoardReviewSentCard,
|
||||
IntakeForm,
|
||||
BatchReviewCard,
|
||||
} from "@/components/prompter";
|
||||
@@ -42,7 +44,7 @@ export default function PrompterPage() {
|
||||
batch,
|
||||
batchWaves,
|
||||
batchResult,
|
||||
updateBatchDraftProject,
|
||||
setBatchDraftProjects,
|
||||
confirmBatch,
|
||||
} = usePrompter();
|
||||
|
||||
@@ -113,22 +115,40 @@ export default function PrompterPage() {
|
||||
createdTaskTeam ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-8 py-8">
|
||||
<div className="w-full max-w-md space-y-3">
|
||||
<SuccessCard
|
||||
taskId={createdTaskId}
|
||||
taskTitle={createdTaskTitle}
|
||||
team={createdTaskTeam}
|
||||
onStartAnother={startAnother}
|
||||
/>
|
||||
{batchResult && (
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{batchResult.root_subtask_ids.length} tasks sequenced into{" "}
|
||||
{batchResult.waves.length} wave
|
||||
{batchResult.waves.length === 1 ? "" : "s"}.
|
||||
{batchResult.warnings.length > 0 &&
|
||||
` ${batchResult.warnings.length} advisory note${
|
||||
batchResult.warnings.length === 1 ? "" : "s"
|
||||
}.`}
|
||||
</p>
|
||||
{/* Board-routed MegaTask: created HELD for PO+HoM review, not
|
||||
dispatched — the CEO releases it with Approve & Start on the
|
||||
umbrella task. Every other path is a real "created/launched"
|
||||
success. ``createdTaskTeam === BOARD`` is set only by the
|
||||
batch board route (the single-draft board route parks and
|
||||
never reaches success). */}
|
||||
{createdTaskTeam === Team.BOARD && batchResult ? (
|
||||
<BoardReviewSentCard
|
||||
taskId={createdTaskId}
|
||||
taskTitle={createdTaskTitle}
|
||||
rootSubtaskCount={batchResult.root_subtask_ids.length}
|
||||
waveCount={batchResult.waves.length}
|
||||
onStartAnother={startAnother}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<SuccessCard
|
||||
taskId={createdTaskId}
|
||||
taskTitle={createdTaskTitle}
|
||||
team={createdTaskTeam}
|
||||
onStartAnother={startAnother}
|
||||
/>
|
||||
{batchResult && (
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{batchResult.root_subtask_ids.length} tasks sequenced
|
||||
into {batchResult.waves.length} wave
|
||||
{batchResult.waves.length === 1 ? "" : "s"}.
|
||||
{batchResult.warnings.length > 0 &&
|
||||
` ${batchResult.warnings.length} advisory note${
|
||||
batchResult.warnings.length === 1 ? "" : "s"
|
||||
}.`}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -149,7 +169,7 @@ export default function PrompterPage() {
|
||||
waves={batchWaves}
|
||||
projectIds={projectIds}
|
||||
onKeepChatting={keepChatting}
|
||||
onProjectChange={updateBatchDraftProject}
|
||||
onSetProjects={setBatchDraftProjects}
|
||||
onConfirm={confirmBatch}
|
||||
isLaunching={isLaunching}
|
||||
/>
|
||||
|
||||
@@ -11,15 +11,10 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import type { BatchProposal, StartRoute } from "@/hooks/use-prompter";
|
||||
import type { ProjectSummary } from "@/types";
|
||||
import type { CellWork, DraftProposal } from "@/lib/api/prompter";
|
||||
import { Team } from "@/types";
|
||||
|
||||
@@ -32,38 +27,40 @@ const CELL_LABEL: Record<string, string> = {
|
||||
ux_ui: "UX/UI",
|
||||
};
|
||||
|
||||
/** One the_work entry whose team is a delivery cell — a per-cell project picker. */
|
||||
interface CellEntry {
|
||||
entry: CellWork;
|
||||
entryIndex: number;
|
||||
team: Team;
|
||||
}
|
||||
|
||||
/** A draft's per-cell entries (the the_work slots that carry a cell team), in
|
||||
* the_work order. Empty for a legacy single-cell draft with no cell the_work. */
|
||||
function cellEntries(draft: DraftProposal): CellEntry[] {
|
||||
return (draft.the_work ?? [])
|
||||
.map((entry, entryIndex) => ({ entry, entryIndex, team: entry?.team }))
|
||||
/** The project_ids a draft currently targets: the per-cell ``the_work[].project_id``
|
||||
* set (the multi-select model), falling back to a legacy top-level project_id.
|
||||
* Only scoped ids count — an out-of-scope id is treated as unselected. */
|
||||
function selectedProjectIds(
|
||||
draft: DraftProposal,
|
||||
scoped: Set<string>,
|
||||
): string[] {
|
||||
const work = Array.isArray(draft.the_work) ? draft.the_work : [];
|
||||
const pids = work
|
||||
.filter(
|
||||
(e): e is CellEntry =>
|
||||
!!e.team && (CELL_TEAMS as readonly string[]).includes(e.team),
|
||||
);
|
||||
(w): w is CellWork & { project_id: string } =>
|
||||
!!w?.team &&
|
||||
(CELL_TEAMS as readonly string[]).includes(w.team) &&
|
||||
typeof w.project_id === "string" &&
|
||||
w.project_id !== "" &&
|
||||
scoped.has(w.project_id),
|
||||
)
|
||||
.map((w) => w.project_id);
|
||||
if (pids.length > 0) return pids;
|
||||
if (draft.project_id && scoped.has(draft.project_id))
|
||||
return [draft.project_id];
|
||||
return [];
|
||||
}
|
||||
|
||||
interface BatchReviewCardProps {
|
||||
batch: BatchProposal;
|
||||
/** The conflict-free waves (lists of draft indices), once previewed. */
|
||||
waves: number[][] | null;
|
||||
/** The repos this MegaTask is scoped to — each cell must target one of them. */
|
||||
/** The repos this MegaTask is scoped to — each task targets a subset of them. */
|
||||
projectIds: string[];
|
||||
onKeepChatting: () => void;
|
||||
/** `entryIndex` is the the_work slot (the cell); -1 for a legacy single-cell
|
||||
* draft with no per-cell map (sets the top-level project_id). */
|
||||
onProjectChange: (
|
||||
index: number,
|
||||
entryIndex: number,
|
||||
projectId: string,
|
||||
) => void;
|
||||
/** Set the whole set of projects one task targets (multi-select across cells,
|
||||
* one repo per cell — the backend stores one project per cell). */
|
||||
onSetProjects: (index: number, ids: string[]) => void;
|
||||
onConfirm: (route: StartRoute) => void;
|
||||
/** A launch is in flight — disable the actions so a double-click can't dupe. */
|
||||
isLaunching?: boolean;
|
||||
@@ -71,38 +68,55 @@ interface BatchReviewCardProps {
|
||||
|
||||
/**
|
||||
* The MegaTask review card: every task the agent proposed in one batch, each
|
||||
* with its per-cell target projects (editable) and collision surface, plus the
|
||||
* conflict-free wave plan. A multi-cell task (be+fe, fe+uxui) shows one project
|
||||
* picker per cell, scoped to that cell's repos — a RoboCo project is per-cell,
|
||||
* so each cell lands in its own repo. The human reviews the whole batch and the
|
||||
* sequencing, fixes any cell in the wrong repo, then picks one start path.
|
||||
* with its target projects (a multi-select checkbox list — one task can span
|
||||
* several repos, one repo per delivery cell) and collision surface, plus the
|
||||
* conflict-free wave plan. The human reviews the whole batch and the sequencing,
|
||||
* picks the repos each task lands in, then picks one start path.
|
||||
*/
|
||||
export function BatchReviewCard({
|
||||
batch,
|
||||
waves,
|
||||
projectIds,
|
||||
onKeepChatting,
|
||||
onProjectChange,
|
||||
onSetProjects,
|
||||
onConfirm,
|
||||
isLaunching = false,
|
||||
}: BatchReviewCardProps) {
|
||||
const { data: allProjects = [] } = useProjects();
|
||||
// Only the scoped repos are valid targets (the agent read only those).
|
||||
const scoped = new Set(projectIds);
|
||||
const scopedByCell = (cell: Team): ProjectSummary[] =>
|
||||
allProjects.filter((p) => scoped.has(p.id) && p.assigned_cell === cell);
|
||||
const titleOf = (i: number): string =>
|
||||
batch.drafts[i]?.title ?? `Task ${i + 1}`;
|
||||
// A task is mis-targeted when any of its cells lacks a scoped project (a
|
||||
// multi-cell draft checks every the_work entry; a legacy single-cell draft
|
||||
// with no cell map checks its top-level project_id).
|
||||
const missingProject = batch.drafts.some((d) => {
|
||||
const entries = cellEntries(d);
|
||||
if (entries.length > 0) {
|
||||
return entries.some(
|
||||
(ce) => !ce.entry.project_id || !scoped.has(ce.entry.project_id),
|
||||
// A task is mis-targeted when it has no project selected at all (the backend
|
||||
// re-asserts each targeted project is in scope and the batch spans ≥2 repos).
|
||||
const missingProject = batch.drafts.some(
|
||||
(d) => selectedProjectIds(d, scoped).length === 0,
|
||||
);
|
||||
|
||||
/** Toggle one project in a task's selection. A RoboCo project is per-cell and
|
||||
* the backend stores one project per cell, so checking a repo in a cell that
|
||||
* already has a different repo checked swaps it (unchecks the sibling). */
|
||||
const toggle = (index: number, projectId: string) => {
|
||||
const draft = batch.drafts[index];
|
||||
const current = selectedProjectIds(draft, scoped);
|
||||
const proj = allProjects.find((p) => p.id === projectId);
|
||||
const cell = proj?.assigned_cell;
|
||||
if (current.includes(projectId)) {
|
||||
onSetProjects(
|
||||
index,
|
||||
current.filter((id) => id !== projectId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
return !d.project_id || !scoped.has(d.project_id);
|
||||
});
|
||||
// Checking: drop any other project in the same cell (one repo per cell).
|
||||
const next = current.filter((id) => {
|
||||
const other = allProjects.find((p) => p.id === id);
|
||||
return other?.assigned_cell !== cell;
|
||||
});
|
||||
onSetProjects(index, [...next, projectId]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-primary/40 bg-primary/5">
|
||||
@@ -124,14 +138,22 @@ export function BatchReviewCard({
|
||||
<CardContent className="space-y-3 pb-3">
|
||||
<ol className="space-y-2">
|
||||
{batch.drafts.map((draft, i) => {
|
||||
const entries = cellEntries(draft);
|
||||
const selected = selectedProjectIds(draft, scoped);
|
||||
return (
|
||||
<li
|
||||
key={i}
|
||||
className="rounded-md border bg-background/60 px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-medium leading-tight">
|
||||
{/* Title is clamped to a fixed 2-line space so a long title
|
||||
can't grow the row (or, as a long unbroken token, blow the
|
||||
card width out and wreck the layout). min-w-0 lets the flex
|
||||
item shrink below min-content; break-words stops a token
|
||||
from overflowing; the full title is on the tooltip. */}
|
||||
<span
|
||||
className="min-w-0 flex-1 break-words font-medium leading-tight line-clamp-2"
|
||||
title={`${i + 1}. ${draft.title}`}
|
||||
>
|
||||
{i + 1}. {draft.title}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
@@ -154,87 +176,48 @@ export function BatchReviewCard({
|
||||
{draft.objective || draft.description}
|
||||
</p>
|
||||
)}
|
||||
{entries.length > 0 ? (
|
||||
/* Per-cell project picker — one Select per the_work entry,
|
||||
scoped to that cell's repos (a project is per-cell). */
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{entries.map(({ entry, entryIndex, team }) => {
|
||||
const cellProjects = allProjects.filter(
|
||||
(p) => scoped.has(p.id) && p.assigned_cell === team,
|
||||
);
|
||||
const pid = entry.project_id ?? "";
|
||||
const ok = pid !== "" && scoped.has(pid);
|
||||
return (
|
||||
<div
|
||||
key={entryIndex}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span className="w-16 shrink-0 text-xs text-muted-foreground">
|
||||
{CELL_LABEL[team] ?? team}
|
||||
</span>
|
||||
<Select
|
||||
value={ok ? pid : ""}
|
||||
onValueChange={(v) =>
|
||||
onProjectChange(i, entryIndex, v)
|
||||
}
|
||||
disabled={isLaunching}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={`h-7 flex-1 text-xs ${
|
||||
ok ? "" : "border-destructive"
|
||||
}`}
|
||||
>
|
||||
<SelectValue placeholder="Pick a project…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cellProjects.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Multi-select project picker — one task can span several repos
|
||||
(one per delivery cell). Grouped by cell; one repo per cell. */}
|
||||
<div className="mt-1.5 space-y-1.5">
|
||||
<p
|
||||
className={`text-xs ${
|
||||
selected.length === 0
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Projects {selected.length === 0 && "— pick at least one"}
|
||||
</p>
|
||||
{CELL_TEAMS.map((cell) => {
|
||||
const repos = scopedByCell(cell);
|
||||
if (repos.length === 0) return null;
|
||||
return (
|
||||
<div key={cell} className="space-y-1">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{CELL_LABEL[cell] ?? cell}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{repos.map((p) => {
|
||||
const checked = selected.includes(p.id);
|
||||
return (
|
||||
<label
|
||||
key={p.id}
|
||||
className="flex cursor-pointer items-center gap-1.5 text-xs disabled:cursor-not-allowed"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={isLaunching}
|
||||
onCheckedChange={() => toggle(i, p.id)}
|
||||
/>
|
||||
<span>{p.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* Legacy single-cell draft (no per-cell the_work) — one Select
|
||||
bound to the top-level project_id, scoped to all repos. */
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Project
|
||||
</span>
|
||||
<Select
|
||||
value={
|
||||
draft.project_id && scoped.has(draft.project_id)
|
||||
? draft.project_id
|
||||
: ""
|
||||
}
|
||||
onValueChange={(v) => onProjectChange(i, -1, v)}
|
||||
disabled={isLaunching}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={`h-7 flex-1 text-xs ${
|
||||
draft.project_id && scoped.has(draft.project_id)
|
||||
? ""
|
||||
: "border-destructive"
|
||||
}`}
|
||||
>
|
||||
<SelectValue placeholder="Pick a project…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allProjects
|
||||
.filter((p) => scoped.has(p.id))
|
||||
.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -248,7 +231,11 @@ export function BatchReviewCard({
|
||||
</p>
|
||||
<ol className="space-y-0.5">
|
||||
{waves.map((wave, w) => (
|
||||
<li key={w} className="text-xs">
|
||||
<li
|
||||
key={w}
|
||||
className="break-words text-xs line-clamp-2"
|
||||
title={wave.map((i) => titleOf(i)).join(", ")}
|
||||
>
|
||||
<span className="font-medium">Wave {w + 1}:</span>{" "}
|
||||
{wave.map((i) => titleOf(i)).join(", ")}
|
||||
</li>
|
||||
@@ -259,7 +246,7 @@ export function BatchReviewCard({
|
||||
|
||||
{missingProject && (
|
||||
<p className="text-xs text-destructive">
|
||||
Pick a project for every cell of every task before launching the
|
||||
Pick at least one project for every task before launching the
|
||||
MegaTask.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Users, ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface BoardReviewSentCardProps {
|
||||
/** The umbrella task id — the single board-review / CEO-approve unit. */
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
rootSubtaskCount: number;
|
||||
waveCount: number;
|
||||
onStartAnother: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The MegaTask "Board review & Start" confirmation: the umbrella + root-subtasks
|
||||
* were created HELD (umbrella assigned to the Product Owner, root-subtasks in
|
||||
* BACKLOG) for the Product Owner + Head of Marketing to review. Nothing is
|
||||
* dispatched yet — the CEO releases the sequenced tasks with Approve & Start on
|
||||
* the umbrella task once the board finishes. This is the batch analogue of the
|
||||
* single-draft board route's "sent to the board" wait, reusing the existing CEO
|
||||
* Approve & Start gate on the umbrella (which fires ``approve_and_start`` →
|
||||
* ``_activate_batch_root_subtasks``).
|
||||
*/
|
||||
export function BoardReviewSentCard({
|
||||
taskId,
|
||||
taskTitle,
|
||||
rootSubtaskCount,
|
||||
waveCount,
|
||||
onStartAnother,
|
||||
}: BoardReviewSentCardProps) {
|
||||
return (
|
||||
<Card className="border-primary/30 bg-primary/5">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
<CardTitle className="text-sm font-semibold text-primary">
|
||||
Sent to the Board for review
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pb-3 space-y-2">
|
||||
<p className="text-sm font-medium">{taskTitle}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{rootSubtaskCount} task{rootSubtaskCount === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{waveCount} wave{waveCount === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
ID: {taskId.slice(0, 8)}…
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The Product Owner and Head of Marketing are reviewing this MegaTask.
|
||||
Nothing is dispatched yet. Once they finish, open the umbrella task
|
||||
and use <span className="font-medium">Approve & Start</span> to
|
||||
release the sequenced tasks. You can leave and come back.
|
||||
</p>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="gap-2 pt-0">
|
||||
<Button variant="outline" size="sm" asChild className="flex-1">
|
||||
<Link
|
||||
href={`/tasks/${taskId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
|
||||
View umbrella task
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="sm" className="flex-1" onClick={onStartAnother}>
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
Start another
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,7 @@ function draftToText(draft: DraftProposal): string {
|
||||
"",
|
||||
);
|
||||
}
|
||||
if (draft.the_work?.length) {
|
||||
if (Array.isArray(draft.the_work) && draft.the_work.length) {
|
||||
lines.push("## The Work");
|
||||
for (const cell of draft.the_work) {
|
||||
lines.push(`### ${cellLabel(cell.team)}`, cell.summary);
|
||||
@@ -73,7 +73,7 @@ export function DraftProposalCard({
|
||||
isLaunching = false,
|
||||
}: DraftProposalCardProps) {
|
||||
const priorityLabel = PRIORITY_LABELS[draft.priority ?? 2] ?? "Medium";
|
||||
const cells = draft.the_work ?? [];
|
||||
const cells = Array.isArray(draft.the_work) ? draft.the_work : [];
|
||||
// Distinct cells only: the_work has one entry per work item, so a cell with
|
||||
// several items would otherwise show its badge repeated (Backend Backend …).
|
||||
const distinctTeams = Array.from(new Set(cells.map((c) => c.team)));
|
||||
|
||||
@@ -3,4 +3,5 @@ export { ChatComposer } from "./chat-composer";
|
||||
export { DraftProposalCard } from "./draft-proposal-card";
|
||||
export { BatchReviewCard } from "./batch-review-card";
|
||||
export { SuccessCard } from "./success-card";
|
||||
export { BoardReviewSentCard } from "./board-review-sent-card";
|
||||
export { IntakeForm } from "./intake-form";
|
||||
|
||||
@@ -544,9 +544,9 @@ export function TaskTable({
|
||||
)}
|
||||
onClick={handleRowClick}
|
||||
>
|
||||
<TableCell>
|
||||
<TableCell className="max-w-[22rem]">
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
className="flex items-center gap-1 min-w-0"
|
||||
style={{ paddingLeft: `${node.depth * 1.5}rem` }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
@@ -569,8 +569,10 @@ export function TaskTable({
|
||||
href={"/tasks/" + task.id}
|
||||
className="block hover:underline min-w-0"
|
||||
>
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
<span className="truncate">{task.title}</span>
|
||||
<div className="font-medium flex items-center gap-2 min-w-0">
|
||||
<span className="truncate" title={task.title}>
|
||||
{task.title}
|
||||
</span>
|
||||
{task.batch_id && !task.parent_task_id && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CellWork, DraftProposal } from "@/lib/api/prompter";
|
||||
import { Team } from "@/types";
|
||||
import {
|
||||
fillBatchProjects,
|
||||
rebuildCellWork,
|
||||
type BatchProposal,
|
||||
} from "@/hooks/use-prompter";
|
||||
|
||||
const BE = "be-repo";
|
||||
const FE = "fe-repo";
|
||||
const BE2 = "be-core";
|
||||
|
||||
function proj(id: string, cell: Team) {
|
||||
return { id, name: id, assigned_cell: cell };
|
||||
}
|
||||
|
||||
function draft(partial: Partial<DraftProposal>): DraftProposal {
|
||||
return {
|
||||
title: "T",
|
||||
description: "objective objective objective",
|
||||
acceptance_criteria: ["a"],
|
||||
team: Team.BACKEND,
|
||||
...partial,
|
||||
} as DraftProposal;
|
||||
}
|
||||
|
||||
function mk(drafts: DraftProposal[]): BatchProposal {
|
||||
return { title: "batch", drafts, dropped: 0 };
|
||||
}
|
||||
|
||||
describe("fillBatchProjects", () => {
|
||||
it("fills a single-cell draft from the top-level project_id when it matches the cell", () => {
|
||||
const batch = mk([
|
||||
draft({ the_work: [{ team: Team.BACKEND, summary: "s", items: [] }] }),
|
||||
]);
|
||||
const out = fillBatchProjects(batch, [BE], [proj(BE, Team.BACKEND)]);
|
||||
expect(out.drafts[0].the_work![0].project_id).toBe(BE);
|
||||
});
|
||||
|
||||
it("falls back to the single scoped repo for the cell when no project_id is set", () => {
|
||||
const batch = mk([
|
||||
draft({ the_work: [{ team: Team.FRONTEND, summary: "s", items: [] }] }),
|
||||
]);
|
||||
const out = fillBatchProjects(
|
||||
batch,
|
||||
[BE, FE],
|
||||
[proj(BE, Team.BACKEND), proj(FE, Team.FRONTEND)],
|
||||
);
|
||||
expect(out.drafts[0].the_work![0].project_id).toBe(FE);
|
||||
});
|
||||
|
||||
it("leaves a cell empty when 2+ scoped repos belong to that cell (real ambiguity)", () => {
|
||||
const batch = mk([
|
||||
draft({ the_work: [{ team: Team.BACKEND, summary: "s", items: [] }] }),
|
||||
]);
|
||||
const out = fillBatchProjects(
|
||||
batch,
|
||||
[BE, BE2],
|
||||
[proj(BE, Team.BACKEND), proj(BE2, Team.BACKEND)],
|
||||
);
|
||||
expect(out.drafts[0].the_work![0].project_id).toBeFalsy();
|
||||
});
|
||||
|
||||
it("does not overwrite an explicit per-cell project_id", () => {
|
||||
const batch = mk([
|
||||
draft({
|
||||
the_work: [
|
||||
{ team: Team.BACKEND, summary: "s", items: [], project_id: BE2 },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const out = fillBatchProjects(
|
||||
batch,
|
||||
[BE, BE2],
|
||||
[proj(BE, Team.BACKEND), proj(BE2, Team.BACKEND)],
|
||||
);
|
||||
expect(out.drafts[0].the_work![0].project_id).toBe(BE2);
|
||||
});
|
||||
|
||||
it("does not trust a top-level project_id whose cell mismatches the work entry", () => {
|
||||
const batch = mk([
|
||||
draft({
|
||||
project_id: FE, // frontend repo, but the work says backend
|
||||
the_work: [{ team: Team.BACKEND, summary: "s", items: [] }],
|
||||
}),
|
||||
]);
|
||||
const out = fillBatchProjects(
|
||||
batch,
|
||||
[FE, BE],
|
||||
[proj(FE, Team.FRONTEND), proj(BE, Team.BACKEND)],
|
||||
);
|
||||
expect(out.drafts[0].the_work![0].project_id).toBe(BE);
|
||||
});
|
||||
|
||||
it("is idempotent (returns the same batch reference on a second pass)", () => {
|
||||
const batch = mk([
|
||||
draft({ the_work: [{ team: Team.BACKEND, summary: "s", items: [] }] }),
|
||||
]);
|
||||
const once = fillBatchProjects(batch, [BE], [proj(BE, Team.BACKEND)]);
|
||||
const twice = fillBatchProjects(once, [BE], [proj(BE, Team.BACKEND)]);
|
||||
expect(twice).toBe(once);
|
||||
});
|
||||
|
||||
it("legacy draft (no the_work) keeps a scoped top-level project_id as-is", () => {
|
||||
const batch = mk([draft({ the_work: [], project_id: BE })]);
|
||||
const out = fillBatchProjects(batch, [BE], [proj(BE, Team.BACKEND)]);
|
||||
expect(out).toBe(batch); // nothing to fill → same reference
|
||||
});
|
||||
});
|
||||
|
||||
describe("rebuildCellWork", () => {
|
||||
// The multi-select review card: the human picks a SET of repos for a task
|
||||
// (one repo per delivery cell). rebuildCellWork turns that set into the
|
||||
// the_work[] cell map the backend stores (task_cell_projects is unique per
|
||||
// (task, team) — one repo per cell).
|
||||
|
||||
const projects = [
|
||||
proj(BE, Team.BACKEND),
|
||||
proj(BE2, Team.BACKEND),
|
||||
proj(FE, Team.FRONTEND),
|
||||
proj("ux-repo", Team.UX_UI),
|
||||
];
|
||||
|
||||
it("maps one selected repo per cell and clears the top-level project_id", () => {
|
||||
const out = rebuildCellWork([BE, FE], projects, []);
|
||||
expect(out.project_id).toBeNull();
|
||||
const teams = out.the_work.map((w) => w.team);
|
||||
expect(teams).toEqual([Team.BACKEND, Team.FRONTEND]);
|
||||
expect(out.the_work[0].project_id).toBe(BE);
|
||||
expect(out.the_work[1].project_id).toBe(FE);
|
||||
});
|
||||
|
||||
it("keeps only the first selected repo when two are picked for the same cell (one repo per cell)", () => {
|
||||
const out = rebuildCellWork([BE, BE2], projects, []);
|
||||
expect(out.the_work).toHaveLength(1);
|
||||
expect(out.the_work[0].team).toBe(Team.BACKEND);
|
||||
expect(out.the_work[0].project_id).toBe(BE); // first wins
|
||||
});
|
||||
|
||||
it("preserves an existing cell entry's summary/items when the cell stays selected", () => {
|
||||
const current: CellWork[] = [
|
||||
{
|
||||
team: Team.BACKEND,
|
||||
summary: "build API",
|
||||
items: ["x", "y"],
|
||||
project_id: BE2,
|
||||
},
|
||||
];
|
||||
const out = rebuildCellWork([BE], projects, current);
|
||||
expect(out.the_work).toHaveLength(1);
|
||||
expect(out.the_work[0].summary).toBe("build API");
|
||||
expect(out.the_work[0].items).toEqual(["x", "y"]);
|
||||
expect(out.the_work[0].project_id).toBe(BE); // repo swapped, summary kept
|
||||
});
|
||||
|
||||
it("drops a cell entry when its cell is no longer selected", () => {
|
||||
const current: CellWork[] = [
|
||||
{ team: Team.BACKEND, summary: "s", items: [] },
|
||||
{ team: Team.FRONTEND, summary: "s", items: [] },
|
||||
];
|
||||
const out = rebuildCellWork([FE], projects, current);
|
||||
expect(out.the_work.map((w) => w.team)).toEqual([Team.FRONTEND]);
|
||||
});
|
||||
|
||||
it("appends a minimal entry for a newly-selected cell not in the_work", () => {
|
||||
const out = rebuildCellWork([FE, "ux-repo"], projects, [
|
||||
{ team: Team.BACKEND, summary: "s", items: [] },
|
||||
]);
|
||||
const teams = out.the_work.map((w) => w.team);
|
||||
expect(teams).toEqual([Team.FRONTEND, Team.UX_UI]); // backend dropped, fe+ux added
|
||||
expect(out.the_work[0]).toMatchObject({
|
||||
team: Team.FRONTEND,
|
||||
summary: "",
|
||||
items: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("empty selection empties the_work (the task then has no project → blocked)", () => {
|
||||
const out = rebuildCellWork([], projects, [
|
||||
{ team: Team.BACKEND, summary: "s", items: [] },
|
||||
]);
|
||||
expect(out.the_work).toEqual([]);
|
||||
expect(out.project_id).toBeNull();
|
||||
});
|
||||
});
|
||||
+205
-24
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/lib/api/prompter";
|
||||
import { getErrorMessage } from "@/lib/api/client";
|
||||
import { tasksApi } from "@/lib/api/tasks";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import { Team } from "@/types";
|
||||
import type { TaskType, TaskNature, Complexity } from "@/types";
|
||||
|
||||
@@ -68,7 +69,8 @@ const CELL_TEAMS: Team[] = [Team.BACKEND, Team.FRONTEND, Team.UX_UI];
|
||||
* is a delivery cell and that carries a project_id. Empty for a legacy
|
||||
* single-cell draft that uses a top-level project_id instead. */
|
||||
function draftCellProjectIds(draft: DraftProposal): string[] {
|
||||
return (draft.the_work ?? [])
|
||||
const work = Array.isArray(draft.the_work) ? draft.the_work : [];
|
||||
return work
|
||||
.filter(
|
||||
(w): w is CellWork & { project_id: string } =>
|
||||
!!w?.team &&
|
||||
@@ -173,6 +175,17 @@ function draftFromEvent(data: Record<string, unknown> | undefined): {
|
||||
return { draft: d as unknown as DraftProposal, scale };
|
||||
}
|
||||
|
||||
/** Coerce a value into a `CellWork[]`: a bare object → one-element array, a
|
||||
* non-array non-object → empty. The backend coerces `the_work` before it
|
||||
* reaches SSE, but a stale localStorage payload or a malformed frame could
|
||||
* still carry a non-array, and the batch card does `the_work.map(...)` — so
|
||||
* normalize here rather than crash. */
|
||||
function asCellWork(value: unknown): CellWork[] {
|
||||
if (Array.isArray(value)) return value as CellWork[];
|
||||
if (value && typeof value === "object") return [value as CellWork];
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Pull a MegaTask ({title, drafts[]}) out of a `batch` SSE event's payload. */
|
||||
function batchFromEvent(
|
||||
data: Record<string, unknown> | undefined,
|
||||
@@ -180,10 +193,15 @@ function batchFromEvent(
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const raw = (data as Record<string, unknown>).drafts;
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const drafts = raw.filter(
|
||||
(x): x is DraftProposal =>
|
||||
!!x && typeof (x as DraftProposal).title === "string",
|
||||
);
|
||||
const drafts = raw
|
||||
.filter(
|
||||
(x): x is Record<string, unknown> =>
|
||||
!!x && typeof (x as DraftProposal).title === "string",
|
||||
)
|
||||
.map((d) => ({
|
||||
...(d as unknown as DraftProposal),
|
||||
the_work: asCellWork((d as Record<string, unknown>).the_work),
|
||||
}));
|
||||
if (drafts.length === 0) return null;
|
||||
const title = (data as Record<string, unknown>).title;
|
||||
// Prefer the backend's dropped count; else compute from what we filtered, so a
|
||||
@@ -196,6 +214,128 @@ function batchFromEvent(
|
||||
return { title: typeof title === "string" ? title : "", drafts, dropped };
|
||||
}
|
||||
|
||||
/** Rebuild a draft's ``the_work`` from the set of projects the human selected
|
||||
* for it (the multi-select review card: one task can span several repos, one
|
||||
* repo per delivery cell — the backend's ``task_cell_projects`` is unique per
|
||||
* ``(task, team)``). Existing cell entries keep their summary/items; a newly-
|
||||
* selected cell gets a minimal entry; a deselected cell's entry is dropped
|
||||
* (that cell no longer participates). The top-level ``project_id`` is cleared
|
||||
* — a multi-repo task targets via its cell map, not a top-level repo. Pure, so
|
||||
* ``setBatchDraftProjects`` can stay a thin setState wrapper and this is
|
||||
* unit-tested directly. */
|
||||
export function rebuildCellWork(
|
||||
ids: string[],
|
||||
allProjects: { id: string; assigned_cell?: Team | "" }[],
|
||||
currentWork: CellWork[],
|
||||
): { the_work: CellWork[]; project_id: null } {
|
||||
const cellToPid = new Map<Team, string>();
|
||||
for (const id of ids) {
|
||||
const proj = allProjects.find((p) => p.id === id);
|
||||
const cell = proj?.assigned_cell;
|
||||
if (cell && !cellToPid.has(cell)) cellToPid.set(cell, id);
|
||||
}
|
||||
const covered = new Set<Team>();
|
||||
// Update existing cell entries in place, preserve summary/items.
|
||||
const updated = currentWork.map((w) => {
|
||||
const team = w?.team;
|
||||
if (team && cellToPid.has(team)) {
|
||||
covered.add(team);
|
||||
return { ...w, project_id: cellToPid.get(team)! };
|
||||
}
|
||||
return w;
|
||||
});
|
||||
// Append a minimal entry for a newly-selected cell not already in the_work.
|
||||
const appended: CellWork[] = [];
|
||||
for (const [team, pid] of cellToPid) {
|
||||
if (!covered.has(team)) {
|
||||
appended.push({ team, summary: "", items: [], project_id: pid });
|
||||
}
|
||||
}
|
||||
// Drop entries for cells no longer selected.
|
||||
const the_work = [...updated, ...appended].filter((w) => {
|
||||
const team = w?.team;
|
||||
return team && cellToPid.has(team);
|
||||
});
|
||||
return { the_work, project_id: null };
|
||||
}
|
||||
|
||||
/** Fill each draft's missing project assignment from the MegaTask's scoped
|
||||
* repos, so the review card is pre-filled instead of forcing the human to
|
||||
* re-pick the projects they already scoped at intake. The intake prompt tells
|
||||
* the agent to set a top-level ``project_id`` (each task lives in one repo),
|
||||
* and the backend's scope validator falls back to it — so this mirrors that:
|
||||
* an explicit per-cell ``the_work[].project_id`` wins; else the draft's
|
||||
* top-level ``project_id`` (when scoped); else the single scoped repo that
|
||||
* belongs to this cell, when unambiguous. An empty field stays empty (real
|
||||
* ambiguity — 2+ scoped repos for the cell — the human picks). Returns the
|
||||
* same batch reference when nothing changed (idempotent, no render loop). */
|
||||
export function fillBatchProjects(
|
||||
batch: BatchProposal,
|
||||
projectIds: string[],
|
||||
allProjects: { id: string; assigned_cell?: Team | "" }[],
|
||||
): BatchProposal {
|
||||
const scoped = new Set(projectIds);
|
||||
const scopedProjects = allProjects.filter((p) => scoped.has(p.id));
|
||||
const byCell = (team: Team) =>
|
||||
scopedProjects.filter((p) => p.assigned_cell === team);
|
||||
const isCell = (team: unknown): team is Team =>
|
||||
typeof team === "string" &&
|
||||
(CELL_TEAMS as readonly string[]).includes(team as Team);
|
||||
|
||||
let changed = false;
|
||||
const drafts = batch.drafts.map((d) => {
|
||||
const work = Array.isArray(d.the_work) ? d.the_work : [];
|
||||
const cellWork = work.filter((w) => isCell(w?.team));
|
||||
|
||||
// Draft with at least one the_work cell entry: fill each cell's project_id.
|
||||
if (cellWork.length > 0) {
|
||||
let workChanged = false;
|
||||
const newWork = work.map((w) => {
|
||||
if (
|
||||
!w ||
|
||||
!isCell(w.team) ||
|
||||
(w.project_id && scoped.has(w.project_id))
|
||||
) {
|
||||
return w;
|
||||
}
|
||||
// (1) the draft's top-level project_id (the agent's main assignment),
|
||||
// but only when that repo actually belongs to this cell (a single-cell
|
||||
// draft's repo should match its one cell; a mismatch is an agent error
|
||||
// — fall through to auto-assign rather than land the wrong repo).
|
||||
const top = d.project_id;
|
||||
if (top && scoped.has(top)) {
|
||||
const proj = scopedProjects.find((p) => p.id === top);
|
||||
if (proj && proj.assigned_cell === w.team) {
|
||||
workChanged = true;
|
||||
return { ...w, project_id: top };
|
||||
}
|
||||
}
|
||||
// (2) exactly one scoped repo belongs to this cell.
|
||||
const matching = byCell(w.team);
|
||||
if (matching.length === 1) {
|
||||
workChanged = true;
|
||||
return { ...w, project_id: matching[0].id };
|
||||
}
|
||||
return w;
|
||||
});
|
||||
if (!workChanged) return d;
|
||||
changed = true;
|
||||
return { ...d, the_work: newWork };
|
||||
}
|
||||
|
||||
// Legacy draft (no cell the_work): one top-level project_id. Fill it only
|
||||
// when exactly one scoped repo exists (truly unambiguous).
|
||||
if (d.project_id && scoped.has(d.project_id)) return d;
|
||||
if (scopedProjects.length === 1) {
|
||||
changed = true;
|
||||
return { ...d, project_id: scopedProjects[0].id };
|
||||
}
|
||||
return d;
|
||||
});
|
||||
|
||||
return changed ? { ...batch, drafts } : batch;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Refresh durability
|
||||
//
|
||||
@@ -271,6 +411,10 @@ export function usePrompter() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
// Every project (carries assigned_cell), for pre-filling a MegaTask batch's
|
||||
// per-cell project_ids from the scoped repos (useProjects dedups with the
|
||||
// batch card's own query by key).
|
||||
const { data: allProjects = [] } = useProjects();
|
||||
const [isLaunching, setIsLaunching] = useState(false);
|
||||
/** The latest tool the agent is using — "watch it work" status line. */
|
||||
const [activity, setActivity] = useState<string | null>(null);
|
||||
@@ -366,7 +510,15 @@ export function usePrompter() {
|
||||
if (evt.text) {
|
||||
setActivity(null); // first text clears the "preparing…" indicator
|
||||
appendDelta(evt.text);
|
||||
setState("streaming");
|
||||
// Trailing prose AFTER a draft/batch tool call (the agent keeps
|
||||
// talking once the card is up) must still render in the bubble —
|
||||
// but it must NOT clobber the preview state, or the card vanishes
|
||||
// mid-turn and turn_end can't restore it (it only preserves these
|
||||
// states if they're still set). Keep the card up; the delta lands
|
||||
// in a fresh bubble (the batch/draft handler cleared streamingId).
|
||||
setState((s) =>
|
||||
s === "draft_preview" || s === "batch_preview" ? s : "streaming",
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "tool_use":
|
||||
@@ -508,6 +660,17 @@ export function usePrompter() {
|
||||
}
|
||||
}, [sessionId, messages, state, editableDraft, batch, batchWaves]);
|
||||
|
||||
// Pre-fill each MegaTask draft's project from the scoped repos (the agent
|
||||
// sets a top-level project_id; the backend falls back to it). Without this
|
||||
// the review card shows empty Selects and blocks launch, forcing the human
|
||||
// to re-pick the very projects they scoped at intake. Idempotent: once every
|
||||
// cell has a project_id it returns the same batch reference (no loop).
|
||||
useEffect(() => {
|
||||
if (!batch) return;
|
||||
const filled = fillBatchProjects(batch, projectIds, allProjects);
|
||||
if (filled !== batch) setBatch(filled);
|
||||
}, [batch, projectIds, allProjects]);
|
||||
|
||||
// On mount, reconnect to a still-running session left behind by a reload.
|
||||
const didRestoreRef = useRef(false);
|
||||
useEffect(() => {
|
||||
@@ -804,29 +967,33 @@ export function usePrompter() {
|
||||
// Confirm a MegaTask — create the umbrella + sequenced root-subtasks, reap
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Reassign one cell of one task in the proposed MegaTask to a different
|
||||
* project. `entryIndex` is the the_work slot (the cell); pass -1 for a legacy
|
||||
* single-cell draft that has no per-cell map (sets the top-level project_id).
|
||||
* Project does not affect the wave plan (waves derive from collision surface),
|
||||
* so the previewed waves stay valid. */
|
||||
const updateBatchDraftProject = useCallback(
|
||||
(index: number, entryIndex: number, projectId: string) => {
|
||||
/** Set the projects one MegaTask task targets. The review card exposes a
|
||||
* multi-select checkbox list per task (one task can span several repos), so
|
||||
* the human picks the whole set at once instead of one dropdown per cell.
|
||||
* A RoboCo project is per-cell, so the selection maps to one project per
|
||||
* cell in ``the_work[]`` (the backend's ``task_cell_projects`` is unique per
|
||||
* ``(task, team)`` — one repo per cell). Existing entries keep their
|
||||
* summary/items; a newly-selected cell gets a minimal entry; a deselected
|
||||
* cell's entry is dropped (that cell no longer participates). The top-level
|
||||
* ``project_id`` is cleared — a multi-repo task targets via its cell map.
|
||||
* Project choice does not affect the wave plan (waves derive from collision
|
||||
* surface), so the previewed waves stay valid. */
|
||||
const setBatchDraftProjects = useCallback(
|
||||
(index: number, ids: string[]) => {
|
||||
setBatch((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
drafts: prev.drafts.map((d, i) => {
|
||||
if (i !== index) return d;
|
||||
if (entryIndex < 0) return { ...d, project_id: projectId };
|
||||
const the_work = (d.the_work ?? []).map((w, wi) =>
|
||||
wi === entryIndex ? { ...w, project_id: projectId } : w,
|
||||
);
|
||||
return { ...d, the_work };
|
||||
const work = Array.isArray(d.the_work) ? d.the_work : [];
|
||||
const rebuilt = rebuildCellWork(ids, allProjects, work);
|
||||
return { ...d, the_work: rebuilt.the_work, project_id: null };
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
[allProjects],
|
||||
);
|
||||
|
||||
const confirmBatch = useCallback(
|
||||
@@ -891,10 +1058,24 @@ export function usePrompter() {
|
||||
setCreatedTaskId(result.umbrella_task_id);
|
||||
setCreatedTaskTitle(batch.title.trim() || "MegaTask");
|
||||
setCreatedTaskTeam(route === "board" ? Team.BOARD : Team.MAIN_PM);
|
||||
toast.success(
|
||||
`MegaTask launched — ${result.root_subtask_ids.length} tasks in ` +
|
||||
`${result.waves.length} wave${result.waves.length === 1 ? "" : "s"}.`,
|
||||
);
|
||||
if (route === "board") {
|
||||
// Board route: the umbrella + root-subtasks are created HELD for the
|
||||
// PO + HoM to review — nothing is dispatched yet. The CEO releases the
|
||||
// sequenced tasks with Approve & Start on the umbrella task once the
|
||||
// board finishes (the existing CEO gate, not this chat). Say so, so
|
||||
// "Board review & Start" doesn't read as "launched" the way the
|
||||
// Main-PM route does.
|
||||
toast.success(
|
||||
"Sent to the Board for review — the Product Owner and Head of " +
|
||||
"Marketing will review this MegaTask. Approve & Start it from the " +
|
||||
"umbrella task once they're done.",
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
`MegaTask launched — ${result.root_subtask_ids.length} tasks in ` +
|
||||
`${result.waves.length} wave${result.waves.length === 1 ? "" : "s"}.`,
|
||||
);
|
||||
}
|
||||
setState("success");
|
||||
} catch (err) {
|
||||
toast.error(`Failed to launch MegaTask: ${getErrorMessage(err)}`);
|
||||
@@ -978,7 +1159,7 @@ export function usePrompter() {
|
||||
batch,
|
||||
batchWaves,
|
||||
batchResult,
|
||||
updateBatchDraftProject,
|
||||
setBatchDraftProjects,
|
||||
confirmBatch,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user