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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,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
50 changes: 48 additions & 2 deletions apps/web/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { NextConfig } from 'next';
import { existsSync, realpathSync } from 'node:fs';
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 +24,52 @@ 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 computed = dirname(dirname(WEB_ROOT));
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.

const resolved = isAbsolute(override.trim()) ? override.trim() : resolve(WEB_ROOT, override.trim());
if (!existsSync(resolved)) {
throw new Error(
`OD_WORKSPACE_ROOT="${override}" resolved to "${resolved}" which does not exist. ` +
`Fix the path or unset the variable to use the computed default.`,
);
}
// Canonicalize via realpathSync so that symlinked paths (e.g. macOS
// /tmp → /private/tmp) compare correctly against WEB_ROOT.
const canonicalResolved = realpathSync(resolved);
const canonicalWebRoot = realpathSync(WEB_ROOT);
const rel = relative(canonicalResolved, canonicalWebRoot);
// rel.startsWith('..') catches the non-ancestor case on POSIX.
// isAbsolute(rel) catches the Windows cross-drive case where relative()
// returns an absolute path (e.g. C:\repo\apps\web) instead of a ..-path.
if (rel.startsWith('..') || isAbsolute(rel)) {
throw new Error(
`OD_WORKSPACE_ROOT="${override}" resolved to "${canonicalResolved}" but WEB_ROOT "${canonicalWebRoot}" ` +
`is not inside it (relative path "${rel}"). ` +
`The override must be an ancestor of apps/web.`,
);
}
Comment on lines +33 to +53
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.

resolveWorkspaceRoot() now treats an invalid OD_WORKSPACE_ROOT as a warning-and-fallback path instead of failing at config load. That matters because this PR introduced the override specifically to make worktree Playwright runs deterministic; a typo or stale path can silently switch outputFileTracingRoot/turbopack.root back to the computed default and send the run back to the original opaque resolution behavior. The evidence is in these branches: both the missing-directory case and the WEB_ROOT-outside-root case call console.warn(...) and return computed. Please replace those fallbacks with a thrown configuration error (or equivalent hard failure) so an explicitly provided but invalid OD_WORKSPACE_ROOT fails fast with a clear message.

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

// Require the resolved path to be a real pnpm workspace root. Without this,
// an ancestor like `<repo>/apps` would pass the relative-path check but
// miss the sibling `packages/*` directory that `apps/web` imports from
// (for example `@open-design/contracts`), and Next would later fail deep
// inside file tracing / Turbopack with a much harder-to-diagnose error.
if (!existsSync(resolve(canonicalResolved, 'pnpm-workspace.yaml'))) {
throw new Error(
`OD_WORKSPACE_ROOT="${override}" resolved to "${canonicalResolved}" but no ` +
`pnpm-workspace.yaml was found there. The override must point at the ` +
`pnpm workspace root so outputFileTracingRoot and turbopack.root can ` +
`resolve sibling packages.`,
);
}
return canonicalResolved;
}
return computed;
}

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 { useAnalytics } from '../analytics/provider';
import { trackChatPanelClick } from '../analytics/events';
import { useT } from '../i18n';
Expand Down Expand Up @@ -357,6 +357,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 @@ -605,12 +606,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 @@ -619,6 +640,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 @@ -919,6 +949,7 @@ export function ChatPane({
streaming={streaming}
dismissedKey={dismissedPinnedTodoKey}
onDismiss={setDismissedPinnedTodoKey}
containerRef={pinnedTodoRef}
/>
<ChatComposer
ref={composerRef}
Expand Down Expand Up @@ -970,11 +1001,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 @@ -990,7 +1023,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
Loading
Loading