Skip to content

Commit bb5dfe3

Browse files
Merge upstream PR pingdotgg#16889: continue a usage-limited thread with the picked model
The explicit account from the limit banner still wins over the composer's selection, and the pill uses the send button's current disabled condition. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 parents 60dea67 + 019c017 commit bb5dfe3

4 files changed

Lines changed: 71 additions & 10 deletions

File tree

‎apps/server/src/orchestration-v2/runtimeLayer.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4800,6 +4800,10 @@ it.layer(layerTest)("usage-limit recovery", (it) => {
48004800
const orchestrator = yield* Orchestrator.OrchestratorV2;
48014801
const events = yield* EventSink.EventSinkV2;
48024802
const threadId = ThreadId.make(`manual-resume:${reason}`);
4803+
const switchedModelSelection = {
4804+
instanceId: alternateInstanceId,
4805+
model: "gpt-5.5",
4806+
} satisfies ModelSelection;
48034807
const projectId = ProjectId.make(`manual-resume:project:${reason}`);
48044808
const now = yield* DateTime.now;
48054809
const createdAt = DateTime.formatIso(now);
@@ -4932,6 +4936,8 @@ it.layer(layerTest)("usage-limit recovery", (it) => {
49324936
manualContinuationOfRunId: source.id,
49334937
text: "Continue where you left off.",
49344938
attachments: [],
4939+
// A usage limit is often sidestepped by continuing on another provider.
4940+
...(reason === "usage_limit" ? { modelSelection: switchedModelSelection } : {}),
49354941
dispatchMode: { type: "start_immediately" as const },
49364942
createdBy: "user" as const,
49374943
creationSource: "web" as const,
@@ -4941,6 +4947,10 @@ it.layer(layerTest)("usage-limit recovery", (it) => {
49414947
assert.lengthOf(after.runs, 3);
49424948
assert.equal(after.runs[1]?.status, "queued");
49434949
assert.equal(after.runs[2]?.status, "starting");
4950+
const expectedModelSelection =
4951+
reason === "usage_limit" ? switchedModelSelection : modelSelection;
4952+
assert.deepEqual(after.runs[2]?.modelSelection, expectedModelSelection);
4953+
assert.deepEqual(after.thread.modelSelection, expectedModelSelection);
49444954
assert.equal(
49454955
(yield* orchestrator.dispatch(resume("second")).pipe(Effect.exit))._tag,
49464956
"Failure",

‎apps/web/src/components/ChatView.tsx‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2153,16 +2153,17 @@ export default function ChatView(props: ChatViewProps) {
21532153
isServerThread &&
21542154
serverProjection?.runs.some((run) => run.status === "queued" && run.queueHeld === true) ===
21552155
true;
2156-
const resumableRunId = useMemo(() => {
2156+
const resumableRun = useMemo(() => {
21572157
if (!isServerThread || serverProjection === null) return null;
21582158
const run = latestExecutedRun(serverProjection.runs);
2159-
if (run?.status === "interrupted") return run.id;
2159+
if (run?.status === "interrupted") return run;
21602160
return run?.status === "failed" &&
21612161
serverRuntime?.lastErrorClass === "usage_limit" &&
21622162
latestRootProviderFailure(run, serverProjection.turnItems)?.class === "usage_limit"
2163-
? run.id
2163+
? run
21642164
: null;
21652165
}, [isServerThread, serverProjection, serverRuntime?.lastErrorClass]);
2166+
const resumableRunId = resumableRun?.id ?? null;
21662167
const parentSubagentThreadId =
21672168
activeThread?.lineage.relationshipToParent === "subagent"
21682169
? activeThread.lineage.parentThreadId
@@ -8731,8 +8732,8 @@ export default function ChatView(props: ChatViewProps) {
87318732
void sendStandaloneCommand("/compact", "Failed to compact context.");
87328733
};
87338734

8734-
// `modelSelection` moves the thread to another account while it continues.
8735-
const resumeThread = async (modelSelection?: ModelSelection) => {
8735+
// `switchTo` moves the thread to another account while it continues.
8736+
const resumeThread = async (switchTo?: ModelSelection) => {
87368737
if (
87378738
!activeThread ||
87388739
(resumableRunId === null && !hasHeldQueuedRuns) ||
@@ -8766,6 +8767,9 @@ export default function ChatView(props: ChatViewProps) {
87668767
interactionMode,
87678768
});
87688769
if (settingsResult._tag === "Failure") return settingsResult;
8770+
// A model picked after the run stopped (say, to get past a usage limit) carries the turn.
8771+
const modelSelection =
8772+
switchTo ?? composerRef.current?.getSendContext().selectedModelSelection;
87698773
const turnResult = await startThreadTurn({
87708774
environmentId,
87718775
input: {
@@ -11699,6 +11703,7 @@ export default function ChatView(props: ChatViewProps) {
1169911703
isConnecting={isConnecting}
1170011704
isSendBusy={isSendBusy || isSavingQueuedEdit || isResuming}
1170111705
canResume={resumableRunId !== null || hasHeldQueuedRuns}
11706+
resumeRunModelSelection={resumableRun?.modelSelection ?? null}
1170211707
isRevertingCheckpoint={isRevertingCheckpoint}
1170311708
sendDisabledReason={
1170411709
!canOperateThread

‎apps/web/src/components/chat/ChatComposer.tsx‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,6 +1379,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
13791379
isEnvironmentUnavailable: boolean;
13801380
hasSendableContent: boolean;
13811381
canResume: boolean;
1382+
resumeModelName: string | null;
13821383
preserveComposerFocusOnPointerDown?: boolean;
13831384
isEditingQueuedMessage: boolean;
13841385
onSubmitMessage: React.MouseEventHandler<HTMLButtonElement>;
@@ -1423,6 +1424,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
14231424
isPreparingWorktree={props.isPreparingWorktree}
14241425
hasSendableContent={props.hasSendableContent}
14251426
canResume={props.canResume}
1427+
resumeModelName={props.resumeModelName}
14261428
preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false}
14271429
isEditingQueuedMessage={props.isEditingQueuedMessage}
14281430
onSubmitMessage={props.onSubmitMessage}
@@ -1549,6 +1551,8 @@ export interface ChatComposerProps {
15491551
isConnecting: boolean;
15501552
isSendBusy: boolean;
15511553
canResume: boolean;
1554+
/** The model the resumable run used; resuming with a different composer model continues with it. */
1555+
resumeRunModelSelection: ModelSelection | null;
15521556
isRevertingCheckpoint?: boolean;
15531557
sendDisabledReason: string | null;
15541558
isPreparingWorktree: boolean;
@@ -1733,6 +1737,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
17331737
isConnecting,
17341738
isSendBusy,
17351739
canResume,
1740+
resumeRunModelSelection,
17361741
isRevertingCheckpoint = false,
17371742
sendDisabledReason: externalSendDisabledReason,
17381743
isPreparingWorktree,
@@ -3035,6 +3040,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
30353040
);
30363041
const showResumeAction =
30373042
canResume && !composerDraftHasUserContent(composerDraft) && !isEditingQueuedMessage;
3043+
const resumeModelName =
3044+
showResumeAction &&
3045+
resumeRunModelSelection !== null &&
3046+
(resumeRunModelSelection.instanceId !== selectedModelSelection.instanceId ||
3047+
resumeRunModelSelection.model !== selectedModelSelection.model)
3048+
? resolveContextWindowModelDisplayName(selectedModelSelection, modelOptionsByInstance)
3049+
: null;
30383050
const collapsedComposerPrimaryActionDisabled =
30393051
phase === "running" ||
30403052
isSendBusy ||
@@ -3044,11 +3056,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
30443056
projectSelectionRequired ||
30453057
environmentUnavailable !== null ||
30463058
(!composerSendState.hasSendableContent && !showResumeAction);
3047-
const collapsedComposerPrimaryActionLabel = showResumeAction
3048-
? "Resume thread"
3049-
: props.resumeCompactionTokens !== null && !props.keepFullHistory
3050-
? "Open composer to compact and send"
3051-
: "Send message";
3059+
const collapsedComposerPrimaryActionLabel = resumeModelName
3060+
? `Continue with ${resumeModelName}`
3061+
: showResumeAction
3062+
? "Resume thread"
3063+
: props.resumeCompactionTokens !== null && !props.keepFullHistory
3064+
? "Open composer to compact and send"
3065+
: "Send message";
30523066
const showMobilePendingAnswerActions =
30533067
isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null;
30543068

@@ -7690,6 +7704,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
76907704
isPreparingWorktree={isPreparingWorktree}
76917705
hasSendableContent={composerSendState.hasSendableContent}
76927706
canResume={showResumeAction}
7707+
resumeModelName={resumeModelName}
76937708
preserveComposerFocusOnPointerDown={isMobileViewport || isComposerResting}
76947709
isEditingQueuedMessage={isEditingQueuedMessage}
76957710
onSubmitMessage={handleSubmitMessage}

‎apps/web/src/components/chat/ComposerPrimaryActions.tsx‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ interface ComposerPrimaryActionsProps {
4444
isPreparingWorktree: boolean;
4545
hasSendableContent: boolean;
4646
canResume?: boolean;
47+
/** Set when the composer's model differs from the run being resumed: resume continues with it. */
48+
resumeModelName?: string | null;
4749
preserveComposerFocusOnPointerDown?: boolean;
4850
isEditingQueuedMessage?: boolean;
4951
onSubmitMessage?: MouseEventHandler<HTMLButtonElement>;
@@ -102,6 +104,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
102104
isPreparingWorktree,
103105
hasSendableContent,
104106
canResume = false,
107+
resumeModelName = null,
105108
preserveComposerFocusOnPointerDown = false,
106109
isEditingQueuedMessage = false,
107110
onSubmitMessage,
@@ -276,6 +279,34 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
276279
: null;
277280
const compactsBeforeSend = compactTokens !== null && !keepFullHistory;
278281

282+
if (showResume && resumeModelName) {
283+
const continueLabel = `Continue with ${resumeModelName}`;
284+
return (
285+
<Tooltip key="continue">
286+
<TooltipTrigger
287+
render={
288+
<button
289+
type="button"
290+
className={cn(messageActionPillClassName, "h-9 sm:h-8", compact ? "px-3" : "px-4")}
291+
{...pointerFocusProps}
292+
onClick={onResume}
293+
disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
294+
aria-label={continueLabel}
295+
/>
296+
}
297+
>
298+
{isConnecting || isSendBusy ? (
299+
<Spinner size="sm" aria-hidden="true" />
300+
) : (
301+
<PlayIcon className="size-3.5 fill-current" aria-hidden="true" />
302+
)}
303+
{compact ? "Continue" : continueLabel}
304+
</TooltipTrigger>
305+
<TooltipPopup>{sendDisabledReason ?? continueLabel}</TooltipPopup>
306+
</Tooltip>
307+
);
308+
}
309+
279310
const submitLabel = showResume
280311
? "Resume thread"
281312
: isEditingQueuedMessage

0 commit comments

Comments
 (0)