Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions apps/api/src/storage/task-board.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,6 @@ describe("shouldAdvanceToReview", () => {
).toBe(false);
});

it("advances a repo-backed task on thread-finish too (backstop for missed PR detection)", () => {
expect(
shouldAdvanceToReview({
status: "in_progress",
threads: [thread("completed")],
}),
).toBe(true);
});

it("only fires from in_progress, not from other lanes", () => {
for (const status of ["triage", "todo", "in_review", "done"] as const) {
expect(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,34 @@ describe("reports-task guards", () => {
expect(cleared.item.repo).toBeNull();
});

// The CMS submit-for-review path opens the PR directly (no run), so it links the PR + advances via UPDATE — the only place these two effects happen together off a run.
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,
Expand Down
30 changes: 30 additions & 0 deletions apps/api/src/tools/task-board/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,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) => {
Expand Down Expand Up @@ -167,6 +179,24 @@ export const TASK_BOARD_ITEM_UPDATE = defineTool({
);
}

// Associate a PR opened outside a run (CMS submit-for-review) — org-verify, then 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 ||
Expand Down
91 changes: 91 additions & 0 deletions apps/web/src/components/chat/pills/new-task-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="flex flex-col gap-6 rounded-2xl p-8 sm:max-w-lg">
<DialogTitle className="text-xl font-medium text-foreground">
{t("chat.newTask.heading")}
</DialogTitle>

<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
// Cmd/Ctrl+Enter submits; a plain Enter keeps adding lines.
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
submit();
}
}}
placeholder={t("chat.newTask.placeholder")}
autoFocus
rows={5}
className="w-full resize-none rounded-xl border border-border bg-background p-4 text-sm text-foreground outline-none transition-colors placeholder:text-foreground/30 focus:border-foreground/30"
/>

<Button
size="lg"
disabled={!text.trim() || isSubmitting}
onClick={submit}
>
<Lightning01 size={16} />
{t("chat.newTask.submit")}
</Button>

<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span className="h-px flex-1 bg-border" />
{t("chat.newTask.orDivider")}
<span className="h-px flex-1 bg-border" />
</div>

<Button
variant="outline"
size="lg"
disabled={isSubmitting}
onClick={onEditManually}
>
<Edit05 size={16} />
{t("chat.newTask.editManually")}
</Button>
</DialogContent>
</Dialog>
);
}
Loading
Loading