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 <noreply@anthropic.com>
This commit is contained in:
klopez4212
2026-07-04 13:15:16 +01:00
parent e787698a76
commit 463c74d5a2
4 changed files with 241 additions and 7 deletions

View File

@@ -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);
});

View File

@@ -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 <path> …` 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 <path> <branch>` — 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 <path>` 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 <branch>` / `git switch <branch>`: 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);
}

View File

@@ -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({
</div>
</div>
<ChatWorkPanel
branch={workBranch}
chatId={chat.id}
onAutomationPrompt={(content) => void onSend(content, [])}
open={showWorkPanel}

View File

@@ -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({
<div className="flex flex-col gap-2">
<div className={CHIP_CLASS}>
<GitBranch className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{pr?.headRef?.trim() ? (
<span className="min-w-0 truncate font-mono">
{pr.headRef.trim()}
</span>
) : (
<span className="text-muted-foreground">No current branch</span>
)}
{/* Swaps with the title animation when the agent moves to a new
worktree/branch (or the placeholder resolves to a branch). */}
<AnimatedTitleText
className={cn(
"min-w-0",
currentBranch ? "font-mono" : "text-muted-foreground",
)}
text={currentBranch ?? "No current branch"}
/>
</div>
{preview ? (
<GithubPullRequestCard className="w-full" preview={preview} />