From c7431fef933e2eb9a3a3be0bf72a2c43dcc74252 Mon Sep 17 00:00:00 2001 From: guitavano Date: Tue, 11 Aug 2026 19:49:44 -0300 Subject: [PATCH] feat(task-board): task-based flow behind `taskBasedFlow` org flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operate a repo-bound site entirely in terms of Tasks, hiding Git (branches/PRs/sync). All gated behind the org flag `taskBasedFlow` (off by default), toggleable in Settings → Organization → Experimental. Web - Task pill replaces the branch picker in the workspace header, with the task board as source of truth: names the current task, filters by column, switches tasks, and creates new ones (describe → agent, or edit manually → fresh CMS branch). Each task gets its own branch. - New-task dialog (describe / edit manually); manual edit opens a fresh branch straight in the CMS (chat closed, `?cms=1` auto-opens the editor). - Hides Git surfaces under the flag: branch picker, Sync, open-in-board. - Repo selector in the task dialog properties. API - `task_board_items` gains `repo_owner`/`repo_name` (migration 169) so a task is scoped to its site; threaded through storage, create/update tools, schemas. - Repo-backed tasks advance to In Review only via the PR-open hook, never on thread-finish (In Review with no PR is the wrong state) — keyed on the task's `repoOwner` (CMS threads carry no repo metadata). - `TASK_BOARD_ITEM_UPDATE` gains `linkPr` (mirrors `linkThreadId`) so the CMS submit-for-review flow — which opens PRs outside a run — links the PR and advances the task to In Review. Tests: unit for the review-advance gate + repo helpers; real-Postgres for repo round-trip, repo-named no-advance-on-finish, and PR link+advance. Note: `tool-io.ts` was hand-patched (contract generator is degraded in this env) — re-run `generate:tool-contracts` in a full env to confirm. Co-Authored-By: Claude Opus 4.8 --- .../migrations/169-task-board-item-repo.ts | 22 ++ apps/api/migrations/index.ts | 2 + ...k-board-advance-review.integration.test.ts | 50 +++ apps/api/src/storage/task-board.test.ts | 30 +- apps/api/src/storage/task-board.ts | 30 +- apps/api/src/storage/types.ts | 6 + apps/api/src/tools/task-board/create.ts | 4 + .../reports-task-guards.integration.test.ts | 61 ++++ apps/api/src/tools/task-board/schema.ts | 3 + .../tools/task-board/stall-recovery.test.ts | 10 +- apps/api/src/tools/task-board/update.test.ts | 2 + apps/api/src/tools/task-board/update.ts | 35 ++ .../components/chat/pills/new-task-dialog.tsx | 91 +++++ .../src/components/chat/pills/task-pill.tsx | 339 ++++++++++++++++++ .../components/sandbox/preview/preview.tsx | 12 +- .../settings/experimental-settings.tsx | 30 ++ .../components/settings/review-settings.tsx | 2 +- .../thread/github/header-actions.tsx | 29 +- .../thread/github/publish-dialog.tsx | 6 + .../thread/open-in-board-button.tsx | 5 +- .../src/hooks/use-organization-settings.ts | 9 + apps/web/src/i18n/en/chat.ts | 11 + apps/web/src/i18n/en/settings.ts | 6 + apps/web/src/i18n/en/task-board.ts | 2 + apps/web/src/i18n/pt-br/chat.ts | 11 + apps/web/src/i18n/pt-br/settings.ts | 6 + apps/web/src/i18n/pt-br/task-board.ts | 2 + .../workspace-panel-group.tsx | 18 +- .../web/src/layouts/task-board/config.test.ts | 2 + .../src/layouts/task-board/task-dialog.tsx | 74 ++++ apps/web/src/router.tsx | 3 + apps/web/src/views/settings/org-general.tsx | 2 + packages/shared/src/entities.ts | 3 + packages/shared/src/organization/schema.ts | 6 + packages/shared/src/tools/tool-io.ts | 22 ++ 35 files changed, 932 insertions(+), 14 deletions(-) create mode 100644 apps/api/migrations/169-task-board-item-repo.ts create mode 100644 apps/web/src/components/chat/pills/new-task-dialog.tsx create mode 100644 apps/web/src/components/chat/pills/task-pill.tsx create mode 100644 apps/web/src/components/settings/experimental-settings.tsx diff --git a/apps/api/migrations/169-task-board-item-repo.ts b/apps/api/migrations/169-task-board-item-repo.ts new file mode 100644 index 0000000000..3310c8b7b5 --- /dev/null +++ b/apps/api/migrations/169-task-board-item-repo.ts @@ -0,0 +1,22 @@ +import { type Kysely } from "kysely"; + +/** + * Task board items can pertain to a specific repo (site), so the task-based + * flow can scope a site's tasks to it. Nullable — org-wide tasks (no site + * context) carry neither. + */ +export async function up(db: Kysely): Promise { + await db.schema + .alterTable("task_board_items") + .addColumn("repo_owner", "text") + .addColumn("repo_name", "text") + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable("task_board_items") + .dropColumn("repo_owner") + .dropColumn("repo_name") + .execute(); +} diff --git a/apps/api/migrations/index.ts b/apps/api/migrations/index.ts index ec90c04a1a..1d046a71b6 100644 --- a/apps/api/migrations/index.ts +++ b/apps/api/migrations/index.ts @@ -167,6 +167,7 @@ import * as migration165taskboardpendingreviewindex from "./165-task-board-pendi import * as migration166taskboardlastsweptat from "./166-task-board-last-swept-at.ts"; import * as migration167taskboardrunretry from "./167-task-board-run-retry.ts"; import * as migration168orgreposync from "./168-org-repo-sync.ts"; +import * as migration169taskboarditemrepo from "./169-task-board-item-repo.ts"; /** * Core migrations for the Studio application. @@ -362,6 +363,7 @@ const migrations: Record = { "166-task-board-last-swept-at": migration166taskboardlastsweptat, "167-task-board-run-retry": migration167taskboardrunretry, "168-org-repo-sync": migration168orgreposync, + "169-task-board-item-repo": migration169taskboarditemrepo, }; export default migrations; diff --git a/apps/api/src/storage/task-board-advance-review.integration.test.ts b/apps/api/src/storage/task-board-advance-review.integration.test.ts index ebbb05c047..bdf7f30e3b 100644 --- a/apps/api/src/storage/task-board-advance-review.integration.test.ts +++ b/apps/api/src/storage/task-board-advance-review.integration.test.ts @@ -172,6 +172,56 @@ describe("advanceToReviewIfInProgress (real Postgres)", () => { expect(results.filter((r) => r !== null)).toHaveLength(1); }); + + // Repo-backed work advances to review ONLY via the PR-open hook. A finished + // run that opened no PR has nothing to review yet — In Review with no PR is + // the wrong state — so on thread-finish it must stay In Progress until the + // user submits (which opens the PR). + // + // This is the CMS/site case: the repo lives on the AGENT, so the thread's own + // `metadata` is EMPTY (hasPreview false) and only the TASK's `repoOwner` marks + // it repo-backed. The hasPreview-only check missed exactly this and advanced + // the card with no PR. + it("does NOT advance a repo-NAMED task on thread-finish — it waits for the PR", async () => { + const task = await taskBoard.create({ + organizationId: ORG, + title: "repo-named, no PR", + status: "in_progress", + repoOwner: "deco-sites", + repoName: "casaevideo", + by: USER, + }); + const thread = await threads.create({ + organization_id: ORG, + title: "Site Agent: run", + status: "completed", + message_storage_version: 2, + created_by: USER, + // No githubRepo on the thread — the repo lives on the agent, so + // hasPreview is false. The task's repoOwner is the repo-backed signal. + }); + await taskBoard.linkThread(task.id, thread.id, ORG); + await database.db + .insertInto("thread_message_parts") + .values({ + id: `${thread.id}:m:0`, + seq: 0, + org_id: ORG, + thread_id: thread.id, + run_id: thread.id, + message_id: `${thread.id}:m`, + role: "user", + kind: "text", + payload: JSON.stringify({ type: "text", text: "go" }), + created_at: new Date().toISOString(), + }) + .execute(); + + await taskBoard.advanceLinkedTasksToReviewOnThreadFinish(thread.id, ORG); + + expect((await taskBoard.getById(task.id, ORG))?.status).toBe("in_progress"); + expect(await inReviewStamps(task.id)).toBe(0); + }); }); /** diff --git a/apps/api/src/storage/task-board.test.ts b/apps/api/src/storage/task-board.test.ts index a86434b4a3..929f9d0489 100644 --- a/apps/api/src/storage/task-board.test.ts +++ b/apps/api/src/storage/task-board.test.ts @@ -1,13 +1,16 @@ import { describe, expect, it } from "bun:test"; import { shouldAdvanceToReview } from "./task-board"; -/** A thread that was actually used — the default for these cases. */ +/** A thread that was actually used — the default for these cases. + * `hasPreview` = repo-backed (a clonable repo bound); defaults to repo-less. */ const thread = ( status: string | null, hasMessages = true, -): { status: string | null; hasMessages: boolean } => ({ + hasPreview = false, +): { status: string | null; hasMessages: boolean; hasPreview: boolean } => ({ status, hasMessages, + hasPreview, }); /** Created and never typed in: born `completed`, must not count. */ @@ -73,13 +76,32 @@ describe("shouldAdvanceToReview", () => { ).toBe(false); }); - it("advances a repo-backed task on thread-finish too (backstop for missed PR detection)", () => { + // Inverted deliberately: this used to advance a repo-backed task on + // thread-finish as a backstop for missed PR detection. But In Review means + // "there is a PR to review" — a finished run that opened no PR has nothing to + // review yet. Repo-backed work now advances ONLY via the PR-open hook; on + // finish it stays In Progress until the user submits (which opens the PR). + it("does NOT advance a repo-backed task on thread-finish (waits for PR-open)", () => { expect( shouldAdvanceToReview({ status: "in_progress", + threads: [thread("completed", true, true)], + }), + ).toBe(false); + }); + + // The CMS/site flow: the repo lives on the AGENT, so the thread's own metadata + // is empty (hasPreview false) — but the TASK names a repo (`repoOwner`). Still + // repo-backed work: wait for the PR, never advance on finish. This is the case + // the hasPreview-only check missed. + it("does NOT advance a repo-NAMED task on finish, even with empty thread metadata", () => { + expect( + shouldAdvanceToReview({ + status: "in_progress", + repoOwner: "deco-sites", threads: [thread("completed")], }), - ).toBe(true); + ).toBe(false); }); it("only fires from in_progress, not from other lanes", () => { diff --git a/apps/api/src/storage/task-board.ts b/apps/api/src/storage/task-board.ts index 698c95a37b..68819da3c0 100644 --- a/apps/api/src/storage/task-board.ts +++ b/apps/api/src/storage/task-board.ts @@ -102,11 +102,27 @@ export const TERMINAL_THREAD_STATUSES = new Set([ */ export function shouldAdvanceToReview(item: { status: TaskBoardItemStatus; - threads: { status: string | null; hasMessages: boolean }[]; + repoOwner?: string | null; + threads: { + status: string | null; + hasMessages: boolean; + hasPreview: boolean; + }[]; }): boolean { if (item.status !== "in_progress") return false; const used = item.threads.filter((t) => t.hasMessages); if (used.length === 0) return false; + // Repo-backed work advances to review ONLY when a PR opens (the PR-open hook), + // never on thread-finish: a finished run that opened no PR has nothing to + // review yet — the user still submits/publishes, which is what opens the PR. + // (In Review with no PR is exactly the wrong state.) + // + // A task is repo-backed if it NAMES a repo (`repoOwner`, set by the CMS/site + // flow — the repo lives on the agent, so the thread's own metadata is empty + // and `hasPreview` is false) OR a linked thread carries a clonable repo + // (`hasPreview` — e.g. the Super Agent's `load_repo`). Only genuinely + // repo-less work — no PR ever possible — advances on finish. + if (item.repoOwner != null || used.some((t) => t.hasPreview)) return false; if ( !used.every( (t) => t.status !== null && TERMINAL_THREAD_STATUSES.has(t.status), @@ -203,6 +219,8 @@ export class TaskBoardStorage { priority?: TaskBoardItemPriority; assigneeId?: string | null; assignedBy?: string | null; + repoOwner?: string | null; + repoName?: string | null; dueDate?: string | null; /** Sender-minted finding identity — see task-board-import. */ externalKey?: string | null; @@ -230,6 +248,8 @@ export class TaskBoardStorage { priority: params.priority ?? "medium", assignee_id: params.assigneeId ?? null, assigned_by: params.assignedBy ?? null, + repo_owner: params.repoOwner ?? null, + repo_name: params.repoName ?? null, due_date: params.dueDate ?? null, external_key: params.externalKey ?? null, sort_order: sql`( @@ -260,6 +280,8 @@ export class TaskBoardStorage { priority?: TaskBoardItemPriority; assigneeId?: string | null; assignedBy?: string | null; + repoOwner?: string | null; + repoName?: string | null; dueDate?: string | null; sortOrder?: number; }, @@ -280,6 +302,8 @@ export class TaskBoardStorage { ...(data.assignedBy !== undefined ? { assigned_by: data.assignedBy } : {}), + ...(data.repoOwner !== undefined ? { repo_owner: data.repoOwner } : {}), + ...(data.repoName !== undefined ? { repo_name: data.repoName } : {}), ...(data.dueDate !== undefined ? { due_date: data.dueDate } : {}), ...(data.sortOrder !== undefined ? { sort_order: data.sortOrder } : {}), updated_by: by, @@ -1578,6 +1602,8 @@ export class TaskBoardStorage { priority: string; assignee_id: string | null; assigned_by: string | null; + repo_owner: string | null; + repo_name: string | null; due_date: string | Date | null; sort_order: number; retry_attempts?: number; @@ -1595,6 +1621,8 @@ export class TaskBoardStorage { priority: row.priority as TaskBoardItemPriority, assigneeId: row.assignee_id, assignedBy: row.assigned_by, + repoOwner: row.repo_owner, + repoName: row.repo_name, dueDate: row.due_date instanceof Date ? row.due_date.toISOString() diff --git a/apps/api/src/storage/types.ts b/apps/api/src/storage/types.ts index 57a11524a3..af7c02a7eb 100644 --- a/apps/api/src/storage/types.ts +++ b/apps/api/src/storage/types.ts @@ -1632,6 +1632,8 @@ export interface TaskBoardItemTable { >; assignee_id: string | null; assigned_by: string | null; + repo_owner: string | null; + repo_name: string | null; due_date: ColumnType< Date | null, Date | string | null | undefined, @@ -1802,6 +1804,10 @@ export interface TaskBoardItem { priority: TaskBoardItemPriority; assigneeId: string | null; assignedBy: string | null; + /** Which repo (site) this task pertains to — scopes it to a CMS/site. + * Nullable: tasks created org-wide (no site context) carry neither. */ + repoOwner: string | null; + repoName: string | null; dueDate: string | null; /** Manual drag-to-reorder position within a lane, ascending. */ sortOrder: number; diff --git a/apps/api/src/tools/task-board/create.ts b/apps/api/src/tools/task-board/create.ts index fc481212e6..2a7b500e5c 100644 --- a/apps/api/src/tools/task-board/create.ts +++ b/apps/api/src/tools/task-board/create.ts @@ -28,6 +28,8 @@ export const TASK_BOARD_ITEM_CREATE = defineTool({ status: TaskBoardItemStatusSchema.optional(), priority: TaskBoardItemPrioritySchema.optional(), assigneeId: z.string().nullable().optional(), + repoOwner: z.string().nullable().optional(), + repoName: z.string().nullable().optional(), dueDate: z.string().datetime().nullable().optional(), tagIds: z.array(z.string()).optional(), }), @@ -68,6 +70,8 @@ export const TASK_BOARD_ITEM_CREATE = defineTool({ priority: input.priority, assigneeId: input.assigneeId ?? null, assignedBy: input.assigneeId ? getUserId(ctx)! : null, + repoOwner: input.repoOwner ?? null, + repoName: input.repoName ?? null, dueDate: input.dueDate ?? null, by: getUserId(ctx)!, }); diff --git a/apps/api/src/tools/task-board/reports-task-guards.integration.test.ts b/apps/api/src/tools/task-board/reports-task-guards.integration.test.ts index 1d990863a0..d421f6bb1e 100644 --- a/apps/api/src/tools/task-board/reports-task-guards.integration.test.ts +++ b/apps/api/src/tools/task-board/reports-task-guards.integration.test.ts @@ -125,6 +125,67 @@ describe("reports-task guards", () => { expect(renamed.item.title).toBe("renamed"); }); + it("persists a task's repo through create and update", async () => { + const task = await taskBoard.create({ + organizationId: ORG, + title: "scoped", + repoOwner: "acme", + repoName: "site", + by: USER, + }); + expect(task.repoOwner).toBe("acme"); + expect(task.repoName).toBe("site"); + + // Re-point to a different repo — proves the UPDATE whitelist actually + // carries repo_owner/repo_name (an in-memory fake would accept the column + // but silently drop it), and that it round-trips on re-read. + const moved = await TASK_BOARD_ITEM_UPDATE.handler( + { id: task.id, repoOwner: "acme", repoName: "store" }, + ctx, + ); + expect(moved.item.repoName).toBe("store"); + const reread = await taskBoard.getById(task.id, ORG); + expect(reread?.repoName).toBe("store"); + + // Clearing round-trips too — explicit null, not "unchanged". + const cleared = await TASK_BOARD_ITEM_UPDATE.handler( + { id: task.id, repoOwner: null, repoName: null }, + ctx, + ); + expect(cleared.item.repoOwner).toBeNull(); + expect(cleared.item.repoName).toBeNull(); + }); + + // The CMS submit-for-review path: the browser opens the PR directly (no run), + // so it links the PR + advances via UPDATE. A run-based flow never reaches + // here, so this is the only place these two effects can happen together. + it("links a PR and advances to In Review via UPDATE (CMS submit-for-review)", async () => { + const task = await taskBoard.create({ + organizationId: ORG, + title: "cms review", + status: "in_progress", + by: USER, + }); + const pr = { + url: "https://github.com/deco-sites/casaevideo/pull/7", + prNumber: 7, + repoOwner: "deco-sites", + repoName: "casaevideo", + }; + const res = await TASK_BOARD_ITEM_UPDATE.handler( + { id: task.id, status: "in_review", linkPr: pr }, + ctx, + ); + expect(res.item.status).toBe("in_review"); + expect( + (await taskBoard.listPrs(task.id, ORG)).map((p) => p.number), + ).toEqual([7]); + + // Idempotent — re-linking the same PR (a retry) does not duplicate. + await TASK_BOARD_ITEM_UPDATE.handler({ id: task.id, linkPr: pr }, ctx); + expect(await taskBoard.listPrs(task.id, ORG)).toHaveLength(1); + }); + it("the paywall fires BEFORE the delegation write (no delegated-but-idle task)", async () => { const config = { enforced: true, diff --git a/apps/api/src/tools/task-board/schema.ts b/apps/api/src/tools/task-board/schema.ts index 7f249e7eec..19769d767c 100644 --- a/apps/api/src/tools/task-board/schema.ts +++ b/apps/api/src/tools/task-board/schema.ts @@ -92,6 +92,9 @@ export const TaskBoardItemSchema = z.object({ priority: TaskBoardItemPrioritySchema, assigneeId: z.string().nullable(), assignedBy: z.string().nullable(), + // Which repo (site) this task pertains to — scopes it to a CMS/site. + repoOwner: z.string().nullable(), + repoName: z.string().nullable(), dueDate: z.string().datetime().nullable(), // Manual drag-to-reorder position within a lane, ascending. sortOrder: z.number(), diff --git a/apps/api/src/tools/task-board/stall-recovery.test.ts b/apps/api/src/tools/task-board/stall-recovery.test.ts index 9505134e43..ae1608fd93 100644 --- a/apps/api/src/tools/task-board/stall-recovery.test.ts +++ b/apps/api/src/tools/task-board/stall-recovery.test.ts @@ -2,10 +2,16 @@ import { describe, expect, test } from "bun:test"; import { shouldAdvanceToReview } from "@/storage/task-board"; import { decideStallAction, isNeverStartedRun } from "./stall-recovery"; -/** A used thread — `shouldAdvanceToReview` filters out message-less ones. */ -const thread = (status: string | null, hasMessages = true) => ({ +/** A used thread — `shouldAdvanceToReview` filters out message-less ones. + * `hasPreview` = repo-backed; defaults to repo-less (advances on finish). */ +const thread = ( + status: string | null, + hasMessages = true, + hasPreview = false, +) => ({ status, hasMessages, + hasPreview, }); /** A thread on the current storage format. */ diff --git a/apps/api/src/tools/task-board/update.test.ts b/apps/api/src/tools/task-board/update.test.ts index b0bf04849c..698fcc6a07 100644 --- a/apps/api/src/tools/task-board/update.test.ts +++ b/apps/api/src/tools/task-board/update.test.ts @@ -19,6 +19,8 @@ function item(overrides: Partial = {}): TaskBoardItem { priority: "medium", assigneeId: null, assignedBy: null, + repoOwner: null, + repoName: null, dueDate: null, sortOrder: 0, retryAttempts: 0, diff --git a/apps/api/src/tools/task-board/update.ts b/apps/api/src/tools/task-board/update.ts index 02cfc3d46a..ca7c7434d4 100644 --- a/apps/api/src/tools/task-board/update.ts +++ b/apps/api/src/tools/task-board/update.ts @@ -91,6 +91,8 @@ export const TASK_BOARD_ITEM_UPDATE = defineTool({ status: TaskBoardItemStatusSchema.optional(), priority: TaskBoardItemPrioritySchema.optional(), assigneeId: z.string().nullable().optional(), + repoOwner: z.string().nullable().optional(), + repoName: z.string().nullable().optional(), dueDate: z.string().datetime().nullable().optional(), /** New drag-to-reorder position within its lane (ascending). */ sortOrder: z.number().optional(), @@ -98,6 +100,18 @@ export const TASK_BOARD_ITEM_UPDATE = defineTool({ tagIds: z.array(z.string()).optional(), /** Link an existing chat thread to this task (many-to-many, idempotent). */ linkThreadId: z.string().optional(), + /** Associate an opened pull request with this task (idempotent). Used by the + * CMS submit-for-review flow, which opens the PR via a direct API call + * outside any agent run — so the run-based PR hooks never see it. */ + linkPr: z + .object({ + url: z.string(), + prNumber: z.number(), + repoOwner: z.string(), + repoName: z.string(), + connectionId: z.string().nullable().optional(), + }) + .optional(), }), outputSchema: z.object({ item: TaskBoardItemSchema }), handler: async (input, ctx) => { @@ -138,6 +152,25 @@ export const TASK_BOARD_ITEM_UPDATE = defineTool({ ); } + // Associate a PR opened outside a run (the CMS submit-for-review flow). Verify + // the task belongs to this org before the (idempotent) insert. + if (input.linkPr) { + const target = await ctx.storage.taskBoard.getById( + input.id, + organizationId, + ); + if (!target) throw new Error(`Task board item not found: ${input.id}`); + await ctx.storage.taskBoard.linkPr({ + taskBoardItemId: input.id, + organizationId, + url: input.linkPr.url, + prNumber: input.linkPr.prNumber, + repoOwner: input.linkPr.repoOwner, + repoName: input.linkPr.repoName, + connectionId: input.linkPr.connectionId ?? null, + }); + } + const hasFieldUpdate = input.title !== undefined || input.description !== undefined || @@ -228,6 +261,8 @@ export const TASK_BOARD_ITEM_UPDATE = defineTool({ ? getUserId(ctx)! : null : undefined, + repoOwner: input.repoOwner, + repoName: input.repoName, dueDate: input.dueDate, sortOrder: input.sortOrder, }, diff --git a/apps/web/src/components/chat/pills/new-task-dialog.tsx b/apps/web/src/components/chat/pills/new-task-dialog.tsx new file mode 100644 index 0000000000..3201511682 --- /dev/null +++ b/apps/web/src/components/chat/pills/new-task-dialog.tsx @@ -0,0 +1,91 @@ +/** + * "New task" entry for the task-based flow (opened from the task pill). Ask what + * to do: describe it and the agent runs it on a fresh task, or edit the site by + * hand in a new CMS environment. Presentational — the caller owns both actions. + */ + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogTitle, +} from "@decocms/ui/components/dialog.tsx"; +import { Button } from "@decocms/ui/components/button.tsx"; +import { Edit05, Lightning01 } from "@untitledui/icons"; +import { useT } from "@/i18n/use-t.ts"; + +export function NewTaskDialog({ + open, + onClose, + onSubmitPrompt, + onEditManually, + isSubmitting, +}: { + open: boolean; + onClose: () => void; + /** Describe → run on the agent. Receives the trimmed prompt. */ + onSubmitPrompt: (text: string) => void; + /** New CMS environment to edit by hand. */ + onEditManually: () => void; + isSubmitting?: boolean; +}) { + const t = useT(); + const [text, setText] = useState(""); + + const submit = () => { + const trimmed = text.trim(); + if (!trimmed) return; + onSubmitPrompt(trimmed); + }; + + return ( + !next && onClose()}> + + + {t("chat.newTask.heading")} + + +