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
7 changes: 6 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ import {
type TimelineEntriesProjection,
deriveActivePlanState,
deriveActiveWorkStartedAt,
deriveCanInterruptRunningThread,
findLatestProposedPlan,
hasActionableProposedPlan,
isLatestRunSettled,
Expand Down Expand Up @@ -4288,7 +4289,10 @@ export default function ChatView(props: ChatViewProps) {
const focusComposer = useCallback(() => {
composerRef.current?.focusAtEnd();
}, [composerRef]);
const canInterruptRunningThread = activeThread !== undefined && phase === "running";
const canInterruptRunningThread = deriveCanInterruptRunningThread(
activeThread !== undefined,
activeRuntime,
);
const onInterrupt = useCallback(async () => {
if (!activeThread) return;
const result = await interruptThreadTurn({
Expand Down Expand Up @@ -10830,6 +10834,7 @@ export default function ChatView(props: ChatViewProps) {
isLocalDraftThread && activeProject === null
}
phase={phase}
canInterrupt={canInterruptRunningThread}
isConnecting={isConnecting}
isSendBusy={isSendBusy || isSavingQueuedEdit || isResuming}
canResume={resumableRunId !== null || hasHeldQueuedRuns}
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1354,6 +1354,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
isComplete: boolean;
} | null;
isRunning: boolean;
canInterrupt: boolean;
followUpBehavior: "queue" | "steer";
alternateShortcutLabel: string | null;
showPlanFollowUpPrompt: boolean;
Expand Down Expand Up @@ -1392,6 +1393,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
compact={props.compact}
pendingAction={props.pendingAction}
isRunning={props.isRunning}
canInterrupt={props.canInterrupt}
followUpBehavior={props.followUpBehavior}
alternateShortcutLabel={props.alternateShortcutLabel}
showPlanFollowUpPrompt={props.showPlanFollowUpPrompt}
Expand Down Expand Up @@ -1519,6 +1521,8 @@ export interface ChatComposerProps {

// Session phase
phase: SessionPhase;
/** Stop is offered: a run is preparing, starting, or running. */
canInterrupt: boolean;
isConnecting: boolean;
isSendBusy: boolean;
canResume: boolean;
Expand Down Expand Up @@ -1693,6 +1697,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
forceExpandedOnMobile,
projectSelectionRequired,
phase,
canInterrupt,
isConnecting,
isSendBusy,
canResume,
Expand Down Expand Up @@ -6660,6 +6665,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
compact
pendingAction={pendingPrimaryAction}
isRunning={false}
canInterrupt={false}
showPlanFollowUpPrompt={false}
promptHasText={false}
isSendBusy={isSendBusy}
Expand Down Expand Up @@ -7324,6 +7330,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
compact
pendingAction={pendingPrimaryAction}
isRunning={false}
canInterrupt={false}
showPlanFollowUpPrompt={false}
promptHasText={false}
isSendBusy={isSendBusy}
Expand Down Expand Up @@ -7433,6 +7440,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
activeThreadModelDisplayName={activeThreadModelDisplayName}
pendingAction={pendingPrimaryAction}
isRunning={phase === "running"}
canInterrupt={canInterrupt}
followUpBehavior={settings.followUpBehavior}
alternateShortcutLabel={shortcutLabelForCommand(
keybindings,
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/chat/ComposerPrimaryActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function renderPendingActions(isRunning: boolean) {
isComplete: true,
},
isRunning,
canInterrupt: isRunning,
showPlanFollowUpPrompt: false,
promptHasText: false,
isSendBusy: false,
Expand All @@ -50,6 +51,7 @@ function renderSendButton(sendDisabledReason: string | null = null) {
compact: true,
pendingAction: null,
isRunning: false,
canInterrupt: false,
showPlanFollowUpPrompt: false,
promptHasText: true,
isSendBusy: false,
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/components/chat/ComposerPrimaryActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ interface PendingActionState {
interface ComposerPrimaryActionsProps {
compact: boolean;
pendingAction: PendingActionState | null;
/** The turn is running: sending steers or queues instead of starting a turn. */
isRunning: boolean;
/** Stop can reach a run, including one still preparing or starting. */
canInterrupt: boolean;
followUpBehavior?: "queue" | "steer";
alternateShortcutLabel?: string | null;
showPlanFollowUpPrompt: boolean;
Expand Down Expand Up @@ -84,6 +87,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
compact,
pendingAction,
isRunning,
canInterrupt,
followUpBehavior = "steer",
alternateShortcutLabel = null,
showPlanFollowUpPrompt,
Expand Down Expand Up @@ -148,7 +152,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
if (pendingAction) {
return (
<div className={cn("flex items-center justify-end", compact ? "gap-1.5" : "gap-2")}>
{isRunning ? renderStopGenerationButton(true) : null}
{canInterrupt ? renderStopGenerationButton(true) : null}
{pendingAction.questionIndex > 0 ? (
compact ? (
<Button
Expand Down Expand Up @@ -248,7 +252,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
);
}

if (isRunning && !hasSendableContent && !isEditingQueuedMessage) {
if (canInterrupt && !hasSendableContent && !isEditingQueuedMessage) {
return renderStopGenerationButton(false);
}

Expand Down
41 changes: 41 additions & 0 deletions apps/web/src/session-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,18 @@ import {
type OrchestrationV2RunAttempt,
type OrchestrationV2TurnItem,
} from "@t3tools/contracts";
import type { ThreadRuntimeSummary } from "@t3tools/client-runtime/state/shell";
import { deriveMessagesTimelineRows } from "./components/chat/MessagesTimeline.logic";
import * as DateTime from "effect/DateTime";
import { describe, expect, it } from "vite-plus/test";

import {
deriveActivePlanState,
deriveCanInterruptRunningThread,
deriveTimelineEntriesFromVisibleTurnItems,
deriveTimelineEntriesFromVisibleTurnItemsWithState,
deriveRevertTurnCountByUserMessageId,
derivePhase,
findLatestProposedPlan,
isLatestRunSettled,
selectHandoffImageResources,
Expand Down Expand Up @@ -68,6 +71,44 @@ describe("V2 session presentation", () => {
).toBe(false);
});

it("offers Stop while a run is preparing/starting, not just once it's running (#13392)", () => {
const runtimeWithStatus = (
status: ThreadRuntimeSummary["status"],
activeRunId: RunId | null = null,
): ThreadRuntimeSummary => ({
status,
activeRunId,
providerInstanceId: ProviderInstanceId.make("claude-default"),
providerName: null,
lastError: null,
updatedAt: "2026-09-29T00:00:00.000Z",
});
const runId = RunId.make("run-stop-while-starting");

for (const status of ["preparing", "starting", "running"] as const) {
const runtime = runtimeWithStatus(status, runId);
expect(deriveCanInterruptRunningThread(true, runtime)).toBe(true);
}

// No active thread: never offer Stop, regardless of run status.
expect(deriveCanInterruptRunningThread(false, runtimeWithStatus("running", runId))).toBe(false);

// Queued with nothing interruptible: the server rejects interrupting a
// queued run, so Stop stays hidden.
expect(derivePhase(runtimeWithStatus("queued"))).toBe("connecting");
expect(deriveCanInterruptRunningThread(true, runtimeWithStatus("queued"))).toBe(false);
// Queued behind a run that is still interruptible: Stop targets that run.
expect(deriveCanInterruptRunningThread(true, runtimeWithStatus("queued", runId))).toBe(true);

// Waiting (e.g. on a subagent) is treated as "running" by derivePhase and
// keeps offering Stop, unchanged from before.
expect(derivePhase(runtimeWithStatus("waiting"))).toBe("running");
expect(deriveCanInterruptRunningThread(true, runtimeWithStatus("waiting"))).toBe(true);

// No runtime at all: nothing to interrupt.
expect(deriveCanInterruptRunningThread(true, null)).toBe(false);
});

it("labels provider retry progress, delay, recovery, and exhaustion", () => {
const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
const retryItem = {
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/session-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type {
ThreadPendingUserInput,
} from "@t3tools/client-runtime/state/thread-requests";
import type { ThreadRunSummary, ThreadRuntimeSummary } from "@t3tools/client-runtime/state/shell";
import { threadRuntimeHasInterruptibleRun } from "@t3tools/client-runtime/state/thread-execution";
import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation";

import {
Expand Down Expand Up @@ -1013,4 +1014,20 @@ export function derivePhase(runtime: ThreadRuntimeSummary | null): SessionPhase
return "ready";
}

/**
* Whether web and desktop offer Stop for the active thread. The server settles
* a preparing or starting run on `run.interrupt` (Orchestrator.dispatchRunInterrupt),
* so Stop must not wait for the phase to reach "running". A queued thread offers
* Stop only while an earlier run is still interruptible; Stop targets that run.
*/
export function deriveCanInterruptRunningThread(
hasActiveThread: boolean,
runtime: ThreadRuntimeSummary | null,
): boolean {
return (
hasActiveThread &&
(derivePhase(runtime) === "running" || threadRuntimeHasInterruptibleRun(runtime))
);
}

export type { TurnDiffSummary };
Loading