diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 72361dd242047..45d2aad72e428 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -176,6 +176,8 @@ import { PendingApprovalCard } from "./PendingApprovalCard"; import { ComposerErrorNotice } from "./ComposerErrorNotice"; import { ManagedRuntimeModeNotice } from "./ManagedRuntimeModeNotice"; import { FreshSessionNotice } from "./FreshSessionNotice"; +import { Notice } from "../../components/ui/Notice"; +import { formatTokens } from "@t3tools/shared/usageFormat"; import { ComposerFeedback } from "./ComposerFeedback"; import { ComposerUsageLimits } from "./ComposerUsageLimits"; import { PendingUserInputCard } from "./PendingUserInputCard"; @@ -244,6 +246,11 @@ export interface ThreadDetailScreenProps { readonly providerSubagentStatus?: ProviderSubagentStatus | null; readonly extensionStatuses?: NonNullable; readonly isCompacting: boolean; + /** Set while the next send compacts the thread first. */ + readonly compactBeforeSend?: { + readonly tokens: number; + readonly keepFullHistory: () => void; + } | null; readonly creationState: | { readonly kind: "preparing"; @@ -1681,6 +1688,17 @@ function ThreadDetailBody( {freshSessionAvailable ? ( ) : null} + {props.compactBeforeSend ? ( + + + + ) : null} {props.canOperateThread ? ( ({ pendingRequests: { approvals: [], userInputs: [] } as PendingThreadRequests, enqueue: vi.fn(async () => undefined), freshPending: false, - compactDue: false, + compactModel: null as string | null, + compactDueAt: "", setFreshPending: vi.fn(), clearDraft: vi.fn(), approve: vi.fn(), @@ -79,8 +80,12 @@ vi.mock("react", () => ({ vi.mock("expo-crypto", () => ({ randomUUID: () => "uuid" })); vi.mock("@t3tools/client-runtime/state/context-window", async (importOriginal) => ({ ...(await importOriginal()), - shouldCompactBeforeSend: () => state.compactDue, + compactBeforeSendTokens: (input: { readonly selection: ModelSelection; readonly now: string }) => + input.selection.model === state.compactModel && input.now >= state.compactDueAt + ? 248_000 + : null, })); +vi.mock("../lib/clock", () => ({ useNowMinuteMs: () => 0 })); vi.mock("../lib/attachmentUpload", () => ({ prepareTurnAttachments: vi.fn() })); vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid" })); vi.mock("./queued-run-edit", () => ({ @@ -203,7 +208,8 @@ beforeEach(() => { runtimeMode: undefined, }; state.freshPending = false; - state.compactDue = false; + state.compactModel = null; + state.compactDueAt = ""; state.thread.latestRun = null; state.projection = null; state.serverConfig = { @@ -288,7 +294,7 @@ describe("mobile task permissions", () => { expect(state.setFreshPending).toHaveBeenCalledWith("secondary:thread", false); }); - it("compacts a stale thread before sending unless a fresh session is armed", async () => { + it("compacts a stale thread before sending unless kept or a fresh session is armed", async () => { state.grantedEnvironments.add("secondary"); state.projection = { thread: { @@ -314,12 +320,41 @@ describe("mobile task permissions", () => { visibleTurnItems: [], updatedAt: DateTime.makeUnsafe(0), } as unknown as OrchestrationV2ThreadProjection; - state.compactDue = true; + state.compactModel = "gpt-5.4"; + // The notice's minute clock (the epoch here) trails a deadline the send's exact time has passed. + state.compactDueAt = "2026-01-01T00:00:00.000Z"; + const unannounced = useThreadComposerState(NO_SIDE_CHATS); + expect(unannounced.compactBeforeSend).toBeNull(); + await unannounced.onSendMessage(); + expect(state.enqueue).toHaveBeenLastCalledWith( + expect.not.objectContaining({ compactBeforeSend: true }), + ); + state.compactDueAt = ""; const composer = useThreadComposerState(NO_SIDE_CHATS); + expect(composer.compactBeforeSend?.tokens).toBe(248_000); await composer.onSendMessage(); expect(state.enqueue).toHaveBeenLastCalledWith( expect.objectContaining({ compactBeforeSend: true }), ); + // A model picked after the last render still decides the send. + state.draft = { + ...state.draft, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.5" }, + }; + await composer.onSendMessage(); + expect(state.enqueue).toHaveBeenLastCalledWith( + expect.not.objectContaining({ compactBeforeSend: true }), + ); + state.draft = { ...state.draft, modelSelection: undefined }; + composer.compactBeforeSend?.keepFullHistory(); + const kept = useThreadComposerState(NO_SIDE_CHATS); + expect(kept.compactBeforeSend).toBeNull(); + await kept.onSendMessage(); + expect(state.enqueue).toHaveBeenLastCalledWith( + expect.not.objectContaining({ compactBeforeSend: true }), + ); + // Keeping covers one send, as on web. + expect(useThreadComposerState(NO_SIDE_CHATS).compactBeforeSend?.tokens).toBe(248_000); state.freshPending = true; await composer.onSendMessage(); expect(state.enqueue).toHaveBeenLastCalledWith( diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 702aa53f90f0f..896adc53f5203 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -3,7 +3,7 @@ import * as Dialog from "../components/ui/Dialog"; import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import { readEnvironmentScope } from "./session"; import { useAtomValue } from "@effect/atom-react"; -import { shouldCompactBeforeSend } from "@t3tools/client-runtime/state/context-window"; +import { compactBeforeSendTokens } from "@t3tools/client-runtime/state/context-window"; import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; import { threadSteersDuringCompaction } from "@t3tools/client-runtime/state/thread-workflows"; @@ -42,6 +42,7 @@ import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLega import { restoreAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences"; import { composerContextSendBlockReason, reidentifyComposerContext } from "../lib/composerContext"; import { uuidv4 } from "../lib/uuid"; +import { useNowMinuteMs } from "../lib/clock"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; import { @@ -120,6 +121,17 @@ import { useAtomCommand } from "./use-atom-command"; const EMPTY_QUEUE_WORKFLOW_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile-thread-queue-workflow:empty"), ); +// Threads whose next send keeps the full history instead of compacting first. +const fullHistoryThreadKeysAtom = Atom.make>>({}).pipe( + Atom.keepAlive, +); + +function setKeepFullHistory(threadKey: string, keep: boolean) { + const { [threadKey]: kept, ...rest } = appAtomRegistry.get(fullHistoryThreadKeysAtom); + if ((kept === true) === keep) return; + appAtomRegistry.set(fullHistoryThreadKeysAtom, keep ? { ...rest, [threadKey]: true } : rest); +} + const EMPTY_CHECKPOINT_SUMMARIES_ATOM = Atom.make>([]); const EMPTY_THREAD_SEQUENCE_ATOM = Atom.make(0); @@ -398,6 +410,34 @@ export function useThreadComposerState(input: { : null; const activeThreadBusy = threadRuntimeIsActive(selectedThreadRuntime); + const resumeCompactionTokens = (selection: ModelSelection | null, text: string, now: string) => + selectedEnvironmentRuntime?.connectionState === "connected" && + selectedThreadProjection !== null && + serverConfig != null && + selection !== null + ? compactBeforeSendTokens({ + projection: selectedThreadProjection.projection, + visibleTurnItems: selectedThreadVisibleTurnItems, + selection, + providers: serverConfig.providers, + settings: serverConfig.settings, + text, + threadIdle: + !activeThreadBusy && + !runlessWorkActive && + !isCompacting && + selectedThreadQueuedMessages.length === 0, + now, + }) + : null; + const nowMinuteMs = useNowMinuteMs(); + const compactTokens = + selectedDraft.freshContext === true + ? null + : resumeCompactionTokens(modelSelection, "", new Date(nowMinuteMs).toISOString()); + const fullHistoryThreadKeys = useAtomValue(fullHistoryThreadKeysAtom); + const keepFullHistory = + selectedThreadKey !== null && fullHistoryThreadKeys[selectedThreadKey] === true; const interruptibleRunId = threadRuntimeHasInterruptibleRun(selectedThreadRuntime) ? (selectedThreadRuntime?.activeRunId ?? null) : null; @@ -731,27 +771,14 @@ export function useThreadComposerState(input: { // A redirect belongs to the approval it answers; the armed session waits for the next message. const freshContext = beforeDispatch === undefined && isFreshSessionPending(threadKey); const metadata = makeQueuedMessageMetadata(); - // A fresh session drops the context a compaction would summarize. + // A fresh session drops the context a compaction would summarize. Only a send the notice + // announced compacts, since the notice's minute clock can trail the exact deadline. const compactBeforeSend = + compactTokens !== null && !freshContext && followUpDispatchMode === null && - selectedEnvironmentRuntime?.connectionState === "connected" && - selectedThreadProjection !== null && - serverConfig != null && - shouldCompactBeforeSend({ - projection: selectedThreadProjection.projection, - visibleTurnItems: selectedThreadVisibleTurnItems, - selection: modelSelection, - providers: serverConfig.providers, - settings: serverConfig.settings, - text, - threadIdle: - !activeThreadBusy && - !runlessWorkActive && - !isCompacting && - selectedThreadQueuedMessages.length === 0, - now: metadata.createdAt, - }); + appAtomRegistry.get(fullHistoryThreadKeysAtom)[threadKey] !== true && + resumeCompactionTokens(modelSelection, text, metadata.createdAt) !== null; const messageId = MessageId.make(metadata.messageId); onWillEnqueue?.(); clearThreadComposerError(threadKey); @@ -777,7 +804,10 @@ export function useThreadComposerState(input: { clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true }); if (freshContext) setFreshSessionPending(threadKey, false); enqueuePromise.then( - () => scheduleUnusedComposerAttachmentCleanup(attachments), + () => { + setKeepFullHistory(threadKey, false); + scheduleUnusedComposerAttachmentCleanup(attachments); + }, (error: unknown) => { // Restore with uncapped append; capped merge could drop attachments added during the failed write. void mergeComposerDraftContent(threadKey, { @@ -861,6 +891,13 @@ export function useThreadComposerState(input: { ), providerSubagentStatus, isCompacting, + compactBeforeSend: + compactTokens === null || selectedThreadKey === null || keepFullHistory + ? null + : { + tokens: compactTokens, + keepFullHistory: () => setKeepFullHistory(selectedThreadKey, true), + }, composerDraftKey, followUpBehavior, canSteerActiveTurn, diff --git a/packages/client-runtime/src/state/contextWindow.test.ts b/packages/client-runtime/src/state/contextWindow.test.ts index 3f1a4d3d41994..994d3d62a2065 100644 --- a/packages/client-runtime/src/state/contextWindow.test.ts +++ b/packages/client-runtime/src/state/contextWindow.test.ts @@ -14,7 +14,7 @@ import { import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; -import { shouldCompactBeforeSend } from "./contextWindow.ts"; +import { compactBeforeSendTokens } from "./contextWindow.ts"; import type { ThreadStructure } from "./models.ts"; import { v2Projection } from "./orchestrationV2TestFixtures.ts"; @@ -181,7 +181,7 @@ function sendInput( }; } -describe("shouldCompactBeforeSend", () => { +describe("compactBeforeSendTokens", () => { it.each([ { name: "a stale Claude thread over 200K", expected: true }, { @@ -257,8 +257,8 @@ describe("shouldCompactBeforeSend", () => { { name: "with no conversation but /compact", onlyCompactMessages: true, expected: false }, { name: "for a typed /compact", text: "/compact keep the plan", expected: false }, ] as const)("$name", ({ name: _name, expected, ...overrides }) => { - expect(shouldCompactBeforeSend(sendInput(overrides as Parameters[0]))).toBe( - expected, + expect(compactBeforeSendTokens(sendInput(overrides as Parameters[0]))).toBe( + expected ? 293_611 : null, ); }); }); diff --git a/packages/client-runtime/src/state/contextWindow.ts b/packages/client-runtime/src/state/contextWindow.ts index 66c48807465af..94be1e481ad42 100644 --- a/packages/client-runtime/src/state/contextWindow.ts +++ b/packages/client-runtime/src/state/contextWindow.ts @@ -234,11 +234,12 @@ export function shouldOfferResumeCompaction(input: { } /** - * Whether a plain send into an idle thread compacts it first, as web's composer does under - * "When you return". Web reads the same state for its "Compact and send" button. A send that - * hands off to another conversation starts that one fresh, so only the resumed one counts. + * The tokens a plain send into an idle thread compacts first, as web's composer does under + * "When you return", or null when it sends as is. Web reads the same state for its "Compact and + * send" button. A send that hands off to another conversation starts that one fresh, so only the + * resumed one counts. */ -export function shouldCompactBeforeSend(input: { +export function compactBeforeSendTokens(input: { readonly projection: Pick< ThreadStructure, | "thread" @@ -259,7 +260,7 @@ export function shouldCompactBeforeSend(input: { readonly text: string; readonly threadIdle: boolean; readonly now: string; -}): boolean { +}): number | null { const { projection } = input; const provider = input.providers.find((entry) => entry.instanceId === input.selection.instanceId); if ( @@ -290,7 +291,7 @@ export function shouldCompactBeforeSend(input: { .map((request) => ({ kind: "user-input.resolved", payload: { answers: request.answers } })), ) ) { - return false; + return null; } const activeProviderThreadId = projection.thread.activeProviderThreadId; const providerThread = projection.providerThreads.find( @@ -306,8 +307,7 @@ export function shouldCompactBeforeSend(input: { liveUsage, providerThread, ); - return ( - contextWindow !== null && + return contextWindow !== null && shouldOfferResumeCompaction({ provider: provider.driver, usedTokens: contextWindow.usedTokens, @@ -315,5 +315,6 @@ export function shouldCompactBeforeSend(input: { promptCacheTtlMs: liveUsage?.promptCacheTtlMs, now: input.now, }) - ); + ? contextWindow.usedTokens + : null; }