From 463c74d5a20c1a2030f2cad142fb2a08cabf5a4d Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 13:15:16 +0100 Subject: [PATCH] Show the agent's live branch in the work panel, animated on change The branch chip previously only knew head.ref from the GitHub PR, so it sat on "No current branch" until a PR existed and snapped when it did. The chat's channel-scoped transcript already carries the agent's shell commands, so the panel now derives the current branch from the latest `git worktree add` / `checkout -b` / `switch` the agent ran (live activity wins over the PR's head ref) and swaps the chip text with the same character animation the chat title uses. File checkouts, `--` restores, and bare shas are ignored so only real branch moves update the chip. Co-Authored-By: Claude Fable 5 --- .../chats/lib/chatWorkBranch.test.mjs | 93 +++++++++++++ .../src/features/chats/lib/chatWorkBranch.ts | 124 ++++++++++++++++++ desktop/src/features/chats/ui/ChatDetail.tsx | 8 ++ .../src/features/chats/ui/ChatWorkPanel.tsx | 23 +++- 4 files changed, 241 insertions(+), 7 deletions(-) create mode 100644 desktop/src/features/chats/lib/chatWorkBranch.test.mjs create mode 100644 desktop/src/features/chats/lib/chatWorkBranch.ts diff --git a/desktop/src/features/chats/lib/chatWorkBranch.test.mjs b/desktop/src/features/chats/lib/chatWorkBranch.test.mjs new file mode 100644 index 0000000000..cdf9339f10 --- /dev/null +++ b/desktop/src/features/chats/lib/chatWorkBranch.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + deriveChatWorkBranch, + parseBranchFromCommand, +} from "./chatWorkBranch.ts"; + +function toolItem(command, overrides = {}) { + return { + id: overrides.id ?? command, + type: "tool", + renderClass: "generic", + descriptor: { category: "shell" }, + title: "Ran command", + toolName: "shell", + buzzToolName: null, + status: "completed", + args: { command }, + result: "", + isError: false, + timestamp: "2026-07-04T00:00:00Z", + startedAt: "2026-07-04T00:00:00Z", + completedAt: null, + channelId: overrides.channelId ?? "chat-1", + turnId: overrides.turnId ?? "turn-1", + }; +} + +test("worktree add with -b names the new branch", () => { + assert.equal( + parseBranchFromCommand("git worktree add ../wt/feature -b feature-x"), + "feature-x", + ); +}); + +test("worktree add with existing branch uses the second positional", () => { + assert.equal( + parseBranchFromCommand("git worktree add ../wt/fix fix-panel"), + "fix-panel", + ); +}); + +test("worktree add with only a path uses the basename", () => { + assert.equal( + parseBranchFromCommand("git worktree add /tmp/worktrees/chat-panel"), + "chat-panel", + ); +}); + +test("checkout -b and switch -c name the branch", () => { + assert.equal( + parseBranchFromCommand("git checkout -b kenny/new-panel"), + "kenny/new-panel", + ); + assert.equal(parseBranchFromCommand("git switch -c wip"), "wip"); +}); + +test("plain switch to an existing branch counts", () => { + assert.equal(parseBranchFromCommand("git switch main"), "main"); +}); + +test("compound commands take the last branch operation", () => { + assert.equal( + parseBranchFromCommand( + "cd /repo && git fetch origin && git worktree add ../wt -b first && git -C ../wt checkout -b second", + ), + "second", + ); +}); + +test("file checkouts, detached heads, and non-git commands are ignored", () => { + assert.equal(parseBranchFromCommand("git checkout -- src/app.ts"), null); + assert.equal(parseBranchFromCommand("git checkout src/a.ts src/b.ts"), null); + assert.equal(parseBranchFromCommand("git checkout deadbeefcafe"), null); + assert.equal(parseBranchFromCommand("cargo build --release"), null); + assert.equal(parseBranchFromCommand("echo git checkout"), null); +}); + +test("deriveChatWorkBranch returns the latest branch across the transcript", () => { + const transcript = [ + toolItem("ls -la", { id: "1" }), + toolItem("git worktree add ../wt -b first-branch", { id: "2" }), + { id: "3", type: "lifecycle", renderClass: "status" }, + toolItem("git checkout -b second-branch", { id: "4" }), + toolItem("cargo test", { id: "5" }), + ]; + assert.equal(deriveChatWorkBranch(transcript), "second-branch"); +}); + +test("deriveChatWorkBranch is null without branch activity", () => { + assert.equal(deriveChatWorkBranch([toolItem("pnpm test")]), null); +}); diff --git a/desktop/src/features/chats/lib/chatWorkBranch.ts b/desktop/src/features/chats/lib/chatWorkBranch.ts new file mode 100644 index 0000000000..8e74a9a55f --- /dev/null +++ b/desktop/src/features/chats/lib/chatWorkBranch.ts @@ -0,0 +1,124 @@ +import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes"; +import { getToolString } from "@/features/agents/ui/agentSessionUtils"; + +/** + * Derive the branch the chat's agent is currently working on from its shell + * activity: the latest `git worktree add` / `git checkout` / `git switch` + * that names a branch wins. This is what lets the work panel show a branch + * as soon as the agent sets up a worktree — before any PR exists to report + * `head.ref`. + */ +export function deriveChatWorkBranch( + transcript: readonly TranscriptItem[], +): string | null { + let branch: string | null = null; + for (const item of transcript) { + if (item.type !== "tool") { + continue; + } + const command = getToolString(item.args, ["command"]); + if (!command) { + continue; + } + const parsed = parseBranchFromCommand(command); + if (parsed) { + branch = parsed; + } + } + return branch; +} + +/** Latest branch named by any segment of a (possibly compound) command. */ +export function parseBranchFromCommand(command: string): string | null { + let branch: string | null = null; + for (const segment of command.split(/&&|\|\||;|\n/)) { + const parsed = parseBranchFromSegment(segment.trim()); + if (parsed) { + branch = parsed; + } + } + return branch; +} + +function parseBranchFromSegment(segment: string): string | null { + const tokens = tokenize(segment); + const gitIndex = tokens.indexOf("git"); + if (gitIndex === -1) { + return null; + } + // Skip `git -C …` style global flags between `git` and the verb. + let verbIndex = gitIndex + 1; + while (verbIndex < tokens.length && tokens[verbIndex].startsWith("-")) { + verbIndex += tokens[verbIndex] === "-C" ? 2 : 1; + } + const verb = tokens[verbIndex]; + const rest = tokens.slice(verbIndex + 1); + + if (verb === "worktree" && rest[0] === "add") { + return parseWorktreeAdd(rest.slice(1)); + } + if (verb === "checkout" || verb === "switch") { + return parseCheckoutOrSwitch(rest); + } + return null; +} + +function parseWorktreeAdd(args: string[]): string | null { + const flagged = valueOfFlag(args, ["-b", "-B"]); + if (flagged) { + return flagged; + } + const positional = args.filter((token) => !token.startsWith("-")); + if (positional.length >= 2) { + // `git worktree add ` — a commit-ish second arg (sha) + // isn't a branch name worth showing. + return looksLikeSha(positional[1]) ? null : positional[1]; + } + if (positional.length === 1) { + // `git worktree add ` creates a branch named after the basename. + const parts = positional[0].split("/").filter(Boolean); + return parts[parts.length - 1] || null; + } + return null; +} + +function parseCheckoutOrSwitch(args: string[]): string | null { + const flagged = valueOfFlag(args, ["-b", "-B", "-c", "-C"]); + if (flagged) { + return flagged; + } + // Plain `git checkout ` / `git switch `: only a single + // non-flag argument reads as a branch move — more args means file paths, + // and `--` restores files, and a bare sha is a detached head. + if (args.includes("--")) { + return null; + } + const positional = args.filter((token) => !token.startsWith("-")); + if (positional.length !== 1 || looksLikeSha(positional[0])) { + return null; + } + return positional[0]; +} + +function valueOfFlag(args: string[], flags: string[]): string | null { + for (let index = 0; index < args.length; index += 1) { + if (flags.includes(args[index])) { + const value = args[index + 1]; + if (value && !value.startsWith("-")) { + return value; + } + } + } + return null; +} + +function looksLikeSha(token: string): boolean { + return /^[0-9a-f]{7,40}$/i.test(token); +} + +function tokenize(segment: string): string[] { + return segment + .split(/\s+/) + .map((token) => token.replace(/^['"]|['"]$/g, "")) + .filter((token) => token.length > 0); +} diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index 00ce76f7c9..5a1edd530e 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -26,6 +26,7 @@ import { NO_PROJECT_SELECTION_ID, } from "@/features/chats/lib/chatSetup"; import { ChatActivityTranscript } from "@/features/chats/ui/ChatActivityTranscript"; +import { deriveChatWorkBranch } from "@/features/chats/lib/chatWorkBranch"; import { ChatWorkPanel } from "@/features/chats/ui/ChatWorkPanel"; import { isHumanFacingAssistantText } from "@/features/chats/ui/chatActivityText"; import { entranceClassForCreatedAt } from "@/features/chats/ui/messageEntrance"; @@ -144,6 +145,12 @@ export function ChatDetail({ }), [defaultAgent?.pubkey, messages, scopedTranscript], ); + // Branch the agent is on, straight from its worktree/checkout commands — + // the work panel shows it live, before any PR exists to report head.ref. + const workBranch = React.useMemo( + () => deriveChatWorkBranch(scopedTranscript), + [scopedTranscript], + ); const selectedProject = React.useMemo( () => chatProjectForMetadata(metadata), [metadata], @@ -625,6 +632,7 @@ export function ChatDetail({ void onSend(content, [])} open={showWorkPanel} diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index 1ac9f3a566..5d7a7fe373 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -22,6 +22,7 @@ import { } from "@/shared/lib/githubPullRequest"; import { parseSupportedLinkPreview } from "@/shared/lib/linkPreview"; import { cn } from "@/shared/lib/cn"; +import { AnimatedTitleText } from "@/shared/ui/animated-title-text"; import { Checkbox } from "@/shared/ui/checkbox"; import { GithubPullRequestCard } from "@/shared/ui/link-preview-attachment"; @@ -36,11 +37,14 @@ const CHIP_CLASS = * chat's agent automatically (deduped per head sha / open-thread watermark). */ export function ChatWorkPanel({ + branch = null, chatId, onAutomationPrompt, open = true, prHref, }: { + /** Live branch from the agent's worktree/checkout activity, if any. */ + branch?: string | null; chatId: string; onAutomationPrompt?: (content: string) => void; open?: boolean; @@ -55,6 +59,9 @@ export function ChatWorkPanel({ const commentStateQuery = useGithubCommentStateQuery(ref); const openThreads = commentStateQuery.data?.openThreads ?? 0; const automation = useChatWorkAutomation(chatId); + // 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; // Automation: prompt the agent on CI failure / newly-open review threads. // Watermarks in storage keep this to one nudge per failing sha and per @@ -101,13 +108,15 @@ export function ChatWorkPanel({
- {pr?.headRef?.trim() ? ( - - {pr.headRef.trim()} - - ) : ( - No current branch - )} + {/* Swaps with the title animation when the agent moves to a new + worktree/branch (or the placeholder resolves to a branch). */} +
{preview ? (