mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
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:
58
src/prompts/context-summarize.md
Normal file
58
src/prompts/context-summarize.md
Normal 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:
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
522
src/renderer/components/SummarizeProgressModal.tsx
Normal file
522
src/renderer/components/SummarizeProgressModal.tsx
Normal 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;
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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);
|
||||
|
||||
240
src/renderer/hooks/useSummarizeAndContinue.ts
Normal file
240
src/renderer/hooks/useSummarizeAndContinue.ts
Normal 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;
|
||||
382
src/renderer/services/contextSummarizer.ts
Normal file
382
src/renderer/services/contextSummarizer.ts
Normal 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();
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user