Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/stream-post-order.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion e2e/embed-slots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions e2e/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<p>one</p>", title: "First post" });
await publish(server.url, { html: "<p>two</p>", 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: "<p>three</p>",
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);

Expand Down
38 changes: 34 additions & 4 deletions viewer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,10 @@ import {
initialLoaded,
live,
navOpen,
nearBottom,
nearNewest,
newestOnTop,
pillTarget,
postOrder,
refreshSessionsQuiet,
select,
selectAdjacent,
Expand All @@ -58,6 +60,7 @@ import {
setNavOpen,
setPillTarget,
setUnread,
setPostOrder,
setViewMode,
standalonePost,
streamLoading,
Expand Down Expand Up @@ -343,7 +346,7 @@ export default function App() {
</Show>
<main
onScroll={() => {
if (nearBottom()) setPillTarget(null);
if (nearNewest()) setPillTarget(null);
}}
>
{/* Host-overridable main pane (SLOTS.main). Fallback is the normal
Expand Down Expand Up @@ -389,7 +392,7 @@ export default function App() {
setPillTarget(null);
}}
>
new post ↓
{newestOnTop() ? "new post ↑" : "new post ↓"}
</button>
</>
}
Expand Down Expand Up @@ -683,6 +686,9 @@ function SessionView() {
{current() ? `${current()!.agent} · started ${relTime(current()!.createdAt)}` : ""}
</span>
<span class="head-sp"></span>
<Show when={viewMode() === "stream"}>
<OrderToggle />
</Show>
<ViewToggle />
{/* Host-overridable region (SLOTS.sessionActions): session-scoped controls
an embedder projects beside the toggle (e.g. cloud "Share"). Empty
Expand All @@ -703,7 +709,9 @@ function SessionView() {
No posts in this session yet.
</div>
</Show>
<For each={posts}>{(s) => <Card post={s} />}</For>
<For each={newestOnTop() ? [...posts].reverse() : posts}>
{(s) => <Card post={s} />}
</For>
</>
}
>
Expand Down Expand Up @@ -737,6 +745,28 @@ function ViewToggle() {
);
}

// Stream order by post createdAt: oldest first (default) or newest first.
function OrderToggle() {
return (
<div class="view-toggle" role="group" aria-label="Post order">
<button
classList={{ on: postOrder() === "oldest" }}
aria-pressed={postOrder() === "oldest"}
onClick={() => setPostOrder("oldest")}
>
Oldest
</button>
<button
classList={{ on: postOrder() === "newest" }}
aria-pressed={postOrder() === "newest"}
onClick={() => setPostOrder("newest")}
>
Newest
</button>
</div>
);
}

function SessionTitle(props: { current: SessionRow | undefined }) {
let el!: HTMLSpanElement;
// contenteditable owns its text while focused; sync from state otherwise
Expand Down
25 changes: 21 additions & 4 deletions viewer/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ViewMode>("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<PostOrder>(
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).
Expand Down Expand Up @@ -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);
Expand All @@ -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[]) {
Expand Down