Skip to content

Commit 75bea13

Browse files
committed
🤖 feat: add progressive compaction warnings
- Show countdown starting at 60% usage (10% before threshold) - Display 'Context left until Auto-Compact: X% remaining' message - Switch to urgent message at 70% threshold - Centralize threshold logic in shouldAutoCompact() utility - Pass image parts through compaction continue messages - Structure for future threshold configurability
1 parent 256e7aa commit 75bea13

File tree

12 files changed

+298
-46
lines changed

12 files changed

+298
-46
lines changed

src/browser/components/AIView.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,21 @@ import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
2020
import { useAutoScroll } from "@/browser/hooks/useAutoScroll";
2121
import { usePersistedState } from "@/browser/hooks/usePersistedState";
2222
import { useThinking } from "@/browser/contexts/ThinkingContext";
23-
import { useWorkspaceState, useWorkspaceAggregator } from "@/browser/stores/WorkspaceStore";
23+
import {
24+
useWorkspaceState,
25+
useWorkspaceAggregator,
26+
useWorkspaceUsage,
27+
} from "@/browser/stores/WorkspaceStore";
2428
import { WorkspaceHeader } from "./WorkspaceHeader";
2529
import { getModelName } from "@/common/utils/ai/models";
2630
import type { DisplayedMessage } from "@/common/types/message";
2731
import type { RuntimeConfig } from "@/common/types/runtime";
2832
import { useAIViewKeybinds } from "@/browser/hooks/useAIViewKeybinds";
2933
import { evictModelFromLRU } from "@/browser/hooks/useModelLRU";
3034
import { QueuedMessage } from "./Messages/QueuedMessage";
35+
import { CompactionWarning } from "./CompactionWarning";
36+
import { shouldAutoCompact } from "@/browser/utils/compaction/autoCompactionCheck";
37+
import { use1MContext } from "@/browser/hooks/use1MContext";
3138

3239
interface AIViewProps {
3340
workspaceId: string;
@@ -71,6 +78,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
7178

7279
const workspaceState = useWorkspaceState(workspaceId);
7380
const aggregator = useWorkspaceAggregator(workspaceId);
81+
const workspaceUsage = useWorkspaceUsage(workspaceId);
82+
const [use1M] = use1MContext();
7483
const handledModelErrorsRef = useRef<Set<string>>(new Set());
7584

7685
useEffect(() => {
@@ -311,6 +320,13 @@ const AIViewInner: React.FC<AIViewProps> = ({
311320
// Get active stream message ID for token counting
312321
const activeStreamMessageId = aggregator.getActiveStreamMessageId();
313322

323+
const autoCompactionCheck = currentModel
324+
? shouldAutoCompact(workspaceUsage, currentModel, use1M)
325+
: { shouldShowWarning: false, usagePercentage: 0, thresholdPercentage: 70 };
326+
327+
// Show warning when: shouldShowWarning flag is true AND not currently compacting
328+
const shouldShowCompactionWarning = !isCompacting && autoCompactionCheck.shouldShowWarning;
329+
314330
// Note: We intentionally do NOT reset autoRetry when streams start.
315331
// If user pressed the interrupt key, autoRetry stays false until they manually retry.
316332
// This makes state transitions explicit and predictable.
@@ -496,6 +512,12 @@ const AIViewInner: React.FC<AIViewProps> = ({
496512
</button>
497513
)}
498514
</div>
515+
{shouldShowCompactionWarning && (
516+
<CompactionWarning
517+
usagePercentage={autoCompactionCheck.usagePercentage}
518+
thresholdPercentage={autoCompactionCheck.thresholdPercentage}
519+
/>
520+
)}
499521
<ChatInput
500522
variant="workspace"
501523
workspaceId={workspaceId}
@@ -509,6 +531,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
509531
onEditLastUserMessage={() => void handleEditLastUserMessage()}
510532
canInterrupt={canInterrupt}
511533
onReady={handleChatInputReady}
534+
autoCompactionCheck={autoCompactionCheck}
512535
/>
513536
</div>
514537

src/browser/components/ChatInput/index.tsx

Lines changed: 91 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
handleCompactCommand,
3131
forkWorkspace,
3232
prepareCompactionMessage,
33+
executeCompaction,
3334
type CommandHandlerContext,
3435
} from "@/browser/utils/chatCommands";
3536
import { CUSTOM_EVENTS } from "@/common/constants/events";
@@ -468,6 +469,32 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
468469
// Workspace variant: full command handling + message send
469470
if (variant !== "workspace") return; // Type guard
470471

472+
// Prepare image parts if any
473+
const imageParts = imageAttachments.map((img, index) => {
474+
// Validate before sending to help with debugging
475+
if (!img.url || typeof img.url !== "string") {
476+
console.error(
477+
`Image attachment [${index}] has invalid url:`,
478+
typeof img.url,
479+
img.url?.slice(0, 50)
480+
);
481+
}
482+
if (!img.url?.startsWith("data:")) {
483+
console.error(`Image attachment [${index}] url is not a data URL:`, img.url?.slice(0, 100));
484+
}
485+
if (!img.mediaType || typeof img.mediaType !== "string") {
486+
console.error(
487+
`Image attachment [${index}] has invalid mediaType:`,
488+
typeof img.mediaType,
489+
img.mediaType
490+
);
491+
}
492+
return {
493+
url: img.url,
494+
mediaType: img.mediaType,
495+
};
496+
});
497+
471498
try {
472499
// Parse command
473500
const parsed = parseCommand(messageText);
@@ -567,8 +594,10 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
567594
const context: CommandHandlerContext = {
568595
workspaceId: props.workspaceId,
569596
sendMessageOptions,
597+
imageParts,
570598
editMessageId: editingMessage?.id,
571599
setInput,
600+
setImageAttachments,
572601
setIsSending,
573602
setToast,
574603
onCancelEdit: props.onCancelEdit,
@@ -632,7 +661,9 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
632661
const context: CommandHandlerContext = {
633662
workspaceId: props.workspaceId,
634663
sendMessageOptions,
664+
imageParts: undefined, // /new doesn't use images
635665
setInput,
666+
setImageAttachments,
636667
setIsSending,
637668
setToast,
638669
};
@@ -652,42 +683,70 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
652683
}
653684
}
654685

655-
// Regular message - send directly via API
656-
setIsSending(true);
657-
658686
// Save current state for restoration on error
659687
const previousImageAttachments = [...imageAttachments];
660688

661-
try {
662-
// Prepare image parts if any
663-
const imageParts = imageAttachments.map((img, index) => {
664-
// Validate before sending to help with debugging
665-
if (!img.url || typeof img.url !== "string") {
666-
console.error(
667-
`Image attachment [${index}] has invalid url:`,
668-
typeof img.url,
669-
img.url?.slice(0, 50)
670-
);
671-
}
672-
if (!img.url?.startsWith("data:")) {
673-
console.error(
674-
`Image attachment [${index}] url is not a data URL:`,
675-
img.url?.slice(0, 100)
676-
);
677-
}
678-
if (!img.mediaType || typeof img.mediaType !== "string") {
679-
console.error(
680-
`Image attachment [${index}] has invalid mediaType:`,
681-
typeof img.mediaType,
682-
img.mediaType
683-
);
689+
// Auto-compaction check (workspace variant only)
690+
// Check if we should auto-compact before sending this message
691+
// Result is computed in parent (AIView) and passed down to avoid duplicate calculation
692+
const shouldAutoCompact =
693+
props.autoCompactionCheck &&
694+
props.autoCompactionCheck.usagePercentage >= props.autoCompactionCheck.thresholdPercentage;
695+
if (variant === "workspace" && !editingMessage && shouldAutoCompact) {
696+
// Clear input immediately for responsive UX
697+
setInput("");
698+
setImageAttachments([]);
699+
setIsSending(true);
700+
701+
try {
702+
const result = await executeCompaction({
703+
workspaceId: props.workspaceId,
704+
continueMessage: {
705+
text: messageText,
706+
imageParts,
707+
},
708+
sendMessageOptions,
709+
});
710+
711+
if (!result.success) {
712+
// Restore on error
713+
setInput(messageText);
714+
setImageAttachments(previousImageAttachments);
715+
setToast({
716+
id: Date.now().toString(),
717+
type: "error",
718+
title: "Auto-Compaction Failed",
719+
message: result.error ?? "Failed to start auto-compaction",
720+
});
721+
} else {
722+
setToast({
723+
id: Date.now().toString(),
724+
type: "success",
725+
message: `Context threshold reached - auto-compacting...`,
726+
});
684727
}
685-
return {
686-
url: img.url,
687-
mediaType: img.mediaType,
688-
};
689-
});
728+
} catch (error) {
729+
// Restore on unexpected error
730+
setInput(messageText);
731+
setImageAttachments(previousImageAttachments);
732+
setToast({
733+
id: Date.now().toString(),
734+
type: "error",
735+
title: "Auto-Compaction Failed",
736+
message:
737+
error instanceof Error ? error.message : "Unexpected error during auto-compaction",
738+
});
739+
} finally {
740+
setIsSending(false);
741+
}
690742

743+
return; // Skip normal send
744+
}
745+
746+
// Regular message - send directly via API
747+
setIsSending(true);
748+
749+
try {
691750
// When editing a /compact command, regenerate the actual summarization request
692751
let actualMessageText = messageText;
693752
let muxMetadata: MuxFrontendMetadata | undefined;
@@ -703,7 +762,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
703762
} = prepareCompactionMessage({
704763
workspaceId: props.workspaceId,
705764
maxOutputTokens: parsed.maxOutputTokens,
706-
continueMessage: parsed.continueMessage,
765+
continueMessage: { text: parsed.continueMessage ?? "", imageParts },
707766
model: parsed.model,
708767
sendMessageOptions,
709768
});

src/browser/components/ChatInput/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ImagePart } from "@/common/types/ipc";
22
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
3+
import type { AutoCompactionCheckResult } from "@/browser/utils/compaction/autoCompactionCheck";
34

45
export interface ChatInputAPI {
56
focus: () => void;
@@ -23,6 +24,7 @@ export interface ChatInputWorkspaceVariant {
2324
canInterrupt?: boolean;
2425
disabled?: boolean;
2526
onReady?: (api: ChatInputAPI) => void;
27+
autoCompactionCheck?: AutoCompactionCheckResult; // Computed in parent (AIView) to avoid duplicate calculation
2628
}
2729

2830
// Creation variant: simplified for first message / workspace creation
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import React from "react";
2+
3+
/**
4+
* Warning banner shown when context usage is approaching the compaction threshold.
5+
*
6+
* Displays progressive warnings:
7+
* - Below threshold: "Context left until Auto-Compact: X% remaining" (where X = threshold - current)
8+
* - At/above threshold: "Approaching context limit. Next message will trigger auto-compaction."
9+
*
10+
* Displayed above ChatInput when:
11+
* - Token usage >= (threshold - 10%) of model's context window
12+
* - Not currently compacting (user can still send messages)
13+
*
14+
* @param usagePercentage - Current token usage as percentage (0-100)
15+
* @param thresholdPercentage - Auto-compaction trigger threshold (0-100, default 70)
16+
*/
17+
export const CompactionWarning: React.FC<{
18+
usagePercentage: number;
19+
thresholdPercentage: number;
20+
}> = (props) => {
21+
// At threshold or above, next message will trigger compaction
22+
const willCompactNext = props.usagePercentage >= props.thresholdPercentage;
23+
24+
// Calculate remaining percentage until threshold
25+
const remaining = props.thresholdPercentage - props.usagePercentage;
26+
27+
const message = willCompactNext
28+
? "⚠️ Approaching context limit. Next message will trigger auto-compaction."
29+
: `Context left until Auto-Compact: ${Math.round(remaining)}%`;
30+
31+
return (
32+
<div className="text-plan-mode bg-plan-mode/10 mx-4 my-4 rounded-sm px-4 py-3 text-center text-xs font-medium">
33+
{message}
34+
</div>
35+
);
36+
};

src/browser/hooks/useResumeManager.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,10 @@ export function useResumeManager() {
171171
if (lastUserMsg?.compactionRequest) {
172172
// Apply compaction overrides using shared function (same as ChatInput)
173173
// This ensures custom model/tokens are preserved across resume
174-
options = applyCompactionOverrides(options, lastUserMsg.compactionRequest.parsed);
174+
options = applyCompactionOverrides(options, {
175+
maxOutputTokens: lastUserMsg.compactionRequest.parsed.maxOutputTokens,
176+
continueMessage: { text: lastUserMsg.compactionRequest.parsed.continueMessage ?? "" },
177+
});
175178
}
176179
}
177180

src/browser/stores/WorkspaceStore.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -424,11 +424,14 @@ export class WorkspaceStore {
424424
* Extract usage from messages (no tokenization).
425425
* Each usage entry calculated with its own model for accurate costs.
426426
*
427-
* REQUIRES: Workspace must have been added via addWorkspace() first.
427+
* Returns empty state if workspace doesn't exist (e.g., creation mode).
428428
*/
429429
getWorkspaceUsage(workspaceId: string): WorkspaceUsageState {
430430
return this.usageStore.get(workspaceId, () => {
431-
const aggregator = this.assertGet(workspaceId);
431+
const aggregator = this.aggregators.get(workspaceId);
432+
if (!aggregator) {
433+
return { usageHistory: [], totalTokens: 0 };
434+
}
432435

433436
const messages = aggregator.getAllMessages();
434437
const model = aggregator.getCurrentModel();

0 commit comments

Comments
 (0)