Skip to content
35 changes: 22 additions & 13 deletions apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import {
useComposerDraftSettings,
} from "./use-composer-drafts";
import {
isExplicitCompactionActive,
resolveComposerDispatchMode,
type ActiveTurnComposerAction,
} from "@t3tools/client-runtime/state/composer-dispatch";
Expand Down Expand Up @@ -334,24 +335,19 @@ export function useThreadComposerState() {
? deriveThreadActivityRun(selectedThreadProjection.projection)
: (selectedThreadShell?.latestRun ?? null);

const isCompacting = (() => {
const activeRunId =
selectedThreadRuntime?.activeRunId && threadRuntimeIsActive(selectedThreadRuntime)
? selectedThreadRuntime.activeRunId
: null;
const compactCommandIsActive = (() => {
const queuedCompact = selectedThreadQueuedMessages.some(
(message) =>
message.messageId === dispatchingQueuedMessageId &&
message.text.trim().toLowerCase() === "/compact" &&
message.attachments.length === 0,
);
if (queuedCompact) return true;
const activeRunId = selectedThreadRuntime?.activeRunId;
if (!activeRunId || !threadRuntimeIsActive(selectedThreadRuntime)) return false;
if (
selectedThreadVisibleTurnItems.some(
({ item }) =>
item.runId === activeRunId && item.type === "compaction" && item.status === "running",
)
) {
return true;
}
if (!activeRunId) return false;
const compactMessage = selectedThreadVisibleTurnItems.findLast(
({ item }) =>
item.runId === activeRunId &&
Expand All @@ -370,8 +366,21 @@ export function useThreadComposerState() {
item.status === "cancelled"),
);
})();
// A compacting turn cannot take steering input, so follow-ups queue meanwhile.
const canSteerActiveTurn = queueWorkflow?.canPromoteToSteer === true && !isCompacting;
const compactionRunning =
activeRunId !== null &&
selectedThreadVisibleTurnItems.some(
({ item }) =>
item.runId === activeRunId && item.type === "compaction" && item.status === "running",
);
const isCompacting = compactCommandIsActive || compactionRunning;
const explicitCompactionIsActive = isExplicitCompactionActive({
compactCommandIsActive,
compactionRunning,
run: selectedThreadProjection?.projection.runs.find((run) => run.id === activeRunId),
messages: selectedThreadMessages,
});
const canSteerActiveTurn =
queueWorkflow?.canPromoteToSteer === true && !explicitCompactionIsActive;
// A stop or a restart holds the queue, completion wakes included, until it
// is resumed; the queue sheet lists only the user's own messages.
const queueHeld = queueWorkflow?.isHeld === true;
Expand Down
12 changes: 10 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import {
getUserQueuedThreadRuns,
threadSupportsProviderHandoff,
} from "@t3tools/client-runtime/state/thread-workflows";
import { isExplicitCompactionActive } from "@t3tools/client-runtime/state/composer-dispatch";
import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff";
import { HandoffRow } from "./chat/V2LifecycleRow";
import {
Expand Down Expand Up @@ -1345,9 +1346,16 @@ export default function ChatView(props: ChatViewProps) {
item.runId === activeRuntime?.activeRunId &&
item.status === "running",
);
const compactCommandIsActive = compactRequestIsActive && !compactionSettled;
const isCompacting =
(isSendBusy || phase === "connecting" || phase === "running") &&
(compactionRunning || (compactRequestIsActive && !compactionSettled));
(compactionRunning || compactCommandIsActive);
const explicitCompactionIsActive = isExplicitCompactionActive({
compactCommandIsActive,
compactionRunning,
run: serverProjection?.runs.find((run) => run.id === activeRuntime?.activeRunId),
messages: serverProjection?.messages,
});
const isWorking =
phase === "running" ||
isSendBusy ||
Expand Down Expand Up @@ -3494,7 +3502,7 @@ export default function ChatView(props: ChatViewProps) {
isLocalDraftThread && activeProject === null
}
phase={phase}
isCompacting={isCompacting}
canSteer={!explicitCompactionIsActive}
canInterrupt={canInterruptRunningThread}
isConnecting={isConnecting}
isSendBusy={
Expand Down
14 changes: 7 additions & 7 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ export interface ChatComposerProps {
isRevertingCheckpoint?: boolean;
sendDisabledReason: string | null;
isPreparingWorktree: boolean;
isCompacting: boolean;
canSteer: boolean;
bannerItems: readonly ComposerBannerStackItem[];
resumeCompactionTokens: number | null;
keepFullHistory: boolean;
Expand Down Expand Up @@ -548,7 +548,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
isRevertingCheckpoint = false,
sendDisabledReason: externalSendDisabledReason,
isPreparingWorktree,
isCompacting,
canSteer,
environmentUnavailable,
activePendingApproval: incomingPendingApproval,
pendingApprovals,
Expand Down Expand Up @@ -2123,7 +2123,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
running: phase === "running",
alternateModifier: false,
activeTurnDefault: followUpBehavior,
canSteer: !isCompacting,
canSteer,
}),
submissionIntent,
);
Expand All @@ -2146,7 +2146,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
running: phase === "running",
alternateModifier: event.metaKey || event.ctrlKey,
activeTurnDefault: followUpBehavior,
canSteer: !isCompacting,
canSteer,
}),
);
};
Expand All @@ -2158,7 +2158,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
running: phase === "running",
alternateModifier: false,
activeTurnDefault: followUpBehavior,
canSteer: !isCompacting,
canSteer,
}),
);
};
Expand Down Expand Up @@ -2218,7 +2218,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
sendShortcut: settings.sendShortcut,
routeKind,
phase,
isCompacting,
canSteer,
followUpBehavior,
isSoftwareKeyboardOpen,
planModeUiEnabled,
Expand Down Expand Up @@ -3785,7 +3785,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
isRunning={phase === "running"}
canInterrupt={canInterrupt}
followUpBehavior={followUpBehavior}
canSteer={!isCompacting}
canSteer={canSteer}
alternateShortcutLabel={shortcutLabelForCommand(
keybindings,
"composer.sendAlternate",
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/chat/composerCommandKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function composerCommandKeyHandler(input: {
readonly sendShortcut: ChatComposerProps["settings"]["sendShortcut"];
readonly routeKind: ChatComposerProps["routeKind"];
readonly phase: ChatComposerProps["phase"];
readonly isCompacting: boolean;
readonly canSteer: boolean;
readonly followUpBehavior: ChatComposerProps["settings"]["followUpBehavior"];
readonly isSoftwareKeyboardOpen: boolean;
readonly planModeUiEnabled: boolean;
Expand Down Expand Up @@ -85,7 +85,7 @@ export function composerCommandKeyHandler(input: {
sendShortcut,
routeKind,
phase,
isCompacting,
canSteer,
followUpBehavior,
isSoftwareKeyboardOpen,
planModeUiEnabled,
Expand Down Expand Up @@ -222,7 +222,7 @@ export function composerCommandKeyHandler(input: {
running: phase === "running",
alternateModifier: submissionIntent === "alternate",
activeTurnDefault: followUpBehavior,
canSteer: !isCompacting,
canSteer,
}),
submissionIntent,
);
Expand Down
96 changes: 96 additions & 0 deletions packages/client-runtime/src/state/composerDispatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { expect, it } from "vite-plus/test";

import { isExplicitCompactionActive, resolveComposerDispatchMode } from "./composerDispatch.ts";

it("uses the run's retained message when its initiating timeline item is paged out", () => {
expect(
isExplicitCompactionActive({
compactCommandIsActive: false,
compactionRunning: true,
run: { userMessageId: "command" },
messages: [
{ id: "command", text: "/compact keep the debugging notes" },
{ id: "steer", text: "A later follow-up" },
],
}),
).toBe(true);
});

it("allows steering during automatic compaction and preserves Queue and alternate dispatch", () => {
const canSteer = !isExplicitCompactionActive({
compactCommandIsActive: false,
compactionRunning: true,
run: { userMessageId: "user" },
messages: [{ id: "user", text: "Read the code" }],
});
expect(resolveComposerDispatchMode({ running: true, alternateModifier: false, canSteer })).toBe(
"steer",
);
expect(resolveComposerDispatchMode({ running: true, alternateModifier: true, canSteer })).toBe(
"queue",
);
expect(
resolveComposerDispatchMode({
running: true,
alternateModifier: false,
activeTurnDefault: "queue",
canSteer,
}),
).toBe("queue");
});

it("blocks steering only for a pending compact command or a running explicit compaction", () => {
expect(
isExplicitCompactionActive({
compactCommandIsActive: true,
compactionRunning: false,
run: undefined,
messages: undefined,
}),
).toBe(true);
for (const text of ["/compact", " /COMPACT ", "/compact keep the debugging notes"]) {
const canSteer = !isExplicitCompactionActive({
compactCommandIsActive: false,
compactionRunning: true,
run: { userMessageId: "user" },
messages: text === undefined ? [] : [{ id: "user", text }],
});
expect(resolveComposerDispatchMode({ running: true, alternateModifier: false, canSteer })).toBe(
"queue",
);
expect(resolveComposerDispatchMode({ running: true, alternateModifier: true, canSteer })).toBe(
"queue",
);
expect(
isExplicitCompactionActive({
compactCommandIsActive: false,
compactionRunning: false,
run: { userMessageId: "user" },
messages: text === undefined ? [] : [{ id: "user", text }],
}),
).toBe(false);
}
for (const text of ["/compactify", "please /compact", undefined]) {
expect(
isExplicitCompactionActive({
compactCommandIsActive: false,
compactionRunning: true,
run: { userMessageId: "user" },
messages: text === undefined ? [] : [{ id: "user", text }],
}),
).toBe(false);
}
});

it("allows steering when an attached compact prompt compacts automatically", () => {
const message = { id: "user", text: "/compact", attachments: [{}] };
const canSteer = !isExplicitCompactionActive({
compactCommandIsActive: false,
compactionRunning: true,
run: { userMessageId: "user" },
messages: [message],
});
expect(resolveComposerDispatchMode({ running: true, alternateModifier: false, canSteer })).toBe(
"steer",
);
});
24 changes: 23 additions & 1 deletion packages/client-runtime/src/state/composerDispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export function resolveComposerDispatchMode(input: {
readonly running: boolean;
readonly alternateModifier: boolean;
readonly activeTurnDefault?: ActiveTurnComposerAction;
/** False while the active turn cannot take steering input, such as during compaction. */
/** False while the active turn cannot take steering input, such as an explicit /compact. */
readonly canSteer?: boolean;
}): ComposerDispatchMode {
if (!input.running) return "auto";
Expand All @@ -15,6 +15,28 @@ export function resolveComposerDispatchMode(input: {
return defaultAction;
}

// Automatic compaction keeps accepting input; only explicit compaction blocks it.
export function isExplicitCompactionActive(input: {
readonly compactCommandIsActive: boolean;
readonly compactionRunning: boolean;
readonly run: { readonly userMessageId: string } | undefined;
readonly messages:
| ReadonlyArray<{
readonly id: string;
readonly text: string;
readonly attachments?: ReadonlyArray<unknown>;
}>
| undefined;
}): boolean {
if (input.compactCommandIsActive) return true;
if (!input.compactionRunning || input.run === undefined) return false;
const message = input.messages?.find((message) => message.id === input.run?.userMessageId);
return (
(message?.attachments?.length ?? 0) === 0 &&
/^\/compact(?:\s|$)/i.test(message?.text.trim() ?? "")
);
}

export function alternateComposerDispatchAction(
activeTurnDefault?: ActiveTurnComposerAction,
): ActiveTurnComposerAction {
Expand Down
Loading