Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions apps/desktop/e2e/keyboard-answers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,73 @@ test("Jump to latest from the keyboard hands focus to the message box at the bot
await expect(tid(page, "jump-latest")).toHaveCount(0);
await expect(tid(page, "composer-input")).toBeFocused();
});

/** Enough finished turns for the transcript to scroll, whatever ran before (a failed test restarts the app). */
async function ensureScrollable() {
for (let turn = 0; turn < 4 && !(await tid(page, "transcript").evaluate((el) => el.scrollHeight > el.clientHeight + 300).catch(() => false)); turn++) {
if (!(await tid(page, "composer-input").count())) await page.keyboard.press("Meta+n");
await tid(page, "composer-input").focus();
await page.keyboard.type(`Turn ${turn} for scrolling`);
await page.keyboard.press("Meta+Enter");
await items(page, "approval").last().getByRole("button", { name: "Approve", exact: true }).click();
await expect(currentRow(page)).toHaveAttribute("data-status", "idle");
}
}

/** Page Up until Jump to latest shows, then let the keyboard scroll finish, so a test controls what happens next. */
async function scrollUpAndSettle() {
await ensureScrollable();
await tid(page, "turn-label").last().focus();
await expect(async () => {
await page.keyboard.press("PageUp");
await expect(tid(page, "jump-latest")).toBeVisible({ timeout: 1_000 });
}).toPass({ timeout: 10_000 });
await expect.poll(async () => {
const a = await tid(page, "transcript").evaluate((el) => el.scrollTop);
await page.waitForTimeout(100);
return a === await tid(page, "transcript").evaluate((el) => el.scrollTop);
}).toBe(true);
}
const gap = () => tid(page, "transcript").evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight);

test("after Jump to latest, the transcript stays at the bottom when the composer grows before the scroll event", async () => {
// A follow-up chip can appear in the composer right after the jump (#347). The jump's scroll event then
// measured a shorter view and decided the reader had left the bottom, so the button stayed.
await scrollUpAndSettle();
await tid(page, "jump-latest").focus();
// The jump and the growth in one task, so the growth lands before the jump's scroll event is delivered.
await page.evaluate(() => {
(document.activeElement as HTMLElement).click();
(document.querySelector('[data-testid="composer"]') as HTMLElement).style.paddingTop = "120px";
});
await page.waitForTimeout(500);
await expect(tid(page, "jump-latest")).toHaveCount(0);
expect(await gap()).toBeLessThanOrEqual(2);
await expect(tid(page, "composer-input")).toBeFocused();
await tid(page, "composer").evaluate((el) => { (el as HTMLElement).style.paddingTop = ""; });
});

test("a Page Up still animating when Jump to latest is pressed does not carry the view back up", async () => {
// The keyboard scroll keeps applying its remaining distance after the page sets a new position; on a
// slow runner the reader's Enter on Jump to latest lands mid-animation (#347).
await ensureScrollable();
await tid(page, "transcript").evaluate((el) => { el.scrollTop = el.scrollHeight; });
await expect(tid(page, "jump-latest")).toHaveCount(0);
await tid(page, "turn-label").last().focus();
await page.keyboard.press("PageUp");
await expect(tid(page, "jump-latest")).toBeVisible();
await page.evaluate(() => (document.querySelector('[data-testid="jump-latest"]') as HTMLElement).click());
await page.waitForTimeout(600);
expect(await gap()).toBeLessThanOrEqual(2);
await expect(tid(page, "jump-latest")).toHaveCount(0);
});

test("after Jump to latest, the reader scrolling up again is followed at once", async () => {
await scrollUpAndSettle();
await page.evaluate(() => (document.querySelector('[data-testid="jump-latest"]') as HTMLElement).click());
await expect(tid(page, "jump-latest")).toHaveCount(0);
await tid(page, "transcript").hover();
await page.mouse.wheel(0, -400);
await expect(tid(page, "jump-latest")).toBeVisible();
expect(await gap()).toBeGreaterThan(2);
});
27 changes: 24 additions & 3 deletions apps/desktop/src/renderer/components/ThreadView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,18 +107,39 @@ export function ThreadView({ thread, project, items, text, onText, attachments,
el.scrollTop = el.scrollHeight;
autoTop.current = el.scrollTop;
}).current;
// When the reader last scrolled by hand (wheel, keys, the scrollbar, touch), and when they jumped to
// the latest. A Page Up still animating keeps moving the view after a jump: the browser's keyboard
// scroll applies its remaining distance whatever the page sets. A scroll with no input since the
// jump is that leftover motion, not the reader, and the jump holds.
const lastInput = useRef(0);
const jumpedAt = useRef(-Infinity);
const onInput = () => { lastInput.current = performance.now(); };
const pin = (el: HTMLDivElement) => {
el.scrollTop = el.scrollHeight;
autoTop.current = el.scrollTop;
};
const onScroll = () => {
const el = scroller.current;
if (!el) return;
const leftover = lastInput.current < jumpedAt.current && performance.now() - jumpedAt.current < 1000;
// A scroll event is delivered before the browser reports a resize. If the view only got shorter
// under a reader who is following (the composer gaining a follow-up chip right after Jump to
// latest), it has not been scrolled up: keep following.
if (stick.current && (leftover || el.scrollTop >= autoTop.current - 2)) {
pin(el);
setAway(false);
return;
}
stick.current = isNearBottom(el);
if (stick.current) autoTop.current = el.scrollTop;
setAway(!stick.current);
};
const jumpToLatest = () => {
const el = scroller.current;
if (!el) return;
stick.current = true;
el.scrollTop = el.scrollHeight;
autoTop.current = el.scrollTop;
jumpedAt.current = performance.now();
pin(el);
setAway(false);
};
useLayoutEffect(() => {
Expand Down Expand Up @@ -184,7 +205,7 @@ export function ThreadView({ thread, project, items, text, onText, attachments,
<ReaderStateContext.Provider value={reader}><section className="thread-view" data-testid="thread-view" data-thread-id={thread.id}>

<TranscriptThread.Provider value={thread.id}>
<div className="transcript" ref={scroller} data-testid="transcript" onScroll={onScroll}>
<div className="transcript" ref={scroller} data-testid="transcript" onScroll={onScroll} onWheel={onInput} onKeyDown={onInput} onPointerDown={onInput} onTouchStart={onInput}>
{items.length === 0 && (
<div className="transcript-empty">
<p>Describe a task. Modex will inspect <code>{thread.cwd}</code>, then read, edit, and run what it needs — asking first when the mode requires it.</p>
Expand Down
20 changes: 20 additions & 0 deletions docs/reviews/2026-10-18-resolved-rough-edges.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ These entries left [Known rough edges](../status.md#known-rough-edges) in `docs/
PR that fixed it, as the record of how the problem was found. Run ids, job ids and paths are as
recorded at the time.

## Jump to latest from the keyboard, on CI runners, handled 2026-10-20

Fixed in #352 (2026-10-20).

`e2e/keyboard-answers.spec.ts:88` (#342)
failed in three CI runs of #345 in a row: after Enter, the Jump to latest button stayed. It passed
on #342, on `main` and 25/25 locally. In the failure snapshot, focus is in the message box and a
"Use suggested follow-up" chip sits above it. So the composer probably grew after the jump and
left the transcript short of the bottom. [Analysis on #342](https://github.com/TypeSafeAI/modex/pull/342#issuecomment-6291857270).

**Found when fixing it.** A frame-by-frame trace in the app showed two causes.
- **The composer grew before the scroll event.** Scroll events arrive before resize observations,
so after the chip appeared, `onScroll` measured the shorter view and stopped following.
- **A Page Up still animating.** It keeps applying its remaining distance after the page sets a
position. Assigning `scrollTop`, `scrollTo({behavior: "instant"})` and toggling `overflow` all
lost to it, sliding the view 141 px back up.

`onScroll` now keeps following until the view moves above where auto-scroll put it. A scroll with no
input from the reader in the second after a jump pins back to the bottom.

## `iPhone companion` CI flakes, handled 2026-10-09

Fixed in #153 (2026-10-09).
Expand Down
5 changes: 0 additions & 5 deletions docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,11 +218,6 @@ that fixed each.
requires both sizes to settle. The original 15-second budget and permanent-clamp refusal
remain. This addresses a locally reproduced test failure from job `114782323496`; the
underlying cause of the native/renderer divergence is unproven. App sizing is unchanged.
- **Jump to latest from the keyboard, on CI runners.** `e2e/keyboard-answers.spec.ts:88` (#342)
failed in three CI runs of #345 in a row: after Enter, the Jump to latest button stayed. It passed
on #342, on `main` and 25/25 locally. In the failure snapshot, focus is in the message box and a
"Use suggested follow-up" chip sits above it. So the composer probably grew after the jump and
left the transcript short of the bottom. [Analysis on #342](https://github.com/TypeSafeAI/modex/pull/342#issuecomment-6291857270).
- **Auto-titles run a real CLI turn.** It is a separate, non-resumed conversation with tools
refused, but it does consume one small model call per new thread on Claude and Codex.
- **Title quality and Jev's follow-up picks are unproven by the suite.** The mock backend does
Expand Down
Loading