Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 42 additions & 6 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { useShallow } from "zustand/react/shallow";
import { useGitStatus } from "~/lib/gitStatusState";
import { resolveThreadBranchAutoLink } from "~/lib/threadBranchTracking";
import { usePrimaryEnvironmentId } from "../environments/primary";
import { readEnvironmentApi } from "../environmentApi";
import { isElectron } from "../env";
Expand Down Expand Up @@ -150,6 +151,7 @@ import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./Branch
import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
import { useThreadBranchTracking } from "./chat/useThreadBranchTracking";
import {
MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
buildExpiredTerminalContextToastCopy,
Expand Down Expand Up @@ -1659,6 +1661,25 @@ export default function ChatView(props: ChatViewProps) {
: (storeServerTerminalLaunchContext ?? null);
// Default true while loading to avoid toolbar flicker.
const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
// Branch-tracking glue: auto-link the chat to its first observed branch
// and surface a mismatch banner with checkout/relink actions when the
// working tree drifts. Only meaningful for server threads inside a repo.
const { mismatchBannerItem: branchMismatchBannerItem } = useThreadBranchTracking({
threadRef: isServerThread ? activeThreadRef : null,
threadBranch: activeThread?.branch ?? null,
worktreePath: activeThread?.worktreePath ?? null,
projectCwd: activeProject?.cwd ?? null,
gitStatus: isGitRepo ? (gitStatusQuery.data ?? null) : null,
isSendInFlight: isSendBusy,
});
const composerBannerItemsWithBranchMismatch = useMemo<ComposerBannerStackItem[]>(() => {
if (!branchMismatchBannerItem) {
return composerBannerItems;
}
// Mismatch goes first: it actively blocks the user's mental model of
// "where will this run?" and we want it to be the front-of-stack item.
return [branchMismatchBannerItem, ...composerBannerItems];
}, [branchMismatchBannerItem, composerBannerItems]);
const terminalShortcutLabelOptions = useMemo(
() => ({
context: {
Expand Down Expand Up @@ -2334,6 +2355,18 @@ export default function ChatView(props: ChatViewProps) {
canOverrideServerThreadEnvMode && pendingServerThreadBranch !== undefined
? pendingServerThreadBranch
: (activeThread?.branch ?? null);
const liveThreadBranch =
resolveThreadBranchAutoLink({
threadBranch: null,
gitStatus: gitStatusQuery.data ?? null,
})?.branch ?? null;
// First-send binding happens here, not when a new draft is opened. Drafts
// may carry a seeded branch from the previous chat or from the toolbar, but
// the actual chat link should reflect the working tree at send time.
const initialThreadBranch =
activeThread?.messages.length === 0
? (liveThreadBranch ?? activeThreadBranch)
: (activeThreadBranch ?? liveThreadBranch);
const sendEnvMode = resolveSendEnvMode({
requestedEnvMode: envMode,
isGitRepo,
Expand Down Expand Up @@ -2691,14 +2724,14 @@ export default function ChatView(props: ChatViewProps) {
const isFirstMessage = !isServerThread || activeThread.messages.length === 0;
const baseBranchForWorktree =
isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath
? activeThreadBranch
? initialThreadBranch
: null;

// In worktree mode, require an explicit base branch so we don't silently
// fall back to local execution when branch selection is missing.
const shouldCreateWorktree =
isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath;
if (shouldCreateWorktree && !activeThreadBranch) {
if (shouldCreateWorktree && !initialThreadBranch) {
setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");
return;
}
Expand Down Expand Up @@ -2834,7 +2867,7 @@ export default function ChatView(props: ChatViewProps) {
modelSelection: threadCreateModelSelection,
runtimeMode,
interactionMode,
branch: activeThreadBranch,
branch: initialThreadBranch,
worktreePath: activeThread.worktreePath,
createdAt: activeThread.createdAt,
},
Expand Down Expand Up @@ -3288,7 +3321,7 @@ export default function ChatView(props: ChatViewProps) {
modelSelection: nextThreadModelSelection,
runtimeMode,
interactionMode: "default",
branch: activeThreadBranch,
branch: initialThreadBranch,
worktreePath: activeThread.worktreePath,
createdAt,
})
Expand Down Expand Up @@ -3351,7 +3384,7 @@ export default function ChatView(props: ChatViewProps) {
}, [
activeProject,
activeProposedPlan,
activeThreadBranch,
initialThreadBranch,
activeThread,
beginLocalDispatch,
activeEnvironmentUnavailable,
Expand Down Expand Up @@ -3602,7 +3635,10 @@ export default function ChatView(props: ChatViewProps) {
)}
>
<div className="relative isolate">
<ComposerBannerStack className="relative z-0" items={composerBannerItems} />
<ComposerBannerStack
className="relative z-0"
items={composerBannerItemsWithBranchMismatch}
/>
<div className="relative z-10">
<ChatComposer
ref={composerRef}
Expand Down
50 changes: 0 additions & 50 deletions apps/web/src/components/GitActionsControl.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
requiresDefaultBranchConfirmation,
resolveAutoFeatureBranchName,
resolveDefaultBranchActionDialogCopy,
resolveLiveThreadBranchUpdate,
resolveQuickAction,
resolveThreadBranchUpdate,
} from "./GitActionsControl.logic";
Expand Down Expand Up @@ -1053,55 +1052,6 @@ describe("resolveThreadBranchUpdate", () => {
});
});

describe("resolveLiveThreadBranchUpdate", () => {
it("returns a branch update when live git status differs from stored thread metadata", () => {
const update = resolveLiveThreadBranchUpdate({
threadBranch: "feature/old-ref",
gitStatus: status({ refName: "effect-atom" }),
});

assert.deepEqual(update, {
branch: "effect-atom",
});
});

it("returns null when live git status is unavailable", () => {
const update = resolveLiveThreadBranchUpdate({
threadBranch: "feature/old-ref",
gitStatus: null,
});

assert.equal(update, null);
});

it("returns null when the stored thread ref already matches git status", () => {
const update = resolveLiveThreadBranchUpdate({
threadBranch: "effect-atom",
gitStatus: status({ refName: "effect-atom" }),
});

assert.equal(update, null);
});

it("returns null when git status is detached HEAD but the thread already has a ref", () => {
const update = resolveLiveThreadBranchUpdate({
threadBranch: "effect-atom",
gitStatus: status({ refName: null }),
});

assert.equal(update, null);
});

it("does not regress a semantic thread ref back to a temporary worktree ref", () => {
const update = resolveLiveThreadBranchUpdate({
threadBranch: "t3code/github-query-rate-limit",
gitStatus: status({ refName: "t3code/bda76797" }),
});

assert.equal(update, null);
});
});

describe("resolveAutoFeatureBranchName", () => {
it("uses semantic preferred ref names when available", () => {
const ref = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy");
Expand Down
35 changes: 5 additions & 30 deletions apps/web/src/components/GitActionsControl.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import type {
GitStackedAction,
VcsStatusResult,
} from "@t3tools/contracts";
import { isTemporaryWorktreeBranch } from "@t3tools/shared/git";
import {
DEFAULT_CHANGE_REQUEST_TERMINOLOGY,
getChangeRequestTerminology,
Expand Down Expand Up @@ -373,35 +372,11 @@ export function resolveThreadBranchUpdate(
};
}

export function resolveLiveThreadBranchUpdate(input: {
threadBranch: string | null;
gitStatus: VcsStatusResult | null;
}): { branch: string | null } | null {
if (!input.gitStatus) {
return null;
}

if (input.gitStatus.refName === null && input.threadBranch !== null) {
return null;
}

if (input.threadBranch === input.gitStatus.refName) {
return null;
}

if (
input.threadBranch !== null &&
input.gitStatus.refName !== null &&
!isTemporaryWorktreeBranch(input.threadBranch) &&
isTemporaryWorktreeBranch(input.gitStatus.refName)
) {
return null;
}

return {
branch: input.gitStatus.refName,
};
}
// NOTE: `resolveLiveThreadBranchUpdate` was removed. It silently rewrote a
// chat's `branch` whenever the working tree was on a different ref, which
// conflicts with explicit per-chat branch tracking. The mismatch is now
// surfaced to the user via the chat-view banner instead. See
// `apps/web/src/lib/threadBranchTracking.ts`.

// Re-export from shared for backwards compatibility in this module's exports
export { resolveAutoFeatureBranchName } from "@t3tools/shared/git";
29 changes: 6 additions & 23 deletions apps/web/src/components/GitActionsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ import {
type DefaultBranchConfirmableAction,
requiresDefaultBranchConfirmation,
resolveDefaultBranchActionDialogCopy,
resolveLiveThreadBranchUpdate,
resolveQuickAction,
resolveThreadBranchUpdate,
} from "./GitActionsControl.logic";
Expand Down Expand Up @@ -1105,28 +1104,12 @@ export default function GitActionsControl({
activeDraftThread?.envMode === "worktree" &&
activeDraftThread.worktreePath === null;

useEffect(() => {
if (isGitActionRunning || isSelectingWorktreeBase) {
return;
}

const branchUpdate = resolveLiveThreadBranchUpdate({
threadBranch: activeServerThread?.branch ?? activeDraftThread?.branch ?? null,
gitStatus: gitStatusForActions,
});
if (!branchUpdate) {
return;
}

persistThreadBranchSync(branchUpdate.branch);
}, [
activeServerThread?.branch,
activeDraftThread?.branch,
gitStatusForActions,
isGitActionRunning,
isSelectingWorktreeBase,
persistThreadBranchSync,
]);
// NOTE: We deliberately no longer auto-relink a chat's branch to whatever
// the working tree is currently on. Branch tracking now belongs to the chat
// (see lib/threadBranchTracking + chat/useThreadBranchTracking), and silent
// overwrites would defeat the mismatch banner's purpose. We still sync the
// branch *after explicit user actions* via syncThreadBranchAfterGitAction
// (e.g. when the user creates a feature branch through the menu).

const isDefaultRef = useMemo(() => {
return gitStatusForActions?.isDefaultRef ?? false;
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ThreadStatusLabel,
} from "./ThreadStatusIndicators";
import { ProjectFavicon } from "./ProjectFavicon";
import { SidebarThreadBranchBadge } from "./SidebarThreadBranchBadge";
import { autoAnimate } from "@formkit/auto-animate";
import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
Expand Down Expand Up @@ -593,6 +594,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
</TooltipPopup>
</Tooltip>
)}
<SidebarThreadBranchBadge branch={thread.branch} />
</div>
<div className="ml-auto flex shrink-0 items-center gap-1.5">
{terminalStatus && (
Expand Down
39 changes: 39 additions & 0 deletions apps/web/src/components/SidebarThreadBranchBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { GitBranchIcon } from "lucide-react";
import { memo } from "react";

import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";

interface SidebarThreadBranchBadgeProps {
readonly branch: string | null;
}

/**
* Static branch label per chat row. Kept intentionally inert — no per-row
* git-status subscription — so we don't add N TanStack queries to the
* sidebar. Mismatch resolution happens in the chat header for the active
* thread, which is the only place the user can act on it anyway.
*/
export const SidebarThreadBranchBadge = memo(function SidebarThreadBranchBadge({
branch,
}: SidebarThreadBranchBadgeProps) {
if (!branch) {
return null;
}
return (
<Tooltip>
<TooltipTrigger
render={
<span
aria-label={branch}
data-testid="sidebar-thread-branch-badge"
className="pointer-events-auto inline-flex h-4 max-w-[7rem] shrink-0 items-center gap-0.5 rounded-sm border border-border/50 bg-muted/40 px-1 font-mono text-[10px] leading-none tracking-tight text-muted-foreground/70"
>
<GitBranchIcon className="size-2.5 shrink-0" aria-hidden="true" />
<span className="truncate">{branch}</span>
</span>
}
/>
<TooltipPopup side="top">{branch}</TooltipPopup>
</Tooltip>
);
});
Loading
Loading