Skip to content
Draft
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
38 changes: 37 additions & 1 deletion apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type * as AcpSchema from "effect-acp/schema";
import type * as AcpCompat from "effect-acp/compat";

import { beginAcpMockPrompt } from "./acpMockCancellationState.ts";
import { timelineFollowFixture, waitForTimelineFixtureRelease } from "./timeline-follow-fixture.ts";

const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH;
const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH;
Expand Down Expand Up @@ -126,7 +127,10 @@ const permissionRequestCount = Math.max(
1,
Number(process.env.T3_ACP_PERMISSION_REQUEST_COUNT ?? "1") || 1,
);
const sessionId = "mock-session-1";
const sessionId =
process.env.T3_ACP_TIMELINE_FIXTURE_DIRECTORY === undefined
? "mock-session-1"
: `timeline-fixture-${process.pid}`;

let currentModeId = antigravityProfile ? "default" : "ask";
let currentModelId = antigravityProfile ? "gemini-test-low" : "default";
Expand Down Expand Up @@ -962,6 +966,38 @@ const program = Effect.gen(function* () {
const requestedSessionId = String(request.sessionId ?? sessionId);
beginAcpMockPrompt(cancelledSessions, requestedSessionId);
promptCount += 1;
const timelineFixtureRoot = process.env.T3_ACP_TIMELINE_FIXTURE_DIRECTORY;
if (timelineFixtureRoot !== undefined) {
const timelineFixtureDirectory = `${timelineFixtureRoot}/${process.pid}-${promptCount}`;
const kind = request.prompt.some(
(part) =>
part.type === "text" &&
typeof part.text === "string" &&
part.text.includes("timeline-fixture:short"),
)
? "short"
: "long";
for (const { phase, updates } of timelineFollowFixture(kind)) {
yield* Effect.sync(() => NodeFS.mkdirSync(timelineFixtureDirectory, { recursive: true }));
yield* Effect.sync(() =>
NodeFS.writeFileSync(
`${timelineFixtureDirectory}/waiting.json`,
encodeJson({ kind, phase, sessionId: requestedSessionId }),
),
);
yield* Effect.promise(() =>
waitForTimelineFixtureRelease(timelineFixtureDirectory, phase),
);
for (const update of updates) {
yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, update });
yield* Effect.sleep("40 millis");
}
yield* Effect.sync(() =>
NodeFS.writeFileSync(`${timelineFixtureDirectory}/${phase}.done`, "done\n"),
);
}
return yield* finishPrompt(requestedSessionId, "end_turn");
}
if (
process.env.T3_ACP_CRASH_PROMPT === "1" &&
request.prompt.some((part) => part.type === "text" && part.text === "crash now")
Expand Down
165 changes: 165 additions & 0 deletions apps/server/scripts/timeline-follow-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// @effect-diagnostics nodeBuiltinImport:off - Synthetic capture data and local release barriers.
import * as NodeFS from "node:fs";
import * as NodePath from "node:path";
import * as NodeChildProcess from "node:child_process";
import * as NodeURL from "node:url";

import type * as AcpSchema from "effect-acp/schema";

const timelineFixtureEpoch = "2026-01-01T12:00:00.000Z";
const timelineFixturePhases = ["entry", "tool-start", "tool-finish", "prose", "resumed"] as const;

export function timelineFollowFixture(kind: "long" | "short") {
const say = (text: string): AcpSchema.SessionUpdate => ({
sessionUpdate: "agent_message_chunk",
messageId: "timeline-fixture-answer",
content: { type: "text", text },
});
const paragraph = (index: number) =>
`Paragraph ${String(index).padStart(3, "0")}. The amber lighthouse overlooks a quiet harbor. ` +
"A keeper records the tide, checks the lantern, and leaves a clear note for the next watch. " +
"This fixed paragraph marks a stable reading position while new output arrives.\n\n";
const tool = (status: "in_progress" | "completed"): AcpSchema.SessionUpdate => ({
sessionUpdate: "tool_call_update",
toolCallId: "timeline-fixture-tool",
title: "Check the harbor log",
kind: "execute",
status,
rawInput: { command: "printf 'harbor clear\\n'" },
...(status === "completed"
? { rawOutput: { stdout: "harbor clear\n", stderr: "", exitCode: 0 } }
: {}),
});
return [
{
phase: "entry",
updates:
kind === "long"
? [say(Array.from({ length: 100 }, (_, i) => paragraph(i + 1)).join(""))]
: [say("The harbor is quiet.\n\n")],
},
{ phase: "tool-start", updates: [tool("in_progress")] },
{ phase: "tool-finish", updates: [tool("completed")] },
{
phase: "prose",
updates:
kind === "long"
? [say(Array.from({ length: 20 }, (_, i) => paragraph(i + 101)).join(""))]
: [say("The lantern is ready.\n\n")],
},
{
phase: "resumed",
updates: [say(kind === "long" ? paragraph(121) + paragraph(122) : "All clear.")],
},
];
}

// The driver creates a release file only after capturing the previous checkpoint.
// Watching the directory before checking the file avoids a lost release race.
export function waitForTimelineFixtureRelease(directory: string, phase: string) {
NodeFS.mkdirSync(directory, { recursive: true });
const releasePath = NodePath.join(directory, `${phase}.release`);
return new Promise<void>((resolve, reject) => {
const watcher = NodeFS.watch(directory, () => {
if (!NodeFS.existsSync(releasePath)) return;
watcher.close();
resolve();
});
watcher.on("error", (error) => {
watcher.close();
reject(error);
});
if (NodeFS.existsSync(releasePath)) {
watcher.close();
resolve();
}
});
}

if (process.argv[1] && import.meta.url === NodeURL.pathToFileURL(process.argv[1]).href) {
const [command, directory, phase, provider, kind] = process.argv.slice(2);
if (command === "print") {
process.stdout.write(
JSON.stringify(
{
epoch: timelineFixtureEpoch,
long: timelineFollowFixture("long"),
short: timelineFollowFixture("short"),
},
null,
2,
) + "\n",
);
} else if (
command === "release" &&
directory &&
timelineFixturePhases.some((value) => value === phase)
) {
NodeFS.mkdirSync(directory, { recursive: true });
NodeFS.writeFileSync(NodePath.join(directory, `${phase}.release`), "release\n");
} else if (command === "provider" && directory) {
process.stdout.write(
JSON.stringify(
{
operation: "create",
instanceId: "timeline_fixture",
instance: {
driver: "acpRegistry",
displayName: "Timeline fixture",
enabled: true,
config: {
source: "local",
commandPath: process.execPath,
commandArgs: [NodePath.join(import.meta.dirname, "acp-mock-agent.ts")],
},
environment: [
{
name: "T3_ACP_TIMELINE_FIXTURE_DIRECTORY",
value: NodePath.resolve(directory),
sensitive: false,
},
],
},
},
null,
2,
) + "\n",
);
} else if (
command === "seed" &&
directory &&
phase &&
provider &&
(kind === "long" || kind === "short")
) {
// Launch through the installed app service; do not write projections or credentials.
const result = NodeChildProcess.spawnSync(
process.execPath,
[
NodePath.join(import.meta.dirname, "../src/bin.ts"),
"thread",
"start",
"--base-dir",
NodePath.resolve(directory),
phase,
`timeline-fixture:${kind}`,
"--provider",
provider,
"--model",
"default",
"--title",
`Timeline fixture ${kind}`,
"--json",
],
{ encoding: "utf8" },
);
process.stdout.write(result.stdout ?? "");
process.stderr.write(result.stderr ?? "");
process.exitCode = result.status ?? 1;
} else {
process.stderr.write(
"Usage: node apps/server/scripts/timeline-follow-fixture.ts\n print\n provider <fresh-barrier-root>\n seed <isolated-home> <project-id-or-path> <provider-instance> <long|short>\n release <barrier-root/pid-prompt> <entry|tool-start|tool-finish|prose|resumed>\nConfigure the provider JSON through Settings in the isolated environment. Each seed prints actual app thread/run IDs. waiting.json names the next phase; <phase>.done marks delivery. Capture before releasing the next phase. Use a fresh root for each capture run.\n",
);
process.exitCode = 2;
}
}
3 changes: 2 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2050,14 +2050,14 @@ export default function ChatView(props: ChatViewProps) {
onToolOutputCollapsedAtEndStable,
onIsAtEndChangeStable,
anchorNewTurn,
setTimelineScrollNode,
} = useTimelineFollow({
legendListRef,
composerRef,
composerOverlayElement,
composerTimelineInset,
routeThreadKey,
activeThreadKey,
activeThreadId: activeThread?.id,
isServerThread,
serverProjection,
threadStatus,
Expand Down Expand Up @@ -3313,6 +3313,7 @@ export default function ChatView(props: ChatViewProps) {
)
}
listRef={legendListRef}
onScrollNodeChange={setTimelineScrollNode}
runs={paintOnlyDisplayedTimeline ? [] : (serverProjection?.runs ?? [])}
latestRun={paintOnlyDisplayedTimeline ? null : activeActivityRun}
runningRunId={paintOnlyDisplayedTimeline ? null : activeRunningTurnId}
Expand Down
88 changes: 86 additions & 2 deletions apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { EnvironmentId, MessageId, RunId, ScheduledTaskId } from "@t3tools/contracts";
import type { LegendListRef } from "@legendapp/list/react";
import { createRef, Profiler, type ComponentProps, type ReactElement } from "react";
import {
createRef,
Profiler,
useRef,
useState,
type ComponentProps,
type ReactElement,
} from "react";
import { describe, expect, it, vi } from "vite-plus/test";

import "~/index.css";

import { act, render } from "../../../test/render";
import { act, fakeTimers, render } from "../../../test/render";
import { MessagesTimeline } from "./MessagesTimeline";
import { useTimelineFollow } from "./useTimelineFollow";
import type { ChatComposerHandle } from "./ChatComposer";
import { remarkThoughtPreview } from "./TimelineWorkEntryRow.logic";
import { parseMarkdownWithMath } from "@t3tools/shared/markdownMath";
import { collectThreadFindRanges } from "./threadFindHighlights";
Expand Down Expand Up @@ -121,6 +130,46 @@ async function click(element: HTMLElement) {
await act(async () => element.click());
}

function FollowHarness({ entries, loading }: { entries: Entry[]; loading: boolean }) {
const listRef = useRef<LegendListRef | null>(null);
const composerRef = useRef<ChatComposerHandle | null>(null);
const [routeThreadKey] = useState(() => baseProps().routeThreadKey);
const follow = useTimelineFollow({
legendListRef: listRef,
composerRef,
composerOverlayElement: null,
composerTimelineInset: 0,
routeThreadKey,
activeThreadKey: routeThreadKey,
isServerThread: false,
serverProjection: null,
threadStatus: "live",
activeActivityRun: null,
activeRunningTurnId: null,
timelineEntries: entries,
timelineAnchorMessageId: null,
setTimelineAnchor: () => {},
});
return (
<div
data-live-follow={String(follow.timelineLiveFollowEnabled)}
style={{ height: "100%", minHeight: 0, display: "flex", flexDirection: "column" }}
>
<MessagesTimeline
{...baseProps()}
listRef={listRef}
routeThreadKey={routeThreadKey}
timelineEntries={loading ? [] : entries}
hideEmptyPlaceholder={loading}
liveFollowEnabled={follow.timelineLiveFollowEnabled}
onIsAtEndChange={follow.onIsAtEndChangeStable}
onManualNavigation={follow.cancelTimelineLiveFollowForUserNavigationStable}
onScrollNodeChange={follow.setTimelineScrollNode}
/>
</div>
);
}

function setup({ scheduledTasks = [] as ReadonlyArray<{ id: string; title: string }> } = {}) {
environmentQuery.data = { tasks: scheduledTasks };
}
Expand Down Expand Up @@ -220,6 +269,41 @@ describe("MessagesTimeline messages", () => {
});
});

describe("timeline live follow", () => {
it("lets a wheel up break follow when the list mounts after a slow hydration", async () => {
const entries = Array.from({ length: 40 }, (_, index) =>
message(`Paragraph ${index}\n\n${"lorem ipsum ".repeat(60)}`, {}, `message-${index}`),
);
const container = document.createElement("div");
container.style.cssText =
"height: 1200px; width: 900px; display: flex; flex-direction: column;";
let screen: Awaited<ReturnType<typeof render>>;
{
// Hydration can outlast any fixed frame budget before the list mounts.
using _timers = fakeTimers();
screen = await render(<FollowHarness entries={entries} loading />, { container });
expect(container.querySelector(".messages-timeline-scroll")).toBeNull();
await act(async () => vi.advanceTimersByTime(16 * 20));
}
await using _screen = screen;
await screen.rerender(<FollowHarness entries={entries} loading={false} />);
const scrollNode = container.querySelector<HTMLElement>(".messages-timeline-scroll");
if (!scrollNode) throw new Error("Timeline did not mount");
await expect.poll(() => scrollNode.scrollHeight > scrollNode.clientHeight).toBe(true);
const follow = () => container.firstElementChild?.getAttribute("data-live-follow");
expect(follow()).toBe("true");
const row = scrollNode.querySelector<HTMLElement>("[data-timeline-root]");
if (!row) throw new Error("No timeline row");
// LegendList measures its viewport a frame after mount; retry until the gate sees overflow.
await vi.waitFor(async () => {
await act(async () => {
row.dispatchEvent(new WheelEvent("wheel", { deltaY: -120, bubbles: true }));
});
expect(follow()).toBe("false");
});
});
});

describe("MessagesTimeline automation prompts", () => {
it("folds an automation prompt into a bubble titled by its task", async () => {
setup({ scheduledTasks: [{ id: TASK_ID, title: "Nightly triage" }] });
Expand Down
13 changes: 10 additions & 3 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ interface MessagesTimelineProps {
onContentOverflowChange?: (overflows: boolean) => void;
onToolOutputCollapsedAtEnd?: () => void;
onManualNavigation: () => void;
/** Reports the list's scroll node when it mounts, remounts, or unmounts. */
onScrollNodeChange?: (node: HTMLElement | null) => void;
findOpen?: boolean;
cancelPositionRestoreRef?: React.RefObject<(() => void) | null> | undefined;
hideEmptyPlaceholder?: boolean;
Expand Down Expand Up @@ -346,6 +348,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
liveFollowEnabled,
onToolOutputCollapsedAtEnd,
onManualNavigation,
onScrollNodeChange,
cancelPositionRestoreRef,
hideEmptyPlaceholder = false,
emptyPlaceholderInset,
Expand Down Expand Up @@ -981,10 +984,14 @@ const ConversationTimeline = memo(function ConversationTimeline({

const canvas = useChatCanvas();
const registerTimeline = canvas?.registerTimeline;
const setTimelineList = (list: LegendListRef | null) => {
// A stable ref callback: a fresh identity would make React report null and
// then the node again on every render.
const setTimelineList = useStableCallback((list: LegendListRef | null) => {
listRef.current = list;
registerTimeline?.(list?.getScrollableNode() ?? null);
};
const scrollNode = list?.getScrollableNode() ?? null;
registerTimeline?.(scrollNode);
onScrollNodeChange?.(scrollNode);
});

useThreadFindPositionReader({
findPositionReaderRef,
Expand Down
Loading
Loading