diff --git a/apps/web/src/components/thread/github/publish-dialog.tsx b/apps/web/src/components/thread/github/publish-dialog.tsx index 023094aba2..a80f8725c0 100644 --- a/apps/web/src/components/thread/github/publish-dialog.tsx +++ b/apps/web/src/components/thread/github/publish-dialog.tsx @@ -39,6 +39,7 @@ import { import type { PrSummary } from "./use-pr-data.ts"; import { useSandboxStart } from "@/components/sandbox/hooks/use-sandbox-start"; import { publishToBaseLabel } from "./publish-label.ts"; +import { useRecordPrOnBoard } from "@/hooks/use-record-pr-on-board"; import { useResolvedPublishGate } from "@/components/sandbox/hooks/use-publish-gate.ts"; import { combinePublishDiffs, @@ -129,6 +130,7 @@ function PublishDialogBody({ onPublished, }: PublishDialogProps) { const t = useT(); + const recordPrOnBoard = useRecordPrOnBoard(); const githubClient = useMCPClient({ connectionId: githubConnectionId, orgId, @@ -360,9 +362,14 @@ function PublishDialogBody({ setIsPublishing(true); setPublishError(undefined); try { - await runPublishFlow(publishTarget, messageParts(), t); + const pr = await runPublishFlow(publishTarget, messageParts(), t); toast.success(t("thread.publishDialog.publishedTo", { baseBranch })); + await recordPrOnBoard({ + url: pr.htmlUrl, + repo: `${owner}/${repo}`, + merged: true, + }); handleOpenChange(false); setGitDiff(null); setPublishTitle(""); @@ -430,6 +437,7 @@ function PublishDialogBody({ const pr = await runSubmitForReviewFlow(publishTarget, messageParts()); notifySubmittedForReview(pr, t); + await recordPrOnBoard({ url: pr.htmlUrl, repo: `${owner}/${repo}` }); handleOpenChange(false); await onPullRequestChanged?.(); } catch (error) { diff --git a/apps/web/src/components/thread/github/use-cms-publish-actions.ts b/apps/web/src/components/thread/github/use-cms-publish-actions.ts index fa9a38b849..1ceb7135e0 100644 --- a/apps/web/src/components/thread/github/use-cms-publish-actions.ts +++ b/apps/web/src/components/thread/github/use-cms-publish-actions.ts @@ -19,6 +19,7 @@ import { type PublishTarget, } from "./publish-flow.ts"; import { discardGitFiles } from "./sandbox-git-api.ts"; +import { useRecordPrOnBoard } from "@/hooks/use-record-pr-on-board"; /** `publish` merges to production; `review` stops at the pull request. */ export type CmsPublishMode = "publish" | "review"; @@ -66,6 +67,7 @@ export function useCmsPublishActions( onPublished, } = args; const t = useT(); + const recordPrOnBoard = useRecordPrOnBoard(); const [isPublishing, setIsPublishing] = useState(false); const [isDiscarding, setIsDiscarding] = useState(false); const [publishError, setPublishError] = useState(); @@ -81,8 +83,13 @@ export function useCmsPublishActions( setIsPublishing(true); setPublishError(undefined); try { - await runPublishFlow(target, noteParts(), t); + const pr = await runPublishFlow(target, noteParts(), t); + await recordPrOnBoard({ + url: pr.htmlUrl, + repo: `${target.owner}/${target.repo}`, + merged: true, + }); toast.success( destinationHost ? t("thread.publishPopover.publishedTo", { host: destinationHost }) @@ -113,6 +120,10 @@ export function useCmsPublishActions( const pr = await runSubmitForReviewFlow(target, noteParts()); notifySubmittedForReview(pr, t); + await recordPrOnBoard({ + url: pr.htmlUrl, + repo: `${target.owner}/${target.repo}`, + }); onOpenChange(false); await onPullRequestChanged?.(); } catch (error) { diff --git a/apps/web/src/components/thread/open-in-board-button.tsx b/apps/web/src/components/thread/open-in-board-button.tsx deleted file mode 100644 index 174e01b33d..0000000000 --- a/apps/web/src/components/thread/open-in-board-button.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useNavigate } from "@tanstack/react-router"; -import { LayoutAlt01 } from "@untitledui/icons"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@decocms/ui/components/tooltip.tsx"; -import { useChatTask } from "@/components/chat/chat-context"; -import { useBoardTaskForThread } from "@/hooks/use-task-for-thread"; -import { useT } from "@/i18n/use-t.ts"; -import { ToolbarIconButton } from "@/components/toolbar-icon-button"; - -/** - * Header action: when the current thread is linked to a task board item, open - * the Tasks overlay in the main panel with that task's modal open (same - * `?main=board` surface as the Tasks toggle). Renders nothing when the thread - * has no linked task (or the board is disabled — the lookup returns null then). - */ -export function OpenInBoardButton() { - const t = useT(); - const { taskId } = useChatTask(); - const navigate = useNavigate(); - const boardTaskId = useBoardTaskForThread(taskId)?.id ?? null; - - if (!boardTaskId) return null; - - return ( - - - - - navigate({ - to: ".", - search: (prev: Record) => ({ - ...prev, - main: "board", - task: boardTaskId, - }), - }) - } - > - - - - - {t("thread.openInBoardButton.openTaskInBoard")} - - - - ); -} diff --git a/apps/web/src/hooks/use-record-pr-on-board.ts b/apps/web/src/hooks/use-record-pr-on-board.ts new file mode 100644 index 0000000000..0875fa578f --- /dev/null +++ b/apps/web/src/hooks/use-record-pr-on-board.ts @@ -0,0 +1,74 @@ +/** + * Every PR opened from the product lands on the board. + * + * Publishing from the CMS or from a chat used to leave no trace on the board: + * the work shipped and the card never existed, so the board stopped being the + * record of what the team was doing. A PR is durable work by definition, so + * this promotes the thread (or updates its existing card) the moment one opens. + * + * Shared by the chat publish dialog and the CMS publish popover because they + * share `publish-flow.ts` — one meaning of "this shipped" for both. + */ + +import { useNavigate } from "@tanstack/react-router"; +import { toast } from "sonner"; +import { taskKey } from "@decocms/shared/task-key"; +import { useOptionalChatTask } from "@/components/chat/context"; +import { useOptionalThreadManager } from "@/components/chat/store/hooks"; +import { usePromoteThreadToTask } from "@/hooks/use-promote-thread-to-task"; +import { useProjectContext } from "@/sdk"; +import { useT } from "@/i18n/use-t.ts"; + +export interface RecordPrInput { + /** The PR that just opened. */ + url: string; + /** `owner/name` of the repo it opened against. */ + repo?: string | null; + /** True when the change is already merged (publish-and-merge). */ + merged?: boolean; +} + +export function useRecordPrOnBoard() { + const t = useT(); + const { org } = useProjectContext(); + const navigate = useNavigate(); + const threadId = useOptionalChatTask()?.taskId; + const manager = useOptionalThreadManager(); + const promote = usePromoteThreadToTask(); + + return async (input: RecordPrInput) => { + if (!threadId) return; + try { + const thread = manager?.threads.get().find((row) => row.id === threadId); + const task = await promote({ + threadId, + title: thread?.title?.trim() || t("thread.addToBoard.defaultTitle"), + repo: input.repo ?? null, + prUrl: input.url, + status: input.merged ? "in_review" : "in_progress", + }); + toast.success( + t("thread.addToBoard.added", { + key: taskKey(org.slug, task.keySeq) ?? task.title, + }), + { + action: { + label: t("thread.addToBoard.openTask"), + onClick: () => + navigate({ + to: ".", + search: (prev: Record) => ({ + ...prev, + main: "board", + task: task.id, + }), + }), + }, + }, + ); + } catch { + /* Publishing succeeded; failing to file the card must not read as a + failed publish. The board reconciles on its next list. */ + } + }; +} diff --git a/apps/web/src/i18n/en/thread.ts b/apps/web/src/i18n/en/thread.ts index 17f0eabcaa..0e7de782b0 100644 --- a/apps/web/src/i18n/en/thread.ts +++ b/apps/web/src/i18n/en/thread.ts @@ -132,10 +132,10 @@ export const thread = { "Getting your environment ready — this only takes a moment", "thread.headerActions.waitingForSandboxBranchTooltip": "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.addToBoard.added": "Added to board · {key}", + "thread.addToBoard.openTask": "Open task", "thread.publishDialog.allChangesDiscarded": "All changes discarded", "thread.publishDialog.branchLabel": "Branch:", "thread.publishDialog.cancel": "Cancel", diff --git a/apps/web/src/i18n/pt-br/thread.ts b/apps/web/src/i18n/pt-br/thread.ts index dafc95726e..6a8bee6765 100644 --- a/apps/web/src/i18n/pt-br/thread.ts +++ b/apps/web/src/i18n/pt-br/thread.ts @@ -138,10 +138,10 @@ export const thread = { "Preparando seu ambiente — leva só um instante", "thread.headerActions.waitingForSandboxBranchTooltip": "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.addToBoard.added": "Adicionado ao quadro · {key}", + "thread.addToBoard.openTask": "Abrir tarefa", "thread.publishDialog.allChangesDiscarded": "Todas as alterações foram descartadas", "thread.publishDialog.branchLabel": "Branch:", diff --git a/apps/web/src/views/virtual-mcp/header-info.tsx b/apps/web/src/views/virtual-mcp/header-info.tsx index d29f68c94b..4913a4778b 100644 --- a/apps/web/src/views/virtual-mcp/header-info.tsx +++ b/apps/web/src/views/virtual-mcp/header-info.tsx @@ -4,7 +4,6 @@ import { resolveFastPreview } from "@/sdk/fast-preview"; import { CmsHeaderActions } from "../../components/thread/github/cms-header-actions.tsx"; import { HeaderActions } from "../../components/thread/github/header-actions.tsx"; import { DevAgentControl } from "../../components/dev-agent/dev-agent-control.tsx"; -import { OpenInBoardButton } from "../../components/thread/open-in-board-button.tsx"; /** * The agent's header actions (dev-agent control + GitHub publish/PR buttons), @@ -23,7 +22,6 @@ export function VirtualMcpHeaderInfo({ return (
- {agentShowsGithubHeaderActions(virtualMcp) ? ( fastPreviewActive ? (