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
55 changes: 55 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,14 @@ import {
isBranchMismatchDismissedForSession,
reconcileMountedTerminalThreadIds,
reconcileRetainedMountedThreadIds,
resolveBackgroundDraftWorkspaceOptions,
resolveDraftPromotionNavigationTarget,
resolveThreadMetadataUpdateForNextTurn,
resolveSendEnvMode,
resolveDraftHeroState,
scheduleEnvironmentReconnectWarning,
startNewThreadForProject,
shouldDockDraftHeroForSubmission,
shouldShowBranchMismatchBanner,
shouldWriteThreadErrorToCurrentServerThread,
} from "./ChatView.logic";
Expand All @@ -39,6 +43,40 @@ const projectId = ProjectId.make("project-1");
const threadId = ThreadId.make("thread-1");
const now = "2026-03-29T00:00:00.000Z";

describe("draft hero submission transition", () => {
it("does not dock the composer before a background submission", () => {
expect(
shouldDockDraftHeroForSubmission({
isDraftHeroState: true,
activeThreadKey: "environment-local:thread-1",
submissionIntent: "background",
}),
).toBe(false);
});

it("keeps the composer in the hero layout until navigation after server promotion", () => {
expect(
resolveDraftHeroState({
isLocalDraftThread: false,
hasTimelineEntries: true,
isWorking: true,
draftHeroDockRequested: false,
backgroundSubmissionPending: true,
}),
).toBe(true);
});

it("does not auto-navigate a background submission after server promotion", () => {
expect(
resolveDraftPromotionNavigationTarget({
serverThreadRef: { environmentId, threadId },
serverThreadStarted: true,
backgroundSubmissionPending: true,
}),
).toBeNull();
});
});

describe("environment reconnect warning grace", () => {
afterEach(() => vi.useRealTimers());

Expand Down Expand Up @@ -382,6 +420,23 @@ describe("resolveSendEnvMode", () => {
});
});

describe("resolveBackgroundDraftWorkspaceOptions", () => {
it("keeps New worktree selected without reusing the launched worktree", () => {
expect(
resolveBackgroundDraftWorkspaceOptions({
envMode: "worktree",
branch: "main",
startFromOrigin: true,
}),
).toEqual({
envMode: "worktree",
branch: "main",
worktreePath: null,
startFromOrigin: true,
});
});
});

describe("branchMismatchKey", () => {
it("builds a key from thread id and both branches", () => {
expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe(
Expand Down
67 changes: 66 additions & 1 deletion apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type TerminalContextDraft,
} from "../lib/terminalContext";
import type { DraftThreadEnvMode } from "../composerDraftStore";
import type { ComposerSubmissionIntent } from "../composer-logic";

export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
Expand All @@ -29,6 +30,47 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000;

export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);

export function shouldDockDraftHeroForSubmission(input: {
isDraftHeroState: boolean;
activeThreadKey: string | null;
submissionIntent: ComposerSubmissionIntent;
}): boolean {
return (
input.submissionIntent === "foreground" &&
input.isDraftHeroState &&
input.activeThreadKey !== null
);
}

export function resolveDraftHeroState(input: {
isLocalDraftThread: boolean;
hasTimelineEntries: boolean;
isWorking: boolean;
draftHeroDockRequested: boolean;
backgroundSubmissionPending: boolean;
}): boolean {
if (input.backgroundSubmissionPending) {
return true;
}
return (
input.isLocalDraftThread &&
!input.hasTimelineEntries &&
!input.isWorking &&
!input.draftHeroDockRequested
);
}

export function resolveDraftPromotionNavigationTarget(input: {
serverThreadRef: ScopedThreadRef | null;
serverThreadStarted: boolean;
backgroundSubmissionPending: boolean;
}): ScopedThreadRef | null {
if (input.backgroundSubmissionPending) {
return null;
}
return input.serverThreadStarted ? input.serverThreadRef : null;
}

export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void {
const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS);
return () => globalThis.clearTimeout(timeoutId);
Expand Down Expand Up @@ -257,6 +299,24 @@ export function resolveSendEnvMode(input: {
return input.isGitRepo ? input.requestedEnvMode : "local";
}

export function resolveBackgroundDraftWorkspaceOptions(input: {
envMode: DraftThreadEnvMode;
branch: string | null;
startFromOrigin: boolean;
}): {
envMode: DraftThreadEnvMode;
branch: string | null;
worktreePath: null;
startFromOrigin: boolean;
} {
return {
envMode: input.envMode,
branch: input.branch,
worktreePath: null,
startFromOrigin: input.envMode === "worktree" && input.startFromOrigin,
};
}

export function cloneComposerImageForRetry(
image: ComposerImageAttachment,
): ComposerImageAttachment {
Expand Down Expand Up @@ -491,6 +551,7 @@ export async function waitForStartedServerThread(
export interface LocalDispatchSnapshot {
startedAt: string;
preparingWorktree: boolean;
submissionIntent: ComposerSubmissionIntent;
latestUserMessageId: ChatMessage["id"] | null;
latestTurnTurnId: TurnId | null;
latestTurnRequestedAt: string | null;
Expand All @@ -502,14 +563,18 @@ export interface LocalDispatchSnapshot {

export function createLocalDispatchSnapshot(
activeThread: Thread | undefined,
options?: { preparingWorktree?: boolean },
options?: {
preparingWorktree?: boolean;
submissionIntent?: ComposerSubmissionIntent;
},
): LocalDispatchSnapshot {
const latestTurn = activeThread?.latestTurn ?? null;
const session = activeThread?.session ?? null;
const latestUserMessage = activeThread?.messages.findLast((message) => message.role === "user");
return {
startedAt: new Date().toISOString(),
preparingWorktree: Boolean(options?.preparingWorktree),
submissionIntent: options?.submissionIntent ?? "foreground",
latestUserMessageId: latestUserMessage?.id ?? null,
latestTurnTurnId: latestTurn?.turnId ?? null,
latestTurnRequestedAt: latestTurn?.requestedAt ?? null,
Expand Down
Loading
Loading