diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts
index bf72ec0b8..365cd2807 100644
--- a/apps/web/src/components/ChatView.logic.test.ts
+++ b/apps/web/src/components/ChatView.logic.test.ts
@@ -1,7 +1,11 @@
import { ThreadId } from "@t3tools/contracts";
import { describe, expect, it } from "vitest";
-import { buildExpiredTerminalContextToastCopy, deriveComposerSendState } from "./ChatView.logic";
+import {
+ buildExpiredTerminalContextToastCopy,
+ buildRunningTurnBlockedMessage,
+ deriveComposerSendState,
+} from "./ChatView.logic";
describe("deriveComposerSendState", () => {
it("treats expired terminal pills as non-sendable content", () => {
@@ -67,3 +71,17 @@ describe("buildExpiredTerminalContextToastCopy", () => {
});
});
});
+
+describe("buildRunningTurnBlockedMessage", () => {
+ it("explains when a turn is still running", () => {
+ expect(buildRunningTurnBlockedMessage(false)).toBe(
+ "A turn is still running. Stop it before sending another prompt.",
+ );
+ });
+
+ it("explains when a long-running command is keeping the turn active", () => {
+ expect(buildRunningTurnBlockedMessage(true)).toBe(
+ "A long-running command is still active. Stop the current turn before sending another prompt.",
+ );
+ });
+});
diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts
index ddc84718e..899291b67 100644
--- a/apps/web/src/components/ChatView.logic.ts
+++ b/apps/web/src/components/ChatView.logic.ts
@@ -160,3 +160,9 @@ export function buildExpiredTerminalContextToastCopy(
description: "Re-add it if you want that terminal output included.",
};
}
+
+export function buildRunningTurnBlockedMessage(hasRunningSubprocess: boolean): string {
+ return hasRunningSubprocess
+ ? "A long-running command is still active. Stop the current turn before sending another prompt."
+ : "A turn is still running. Stop it before sending another prompt.";
+}
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index e628f6ea6..86daf7602 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -164,6 +164,7 @@ import { ProviderHealthBanner } from "./chat/ProviderHealthBanner";
import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
import {
buildExpiredTerminalContextToastCopy,
+ buildRunningTurnBlockedMessage,
buildLocalDraftThread,
buildTemporaryWorktreeBranchName,
cloneComposerImageForRetry,
@@ -661,6 +662,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
const isSendBusy = sendPhase !== "idle";
const isPreparingWorktree = sendPhase === "preparing-worktree";
const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
+ const hasRunningTerminalSubprocess = terminalState.runningTerminalIds.length > 0;
+ const runningTurnBlockedMessage = buildRunningTurnBlockedMessage(hasRunningTerminalSubprocess);
const nowIso = new Date(nowTick).toISOString();
const activeWorkStartedAt = deriveActiveWorkStartedAt(
activeLatestTurn,
@@ -2346,6 +2349,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
e?.preventDefault();
const api = readNativeApi();
if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return;
+ if (phase === "running") {
+ setThreadError(activeThread.id, runningTurnBlockedMessage);
+ return;
+ }
if (activePendingProgress) {
onAdvanceActivePendingUserInput();
return;
@@ -3931,22 +3938,29 @@ export default function ChatView({ threadId }: ChatViewProps) {
) : phase === "running" ? (
-
+
+ Stop
+
+
) : pendingUserInputs.length === 0 ? (
showPlanFollowUpPrompt ? (
prompt.trim().length > 0 ? (