diff --git a/apps/api/src/tools/task-board/enqueue-super-agent.test.ts b/apps/api/src/tools/task-board/enqueue-super-agent.test.ts index 6d6eee8451..a0cb124443 100644 --- a/apps/api/src/tools/task-board/enqueue-super-agent.test.ts +++ b/apps/api/src/tools/task-board/enqueue-super-agent.test.ts @@ -14,7 +14,7 @@ const task = { id: "board_1", title: "Fix the thing", description: null }; const pr = { number: 7, url: "https://github.com/x/y/pull/7" }; const CONFLICT_LEAD = "MERGE CONFLICT"; -const FEEDBACK_LEAD = "A reviewer requested changes"; +const FEEDBACK_LEAD = "Changes were requested"; const CONTINUE_LEAD = "already has an open pull request"; const OPEN_A_PR = "commit on a new branch, push, and open a pull request"; @@ -60,7 +60,7 @@ describe("buildSuperAgentTaskPrompt", () => { expect(p).not.toContain(FEEDBACK_LEAD); }); - it("a reviewer change request leads with the feedback block", () => { + it("a change request leads with the feedback block", () => { const p = buildSuperAgentTaskPrompt(task, { pr, feedback: "QA Agent: the button is broken", @@ -70,6 +70,22 @@ describe("buildSuperAgentTaskPrompt", () => { expect(p).not.toContain(CONFLICT_LEAD); }); + /** + * "Comment & re-run" from the board: a person's comment is the lead, with no + * PR and no reviewer involved. Without this the comment posted and the run + * started from the title, which reads to the user as being ignored. + */ + it("caller feedback leads even with no PR", () => { + const p = buildSuperAgentTaskPrompt(task, { + feedback: "Use the design system tokens, not raw hex", + }); + expect(p).toContain(FEEDBACK_LEAD); + expect(p).toContain("Use the design system tokens, not raw hex"); + expect(p).toContain("Address this feedback."); + expect(p).not.toContain(CONFLICT_LEAD); + expect(p).not.toContain(CONTINUE_LEAD); + }); + it("conflict resolution wins over feedback when both are set", () => { const p = buildSuperAgentTaskPrompt(task, { pr, diff --git a/apps/api/src/tools/task-board/enqueue-super-agent.ts b/apps/api/src/tools/task-board/enqueue-super-agent.ts index db712882a8..549ca2def6 100644 --- a/apps/api/src/tools/task-board/enqueue-super-agent.ts +++ b/apps/api/src/tools/task-board/enqueue-super-agent.ts @@ -57,7 +57,8 @@ export async function reactToSuperAgentDelegation( */ /** Options that steer the Super Agent prompt for a re-run on an existing PR. */ export type SuperAgentPromptOpts = { - /** A reviewer's change request — leads the re-run prompt. */ + /** A change request — a reviewer's, or a person's comment on the card. Leads + * the re-run prompt. */ feedback?: string; /** The PR already under review, so the re-run updates it in place instead * of opening a second PR. */ @@ -119,8 +120,8 @@ export function buildSuperAgentTaskPrompt( opts?.feedback ? [ opts.pr - ? `A reviewer requested changes on the existing pull request #${opts.pr.number} (${opts.pr.url}):` - : "A reviewer requested changes on your previous work:", + ? `Changes were requested on the existing pull request #${opts.pr.number} (${opts.pr.url}):` + : "Changes were requested on your previous work:", opts.feedback, opts.pr ? `Load the repo, then CHECK OUT that PR's branch (e.g. \`gh pr checkout ${opts.pr.number}\`) before editing, address the feedback, commit, and push to update the SAME pull request — do NOT open a new one or start a new branch.` diff --git a/apps/api/src/tools/task-board/rerun.ts b/apps/api/src/tools/task-board/rerun.ts index c293a8d255..a1db6bf521 100644 --- a/apps/api/src/tools/task-board/rerun.ts +++ b/apps/api/src/tools/task-board/rerun.ts @@ -290,14 +290,16 @@ export const TASK_BOARD_ITEM_RERUN = defineTool({ idempotentHint: false, openWorldHint: true, }, - // ponytail: no `feedback` / "what to do differently" input from the CALLER. - // A re-run on an existing PR is not blind, though: the dispatch funnel picks - // up the reviewer's outstanding change request by itself - // (`outstandingReviewFeedback`), so the run continues from there instead of - // restarting. Add a caller-supplied lead when someone needs to say something - // the reviewers did not. inputSchema: z.object({ id: z.string().describe("The task board item to re-run."), + /** Takes precedence over carried reviewer feedback (see `wantsCarry`). */ + feedback: z + .string() + .optional() + .describe( + "What the run should do differently. Leads the prompt, taking " + + "precedence over carried-over reviewer feedback.", + ), }), outputSchema: z.object({ status: z.string().describe("The task's lane after the re-run was queued."), @@ -305,7 +307,7 @@ export const TASK_BOARD_ITEM_RERUN = defineTool({ .array(z.string()) .describe("Runs that were failed to make room for this one."), }), - handler: async ({ id }, ctx) => { + handler: async ({ id, feedback }, ctx) => { requireAuth(ctx); await ctx.access.check(); @@ -379,7 +381,10 @@ export const TASK_BOARD_ITEM_RERUN = defineTool({ }); emitTaskBoardUpdated(organizationId, updated); - await enqueueSuperAgentForTask(ctx, updated, { userInitiated: true }); + await enqueueSuperAgentForTask(ctx, updated, { + userInitiated: true, + ...(feedback?.trim() ? { feedback: feedback.trim() } : {}), + }); return { status: updated.status, supersededThreadIds }; }, diff --git a/apps/web/src/components/chat/pills/chat-mode-row.tsx b/apps/web/src/components/chat/pills/chat-mode-row.tsx index 71e035e52a..ca074b7825 100644 --- a/apps/web/src/components/chat/pills/chat-mode-row.tsx +++ b/apps/web/src/components/chat/pills/chat-mode-row.tsx @@ -1,26 +1,13 @@ import type { ReactNode } from "react"; import type { VirtualMCPEntity } from "@decocms/shared/sdk/types"; -import { useOptionalChatStream, useOptionalChatTask } from "../context"; -import { BranchPill } from "./branch-pill"; +import { TaskPill } from "./task-pill"; import { getActiveGithubRepo } from "@/lib/github-repo"; -import { shouldStartBranchAsCms } from "@/sdk/fast-preview"; -import { useProjectContext } from "@/sdk"; -import { authClient } from "@/lib/auth-client"; -import { branchUserLabel } from "@decocms/shared/branch-name"; interface PureProps { branchPill: ReactNode; } -/** - * Pure layout — used by tests. Renders the branch pill (when present) in the - * parent flex flow. Returns null when there is nothing to show. - * - * The runtime choice (Cloud sandbox vs This device) is NOT surfaced here — it - * lives in the "Smart" model selector's Cloud ⟷ This device toggle, which - * writes through the same `pendingAgentOption`. A standalone pill here was - * redundant, so this row only carries the branch pill. - */ +/** Pure layout, used by tests. */ export function ChatModeRowPure({ branchPill }: PureProps) { if (!branchPill) return null; return <>{branchPill}; @@ -28,68 +15,13 @@ export function ChatModeRowPure({ branchPill }: PureProps) { interface SmartProps { virtualMcp: VirtualMCPEntity | null | undefined; - currentBranch: string | null; } -/** - * Smart wrapper. Renders the BranchPill for agents imported from GitHub — - * `metadata.githubRepo` exists AND has an attached `connectionId` (an - * authenticated user repo, not a public-template clone). Start Website agents - * populate `metadata.githubRepo.url` for the template but leave `connectionId` - * unset; branches aren't meaningful there. - * - * Locked flag is derived from `useOptionalChatStream().messages.length > 0`. - */ -export function ChatModeRow({ virtualMcp, currentBranch }: SmartProps) { - const stream = useOptionalChatStream(); - const taskCtx = useOptionalChatTask(); - const locked = - (stream?.messages ?? []).length > 0 || (taskCtx?.isThreadLocked ?? false); - const setCurrentTaskBranch = taskCtx?.setCurrentTaskBranch; - const createTask = taskCtx?.createTask; - const createBranchAsCms = shouldStartBranchAsCms( - virtualMcp?.metadata, - taskCtx?.activeTask?.metadata, - ); - +/** The header context control is the task, not the branch, so `TaskPill` renders here. */ +export function ChatModeRow({ virtualMcp }: SmartProps) { const githubRepo = getActiveGithubRepo(virtualMcp); - const connectionId = githubRepo?.connectionId; - - const { data: session } = authClient.useSession(); - const userId = session?.user?.id ?? ""; - const userLabel = branchUserLabel(session?.user); - const { org } = useProjectContext(); - - const branchPill = - githubRepo && connectionId ? ( - { - if (setCurrentTaskBranch) void setCurrentTaskBranch(next); - }} - onCreateBranch={(next) => { - if (createBranchAsCms && createTask) { - createTask({ branch: next }); - } else if (setCurrentTaskBranch) { - void setCurrentTaskBranch(next); - } - }} - locked={locked} - placement="chat" - /> - ) : null; - - return ; + const taskPill = githubRepo?.connectionId ? ( + + ) : null; + return ; } diff --git a/apps/web/src/components/chat/pills/task-pill.tsx b/apps/web/src/components/chat/pills/task-pill.tsx new file mode 100644 index 0000000000..853291e8df --- /dev/null +++ b/apps/web/src/components/chat/pills/task-pill.tsx @@ -0,0 +1,195 @@ +/** + * Which task this chat is working in, and the way to switch. + * + * Replaces the branch selector in the workspace header. A task owns a branch and + * holds the sessions that run on it, so the task is what you pick; the branch it + * resolves to is a detail (it rides in the tooltip). A loose chat reads "No + * task" and can join one, or become one, from the same menu. + */ + +import { useState } from "react"; +import { LayoutAlt01, Plus } from "@untitledui/icons"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@decocms/ui/components/popover.tsx"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@decocms/ui/components/command.tsx"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@decocms/ui/components/tooltip.tsx"; +import { cn } from "@decocms/ui/lib/utils.ts"; +import { taskKey } from "@decocms/shared/task-key"; +import { useOptionalChatTask } from "@/components/chat/context"; +import { useOptionalThreadManager } from "@/components/chat/store/hooks"; +import { useBoardTaskForThread } from "@/hooks/use-task-for-thread"; +import { usePromoteThreadToTask } from "@/hooks/use-promote-thread-to-task"; +import { useTaskBoardItems } from "@/hooks/use-task-board-items"; +import { usePanelActions } from "@/layouts/shell-layout"; +import { + HIDDEN_STATUSES, + STATUS_CONFIG, + statusIconClassName, + type TaskBoardItem, +} from "@/layouts/task-board/config"; +import { resolveNewestSession } from "@/layouts/task-board/task-branch"; +import { getActiveGithubRepo } from "@/lib/github-repo"; +import { useProjectContext, useVirtualMCP } from "@/sdk"; +import { useT } from "@/i18n/use-t.ts"; + +export function TaskPill({ placement }: { placement?: "chat" | "header" }) { + const t = useT(); + const { org } = useProjectContext(); + const chatTask = useOptionalChatTask(); + const threadId = chatTask?.taskId; + const virtualMcpId = chatTask?.virtualMcpId; + const boardTask = useBoardTaskForThread(threadId); + const { items } = useTaskBoardItems(); + const { setTaskId } = usePanelActions(); + const manager = useOptionalThreadManager(); + const promote = usePromoteThreadToTask(); + const agent = useVirtualMCP(virtualMcpId); + const [open, setOpen] = useState(false); + const [pending, setPending] = useState(false); + + const isHeader = placement === "header"; + const key = boardTask ? taskKey(org.slug, boardTask.keySeq) : null; + const label = boardTask + ? `${key ? `${key} ` : ""}${boardTask.title}` + : t("chat.taskPill.noTask"); + const StatusIcon = boardTask ? STATUS_CONFIG[boardTask.status].icon : null; + const branch = manager?.threads + .get() + .find((row) => row.id === threadId) + ?.branch?.trim(); + + /** Switching task = opening its newest session, which carries its branch. */ + const openTask = (task: TaskBoardItem) => { + setOpen(false); + const newest = resolveNewestSession(task.threads); + if (newest?.virtualMcpId) { + setTaskId(newest.threadId, newest.virtualMcpId, { + sidepanel: "chat", + main: "board", + }); + return; + } + setTaskId(threadId ?? "", virtualMcpId, { main: "board" }); + }; + + const addThisChat = async () => { + if (!threadId) return; + setOpen(false); + setPending(true); + try { + const thread = manager?.threads.get().find((row) => row.id === threadId); + const repo = getActiveGithubRepo(agent); + await promote({ + threadId, + title: thread?.title?.trim() || t("thread.addToBoard.defaultTitle"), + repo: repo ? `${repo.owner}/${repo.name}` : null, + }); + } finally { + setPending(false); + } + }; + + const selectable = items.filter( + (item) => + !HIDDEN_STATUSES.includes(item.status) && item.id !== boardTask?.id, + ); + + return ( + + + + + + + + + {branch + ? t("chat.taskPill.tooltipWithBranch", { label, branch }) + : label} + + + + + + + {t("chat.taskPill.noneFound")} + {!boardTask && threadId && ( + <> + + void addThisChat()}> + + {t("chat.taskPill.addThisChat")} + + + + + )} + + {selectable.map((item) => { + const itemKey = taskKey(org.slug, item.keySeq); + const Icon = STATUS_CONFIG[item.status].icon; + return ( + openTask(item)} + > + + {itemKey && ( + + {itemKey} + + )} + {item.title} + + ); + })} + + + + + + ); +} diff --git a/apps/web/src/components/chat/session-tabs.tsx b/apps/web/src/components/chat/session-tabs.tsx new file mode 100644 index 0000000000..21f82de0a6 --- /dev/null +++ b/apps/web/src/components/chat/session-tabs.tsx @@ -0,0 +1,249 @@ +/** + * The task's sessions, as tabs across the top of the chat panel. + * + * A task is a workspace holding N conversations on one branch, so switching + * between them is a tab switch, not a navigation: the task, its branch, its + * sandbox and its preview all stay put. Renders nothing when the current thread + * belongs to no task — a loose chat has nothing to tab between, and + * `ThreadsMenu` is its switcher. + */ + +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { DotsHorizontal, Plus, XClose } from "@untitledui/icons"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@decocms/ui/components/dropdown-menu.tsx"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@decocms/ui/components/tooltip.tsx"; +import { cn } from "@decocms/ui/lib/utils.ts"; +import type { StudioToolOutput as ToolOutput } from "@decocms/shared/tools/tool-io"; +import { useOptionalChatTask } from "@/components/chat/context"; +import { useThreadActions } from "@/components/chat/store/hooks"; +import { useBoardTaskForThread } from "@/hooks/use-task-for-thread"; +import { useStartTaskSession } from "@/hooks/use-start-task-session"; +import { usePanelActions } from "@/layouts/shell-layout"; +import { threadStatusStyle } from "@/layouts/task-board/config"; +import { useT } from "@/i18n/use-t.ts"; +import { useProjectContext } from "@/sdk"; +import { KEYS } from "@/lib/query-keys"; + +const MAX_VISIBLE_TABS = 3; + +type TaskBoardItem = ToolOutput<"TASK_BOARD_ITEM_LIST">["items"][number]; + +export function SessionTabs() { + const t = useT(); + const { locator } = useProjectContext(); + const queryClient = useQueryClient(); + const threadId = useOptionalChatTask()?.taskId; + const boardTask = useBoardTaskForThread(threadId); + const { setTaskId } = usePanelActions(); + const { rename, hide } = useThreadActions(); + const startSession = useStartTaskSession(); + const [renaming, setRenaming] = useState(null); + + if (!boardTask || boardTask.threads.length === 0) return null; + + // Oldest first, so a tab never moves under the cursor as runs change status. + const sessions = [...boardTask.threads].sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + + // Cap visible tabs so they don't shrink to unreadable slivers; the rest go in a dropdown. + let visibleSessions = sessions; + let overflowSessions: typeof sessions = []; + if (sessions.length > MAX_VISIBLE_TABS) { + const activeIndex = sessions.findIndex((s) => s.threadId === threadId); + const start = Math.min( + Math.max(0, sessions.length - MAX_VISIBLE_TABS), + activeIndex < 0 ? Infinity : activeIndex, + ); + const end = start + MAX_VISIBLE_TABS; + visibleSessions = sessions.slice(start, end); + overflowSessions = [...sessions.slice(0, start), ...sessions.slice(end)]; + } + + const closeSession = (session: (typeof sessions)[number]) => { + queryClient.setQueryData( + KEYS.taskBoardItems(locator), + (prev) => + prev?.map((item) => + item.id === boardTask.id + ? { + ...item, + threads: item.threads.filter( + (t) => t.threadId !== session.threadId, + ), + } + : item, + ), + ); + void hide(session.threadId); + if (session.threadId !== threadId) return; + const next = sessions.find((s) => s.threadId !== session.threadId); + if (next?.virtualMcpId) setTaskId(next.threadId, next.virtualMcpId); + }; + + return ( +
+ {visibleSessions.map((session) => { + const active = session.threadId === threadId; + const label = session.title || t("tasksPanel.taskRow.untitledTask"); + const state = session.status + ? threadStatusStyle({ ...session, status: session.status }, t) + : null; + if (active && renaming === session.threadId) { + return ( + setRenaming(null)} + onCommit={(next) => { + setRenaming(null); + if (next && next !== label) void rename(session.threadId, next); + }} + /> + ); + } + return ( +
+ + +
+ ); + })} + {overflowSessions.length > 0 && ( + + + + + + {overflowSessions.map((session) => { + const label = + session.title || t("tasksPanel.taskRow.untitledTask"); + const state = session.status + ? threadStatusStyle({ ...session, status: session.status }, t) + : null; + return ( + { + if (session.virtualMcpId) + setTaskId(session.threadId, session.virtualMcpId); + }} + > + {state && ( + + )} + {label} + + ); + })} + + + )} + + + + + + {t("chat.sessionTabs.newSession")} + + +
+ ); +} + +/** Inline rename on the active tab: clicking it again starts an edit. */ +function RenameField({ + defaultValue, + onCommit, + onCancel, +}: { + defaultValue: string; + onCommit: (next: string) => void; + onCancel: () => void; +}) { + return ( + onCommit(e.currentTarget.value.trim())} + onKeyDown={(e) => { + if (e.key === "Enter") onCommit(e.currentTarget.value.trim()); + if (e.key === "Escape") onCancel(); + }} + className="h-7 w-[9rem] min-w-0 rounded-lg bg-sidebar-accent px-2.5 text-sm font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50" + /> + ); +} diff --git a/apps/web/src/components/chat/threads-menu.tsx b/apps/web/src/components/chat/threads-menu.tsx index 778734ba4e..5f67f0abeb 100644 --- a/apps/web/src/components/chat/threads-menu.tsx +++ b/apps/web/src/components/chat/threads-menu.tsx @@ -60,7 +60,7 @@ export function ThreadsMenu() { type="button" aria-label={t("chat.threadsMenu.chats")} className={cn( - "flex h-[34px] min-w-0 shrink items-center gap-1.5 rounded-lg px-2 text-sm text-foreground transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50", + "flex h-7 min-w-0 shrink items-center gap-1.5 rounded-lg px-2.5 text-sm text-foreground transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50", open && "bg-sidebar-accent", )} > diff --git a/apps/web/src/components/thread/task-crumb.tsx b/apps/web/src/components/thread/task-crumb.tsx new file mode 100644 index 0000000000..9d16884619 --- /dev/null +++ b/apps/web/src/components/thread/task-crumb.tsx @@ -0,0 +1,69 @@ +/** + * Which task the current chat is inside, and the way back to it. + * + * The chat panel is a global fixture: it sits beside preview, content and the + * board alike, bound to whichever thread is in the URL. Without this, a session + * that belongs to a task looks exactly like a loose chat. + */ + +import { useNavigate } from "@tanstack/react-router"; +import { cn } from "@decocms/ui/lib/utils.ts"; +import { taskKey } from "@decocms/shared/task-key"; +import { useChatTask } from "@/components/chat/chat-context"; +import { useOptionalThreadManager } from "@/components/chat/store/hooks"; +import { useBoardTaskForThread } from "@/hooks/use-task-for-thread"; +import { + STATUS_CONFIG, + statusIconClassName, +} from "@/layouts/task-board/config"; +import { useProjectContext } from "@/sdk"; +import { useT } from "@/i18n/use-t.ts"; + +export function TaskCrumb() { + const t = useT(); + const { org } = useProjectContext(); + const { taskId } = useChatTask(); + const navigate = useNavigate(); + const boardTask = useBoardTaskForThread(taskId); + const manager = useOptionalThreadManager(); + + if (!boardTask) return null; + + /** The branch is a detail of the task now: it rides in the tooltip. */ + const branch = manager?.threads + .get() + .find((row) => row.id === taskId) + ?.branch?.trim(); + + // Null for a card written before the key backfill: fall back to the title. + const key = taskKey(org.slug, boardTask.keySeq) ?? ""; + const StatusIcon = STATUS_CONFIG[boardTask.status].icon; + + return ( + + ); +} diff --git a/apps/web/src/hooks/use-promote-thread-to-task.ts b/apps/web/src/hooks/use-promote-thread-to-task.ts new file mode 100644 index 0000000000..dc770f9bfa --- /dev/null +++ b/apps/web/src/hooks/use-promote-thread-to-task.ts @@ -0,0 +1,81 @@ +/** + * Put a chat's work on the board. + * + * The one bridge between a loose conversation and the board. Create-or-link, + * never plain create: a thread that already has a card gets the new detail + * added to it, or re-publishing the same branch would fill the board with + * duplicates of one piece of work. + */ + +import type { StudioToolOutput as ToolOutput } from "@decocms/shared/tools/tool-io"; +import { useStudioTools } from "@/lib/studio-tools"; +import { useTaskBoardItemActions } from "@/hooks/use-task-board-items"; + +type TaskBoardItem = ToolOutput<"TASK_BOARD_ITEM_LIST">["items"][number]; +type TaskBoardItemStatus = TaskBoardItem["status"]; + +export interface PromoteInput { + threadId: string; + title: string; + /** `owner/name`, when the thread is working in a repo. */ + repo?: string | null; + /** A PR the work landed in. Recorded as a link in the card's description. */ + prUrl?: string | null; + /** Where the card should land. Defaults to in progress. */ + status?: TaskBoardItemStatus; +} + +export function usePromoteThreadToTask() { + const studio = useStudioTools(); + const actions = useTaskBoardItemActions(); + + return async (input: PromoteInput): Promise => { + /* Existing card wins: keyed on the thread first, then on the PR url, so a + second publish of the same work updates one card instead of adding one. */ + const items = await studio + .call("TASK_BOARD_ITEM_LIST", {}) + .then((r) => r.items) + .catch(() => [] as TaskBoardItem[]); + const prUrl = input.prUrl; + const existing = + items.find((item) => + item.threads.some((t) => t.threadId === input.threadId), + ) ?? + (prUrl + ? items.find((item) => item.description?.includes(prUrl)) + : undefined); + if (existing) { + if (prUrl && !existing.description?.includes(prUrl)) { + await actions.update.mutateAsync({ + id: existing.id, + description: appendLink(existing.description, prUrl), + }); + } + await actions.link.mutateAsync({ + id: existing.id, + linkThreadId: input.threadId, + }); + return existing; + } + /* No assignee: the person is doing this by hand, and setting the Super + Agent here would dispatch a second autonomous run over their work. */ + const { item: created } = await actions.create.mutateAsync({ + title: input.title, + description: prUrl ? appendLink(null, prUrl) : null, + status: input.status ?? "in_progress", + repo: input.repo ?? null, + }); + await actions.link.mutateAsync({ + id: created.id, + linkThreadId: input.threadId, + }); + return created; + }; +} + +/** Keep the PR discoverable from the card: `LinksSection` renders description + * links as rows, so appending the url is enough to surface it. */ +function appendLink(description: string | null, url: string): string { + const body = description?.trimEnd(); + return body ? `${body}\n\n${url}` : url; +} diff --git a/apps/web/src/i18n/en/chat.ts b/apps/web/src/i18n/en/chat.ts index fef93af571..69452492c5 100644 --- a/apps/web/src/i18n/en/chat.ts +++ b/apps/web/src/i18n/en/chat.ts @@ -454,6 +454,15 @@ export const chat = { "chat.tierTrigger.tierSmart": "Smart", "chat.tierTrigger.tierThinking": "Thinking", "chat.threadsMenu.chats": "Chats", + "chat.sessionTabs.newSession": "New chat in this task", + "chat.taskPill.noTask": "No task", + "chat.taskPill.searchPlaceholder": "Search tasks...", + "chat.taskPill.noneFound": "No tasks found.", + "chat.taskPill.addThisChat": "Add this chat to the board", + "chat.taskPill.switchTo": "Switch to", + "chat.taskPill.tooltipWithBranch": "{label} · {branch}", + "chat.sessionTabs.moreSessions": "{count} more chats", + "chat.sessionTabs.closeSession": "Close chat", "chat.toolsPopover.addFile": "Add file", "chat.toolsPopover.approval": "Approval", "chat.toolsPopover.connections": "Connections", diff --git a/apps/web/src/i18n/en/task-board.ts b/apps/web/src/i18n/en/task-board.ts index 5888ed5506..2d09673ebe 100644 --- a/apps/web/src/i18n/en/task-board.ts +++ b/apps/web/src/i18n/en/task-board.ts @@ -137,6 +137,8 @@ export const taskBoard = { "taskBoard.taskDialog.editTaskTitle": "Edit task", "taskBoard.taskDialog.membersGroupHeading": "Members", "taskBoard.taskDialog.newChatButton": "New chat", + "taskBoard.taskDialog.sessionsLabel": "Chats", + "taskBoard.taskDialog.commentAndRerun": "Comment & re-run", "taskBoard.taskDialog.newTaskTitle": "New task", "taskBoard.taskDialog.noMembersFound": "No members found.", "taskBoard.taskDialog.prChecksFailing": "Checks failing", @@ -183,6 +185,8 @@ export const taskBoard = { "taskBoard.taskDialog.showLess": "Show less", "taskBoard.taskDialog.copyIdAriaLabel": "Copy task ID", "taskBoard.taskDialog.idCopied": "Task ID copied", + "taskBoard.taskDialog.previewAriaLabel": "Open preview for this task", + "taskBoard.taskDialog.previewTitle": "Open preview", "taskBoard.taskDialog.shareAriaLabel": "Copy link to this task", "taskBoard.taskDialog.shareTitle": "Copy link", "taskBoard.taskDialog.linkCopied": "Link copied", diff --git a/apps/web/src/i18n/en/thread.ts b/apps/web/src/i18n/en/thread.ts index 59c4c4b181..17f0eabcaa 100644 --- a/apps/web/src/i18n/en/thread.ts +++ b/apps/web/src/i18n/en/thread.ts @@ -134,6 +134,8 @@ export const thread = { "Getting your environment ready — this only takes a moment", "thread.openInBoardButton.openTaskAriaLabel": "Open task in board", "thread.openInBoardButton.openTaskInBoard": "Open task in board", + "thread.taskCrumb.openTask": "Open {key} in the board", + "thread.addToBoard.defaultTitle": "Untitled task", "thread.publishDialog.allChangesDiscarded": "All changes discarded", "thread.publishDialog.branchLabel": "Branch:", "thread.publishDialog.cancel": "Cancel", diff --git a/apps/web/src/i18n/pt-br/chat.ts b/apps/web/src/i18n/pt-br/chat.ts index c78f90331e..e9af736547 100644 --- a/apps/web/src/i18n/pt-br/chat.ts +++ b/apps/web/src/i18n/pt-br/chat.ts @@ -467,6 +467,15 @@ export const chat = { "chat.tierTrigger.tierSmart": "Inteligente", "chat.tierTrigger.tierThinking": "Pensamento", "chat.threadsMenu.chats": "Chats", + "chat.sessionTabs.newSession": "Novo chat nesta tarefa", + "chat.taskPill.noTask": "Sem tarefa", + "chat.taskPill.searchPlaceholder": "Buscar tarefas...", + "chat.taskPill.noneFound": "Nenhuma tarefa encontrada.", + "chat.taskPill.addThisChat": "Adicionar este chat ao quadro", + "chat.taskPill.switchTo": "Ir para", + "chat.taskPill.tooltipWithBranch": "{label} · {branch}", + "chat.sessionTabs.moreSessions": "Mais {count} chats", + "chat.sessionTabs.closeSession": "Fechar chat", "chat.toolsPopover.addFile": "Adicionar arquivo", "chat.toolsPopover.approval": "Aprovação", "chat.toolsPopover.connections": "Conexões", diff --git a/apps/web/src/i18n/pt-br/task-board.ts b/apps/web/src/i18n/pt-br/task-board.ts index f0e80475b2..409e25b81b 100644 --- a/apps/web/src/i18n/pt-br/task-board.ts +++ b/apps/web/src/i18n/pt-br/task-board.ts @@ -145,6 +145,8 @@ export const taskBoard = { "taskBoard.taskDialog.editTaskTitle": "Editar tarefa", "taskBoard.taskDialog.membersGroupHeading": "Membros", "taskBoard.taskDialog.newChatButton": "Novo chat", + "taskBoard.taskDialog.sessionsLabel": "Chats", + "taskBoard.taskDialog.commentAndRerun": "Comentar e rodar de novo", "taskBoard.taskDialog.newTaskTitle": "Nova tarefa", "taskBoard.taskDialog.noMembersFound": "Nenhum membro encontrado.", "taskBoard.taskDialog.prChecksFailing": "Verificações falhando", @@ -193,6 +195,8 @@ export const taskBoard = { "taskBoard.taskDialog.showLess": "Ver menos", "taskBoard.taskDialog.copyIdAriaLabel": "Copiar ID da tarefa", "taskBoard.taskDialog.idCopied": "ID da tarefa copiado", + "taskBoard.taskDialog.previewAriaLabel": "Abrir preview desta tarefa", + "taskBoard.taskDialog.previewTitle": "Abrir preview", "taskBoard.taskDialog.shareAriaLabel": "Copiar link desta tarefa", "taskBoard.taskDialog.shareTitle": "Copiar link", "taskBoard.taskDialog.linkCopied": "Link copiado", diff --git a/apps/web/src/i18n/pt-br/thread.ts b/apps/web/src/i18n/pt-br/thread.ts index db818fda31..dafc95726e 100644 --- a/apps/web/src/i18n/pt-br/thread.ts +++ b/apps/web/src/i18n/pt-br/thread.ts @@ -140,6 +140,8 @@ export const thread = { "Preparando seu ambiente — leva só um instante", "thread.openInBoardButton.openTaskAriaLabel": "Abrir tarefa no quadro", "thread.openInBoardButton.openTaskInBoard": "Abrir tarefa no quadro", + "thread.taskCrumb.openTask": "Abrir {key} no quadro", + "thread.addToBoard.defaultTitle": "Tarefa sem título", "thread.publishDialog.allChangesDiscarded": "Todas as alterações foram descartadas", "thread.publishDialog.branchLabel": "Branch:", diff --git a/apps/web/src/layouts/agent-shell-layout/workspace-panel-group.tsx b/apps/web/src/layouts/agent-shell-layout/workspace-panel-group.tsx index dd8f985277..6c231c8671 100644 --- a/apps/web/src/layouts/agent-shell-layout/workspace-panel-group.tsx +++ b/apps/web/src/layouts/agent-shell-layout/workspace-panel-group.tsx @@ -41,7 +41,6 @@ import { CmsTour } from "@/components/cms-tour/cms-tour"; import { headerLayout } from "./header-layout"; import { VirtualMcpHeaderInfo } from "@/views/virtual-mcp/header-info"; import { ChatModeRow } from "@/components/chat/pills/chat-mode-row"; -import { useOptionalChatTask } from "@/components/chat/context"; import { AgentSwitcherCrumb, NewChatCrumb, @@ -49,6 +48,9 @@ import { import { useSidebar } from "@decocms/ui/components/sidebar.tsx"; import { cn } from "@decocms/ui/lib/utils.ts"; import { ThreadsMenu } from "@/components/chat/threads-menu"; +import { SessionTabs } from "@/components/chat/session-tabs"; +import { TaskCrumb } from "@/components/thread/task-crumb"; +import { useBoardTaskForThread } from "@/hooks/use-task-for-thread"; import { useNavV2 } from "@/hooks/use-organization-settings"; import { SidePanel } from "./side-panel"; import { ChatToggle, PanelCollapseToggle } from "./toggle-buttons"; @@ -185,6 +187,8 @@ export function WorkspacePanelGroup({ const agentCrumb = sidebarCollapsed && !navV2 ? : null; const newChatCrumb = sidebarCollapsed || navV2 ? : null; const threadsMenu = navV2 ? : null; + /** In a task, the chat row below is the switcher, not the thread list. */ + const inTask = !!useBoardTaskForThread(taskId); /** * The main panel's controls (view tabs + branch + publish) belong to the main @@ -201,10 +205,7 @@ export function WorkspacePanelGroup({ // sandbox/preview runs on), shared by chat and preview alike. Renders null for // agents without a connected GitHub repo. Reads the branch from the task // context (this tree is inside Chat.ActiveTaskProvider). - const currentBranch = useOptionalChatTask()?.currentBranch ?? null; - const branchSelector = ( - - ); + const branchSelector = ; // oxlint-disable-next-line ban-use-effect/ban-use-effect -- syncs URL-derived visibility with the resizable panels' imperative layout API useEffect(() => { @@ -215,40 +216,47 @@ export function WorkspacePanelGroup({ }, [sideSize, mainSize]); const chatHeader = ( - - {threadsMenu} - {agentCrumb} - {/* The collapse pair below already owns hide/show for both panels. */} - {!navV2 && ( - - )} - {mainControlsInChat && ( - - )} -
- {mainControlsInChat && branchSelector} - {mainControlsInChat && publishActions} - {newChatCrumb} - {/* The main panel's own toggle lives in ITS header; it only relocates - here once that header is gone. */} - {navV2 && !mainOpen && ( - + + {navV2 && inTask ? : threadsMenu} + {agentCrumb} + {/* The collapse pair below already owns hide/show for both panels. */} + {!navV2 && ( + )} -
-
+ {mainControlsInChat && ( + + )} +
+ {mainControlsInChat && branchSelector} + {mainControlsInChat && publishActions} + {newChatCrumb} + {/* The main panel's own toggle lives in ITS header; it only relocates + here once that header is gone. */} + {navV2 && !mainOpen && ( + + )} +
+ + {navV2 && inTask && ( +
+ +
+ )} + ); // Three content-sized zones spaced with justify-between: tabs left, publish diff --git a/apps/web/src/layouts/task-board/config.tsx b/apps/web/src/layouts/task-board/config.tsx index 75daa55add..daa6f6937c 100644 --- a/apps/web/src/layouts/task-board/config.tsx +++ b/apps/web/src/layouts/task-board/config.tsx @@ -1,9 +1,11 @@ import { AlertCircle, + AlertSquare, Archive, CheckCircle, Circle, Eye, + HelpCircle, Loading02, } from "@untitledui/icons"; import type { StudioToolOutput as ToolOutput } from "@decocms/shared/tools/tool-io"; @@ -13,7 +15,8 @@ import { REVIEWER_KINDS, type ReviewerKind, } from "@decocms/shared/task-board"; -import type { TranslationKey } from "@/i18n/use-t.ts"; +import { isResolvedRunFailure } from "@decocms/shared/entities"; +import type { TranslationKey, useT } from "@/i18n/use-t.ts"; export { SUPER_AGENT_ASSIGNEE_ID, @@ -71,6 +74,21 @@ export function primaryThread( return item.threads[0]; } +/** Rank for ordering agent sessions — lower wins. A running/awaiting-input + * session is always the most important thing; once nothing is running, a + * failure the user can act on is; a clean run outranks a settled failure (a + * superseded attempt, or a run that died after delivering — see + * `isResolvedRunFailure`), which is history and must not paint the card red. */ +export function statusPriority(thread: TaskBoardItemThread): number { + if (thread.status === "in_progress" || thread.status === "requires_action") { + return 0; + } + if (thread.status === "failed") { + return isResolvedRunFailure(thread.failureKind) ? 3 : 1; + } + return 2; +} + /** The QA/code-review threads linked to this task, in `REVIEWER_KINDS` order — * present only once that reviewer has actually run. */ export function reviewerThreads( @@ -232,3 +250,72 @@ export const PRIORITY_CONFIG: Record< export function tagDotColor(color: string | null | undefined): string { return color ?? DEFAULT_TAG_COLOR; } + +/** + * Live-status style for a linked thread (agent session). + * + * Takes the thread, not just its status: a `failed` run whose failure is settled + * history — a newer attempt replaced it, or it died after already delivering — + * is not an error the user can act on, and painting it red is what made a card + * the reviewers approved look broken. + */ +export function threadStatusStyle( + thread: { + status: NonNullable; + failureKind?: string | null; + }, + t: ReturnType, +): { + label: string; + className: string; + icon: typeof AlertSquare; + spin?: boolean; +} { + switch (thread.status) { + case "failed": + if (isResolvedRunFailure(thread.failureKind)) { + return { + label: + thread.failureKind === "superseded" + ? t("taskBoard.taskDialog.threadStatusSuperseded") + : t("taskBoard.taskDialog.threadStatusEndedAfterDelivery"), + className: "text-muted-foreground", + icon: AlertCircle, + }; + } + return { + label: t("taskBoard.taskDialog.threadStatusError"), + className: "text-destructive", + icon: AlertSquare, + }; + case "requires_action": + return { + label: t("taskBoard.taskDialog.threadStatusNeedsInput"), + className: "text-warning", + icon: HelpCircle, + }; + case "in_progress": + return { + label: t("taskBoard.taskDialog.threadStatusRunning"), + className: "text-primary", + icon: Loading02, + spin: true, + }; + case "completed": + return { + label: t("taskBoard.taskDialog.threadStatusCompleted"), + className: "text-success", + icon: CheckCircle, + }; + case "expired": + return { + label: t("taskBoard.taskDialog.threadStatusExpired"), + className: "text-muted-foreground", + icon: AlertCircle, + }; + default: { + const _exhaustive: never = thread.status; + return _exhaustive; + } + } +} diff --git a/apps/web/src/layouts/task-board/index.tsx b/apps/web/src/layouts/task-board/index.tsx index 5fcfc48e55..508cd76c98 100644 --- a/apps/web/src/layouts/task-board/index.tsx +++ b/apps/web/src/layouts/task-board/index.tsx @@ -103,6 +103,8 @@ import { PRIORITY_CONFIG, runSortOrders, statusIconClassName, + statusPriority, + threadStatusStyle, STATUS_CONFIG, STATUSES, SUPER_AGENT_ASSIGNEE_ID, @@ -116,22 +118,15 @@ import { } from "./config"; import { useTags } from "@/hooks/use-tags"; import { usePreferences } from "@/hooks/use-preferences"; -import { - TaskBoardItemDialog, - threadStatusStyle, - toEndOfDayIso, -} from "./task-dialog"; +import { TaskBoardItemDialog, toEndOfDayIso } from "./task-dialog"; import { AssigneePickerContent } from "./assignee-picker"; import { SubscriptionPaywallDialog } from "./subscription-paywall-dialog"; import { RerunDialog } from "./rerun-dialog"; import { subscriptionErrorKind } from "@/components/task-board/is-subscription-error"; import { isReportsTask, type ReviewerKind } from "@decocms/shared/task-board"; -import { isResolvedRunFailure } from "@decocms/shared/entities"; import { useFlipLanes } from "./use-flip-lanes"; import { Calendar as DayPickerCalendar } from "@decocms/ui/components/calendar.tsx"; -import { buildTaskChatContext } from "./build-task-chat-context"; import { track } from "@/lib/posthog-client"; -import { useStudioTools } from "@/lib/studio-tools"; import { EMPTY_FILTERS, TaskFiltersBar, @@ -142,10 +137,7 @@ import { import { useBoardSearch } from "./filters-search"; import { usePanelActions } from "@/layouts/shell-layout"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import { useThreadActions } from "@/components/chat/store/hooks"; -import { writeChatDraft } from "@/lib/chat-draft"; -import { createMentionDoc } from "@/components/chat/tiptap/mention"; -import type { TiptapDoc } from "@/components/chat/types"; +import { useStartTaskSession } from "@/hooks/use-start-task-session"; import { toast } from "sonner"; // Warm the chat chunk so opening a task's activity doesn't cold-load it (flash). @@ -389,21 +381,30 @@ export function TaskBoardPage() { // task's live run, so it is confirmed rather than fired on click. // One entry for a card's own Re-run, many for a selection. const [rerunTargets, setRerunTargets] = useState([]); + /** A comment the re-run should lead with, when it came from "Comment & re-run". */ + const [rerunFeedback, setRerunFeedback] = useState(null); + const clearRerun = () => { + setRerunTargets([]); + setRerunFeedback(null); + }; const confirmRerun = () => { if (rerunTargets.length === 0) return; // Same GitHub precondition as delegating: the run is expected to open a PR. if (blockSuperAgentWithoutGithub(SUPER_AGENT_ASSIGNEE_ID)) { - setRerunTargets([]); + clearRerun(); return; } // ponytail: fire-and-forget per task, like every other bulk action here — // the board reconciles from the invalidation each one triggers. for (const target of rerunTargets) actions.rerun.mutate( - { id: target.id }, + { + id: target.id, + ...(rerunFeedback ? { feedback: rerunFeedback } : {}), + }, { onError: (err) => onDelegateError(err as Error) }, ); - setRerunTargets([]); + clearRerun(); clearSelection(); }; const { data: membersData } = useMembers(); @@ -442,9 +443,8 @@ export function TaskBoardPage() { null, ); const { setTaskId } = usePanelActions(); - const { create } = useThreadActions(); - const studio = useStudioTools(); - const { org, locator } = useProjectContext(); + const startTaskSession = useStartTaskSession(); + const { org } = useProjectContext(); const navigate = useNavigate(); // Deep link: `?main=board&task=` opens that task's modal (from a linked // chat's "open in board" button). Derived, so it opens as soon as the item @@ -465,55 +465,81 @@ export function TaskBoardPage() { }); }; - // Start a fresh chat on the default Decopilot agent, seeded with the task's - // title + description as the first user message (via the autosend buffer), - // and link the new thread to the task so it shows on the modal. const startChatFromTask = async (task: TaskBoardItem) => { - const newId = crypto.randomUUID(); - const agentId = getWellKnownDecopilotVirtualMCP(org.id).id; - // Pull the task's linked PRs (best-effort — the chat still opens without - // them) so the seeded context references prior work, not just the title. - const prs = await studio - .call("TASK_BOARD_ITEM_PRS_GET", { taskBoardItemId: task.id }) - .then((r) => r.prs) - .catch(() => []); - const context = buildTaskChatContext(task, prs); - // Prefill the composer with a removable task @ref chip (not raw text) and - // do NOT auto-send — the user reviews/adds to it, then hits send. The chip - // expands to the task context at send time (see derive-parts). - const doc: TiptapDoc = { - type: "doc", - content: [ - { - type: "paragraph", - content: [ - createMentionDoc({ - id: task.id, - name: task.title, - char: "@", - kind: "task", - metadata: { - title: task.title, - description: task.description, - context, - }, - }), - { type: "text", text: " " }, - ], - }, - ], - }; - writeChatDraft(sessionStorage, locator, newId, doc); setDialogOpen(false); - try { - await create({ id: newId, virtual_mcp_id: agentId }); - // Best-effort — a link failure shouldn't block navigating into the chat. - await actions.link.mutateAsync({ id: task.id, linkThreadId: newId }); - } catch { - // Toast already fired by the manager; navigate anyway so the route - // loader's ensure-fallback can retry the create. + await startTaskSession(task); + }; + + /** + * Autosave for an existing card. Reports-generated tasks reject a write + * touching title/description/priority (the reports sync owns them), so those + * are dropped rather than sent as a payload the server would 500 on. Board + * fields (status/assignee/dueDate/tagIds) always go through. + */ + const submitTask = ( + target: TaskBoardItem, + input: { + title: string; + description: string | null; + status: TaskBoardItemStatus; + priority: TaskBoardItemPriority; + assigneeId: string | null; + repo: string | null; + dueDate: string | null; + tagIds: string[]; + }, + ) => { + if (blockSuperAgentWithoutGithub(input.assigneeId)) { + closeDialog(); + return; } - setTaskId(newId, agentId); + const { title, description, priority, ...boardFields } = input; + const contentFields = isReportsTask(target) + ? {} + : { title, description, priority }; + actions.update.mutate( + { ...boardFields, id: target.id, ...contentFields }, + { onError: onDelegateError }, + ); + }; + + /** A copy starts fresh and undelegated: no assignee, no threads. */ + const cloneTask = (target: TaskBoardItem) => { + actions.create.mutate({ + title: t("taskBoard.taskDialog.cloneTitle", { title: target.title }), + description: target.description, + status: target.status, + priority: target.priority, + repo: target.repo, + dueDate: target.dueDate, + tagIds: target.tags.map((tag) => tag.id), + }); + toast.success(t("taskBoard.taskDialog.cloneSuccess")); + closeDialog(); + }; + + const delegateToSuperAgent = (target: TaskBoardItem) => { + if (blockSuperAgentWithoutGithub(SUPER_AGENT_ASSIGNEE_ID)) return; + actions.update.mutate( + { id: target.id, assigneeId: SUPER_AGENT_ASSIGNEE_ID }, + { onError: onDelegateError }, + ); + closeDialog(); + }; + + /** + * Open a session's chat: close the task dialog (it would cover the chat) and + * force the chat panel open so the conversation is actually visible. Falls + * back to the org agent for a session linked without one, rather than + * silently doing nothing. + */ + const openThread = (thread: TaskBoardItemThread) => { + closeDialog(); + setTaskId( + thread.threadId, + thread.virtualMcpId ?? getWellKnownDecopilotVirtualMCP(org.id).id, + { main: thread.hasPreview ? "preview" : "board", sidepanel: "chat" }, + ); }; const visibleItems = items.filter((item) => @@ -549,11 +575,10 @@ export function TaskBoardPage() { }); }; - // Opening a card always opens the task modal. The modal's activity area is - // what navigates into the run's chat (see onOpenThread below). + /** Opening a card swaps the panel to its workspace. */ const openTask = openEdit; - // The task the modal is editing — a locally-opened card, or the deep-linked + // The open task — a locally-opened card, or the deep-linked // one. Resolve the LIVE row from the SSE-patched list by id (falling back to // the click-time snapshot if it's momentarily absent) so threads/status // linked while the modal is open — e.g. the QA Agent session handed off @@ -756,26 +781,12 @@ export function TaskBoardPage() { defaultStatus={createStatus ?? undefined} isSaving={actions.create.isPending || actions.update.isPending} onSubmit={(input) => { - if (blockSuperAgentWithoutGithub(input.assigneeId)) { - closeDialog(); + if (activeItem) { + submitTask(activeItem, input); return; } - if (activeItem) { - // Reports-generated tasks reject a write touching title/ - // description/priority (their content is owned by the reports - // sync) — the dialog locks those fields, but still round-trips - // their unchanged values here, so drop them instead of sending a - // payload the server would 500 on. Board interactions - // (status/assignee/dueDate/tagIds) always go through. - const { title, description, priority, ...boardFields } = input; - const contentFields = isReportsTask(activeItem) - ? {} - : { title, description, priority }; - actions.update.mutate( - { id: activeItem.id, ...boardFields, ...contentFields }, - { onError: onDelegateError }, - ); - // Autosave: the dialog stays open. + if (blockSuperAgentWithoutGithub(input.assigneeId)) { + closeDialog(); return; } actions.create.mutate(input); @@ -789,26 +800,7 @@ export function TaskBoardPage() { } : undefined } - onClone={ - activeItem - ? () => { - // A copy starts fresh and undelegated: no assignee, no threads. - actions.create.mutate({ - title: t("taskBoard.taskDialog.cloneTitle", { - title: activeItem.title, - }), - description: activeItem.description, - status: activeItem.status, - priority: activeItem.priority, - repo: activeItem.repo, - dueDate: activeItem.dueDate, - tagIds: activeItem.tags.map((tag) => tag.id), - }); - toast.success(t("taskBoard.taskDialog.cloneSuccess")); - closeDialog(); - } - : undefined - } + onClone={activeItem ? () => cloneTask(activeItem) : undefined} onArchive={ activeItem ? () => { @@ -825,39 +817,26 @@ export function TaskBoardPage() { activeItem ? () => void startChatFromTask(activeItem) : undefined } onAutoFix={ + activeItem ? () => delegateToSuperAgent(activeItem) : undefined + } + onRerun={ activeItem ? () => { - if (blockSuperAgentWithoutGithub(SUPER_AGENT_ASSIGNEE_ID)) - return; - actions.update.mutate( - { - id: activeItem.id, - assigneeId: SUPER_AGENT_ASSIGNEE_ID, - }, - { onError: onDelegateError }, - ); closeDialog(); + setRerunTargets([activeItem]); } : undefined } - onRerun={ + onRerunWithFeedback={ activeItem - ? () => { - // Confirm in the shared dialog rather than firing from here — - // the card path does the same, so the takeover warning has one - // home. Closing the task dialog first keeps them unstacked. + ? (feedback) => { + setRerunFeedback(feedback); closeDialog(); setRerunTargets([activeItem]); } : undefined } - onOpenThread={(thread) => { - if (!thread.virtualMcpId) return; - closeDialog(); - setTaskId(thread.threadId, thread.virtualMcpId, { - main: thread.hasPreview ? "preview" : "board", - }); - }} + onOpenThread={openThread} /> !open && setRerunTargets([])} + onOpenChange={(open) => !open && clearRerun()} onConfirm={confirmRerun} /> @@ -2086,22 +2065,6 @@ type FooterAgent = { thread: TaskBoardItemThread; }; -/** Rank for picking which agent's row the card footer shows — lower wins. A - * running/awaiting-input agent is always the most important thing on the - * card; once nothing is running, a failure the user can act on is; a clean run - * outranks a settled failure (a superseded attempt, or a run that died after - * delivering — see `isResolvedRunFailure`), which is history and must not paint - * the card red; otherwise the most recently run agent wins. */ -function statusPriority(thread: TaskBoardItemThread): number { - if (thread.status === "in_progress" || thread.status === "requires_action") { - return 0; - } - if (thread.status === "failed") { - return isResolvedRunFailure(thread.failureKind) ? 3 : 1; - } - return 2; -} - /** * The card footer shows a single row for whichever agent thread — the Super * Agent's own run, or a QA/code-review thread — matters most right now: diff --git a/apps/web/src/layouts/task-board/task-comments.tsx b/apps/web/src/layouts/task-board/task-comments.tsx index 48c2723acf..00e3441f9e 100644 --- a/apps/web/src/layouts/task-board/task-comments.tsx +++ b/apps/web/src/layouts/task-board/task-comments.tsx @@ -26,9 +26,11 @@ import { ChevronSelectorVertical, DotsHorizontal, MessageCheckCircle, + RefreshCw01, Trash03, X, } from "@untitledui/icons"; +import { Button } from "@decocms/ui/components/button.tsx"; import { cn } from "@decocms/ui/lib/utils.ts"; import { MemoizedMarkdown } from "@/components/chat/markdown"; import { SuperAgentIcon } from "@/components/super-agent-icon"; @@ -178,9 +180,13 @@ function resolvedSummary(thread: TaskComment, t: TFunction): string { export function NewCommentComposer({ me, onSubmit, + onSubmitAndRerun, }: { me: CommentAuthor; onSubmit: (body: string) => void; + /** Post the comment and steer the agent with it. Absent when the task has no + * run to steer. */ + onSubmitAndRerun?: (body: string) => void; }) { const t = useT(); @@ -190,6 +196,7 @@ export function NewCommentComposer({ placeholder={t("taskBoard.taskDialog.commentPlaceholder")} author={me} onSubmit={onSubmit} + onSubmitAndRerun={onSubmitAndRerun} /> ); } @@ -326,20 +333,22 @@ function CommentComposer({ placeholder, author, onSubmit, + onSubmitAndRerun, }: { variant: "root" | "reply"; placeholder: string; author: CommentAuthor; onSubmit: (body: string) => void; + onSubmitAndRerun?: (body: string) => void; }) { const t = useT(); const ref = useRef(null); const [value, setValue] = useState(""); - const submit = () => { + const submit = (handler: (body: string) => void = onSubmit) => { const body = value.trim(); if (!body) return; - onSubmit(body); + handler(body); setValue(""); const el = ref.current; if (el) { @@ -377,7 +386,7 @@ function CommentComposer({ + )} + {actions} + ); } diff --git a/apps/web/src/layouts/task-board/task-dialog.tsx b/apps/web/src/layouts/task-board/task-dialog.tsx index f11b80e290..0d60a007c1 100644 --- a/apps/web/src/layouts/task-board/task-dialog.tsx +++ b/apps/web/src/layouts/task-board/task-dialog.tsx @@ -28,7 +28,6 @@ import { import { useCopy } from "@decocms/ui/hooks/use-copy.ts"; import { AlertCircle, - AlertSquare, Archive, Bookmark, Calendar, @@ -41,6 +40,7 @@ import { Copy06, DotsHorizontal, Edit05, + Eye, GitMerge, GitPullRequest, Globe01, @@ -78,6 +78,8 @@ import { STATUS_CONFIG, STATUSES, statusIconClassName, + statusPriority, + threadStatusStyle, SUPER_AGENT_ASSIGNEE_ID, tagDotColor, type Member, @@ -106,7 +108,6 @@ import { formatTimeAgo } from "@/lib/format-time"; import { GitHubIcon } from "@/components/icons/github-icon"; import { useConnections, useProjectContext } from "@/sdk"; import { listRepoScopeLabels } from "@decocms/shared/github-repo-scope"; -import { isResolvedRunFailure } from "@decocms/shared/entities"; import { AssigneePickerContent } from "./assignee-picker"; import { TagPickerContent } from "./tag-picker"; import { extractDescriptionLinks } from "./description-links"; @@ -332,9 +333,16 @@ function RecordSection({ ); } -export function TaskBoardItemDialog({ - open, +/** + * The task workspace: everything a card is, minus a host. Rendered full-height + * inside the main panel (the board swaps to it when a card is open) and, for + * the legacy surfaces, inside `TaskBoardItemDialog` below. + * + * Form state initialises from `item`, so the host must key this by task id. + */ +function TaskWorkspace({ onClose, + flushRef, item, defaultStatus, onSubmit, @@ -345,10 +353,13 @@ export function TaskBoardItemDialog({ onNewChat, onAutoFix, onRerun, + onRerunWithFeedback, isSaving, }: { - open: boolean; onClose: () => void; + /** Filled with the pending-write flush so a host that dismisses this without + * the close button (a modal overlay, Esc) doesn't drop a debounced edit. */ + flushRef?: { current: (() => void) | null }; /** Present in edit mode, prefills the form. */ item?: TaskBoardItem; /** In create mode, the status to start the new task in (e.g. the lane the @@ -375,6 +386,8 @@ export function TaskBoardItemDialog({ /** Edit mode only: hand the task to the Super Agent. */ onAutoFix?: () => void; onRerun?: () => void; + /** Re-run the task, leading the prompt with this comment. */ + onRerunWithFeedback?: (feedback: string) => void; isSaving?: boolean; }) { const t = useT(); @@ -461,6 +474,7 @@ export function TaskBoardItemDialog({ if (saveTimer.current) clearTimeout(saveTimer.current); if (debounce) saveTimer.current = setTimeout(commit, AUTOSAVE_DELAY_MS); else commit(); + if (flushRef) flushRef.current = flush; }; /** Write a pending edit now instead of waiting out the debounce. */ @@ -504,6 +518,9 @@ export function TaskBoardItemDialog({ ? members.find((m) => m.userId === item.assignedBy) : undefined; const StatusIcon = STATUS_CONFIG[status].icon; + const previewThread = item?.threads.find( + (th) => th.hasPreview && th.virtualMcpId, + ); // Reports-generated tasks: content (title/description/priority) is owned by // the reports sync, which refreshes it on open items — TASK_BOARD_ITEM_UPDATE // rejects a write that touches any of those fields. Board interactions @@ -511,744 +528,702 @@ export function TaskBoardItemDialog({ const contentLocked = !!item && isReportsTask(item); return ( - !next && close()}> - - - {item - ? t("taskBoard.taskDialog.editTaskTitle") - : t("taskBoard.taskDialog.newTaskTitle")} - - - {/* Header row: the task's id on the left, its actions on the right. +
+ {/* Header row: the task's id on the left, its actions on the right. Outside the scroll area, so it never moves. */} -
- {/* Null only for a card written before the key backfill, which has +
+ {/* Null only for a card written before the key backfill, which has no key to show. */} - {key ? ( - - ) : ( - /* Create mode: the key is minted on save. A placeholder keeps the + className="-ml-2 gap-2 px-2 text-[15px] text-muted-foreground hover:text-foreground" + onClick={() => { + copyId(key); + toast.success(t("taskBoard.taskDialog.idCopied")); + }} + > + {idCopied ? : } + {key} + + ) : ( + /* Create mode: the key is minted on save. A placeholder keeps the row from reading as broken. */ - - – + + – + + )} + +
+ {/* Autosave has no button, so this is the only sign of a write. */} + {item && isSaving && ( + + {t("taskBoard.taskDialog.savingLabel")} )} - -
- {/* Autosave has no button, so this is the only sign of a write. */} - {item && isSaving && ( - - {t("taskBoard.taskDialog.savingLabel")} - - )} - {item && ( - <> - - - - - - - {onNewChat && ( - - - {t("taskBoard.taskDialog.newChatButton")} - - )} - {showAutoFix && ( - - - {t("taskBoard.taskBoard.autoFix")} - - )} - {showRerun && ( - - - {t("taskBoard.taskBoard.rerun")} - - )} - {(onNewChat || showAutoFix || showRerun) && ( - - )} - {description && ( - handleCopy(description)} - > - {copied ? : } - {t("taskBoard.taskDialog.copyDescription")} - - )} - {onClone && ( - - - {t("taskBoard.taskDialog.cloneTask")} - - )} - {onArchive && status !== "archived" && ( - - - {t("taskBoard.taskDialog.archiveTask")} - - )} - {onDelete && ( - - - {t("taskBoard.taskDialog.deleteTask")} - - )} - - - - )} + {item && previewThread && ( -
+ )} + {item && ( + <> + + + + + + + {onNewChat && ( + + + {t("taskBoard.taskDialog.newChatButton")} + + )} + {showAutoFix && ( + + + {t("taskBoard.taskBoard.autoFix")} + + )} + {showRerun && ( + + + {t("taskBoard.taskBoard.rerun")} + + )} + {(onNewChat || showAutoFix || showRerun) && ( + + )} + {description && ( + handleCopy(description)}> + {copied ? : } + {t("taskBoard.taskDialog.copyDescription")} + + )} + {onClone && ( + + + {t("taskBoard.taskDialog.cloneTask")} + + )} + {onArchive && status !== "archived" && ( + + + {t("taskBoard.taskDialog.archiveTask")} + + )} + {onDelete && ( + + + {t("taskBoard.taskDialog.deleteTask")} + + )} + + + + )} +
+
-
- {/* Editor pane — content-height on mobile so it doesn't leave a big +
+ {/* Editor pane — content-height on mobile so it doesn't leave a big gap above the properties; fills the column on desktop. */} -
-
-