Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ For the full tag dictionary, operational playbook (direct merge / duplicate-titl
## Chat UI conventions

- `apps/web/src/components/file-viewer-render-mode.ts` decides URL-load vs srcDoc for HTML previews. Bridges (deck, comment/inspect selection, palette, edit, tweaks) can ONLY inject through the srcDoc path. Add a new disqualifier to `UrlLoadDecision` whenever a feature needs a srcDoc-only bridge; pass it from `FileViewer.tsx` based on a source-content heuristic where appropriate (e.g. `hasTweaksTemplate`). The host keeps both iframes mounted simultaneously and swaps CSS visibility so toggling render mode does not cause an iframe reload flash; `iframeRef.current` stays aligned with the active iframe via `useEffect`. Receive filters use `isOurIframe(ev.source)` to accept messages from either iframe but signals that should ONLY come from the active iframe (e.g. `od:tweaks-available`) re-check `ev.source === iframeRef.current?.contentWindow`.
- TodoWrite UI pins one canonical task list above the chat composer via `PinnedTodoSlot` in `ChatPane.tsx`. The slot reads the latest TodoWrite snapshot across the conversation through `latestTodoWriteInputFromMessages` (`apps/web/src/runtime/todos.ts`). `AssistantMessage.stripTodoToolGroups` removes any TodoWrite tool groups from per message rendering so there is exactly one TodoCard on screen. The progress count includes both `completed` and `in_progress` items (1/4 reads "one underway" not "zero finished"). Dismissal via the Done button is keyed on the snapshot's JSON, so a fresh TodoWrite from the agent automatically re shows the card.
- TodoWrite UI pins one canonical task list above the chat composer via `PinnedTodoSlot` in `ChatPane.tsx`. The slot reads the latest TodoWrite snapshot across the conversation through `latestTodoWriteInputFromMessages` (`apps/web/src/runtime/todos.ts`). `AssistantMessage.stripTodoToolGroups` removes any TodoWrite tool groups from per message rendering so there is exactly one TodoCard on screen. The progress count includes both `completed` and `in_progress` items (1/4 reads "one underway" not "zero finished"). Dismissal via the Done button is keyed on the snapshot's JSON, so a fresh TodoWrite from the agent automatically re shows the card. `PinnedTodoSlot` sits OUTSIDE the `.chat-log` scroll container, so auto-scroll requires explicit coverage: `ChatPane`'s `ResizeObserver` accepts a `containerRef` from `PinnedTodoSlot` and observes that element directly, and a pane-level `MutationObserver` (`childList: true` on the chat pane ancestor) re-syncs that observation whenever the slot mounts or unmounts as new TodoWrite snapshots arrive.
- `AskUserQuestionCard` (in `ToolCard.tsx`) prefers the live `onAnswerToolUse(toolUseId, content)` route (POSTs to `/api/runs/:id/tool-result`) and falls back to the legacy `onSubmitForm(text)` path when the run has already terminated. Selected chips persist across reloads by parsing the stored `tool_result.content` back into the selections shape.
- Tool group rendering uses `dedupeSnapshotToolRetries` to collapse identical `AskUserQuestion` retries (one card per unique input, keeping the latest tool_use_id) and `TodoWrite` snapshots (only the most recent call, since each call is a state replace).

Expand Down
13 changes: 11 additions & 2 deletions apps/web/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { NextConfig } from 'next';
import { networkInterfaces } from 'node:os';
import { dirname, isAbsolute, relative } from 'node:path';
import { dirname, isAbsolute, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

// Daemon port the local Express server binds to (see apps/daemon/src/cli.ts). The
Expand All @@ -23,7 +23,16 @@ const isServerOutput = webOutputMode === 'server' || webOutputMode === 'standalo
const shouldStaticExport = isProd && !isServerOutput;

const WEB_ROOT = dirname(fileURLToPath(import.meta.url));
const WORKSPACE_ROOT = dirname(dirname(WEB_ROOT));

function resolveWorkspaceRoot(): string {
const override = process.env.OD_WORKSPACE_ROOT;
if (override && override.trim()) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OD_WORKSPACE_ROOT is still treated as valid as soon as it is non-blank, even though this branch introduced it specifically as a config override. If the value points at a missing directory or a directory that does not actually contain apps/web, Next will fail later inside file tracing / Turbopack with a much harder-to-diagnose error instead of failing at config load time. The evidence is in resolveWorkspaceRoot(): the override is trimmed and resolved, but never checked for existence or containment before being assigned to outputFileTracingRoot and turbopack.root. Please validate the resolved path here (for example, stat/realpath it and throw when it does not exist or when relative(resolvedRoot, WEB_ROOT) escapes the root) so this new override follows the repo's fail-fast rule.

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

return isAbsolute(override) ? override : resolve(WEB_ROOT, override);
}
return dirname(dirname(WEB_ROOT));
}

const WORKSPACE_ROOT = resolveWorkspaceRoot();
const toPosixPath = (value: string) => value.replaceAll('\\', '/');

function resolveDistDir(defaultValue: string) {
Expand Down
37 changes: 35 additions & 2 deletions apps/web/src/components/ChatPane.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Fragment, useEffect, useRef, useState } from 'react';
import { Fragment, useEffect, useRef, useState, type MutableRefObject } from 'react';
import { useT } from '../i18n';
import type { Dict } from '../i18n/types';
import { copyToClipboard } from '../lib/copy-to-clipboard';
Expand Down Expand Up @@ -341,6 +341,7 @@ export function ChatPane({
const logRef = useRef<HTMLDivElement | null>(null);
const historyWrapRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<ChatComposerHandle | null>(null);
const pinnedTodoRef = useRef<HTMLDivElement | null>(null);
const didInitialScrollRef = useRef(false);
// Tracks whether the user is glued close enough to the bottom that
// streamed content should auto-follow. Distinct from the jump-button
Expand Down Expand Up @@ -589,12 +590,32 @@ export function ChatPane({
}
};

// The PinnedTodoSlot renders outside the scroll container. When the todo
// card grows, the chat-log's clientHeight shrinks (flex layout) and the
// user drifts away from the bottom. Observe the pinned-todo div so
// followLatestIfPinned fires whenever the card changes height.
let observedPinnedTodo: Element | null = null;
const syncPinnedTodo = () => {
if (!resizeObserver) return;
const pinnedEl = pinnedTodoRef.current;
if (pinnedEl && observedPinnedTodo !== pinnedEl) {
if (observedPinnedTodo) resizeObserver.unobserve(observedPinnedTodo);
resizeObserver.observe(pinnedEl);
observedPinnedTodo = pinnedEl;
} else if (!pinnedEl && observedPinnedTodo) {
resizeObserver.unobserve(observedPinnedTodo);
observedPinnedTodo = null;
}
};

syncObservedChildren();
syncPinnedTodo();

const mutationObserver =
typeof MutationObserver !== 'undefined'
? new MutationObserver(() => {
syncObservedChildren();
syncPinnedTodo();
followLatestIfPinned();
})
: null;
Expand All @@ -603,6 +624,15 @@ export function ChatPane({
subtree: true,
characterData: true,
});
// PinnedTodoSlot lives outside the chat-log subtree (it is a sibling of
// .chat-log-wrap inside .pane). The MutationObserver above only fires for
// changes inside el, so it cannot detect the slot mounting or unmounting.
// Watch the nearest common ancestor (.pane) with childList-only to catch
// those transitions and keep syncPinnedTodo current.
const paneEl = el.parentElement?.parentElement ?? null;
if (paneEl && mutationObserver) {
mutationObserver.observe(paneEl, { childList: true });
}

return () => {
if (followFrame !== null) cancelAnimationFrame(followFrame);
Expand Down Expand Up @@ -854,6 +884,7 @@ export function ChatPane({
streaming={streaming}
dismissedKey={dismissedPinnedTodoKey}
onDismiss={setDismissedPinnedTodoKey}
containerRef={pinnedTodoRef}
/>
<ChatComposer
ref={composerRef}
Expand Down Expand Up @@ -905,11 +936,13 @@ function PinnedTodoSlot({
streaming,
dismissedKey,
onDismiss,
containerRef,
}: {
messages: ChatMessage[];
streaming: boolean;
dismissedKey: string | null;
onDismiss: (key: string | null) => void;
containerRef?: MutableRefObject<HTMLDivElement | null>;
}) {
// `exiting` lets the dismiss click play a slide-down transition before
// the slot tears down. Without it React would unmount immediately and
Expand All @@ -925,7 +958,7 @@ function PinnedTodoSlot({
}
if (snapshotKey === dismissedKey) return null;
return (
<div className={`chat-pinned-todo${exiting ? ' chat-pinned-todo-exit' : ''}`}>
<div className={`chat-pinned-todo${exiting ? ' chat-pinned-todo-exit' : ''}`} ref={containerRef}>
<TodoCard
input={input}
runStreaming={streaming}
Expand Down
217 changes: 217 additions & 0 deletions apps/web/tests/components/chat-todo-autoscroll.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
// @vitest-environment jsdom

// Polyfill scrollTo for jsdom (not available in jsdom's HTMLElement)
if (typeof HTMLElement.prototype.scrollTo !== 'function') {
HTMLElement.prototype.scrollTo = function (
options?: ScrollToOptions | number,
_y?: number,
) {
if (typeof options === 'object' && options !== null) {
if (options.top !== undefined) this.scrollTop = options.top;
if (options.left !== undefined) this.scrollLeft = options.left;
}
};
}

import { act, cleanup, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ChatPane } from '../../src/components/ChatPane';
import type { ChatMessage } from '../../src/types';

// Per-test geometry for the chat-log scroll container. jsdom has no
// layout engine so we patch the prototype to route reads/writes through
// this object, matching the technique in chat-scroll-preservation.test.tsx.
type Geom = { scrollHeight: number; clientHeight: number; scrollTop: number };
let geom: Geom;
let rafCallbacks: FrameRequestCallback[];
let resizeCallbacks: ResizeObserverCallback[];
// All elements passed to any ResizeObserver.observe() call — used to
// assert that the pinned-todo div is observed so real-browser resizes fire.
let observedElements: Element[];
let savedDescriptors: Record<
'scrollTop' | 'scrollHeight' | 'clientHeight',
PropertyDescriptor | undefined
>;
let originalResizeObserver: typeof ResizeObserver | undefined;

function isChatLog(el: HTMLElement): boolean {
return typeof el?.classList?.contains === 'function' && el.classList.contains('chat-log');
}

beforeEach(() => {
geom = { scrollHeight: 1000, clientHeight: 400, scrollTop: 1000 };
rafCallbacks = [];
resizeCallbacks = [];
observedElements = [];

vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
rafCallbacks.push(callback);
return rafCallbacks.length;
});

originalResizeObserver = globalThis.ResizeObserver;
class MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallbacks.push(callback);
}
observe = vi.fn((el: Element) => {
observedElements.push(el);
});
unobserve = vi.fn();
disconnect = vi.fn();
}
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
writable: true,
value: MockResizeObserver,
});

savedDescriptors = {
scrollTop: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTop'),
scrollHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollHeight'),
clientHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight'),
};
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get(this: HTMLElement) {
return isChatLog(this) ? geom.scrollTop : 0;
},
set(this: HTMLElement, v: number) {
if (isChatLog(this)) geom.scrollTop = v;
},
});
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get(this: HTMLElement) {
return isChatLog(this) ? geom.scrollHeight : 0;
},
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
get(this: HTMLElement) {
return isChatLog(this) ? geom.clientHeight : 0;
},
});
});

afterEach(() => {
cleanup();
vi.restoreAllMocks();
rafCallbacks = [];
resizeCallbacks = [];
observedElements = [];
if (originalResizeObserver) {
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
writable: true,
value: originalResizeObserver,
});
} else {
delete (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver;
}
for (const key of ['scrollTop', 'scrollHeight', 'clientHeight'] as const) {
const original = savedDescriptors[key];
if (original) {
Object.defineProperty(HTMLElement.prototype, key, original);
} else {
delete (HTMLElement.prototype as unknown as Record<string, unknown>)[key];
}
}
});

async function flushFrames() {
await act(async () => {
const callbacks = rafCallbacks.splice(0);
callbacks.forEach((callback) => callback(performance.now()));
await Promise.resolve();
});
}

// Build a message set that includes a TodoWrite event so PinnedTodoSlot renders.
function messagesWithTodo(taskCount: number): ChatMessage[] {
const todos = Array.from({ length: taskCount }, (_, i) => ({
content: `Task ${i + 1}`,
status: 'pending',
}));
return [
{ id: 'u1', role: 'user' as const, content: 'build something', createdAt: Date.now() },
{
id: 'a1',
role: 'assistant' as const,
content: 'on it',
createdAt: Date.now(),
events: [
{
kind: 'tool_use' as const,
id: 'tw-1',
name: 'TodoWrite',
input: { todos },
},
],
},
];
}

function chatPaneEl(messages: ChatMessage[]) {
return (
<ChatPane
messages={messages}
streaming={false}
error={null}
projectId="project-1"
projectFiles={[]}
onEnsureProject={async () => 'project-1'}
onSend={() => {}}
onStop={() => {}}
conversations={[]}
activeConversationId={null}
onSelectConversation={() => {}}
onDeleteConversation={() => {}}
/>
);
}

describe('chat-log autoscroll when pinned todo card grows', () => {
it('observes the pinned-todo element so its resize triggers the bottom-pin follow', async () => {
// The PinnedTodoSlot lives outside the chat-log scroll container.
// When the todo card grows, the chat-log viewport (clientHeight)
// shrinks. The ResizeObserver must observe the pinned-todo div so
// `followLatestIfPinned` fires and corrects the scroll position.
render(chatPaneEl(messagesWithTodo(3)));
await flushFrames();

const pinnedTodoEl = document.querySelector('.chat-pinned-todo');
expect(pinnedTodoEl, 'PinnedTodoSlot should render with a TodoWrite message').not.toBeNull();

// The pinned-todo element must be registered with the ResizeObserver
// so that real-browser growth of the todo card triggers followLatestIfPinned.
expect(observedElements).toContain(pinnedTodoEl);
});

it('scrolls to the bottom when pinned and the todo card grows', async () => {
// Start pinned: scrollTop == scrollHeight (user is at the very bottom).
geom = { scrollHeight: 1000, clientHeight: 400, scrollTop: 1000 };
render(chatPaneEl(messagesWithTodo(2)));
await flushFrames();

// The initial-bottom-scroll effect fires and confirms pinnedToBottomRef = true.
// Now simulate the todo card growing: the viewport (clientHeight) shrinks,
// which means the user can no longer see the latest content even though
// scrollTop is still at its old value. The ResizeObserver callback should
// fire followLatestIfPinned, which snaps scrollTop back to scrollHeight.
geom = { ...geom, clientHeight: 300, scrollHeight: 1000, scrollTop: 600 };

await act(async () => {
const callbacks = [...resizeCallbacks];
callbacks.forEach((callback) => callback([], {} as ResizeObserver));
await Promise.resolve();
});
await flushFrames();

// followLatestIfPinned fires from the shared callback and snaps scrollTop
// to scrollHeight (1000). The structural guarantee that the pinned-todo
// element is observed (tested separately above) ensures this path runs in
// the real browser when the card grows.
expect(geom.scrollTop).toBe(1000);
});
});
Loading