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
156 changes: 156 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import "../index.css";

import {
CheckpointRef,
EventId,
ORCHESTRATION_WS_METHODS,
type MessageId,
Expand Down Expand Up @@ -297,6 +298,70 @@ function createSnapshotForTargetUser(options: {
};
}

function withLatestTurnState(
snapshot: OrchestrationReadModel,
options?: {
turnId?: TurnId;
completedAt?: string;
includeCheckpoint?: boolean;
sessionStatus?: OrchestrationSessionStatus;
},
): OrchestrationReadModel {
const turnId = options?.turnId ?? ("turn-browser-running" as TurnId);
const completedAt = options?.completedAt ?? isoAt(180);
const sessionStatus = options?.sessionStatus ?? "running";
const threads = [...snapshot.threads];
const threadIndex = threads.findIndex((thread) => thread.id === THREAD_ID);
if (threadIndex < 0) {
return snapshot;
}

const thread = threads[threadIndex];
if (!thread) {
return snapshot;
}

threads[threadIndex] = {
...thread,
latestTurn: {
turnId,
state: "completed",
requestedAt: isoAt(120),
startedAt: isoAt(121),
completedAt,
assistantMessageId: null,
},
session: thread.session
? {
...thread.session,
status: sessionStatus,
activeTurnId: sessionStatus === "running" ? turnId : null,
updatedAt: options?.includeCheckpoint ? isoAt(181) : isoAt(121),
}
: null,
checkpoints: options?.includeCheckpoint
? [
{
turnId,
checkpointTurnCount: 1,
checkpointRef: CheckpointRef.makeUnsafe(
"refs/t3/checkpoints/thread-browser-test/turn/1",
),
status: "ready",
files: [],
assistantMessageId: null,
completedAt: isoAt(181),
},
]
: [],
};

return {
...snapshot,
threads,
};
}

function buildFixture(snapshot: OrchestrationReadModel): TestFixture {
return {
snapshot,
Expand Down Expand Up @@ -2104,6 +2169,97 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});

it("restores the composer send button when a running session already has a finalized checkpoint for its latest turn", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: withLatestTurnState(
createSnapshotForTargetUser({
targetMessageId: "msg-user-stale-running-target" as MessageId,
targetText: "stale running target",
}),
{ includeCheckpoint: true },
),
});

try {
const sendButton = await waitForSendButton();
expect(sendButton).toBeTruthy();
expect(
document.querySelector<HTMLButtonElement>('button[aria-label="Stop generation"]'),
).toBeNull();
} finally {
await mounted.cleanup();
}
});

it("keeps the composer stop button while a running session lacks a finalized checkpoint for its latest turn", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: withLatestTurnState(
createSnapshotForTargetUser({
targetMessageId: "msg-user-active-running-target" as MessageId,
targetText: "active running target",
}),
),
});

try {
const stopButton = await waitForElement(
() => document.querySelector<HTMLButtonElement>('button[aria-label="Stop generation"]'),
"Unable to find stop generation button.",
);
expect(stopButton).toBeTruthy();
expect(
document.querySelector<HTMLButtonElement>('button[aria-label="Send message"]'),
).toBeNull();
} finally {
await mounted.cleanup();
}
});

it("keeps the composer in sending state during a delayed turn start", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: withLatestTurnState(
createSnapshotForTargetUser({
targetMessageId: "msg-user-delayed-turn-start" as MessageId,
targetText: "delayed turn start target",
}),
{ includeCheckpoint: true, sessionStatus: "ready" },
),
resolveRpc: (body) => {
if (
body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand &&
body.type === "thread.turn.start"
) {
return new Promise(() => undefined);
}
return undefined;
},
});

try {
await page.getByTestId("composer-editor").fill("follow up while waiting");
const sendButton = await waitForSendButton();
expect(sendButton.disabled).toBe(false);
sendButton.click();

await vi.waitFor(
() => {
expect(
document.querySelector<HTMLButtonElement>('button[aria-label="Sending"]'),
).toBeTruthy();
expect(
document.querySelector<HTMLButtonElement>('button[aria-label="Stop generation"]'),
).toBeNull();
},
{ timeout: 8_000, interval: 16 },
);
} finally {
await mounted.cleanup();
}
});

it("hides the archive action when the pointer leaves a thread row", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
Expand Down
72 changes: 72 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
deriveComposerSendState,
hasServerAcknowledgedLocalDispatch,
reconcileMountedTerminalThreadIds,
shouldShowComposerRunningState,
waitForStartedServerThread,
} from "./ChatView.logic";

Expand Down Expand Up @@ -455,3 +456,74 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
).toBe(true);
});
});

describe("shouldShowComposerRunningState", () => {
const latestTurn = {
turnId: TurnId.makeUnsafe("turn-running"),
state: "completed" as const,
requestedAt: "2026-03-29T00:01:00.000Z",
startedAt: "2026-03-29T00:01:01.000Z",
completedAt: "2026-03-29T00:01:30.000Z",
assistantMessageId: null,
};

it("hides the composer stop state once the running turn has a finalized checkpoint", () => {
expect(
shouldShowComposerRunningState({
phase: "running",
latestTurn,
turnDiffSummaries: [
{
turnId: latestTurn.turnId,
completedAt: "2026-03-29T00:01:31.000Z",
status: "ready",
files: [],
checkpointTurnCount: 1,
},
],
}),
).toBe(false);
});

it("keeps the composer in stop state while the turn is still running without a finalized checkpoint", () => {
expect(
shouldShowComposerRunningState({
phase: "running",
latestTurn,
turnDiffSummaries: [],
}),
).toBe(true);
});

it("keeps the composer in stop state while the latest turn has not completed yet", () => {
expect(
shouldShowComposerRunningState({
phase: "running",
latestTurn: {
...latestTurn,
state: "running",
completedAt: null,
},
turnDiffSummaries: [
{
turnId: latestTurn.turnId,
completedAt: "2026-03-29T00:01:31.000Z",
status: "ready",
files: [],
checkpointTurnCount: 1,
},
],
}),
).toBe(true);
});

it("returns false when the session itself is not running", () => {
expect(
shouldShowComposerRunningState({
phase: "ready",
latestTurn,
turnDiffSummaries: [],
}),
).toBe(false);
});
});
17 changes: 17 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,3 +304,20 @@ export function hasServerAcknowledgedLocalDispatch(input: {
input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null)
);
}

export function shouldShowComposerRunningState(input: {
phase: SessionPhase;
latestTurn: Thread["latestTurn"] | null;
turnDiffSummaries: ReadonlyArray<Thread["turnDiffSummaries"][number]>;
}): boolean {
if (input.phase !== "running") {
return false;
}

const latestTurn = input.latestTurn;
if (!latestTurn?.completedAt) {
return true;
}

return !input.turnDiffSummaries.some((summary) => summary.turnId === latestTurn.turnId);
}
16 changes: 11 additions & 5 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ import {
reconcileMountedTerminalThreadIds,
revokeBlobPreviewUrl,
revokeUserMessagePreviewUrls,
shouldShowComposerRunningState,
threadHasStarted,
waitForStartedServerThread,
} from "./ChatView.logic";
Expand Down Expand Up @@ -869,6 +870,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
}, [activeThreadId, existingOpenTerminalThreadIds, terminalState.terminalOpen]);
const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
const activeProject = useProjectById(activeThread?.projectId);
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);

const openPullRequestDialog = useCallback(
(reference?: string) => {
Expand Down Expand Up @@ -1121,6 +1124,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
threadError: activeThread?.error,
});
const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
const composerShowsRunningState = shouldShowComposerRunningState({
phase,
latestTurn: activeLatestTurn,
turnDiffSummaries,
});
const nowIso = new Date(nowTick).toISOString();
const activeWorkStartedAt = deriveActiveWorkStartedAt(
activeLatestTurn,
Expand All @@ -1137,7 +1145,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
if (activePendingProgress) {
return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`;
}
if (phase === "running") {
if (composerShowsRunningState) {
return "running";
}
if (showPlanFollowUpPrompt) {
Expand All @@ -1148,10 +1156,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
activePendingIsResponding,
activePendingProgress,
composerSendState.hasSendableContent,
composerShowsRunningState,
isConnecting,
isPreparingWorktree,
isSendBusy,
phase,
prompt,
showPlanFollowUpPrompt,
]);
Expand Down Expand Up @@ -1318,8 +1326,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries),
[activeThread?.proposedPlans, timelineMessages, workLogEntries],
);
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
const turnDiffSummaryByAssistantMessageId = useMemo(() => {
const byMessageId = new Map<MessageId, TurnDiffSummary>();
for (const summary of turnDiffSummaries) {
Expand Down Expand Up @@ -4382,7 +4388,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
}
: null
}
isRunning={phase === "running"}
isRunning={composerShowsRunningState}
showPlanFollowUpPrompt={
pendingUserInputs.length === 0 && showPlanFollowUpPrompt
}
Expand Down
Loading