From 50ae1aef6f6a8bc6fc8e33ea2f7e09e2bb99ad7d Mon Sep 17 00:00:00 2001 From: cachewraith Date: Tue, 27 Oct 2026 00:53:33 +0700 Subject: [PATCH 1/2] fix(chat): a sideways swipe no longer pauses follow Chromium sometimes scrolls the chat by a sideways swipe's few px of vertical noise, and onScroll took that upward move as the reader going back: the "Scroll to the latest message" button showed and follow stopped. This is the intermittent chat.spec.ts:299 CI failure (main runs after #146 and #148). For 150 ms after a mostly sideways wheel the chat's own upward scroll is ignored. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ docs/TODO.md | 2 +- src/renderer/components/chat/ChatView.tsx | 12 ++++++++++-- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7f9f19..1d4266b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 2026-10-27 + +- [chat] A sideways swipe over the chat no longer pauses follow (the cause of the intermittent `chat.spec.ts:299` CI failure): Chromium sometimes scrolls the chat by the swipe's few px of vertical noise, and that upward move read as the reader going back; for 150 ms after a mostly sideways wheel the chat's own upward scroll is ignored (files: src/renderer/components/chat/ChatView.tsx) + ## 2026-10-26 - [git] The Changes panel follows more edits to a file that is already changed (#139): git's status line stays ` M a.ts`, so the open panel now fetches the diff again every 5 s (not while the window is hidden, never two at once) and swaps it in only when patch or untracked list differ (`sameDiff`) (files: src/renderer/components/DiffPanel.tsx, src/renderer/lib/diff.ts, tests/unit/git.test.ts, tests/e2e/git-palette.spec.ts) diff --git a/docs/TODO.md b/docs/TODO.md index 07bbb36..2f9ffa8 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,7 +2,7 @@ ## Now - #76 Rust host: phase 3 (macOS + Windows) and 4 (default on, drop node-pty) left; phase 5 arm64 binary never run on real arm64; old `~/.wraithgrid/bin/wraithgrid-host-*` versions on remote machines are never removed -- CI: chat.spec.ts:299 (scroll follow after a sideways wheel) fails intermittently on the Linux runner, also on main +- CI: chat-history.spec.ts:94 (`/` menu not shown after fill('/')) failed once on main (run 40367324797) and 2 of 15 locally under load, then 0 of 100+; cause not found. chat.spec.ts:299 fixed (sideways-wheel noise paused follow) - Audit (2026-10-26) bugs: #140 smaller bugs (checklist). #139 fixed on fix/stale-diff-20261026. #135–#138 fixed (PR #148; #138 needs a real cancelled pkexec prompt to see live) - Audit (2026-10-26) perf: #141 done (PR #149); not measured live yet (a DevTools profile of a long streaming chat before/after would confirm) - #41 (2026-10-22): agy in the chat UI (`src/main/chat/agy.ts`, `chat: true`); agy 1.4.4 has `-p --input-format/--output-format stream-json`, `--model/--effort/--mode`, `--continue/--conversation`; also set its `resumeArgs` diff --git a/src/renderer/components/chat/ChatView.tsx b/src/renderer/components/chat/ChatView.tsx index 8ea24a9..5b67c03 100644 --- a/src/renderer/components/chat/ChatView.tsx +++ b/src/renderer/components/chat/ChatView.tsx @@ -207,6 +207,8 @@ const motionReduced = (): boolean => */ /** How long text must stop growing before the working line shows under it again (#129). */ const TEXT_QUIET_MS = 1200 +/** How long after a sideways wheel the chat's own upward scroll is taken as its noise. */ +const SIDEWAYS_MS = 150 function usePacedText(text: string, streaming: boolean, visible: boolean): string { const [paced] = useState(() => streaming && !motionReduced()) @@ -392,13 +394,18 @@ function ChatViewBody({ paneId, visible, focused }: Props) { stick.current = false setAtBottom(false) } + // A sideways swipe (over a wide code block) still scrolls the chat by its few px of vertical + // noise, sometimes: that upward move is not the reader going back. + const sidewaysAt = useRef(0) const onScroll = (): void => { const el = scrollRef.current if (!el) return // Content growing can increase the bottom gap before its resize callback runs. // Only upward movement pauses follow; a gap by itself is not user input. const maxTop = el.scrollHeight - el.clientHeight - if (el.scrollTop < Math.min(lastScrollTop.current, maxTop) - 0.5) stick.current = false + const sideways = performance.now() - sidewaysAt.current < SIDEWAYS_MS + if (!sideways && el.scrollTop < Math.min(lastScrollTop.current, maxTop) - 0.5) + stick.current = false lastScrollTop.current = el.scrollTop // A small upward movement remains paused until the actual bottom is reached. if (maxTop - el.scrollTop < 1) stick.current = true @@ -463,7 +470,8 @@ function ChatViewBody({ paneId, visible, focused }: Props) { ref={scrollRef} onScroll={onScroll} onWheelCapture={(e) => { - if (wheelScrollsUp(e.nativeEvent, scrollRef.current)) pauseFollow() + if (Math.abs(e.deltaY) < Math.abs(e.deltaX)) sidewaysAt.current = performance.now() + else if (wheelScrollsUp(e.nativeEvent, scrollRef.current)) pauseFollow() }} onTouchStartCapture={pauseFollow} onPointerDown={(e) => { From 8651184b9e0e10cdbfd87f13ffa3f025227a411f Mon Sep 17 00:00:00 2001 From: cachewraith Date: Tue, 27 Oct 2026 01:06:45 +0700 Subject: [PATCH 2/2] fix(chat): place a recalled or picked prompt's caret right away, not a frame later MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ↑ recall and the `/` menu pick moved the caret in the next animation frame. That frame could land between a select-all and the typing that replaces it (Playwright's fill, or a fast user), collapsing the selection so the new text was added after the old: CI saw "second promptline one" and "second prompt/" (no `/` menu) in chat-history.spec.ts. The caret is now placed in a layout effect, right after React put the text in the box, in the same task as the key. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/TODO.md | 2 +- src/renderer/components/chat/Composer.tsx | 26 +++++++++++++---------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d4266b..b036fe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2026-10-27 +- [chat] A ↑ recall or a `/` pick no longer moves the caret a frame later (cause of the intermittent `chat-history.spec.ts` CI failures, e.g. `second promptline one`): the frame could land between a select-all and the typing that replaces it, so the text was added instead; the caret is now placed in a layout effect right after the text is in the box (files: src/renderer/components/chat/Composer.tsx) - [chat] A sideways swipe over the chat no longer pauses follow (the cause of the intermittent `chat.spec.ts:299` CI failure): Chromium sometimes scrolls the chat by the swipe's few px of vertical noise, and that upward move read as the reader going back; for 150 ms after a mostly sideways wheel the chat's own upward scroll is ignored (files: src/renderer/components/chat/ChatView.tsx) ## 2026-10-26 diff --git a/docs/TODO.md b/docs/TODO.md index 2f9ffa8..1621a0e 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,7 +2,7 @@ ## Now - #76 Rust host: phase 3 (macOS + Windows) and 4 (default on, drop node-pty) left; phase 5 arm64 binary never run on real arm64; old `~/.wraithgrid/bin/wraithgrid-host-*` versions on remote machines are never removed -- CI: chat-history.spec.ts:94 (`/` menu not shown after fill('/')) failed once on main (run 40367324797) and 2 of 15 locally under load, then 0 of 100+; cause not found. chat.spec.ts:299 fixed (sideways-wheel noise paused follow) +- CI flakes fixed 2026-10-27 (PR #152): chat.spec.ts:299 (sideways-wheel noise paused follow), chat-history.spec.ts (caret placed a frame late collapsed a select-all). Still seen once locally: responsive.spec.ts:131 (Settings nav heading not hidden in a small window), passed 3/3 on rerun - Audit (2026-10-26) bugs: #140 smaller bugs (checklist). #139 fixed on fix/stale-diff-20261026. #135–#138 fixed (PR #148; #138 needs a real cancelled pkexec prompt to see live) - Audit (2026-10-26) perf: #141 done (PR #149); not measured live yet (a DevTools profile of a long streaming chat before/after would confirm) - #41 (2026-10-22): agy in the chat UI (`src/main/chat/agy.ts`, `chat: true`); agy 1.4.4 has `-p --input-format/--output-format stream-json`, `--model/--effort/--mode`, `--continue/--conversation`; also set its `resumeArgs` diff --git a/src/renderer/components/chat/Composer.tsx b/src/renderer/components/chat/Composer.tsx index aebb939..d16a2fe 100644 --- a/src/renderer/components/chat/Composer.tsx +++ b/src/renderer/components/chat/Composer.tsx @@ -1,6 +1,7 @@ import { useEffect, useId, + useLayoutEffect, useRef, useState, type ClipboardEvent, @@ -142,6 +143,18 @@ export function Composer({ setActive(0) } const syncCaret = (): void => setCaret(ref.current?.selectionStart ?? 0) + // Where the caret goes once a recalled or picked text is in the box: right after React put + // it there, in the same task as the key. A frame later could land between someone's select + // all and their typing, and collapse the selection so the text is added, not replaced. + const placeCaret = useRef<{ text: string; at: number; focus?: boolean } | null>(null) + useLayoutEffect(() => { + const p = placeCaret.current + const box = ref.current + placeCaret.current = null + if (!p || !box || box.value !== p.text) return + if (p.focus) box.focus() + box.setSelectionRange(p.at, p.at) + }) // ↑/↓ through sent prompts (#132); typing or sending ends browsing. const nav = useRef(NOT_BROWSING) const recall = (dir: -1 | 1, box: HTMLTextAreaElement): boolean => { @@ -153,10 +166,7 @@ export function Composer({ setCaret(step.text.length) // A recalled `/command` doesn't open the menu: ↑/↓ keep going through history. setDismissed(step.text) - requestAnimationFrame(() => { - if (ref.current?.value === step.text) - ref.current.setSelectionRange(step.text.length, step.text.length) - }) + placeCaret.current = { text: step.text, at: step.text.length } return true } @@ -172,13 +182,7 @@ export function Composer({ const next = item.sigil === '$' ? head + text.slice(caret).trimStart() : head setText(next) setCaret(head.length) - requestAnimationFrame(() => { - const box = ref.current - if (!box) return - box.focus() - // Only if nothing was typed since: a fast typist's first letters must stay in order. - if (box.value === next) box.setSelectionRange(head.length, head.length) - }) + placeCaret.current = { text: next, at: head.length, focus: true } } useEffect(() => {