Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/frontend-architecture-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
98 changes: 1 addition & 97 deletions packages/app/src/pages/session/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
201 changes: 201 additions & 0 deletions packages/app/src/pages/session/session-timeline-staging.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading