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 ? (