diff --git a/apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx b/apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx index 1ee9b7973..aa4b0e860 100644 --- a/apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx +++ b/apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx @@ -582,6 +582,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 @@ -1173,6 +1175,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) { @@ -1203,6 +1208,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(() => { @@ -1216,6 +1228,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 @@ -1696,7 +1709,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 51235562a..fd6dbb8d3 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,9 +33,7 @@ log, not the system of record. | slug | title | source | feasibility | status | issue | pr | branch | updated | notes | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| 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). | -| 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). | +| 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. | | 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/app.ts b/e2e/app.ts index 98c93a451..c349502c6 100644 --- a/e2e/app.ts +++ b/e2e/app.ts @@ -22,6 +22,16 @@ const E2E_DIR = join(ROOT_DIR, '.e2e'); const MAIN_BUNDLE = join(ELECTRON_DIR, 'dist/main.cjs'); const RENDERER_HTML = join(ELECTRON_DIR, 'dist/renderer/index.html'); +/** The isolated per-launch profile directories, handed to a {@link LaunchOptions.seed} hook. */ +export interface ProfileDirs { + /** Electron userData (cache, cookies, local storage, single-instance lock). */ + userDataDir: string; + /** App config + workspace registry (CRAFT_CONFIG_DIR / ~/.craft-agent). */ + configDir: string; + /** Root of the default conversation workspace (QWEN_DEFAULT_WORKSPACE_DIR). */ + workspaceDir: string; +} + export interface LaunchOptions { /** Override the remote-debugging port (default: an ephemeral free port). */ port?: number; @@ -29,6 +39,12 @@ export interface LaunchOptions { rebuild?: boolean; /** Milliseconds to wait for the main renderer target to appear. */ startupTimeoutMs?: number; + /** + * Runs after the isolated profile dirs are created but BEFORE Electron starts, + * so an assertion can pre-seed on-disk state (e.g. a workspace + a session + * with messages) that the app will load on launch. Backend-independent. + */ + seed?: (dirs: ProfileDirs) => void | Promise; } export interface LaunchedApp { @@ -134,6 +150,11 @@ export async function launchApp(options: LaunchOptions = {}): Promise