MAESTRO: Add Summarize & Continue feature for context compaction

Implements Phase 5 of context management - allows users to compact
a conversation's context into a fresh tab with summarized content.

Features:
- Context summarization via AI with structured output format
- Chunked summarization for large contexts (>50k tokens)
- Progress modal with stage indicators and elapsed time
- Token reduction statistics display
- Cancel functionality with confirmation

Entry points:
- Tab hover overlay button (for tabs with ≥5 logs)
- Command K palette action
- Keyboard shortcut: Alt+Meta+K

New files:
- src/prompts/context-summarize.md: Summarization prompt
- src/renderer/services/contextSummarizer.ts: Core service
- src/renderer/hooks/useSummarizeAndContinue.ts: React hook
- src/renderer/components/SummarizeProgressModal.tsx: Progress UI

Modified:
- TabBar.tsx: Added button in hover overlay
- QuickActionsModal.tsx: Added Command K action
- App.tsx: Hook integration and modal rendering
- MainPanel.tsx: Prop threading
- shortcuts.ts: Added summarizeAndContinue shortcut
- useMainKeyboardHandler.ts: Shortcut handler
- tabHelpers.ts: Added createTabAtPosition
- contextMerge.ts: Added Summarize types
This commit is contained in:
Pedram Amini
2025-12-22 22:26:46 -06:00
parent a70b2d2068
commit 6a73d1ece7
15 changed files with 1432 additions and 3 deletions

View File

@@ -0,0 +1,58 @@
# Context Summarization Instructions
You are compacting a conversation context to continue work in a fresh session. Your goal is to preserve all important technical details while reducing token usage.
## MUST PRESERVE (never omit or abbreviate)
- All file paths mentioned (exact paths with line numbers)
- All function/class/variable names discussed
- All code snippets that were written or modified
- All technical decisions made and their rationale
- All error messages and their resolutions
- All configuration values and settings
- Current state of the work (what's done, what's pending)
- Any TODOs or next steps discussed
## SHOULD COMPRESS
- Back-and-forth clarification dialogues → summarize the conclusion
- Repeated explanations of the same concept → single clear explanation
- Verbose acknowledgments and pleasantries → remove entirely
- Step-by-step debugging that led to a fix → keep the fix, summarize the journey
- Large data dumps or log outputs → truncate with "[...N lines truncated...]"
- Repeated similar code blocks → show one example, note "similar pattern in X other locations"
## SHOULD REMOVE
- Greetings and sign-offs
- "Sure, I can help with that" type responses
- Redundant confirmations
- Explanations of concepts already well-understood
- Failed approaches that were completely abandoned (unless instructive)
## OUTPUT FORMAT
Structure your summary as:
### Project Context
- Working directory: [path]
- Key files involved: [list with paths]
### Work Completed
[Bullet points of what was accomplished, with file:line references]
### Key Decisions
[Important technical decisions and why they were made]
### Current State
[Where the work stands right now]
### Code Changes Summary
[Key code that was written/modified - preserve exact snippets]
### Pending Items
[What still needs to be done]
### Important References
[Any URLs, documentation, or external resources mentioned]
---
Now summarize the following conversation context:

View File

@@ -32,6 +32,7 @@ import groupChatParticipantRequestPrompt from './group-chat-participant-request.
// Context management prompts
import contextGroomingPrompt from './context-grooming.md?raw';
import contextTransferPrompt from './context-transfer.md?raw';
import contextSummarizePrompt from './context-summarize.md?raw';
export {
// Wizard
@@ -61,4 +62,5 @@ export {
// Context management
contextGroomingPrompt,
contextTransferPrompt,
contextSummarizePrompt,
};

View File

@@ -45,6 +45,7 @@ import { MergeSessionModal } from './components/MergeSessionModal';
import { MergeProgressModal } from './components/MergeProgressModal';
import { SendToAgentModal } from './components/SendToAgentModal';
import { TransferProgressModal } from './components/TransferProgressModal';
import { SummarizeProgressModal } from './components/SummarizeProgressModal';
// Group Chat Components
import { GroupChatPanel } from './components/GroupChatPanel';
@@ -79,6 +80,7 @@ import { useAgentErrorRecovery } from './hooks/useAgentErrorRecovery';
import { useAgentCapabilities } from './hooks/useAgentCapabilities';
import { useMergeSessionWithSessions } from './hooks/useMergeSession';
import { useSendToAgentWithSessions } from './hooks/useSendToAgent';
import { useSummarizeAndContinue } from './hooks/useSummarizeAndContinue';
// Import contexts
import { useLayerStack } from './contexts/LayerStackContext';
@@ -2667,6 +2669,56 @@ export default function MaestroConsole() {
},
});
// Summarize & Continue hook for context compaction
const {
summarizeState,
progress: summarizeProgress,
result: summarizeResult,
error: summarizeError,
startSummarize,
cancel: cancelSummarize,
canSummarize,
minLogsRequired: summarizeMinLogs,
} = useSummarizeAndContinue(activeSession ?? null);
// State for summarize progress modal visibility
const [summarizeModalOpen, setSummarizeModalOpen] = useState(false);
// Handler for starting summarization
const handleSummarizeAndContinue = useCallback((tabId?: string) => {
if (!activeSession || activeSession.inputMode !== 'ai') return;
const targetTabId = tabId || activeSession.activeTabId;
const targetTab = activeSession.aiTabs.find(t => t.id === targetTabId);
if (!targetTab || !canSummarize(targetTab)) {
addToast({
type: 'warning',
title: 'Cannot Summarize',
message: `Tab needs at least ${summarizeMinLogs} log entries to summarize.`,
});
return;
}
setSummarizeModalOpen(true);
startSummarize(targetTabId).then((result) => {
if (result) {
// Update session with the new tab
setSessions(prev => prev.map(s =>
s.id === activeSession.id ? result.updatedSession : s
));
// Show success notification
addToast({
type: 'success',
title: 'Context Compacted',
message: `Created compacted tab with ${summarizeResult?.reductionPercent ?? 0}% token reduction`,
});
}
});
}, [activeSession, canSummarize, summarizeMinLogs, startSummarize, setSessions, addToast, summarizeResult?.reductionPercent]);
// Fetch available agents when Send to Agent modal opens
useEffect(() => {
if (sendToAgentModalOpen) {
@@ -6518,7 +6570,14 @@ export default function MaestroConsole() {
hasActiveSessionCapability,
// Merge session modal and send to agent modal
setMergeSessionModalOpen,
setSendToAgentModalOpen
setSendToAgentModalOpen,
// Summarize and continue
canSummarizeActiveTab: (() => {
if (!activeSession || !activeSession.activeTabId) return false;
const tab = activeSession.aiTabs.find(t => t.id === activeSession.activeTabId);
return tab ? canSummarize(tab) : false;
})(),
summarizeAndContinue: handleSummarizeAndContinue,
};
// Update flat file list when active session's tree, expanded folders, filter, or hidden files setting changes
@@ -6921,6 +6980,8 @@ export default function MaestroConsole() {
setCreatePRSession(session);
setCreatePRModalOpen(true);
}}
onSummarizeAndContinue={() => handleSummarizeAndContinue()}
canSummarizeActiveTab={activeTab ? canSummarize(activeTab) : false}
onToggleRemoteControl={async () => {
await toggleGlobalLive();
// Show flash notification based on the NEW state (opposite of current)
@@ -7532,6 +7593,23 @@ export default function MaestroConsole() {
/>
)}
{/* --- SUMMARIZE PROGRESS MODAL --- */}
{summarizeModalOpen && summarizeState !== 'idle' && (
<SummarizeProgressModal
theme={theme}
isOpen={summarizeModalOpen}
progress={summarizeProgress}
result={summarizeResult}
onCancel={() => {
cancelSummarize();
setSummarizeModalOpen(false);
}}
onComplete={() => {
setSummarizeModalOpen(false);
}}
/>
)}
{/* --- SEND TO AGENT MODAL --- */}
{sendToAgentModalOpen && activeSession && activeSession.activeTabId && (
<SendToAgentModal
@@ -8643,6 +8721,7 @@ export default function MaestroConsole() {
onOpenWorktreeConfig={() => setWorktreeConfigModalOpen(true)}
onOpenCreatePR={() => setCreatePRModalOpen(true)}
isWorktreeChild={!!activeSession?.parentSessionId}
onSummarizeAndContinue={handleSummarizeAndContinue}
/>
)}

View File

@@ -189,6 +189,9 @@ interface MainPanelProps {
onOpenCreatePR?: () => void;
/** True if this session is a worktree child (has parentSessionId) */
isWorktreeChild?: boolean;
// Context summarization
onSummarizeAndContinue?: (tabId: string) => void;
}
export const MainPanel = forwardRef<MainPanelHandle, MainPanelProps>(function MainPanel(props, ref) {
@@ -215,6 +218,7 @@ export const MainPanel = forwardRef<MainPanelHandle, MainPanelProps>(function Ma
onOpenWorktreeConfig,
onOpenCreatePR,
isWorktreeChild,
onSummarizeAndContinue,
} = props;
// isCurrentSessionAutoMode: THIS session has active batch run (for all UI indicators)
@@ -833,6 +837,7 @@ export const MainPanel = forwardRef<MainPanelHandle, MainPanelProps>(function Ma
onTabReorder={onTabReorder}
onTabStar={onTabStar}
onTabMarkUnread={onTabMarkUnread}
onSummarizeAndContinue={onSummarizeAndContinue}
showUnreadOnly={showUnreadOnly}
onToggleUnreadFilter={onToggleUnreadFilter}
onOpenTabSearch={onOpenTabSearch}

View File

@@ -87,6 +87,9 @@ interface QuickActionsModalProps {
onToggleRemoteControl?: () => void;
// Worktree PR creation
onOpenCreatePR?: (session: Session) => void;
// Summarize and continue
onSummarizeAndContinue?: () => void;
canSummarizeActiveTab?: boolean;
}
export function QuickActionsModal(props: QuickActionsModalProps) {
@@ -103,7 +106,8 @@ export function QuickActionsModal(props: QuickActionsModalProps) {
onRenameTab, onToggleReadOnlyMode, onToggleTabShowThinking, onOpenTabSwitcher, tabShortcuts, isAiMode, setPlaygroundOpen, onRefreshGitFileState,
onDebugReleaseQueuedItem, markdownEditMode, onToggleMarkdownEditMode, setUpdateCheckModalOpen, openWizard, wizardGoToStep, setDebugWizardModalOpen, setDebugPackageModalOpen, startTour, setFuzzyFileSearchOpen, onEditAgent,
groupChats, onNewGroupChat, onOpenGroupChat, onCloseGroupChat, onDeleteGroupChat, activeGroupChatId,
hasActiveSessionCapability, onOpenMergeSession, onOpenSendToAgent, onOpenCreatePR
hasActiveSessionCapability, onOpenMergeSession, onOpenSendToAgent, onOpenCreatePR,
onSummarizeAndContinue, canSummarizeActiveTab
} = props;
const [search, setSearch] = useState('');
@@ -300,6 +304,7 @@ export function QuickActionsModal(props: QuickActionsModalProps) {
...(activeSession && hasActiveSessionCapability?.('supportsSessionStorage') ? [{ id: 'agentSessions', label: `View Agent Sessions for ${activeSession.name}`, shortcut: shortcuts.agentSessions, action: () => { setActiveAgentSessionId(null); setAgentSessionsOpen(true); setQuickActionOpen(false); } }] : []),
...(activeSession && hasActiveSessionCapability?.('supportsContextMerge') && onOpenMergeSession ? [{ id: 'mergeSession', label: 'Merge with another session', shortcut: shortcuts.mergeSession, subtext: 'Combine contexts from multiple sessions', action: () => { onOpenMergeSession(); setQuickActionOpen(false); } }] : []),
...(activeSession && hasActiveSessionCapability?.('supportsContextMerge') && onOpenSendToAgent ? [{ id: 'sendToAgent', label: 'Send to another agent', shortcut: shortcuts.sendToAgent, subtext: 'Transfer context to a different AI agent', action: () => { onOpenSendToAgent(); setQuickActionOpen(false); } }] : []),
...(isAiMode && canSummarizeActiveTab && onSummarizeAndContinue ? [{ id: 'summarizeAndContinue', label: 'Summarize & Continue', shortcut: tabShortcuts?.summarizeAndContinue, subtext: 'Compact context into a fresh tab', action: () => { onSummarizeAndContinue(); setQuickActionOpen(false); } }] : []),
...(activeSession?.isGitRepo ? [{ id: 'gitDiff', label: 'View Git Diff', shortcut: shortcuts.viewGitDiff, action: async () => {
const cwd = activeSession.inputMode === 'terminal' ? (activeSession.shellCwd || activeSession.cwd) : activeSession.cwd;
const diff = await gitService.getDiff(cwd);

View File

@@ -0,0 +1,522 @@
/**
* SummarizeProgressModal - Modal showing progress during context summarization
*
* Displays real-time progress through the summarization stages:
* 1. Extracting context
* 2. Summarizing with AI
* 3. Creating new tab
* 4. Complete
*
* Features:
* - Animated spinner with pulsing center
* - Stage progression with checkmarks for completed stages
* - Progress bar with percentage
* - Elapsed time tracking
* - Token reduction statistics
* - Cancel functionality with confirmation
*/
import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
import { X, Check, Loader2, AlertTriangle, TrendingDown } from 'lucide-react';
import type { Theme } from '../types';
import type { SummarizeProgress, SummarizeResult } from '../types/contextMerge';
import { useLayerStack } from '../contexts/LayerStackContext';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
/**
* Progress stage definition for display
*/
interface ProgressStage {
id: SummarizeProgress['stage'];
label: string;
activeLabel: string;
}
/**
* Stage definitions with their display labels
*/
const STAGES: ProgressStage[] = [
{ id: 'extracting', label: 'Extract context', activeLabel: 'Extracting context...' },
{ id: 'summarizing', label: 'Summarize with AI', activeLabel: 'Summarizing with AI...' },
{ id: 'creating', label: 'Create new tab', activeLabel: 'Creating new tab...' },
{ id: 'complete', label: 'Complete', activeLabel: 'Complete' },
];
export interface SummarizeProgressModalProps {
theme: Theme;
isOpen: boolean;
progress: SummarizeProgress | null;
result: SummarizeResult | null;
onCancel: () => void;
onComplete: () => void;
}
/**
* Format milliseconds as a readable time string
*/
function formatElapsedTime(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes > 0) {
return `${minutes}m ${remainingSeconds}s`;
}
return `${remainingSeconds}s`;
}
/**
* Elapsed time display component with auto-updating timer
*/
const ElapsedTimeDisplay = memo(({
startTime,
textColor,
}: {
startTime: number;
textColor: string;
}) => {
const [elapsedMs, setElapsedMs] = useState(Date.now() - startTime);
useEffect(() => {
const interval = setInterval(() => {
setElapsedMs(Date.now() - startTime);
}, 1000);
return () => clearInterval(interval);
}, [startTime]);
return (
<span className="font-mono text-xs" style={{ color: textColor }}>
{formatElapsedTime(elapsedMs)}
</span>
);
});
ElapsedTimeDisplay.displayName = 'ElapsedTimeDisplay';
/**
* Animated spinner component
*/
function Spinner({ theme }: { theme: Theme }) {
return (
<div className="relative">
<div
className="w-12 h-12 rounded-full border-4 border-t-transparent animate-spin"
style={{
borderColor: theme.colors.border,
borderTopColor: theme.colors.accent,
}}
/>
{/* Inner pulsing circle */}
<div className="absolute inset-0 flex items-center justify-center">
<div
className="w-6 h-6 rounded-full animate-pulse"
style={{ backgroundColor: `${theme.colors.accent}30` }}
/>
</div>
</div>
);
}
/**
* Cancel confirmation dialog
*/
function CancelConfirmDialog({
theme,
onConfirm,
onCancel,
}: {
theme: Theme;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<div
className="absolute inset-0 flex items-center justify-center z-10"
style={{ backgroundColor: `${theme.colors.bgMain}ee` }}
>
<div
className="p-6 rounded-xl border shadow-xl max-w-sm"
style={{
backgroundColor: theme.colors.bgSidebar,
borderColor: theme.colors.border,
}}
>
<div className="flex items-center gap-3 mb-4">
<AlertTriangle className="w-5 h-5" style={{ color: theme.colors.warning }} />
<h3 className="text-sm font-bold" style={{ color: theme.colors.textMain }}>
Cancel Summarization?
</h3>
</div>
<p className="text-xs mb-4" style={{ color: theme.colors.textDim }}>
This will abort the summarization and discard any progress.
Your original tab will remain unchanged.
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="px-3 py-1.5 rounded text-xs border hover:bg-white/5 transition-colors"
style={{
borderColor: theme.colors.border,
color: theme.colors.textMain,
}}
>
Continue
</button>
<button
type="button"
onClick={onConfirm}
className="px-3 py-1.5 rounded text-xs font-medium transition-colors"
style={{
backgroundColor: theme.colors.error,
color: '#fff',
}}
>
Cancel
</button>
</div>
</div>
</div>
);
}
/**
* Token reduction stats display
*/
function TokenReductionStats({
result,
theme,
}: {
result: SummarizeResult;
theme: Theme;
}) {
return (
<div
className="mt-4 p-3 rounded-lg border"
style={{
backgroundColor: `${theme.colors.success}10`,
borderColor: `${theme.colors.success}30`,
}}
>
<div className="flex items-center gap-2 mb-2">
<TrendingDown className="w-4 h-4" style={{ color: theme.colors.success }} />
<span className="text-xs font-medium" style={{ color: theme.colors.success }}>
Context Reduced by {result.reductionPercent}%
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs" style={{ color: theme.colors.textDim }}>
<div>
<span className="text-[10px] uppercase">Before</span>
<div className="font-mono" style={{ color: theme.colors.textMain }}>
~{result.originalTokens.toLocaleString()} tokens
</div>
</div>
<div>
<span className="text-[10px] uppercase">After</span>
<div className="font-mono" style={{ color: theme.colors.textMain }}>
~{result.compactedTokens.toLocaleString()} tokens
</div>
</div>
</div>
</div>
);
}
/**
* SummarizeProgressModal Component
*/
export function SummarizeProgressModal({
theme,
isOpen,
progress,
result,
onCancel,
onComplete,
}: SummarizeProgressModalProps) {
// Track start time for elapsed time display
const [startTime] = useState(() => Date.now());
// Cancel confirmation state
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
// Layer stack registration
const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack();
const layerIdRef = useRef<string>();
const onCancelRef = useRef(onCancel);
const onCompleteRef = useRef(onComplete);
// Keep refs up to date
useEffect(() => {
onCancelRef.current = onCancel;
onCompleteRef.current = onComplete;
});
// Handle escape key - show confirmation or close
const handleEscape = useCallback(() => {
if (progress?.stage === 'complete') {
onCompleteRef.current();
} else {
setShowCancelConfirm(true);
}
}, [progress?.stage]);
// Register layer on mount
useEffect(() => {
if (!isOpen) return;
layerIdRef.current = registerLayer({
type: 'modal',
priority: MODAL_PRIORITIES.SUMMARIZE_PROGRESS || 683, // Fallback if not defined
blocksLowerLayers: true,
capturesFocus: true,
focusTrap: 'strict',
ariaLabel: 'Summarization Progress',
onEscape: handleEscape,
});
return () => {
if (layerIdRef.current) {
unregisterLayer(layerIdRef.current);
}
};
}, [isOpen, registerLayer, unregisterLayer, handleEscape]);
// Update handler when callbacks change
useEffect(() => {
if (layerIdRef.current) {
updateLayerHandler(layerIdRef.current, handleEscape);
}
}, [updateLayerHandler, handleEscape]);
// Get the current stage index
const currentStageIndex = useMemo(() => {
if (!progress) return 0;
return STAGES.findIndex(s => s.id === progress.stage);
}, [progress]);
// Handle cancel confirmation
const handleConfirmCancel = useCallback(() => {
setShowCancelConfirm(false);
onCancel();
}, [onCancel]);
const handleDismissCancel = useCallback(() => {
setShowCancelConfirm(false);
}, []);
// Handle cancel/done button click
const handleButtonClick = useCallback(() => {
if (progress?.stage === 'complete') {
onComplete();
} else {
setShowCancelConfirm(true);
}
}, [progress?.stage, onComplete]);
if (!isOpen) return null;
const isComplete = progress?.stage === 'complete';
const progressValue = progress?.progress ?? 0;
return (
<div
className="fixed inset-0 modal-overlay flex items-center justify-center z-[9999]"
role="dialog"
aria-modal="true"
aria-label="Summarization Progress"
tabIndex={-1}
>
<div
className="w-[450px] rounded-xl shadow-2xl border outline-none relative overflow-hidden"
style={{
backgroundColor: theme.colors.bgSidebar,
borderColor: theme.colors.border,
}}
onClick={(e) => e.stopPropagation()}
>
{/* Cancel Confirmation Overlay */}
{showCancelConfirm && (
<CancelConfirmDialog
theme={theme}
onConfirm={handleConfirmCancel}
onCancel={handleDismissCancel}
/>
)}
{/* Header */}
<div
className="p-4 border-b flex items-center justify-between"
style={{ borderColor: theme.colors.border }}
>
<h2
className="text-sm font-bold"
style={{ color: theme.colors.textMain }}
>
{isComplete ? 'Summarization Complete' : 'Summarizing Context...'}
</h2>
{isComplete && (
<button
type="button"
onClick={onComplete}
className="p-1 rounded hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textDim }}
aria-label="Close modal"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Content */}
<div className="p-6">
{/* Spinner or Success Icon */}
<div className="flex justify-center mb-6">
{isComplete ? (
<div
className="w-12 h-12 rounded-full flex items-center justify-center"
style={{ backgroundColor: `${theme.colors.success}20` }}
>
<Check className="w-6 h-6" style={{ color: theme.colors.success }} />
</div>
) : (
<Spinner theme={theme} />
)}
</div>
{/* Current Status Message */}
<div className="text-center mb-6">
<p
className="text-sm font-medium mb-1"
style={{ color: theme.colors.textMain }}
>
{progress?.message || STAGES[currentStageIndex]?.activeLabel || 'Processing...'}
</p>
{!isComplete && (
<div className="flex items-center justify-center gap-2 text-xs" style={{ color: theme.colors.textDim }}>
<span>Elapsed:</span>
<ElapsedTimeDisplay startTime={startTime} textColor={theme.colors.textMain} />
</div>
)}
</div>
{/* Progress Bar */}
<div className="mb-6">
<div className="flex justify-between text-xs mb-1">
<span style={{ color: theme.colors.textDim }}>Progress</span>
<span style={{ color: theme.colors.textMain }}>{progressValue}%</span>
</div>
<div
className="h-2 rounded-full overflow-hidden"
style={{ backgroundColor: theme.colors.bgMain }}
>
<div
className="h-full rounded-full transition-all duration-300 ease-out"
style={{
width: `${progressValue}%`,
backgroundColor: isComplete ? theme.colors.success : theme.colors.accent,
}}
/>
</div>
</div>
{/* Stage Progress */}
<div className="space-y-2">
{STAGES.map((stage, index) => {
const isActive = index === currentStageIndex;
const isCompleted = index < currentStageIndex;
return (
<div
key={stage.id}
className="flex items-center gap-3"
>
{/* Stage Indicator */}
<div className="w-6 h-6 flex items-center justify-center shrink-0">
{isCompleted ? (
<div
className="w-5 h-5 rounded-full flex items-center justify-center"
style={{ backgroundColor: theme.colors.success }}
>
<Check className="w-3 h-3" style={{ color: '#fff' }} />
</div>
) : isActive ? (
<Loader2
className="w-5 h-5 animate-spin"
style={{ color: theme.colors.accent }}
/>
) : (
<div
className="w-5 h-5 rounded-full border-2"
style={{ borderColor: theme.colors.border }}
/>
)}
</div>
{/* Stage Label */}
<span
className="text-xs"
style={{
color: isActive
? theme.colors.textMain
: isCompleted
? theme.colors.success
: theme.colors.textDim,
fontWeight: isActive ? 500 : 400,
}}
>
{isActive ? stage.activeLabel : stage.label}
</span>
</div>
);
})}
</div>
{/* Token Reduction Stats (on completion) */}
{isComplete && result && result.success && (
<TokenReductionStats result={result} theme={theme} />
)}
{/* Error message (on failure) */}
{isComplete && result && !result.success && result.error && (
<div
className="mt-4 p-3 rounded-lg border"
style={{
backgroundColor: `${theme.colors.error}10`,
borderColor: `${theme.colors.error}30`,
}}
>
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4" style={{ color: theme.colors.error }} />
<span className="text-xs" style={{ color: theme.colors.error }}>
{result.error}
</span>
</div>
</div>
)}
</div>
{/* Footer */}
<div
className="p-4 border-t flex justify-end"
style={{ borderColor: theme.colors.border }}
>
<button
type="button"
onClick={handleButtonClick}
className="px-4 py-2 rounded text-sm border transition-colors"
style={{
borderColor: theme.colors.border,
color: theme.colors.textMain,
backgroundColor: isComplete ? theme.colors.accent : 'transparent',
...(isComplete && { borderColor: theme.colors.accent, color: theme.colors.accentForeground }),
}}
>
{isComplete ? 'Done' : 'Cancel'}
</button>
</div>
</div>
</div>
);
}
export default SummarizeProgressModal;

View File

@@ -1,6 +1,6 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X, Plus, Star, Copy, Edit2, Mail, Pencil, Search, GitMerge, ArrowRightCircle } from 'lucide-react';
import { X, Plus, Star, Copy, Edit2, Mail, Pencil, Search, GitMerge, ArrowRightCircle, Minimize2 } from 'lucide-react';
import type { AITab, Theme } from '../types';
import { hasDraft } from '../utils/tabHelpers';
@@ -19,6 +19,8 @@ interface TabBarProps {
onMergeWith?: (tabId: string) => void;
/** Handler to open send to agent modal with this tab as source */
onSendToAgent?: (tabId: string) => void;
/** Handler to summarize and continue in a new tab */
onSummarizeAndContinue?: (tabId: string) => void;
showUnreadOnly?: boolean;
onToggleUnreadFilter?: () => void;
onOpenTabSearch?: () => void;
@@ -45,6 +47,8 @@ interface TabProps {
onMergeWith?: () => void;
/** Handler to open send to agent modal with this tab as source */
onSendToAgent?: () => void;
/** Handler to summarize and continue in a new tab */
onSummarizeAndContinue?: () => void;
shortcutHint?: number | null;
registerRef?: (el: HTMLDivElement | null) => void;
hasDraft?: boolean;
@@ -113,6 +117,7 @@ function Tab({
onMarkUnread,
onMergeWith,
onSendToAgent,
onSummarizeAndContinue,
shortcutHint,
registerRef,
hasDraft
@@ -217,6 +222,12 @@ function Tab({
setOverlayOpen(false);
};
const handleSummarizeAndContinueClick = (e: React.MouseEvent) => {
e.stopPropagation();
onSummarizeAndContinue?.();
setOverlayOpen(false);
};
const displayName = getTabDisplayName(tab);
// Browser-style tab: all tabs have borders, active tab "connects" to content
@@ -448,6 +459,18 @@ function Tab({
</button>
)}
{/* Summarize & Continue button - only show for tabs with substantial content */}
{(tab.logs?.length ?? 0) >= 5 && onSummarizeAndContinue && (
<button
onClick={handleSummarizeAndContinueClick}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textMain }}
>
<Minimize2 className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
Summarize & Continue
</button>
)}
<button
onClick={handleMarkUnreadClick}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-white/10 transition-colors"
@@ -482,6 +505,7 @@ export function TabBar({
onTabMarkUnread,
onMergeWith,
onSendToAgent,
onSummarizeAndContinue,
showUnreadOnly: showUnreadOnlyProp,
onToggleUnreadFilter,
onOpenTabSearch
@@ -678,6 +702,7 @@ export function TabBar({
onMarkUnread={onTabMarkUnread ? () => onTabMarkUnread(tab.id) : undefined}
onMergeWith={onMergeWith && tab.agentSessionId ? () => onMergeWith(tab.id) : undefined}
onSendToAgent={onSendToAgent && tab.agentSessionId ? () => onSendToAgent(tab.id) : undefined}
onSummarizeAndContinue={onSummarizeAndContinue && (tab.logs?.length ?? 0) >= 5 ? () => onSummarizeAndContinue(tab.id) : undefined}
shortcutHint={!showUnreadOnly && originalIndex < 9 ? originalIndex + 1 : null}
hasDraft={hasDraft(tab)}
registerRef={(el) => {

View File

@@ -128,6 +128,9 @@ export const MODAL_PRIORITIES = {
/** Transfer error modal (appears when cross-agent transfer fails) */
TRANSFER_ERROR: 682,
/** Summarization progress modal (appears during context compaction) */
SUMMARIZE_PROGRESS: 681,
/** Agent sessions browser (Cmd+Shift+L) */
AGENT_SESSIONS: 680,

View File

@@ -64,6 +64,7 @@ export const TAB_SHORTCUTS: Record<string, Shortcut> = {
toggleShowThinking: { id: 'toggleShowThinking', label: 'Toggle Show Thinking', keys: ['Meta', 'Shift', 'k'] },
filterUnreadTabs: { id: 'filterUnreadTabs', label: 'Filter Unread Tabs', keys: ['Meta', 'u'] },
toggleTabUnread: { id: 'toggleTabUnread', label: 'Toggle Tab Unread', keys: ['Meta', 'Shift', 'u'] },
summarizeAndContinue: { id: 'summarizeAndContinue', label: 'Summarize & Continue', keys: ['Alt', 'Meta', 'k'] },
goToTab1: { id: 'goToTab1', label: 'Go to Tab 1', keys: ['Meta', '1'] },
goToTab2: { id: 'goToTab2', label: 'Go to Tab 2', keys: ['Meta', '2'] },
goToTab3: { id: 'goToTab3', label: 'Go to Tab 3', keys: ['Meta', '3'] },

View File

@@ -446,6 +446,13 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn {
e.preventDefault();
ctx.toggleTabUnread();
}
if (ctx.isTabShortcut(e, 'summarizeAndContinue')) {
e.preventDefault();
// Only trigger if summarization is available for the current tab
if (ctx.canSummarizeActiveTab && ctx.summarizeAndContinue) {
ctx.summarizeAndContinue();
}
}
if (ctx.isTabShortcut(e, 'nextTab')) {
e.preventDefault();
const result = ctx.navigateToNextTab(ctx.activeSession, ctx.showUnreadOnly);

View File

@@ -0,0 +1,240 @@
/**
* useSummarizeAndContinue Hook
*
* React hook for managing the "Summarize & Continue" workflow.
* This hook handles:
* - Extracting context from the source tab
* - Running the summarization process
* - Creating a new compacted tab with the summarized context
* - Tracking progress and errors throughout the process
*
* The new tab is created immediately to the right of the source tab
* with the name format: "{original name} Compacted YYYY-MM-DD"
*/
import { useState, useRef, useCallback } from 'react';
import type { Session, AITab } from '../types';
import type { SummarizeProgress, SummarizeResult } from '../types/contextMerge';
import { contextSummarizationService } from '../services/contextSummarizer';
import { createTabAtPosition, getActiveTab } from '../utils/tabHelpers';
/**
* State type for the summarization process.
*/
export type SummarizeState = 'idle' | 'summarizing' | 'complete' | 'error';
/**
* Result of the useSummarizeAndContinue hook.
*/
export interface UseSummarizeAndContinueResult {
/** Current state of the summarization process */
summarizeState: SummarizeState;
/** Progress information during summarization */
progress: SummarizeProgress | null;
/** Result after successful summarization */
result: SummarizeResult | null;
/** Error message if summarization failed */
error: string | null;
/** Start the summarization process for a specific tab */
startSummarize: (sourceTabId: string) => Promise<{
newTabId: string;
updatedSession: Session;
} | null>;
/** Cancel the current summarization operation */
cancel: () => void;
/** Check if a tab can be summarized (has enough content) */
canSummarize: (tab: AITab) => boolean;
/** Get the minimum number of logs required for summarization */
minLogsRequired: number;
}
/**
* Hook for managing the "Summarize & Continue" workflow.
*
* @param session - The Maestro session containing the tabs
* @param onSessionUpdate - Callback to update the session state
* @returns Object with summarization state and control functions
*
* @example
* function MyComponent({ session, onSessionUpdate }) {
* const {
* summarizeState,
* progress,
* result,
* error,
* startSummarize,
* canSummarize,
* } = useSummarizeAndContinue(session, onSessionUpdate);
*
* const handleSummarize = async () => {
* const activeTab = getActiveTab(session);
* if (activeTab && canSummarize(activeTab)) {
* const result = await startSummarize(activeTab.id);
* if (result) {
* // Switch to the new tab
* onSessionUpdate(result.updatedSession);
* }
* }
* };
*
* return (
* <button onClick={handleSummarize} disabled={summarizeState === 'summarizing'}>
* {summarizeState === 'summarizing' ? `${progress?.progress}%` : 'Summarize & Continue'}
* </button>
* );
* }
*/
export function useSummarizeAndContinue(
session: Session | null
): UseSummarizeAndContinueResult {
const [state, setState] = useState<SummarizeState>('idle');
const [progress, setProgress] = useState<SummarizeProgress | null>(null);
const [result, setResult] = useState<SummarizeResult | null>(null);
const [error, setError] = useState<string | null>(null);
const cancelRef = useRef(false);
/**
* Start the summarization process for a specific tab.
*/
const startSummarize = useCallback(async (
sourceTabId: string
): Promise<{ newTabId: string; updatedSession: Session } | null> => {
if (!session) {
setError('No active session');
setState('error');
return null;
}
const sourceTab = session.aiTabs.find(t => t.id === sourceTabId);
if (!sourceTab) {
setError('Source tab not found');
setState('error');
return null;
}
// Check if tab has enough content
if (!contextSummarizationService.canSummarize(sourceTab)) {
setError(`Context too small to summarize. Need at least ${contextSummarizationService.getMinLogsForSummarize()} log entries.`);
setState('error');
return null;
}
setState('summarizing');
setError(null);
setResult(null);
cancelRef.current = false;
try {
// Run the summarization
const summarizeResult = await contextSummarizationService.summarizeContext(
{
sourceSessionId: session.id,
sourceTabId,
projectRoot: session.projectRoot,
agentType: session.toolType,
},
sourceTab.logs,
(p) => {
if (!cancelRef.current) {
setProgress(p);
}
}
);
if (cancelRef.current) {
return null;
}
if (!summarizeResult) {
throw new Error('Summarization returned no result');
}
// Create the new compacted tab
const compactedTabName = contextSummarizationService.formatCompactedTabName(
sourceTab.name
);
const tabResult = createTabAtPosition(session, {
afterTabId: sourceTabId,
name: compactedTabName,
logs: summarizeResult.summarizedLogs,
saveToHistory: sourceTab.saveToHistory,
});
if (!tabResult) {
throw new Error('Failed to create compacted tab');
}
// Calculate final result
const finalResult: SummarizeResult = {
success: true,
newTabId: tabResult.tab.id,
originalTokens: summarizeResult.originalTokens,
compactedTokens: summarizeResult.compactedTokens,
reductionPercent: Math.round(
(1 - summarizeResult.compactedTokens / summarizeResult.originalTokens) * 100
),
};
setResult(finalResult);
setState('complete');
setProgress({
stage: 'complete',
progress: 100,
message: 'Complete!',
});
return {
newTabId: tabResult.tab.id,
updatedSession: {
...tabResult.session,
activeTabId: tabResult.tab.id, // Switch to the new tab
},
};
} catch (err) {
if (!cancelRef.current) {
const errorMessage = err instanceof Error ? err.message : 'Summarization failed';
setError(errorMessage);
setState('error');
setResult({
success: false,
originalTokens: 0,
compactedTokens: 0,
reductionPercent: 0,
error: errorMessage,
});
}
return null;
}
}, [session]);
/**
* Cancel the current summarization operation.
*/
const cancel = useCallback(() => {
cancelRef.current = true;
contextSummarizationService.cancelSummarization();
setState('idle');
setProgress(null);
}, []);
/**
* Check if a tab can be summarized.
*/
const canSummarize = useCallback((tab: AITab): boolean => {
return contextSummarizationService.canSummarize(tab);
}, []);
return {
summarizeState: state,
progress,
result,
error,
startSummarize,
cancel,
canSummarize,
minLogsRequired: contextSummarizationService.getMinLogsForSummarize(),
};
}
export default useSummarizeAndContinue;

View File

@@ -0,0 +1,382 @@
/**
* Context Summarization Service
*
* Manages the summarization process for compacting conversation contexts.
* The summarization process:
* 1. Extracts full context from the source tab
* 2. Creates a temporary AI session for summarization
* 3. Sends the context with a summarization prompt
* 4. Receives the compacted summary
* 5. Creates a new tab with the summarized context
* 6. Cleans up the temporary session
*
* This service abstracts the complexity of managing temporary sessions
* and provides progress callbacks for UI updates during the operation.
*/
import type { ToolType } from '../../shared/types';
import type { SummarizeRequest, SummarizeProgress, SummarizeResult } from '../types/contextMerge';
import type { LogEntry, AITab, Session } from '../types';
import { formatLogsForGrooming, parseGroomedOutput, estimateTextTokenCount } from '../utils/contextExtractor';
import { contextSummarizePrompt } from '../../prompts';
/**
* Configuration options for the summarization service.
*/
export interface SummarizationConfig {
/** Maximum time to wait for summarization response (ms) */
timeoutMs?: number;
/** Default agent type for summarization session */
defaultAgentType?: ToolType;
/** Minimum number of logs to require for summarization */
minLogsForSummarize?: number;
}
/**
* Default configuration for summarization operations.
*/
const DEFAULT_CONFIG: Required<SummarizationConfig> = {
timeoutMs: 120000, // 2 minutes
defaultAgentType: 'claude-code',
minLogsForSummarize: 5,
};
/**
* Maximum tokens to summarize in a single pass.
* Larger contexts may need chunked summarization.
*/
const MAX_SUMMARIZE_TOKENS = 50000;
/**
* Service for summarizing and compacting conversation contexts.
*
* @example
* const summarizer = new ContextSummarizationService();
* const result = await summarizer.summarizeAndContinue(
* request,
* (progress) => updateUI(progress)
* );
*/
export class ContextSummarizationService {
private config: Required<SummarizationConfig>;
private activeSummarizationSessionId: string | null = null;
constructor(config: SummarizationConfig = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/**
* Summarize a tab's context and prepare it for a new compacted tab.
*
* This method orchestrates the entire summarization process:
* 1. Extracts full context from the source tab
* 2. Creates a temporary summarization session
* 3. Sends the context with summarization instructions
* 4. Returns the summarized content and token statistics
*
* @param request - The summarization request containing source tab info
* @param sourceLogs - The logs from the source tab
* @param onProgress - Callback for progress updates during the summarization process
* @returns Promise resolving to the summarization result
*/
async summarizeContext(
request: SummarizeRequest,
sourceLogs: LogEntry[],
onProgress: (progress: SummarizeProgress) => void
): Promise<{ summarizedLogs: LogEntry[]; originalTokens: number; compactedTokens: number } | null> {
// Initial progress update
onProgress({
stage: 'extracting',
progress: 0,
message: 'Extracting context...',
});
try {
// Stage 1: Extract and format context
const formattedContext = formatLogsForGrooming(sourceLogs);
const originalTokens = estimateTextTokenCount(formattedContext);
onProgress({
stage: 'extracting',
progress: 20,
message: `Extracted ~${originalTokens.toLocaleString()} tokens`,
});
// Check if context is too large and needs chunking
if (originalTokens > MAX_SUMMARIZE_TOKENS) {
onProgress({
stage: 'summarizing',
progress: 25,
message: 'Large context detected, using chunked summarization...',
});
// For very large contexts, chunk and summarize in parts
return await this.summarizeInChunks(
request,
sourceLogs,
originalTokens,
onProgress
);
}
// Stage 2: Create summarization session
onProgress({
stage: 'summarizing',
progress: 30,
message: 'Starting summarization session...',
});
const summarizationSessionId = await this.createSummarizationSession(request.projectRoot);
onProgress({
stage: 'summarizing',
progress: 40,
message: 'Sending context for compaction...',
});
// Stage 3: Send summarization prompt and get response
const prompt = this.buildSummarizationPrompt(formattedContext);
const summarizedText = await this.sendSummarizationPrompt(summarizationSessionId, prompt);
onProgress({
stage: 'summarizing',
progress: 75,
message: 'Processing summarized output...',
});
// Stage 4: Parse the summarized output
const summarizedLogs = parseGroomedOutput(summarizedText);
const compactedTokens = estimateTextTokenCount(summarizedText);
// Stage 5: Cleanup
onProgress({
stage: 'creating',
progress: 90,
message: 'Cleaning up summarization session...',
});
await this.cleanupSummarizationSession(summarizationSessionId);
return {
summarizedLogs,
originalTokens,
compactedTokens,
};
} catch (error) {
// Ensure cleanup on error
if (this.activeSummarizationSessionId) {
try {
await this.cleanupSummarizationSession(this.activeSummarizationSessionId);
} catch {
// Ignore cleanup errors
}
}
throw error;
}
}
/**
* Summarize large contexts by breaking them into chunks.
*/
private async summarizeInChunks(
request: SummarizeRequest,
sourceLogs: LogEntry[],
_originalTokens: number,
onProgress: (progress: SummarizeProgress) => void
): Promise<{ summarizedLogs: LogEntry[]; originalTokens: number; compactedTokens: number }> {
// Split logs into chunks that fit within token limits
const chunks = this.chunkLogs(sourceLogs, MAX_SUMMARIZE_TOKENS);
const chunkSummaries: string[] = [];
let totalOriginalTokens = 0;
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const chunkText = formatLogsForGrooming(chunk);
totalOriginalTokens += estimateTextTokenCount(chunkText);
onProgress({
stage: 'summarizing',
progress: 30 + Math.round((i / chunks.length) * 40),
message: `Summarizing chunk ${i + 1}/${chunks.length}...`,
});
const sessionId = await this.createSummarizationSession(request.projectRoot);
try {
const prompt = this.buildSummarizationPrompt(chunkText);
const summary = await this.sendSummarizationPrompt(sessionId, prompt);
chunkSummaries.push(summary);
} finally {
await this.cleanupSummarizationSession(sessionId);
}
}
// Combine chunk summaries
const combinedSummary = chunkSummaries.join('\n\n---\n\n');
const summarizedLogs = parseGroomedOutput(combinedSummary);
const compactedTokens = estimateTextTokenCount(combinedSummary);
return {
summarizedLogs,
originalTokens: totalOriginalTokens,
compactedTokens,
};
}
/**
* Split logs into chunks that fit within token limits.
*/
private chunkLogs(logs: LogEntry[], maxTokensPerChunk: number): LogEntry[][] {
const chunks: LogEntry[][] = [];
let currentChunk: LogEntry[] = [];
let currentTokens = 0;
for (const log of logs) {
const logTokens = estimateTextTokenCount(log.text);
if (currentTokens + logTokens > maxTokensPerChunk && currentChunk.length > 0) {
chunks.push(currentChunk);
currentChunk = [];
currentTokens = 0;
}
currentChunk.push(log);
currentTokens += logTokens;
}
if (currentChunk.length > 0) {
chunks.push(currentChunk);
}
return chunks;
}
/**
* Build the complete summarization prompt with system instructions and context.
*
* @param formattedContext - The formatted context string
* @returns Complete prompt to send to the summarization agent
*/
private buildSummarizationPrompt(formattedContext: string): string {
return `${contextSummarizePrompt}
${formattedContext}
---
Please provide a comprehensive but compacted summary of the above conversation, following the output format specified. Preserve all technical details, code snippets, and decisions while removing redundant content.`;
}
/**
* Create a temporary session for the summarization process.
*
* @param projectRoot - The project root path for the summarization session
* @returns Promise resolving to the temporary session ID
*/
private async createSummarizationSession(projectRoot: string): Promise<string> {
const sessionId = `summarize-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
this.activeSummarizationSessionId = sessionId;
try {
const result = await window.maestro.context.createGroomingSession(
projectRoot,
this.config.defaultAgentType
);
if (result) {
this.activeSummarizationSessionId = result;
return result;
}
return sessionId;
} catch {
// If IPC is not available, return the generated ID
return sessionId;
}
}
/**
* Send the summarization prompt to the temporary session.
*
* @param sessionId - The summarization session ID
* @param prompt - The complete summarization prompt
* @returns Promise resolving to the summarized output text
*/
private async sendSummarizationPrompt(sessionId: string, prompt: string): Promise<string> {
try {
const response = await window.maestro.context.sendGroomingPrompt(sessionId, prompt);
return response || '';
} catch {
throw new Error('Context summarization IPC not available. IPC handlers must be configured.');
}
}
/**
* Clean up the temporary summarization session.
*
* @param sessionId - The summarization session ID to clean up
*/
private async cleanupSummarizationSession(sessionId: string): Promise<void> {
try {
await window.maestro.context.cleanupGroomingSession(sessionId);
} catch {
// Ignore cleanup errors
} finally {
if (this.activeSummarizationSessionId === sessionId) {
this.activeSummarizationSessionId = null;
}
}
}
/**
* Format a compacted tab name from the original name.
*
* @param originalName - The original tab name
* @returns The new tab name with "Compacted YYYY-MM-DD" suffix
*/
formatCompactedTabName(originalName: string | null): string {
const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
const baseName = originalName || 'Session';
return `${baseName} Compacted ${date}`;
}
/**
* Check if a tab has enough content to warrant summarization.
*
* @param tab - The AI tab to check
* @returns True if the tab has enough content for summarization
*/
canSummarize(tab: AITab): boolean {
return tab.logs.length >= this.config.minLogsForSummarize;
}
/**
* Get the minimum log count required for summarization.
*/
getMinLogsForSummarize(): number {
return this.config.minLogsForSummarize;
}
/**
* Cancel any active summarization operation.
*/
async cancelSummarization(): Promise<void> {
if (this.activeSummarizationSessionId) {
await this.cleanupSummarizationSession(this.activeSummarizationSessionId);
}
}
/**
* Check if a summarization operation is currently in progress.
*/
isSummarizationActive(): boolean {
return this.activeSummarizationSessionId !== null;
}
}
/**
* Default singleton instance of the summarization service.
* Use this for most cases unless you need custom configuration.
*/
export const contextSummarizationService = new ContextSummarizationService();

View File

@@ -25,3 +25,7 @@ export type { IpcMethodOptions } from './ipcWrapper';
// Context grooming service
export { ContextGroomingService, contextGroomingService } from './contextGroomer';
export type { GroomingResult, GroomingConfig } from './contextGroomer';
// Context summarization service
export { ContextSummarizationService, contextSummarizationService } from './contextSummarizer';
export type { SummarizationConfig } from './contextSummarizer';

View File

@@ -97,3 +97,48 @@ export interface DuplicateDetectionResult {
/** Estimated token savings from removing duplicates */
estimatedSavings: number;
}
/**
* Request to summarize and continue a conversation in a new tab.
*/
export interface SummarizeRequest {
/** The Maestro session ID containing the source tab */
sourceSessionId: string;
/** The ID of the tab to summarize */
sourceTabId: string;
/** Project root path for context */
projectRoot: string;
/** The agent type for the session */
agentType: ToolType;
}
/**
* Result of a summarization operation.
*/
export interface SummarizeResult {
/** Whether the summarization completed successfully */
success: boolean;
/** ID of the newly created tab (on success) */
newTabId?: string;
/** Estimated tokens in the original context */
originalTokens: number;
/** Estimated tokens in the compacted context */
compactedTokens: number;
/** Percentage reduction in token count */
reductionPercent: number;
/** Error message if summarization failed */
error?: string;
}
/**
* Progress information during a summarization operation.
* Used to update the UI during the summarization process.
*/
export interface SummarizeProgress {
/** Current stage of the summarization process */
stage: 'extracting' | 'summarizing' | 'creating' | 'complete';
/** Progress percentage (0-100) */
progress: number;
/** Human-readable status message */
message: string;
}

View File

@@ -661,6 +661,57 @@ export function navigateToLastTab(session: Session, showUnreadOnly = false): Set
return navigateToTabByIndex(session, lastIndex, showUnreadOnly);
}
/**
* Options for creating a new AI tab at a specific position.
*/
export interface CreateTabAtPositionOptions extends CreateTabOptions {
/** Insert the new tab after this tab ID */
afterTabId: string;
}
/**
* Create a new AI tab at a specific position in the session's tab list.
* The new tab is inserted immediately after the specified tab.
*
* @param session - The Maestro session to add the tab to
* @param options - Tab configuration including position (afterTabId)
* @returns Object containing the new tab and updated session, or null on error
*
* @example
* // Create a compacted tab right after the source tab
* const result = createTabAtPosition(session, {
* afterTabId: sourceTab.id,
* name: 'Session Compacted 2024-01-15',
* logs: summarizedLogs,
* });
*/
export function createTabAtPosition(
session: Session,
options: CreateTabAtPositionOptions
): CreateTabResult | null {
const result = createTab(session, options);
if (!result) return null;
// Find the index of the afterTabId
const afterIndex = result.session.aiTabs.findIndex(t => t.id === options.afterTabId);
if (afterIndex === -1) return result;
// Move the new tab to be right after afterTabId
const tabs = [...result.session.aiTabs];
const newTabIndex = tabs.findIndex(t => t.id === result.tab.id);
// Only move if the new tab isn't already in the right position
if (newTabIndex !== afterIndex + 1) {
const [newTab] = tabs.splice(newTabIndex, 1);
tabs.splice(afterIndex + 1, 0, newTab);
}
return {
tab: result.tab,
session: { ...result.session, aiTabs: tabs },
};
}
/**
* Options for creating a merged session from multiple context sources.
*/