Skip to content

Commit b03bbe8

Browse files
authored
Merge pull request #365 from RunMaestro/0.15.0-polish
fix: 0.15.0 polish — ToC, worktree dupes, Auto Run targeting
2 parents 54f7c45 + 8f375e8 commit b03bbe8

File tree

8 files changed

+220
-66
lines changed

8 files changed

+220
-66
lines changed

src/__tests__/renderer/components/MainPanel.test.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,8 +1088,9 @@ describe('MainPanel', () => {
10881088
expect(screen.getByText('Stopping')).toBeInTheDocument();
10891089
});
10901090

1091-
it('should call onStopBatchRun directly when Auto button is clicked', () => {
1091+
it('should call onStopBatchRun with active session ID when Auto button is clicked', () => {
10921092
const onStopBatchRun = vi.fn();
1093+
const session = createSession({ id: 'session-abc', name: 'My Agent' });
10931094
const currentSessionBatchState: BatchRunState = {
10941095
isRunning: true,
10951096
isStopping: false,
@@ -1113,15 +1114,16 @@ describe('MainPanel', () => {
11131114
render(
11141115
<MainPanel
11151116
{...defaultProps}
1117+
activeSession={session}
11161118
currentSessionBatchState={currentSessionBatchState}
11171119
onStopBatchRun={onStopBatchRun}
11181120
/>
11191121
);
11201122

11211123
fireEvent.click(screen.getByText('Auto'));
11221124

1123-
// onStopBatchRun handles its own confirmation modal, so it should be called directly
1124-
expect(onStopBatchRun).toHaveBeenCalled();
1125+
// onStopBatchRun should be called with the active session's ID
1126+
expect(onStopBatchRun).toHaveBeenCalledWith('session-abc');
11251127
});
11261128

11271129
it('should not call onStopBatchRun when Auto button is clicked while stopping', () => {

src/__tests__/renderer/utils/contextUsage.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { describe, it, expect } from 'vitest';
66
import {
77
estimateContextUsage,
88
calculateContextTokens,
9+
calculateContextDisplay,
910
estimateAccumulatedGrowth,
1011
DEFAULT_CONTEXT_WINDOWS,
1112
} from '../../../renderer/utils/contextUsage';
@@ -348,6 +349,104 @@ describe('estimateAccumulatedGrowth', () => {
348349
});
349350
});
350351

352+
describe('calculateContextDisplay', () => {
353+
it('should calculate tokens and percentage for normal usage', () => {
354+
const result = calculateContextDisplay(
355+
{ inputTokens: 50000, cacheReadInputTokens: 30000, cacheCreationInputTokens: 20000 },
356+
200000,
357+
'claude-code'
358+
);
359+
// (50000 + 30000 + 20000) / 200000 = 50%
360+
expect(result.tokens).toBe(100000);
361+
expect(result.percentage).toBe(50);
362+
expect(result.contextWindow).toBe(200000);
363+
});
364+
365+
it('should fall back to fallbackPercentage when tokens exceed context window', () => {
366+
const result = calculateContextDisplay(
367+
{
368+
inputTokens: 50000,
369+
cacheReadInputTokens: 758000,
370+
cacheCreationInputTokens: 200000,
371+
},
372+
200000,
373+
'claude-code',
374+
75 // preserved contextUsage from session
375+
);
376+
// Raw = 1008000 > 200000, so falls back: tokens = round(75/100 * 200000) = 150000
377+
expect(result.tokens).toBe(150000);
378+
expect(result.percentage).toBe(75);
379+
});
380+
381+
it('should cap percentage at 100 when tokens are close to window', () => {
382+
const result = calculateContextDisplay(
383+
{ inputTokens: 190000, cacheReadInputTokens: 15000, cacheCreationInputTokens: 0 },
384+
200000,
385+
'claude-code'
386+
);
387+
// (190000 + 15000) / 200000 = 102.5% -> capped at 100%
388+
expect(result.percentage).toBe(100);
389+
});
390+
391+
it('should return zeros when context window is 0', () => {
392+
const result = calculateContextDisplay(
393+
{ inputTokens: 50000 },
394+
0,
395+
'claude-code'
396+
);
397+
expect(result.tokens).toBe(0);
398+
expect(result.percentage).toBe(0);
399+
expect(result.contextWindow).toBe(0);
400+
});
401+
402+
it('should not fall back when no fallbackPercentage is provided', () => {
403+
const result = calculateContextDisplay(
404+
{
405+
inputTokens: 50000,
406+
cacheReadInputTokens: 758000,
407+
cacheCreationInputTokens: 200000,
408+
},
409+
200000,
410+
'claude-code'
411+
// no fallback
412+
);
413+
// Raw = 1008000 > 200000, but no fallback, so tokens stay at raw value
414+
// Percentage is capped at 100%
415+
expect(result.tokens).toBe(1008000);
416+
expect(result.percentage).toBe(100);
417+
});
418+
419+
it('should use Codex semantics (includes output tokens)', () => {
420+
const result = calculateContextDisplay(
421+
{ inputTokens: 50000, outputTokens: 30000, cacheCreationInputTokens: 20000 },
422+
200000,
423+
'codex'
424+
);
425+
// Codex: (50000 + 20000 + 30000) / 200000 = 50%
426+
expect(result.tokens).toBe(100000);
427+
expect(result.percentage).toBe(50);
428+
});
429+
430+
it('should handle history entries with accumulated tokens and preserved contextUsage', () => {
431+
// Simulates what HistoryDetailModal sees: accumulated stats + entry.contextUsage
432+
const result = calculateContextDisplay(
433+
{
434+
inputTokens: 5676,
435+
outputTokens: 8522,
436+
cacheReadInputTokens: 1128700,
437+
cacheCreationInputTokens: 0,
438+
},
439+
200000,
440+
undefined, // history entries don't have agent type
441+
100 // the screenshot showed 100% context
442+
);
443+
// Raw = 5676 + 1128700 + 0 = 1134376 > 200000
444+
// Falls back to: round(100/100 * 200000) = 200000
445+
expect(result.tokens).toBe(200000);
446+
expect(result.percentage).toBe(100);
447+
});
448+
});
449+
351450
describe('DEFAULT_CONTEXT_WINDOWS', () => {
352451
it('should have context windows defined for all ToolType agent types', () => {
353452
// Only ToolType values have context windows defined

src/renderer/App.tsx

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1949,7 +1949,17 @@ function MaestroConsoleInner() {
19491949

19501950
const unsubModeratorUsage = window.maestro.groupChat.onModeratorUsage?.((id, usage) => {
19511951
if (id === activeGroupChatId) {
1952-
setModeratorUsage(usage);
1952+
// When contextUsage is -1, tokens were accumulated from multi-tool turns.
1953+
// Preserve previous context/token values; only update cost.
1954+
if (usage.contextUsage < 0) {
1955+
setModeratorUsage((prev) =>
1956+
prev
1957+
? { ...prev, totalCost: usage.totalCost }
1958+
: { contextUsage: 0, totalCost: usage.totalCost, tokenCount: 0 }
1959+
);
1960+
} else {
1961+
setModeratorUsage(usage);
1962+
}
19531963
}
19541964
});
19551965

@@ -2048,6 +2058,7 @@ function MaestroConsoleInner() {
20482058
const fileTreeContainerRef = useRef<HTMLDivElement>(null);
20492059
const fileTreeFilterInputRef = useRef<HTMLInputElement>(null);
20502060
const fileTreeKeyboardNavRef = useRef(false); // Track if selection change came from keyboard
2061+
const recentlyCreatedWorktreePathsRef = useRef(new Set<string>()); // Prevent duplicate UI entries from file watcher
20512062
const rightPanelRef = useRef<RightPanelHandle>(null);
20522063
const mainPanelRef = useRef<MainPanelHandle>(null);
20532064

@@ -6234,6 +6245,11 @@ You are taking over this conversation. Based on the context above, provide a bri
62346245
const cleanup = window.maestro.git.onWorktreeDiscovered(async (data) => {
62356246
const { sessionId, worktree } = data;
62366247

6248+
// Skip worktrees that were just manually created (prevents duplicate UI entries)
6249+
if (recentlyCreatedWorktreePathsRef.current.has(worktree.path)) {
6250+
return;
6251+
}
6252+
62376253
// Skip main/master/HEAD branches (already filtered by main process, but double-check)
62386254
if (
62396255
worktree.branch === 'main' ||
@@ -6669,13 +6685,14 @@ You are taking over this conversation. Based on the context above, provide a bri
66696685

66706686
// Handler to stop batch run (with confirmation)
66716687
// If targetSessionId is provided, stops that specific session's batch run.
6672-
// Otherwise, stops the first active batch run or falls back to active session.
6688+
// Otherwise, falls back to active session, then first active batch run.
66736689
const handleStopBatchRun = useCallback(
66746690
(targetSessionId?: string) => {
6675-
// Use provided targetSessionId, or fall back to first active batch, or active session
6691+
// Use provided targetSessionId, or fall back to active session, or first active batch
66766692
const sessionId =
66776693
targetSessionId ??
6678-
(activeBatchSessionIds.length > 0 ? activeBatchSessionIds[0] : activeSession?.id);
6694+
activeSession?.id ??
6695+
(activeBatchSessionIds.length > 0 ? activeBatchSessionIds[0] : undefined);
66796696
console.log(
66806697
'[App:handleStopBatchRun] targetSessionId:',
66816698
targetSessionId,
@@ -9918,6 +9935,10 @@ You are taking over this conversation. Based on the context above, provide a bri
99189935
sessionSshRemoteConfig: activeSession.sessionSshRemoteConfig,
99199936
};
99209937

9938+
// Mark path so the file watcher discovery handler skips it
9939+
recentlyCreatedWorktreePathsRef.current.add(worktreePath);
9940+
setTimeout(() => recentlyCreatedWorktreePathsRef.current.delete(worktreePath), 10000);
9941+
99219942
setSessions((prev) => [...prev, worktreeSession]);
99229943

99239944
// Expand parent's worktrees
@@ -10071,6 +10092,10 @@ You are taking over this conversation. Based on the context above, provide a bri
1007110092
sessionSshRemoteConfig: createWorktreeSession.sessionSshRemoteConfig,
1007210093
};
1007310094

10095+
// Mark path so the file watcher discovery handler skips it
10096+
recentlyCreatedWorktreePathsRef.current.add(worktreePath);
10097+
setTimeout(() => recentlyCreatedWorktreePathsRef.current.delete(worktreePath), 10000);
10098+
1007410099
setSessions((prev) => [...prev, worktreeSession]);
1007510100

1007610101
// Expand parent's worktrees

src/renderer/components/FilePreview.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import ReactMarkdown from 'react-markdown';
1111
import remarkGfm from 'remark-gfm';
1212
import rehypeRaw from 'rehype-raw';
1313
import rehypeSlug from 'rehype-slug';
14+
import GithubSlugger from 'github-slugger';
1415
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
1516
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
1617
import {
@@ -308,6 +309,7 @@ const extractHeadings = (content: string): TocEntry[] => {
308309
const headings: TocEntry[] = [];
309310
const lines = content.split('\n');
310311
let inCodeFence = false;
312+
const slugger = new GithubSlugger();
311313

312314
for (const line of lines) {
313315
// Track code fence boundaries (``` or ~~~, optionally with language specifier)
@@ -326,12 +328,8 @@ const extractHeadings = (content: string): TocEntry[] => {
326328
if (match) {
327329
const level = match[1].length;
328330
const text = match[2].trim();
329-
// Generate slug same way rehype-slug does (lowercase, replace spaces with hyphens, remove special chars)
330-
const slug = text
331-
.toLowerCase()
332-
.replace(/[^\w\s-]/g, '')
333-
.replace(/\s+/g, '-')
334-
.replace(/^-+|-+$/g, '');
331+
// Use github-slugger to match rehype-slug's ID generation exactly
332+
const slug = slugger.slug(text);
335333
headings.push({ level, text, slug });
336334
}
337335
}

src/renderer/components/HistoryDetailModal.tsx

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { formatElapsedTime } from '../utils/formatters';
2424
import { stripAnsiCodes } from '../../shared/stringUtils';
2525
import { MarkdownRenderer } from './MarkdownRenderer';
2626
import { generateTerminalProseStyles } from '../utils/markdownConfig';
27-
import { calculateContextTokens } from '../utils/contextUsage';
27+
import { calculateContextDisplay } from '../utils/contextUsage';
2828
import { getContextColor } from '../utils/theme';
2929

3030
// Double checkmark SVG component for validated entries
@@ -400,20 +400,18 @@ export function HistoryDetailModal({
400400
</span>
401401
</div>
402402
{(() => {
403-
// Context usage using agent-specific calculation
404-
// Note: History entries don't store agent type, defaults to Claude behavior
405-
// SYNC: Uses calculateContextTokens() from shared/contextUsage.ts
406-
// See that file for the canonical formula and all locations that must stay in sync.
407-
const contextTokens = calculateContextTokens({
408-
inputTokens: entry.usageStats!.inputTokens,
409-
outputTokens: entry.usageStats!.outputTokens,
410-
cacheCreationInputTokens: entry.usageStats!.cacheCreationInputTokens ?? 0,
411-
cacheReadInputTokens: entry.usageStats!.cacheReadInputTokens ?? 0,
412-
});
413-
const contextUsage = Math.min(
414-
100,
415-
Math.round((contextTokens / entry.usageStats!.contextWindow) * 100)
416-
);
403+
const { tokens: contextTokens, percentage: contextUsage } =
404+
calculateContextDisplay(
405+
{
406+
inputTokens: entry.usageStats!.inputTokens,
407+
outputTokens: entry.usageStats!.outputTokens,
408+
cacheCreationInputTokens: entry.usageStats!.cacheCreationInputTokens ?? 0,
409+
cacheReadInputTokens: entry.usageStats!.cacheReadInputTokens ?? 0,
410+
},
411+
entry.usageStats!.contextWindow,
412+
undefined,
413+
entry.contextUsage
414+
);
417415
return (
418416
<div className="flex flex-col gap-1">
419417
<div className="flex items-center gap-2">

src/renderer/components/MainPanel.tsx

Lines changed: 10 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import { gitService } from '../services/git';
3737
import { remoteUrlToBrowserUrl } from '../../shared/gitUtils';
3838
import { useGitBranch, useGitDetail, useGitFileStatus } from '../contexts/GitStatusContext';
3939
import { formatShortcutKeys } from '../utils/shortcutFormatter';
40-
import { calculateContextTokens } from '../utils/contextUsage';
40+
import { calculateContextDisplay } from '../utils/contextUsage';
4141
import { useAgentCapabilities, useHoverTooltip } from '../hooks';
4242
import type {
4343
Session,
@@ -594,47 +594,28 @@ export const MainPanel = React.memo(
594594
return configured > 0 ? configured : reported;
595595
}, [configuredContextWindow, activeTab?.usageStats?.contextWindow]);
596596

597-
// Compute context tokens using agent-specific calculation.
598-
// Claude: input + cacheRead + cacheCreation (total input for the request)
599-
// Codex: input + output (combined limit)
600-
// When values are accumulated from multi-tool turns, total may exceed contextWindow.
601-
// In that case, derive tokens from session.contextUsage (preserved last valid percentage).
602-
const activeTabContextTokens = useMemo(() => {
603-
if (!activeTab?.usageStats) return 0;
604-
const raw = calculateContextTokens(
597+
// Compute context tokens and percentage using the shared helper.
598+
// Handles accumulated multi-tool turns by falling back to session.contextUsage.
599+
const { tokens: activeTabContextTokens, percentage: activeTabContextUsage } = useMemo(() => {
600+
if (!activeTab?.usageStats) return { tokens: 0, percentage: 0 };
601+
return calculateContextDisplay(
605602
{
606603
inputTokens: activeTab.usageStats.inputTokens,
607604
outputTokens: activeTab.usageStats.outputTokens,
608605
cacheCreationInputTokens: activeTab.usageStats.cacheCreationInputTokens ?? 0,
609606
cacheReadInputTokens: activeTab.usageStats.cacheReadInputTokens ?? 0,
610607
},
611-
activeSession?.toolType
608+
activeTabContextWindow,
609+
activeSession?.toolType,
610+
activeSession?.contextUsage
612611
);
613-
614-
// If raw exceeds window, values are accumulated from multi-tool turns.
615-
// Fall back to deriving from the preserved contextUsage percentage.
616-
const effectiveWindow = activeTabContextWindow || 200000;
617-
if (raw > effectiveWindow && activeSession?.contextUsage != null) {
618-
return Math.round((activeSession.contextUsage / 100) * effectiveWindow);
619-
}
620-
621-
return raw;
622612
}, [
623613
activeTab?.usageStats,
624614
activeSession?.toolType,
625615
activeTabContextWindow,
626616
activeSession?.contextUsage,
627617
]);
628618

629-
// Compute context usage percentage from context tokens and window size.
630-
// Since we already handle accumulated values in activeTabContextTokens,
631-
// we just calculate the percentage directly.
632-
const activeTabContextUsage = useMemo(() => {
633-
if (!activeTabContextWindow || activeTabContextWindow === 0) return 0;
634-
if (activeTabContextTokens === 0) return 0;
635-
return Math.round((activeTabContextTokens / activeTabContextWindow) * 100);
636-
}, [activeTabContextTokens, activeTabContextWindow]);
637-
638619
// PERF: Track panel width for responsive widget hiding with threshold-based updates
639620
// Only update state when width crosses a meaningful threshold (20px) to prevent
640621
// unnecessary re-renders during window resize animations
@@ -1861,7 +1842,7 @@ export const MainPanel = React.memo(
18611842
thinkingSessions={thinkingSessions}
18621843
onSessionClick={handleSessionClick}
18631844
autoRunState={currentSessionBatchState || undefined}
1864-
onStopAutoRun={onStopBatchRun}
1845+
onStopAutoRun={() => onStopBatchRun?.(activeSession.id)}
18651846
onOpenQueueBrowser={onOpenQueueBrowser}
18661847
tabReadOnlyMode={activeTab?.readOnlyMode ?? false}
18671848
onToggleTabReadOnlyMode={props.onToggleTabReadOnlyMode}

0 commit comments

Comments
 (0)