Skip to content
Open
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
41 changes: 40 additions & 1 deletion apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,8 @@ export const ChatDisplay = React.forwardRef<ChatDisplayHandle, ChatDisplayProps>
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
Expand Down Expand Up @@ -1173,6 +1175,9 @@ export const ChatDisplay = React.forwardRef<ChatDisplayHandle, ChatDisplayProps>
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) {
Expand Down Expand Up @@ -1203,6 +1208,13 @@ export const ChatDisplay = React.forwardRef<ChatDisplayHandle, ChatDisplayProps>
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(() => {
Expand All @@ -1216,6 +1228,7 @@ export const ChatDisplay = React.forwardRef<ChatDisplayHandle, ChatDisplayProps>
if (isSessionSwitch) {
isStickToBottomRef.current = true
setVisibleTurnCount(TURNS_PER_PAGE)
setShowScrollToBottom(false)
}

// Debounced scroll for streaming - waits for layout to settle
Expand Down Expand Up @@ -1696,7 +1709,7 @@ export const ChatDisplay = React.forwardRef<ChatDisplayHandle, ChatDisplayProps>
WebkitMaskImage: 'linear-gradient(to bottom, transparent 0%, black 32px, black calc(100% - 32px), transparent 100%)'
}}
>
<ScrollArea className="h-full min-w-0" viewportRef={scrollViewportRef}>
<ScrollArea className="h-full min-w-0" viewportRef={scrollViewportRef} data-testid="chat-transcript">
<div className={cn(
CHAT_LAYOUT.maxWidth,
"mx-auto min-w-0",
Expand Down Expand Up @@ -2071,6 +2084,32 @@ export const ChatDisplay = React.forwardRef<ChatDisplayHandle, ChatDisplayProps>
</div>
</ScrollArea>
</div>

{/* Jump-to-latest: floating button shown when scrolled up from the bottom */}
<AnimatePresence>
{showScrollToBottom && (
<motion.button
type="button"
onClick={scrollToBottom}
aria-label={t('chat.scrollToBottom')}
title={t('chat.scrollToBottom')}
data-testid="scroll-to-bottom"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ duration: 0.12, ease: 'easeOut' }}
className={cn(
"absolute bottom-4 left-1/2 -translate-x-1/2 z-10",
"flex items-center justify-center h-9 w-9 rounded-full",
"bg-background/90 backdrop-blur border border-border shadow-md",
"text-muted-foreground hover:text-foreground hover:bg-accent",
"transition-colors"
)}
>
<ChevronDown className="h-5 w-5" />
</motion.button>
)}
</AnimatePresence>
</div>

{/* === INPUT CONTAINER: FreeForm or Structured Input === */}
Expand Down
3 changes: 2 additions & 1 deletion docs/loop/feature-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ log, not the system of record.

| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 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 | pr-open | [#44](https://github.com/modelstudioai/openwork/issues/44) | [#45](https://github.com/modelstudioai/openwork/pull/45) | loop/thinking-level-picker | 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). typecheck/`bun test` zero-delta vs main (3578 pass/56 fail on both); renderer build ✅. **CDP could not run locally**: this sandbox's egress policy 403s the Electron binary download and the `libsignal` GitHub dep (WhatsApp worker), so the app can't be built/launched here — assertion included for CI/reviewer. |
| 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. |
21 changes: 21 additions & 0 deletions e2e/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,29 @@ 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;
/** Force a rebuild of the Electron bundles before launching. */
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<void>;
}

export interface LaunchedApp {
Expand Down Expand Up @@ -134,6 +150,11 @@ export async function launchApp(options: LaunchOptions = {}): Promise<LaunchedAp
mkdirSync(configDir, { recursive: true });
mkdirSync(workspaceDir, { recursive: true });

// Pre-seed on-disk state (workspaces/sessions) before the app boots.
if (options.seed) {
await options.seed({ userDataDir, configDir, workspaceDir });
}

const electronArgs = [
'apps/electron',
`--remote-debugging-port=${port}`,
Expand Down
177 changes: 177 additions & 0 deletions e2e/assertions/scroll-to-bottom.assert.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {
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<boolean>(`(() => {
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<boolean>(
`!!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<boolean>(`(() => {
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;
10 changes: 8 additions & 2 deletions e2e/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import { Glob } from 'bun';
import { join } from 'node:path';
import { mkdirSync } from 'node:fs';
import { launchApp, type LaunchedApp } from './app';
import { launchApp, type LaunchedApp, type ProfileDirs } from './app';

const ROOT_DIR = join(import.meta.dir, '..');
const ASSERTIONS_DIR = join(import.meta.dir, 'assertions');
Expand All @@ -22,6 +22,12 @@ const SCREENSHOT_DIR = join(ROOT_DIR, '.e2e/screenshots');
export interface Assertion {
/** Human-readable description, shown in the report. */
name: string;
/**
* Optional: pre-seed on-disk state before the app launches (e.g. a workspace
* and a session with messages). Runs after the isolated profile is created,
* before Electron starts. Backend-independent.
*/
seed?: (dirs: ProfileDirs) => void | Promise<void>;
/** Drive the running app and throw if the expected behavior is missing. */
run(app: LaunchedApp): Promise<void>;
}
Expand Down Expand Up @@ -60,7 +66,7 @@ async function runOne(file: string, assertion: Assertion): Promise<AssertionResu
const start = Date.now();
let app: LaunchedApp | undefined;
try {
app = await launchApp();
app = await launchApp({ seed: assertion.seed });
const capturedApp = app;
await Promise.race([
assertion.run(capturedApp),
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/i18n/locales/hu.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading