Skip to content

Commit 2825725

Browse files
authored
refactor(app): extract timeline staging helper
Goal: Extract the timeline staging owner out of MessageTimeline as the first #601 message-flow stack root. Scope: - Move createTimelineStaging into packages/app/src/pages/session/session-timeline-staging.ts. - Keep MessageTimeline wired with the same sessionKey, turnStart, renderedUserMessages, and { init: 10, batch: 3 } config. - Add browser-condition staging tests for non-windowed render, staged batches, active-session message growth, completed-session backfill, and session switch rAF cancellation. - Preserve active staging when the same session receives more messages mid-stage so the historical window does not pop to full render. - Add the concrete #670 boundary to the frontend architecture manifest. Verification: - bun --cwd packages/app test --preload ./happydom.ts src/pages/session/session-timeline-staging.test.ts src/pages/session/use-session-history-window.test.ts src/pages/session/session-timeline-scroll-controller.test.ts src/pages/session/session-timeline-scroll-anchors.test.ts -> 1110 pass / 2674 expects - bun run typecheck -> 8 successful tasks - git diff --check - GitHub checks green, including ci, unit-app, unit-opencode, unit-desktop, unit-ui-focused, desktop smoke, e2e artifacts, perf-probe-baseline, CodeQL, and CodeRabbit. - reviewThreads unresolved = 0 Review follow-ups: - Fixed Gemini staging-pop thread and resolved it after replying in-thread. - Added the missing manifest entry that the PR body claimed. - Refreshed PR body verification after #667 and #669 landed on dev. Residual risk: - Electron manual verification was not run because this is scoped to behavior-preserving extraction plus tests, with no visible UI or copy change.
1 parent 187a929 commit 2825725

4 files changed

Lines changed: 305 additions & 97 deletions

File tree

.github/frontend-architecture-manifest.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ bun run frontend:inventory -- --format markdown --max-rows 120
173173
| 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 |
174174
| 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 |
175175
| 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 |
176+
| [#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 |
176177
| 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 |
177178
| 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 |
178179

packages/app/src/pages/session/message-timeline.tsx

Lines changed: 1 addition & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
type TimelineScrollMetrics,
2525
type TimelineScrollObservation,
2626
} from "@/pages/session/session-timeline-scroll-controller"
27+
import { createTimelineStaging } from "@/pages/session/session-timeline-staging"
2728
import { taskDescription } from "@/pages/session/task-description"
2829
import { buildTurnMessagesByUserID, emptyAssistantMessages } from "@/pages/session/session-messages"
2930
import {
@@ -191,103 +192,6 @@ const shouldMarkLegacyScrollIntent = (intent: ScrollViewScrollIntent) => {
191192
return intent.type === "scrollbar_drag_start"
192193
}
193194

194-
type StageConfig = {
195-
init: number
196-
batch: number
197-
}
198-
199-
type TimelineStageInput = {
200-
sessionKey: () => string
201-
turnStart: () => number
202-
messages: () => UserMessage[]
203-
config: StageConfig
204-
}
205-
206-
/**
207-
* Defer-mounts small timeline windows so revealing older turns does not
208-
* block first paint with a large DOM mount.
209-
*
210-
* Once staging completes for a session it never re-stages — backfill and
211-
* new messages render immediately.
212-
*/
213-
function createTimelineStaging(input: TimelineStageInput) {
214-
const [state, setState] = createStore({
215-
activeSession: "",
216-
completedSession: "",
217-
count: 0,
218-
})
219-
220-
const stagedCount = createMemo(() => {
221-
const total = input.messages().length
222-
if (input.turnStart() <= 0) return total
223-
if (state.completedSession === input.sessionKey()) return total
224-
const init = Math.min(total, input.config.init)
225-
if (state.count <= init) return init
226-
if (state.count >= total) return total
227-
return state.count
228-
})
229-
230-
const stagedUserMessages = createMemo(() => {
231-
const list = input.messages()
232-
const count = stagedCount()
233-
if (count >= list.length) return list
234-
return list.slice(Math.max(0, list.length - count))
235-
})
236-
237-
let frame: number | undefined
238-
const cancel = () => {
239-
if (frame === undefined) return
240-
cancelAnimationFrame(frame)
241-
frame = undefined
242-
}
243-
244-
createEffect(
245-
on(
246-
() => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const,
247-
([sessionKey, isWindowed, total]) => {
248-
cancel()
249-
const shouldStage =
250-
isWindowed &&
251-
total > input.config.init &&
252-
state.completedSession !== sessionKey &&
253-
state.activeSession !== sessionKey
254-
if (!shouldStage) {
255-
setState({ activeSession: "", count: total })
256-
return
257-
}
258-
259-
let count = Math.min(total, input.config.init)
260-
setState({ activeSession: sessionKey, count })
261-
262-
const step = () => {
263-
if (input.sessionKey() !== sessionKey) {
264-
frame = undefined
265-
return
266-
}
267-
const currentTotal = input.messages().length
268-
count = Math.min(currentTotal, count + input.config.batch)
269-
setState("count", count)
270-
if (count >= currentTotal) {
271-
setState({ completedSession: sessionKey, activeSession: "" })
272-
frame = undefined
273-
return
274-
}
275-
frame = requestAnimationFrame(step)
276-
}
277-
frame = requestAnimationFrame(step)
278-
},
279-
),
280-
)
281-
282-
const isStaging = createMemo(() => {
283-
const key = input.sessionKey()
284-
return state.activeSession === key && state.completedSession !== key
285-
})
286-
287-
onCleanup(cancel)
288-
return { messages: stagedUserMessages, isStaging }
289-
}
290-
291195
export function MessageTimeline(props: {
292196
sessionID: string
293197
sessionKey: string
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { describe, expect, test } from "bun:test"
2+
3+
const browserCheck = String.raw`
4+
import { render } from "solid-js/web"
5+
import { createSignal } from "solid-js"
6+
import { createTimelineStaging } from "./src/pages/session/session-timeline-staging.ts"
7+
8+
const assert = (condition, message) => {
9+
if (!condition) throw new Error(message)
10+
}
11+
12+
const message = (id) => ({
13+
id: "msg_" + id,
14+
role: "user",
15+
time: { created: id },
16+
})
17+
const messages = (count) => Array.from({ length: count }, (_, index) => message(index))
18+
const ids = (list) => list.map((item) => item.id).join(",")
19+
20+
const installAnimationFrameQueue = () => {
21+
let nextID = 1
22+
const frames = new Map()
23+
const canceled = []
24+
25+
globalThis.requestAnimationFrame = (callback) => {
26+
const id = nextID++
27+
frames.set(id, callback)
28+
return id
29+
}
30+
31+
globalThis.cancelAnimationFrame = (id) => {
32+
canceled.push(id)
33+
frames.delete(id)
34+
}
35+
36+
return {
37+
canceled,
38+
pending: () => frames.size,
39+
pendingIDs: () => [...frames.keys()],
40+
flushOne: () => {
41+
const next = frames.entries().next()
42+
if (next.done) return false
43+
const [id, callback] = next.value
44+
frames.delete(id)
45+
callback(performance.now())
46+
return true
47+
},
48+
}
49+
}
50+
51+
const mount = (factory) => {
52+
const root = document.createElement("div")
53+
document.body.append(root)
54+
const dispose = render(factory, root)
55+
return () => {
56+
dispose()
57+
root.remove()
58+
}
59+
}
60+
61+
{
62+
const raf = installAnimationFrameQueue()
63+
let staging
64+
const dispose = mount(() => {
65+
staging = createTimelineStaging({
66+
sessionKey: () => "ses_1",
67+
turnStart: () => 0,
68+
messages: () => messages(14),
69+
config: { init: 10, batch: 3 },
70+
})
71+
return null
72+
})
73+
74+
assert(ids(staging.messages()) === ids(messages(14)), "non-windowed timeline should render all messages")
75+
assert(staging.isStaging() === false, "non-windowed timeline should not stage")
76+
assert(raf.pending() === 0, "non-windowed timeline should not schedule frames")
77+
dispose()
78+
}
79+
80+
{
81+
const raf = installAnimationFrameQueue()
82+
let staging
83+
const dispose = mount(() => {
84+
staging = createTimelineStaging({
85+
sessionKey: () => "ses_1",
86+
turnStart: () => 6,
87+
messages: () => messages(16),
88+
config: { init: 10, batch: 3 },
89+
})
90+
return null
91+
})
92+
93+
assert(ids(staging.messages()) === ids(messages(16).slice(6)), "history window should start at init size")
94+
assert(staging.isStaging() === true, "history window should report active staging")
95+
assert(raf.pending() === 1, "history window should schedule one frame")
96+
assert(raf.flushOne() === true, "first staging frame should run")
97+
assert(ids(staging.messages()) === ids(messages(16).slice(3)), "first frame should add one batch")
98+
assert(staging.isStaging() === true, "staging should remain active before completion")
99+
assert(raf.flushOne() === true, "second staging frame should run")
100+
assert(ids(staging.messages()) === ids(messages(16)), "second frame should complete staging")
101+
assert(staging.isStaging() === false, "completed staging should clear active state")
102+
assert(raf.pending() === 0, "completed staging should not leave pending frames")
103+
dispose()
104+
}
105+
106+
{
107+
const raf = installAnimationFrameQueue()
108+
let staging
109+
let setCount
110+
const dispose = mount(() => {
111+
const [count, nextCount] = createSignal(16)
112+
setCount = nextCount
113+
staging = createTimelineStaging({
114+
sessionKey: () => "ses_1",
115+
turnStart: () => 6,
116+
messages: () => messages(count()),
117+
config: { init: 10, batch: 3 },
118+
})
119+
return null
120+
})
121+
122+
const firstFrame = raf.pendingIDs()[0]
123+
assert(firstFrame !== undefined, "active staging should schedule a frame")
124+
setCount(18)
125+
assert(ids(staging.messages()) === ids(messages(18).slice(8)), "active staging should not pop to all messages")
126+
assert(staging.isStaging() === true, "message growth should keep staging active")
127+
assert(raf.pendingIDs().includes(firstFrame), "message growth should keep the existing staging frame")
128+
assert(raf.flushOne() === true, "existing staging frame should continue after growth")
129+
assert(ids(staging.messages()) === ids(messages(18).slice(5)), "continued staging should add one batch after growth")
130+
dispose()
131+
}
132+
133+
{
134+
const raf = installAnimationFrameQueue()
135+
let staging
136+
let setCount
137+
const dispose = mount(() => {
138+
const [count, nextCount] = createSignal(13)
139+
setCount = nextCount
140+
staging = createTimelineStaging({
141+
sessionKey: () => "ses_1",
142+
turnStart: () => 3,
143+
messages: () => messages(count()),
144+
config: { init: 10, batch: 3 },
145+
})
146+
return null
147+
})
148+
149+
assert(ids(staging.messages()) === ids(messages(13).slice(3)), "completed-session case should start windowed")
150+
assert(raf.flushOne() === true, "completion frame should run")
151+
assert(ids(staging.messages()) === ids(messages(13)), "completion frame should reveal all")
152+
assert(staging.isStaging() === false, "completion should clear active state")
153+
setCount(16)
154+
assert(ids(staging.messages()) === ids(messages(16)), "completed session backfill should render immediately")
155+
assert(staging.isStaging() === false, "completed session backfill should not restage")
156+
assert(raf.pending() === 0, "completed session backfill should not schedule frames")
157+
dispose()
158+
}
159+
160+
{
161+
const raf = installAnimationFrameQueue()
162+
let staging
163+
let setSessionKey
164+
const dispose = mount(() => {
165+
const [sessionKey, nextSessionKey] = createSignal("ses_1")
166+
setSessionKey = nextSessionKey
167+
staging = createTimelineStaging({
168+
sessionKey,
169+
turnStart: () => 6,
170+
messages: () => messages(16),
171+
config: { init: 10, batch: 3 },
172+
})
173+
return null
174+
})
175+
176+
const firstFrame = raf.pendingIDs()[0]
177+
assert(firstFrame !== undefined, "initial history staging should schedule a frame")
178+
setSessionKey("ses_2")
179+
assert(raf.canceled.includes(firstFrame), "session switch should cancel the previous frame")
180+
assert(raf.pending() === 1, "session switch should leave one new frame for the new session")
181+
assert(ids(staging.messages()) === ids(messages(16).slice(6)), "new session should restart at init size")
182+
assert(raf.flushOne() === true, "new session frame should run")
183+
assert(ids(staging.messages()) === ids(messages(16).slice(3)), "new session frame should add one batch")
184+
dispose()
185+
}
186+
`
187+
188+
describe("createTimelineStaging", () => {
189+
test("preserves browser staging behavior", () => {
190+
const result = Bun.spawnSync({
191+
cmd: [process.execPath, "--conditions=browser", "--preload", "./happydom.ts", "-e", browserCheck],
192+
cwd: new URL("../../..", import.meta.url).pathname,
193+
stdout: "pipe",
194+
stderr: "pipe",
195+
})
196+
197+
const output = `${new TextDecoder().decode(result.stdout)}${new TextDecoder().decode(result.stderr)}`
198+
expect(output).toBe("")
199+
expect(result.exitCode).toBe(0)
200+
})
201+
})

0 commit comments

Comments
 (0)