From f3a035c424132b9d728df49e1c92c4cd65c66045 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 07:20:02 +0100 Subject: [PATCH] Animate the work panel only for new information MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch swap and the card pop-in replayed on every visit to a chat: the panel mounts on "No current branch", async PR data resolves a beat later, and the change animated even though nothing was new. The panel now remembers the last branch/PR shown per chat — revisits render the cached value statically from the first frame, the pop-in decision is latched per mount (recording the href as seen must not strip the class mid-animation), and the branch text is keyed by chat so switching chats never animates one chat's branch into another's. A genuinely new branch or PR still animates exactly once. Co-Authored-By: Claude Fable 5 --- .../src/features/chats/ui/ChatWorkPanel.tsx | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index 23f2d774d..80b0ee274 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -30,6 +30,13 @@ import { GithubPullRequestCard } from "@/shared/ui/link-preview-attachment"; const CHIP_CLASS = "flex items-center gap-1.5 rounded-2xl border border-border/70 bg-muted/30 px-3 py-2.5 text-xs"; +// Last branch/PR the panel showed per chat. Async sources (the PR query) +// resolve after mount, and without this the chip replays its swap animation +// and the card replays its pop-in on every visit to the chat — the +// animations should mark NEW information, not navigation. +const lastShownBranchByChat = new Map(); +const lastShownPrByChat = new Map(); + /** * Right-hand work drawer for a chat: branch, live PR card, and a CI monitor * once the agent has produced a pull request; an empty state before that. @@ -83,7 +90,35 @@ export function ChatWorkPanel({ const openThreads = commentStateQuery.data?.openThreads ?? 0; // Live activity wins over the PR's head ref: the agent may have moved to a // new worktree since opening the PR, and activity updates immediately. - const currentBranch = branch?.trim() || pr?.headRef?.trim() || null; + // While async sources are still resolving, fall back to what this chat + // last showed so a revisit renders the branch statically from the first + // frame instead of animating in from the placeholder. + const resolvedBranch = branch?.trim() || pr?.headRef?.trim() || null; + const currentBranch = + resolvedBranch ?? lastShownBranchByChat.get(chatId) ?? null; + React.useEffect(() => { + if (resolvedBranch) { + lastShownBranchByChat.set(chatId, resolvedBranch); + } + }, [chatId, resolvedBranch]); + // Latched per href for this mount: the effect below records the href as + // seen immediately, and un-latching would strip the class mid-animation. + const prEntranceDecisions = React.useRef(new Map()); + let isNewPrForChat = false; + if (preview) { + const latched = prEntranceDecisions.current.get(preview.href); + if (latched === undefined) { + isNewPrForChat = lastShownPrByChat.get(chatId) !== preview.href; + prEntranceDecisions.current.set(preview.href, isNewPrForChat); + } else { + isNewPrForChat = latched; + } + } + React.useEffect(() => { + if (preview?.href) { + lastShownPrByChat.set(chatId, preview.href); + } + }, [chatId, preview?.href]); // Automation: prompt the agent on CI failure / newly-open review threads. // Watermarks in storage keep this to one nudge per failing sha and per @@ -145,6 +180,10 @@ export function ChatWorkPanel({ "min-w-0", currentBranch ? "font-mono" : "text-muted-foreground", )} + // Keyed by chat: switching chats remounts the text statically + // (first render never animates) — only an in-place branch + // change for THIS chat plays the swap. + key={chatId} text={currentBranch ?? "No current branch"} /> @@ -152,7 +191,10 @@ export function ChatWorkPanel({ // Keyed by href so a NEW pull request re-runs the pop-in, not // just the first one.