From 98ff1f79c9db106072de2e011d85a5069cd2b6e3 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Mon, 15 Jun 2026 12:14:30 +0800 Subject: [PATCH 1/3] Fixed the topmost back panel being cut off --- packages/desktop/src/shared/MessageItem.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/desktop/src/shared/MessageItem.tsx b/packages/desktop/src/shared/MessageItem.tsx index 2e97cbf..9f82bc4 100644 --- a/packages/desktop/src/shared/MessageItem.tsx +++ b/packages/desktop/src/shared/MessageItem.tsx @@ -44,13 +44,12 @@ const MessageItem = memo(function MessageItem({ useLayoutEffect(() => { if (!rollbackMenuOpen || !rollbackMenuRef.current || !rollbackBtnRef.current) return; const menuRect = rollbackMenuRef.current.getBoundingClientRect(); - const btnRect = rollbackBtnRef.current.getBoundingClientRect(); const flip: { vertical?: boolean; horizontal?: boolean } = {}; - // If menu goes above viewport, flip to below button - if (menuRect.top < 0) { + // Account for title bar overlay (~36px on Windows, ~28px on macOS) + const safeTop = 40; + if (menuRect.top < safeTop) { flip.vertical = true; } - // If menu goes beyond right edge, flip to left-align if (menuRect.right > window.innerWidth) { flip.horizontal = true; } From 664233c6693df6e8bc1716e219a6576760ae7f1b Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Mon, 15 Jun 2026 19:45:57 +0800 Subject: [PATCH 2/3] Move the context configuration constants to the top of the code --- packages/codingcode/src/agent/agent.ts | 4 +++- packages/codingcode/src/context/service.ts | 16 +++++++++++----- packages/codingcode/src/session/messages.ts | 4 +++- .../test/agent/agent-cache-stability.test.ts | 5 ----- .../test/agent/agent-concurrent.test.ts | 5 ----- .../test/agent/agent-todo-event.test.ts | 5 ----- packages/codingcode/test/agent/agent.test.ts | 5 ----- packages/codingcode/test/agent/config.test.ts | 5 ----- .../test/agent/hooks-deps-type.test.ts | 5 ----- .../codingcode/test/agent/loop-options.test.ts | 5 ----- .../test/agent/memory-snapshot.test.ts | 5 ----- packages/codingcode/test/agent/stop-hook.test.ts | 5 ----- .../test/context/append-turn-end.test.ts | 11 +---------- .../test/context/budget-integration.test.ts | 5 ----- .../test/context/compressor/behavior.test.ts | 13 ++++--------- .../context/compressor/compact-if-needed.test.ts | 5 ----- .../codingcode/test/context/organizer.test.ts | 5 ----- .../session/record-tool-result-persist.test.ts | 5 ----- packages/infra/src/config.ts | 10 ---------- 19 files changed, 22 insertions(+), 101 deletions(-) diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index 378eb56..1a6aa0e 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -28,6 +28,8 @@ import { LLMFactoryService } from '../llm/factory.js'; import { getBuiltinTools } from '../tools/providers.js'; import { canonicalizeSchema } from '../tools/utils/canonicalize-schema.js'; import { normalizePath } from '../core/path.js'; + +const REACTIVE_COMPACT_MAX_RETRIES = 3; import { RulesService } from '../rules/index.js'; const logger = createLogger(); @@ -250,7 +252,7 @@ export function agentLoop( const system = [basePrompt, memorySection].filter(Boolean).join('\n\n'); const config = getContextConfig(); - const maxOverflowRetries = config.reactiveCompactMaxRetries; + const maxOverflowRetries = REACTIVE_COMPACT_MAX_RETRIES; const model = state.sessionMeta?.model ?? 'unknown'; const effectiveMaxSteps = opts.maxStepsOverride ?? maxSteps; diff --git a/packages/codingcode/src/context/service.ts b/packages/codingcode/src/context/service.ts index 891ff4b..c7dafe8 100644 --- a/packages/codingcode/src/context/service.ts +++ b/packages/codingcode/src/context/service.ts @@ -29,6 +29,12 @@ const COMPACTABLE_TOOLS = new Set([ 'edit_file', ]); +const MICRO_COMPACT_THRESHOLD = 0.25; +const MICRO_COMPACT_MIN_CHARS = 120; +const COMPACTION_THRESHOLD = 0.9; +const KEEP_RECENT_TURNS = 1; +const REACTIVE_COMPACT_MAX_RETRIES = 3; + export class ContextService extends Effect.Service()('Context', { effect: Effect.gen(function* () { const session = yield* SessionService; @@ -107,7 +113,7 @@ export class ContextService extends Effect.Service()('Context', contextWindow: number, jsonlPath: string ): boolean { - if (promptEstimate <= contextWindow * config.microCompactThreshold) return false; + if (promptEstimate <= contextWindow * MICRO_COMPACT_THRESHOLD) return false; const compactedTurnIds = new Set(); for (const ev of events) { @@ -124,7 +130,7 @@ export class ContextService extends Effect.Service()('Context', if (ev.turnId >= currentTurnId - 1) continue; if (compactedTurnIds.has(ev.turnId)) continue; if (!COMPACTABLE_TOOLS.has(ev.toolName.toLowerCase())) continue; - if (ev.output.length <= config.microCompactMinChars) continue; + if (ev.output.length <= MICRO_COMPACT_MIN_CHARS) continue; oldResults.push(ev); } @@ -165,7 +171,7 @@ export class ContextService extends Effect.Service()('Context', return { didCompress: false, released: 0, promptEstimate }; } - const threshold = modelMaxTokens * config.compactionThreshold; + const threshold = modelMaxTokens * COMPACTION_THRESHOLD; if (promptEstimate <= threshold) { return { didCompress: false, released: 0, promptEstimate }; } @@ -208,7 +214,7 @@ export class ContextService extends Effect.Service()('Context', let released = 0; - const threshold = modelMaxTokens ? modelMaxTokens * config.compactionThreshold : Infinity; + const threshold = modelMaxTokens ? modelMaxTokens * COMPACTION_THRESHOLD : Infinity; if (usage === undefined || usage - released > threshold) { released += await tryCompaction( sessionId, @@ -236,7 +242,7 @@ export class ContextService extends Effect.Service()('Context', currentTurnId: number, compactedTurnIds: Set ): Promise { - const endTurn = currentTurnId - config.keepRecentTurns - 1; + const endTurn = currentTurnId - KEEP_RECENT_TURNS - 1; if (endTurn < 1) return 0; const inRange = compactedEvents.filter((ev) => { diff --git a/packages/codingcode/src/session/messages.ts b/packages/codingcode/src/session/messages.ts index 05a8bcc..87fe877 100644 --- a/packages/codingcode/src/session/messages.ts +++ b/packages/codingcode/src/session/messages.ts @@ -15,6 +15,8 @@ const COMPACTABLE_TOOLS = new Set([ 'edit_file', ]); +const MICRO_COMPACT_MIN_CHARS = 120; + export interface VisibilityResult { hidden: Set; compactedTurnIds: Set; @@ -109,7 +111,7 @@ export function buildMessagesFromEvents( if ( compactedTurnIds.has(event.turnId) && COMPACTABLE_TOOLS.has(event.toolName.toLowerCase()) && - event.output.length > getContextConfig().microCompactMinChars + event.output.length > MICRO_COMPACT_MIN_CHARS ) { output = `[Earlier: used ${event.toolName}]`; } diff --git a/packages/codingcode/test/agent/agent-cache-stability.test.ts b/packages/codingcode/test/agent/agent-cache-stability.test.ts index 20039fb..7333510 100644 --- a/packages/codingcode/test/agent/agent-cache-stability.test.ts +++ b/packages/codingcode/test/agent/agent-cache-stability.test.ts @@ -9,12 +9,7 @@ import { MemoryService } from '../../src/memory/index.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ context: { - microCompactThreshold: 0.7, - microCompactMinChars: 200, - compactionThreshold: 0.8, - keepRecentTurns: 10, compactionModel: '', - reactiveCompactMaxRetries: 1, }, memory: { enabled: false, diff --git a/packages/codingcode/test/agent/agent-concurrent.test.ts b/packages/codingcode/test/agent/agent-concurrent.test.ts index aa32c12..71ded62 100644 --- a/packages/codingcode/test/agent/agent-concurrent.test.ts +++ b/packages/codingcode/test/agent/agent-concurrent.test.ts @@ -9,12 +9,7 @@ import { MemoryService } from '../../src/memory/index.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ context: { - microCompactThreshold: 0.7, - microCompactMinChars: 200, - compactionThreshold: 0.8, - keepRecentTurns: 10, compactionModel: '', - reactiveCompactMaxRetries: 1, }, memory: { enabled: false, diff --git a/packages/codingcode/test/agent/agent-todo-event.test.ts b/packages/codingcode/test/agent/agent-todo-event.test.ts index b018cfe..851a4bb 100644 --- a/packages/codingcode/test/agent/agent-todo-event.test.ts +++ b/packages/codingcode/test/agent/agent-todo-event.test.ts @@ -9,12 +9,7 @@ import { MemoryService } from '../../src/memory/index.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ context: { - microCompactThreshold: 0.7, - microCompactMinChars: 200, - compactionThreshold: 0.8, - keepRecentTurns: 10, compactionModel: '', - reactiveCompactMaxRetries: 1, }, memory: { enabled: false, diff --git a/packages/codingcode/test/agent/agent.test.ts b/packages/codingcode/test/agent/agent.test.ts index 6cdfa97..e1b2391 100644 --- a/packages/codingcode/test/agent/agent.test.ts +++ b/packages/codingcode/test/agent/agent.test.ts @@ -15,12 +15,7 @@ import { MemoryService } from '../../src/memory/index.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ context: { - microCompactThreshold: 0.7, - microCompactMinChars: 200, - compactionThreshold: 0.8, - keepRecentTurns: 10, compactionModel: '', - reactiveCompactMaxRetries: 1, }, memory: { enabled: false, diff --git a/packages/codingcode/test/agent/config.test.ts b/packages/codingcode/test/agent/config.test.ts index 0419f50..8886fc7 100644 --- a/packages/codingcode/test/agent/config.test.ts +++ b/packages/codingcode/test/agent/config.test.ts @@ -4,12 +4,7 @@ import { resolveConfig } from '../../src/agent/config.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ context: { - microCompactThreshold: 0.7, - microCompactMinChars: 200, - compactionThreshold: 0.8, - keepRecentTurns: 10, compactionModel: '', - reactiveCompactMaxRetries: 1, }, memory: { enabled: false, diff --git a/packages/codingcode/test/agent/hooks-deps-type.test.ts b/packages/codingcode/test/agent/hooks-deps-type.test.ts index 0c0023a..f5895d9 100644 --- a/packages/codingcode/test/agent/hooks-deps-type.test.ts +++ b/packages/codingcode/test/agent/hooks-deps-type.test.ts @@ -9,12 +9,7 @@ import { MemoryService } from '../../src/memory/index.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ context: { - microCompactThreshold: 0.7, - microCompactMinChars: 200, - compactionThreshold: 0.8, - keepRecentTurns: 10, compactionModel: '', - reactiveCompactMaxRetries: 1, }, memory: { enabled: false, diff --git a/packages/codingcode/test/agent/loop-options.test.ts b/packages/codingcode/test/agent/loop-options.test.ts index c8e185b..081211c 100644 --- a/packages/codingcode/test/agent/loop-options.test.ts +++ b/packages/codingcode/test/agent/loop-options.test.ts @@ -9,12 +9,7 @@ import { MemoryService } from '../../src/memory/index.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ context: { - microCompactThreshold: 0.7, - microCompactMinChars: 200, - compactionThreshold: 0.8, - keepRecentTurns: 10, compactionModel: '', - reactiveCompactMaxRetries: 1, }, memory: { enabled: false, diff --git a/packages/codingcode/test/agent/memory-snapshot.test.ts b/packages/codingcode/test/agent/memory-snapshot.test.ts index bf3a05f..421297c 100644 --- a/packages/codingcode/test/agent/memory-snapshot.test.ts +++ b/packages/codingcode/test/agent/memory-snapshot.test.ts @@ -9,12 +9,7 @@ import { MemoryService } from '../../src/memory/index.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ context: { - microCompactThreshold: 0.7, - microCompactMinChars: 200, - compactionThreshold: 0.8, - keepRecentTurns: 10, compactionModel: '', - reactiveCompactMaxRetries: 1, }, memory: { enabled: false, diff --git a/packages/codingcode/test/agent/stop-hook.test.ts b/packages/codingcode/test/agent/stop-hook.test.ts index 13dcf5a..fd39033 100644 --- a/packages/codingcode/test/agent/stop-hook.test.ts +++ b/packages/codingcode/test/agent/stop-hook.test.ts @@ -9,12 +9,7 @@ import { MemoryService } from '../../src/memory/index.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ context: { - microCompactThreshold: 0.7, - microCompactMinChars: 200, - compactionThreshold: 0.8, - keepRecentTurns: 10, compactionModel: '', - reactiveCompactMaxRetries: 1, }, memory: { enabled: false, diff --git a/packages/codingcode/test/context/append-turn-end.test.ts b/packages/codingcode/test/context/append-turn-end.test.ts index 7e832f6..5ea8a24 100644 --- a/packages/codingcode/test/context/append-turn-end.test.ts +++ b/packages/codingcode/test/context/append-turn-end.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; @@ -9,12 +9,7 @@ import { getContextConfig } from '../../src/context/config.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ context: { - microCompactThreshold: 0.7, - microCompactMinChars: 200, - compactionThreshold: 0.8, - keepRecentTurns: 10, compactionModel: '', - reactiveCompactMaxRetries: 1, }, memory: { enabled: false, @@ -74,8 +69,4 @@ describe('appendTurnEnd', () => { expect(parsed.tokenCount).toBe(tokens); }); - it('compression thresholds have sensible defaults', () => { - const config = getContextConfig(); - expect(config.compactionThreshold).toBeGreaterThan(0); - }); }); diff --git a/packages/codingcode/test/context/budget-integration.test.ts b/packages/codingcode/test/context/budget-integration.test.ts index 56ba047..6a68a19 100644 --- a/packages/codingcode/test/context/budget-integration.test.ts +++ b/packages/codingcode/test/context/budget-integration.test.ts @@ -13,12 +13,7 @@ const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); function makeConfig() { return { - microCompactThreshold: 0.5, - microCompactMinChars: 120, - compactionThreshold: 0.9, - keepRecentTurns: 1, compactionModel: '', - reactiveCompactMaxRetries: 3, }; } diff --git a/packages/codingcode/test/context/compressor/behavior.test.ts b/packages/codingcode/test/context/compressor/behavior.test.ts index 87813b3..3b4ea8a 100644 --- a/packages/codingcode/test/context/compressor/behavior.test.ts +++ b/packages/codingcode/test/context/compressor/behavior.test.ts @@ -110,12 +110,7 @@ function readSummaryEvents(jsonlPath: string): SummaryEvent[] { function tinyConfig(overrides: Partial = {}): ContextConfig { return { - microCompactThreshold: 0.5, - microCompactMinChars: 120, - compactionThreshold: 0.5, - keepRecentTurns: 2, compactionModel: '', - reactiveCompactMaxRetries: 1, ...overrides, }; } @@ -164,7 +159,7 @@ describe('compressor behavior', () => { it('writes summary event with five-section system summary', async () => { const fx = makeFixture({ numTurns: 5 }); try { - const cfg = tinyConfig({ keepRecentTurns: 2 }); + const cfg = tinyConfig(); const summary = '## Compacted History\n\n### Goal\nfix bug\n\n### Instructions\nbe careful\n\n### Discoveries\nrace condition\n\n### Accomplished\npatched\n\n### Relevant Files\nsrc/x.ts'; const llm = makeMockLLM(summary); @@ -182,7 +177,7 @@ describe('compressor behavior', () => { it('returns no-op when no LLM available', async () => { const fx = makeFixture({ numTurns: 5 }); try { - const cfg = tinyConfig({ keepRecentTurns: 2 }); + const cfg = tinyConfig(); const ctx = await getCtxService(); const result = await ctx.compactWithLLM(fx.sessionId, fx.slug, cfg, null); expect(result.didCompress).toBe(false); @@ -198,7 +193,7 @@ describe('compressor behavior', () => { it('appends summary event directly to JSONL after L5', async () => { const fx = makeFixture({ numTurns: 5 }); try { - const cfg = tinyConfig({ keepRecentTurns: 2 }); + const cfg = tinyConfig(); const llm = makeMockLLM( '## Compacted History\n\n### Goal\na\n\n### Instructions\nb\n\n### Discoveries\nc\n\n### Accomplished\nd\n\n### Relevant Files\ne' ); @@ -219,7 +214,7 @@ describe('compressor behavior', () => { const fx = makeFixture({ numTurns: 5 }); try { const before = estimateTokens(buildMessages(fx.transcriptPath)); - const cfg = tinyConfig({ keepRecentTurns: 2 }); + const cfg = tinyConfig(); const llm = makeMockLLM( '## Compacted History\n\n### Goal\na\n\n### Instructions\nb\n\n### Discoveries\nc\n\n### Accomplished\nd\n\n### Relevant Files\ne' ); diff --git a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts index 1bf5eef..20d10ef 100644 --- a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts +++ b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts @@ -90,12 +90,7 @@ async function getCtxService(): Promise { function config(threshold: number, maxTokens = 10000) { return { - microCompactThreshold: 0.5, - microCompactMinChars: 120, - compactionThreshold: threshold, - keepRecentTurns: 1, compactionModel: '', - reactiveCompactMaxRetries: 1, } as any; } diff --git a/packages/codingcode/test/context/organizer.test.ts b/packages/codingcode/test/context/organizer.test.ts index f63e642..809bd37 100644 --- a/packages/codingcode/test/context/organizer.test.ts +++ b/packages/codingcode/test/context/organizer.test.ts @@ -6,12 +6,7 @@ import { LLMFactoryService } from '../../src/llm/factory.js'; import type { SessionEvent, ToolResultEvent } from '../../src/session/types.js'; const baseConfig = { - microCompactThreshold: 0.5, - microCompactMinChars: 120, - compactionThreshold: 0.9, - keepRecentTurns: 1, compactionModel: '', - reactiveCompactMaxRetries: 3, }; function makeUserEvent(content: string, turnId: number): SessionEvent { diff --git a/packages/codingcode/test/session/record-tool-result-persist.test.ts b/packages/codingcode/test/session/record-tool-result-persist.test.ts index e580440..f6e4cdc 100644 --- a/packages/codingcode/test/session/record-tool-result-persist.test.ts +++ b/packages/codingcode/test/session/record-tool-result-persist.test.ts @@ -4,12 +4,7 @@ import { SessionService } from '../../src/session/store.js'; vi.mock('../../src/context/config.js', () => ({ getContextConfig: vi.fn(() => ({ - microCompactThreshold: 0.5, - microCompactMinChars: 120, - compactionThreshold: 0.9, - keepRecentTurns: 1, compactionModel: '', - reactiveCompactMaxRetries: 3, })), })); diff --git a/packages/infra/src/config.ts b/packages/infra/src/config.ts index 6600df6..c3cdffa 100644 --- a/packages/infra/src/config.ts +++ b/packages/infra/src/config.ts @@ -4,15 +4,10 @@ import { homedir } from 'os'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; export interface ContextConfig { - microCompactThreshold: number; - microCompactMinChars: number; - compactionThreshold: number; - keepRecentTurns: number; /** Model for context compaction. Empty string falls back to main session LLM. * Use full id format "model@API_KEY_ENV" to avoid ambiguity (e.g. "deepseek-chat@DEEPSEEK_API_KEY"). * Can also use bare model id (e.g. "deepseek-chat") or display name, first match wins. */ compactionModel: string; - reactiveCompactMaxRetries: number; } export interface MemoryTypeConfig { @@ -52,12 +47,7 @@ export interface AppConfig { } const DEFAULT_CONTEXT: ContextConfig = { - microCompactThreshold: 0.25, - microCompactMinChars: 120, - compactionThreshold: 0.9, - keepRecentTurns: 1, compactionModel: '', - reactiveCompactMaxRetries: 3, }; export const DEFAULT_MEMORY_TYPES: MemoryTypeConfig[] = [ From d905d9eda2169d5fc902787ffcc5cda0917a64e9 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Mon, 15 Jun 2026 20:51:58 +0800 Subject: [PATCH 3/3] Change the scroll effect when switching conversations --- packages/desktop/src/agent/AgentWorkspace.tsx | 2 +- packages/desktop/src/agent/MessageStream.tsx | 11 +- .../test/message-stream-scroll.test.tsx | 156 ++++++++++++++++++ packages/desktop/test/scroll-layout.test.ts | 21 ++- 4 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 packages/desktop/test/message-stream-scroll.test.tsx diff --git a/packages/desktop/src/agent/AgentWorkspace.tsx b/packages/desktop/src/agent/AgentWorkspace.tsx index 986a453..301f96c 100644 --- a/packages/desktop/src/agent/AgentWorkspace.tsx +++ b/packages/desktop/src/agent/AgentWorkspace.tsx @@ -370,7 +370,7 @@ export default function AgentWorkspace({ sendMessage, abort }: AgentWorkspacePro return (
- + {isCompressing && ( diff --git a/packages/desktop/src/agent/MessageStream.tsx b/packages/desktop/src/agent/MessageStream.tsx index f939253..adfab30 100644 --- a/packages/desktop/src/agent/MessageStream.tsx +++ b/packages/desktop/src/agent/MessageStream.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } from 'react'; import { Copy, Check } from 'lucide-react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useGlobalStore } from '../stores/global.store'; @@ -229,6 +229,7 @@ export default function MessageStream({ threadId }: MessageStreamProps) { } = useAgentRollback(); const { copiedId, copy } = useCopyToClipboard(); const parentRef = useRef(null); + const didScrollToEndRef = useRef(false); const markFileRestored = useGlobalStore((s) => s.markFileRestored); const setPendingInput = useGlobalStore((s) => s.setPendingInput); @@ -371,8 +372,16 @@ export default function MessageStream({ threadId }: MessageStreamProps) { anchorTo: 'end', followOnAppend: 'smooth', scrollEndThreshold: 80, + initialOffset: () => Number.MAX_SAFE_INTEGER, }); + useLayoutEffect(() => { + if (renderEntries.length === 0) return; + if (didScrollToEndRef.current) return; + virtualizer.scrollToEnd({ behavior: 'instant' }); + didScrollToEndRef.current = true; + }, [renderEntries.length, virtualizer]); + const turnStatusKey = useMemo(() => turns.map((t) => `${t.id}:${t.status}`).join(','), [turns]); const handleLoadDiff = useCallback( diff --git a/packages/desktop/test/message-stream-scroll.test.tsx b/packages/desktop/test/message-stream-scroll.test.tsx new file mode 100644 index 0000000..67111fb --- /dev/null +++ b/packages/desktop/test/message-stream-scroll.test.tsx @@ -0,0 +1,156 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, cleanup, act } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { useGlobalStore } from '../src/stores/global.store'; +import MessageStream from '../src/agent/MessageStream'; +import type { Turn } from '../shared/types'; + +let lastVirtualizerOptions: Record | null = null; +const scrollToEndMock = vi.fn(); +const measureElementMock = vi.fn(); + +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: (options: Record) => { + lastVirtualizerOptions = options; + return { + getTotalSize: () => (options.count as number) * 60, + getVirtualItems: () => [], + measureElement: measureElementMock, + scrollToEnd: scrollToEndMock, + }; + }, +})); + +vi.mock('../src/hooks/useAgent', () => ({ + useAgentApproval: () => ({ approveTool: vi.fn(), rejectTool: vi.fn() }), + useAgentRollback: () => ({ + loadCheckpointDiff: vi.fn().mockResolvedValue({ turnId: 0, files: [] }), + revertFile: vi.fn(), + revertFiles: vi.fn(), + previewRollback: vi.fn(), + rollbackCtx: vi.fn(), + rollbackBoth: vi.fn(), + undoCodeRollback: vi.fn(), + forkThread: vi.fn(), + initRollbackState: vi.fn(), + deleteThread: vi.fn(), + revertedFilesByTurnId: {}, + }), +})); + +vi.mock('../src/hooks/useCopyToClipboard', () => ({ + useCopyToClipboard: () => ({ copiedId: null, copy: vi.fn() }), +})); + +function makeTurn(id: string, items: Turn['items']): Turn { + return { id, items, status: 'completed' }; +} + +function setThread(threadId: string, turns: Turn[]) { + act(() => { + useGlobalStore.setState((s) => { + s.agent.threads[threadId] = { + id: threadId, + projectId: '', + title: threadId, + cwd: '/cwd', + turns, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + }); + }); +} + +beforeEach(() => { + cleanup(); + scrollToEndMock.mockClear(); + measureElementMock.mockClear(); + lastVirtualizerOptions = null; + useGlobalStore.setState({ + agent: { + currentThreadId: null, + threads: {}, + approvalPolicy: 'ask-all', + model: '', + models: [], + contextUsage: null, + todoByThreadId: {}, + pendingInput: null, + usageByThreadId: {}, + isCompressing: false, + hasRunningTurn: false, + automations: [], + }, + rollback: { + rollbackStateByThreadId: {}, + checkpointDiffByTurnId: {}, + rollbackPreviewByThreadId: {}, + revertedFilesByTurnId: {}, + turnCheckpointMapping: {}, + }, + }); +}); + +describe('MessageStream scroll behavior', () => { + it('configures virtualizer with initialOffset set to a very large value', () => { + setThread('t1', [makeTurn('t1-1', [{ id: 'm1', type: 'message', role: 'user', content: 'hi' }])]); + render(); + expect(lastVirtualizerOptions).not.toBeNull(); + expect(typeof lastVirtualizerOptions!.initialOffset).toBe('function'); + expect((lastVirtualizerOptions!.initialOffset as () => number)()).toBe(Number.MAX_SAFE_INTEGER); + }); + + it('scrolls to end instantly on initial render with messages', () => { + setThread('t1', [makeTurn('t1-1', [{ id: 'm1', type: 'message', role: 'user', content: 'hi' }])]); + render(); + expect(scrollToEndMock).toHaveBeenCalledTimes(1); + expect(scrollToEndMock).toHaveBeenCalledWith({ behavior: 'instant' }); + }); + + it('does not scroll again when messages append to the same thread', () => { + setThread('t1', [makeTurn('t1-1', [{ id: 'm1', type: 'message', role: 'user', content: 'hi' }])]); + const { rerender } = render(); + expect(scrollToEndMock).toHaveBeenCalledTimes(1); + + act(() => { + setThread('t1', [ + makeTurn('t1-1', [{ id: 'm1', type: 'message', role: 'user', content: 'hi' }]), + makeTurn('t1-2', [{ id: 'm2', type: 'message', role: 'assistant', content: 'hello' }]), + ]); + }); + + rerender(); + expect(scrollToEndMock).toHaveBeenCalledTimes(1); + }); + + it('scrolls to end when a thread starts empty and then loads messages', () => { + setThread('t1', []); + const { rerender } = render(); + expect(scrollToEndMock).not.toHaveBeenCalled(); + + act(() => { + setThread('t1', [makeTurn('t1-1', [{ id: 'm1', type: 'message', role: 'user', content: 'hi' }])]); + }); + + rerender(); + expect(scrollToEndMock).toHaveBeenCalledTimes(1); + expect(scrollToEndMock).toHaveBeenCalledWith({ behavior: 'instant' }); + }); + + it('scrolls to end again after switching to a different thread', () => { + setThread('t1', [makeTurn('t1-1', [{ id: 'm1', type: 'message', role: 'user', content: 'hi' }])]); + setThread('t2', [makeTurn('t2-1', [{ id: 'm2', type: 'message', role: 'user', content: 'yo' }])]); + + const { unmount } = render(); + expect(scrollToEndMock).toHaveBeenCalledTimes(1); + + unmount(); + render(); + expect(scrollToEndMock).toHaveBeenCalledTimes(2); + expect(scrollToEndMock).toHaveBeenLastCalledWith({ behavior: 'instant' }); + }); +}); diff --git a/packages/desktop/test/scroll-layout.test.ts b/packages/desktop/test/scroll-layout.test.ts index 7c99c53..fc7b67e 100644 --- a/packages/desktop/test/scroll-layout.test.ts +++ b/packages/desktop/test/scroll-layout.test.ts @@ -3,11 +3,15 @@ import { readFileSync } from 'fs'; import { resolve } from 'path'; function classNamesFromSource(relativePath: string): string[] { - const src = readFileSync(resolve(__dirname, '..', 'src', relativePath), 'utf-8'); + const src = readSource(relativePath); const matches = src.matchAll(/className="([^"]*)"/g); return [...matches].map((m) => m[1]!); } +function readSource(relativePath: string): string { + return readFileSync(resolve(__dirname, '..', 'src', relativePath), 'utf-8'); +} + describe('MessageStream scroll layout', () => { const allClasses = classNamesFromSource('agent/MessageStream.tsx'); @@ -27,4 +31,19 @@ describe('MessageStream scroll layout', () => { ).length; expect(minH0Count).toBeGreaterThanOrEqual(1); }); + + it('virtualizer starts at the bottom via initialOffset', () => { + const src = readSource('agent/MessageStream.tsx'); + expect(src).toContain('initialOffset: () => Number.MAX_SAFE_INTEGER'); + }); + + it('scrollToEnd uses instant behavior to avoid top-to-bottom animation', () => { + const src = readSource('agent/MessageStream.tsx'); + expect(src).toContain("scrollToEnd({ behavior: 'instant' })"); + }); + + it('AgentWorkspace remounts MessageStream per thread via key', () => { + const src = readSource('agent/AgentWorkspace.tsx'); + expect(src).toContain(''); + }); });