diff --git a/CHANGELOG.md b/CHANGELOG.md index b2ac7ce..e6b8742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,12 +27,24 @@ All notable user-visible changes to this project are documented in this file. ### Changed +- New snippets no longer steal the scroll position: the viewer only follows + them when already at the bottom of the stream, and shows a "new snippet ↓" + pill otherwise. +- Activity the user isn't looking at — another session, or any session while + the tab is hidden — badges the tab title with an unread count. - The Claude Code skill now documents the repo-local CLI fallback and a checkpoint-drain feedback pattern for harnesses that cannot surface background watcher output. ### Fixed +- A comment that failed to send was silently lost (input cleared, no error). + The viewer now echoes comments immediately (pending until confirmed) and on + failure restores the text to the input with an error toast. +- After an SSE reconnect the viewer refetches the selected session, so + snippets and comments that arrived during the gap can no longer be + silently missing from a live-looking board. + - Comments not attached to a snippet (e.g. `sideshow comment` without `--snippet`) were stored and delivered to agents but never shown in the viewer; they now render in the session thread. diff --git a/e2e/viewer.spec.ts b/e2e/viewer.spec.ts index 31c5a56..9a29ae8 100644 --- a/e2e/viewer.spec.ts +++ b/e2e/viewer.spec.ts @@ -86,6 +86,103 @@ test("session thread shows snippet-less comments and messages the agent", async await expect(page.locator("#stream > .card")).toHaveCount(3); }); +test("a failed comment send restores the input instead of losing the message", async ({ + page, + server, +}) => { + await publish(server.url, { html: "

x

", title: "Doc", agent: "e2e" }); + + await page.goto(server.url); + const card = page.locator(".card:not(#sessionThread)"); + await page.route("**/api/comments", (route) => + route.request().method() === "POST" ? route.abort() : route.fallback(), + ); + + const input = card.locator(".composer input"); + await input.fill("important feedback"); + await input.press("Enter"); + + await expect(page.locator("#toast")).toContainText("Couldn't send"); + await expect(input).toHaveValue("important feedback"); + await expect(card.locator(".cmt")).toHaveCount(0); + + // and once the network is back, the same send goes through + await page.unroute("**/api/comments"); + await input.press("Enter"); + await expect(card.locator(".cmt .txt")).toHaveText("important feedback"); +}); + +test("a comment echoes immediately, before the SSE round-trip confirms it", async ({ + page, + server, +}) => { + await publish(server.url, { html: "

x

", title: "Doc", agent: "e2e" }); + + await page.goto(server.url); + const card = page.locator(".card:not(#sessionThread)"); + // hold the POST open so only the optimistic echo can render + await page.route("**/api/comments", async (route) => { + if (route.request().method() !== "POST") return route.fallback(); + await new Promise((r) => setTimeout(r, 1500)); + await route.continue(); + }); + + const input = card.locator(".composer input"); + await input.fill("instant echo"); + await input.press("Enter"); + + const cmt = card.locator(".cmt"); + await expect(cmt).toHaveClass(/pending/); + await expect(cmt.locator(".txt")).toHaveText("instant echo"); + // settles into a confirmed comment, still exactly one copy + await expect(cmt).not.toHaveClass(/pending/, { timeout: 10_000 }); + await expect(card.locator(".cmt")).toHaveCount(1); +}); + +test("a snippet published while scrolled up shows a pill instead of yanking", async ({ + page, + server, +}) => { + const first = await publish(server.url, { + html: '
tall content
', + title: "Tall", + agent: "e2e", + }); + + await page.goto(server.url); + const main = page.locator("main"); + // wait for the bridge to grow the iframe so the stream actually overflows + await expect + .poll(() => main.evaluate((m) => m.scrollHeight - m.clientHeight), { timeout: 15_000 }) + .toBeGreaterThan(400); + await main.evaluate((m) => (m.scrollTop = 0)); + + await publish(server.url, { html: "

new

", title: "Later", session: first.sessionId }); + + await expect(page.locator("#newPill")).toBeVisible(); + expect(await main.evaluate((m) => m.scrollTop)).toBe(0); // reading position kept + + await page.locator("#newPill").click(); + await expect(page.locator("#newPill")).toBeHidden(); + await expect.poll(() => main.evaluate((m) => m.scrollTop)).toBeGreaterThan(200); +}); + +test("activity in an unselected session badges the tab title until viewed", async ({ + page, + server, +}) => { + await publish(server.url, { html: "

a

", title: "First", agent: "one" }); + + await page.goto(server.url); + await expect(page).toHaveTitle("sideshow"); + + await publish(server.url, { html: "

b

", title: "Second", agent: "two" }); + + await expect(page).toHaveTitle("(1) sideshow"); + await page.locator(".sess", { hasText: "two" }).click(); + await expect(page).toHaveTitle("sideshow"); +}); + test("version select appears live after an update", async ({ page, server }) => { const snippet = await publish(server.url, { html: "

v1

", title: "Doc", agent: "e2e" }); diff --git a/viewer/index.html b/viewer/index.html index 68afcd3..8db15fa 100644 --- a/viewer/index.html +++ b/viewer/index.html @@ -345,6 +345,25 @@ color: var(--text); background: var(--hover); } + .cmt.pending { + opacity: 0.55; + } + #newPill { + position: fixed; + left: 50%; + bottom: 64px; + transform: translateX(-50%); + font-family: inherit; + font-size: 12.5px; + color: var(--accent); + background: var(--accent-bg); + border: 0.5px solid var(--accent); + border-radius: 999px; + padding: 6px 14px; + cursor: pointer; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12); + z-index: 40; + } .empty { text-align: center; @@ -480,6 +499,7 @@

or try it yourself

+