diff --git a/.changeset/stream-post-order.md b/.changeset/stream-post-order.md
new file mode 100644
index 0000000..fdf5c6a
--- /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 0841e52..531de18 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 2530419..87267ac 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: "
",
+ 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 f20f7df..2b3485d 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 8cac443..cba1b75 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[]) {