diff --git a/.github/frontend-architecture-manifest.md b/.github/frontend-architecture-manifest.md index 0aa52ec58..9c6f4a362 100644 --- a/.github/frontend-architecture-manifest.md +++ b/.github/frontend-architecture-manifest.md @@ -173,6 +173,7 @@ bun run frontend:inventory -- --format markdown --max-rows 120 | Governance PR | #599 mainline / governance | `dev` | None | Manifest, schema, owner map, warn-only script, baseline report command | boundary created, owner map established, ratchet command added | `bun run frontend:inventory`, `node script/frontend-inventory.mjs --format json`, `bun run frontend:inventory -- --format markdown --max-rows 120` | in progress | PR body only | | Contract PR | #638 interface audit | Governance branch or post-merge `dev` | Governance PR | Public contract/import boundary and compatibility checks | public contract stabilized, private import risk surfaced | typecheck plus contract-specific compatibility check | planned | PR body only | | Message-flow PR stack | #601 message flow | post-governance `dev` unless stacked | Governance PR, maybe Contract PR if public imports move | Current launch-path message flow files only | owner extracted, LOC reduced, verification added | typecheck, unit/e2e, #600 perf gate, visual smoke | planned | PR body only | +| [#670](https://github.com/Astro-Han/pawwork/pull/670) | #601 message flow | `dev` | #667, #669 | Extract `createTimelineStaging` from `MessageTimeline` into `session-timeline-staging.ts` with browser-condition staging tests | timeline staging owner isolated; active-session message growth remains staged instead of popping to full render | focused staging/history/scroll tests, typecheck, diff check, PR CI | in review | PR body + manifest | | Scroll/perf PR stack | #595/#615 scroll-perf | `dev` or message-flow stack if shared files force it | Governance PR | Scroll owner and perf guard work only | owner extracted, perf verification added | typecheck, targeted unit/e2e, #600 perf gate | planned | PR body only | | Settings PR stack | #604 settings | `dev` after checking #642 overlap | Governance PR | Settings page/dialog family only | owner extracted, LOC reduced | typecheck, settings tests/e2e/manual UI check | planned | PR body only | diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 0b07c6135..625207e86 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -24,6 +24,7 @@ import { type TimelineScrollMetrics, type TimelineScrollObservation, } from "@/pages/session/session-timeline-scroll-controller" +import { createTimelineStaging } from "@/pages/session/session-timeline-staging" import { taskDescription } from "@/pages/session/task-description" import { buildTurnMessagesByUserID, emptyAssistantMessages } from "@/pages/session/session-messages" import { @@ -191,103 +192,6 @@ const shouldMarkLegacyScrollIntent = (intent: ScrollViewScrollIntent) => { return intent.type === "scrollbar_drag_start" } -type StageConfig = { - init: number - batch: number -} - -type TimelineStageInput = { - sessionKey: () => string - turnStart: () => number - messages: () => UserMessage[] - config: StageConfig -} - -/** - * Defer-mounts small timeline windows so revealing older turns does not - * block first paint with a large DOM mount. - * - * Once staging completes for a session it never re-stages — backfill and - * new messages render immediately. - */ -function createTimelineStaging(input: TimelineStageInput) { - const [state, setState] = createStore({ - activeSession: "", - completedSession: "", - count: 0, - }) - - const stagedCount = createMemo(() => { - const total = input.messages().length - if (input.turnStart() <= 0) return total - if (state.completedSession === input.sessionKey()) return total - const init = Math.min(total, input.config.init) - if (state.count <= init) return init - if (state.count >= total) return total - return state.count - }) - - const stagedUserMessages = createMemo(() => { - const list = input.messages() - const count = stagedCount() - if (count >= list.length) return list - return list.slice(Math.max(0, list.length - count)) - }) - - let frame: number | undefined - const cancel = () => { - if (frame === undefined) return - cancelAnimationFrame(frame) - frame = undefined - } - - createEffect( - on( - () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const, - ([sessionKey, isWindowed, total]) => { - cancel() - const shouldStage = - isWindowed && - total > input.config.init && - state.completedSession !== sessionKey && - state.activeSession !== sessionKey - if (!shouldStage) { - setState({ activeSession: "", count: total }) - return - } - - let count = Math.min(total, input.config.init) - setState({ activeSession: sessionKey, count }) - - const step = () => { - if (input.sessionKey() !== sessionKey) { - frame = undefined - return - } - const currentTotal = input.messages().length - count = Math.min(currentTotal, count + input.config.batch) - setState("count", count) - if (count >= currentTotal) { - setState({ completedSession: sessionKey, activeSession: "" }) - frame = undefined - return - } - frame = requestAnimationFrame(step) - } - frame = requestAnimationFrame(step) - }, - ), - ) - - const isStaging = createMemo(() => { - const key = input.sessionKey() - return state.activeSession === key && state.completedSession !== key - }) - - onCleanup(cancel) - return { messages: stagedUserMessages, isStaging } -} - export function MessageTimeline(props: { sessionID: string sessionKey: string diff --git a/packages/app/src/pages/session/session-timeline-staging.test.ts b/packages/app/src/pages/session/session-timeline-staging.test.ts new file mode 100644 index 000000000..969ea6caf --- /dev/null +++ b/packages/app/src/pages/session/session-timeline-staging.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, test } from "bun:test" + +const browserCheck = String.raw` +import { render } from "solid-js/web" +import { createSignal } from "solid-js" +import { createTimelineStaging } from "./src/pages/session/session-timeline-staging.ts" + +const assert = (condition, message) => { + if (!condition) throw new Error(message) +} + +const message = (id) => ({ + id: "msg_" + id, + role: "user", + time: { created: id }, +}) +const messages = (count) => Array.from({ length: count }, (_, index) => message(index)) +const ids = (list) => list.map((item) => item.id).join(",") + +const installAnimationFrameQueue = () => { + let nextID = 1 + const frames = new Map() + const canceled = [] + + globalThis.requestAnimationFrame = (callback) => { + const id = nextID++ + frames.set(id, callback) + return id + } + + globalThis.cancelAnimationFrame = (id) => { + canceled.push(id) + frames.delete(id) + } + + return { + canceled, + pending: () => frames.size, + pendingIDs: () => [...frames.keys()], + flushOne: () => { + const next = frames.entries().next() + if (next.done) return false + const [id, callback] = next.value + frames.delete(id) + callback(performance.now()) + return true + }, + } +} + +const mount = (factory) => { + const root = document.createElement("div") + document.body.append(root) + const dispose = render(factory, root) + return () => { + dispose() + root.remove() + } +} + +{ + const raf = installAnimationFrameQueue() + let staging + const dispose = mount(() => { + staging = createTimelineStaging({ + sessionKey: () => "ses_1", + turnStart: () => 0, + messages: () => messages(14), + config: { init: 10, batch: 3 }, + }) + return null + }) + + assert(ids(staging.messages()) === ids(messages(14)), "non-windowed timeline should render all messages") + assert(staging.isStaging() === false, "non-windowed timeline should not stage") + assert(raf.pending() === 0, "non-windowed timeline should not schedule frames") + dispose() +} + +{ + const raf = installAnimationFrameQueue() + let staging + const dispose = mount(() => { + staging = createTimelineStaging({ + sessionKey: () => "ses_1", + turnStart: () => 6, + messages: () => messages(16), + config: { init: 10, batch: 3 }, + }) + return null + }) + + assert(ids(staging.messages()) === ids(messages(16).slice(6)), "history window should start at init size") + assert(staging.isStaging() === true, "history window should report active staging") + assert(raf.pending() === 1, "history window should schedule one frame") + assert(raf.flushOne() === true, "first staging frame should run") + assert(ids(staging.messages()) === ids(messages(16).slice(3)), "first frame should add one batch") + assert(staging.isStaging() === true, "staging should remain active before completion") + assert(raf.flushOne() === true, "second staging frame should run") + assert(ids(staging.messages()) === ids(messages(16)), "second frame should complete staging") + assert(staging.isStaging() === false, "completed staging should clear active state") + assert(raf.pending() === 0, "completed staging should not leave pending frames") + dispose() +} + +{ + const raf = installAnimationFrameQueue() + let staging + let setCount + const dispose = mount(() => { + const [count, nextCount] = createSignal(16) + setCount = nextCount + staging = createTimelineStaging({ + sessionKey: () => "ses_1", + turnStart: () => 6, + messages: () => messages(count()), + config: { init: 10, batch: 3 }, + }) + return null + }) + + const firstFrame = raf.pendingIDs()[0] + assert(firstFrame !== undefined, "active staging should schedule a frame") + setCount(18) + assert(ids(staging.messages()) === ids(messages(18).slice(8)), "active staging should not pop to all messages") + assert(staging.isStaging() === true, "message growth should keep staging active") + assert(raf.pendingIDs().includes(firstFrame), "message growth should keep the existing staging frame") + assert(raf.flushOne() === true, "existing staging frame should continue after growth") + assert(ids(staging.messages()) === ids(messages(18).slice(5)), "continued staging should add one batch after growth") + dispose() +} + +{ + const raf = installAnimationFrameQueue() + let staging + let setCount + const dispose = mount(() => { + const [count, nextCount] = createSignal(13) + setCount = nextCount + staging = createTimelineStaging({ + sessionKey: () => "ses_1", + turnStart: () => 3, + messages: () => messages(count()), + config: { init: 10, batch: 3 }, + }) + return null + }) + + assert(ids(staging.messages()) === ids(messages(13).slice(3)), "completed-session case should start windowed") + assert(raf.flushOne() === true, "completion frame should run") + assert(ids(staging.messages()) === ids(messages(13)), "completion frame should reveal all") + assert(staging.isStaging() === false, "completion should clear active state") + setCount(16) + assert(ids(staging.messages()) === ids(messages(16)), "completed session backfill should render immediately") + assert(staging.isStaging() === false, "completed session backfill should not restage") + assert(raf.pending() === 0, "completed session backfill should not schedule frames") + dispose() +} + +{ + const raf = installAnimationFrameQueue() + let staging + let setSessionKey + const dispose = mount(() => { + const [sessionKey, nextSessionKey] = createSignal("ses_1") + setSessionKey = nextSessionKey + staging = createTimelineStaging({ + sessionKey, + turnStart: () => 6, + messages: () => messages(16), + config: { init: 10, batch: 3 }, + }) + return null + }) + + const firstFrame = raf.pendingIDs()[0] + assert(firstFrame !== undefined, "initial history staging should schedule a frame") + setSessionKey("ses_2") + assert(raf.canceled.includes(firstFrame), "session switch should cancel the previous frame") + assert(raf.pending() === 1, "session switch should leave one new frame for the new session") + assert(ids(staging.messages()) === ids(messages(16).slice(6)), "new session should restart at init size") + assert(raf.flushOne() === true, "new session frame should run") + assert(ids(staging.messages()) === ids(messages(16).slice(3)), "new session frame should add one batch") + dispose() +} +` + +describe("createTimelineStaging", () => { + test("preserves browser staging behavior", () => { + const result = Bun.spawnSync({ + cmd: [process.execPath, "--conditions=browser", "--preload", "./happydom.ts", "-e", browserCheck], + cwd: new URL("../../..", import.meta.url).pathname, + stdout: "pipe", + stderr: "pipe", + }) + + const output = `${new TextDecoder().decode(result.stdout)}${new TextDecoder().decode(result.stderr)}` + expect(output).toBe("") + expect(result.exitCode).toBe(0) + }) +}) diff --git a/packages/app/src/pages/session/session-timeline-staging.ts b/packages/app/src/pages/session/session-timeline-staging.ts new file mode 100644 index 000000000..14abcb607 --- /dev/null +++ b/packages/app/src/pages/session/session-timeline-staging.ts @@ -0,0 +1,102 @@ +import type { UserMessage } from "@opencode-ai/sdk/v2" +import { createEffect, createMemo, on, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" + +export type TimelineStageConfig = { + init: number + batch: number +} + +export type TimelineStageInput = { + sessionKey: () => string + turnStart: () => number + messages: () => UserMessage[] + config: TimelineStageConfig +} + +/** + * Defer-mounts small timeline windows so revealing older turns does not + * block first paint with a large DOM mount. + * + * Once staging completes for a session it never re-stages. Backfill and + * new messages render immediately. + */ +export function createTimelineStaging(input: TimelineStageInput) { + const [state, setState] = createStore({ + activeSession: "", + completedSession: "", + count: 0, + }) + + const stagedCount = createMemo(() => { + const total = input.messages().length + if (input.turnStart() <= 0) return total + if (state.completedSession === input.sessionKey()) return total + const init = Math.min(total, input.config.init) + if (state.count <= init) return init + if (state.count >= total) return total + return state.count + }) + + const stagedUserMessages = createMemo(() => { + const list = input.messages() + const count = stagedCount() + if (count >= list.length) return list + return list.slice(Math.max(0, list.length - count)) + }) + + let frame: number | undefined + const cancel = () => { + if (frame === undefined) return + cancelAnimationFrame(frame) + frame = undefined + } + + createEffect( + on( + () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const, + ([sessionKey, isWindowed, total]) => { + const shouldStage = + isWindowed && + total > input.config.init && + state.completedSession !== sessionKey + if (!shouldStage) { + cancel() + setState({ activeSession: "", count: total }) + return + } + + if (state.activeSession === sessionKey) return + + cancel() + let count = Math.min(total, input.config.init) + setState({ activeSession: sessionKey, count }) + + const step = () => { + if (input.sessionKey() !== sessionKey) { + frame = undefined + return + } + const currentTotal = input.messages().length + count = Math.min(currentTotal, count + input.config.batch) + setState("count", count) + if (count >= currentTotal) { + setState({ completedSession: sessionKey, activeSession: "" }) + frame = undefined + return + } + frame = requestAnimationFrame(step) + } + frame = requestAnimationFrame(step) + }, + ), + ) + + const isStaging = createMemo(() => { + const key = input.sessionKey() + return state.activeSession === key && state.completedSession !== key + }) + + onCleanup(cancel) + return { messages: stagedUserMessages, isStaging } +}