From 25f3cedef63e496be4cee46a6a1c33328b3f96ca Mon Sep 17 00:00:00 2001 From: zerob13 Date: Wed, 3 Jun 2026 21:43:21 +0800 Subject: [PATCH 1/4] chore: update markdown vue --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 49928257b..1d8430b39 100644 --- a/package.json +++ b/package.json @@ -176,7 +176,7 @@ "katex": "^0.16.47", "lint-staged": "^16.4.0", "lucide-vue-next": "^0.544.0", - "markstream-vue": "1.0.0-rc.0", + "markstream-vue": "1.0.1-beta.4", "mermaid": "^11.15.0", "minimatch": "^10.2.5", "monaco-editor": "^0.55.1", @@ -186,7 +186,7 @@ "pinia": "^3.0.4", "reka-ui": "^2.9.7", "simple-git-hooks": "^2.13.1", - "stream-monaco": "^0.0.40", + "stream-monaco": "^0.0.41", "tailwind-merge": "^3.6.0", "tailwind-scrollbar-hide": "^4.0.0", "tailwindcss": "^4.3.0", From 9d82aa67111c0ba76793735655e81e17462ac71d Mon Sep 17 00:00:00 2001 From: zerob13 Date: Wed, 3 Jun 2026 22:18:56 +0800 Subject: [PATCH 2/4] fix(chat): stabilize streaming interactions --- .../plan.md | 101 ++++++++++ .../spec.md | 62 ++++++ .../tasks.md | 14 ++ .../reasoning-heading-font-size/plan.md | 27 +++ .../reasoning-heading-font-size/spec.md | 20 ++ .../reasoning-heading-font-size/tasks.md | 7 + docs/issues/stop-pauses-pending-queue/plan.md | 16 ++ docs/issues/stop-pauses-pending-queue/spec.md | 27 +++ .../issues/stop-pauses-pending-queue/tasks.md | 7 + .../presenter/agentRuntimePresenter/index.ts | 42 +++- src/renderer/src/assets/style.css | 2 +- .../components/markdown/MarkdownRenderer.vue | 2 +- .../components/think-content/ThinkContent.vue | 14 ++ src/renderer/src/pages/ChatPage.vue | 129 +++++++++++- .../agentRuntimePresenter.test.ts | 50 +++++ .../assets/markstreamTailwindSource.test.ts | 23 +++ test/renderer/components/ChatPage.test.ts | 186 ++++++++++++++++++ .../think-content/ThinkContentStyle.test.ts | 19 ++ 18 files changed, 744 insertions(+), 4 deletions(-) create mode 100644 docs/issues/markdown-codeblock-session-scroll-regressions/plan.md create mode 100644 docs/issues/markdown-codeblock-session-scroll-regressions/spec.md create mode 100644 docs/issues/markdown-codeblock-session-scroll-regressions/tasks.md create mode 100644 docs/issues/reasoning-heading-font-size/plan.md create mode 100644 docs/issues/reasoning-heading-font-size/spec.md create mode 100644 docs/issues/reasoning-heading-font-size/tasks.md create mode 100644 docs/issues/stop-pauses-pending-queue/plan.md create mode 100644 docs/issues/stop-pauses-pending-queue/spec.md create mode 100644 docs/issues/stop-pauses-pending-queue/tasks.md create mode 100644 test/renderer/assets/markstreamTailwindSource.test.ts create mode 100644 test/renderer/components/think-content/ThinkContentStyle.test.ts diff --git a/docs/issues/markdown-codeblock-session-scroll-regressions/plan.md b/docs/issues/markdown-codeblock-session-scroll-regressions/plan.md new file mode 100644 index 000000000..b596fbed1 --- /dev/null +++ b/docs/issues/markdown-codeblock-session-scroll-regressions/plan.md @@ -0,0 +1,101 @@ +# Plan + +## Diagnosis + +### Code Block Toolbar + +`src/renderer/src/assets/style.css` imports `markstream-vue/index.tailwind.css`, but Tailwind still +needs to scan the package's generated class candidates. The current source points at: + +```css +@source '../../../../node_modules/markstream-vue/dist/tailwind.ts'; +``` + +The installed `markstream-vue@1.0.0-rc.0` package ships `dist/tailwind.js` and +`dist/tailwind.d.ts`, not `dist/tailwind.ts`. Because the source target does not exist, Tailwind +does not see the class candidates used by the package's code block shell, including +`code-block-header`, `px-[var(--ms-inset-panel-x)]`, `py-[var(--ms-inset-panel-y)]`, and +`p-[var(--ms-action-btn-padding)]`. + +The package CSS import still provides variables and base styles, so the failure appears as a partial +style regression instead of a fully unstyled component. + +### Session Switch Scroll + +`src/renderer/src/pages/ChatPage.vue` restores a session by loading messages, waiting for +`nextTick()`, syncing scroll metrics, and then calling `scrollToBottom(true)`. +`scrollToBottom(true)` currently performs a single `requestAnimationFrame` measurement and sets +`scrollTop` from the scroll height available in that frame. + +Message rows use `content-visibility: auto` with `contain-intrinsic-size: auto 180px`, and rendered +message content can continue changing size after the first frame. Markdown blocks, code blocks, +images, status rows, and input-area layout can all increase the final scroll height after the forced +scroll has already run. Since no message revision necessarily changes after this late layout settle, +the existing auto-follow watchers do not perform another corrective scroll. + +## Proposed Solution + +### 1. Restore `markstream-vue` Tailwind Scanning + +- Change the renderer Tailwind source from `dist/tailwind.ts` to `dist/tailwind.js`. +- Keep the existing `@import 'markstream-vue/index.tailwind.css'` import for package CSS and design + variables. +- Add a focused guard so future package path changes fail loudly. The guard should verify that + representative code block class candidates from `markstream-vue` are included in Tailwind's source + scanning or generated CSS. +- Manually verify a rendered code block in light and dark themes after the implementation. + +### 2. Settle Bottom Scroll During Session Restore + +- Add a dedicated session-restore bottom-scroll helper instead of changing normal streaming + auto-follow behavior. +- The helper should force bottom scroll immediately after session restore and then continue for a + short bounded settle window. +- Recommended implementation: + - Use a session-local request id so pending settle work cancels when the user switches sessions + again. + - Run a small number of animation-frame retries and stop once `scrollHeight` has remained stable + for consecutive frames. + - Attach a temporary `ResizeObserver` to the scroll area or message root for roughly the first + few hundred milliseconds, forcing bottom again when late layout changes arrive. + - Disconnect the observer and cancel queued frames once the settle window ends, a spotlight jump is + requested, the session changes, or the user intentionally scrolls away. +- Keep the current near-bottom logic for streaming updates so the previous bottom-shake fix remains + intact. + +### 3. Force Bottom Scroll After User Submit + +- User submit is an explicit intent to continue at the newest message, so submit and command-submit + paths should schedule a forced bottom scroll after input state has cleared. +- This scroll should complement, not replace, the normal message-list watcher. The forced pass makes + the watcher robust when `isNearBottom` was stale after session restore or late layout changes. + +## Affected Interfaces + +- `src/renderer/src/assets/style.css` +- `src/renderer/src/pages/ChatPage.vue` +- Potential focused tests under `test/renderer/**` + +No main-process, IPC, persisted data, or i18n surfaces are expected to change. + +## Compatibility + +- The Tailwind path fix should be compatible with the current pnpm-linked package layout because it + targets the file that exists in the installed package. +- Session scroll settling is renderer-only and should not change saved session data. +- The bounded observer/retry design keeps the existing `content-visibility` optimization in place + and limits extra work to the initial restore window. + +## Test Strategy + +- Add or update a renderer-side guard that fails if `markstream-vue` code block utility candidates + are no longer visible to Tailwind scanning. +- Add a focused `ChatPage` test that simulates session restore followed by a late `scrollHeight` + increase without a message revision change, then asserts the scroll position reaches the new + bottom. +- Keep or extend existing streaming auto-follow tests to verify the session-restore helper does not + force bottom after the user scrolls away. +- Add a focused submit test that first records a non-bottom scroll metric, sends a message, and then + asserts the submit path still forces bottom scroll. +- After implementation, run the required project checks: `pnpm run format`, `pnpm run i18n`, and + `pnpm run lint`, plus targeted renderer tests. diff --git a/docs/issues/markdown-codeblock-session-scroll-regressions/spec.md b/docs/issues/markdown-codeblock-session-scroll-regressions/spec.md new file mode 100644 index 000000000..07f60ddd0 --- /dev/null +++ b/docs/issues/markdown-codeblock-session-scroll-regressions/spec.md @@ -0,0 +1,62 @@ +# Markdown Codeblock Session Scroll Regressions + +## User Need + +Chat markdown rendering should keep code blocks visually readable, and switching sessions should land +at the actual bottom of the restored conversation. Two regressions currently make the chat view feel +unfinished: + +- Fenced code block chrome, especially the toolbar, renders too compact because the expected + `markstream-vue` Tailwind utility classes are not generated. +- Switching conversations often scrolls close to the bottom but stops slightly short after message + content finishes laying out. + +## Goals + +- Restore the intended `markstream-vue` code block toolbar spacing, background, border, and action + button styles. +- Make session restore scroll to the final settled bottom of the message list when no message + spotlight jump is requested. +- Preserve existing streaming auto-follow behavior, scroll-away behavior, and message rendering + performance optimizations. + +## Acceptance Criteria + +- Generated renderer CSS includes representative `markstream-vue` code block utility candidates, + including `py-[var(--ms-inset-panel-y)]`, `px-[var(--ms-inset-panel-x)]`, + `p-[var(--ms-action-btn-padding)]`, `bg-[var(--code-header-bg)]`, and + `text-[var(--code-action-fg)]`. +- Code block headers, language labels, copy buttons, and overflow controls render with the intended + spacing in light and dark themes. +- Switching to an existing session without a spotlight target scrolls to the real bottom after + markdown, code blocks, images, status rows, and input-area layout settle. +- Switching sessions does not reintroduce bottom shaking or overscroll during streaming updates. +- Sending a new message forces the conversation back to the bottom even if the previous bottom + proximity metric was stale. +- If a spotlight target is requested, the message jump remains the winning scroll behavior. +- User-initiated scroll-away from the bottom is respected after the initial session restore has + completed. + +## Constraints + +- Keep the fix scoped to renderer markdown styling and chat scroll restoration. +- Keep `markstream-vue` as a package dependency; do not fork or patch the package unless the package + path fix proves insufficient. +- Keep the message row `content-visibility` performance optimization unless a later benchmark shows + it is the actual blocker. +- Use bounded scroll settling so the renderer does not keep observers or animation-frame loops alive + after session restore. +- Do not introduce new runtime dependencies. + +## Non-goals + +- Redesign the markdown renderer or code block component. +- Rewrite chat virtualization, message storage, or streaming message flow. +- Change the composer layout, sticky input behavior, or session loading UX. +- Add a new user-facing setting for scroll behavior. + +## Discussion Points + +- The recommended scroll-settling approach is a short `ResizeObserver` window plus bounded animation + frame retries. A smaller bounded-rAF-only fix is possible, but it is less robust when late content + changes arrive outside the first few frames. diff --git a/docs/issues/markdown-codeblock-session-scroll-regressions/tasks.md b/docs/issues/markdown-codeblock-session-scroll-regressions/tasks.md new file mode 100644 index 000000000..852fd2d9a --- /dev/null +++ b/docs/issues/markdown-codeblock-session-scroll-regressions/tasks.md @@ -0,0 +1,14 @@ +# Tasks + +- [x] Investigate the compact code block toolbar regression. +- [x] Investigate the session-switch bottom scroll regression. +- [x] Record proposed fixes for discussion before implementation. +- [x] Confirm the implementation approach with the reviewer. +- [x] Update the `markstream-vue` Tailwind source path from `dist/tailwind.ts` to + `dist/tailwind.js`. +- [x] Add a focused guard for representative `markstream-vue` code block utility candidates. +- [x] Add bounded session-restore scroll settling with cancellation and user-scroll guards. +- [x] Add renderer coverage for late layout growth after session restore. +- [x] Add renderer coverage for forced bottom scroll after submit. +- [ ] Manually verify code block rendering and session switching in the app. +- [x] Run `pnpm run format`, `pnpm run i18n`, `pnpm run lint`, and targeted renderer tests. diff --git a/docs/issues/reasoning-heading-font-size/plan.md b/docs/issues/reasoning-heading-font-size/plan.md new file mode 100644 index 000000000..c7ac092ad --- /dev/null +++ b/docs/issues/reasoning-heading-font-size/plan.md @@ -0,0 +1,27 @@ +# Plan + +## Diagnosis + +`ThinkContent.vue` renders reasoning content through `markstream-vue` `NodeRenderer`. The component +sets compact body variables on `.think-prose`, but `markstream-vue` also defines heading-specific +variables such as `--ms-text-h1`, `--ms-text-h2`, and `--ms-text-h3`. Without overriding those +heading variables, markdown headings inside thinking content can render larger than the surrounding +text. + +Because `ThinkContent.vue` uses scoped styles and `NodeRenderer` is a child component, selector +fallbacks that target rendered heading elements must use `:deep(...)`. + +## Approach + +- Override heading font-size and line-height CSS variables in `.think-prose` so markstream headings + inherit the compact thinking body size. +- Add a scoped `:deep(...)` fallback for rendered `h1` through `h6` and `.heading-node` elements. +- Add a small source-level guard test to keep the reasoning heading overrides from being removed + accidentally. + +## Test Strategy + +- Run a focused renderer test that verifies `ThinkContent.vue` contains the heading variable + overrides and deep heading fallback. +- Run existing thinking block tests. +- Run formatting and renderer quality checks. diff --git a/docs/issues/reasoning-heading-font-size/spec.md b/docs/issues/reasoning-heading-font-size/spec.md new file mode 100644 index 000000000..093626872 --- /dev/null +++ b/docs/issues/reasoning-heading-font-size/spec.md @@ -0,0 +1,20 @@ +# Reasoning Heading Font Size + +## User Need + +Reasoning/thinking blocks should feel like compact diagnostic text. Markdown heading syntax inside a +thinking block must not enlarge the text, because model reasoning often uses `#`, `##`, or `###` as +internal outline markers rather than user-facing document headings. + +## Acceptance Criteria + +- `h1` through `h6` rendered inside a reasoning/thinking block use the same font size as the rest of + the thinking text. +- The fix is scoped to `ThinkContent` and does not change normal assistant markdown heading styles. +- The thinking block continues to render markdown, lists, links, and code blocks. + +## Non-goals + +- Redesign the thinking block. +- Change how normal assistant message markdown headings are rendered. +- Disable markdown parsing inside reasoning content. diff --git a/docs/issues/reasoning-heading-font-size/tasks.md b/docs/issues/reasoning-heading-font-size/tasks.md new file mode 100644 index 000000000..e6057b13f --- /dev/null +++ b/docs/issues/reasoning-heading-font-size/tasks.md @@ -0,0 +1,7 @@ +# Tasks + +- [x] Diagnose why reasoning headings inherit large markdown heading styles. +- [x] Document the scoped fix. +- [x] Override thinking heading font-size and line-height styles. +- [x] Add a focused style guard test. +- [x] Run targeted renderer tests and required checks. diff --git a/docs/issues/stop-pauses-pending-queue/plan.md b/docs/issues/stop-pauses-pending-queue/plan.md new file mode 100644 index 000000000..d5a3d47e6 --- /dev/null +++ b/docs/issues/stop-pauses-pending-queue/plan.md @@ -0,0 +1,16 @@ +# Plan + +## Approach + +- Track sessions whose pending turn queue was paused by an explicit user stop. +- Set that pause in `cancelGeneration` when a queue drain is active or pending turn input exists. +- Prevent automatic queue drains for `enqueue` and `completed` while the pause is active. +- Clear the pause when the user explicitly calls `resumePendingQueue`, when the session is + destroyed, and when all pending inputs are gone. + +## Test Strategy + +- Add a main-process `AgentRuntimePresenter` regression test that starts a queued pending item, + makes `processStream` return `aborted`, and verifies the item is released but not immediately + claimed again. +- Keep existing queue and cancellation tests passing. diff --git a/docs/issues/stop-pauses-pending-queue/spec.md b/docs/issues/stop-pauses-pending-queue/spec.md new file mode 100644 index 000000000..e56ded68a --- /dev/null +++ b/docs/issues/stop-pauses-pending-queue/spec.md @@ -0,0 +1,27 @@ +# Stop Pauses Pending Queue + +## User Need + +When a user stops an active generation, DeepChat must stop the current turn and must not immediately +continue queued pending inputs. The pending queue should remain visible so the user can resume it +explicitly. + +## Problem + +If the active turn was launched from the pending queue, stopping the stream aborts that turn and +releases the claimed queue item back to `pending`. `drainPendingQueueIfPossible` then sees the +session is idle and still has pending input, so it automatically drains the same item again. + +## Acceptance Criteria + +- Stopping an active queued turn releases the queued input back to the waiting lane but does not + auto-start it again. +- Stopping a normal active turn while queued items exist pauses automatic queue draining. +- Clicking resume queue clears the pause and allows pending items to drain. +- Destroying or emptying a session clears any stale pause state. + +## Non-goals + +- Remove the pending queue feature. +- Change rate-limit provider queues. +- Change normal stream cancellation behavior when no pending inputs are involved. diff --git a/docs/issues/stop-pauses-pending-queue/tasks.md b/docs/issues/stop-pauses-pending-queue/tasks.md new file mode 100644 index 000000000..63f323917 --- /dev/null +++ b/docs/issues/stop-pauses-pending-queue/tasks.md @@ -0,0 +1,7 @@ +# Tasks + +- [x] Diagnose repeated restart after stopping a queued turn. +- [x] Document expected stop and resume behavior. +- [x] Add pending queue pause state in `AgentRuntimePresenter`. +- [x] Add regression coverage for stop-paused queue drain. +- [x] Run focused and required checks. diff --git a/src/main/presenter/agentRuntimePresenter/index.ts b/src/main/presenter/agentRuntimePresenter/index.ts index d6c56b45b..6aaf795ba 100644 --- a/src/main/presenter/agentRuntimePresenter/index.ts +++ b/src/main/presenter/agentRuntimePresenter/index.ts @@ -298,6 +298,7 @@ export class AgentRuntimePresenter implements IAgentImplementation { private readonly interactionLocks: Set = new Set() private readonly resumingMessages: Set = new Set() private readonly drainingPendingQueues: Set = new Set() + private readonly userPausedPendingQueues: Set = new Set() private readonly activeProviderPermissions: Map = new Map() private readonly compactionService: CompactionService private readonly toolOutputGuard: ToolOutputGuard @@ -473,6 +474,7 @@ export class AgentRuntimePresenter implements IAgentImplementation { this.toolProfileCache.delete(sessionId) this.sessionCompactionStates.delete(sessionId) this.drainingPendingQueues.delete(sessionId) + this.userPausedPendingQueues.delete(sessionId) this.toolPresenter?.clearConversationToolMapping?.(sessionId) } @@ -535,6 +537,7 @@ export class AgentRuntimePresenter implements IAgentImplementation { ? this.resolveProjectDir(sessionId, options.projectDir) : this.resolveProjectDir(sessionId) + this.clearPendingQueuePauseIfEmpty(sessionId) const shouldClaimImmediately = ((options?.source ?? 'send') === 'send' && this.isAwaitingToolQuestionFollowUp(sessionId)) || this.shouldStartQueuedInputImmediately(sessionId, state.status) @@ -612,6 +615,7 @@ export class AgentRuntimePresenter implements IAgentImplementation { async deletePendingInput(sessionId: string, itemId: string): Promise { await this.ensureSessionReadyForPendingInputMutation(sessionId) this.pendingInputCoordinator.deletePendingInput(sessionId, itemId) + this.clearPendingQueuePauseIfEmpty(sessionId) } async resumePendingQueue(sessionId: string): Promise { @@ -623,6 +627,7 @@ export class AgentRuntimePresenter implements IAgentImplementation { return } + this.userPausedPendingQueues.delete(sessionId) void this.drainPendingQueueIfPossible(sessionId, 'resume') } @@ -795,6 +800,7 @@ export class AgentRuntimePresenter implements IAgentImplementation { if (context?.pendingQueueItemId && pendingInputSource === 'send') { this.pendingInputCoordinator.consumeQueuedInput(sessionId, context.pendingQueueItemId) + this.clearPendingQueuePauseIfEmpty(sessionId) consumedPendingQueueItem = true } @@ -832,6 +838,7 @@ export class AgentRuntimePresenter implements IAgentImplementation { } } else { this.pendingInputCoordinator.consumeQueuedInput(sessionId, context.pendingQueueItemId) + this.clearPendingQueuePauseIfEmpty(sessionId) consumedPendingQueueItem = true } } @@ -1541,6 +1548,10 @@ export class AgentRuntimePresenter implements IAgentImplementation { } async cancelGeneration(sessionId: string): Promise { + if (this.shouldPausePendingQueueOnStop(sessionId)) { + this.userPausedPendingQueues.add(sessionId) + } + const activeGeneration = this.activeGenerations.get(sessionId) if (activeGeneration) { activeGeneration.abortController.abort() @@ -2660,6 +2671,9 @@ export class AgentRuntimePresenter implements IAgentImplementation { if (this.drainingPendingQueues.has(sessionId)) { return false } + if (this.isPendingQueuePausedByUser(sessionId, reason)) { + return false + } const state = await this.getSessionState(sessionId) if (!state || !this.canDrainPendingQueueFromStatus(state.status, reason)) { @@ -2705,7 +2719,8 @@ export class AgentRuntimePresenter implements IAgentImplementation { if ( this.pendingInputCoordinator.hasPendingTurnInput(sessionId) && (await this.getSessionState(sessionId))?.status === 'idle' && - !this.hasPendingInteractions(sessionId) + !this.hasPendingInteractions(sessionId) && + !this.isPendingQueuePausedByUser(sessionId, 'completed') ) { void this.drainPendingQueueIfPossible(sessionId, 'completed') } @@ -2725,9 +2740,32 @@ export class AgentRuntimePresenter implements IAgentImplementation { if (this.drainingPendingQueues.has(sessionId)) { return false } + if (this.userPausedPendingQueues.has(sessionId)) { + return false + } return !this.pendingInputCoordinator.hasPendingTurnInput(sessionId) } + private shouldPausePendingQueueOnStop(sessionId: string): boolean { + return ( + this.drainingPendingQueues.has(sessionId) || + this.pendingInputCoordinator.hasPendingTurnInput(sessionId) + ) + } + + private isPendingQueuePausedByUser( + sessionId: string, + reason: 'enqueue' | 'resume' | 'completed' + ): boolean { + return reason !== 'resume' && this.userPausedPendingQueues.has(sessionId) + } + + private clearPendingQueuePauseIfEmpty(sessionId: string): void { + if (!this.pendingInputCoordinator.hasPendingTurnInput(sessionId)) { + this.userPausedPendingQueues.delete(sessionId) + } + } + private canDrainPendingQueueFromStatus( status: DeepChatSessionState['status'], reason: 'enqueue' | 'resume' | 'completed' @@ -2760,9 +2798,11 @@ export class AgentRuntimePresenter implements IAgentImplementation { ): void { if (pendingInputSource === 'steer') { this.pendingInputCoordinator.consumeSteerInput(sessionId, pendingInputId) + this.clearPendingQueuePauseIfEmpty(sessionId) return } this.pendingInputCoordinator.consumeQueuedInput(sessionId, pendingInputId) + this.clearPendingQueuePauseIfEmpty(sessionId) } private releaseClaimedPendingInput( diff --git a/src/renderer/src/assets/style.css b/src/renderer/src/assets/style.css index 8160ad045..26326ee22 100644 --- a/src/renderer/src/assets/style.css +++ b/src/renderer/src/assets/style.css @@ -8,7 +8,7 @@ @source '../**/*.{vue,ts,tsx,js,jsx}'; @source '../../browser/**/*.{vue,ts,tsx,js,jsx}'; @source '../../../shadcn/components/**/*.{vue,ts,tsx,js,jsx}'; -@source '../../../../node_modules/markstream-vue/dist/tailwind.ts'; +@source '../../../../node_modules/markstream-vue/dist/tailwind.js'; @custom-variant dark (&:where(.dark &, [data-theme='dark'] &)); diff --git a/src/renderer/src/components/markdown/MarkdownRenderer.vue b/src/renderer/src/components/markdown/MarkdownRenderer.vue index c057183b3..0851dbae3 100644 --- a/src/renderer/src/components/markdown/MarkdownRenderer.vue +++ b/src/renderer/src/components/markdown/MarkdownRenderer.vue @@ -44,7 +44,7 @@ const props = withDefaults( smoothStreaming?: boolean }>(), { - smoothStreaming: false + smoothStreaming: true } ) const themeStore = useThemeStore() diff --git a/src/renderer/src/components/think-content/ThinkContent.vue b/src/renderer/src/components/think-content/ThinkContent.vue index 5bbe5ebce..dfb576e69 100644 --- a/src/renderer/src/components/think-content/ThinkContent.vue +++ b/src/renderer/src/components/think-content/ThinkContent.vue @@ -109,9 +109,23 @@ setCustomComponents(customId, { .think-prose { --ms-text-body: calc(0.75rem * var(--dc-font-scale)); --ms-leading-body: calc(1rem * var(--dc-font-scale)); + --ms-text-h1: var(--ms-text-body); + --ms-text-h2: var(--ms-text-body); + --ms-text-h3: var(--ms-text-body); + --ms-text-h4: var(--ms-text-body); + --ms-text-h5: var(--ms-text-body); + --ms-text-h6: var(--ms-text-body); + --ms-leading-h1: var(--ms-leading-body); + --ms-leading-h2: var(--ms-leading-body); + --ms-leading-h3: var(--ms-leading-body); --ms-font-sans: var(--dc-font-family); } +.think-prose :deep(:where(h1, h2, h3, h4, h5, h6, .heading-node)) { + font-size: inherit; + line-height: inherit; +} + .think-prose :where(p, ul, li) { @apply mb-1 mt-0; } diff --git a/src/renderer/src/pages/ChatPage.vue b/src/renderer/src/pages/ChatPage.vue index d5cd0a40f..77e0b0f0e 100644 --- a/src/renderer/src/pages/ChatPage.vue +++ b/src/renderer/src/pages/ChatPage.vue @@ -271,6 +271,8 @@ const TOP_HISTORY_THRESHOLD = 80 const MESSAGE_JUMP_RETRY_INTERVAL = 80 const MESSAGE_HIGHLIGHT_DURATION = 2000 const MAX_MESSAGE_JUMP_RETRIES = 8 +const SESSION_RESTORE_SCROLL_SETTLE_FRAMES = 8 +const SESSION_RESTORE_SCROLL_SETTLE_TIMEOUT = 600 const PLAN_FLOAT_SAFE_GAP = 16 const planFloatReservedHeight = ref(0) const displayMessageCache = new Map< @@ -297,13 +299,17 @@ const chatSearchBarRef = ref<{ let spotlightJumpTimer: number | null = null let scrollReadFrame: number | null = null let scrollWriteFrame: number | null = null +let sessionRestoreScrollFrame: number | null = null +let sessionRestoreScrollTimer: number | null = null let chatSearchRefreshFrame: number | null = null let pendingForcedScroll = false let lastObservedScrollHeight = 0 let cancelSessionRestoreTask: (() => void) | null = null +let cancelSessionRestoreScrollIntentListeners: (() => void) | null = null let cancelPlanUpdatedListener: (() => void) | null = null let sessionRestoreRequestId = 0 let planFloatResizeObserver: ResizeObserver | null = null +let sessionRestoreResizeObserver: ResizeObserver | null = null const resolveChatInputBoxElement = () => (chatInputHeroHostRef.value?.querySelector( @@ -315,6 +321,25 @@ function disconnectPlanFloatResizeObserver() { planFloatResizeObserver = null } +function disconnectSessionRestoreResizeObserver() { + sessionRestoreResizeObserver?.disconnect() + sessionRestoreResizeObserver = null +} + +function cancelSessionRestoreScrollSettle() { + if (sessionRestoreScrollFrame !== null) { + window.cancelAnimationFrame(sessionRestoreScrollFrame) + sessionRestoreScrollFrame = null + } + if (sessionRestoreScrollTimer !== null) { + window.clearTimeout(sessionRestoreScrollTimer) + sessionRestoreScrollTimer = null + } + cancelSessionRestoreScrollIntentListeners?.() + cancelSessionRestoreScrollIntentListeners = null + disconnectSessionRestoreResizeObserver() +} + function syncPlanFloatReservedHeight() { const layer = planFloatLayer.value if (!latestPlanSnapshot.value || !layer) { @@ -394,6 +419,103 @@ function scrollToBottom(force = false) { }) } +function schedulePostSubmitScrollToBottom() { + void nextTick(() => { + scrollToBottom(true) + }) +} + +function canSettleSessionRestoreScroll(requestId: number, sessionId: string) { + return ( + requestId === sessionRestoreRequestId && + props.sessionId === sessionId && + spotlightStore.pendingMessageJump?.sessionId !== sessionId + ) +} + +function applySessionRestoreBottomScroll(requestId: number, sessionId: string): boolean { + if (!canSettleSessionRestoreScroll(requestId, sessionId)) { + return false + } + + const el = scrollContainer.value + if (!el) { + return false + } + + el.scrollTop = Math.max(el.scrollHeight - el.clientHeight, 0) + syncScrollPosition() + return true +} + +function settleSessionRestoreScrollToBottom(requestId: number, sessionId: string) { + cancelSessionRestoreScrollSettle() + + if (!canSettleSessionRestoreScroll(requestId, sessionId)) { + return + } + + const el = scrollContainer.value + let remainingFrames = SESSION_RESTORE_SCROLL_SETTLE_FRAMES + + if (el) { + const cancelForUserScrollIntent = () => { + cancelSessionRestoreScrollSettle() + } + + el.addEventListener('wheel', cancelForUserScrollIntent, { passive: true }) + el.addEventListener('touchstart', cancelForUserScrollIntent, { passive: true }) + cancelSessionRestoreScrollIntentListeners = () => { + el.removeEventListener('wheel', cancelForUserScrollIntent) + el.removeEventListener('touchstart', cancelForUserScrollIntent) + } + } + + const scheduleNextFrame = () => { + if (remainingFrames <= 0 || sessionRestoreScrollFrame !== null) { + return + } + + sessionRestoreScrollFrame = window.requestAnimationFrame(() => { + sessionRestoreScrollFrame = null + + if (!applySessionRestoreBottomScroll(requestId, sessionId)) { + cancelSessionRestoreScrollSettle() + return + } + + remainingFrames -= 1 + scheduleNextFrame() + }) + } + + if (typeof ResizeObserver !== 'undefined') { + const observedTargets: Element[] = [] + if (messageSearchRoot.value) { + observedTargets.push(messageSearchRoot.value) + } + if (chatInputHeroHostRef.value) { + observedTargets.push(chatInputHeroHostRef.value) + } + + if (observedTargets.length > 0) { + sessionRestoreResizeObserver = new ResizeObserver(() => { + if (!applySessionRestoreBottomScroll(requestId, sessionId)) { + cancelSessionRestoreScrollSettle() + } + }) + + observedTargets.forEach((target) => sessionRestoreResizeObserver?.observe(target)) + } + } + + sessionRestoreScrollTimer = window.setTimeout(() => { + cancelSessionRestoreScrollSettle() + }, SESSION_RESTORE_SCROLL_SETTLE_TIMEOUT) + + scheduleNextFrame() +} + function onScroll() { scheduleScrollMetricsRead() const el = scrollContainer.value @@ -478,6 +600,7 @@ watch( sessionRestoreRequestId += 1 cancelSessionRestoreTask?.() cancelSessionRestoreTask = null + cancelSessionRestoreScrollSettle() messageStore.clear() pendingInputStore.clear() if (id) { @@ -502,10 +625,11 @@ watch( await nextTick() syncScrollPosition() if (spotlightStore.pendingMessageJump?.sessionId === id) { + cancelSessionRestoreScrollSettle() void focusPendingSpotlightMessageJump() return } - scrollToBottom(true) + settleSessionRestoreScrollToBottom(requestId, id) }) return } @@ -1279,6 +1403,7 @@ async function onSubmit() { } message.value = '' attachedFiles.value = [] + schedulePostSubmitScrollToBottom() } async function onCommandSubmit(command: string) { @@ -1300,6 +1425,7 @@ async function onCommandSubmit(command: string) { await chatClient.sendMessage(props.sessionId, { text, files }) } attachedFiles.value = [] + schedulePostSubmitScrollToBottom() } async function handleManualCompactionCommand(text: string): Promise { @@ -1543,6 +1669,7 @@ onMounted(() => { onUnmounted(() => { removeModelConfigChangedListener() disconnectPlanFloatResizeObserver() + cancelSessionRestoreScrollSettle() cancelPlanUpdatedListener?.() cancelPlanUpdatedListener = null voiceInput.cleanup() diff --git a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts index f8c7aef77..fbee17f32 100644 --- a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts +++ b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts @@ -3444,6 +3444,56 @@ describe('AgentRuntimePresenter', () => { expect(processSpy).not.toHaveBeenCalled() expect(result).toBe(pendingRecord) }) + + it('pauses automatic queue draining when a queued turn is stopped', async () => { + await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' }) + ;(agent as any).pendingInputCoordinator.queuePendingInput('s1', 'Queued retry') + + let resolveStreamStarted: () => void = () => {} + const streamStarted = new Promise((resolve) => { + resolveStreamStarted = resolve + }) + let resolveStream: () => void = () => {} + const streamRelease = new Promise((resolve) => { + resolveStream = resolve + }) + ;(processStream as ReturnType).mockImplementationOnce(async () => { + resolveStreamStarted() + await streamRelease + return { + status: 'aborted', + stopReason: 'user_stop', + errorMessage: 'common.error.userCanceledGeneration' + } + }) + + const drainPromise = (agent as any).drainPendingQueueIfPossible('s1', 'resume') + await streamStarted + + await agent.cancelGeneration('s1') + resolveStream() + await drainPromise + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(processStream).toHaveBeenCalledTimes(1) + expect(await agent.listPendingInputs('s1')).toEqual([ + expect.objectContaining({ + mode: 'queue', + state: 'pending', + payload: { text: 'Queued retry', files: [] } + }) + ]) + + ;(processStream as ReturnType).mockResolvedValueOnce({ + status: 'completed', + stopReason: 'complete' + }) + await agent.resumePendingQueue('s1') + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(processStream).toHaveBeenCalledTimes(2) + expect(await agent.listPendingInputs('s1')).toEqual([]) + }) }) describe('getMessages / getMessageIds / getMessage', () => { diff --git a/test/renderer/assets/markstreamTailwindSource.test.ts b/test/renderer/assets/markstreamTailwindSource.test.ts new file mode 100644 index 000000000..74369e54d --- /dev/null +++ b/test/renderer/assets/markstreamTailwindSource.test.ts @@ -0,0 +1,23 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const readText = (path: string) => readFileSync(path, 'utf8') + +describe('markstream Tailwind source', () => { + it('points Tailwind at the generated markstream candidate file', () => { + const styleCss = readText(resolve('src/renderer/src/assets/style.css')) + const markstreamTailwindSource = resolve('node_modules/markstream-vue/dist/tailwind.js') + + expect(styleCss).toContain('markstream-vue/dist/tailwind.js') + expect(existsSync(markstreamTailwindSource)).toBe(true) + + const candidates = readText(markstreamTailwindSource) + expect(candidates).toContain('code-block-header') + expect(candidates).toContain('px-[var(--ms-inset-panel-x)]') + expect(candidates).toContain('py-[var(--ms-inset-panel-y)]') + expect(candidates).toContain('p-[var(--ms-action-btn-padding)]') + expect(candidates).toContain('bg-[var(--code-header-bg)]') + expect(candidates).toContain('text-[var(--code-action-fg)]') + }) +}) diff --git a/test/renderer/components/ChatPage.test.ts b/test/renderer/components/ChatPage.test.ts index d9fb7270f..a007b32de 100644 --- a/test/renderer/components/ChatPage.test.ts +++ b/test/renderer/components/ChatPage.test.ts @@ -906,6 +906,73 @@ describe('ChatPage', () => { }) }) + it('forces bottom scroll after sending a new message', async () => { + let nextFrameId = 1 + const rafCallbacks = new Map() + const flushRaf = async () => { + const callbacks = Array.from(rafCallbacks.values()) + rafCallbacks.clear() + callbacks.forEach((cb) => cb(0)) + await flushPromises() + } + const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + const frameId = nextFrameId + nextFrameId += 1 + rafCallbacks.set(frameId, cb) + return frameId + }) + const cancelRafSpy = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((frameId) => { + rafCallbacks.delete(frameId) + }) + + try { + const { wrapper, chatClient } = await setup({ + deferStartupTasks: true + }) + const chatPage = wrapper.get('[data-testid="chat-page"]').element as HTMLDivElement + + let scrollTop = 120 + Object.defineProperty(chatPage, 'clientHeight', { + configurable: true, + get: () => 500 + }) + Object.defineProperty(chatPage, 'scrollHeight', { + configurable: true, + get: () => 1200 + }) + Object.defineProperty(chatPage, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value: number) => { + scrollTop = value + } + }) + + await wrapper.get('[data-testid="chat-page"]').trigger('scroll') + await flushPromises() + await flushRaf() + + const inputBox = wrapper.findComponent({ name: 'ChatInputBox' }) + await inputBox.vm.$emit('update:modelValue', 'send this') + await flushPromises() + + inputBox.vm.$emit('submit') + await flushPromises() + await flushRaf() + + expect(chatClient.sendMessage).toHaveBeenCalledWith('s1', { + text: 'send this', + files: [] + }) + expect(scrollTop).toBe(700) + + wrapper.unmount() + } finally { + rafSpy.mockRestore() + cancelRafSpy.mockRestore() + } + }) + it('queues active draft on submit while generating', async () => { const { wrapper, pendingInputStore, chatClient } = await setup({ isStreaming: true @@ -1030,6 +1097,125 @@ describe('ChatPage', () => { } }) + it('keeps scrolling to bottom while restored session layout settles', async () => { + let nextFrameId = 1 + const rafCallbacks = new Map() + const flushRaf = async () => { + const callbacks = Array.from(rafCallbacks.values()) + rafCallbacks.clear() + callbacks.forEach((cb) => cb(0)) + await flushPromises() + } + const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + const frameId = nextFrameId + nextFrameId += 1 + rafCallbacks.set(frameId, cb) + return frameId + }) + const cancelRafSpy = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((frameId) => { + rafCallbacks.delete(frameId) + }) + + try { + const { wrapper, flushStartupDeferredTasks } = await setup({ + deferStartupTasks: true + }) + const chatPage = wrapper.get('[data-testid="chat-page"]').element as HTMLDivElement + + let scrollHeight = 1200 + let scrollTop = 0 + Object.defineProperty(chatPage, 'clientHeight', { + configurable: true, + get: () => 500 + }) + Object.defineProperty(chatPage, 'scrollHeight', { + configurable: true, + get: () => scrollHeight + }) + Object.defineProperty(chatPage, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value: number) => { + scrollTop = value + } + }) + + await flushStartupDeferredTasks() + await flushRaf() + expect(scrollTop).toBe(700) + + scrollHeight = 1350 + await flushRaf() + expect(scrollTop).toBe(850) + + wrapper.unmount() + } finally { + rafSpy.mockRestore() + cancelRafSpy.mockRestore() + } + }) + + it('stops session restore bottom settling after user scroll intent', async () => { + let nextFrameId = 1 + const rafCallbacks = new Map() + const flushRaf = async () => { + const callbacks = Array.from(rafCallbacks.values()) + rafCallbacks.clear() + callbacks.forEach((cb) => cb(0)) + await flushPromises() + } + const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + const frameId = nextFrameId + nextFrameId += 1 + rafCallbacks.set(frameId, cb) + return frameId + }) + const cancelRafSpy = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((frameId) => { + rafCallbacks.delete(frameId) + }) + + try { + const { wrapper, flushStartupDeferredTasks } = await setup({ + deferStartupTasks: true + }) + const chatPage = wrapper.get('[data-testid="chat-page"]').element as HTMLDivElement + + let scrollHeight = 1200 + let scrollTop = 0 + Object.defineProperty(chatPage, 'clientHeight', { + configurable: true, + get: () => 500 + }) + Object.defineProperty(chatPage, 'scrollHeight', { + configurable: true, + get: () => scrollHeight + }) + Object.defineProperty(chatPage, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value: number) => { + scrollTop = value + } + }) + + await flushStartupDeferredTasks() + await flushRaf() + expect(scrollTop).toBe(700) + + scrollTop = 420 + await wrapper.get('[data-testid="chat-page"]').trigger('wheel') + scrollHeight = 1350 + await flushRaf() + + expect(scrollTop).toBe(420) + + wrapper.unmount() + } finally { + rafSpy.mockRestore() + cancelRafSpy.mockRestore() + } + }) + it('opens the inline search with Ctrl+F and closes it with Escape', async () => { const { wrapper } = await setup() diff --git a/test/renderer/components/think-content/ThinkContentStyle.test.ts b/test/renderer/components/think-content/ThinkContentStyle.test.ts new file mode 100644 index 000000000..6c8fd4fce --- /dev/null +++ b/test/renderer/components/think-content/ThinkContentStyle.test.ts @@ -0,0 +1,19 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +describe('ThinkContent styles', () => { + it('keeps markdown headings at the reasoning body font size', () => { + const source = readFileSync( + resolve('src/renderer/src/components/think-content/ThinkContent.vue'), + 'utf8' + ) + + expect(source).toContain('--ms-text-h1: var(--ms-text-body)') + expect(source).toContain('--ms-text-h2: var(--ms-text-body)') + expect(source).toContain('--ms-text-h3: var(--ms-text-body)') + expect(source).toContain('--ms-leading-h1: var(--ms-leading-body)') + expect(source).toContain(':deep(:where(h1, h2, h3, h4, h5, h6, .heading-node))') + expect(source).toContain('font-size: inherit') + }) +}) From 75d8a88789dcaad672f801e539977a12504fc22b Mon Sep 17 00:00:00 2001 From: zerob13 Date: Wed, 3 Jun 2026 22:26:38 +0800 Subject: [PATCH 3/4] docs(agents): default prs to dev --- AGENTS.md | 1 + docs/issues/pr-base-dev-default/plan.md | 11 +++++++++++ docs/issues/pr-base-dev-default/spec.md | 24 ++++++++++++++++++++++++ docs/issues/pr-base-dev-default/tasks.md | 5 +++++ 4 files changed, 41 insertions(+) create mode 100644 docs/issues/pr-base-dev-default/plan.md create mode 100644 docs/issues/pr-base-dev-default/spec.md create mode 100644 docs/issues/pr-base-dev-default/tasks.md diff --git a/AGENTS.md b/AGENTS.md index 4fc5f9992..fb1408728 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ - Conventional commits enforced by hook: `type(scope): subject` ≤ 50 chars; types: `feat|fix|docs|dx|style|refactor|perf|test|workflow|build|ci|chore|types|wip|release`. - Do not include AI co-authoring footers in commits. - PRs: clear description, link issues (`Closes #123`), screenshots/GIFs for UI, pass lint/typecheck/tests. Keep changes focused. +- Default PR base is `dev`; use `gh pr create --base dev` for routine feature, bugfix, docs, test, and refactor branches. Target `main` only for `release/` branches following `docs/release-flow.md`. - UI changes: include BEFORE/AFTER ASCII layout blocks to communicate structure. ## Architecture Notes & Security diff --git a/docs/issues/pr-base-dev-default/plan.md b/docs/issues/pr-base-dev-default/plan.md new file mode 100644 index 000000000..4a965671d --- /dev/null +++ b/docs/issues/pr-base-dev-default/plan.md @@ -0,0 +1,11 @@ +# Plan + +## Approach + +- Add an explicit PR base rule to `AGENTS.md` under commit and pull request guidelines. +- Keep the rule aligned with `docs/release-flow.md`, where `dev` is the long-lived integration + branch and `main` is the stable release mirror. + +## Validation + +- Run formatting and lint checks for documentation consistency. diff --git a/docs/issues/pr-base-dev-default/spec.md b/docs/issues/pr-base-dev-default/spec.md new file mode 100644 index 000000000..bbe437743 --- /dev/null +++ b/docs/issues/pr-base-dev-default/spec.md @@ -0,0 +1,24 @@ +# Default PR Base Branch + +## User Need + +Contributors and coding agents need a clear repository-level instruction that routine pull requests +target `dev` by default instead of accidentally targeting `main`. + +## Problem + +`docs/release-flow.md` defines `dev` as the integration branch and `main` as the release mirror, but +`AGENTS.md` did not state the default PR base branch in the commit and pull request guidelines. +Automation can therefore fall back to `main` when creating PRs. + +## Acceptance Criteria + +- `AGENTS.md` states that routine PRs default to `dev`. +- `AGENTS.md` states that `main` is only for `release/` PRs following the release flow. +- The instruction is located in the section that coding agents read before creating commits and PRs. + +## Non-goals + +- Change the release flow. +- Change GitHub repository settings. +- Change CI branch filters. diff --git a/docs/issues/pr-base-dev-default/tasks.md b/docs/issues/pr-base-dev-default/tasks.md new file mode 100644 index 000000000..b379979d4 --- /dev/null +++ b/docs/issues/pr-base-dev-default/tasks.md @@ -0,0 +1,5 @@ +# Tasks + +- [x] Inspect existing branch and release guidance. +- [x] Add the default PR base rule to `AGENTS.md`. +- [x] Run required checks. From 874229f11a2c4770b2299ec8baa8d9d6b883486a Mon Sep 17 00:00:00 2001 From: zerob13 Date: Wed, 3 Jun 2026 22:47:13 +0800 Subject: [PATCH 4/4] fix(chat): address review feedback --- .../presenter/agentRuntimePresenter/index.ts | 2 +- src/renderer/src/pages/ChatPage.vue | 56 +++++++- .../agentRuntimePresenter.test.ts | 11 +- test/renderer/components/ChatPage.test.ts | 136 +++++++++++------- 4 files changed, 143 insertions(+), 62 deletions(-) diff --git a/src/main/presenter/agentRuntimePresenter/index.ts b/src/main/presenter/agentRuntimePresenter/index.ts index 6aaf795ba..8b039f986 100644 --- a/src/main/presenter/agentRuntimePresenter/index.ts +++ b/src/main/presenter/agentRuntimePresenter/index.ts @@ -623,11 +623,11 @@ export class AgentRuntimePresenter implements IAgentImplementation { if (!state) { throw new Error(`Session ${sessionId} not found`) } + this.userPausedPendingQueues.delete(sessionId) if (this.isAwaitingToolQuestionFollowUp(sessionId)) { return } - this.userPausedPendingQueues.delete(sessionId) void this.drainPendingQueueIfPossible(sessionId, 'resume') } diff --git a/src/renderer/src/pages/ChatPage.vue b/src/renderer/src/pages/ChatPage.vue index 77e0b0f0e..d0a86cc95 100644 --- a/src/renderer/src/pages/ChatPage.vue +++ b/src/renderer/src/pages/ChatPage.vue @@ -273,6 +273,16 @@ const MESSAGE_HIGHLIGHT_DURATION = 2000 const MAX_MESSAGE_JUMP_RETRIES = 8 const SESSION_RESTORE_SCROLL_SETTLE_FRAMES = 8 const SESSION_RESTORE_SCROLL_SETTLE_TIMEOUT = 600 +const SESSION_RESTORE_SCROLL_INTENT_KEYS = new Set([ + 'ArrowUp', + 'ArrowDown', + 'PageUp', + 'PageDown', + 'Home', + 'End', + ' ', + 'Spacebar' +]) const PLAN_FLOAT_SAFE_GAP = 16 const planFloatReservedHeight = ref(0) const displayMessageCache = new Map< @@ -340,6 +350,31 @@ function cancelSessionRestoreScrollSettle() { disconnectSessionRestoreResizeObserver() } +function isSessionRestoreScrollSettleActive(): boolean { + return sessionRestoreScrollFrame !== null || sessionRestoreScrollTimer !== null +} + +function isEditableKeyboardTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false + } + + return Boolean( + target.closest('input, textarea, select, [contenteditable="true"], [role="textbox"]') + ) +} + +function isSessionRestoreKeyboardScrollIntent(event: KeyboardEvent): boolean { + return ( + !event.defaultPrevented && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + SESSION_RESTORE_SCROLL_INTENT_KEYS.has(event.key) && + !isEditableKeyboardTarget(event.target) + ) +} + function syncPlanFloatReservedHeight() { const layer = planFloatLayer.value if (!latestPlanSnapshot.value || !layer) { @@ -462,12 +497,23 @@ function settleSessionRestoreScrollToBottom(requestId: number, sessionId: string const cancelForUserScrollIntent = () => { cancelSessionRestoreScrollSettle() } + const cancelForKeyboardScrollIntent = (event: KeyboardEvent) => { + if (isSessionRestoreKeyboardScrollIntent(event)) { + cancelSessionRestoreScrollSettle() + } + } el.addEventListener('wheel', cancelForUserScrollIntent, { passive: true }) el.addEventListener('touchstart', cancelForUserScrollIntent, { passive: true }) + el.addEventListener('pointerdown', cancelForUserScrollIntent, { passive: true }) + el.addEventListener('mousedown', cancelForUserScrollIntent, { passive: true }) + window.addEventListener('keydown', cancelForKeyboardScrollIntent, { capture: true }) cancelSessionRestoreScrollIntentListeners = () => { el.removeEventListener('wheel', cancelForUserScrollIntent) el.removeEventListener('touchstart', cancelForUserScrollIntent) + el.removeEventListener('pointerdown', cancelForUserScrollIntent) + el.removeEventListener('mousedown', cancelForUserScrollIntent) + window.removeEventListener('keydown', cancelForKeyboardScrollIntent, true) } } @@ -517,8 +563,16 @@ function settleSessionRestoreScrollToBottom(requestId: number, sessionId: string } function onScroll() { - scheduleScrollMetricsRead() const el = scrollContainer.value + if ( + el && + isSessionRestoreScrollSettleActive() && + el.scrollHeight - el.scrollTop - el.clientHeight > NEAR_BOTTOM_THRESHOLD + ) { + cancelSessionRestoreScrollSettle() + } + + scheduleScrollMetricsRead() if (!el || el.scrollTop > TOP_HISTORY_THRESHOLD) { return } diff --git a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts index fbee17f32..abd9bcb48 100644 --- a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts +++ b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts @@ -3467,14 +3467,15 @@ describe('AgentRuntimePresenter', () => { } }) + const drainSpy = vi.spyOn(agent as any, 'drainPendingQueueIfPossible') const drainPromise = (agent as any).drainPendingQueueIfPossible('s1', 'resume') await streamStarted await agent.cancelGeneration('s1') resolveStream() await drainPromise - await new Promise((resolve) => setTimeout(resolve, 10)) + expect(drainSpy).toHaveBeenCalledTimes(1) expect(processStream).toHaveBeenCalledTimes(1) expect(await agent.listPendingInputs('s1')).toEqual([ expect.objectContaining({ @@ -3489,10 +3490,10 @@ describe('AgentRuntimePresenter', () => { stopReason: 'complete' }) await agent.resumePendingQueue('s1') - await new Promise((resolve) => setTimeout(resolve, 10)) - - expect(processStream).toHaveBeenCalledTimes(2) - expect(await agent.listPendingInputs('s1')).toEqual([]) + await vi.waitFor(async () => { + expect(processStream).toHaveBeenCalledTimes(2) + expect(await agent.listPendingInputs('s1')).toEqual([]) + }) }) }) diff --git a/test/renderer/components/ChatPage.test.ts b/test/renderer/components/ChatPage.test.ts index a007b32de..84c3abc0f 100644 --- a/test/renderer/components/ChatPage.test.ts +++ b/test/renderer/components/ChatPage.test.ts @@ -450,6 +450,74 @@ const setup = async (options: SetupOptions = {}) => { } } +type ChatPageSetupResult = Awaited> + +async function expectSessionRestoreSettleStopsAfter( + triggerIntent: (context: { + wrapper: ChatPageSetupResult['wrapper'] + chatPage: HTMLDivElement + }) => Promise | void +) { + let nextFrameId = 1 + const rafCallbacks = new Map() + const flushRaf = async () => { + const callbacks = Array.from(rafCallbacks.values()) + rafCallbacks.clear() + callbacks.forEach((cb) => cb(0)) + await flushPromises() + } + const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + const frameId = nextFrameId + nextFrameId += 1 + rafCallbacks.set(frameId, cb) + return frameId + }) + const cancelRafSpy = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((frameId) => { + rafCallbacks.delete(frameId) + }) + + try { + const { wrapper, flushStartupDeferredTasks } = await setup({ + deferStartupTasks: true + }) + const chatPage = wrapper.get('[data-testid="chat-page"]').element as HTMLDivElement + + let scrollHeight = 1200 + let scrollTop = 0 + Object.defineProperty(chatPage, 'clientHeight', { + configurable: true, + get: () => 500 + }) + Object.defineProperty(chatPage, 'scrollHeight', { + configurable: true, + get: () => scrollHeight + }) + Object.defineProperty(chatPage, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value: number) => { + scrollTop = value + } + }) + + await flushStartupDeferredTasks() + await flushRaf() + expect(scrollTop).toBe(700) + + scrollTop = 420 + await triggerIntent({ wrapper, chatPage }) + scrollHeight = 1350 + await flushRaf() + + expect(scrollTop).toBe(420) + + wrapper.unmount() + } finally { + rafSpy.mockRestore() + cancelRafSpy.mockRestore() + } +} + describe('ChatPage', () => { it('renders the agent plan inside an absolute overlay layer above the composer', async () => { const { wrapper, agentPlanStore } = await setup() @@ -1156,64 +1224,22 @@ describe('ChatPage', () => { }) it('stops session restore bottom settling after user scroll intent', async () => { - let nextFrameId = 1 - const rafCallbacks = new Map() - const flushRaf = async () => { - const callbacks = Array.from(rafCallbacks.values()) - rafCallbacks.clear() - callbacks.forEach((cb) => cb(0)) - await flushPromises() - } - const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { - const frameId = nextFrameId - nextFrameId += 1 - rafCallbacks.set(frameId, cb) - return frameId - }) - const cancelRafSpy = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((frameId) => { - rafCallbacks.delete(frameId) - }) - - try { - const { wrapper, flushStartupDeferredTasks } = await setup({ - deferStartupTasks: true - }) - const chatPage = wrapper.get('[data-testid="chat-page"]').element as HTMLDivElement - - let scrollHeight = 1200 - let scrollTop = 0 - Object.defineProperty(chatPage, 'clientHeight', { - configurable: true, - get: () => 500 - }) - Object.defineProperty(chatPage, 'scrollHeight', { - configurable: true, - get: () => scrollHeight - }) - Object.defineProperty(chatPage, 'scrollTop', { - configurable: true, - get: () => scrollTop, - set: (value: number) => { - scrollTop = value - } - }) - - await flushStartupDeferredTasks() - await flushRaf() - expect(scrollTop).toBe(700) - - scrollTop = 420 + await expectSessionRestoreSettleStopsAfter(async ({ wrapper }) => { await wrapper.get('[data-testid="chat-page"]').trigger('wheel') - scrollHeight = 1350 - await flushRaf() + }) + }) - expect(scrollTop).toBe(420) + it('stops session restore bottom settling after manual scroll events', async () => { + await expectSessionRestoreSettleStopsAfter(async ({ wrapper }) => { + await wrapper.get('[data-testid="chat-page"]').trigger('scroll') + }) + }) - wrapper.unmount() - } finally { - rafSpy.mockRestore() - cancelRafSpy.mockRestore() - } + it('stops session restore bottom settling after keyboard scroll intent', async () => { + await expectSessionRestoreSettleStopsAfter(async () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageUp' })) + await flushPromises() + }) }) it('opens the inline search with Ctrl+F and closes it with Escape', async () => {