From 884ffc5123fa99cb79f62ecc5a65bbc7aa77fcc0 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 09:38:04 +0100 Subject: [PATCH] Pin a PR to the work panel manually MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inverse of Unlink: when the panel shows nothing (agent never posted the link, discovery found no match) or the wrong PR, "Pin a pull request…" / "Not this chat's PR? Change" opens an inline URL input. A manual pin outranks every automatic source — posted links, remembered auto pins, and branch discovery — and the auto-pin effect never downgrades it. Pins store {href, manual} (older bare-string entries still parse). Co-Authored-By: Claude Fable 5 --- .../features/chats/lib/chatWorkAutomation.ts | 35 ++++- .../src/features/chats/ui/ChatWorkPanel.tsx | 120 +++++++++++++++--- desktop/tests/e2e/chats-first-message.spec.ts | 31 +++++ 3 files changed, 161 insertions(+), 25 deletions(-) diff --git a/desktop/src/features/chats/lib/chatWorkAutomation.ts b/desktop/src/features/chats/lib/chatWorkAutomation.ts index 11b00fad1..f3989e6c8 100644 --- a/desktop/src/features/chats/lib/chatWorkAutomation.ts +++ b/desktop/src/features/chats/lib/chatWorkAutomation.ts @@ -151,12 +151,23 @@ const PR_STORAGE_PREFIX = "buzz:chat-work-pr:v1"; * worktree to the same PR — the pin keeps each chat on the PR it actually * resolved first, with posted links always overriding. */ -export function readChatPinnedPr(chatId: string): string | null { +export function readChatPinnedPr(chatId: string): ChatPinnedPr | null { if (typeof window === "undefined") { return null; } try { - return window.localStorage.getItem(`${PR_STORAGE_PREFIX}:${chatId}`); + const raw = window.localStorage.getItem(`${PR_STORAGE_PREFIX}:${chatId}`); + if (raw === null) { + return null; + } + // Older entries stored the bare href string. + if (!raw.startsWith("{")) { + return { href: raw, manual: raw === CHAT_PR_UNPINNED }; + } + const parsed = JSON.parse(raw) as Partial; + return typeof parsed.href === "string" + ? { href: parsed.href, manual: Boolean(parsed.manual) } + : null; } catch { return null; } @@ -168,12 +179,28 @@ export function readChatPinnedPr(chatId: string): string | null { */ export const CHAT_PR_UNPINNED = ""; -export function writeChatPinnedPr(chatId: string, href: string) { +export type ChatPinnedPr = { + href: string; + /** + * True when the user pinned (or unlinked) explicitly — a manual pin + * outranks every automatic source, including links posted in the chat. + */ + manual: boolean; +}; + +export function writeChatPinnedPr( + chatId: string, + href: string, + manual = false, +) { if (typeof window === "undefined") { return; } try { - window.localStorage.setItem(`${PR_STORAGE_PREFIX}:${chatId}`, href); + window.localStorage.setItem( + `${PR_STORAGE_PREFIX}:${chatId}`, + JSON.stringify({ href, manual }), + ); } catch { // Best-effort. } diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index 4904d3555..26f33049d 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -8,10 +8,12 @@ import { GitBranch, LoaderCircle, MessageSquareText, + Pin, } from "lucide-react"; import { CHAT_PR_UNPINNED, + type ChatPinnedPr, readChatPinnedPr, updateChatWorkAutomation, useChatWorkAutomation, @@ -28,6 +30,8 @@ import { import { parseSupportedLinkPreview } from "@/shared/lib/linkPreview"; import { cn } from "@/shared/lib/cn"; import { AnimatedTitleText } from "@/shared/ui/animated-title-text"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; import { Checkbox } from "@/shared/ui/checkbox"; import { GithubPullRequestCard } from "@/shared/ui/link-preview-attachment"; @@ -86,35 +90,58 @@ export function ChatWorkPanel({ // hidden panel stops polling entirely. const monitorActive = open || automation.autoFixCi || automation.addressComments; - // Pin resolution order: a link posted in THIS chat wins, then the chat's - // previously pinned PR, then branch discovery — discovery alone is - // ambiguous when agents reuse a worktree across chats in one project. - const [pinnedHref, setPinnedHref] = React.useState(() => + // Pin resolution order: a MANUAL pin outranks everything (the user said + // "this is the PR"), then a link posted in THIS chat, then the remembered + // auto pin, then branch discovery — discovery alone is ambiguous when + // agents reuse a worktree across chats in one project. + const [pinned, setPinned] = React.useState(() => readChatPinnedPr(chatId), ); React.useEffect(() => { - setPinnedHref(readChatPinnedPr(chatId)); + setPinned(readChatPinnedPr(chatId)); }, [chatId]); // The empty-string sentinel means "user unlinked — no PR for this chat": // discovery stays off, posted links still win. - const isUnpinned = pinnedHref === CHAT_PR_UNPINNED; + const isUnpinned = pinned?.href === CHAT_PR_UNPINNED && pinned.manual; const discoveredPrQuery = useGithubPrForBranchQuery( - monitorActive && !prHref && pinnedHref === null ? projectPath : null, + monitorActive && !prHref && pinned === null ? projectPath : null, branch, ); + const manualHref = pinned?.manual && pinned.href ? pinned.href : null; const effectiveHref = + manualHref ?? prHref ?? - (isUnpinned ? null : (pinnedHref ?? discoveredPrQuery.data ?? null)); + (isUnpinned + ? null + : ((pinned?.href || null) ?? discoveredPrQuery.data ?? null)); React.useEffect(() => { - if (effectiveHref && effectiveHref !== readChatPinnedPr(chatId)) { + // Remember what the chat resolved to — but never downgrade a manual pin. + const current = readChatPinnedPr(chatId); + if (current?.manual) { + return; + } + if (effectiveHref && effectiveHref !== current?.href) { writeChatPinnedPr(chatId, effectiveHref); - setPinnedHref(effectiveHref); + setPinned({ href: effectiveHref, manual: false }); } }, [chatId, effectiveHref]); const handleUnlinkPr = React.useCallback(() => { - writeChatPinnedPr(chatId, CHAT_PR_UNPINNED); - setPinnedHref(CHAT_PR_UNPINNED); + writeChatPinnedPr(chatId, CHAT_PR_UNPINNED, true); + setPinned({ href: CHAT_PR_UNPINNED, manual: true }); }, [chatId]); + const [isPinEditorOpen, setIsPinEditorOpen] = React.useState(false); + const [pinInput, setPinInput] = React.useState(""); + const handlePinSubmit = React.useCallback(() => { + const trimmed = pinInput.trim(); + if (!parseGithubPullRequestRef(trimmed)) { + toast.error("Enter a full GitHub pull request URL"); + return; + } + writeChatPinnedPr(chatId, trimmed, true); + setPinned({ href: trimmed, manual: true }); + setIsPinEditorOpen(false); + setPinInput(""); + }, [chatId, pinInput]); const preview = effectiveHref ? parseSupportedLinkPreview(effectiveHref) : null; @@ -275,15 +302,26 @@ export function ChatWorkPanel({ key={preview.href} > - {!prHref ? ( - + {manualHref || !prHref ? ( +
+ + + +
) : null}
) : null} + {isPinEditorOpen ? ( +
{ + event.preventDefault(); + handlePinSubmit(); + }} + > + setPinInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + setIsPinEditorOpen(false); + setPinInput(""); + } + }} + placeholder="https://github.com/owner/repo/pull/123" + value={pinInput} + /> + +
+ ) : !preview ? ( + + ) : null} diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index d197f3144..9ff146f76 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -288,6 +288,37 @@ test("new chat screen shows agent, directory, and invite preset cards", async ({ await page.screenshot({ path: "test-results/chat-start-presets.png" }); }); +test("a PR can be pinned to the work panel manually", async ({ page }) => { + await installMockBridge(page); + await page.goto("/#/chats"); + const composer = page.locator("[contenteditable='true'], textarea").first(); + await expect(composer).toBeVisible(); + await composer.click(); + await composer.fill("pin a pr here"); + await composer.press("Enter"); + await expect(page).toHaveURL(/\/chats\/.+/); + + await page.getByTestId("toggle-work-panel").click(); + const workPanel = page.getByTestId("chat-work-panel"); + await expect(workPanel).toBeVisible(); + + await page.getByTestId("chat-work-pin-pr").click(); + await page + .getByTestId("chat-work-pin-input") + .fill("https://github.com/block/buzz/pull/1460"); + await page.getByRole("button", { name: "Pin", exact: true }).click(); + await expect( + workPanel.locator("[data-link-preview='github-pull-request']"), + ).toBeVisible({ timeout: 10_000 }); + + // Unlink clears it back to the empty state. + await page.getByTestId("chat-work-unlink-pr").click(); + await expect( + workPanel.locator("[data-link-preview='github-pull-request']"), + ).toHaveCount(0); + await expect(page.getByTestId("chat-work-pin-pr")).toBeVisible(); +}); + test("sidebar chat title shimmers while the agent has an active turn", async ({ page, }) => {