diff --git a/apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx b/apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx index f0c1a54a2..81d0f1978 100644 --- a/apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx +++ b/apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx @@ -584,6 +584,8 @@ export const ChatDisplay = React.forwardRef const [visibleTurnCount, setVisibleTurnCount] = React.useState(TURNS_PER_PAGE) // Sticky-bottom: When true, auto-scroll on content changes. Toggled by user scroll behavior. const isStickToBottomRef = React.useRef(true) + // Show a floating "jump to latest" button when the user has scrolled far from the bottom. + const [showScrollToBottom, setShowScrollToBottom] = React.useState(false) // Mirror isFocusedPanel into a ref so the ResizeObserver closure reads the latest value const isFocusedPanelRef = React.useRef(isFocusedPanel) isFocusedPanelRef.current = isFocusedPanel @@ -1175,6 +1177,9 @@ export const ChatDisplay = React.forwardRef const distanceFromBottom = scrollHeight - scrollTop - clientHeight // 20px threshold for "at bottom" detection isStickToBottomRef.current = distanceFromBottom < 20 + // Reveal the jump-to-latest button once the user is well away from the bottom + // (200px hysteresis so it doesn't flicker while reading near the end). + setShowScrollToBottom(distanceFromBottom > 200) // Load more turns when scrolling near top (within 100px) if (scrollTop < 100) { @@ -1205,6 +1210,13 @@ export const ChatDisplay = React.forwardRef return () => viewport.removeEventListener('scroll', handleScroll) }, [handleScroll]) + // Jump back to the latest message and resume sticky-bottom auto-scroll. + const scrollToBottom = React.useCallback(() => { + isStickToBottomRef.current = true + setShowScrollToBottom(false) + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, []) + // Auto-scroll using ResizeObserver for streaming content // Initial scroll is handled by ScrollOnMount (useLayoutEffect, before paint) React.useEffect(() => { @@ -1218,6 +1230,7 @@ export const ChatDisplay = React.forwardRef if (isSessionSwitch) { isStickToBottomRef.current = true setVisibleTurnCount(TURNS_PER_PAGE) + setShowScrollToBottom(false) } // Debounced scroll for streaming - waits for layout to settle @@ -1698,7 +1711,7 @@ export const ChatDisplay = React.forwardRef WebkitMaskImage: 'linear-gradient(to bottom, transparent 0%, black 32px, black calc(100% - 32px), transparent 100%)' }} > - +
+ + {/* Jump-to-latest: floating button shown when scrolled up from the bottom */} + + {showScrollToBottom && ( + + + + )} + {/* === INPUT CONTAINER: FreeForm or Structured Input === */} diff --git a/docs/loop/feature-ledger.md b/docs/loop/feature-ledger.md index 98e4a3edb..404fb476b 100644 --- a/docs/loop/feature-ledger.md +++ b/docs/loop/feature-ledger.md @@ -12,6 +12,7 @@ log, not the system of record. | status | meaning | | --- | --- | +| reduce-motion | "Reduce motion" accessibility setting in Appearance | Claude desktop / macOS / Windows reduce-motion + `prefers-reduced-motion` | frontend-only | pr-open | [#50](https://github.com/modelstudioai/openwork/issues/50) | [#51](https://github.com/modelstudioai/openwork/pull/51) | loop/reduce-motion | 2026-07-03 | Renderer-only pref (localStorage) applied app-wide via `` + `data-reduce-motion` on `` + global CSS guard. Off ⇒ `reducedMotion="user"` (still honors OS). New `ReduceMotionProvider` in `main.tsx`; toggle in Appearance→Interface; 2 new i18n keys ×7 locales. typecheck/`bun test` zero-delta vs main (56-failure set byte-identical); renderer build ✅; i18n parity ✅. CDP assertion included; **could not run locally** (egress 403s Electron binary download). | | `proposed` | Identified as a gap, not started. | | `in-progress` | Issue filed, branch open, implementation underway. | | `pr-open` | PR submitted, awaiting human review/merge. | @@ -32,10 +33,8 @@ log, not the system of record. | slug | title | source | feasibility | status | issue | pr | branch | updated | notes | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| copy-message | "Copy" (copy-as-Markdown) action on assistant/agent responses | Codex desktop "Copy as Markdown" (openai/codex #2880, #17241) + Claude/ChatGPT desktop per-message copy | frontend-only | pr-open | [#70](https://github.com/modelstudioai/openwork/issues/70) | [#71](https://github.com/modelstudioai/openwork/pull/71) | loop/copy-message | 2026-07-08 | User messages + code blocks were already copyable, but full assistant responses had only a pop-out button — no copy. Extracted `AssistantMessage` component in `ChatDisplay.tsx` (owns `copied` state, mirrors the existing `ErrorMessage` extraction) with a hover copy button next to pop-out; copies raw `message.content` via `navigator.clipboard.writeText`, 2s "copied" check state, `toast.copyFailed` on error. **Zero new i18n keys** (reuses `common.copy`/`common.copied`/`toast.copyFailed`). Added a general `seed` hook to the e2e harness (`app.ts`/`runner.ts`) so assertions can pre-seed an on-disk session the embedded `SessionManager` loads on boot (no backend). typecheck:all zero-delta; `bun test` 56-failure set byte-identical to main; renderer build ✅; i18n parity ✅ (1544 keys); eslint 0 errors. CDP assertion (`copy-message.assert.ts`) seeds a user+assistant session, opens it, clicks the copy button, and asserts the exact response markdown reaches the (stubbed) clipboard + the button enters its copied state; **could not run locally** (org egress policy 403s the Electron binary download — same block as prior rounds). | -| reduce-motion | "Reduce motion" accessibility setting in Appearance | Claude desktop / macOS / Windows reduce-motion + `prefers-reduced-motion` | frontend-only | merged | [#50](https://github.com/modelstudioai/openwork/issues/50) | [#51](https://github.com/modelstudioai/openwork/pull/51) | loop/reduce-motion | 2026-07-08 | **Merged** into `main` (2026-07-06). Renderer-only pref (localStorage) applied app-wide via `` + `data-reduce-motion` on `` + global CSS guard. Off ⇒ `reducedMotion="user"` (still honors OS). New `ReduceMotionProvider` in `main.tsx`; toggle in Appearance→Interface; 2 new i18n keys ×7 locales. | +| scroll-to-bottom | "Jump to latest" (scroll-to-bottom) button in the chat transcript | Claude Code Desktop / ChatGPT / Codex desktop jump-to-latest affordance | frontend-only | pr-open | [#46](https://github.com/modelstudioai/openwork/issues/46) | [#47](https://github.com/modelstudioai/openwork/pull/47) | loop/scroll-to-bottom | 2026-07-02 | Reuses existing `ChatDisplay` scroll state (`distanceFromBottom`, `isStickToBottomRef`, `messagesEndRef`); floating `AnimatePresence` button shown when >200px from bottom. One new i18n key `chat.scrollToBottom` across all 6 locales. Added a reusable `seed(profileDirs)` hook to the e2e harness (`app.ts`/`runner.ts`) so assertions can pre-seed an on-disk session (backend-independent) — the scroll assertion seeds a 40-message session, opens it, scrolls up, asserts the button appears, clicks it, asserts return-to-bottom + hide. typecheck/`bun test` zero-delta vs main (11 pre-existing tsc errors, 56 pre-existing test fails, identical sets); renderer build ✅. **CDP could not run locally**: the Electron binary download is 403'd by the sandbox egress policy (github release host), so the app can't launch here — assertion transpiles and is included for CI/reviewer. | +| thinking-level-picker | Thinking-level (reasoning effort) picker in the chat composer | Claude Code Desktop effort menu (⌘⇧E) + OpenWork's own model picker | frontend-only | merged | [#44](https://github.com/modelstudioai/openwork/issues/44) | [#45](https://github.com/modelstudioai/openwork/pull/45) | loop/thinking-level-picker | 2026-07-02 | Merged into `main`. `thinkingLevel`/`onThinkingLevelChange` already plumbed to `FreeFormInput`; only the UI trigger was missing. Reuses `thinking.*` + `settings.ai.thinking` i18n keys (zero new keys). typecheck/`bun test` zero-delta vs main. | | composer-expand | Expand / collapse (maximize) toggle for the chat composer | Claude/ChatGPT/Codex desktop composer maximize | frontend-only | pr-open | [#48](https://github.com/modelstudioai/openwork/issues/48) | [#49](https://github.com/modelstudioai/openwork/pull/49) | loop/composer-expand | 2026-07-03 | Opened by a prior run. Adds `isComposerExpanded` toggle in `FreeFormInput`; 2 new i18n keys. Awaiting review. | -| scroll-to-bottom | "Jump to latest" (scroll-to-bottom) button in the chat transcript | Claude Code / ChatGPT / Codex desktop | frontend-only | pr-open | [#46](https://github.com/modelstudioai/openwork/issues/46) | [#47](https://github.com/modelstudioai/openwork/pull/47) | loop/scroll-to-bottom | 2026-07-02 | Opened by a prior run. Floating jump button in `ChatDisplay` + `seed()` harness hook. Awaiting review. | -| thinking-level-picker | Thinking-level (reasoning effort) picker in the chat composer | Claude Code Desktop effort menu (⌘⇧E) + OpenWork's own model picker | frontend-only | merged | [#44](https://github.com/modelstudioai/openwork/issues/44) | [#45](https://github.com/modelstudioai/openwork/pull/45) | loop/thinking-level-picker | 2026-07-03 | **Merged** into `main` (2026-07-02). `thinkingLevel`/`onThinkingLevelChange` already plumbed to `FreeFormInput`; only the UI trigger was missing. Reuses `thinking.*` + `settings.ai.thinking` i18n keys (zero new keys). | | command-palette | Global command palette (⌘K/Ctrl+K) to search & run any action | Claude Code Desktop ⌘K / VS Code & Codex ⌘⇧P / Linear ⌘K | frontend-only | merged | [#41](https://github.com/modelstudioai/openwork/issues/41) | [#42](https://github.com/modelstudioai/openwork/pull/42) | loop/command-palette | 2026-07-02 | Merged into `main`. Reuses action registry `execute()` + cmdk primitives; zero new i18n keys. CDP e2e 2/2 pass. typecheck/test +0 vs main. | | settings-search | Searchable/filterable settings navigation | Claude Code Desktop / VS Code / Codex desktop settings search | frontend-only | merged | [#39](https://github.com/modelstudioai/openwork/issues/39) | [#40](https://github.com/modelstudioai/openwork/pull/40) | loop/settings-search | 2026-07-01 | Merged into `main`. Filters `SettingsNavigator` by title+description; reuses `common.search`/`common.noResultsFound` (no new locale keys). Also hardened `e2e/app.ts` teardown (per-launch profile dir + setsid process-group kill) so multiple CDP assertions run under headless xvfb. | diff --git a/e2e/assertions/scroll-to-bottom.assert.ts b/e2e/assertions/scroll-to-bottom.assert.ts new file mode 100644 index 000000000..7ab17025b --- /dev/null +++ b/e2e/assertions/scroll-to-bottom.assert.ts @@ -0,0 +1,177 @@ +/** + * Feature assertion: the chat transcript's "jump to latest" (scroll-to-bottom) + * button. + * + * Drives the real built app over CDP through the full path: + * seed a 40-message session on disk → open it → transcript renders at the + * bottom with the button hidden → scroll up → the floating button appears → + * click it → the transcript returns to the bottom and the button disappears. + * + * The final steps prove the button actually *scrolls* the transcript back to + * the latest message, not merely that it renders. + * + * A backend is NOT required: the session is pre-seeded as a plain `session.jsonl` + * file under the isolated profile's default-workspace root before the app boots, + * so the transcript is real, local, and scrollable without invoking qwen-code. + */ + +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Assertion } from '../runner'; +import type { ProfileDirs } from '../app'; + +const SESSION_ID = 'e2e-scroll-seeded'; +const SESSION_NAME = 'Scroll seed'; +const MESSAGE_COUNT = 40; + +const ROW = `[data-session-id="${SESSION_ID}"]`; +const BUTTON = '[data-testid="scroll-to-bottom"]'; + +/** The scrollable transcript viewport (Radix ScrollArea viewport inside our tagged region). */ +const VIEWPORT = `document.querySelector('[data-testid="chat-transcript"] [data-radix-scroll-area-viewport]')`; + +/** distanceFromBottom of the transcript viewport, or -1 when it isn't mounted yet. */ +const DISTANCE_EXPR = `(() => { + const v = ${VIEWPORT}; + if (!v) return -1; + return v.scrollHeight - v.scrollTop - v.clientHeight; +})()`; + +/** + * Pre-seed a session with many messages so the transcript overflows the + * viewport. Written before Electron launches; the app lists it on boot. + */ +function seed({ workspaceDir }: ProfileDirs): void { + const sessionDir = join(workspaceDir, 'sessions', SESSION_ID); + mkdirSync(sessionDir, { recursive: true }); + + const base = 1700000000000; + const header = { + id: SESSION_ID, + workspaceRootPath: workspaceDir, + name: SESSION_NAME, + createdAt: base, + lastUsedAt: base + MESSAGE_COUNT, + lastMessageAt: base + MESSAGE_COUNT, + sessionStatus: 'todo', + messageCount: MESSAGE_COUNT, + lastMessageRole: 'assistant', + preview: 'Seeded conversation for scroll-to-bottom testing', + }; + + const lines = [JSON.stringify(header)]; + for (let i = 1; i <= MESSAGE_COUNT; i++) { + const isUser = i % 2 === 1; + // Long, multi-paragraph body so 40 messages reliably overflow the viewport. + const body = + `${isUser ? 'User' : 'Assistant'} message ${i}.\n\n` + + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(6); + const msg: Record = { + id: `m${i}`, + type: isUser ? 'user' : 'assistant', + content: body, + timestamp: base + i, + }; + if (!isUser) msg.turnId = `t${i}`; + lines.push(JSON.stringify(msg)); + } + writeFileSync(join(sessionDir, 'session.jsonl'), lines.join('\n') + '\n', 'utf-8'); +} + +const assertion: Assertion = { + name: 'chat scroll-to-bottom button appears when scrolled up and jumps to latest', + seed, + async run(app) { + const { session } = app; + + // App fully mounted and at the ready AppShell (same anchors other assertions use). + await session.waitForFunction( + '!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0', + { timeoutMs: 30000, message: 'React UI did not mount' }, + ); + await session.waitForSelector('[aria-label="Craft menu"]', { + timeoutMs: 30000, + message: 'app did not reach the ready AppShell state', + }); + + // 1. The seeded session appears in the sidebar list. + await session.waitForSelector(ROW, { + timeoutMs: 20000, + message: 'seeded session row did not appear in the session list', + }); + + // 2. Open it. The row selects on `mousedown` (not click), so dispatch a real + // left-button pointer press on the row's button. + const opened = await session.evaluate(`(() => { + const btn = document.querySelector(${JSON.stringify(ROW)} + ' button.entity-row-btn') + || document.querySelector(${JSON.stringify(ROW)} + ' button') + || document.querySelector(${JSON.stringify(ROW)}); + if (!btn) return false; + btn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 })); + btn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 })); + return true; + })()`); + if (!opened) throw new Error('could not find/press the seeded session row'); + + // 3. The transcript renders and overflows the viewport (proof the seeded + // conversation actually loaded, not the empty/draft state). + await session.waitForFunction( + `(() => { const v = ${VIEWPORT}; return !!v && v.clientHeight > 0 && v.scrollHeight > v.clientHeight + 200; })()`, + { timeoutMs: 20000, message: 'seeded transcript did not render as a scrollable viewport' }, + ); + + // 4. On open the transcript sticks to the bottom, so the jump button is hidden. + await session.waitForFunction(`${DISTANCE_EXPR} >= 0 && ${DISTANCE_EXPR} < 40`, { + timeoutMs: 8000, + message: 'transcript did not settle at the bottom on open', + }); + const buttonVisibleAtBottom = await session.evaluate( + `!!document.querySelector(${JSON.stringify(BUTTON)})`, + ); + if (buttonVisibleAtBottom) { + throw new Error('scroll-to-bottom button was visible while already at the bottom'); + } + + // 5. Scroll to the top. Setting scrollTop fires a scroll event; also dispatch + // one explicitly so the handler recomputes regardless. + await session.evaluate(`(() => { + const v = ${VIEWPORT}; + if (!v) return false; + v.scrollTop = 0; + v.dispatchEvent(new Event('scroll', { bubbles: true })); + return true; + })()`); + + // 6. Now well away from the bottom, the button appears. + await session.waitForFunction(`${DISTANCE_EXPR} > 200`, { + timeoutMs: 5000, + message: 'scrolling to top did not move the viewport away from the bottom', + }); + await session.waitForSelector(BUTTON, { + timeoutMs: 5000, + message: 'scroll-to-bottom button did not appear after scrolling up', + }); + + // 7. Click it — it must scroll the transcript back to the latest message... + const clicked = await session.evaluate(`(() => { + const btn = document.querySelector(${JSON.stringify(BUTTON)}); + if (!btn) return false; + btn.click(); + return true; + })()`); + if (!clicked) throw new Error('could not click the scroll-to-bottom button'); + + await session.waitForFunction(`${DISTANCE_EXPR} >= 0 && ${DISTANCE_EXPR} < 40`, { + timeoutMs: 8000, + message: 'clicking the button did not return the transcript to the bottom', + }); + + // 8. ...and hide itself again once back at the bottom. + await session.waitForFunction(`!document.querySelector(${JSON.stringify(BUTTON)})`, { + timeoutMs: 5000, + message: 'scroll-to-bottom button did not disappear after returning to the bottom', + }); + }, +}; + +export default assertion; diff --git a/packages/shared/src/i18n/locales/de.json b/packages/shared/src/i18n/locales/de.json index 5f194ecd1..1d48afa80 100644 --- a/packages/shared/src/i18n/locales/de.json +++ b/packages/shared/src/i18n/locales/de.json @@ -215,6 +215,7 @@ "chat.processing.zipping": "Flitzt...", "chat.processing.zooming": "Rast...", "chat.renameSession": "Sitzung umbenennen", + "chat.scrollToBottom": "Nach unten scrollen", "chat.scrollUpForEarlier": "Nach oben scrollen für ältere Nachrichten ({{count}} weitere)", "chat.selectedText": "Ausgewählter Text", "chat.session": "Sitzung", diff --git a/packages/shared/src/i18n/locales/en.json b/packages/shared/src/i18n/locales/en.json index a3da8409b..9935f62d5 100644 --- a/packages/shared/src/i18n/locales/en.json +++ b/packages/shared/src/i18n/locales/en.json @@ -215,6 +215,7 @@ "chat.processing.zipping": "Zipping...", "chat.processing.zooming": "Zooming...", "chat.renameSession": "Rename Session", + "chat.scrollToBottom": "Scroll to bottom", "chat.scrollUpForEarlier": "Scroll up for earlier messages ({{count}} more)", "chat.selectedText": "Selected text", "chat.session": "Session", diff --git a/packages/shared/src/i18n/locales/es.json b/packages/shared/src/i18n/locales/es.json index 1b41a0984..1fdba2fa7 100644 --- a/packages/shared/src/i18n/locales/es.json +++ b/packages/shared/src/i18n/locales/es.json @@ -215,6 +215,7 @@ "chat.processing.zipping": "Volando...", "chat.processing.zooming": "¡Zoom!...", "chat.renameSession": "Renombrar sesión", + "chat.scrollToBottom": "Desplazarse al final", "chat.scrollUpForEarlier": "Sube para ver mensajes anteriores ({{count}} más)", "chat.selectedText": "Texto seleccionado", "chat.session": "Sesión", diff --git a/packages/shared/src/i18n/locales/hu.json b/packages/shared/src/i18n/locales/hu.json index 6ff355e60..465aa79a7 100644 --- a/packages/shared/src/i18n/locales/hu.json +++ b/packages/shared/src/i18n/locales/hu.json @@ -215,6 +215,7 @@ "chat.processing.zipping": "Száguldok...", "chat.processing.zooming": "Zoom...", "chat.renameSession": "Munkamenet átnevezése", + "chat.scrollToBottom": "Görgetés az aljára", "chat.scrollUpForEarlier": "Görgess fel a korábbi üzenetekért (még {{count}})", "chat.selectedText": "Kijelölt szöveg", "chat.session": "Munkamenet", diff --git a/packages/shared/src/i18n/locales/ja.json b/packages/shared/src/i18n/locales/ja.json index 5b9e95dff..c859d5ef6 100644 --- a/packages/shared/src/i18n/locales/ja.json +++ b/packages/shared/src/i18n/locales/ja.json @@ -215,6 +215,7 @@ "chat.processing.zipping": "シュッシュッ...", "chat.processing.zooming": "ズーム中...", "chat.renameSession": "セッション名を変更", + "chat.scrollToBottom": "最下部へスクロール", "chat.scrollUpForEarlier": "上にスクロールして以前のメッセージを表示 (あと {{count}} 件)", "chat.selectedText": "選択テキスト", "chat.session": "セッション", diff --git a/packages/shared/src/i18n/locales/pl.json b/packages/shared/src/i18n/locales/pl.json index aa6435f76..205d92aa6 100644 --- a/packages/shared/src/i18n/locales/pl.json +++ b/packages/shared/src/i18n/locales/pl.json @@ -215,6 +215,7 @@ "chat.processing.zipping": "Gnam...", "chat.processing.zooming": "Pędzę...", "chat.renameSession": "Zmień nazwę sesji", + "chat.scrollToBottom": "Przewiń na dół", "chat.scrollUpForEarlier": "Przewiń w górę, aby zobaczyć wcześniejsze wiadomości (jeszcze {{count}})", "chat.selectedText": "Zaznaczony tekst", "chat.session": "Sesja", diff --git a/packages/shared/src/i18n/locales/zh-Hans.json b/packages/shared/src/i18n/locales/zh-Hans.json index b7e77ee3c..859f25c33 100644 --- a/packages/shared/src/i18n/locales/zh-Hans.json +++ b/packages/shared/src/i18n/locales/zh-Hans.json @@ -215,6 +215,7 @@ "chat.processing.zipping": "飞速中...", "chat.processing.zooming": "疾驰中...", "chat.renameSession": "重命名会话", + "chat.scrollToBottom": "滚动到底部", "chat.scrollUpForEarlier": "向上滚动查看更早的消息 (还有 {{count}} 条)", "chat.selectedText": "选中文本", "chat.session": "会话",