Skip to content
40 changes: 34 additions & 6 deletions src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ import { ConcurrentLocalWarning } from "./ConcurrentLocalWarning";
import { BackgroundProcessesBanner } from "./BackgroundProcessesBanner";
import { useBackgroundBashHandlers } from "@/browser/hooks/useBackgroundBashHandlers";
import { checkAutoCompaction } from "@/browser/utils/compaction/autoCompactionCheck";
import { useContextSwitchWarning } from "@/browser/hooks/useContextSwitchWarning";
import { ContextSwitchWarning as ContextSwitchWarningBanner } from "./ContextSwitchWarning";
import { executeCompaction, buildContinueMessage } from "@/browser/utils/chatCommands";
import { useProviderOptions } from "@/browser/hooks/useProviderOptions";
import { useAutoCompactionSettings } from "../hooks/useAutoCompactionSettings";
Expand Down Expand Up @@ -166,6 +168,22 @@ const AIViewInner: React.FC<AIViewProps> = ({
}, [workspaceState]);
const { messages, canInterrupt, isCompacting, loading } = workspaceState;

// Context switch warning - shown when user switches to a model that can't fit current context
const {
warning: contextSwitchWarning,
handleModelChange,
handleCompact: handleContextSwitchCompact,
handleDismiss: handleContextSwitchDismiss,
} = useContextSwitchWarning({
workspaceId,
messages,
pendingModel,
use1M,
workspaceUsage,
api: api ?? undefined,
pendingSendOptions,
});

// Apply message transformations:
// 1. Merge consecutive identical stream errors
// (bash_output grouping is done at render-time, not as a transformation)
Expand Down Expand Up @@ -726,13 +744,22 @@ const AIViewInner: React.FC<AIViewProps> = ({
</button>
)}
</div>
{shouldShowCompactionWarning && (
<CompactionWarning
usagePercentage={autoCompactionResult.usagePercentage}
thresholdPercentage={autoCompactionResult.thresholdPercentage}
isStreaming={canInterrupt}
onCompactClick={handleCompactClick}
{/* Show only one compaction warning at a time - context switch takes priority */}
{contextSwitchWarning ? (
<ContextSwitchWarningBanner
warning={contextSwitchWarning}
onCompact={handleContextSwitchCompact}
onDismiss={handleContextSwitchDismiss}
/>
) : (
shouldShowCompactionWarning && (
<CompactionWarning
usagePercentage={autoCompactionResult.usagePercentage}
thresholdPercentage={autoCompactionResult.thresholdPercentage}
isStreaming={canInterrupt}
onCompactClick={handleCompactClick}
/>
)
)}
<BackgroundProcessesBanner
processes={backgroundBashes}
Expand Down Expand Up @@ -774,6 +801,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
onCheckReviews={handleCheckReviews}
onDeleteReview={reviews.removeReview}
onUpdateReviewNote={reviews.updateReviewNote}
onModelChange={handleModelChange}
/>
</div>

Expand Down
9 changes: 8 additions & 1 deletion src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
variant === "workspace" ? (props.hasQueuedCompaction ?? false) : false;
// runtimeType for telemetry - defaults to "worktree" if not provided
const runtimeType = variant === "workspace" ? (props.runtimeType ?? "worktree") : "worktree";
// Callback for model changes (both variants support this)
const onModelChange = props.onModelChange;

// Storage keys differ by variant
const storageKeys = (() => {
Expand Down Expand Up @@ -384,6 +386,10 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
ensureModelInSettings(canonicalModel); // Ensure model exists in Settings
updatePersistedState(storageKeys.modelKey, canonicalModel); // Update workspace or project-specific

// Notify parent of model change (for context switch warning)
// Called before early returns so warning works even offline or with custom agents
onModelChange?.(canonicalModel);

if (variant !== "workspace" || !workspaceId) {
return;
}
Expand Down Expand Up @@ -434,6 +440,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
thinkingLevel,
variant,
workspaceId,
onModelChange,
]
);

Expand Down Expand Up @@ -1305,7 +1312,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
if (parsed.type === "model-set") {
setInput(""); // Clear input immediately
setPreferredModel(parsed.modelString);
props.onModelChange?.(parsed.modelString);
// Note: onModelChange is called within setPreferredModel
pushToast({ type: "success", message: `Model changed to ${parsed.modelString}` });
return;
}
Expand Down
63 changes: 63 additions & 0 deletions src/browser/components/ContextSwitchWarning.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React from "react";
import { X } from "lucide-react";
import { getModelName } from "@/common/utils/ai/models";
import type { ContextSwitchWarning as WarningData } from "@/browser/utils/compaction/contextSwitchCheck";

function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(".0", "")}M`;
if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
return String(n);
}

interface Props {
warning: WarningData;
onCompact: () => void;
onDismiss: () => void;
}

/**
* Warning banner shown when user switches to a model that can't fit the current context.
*/
export const ContextSwitchWarning: React.FC<Props> = ({ warning, onCompact, onDismiss }) => {
const targetName = getModelName(warning.targetModel);
const compactName = warning.compactionModel ? getModelName(warning.compactionModel) : null;

return (
<div className="bg-plan-mode/10 border-plan-mode/30 mx-4 my-2 rounded-md border px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="text-plan-mode mb-1 flex items-center gap-2 text-[13px] font-medium">
<span>⚠️</span>
<span>Context May Exceed Model Limit</span>
</div>
<p className="text-foreground/80 text-[12px] leading-relaxed">
Current context ({formatTokens(warning.currentTokens)} tokens) may exceed the{" "}
<span className="font-medium">{targetName}</span> limit (
{formatTokens(warning.targetLimit)}). Consider compacting before sending.
</p>
</div>
<button
type="button"
onClick={onDismiss}
className="text-muted hover:text-foreground -mt-1 -mr-1 cursor-pointer p-1"
title="Dismiss"
>
<X size={14} />
</button>
</div>
<div className="mt-2.5 flex items-center gap-3">
{warning.errorMessage ? (
<span className="text-error text-[12px]">{warning.errorMessage}</span>
) : (
<button
type="button"
onClick={onCompact}
className="bg-plan-mode/20 hover:bg-plan-mode/30 text-plan-mode cursor-pointer rounded px-3 py-1.5 text-[12px] font-medium transition-colors"
>
Compact with {compactName}
</button>
)}
</div>
</div>
);
};
150 changes: 150 additions & 0 deletions src/browser/hooks/useContextSwitchWarning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* Hook for managing context switch warnings.
*
* Shows a warning when the user switches to a model that can't fit the current context.
* Handles model changes, 1M toggle changes, and provides compact/dismiss actions.
*/

import { useState, useRef, useEffect, useCallback } from "react";
import type { RouterClient } from "@orpc/server";
import type { AppRouter } from "@/node/orpc/router";
import type { SendMessageOptions } from "@/common/orpc/types";
import type { DisplayedMessage } from "@/common/types/message";
import type { WorkspaceUsageState } from "@/browser/stores/WorkspaceStore";
import {
checkContextSwitch,
findPreviousModel,
type ContextSwitchWarning,
} from "@/browser/utils/compaction/contextSwitchCheck";
import { getHigherContextCompactionSuggestion } from "@/browser/utils/compaction/suggestion";
import { useProvidersConfig } from "./useProvidersConfig";
import { executeCompaction } from "@/browser/utils/chatCommands";

interface UseContextSwitchWarningProps {
workspaceId: string;
messages: DisplayedMessage[];
pendingModel: string;
use1M: boolean;
workspaceUsage: WorkspaceUsageState | undefined;
api: RouterClient<AppRouter> | undefined;
pendingSendOptions: SendMessageOptions;
}

interface UseContextSwitchWarningResult {
warning: ContextSwitchWarning | null;
handleModelChange: (newModel: string) => void;
handleCompact: () => void;
handleDismiss: () => void;
}

export function useContextSwitchWarning(
props: UseContextSwitchWarningProps
): UseContextSwitchWarningResult {
const { workspaceId, messages, pendingModel, use1M, workspaceUsage, api, pendingSendOptions } =
props;

const [warning, setWarning] = useState<ContextSwitchWarning | null>(null);
const prevUse1MRef = useRef(use1M);
// Track previous model so we can use it as compaction fallback on switch.
// Initialize to null so first render triggers check (handles page reload after model switch).
const prevPendingModelRef = useRef<string | null>(null);
const { config: providersConfig } = useProvidersConfig();

const getCurrentTokens = useCallback(() => {
const usage = workspaceUsage?.liveUsage ?? workspaceUsage?.lastContextUsage;
return usage ? usage.input.tokens + usage.cached.tokens + usage.cacheCreate.tokens : 0;
}, [workspaceUsage]);

// Enhance warning with smarter model suggestion when basic resolution fails.
// Searches all known models for one with larger context that user can access.
const enhanceWarning = useCallback(
(w: ContextSwitchWarning | null): ContextSwitchWarning | null => {
if (!w || w.compactionModel) return w;

const suggestion = getHigherContextCompactionSuggestion({
currentModel: w.targetModel,
providersConfig,
});

if (suggestion) {
return { ...w, compactionModel: suggestion.modelId, errorMessage: null };
}
return w;
},
[providersConfig]
);

const handleModelChange = useCallback(
(newModel: string) => {
const tokens = getCurrentTokens();
// Use the model user was just on (not last assistant message's model)
// so compaction fallback works even if user switches without sending
const previousModel = prevPendingModelRef.current;
prevPendingModelRef.current = newModel;
const result = tokens > 0 ? checkContextSwitch(tokens, newModel, previousModel, use1M) : null;
setWarning(enhanceWarning(result));
},
[getCurrentTokens, use1M, enhanceWarning]
);

const handleCompact = useCallback(() => {
if (!api || !warning?.compactionModel) return;

void executeCompaction({
api,
workspaceId,
model: warning.compactionModel,
sendMessageOptions: pendingSendOptions,
});
setWarning(null);
}, [api, workspaceId, pendingSendOptions, warning]);

const handleDismiss = useCallback(() => {
setWarning(null);
}, []);

// Sync with indirect model changes (e.g., WorkspaceModeAISync updating model on mode/agent change).
// Effect is appropriate: pendingModel comes from usePersistedState (localStorage), and external
// components like WorkspaceModeAISync can update it without going through handleModelChange.
// Also re-check when workspaceUsage changes (tokens may not be available on first render).
const tokens = getCurrentTokens();
useEffect(() => {
const prevModel = prevPendingModelRef.current;
if (prevModel !== pendingModel) {
prevPendingModelRef.current = pendingModel;
const result = tokens > 0 ? checkContextSwitch(tokens, pendingModel, prevModel, use1M) : null;
setWarning(enhanceWarning(result));
} else if (tokens > 0 && !warning) {
// Re-check if tokens became available after initial render (usage data loaded)
// Use findPreviousModel since we don't have a "previous" model in this case
const previousModel = findPreviousModel(messages);
if (previousModel && previousModel !== pendingModel) {
setWarning(enhanceWarning(checkContextSwitch(tokens, pendingModel, previousModel, use1M)));
}
}
}, [pendingModel, tokens, use1M, warning, messages, enhanceWarning]);

// Sync with 1M toggle changes from ProviderOptionsContext.
// Effect is appropriate here: we're syncing with an external context (not our own state),
// and the toggle change happens in ModelSettings which can't directly call our handlers.
useEffect(() => {
const wasEnabled = prevUse1MRef.current;
prevUse1MRef.current = use1M;

// Recompute warning when toggle changes (either direction)
// OFF → ON: may clear warning if context now fits
// ON → OFF: may show warning if context no longer fits
if (wasEnabled !== use1M) {
const tokens = getCurrentTokens();
if (tokens > 0) {
const result = checkContextSwitch(tokens, pendingModel, findPreviousModel(messages), use1M);
setWarning(enhanceWarning(result));
} else if (use1M) {
// No tokens but toggled ON - clear any stale warning
setWarning(null);
}
}
}, [use1M, getCurrentTokens, pendingModel, messages, enhanceWarning]);

return { warning, handleModelChange, handleCompact, handleDismiss };
}
56 changes: 56 additions & 0 deletions src/browser/stories/App.chat.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1620,3 +1620,59 @@ export const ToolHooksOutputExpanded: AppStory = {
},
},
};

/**
* Context switch warning banner - shows when switching to a model that can't fit current context.
*
* Scenario: Workspace has ~150K tokens of context. The user switches from Sonnet (200K+ limit)
* to GPT-4o (128K limit). Since 150K > 90% of 128K, the warning banner appears.
*/
export const ContextSwitchWarning: AppStory = {
render: () => (
<AppWithMocks
setup={() => {
const workspaceId = "ws-context-switch";

// Set GPT-4o as current model (128K limit)
// Previous message was from Sonnet with 150K tokens
// On mount, effect sees model "changed" from Sonnet → GPT-4o and triggers warning
updatePersistedState(getModelKey(workspaceId), "openai:gpt-4o");

return setupSimpleChatStory({
workspaceId,
messages: [
createUserMessage("msg-1", "Help me refactor this large codebase", {
historySequence: 1,
timestamp: STABLE_TIMESTAMP - 300000,
}),
// Large context usage - 150K tokens from Sonnet (which handles 200K+)
// Now switching to GPT-4o (128K limit): 150K > 90% of 128K triggers warning
createAssistantMessage(
"msg-2",
"I've analyzed the codebase. Here's my refactoring plan...",
{
historySequence: 2,
timestamp: STABLE_TIMESTAMP - 290000,
model: "anthropic:claude-sonnet-4-5",
contextUsage: {
inputTokens: 150000,
outputTokens: 2000,
},
}
),
],
});
}}
/>
),
parameters: {
docs: {
description: {
story:
"Shows the context switch warning banner. Previous message used Sonnet (150K tokens), " +
"but workspace is now set to GPT-4o (128K limit). Since 150K exceeds 90% of 128K, " +
"the warning banner appears offering a one-click compact action.",
},
},
},
};
Loading
Loading