mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Pin a PR to the work panel manually
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
7cf50584f2
commit
884ffc5123
@@ -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<ChatPinnedPr>;
|
||||
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.
|
||||
}
|
||||
|
||||
@@ -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<string | null>(() =>
|
||||
// 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<ChatPinnedPr | null>(() =>
|
||||
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}
|
||||
>
|
||||
<GithubPullRequestCard className="w-full" preview={preview} />
|
||||
{!prHref ? (
|
||||
<button
|
||||
className="self-end text-2xs text-muted-foreground/70 hover:text-foreground hover:underline"
|
||||
data-testid="chat-work-unlink-pr"
|
||||
onClick={handleUnlinkPr}
|
||||
type="button"
|
||||
>
|
||||
Not this chat's PR? Unlink
|
||||
</button>
|
||||
{manualHref || !prHref ? (
|
||||
<div className="flex items-center justify-end gap-2 text-2xs text-muted-foreground/70">
|
||||
<button
|
||||
className="hover:text-foreground hover:underline"
|
||||
data-testid="chat-work-change-pr"
|
||||
onClick={() => setIsPinEditorOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
Not this chat's PR? Change
|
||||
</button>
|
||||
<span aria-hidden="true">·</span>
|
||||
<button
|
||||
className="hover:text-foreground hover:underline"
|
||||
data-testid="chat-work-unlink-pr"
|
||||
onClick={handleUnlinkPr}
|
||||
type="button"
|
||||
>
|
||||
Unlink
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<details
|
||||
className={cn(CHIP_CLASS, "group/ci block p-0")}
|
||||
@@ -381,6 +419,46 @@ export function ChatWorkPanel({
|
||||
</details>
|
||||
</div>
|
||||
) : null}
|
||||
{isPinEditorOpen ? (
|
||||
<form
|
||||
className="flex items-center gap-1.5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handlePinSubmit();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-8 flex-1 text-xs"
|
||||
data-testid="chat-work-pin-input"
|
||||
onChange={(event) => setPinInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsPinEditorOpen(false);
|
||||
setPinInput("");
|
||||
}
|
||||
}}
|
||||
placeholder="https://github.com/owner/repo/pull/123"
|
||||
value={pinInput}
|
||||
/>
|
||||
<Button size="sm" type="submit" variant="secondary">
|
||||
Pin
|
||||
</Button>
|
||||
</form>
|
||||
) : !preview ? (
|
||||
<button
|
||||
className={cn(
|
||||
CHIP_CLASS,
|
||||
"cursor-pointer text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground",
|
||||
)}
|
||||
data-testid="chat-work-pin-pr"
|
||||
onClick={() => setIsPinEditorOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<Pin className="h-3.5 w-3.5 shrink-0" />
|
||||
Pin a pull request…
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user