Skip to content

Commit 23887d5

Browse files
t3dotggclaude
authored andcommitted
fix: tone down branch-mismatch banner (pingdotgg#4416)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit fc3f78f)
1 parent c77eab4 commit 23887d5

3 files changed

Lines changed: 236 additions & 86 deletions

File tree

apps/web/src/components/ChatView.logic.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,20 @@ import type { Thread } from "../types";
1212
import {
1313
MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
1414
MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
15+
branchMismatchKey,
1516
buildExpiredTerminalContextToastCopy,
1617
buildThreadTurnInterruptInput,
1718
createLocalDispatchSnapshot,
1819
deriveComposerSendState,
20+
dismissBranchMismatchForSession,
1921
getStartedThreadModelChangeBlockReason,
2022
hasServerAcknowledgedLocalDispatch,
23+
isBranchMismatchDismissedForSession,
2124
reconcileMountedTerminalThreadIds,
2225
reconcileRetainedMountedThreadIds,
2326
resolveThreadMetadataUpdateForNextTurn,
2427
resolveSendEnvMode,
28+
shouldShowBranchMismatchBanner,
2529
shouldWriteThreadErrorToCurrentServerThread,
2630
} from "./ChatView.logic";
2731

@@ -292,6 +296,61 @@ describe("resolveSendEnvMode", () => {
292296
});
293297
});
294298

299+
describe("branchMismatchKey", () => {
300+
it("builds a key from thread id and both branches", () => {
301+
expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe(
302+
"thread-1:feat/a:feat/b",
303+
);
304+
});
305+
306+
it("returns null without a thread or mismatch", () => {
307+
expect(branchMismatchKey(null, { threadBranch: "a", currentBranch: "b" })).toBeNull();
308+
expect(branchMismatchKey("thread-1", null)).toBeNull();
309+
});
310+
});
311+
312+
describe("shouldShowBranchMismatchBanner", () => {
313+
const base = {
314+
hasMismatch: true,
315+
isDismissed: false,
316+
composerHasContent: false,
317+
wasShownForCurrentMismatch: false,
318+
};
319+
320+
it("stays hidden during passive browsing (even though the composer autofocuses)", () => {
321+
expect(shouldShowBranchMismatchBanner(base)).toBe(false);
322+
});
323+
324+
it("shows once the composer has draft content", () => {
325+
expect(shouldShowBranchMismatchBanner({ ...base, composerHasContent: true })).toBe(true);
326+
});
327+
328+
it("stays mounted after the draft clears once shown for the current mismatch", () => {
329+
expect(shouldShowBranchMismatchBanner({ ...base, wasShownForCurrentMismatch: true })).toBe(
330+
true,
331+
);
332+
});
333+
334+
it("never shows when dismissed or without a mismatch", () => {
335+
expect(
336+
shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, isDismissed: true }),
337+
).toBe(false);
338+
expect(
339+
shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, hasMismatch: false }),
340+
).toBe(false);
341+
});
342+
});
343+
344+
describe("session branch mismatch dismissal", () => {
345+
it("tracks dismissed keys and treats other keys as active", () => {
346+
expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(false);
347+
dismissBranchMismatchForSession("t1:a:b");
348+
expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(true);
349+
expect(isBranchMismatchDismissedForSession("t1:a:c")).toBe(false);
350+
expect(isBranchMismatchDismissedForSession(null)).toBe(false);
351+
});
352+
});
353+
295354
describe("reconcileMountedTerminalThreadIds", () => {
296355
it("keeps open threads and makes the active thread most recent", () => {
297356
expect(

apps/web/src/components/ChatView.logic.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,46 @@ export function buildExpiredTerminalContextToastCopy(
289289
};
290290
}
291291

292+
export function branchMismatchKey(
293+
threadId: string | null,
294+
mismatch: { threadBranch: string; currentBranch: string } | null,
295+
): string | null {
296+
if (!threadId || !mismatch) {
297+
return null;
298+
}
299+
return `${threadId}:${mismatch.threadBranch}:${mismatch.currentBranch}`;
300+
}
301+
302+
// The mismatch banner only matters when the user is about to send: passive
303+
// reading of an old thread carries no risk (the branch picker tint already
304+
// covers ambient awareness). Draft content is the intent signal — composer
305+
// focus is useless here because ChatView autofocuses the composer on every
306+
// thread open. `wasShownForCurrentMismatch` keeps the banner mounted once
307+
// revealed so it doesn't flicker away when the draft is cleared.
308+
export function shouldShowBranchMismatchBanner(input: {
309+
hasMismatch: boolean;
310+
isDismissed: boolean;
311+
composerHasContent: boolean;
312+
wasShownForCurrentMismatch: boolean;
313+
}): boolean {
314+
if (!input.hasMismatch || input.isDismissed) {
315+
return false;
316+
}
317+
return input.composerHasContent || input.wasShownForCurrentMismatch;
318+
}
319+
320+
// Session-scoped (module-level so it survives ChatView remounts, e.g. route
321+
// changes). Durable cross-device dismissal is planned as a server-side ack.
322+
const sessionDismissedBranchMismatchKeys = new Set<string>();
323+
324+
export function dismissBranchMismatchForSession(key: string): void {
325+
sessionDismissedBranchMismatchKeys.add(key);
326+
}
327+
328+
export function isBranchMismatchDismissedForSession(key: string | null): boolean {
329+
return key !== null && sessionDismissedBranchMismatchKeys.has(key);
330+
}
331+
292332
export function threadHasStarted(thread: Thread | null | undefined): boolean {
293333
return Boolean(
294334
thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null),

0 commit comments

Comments
 (0)