Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
61 changes: 61 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,20 @@ import type { Thread } from "../types";
import {
MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
branchMismatchKey,
buildExpiredTerminalContextToastCopy,
buildThreadTurnInterruptInput,
createLocalDispatchSnapshot,
deriveComposerSendState,
dismissBranchMismatchForSession,
getStartedThreadModelChangeBlockReason,
hasServerAcknowledgedLocalDispatch,
isBranchMismatchDismissedForSession,
reconcileMountedTerminalThreadIds,
reconcileRetainedMountedThreadIds,
resolveThreadMetadataUpdateForNextTurn,
resolveSendEnvMode,
shouldShowBranchMismatchBanner,
shouldWriteThreadErrorToCurrentServerThread,
} from "./ChatView.logic";

Expand Down Expand Up @@ -292,6 +296,63 @@ describe("resolveSendEnvMode", () => {
});
});

describe("branchMismatchKey", () => {
it("builds a key from thread id and both branches", () => {
expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe(
"thread-1:feat/a:feat/b",
);
});

it("returns null without a thread or mismatch", () => {
expect(branchMismatchKey(null, { threadBranch: "a", currentBranch: "b" })).toBeNull();
expect(branchMismatchKey("thread-1", null)).toBeNull();
});
});

describe("shouldShowBranchMismatchBanner", () => {
const base = {
hasMismatch: true,
isDismissed: false,
composerFocused: false,
composerHasContent: false,
wasShownForCurrentMismatch: false,
};

it("stays hidden during passive browsing", () => {
expect(shouldShowBranchMismatchBanner(base)).toBe(false);
});

it("shows on composer focus or content", () => {
expect(shouldShowBranchMismatchBanner({ ...base, composerFocused: true })).toBe(true);
expect(shouldShowBranchMismatchBanner({ ...base, composerHasContent: true })).toBe(true);
});

it("stays mounted after blur once shown for the current mismatch", () => {
expect(shouldShowBranchMismatchBanner({ ...base, wasShownForCurrentMismatch: true })).toBe(
true,
);
});

it("never shows when dismissed or without a mismatch", () => {
expect(
shouldShowBranchMismatchBanner({ ...base, composerFocused: true, isDismissed: true }),
).toBe(false);
expect(
shouldShowBranchMismatchBanner({ ...base, composerFocused: true, hasMismatch: false }),
).toBe(false);
});
});

describe("session branch mismatch dismissal", () => {
it("tracks dismissed keys and treats other keys as active", () => {
expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(false);
dismissBranchMismatchForSession("t1:a:b");
expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(true);
expect(isBranchMismatchDismissedForSession("t1:a:c")).toBe(false);
expect(isBranchMismatchDismissedForSession(null)).toBe(false);
});
});

describe("reconcileMountedTerminalThreadIds", () => {
it("keeps open threads and makes the active thread most recent", () => {
expect(
Expand Down
39 changes: 39 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,45 @@ export function buildExpiredTerminalContextToastCopy(
};
}

export function branchMismatchKey(
threadId: string | null,
mismatch: { threadBranch: string; currentBranch: string } | null,
): string | null {
if (!threadId || !mismatch) {
return null;
}
return `${threadId}:${mismatch.threadBranch}:${mismatch.currentBranch}`;
}

// The mismatch banner only matters when the user is about to send: passive
// reading of an old thread carries no risk (the branch picker tint already
// covers ambient awareness). `wasShownForCurrentMismatch` keeps the banner
// mounted once revealed so it doesn't flicker away when the composer blurs.
export function shouldShowBranchMismatchBanner(input: {
hasMismatch: boolean;
isDismissed: boolean;
composerFocused: boolean;
composerHasContent: boolean;
wasShownForCurrentMismatch: boolean;
}): boolean {
if (!input.hasMismatch || input.isDismissed) {
return false;
}
return input.composerFocused || input.composerHasContent || input.wasShownForCurrentMismatch;
}

// Session-scoped (module-level so it survives ChatView remounts, e.g. route
// changes). Durable cross-device dismissal is planned as a server-side ack.
const sessionDismissedBranchMismatchKeys = new Set<string>();

export function dismissBranchMismatchForSession(key: string): void {
sessionDismissedBranchMismatchKeys.add(key);
}

export function isBranchMismatchDismissedForSession(key: string | null): boolean {
return key !== null && sessionDismissedBranchMismatchKeys.has(key);
}

export function threadHasStarted(thread: Thread | null | undefined): boolean {
return Boolean(
thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null),
Expand Down
Loading
Loading