Skip to content
Merged
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
59 changes: 59 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,61 @@ 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,
composerHasContent: false,
wasShownForCurrentMismatch: false,
};

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

it("shows once the composer has draft content", () => {
expect(shouldShowBranchMismatchBanner({ ...base, composerHasContent: true })).toBe(true);
});

it("stays mounted after the draft clears 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, composerHasContent: true, isDismissed: true }),
).toBe(false);
expect(
shouldShowBranchMismatchBanner({ ...base, composerHasContent: 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
40 changes: 40 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,46 @@ 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). Draft content is the intent signal — composer
// focus is useless here because ChatView autofocuses the composer on every
// thread open. `wasShownForCurrentMismatch` keeps the banner mounted once
// revealed so it doesn't flicker away when the draft is cleared.
export function shouldShowBranchMismatchBanner(input: {
hasMismatch: boolean;
isDismissed: boolean;
composerHasContent: boolean;
wasShownForCurrentMismatch: boolean;
}): boolean {
if (!input.hasMismatch || input.isDismissed) {
return false;
}
return 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