diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index e3d2c6ab565d..62d7444c6968 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -55,6 +55,7 @@ const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabili const CLAUDE_PRESENTATION = { displayName: "Claude", showInteractionModeToggle: true, + reportsContextWindow: true, } as const; function toTitleCaseWords(value: string): string { const parts: Array = []; diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 48f67c993e15..1971913f1f98 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -62,6 +62,7 @@ const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; const CODEX_PRESENTATION = { displayName: "Codex", showInteractionModeToggle: true, + reportsContextWindow: true, } as const; export interface CodexAppServerProviderSnapshot { diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 8c94b8bb977d..d53367a90640 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -64,6 +64,7 @@ export interface ServerProviderPresentation { readonly displayName: string; readonly badgeLabel?: string; readonly showInteractionModeToggle?: boolean; + readonly reportsContextWindow?: boolean; readonly requiresNewThreadForModelChange?: boolean; } @@ -212,6 +213,9 @@ export function buildServerProvider(input: { ...(typeof input.presentation.showInteractionModeToggle === "boolean" ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle } : {}), + ...(typeof input.presentation.reportsContextWindow === "boolean" + ? { reportsContextWindow: input.presentation.reportsContextWindow } + : {}), ...(typeof input.presentation.requiresNewThreadForModelChange === "boolean" ? { requiresNewThreadForModelChange: input.presentation.requiresNewThreadForModelChange } : {}), diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index b754c9408904..821044f2e774 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -47,6 +47,8 @@ import { isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, + recallCheckoutIsRepo, + rememberCheckoutIsRepo, resolveBackgroundDraftWorkspaceOptions, resolveComposerInteractionMode, resolveComposerProviderSelection, @@ -55,6 +57,7 @@ import { resolveProactiveTurnDiffAction, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + threadShellHasStarted, resolveDraftHeroState, scheduleEnvironmentReconnectWarning, startNewThreadForProject, @@ -1958,3 +1961,62 @@ describe("shouldRefocusComposerOnWindowFocus", () => { expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "-popup" }))).toBe(false); }); }); + +describe("checkout Git memory", () => { + it("answers from the last status seen for the same checkout", () => { + rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", false); + expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(false); + rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", true); + expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(true); + }); + + it("does not answer for a checkout it has not seen", () => { + expect(recallCheckoutIsRepo(environmentId, "/repo/never-opened")).toBeUndefined(); + expect(recallCheckoutIsRepo(environmentId, null)).toBeUndefined(); + }); + + it("keeps environments apart", () => { + rememberCheckoutIsRepo(environmentId, "/repo/shared-path", false); + expect( + recallCheckoutIsRepo(EnvironmentId.make("env-other"), "/repo/shared-path"), + ).toBeUndefined(); + }); + + it("does not confuse an environment id containing the separator with a path", () => { + rememberCheckoutIsRepo(EnvironmentId.make("env"), "a:b", false); + expect(recallCheckoutIsRepo(EnvironmentId.make("env:a"), "b")).toBeUndefined(); + }); +}); + +describe("threadShellHasStarted", () => { + it("counts a thread that has a user message but no latest turn", () => { + expect( + threadShellHasStarted({ latestTurn: null, latestUserMessageAt: now, session: null }), + ).toBe(true); + }); + + it("counts a thread with a live session and nothing else", () => { + expect( + threadShellHasStarted({ + latestTurn: null, + latestUserMessageAt: null, + session: { + threadId, + status: "starting", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ).toBe(true); + }); + + it("does not count a thread that never sent anything", () => { + expect( + threadShellHasStarted({ latestTurn: null, latestUserMessageAt: null, session: null }), + ).toBe(false); + expect(threadShellHasStarted(null)).toBe(false); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index c251e8ffba32..950a9c73fa91 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -794,12 +794,55 @@ export function isBranchMismatchDismissedForSession(key: string | null): boolean return key !== null && sessionDismissedBranchMismatchKeys.has(key); } +// Git status for a checkout arrives after the composer paints, and the branch +// strip mounts on the assumption that a project is a Git repo. Without a +// memory, a non-Git project would mount the strip and drop it on every visit. +// Keyed by environment and checkout for the session; never persisted. +const sessionCheckoutIsRepo = new Map(); + +function checkoutIsRepoKey(environmentId: EnvironmentId, cwd: string): string { + return JSON.stringify([environmentId, cwd]); +} + +export function rememberCheckoutIsRepo( + environmentId: EnvironmentId, + cwd: string, + isRepo: boolean, +): void { + sessionCheckoutIsRepo.set(checkoutIsRepoKey(environmentId, cwd), isRepo); +} + +export function recallCheckoutIsRepo( + environmentId: EnvironmentId, + cwd: string | null, +): boolean | undefined { + return cwd === null + ? undefined + : sessionCheckoutIsRepo.get(checkoutIsRepoKey(environmentId, cwd)); +} + export function threadHasStarted(thread: Thread | null | undefined): boolean { return Boolean( thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null), ); } +/** + * Whether a thread ran at least one turn, judged from its shell alone. + * + * `threadHasStarted` needs the detail: a thread whose latest turn was cleared + * still has messages, and the loading shell carries none. The shell records + * when the last user message landed, which every started thread has. + */ +export function threadShellHasStarted( + shell: Pick | null | undefined, +): boolean { + return Boolean( + shell && + (shell.latestTurn !== null || shell.latestUserMessageAt !== null || shell.session !== null), + ); +} + // Imported history has no session until its first prompt. Resolve its instance // through the environment's provider catalog before locking to a driver. export function deriveLockedProvider(input: { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5f339b0147dc..0fbef88c81e1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -403,6 +403,8 @@ import { readFileAsDataUrl, resolveFileAttachmentUrl, reconcileMountedTerminalThreadIds, + recallCheckoutIsRepo, + rememberCheckoutIsRepo, resolveBackgroundDraftWorkspaceOptions, resolveComposerInteractionMode, resolveComposerProviderSelection, @@ -3327,8 +3329,17 @@ export default function ChatView(props: ChatViewProps) { const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; - // Default true while loading to avoid toolbar flicker. - const isGitRepo = gitStatusQuery.data?.isRepo ?? true; + // Git status arrives after the composer paints. A checkout seen earlier in + // this session answers from memory, so a non-Git project does not mount the + // branch strip and then drop it. A never-seen checkout assumes Git, which + // is what nearly every project is. + const liveIsGitRepo = gitStatusQuery.data?.isRepo; + useEffect(() => { + if (gitStatusCwd !== null && liveIsGitRepo !== undefined) { + rememberCheckoutIsRepo(environmentId, gitStatusCwd, liveIsGitRepo); + } + }, [environmentId, gitStatusCwd, liveIsGitRepo]); + const isGitRepo = liveIsGitRepo ?? recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? true; // Keep a hidden, off-flow strip mounted for existing threads so the composer // can measure whether its relocated controls fit. The visible chrome remains // content-driven: Git/environment context or controls that actually fit. @@ -8341,6 +8352,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadId={activeThreadId} activeThreadEnvironmentId={activeThread?.environmentId} activeThread={activeThread} + activeThreadShell={routeServerThreadShell} promptHistoryMessages={timelineMessages} isServerThread={isServerThread} isLocalDraftThread={isLocalDraftThread} @@ -8386,6 +8398,7 @@ export default function ChatView(props: ChatViewProps) { interactionMode={interactionMode} lockedProvider={lockedProvider} providerStatuses={providerStatuses as ServerProvider[]} + providerCatalogKnown={serverConfig !== null} activeProjectDefaultModelSelection={activeProjectDefaultModelSelection} activeThreadModelSelection={activeThread?.modelSelection} activeContextWindow={activeContextWindow} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index bff16c88bbcc..7b2e65f8bf3e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -63,6 +63,7 @@ import { readFileAsDataUrl, resolveComposerInteractionMode, resolveComposerProviderSelection, + threadShellHasStarted, } from "../ChatView.logic"; import { dataTransferHasComposerMention, @@ -198,10 +199,11 @@ import { renderProviderTraitsMenuContent, renderProviderTraitsPicker, } from "./composerProviderState"; -import { ContextWindowMeter } from "./ContextWindowMeter"; +import { ContextWindowMeter, ContextWindowMeterPlaceholder } from "./ContextWindowMeter"; import { providerSupportsManualCompaction, resolveContextWindowModelDisplayName, + shouldReserveContextWindowMeter, } from "./ContextWindowMeter.logic"; import { attachVideoThumbnail, @@ -866,7 +868,13 @@ import { } from "../../providerInstances"; import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; import type { UnifiedSettings } from "@t3tools/contracts/settings"; -import { type ChatMessage, type SessionPhase, type Thread, videoMimeType } from "../../types"; +import { + type ChatMessage, + type SessionPhase, + type Thread, + type ThreadShell, + videoMimeType, +} from "../../types"; import { buildComposerPromptHistoryEntries, stepComposerPromptHistory, @@ -1107,6 +1115,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { compact: boolean; activeContextWindow: ContextWindowSnapshot | null; + reserveContextWindowMeter: boolean; activeThreadModelDisplayName: string | null; isPreparingWorktree: boolean; pendingAction: { @@ -1143,6 +1152,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( compactDisabled={props.compactDisabled} compactDisabledReason={props.compactDisabledReason} /> + ) : props.reserveContextWindowMeter ? ( + ) : null} ; isServerThread: boolean; @@ -1298,6 +1311,8 @@ export interface ChatComposerProps { // Provider / model lockedProvider: ProviderDriverKind | null; providerStatuses: ServerProvider[]; + /** False until the environment's server config has arrived at least once. */ + providerCatalogKnown: boolean; activeProjectDefaultModelSelection: ModelSelection | null | undefined; activeThreadModelSelection: ModelSelection | null | undefined; @@ -1416,6 +1431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) interactionMode: requestedInteractionMode, lockedProvider, providerStatuses, + providerCatalogKnown, activeProjectDefaultModelSelection, activeThreadModelSelection, activeContextWindow, @@ -1712,6 +1728,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const selectedInstanceId = selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId; const noProviderAvailable = selectedProviderEntry === undefined; + // Before the catalog arrives, every thread resolves to "no provider". Send + // stays blocked either way; only the chrome waits, keeping the picker with + // the thread's own selection instead of swapping in the setup button and + // back once the catalog lands. + const providerCatalogPending = noProviderAvailable && !providerCatalogKnown; + const showProviderUnavailable = noProviderAvailable && !providerCatalogPending; const providerSetupInstanceId = noProviderAvailable ? (unavailableProviderInstanceId ?? (lockedProvider === null @@ -1887,6 +1909,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance), [activeThreadModelSelection, modelOptionsByInstance], ); + const reserveContextWindowMeter = shouldReserveContextWindowMeter({ + meterEnabled: settings.contextWindowMeterEnabled, + detailLoading: props.threadSyncPhase === "loading", + threadStarted: threadShellHasStarted(props.activeThreadShell), + providerReportsContextWindow: selectedProviderStatus + ? selectedProviderStatus.reportsContextWindow === true + : null, + }); // ------------------------------------------------------------------ // Composer-local state @@ -3843,7 +3873,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isStashMenuOpen || isDragOverComposer || isPreparingWorktree || - noProviderAvailable || + showProviderUnavailable || projectSelectionRequired || environmentUnavailable !== null || composerSubmissionError !== null || @@ -4088,7 +4118,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const hiddenRestingBlockIds = restingBlockDefs .slice(restingBlockDefs.length - restingHiddenBlockCount) .map((def) => def.id); - const composerControls = noProviderAvailable ? ( + const composerControls = showProviderUnavailable ? (