From 700c2a899c17fe9304ebc7e522b73d2b422ea2eb Mon Sep 17 00:00:00 2001 From: Satrya Date: Wed, 11 Nov 2026 09:27:31 +0700 Subject: [PATCH] feat(viewer): order session stream by newest or oldest first Add an Oldest/Newest toggle to the stream view. Posts stay stored in createdAt order; Newest reverses the rendering only. Auto-follow and the new-post pill track whichever edge new posts land on. Timeline view is unaffected. --- .changeset/stream-post-order.md | 5 +++++ e2e/embed-slots.spec.ts | 2 +- e2e/viewer.spec.ts | 23 ++++++++++++++++++++ viewer/src/App.tsx | 38 +++++++++++++++++++++++++++++---- viewer/src/state.ts | 25 ++++++++++++++++++---- 5 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 .changeset/stream-post-order.md diff --git a/.changeset/stream-post-order.md b/.changeset/stream-post-order.md new file mode 100644 index 00000000..fdf5c6ae --- /dev/null +++ b/.changeset/stream-post-order.md @@ -0,0 +1,5 @@ +--- +"sideshow": minor +--- + +Viewer: add an Oldest / Newest toggle to the session stream so the latest post can appear first. The choice is remembered per browser, and live arrivals follow the chosen edge. diff --git a/e2e/embed-slots.spec.ts b/e2e/embed-slots.spec.ts index 0841e521..531de182 100644 --- a/e2e/embed-slots.spec.ts +++ b/e2e/embed-slots.spec.ts @@ -61,7 +61,7 @@ test("embedded engine: ss:session-actions slot projects host content into the se // The session header (with the stream/timeline toggle) renders in "full" layout. const head = page.locator(".session-head"); await expect(head).toBeVisible(); - await expect(head.locator(".view-toggle")).toBeVisible(); + await expect(head.getByRole("group", { name: "View mode" })).toBeVisible(); // The host's light-DOM button projects into the session-actions slot, landing // inside the engine's header next to the toggle — and is the embedder's element, diff --git a/e2e/viewer.spec.ts b/e2e/viewer.spec.ts index 25304193..87267ac2 100644 --- a/e2e/viewer.spec.ts +++ b/e2e/viewer.spec.ts @@ -934,6 +934,29 @@ test("timeline traces wrap cleanly at iPhone 14 Pro width", async ({ page, serve await expectNoHorizontalOverflow(page, ".timeline"); }); +test("the stream can order posts newest first, and remembers it", async ({ page, server }) => { + const first = await publish(server.url, { html: "

one

", title: "First post" }); + await publish(server.url, { html: "

two

", title: "Second post", session: first.sessionId }); + + await page.goto(`${server.url}/session/${first.sessionId}`); + const titles = page.locator("#stream .card:not(#whatsNew) .card-title"); + await expect(titles).toHaveText(["First post", "Second post"]); + + await page.getByRole("button", { name: "Newest" }).click(); + await expect(titles).toHaveText(["Second post", "First post"]); + + // a post arriving live lands on top + await publish(server.url, { + html: "

three

", + title: "Third post", + session: first.sessionId, + }); + await expect(titles).toHaveText(["Third post", "Second post", "First post"]); + + await page.reload(); + await expect(titles).toHaveText(["Third post", "Second post", "First post"]); +}); + test("the Connect an agent page shows the add-mcp logo picker", async ({ page, server }) => { await page.goto(server.url); diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx index f20f7df2..2b3485d4 100644 --- a/viewer/src/App.tsx +++ b/viewer/src/App.tsx @@ -47,8 +47,10 @@ import { initialLoaded, live, navOpen, - nearBottom, + nearNewest, + newestOnTop, pillTarget, + postOrder, refreshSessionsQuiet, select, selectAdjacent, @@ -58,6 +60,7 @@ import { setNavOpen, setPillTarget, setUnread, + setPostOrder, setViewMode, standalonePost, streamLoading, @@ -343,7 +346,7 @@ export default function App() {
{ - if (nearBottom()) setPillTarget(null); + if (nearNewest()) setPillTarget(null); }} > {/* Host-overridable main pane (SLOTS.main). Fallback is the normal @@ -389,7 +392,7 @@ export default function App() { setPillTarget(null); }} > - new post ↓ + {newestOnTop() ? "new post ↑" : "new post ↓"} } @@ -683,6 +686,9 @@ function SessionView() { {current() ? `${current()!.agent} · started ${relTime(current()!.createdAt)}` : ""} + + + {/* Host-overridable region (SLOTS.sessionActions): session-scoped controls an embedder projects beside the toggle (e.g. cloud "Share"). Empty @@ -703,7 +709,9 @@ function SessionView() { No posts in this session yet. - {(s) => } + + {(s) => } + } > @@ -737,6 +745,28 @@ function ViewToggle() { ); } +// Stream order by post createdAt: oldest first (default) or newest first. +function OrderToggle() { + return ( +
+ + +
+ ); +} + function SessionTitle(props: { current: SessionRow | undefined }) { let el!: HTMLSpanElement; // contenteditable owns its text while focused; sync from state otherwise diff --git a/viewer/src/state.ts b/viewer/src/state.ts index 8cac4431..cba1b751 100644 --- a/viewer/src/state.ts +++ b/viewer/src/state.ts @@ -107,6 +107,20 @@ export const [navOpen, setNavOpen] = createSignal(false); // spine with the trace steps between them). Per-workspace view preference. export type ViewMode = "stream" | "timeline"; export const [viewMode, setViewMode] = createSignal("stream"); +// Stream post order by createdAt. Posts stay stored oldest-first (the server's +// order, new arrivals appended); "newest" only reverses the stream rendering. +// The timeline is chronological by design and ignores this. +export type PostOrder = "oldest" | "newest"; +const POST_ORDER_KEY = "sideshow-post-order"; +const [postOrderState, setPostOrderInternal] = createSignal( + localStorage.getItem(POST_ORDER_KEY) === "newest" ? "newest" : "oldest", +); +export const postOrder = postOrderState; +export function setPostOrder(order: PostOrder) { + localStorage.setItem(POST_ORDER_KEY, order); + setPostOrderInternal(order); +} +export const newestOnTop = () => viewMode() === "stream" && postOrder() === "newest"; // Post id the next mounted card should scroll to (set for SSE arrivals // landing while the user is near the bottom, not the initial batch of a // session switch). @@ -444,10 +458,10 @@ async function upsertPost(id: string, { scroll = true } = {}) { if (idx >= 0) { setPostsInternal(idx, reconcile(s, { key: "id" })); } else { - // Follow new posts only when the user is already at the bottom; + // Follow new posts only when the user is already at the newest edge; // never yank them away from whatever they're reading mid-scroll. if (scroll) { - if (nearBottom()) setScrollTarget(s.id); + if (nearNewest()) setScrollTarget(s.id); else setPillTarget(s.id); } setPostsInternal(posts.length, s); @@ -463,9 +477,12 @@ export async function fetchTrace(sessionId: string) { if (res && selected() === sessionId) setTraceStepsInternal(res.steps); } -export function nearBottom() { +// Is the reader at the edge where new posts land — the top when the stream +// shows newest first, the bottom otherwise. +export function nearNewest() { const m = root().querySelector("main"); - return !!m && m.scrollHeight - m.scrollTop - m.clientHeight < 200; + if (!m) return false; + return newestOnTop() ? m.scrollTop < 200 : m.scrollHeight - m.scrollTop - m.clientHeight < 200; } function mergeComments(list: Comment[]) {